Files

1095 lines
105 KiB
Markdown
Raw Permalink Normal View History

# Packet Station policy
Who may do what to a packet, and when. This file is written from the code in `server/packetstation/policy/`. When they disagree, the code is the rule and this file is out of date.
## Purpose
The policy is a Python port of the old `guard.js`. It runs only on the server. Every packet write calls `policy.check()` inside the write transaction, under the packet row lock. Every packet read returns `can`, `blocked` and `holder` from the same rules, so the screens only show what the server will accept. The Q.C. folder and ITP writes belong to a project or a unit, not to a packet; they call `policy.check_scoped()` under their own row lock (see "Scoped checks").
The package is pure functions with no I/O:
- `actions.py`: the `Action` enum, the `Who` predicates, the `Rule` record and the `RULES` table.
- `principal.py`: `Principal`, `PacketState`, `principal_from_roles()`.
- `custody.py`: `holds()`, `works_on()`, `holder_name()` and the Who predicates.
- `preconditions.py`: the named preconditions and their messages.
- `engine.py`: `check()`, `check_scoped()`, `evaluate()`, `can()` and `Decision`.
## Roles
Every role comes from an AD group (see docs/SECURITY.md). The role strings are `admin`, `clerical`, `clerical_supervisor`, `warehouse`, `warehouse_supervisor`, `qc`, `qc_supervisor`, `pm` and `shop:<code>:<worker|lead|supervisor>`. A person's roles are the union of their groups.
`principal_from_roles(user_id, roles, station_limits=(), session_kind="password")` builds the policy's `Principal`:
| Field | Meaning |
|---|---|
| `admin` | In PS-Admins. Admin is not membership. It is the bypass rule below. |
| `support` | The codes among CL, WH and Q.C. the person belongs to, at any level. A supervisor role implies membership. |
| `support_sup` | The codes among CL, WH and Q.C. where the person is Supervisor. |
| `shop` | Shop department code to level: 1 worker, 2 lead, 3 supervisor. The highest level wins. |
| `station_limits` | Station ids a worker is limited to. Empty means any station. |
| `pm` | Project Management. PM can read every packet and change nothing. |
| `session_kind` | `password` or `pin`. |
| `system` | `visual-feed` for the worker. Never set from a session. |
A role string that does not parse is ignored.
## Custody
Only whoever holds the packet may change it. Everyone else can open it and follow it.
| Status | Holder |
|---|---|
| draft | Clerical |
| warehouse | Warehouse |
| ready, review | Lead or supervisor of the packet's department |
| assigned, in_progress | Lead or supervisor of the department, or a worker who works on it |
| qc | Q.C. |
| complete | Clerical (data book) |
| filed | Nobody |
`works_on(principal, state)`: a member of the packet's department. Leads and supervisors always pass. A worker passes when they have no station limits, or the limits hold the packet's station. START and SUBMIT also need the assigned welder for a worker (see the tightenings).
`holder_name(state, dept_name, station_name)` gives the text for messages: "Clerical", "Warehouse", "Q.C.", "Clerical (data book)", "the data book (filed)", else the department name, plus " · <station>" while the packet is at a station.
Statuses in workflow order: draft, warehouse, ready, assigned, in_progress, review, qc, complete, filed. SHOP is ready, assigned, in_progress and review. ACTIVE is assigned and in_progress.
## Who
| Who | Passes when |
|---|---|
| CL, WH, QC | A member of that support department, any level |
| CL_SUP, WH_SUP, QC_SUP | Supervisor of that support department |
| CL_OR_QC | A member of Clerical or Q.C. |
| HOLDER | Holds the packet (custody above) |
| DEPT_LEAD | Lead or supervisor in the packet's department |
| DEPT_SUP | Supervisor in the packet's department |
| WORKS_ON | Lead or above in the department, or a worker in the department who is the assigned welder and whose station limits allow the station |
| CO_VERIFIER | Supervisor of the department named in the request: a shop Supervisor for a shop code, WH or Q.C. Supervisor for WH or QC. Clerical never verifies. |
| ADMIN | In PS-Admins |
| SYSTEM | The worker (`system` is set) |
## Rule table
`from` is the set of statuses the action starts from. "any" means any status, with the filed lock applied separately. `co hold` means an open change order holds the step.
| Action | Who | From | To | Dept | CO hold | Preconditions | Signature | Reason |
|---|---|---|---|---|---|---|---|---|
| send_to_warehouse | CL | draft | warehouse | no | | heat_map_answered, drawing_attached | | |
| release_to_shop | CL | draft | ready | allowed | | heat_map_answered, drawing_attached, target_dept_given | | |
| send_draft_to_qc | CL | draft | qc | no | | heat_map_answered, drawing_attached | | |
| wh_send_back | WH | warehouse | draft | no | | reason_given | | yes |
| return_to_clerical | DEPT_LEAD | ready, only when wh_skipped | draft | no | | reason_given | | yes |
| wh_release | WH | warehouse | ready | no | yes | bom_all_pulled_or_short, heats_recorded | | |
| send_back_to_warehouse | DEPT_LEAD | ready | warehouse | no | | reason_given | | yes |
| assign | DEPT_LEAD | ready | assigned | no | | welder_set | | |
| unassign | DEPT_LEAD | assigned | ready | no | | | | |
| pull_back | DEPT_LEAD | in_progress | ready | no | | | | |
| start | WORKS_ON | assigned | in_progress | no | | | | |
| submit | WORKS_ON | assigned, in_progress | review | no | yes | checklist_complete, welds_recorded, filler_set | Submit work to the supervisor | |
| return_to_station | DEPT_SUP | review | assigned | no | | reason_given | | yes |
| send_to_qc | DEPT_SUP | review | qc | no | yes | qc_book_spool_location | Send to Q.C. | |
| route_from_review | DEPT_SUP | review | ready | required | yes | target_dept_given | Route to another department | |
| qc_reject | QC | qc | review | no | | reason_given | Q.C. reject | yes |
| qc_accept | QC | qc | complete | no | yes | qc_missing_empty, shorts_approved, route_given, ncr_given | Q.C. accept | |
| qc_accept_and_route | QC | qc | ready | required | yes | qc_missing_empty, target_dept_given, ncr_given | Q.C. accept and route | |
| file | CL | complete | filed | no | yes | closed_in_visual, itp_complete, shorts_approved | File in the data book | |
| admin_reopen | ADMIN | filed (filed_ok) | complete | no | | reason_given | Reopen a filed packet | yes |
| edit_header | CL | draft | | | | | | |
| edit_drawing_ref | CL | any | | | | | | |
| edit_dept | CL | draft | | | | | | |
| move_unit | CL | any | | | | | | |
| edit_wos | CL | draft | | | | | | |
| edit_bom | HOLDER | draft, warehouse, SHOP | | | | | | |
| wh_receive_short | WH | any | | | | short_open | | |
| short_approve | DEPT_SUP | any | | | | | Approve a received short | |
| edit_heats | HOLDER | warehouse, SHOP | | | | | | |
| edit_heat_table | HOLDER | warehouse, SHOP | | | | | | |
| edit_welds | HOLDER | SHOP | | | | | | |
| edit_checklist | HOLDER | SHOP | | | | | | |
| edit_stage_fields | HOLDER | SHOP | | | | | | |
| clock | HOLDER | SHOP | | | | | | |
| edit_qc | QC | qc | | | | | | |
| set_wh_stage | WH | warehouse | | | | | | |
| edit_document_set | CL | any | | | | | Attach a drawing by hand (attach only) | |
| doc_add | CL_OR_QC | any | | | | | | |
| doc_remove | CL_OR_QC | any | | | | | | |
| rev_approve | CL_SUP | draft | | | | reason_given | Approve the M-Files revision | yes |
| set_vclosed | CL | complete, only for a hand packet routed to the data book | | | | | | |
| set_databook_at | CL_OR_QC | any (filed_ok) | | | | | | |
| remove | CL | draft | | | | reason_given | | yes |
| co_issue | CL | any | | | | | Issue a change order | |
| co_approve | CL | any | | | | | Approve a change order | |
| co_reject | CL | any | | | | reason_given | Reject a change order | yes |
| co_hold_toggle | CL | any | | | | | | |
| co_verify | CO_VERIFIER | any | | | | | Verify a change order for the department | |
| qc_doc_add | CL_OR_QC | no packet (scoped) | | | | | | |
| itp_start | QC | no packet (scoped) | | | | | | |
| itp_line_sign | QC | no packet (scoped) | | | | | | |
| itp_line_unsign | QC | no packet (scoped) | | | | | | |
| itp_edit_line | QC | no packet (scoped) | | | | | | |
| itp_block_sign | QC | no packet (scoped) | | | | | Sign an ITP block | |
| itp_block_unsign | QC | no packet (scoped) | | | | | | |
| itp_sign_off | QC | no packet (scoped) | | | | | Sign off the ITP | |
| itp_reopen | QC_SUP (Admin through the bypass) | no packet (scoped) | | | | reason_given | | yes |
| vis_update_draft | SYSTEM | draft | | | | | | |
| vis_retire_draft | SYSTEM | draft | | | | | | |
| vis_open_co | SYSTEM | any but draft and filed | | | | | | |
| vis_auto_release | SYSTEM | draft | warehouse | | | heat_map_answered, drawing_attached | | |
A holder of the packet who is not in the warehouse receives a short through edit_bom (see "Receive by custody").
The `vis_*` actions are system only. People can never do them, and the worker can do nothing else.
The Q.C. folder and ITP actions ("no packet (scoped)") belong to a project or a unit, not to a packet. They are checked with `check_scoped()` (see "Scoped checks"), never with the packet status, custody or the filed lock.
qc_reject always needs a reason of 8 characters and may also carry an NCR number. After a reject with no NCR number, qc_accept and qc_accept_and_route carry one (Gitea issue 24, change C2; see "Q.C. (phase 6)", "Reject").
short_approve is the department supervisor's approval of one received short (Gitea issue 18, change C7; see "Supervisor approval of received shorts"). A lead is not enough. The filed lock applies, so it is open in any status but filed.
set_vclosed is for hand packets only. On a VISUAL packet the feed sets the close (Gitea issue 31, change C5; see "Close in VISUAL").
## Preconditions
Each precondition is a named predicate over the packet state and the request input. They are never bypassed, Admin included. The first one that fails gives the message.
| Name | Passes when | Message |
|---|---|---|
| heat_map_answered | heat_map is yes or no | Say whether this packet needs a heat map. |
| drawing_attached | a drawing is attached | Attach the drawing first. |
| bom_all_pulled_or_short | every line not marked n/a is pulled or has an open short | Pull every material line or mark it short first. |
| heats_recorded | no line needs a heat: every line that is not N/A, not a sub-assembly, is pulled and issued pieces has a heat | Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability. |
| short_open | at least one BOM line has an open short (the old whCanReceive) | No part is short on this packet. |
| welder_set (input) | the request gives station_id and welder_id | Pick a station and a welder. |
| checklist_complete | every key of the department checklist is done | Finish the checklist first. |
| welds_recorded | at least one weld in the packet's current department, in a weld department | Record at least one weld first. |
| filler_set | filler metal entered, in a weld department | Enter the filler metal first. |
| qc_book_spool_location (input) | book and spool confirmed and a location given | Confirm the book and the spool, and give the location. |
| qc_missing_empty | the Q.C. record has nothing missing | Q.C. still needs: (the list). |
| reason_given (input) | a reason of at least 8 characters after cleaning | Give a reason of at least 8 characters. |
| closed_in_visual | VISUAL packet, either route: the feed shows no work order open and has set `vclosed`. Hand packet: routed to Shipping, or Clerical confirmed the close | Hand packet: Close the job in VISUAL first. VISUAL packet: VISUAL still shows work orders open on this packet: L1/001, L2/7A. Filing waits until VISUAL closes them. / Filing waits until the VISUAL feed sees the job closed. |
| itp_complete | the packet's unit has no ITP, or its ITP is complete | Complete the unit's ITP first. Q.C. signs it off on the ITP page. |
| shorts_approved | no open short, and every short receipt has the supervisor's approval | Every short must be received and approved by the supervisor first. |
| ncr_given (input) | no Q.C. reject since the last accept lacks an NCR number, or the request gives a well-formed `ncr` | Enter the NCR number of the reject first. |
| co_staged_if_dwg_change (input) | no drawing change, or the new drawing is staged (phase 3) | Stage the new drawing for the change order first. |
| route_given (input) | routed_to is databook or shipping | Choose Data Book or Shipping. |
| target_dept_given (input) | a shop department different from the packet's; from a draft any shop department | Choose a department. / Choose a different department. |
"(input)" marks a precondition that reads request input, not packet state. `can()` skips those. `ncr_given` reads the state too (`ncr_required`), but the user gives the number with the step, so `can()` skips it and the packet read carries `ncr_required` for the screen.
closed_in_visual replaced vclosed_if_databook (Gitea issue 31, change C5). itp_complete is Gitea issue 25 (change C3). shorts_approved is Gitea issue 18 (change C7). ncr_given is Gitea issue 24 (change C2). All built 2026-10-30. A FILE that fails several shows every reason in `blocked.file`.
## Order of checks
`check(principal, state, action, *, extra={}, it_reason=None) -> Decision` runs these in this order and stops at the first failure:
1. **Removed.** A removed packet refuses everything, Admin included. Code `removed`.
2. **Status.** The status must be in the rule's from set. RETURN_TO_CLERICAL also needs wh_skipped. SET_VCLOSED also needs a hand packet routed to the data book; on a VISUAL packet it fails with "The VISUAL feed marks this job closed when it sees every work order closed." An unknown status fails here too. Code `wrong_status`.
3. **Change-order hold.** A held action refuses while an approved change order waits for verification, or a pending one is on hold. Admin included. Code `co_hold`, with the hold text.
4. **System.** A system-only action refuses every person (`system_only`). The system principal passes only system-only actions (`forbidden`).
5. **Admin bypass.** An admin who is not allowed by the rule's Who passes who and custody when an IT reason of at least 8 characters is given. Without one the code is `it_reason_required`. SET_DATABOOK_AT needs no reason. The decision is marked `admin_bypass`, and the write marks the log row as an IT edit.
6. **Filed lock.** A filed packet refuses everything except SET_DATABOOK_AT and ADMIN_REOPEN, Admin included. Code `filed`: "This packet is filed and locked. IT can reopen it if a correction is needed."
7. **Who and custody.** Code `forbidden`. For a step: "Your access doesn't allow this step." For an edit: "Your access doesn't allow changing (what) on this packet. It's with (holder) right now." PM fails here for everything.
8. **Preconditions,** in rule order, never bypassed. Code `precondition` with the message, `reason_required` when the missing piece is the rule's reason, so the screen can ask for one, or `ncr_required` when `ncr_given` fails.
`Decision` carries `ok`, `code`, `why`, `admin_bypass`, `signature` (the meaning text when the rule needs one) and, in read mode only, `reasons`.
The packet API answers 403 with the code for most failures, 400 for `it_reason_required` and `reason_required`, and 422 for `ncr_required`.
### Scoped checks
`check_scoped(principal, action, *, extra=None, it_reason=None) -> Decision` runs the same steps as `check()` without a packet, for the actions that belong to a project or a unit (the Q.C. folder and the ITP):
1. **System.** A system principal fails (`forbidden`). A system-only action fails every person (`system_only`).
2. **Admin bypass.** An admin not allowed by the rule's Who passes with an IT reason of at least 8 characters, else `it_reason_required`. The decision is marked `admin_bypass`.
3. **Who.** Code `forbidden`, "Your access doesn't allow this."
4. **Preconditions,** the input ones in rule order. `reason_given` fails as `reason_required`.
There is no removed, status, change-order hold, filed lock or custody step: there is no packet and no holder. The ITP and Q.C. folder writes call it inside their own transaction, under the `itps` row lock or the lock on the Q.C. document being replaced. They never take a packet row lock, because they change no packet and must not wait on, or block, the packets of the unit.
## Signatures
These actions need a signature: submit, send_to_qc, route_from_review, qc_reject, qc_accept, qc_accept_and_route, file, admin_reopen, short_approve, edit_document_set (attach only), rev_approve, co_issue, co_approve, co_reject, co_verify, itp_block_sign and itp_sign_off. The rule's signature text is the meaning that is signed.
The request carries `sign: {factor, secret}`. The write pipeline calls the phase 1 helper `packetstation.auth.signatures.sign()` inside the same transaction, after the policy passes. It checks the PIN or domain password against the session's own user. A PIN works in any session. It logs the attempt and applies the five-strike PIN disable and the three-strike session end. It writes an append-only `signatures` row with the user, session, account, display name, session kind, factor, action, meaning, packet, subject and a sha256 over the canonical JSON of what was signed (the action, the packet id, the expected state and the request fields). The `packet_transitions` and `packet_log` rows point at it.
## Concurrency
- **Workflow steps** are compare-and-set on `expect: {status, dept}` under the packet row lock (`SELECT ... FOR UPDATE`). When the packet moved on, the answer is 409 `stale_step` "Someone else already took this step." with the current status, department and version. There is no packet version check on a step, so a weld edit never blocks a send. Side effects run in the same transaction.
- **Keyed child rows** (BOM lines, Q.C. inspections, stage fields, welds, heat tags) take `If-Match: <row_version>`. A mismatch is 409 `row_conflict` with the current row.
- **Numbers** for welds, heat tags, pages and change orders come from counters on the packet row under the row lock, so concurrent adds never collide.
- **Header scalars** use field-level compare-and-set: the body sends `set` and `base`. A key whose base differs from the current value is 409 `field_conflict` with the current value. A key with no base is written as is.
- **Work orders** are a draft-only full replace with If-Match on the packet version.
- **Shorts and change-order actions** are state compare-and-set. Checklist keys are written one key at a time. The heat table position is an idempotent upsert. A change-order verification is an insert, so a second one is "already verified".
- **ITP lines** are approved under the `itps` row lock, with a state check and an optional `If-Match: <row_version>`. ITP header fields use the same `set` and `base` field compare-and-set as packet headers.
- **Append-only tables** never conflict: packet_transitions, packet_log, qc_history, short_receipts, short_approvals, project_renumbers, itp_rejects, itp_log, print_log and co_verifications. Triggers refuse UPDATE, DELETE and TRUNCATE, and the app role has no grant for them.
## Reads: can, blocked and holder
Every packet read returns:
- `can`: the actions that pass now. Input-only preconditions are skipped, because they are what the user gives with the step.
- `blocked`: the actions that pass who and custody but fail a state precondition or the change-order hold, with every reason. The screen shows the control disabled with the reason.
- `holder`: `holder_name()` for the packet.
`can(principal, state, assume_it_reason=True) -> (list[Action], dict[Action, list[str]])`. For an admin the IT reason is assumed, so the list shows what the admin may do once they give one. `evaluate(principal, state, action)` returns the read-mode decision with `admin_bypass` set, so the screen knows to ask for the reason. System-only actions are never listed. CO_VERIFY needs a department in the request, so it is not listed; the change order list says which departments the person can verify.
## Warehouse (phase 4)
### Stages
The warehouse stage is `packet_wh`, separate from the packet status. SET_WH_STAGE (WH, status warehouse) moves it, compare-and-set on `expect_stage` (409 `stale_step` when it moved on).
| From | To |
|---|---|
| incoming | pulling, hold |
| pulling | pulled, hold |
| hold | pulling |
| pulled | pulling |
A move to the same stage, or one not in the table, is 409 `wh_transition`. A packet with no `packet_wh` row is at incoming. The first Pulled tick on a line while the stage is incoming or on hold starts pulling by itself (`auto` true).
### Hold reasons
A closed set, checked on the server:
- Waiting on material (not in stock)
- Waiting on purchasing / PO
- Question for engineering or clerical
- Drawing / BOM problem
- Other
Any other text is 400 `invalid_reason` "Pick one of the hold reasons." "Other" needs a note, else 400 `reason_required` "Say what it's waiting on." The note is cleaned and cut to 200 characters.
### Short reasons
A closed set, checked on the server:
- Not in stock
- On order
- Back-ordered
- Damaged / rejected
- Wrong part received
- Needs cutting / prep
- Other
Any other text is 400 `invalid_reason` "Pick one of the short reasons." A short has a quantity from 1 to the line quantity, a reason, an optional PO or requisition, an optional expected date, an optional note, and the optional heat of the pieces that were pulled (never on an N/A line). Opening, editing and clearing a short is edit_bom, so the holder decides. In practice that is the warehouse.
### The release gate
WH_RELEASE checks two preconditions, in this order:
1. `bom_all_pulled_or_short`: every line not N/A is pulled or has an open short.
2. `heats_recorded`: no line needs a heat. A line needs a heat when it is not N/A, not a sub-assembly (`sub` is `Y`; its heat is on its own packet), is pulled, has issued more than 0 and has no heat.
Both are state preconditions, so `blocked.wh_release` lists the failing message and the screen shows Release disabled with it. Heat numbers are checked against the old pattern: letters, numbers, space . / # _ - only, up to 40 characters, starting with a letter or a number. A bad heat is 422 `invalid_heat`.
### Receive by custody
Receiving short parts (`POST .../bom/{item}/short/receipts`) picks its action from the person:
- A member of the warehouse (WH in `support`) receives through WH_RECEIVE_SHORT, in any status but filed, while the line has an open short. The warehouse does not need to hold the packet.
- Anyone else receives through EDIT_BOM, so only the holder may: the lead or supervisor of the department that has the packet, Q.C. at Q.C., Clerical in draft.
- Everyone else is refused by the policy (403 `forbidden`).
The warehouse still cannot change any other field of a packet it no longer holds. EDIT_BOM stays the holder's. A receipt needs a heat unless the line is N/A, and its quantity is 1 to what is still open.
### Supervisor approval of received shorts
Built 2026-10-30 (Gitea issue 18, change C7). Every receipt also needs the approval of the department supervisor, who checks the part and its heat number against what the warehouse issued. One approval per receipt.
- SHORT_APPROVE: the supervisor of the packet's current department (DEPT_SUP; a lead is not enough), or Admin through the bypass with an IT reason. Any status but filed. Signature "Approve a received short".
- `POST /api/packets/{id}/shorts/receipts/{receipt_id}/approve` `{sign, note?, it_reason?}` answers the packet read. A receipt not on the packet is 404 `not_found`. A second approval is 409 `already_approved` "This receipt is already approved.", checked before the signature and again under the packet lock; the unique key on `short_approvals.receipt_id` backs it.
- The approval row keeps the department the packet was in. After a QC_ACCEPT_AND_ROUTE the new department's supervisor approves the earlier receipts.
- `shorts_unapproved` (packet read, board cards and the policy state) counts open shorts plus receipts without an approval. The precondition `shorts_approved` needs it at 0 on QC_ACCEPT and FILE. QC_ACCEPT_AND_ROUTE does not need it, because the packet goes on to another department and is not complete.
- The packet read has `shorts`: every short of the packet, open or closed, each with its `receipts` and each receipt's `approval` (`null`, or `{at, by, note, dept_code}`). A short that is fully received closes and leaves the BOM line, but its receipts still need approval, so the screen reads `shorts`. `can` holds `short_approve` for the supervisor in every status but filed; the screen shows Approve only on a receipt without an approval.
### MTR files
The MTR on a line must be an asset whose type is PDF, PNG or JPEG. Anything else, or an asset that is not there, is 422 `invalid_asset`. Clearing the MTR is allowed.
### Packet log texts
The `action` column of `packet_log` for each warehouse write. `{item}` is the BOM item number.
| Write | action | note |
|---|---|---|
| Heat typed or scanned | `Heat recorded · item {item}` | the heat |
| Heat cleared | `Heat cleared · item {item}` | |
| Pulled ticked | `Pulled · item {item}` | |
| Pulled unticked | `Pulled undone · item {item}` | |
| N/A ticked | `Marked N/A · item {item}` | |
| N/A unticked | `Traceable again · item {item}` | |
| Issued only | `Issued qty changed · item {item}` | `{issued} of {qty}` |
| Location only | `Location set · item {item}` | the location |
| Several fields | `Line changed · item {item}` | |
| MTR attached | `MTR attached · item {item}` | |
| MTR removed | `MTR removed · item {item}` | |
| Short opened | `Short recorded · item {item}` | `{qty} of {need} short · {reason} · {ref} · ETA {eta}`, parts present only |
| Short edited | `Short updated · item {item}` | the same as recorded |
| Not short after all | `Short cleared · item {item}` | |
| Short approved | `Short approved · item {item}` | `{qty} pc · heat {heat} · {note}`, parts present only |
| Short received | `Short received · item {item}` | `{qty} pc · heat {heat} · {still} still short` or `... · short closed`, then ` · packet {status}` (lower case) and ` in {station}` when at a station |
| Stage incoming to pulling | `Warehouse: Started pulling` | |
| Stage to hold | `Warehouse: Put on hold` | `{reason} · {note}` |
| Stage pulling to pulled | `Warehouse: Marked pulled, ready for production` | |
| Stage hold to pulling | `Warehouse: Pulling resumed` | |
| Stage pulled to pulling | `Warehouse: Back to pulling` | |
| wh_release | `Released to supervisor`, or `Released to supervisor with {n} short` when shorts are open | `{heats} heats recorded`, plus ` · SHORT: item {item} ×{qty_open} ({reason}), ...` when shorts are open |
| wh_send_back | `Sent back to Clerical` | the reason |
When a Pulled tick starts pulling by itself, there is no separate stage row in the log: the line's own row gets ` · started pulling` or ` · pulling resumed` on its note. Every write above also writes its audit row through the write pipeline, with `detail.item`, `wh_from` and `wh_to` as before, and `detail.qty` on short writes.
## Shop (phase 5)
### Who takes each step
| Action | Who | From | Notes |
|---|---|---|---|
| assign | Lead or supervisor of the packet's department (DEPT_LEAD) | ready | Station and welder checked at assign time (below) |
| unassign | DEPT_LEAD | assigned | Clears station and welder, folds the clock |
| pull_back | DEPT_LEAD | in_progress | Clears station and welder, folds the clock |
| start | Lead or supervisor of the department, or the assigned welder within their station limits (WORKS_ON) | assigned | Starts the clock |
| submit | WORKS_ON | assigned, in_progress | Stops the clock. Signature "Submit work to the supervisor" |
| return_to_station | Supervisor of the department (DEPT_SUP) | review | Reason |
| send_to_qc | DEPT_SUP | review | Signature "Send to Q.C.". Opens the inspection |
| route_from_review | DEPT_SUP | review | Signature "Route to another department". Closes the stage and opens one in the target department |
| send_back_to_warehouse | DEPT_LEAD | ready | Reason |
| return_to_clerical | DEPT_LEAD | ready, only when wh_skipped | Reason |
A worker who is not the assigned welder cannot start or submit. Neither can the assigned welder when their station limits leave out the packet's station. Every step sends `expect: {status, dept}`, so a stale screen gets 409 `stale_step` "Someone else already took this step."
### Assign checks
ASSIGN checks the station and the welder on the server, at assign time:
| Check | Refusal |
|---|---|
| The station is an active station of the packet's department | 422 `invalid_station` |
| The welder is an active user with a shop role (worker, lead or supervisor) in the packet's department | 422 `invalid_welder` "Pick a welder in this department." |
| A worker with station limits (no lead or supervisor role in the department) is limited to the chosen station | 422 `station_limit` "{name} is limited to {station names}." |
| A `stamp` in the body matches the stamp pattern | 422 `invalid_stamp` |
The stamp in the body overrides the user's `welder_stamp` for this assignment. Without it, the user's stamp is used. The packet and the stage row both get the station, the welder and the stamp. `GET /api/departments/{code}/people` gives the assign sheet its welder list, with each person's level and station limits.
### The lead's review
A lead holds a packet in review (see "Custody"), but return_to_station, send_to_qc and route_from_review are the supervisor's. The server refuses them to a lead with 403 `forbidden`. The screen shows the lead the review read-only: "A supervisor for 03 Weld Parts reviews it and sends it on."
### Preconditions on the shop steps
| Step | Precondition | Passes when | Message |
|---|---|---|---|
| assign | welder_set (input) | the request gives station_id and welder_id | Pick a station and a welder. |
| submit | checklist_complete | every key of the stage's checklist is done: the list frozen at START, else the department's live list (see "Checklists") | Finish the checklist first. |
| submit | welds_recorded | at least one live weld whose `dept_code` is the packet's current department, in a weld department | Record at least one weld first. |
| submit | filler_set | filler metal entered on the open stage, in a weld department | Enter the filler metal first. |
| send_to_qc | qc_book_spool_location (input) | book and spool confirmed and a location given | Confirm the book and the spool, and give the location. |
| return_to_station, send_back_to_warehouse, return_to_clerical | reason_given (input) | a reason of at least 8 characters after cleaning | Give a reason of at least 8 characters. |
| route_from_review | target_dept_given (input) | a shop department different from the packet's | Choose a department. / Choose a different department. |
welds_recorded counts only the current department's welds. After a route, the new department needs a weld of its own before it can submit. The welds from earlier departments are carried marks (see [SCHEMA.md](SCHEMA.md), "Carried marks").
### Change-order hold
An open change order holds these steps: WH_RELEASE, SUBMIT, SEND_TO_QC, ROUTE_FROM_REVIEW, QC_ACCEPT, QC_ACCEPT_AND_ROUTE and FILE. It does not hold ASSIGN, START, the clock or the edits, so the shop keeps working while the departments verify. A change order is open when it is approved with departments still to verify, or pending with hold on (`v_open_cos`).
The hold text (the `co_hold` refusal and the `blocked` reason):
| Change order | Text |
|---|---|
| Pending, with hold | `CO-2: Clerical is reviewing a change from VISUAL. Held until Clerical approves or rejects it.` |
| Approved, departments pending | `Change order CO-2 must be verified first by: 03 Weld Parts, 09 Hydro.` |
With several open change orders, the texts are joined by a space, one sentence each. The id is `CO-<co_no>` everywhere.
### Change-order verification
CO_VERIFY is the supervisor of the department named in the request: a shop Supervisor for a shop code, the WH or Q.C. Supervisor for WH or QC. A lead cannot verify (403 `forbidden`). Clerical never verifies. Each verification needs a signature ("Verify a change order for the department") and is an insert into `co_verifications`. A second one for the same department is 409 `already_verified`. A department the change order does not need is 422 `invalid_dept`. A verification cannot be withdrawn.
The required departments on approval are the departments the change touches, without Clerical and PM. A minor change order needs none.
### The clock
CLOCK is the holder's in the shop statuses. The ops are `start`, `pause` and `resume`. An op in the wrong state is 409 `clock_state`. The server keeps the time with the database clock. No request sets the elapsed time. START starts the clock when it is not running. SUBMIT stops it. UNASSIGN and PULL_BACK fold the running time in, so the clock freezes. See [SCHEMA.md](SCHEMA.md), "The clock".
### Marks on the drawing
Welds (EDIT_WELDS), heat tags (EDIT_HEATS) and the heat table (EDIT_HEAT_TABLE) follow the rule table: the holder in the shop statuses, and for heat tags and the heat table also the warehouse while it holds the packet. The server hands out `W<n>` and `H<n>` from the packet counters under the packet row lock.
A VOID sheet refuses marks. A new weld or heat tag on a voided page, or a move of one already there, is 422 `void_page` "This sheet is VOID. Mark the current sheet." The marks on a void page stay and are shown, but cannot be edited.
### Heat map borrowing
A packet borrows a sibling's heat map when all of these hold (the old heatSrc):
- It has no heat map of its own: no heat table placement and no heat tag.
- It has no raw material of its own: every BOM line is N/A or a sub-assembly, or the BOM is empty.
- It has a drawing.
The sibling is a live packet in the same unit, with a different id and the same drawing (compared upper-case with white space removed), that has a heat map. The same rev is preferred, then the oldest. The read model gives it as `heat_src`. It is read-only: nothing on it can be written through the borrowing packet. The screen says "Heat map from <op> on this unit (same drawing). Record heats there."
### Shop screens
- `GET /api/shop/board` covers the shop departments where the person is lead or supervisor. Admin sees every active shop department. A department outside that scope is 403 `forbidden`. A person who leads no department gets 403 `forbidden` "You do not lead a department." The change order list also shows open change orders anywhere that wait on one of the person's departments.
- `GET /api/stall/mine` is the Stall screen (Gitea issue 36, change C11, built 2026-10-30). It lists the packets assigned to the signed-in person (`welder_id` is them; status assigned, in progress or review), each at the station the supervisor picked, across stations. Nobody picks a station on the Stall screen, and the request names none: the session decides. The answer is `{station: null, mine, submitted, facets, empty}`; `mine` holds the assigned and in-progress cards, `submitted` the cards in review, and every card carries `station_id` and `station_name`. With nothing assigned, `empty` says "Nothing is assigned to you now. Your supervisor assigns packets to you." The find bar narrows the cards; a `station_id` in the query narrows, never widens. Moving a welder to another station is a re-assign by the supervisor.
- `GET /api/shop/stations` lists the stations of the person's shop departments, cut to their station limits for a worker. Admin gets every station. The Stall screen no longer uses it.
- `GET /api/shop/stall?station_id=` is the board of one station, 403 for a station not in that list. Admin passes. It stays for now; the Stall screen no longer uses it.
### Presence
The packet page shows who else has the packet open. Presence comes over SSE and carries the user id, display name, screen and packet id only. Phase 8 adds the role and the time the person arrived, and drops locked sessions (see "Phase 8: Project Management, find bar, presence").
### History texts
The `action` and `note` columns of `packet_log` for each shop write. A department is shown as code and name (`03 Weld Parts`), except in the verify text, which uses the name only. The station name is as stored (`Stall 3`).
| Write | action | note |
|---|---|---|
| assign | `Sent to Stall 3` | `Andy Welder [75]` (the stamp in brackets when set) |
| unassign | `Taken off Stall 3` | |
| pull_back | `Pulled back from Stall 3` | |
| start | `Work started` | `Stall 3` |
| submit | `Submitted to supervisor` | `4 welds · 1.5 h` |
| return_to_station | `Returned to Stall 3` | the reason |
| send_to_qc | `Sent to Q.C.` | `Spool at Bay 2 · note` |
| route_from_review | `Routed to 09 Hydro` | `Supervisor, Q.C. skipped · from 03 Weld Parts` |
| send_back_to_warehouse | `Sent back to the warehouse` | the reason |
| return_to_clerical | `Sent back to Clerical` | the reason |
| Weld added | `Weld W4 marked` | `Fillet 5/16" · items 1 + 2 · L662001 #1` |
| Weld edited | `Weld W4 changed` | the changed fields, `type Groove · items 1 + 3` |
| Weld moved (position only) | `Weld W4 moved` | |
| Weld removed | `Weld W4 removed` | the reason |
| Heat tag | `Heat tag H2 placed`, `Heat tag H2 changed`, `Heat tag H2 moved`, `Heat tag H2 removed` | `item 3` |
| Heat table placed | `Heat table placed on drawing` | the page's dwg, else `sheet 2` |
| Heat table moved | `Heat table moved` | the same |
| Heat table resized | `Heat table resized` | `A+` or `A-` and the new size, `A+ 1.15` |
| Heat table removed | `Heat table removed from drawing` | the same |
| Heat table copied on a change order | `Heat table copied to new sheet` | `from the VOID copy, check its position on the new revision` |
| Checklist | `Checked: <item text>`, `Unchecked: <item text>` | |
| Stage fields | `Weld record changed` | the changed fields, `filler ER70S-6 · filler lot 1234` |
| Clock | `Clock started`, `Paused`, `Resumed` | |
| Change order issued | `Change order CO-2 issued` | |
| Change order approved | `Change order CO-2 approved` | |
| Change order rejected | `Change order CO-2 rejected` | the reason |
| Change order hold | `Change order CO-2 holds the packet`, `Change order CO-2 no longer holds the packet` | |
| Change order verified | `Change order CO-2 verified by Weld Parts` | `note · still waiting on Hydro`, or `note · change order fully verified` |
The submit hours are the stage's `clock_elapsed_ms` after the stop, with one decimal.
## Q.C. (phase 6)
### Who inspects
Q.C. holds a packet in status qc. EDIT_QC (QC, status qc) covers every write to the open inspection: the check items, the visual result per weld, the NDE fields, hardness, the inspector name, the stamp and the notes. A write with no open inspection is 409 `no_inspection`.
| Write | Endpoint | Concurrency |
|---|---|---|
| One check item | `PUT /api/packets/{id}/qc/checks/{key}` `{done}` | one key by `jsonb_set` under the packet row lock, no If-Match. Unknown key 422 `invalid_key`. |
| One weld's visual result | `PUT /api/packets/{id}/qc/welds/{no}` `{result?, note?}` | one weld by `jsonb_set`. The weld must be live on the packet (422 `invalid_weld`). `result` is `accept`, `reject` or empty. |
| The other fields | `PATCH /api/packets/{id}/qc` `{set, base}` | field-level compare-and-set over `inspector`, `stamp`, `nde_method`, `nde_result`, `nde_report`, `hardness`, `notes`; 409 `field_conflict` with the current value. |
`nde_method` must be one of `None required`, `RT`, `UT`, `MT`, `PT` (422 `invalid_field` "Pick an NDE method from the list."). `nde_result` is empty, `Acceptable` or `Rejectable`. Setting the method to `None required` clears the result and the report number. The phase 2 flat body (with `If-Match` on the inspection's `row_version`, merging `checks` and `weld_exam`) still works; a body with `set` selects the field compare-and-set. The NDE rules apply to both. Hardness is labelled `Hardness (NACE)` when the packet's WPS contains `NACE`. The server computes this; no request sets it.
### Accept
Two actions accept. Both are Q.C. from status qc, held by an open change order, gated by `qc_missing_empty` and `ncr_given`, and signed. qc_accept, which marks the packet complete, also needs `shorts_approved` (Gitea issue 18, change C7): no open short and every receipt approved by the supervisor. qc_accept_and_route does not, because the packet goes on to another department.
| Route | Action | Body | Result |
|---|---|---|---|
| Clerical – Data Book | qc_accept | `routed_to: "databook"` | complete, routed to the data book |
| Shipping | qc_accept | `routed_to: "shipping"` | complete, routed to Shipping |
| A shop department | qc_accept_and_route | `dept: "09"` | ready in that department, a new stage opened |
The signature meanings are "Q.C. accept" and "Q.C. accept and route". The accept closes the inspection into `qc_history` and closes the stage with the inspection as its `qc_snapshot`. The note in the log counts every live weld of the packet.
NCR on the accept (Gitea issue 24, change C2, built 2026-10-30). The packet read's `ncr_required` is true when a Q.C. reject after the packet's last Q.C. accept has no NCR number (a `qc_history` row with result `rejected` and no `snapshot.ncr`). Then both accepts need `ncr` in the request: without it, or with a blank one, the answer is 422 `ncr_required` "Enter the NCR number of the reject first." A malformed one is 422 `invalid_ncr`. The number goes through `validate_ncr`, is stored on the accepting inspection (`qc_inspections.ncr`, so `qc_last` and the `qc_history` snapshot show it) and is added to the log note. An NCR given on an accept that does not need one is stored the same way. `can()` skips `ncr_given`, since the number comes with the step; the screen shows the NCR field as required from `ncr_required`.
### What Q.C. still needs
`qc_missing_empty` reads the list built by `checklists.qc_missing()`. Its message is `Q.C. still needs: ` followed by the parts joined by `, ` and a full stop. `blocked.qc_accept` and `blocked.qc_accept_and_route` carry it, so the screen shows Accept disabled with the reason.
| Part | When |
|---|---|
| `3 inspection items` | that many check items are not done |
| `2 welds without a visual result` | that many live welds have no accept or reject |
| `a rejected weld (reject the packet instead)` | any weld is rejected |
| `NDE result` | an NDE method other than `None required` is set and the result is not `Acceptable` |
| `inspector name` | the inspector is empty |
### Reject
qc_reject is Q.C. from status qc back to review, signed ("Q.C. reject"). It always needs a detailed reason and may also carry an NCR number (Gitea issue 24, change C2, built 2026-10-30):
- The reason must pass `reason_given`: at least 8 characters after cleaning. An NCR number no longer stands in for it. Without one the answer is 400 `reason_required` with the rule's message.
- An NCR number, when given, goes through `validate_ncr`: trimmed, upper case, then the pattern `NCR-` four digits `-` four digits. A bad one is 422 `invalid_ncr` "Enter an NCR number like NCR-2026-0021."
- With both, the return note and the result note read `NCR-2026-0021 · <reason>`.
- A reject without an NCR number means the next accept must carry one (see "Accept").
Q.C. can add the NCR document to the packet as kind `ncr` (see "Packet documents"). It goes into section 5 of the data book.
The reject stores the NCR on the inspection, sets the packet's `return_note` to the reason, and closes the inspection into `qc_history` at once. The packet in review has no open inspection; the read model gives the reject as `qc_last`, and the screens show the "Q.C. rejected" callout from it. The supervisor's next send to Q.C. opens a fresh inspection.
The change-order hold applies to both accepts and to file. It does not apply to the reject: Q.C. can always send a packet back.
The NCR link is `https://ncr.pescoinc.biz/ncrs/<number>`. `domain/ncr.py` `validate_ncr` is the hook for checking the number against pesco-ncr. Today it accepts any well-formed number. The check against pesco-ncr is a later phase.
### Packet documents
DOC_ADD and DOC_REMOVE are Clerical or Q.C. (CL_OR_QC), in any status but filed (the filed lock applies).
- `POST /api/packets/{id}/docs` `{asset_id, kind, title?, note?}`. `kind` is one of the nine kinds (see [SCHEMA.md](SCHEMA.md), "Packet documents"); any other is 422 `invalid_kind` "Pick a document type from the list." The asset must be a PDF, PNG or JPEG (422 `invalid_asset`). The title defaults to the kind's label and is up to 160 characters. The note is up to 300 characters.
- `DELETE /api/packets/{id}/docs/{doc_id}` `{reason}` takes a document out of the data book. The reason needs 3 characters after cleaning (400 `reason_required` "Say why it comes out of the data book."). The row is kept with who, when and why. A second take-out is 409 `already_removed`.
Drawings are never packet documents. They come from M-Files or the hand attach only.
### Q.C. folder documents
QC_DOC_ADD is Clerical or Q.C. (CL_OR_QC), checked with `check_scoped()`. `POST /api/qc/docs` `{project_id | unit_id, type, title?, rev?, note?, asset_id, replaces?, it_reason?}`:
- Exactly one of `project_id` and `unit_id` (422 `invalid_request`).
- `type` is one of `ITP`, `WPS / PQR`, `NDE procedure`, `Hydrotest procedure`, `PMI / material`, `Calibration certificate`, `Customer spec`, `Other` (422 `invalid_type` "Pick a document type from the list.").
- The asset must be a PDF, PNG or JPEG (422 `invalid_asset`). Limits: title 140 (defaults to the type), rev 12, note 600.
- `replaces` files a new revision. The replaced row must be a current document of the same folder (422 `invalid_replace`). One that already points at a newer row is 409 `already_superseded`. The old row is kept and points at the new one. Nothing is deleted.
Reads (`GET /api/qc/home`, `/api/qc/folders`, the project and unit folder reads) need any signed-in session. PM reads and changes nothing.
Clerical may add Q.C. folder documents. The old build allowed Q.C. and Admin only. This is wider than the old build; Seth can narrow it to QC.
### The ITP
Each unit has at most one digital ITP, started from the code template `ITP_Generation_Revised_2` and frozen at start. Reads (`GET /api/units/{unit_id}/itp`) need any session. Every write runs `check_scoped()` with its action, then finds the ITP (404), then refuses a complete one (409 `itp_complete`), locks the `itps` row, bumps `itps.version` once, and writes one `itp_log` row per change, one audit row for each (`itp.<action value>`, so `itp.itp_start`, `itp.itp_line_sign`; subject `itp`; detail `{text, note, unit_id}`, plus `signature_id` on a signed one) and a change event, in one transaction. Block sign and sign-off check their gates before the signature, so a refused step costs no PIN attempt, and check them again under the lock.
Every line and block row is made at start. A Q.C. user's packet read still lists the scoped actions in `can`; they mean nothing on a packet.
| Action | Who | Endpoint | Gate |
|---|---|---|---|
| itp_start | QC | `POST /api/units/{unit_id}/itp` | 409 `itp_exists` when the unit has one |
| itp_edit_line | QC | `PATCH /api/itps/{id}` (header), `PATCH .../lines/{key}`, `POST .../sections/{n}/na`, `POST .../lines/{key}/reject`, `POST .../lines/{key}/ncr`, `PATCH .../blocks/{key}` | see below |
| itp_line_sign | QC | `POST /api/itps/{id}/lines/{key}/sign` | the line is not approved (409 `already_signed`) or N/A (409 `line_na`); a rejected line needs its NCR (409 `ncr_required` "Enter the NCR # before approving this line.") |
| itp_line_unsign | QC | `POST /api/itps/{id}/lines/{key}/unsign` | the line is approved (409 `not_signed`); only the inspector who initialed it, or an admin (403 `forbidden` "Only the inspector who initialed this line, or an admin, can remove it."); refused while the block is signed (409 `block_signed` "Undo the section signature first.") |
| itp_block_sign | QC, signature "Sign an ITP block" | `POST /api/itps/{id}/blocks/{key}/sign` | every line of the block done (409 `block_not_ready` "Every line must be initialed or N/A first."); not already signed (409 `already_signed`) |
| itp_block_unsign | QC | `DELETE /api/itps/{id}/blocks/{key}/sign` | the block is signed (409 `not_signed`); the signer or an admin (403 `forbidden` "Only the inspector who signed this block, or an admin, can remove it."); clears the signature and keeps the block's fields |
| itp_sign_off | QC, signature "Sign off the ITP" | `POST /api/itps/{id}/signoff` | every block signed (409 `blocks_unsigned` "Every section must be signed first.") |
| itp_reopen | QC_SUP, or an admin through the bypass | `POST /api/itps/{id}/reopen` `{reason, it_reason?}` | a reason of 8 characters; the ITP is complete (409 `itp_open` "The ITP is open already."). An admin who gives no `it_reason` has the reason used as both. |
Line approval is a compare-and-set: the `itps` row lock, the state check above, and an optional `If-Match: <line row_version>` (409 `row_conflict`). Approval sets `result approve`, `by`, `at` and the initials (the first letter of each word of the display name, upper case, at most three). Unsign clears the result, the approver, the time and the initials, so the line is open again. The line's `ncr` and every `itp_rejects` row stay.
The itp_edit_line writes:
- Header (`no`, `client`, `pname`, `pno`, `po`, `unit`): field compare-and-set with `{set, base}` (409 `field_conflict`), 120 characters each. `no` cannot be empty (422 `invalid_field`).
- Line details: `na` is refused on an approved line (409 `already_signed`) or one with an open reject (409 `line_rejected`). `cust_code` is one of `R`, `R/AU`, `AU`, `W`, `H`, `NA`, or empty for the template's (422 `invalid_field` "Pick a customer code from the list."). `cust_by` 80, `note` 500. `cust_at` defaults to today when a name is given. A change that changes nothing writes no log and no version.
- Section N/A: sets or clears N/A on every line of the section. Marking N/A is refused when any line of the section is approved (409 `already_signed`) or has an open reject (409 `line_rejected`).
- Reject: refused on a line that is approved (409 `already_signed`), N/A (409 `line_na`) or already rejected (409 `line_rejected`). The NCR is optional and validated when given. An `itp_rejects` row (kind `reject`) is added.
- NCR entered later: the line must have an open reject without an NCR (409 `no_open_reject`). An `itp_rejects` row (kind `ncr`) is added.
- Block fields: `qn` 40, `remarks` 400, `cust_name` 80, `cust_at` (today when a name is given), `cust_clear` clears the customer entry. An empty `cust_name` without `cust_clear` is 422 `invalid_field` "Enter the customer rep's name." No signature.
2026-10-17 23:49:14 -06:00
A write to a complete ITP, other than the reopen, is 409 `itp_complete` "The ITP is signed off and locked. An admin can reopen it." The sign-off sets status `complete` and calls `pdf.itp.file_signed_itp_pdf()`, which builds the signed ITP PDF (phase 7). The sign-off sets `signoff_asset_id` and files a `qc_docs` row on the unit (type `ITP`, `auto`), superseding any earlier automatic ITP document of the unit. A PDF that cannot be built answers 500 `itp_pdf_failed` and nothing is signed off. A reopen sets status `open` with the reason. Block and line signatures stay; the inspector undoes what must change.
ITP actions never look at a packet. A filed packet in the unit does not lock the ITP, because the ITP belongs to the unit (the phase 5 follow-up, open question 28).
### Blocks
A signature block closes at every template section marked to sign, and covers the sections since the last block. The template gives blocks `3` (sections 1 to 3), `4`, `5`, `6`, `7`, `8` and `9` (the final block). A line is done when it is N/A or approved. A block is ready when every line in it is done, so a block of N/A lines only can be signed.
### Hold points
A hold code is `H` or `W`, alone or after a slash (`R/H`), on the PESCO code or the customer code (the line's own customer code when set, else the template's). An open hold point is a line with a hold code that is not done, or any rejected line.
The packet read gives `itp_hold` while the unit's ITP is open: the open hold and rejected points, filtered by the packet's department page group. It is a warning only. No precondition reads it, and no step is blocked by it. Q.C. and the shop check with each other before moving past a point.
| Department | Pages it sees |
|---|---|
| 01, 02, 03, 04, 05, 10 | WP (Weld Parts) |
| 06, 07, 13 | C (Code) |
| 09 | WP and C |
| 08, 11, 12, 14 | FA (Final Assembly) |
| CL, WH, QC, or an unknown code | every page |
### Log texts
The `action` and `note` of `packet_log` for each Q.C. packet write.
| Write | action | note |
|---|---|---|
| Check ticked | `Q.C. checked: <item text>` | |
| Check unticked | `Q.C. unchecked: <item text>` | |
| Visual result set | `VT accept · W3`, `VT reject · W3` | the note when given |
| Visual result cleared | `VT cleared · W3` | |
| Weld note only | `Q.C. record changed` | `W3 note` |
| NDE method | `NDE method: RT`, `NDE method: -` when cleared | |
| NDE result | `NDE result: Acceptable`, `NDE result: -` when cleared (no line of its own when `None required` cleared it) | |
| Other Q.C. fields | `Q.C. record changed` | the changed fields, `inspector Quinn QC · stamp Q1 · hardness 198 HB · report RPT-1 · notes changed` (`<field> cleared`, `notes cleared`) |
| Flat-body checks or weld exam merge | `Q.C. record changed` | |
| qc_accept to the data book | `Q.C. accepted → Clerical – Data Book` | `Quinn QC [Q1] · 3 welds VT accepted`, plus ` · RT Acceptable` when an NDE method other than None required is set, plus ` · <note>` when the step carries a note |
| qc_accept to Shipping | `Q.C. accepted → Shipping` | the same |
| qc_accept_and_route | `Q.C. accepted → 09 Hydro` | `Quinn QC [Q1] · from 03 Weld Parts` |
| either accept, with an NCR number | as above | the note above plus ` · NCR-2026-0021` |
| qc_reject | `Q.C. rejected → supervisor` | `NCR-2026-0021 · <reason> · welds W1, W3` (parts present only; the rejected welds by number) |
| Document added | `Document added` | `<label> · <title> · <file name>` |
| Document taken out | `Document taken out of the data book` | `<title> · <reason>` |
One PATCH that changes several kinds of field writes one line for each. The arrow and the en dash in `Clerical – Data Book` are the old build's texts. The stamp shows in brackets only when set. The inspector is the inspection's `inspector` field.
The `action` and `note` of `itp_log` (and the audit row's `detail.text`) for each ITP write. Dates are `M/D/YYYY` in America/Denver.
| Write | action | note |
|---|---|---|
| Start | `ITP started` | `L073028-ITP · template ITP_Generation_Revised_2` |
| Header field | `ITP header changed`; `ITP plan no. changed` for `no` | `client: ACME`; the new number for `no` |
| Line approved | `ITP line 1.3 approved` | `after NCR NCR-2026-0021` when the line was rejected before |
| Line unsigned | `ITP line 1.3 initial removed` | `was Quinn QC 10/17/2026` |
| Line details | `ITP line 1.3 updated` | the changes joined by `, `: `marked N/A`, `applies again`, `customer code W`, `customer code as template`, `customer Rick Inspector`, `customer cleared`, `customer date 10/17/2026`, `note` |
| Line rejected | `ITP line 1.3 rejected` | `NCR NCR-2026-0021 · <note>` or `NCR # pending · <note>` |
| NCR entered later | `ITP line 1.3 NCR number entered` | `NCR NCR-2026-0021` |
| Section N/A | `ITP section 5 marked N/A`, `ITP section 5 applies again` | the section title |
| Block fields | `ITP block 3 QN # changed`, `ITP block 3 remarks changed` | the value |
| Block signed | `ITP block 3 signed` | `Sections 1–3` or `Section 4` |
| Block unsigned | `ITP block 3 signature removed` | |
| Customer complete | `ITP block 3 customer complete recorded`, `ITP block 3 customer complete cleared` | `Rick Inspector` (the cleared name on a clear) |
2026-10-17 23:49:14 -06:00
| Sign-off | `ITP signed off` | `PDF filed` |
| Reopen | `ITP reopened` | the reason |
A Q.C. folder document writes an audit row `qc_doc.add` or `qc_doc.revise` (subject `qc_doc`), with `doc_id`, `project_id`, `unit_id` and `replaces` in the detail, `detail.text` `Q.C. document added` or `Q.C. document revised` and `detail.note` `L670 · WPS / PQR · WPS-101 GTAW/SMAW Rev 2 · wps.pdf`.
2026-10-17 23:49:14 -06:00
## Phase 7: close, file, PDFs and prints
Nothing in the rule table changes in phase 7. FILE, ADMIN_REOPEN, SET_VCLOSED and SET_DATABOOK_AT keep their rows. Changes C2, C3, C5 and C7 (built 2026-10-30) changed FILE and SET_VCLOSED, as below.
2026-10-17 23:49:14 -06:00
### Close in VISUAL
`packets.vclosed_at` is the one record of "closed in VISUAL" (Gitea issue 31, change C5, built 2026-10-30). Who writes it depends on the packet:
2026-10-17 23:49:14 -06:00
- A VISUAL packet: the feed alone. It sets `vclosed_at` (with `vclosed_by` null) when every leg is closed or cancelled, and clears it when a leg shows open again (see [FEED.md](FEED.md), "The apply"). The Clerical board reads it, so the packet moves from "Ready to close" to "Ready for data book" by itself. SET_VCLOSED on a VISUAL packet is refused as `wrong_status`: "The VISUAL feed marks this job closed when it sees every work order closed."
- A hand packet has no feed state. SET_VCLOSED is Clerical, in status complete, only when the packet is routed to the data book (`POST /api/packets/{id}/vclosed` `{closed}`). Confirming sets `vclosed_at` and `vclosed_by`. Taking it back clears both ("Not closed in VISUAL, move back"). A hand packet routed to Shipping files without it.
The packet read carries `vclosed` (`{at, by}`; `by` is null when the feed set it) and `vclose_check`, outside the seal. `vclose_check` is null for a hand packet. For a feed packet: `observed_at` and `closed_at` from the feed's VISUAL state, `open_wos` (the legs whose status is not `C` and not cancelled, as `wo_base/lot` text, never cast), and `agrees`. The precondition `closed_in_visual` reads `vclosed_at` and, on a VISUAL packet, the open legs.
2026-10-17 23:49:14 -06:00
### Filing
FILE is Clerical, from complete, held by an open change order, signed ("File in the data book"), with three preconditions. `blocked.file` lists every one that fails, so the close panel says why.
2026-10-17 23:49:14 -06:00
- `closed_in_visual` (Gitea issue 31, change C5). A VISUAL packet, on both routes: no work order open in the feed's state and `vclosed_at` set by the feed. While a work order is open: "VISUAL still shows work orders open on this packet: L489052/1, L489052/2. Filing waits until VISUAL closes them." With none open but the close not yet seen: "Filing waits until the VISUAL feed sees the job closed." A hand packet: "Close the job in VISUAL first." on the Data Book route until Clerical confirms; none on Shipping.
- `itp_complete` (Gitea issue 25, change C3): the unit's ITP is complete. An ITP that is open, signed or reopened blocks with "Complete the unit's ITP first. Q.C. signs it off on the ITP page." A unit with no ITP does not block.
- `shorts_approved` (Gitea issue 18, change C7): no open short and every receipt approved by the supervisor.
Filing is blocked, not warned. The phase 7 second confirmation (`confirm_open_wos` and the 409 `open_work_orders`) is gone; the server ignores `confirm_open_wos` if a client still sends it. New seals never record `open_wos`; older seals keep theirs.
2026-10-17 23:49:14 -06:00
In one transaction, filing sets status filed and `databook_at`, writes the seal, builds the data book, stores it as an asset on the seal, and writes the log and audit rows. A data book that cannot be built answers 500 `databook_failed` "The data book could not be built: <reason>. Nothing was filed." and nothing is written. A part that cannot be read (a missing file, an unreadable MTR) does not fail the filing: it becomes a warning row in the data book index.
### The filed lock
A filed packet refuses every write except SET_DATABOOK_AT and ADMIN_REOPEN, Admin included (code `filed`). Reads, PDFs, the CSV and prints stay open.
### The seal and reopen
The seal is HMAC-SHA256 with the server seal key over the packet's read model in seal mode. `GET /api/packets/{id}/seal` rebuilds the content and answers `{seal, valid, seals}`. `valid` is null when the key is not available.
ADMIN_REOPEN is Admin, from filed, with a reason of at least 8 characters and a signature ("Reopen a filed packet"). The packet goes back to complete. The seal is marked broken with `broken_at`, `broken_by` and `broken_reason`. The row and its data book are kept: they are the record of what was filed. Filing again writes a second seal with its own data book. The packet read shows the newest seal, broken or not, with its data book, and `seals`, the number of seal rows.
### PDFs, the CSV and prints
Every read below needs a signed-in session and a packet that exists and is not removed (404 `not_found`). Every role that can open the packet may read them, PM included. No policy action guards them, and the filed lock does not apply. Each one writes a `print_log` row and an audit row after the bytes are built.
| Endpoint | What | When | Log kind | Audit |
|---|---|---|---|---|
| `GET /api/packets/{id}/pdf/databook` | The filed data book as stored, or one built now and stamped "not yet filed" | complete or filed, else 409 `wrong_status` "The data book is built once the packet is back from Q.C." | `databook-export` | `export` |
| `GET /api/packets/{id}/pdf/traveler` | The job traveler with the work order QR codes | any status | `traveler` | `export` |
| `GET /api/packets/{id}/export/traceability.csv` | The traceability CSV, with the formula guard | any status | `csv` | `export` |
| `GET /api/packets/{id}/print/{kind}` | A printed copy: `traveler`, `map-h`, `map-w`, `clean`, `void`, `databook`, `doc` | `databook` from complete on (409); `void` only with void pages (404 `nothing_to_print` "Nothing to print."); `doc` only for an asset of the packet, its unit or project folder, or its filed data book (404) | the kind | `print` |
| `GET /api/packets/{id}/prints` | The print log of the packet, newest first, 200 rows | any status | | |
| `GET /api/itps/{id}/pdf` | The ITP PDF built now, stamped signed off or open | any status | `itp-pdf` | `export` |
| `GET /api/itps/{id}/print` | The ITP PDF stamped PRINTED COPY | any status | `itp` | `print` |
`?download=1` serves the file as an attachment. `?sheet=n` limits `map-h`, `map-w` and `clean` to packet page n. Prints and exports write no `packet_log` row, because they change nothing on the packet. The History tab lists them from `GET .../prints`.
A print in the app goes straight to the browser's print dialog from the page: the screen fetches the stamped print PDF (`print/{kind}`, or `/api/itps/{id}/print`), renders it into a hidden print area and prints it (Gitea issue 27, change C4, built 2026-10-30). Viewing a PDF still opens it in a tab. The endpoints, the stamps and the print log did not change.
2026-10-17 23:49:14 -06:00
### Log texts
The `action` and `note` of `packet_log` for close, file and reopen.
| Write | action | note |
|---|---|---|
| set_vclosed, confirm (hand packet) | `Job closed in VISUAL` | the packet's work order bases, `L489052, L489053` |
2026-10-17 23:49:14 -06:00
| set_vclosed, taken back | `Moved back to ready to close` | |
| file | `Filed in the data book` | the note given with the step |
2026-10-17 23:49:14 -06:00
| file, the data book | `Data book filed` | `N pages` |
| admin_reopen | `Reopened by IT` | the reason |
| set_databook_at | `Data book date set` | |
The audit gains `print` (detail `{kind, what, pages, stamp_sig}`, plus `sheet` and `asset_id` when given) and `export` (detail `{kind, what, pages, stamp_sig}`, kind `databook`, `traveler`, `csv` or `itp`). FILE, ADMIN_REOPEN and SET_VCLOSED write their audit rows through the write pipeline as before.
## Phase 8: Project Management, find bar, presence
Nothing in the rule table changes in phase 8. Project Management, the find bar filters and presence add no policy action and no write.
### Who reads the Project Management endpoints
Every endpoint under `/api/pm` is for PS-ProjectMgmt (role `pm`) and Admin only (Gitea issue 33, change C10, built 2026-10-30; it replaced the phase 8 default of any signed-in role, open question 39). Any other role is 403 `forbidden` "Project Management only." An unsigned request is 401. No other screen calls `/api/pm`: the find bar and the boards read `/api/packets` and the board endpoints. The PM entry in the screen switcher shows only for PM and Admin, and anyone else who types `/pm` sees "The Project Management screen is not for your role." PM writes nothing: there is no POST, PUT, PATCH or DELETE under `/api/pm`, and the PM role passes no write action anywhere else. A packet opened from the PM screen shows "View only." The reports read PostgreSQL only. They never read VISUAL or M-Files.
| Endpoint | What |
|---|---|
| `GET /api/pm/overview` | The scope, the KPI tiles, the summary lines, the pipeline by step, and the units late or at risk |
| `GET /api/pm/projects?expand=all\|<project ids>` | Projects and units, with the units of the opened projects, or every unit with `expand=all` |
| `GET /api/pm/aging` | The pipeline and the three aging tables |
| `GET /api/pm/hours` | The four hours tables |
| `GET /api/pm/quality` | The five quality tables |
| `GET /api/pm/find?sort=` | The cards in scope, sorted |
| `GET /api/pm/export.csv?table=<key>\|all` | One table, or every table, as CSV. An unknown key is 404 `not_found`. |
| `GET /api/pm/report.pdf` | The production status report, inline unless `download=1` |
### The scope
Every PM endpoint takes the scope params: `project_id`, `unit_id`, `dept`, `status`, `station_id`, `welder_id`, `flags`, `flag`, `q` and `period`. They mean what they mean on `GET /api/packets` (below). `period` is `30`, `90`, `365` or `all`, default `90`. It limits the history figures only: Q.C. rejects, filed, lead time, sent back and the step history. `mine` in `flags` is ignored on PM.
- Projects in scope: every project not removed. A closed project only when a packet in scope belongs to it.
- Units in scope: the project's units. When any filter other than the project is set, only the units with a packet in scope.
- Health follows the old healthOf. `empty` with no packets. `done` when every packet is filed. `late` past the due date. `risk` when due within 7 days and under 75% done, or when any packet has an open change order or a warehouse hold. Else `ok`. A unit uses its own due date, else the project's. A project is as healthy as its worst unit.
- Over target means VISUAL ticket hours over the work-order target hours (Gitea issue 1, change C1, built 2026-10-30; it replaced open question 38). A packet with no ticket hours, or no target, is not over target. The station timer is shown beside the ticket hours and never decides the flag. The group tables' variance and ratio are ticket based ("Ticket variance h", "Ticket h / target", "Over target (tickets)"), the over-target table ends with "Over by (tickets)", and the KPI reads "Packets over target hours (tickets)". The CSV and the report PDF follow.
### Search
`q` on `GET /api/packets`, the boards and PM. Up to 8 words, and every word must match. A word matches when any field below contains it, case insensitive. A word over 60 characters is cut. The words `lot` and `rev` on their own match everything, so "lot 52" and "rev B" work as they did in the old build.
- The project: number, name, customer, PO and aliases.
- The unit: the unit number (`L670` and the lot) and the lot, both also with the lot's leading zeros stripped, so `52` finds lot `052`.
- The packet: op, title, drawing, rev, WPS, customer, the department name, the status label and the flag kinds. The word `flagged` matches any flagged draft.
- The station name, and the assigned welder's display name and stamp.
- The work orders: the base, and the base with the lot, split and sub (`L489052/1`).
- The BOM: heat, part ID, description and material.
- Change orders: `CO-<n>`, the bare number and the ECN.
- Welds: the stamp and the welder's display name.
A child match never repeats a packet. IDs are matched as text, never cast.
### Filters
The server applies the filters on every list and board (open question 41). A board never fetches more than it shows. A filter only narrows what the person could already read. It never widens access, and each board keeps its own scope checks (a supervisor's departments, the stall's own assignments).
`status` takes one of the words below, or a comma list of raw statuses as before (`status=ready,review`).
| value | label | packets |
|---|---|---|
| draft | Ready to release | draft |
| warehouse | With warehouse (any stage) | warehouse |
| ready | Ready to assign | ready |
| shop | In the shop (ready to Q.C.) | ready, assigned, in_progress, review, qc |
| station | At a station (assigned / working) | assigned, in_progress |
| review | Supervisor review | review |
| qc | At Q.C. | qc |
| complete | Accepted / routed | complete |
| filed | Filed | filed |
| open | Everything not filed | every status but filed |
The other filters: `dept` (department code), `station_id`, `welder_id` (the assigned welder), `project_id` and `unit_id` (the project and unit pick), `flag` (the flag kind, below) and `flags` (the quick flags, below). The filters are joined with AND.
### Quick flags and Mine
`flags` is a comma list.
| value | chip | packets |
|---|---|---|
| co | Change order open | an open change order: pending, or approved with verifications still pending |
| short | Shorts open | at least one open short |
| overdue | Project overdue | the effective due date is before today |
| week | Due within 7 days | the effective due date is today to today + 7, or overdue |
| mine | Mine | the screen's own work, below |
Today is the date in the site time zone (`site_tz`, America/Denver by default).
Mine is decided on the server from the person's AD roles and the screen they name (`mine_screen`), never from anything the browser keeps (open question 42). Without `mine_screen` it is the first screen the roles give. A board defaults it to its own screen.
| Screen | Mine |
|---|---|
| clerical | status draft, or status complete routed to the data book |
| warehouse | status warehouse, or an open short |
| supervisor | the departments where the person is lead or supervisor |
| stall | the packets assigned to the person (`welder_id` is them), in status assigned, in progress or review, across stations. No station is needed; `mine_station_id` is accepted and ignored (Gitea issue 36, change C11, built 2026-10-30). |
| qc | status qc |
| pm, or admin with no other role | everything. The chip is hidden on the PM and Stall screens. |
### Flag kinds
`flag` applies to drafts only, as the flagged rule does today. The raw values (`1`, `0`, `hand_upload`, a raw kind like `rev_mismatch`) keep working.
| value | label | rule |
|---|---|---|
| any | Flagged (any reason) | any flag |
| rev | Rev mismatch with M-Files | flag kind rev_mismatch |
| unconf | Rev not confirmed yet | a feed draft with its drawing attached (`drawing_state` attached) and `visual.rev_confirmed` not true: the rev approval is still pending |
| hand | Made or uploaded by hand | a hand packet, or flag hand_drawing or hand_bom |
| none | Still in drafting | flag kind not_released |
| vault | Drawing not found in M-Files | flag kind not_found |
| read | Drawing couldn't be read | flag kinds lookup_failed or bad_rev |
| wait | Drawing being looked up | flag kind drawing_needed |
| no | Not flagged | no flag |
### Sorts
`sort` on `GET /api/packets` and `GET /api/pm/find`.
| value | label | order |
|---|---|---|
| updated | Last updated | newest change first (the default) |
| project | Project / lot | project number, then lot, then op, in text order |
| due | Due date | the effective due date, earliest first, none last |
| status | Status | the workflow order |
| dept | Department | department code, then newest change first |
### Facets
`facets=1` on `GET /api/packets`, and always on the shop, stall, warehouse and Q.C. boards, adds the stations and assigned welders among the packets in the project and unit pick, ordered by name. The other filters do not narrow the facets. The find bar's Station and Worker lists come from them. The KPI numbers on every home screen follow the project and unit pick only, never the other filters.
### Exports
Every PM export writes one `print_log` row and one audit row with the action `report exported`, after the bytes are built (open question 39).
| Export | Log kind | what | pages |
|---|---|---|---|
| `GET /api/pm/export.csv` | `report-csv` | `<table name> · CSV · <scope label>` | none |
| `GET /api/pm/report.pdf` | `report-pdf` | `Production status report · PDF · <scope label>` | the page count |
The rows carry no packet and no unit. The audit detail carries `name`, `format`, `scope` and `period`. The CSV has the formula guard on every cell, a BOM and CRLF line ends. The PDF carries the controlled-copy stamp on every page.
### Presence
The packet page shows who else has the packet open: `Also in this packet now: <Name> (<role>)`, joined with ` · `. Nothing is shown when the person is alone.
- Any signed-in person sees who else is on the same packet. A presence entry is the user id, display name, role, screen, packet id and since. Nothing else (open question 40).
- The role is the label of the screen the person has open (`Clerical`, `Warehouse`, `Supervisor`, `Weld Stall`, `Q.C.`, `Project Mgmt`, `Admin`), else the label of their first role.
- A locked session drops out at once. Lock and unlock publish a `session` event (`locked`, `unlocked`) from `POST /api/auth/lock`, `POST /api/auth/unlock` and the server's idle lock. Unlock brings the person back.
- A revoked session drops out at once, and its stream ends.
- Closing the tab or losing the network drops the person out when the stream ends, within the stream's ping cycle (25 seconds). No other idle rule is needed.
- The server also keeps who is on each screen. Phase 9's Admin "Who is in" tab reads it (`GET /api/admin/presence`).
### Tightenings against the old build
| What the old build allowed | What the rebuild does | Why | Can be vetoed |
|---|---|---|---|
| The find bar filtered in the browser, over every packet the browser had loaded. | The server applies the search and every filter on each list and board. A filter only narrows what the person may read. | A board never fetches more than it shows. | No |
| "Mine" was worked out in the browser from the local session's role. | Mine is decided on the server from the person's AD roles and the screen they name. The stall's Mine is the packets assigned to the person, across stations (Gitea issue 36, change C11). | The roles come from AD, not from what the browser keeps. | Yes |
| PM exports were built and saved in the browser, and the browser wrote the `report exported` log line. | Every export is built on the server and logged in `print_log` and the audit as `report exported`, with the scope, the period and, for the PDF, the page count. | The browser alone could skip the log. | Yes |
| The PM reports were on the PM home screen only. | Only PS-ProjectMgmt and Admin may read them, on the server (Gitea issue 33, change C10). PM still writes nothing. | SethD decided the reports are for Project Management. The phase 8 default let every role read them. | No (decided by SethD) |
| Each browser published its own presence, with the name, employee ID, role and stall, refreshed every 20 seconds. | The server builds presence from the session: display name and role, no employee ID. A locked or revoked session drops out at once. | Presence shows only what it needs, and a locked screen is not someone in the packet. | Yes |
| Over target was worked out in the browser from the hours it had. | Over target is VISUAL ticket hours over the work-order target, on the server. The station timer is shown beside them and never decides it (Gitea issue 1, change C1). | SethD decided the ticket hours are the measure. The timer stays as a clock. | No (decided by SethD) |
## Phase 9: Admin, notifications, reconcile
Nothing in the packet rule table changes in phase 9. The new endpoints are Admin endpoints and one Account endpoint. None of them writes a packet.
### Who may use the Admin endpoints
Every endpoint under `/api/admin` needs an Admin (PS-Admins) in a password session, except the stamp check (see "The stamp check"). An Admin in a PIN session is 403 `password_session_required`. Every other role is 403. An unsigned request is 401. Every write also passes the Origin check and the body limit. Tests try every endpoint as every role.
| Endpoint | What | Audit |
|---|---|---|
| `GET /departments` | Every department, its AD groups (and whether each is in the OU; unknown when AD is down) and its stations | none |
| `PATCH /departments/{code}` | Name, active, stations (rename, reorder, add, hide) | `admin.department_updated` |
| `POST /departments` | A new shop department with its stations; answers the three group names IT must create | `admin.department_added` |
| `GET /users`, `GET /users/{id}` | Gain the welder stamp, `profile_active`, station limits and the directory flags | none |
| `PATCH /users/{id}/profile` | Welder stamp, `profile_active`, station limits per department | `admin.profile_updated` |
| `POST /users/import` | The profile CSV, as a preview (`apply: false`) or applied | `admin.profile_updated` per changed person, `admin.profile_import` |
| `GET /checklists`, `PUT /checklists` | The default weld and plain lists, used by a department without its own list (see "Checklists"). The Admin screen no longer calls these; its Checklists tab edits department lists. | `admin.checklists_updated` |
| `GET /security/settings`, `PUT /security/settings` | The session, lock and sign-in limit settings | `admin.security_settings_updated` |
| `GET /file-locations` | The archive root, the saved archive folder, the last check and the copy counts | none |
| `POST /file-locations/check` | Check a candidate archive folder: write, read back and remove a probe file. Saves nothing | `archive.checked` |
| `PUT /file-locations` | Save the archive folder after the same check. 409 `archive_off` while `ARCHIVE_ROOT` is unset | `admin.file_locations_updated` |
| `POST /file-locations/retry` | Ask the worker for an `archive-copy` run now and make every failed copy due | `admin.archive_retry` |
| `GET /security/alerts?hours=` | Security alerts for 1 to 168 hours | none |
| `GET /security/integrity` | The newest finished integrity run and its findings | none |
| `GET /access-log` | Sign-in attempts and identity audit rows for everyone | none |
| `GET /access-log.csv` | The same, as CSV, at most 50,000 rows | `admin.access_log_exported`, a `print_log` row of kind `access-log-csv` |
| `GET /presence` | Live sessions, locked or not, with the screen and packet | none |
| `POST /stamp-verify`, `POST /stamp-verify/pdf` | Checks a stamp line, a 16-character code or a PDF (25 MB) against the print log. Kept as aliases of `/api/stamp-verify`, open to Admin, Q.C. and Clerical (see "The stamp check") | `admin.stamp_verified` |
| `GET /directory-flags` | Active users with no employee ID, and employee IDs on more than one account | none |
| `POST /ad/refresh` | Asks the worker for an `ad-reconcile` run; 202 (open question 48) | `ad.refresh_requested` |
| `GET /ad/reconcile` | The newest finished reconcile run and its report | none |
| `GET /notifications` | Mail settings, the subject prefix, every rule with its recipients, exclusions and last message | none |
| `PUT /notifications/{event_type}` | One rule: on or off, delivery, digest time, its hours or days, recipients, exclusions | `admin.notification_rule_updated` |
| `GET /notifications/groups?q=` | AD group search (2 to 100 characters; 503 when AD is down) | none |
| `POST /notifications/{event_type}/test` | A test message, to the rule's recipients or to one address | `admin.notification_test` |
| `GET /notifications/sent` | The sent log | none |
Every Admin change is audited, with the state before and after where there is one.
### Compare and set
The checklists, the security settings and each notification rule carry a version. A save sends the version it read. When someone else saved first the answer is 409 `version_conflict` with the current value, and nothing is written. The screen reloads and says so.
### Departments
- The code and the short name (the slug) never change. They are in the AD group names. A trigger refuses a change, and a PATCH body that names `code` or `slug` is 422.
- Stations are never deleted. Station limits, packets and history point at them. A station is hidden by setting it inactive. A station listed without an id is added. A station missing from the list is left as it is. Two stations of one department with the same name are refused (422 `duplicate_station`).
- A new department needs a code of 2 to 4 capital letters or digits and a short name of 2 to 31 letters or digits starting with a letter, both unused (409 `code_taken`, `slug_taken`). Only shop departments can be added; a support department is 422 `support_fixed`, because the support roles are fixed (open question 49). The answer names the three groups IT must create. Until they exist the department has no people.
- After a change the app rebuilds its group map from the departments table. The worker reloads it at the start of every reconcile and role refresh run.
### Profiles
- The welder stamp is 1 to 12 letters, digits or dashes, unique among users ignoring case (409 `stamp_taken`). Empty clears it. The packet's own welder stamp field still allows 10; the profile stamp is copied onto the packet as it is.
- `profile_active` off hides the person from the welder picker and the worker filter. It does not change their roles or what they may do, and an assign sent to the API still checks only the role (open question 45). AD decides who may do what.
- Station limits are set per department. A station must belong to that department. An empty list clears the department's limits.
- The CSV import matches employee IDs as text among active users. `0042` is not `42`. Columns: `employee_id` (required), `welder_stamp`, `active`, `stations` (`<dept code>:<station name>` joined by `;`, the person's whole list; open question 50). An empty cell leaves the value. `-` clears it. Up to 2,000 rows and 1 MB. Each row lists its changes and its errors: unknown employee ID, an ID on more than one user, an ID twice in the file, a bad or taken stamp, a bad active value, an unknown department or station. Apply with any error is 422 with the same rows and changes nothing. Otherwise every row is applied in one transaction.
### Checklists
Each shop department has its own checklist (Gitea issue 23, change C8, built 2026-10-30). It replaced the two Admin lists of phase 9 and open questions 26 and 52.
| Endpoint | Who | What |
|---|---|---|
| `GET /api/checklists` | any signed-in session | every active shop department in sort order: `{dept_code, dept_name, weld, custom, version, editable}` |
| `GET /api/checklists/{dept}` | any signed-in session | the same plus `items` (placeholders not filled) and `defaults` (the default list for its kind). An unknown or support department is 404 `not_found` "No such shop department." |
| `PUT /api/checklists/{dept}` `{items, base}` | that department's Supervisor, or Admin, in a password session | the department's list; answers the GET shape. Audit `checklists.dept_updated` (subject `settings` / `checklist:<code>`, detail `{dept, before, after}`) |
- Who edits: `editable` is true for Admin and for a Supervisor of that department. A lead, a worker, Q.C., Clerical, Warehouse and PM do not edit department checklists. A save by anyone else is 403 `forbidden` "Only this department's supervisor or an Admin can change its checklist." An editor in a PIN session is 403 `password_session_required`.
- Compare and set: the save sends the version it started from as `base`, or as `If-Match`. With neither the answer is 428 `if_match_required` "Send the version you started from." A stale version is 409 `version_conflict` "Someone else changed this checklist. Reload and try again." with `current`, the GET shape.
- `items: null` sets the department back to the default for its kind.
- A list needs at least one item and at most 40. A key is lower case letters, digits and underscores, starting with a letter, at most 30 characters, unique in its list. Group and text are not empty, at most 200 characters. The only placeholders are `{drawing}`, `{rev}` and `{wps}`. Anything else is 422 `bad_checklist` with `errors`, one line per problem.
- Default lists: a department without its own list uses the default for its kind, the weld or plain list of the `checklist_templates` row, else the code's defaults. `/api/admin/checklists` still edits that row.
- Frozen at START: START copies the department's live list onto the stage (`packet_stages.checklist_template`), once. A change reaches only stages not yet started. Every reader uses the stage's frozen list when it is set, else the department's live list: the checklist items in the packet read, `checklist_complete`, the key check on a tick (422 for a key not in the list) and the data book's weld record. Answers are kept by key.
- Ticking items (EDIT_CHECKLIST) stays the holder's. The Q.C. check items are not per department and stay Admin only.
### Security settings
Stored as the `security` settings row over the environment defaults. A value out of its bounds is refused (422 `bad_settings`), and a stored value out of bounds is ignored. The keys and bounds are in [SECURITY.md](SECURITY.md), "Security settings (phase 9)". A save applies in the app within 30 seconds. The PIN and step-up failure limits (5 and 3) stay environment settings.
### Access log, presence and the stamp check
- The access log shows every person's sign-in attempts (successful and failed) and their `auth.*`, `pin.*`, `device.*` and `sig.*` audit rows, as the Account access log shows a person their own. The address filter takes an address or a range. Request bodies and secrets are never in it.
- Who is in shows live sessions (not revoked, not expired) seen within the idle minutes that apply to each. The screen and packet come from the event stream, matched by person.
- The stamp check never trusts the text. It finds the 16-character signature, looks it up in `print_log`, recomputes it with the seal key from the logged row and compares the text with the logged stamp line. It answers genuine, does not verify (the signature or the text differs from the log), or not found.
### The stamp check
Built 2026-10-30 (Gitea issue 32, change C9). `POST /api/stamp-verify` and `POST /api/stamp-verify/pdf` take the same bodies and give the same answers as the Admin paths, which stay as aliases with the same rule. Who: Admin, every Q.C. role (`qc`, `qc_supervisor`) and every Clerical role (`clerical`, `clerical_supervisor`). Anyone else is 403 `forbidden` "The stamp check is for Admin, Q.C. and Clerical." A PIN session is 403 `password_session_required`. The audit row stays `admin.stamp_verified`. The screen is `/stamp-check`, linked in the top bar for those roles; Admin's Stamp check tab uses the same endpoints.
### Notifications
- An Admin edits every rule. Recipients are any AD group under the base DN, a user, a mailbox, or (for the department events, `co_waiting` and `qc_reject`) the Supervisor group of the event's department. At most 50 recipients and 200 exclusions per rule.
- An Admin may leave people out of a rule (an exclusion), except on the security event types: `security_alert`, `signin_burst` and `integrity_failure`. An exclusion on those is 422. Nobody can be left out of them (open question 46).
- A test send goes at once, from the app, even on a stack in dry run. With an address it goes only there.
- Recipients are resolved when a message goes, not when the rule is saved: group members (nested, enabled accounts with mail), a user's address while the user is active, a mailbox as typed. An exclusion drops the person by user and by their address.
### Account notifications
| Endpoint | Who | What |
|---|---|---|
| `GET /api/account/notifications` | Any signed-in, unlocked session, PIN included | Each enabled event type: whether it reaches the person and why (`user`, `mailbox`, `group <name>`, `Supervisor group of <code>`, `left out by an Admin`, `directory unavailable`), and whether they opted out or may |
| `PUT /api/account/notifications/{event_type}` | The person, in a password session | `{opt_out}`. Writes or removes their own exclusion. 403 for a security event type. Audit `account.notification_opt_out` when something changed |
A person's own opt-out is kept when an Admin saves the rule. An Admin's exclusion is not removed by the person.
### Security events
The three security event types (`security_alert`, `signin_burst`, `integrity_failure`) have no opt-out and no exclusion. An Admin can still turn the rule off or change its recipients, on the record.
- `security_alert`: an account cooldown started, a PIN disabled after five wrong attempts, or a session ended after three wrong PINs or passwords at unlock or signature. One per account or person per 15 minutes.
- `signin_burst`: one address over its failure limit, or failed sign-ins across accounts in 5 minutes at or over `signin_burst_threshold` (default 20). One per address, or one for the burst, per 30 minutes.
- `integrity_failure`: the integrity job found a problem. Cleared by a clean run.
### Audit rows added in phase 9
- `policy.denied`: a 403 after a person was known. Method, path and code only, never a body or query string. One per person, method, path and code per minute.
- `auth.lockout`: an account went into its cooldown. `auth.signin_burst`: an address over its limit, or a burst across accounts.
- `auth.session_ended` with `{reason, by}` for every session the reconcile or the role refresh ends (`ad_disabled`, `ad_gone`, `roles_lost`).
- The `admin.*` rows in the table above, `ad.refresh_requested` and `account.notification_opt_out`.
### Tightenings against the old build
| What the old build allowed | What the rebuild does | Why | Can be vetoed |
|---|---|---|---|
| The Admin page added a department with any typed code, shop or support, and changed a department's stations as a comma list, so a station could disappear. | Code and short name are checked and never change. Only shop departments are added. Stations are never deleted, only hidden. The three AD group names are shown before the save. | The code and short name are in the AD group names, and limits and packets point at stations. | Yes |
| A welder stamp was set in the Admin user sheet, checked in the browser, 1 to 10 characters, not unique. | The stamp is checked on the server, 1 to 12 characters, unique among users. | Two welders with one stamp break the weld record. | Yes |
| Users, levels and departments were edited in the Admin page. | Roles come only from AD groups. The Admin edits only the profile (stamp, profile active, station limits). Turning a profile off hides the person from pickers and changes no right. | AD is the one place access is granted. | No |
| The security alerts panel and the integrity check ran in IT's browser when Admin was opened. | Both run on the server: alerts from the audit and the sign-in attempts, the integrity check every night in the worker, with an email alert. | A check that runs only when someone looks finds problems late. | No |
| Nothing was emailed. | Events go by email through app-03 to rules an Admin sets. Security events cannot be turned off by the person or excluded by an Admin. | A security alert that can be muted is not one. | Yes |
| The access log export ran in the browser. | The CSV is built on the server with the formula guard and logged in the print log and the audit. | The browser alone could skip the log. | No |
| A stamped copy could be checked only by eye. | The stamp check recomputes the signature with the seal key and compares the text with the print log. | A copy is genuine only if the server says so. | No |
## Worker writes (phase 3)
The worker has no session. It writes through the same policy as a person, as the system principal `Principal(user_id=None, system="visual-feed")`. Each write locks the packet row (`FOR UPDATE SKIP LOCKED`; a row locked by someone else is left for the next run), builds the packet state, runs `policy.check()`, makes its change, bumps `version`, and writes a `packet_log` row (by nobody, by name "VISUAL feed", role `system`), an audit row (`packet.<action>`, role `system`) and a change event. See [FEED.md](FEED.md) for when each write happens.
| Action | From | What the worker does |
|---|---|---|
| vis_update_draft | draft | Updates a draft made by the feed: work orders, BOM (unless uploaded by hand), drawing and rev, dates, flags, drawing pages from M-Files. |
| vis_retire_draft | draft | Soft-removes an untouched draft whose work orders all moved to another packet, reason `moved`. |
| vis_open_co | any but draft and filed | Opens, re-aims or withdraws the packet's one pending feed change order, stages a drawing on it, and records drawing flags on a packet past draft. |
| vis_auto_release | draft to warehouse | Sends a ready draft to the warehouse. Same preconditions as send_to_warehouse (heat map answered, drawing attached). The feed only picks drafts with no flags and the rev confirmed by the drawing job. |
What the feed may write:
- Drafts it made, in place, through vis_update_draft and vis_retire_draft.
- New drafts, projects and units (`created_by` null, audit role `system`).
- The order-derived fields of projects and units, only where the value came from VISUAL. A value Clerical typed is never overwritten.
- On packets past draft: only a change order (vis_open_co), the observed VISUAL state (leg statuses, closed, cancelled, work order gone, ticket hours) and the drawing flags.
- Its own tables: the caches, `drawing_lookups`, the job tables.
What the feed may never write:
- Shop-side tables: stages, welds, heat tags, checklists, the clock, warehouse pulls and shorts, Q.C. records, seals. In-shop packets only get a change order.
- A removed packet. A key whose packets are all removed is never made again.
- A filed packet.
- Any person's action. The system principal passes only the vis_* actions, and people can never take them.
- VISUAL or M-Files. Both libraries are read-only.
### Rev approval
When M-Files has a different released rev from VISUAL, the drawing job stages the M-Files copy. A Clerical Supervisor (PS-Clerical-Supervisor) can approve it on a draft with rev_approve: a reason of at least 8 characters and a signature ("Approve the M-Files revision"). The pages are attached and the approval is kept on the packet (`visual.rev_ok`). With nothing staged the answer is 409 `nothing_staged`.
VISUAL is the source of truth for the rev. When M-Files holds the rev VISUAL calls as Effective or Retired, the drawing job attaches it to the draft with no flag, so there is nothing for Clerical to approve (see [FEED.md](FEED.md), the decision table). rev_approve is only for a mismatch where M-Files has no usable copy of that rev. Packet Station never attaches a drawing at any other M-Files status. A packet whose `visual.rev_ok` has `use: "called"` (Clerical kept the rev VISUAL calls by hand, in the build before this rule) keeps it: that rev is now used anyway.
### Hand uploads on flagged drafts
On a draft made by the feed that is flagged (or already has a hand upload), Clerical can upload a drawing (edit_document_set) or a BOM (edit_bom) by hand. Both need a reason and a signature. A hand drawing sets `drawing_state` `hand` and flag `hand_drawing`; a hand BOM sets flag `hand_bom` and the feed stops replacing the BOM. An unflagged draft refuses with 409 `not_flagged`. Auto-release never takes a hand-flagged draft, so a person sends it on. "Use VISUAL's BOM again" removes the hand BOM.
### Change orders from the feed
- A pending feed change order holds nothing, unless Clerical puts it on hold (co_hold_toggle). Then it blocks the held steps until released or decided.
- Approval (co_approve, Clerical, signature) applies the staged change to the packet: work orders, BOM, drawing and rev. When the drawing changed, the new drawing must be staged first (`co_staged_if_dwg_change`, 409 `co_not_staged`). The old drawing pages are voided, not deleted.
- After approval a major change order asks the departments it touches to verify (co_verify), and holds the packet until they have.
- A minor change order (only target hours or the want date changed) needs Clerical approval only. No department is asked to verify, so it holds nothing after approval.
- Rejection (co_reject) needs a reason and a signature. The feed does not reopen a change order Clerical rejected for the same target.
## Tightenings against the old build
The old policy was looser than the old screens in places. The rebuild follows the screens where they were stricter, and the README where it states the intent. Each row is a change Seth can veto.
SethD answered on 2026-10-26: every row is kept as shown (Gitea issue 6), and he confirmed the data book date (issue 14), the withdrawn verification and ITP initials rows (issue 15), both release rows (issue 16) and the block signature row (issue 26).
| What the old build allowed | What the rebuild does | Why | Can be vetoed |
|---|---|---|---|
| A department lead could write welds, BOM, heat tags, the checklist, stage fields and the clock through the policy whenever the packet was in a shop status. | These edits need the holder of the packet (the holder rule, applied as the old screens did). A lead of another department cannot. | The screens only offered these to the holder. The policy now matches. | Yes |
| Any worker in the department within their station limits could start or submit a packet (`worksOn` ignored who was assigned). | START and SUBMIT need the assigned welder for a worker. Leads and supervisors still can. | The README says only the assigned worker. | Yes |
| A status change could carry any other field in the same save. | A transition carries only its own fields (the step's inputs). Other changes go through their own edit actions and rules. | One rule per write keeps the check honest. | No |
| Clerical "lead" actions (approve an M-Files rev mismatch, move back from VISUAL review) were open to any Clerical member with a lead flag. | REV_APPROVE and moving back from VISUAL review need Clerical Supervisor (PS-Clerical-Supervisor). | There is no Clerical lead AD group. | Yes |
| A packet routed to Shipping had no path to filed. | Complete packets routed to Shipping get the same Clerical close and file path. A VISUAL packet files on either route only once the feed sees the job closed in VISUAL (Gitea issue 31, change C5). A hand packet needs the Clerical close confirm on the Data Book route only. | Shipping packets were stuck at complete. | Yes |
| Anyone could set the data book date, even on a filed packet. | SET_DATABOOK_AT is Clerical or Q.C. only (open question 17). It stays allowed on a filed packet. | The data book date is a Clerical and Q.C. record. | Yes |
| Change-order verifications were stored in the packet document, with no rule against changing them later. | Verifications need a signature, are insert-only and cannot be withdrawn (open question 18). | A signed verification is a record. | Yes |
| The old policy did not look at the BOM when the warehouse released a packet. | WH_RELEASE needs every BOM line not marked n/a to be pulled or to have an open short. | A packet should reach the shop with its material, or with the shorts on record. | Yes |
| The old screen asked for a heat number on every pulled line before release, but the policy did not check it. | WH_RELEASE also needs a heat on every pulled line that issued pieces, unless the line is N/A or a sub-assembly (`heats_recorded`), checked on the server. | Traceability is the point of the pull. The screen alone could be skipped. | Yes |
| Hold reasons and short reasons were any text the screen sent. | Both are closed sets checked on the server. "Other" on a hold needs a note. | The board and the shorts table group and report on the reason, so it must be one of the list. | Yes |
| A hand draft could leave Clerical with no drawing (the packet PDF upload stood in for it). | A hand draft needs a drawing attached before any send from Clerical. | There is no packet PDF upload in the rebuild. | Yes |
| Attaching drawing pages by hand needed no signature. | Attaching a drawing by hand needs a signature ("Attach a drawing by hand"). | It replaces what M-Files would supply, so a person stands behind it. | Yes |
| Admin could write anything on a filed packet with an IT reason. | Admin cannot write a filed packet except through ADMIN_REOPEN (with a reason and a signature), and SET_DATABOOK_AT. | A filed packet is sealed. A change breaks the seal on purpose, on record. | No |
| Admin passed every check after the change-order hold, so an admin could make a step from any status. | Admin goes through the same status check. The bypass covers who and custody only. Preconditions still apply. | Admin fixes data, not the workflow order. | Yes |
| A hand drawing or BOM upload on a flagged VISUAL draft took a reason of 3 characters and a PIN sign-off checked in the browser. | Both need a reason of at least 8 characters and a signature verified on the server, kept on the packet with who and when. | A hand upload stands in for VISUAL or M-Files, so a person stands behind it. | Yes |
| Approving a different M-Files rev was open to a Clerical lead. | rev_approve needs Clerical Supervisor, with a reason and a signature. | There is no Clerical lead AD group, and the approval overrides VISUAL. | Yes |
| The Sync on a Clerical PC skipped removed packets ("removed here, not re-made"), but only as the browser saw them. | A removed packet's key is never recreated, whatever VISUAL says. The match runs on the server across live and removed rows, in the feed's transaction. | A removal is a decision. The feed does not undo it. | No |
| The Sync saved through the same packet document that held the shop's work, so nothing but the code kept it off the shop's fields. | The feed never writes shop-side tables. Packets past draft only get a change order, observed VISUAL state and drawing flags. | The shop's record is the shop's. | No |
| The assign sheet only offered welders of the department and filtered the stations by the welder's limits. The policy checked neither. | ASSIGN checks on the server that the welder has a shop role in the packet's department, and that a limited worker's station is one of their stations. | The picker alone could be skipped. | Yes |
| The old screen asked for a weld of the current department before submit, but the policy did not check welds. | welds_recorded counts the welds of the packet's current department, on the server. After a route the new department marks its own. | Each department records its own welds. | Yes |
| A mark could be placed on a VOID sheet. | A VOID sheet refuses new marks and moves (422 `void_page`). The marks already there stay. | Marks belong on the current revision. | Yes |
| The station clock ran in the browser, and the elapsed time was saved from the browser. | The server keeps the clock with the database time. No request sets the elapsed time. | The clock is a record. A browser clock can be wrong or changed. | No |
| Packet document kinds and Q.C. folder document types were whatever the screen sent. | Both are closed sets checked on the server (and by a CHECK in the database). | The data book sorts and labels by kind and type. | Yes |
| A packet document could be taken out of the data book with no reason. | A take-out needs a reason of at least 3 characters. The row is kept with who, when and why. | A document leaving the record is a decision on record. | Yes |
| A Q.C. reject took any non-empty text. | A reject always needs a reason of at least 8 characters. An NCR number may go with it but no longer stands in for it. A packet rejected without an NCR number cannot be accepted until one is given (Gitea issue 24, change C2, built 2026-10-30; it replaced open question 27). | A short note like "no" says nothing to the supervisor. An NCR number points to the full record. | No (decided by SethD) |
| An ITP line rejected without an NCR could not be approved only because the screen disabled the button. | The server refuses the approval until the NCR number is entered (409 `ncr_required`). | The screen alone could be skipped. | Yes |
| ITP block signatures and the sign-off were PIN checks in the browser. | Both are signatures verified on the server and stored with a content hash. | A signature is a record. | No |
| The ITP sign-off was offered when the screen saw every block signed. | The sign-off is a server rule: every block signed (409 `blocks_unsigned`), and a complete ITP refuses every write but the reopen. | The screen alone could be skipped. | Yes |
| The screen limited removing an ITP line's initials to the same inspector or an admin, and refused it while the block was signed. Nothing else checked. | The server enforces both (open question 18). | The initials are that inspector's record. The screen alone could be skipped. | Yes |
| Any Q.C. user could remove a block signature. | Only the signer or an admin, while the ITP is open (open question 30). | A signature is that person's record. | Yes |
| Clerical marked a job closed in VISUAL by hand, and nothing checked VISUAL. | On a VISUAL packet the feed sets the close when it sees every work order closed, and FILE is blocked while VISUAL shows one open, on both routes. Clerical confirms only hand packets (Gitea issue 31, change C5). | The data book should not be filed for a job still open in VISUAL. | No (decided by SethD) |
| A packet could be filed while its unit's ITP was still open. | FILE needs the unit's ITP complete when the unit has one (Gitea issue 25, change C3). | The data book should not be built from an incomplete ITP. | No (decided by SethD) |
| A received short needed no further check. | The department supervisor approves each receipt with a signature. Q.C. accept to complete and filing wait for every approval (Gitea issue 18, change C7). | The supervisor checks the part and its heat number against what the warehouse issued. | No (decided by SethD) |
| One checklist for weld departments and one for the rest, the same in every department of a kind. | Each department has its own checklist, edited by its supervisor or Admin. A stage keeps the list it started with (Gitea issue 23, change C8). | Each department knows its own work. A list changed during a stage would ask for items the work did not plan for. | No (decided by SethD) |