Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
ci / frontend (push) Successful in 37s
ci / server (push) Successful in 9m18s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 2m17s

The Q.C. screen (KPIs, boards, project folders), the Q.C. tab on the packet
page (check items from the qc_check_items settings row, visual exam per weld,
NDE, hardness with the NACE label, notes), accept with a route and a
signature gated by qc_missing_empty, reject with a reason or an NCR number
closing the inspection into qc_history, Q.C. folder documents per project and
unit with superseding revisions, packet documents as a closed set with
take-out reasons, the digital ITP per unit from the frozen template (line
initials with CAS, unsign, N/A, customer codes, rejects with NCR, block
signatures, sign-off with the PDF hook, admin reopen, the ITP log), hold-point
warnings by department page group, the scoped policy check for unit and
project writes, migration 0007, tests, Playwright scenarios and docs.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-17 20:08:53 -06:00
co-authored by Claude Fable 5.1
parent 8bb1f7ae4d
commit 00531cb329
64 changed files with 11715 additions and 260 deletions
+87 -13
View File
@@ -1,6 +1,6 @@
# Packet Station schema
Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 5 adds the shop columns, the checklist templates and the clock rules (migration 0006). Phase 9 adds the notification rules and delivery. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema".
Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 5 adds the shop columns, the checklist templates and the clock rules (migration 0006). Phase 6 adds the Q.C. columns, the Q.C. check items, the Q.C. folder metadata, the ITP header, line and block details and the ITP log (migration 0007). Phase 9 adds the notification rules and delivery. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema".
## Conventions
@@ -46,9 +46,9 @@ Declaration order matters for `packet_status`: it is the workflow order, so `sta
| `project_status` | open, hold, closed |
| `route_kind` | databook, shipping |
| `drawing_state` | none, needed, attached, rev_mismatch, not_released, not_found, lookup_failed, hand |
| `itp_status` | open, signed, reopened |
| `itp_status` | open, signed, reopened, complete (0007) |
Smaller closed sets are text with a CHECK: `projects.source` and every `*_src` (visual, hand), `project_aliases.kind` (renumber, unit_renumber, import), `packet_pages.type` (drawing, info, overlay), `bom_lines.src` (visual, hand), `qc_inspections.result` (accepted, rejected), `itp_lines.result` (approve, reject, na). Null means unset in each.
Smaller closed sets are text with a CHECK: `projects.source` and every `*_src` (visual, hand), `project_aliases.kind` (renumber, unit_renumber, import), `packet_pages.type` (drawing, info, overlay), `bom_lines.src` (visual, hand), `qc_inspections.result` (accepted, rejected), `itp_lines.result` (approve, reject, na). Migration 0007 adds `packet_docs.kind`, `qc_docs.type` and `itp_rejects.kind` (see "Q.C. (phase 6, migration 0007)"). Null means unset in each.
## Projects and units (phase 2)
@@ -101,7 +101,7 @@ What the warehouse columns mean in practice (phase 4):
- `heat_table_placements`: `page`, `x`, `y`, `s`, `carried_from` (0006), `by`, `at`. Unique on (`packet_id`, `page`), written as an upsert.
- `packet_wh`: the warehouse stage history. `stage` (wh_stage), `by`, `at`, `reason`, `note`, `auto`, `closed_at`. The current row has `closed_at` null and is unique per packet. A stage change closes it and inserts the next.
- `packet_transitions`: append-only. Every status change: `at`, `by`, `action`, `from_status`, `to_status`, `from_dept`, `to_dept`, `reason`, `signature_id`, `it_reason`. Replaces the old `hist` and `sentBack` lists.
- `qc_inspections`: `stage_id`, `sent_at`, `sent_by`, `book`, `spool`, `location`, `sent_note`, `checks` jsonb, `weld_exam` jsonb (weld_no to `{result, note}`), `nde_method`, `nde_result`, `nde_report`, `hardness`, `nace`, `inspector`, `stamp`, `notes`, `result`, `result_at`, `result_by`, `result_note`, `signature_id`, `row_version`, `closed_at`. At most one open inspection per packet.
- `qc_inspections`: `stage_id`, `sent_at`, `sent_by`, `book`, `spool`, `location`, `sent_note`, `checks` jsonb, `weld_exam` jsonb (weld_no to `{result, note}`), `nde_method`, `nde_result`, `nde_report`, `hardness`, `nace`, `inspector`, `stamp`, `notes`, `result`, `result_at`, `result_by`, `result_note`, `signature_id`, `row_version`, `closed_at`, `ncr` (0007). At most one open inspection per packet. See "The Q.C. inspection".
- `qc_history`: append-only. `at`, `result`, `inspector`, `note`, `dept_code`, `snapshot` jsonb.
- `packet_seals`: the HMAC-SHA256 `hash` written at filing, `key_id`, `sealed_at`, `sealed_by`, `signature_id`, and `broken_at`, `broken_by`, `broken_reason` when IT reopens. At most one unbroken seal per packet.
- `packet_log`: append-only. The user-visible history: `at`, `by`, `by_name` and `role` (snapshots), `action`, `note`, `it_reason`, `signature_id`, `transition_id`. Indexed on (`packet_id`, `id`).
@@ -190,6 +190,50 @@ A clock op in the wrong state is 409 `clock_state`.
The read model gives `server_now` (the database `now()`) and `stage.clock`: `{elapsed_ms, stored_ms, running, running_since, started_at}`. `stored_ms` is the column. `elapsed_ms` is live: the stored total plus `now() - clock_running_since` while running. Each closed stage has the same object, with `running` false. The client adds the time since the fetch to `elapsed_ms` for the ticking display only.
## Q.C. (phase 6, migration 0007)
### Column additions
- `qc_inspections`: `ncr` (text). The NCR number given with a Q.C. reject, upper case (`NCR-2026-0021`).
- `packet_docs`: `note` (text, up to 300 characters). `kind` gains a CHECK: one of `nde`, `hydro`, `dimensional`, `pmi`, `coating`, `coc`, `customer`, `ncr`, `other`.
- `qc_docs`: `title`, `note`, `superseded_at` and `superseded_by_user` (the person who filed the newer revision). Two CHECKs: `qc_docs_scope` (exactly one of `project_id` and `unit_id` is set) and `qc_docs_type` (one of `ITP`, `WPS / PQR`, `NDE procedure`, `Hydrotest procedure`, `PMI / material`, `Calibration certificate`, `Customer spec`, `Other`; the label itself is stored). Indexes `qc_docs_project` and `qc_docs_unit` on the current rows (`superseded_by` null).
- `itp_status` gains `complete`. A signed-off ITP is `complete`. `signed` and `reopened` stay in the enum unused: PostgreSQL cannot drop an enum value, and a reopened ITP is `open` again with `reopen_*` set.
- `itps`: `no` (the ITP number, `<unit number>-ITP` at start), `hdr` jsonb (the header: `client`, `pname`, `pno`, `po`, `unit`), `created_at`, `created_by`.
- `itp_lines`: `initials` (from the inspector's display name at approval), `cust_code` (the customer code for this line, null means the template's), `cust_by` (the customer witness or reviewer, as typed), `cust_at` (date), `cust_rec` and `cust_rec_at` (who recorded the customer entry, and when).
- `itp_rejects`: `ncr` is now nullable (a reject can wait for its NCR number). `kind` (text, `reject` or `ncr` by CHECK, default `reject`): `reject` is the reject itself, `ncr` is the NCR number entered later.
- `itp_blocks`: `signed_at` is now nullable with no default, so a block row can hold its fields before it is signed. New: `qn` (the QN number), `remarks`, `cust_name`, `cust_at` (date), `cust_by` and `cust_rec_at` (the customer complete entry and who recorded it), `row_version`.
- `itp_log` (new, append-only): `itp_id`, `at`, `by`, `by_name` (snapshot), `action`, `note`, `signature_id`, `it_reason`. Indexed on (`itp_id`, `id`). The user-visible history of an ITP, as `packet_log` is for a packet.
`ALTER TYPE ... ADD VALUE` runs inside the migration's transaction. That is allowed because 0007 does not use the new value itself.
### Q.C. check items
The `settings` row `qc_check_items` holds the Q.C. check items. Migration 0007 inserts the defaults with `ON CONFLICT DO NOTHING`, so a row already there is kept. The value:
```json
{"groups": [
{"group": "Data book package", "items": [{"key": "db_heats", "text": "Heat number recorded for every BOM item"}, ...]},
{"group": "Physical spool", "items": [{"key": "ph_dim", "text": "Dimensions verified against {drawing} Rev {rev}"}, ...]}
]}
```
| Group | Keys |
|---|---|
| Data book package | `db_heats`, `db_mtr`, `db_weldmap`, `db_wps`, `db_ck` |
| Physical spool | `ph_dim`, `ph_heat`, `ph_stamp`, `ph_clean`, `ph_qty` |
The texts may hold `{wps}` (else `the WPS`), `{stamp}` (`[75] ` with a trailing space, or empty), `{drawing}` (else `the drawing`) and `{rev}` (else `-`), filled from the packet. When the row is missing or malformed, the defaults in `domain/checklists.py` apply and a warning is logged. No screen edits the row in phase 6.
`qc_inspections.checks` maps each key to `{done: true, at, by, stamp}` (the inspection's stamp) or `{done: false}`, written one key at a time with `jsonb_set`. A key not in the list is 422 `invalid_key`. `qc_inspections.weld_exam` maps a weld number to `{result, note, at, by}`, where `result` is `accept`, `reject` or empty. `nde_method` is one of `None required`, `RT`, `UT`, `MT`, `PT`; `nde_result` is empty, `Acceptable` or `Rejectable`. `nace` is no longer written: the NACE label is computed from the packet's WPS.
### The Q.C. inspection
- SEND_TO_QC (from review) and SEND_DRAFT_TO_QC (from draft) open a `qc_inspections` row with `book`, `spool`, `location`, `sent_note` and the stage. Each opens a fresh row with empty checks and exam.
- At most one open row per packet (`qc_inspections_open`, unique on `packet_id` where `closed_at` is null).
- QC_ACCEPT and QC_ACCEPT_AND_ROUTE set the result and close the row into `qc_history`. The stage is closed with the inspection as `qc_snapshot`.
- QC_REJECT sets the result, the reason as `return_note` and the `ncr`, and closes the row into `qc_history` at once. The packet goes to review with no open inspection. A second SEND_TO_QC opens a new row; the first stays in `qc_history`.
- A `qc_history` row keeps `at`, `result`, `inspector`, `note` (the result note) and `dept_code` as columns, and the full inspection row in `snapshot` (checks, weld exam, NDE fields, `ncr`, `result_by`, location, sent time).
## Change orders (phases 2 and 3)
- `change_orders`: `co_no` (from `next_co_no`; unique per packet), `origin` (co_origin), `review` (co_review), `minor`, `hold`, `held_by`, `held_at`, `ecn`, `reason`, `issued_*`, `approved_*` and `rejected_*` (each with a `*_signature_id`), `vis`, `waiting` and `staged` jsonb (feed-shaped), `target_fp`, `row_version`. At most one pending VISUAL change order per packet (unique on `packet_id` where origin is visual and review is pending), so the feed re-aims it instead of stacking new ones.
@@ -200,6 +244,8 @@ The read model gives `server_now` (the database `now()`) and `stage.clock`: `{el
## Documents (phases 2, 4 and 6)
Nothing in this section is ever deleted. A packet document comes out of the data book by soft removal. A Q.C. folder document is replaced by a newer row that the old one points at. ITP rows are corrected forward, and the history is in the append-only `itp_rejects` and `itp_log`.
- `assets`: `id` uuid, `sha256` (indexed, for dedupe), `size`, `mime`, `original_name`, `storage_key` (the path under ASSETS_DIR, unique), `source` (`upload` by default), `source_ref` jsonb, `pages`, `uploaded_by`, `uploaded_at`, `packet_id` (the packet it was uploaded for, without a foreign key; `packet_pages` and `packet_docs` are the real links).
Asset kinds (phase 4). An upload names its kind in the `purpose` field, and `source_ref` keeps `{"purpose": kind}`. No purpose means `document`. The type is read from the content, never from the name or the browser.
@@ -212,25 +258,53 @@ Asset kinds (phase 4). An upload names its kind in the `purpose` field, and `sou
| bom | XLSX, CSV | 5 MB (`upload_max_bytes_bom`) |
`mime` is one of `application/pdf`, `image/png`, `image/jpeg`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` and `text/csv`. An XLSX is a zip that holds `[Content_Types].xml` and `xl/workbook.xml`; a plain zip is refused. A CSV is UTF-8 text with no NUL and no control character other than tab, CR and LF. An upload the virus scanner refuses is never stored, so it has no row.
- `packet_docs`: `asset_id`, `kind`, `title`, `added_at`, `added_by`, soft delete.
- `qc_docs`: `project_id`, `unit_id`, `type`, `rev`, `asset_id`, `auto`, `superseded_by` (the newer row), `added_at`, `added_by`.
- `itps`: one per unit (`unit_id` unique). `template` jsonb, frozen when the ITP is made. `status` (itp_status), `signoff_*` (with the signature and the signed PDF asset), `reopen_*`, `version`.
- `itp_lines`: `key` (unique per ITP), `result`, `by`, `at`, `ncr`, `note`, `signature_id`, `row_version`.
- `itp_rejects`: append-only. `line_key`, `ncr` (required), `note`, `at`, `by`. Every reject keeps its NCR id, even after the line is approved later.
- `itp_blocks`: `block_key` (unique per ITP), `signed_at`, `signed_by`, `signature_id`.
### Packet documents
`packet_docs`: `asset_id`, `kind` (the closed set below), `title` (the kind label when none is given), `note`, `added_at`, `added_by`, and `removed_at`, `removed_by`, `removed_reason`. The asset must be a PDF, PNG or JPEG.
| kind | Label |
|---|---|
| nde | NDE report (RT / UT / MT / PT) |
| hydro | Hydrotest report |
| dimensional | Dimensional report |
| pmi | PMI report |
| coating | Coating / paint report |
| coc | Certificate of conformance |
| customer | Customer inspection report |
| ncr | NCR |
| other | Other |
Taking a document out of the data book sets the three `removed_*` columns, with a reason. The row stays. The packet read lists it with `removed` set, so the record shows what was taken out, by whom and why. A row already taken out cannot be taken out again (409 `already_removed`).
### Q.C. folder documents
`qc_docs`: one row per filed document, on a project (`project_id`) or a unit (`unit_id`), never both. `type` (the label), `title`, `rev`, `note`, `asset_id` (PDF, PNG or JPEG), `auto` (filed by the ITP sign-off, titled with the ITP number and "signed off" as the old build, rev null), `added_at`, `added_by`, and the supersession columns `superseded_by`, `superseded_at`, `superseded_by_user`.
A new revision is a new row. In the same transaction, under `SELECT ... FOR UPDATE` of the old row, the old row gets `superseded_by` (the new row's id), `superseded_at` and `superseded_by_user`. The old row is never changed otherwise and never deleted. A row is current while `superseded_by` is null. The revision must be in the same folder as the row it replaces (422 `invalid_replace`); an old row that already points at a newer one is 409 `already_superseded`.
A unit folder shows its own current rows and inherits the project's current rows. A packet's read lists both as `qc_folder_docs`.
### The ITP
- `itps`: one per unit (`unit_id` unique). `template` jsonb is a copy of the code template `ITP_Generation_Revised_2`, frozen when the ITP is started, so a later template change never moves an existing ITP. `no`, `hdr`, `status` (`open` or `complete`), `signoff_at`, `signoff_by`, `signoff_signature_id`, `signoff_asset_id` (the signed PDF, set only when the phase 7 hook files one), `reopen_at`, `reopen_by`, `reopen_reason`, `created_at`, `created_by`, `version` (bumped by every ITP write).
- `itp_lines`: one row per template item, all made when the ITP is started, so every line has a `row_version` from the first read. `key` is the item number (`1.3`, unique per ITP). `result` is null (open), `approve`, `reject` or `na` (N/A is stored as the result). `by`, `at` and `initials` are the approval; removing it clears those and `result` and keeps `ncr` and the reject history. `ncr` is the NCR of the latest reject, `note` the line note. `cust_code`, `cust_by`, `cust_at`, `cust_rec`, `cust_rec_at` are the customer entry. `row_version` is for the If-Match on approval. A line is done when it is N/A or approved.
- `itp_rejects`: append-only. One `reject` row per reject (`ncr` may be null), and one `ncr` row when the number is entered later. `line_key`, `ncr`, `note`, `kind`, `at`, `by`. The rows survive the later approval of the line.
- `itp_blocks`: one row per signature block, all made when the ITP is started (`block_key` is the number of the section that closes the block, `3` to `9`; unique per ITP). An unsigned row holds `qn`, `remarks` and the customer complete fields. `signed_at`, `signed_by`, `signature_id` are the PESCO Q.C. signature. Removing it clears those three and keeps the rest.
- `itp_log`: append-only. One row per ITP write: `by`, `by_name`, `action`, `note`, `signature_id` (block sign and sign-off), `it_reason` (an admin edit). See [POLICY.md](POLICY.md), "Log texts".
## Append-only set
These tables have the `ps_append_only` row and TRUNCATE triggers, and `ps_app` holds only SELECT and INSERT on them:
`audit_events`, `signin_attempts`, `signatures`, `project_renumbers`, `short_receipts`, `packet_transitions`, `qc_history`, `packet_log`, `itp_rejects`, `co_verifications`.
`audit_events`, `signin_attempts`, `signatures`, `project_renumbers`, `short_receipts`, `packet_transitions`, `qc_history`, `packet_log`, `itp_rejects`, `itp_log` (0007), `co_verifications`.
The app role is refused by privilege first. The migrate role owns the tables, so the trigger refuses it. The views `v_units` and `v_open_cos` are SELECT only for `ps_app`.
## Audit, settings, jobs (phases 1 and 3)
- `audit_events` (migration 0001): append-only. `at`, `user_id`, `session_id`, `role_snapshot` (`system` for the worker), `action`, `subject_type`, `subject_id`, `packet_id`, `detail` jsonb, `it_reason`, `ip`. A hash chain: `row_hash` is sha256 over `prev_hash` and the canonical JSON of the row, and `prev_hash` is the row before it. BRIN index on `at`.
- `settings` (migration 0001): `key` (text PK), `value` jsonb, `version`, `updated_at`, `updated_by`. Phase 3 adds the row `feed`, the feed settings as one JSON object (see [FEED.md](FEED.md), "Settings"). Phase 5 adds the row `checklist_templates` (see "Checklist templates"). A missing key takes its default. A save bumps `version` and sends `ps_notify('ps_settings', ...)` so the worker reschedules.
- `settings` (migration 0001): `key` (text PK), `value` jsonb, `version`, `updated_at`, `updated_by`. Phase 3 adds the row `feed`, the feed settings as one JSON object (see [FEED.md](FEED.md), "Settings"). Phase 5 adds the row `checklist_templates` (see "Checklist templates"). Phase 6 adds the row `qc_check_items` (see "Q.C. check items"). A missing key takes its default. A save bumps `version` and sends `ps_notify('ps_settings', ...)` so the worker reschedules.
Phase 3, migration 0004:
@@ -273,7 +347,7 @@ Column additions in 0004:
## Versioning
- `packets.version` goes up by one in every write transaction that touches the packet or any of its children. The write pipeline does it explicitly (`UPDATE packets SET version = version + 1 ... RETURNING version`) after the caller's SQL. No trigger bumps it, so a read never does. It is the packet ETag and the NOTIFY payload.
- `projects.version`, `units.version` and `itps.version` play the same part for those rows.
- `projects.version`, `units.version` and `itps.version` play the same part for those rows. An ITP write locks the `itps` row (`SELECT ... FOR UPDATE`), not a packet row, and bumps `itps.version`. A Q.C. folder revision locks the row it replaces.
- Child rows that are edited in place carry `row_version`. A PATCH sends `If-Match: <row_version>`. A mismatch is 409 `row_conflict` with the current row. The write bumps `row_version`.
- Every write takes `SELECT ... FOR UPDATE` on the packet row first. That lock replaces the old 8-second lease and serialises the counters: `next_weld_no`, `next_heat_no`, `next_page_no` and `next_co_no` hand out the next number with `UPDATE packets SET next_weld_no = next_weld_no + 1 RETURNING next_weld_no - 1`, so two concurrent adds never get the same number.
- Transitions do not check `version`. They compare and set on `expect: {status, dept}`, so a weld edit never blocks a send.