Phase 2: core domain, policy engine, packet API, Clerical screen
Migration 0003: projects, aliases, renumbers, units, packets and every child table, change orders, assets, documents, ITPs, station limits, department weld flag. Policy package: Principal, PacketState, Action rules, custody, preconditions, CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept, If-Match row versions, server-assigned numbers, field CAS, version bump, packet log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal. API: /api/packets (header, transitions, work orders, pages, docs, move, remove, BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets, /_blob. Frontend: Clerical home, projects and units, packet page with pdf.js and QR codes, hand draft, heat map, send to warehouse, department or Q.C. Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical draft scenario. Verified on docker-1 with the CI stack and the Samba DC. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
// Asset upload and the blob URL. The upload is multipart through the shared request.
|
||||
import { api, request } from "./client";
|
||||
import type { Asset } from "./types";
|
||||
|
||||
/** Same-origin URL of a stored file. */
|
||||
export function blobUrl(assetId: string, download = false): string {
|
||||
return `/_blob/${encodeURIComponent(assetId)}${download ? "?download=1" : ""}`;
|
||||
}
|
||||
|
||||
export const assetsApi = {
|
||||
upload: (file: File, opts: { packet_id?: number; purpose?: string } = {}) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file, file.name);
|
||||
if (opts.packet_id !== undefined) fd.append("packet_id", String(opts.packet_id));
|
||||
if (opts.purpose) fd.append("purpose", opts.purpose);
|
||||
return request<Asset>("/api/assets", { method: "POST", body: fd });
|
||||
},
|
||||
get: (id: string, signal?: AbortSignal) => api.get<Asset>(`/api/assets/${encodeURIComponent(id)}`, signal),
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
// Packet endpoints from the phase 2 contract.
|
||||
import { api, request } from "./client";
|
||||
import { asList, ifMatch, qs } from "./query";
|
||||
import type {
|
||||
AttachPagesBody,
|
||||
BomLine,
|
||||
FieldCas,
|
||||
Guarded,
|
||||
LogRow,
|
||||
PacketCard,
|
||||
PacketCreate,
|
||||
PacketHeader,
|
||||
PacketListQuery,
|
||||
PacketRead,
|
||||
TransitionBody,
|
||||
WorkOrder,
|
||||
} from "./types";
|
||||
|
||||
const base = (id: number) => `/api/packets/${encodeURIComponent(String(id))}`;
|
||||
|
||||
export const packetsApi = {
|
||||
list: async (q: PacketListQuery = {}, signal?: AbortSignal): Promise<PacketCard[]> =>
|
||||
asList<PacketCard>(await api.get<unknown>(`/api/packets${qs({ limit: 500, ...q })}`, signal), "packets"),
|
||||
get: (id: number, signal?: AbortSignal) => api.get<PacketRead>(base(id), signal),
|
||||
log: async (id: number, opts: { limit?: number; before?: number } = {}, signal?: AbortSignal): Promise<LogRow[]> =>
|
||||
asList<LogRow>(await api.get<unknown>(`${base(id)}/log${qs({ limit: opts.limit ?? 300, before: opts.before })}`, signal), "log"),
|
||||
create: (body: PacketCreate) => api.post<PacketRead>("/api/packets", body),
|
||||
patch: (id: number, body: FieldCas<PacketHeader>) => api.patch<PacketRead>(base(id), body),
|
||||
transition: (id: number, body: TransitionBody) => api.post<PacketRead>(`${base(id)}/transitions`, body),
|
||||
putWorkOrders: (id: number, version: number, work_orders: WorkOrder[], extra: Guarded = {}) =>
|
||||
request<PacketRead>(`${base(id)}/work-orders`, { method: "PUT", body: { work_orders, ...extra }, headers: ifMatch(version) }),
|
||||
putBom: (id: number, version: number, bom: BomLine[], extra: Guarded = {}) =>
|
||||
request<PacketRead>(`${base(id)}/bom`, { method: "PUT", body: { bom, ...extra }, headers: ifMatch(version) }),
|
||||
attachPages: (id: number, body: AttachPagesBody) => api.post<PacketRead>(`${base(id)}/pages`, body),
|
||||
removePages: (id: number, n: number, body: { reason?: string } & Guarded = {}) =>
|
||||
api.delWithBody<PacketRead>(`${base(id)}/pages/${n}`, body),
|
||||
addDoc: (id: number, body: { asset_id: string; kind: string; title: string } & Guarded) =>
|
||||
api.post<PacketRead>(`${base(id)}/docs`, body),
|
||||
removeDoc: (id: number, docId: number, extra: Guarded = {}) =>
|
||||
api.delWithBody<PacketRead>(`${base(id)}/docs/${docId}`, extra),
|
||||
move: (id: number, body: { unit_id: number; reason?: string } & Guarded) => api.post<PacketRead>(`${base(id)}/move`, body),
|
||||
remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody<PacketRead | null>(base(id), body),
|
||||
setVclosed: (id: number, closed: boolean, extra: Guarded = {}) =>
|
||||
api.post<PacketRead>(`${base(id)}/vclosed`, { closed, ...extra }),
|
||||
databook: (id: number) => api.post<PacketRead>(`${base(id)}/databook`, {}),
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
// Project and unit endpoints from the phase 2 contract.
|
||||
import { api } from "./client";
|
||||
import { asList, qs } from "./query";
|
||||
import type { FieldCas, Guarded, NumberLookup, Project, ProjectCreate, Unit, UnitsCreate } from "./types";
|
||||
|
||||
export type ProjectShow = "active" | "open" | "hold" | "closed" | "all";
|
||||
|
||||
export interface ProjectFields {
|
||||
name: string | null;
|
||||
customer: string | null;
|
||||
po: string | null;
|
||||
due: string | null;
|
||||
status: Project["status"];
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface UnitFields {
|
||||
name: string | null;
|
||||
due: string | null;
|
||||
status: Unit["status"];
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
const pid = (id: number) => `/api/projects/${encodeURIComponent(String(id))}`;
|
||||
const uid = (id: number) => `/api/units/${encodeURIComponent(String(id))}`;
|
||||
|
||||
export const projectsApi = {
|
||||
list: async (q: string, status: ProjectShow = "active", signal?: AbortSignal): Promise<Project[]> =>
|
||||
asList<Project>(await api.get<unknown>(`/api/projects${qs({ q, status })}`, signal), "projects"),
|
||||
get: (id: number, signal?: AbortSignal) => api.get<Project>(pid(id), signal),
|
||||
create: (body: ProjectCreate & Guarded) => api.post<Project>("/api/projects", body),
|
||||
patch: (id: number, body: FieldCas<ProjectFields>) => api.patch<Project>(pid(id), body),
|
||||
renumber: (id: number, body: { number: string; reason: string } & Guarded) => api.post<Project>(`${pid(id)}/renumber`, body),
|
||||
remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody<null>(pid(id), body),
|
||||
addUnits: async (id: number, body: UnitsCreate & Guarded): Promise<Unit[]> =>
|
||||
asList<Unit>(await api.post<unknown>(`${pid(id)}/units`, body), "units"),
|
||||
units: async (projectId: number, q = "", signal?: AbortSignal): Promise<Unit[]> =>
|
||||
asList<Unit>(await api.get<unknown>(`/api/units${qs({ project_id: projectId, q })}`, signal), "units"),
|
||||
unit: (id: number, signal?: AbortSignal) => api.get<Unit>(uid(id), signal),
|
||||
patchUnit: (id: number, body: FieldCas<UnitFields>) => api.patch<Unit>(uid(id), body),
|
||||
renumberUnit: (id: number, body: { lot: string; reason: string } & Guarded) => api.post<Unit>(`${uid(id)}/renumber`, body),
|
||||
removeUnit: (id: number, body: { reason: string } & Guarded) => api.delWithBody<null>(uid(id), body),
|
||||
lookup: (n: string, signal?: AbortSignal) => api.get<NumberLookup>(`/api/lookup/number${qs({ n })}`, signal),
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
// Query strings and list answers shared by the API modules.
|
||||
|
||||
/** "?a=1&b=x" from the defined, non-empty values. Empty when nothing is set. */
|
||||
export function qs(params: Record<string, string | number | boolean | null | undefined>): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
sp.set(k, String(v));
|
||||
}
|
||||
const s = sp.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
/** A list answer may come bare or wrapped as {key: [...]}. Both give the array. */
|
||||
export function asList<T>(raw: unknown, key: string): T[] {
|
||||
if (Array.isArray(raw)) return raw as T[];
|
||||
if (raw && typeof raw === "object" && Array.isArray((raw as Record<string, unknown>)[key])) {
|
||||
return (raw as Record<string, T[]>)[key] as T[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The If-Match header for a version check. */
|
||||
export function ifMatch(version: number): Record<string, string> {
|
||||
return { "If-Match": String(version) };
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
// API shapes from the phase 2 design contract. IDs stay text where the contract says so.
|
||||
// Work order, lot, split, sub and part IDs are always strings. Timestamps are ISO strings.
|
||||
import type { Factor } from "../auth/types";
|
||||
|
||||
export const STATUSES = ["draft", "warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete", "filed"] as const;
|
||||
export type PacketStatus = (typeof STATUSES)[number];
|
||||
|
||||
export type ProjectStatus = "open" | "hold" | "closed";
|
||||
export type RouteKind = "databook" | "shipping";
|
||||
|
||||
/** Who references in packet answers. The frontend never gets emp_no or email here. */
|
||||
export interface WhoRef {
|
||||
id: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface WelderRef extends WhoRef {
|
||||
stamp: string | null;
|
||||
}
|
||||
|
||||
export interface StationRef {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DeptRef {
|
||||
code: string;
|
||||
name: string;
|
||||
weld?: boolean;
|
||||
}
|
||||
|
||||
/** A signature in a request body. The server checks it against the session user. */
|
||||
export interface SignBody {
|
||||
factor: Factor;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
/** Packet counts per stage on project and unit rows. */
|
||||
export interface StageCounts {
|
||||
draft: number;
|
||||
warehouse: number;
|
||||
shop: number;
|
||||
qc: number;
|
||||
complete: number;
|
||||
filed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/* ---------- Projects and units ---------- */
|
||||
|
||||
export interface ProjectRef {
|
||||
id: number;
|
||||
number: string;
|
||||
name: string | null;
|
||||
customer?: string | null;
|
||||
po?: string | null;
|
||||
status?: ProjectStatus;
|
||||
due?: string | null;
|
||||
}
|
||||
|
||||
/** One row of GET /api/units?project_id= and of a project's `units`. */
|
||||
export interface Unit {
|
||||
id: number;
|
||||
project_id?: number;
|
||||
number: string;
|
||||
lot: string;
|
||||
name: string | null;
|
||||
due: string | null;
|
||||
due_effective: string | null;
|
||||
status: ProjectStatus;
|
||||
notes?: string | null;
|
||||
packets?: StageCounts;
|
||||
version: number;
|
||||
project?: ProjectRef;
|
||||
}
|
||||
|
||||
/** One row of GET /api/projects. GET /api/projects/{id} adds units and aliases. */
|
||||
export interface Project {
|
||||
id: number;
|
||||
number: string;
|
||||
name: string | null;
|
||||
customer: string | null;
|
||||
po: string | null;
|
||||
due: string | null;
|
||||
status: ProjectStatus;
|
||||
notes: string | null;
|
||||
source?: string;
|
||||
needs_details?: boolean;
|
||||
/** A count on list rows, the unit rows on GET /api/projects/{id}. */
|
||||
units: number | Unit[];
|
||||
packets?: StageCounts;
|
||||
aliases?: Array<string | { alias: string; kind?: string }>;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface ProjectCreate {
|
||||
number: string;
|
||||
name: string;
|
||||
customer: string;
|
||||
po: string;
|
||||
due: string | null;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface UnitsCreate {
|
||||
lot?: string;
|
||||
lots?: string[];
|
||||
name: string;
|
||||
due: string | null;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface FieldCas<T> {
|
||||
set: Partial<T>;
|
||||
base: Partial<T>;
|
||||
it_reason?: string;
|
||||
}
|
||||
|
||||
export interface NumberLookup {
|
||||
kind: "project" | "unit";
|
||||
id: number;
|
||||
number: string;
|
||||
alias: boolean;
|
||||
}
|
||||
|
||||
/* ---------- Packets ---------- */
|
||||
|
||||
export interface VclosedRef {
|
||||
at: string;
|
||||
by: WhoRef | null;
|
||||
}
|
||||
|
||||
/** One packet on a board. GET /api/packets -> {packets: [card...]}. */
|
||||
export interface PacketCard {
|
||||
id: number;
|
||||
version: number;
|
||||
unit: { id: number; number: string };
|
||||
project: { id: number; number: string; name: string | null };
|
||||
dept: DeptRef;
|
||||
op: string | null;
|
||||
title: string | null;
|
||||
customer: string | null;
|
||||
drawing: string | null;
|
||||
rev: string | null;
|
||||
status: PacketStatus;
|
||||
status_at: string | null;
|
||||
station: StationRef | null;
|
||||
welder: WelderRef | null;
|
||||
routed_to: RouteKind | null;
|
||||
vclosed: VclosedRef | null;
|
||||
source_kind: "visual" | "hand";
|
||||
flag: string | null;
|
||||
/** CO numbers, or a count. Both are handled. */
|
||||
open_cos: number[] | number;
|
||||
open_shorts: number;
|
||||
wh_stage: string | null;
|
||||
due_effective: string | null;
|
||||
updated_at: string | null;
|
||||
holder: string;
|
||||
can: string[];
|
||||
blocked: Record<string, string[]>;
|
||||
/** Not in the contract's card shape. Shown when the server sends it. */
|
||||
wo_count?: number;
|
||||
}
|
||||
|
||||
export interface PacketList {
|
||||
packets: PacketCard[];
|
||||
}
|
||||
|
||||
export interface PacketListQuery {
|
||||
status?: string;
|
||||
dept?: string;
|
||||
unit_id?: number;
|
||||
project_id?: number;
|
||||
q?: string;
|
||||
flag?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface WorkOrder {
|
||||
id?: number;
|
||||
wo_base: string;
|
||||
lot: string;
|
||||
split: string;
|
||||
sub: string;
|
||||
seq?: number | null;
|
||||
qty: number | string | null;
|
||||
target_hrs: number | string | null;
|
||||
op?: string | null;
|
||||
description?: string | null;
|
||||
visual_status?: string | null;
|
||||
gone_at?: string | null;
|
||||
row_version?: number;
|
||||
}
|
||||
|
||||
export interface PacketPage {
|
||||
n: number;
|
||||
type: "drawing" | "info" | "overlay" | string;
|
||||
asset_id: string | null;
|
||||
pn: number;
|
||||
dwg: string | null;
|
||||
rev: string | null;
|
||||
void: { co_id: number; at: string; new_n: number | null } | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BomShort {
|
||||
id: number;
|
||||
qty: number | string;
|
||||
qty_open: number | string;
|
||||
reason: string | null;
|
||||
ref: string | null;
|
||||
eta: string | null;
|
||||
note: string | null;
|
||||
opened_at: string | null;
|
||||
receipts?: unknown[];
|
||||
}
|
||||
|
||||
export interface BomLine {
|
||||
id?: number;
|
||||
item: number;
|
||||
qty: number | string | null;
|
||||
part_id: string | null;
|
||||
description: string | null;
|
||||
dwg_ref: string | null;
|
||||
cl_item?: string | null;
|
||||
length: string | null;
|
||||
material: string | null;
|
||||
heat?: string | null;
|
||||
na?: boolean;
|
||||
issued?: number | string | null;
|
||||
loc?: string | null;
|
||||
pulled?: boolean;
|
||||
mtr_asset_id?: string | null;
|
||||
src?: string | null;
|
||||
short?: BomShort | null;
|
||||
row_version?: number;
|
||||
}
|
||||
|
||||
export interface PacketDoc {
|
||||
id: number;
|
||||
asset_id: string;
|
||||
kind: string;
|
||||
title: string | null;
|
||||
added_at: string | null;
|
||||
added_by: WhoRef | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** GET /api/packets/{id} and every packet mutation answer. Children the phase 2 screens do not use stay loose. */
|
||||
export interface PacketRead {
|
||||
id: number;
|
||||
version: number;
|
||||
unit: {
|
||||
id: number;
|
||||
number: string;
|
||||
lot: string;
|
||||
name: string | null;
|
||||
project: { id: number; number: string; name: string | null; customer: string | null; po: string | null; status: ProjectStatus };
|
||||
due_effective: string | null;
|
||||
};
|
||||
dept: DeptRef;
|
||||
op: string | null;
|
||||
title: string | null;
|
||||
customer: string | null;
|
||||
drawing: string | null;
|
||||
rev: string | null;
|
||||
wps: string | null;
|
||||
status: PacketStatus;
|
||||
status_at: string | null;
|
||||
station: StationRef | null;
|
||||
welder: WelderRef | null;
|
||||
for_sup: WhoRef | null;
|
||||
heat_map: boolean | null;
|
||||
wh_skipped: boolean;
|
||||
wh_sent_at: string | null;
|
||||
routed_to: RouteKind | null;
|
||||
return_note: string | null;
|
||||
vclosed: VclosedRef | null;
|
||||
databook_at: string | null;
|
||||
source_kind: "visual" | "hand";
|
||||
source: Record<string, unknown>;
|
||||
drawing_state: string;
|
||||
flags: string[];
|
||||
visual: Record<string, unknown>;
|
||||
removed: { at: string; by: WhoRef | null; reason: string | null } | null;
|
||||
work_orders: WorkOrder[];
|
||||
pages: PacketPage[];
|
||||
bom: BomLine[];
|
||||
welds: unknown[];
|
||||
heat_tags: unknown[];
|
||||
heat_table: unknown[];
|
||||
stage: Record<string, unknown> | null;
|
||||
stages: unknown[];
|
||||
wh: Record<string, unknown> | null;
|
||||
qc: Record<string, unknown> | null;
|
||||
qc_history: unknown[];
|
||||
change_orders: unknown[];
|
||||
open_cos: number[];
|
||||
open_shorts: number;
|
||||
docs: PacketDoc[];
|
||||
seal: { sealed_at: string; key_id: string; broken_at: string | null } | null;
|
||||
can: string[];
|
||||
blocked: Record<string, string[]>;
|
||||
holder: string;
|
||||
checklist_items: Array<{ group: string; key: string; text: string }>;
|
||||
qc_items: unknown[];
|
||||
flag: string | null;
|
||||
}
|
||||
|
||||
/** One row of GET /api/packets/{id}/log, newest first. */
|
||||
export interface LogRow {
|
||||
id: number;
|
||||
at: string;
|
||||
by: WhoRef | null;
|
||||
role: string | null;
|
||||
action: string;
|
||||
note: string | null;
|
||||
it_reason: string | null;
|
||||
signed: boolean;
|
||||
}
|
||||
|
||||
/** POST /api/packets: a hand draft. */
|
||||
export interface PacketCreate {
|
||||
unit_id: number;
|
||||
dept_code: string;
|
||||
op: string;
|
||||
title: string;
|
||||
customer: string;
|
||||
drawing: string;
|
||||
rev: string;
|
||||
wps: string;
|
||||
heat_map?: boolean | null;
|
||||
work_orders: Array<Pick<WorkOrder, "wo_base" | "lot" | "split" | "sub" | "qty" | "target_hrs"> & { op?: string; description?: string }>;
|
||||
bom: Array<Pick<BomLine, "item" | "qty" | "part_id" | "description" | "material" | "length" | "dwg_ref">>;
|
||||
}
|
||||
|
||||
/** The header fields PATCH /api/packets/{id} takes. */
|
||||
export interface PacketHeader {
|
||||
title: string | null;
|
||||
customer: string | null;
|
||||
wps: string | null;
|
||||
op: string | null;
|
||||
heat_map: boolean | null;
|
||||
for_sup_id: string | null;
|
||||
drawing: string | null;
|
||||
rev: string | null;
|
||||
dept_code: string;
|
||||
}
|
||||
|
||||
/** POST /api/packets/{id}/transitions. A transition carries only its own fields. */
|
||||
export interface TransitionBody {
|
||||
action: string;
|
||||
expect: { status: PacketStatus; dept: string };
|
||||
reason?: string;
|
||||
note?: string;
|
||||
dept?: string;
|
||||
station_id?: number;
|
||||
welder_id?: string;
|
||||
stamp?: string;
|
||||
routed_to?: RouteKind;
|
||||
book?: boolean;
|
||||
spool?: boolean;
|
||||
location?: string;
|
||||
for_sup_id?: string;
|
||||
sign?: SignBody;
|
||||
it_reason?: string;
|
||||
}
|
||||
|
||||
export interface AttachPagesBody {
|
||||
asset_id: string;
|
||||
type: "drawing";
|
||||
dwg?: string;
|
||||
rev?: string;
|
||||
sign?: SignBody;
|
||||
it_reason?: string;
|
||||
}
|
||||
|
||||
/* ---------- Assets ---------- */
|
||||
|
||||
/** POST /api/assets and GET /api/assets/{id}. */
|
||||
export interface Asset {
|
||||
id: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
pages: number | null;
|
||||
original_name: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Extra fields the guard adds on a retry: the IT reason and the signature. */
|
||||
export interface Guarded {
|
||||
it_reason?: string;
|
||||
sign?: SignBody;
|
||||
}
|
||||
Reference in New Issue
Block a user