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:
2026-10-16 09:31:45 -06:00
co-authored by Claude Fable 5.1
parent 97b0973130
commit 37d9a0cb50
83 changed files with 16490 additions and 55 deletions
+19
View File
@@ -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),
};
+46
View File
@@ -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`, {}),
};
+44
View File
@@ -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),
};
+26
View File
@@ -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) };
}
+396
View File
@@ -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;
}