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]>
354 lines
40 KiB
Markdown
354 lines
40 KiB
Markdown
# 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 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
|
|
|
|
Identity bigints, except users, keyed on the AD objectGUID. Postgres enums for workflow states. Every timestamp is timestamptz in UTC. Soft delete uses removed_at, removed_by and removed_reason. Append-only tables give the app role insert and select only, plus a trigger that refuses updates and deletes.
|
|
|
|
## Database roles
|
|
|
|
A superuser for bootstrap, the migrate role `ps_migrate` (owns the database and the schema, runs migrations) and the app role `ps_app`. The worker shares `ps_app` and sets `application_name` to `ps-worker`, so the two are told apart in `pg_stat_activity`; a separate `ps_worker` role was not needed (decided in phase 3). `ps_app` holds CONNECT and TEMPORARY on the database (the feed COPYs each batch into temp tables) and no CREATE on the schema.
|
|
|
|
## Identity (phase 1, migration 0002)
|
|
|
|
Enums: `role_kind` (admin, clerical, clerical_supervisor, warehouse, warehouse_supervisor, qc, qc_supervisor, pm, shop_worker, shop_lead, shop_supervisor), `session_kind` (password, pin), `sig_factor` (password, pin), `dept_kind` (support, shop).
|
|
|
|
- `users`: `id uuid` is the AD `objectGUID`, stable across renames. Caches `sam_account` (unique, case-insensitive), `upn`, `display_name`, `emp_no` (text; indexed but not unique, because a duplicate must be storable and flagged), `email`, `welder_stamp`, `active`, `directory_flags` jsonb (disabled, missing or duplicate employee ID), `ad_synced_at`, `last_signin_at`. A user whose AD account is disabled or gone keeps the row, marked inactive.
|
|
- `user_roles`: (`user_id`, `role`, `dept_code`) rebuilt at every sign-in, at the 15-minute role refresh and by the AD refresh. Office roles carry their department code, admin carries null, shop roles carry the two-digit code. Only the highest shop level per department is stored.
|
|
- `sessions`: `token_hash` (HMAC-SHA256 of the cookie token with the session key), `kind`, `device_id` for PIN sessions, `expires_at` (absolute), `last_seen_at` (idle), `locked_since`, `revoked_at` and `revoked_reason`, `ip`, `user_agent`, `roles_snapshot` jsonb (`{"roles": [...], "groups": [...]}`), `roles_checked_at`, `stepup_failures`, `hidden_since` (the last hidden-tab report, cleared by the next request).
|
|
- `devices`: registered shop devices. `name`, `token_hash` of the device cookie, `registered_by`, `last_seen_at`, `revoked_at`, `revoked_by`.
|
|
- `user_pins`: one row per user with a PIN. Argon2id `hash`, `failed_count` (across sign-in, unlock and signature), `disabled_at`, `set_at`. Never readable by Admin in clear; Admin may delete the row.
|
|
- `signin_attempts`: append-only. `account` (`lower(login)`, `emp:<emp_no>` or `user:<uuid>`), `ip`, `kind` (password, pin, unlock, signature, pin_set), `ok`, `reason`, `user_id`, `session_id`. Both rate limits count the failed rows here.
|
|
- `signatures`: append-only. Who (`user_id`, `account` and `display_name` snapshots), the `session_id` and `session_kind`, the `factor`, `action`, `meaning`, `packet_id`, `subject_type`, `subject_id` and a `content_hash` (sha256 of the canonical JSON of what was signed). Later phases reference it from every sign-off row.
|
|
|
|
Append-only tables use the `ps_append_only` trigger from 0001 and revoke UPDATE, DELETE and TRUNCATE from `ps_app`.
|
|
|
|
## Organisation (phase 1 seed, phase 2 completes)
|
|
|
|
- `departments`: `code` (text PK, `CL`, `WH`, `QC`, `PM`, `01` to `14`), `slug` (the AD group short name, unique), `name`, `kind`, `sort`, `active`, `supervisor_group` (false only for PM). A trigger refuses changes to `code` and `slug`. Seeded with the 18 departments from the brief and the old `depts.js`. Migration 0003 adds `weld` (bool, default false), true for 03, 04, 05, 06, 07 and 10. A weld department gets the welding checklist, and submit there needs at least one weld and a filler.
|
|
- `stations`: (`dept_code`, `name`, `sort`, `active`), seeded with the old station lists (Stall 1 to 6, Station 1 to 4, Hydro Bay 1 and 2, Blast Booth, Paint Booth 1 and 2).
|
|
- `user_station_limits`: (`user_id`, `station_id`) primary key. A worker with rows here may only work on packets at those stations. No rows means any station in the department. The rows go when the user row goes.
|
|
|
|
The 50 AD group names are derived from `departments` plus `PS-Admins`: `PS-<slug>` and `PS-<slug>-Supervisor` for support departments, `PS-<code>-<slug>-<Worker|Lead|Supervisor>` for shop departments. `packetstation ad-groups --list` prints them.
|
|
|
|
## Enums (phase 2, migration 0003)
|
|
|
|
Declaration order matters for `packet_status`: it is the workflow order, so `status >= 'qc'` works in SQL.
|
|
|
|
| Enum | Values in order |
|
|
|---|---|
|
|
| `packet_status` | draft, warehouse, ready, assigned, in_progress, review, qc, complete, filed |
|
|
| `wh_stage` | incoming, pulling, hold, pulled |
|
|
| `co_review` | pending, approved, rejected |
|
|
| `co_origin` | visual, drawing, manual |
|
|
| `source_kind` | visual, hand |
|
|
| `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, 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). 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)
|
|
|
|
- `projects`: `number` (text, unique among live rows on `upper(number)`), `name`, `customer`, `po`, `due`, `status` (project_status), `notes`, `source` (hand or visual), `needs_details` (the feed made it and Clerical has not filled it in), `version` for field CAS. Order-derived values carry provenance: `customer_src`, `po_src`, `order_date_src` and `ship_date_src` say visual or hand, and `customer_visual` and `po_visual` keep what VISUAL said. The feed never overwrites a hand value. Soft delete.
|
|
- `units`: `project_id`, `lot` (text), `name`, `due`, `status`, `notes`, the same `*_src` companions, `order_date`, `ship_date`, `version`. A CHECK keeps `lot` to 1 to 3 upper-case letters or digits, and a lot of digits only is exactly 3 long (`001`, `7A`). Unique among live rows on (`project_id`, `lot`). Soft delete.
|
|
- `project_aliases`: `alias` (text PK, upper-cased by CHECK), `project_id`, `unit_id` (null for a project number), `kind` (renumber, unit_renumber, import), `created_at`. Every current and old number of every project and unit is here, so a lookup by any number resolves, and a renumber cannot reuse a number that was ever taken.
|
|
- `project_renumbers`: append-only. `at`, `by`, `project_id`, `unit_id`, `old_number`, `new_number`, `reason`.
|
|
- View `v_units`: every `units` column plus `number` (`project.number || lot`, so `L999` and `001` make `L999001`), `project_number`, `project_name`, `project_status` and `due_effective` (the unit's due, else the project's).
|
|
|
|
## Packets (phase 2)
|
|
|
|
### Header
|
|
|
|
`packets` is the header. The columns that matter:
|
|
|
|
- Placement: `unit_id`, `dept_code`, `op`, `title`, `customer`, `drawing`, `rev`, `wps`.
|
|
- Workflow: `status` (packet_status), `status_at`, `station_id`, `welder_id` and `welder_stamp` (a snapshot taken at assign), `for_sup_id`, `wh_skipped` (true when Clerical sent the draft straight to a department or to Q.C.), `wh_sent_at`, `routed_to` (route_kind, set at Q.C. accept), `return_note` (the reason shown after a send-back), `vclosed_at` and `vclosed_by`, `databook_at`.
|
|
- Preconditions: `heat_map` is nullable on purpose. Null means not answered yet, and the warehouse step needs an answer.
|
|
- Source: `source_kind` (visual or hand), `source` jsonb, `visual_key` (the feed's match key), `visual_rel`, `visual_want`, `drawing_state`, `flags` text[], `visual_fp_applied` (the fingerprint the packet matches), `visual_closed_at`, `visual` jsonb (system-written sub-keys).
|
|
- Counters: `next_weld_no`, `next_heat_no`, `next_page_no` and `next_co_no`, all starting at 1. See "Versioning".
|
|
- `version`, `created_at`, `created_by`, `updated_at`, soft delete.
|
|
|
|
Indexes: `packets_visual_key` is unique on `visual_key` where the key is set and the row is live. A removed packet keeps its key, so two removed rows may share one, but only one live row may hold it. Also `(status)` for live rows, `(unit_id)` and `(dept_code, status)` for the boards.
|
|
|
|
The trigger `ps_packet_touch` sets `updated_at` on every UPDATE. It does not touch `version`.
|
|
|
|
### Children
|
|
|
|
- `packet_work_orders`: `wo_base`, `lot`, `split`, `sub` (all text; the last three default to `''` so the key has no nulls), `seq`, `qty`, `target_hrs`, `op`, `description`, `visual_status`, `missing_runs` and `gone_at` (the feed's "work order gone" check), `sort`, `row_version`. Phase 3 adds `partspecs`, `rev` and `dwg`. Unique on (`packet_id`, `wo_base`, `lot`, `split`, `sub`, `seq`) with nulls not distinct, since migration 0004 (see "VISUAL and drawing caches").
|
|
- `packet_pages`: `n` (the packet page number, from `next_page_no`), `type`, `asset_id`, `pn` (page within the asset), `dwg`, `rev`, `co_id` (the change order that added it), `void_co_id`, `void_at`, `void_by`, `void_new_n` (a replaced page is voided, never deleted), `row_version`. Unique on (`packet_id`, `n`).
|
|
- `bom_lines`: `item`, `qty`, `part_id` (text), `description`, `dwg_ref`, `cl_item`, `length`, `material`, `heat`, `na`, `issued`, `loc`, `pulled`, `pulled_at`, `pulled_by`, `mtr_asset_id`, `src`, `vkey`, `whs`, `comm`, `cons`, `sub`, `grp`, `sort`, `row_version`. Unique on (`packet_id`, `item`).
|
|
- `bom_shorts`: `bom_line_id` (cascades with the line), `qty`, `qty_open`, `reason`, `ref`, `eta`, `note`, `opened_at`, `opened_by`, `closed_at`, `row_version`. At most one open short per line (unique on `bom_line_id` where `closed_at` is null).
|
|
- `short_receipts`: append-only. `short_id`, `packet_id`, `qty`, `heat`, `note`, `at`, `by`.
|
|
|
|
What the warehouse columns mean in practice (phase 4):
|
|
|
|
- `bom_lines.pulled`: the line is off the shelf. The first tick sets `pulled_at` and `pulled_by`, and sets `issued` to `qty` when it was empty. Unticking clears all three of `pulled`, `pulled_at` and `pulled_by`. The first tick while the warehouse stage is incoming or on hold also starts pulling (a `packet_wh` row with `auto` true).
|
|
- `bom_lines.issued`: how many pieces went out. Less than `qty` means the rest should be on a short.
|
|
- `bom_lines.loc`: where the parts were taken from, as the warehouse types it.
|
|
- `bom_lines.heat`: the heat number of the pieces issued, checked against the heat pattern. A receipt adds its heat to the line, so a line with parts from two heats reads `H1 / H2` (heats joined with " / ", no repeats).
|
|
- `bom_lines.na`: the part has no traceability. An N/A line needs no heat, takes no heat, and does not count toward the release gate.
|
|
- `bom_lines.mtr_asset_id`: the mill test report for the line, an `assets` row that must be a PDF, PNG or JPEG. Null clears it.
|
|
- A line "needs a heat" when it is not N/A, not a sub-assembly (`sub` is `Y`), is pulled, has `issued` above 0 and has no heat. The release gate counts these lines (see [POLICY.md](POLICY.md)).
|
|
- `bom_shorts`: one open short per line at most. Opening a short sets the line's `issued` to `qty` minus the short quantity and ticks it pulled. `qty` is what was short in total, `qty_open` what is still missing. `reason` is one of the closed short reasons, `ref` the PO or requisition, `eta` the expected date. Editing an open short keeps what was already received in `qty`. "Not short after all" closes it (`closed_at`) and sets `issued` to the line's `qty`. While a short is open, the line's heat, issued and location change only through the short.
|
|
- `short_receipts`: one row per delivery, never changed. Each receipt lowers `qty_open`, adds its pieces to the line's `issued` and its heat to the line's `heat`. The receipt that brings `qty_open` to 0 closes the short.
|
|
- `packet_wh`: `reason` is one of the closed hold reasons when the stage is `hold`, and `note` is up to 200 characters.
|
|
- `packet_stages`: one row per visit to a shop department. `dept_code`, `station_id`, `welder_id`, `welder_stamp`, `checklist` jsonb (key to `{done, at, by, stamp}`, written per key with `jsonb_set`), `filler`, `filler_lot`, `preheat`, `notes`, the clock (`clock_elapsed_ms`, `clock_running_since`, `clock_started_at`; only the server writes them, see "The clock"), `qc_snapshot`, `opened_at`, `closed_at`, `row_version`. At most one open stage per packet.
|
|
- `welds`: `weld_no` (from `next_weld_no`), `page`, `fx` and `fy` (fractions of the page), `bfx` and `bfy` (0006), `wo_base`, `piece`, `items` int[], `type`, `size`, `process`, `stamp`, `welder_id`, `dept_code`, `stage_id`, `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `weld_no`) among live rows.
|
|
- `heat_tags`: `tag_no` (from `next_heat_no`), `page`, `item`, `fx`, `fy`, `bfx` and `bfy` (0006), `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `tag_no`) among live rows.
|
|
- `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`, `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`).
|
|
- `packet_visual_state`: one row per packet, written by the worker. `legs` jsonb, `ticket_hours`, `order_ref`, `observed_at`.
|
|
|
|
Every who column references `users (id)`. Every `signature_id` references `signatures (id)`.
|
|
|
|
## Shop (phase 5, migration 0006)
|
|
|
|
### Column additions
|
|
|
|
- `welds`: `bfx` and `bfy` (double precision, null). The label (bubble) position as fractions of the page. `fx` and `fy` stay where the arrow points.
|
|
- `heat_tags`: `bfx` and `bfy`, the same.
|
|
- `heat_table_placements`: `carried_from` (int, null). The packet page number the table was copied from on a change order page replacement.
|
|
|
|
A null `bfx` means the label has not been moved. The client then draws it at the old default offset: `bfx = clamp01(fx + (fx > 0.85 ? -0.05 : 0.05))` and `bfy = clamp01(fy + (fy < 0.12 ? 0.07 : -0.07))`, where clamp01 keeps a value between 0.01 and 0.99.
|
|
|
|
The API checks the values the columns have no CHECK for:
|
|
|
|
| Value | Rule | Refusal |
|
|
|---|---|---|
|
|
| `fx`, `fy`, `bfx`, `bfy` | 0 to 1, or null for `bfx` and `bfy` | 422 |
|
|
| `welds.type` | Fillet, Groove, Fill, Seal or Tack | 422 `invalid_weld` |
|
|
| `welds.process` | GMAW, FCAW, SMAW, GTAW or SAW | 422 `invalid_weld` |
|
|
| `welds.size` | `5/16"` on insert when missing | |
|
|
| `welds.piece` | 1 or more | 422 |
|
|
| `welds.wo_base` | one of the packet's work order bases | 422 `invalid_wo` |
|
|
| `heat_table_placements.x`, `y` | 0 to 0.97 | 422 `invalid_heat_table` |
|
|
| `heat_table_placements.s` | 0.6 to 2.1, in steps of 0.15 from the screen | 422 `invalid_heat_table` |
|
|
| `page` of a heat table placement | a live or voided drawing page of the packet | 422 `invalid_page` |
|
|
| `page` of a new or moved weld or heat tag | not a voided page | 422 `void_page` |
|
|
|
|
### Checklist templates
|
|
|
|
The `settings` row `checklist_templates` holds the shop checklists. Migration 0006 inserts the defaults with `ON CONFLICT DO NOTHING`, so a row already there is kept. The value:
|
|
|
|
```json
|
|
{
|
|
"weld": [{"group": "Before welding", "key": "dwg", "text": "Drawing {drawing} Rev {rev} matches the packet"}, ...],
|
|
"plain": [{"group": "Before starting", "key": "dwg", "text": "..."}, ...]
|
|
}
|
|
```
|
|
|
|
`weld` is the list for a weld department (`departments.weld` true), `plain` for every other shop department. Each entry has a `group`, a `key` and a `text`. The text may hold the placeholders `{drawing}`, `{rev}` and `{wps}`, filled from the packet.
|
|
|
|
| List | Group | Keys |
|
|
|---|---|---|
|
|
| weld | Before welding | `dwg`, `heat`, `wps`, `fit`, `pre` |
|
|
| weld | After welding | `vt`, `stamp`, `clean`, `qty` |
|
|
| plain | Before starting | `dwg`, `prev`, `mat` |
|
|
| plain | When done | `work`, `insp`, `qty` |
|
|
|
|
When the row is missing or malformed, the defaults in `domain/checklists.py` apply and a warning is logged. The read model and the checklist write both use the loaded templates. No screen edits the row in phase 5. Phase 9 (Admin) adds that.
|
|
|
|
`packet_stages.checklist` maps each key to `{done, at, by, stamp}`. `by` is the user id. `stamp` is the user's `welder_stamp`, else the packet's. A key not in the department's list is 422.
|
|
|
|
### Carried marks
|
|
|
|
Welds, heat tags and heat table placements belong to the packet, not to the stage. Routing a packet to another department copies nothing. The read model computes a `carried` flag instead:
|
|
|
|
- A weld is carried when `stage_id` is set and is not the open stage's id. It was logged in an earlier department's stage.
|
|
- A heat tag is carried when its `at` is before the open stage's `opened_at`. The warehouse or an earlier department placed it.
|
|
|
|
The `carried_from` columns on `welds` and `heat_tags` (from 0003) are not written by routing.
|
|
|
|
There is one copy, on a change order page replacement. When a feed change order is approved and a drawing page is voided, each heat table placement on the voided page is copied to its new page with `carried_from` set to the old page number. The log says `Heat table copied to new sheet` with the note `from the VOID copy, check its position on the new revision`. Welds and heat tags stay on the void page.
|
|
|
|
### The clock
|
|
|
|
`packet_stages` has three clock columns (from 0003):
|
|
|
|
- `clock_elapsed_ms` (bigint, default 0): the stored total.
|
|
- `clock_running_since` (timestamptz): when the clock last started. Null while it is stopped.
|
|
- `clock_started_at` (timestamptz): the first start of the stage.
|
|
|
|
Only the server writes them, with the database `now()`. No request field sets them, and a value from the client is never used.
|
|
|
|
| Event | Write |
|
|
|---|---|
|
|
| START, or clock `start` | `clock_running_since = now()` when it is not running; `clock_started_at = coalesce(clock_started_at, now())` |
|
|
| clock `pause` | `clock_elapsed_ms += now() - clock_running_since`; `clock_running_since = null` |
|
|
| clock `resume` | `clock_running_since = now()` |
|
|
| PULL_BACK, UNASSIGN, SUBMIT | fold: `clock_elapsed_ms += now() - clock_running_since` when running; `clock_running_since = null` |
|
|
|
|
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.
|
|
- `co_items`: the page changes. `old_page`, `old_dwg`, `old_rev`, `new_page`, `new_dwg`, `new_rev`, `added`.
|
|
- `co_required_depts`: (`co_id`, `dept_code`) primary key. The departments that must verify.
|
|
- `co_verifications`: append-only, keyed on (`co_id`, `dept_code`). `by` and `signature_id` are required, plus `at`, `note`, `it_reason`. A primary key violation means the department already verified. A verification cannot be withdrawn.
|
|
- View `v_open_cos` ports the old `openCOs()`: `packet_id`, `co_id`, `co_no`, `review`, `hold`, `pending_depts` (text[], the required departments without a verification, sorted by code). A row appears when review is approved and `pending_depts` is not empty, or when review is pending and `hold` is true. A pending change order without hold holds nothing. A rejected one never appears. The policy's CO hold and the boards' open-CO count read this view.
|
|
|
|
## 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.
|
|
|
|
| Kind | Types | Limit (setting) |
|
|
|---|---|---|
|
|
| drawing | PDF | 25 MB (`upload_max_bytes`) |
|
|
| mtr | PDF, PNG, JPEG | 25 MB (`upload_max_bytes`) |
|
|
| document | PDF, PNG, JPEG | 25 MB (`upload_max_bytes`) |
|
|
| 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 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`, `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"). 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:
|
|
|
|
- `job_requests`: one row per "Run now". `job`, `requested_at`, `requested_by`, `note`, `claimed_run_id` and `claimed_at` (set by the run that took it). Index on `job` where `claimed_at` is null. The app inserts the row and NOTIFYs `ps_job_request` in the same transaction.
|
|
- `job_runs`: one row per run. `job`, `trigger` (schedule, request, chain, cli), `requested_by` uuid[] (the requests claimed), `started_at`, `finished_at`, `status` (running, ok, degraded, failed, skipped, abandoned), `counts` jsonb, `errors` jsonb (a list of redacted strings), `build_version`, `visual_target` (`"<server> / <database>"`), `host`. Index on (`job`, `id DESC`).
|
|
- `job_schedule`: one row per job (`job` PK). `next_run_at`, `interval_seconds`, `paused`, `updated_at`. Written by the worker after each run for the Admin tab. Seeded for `visual-feed`, `drawing-lookup` and `housekeeping`.
|
|
- `worker_heartbeat`: a single row (`id` = 1 by CHECK). `at`, `build`, `host`, `pid`, `running` text[] (the jobs running now). Upserted every 30 seconds.
|
|
|
|
## Notifications (phases 4 and 9)
|
|
|
|
Phase 4, migration 0005:
|
|
|
|
- `notification_events`: one row per alert. `id` bigserial, `kind` (text, for example `assets_quota`), `severity` (`info`, `warning` or `critical` by CHECK, default `warning`), `subject` (what the alert is about, for example `assets`), `detail` jsonb (for the quota alert, the volume usage: `total`, `used`, `free`, `pct`, `warn`), `created_at`, `resolved_at` (set when the condition clears), `acknowledged_at` and `acknowledged_by` (an Admin saw it). The unique index `notification_events_open` on (`kind`, `coalesce(subject, '')`) where `resolved_at` is null keeps one open alert per kind and subject, so a check that runs again does not stack rows. The table is not append-only: the app sets `resolved_at` and the acknowledgement. 0005 also grants `ps_app` USAGE and SELECT on every sequence in the schema.
|
|
|
|
Phase 9 builds the delivery on this table: rules, recipients, exclusions, outbox, sent log, state.
|
|
|
|
## VISUAL and drawing caches (phase 3)
|
|
|
|
Migration 0004. Every ID part is text (`wo_base`, `lot`, `split`, `sub`, part IDs, order IDs), with `''` in place of null in a key. `seq` and `piece` are int. Closed sets are text with a CHECK. The worker fills these tables. Screens read them, never VISUAL. [FEED.md](FEED.md) says how each is written.
|
|
|
|
- `visual_batches`: one row per feed run. `run_id`, `started_at`, `finished_at`, `server`, `database`, `site`, `since` and `until` (the window), `window_rows`, `tracked_keys`, `missing_keys`, `suspect`, `counts` jsonb.
|
|
- `visual_rows`: one row per work order operation (TYPE W), unique on (`wo_base`, `lot`, `split`, `sub`, `seq`). The contract fields: `res`, `dwg`, `rev`, `ps` (partspecs), `descr`, `qty`, `tgt`, `rel`, `want`, `st` (the status code F, R, U, C, X or `''`), `part`, `barcode`. `row_hash` (sha256 of the canonical row), `first_batch`, `last_batch` and `gone_batch` (set when a row in play was not read; cleared when it returns). Indexes on (`wo_base`, `lot`), on `dwg`, and on `rel` for rows not gone.
|
|
- `visual_materials`: one row per material line, unique on (`wo_base`, `lot`, `split`, `sub`, `seq`, `piece`). `part` (null for a line with no part), `qty`, `issued`, `whs`, `loc`, `st`, `istat`, `dwg`, `descr`, `comm`, `cons`, `mat_hash`, `first_batch`, `last_batch`, `gone_batch`. Index on (`wo_base`, `lot`).
|
|
- `visual_orders`: one row per customer order (`cust_order_id` PK). `status`, `customer_id`, `customer_name`, `customer_po_ref`, `order_date`, `desired_ship_date`, `promise_date`, `last_batch`.
|
|
- `visual_order_links`: `kind` (base or project), `key` (the base or the project number), `cust_order_id`, `rank` (open first, newest first), `lines` int[], `top_jobs` text[], `last_batch`. Unique on (`kind`, `key`, `cust_order_id`). Replaced for every key read.
|
|
- `visual_labor`: ticket hours per leg, unique on (`wo_base`, `lot`, `split`, `sub`, `seq`). `hours`, `tickets`, `last_ticket`, `observed_at`. No cost or pay column.
|
|
- `visual_row_changes`: what VISUAL changed, per batch. `batch_id`, the key and `seq`, `kind` (added, changed, gone, returned), `before` and `after` jsonb (the row as text values), `at`. Index on `batch_id`. `visual_material_changes` is the same plus `piece`.
|
|
- `visual_ignored_wos`: work orders kept out by the go-live date, unique on (`wo_base`, `lot`, `split`, `sub`). `rel`, `project`, `unit_number`, `res`, `dwg`, `first_seen_batch`, `last_seen_at`, and `pulled_in_at`, `pulled_in_by`, `pulled_in_reason` when Clerical pulls one in.
|
|
- `visual_resource_map`: `resource` (PK), `target` (`fold`, `ignore` or a two-digit department code, by CHECK), `confirmed`, `updated_at`, `updated_by`. Seeded from the old build's `visual_map.json`, every entry unconfirmed. "Electical" is spelled that way in VISUAL.
|
|
- `drawing_lookups`: one row per drawing number as VISUAL writes it (`dwg` PK, trimmed). The last answer: `checked_at`, `ok`, `rev` (released), `name`, `obj_id`, `obj_version`, `file_id`, `file_version`, `file_size`, `base` (H sheet), `draft_rev`, `draft_where`, `in_drafting`, `note`. Retry state: `fail_count`, `failing_since`, `next_check_at`. The fetched file: `asset_id`, `sha256`. `visual_rev_seen` is the VISUAL rev at the last lookup, so a rev change moves the drawing to the front of the queue. A rev mismatch keeps the staged M-Files copy here for approval.
|
|
|
|
Column additions in 0004:
|
|
|
|
- `packets`: `visual_cancelled_at` and `wo_gone_at`, so boards can filter on cancelled and gone without reading `flags`.
|
|
- `packet_work_orders`: `partspecs`, `rev` and `dwg`, the leg's own planner instructions, rev and drawing as VISUAL has them. The header keeps the highest rev. The unique key now includes `seq`: 0004 drops the 0003 unique on (`packet_id`, `wo_base`, `lot`, `split`, `sub`) and adds `UNIQUE NULLS NOT DISTINCT (packet_id, wo_base, lot, split, sub, seq)`, because the feed puts two operations of one work order on one packet. Hand drafts keep `seq` null, so they still have one row per key.
|
|
- `projects`: `order_date_visual`, `ship_date_visual`, `order_ref` jsonb (the chosen order and how it was chosen), and the Clerical pin `order_pin` (a customer order ID), `order_pin_by`, `order_pin_at`, `order_pin_reason`.
|
|
- `units`: `customer`, `po`, `customer_visual`, `po_visual`, `order_date_visual`, `ship_date_visual`, `order_ref`, and the same pin columns.
|
|
- `change_orders`: nothing new. `vis`, `waiting`, `staged`, `target_fp` and `minor` came in 0003.
|
|
|
|
## 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. 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.
|