A filed packet's sealed data book, documents and a manifest are copied to the archive folder by the worker. A failed copy never blocks filing: it is retried with backoff and raises the archive_failed condition. Co-Authored-By: Claude Opus 5.5 <[email protected]>
433 lines
54 KiB
Markdown
433 lines
54 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 7 adds the print log and the data book on the seal (migration 0008). Phase 8 adds no table and no migration: the PM report exports use new `print_log` kinds and presence uses two new session events. Phase 9 adds the notification rules and delivery, the integrity findings and the Admin profile switch (migration 0010). SethD's changes (built 2026-10-30) add the short approvals and the checklist frozen on each stage (migration 0011), and the per-department checklist settings rows. 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` (closed in VISUAL: on a VISUAL packet the feed writes it with `vclosed_by` null, on a hand packet Clerical; see "Seth's changes"), `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`.
|
|
- `short_approvals` (migration 0011): append-only. The supervisor's approval of one receipt. See "Seth's changes".
|
|
|
|
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. Migration 0011 adds `checklist_template`, the list frozen at START (see "Seth's changes").
|
|
- `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. Migration 0008 adds the filed data book (`databook_asset_id`, `databook_pages`) and `open_wos`. See "Close and file (phase 7, migration 0008)".
|
|
- `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. Since change C8 (built 2026-10-30) this row holds the default lists by kind, and each department may have its own list (see "Seth's changes").
|
|
|
|
`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 stage's list (the frozen `checklist_template`, else the department's live 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).
|
|
|
|
## Close and file (phase 7, migration 0008)
|
|
|
|
### Column additions
|
|
|
|
- `packet_seals`: `databook_asset_id` (uuid, references `assets`), the data book PDF built and stored when the packet was filed. `databook_pages` (int), its page count. `open_wos` (text[]), the work orders the feed showed open in VISUAL when Clerical filed anyway, as `wo_base/lot` text (`L489052/1`), or null. Since change C5 (built 2026-10-30) filing is blocked while VISUAL shows a work order open, so new seals leave it null; older seals keep theirs.
|
|
|
|
### The print log
|
|
|
|
`print_log` (new, append-only): one row per print or export.
|
|
|
|
| Column | Meaning |
|
|
|---|---|
|
|
| `id` | bigserial |
|
|
| `at` | when, default `now()` |
|
|
| `by`, `by_name` | the user and a snapshot of the name |
|
|
| `session_id` | the session that asked |
|
|
| `packet_id`, `unit_id`, `itp_id` | what was printed; `unit_id` and `itp_id` for an ITP; all three null for a PM report |
|
|
| `kind` | `traveler`, `map-h`, `map-w`, `clean`, `void`, `databook`, `doc`, `itp`, `csv`, `databook-export`, `itp-pdf`; phase 8 adds `report-csv` and `report-pdf` |
|
|
| `what` | the human label as printed: `Job traveler`, `Drawing with heat map (sheet 2)`, `MTR - item 3` |
|
|
| `pages` | the page count |
|
|
| `asset_id` | the document printed for kind `doc`; the filed data book for `databook` |
|
|
| `stamp` | the stamp line as printed |
|
|
| `stamp_sig` | the 16 hex characters signed into the stamp |
|
|
| `ip` | the client address |
|
|
|
|
Indexed on (`packet_id`, `id`) and (`itp_id`, `id`). `GET /api/packets/{id}/prints` reads it newest first. Prints and exports write no `packet_log` row. They are not packet changes.
|
|
|
|
### The seal and the data book
|
|
|
|
- FILE writes the `packet_seals` row, then builds the data book in the same transaction, stores it as an `assets` row (`source` `databook`, `source_ref` `{packet_id, seal_id}`) and sets `databook_asset_id` and `databook_pages` on the seal. A data book that cannot be built fails the filing. Nothing is filed half way.
|
|
- ADMIN_REOPEN sets `broken_at`, `broken_by` and `broken_reason` on the unbroken seal. The row and its data book stay. They are the record of what was filed.
|
|
- Filing again after a reopen inserts a second seal with its own data book. The unique partial index allows one unbroken seal per packet, so the broken rows stay beside it.
|
|
- The packet read shows the newest seal row, broken or not, with its data book, and `seals` (how many rows the packet has).
|
|
- The signed ITP PDF filed at sign-off is an `assets` row with `source` `itp_signoff`, stored on `itps.signoff_asset_id`.
|
|
|
|
## Project Management and presence (phase 8, no migration)
|
|
|
|
Phase 8 adds no table, no column and no migration. The Project Management reports read the existing tables (`packets`, `units`, `projects`, `packet_stages`, `packet_transitions`, `packet_wh`, `qc_history`, `bom_shorts`, `change_orders`, `visual_labor`) and never VISUAL.
|
|
|
|
- `print_log` gains two kinds, with no schema change (`kind` is free text): `report-csv` for a PM CSV export and `report-pdf` for the production status report. `packet_id`, `unit_id` and `itp_id` are null on these rows. `what` is `<table name> · CSV · <scope label>` or `Production status report · PDF · <scope label>`. `pages` is the page count for the PDF and null for the CSV. The audit row beside it has the action `report exported`.
|
|
- `ps_events` (the NOTIFY channel the SSE hub listens on) carries two new session events beside `revoked`: `{"event": "session", "session_id", "action": "locked"}` and `"unlocked"`. They are published by the lock and unlock endpoints and by the server's idle lock, where `sessions.locked_since` is set or cleared. The hub uses them to hide and show that session in presence. Presence itself lives in the app process, not in the database.
|
|
|
|
## 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 at sign-off since phase 7), `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), `print_log` (0008), `notifications_sent` and `integrity_findings` (0010), `short_approvals` (0011), `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`; 0010 adds `ad-reconcile`, `session-refresh`, `integrity`, `backup-check`, `notify-send` and `notify-digest`; 0012 adds `archive-copy`.
|
|
- `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, migration 0010, builds the delivery on this table:
|
|
|
|
- `notification_events` gains `event_type` (the catalog key; `kind` holds the same text), `title` (the mail subject after the prefix), `body`, `dept_code` (for the department events, so the department's Supervisor group can be the recipient), `raised_count` and `last_raised_at` (a condition raised again while open only counts), `recovery_of` (the row a "Cleared" notice follows), `dispatched_at` and `dispatch_result` (set by `notify-send`: sent, dry_run, failed, no_recipients, queued for the digest, rule_off, or no_rule; `directory_retry:N` marks an event left pending while AD was down, and the third failure dispatches it failed). The phase 4 quota rows were marked dispatched with `before_phase_9`, so they are never mailed late. A standing condition (feed failed, backup stale, quota, integrity failure, group missing, drawing failing, change order waiting) stays open until it clears, and the open index is the dedupe. A one-shot event (security alert, sign-in burst, reject, reconcile report, version deployed) is inserted already resolved. The plan's separate `notification_state` table is not needed: the open row is the state.
|
|
- `notification_rules`: one row per event type (`event_type` PK). `enabled`, `delivery` (enum `notify_delivery`: immediate or digest), `digest_time` (site time, default 07:00), `param` (N hours for drawing_failing, N days for co_waiting), `version` (compare and set for the Admin tab), `updated_at`, `updated_by`. The code seeds a missing rule enabled, immediate, with PS-Admins as its recipient, and never changes a rule that exists.
|
|
- `notification_recipients`: `event_type`, `kind` (enum `notify_recipient_kind`: group, user, mailbox, dept_supervisor), `group_dn` and `group_name` (any AD group under LDAP_BASE_DN), `user_id`, `address`. CHECKs make each kind carry its own column. Groups are resolved to their members' mail when a message goes, not stored.
|
|
- `notification_exclusions`: (`event_type`, `user_id`) PK, `self` (set by the person on Account settings; never allowed on the security event types), `created_at`, `created_by`.
|
|
- `notification_outbox`: events waiting for their rule's digest. `event_id`, `event_type`, `queued_at`, `sent_at`, `sent_id` (the `notifications_sent` row).
|
|
- `notifications_sent`: append-only. One row per message: `at`, `event_type` (`digest` for a digest), `event_ids`, `digest`, `test`, `subject`, `recipients` jsonb (`[{address, user_id, why}]`, as resolved), `result` (sent, dry_run, failed, no_recipients), `error`, `requested_by` (for a test send).
|
|
|
|
## Admin and integrity (phase 9, migration 0010)
|
|
|
|
- `users.profile_active`: the Admin's switch on a person's profile, default true. `users.active` stays the reconcile's (disabled or gone in AD). An inactive profile drops out of the welder picker and the worker filter. It does not change what the person may do.
|
|
- `departments.created_at` and `created_by`: set when an Admin adds a department in the app. Seeded departments have neither. `code` and `slug` stay immutable by trigger.
|
|
- `assets.hash_checked_at` and `hash_ok`: when the integrity job last rehashed the file and whether it matched `sha256`.
|
|
- `integrity_findings`: append-only. One row per thing the integrity job found wrong: `run_id` (no foreign key, since housekeeping prunes `job_runs`), `at`, `kind` (seal, asset_hash, asset_missing, blob_orphan, audit_chain), `subject` (the packet, asset, file path or audit id), `detail` jsonb.
|
|
- Settings rows used by phase 9: `security` (the Admin's overrides of the session, lockout and rate-limit settings; missing keys fall back to the environment), `checklist_templates` (now edited on the Admin Checklists tab), `notify.last_build` (the build the worker last announced).
|
|
|
|
## Seth's changes (built 2026-10-30, migration 0011)
|
|
|
|
Migration 0011 serves changes C7 and C8 in [PLAN.md](PLAN.md). The others need no schema.
|
|
|
|
- `short_approvals` (new, append-only; Gitea issue 18, change C7): the department supervisor's approval of one short receipt. `receipt_id` (unique, references `short_receipts`), `short_id` (references `bom_shorts`), `packet_id`, `dept_code` (the department the packet was in at the approval), `note`, `signature_id`, `at`, `by`. Indexed on `packet_id`. A second approval of a receipt breaks the unique key; the API answers 409 `already_approved`. The packet read and the board cards count `shorts_unapproved`: open shorts plus receipts with no row here.
|
|
- `packet_stages.checklist_template` (jsonb, new; Gitea issue 23, change C8): the checklist the stage froze at START, as a list of `{group, key, text}` with the placeholders not filled. NULL means the stage has not started and reads its department's live list. START sets it once, only when NULL. Every reader uses it when set. 0011 backfills it on the open stage of every packet in progress, in review or at Q.C. with the default list for the stage's kind from the `checklist_templates` row, so a department edit does not reach work already started. When that row is missing or its list is not an array, the stage stays NULL and reads the department's live list until it closes.
|
|
- Settings rows `checklist:<dept code>` (no migration; for example `checklist:03`): one department's own checklist, `{"items": [{group, key, text}, ...]}`. `{"items": null}` sets the department back to the default for its kind and keeps the row's `version` for compare and set. A row that does not parse is logged and the default applies. A department with no row uses the default for its kind: the `weld` or `plain` list of `checklist_templates`, else the code's defaults. The department's Supervisor or an Admin saves it through `PUT /api/checklists/{dept}` (see [POLICY.md](POLICY.md), "Checklists").
|
|
- `packets.vclosed_at` and `vclosed_by` (no migration; Gitea issue 31, change C5): on a VISUAL packet the feed is now the one writer. It sets `vclosed_at` (with `vclosed_by` null) when every leg is closed or cancelled and clears both when a leg shows open again. On a hand packet Clerical still sets them. See [FEED.md](FEED.md), "The apply".
|
|
- `qc_inspections.ncr` (no migration; Gitea issue 24, change C2) is now also written by the accept that carries the NCR number of an earlier reject.
|
|
|
|
## Archive location (phase 10, migration 0012)
|
|
|
|
Gitea issue 7. Filed packets are copied to a folder on the archive share (see [FEED.md](FEED.md), "archive-copy").
|
|
|
|
- `archive_copies` (new): one row per filed seal to copy. `seal_id` (unique, references `packet_seals`), `packet_id`, `status` (pending, done or failed by CHECK), `attempts` (tries so far, the successful one included), `last_error` (the last failure, for the Admin), `next_try_at` (when a failed row is due again), `folder` (the copy's folder relative to `ARCHIVE_ROOT`, for example `packets/L51234/L51234001/L51234-Lot001_D-5001_Manufacturing_Data_Book-seal7`), `manifest_sha256` (sha256 of the `manifest.json` written there), `created_at`, `done_at`. A done row must have `folder` and `done_at` (CHECK). Indexed on (`next_try_at`, `id`) where not done, and on `packet_id`. `ps_app` has SELECT, INSERT and UPDATE; DELETE and TRUNCATE are revoked, so a copy record is never removed. The worker queues a row for every unbroken seal that has a data book and no row yet, so turning the archive on later also copies earlier filings. A refile after a reopen is a new seal and a new row.
|
|
- `job_schedule` gains the `archive-copy` row.
|
|
- Settings row `files` (no migration): `{"archive_dir": "<folder relative to ARCHIVE_ROOT>"}`, saved by the Admin on File locations with compare and set on `version`. No row, or no `archive_dir`, means no folder is saved and nothing is copied.
|
|
- Audit actions: `admin.file_locations_updated` (detail `archive_dir: {from, to}`), `archive.checked` (every check: `source` admin, save, doctor or job, with `archive_dir`, `ok`, `resolved`, `free_bytes`, `message`; the job records its own check only when the result changes), `archive.copied` (`seal_id`, `folder`, `files`, `manifest_sha256`), `archive.copy_failed` (`seal_id`, `attempts`, `error`), `admin.archive_retry` (the "Retry now" job request).
|
|
- Notification event type `archive_failed` (seeded by the code like the others; condition, subject `archive`).
|
|
|
|
## 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.
|
|
- `drawing_revisions` (0009): other revs of a drawing that M-Files still holds, one row per (`dwg`, `rev`). The drawing job writes it when a packet's VISUAL rev differs from the Effective rev and M-Files has that rev as Effective or Retired, and attaches drafts from it. `controlled` (M-Files' Controlled Status, for example Retired), `state` (workflow state, for example Superseded), `checked_at`, `name`, the M-Files identity (`obj_id`, `obj_version`, `file_id`, `file_version`, `file_size`), `base` (H sheet), and the fetched copy (`asset_id`, `sha256`).
|
|
|
|
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.
|