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

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

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-17 20:08:53 -06:00
co-authored by Claude Fable 5.1
parent 8bb1f7ae4d
commit 00531cb329
64 changed files with 11715 additions and 260 deletions
+11
View File
@@ -11,6 +11,17 @@ All notable changes to this project are recorded here. The format follows [Keep
### Added ### Added
- Phase 6: Q.C. The Q.C. screen (the numbers Waiting for inspection, Accepted last 7 days and Rejected last 7 days; the boards Ready for inspection, Accepted and Rejected back with supervisor; the find bar; the Project folders grid with Active and All). The Q.C. tab on the packet page: the send details read-only as the supervisor set them (book, spool, location, note), inspector and stamp, the ten check items in two groups (Data book package, Physical spool) with the live hints (heats recorded, MTRs attached, welds on the map, checklist progress) ticked one key at a time, the visual weld inspection table with Accept and Reject per weld and a note, NDE method (None required, RT, UT, MT, PT), result (Acceptable, Rejectable) and report number, hardness labelled NACE when the WPS says so, inspection notes, and the earlier inspections. The check texts live in the `qc_check_items` settings row (migration 0007).
- Q.C. accept with a route (Clerical – Data Book, Shipping, or a shop department) and a PIN signature, blocked on the server until every check is done, every weld has a visual result and none is rejected, the NDE result is Acceptable when a method is set, and an inspector is named; the missing items are listed on the bar. Q.C. reject to the supervisor with a reason, an NCR number (NCR-YYYY-NNNN, linked to the NCR system) or both, with a signature; the inspection closes into the history with its snapshot and a fresh one opens when the supervisor sends the packet back to Q.C. The supervisor and the station see the rejection, its NCR and the rejected welds at the top of the packet.
- Q.C. project folders: documents per project and per unit (ITP, WPS / PQR, NDE procedure, Hydrotest procedure, PMI / material, Calibration certificate, Customer spec, Other), added by Q.C. or Clerical; a new revision supersedes the old one, which stays under Superseded; nothing is deleted. Project documents apply to every unit. A unit without an ITP is flagged.
- Packet documents for the data book on the Documents tab: NDE, hydrotest, dimensional, PMI, coating reports, certificates of conformance, customer inspection reports, NCRs and Other, with a note; "Take out of data book" keeps the row and the file with who, when and why; the unit's and project's Q.C. folder documents are listed as also in the data book.
- The digital ITP per unit, started from the Q.C. template ITP_Generation_Revised_2 (frozen into the ITP at creation: nine sections over the pages Weld Parts, Code and Final Assembly; items with the PESCO code, the customer code and the acceptance criteria; the legend O, R, A, I, AU, W, H, NA): line initials with a timestamp, undo by the same inspector or an admin before the block is signed, N/A per line or per section, customer code, witness and date, notes, rejects with an NCR number that must be entered before the line can be approved, signature blocks after sections 3 (covering 1 to 3), 4, 5, 6, 7, 8 and 9 signed with a PIN once every line is approved or N/A, QN number, remarks and the customer complete record per block, sign-off with a signature once every block is signed, reopen by a Q.C. supervisor or an admin with a reason, and the ITP log. Filing the signed ITP PDF is a hook that phase 7 implements.
- Hold-point warnings: an open H or W point, or an open rejection, on a line not yet done shows as a warning on the unit's packets, filtered by the department's page group (01, 02, 03, 04, 05 and 10 see Weld Parts; 06, 07 and 13 see Code; 09 sees both; 08, 11, 12 and 14 see Final Assembly). Nothing is blocked.
- New endpoints: `GET /api/qc/home`, `GET /api/qc/folders`, `GET /api/qc/folders/projects/{id}`, `GET /api/qc/folders/units/{id}`, `POST /api/qc/docs`, `PUT /api/packets/{id}/qc/checks/{key}`, `PUT /api/packets/{id}/qc/welds/{no}`, `PATCH /api/packets/{id}/qc` with field-level compare-and-set, `GET`/`POST /api/units/{id}/itp`, and the ITP writes under `/api/itps/{id}`. New policy actions `qc_doc_add`, `itp_start`, `itp_line_sign`, `itp_line_unsign`, `itp_block_unsign`; the scoped check for writes that belong to a project or unit rather than a packet.
- Packet history texts as the old build: "Q.C. checked: …", "VT accept · W3", "NDE method: RT", "Q.C. accepted → Clerical – Data Book", "Q.C. rejected → supervisor", "Document added", "Document taken out of the data book"; the ITP log: "ITP started", "ITP line 1.3 approved", "ITP line 1.3 rejected", "ITP block 3 signed", "ITP signed off", "ITP reopened"; the audit: "Q.C. document added", "Q.C. document revised".
- Tests: every Q.C. and ITP action as every role and status against a policy oracle, each `qc_missing` case, reject into history and the fresh inspection, Q.C. document supersession, document take-out, ITP line compare-and-set and unsign rules, block gates, the sign-off gate and the PDF hook, reopen, hold points per department page group; frontend tests for the inspection form, the Q.C. bar, the ITP page and the hold callout; Playwright scenarios for accept, reject with an NCR, the Q.C. folders, packet documents and the digital ITP through sign-off.
- docs/SCHEMA.md, POLICY.md and SECURITY.md updated; PLAN.md open questions 27 to 31.
- Phase 5: the shop. The Supervisor screen (department chips for people who lead several departments, the change-order headline that cannot be closed, the numbers Ready to assign, At stations, Waiting on review and Station hours vs target, the Change orders on hold table, the station grid with a tile per station showing the job, the welder and stamp, the ticking clock against the target hours and a CHANGE ORDER tag, and the boards Ready to assign, Waiting on review, At Q.C., With warehouse and Routed on) and the Stall screen (station picker, the station hero, My packets and Submitted, waiting on supervisor). - Phase 5: the shop. The Supervisor screen (department chips for people who lead several departments, the change-order headline that cannot be closed, the numbers Ready to assign, At stations, Waiting on review and Station hours vs target, the Change orders on hold table, the station grid with a tile per station showing the job, the welder and stamp, the ticking clock against the target hours and a CHANGE ORDER tag, and the boards Ready to assign, Waiting on review, At Q.C., With warehouse and Routed on) and the Stall screen (station picker, the station hero, My packets and Submitted, waiting on supervisor).
- The shop steps on the packet page, driven by `can` and `blocked`: assign with the station select, a welder picker with search and the stamp filled from the welder's record; pull back to the queue (the clock freezes); Start work, Pause, Resume; Submit to supervisor with a PIN signature and the missing steps listed as links (checklist items, no welds on the drawing, filler metal); the supervisor's review with Data book package ready, Spool ready for inspection, the spool location and a note before Send to Q.C. (signature), Return to station with a return note, and Skip Q.C. with a route to another department (signature); a lead sees the review read-only. Send back to Warehouse and Send back to Clerical with reasons. - The shop steps on the packet page, driven by `can` and `blocked`: assign with the station select, a welder picker with search and the stamp filled from the welder's record; pull back to the queue (the clock freezes); Start work, Pause, Resume; Submit to supervisor with a PIN signature and the missing steps listed as links (checklist items, no welds on the drawing, filler metal); the supervisor's review with Data book package ready, Spool ready for inspection, the spool location and a note before Send to Q.C. (signature), Return to station with a return note, and Skip Q.C. with a route to another department (signature); a lead sees the review read-only. Send back to Warehouse and Send back to Clerical with reasons.
- The Weld map tab: the drawing rendered with pdf.js at scale min(2.2, 4200/width), pan, pinch and wheel zoom, FIT, full screen; Mark a weld then tap the joint; welds drawn as a leader line and arrowhead in pink with the id in a white circle; drag the bubble or the arrow; the weld sheet (work order, piece, type from Fillet, Groove, Fill, Seal, Tack, size default 5/16", process from GMAW, FCAW, SMAW, GTAW, SAW, stamp, the BOM items it joins) with Log weld, Save changes and Remove weld; the welds-on-this-sheet list; the weld record (filler, filler lot, preheat, notes) with field-level compare-and-set. Marks from an earlier department show as carried. - The Weld map tab: the drawing rendered with pdf.js at scale min(2.2, 4200/width), pan, pinch and wheel zoom, FIT, full screen; Mark a weld then tap the joint; welds drawn as a leader line and arrowhead in pink with the id in a white circle; drag the bubble or the arrow; the weld sheet (work order, piece, type from Fillet, Groove, Fill, Seal, Tack, size default 5/16", process from GMAW, FCAW, SMAW, GTAW, SAW, stamp, the BOM items it joins) with Log weld, Save changes and Remove weld; the welds-on-this-sheet list; the weld record (filler, filler lot, preheat, notes) with field-level compare-and-set. Marks from an earlier department show as carried.
+6 -3
View File
@@ -6,9 +6,9 @@ This repo is the rebuild of Packet Station as a server application: Docker Compo
## Status ## Status
**Rebuild in progress. Phase 5 done: the Supervisor and Stall screens, the weld map, the heat table, the station clock and change-order verification.** **Rebuild in progress. Phase 6 done: Q.C. inspection, accept and reject, Q.C. project folders, packet documents and the digital ITP.**
People can sign in, set up projects and units, make hand drafts, attach a drawing, and send a packet to the warehouse, a department or Q.C. The worker reads released work orders from VISUAL on a schedule, makes and updates drafts, opens change orders on packets already in the shop, looks drawings up in M-Files and attaches them at the rev VISUAL calls. Clerical sees it all on the From VISUAL panel and in the Flagged and Changes to approve columns; Admin sets the feed up on the Feed tab. Warehouse works its task board (waiting, being pulled, on hold, pulled, released), pulls parts line by line with heat numbers, shorts, receipts and MTRs, and releases to the supervisor once every line is pulled or short and every pulled line has a heat. A supervisor assigns the packet to a station and a welder, the welder starts the clock, marks welds on the drawing, places the heat table, finishes the checklist and submits with a PIN signature, and the supervisor sends it to Q.C. or routes it to another department with the marks carried. Department supervisors verify change orders with a signature. Every packet write goes through the server policy. The Q.C. screen is still a placeholder; its API exists. The old build on `M:\Production\Packet Station` stays the live system until go-live. The new system starts empty; nothing is migrated from M:. People can sign in, set up projects and units, make hand drafts, attach a drawing, and send a packet to the warehouse, a department or Q.C. The worker reads released work orders from VISUAL on a schedule, makes and updates drafts, opens change orders on packets already in the shop, looks drawings up in M-Files and attaches them at the rev VISUAL calls. Clerical sees it all on the From VISUAL panel and in the Flagged and Changes to approve columns; Admin sets the feed up on the Feed tab. Warehouse works its task board (waiting, being pulled, on hold, pulled, released), pulls parts line by line with heat numbers, shorts, receipts and MTRs, and releases to the supervisor once every line is pulled or short and every pulled line has a heat. A supervisor assigns the packet to a station and a welder, the welder starts the clock, marks welds on the drawing, places the heat table, finishes the checklist and submits with a PIN signature, and the supervisor sends it to Q.C. or routes it to another department with the marks carried. Department supervisors verify change orders with a signature. Q.C. inspects the packet on its own tab (the check items, a visual result per weld, NDE, hardness, notes), accepts it to the data book, to Shipping or on to another department with a PIN signature, or rejects it back to the supervisor with a reason or an NCR number. Q.C. keeps project folders of documents per project and unit (ITP, WPS / PQR, procedures, certificates) where a new revision supersedes the old one and nothing is deleted, adds reports to a packet for the data book and takes them out with a reason, and works each unit's digital ITP from the Q.C. template through line initials, N/A, rejects with NCR numbers, section block signatures and a sign-off. Open hold and witness points show as a warning on the unit's packets. Every packet write goes through the server policy. The old build on `M:\Production\Packet Station` stays the live system until go-live. The new system starts empty; nothing is migrated from M:.
| Area | Exists yet? | | Area | Exists yet? |
|---|---| |---|---|
@@ -19,7 +19,8 @@ People can sign in, set up projects and units, make hand drafts, attach a drawin
| VISUAL feed, M-Files drawings, change orders from the feed, From VISUAL panel, Admin Feed tab and resource map | Yes (phase 3, run once against VISUAL DEV and the vault from a developer machine; fixture-driven in CI) | | VISUAL feed, M-Files drawings, change orders from the feed, From VISUAL panel, Admin Feed tab and resource map | Yes (phase 3, run once against VISUAL DEV and the vault from a developer machine; fixture-driven in CI) |
| Warehouse screen, pull table, shorts and receipts, MTRs, release gate, upload kinds, optional ClamAV, storage alert | Yes (phase 4, verified in the CI stack on docker-1) | | Warehouse screen, pull table, shorts and receipts, MTRs, release gate, upload kinds, optional ClamAV, storage alert | Yes (phase 4, verified in the CI stack on docker-1) |
| Supervisor and Stall screens, weld map, heat table, station clock, change-order verification | Yes (phase 5, verified in the CI stack on docker-1) | | Supervisor and Stall screens, weld map, heat table, station clock, change-order verification | Yes (phase 5, verified in the CI stack on docker-1) |
| Q.C., close and file, PDFs | API only (phase 2); screens in phases 6 and 7 | | Q.C. screen, inspection, Q.C. folders, packet documents, digital ITP, hold-point warnings | Yes (phase 6, verified in the CI stack on docker-1) |
| Close and file, PDFs | API only (phase 2); phase 7 |
| Project Management, Admin, notifications, backups | No (phases 8 and 9) | | Project Management, Admin, notifications, backups | No (phases 8 and 9) |
| Regression bench and go-live | No (phase 10) | | Regression bench and go-live | No (phase 10) |
@@ -84,6 +85,8 @@ The `packetstation` command has `doctor`, `migrate`, `app`, `worker`, `job run <
Phase 3 adds the worker (`packetstation worker`: APScheduler, the `visual-feed`, `drawing-lookup` and `housekeeping` jobs, settings re-read on every fire, run now through `job_requests`), the VISUAL and drawing cache tables (migration 0004), `/api/feed/*`, `/api/jobs/*`, `/api/admin/feed/*`, rev approval and hand uploads under `/api/packets/{id}`, and the synthetic fixture generator (`python -m tests.fixtures.generator --out DIR`) that CI feeds the worker with. See [docs/FEED.md](docs/FEED.md). Phase 3 adds the worker (`packetstation worker`: APScheduler, the `visual-feed`, `drawing-lookup` and `housekeeping` jobs, settings re-read on every fire, run now through `job_requests`), the VISUAL and drawing cache tables (migration 0004), `/api/feed/*`, `/api/jobs/*`, `/api/admin/feed/*`, rev approval and hand uploads under `/api/packets/{id}`, and the synthetic fixture generator (`python -m tests.fixtures.generator --out DIR`) that CI feeds the worker with. See [docs/FEED.md](docs/FEED.md).
Phase 6 adds the Q.C. screen (`/qc`: numbers, boards, project folders), `GET /api/qc/home`, `GET /api/qc/folders` with the project and unit folder reads, `POST /api/qc/docs` (a new revision supersedes the old row), the per-key Q.C. endpoints under `/api/packets/{id}/qc`, document kinds as a closed set with take-out reasons, and the digital ITP under `/api/units/{id}/itp` and `/api/itps/{id}` (start, line sign and unsign, N/A, reject with NCR, block sign, sign-off, reopen, log). The ITP PDF filed on sign-off is a hook for phase 7. See [docs/POLICY.md](docs/POLICY.md) "Q.C. (phase 6)".
CI stack with the Samba domain controller and the Playwright scenario, on a Docker host: CI stack with the Samba domain controller and the Playwright scenario, on a Docker host:
```bash ```bash
+8
View File
@@ -251,6 +251,14 @@ From phase 5 (2026-10-16), for Seth to veto:
25. The old build's manual "copy marks to the new sheet" button on a change order is not ported. On a page replacement only the heat table placement is copied to the new page. Welds and heat tags stay on the VOID page. Default: not ported in phase 5; a follow-up. 25. The old build's manual "copy marks to the new sheet" button on a change order is not ported. On a page replacement only the heat table placement is copied to the new page. Welds and heat tags stay on the VOID page. Default: not ported in phase 5; a follow-up.
26. Checklist templates live in the `checklist_templates` settings row, with the old lists as defaults. Default: no screen edits them in phase 5; Admin editing comes in phase 9. 26. Checklist templates live in the `checklist_templates` settings row, with the old lists as defaults. Default: no screen edits them in phase 5; Admin editing comes in phase 9.
From phase 6 (2026-10-17), for Seth and Q.C. to veto:
27. Q.C. reject: the old build took any non-empty reason. Default: a reason of at least 8 characters, or a well-formed NCR number (`NCR-YYYY-NNNN`) in its place, or both. The NCR is stored on the inspection and linked to pesco-ncr. Applied in phase 6.
28. A filed packet in a unit and the unit's ITP: the ITP belongs to the unit, not to a packet, so ITP writes never look at a packet's status. Default: a filed packet does not lock the ITP. This resolves the phase 5 follow-up. Applied in phase 6.
29. ITP status: the 0003 enum had `open`, `signed` and `reopened`. Default: `itp_status` gains `complete` for a signed-off ITP; a reopened ITP is `open` again with the reopen recorded. `signed` and `reopened` stay in the enum unused, since PostgreSQL cannot drop an enum value. Applied in phase 6.
30. Removing an ITP block signature: the old build let any Q.C. editor undo it. Default: the signer or an admin, while the ITP is open. The block's QN, remarks and customer fields stay. Applied in phase 6.
31. The signed ITP PDF on sign-off: default a hook (`pdf.itp.file_signed_itp_pdf`) that phase 7 builds. Today it files nothing, the sign-off log says `PDF filing reserved for phase 7`, and the automatic `qc_docs` ITP row is written only when the hook returns a file. Applied in phase 6.
## Verification ## Verification
- Every phase: `pytest` server suite, `npm test` and `npm run build` for the frontend, `docker compose -f deploy/compose.ci.yml up` with the Samba DC and the Playwright scenarios for that phase, `packetstation doctor` inside the image. - Every phase: `pytest` server suite, `npm test` and `npm run build` for the frontend, `docker compose -f deploy/compose.ci.yml up` with the Samba DC and the Playwright scenarios for that phase, `packetstation doctor` inside the image.
+207 -9
View File
@@ -4,7 +4,7 @@ Who may do what to a packet, and when. This file is written from the code in `se
## Purpose ## 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 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: The package is pure functions with no I/O:
@@ -12,7 +12,7 @@ The package is pure functions with no I/O:
- `principal.py`: `Principal`, `PacketState`, `principal_from_roles()`. - `principal.py`: `Principal`, `PacketState`, `principal_from_roles()`.
- `custody.py`: `holds()`, `works_on()`, `holder_name()` and the Who predicates. - `custody.py`: `holds()`, `works_on()`, `holder_name()` and the Who predicates.
- `preconditions.py`: the named preconditions and their messages. - `preconditions.py`: the named preconditions and their messages.
- `engine.py`: `check()`, `evaluate()`, `can()` and `Decision`. - `engine.py`: `check()`, `check_scoped()`, `evaluate()`, `can()` and `Decision`.
## Roles ## Roles
@@ -101,8 +101,6 @@ Statuses in workflow order: draft, warehouse, ready, assigned, in_progress, revi
| edit_wos | CL | draft | | | | | | | | edit_wos | CL | draft | | | | | | |
| edit_bom | HOLDER | draft, warehouse, SHOP | | | | | | | | edit_bom | HOLDER | draft, warehouse, SHOP | | | | | | |
| wh_receive_short | WH | any | | | | short_open | | | | wh_receive_short | WH | any | | | | short_open | | |
A holder of the packet who is not in the warehouse receives a short through edit_bom (see "Receive by custody").
| edit_heats | HOLDER | warehouse, SHOP | | | | | | | | edit_heats | HOLDER | warehouse, SHOP | | | | | | |
| edit_heat_table | HOLDER | warehouse, SHOP | | | | | | | | edit_heat_table | HOLDER | warehouse, SHOP | | | | | | |
| edit_welds | HOLDER | SHOP | | | | | | | | edit_welds | HOLDER | SHOP | | | | | | |
@@ -123,17 +121,28 @@ A holder of the packet who is not in the warehouse receives a short through edit
| co_reject | CL | any | | | | reason_given | Reject a change order | yes | | co_reject | CL | any | | | | reason_given | Reject a change order | yes |
| co_hold_toggle | CL | any | | | | | | | | co_hold_toggle | CL | any | | | | | | |
| co_verify | CO_VERIFIER | any | | | | | Verify a change order for the department | | | co_verify | CO_VERIFIER | any | | | | | Verify a change order for the department | |
| itp_edit_line | QC | any | | | | | | | | qc_doc_add | CL_OR_QC | no packet (scoped) | | | | | | |
| itp_block_sign | QC | any | | | | | Sign an ITP block | | | itp_start | QC | no packet (scoped) | | | | | | |
| itp_sign_off | QC | any | | | | | Sign off the ITP | | | itp_line_sign | QC | no packet (scoped) | | | | | | |
| itp_reopen | QC_SUP (Admin through the bypass) | any | | | | reason_given | | yes | | 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_update_draft | SYSTEM | draft | | | | | | |
| vis_retire_draft | SYSTEM | draft | | | | | | | | vis_retire_draft | SYSTEM | draft | | | | | | |
| vis_open_co | SYSTEM | any but draft and filed | | | | | | | | vis_open_co | SYSTEM | any but draft and filed | | | | | | |
| vis_auto_release | SYSTEM | draft | warehouse | | | heat_map_answered, drawing_attached | | | | 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 `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 takes an NCR number in place of the reason text (see "Q.C. (phase 6)", "Reject").
## Preconditions ## 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. 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.
@@ -176,6 +185,17 @@ Each precondition is a named predicate over the packet state and the request inp
The packet API answers 403 with the code for most failures, and 400 for `it_reason_required` and `reason_required`. The packet API answers 403 with the code for most failures, and 400 for `it_reason_required` and `reason_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 ## Signatures
These actions need a signature: submit, send_to_qc, route_from_review, qc_reject, qc_accept, qc_accept_and_route, file, admin_reopen, 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. These actions need a signature: submit, send_to_qc, route_from_review, qc_reject, qc_accept, qc_accept_and_route, file, admin_reopen, 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.
@@ -190,7 +210,8 @@ The request carries `sign: {factor, secret}`. The write pipeline calls the phase
- **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. - **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. - **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". - **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".
- **Append-only tables** never conflict: packet_transitions, packet_log, qc_history, short_receipts, project_renumbers, itp_rejects and co_verifications. Triggers refuse UPDATE, DELETE and TRUNCATE, and the app role has no grant for them. - **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, project_renumbers, itp_rejects, itp_log and co_verifications. Triggers refuse UPDATE, DELETE and TRUNCATE, and the app role has no grant for them.
## Reads: can, blocked and holder ## Reads: can, blocked and holder
@@ -433,6 +454,175 @@ The `action` and `note` columns of `packet_log` for each shop write. A departmen
The submit hours are the stage's `clock_elapsed_ms` after the stop, with one decimal. 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 signed.
| 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.
### 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 takes a reason, an NCR number, or both:
- An NCR number 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."
- The reason checked by `reason_given` is the NCR alone (`NCR-2026-0021`) when no text is given, else `NCR-2026-0021 · <reason>`. So an NCR number stands in for the reason, and the text has no minimum when an NCR is given.
- With neither, the answer is 400 `reason_required` with the rule's message. A reason without an NCR needs 8 characters.
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.
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 the phase 7 hook `pdf.itp.file_signed_itp_pdf()`. Today the hook returns nothing. When it returns an asset, 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 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` |
| 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) |
| Sign-off | `ITP signed off` | `PDF filed` when the hook filed one, else `PDF filing reserved for phase 7` |
| 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`.
## Worker writes (phase 3) ## 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. 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.
@@ -504,3 +694,11 @@ The old policy was looser than the old screens in places. The rebuild follows th
| 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 | | 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 | | 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 | | 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 needs a reason of at least 8 characters, or a well-formed NCR number, or both (open question 27). | A short note like "no" says nothing to the supervisor. An NCR number points to the full record. | Yes |
| 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 |
+87 -13
View File
@@ -1,6 +1,6 @@
# Packet Station schema # Packet Station schema
Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 5 adds the shop columns, the checklist templates and the clock rules (migration 0006). Phase 9 adds the notification rules and delivery. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema". Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 5 adds the shop columns, the checklist templates and the clock rules (migration 0006). Phase 6 adds the Q.C. columns, the Q.C. check items, the Q.C. folder metadata, the ITP header, line and block details and the ITP log (migration 0007). Phase 9 adds the notification rules and delivery. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema".
## Conventions ## Conventions
@@ -46,9 +46,9 @@ Declaration order matters for `packet_status`: it is the workflow order, so `sta
| `project_status` | open, hold, closed | | `project_status` | open, hold, closed |
| `route_kind` | databook, shipping | | `route_kind` | databook, shipping |
| `drawing_state` | none, needed, attached, rev_mismatch, not_released, not_found, lookup_failed, hand | | `drawing_state` | none, needed, attached, rev_mismatch, not_released, not_found, lookup_failed, hand |
| `itp_status` | open, signed, reopened | | `itp_status` | open, signed, reopened, complete (0007) |
Smaller closed sets are text with a CHECK: `projects.source` and every `*_src` (visual, hand), `project_aliases.kind` (renumber, unit_renumber, import), `packet_pages.type` (drawing, info, overlay), `bom_lines.src` (visual, hand), `qc_inspections.result` (accepted, rejected), `itp_lines.result` (approve, reject, na). Null means unset in each. Smaller closed sets are text with a CHECK: `projects.source` and every `*_src` (visual, hand), `project_aliases.kind` (renumber, unit_renumber, import), `packet_pages.type` (drawing, info, overlay), `bom_lines.src` (visual, hand), `qc_inspections.result` (accepted, rejected), `itp_lines.result` (approve, reject, na). Migration 0007 adds `packet_docs.kind`, `qc_docs.type` and `itp_rejects.kind` (see "Q.C. (phase 6, migration 0007)"). Null means unset in each.
## Projects and units (phase 2) ## Projects and units (phase 2)
@@ -101,7 +101,7 @@ What the warehouse columns mean in practice (phase 4):
- `heat_table_placements`: `page`, `x`, `y`, `s`, `carried_from` (0006), `by`, `at`. Unique on (`packet_id`, `page`), written as an upsert. - `heat_table_placements`: `page`, `x`, `y`, `s`, `carried_from` (0006), `by`, `at`. Unique on (`packet_id`, `page`), written as an upsert.
- `packet_wh`: the warehouse stage history. `stage` (wh_stage), `by`, `at`, `reason`, `note`, `auto`, `closed_at`. The current row has `closed_at` null and is unique per packet. A stage change closes it and inserts the next. - `packet_wh`: the warehouse stage history. `stage` (wh_stage), `by`, `at`, `reason`, `note`, `auto`, `closed_at`. The current row has `closed_at` null and is unique per packet. A stage change closes it and inserts the next.
- `packet_transitions`: append-only. Every status change: `at`, `by`, `action`, `from_status`, `to_status`, `from_dept`, `to_dept`, `reason`, `signature_id`, `it_reason`. Replaces the old `hist` and `sentBack` lists. - `packet_transitions`: append-only. Every status change: `at`, `by`, `action`, `from_status`, `to_status`, `from_dept`, `to_dept`, `reason`, `signature_id`, `it_reason`. Replaces the old `hist` and `sentBack` lists.
- `qc_inspections`: `stage_id`, `sent_at`, `sent_by`, `book`, `spool`, `location`, `sent_note`, `checks` jsonb, `weld_exam` jsonb (weld_no to `{result, note}`), `nde_method`, `nde_result`, `nde_report`, `hardness`, `nace`, `inspector`, `stamp`, `notes`, `result`, `result_at`, `result_by`, `result_note`, `signature_id`, `row_version`, `closed_at`. At most one open inspection per packet. - `qc_inspections`: `stage_id`, `sent_at`, `sent_by`, `book`, `spool`, `location`, `sent_note`, `checks` jsonb, `weld_exam` jsonb (weld_no to `{result, note}`), `nde_method`, `nde_result`, `nde_report`, `hardness`, `nace`, `inspector`, `stamp`, `notes`, `result`, `result_at`, `result_by`, `result_note`, `signature_id`, `row_version`, `closed_at`, `ncr` (0007). At most one open inspection per packet. See "The Q.C. inspection".
- `qc_history`: append-only. `at`, `result`, `inspector`, `note`, `dept_code`, `snapshot` jsonb. - `qc_history`: append-only. `at`, `result`, `inspector`, `note`, `dept_code`, `snapshot` jsonb.
- `packet_seals`: the HMAC-SHA256 `hash` written at filing, `key_id`, `sealed_at`, `sealed_by`, `signature_id`, and `broken_at`, `broken_by`, `broken_reason` when IT reopens. At most one unbroken seal per packet. - `packet_seals`: the HMAC-SHA256 `hash` written at filing, `key_id`, `sealed_at`, `sealed_by`, `signature_id`, and `broken_at`, `broken_by`, `broken_reason` when IT reopens. At most one unbroken seal per packet.
- `packet_log`: append-only. The user-visible history: `at`, `by`, `by_name` and `role` (snapshots), `action`, `note`, `it_reason`, `signature_id`, `transition_id`. Indexed on (`packet_id`, `id`). - `packet_log`: append-only. The user-visible history: `at`, `by`, `by_name` and `role` (snapshots), `action`, `note`, `it_reason`, `signature_id`, `transition_id`. Indexed on (`packet_id`, `id`).
@@ -190,6 +190,50 @@ A clock op in the wrong state is 409 `clock_state`.
The read model gives `server_now` (the database `now()`) and `stage.clock`: `{elapsed_ms, stored_ms, running, running_since, started_at}`. `stored_ms` is the column. `elapsed_ms` is live: the stored total plus `now() - clock_running_since` while running. Each closed stage has the same object, with `running` false. The client adds the time since the fetch to `elapsed_ms` for the ticking display only. The read model gives `server_now` (the database `now()`) and `stage.clock`: `{elapsed_ms, stored_ms, running, running_since, started_at}`. `stored_ms` is the column. `elapsed_ms` is live: the stored total plus `now() - clock_running_since` while running. Each closed stage has the same object, with `running` false. The client adds the time since the fetch to `elapsed_ms` for the ticking display only.
## Q.C. (phase 6, migration 0007)
### Column additions
- `qc_inspections`: `ncr` (text). The NCR number given with a Q.C. reject, upper case (`NCR-2026-0021`).
- `packet_docs`: `note` (text, up to 300 characters). `kind` gains a CHECK: one of `nde`, `hydro`, `dimensional`, `pmi`, `coating`, `coc`, `customer`, `ncr`, `other`.
- `qc_docs`: `title`, `note`, `superseded_at` and `superseded_by_user` (the person who filed the newer revision). Two CHECKs: `qc_docs_scope` (exactly one of `project_id` and `unit_id` is set) and `qc_docs_type` (one of `ITP`, `WPS / PQR`, `NDE procedure`, `Hydrotest procedure`, `PMI / material`, `Calibration certificate`, `Customer spec`, `Other`; the label itself is stored). Indexes `qc_docs_project` and `qc_docs_unit` on the current rows (`superseded_by` null).
- `itp_status` gains `complete`. A signed-off ITP is `complete`. `signed` and `reopened` stay in the enum unused: PostgreSQL cannot drop an enum value, and a reopened ITP is `open` again with `reopen_*` set.
- `itps`: `no` (the ITP number, `<unit number>-ITP` at start), `hdr` jsonb (the header: `client`, `pname`, `pno`, `po`, `unit`), `created_at`, `created_by`.
- `itp_lines`: `initials` (from the inspector's display name at approval), `cust_code` (the customer code for this line, null means the template's), `cust_by` (the customer witness or reviewer, as typed), `cust_at` (date), `cust_rec` and `cust_rec_at` (who recorded the customer entry, and when).
- `itp_rejects`: `ncr` is now nullable (a reject can wait for its NCR number). `kind` (text, `reject` or `ncr` by CHECK, default `reject`): `reject` is the reject itself, `ncr` is the NCR number entered later.
- `itp_blocks`: `signed_at` is now nullable with no default, so a block row can hold its fields before it is signed. New: `qn` (the QN number), `remarks`, `cust_name`, `cust_at` (date), `cust_by` and `cust_rec_at` (the customer complete entry and who recorded it), `row_version`.
- `itp_log` (new, append-only): `itp_id`, `at`, `by`, `by_name` (snapshot), `action`, `note`, `signature_id`, `it_reason`. Indexed on (`itp_id`, `id`). The user-visible history of an ITP, as `packet_log` is for a packet.
`ALTER TYPE ... ADD VALUE` runs inside the migration's transaction. That is allowed because 0007 does not use the new value itself.
### Q.C. check items
The `settings` row `qc_check_items` holds the Q.C. check items. Migration 0007 inserts the defaults with `ON CONFLICT DO NOTHING`, so a row already there is kept. The value:
```json
{"groups": [
{"group": "Data book package", "items": [{"key": "db_heats", "text": "Heat number recorded for every BOM item"}, ...]},
{"group": "Physical spool", "items": [{"key": "ph_dim", "text": "Dimensions verified against {drawing} Rev {rev}"}, ...]}
]}
```
| Group | Keys |
|---|---|
| Data book package | `db_heats`, `db_mtr`, `db_weldmap`, `db_wps`, `db_ck` |
| Physical spool | `ph_dim`, `ph_heat`, `ph_stamp`, `ph_clean`, `ph_qty` |
The texts may hold `{wps}` (else `the WPS`), `{stamp}` (`[75] ` with a trailing space, or empty), `{drawing}` (else `the drawing`) and `{rev}` (else `-`), filled from the packet. When the row is missing or malformed, the defaults in `domain/checklists.py` apply and a warning is logged. No screen edits the row in phase 6.
`qc_inspections.checks` maps each key to `{done: true, at, by, stamp}` (the inspection's stamp) or `{done: false}`, written one key at a time with `jsonb_set`. A key not in the list is 422 `invalid_key`. `qc_inspections.weld_exam` maps a weld number to `{result, note, at, by}`, where `result` is `accept`, `reject` or empty. `nde_method` is one of `None required`, `RT`, `UT`, `MT`, `PT`; `nde_result` is empty, `Acceptable` or `Rejectable`. `nace` is no longer written: the NACE label is computed from the packet's WPS.
### The Q.C. inspection
- SEND_TO_QC (from review) and SEND_DRAFT_TO_QC (from draft) open a `qc_inspections` row with `book`, `spool`, `location`, `sent_note` and the stage. Each opens a fresh row with empty checks and exam.
- At most one open row per packet (`qc_inspections_open`, unique on `packet_id` where `closed_at` is null).
- QC_ACCEPT and QC_ACCEPT_AND_ROUTE set the result and close the row into `qc_history`. The stage is closed with the inspection as `qc_snapshot`.
- QC_REJECT sets the result, the reason as `return_note` and the `ncr`, and closes the row into `qc_history` at once. The packet goes to review with no open inspection. A second SEND_TO_QC opens a new row; the first stays in `qc_history`.
- A `qc_history` row keeps `at`, `result`, `inspector`, `note` (the result note) and `dept_code` as columns, and the full inspection row in `snapshot` (checks, weld exam, NDE fields, `ncr`, `result_by`, location, sent time).
## Change orders (phases 2 and 3) ## Change orders (phases 2 and 3)
- `change_orders`: `co_no` (from `next_co_no`; unique per packet), `origin` (co_origin), `review` (co_review), `minor`, `hold`, `held_by`, `held_at`, `ecn`, `reason`, `issued_*`, `approved_*` and `rejected_*` (each with a `*_signature_id`), `vis`, `waiting` and `staged` jsonb (feed-shaped), `target_fp`, `row_version`. At most one pending VISUAL change order per packet (unique on `packet_id` where origin is visual and review is pending), so the feed re-aims it instead of stacking new ones. - `change_orders`: `co_no` (from `next_co_no`; unique per packet), `origin` (co_origin), `review` (co_review), `minor`, `hold`, `held_by`, `held_at`, `ecn`, `reason`, `issued_*`, `approved_*` and `rejected_*` (each with a `*_signature_id`), `vis`, `waiting` and `staged` jsonb (feed-shaped), `target_fp`, `row_version`. At most one pending VISUAL change order per packet (unique on `packet_id` where origin is visual and review is pending), so the feed re-aims it instead of stacking new ones.
@@ -200,6 +244,8 @@ The read model gives `server_now` (the database `now()`) and `stage.clock`: `{el
## Documents (phases 2, 4 and 6) ## Documents (phases 2, 4 and 6)
Nothing in this section is ever deleted. A packet document comes out of the data book by soft removal. A Q.C. folder document is replaced by a newer row that the old one points at. ITP rows are corrected forward, and the history is in the append-only `itp_rejects` and `itp_log`.
- `assets`: `id` uuid, `sha256` (indexed, for dedupe), `size`, `mime`, `original_name`, `storage_key` (the path under ASSETS_DIR, unique), `source` (`upload` by default), `source_ref` jsonb, `pages`, `uploaded_by`, `uploaded_at`, `packet_id` (the packet it was uploaded for, without a foreign key; `packet_pages` and `packet_docs` are the real links). - `assets`: `id` uuid, `sha256` (indexed, for dedupe), `size`, `mime`, `original_name`, `storage_key` (the path under ASSETS_DIR, unique), `source` (`upload` by default), `source_ref` jsonb, `pages`, `uploaded_by`, `uploaded_at`, `packet_id` (the packet it was uploaded for, without a foreign key; `packet_pages` and `packet_docs` are the real links).
Asset kinds (phase 4). An upload names its kind in the `purpose` field, and `source_ref` keeps `{"purpose": kind}`. No purpose means `document`. The type is read from the content, never from the name or the browser. Asset kinds (phase 4). An upload names its kind in the `purpose` field, and `source_ref` keeps `{"purpose": kind}`. No purpose means `document`. The type is read from the content, never from the name or the browser.
@@ -212,25 +258,53 @@ Asset kinds (phase 4). An upload names its kind in the `purpose` field, and `sou
| bom | XLSX, CSV | 5 MB (`upload_max_bytes_bom`) | | bom | XLSX, CSV | 5 MB (`upload_max_bytes_bom`) |
`mime` is one of `application/pdf`, `image/png`, `image/jpeg`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` and `text/csv`. An XLSX is a zip that holds `[Content_Types].xml` and `xl/workbook.xml`; a plain zip is refused. A CSV is UTF-8 text with no NUL and no control character other than tab, CR and LF. An upload the virus scanner refuses is never stored, so it has no row. `mime` is one of `application/pdf`, `image/png`, `image/jpeg`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` and `text/csv`. An XLSX is a zip that holds `[Content_Types].xml` and `xl/workbook.xml`; a plain zip is refused. A CSV is UTF-8 text with no NUL and no control character other than tab, CR and LF. An upload the virus scanner refuses is never stored, so it has no row.
- `packet_docs`: `asset_id`, `kind`, `title`, `added_at`, `added_by`, soft delete.
- `qc_docs`: `project_id`, `unit_id`, `type`, `rev`, `asset_id`, `auto`, `superseded_by` (the newer row), `added_at`, `added_by`. ### Packet documents
- `itps`: one per unit (`unit_id` unique). `template` jsonb, frozen when the ITP is made. `status` (itp_status), `signoff_*` (with the signature and the signed PDF asset), `reopen_*`, `version`.
- `itp_lines`: `key` (unique per ITP), `result`, `by`, `at`, `ncr`, `note`, `signature_id`, `row_version`. `packet_docs`: `asset_id`, `kind` (the closed set below), `title` (the kind label when none is given), `note`, `added_at`, `added_by`, and `removed_at`, `removed_by`, `removed_reason`. The asset must be a PDF, PNG or JPEG.
- `itp_rejects`: append-only. `line_key`, `ncr` (required), `note`, `at`, `by`. Every reject keeps its NCR id, even after the line is approved later.
- `itp_blocks`: `block_key` (unique per ITP), `signed_at`, `signed_by`, `signature_id`. | kind | Label |
|---|---|
| nde | NDE report (RT / UT / MT / PT) |
| hydro | Hydrotest report |
| dimensional | Dimensional report |
| pmi | PMI report |
| coating | Coating / paint report |
| coc | Certificate of conformance |
| customer | Customer inspection report |
| ncr | NCR |
| other | Other |
Taking a document out of the data book sets the three `removed_*` columns, with a reason. The row stays. The packet read lists it with `removed` set, so the record shows what was taken out, by whom and why. A row already taken out cannot be taken out again (409 `already_removed`).
### Q.C. folder documents
`qc_docs`: one row per filed document, on a project (`project_id`) or a unit (`unit_id`), never both. `type` (the label), `title`, `rev`, `note`, `asset_id` (PDF, PNG or JPEG), `auto` (filed by the ITP sign-off, titled with the ITP number and "signed off" as the old build, rev null), `added_at`, `added_by`, and the supersession columns `superseded_by`, `superseded_at`, `superseded_by_user`.
A new revision is a new row. In the same transaction, under `SELECT ... FOR UPDATE` of the old row, the old row gets `superseded_by` (the new row's id), `superseded_at` and `superseded_by_user`. The old row is never changed otherwise and never deleted. A row is current while `superseded_by` is null. The revision must be in the same folder as the row it replaces (422 `invalid_replace`); an old row that already points at a newer one is 409 `already_superseded`.
A unit folder shows its own current rows and inherits the project's current rows. A packet's read lists both as `qc_folder_docs`.
### The ITP
- `itps`: one per unit (`unit_id` unique). `template` jsonb is a copy of the code template `ITP_Generation_Revised_2`, frozen when the ITP is started, so a later template change never moves an existing ITP. `no`, `hdr`, `status` (`open` or `complete`), `signoff_at`, `signoff_by`, `signoff_signature_id`, `signoff_asset_id` (the signed PDF, set only when the phase 7 hook files one), `reopen_at`, `reopen_by`, `reopen_reason`, `created_at`, `created_by`, `version` (bumped by every ITP write).
- `itp_lines`: one row per template item, all made when the ITP is started, so every line has a `row_version` from the first read. `key` is the item number (`1.3`, unique per ITP). `result` is null (open), `approve`, `reject` or `na` (N/A is stored as the result). `by`, `at` and `initials` are the approval; removing it clears those and `result` and keeps `ncr` and the reject history. `ncr` is the NCR of the latest reject, `note` the line note. `cust_code`, `cust_by`, `cust_at`, `cust_rec`, `cust_rec_at` are the customer entry. `row_version` is for the If-Match on approval. A line is done when it is N/A or approved.
- `itp_rejects`: append-only. One `reject` row per reject (`ncr` may be null), and one `ncr` row when the number is entered later. `line_key`, `ncr`, `note`, `kind`, `at`, `by`. The rows survive the later approval of the line.
- `itp_blocks`: one row per signature block, all made when the ITP is started (`block_key` is the number of the section that closes the block, `3` to `9`; unique per ITP). An unsigned row holds `qn`, `remarks` and the customer complete fields. `signed_at`, `signed_by`, `signature_id` are the PESCO Q.C. signature. Removing it clears those three and keeps the rest.
- `itp_log`: append-only. One row per ITP write: `by`, `by_name`, `action`, `note`, `signature_id` (block sign and sign-off), `it_reason` (an admin edit). See [POLICY.md](POLICY.md), "Log texts".
## Append-only set ## Append-only set
These tables have the `ps_append_only` row and TRUNCATE triggers, and `ps_app` holds only SELECT and INSERT on them: These tables have the `ps_append_only` row and TRUNCATE triggers, and `ps_app` holds only SELECT and INSERT on them:
`audit_events`, `signin_attempts`, `signatures`, `project_renumbers`, `short_receipts`, `packet_transitions`, `qc_history`, `packet_log`, `itp_rejects`, `co_verifications`. `audit_events`, `signin_attempts`, `signatures`, `project_renumbers`, `short_receipts`, `packet_transitions`, `qc_history`, `packet_log`, `itp_rejects`, `itp_log` (0007), `co_verifications`.
The app role is refused by privilege first. The migrate role owns the tables, so the trigger refuses it. The views `v_units` and `v_open_cos` are SELECT only for `ps_app`. The app role is refused by privilege first. The migrate role owns the tables, so the trigger refuses it. The views `v_units` and `v_open_cos` are SELECT only for `ps_app`.
## Audit, settings, jobs (phases 1 and 3) ## Audit, settings, jobs (phases 1 and 3)
- `audit_events` (migration 0001): append-only. `at`, `user_id`, `session_id`, `role_snapshot` (`system` for the worker), `action`, `subject_type`, `subject_id`, `packet_id`, `detail` jsonb, `it_reason`, `ip`. A hash chain: `row_hash` is sha256 over `prev_hash` and the canonical JSON of the row, and `prev_hash` is the row before it. BRIN index on `at`. - `audit_events` (migration 0001): append-only. `at`, `user_id`, `session_id`, `role_snapshot` (`system` for the worker), `action`, `subject_type`, `subject_id`, `packet_id`, `detail` jsonb, `it_reason`, `ip`. A hash chain: `row_hash` is sha256 over `prev_hash` and the canonical JSON of the row, and `prev_hash` is the row before it. BRIN index on `at`.
- `settings` (migration 0001): `key` (text PK), `value` jsonb, `version`, `updated_at`, `updated_by`. Phase 3 adds the row `feed`, the feed settings as one JSON object (see [FEED.md](FEED.md), "Settings"). Phase 5 adds the row `checklist_templates` (see "Checklist templates"). A missing key takes its default. A save bumps `version` and sends `ps_notify('ps_settings', ...)` so the worker reschedules. - `settings` (migration 0001): `key` (text PK), `value` jsonb, `version`, `updated_at`, `updated_by`. Phase 3 adds the row `feed`, the feed settings as one JSON object (see [FEED.md](FEED.md), "Settings"). Phase 5 adds the row `checklist_templates` (see "Checklist templates"). Phase 6 adds the row `qc_check_items` (see "Q.C. check items"). A missing key takes its default. A save bumps `version` and sends `ps_notify('ps_settings', ...)` so the worker reschedules.
Phase 3, migration 0004: Phase 3, migration 0004:
@@ -273,7 +347,7 @@ Column additions in 0004:
## Versioning ## Versioning
- `packets.version` goes up by one in every write transaction that touches the packet or any of its children. The write pipeline does it explicitly (`UPDATE packets SET version = version + 1 ... RETURNING version`) after the caller's SQL. No trigger bumps it, so a read never does. It is the packet ETag and the NOTIFY payload. - `packets.version` goes up by one in every write transaction that touches the packet or any of its children. The write pipeline does it explicitly (`UPDATE packets SET version = version + 1 ... RETURNING version`) after the caller's SQL. No trigger bumps it, so a read never does. It is the packet ETag and the NOTIFY payload.
- `projects.version`, `units.version` and `itps.version` play the same part for those rows. - `projects.version`, `units.version` and `itps.version` play the same part for those rows. An ITP write locks the `itps` row (`SELECT ... FOR UPDATE`), not a packet row, and bumps `itps.version`. A Q.C. folder revision locks the row it replaces.
- Child rows that are edited in place carry `row_version`. A PATCH sends `If-Match: <row_version>`. A mismatch is 409 `row_conflict` with the current row. The write bumps `row_version`. - Child rows that are edited in place carry `row_version`. A PATCH sends `If-Match: <row_version>`. A mismatch is 409 `row_conflict` with the current row. The write bumps `row_version`.
- Every write takes `SELECT ... FOR UPDATE` on the packet row first. That lock replaces the old 8-second lease and serialises the counters: `next_weld_no`, `next_heat_no`, `next_page_no` and `next_co_no` hand out the next number with `UPDATE packets SET next_weld_no = next_weld_no + 1 RETURNING next_weld_no - 1`, so two concurrent adds never get the same number. - Every write takes `SELECT ... FOR UPDATE` on the packet row first. That lock replaces the old 8-second lease and serialises the counters: `next_weld_no`, `next_heat_no`, `next_page_no` and `next_co_no` hand out the next number with `UPDATE packets SET next_weld_no = next_weld_no + 1 RETURNING next_weld_no - 1`, so two concurrent adds never get the same number.
- Transitions do not check `version`. They compare and set on `expect: {status, dept}`, so a weld edit never blocks a send. - Transitions do not check `version`. They compare and set on `expect: {status, dept}`, so a weld edit never blocks a send.
+13 -1
View File
@@ -2,7 +2,7 @@
This document is rewritten for the server build. The old browser build's security document is in the old repo (`C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md`). The design comes from [BRIEF.md](BRIEF.md) and [PLAN.md](PLAN.md). This document is rewritten for the server build. The old browser build's security document is in the old repo (`C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md`). The design comes from [BRIEF.md](BRIEF.md) and [PLAN.md](PLAN.md).
Status: phase 5. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) and the policy controls (server policy on every write, workflow steps as compare-and-set, server-side validation and limits, IT edit reasons, the filed lock, the upload pipeline for drawings and documents) are built and tested. Phase 3 adds the worker controls (read-only VISUAL and M-Files reads, stack guard, advisory locks, redacted errors, audited system writes). No requirement ID closes in phase 3. Phase 4 completes the upload pipeline (a kind per upload with its own types and limit, XLSX and CSV by content, the optional ClamAV scan, the assets volume quota alert) and adds the warehouse rules (the heat gate on release, closed hold and short reasons, receive by custody). Phase 4 closes D5. Phase 5 adds the shop rules (the server as the clock of record, server-assigned mark numbers under the packet row lock, insert-only change-order verifications with a signature, VOID sheets never edited). Every other control names the phase that builds it. Status: phase 6. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) and the policy controls (server policy on every write, workflow steps as compare-and-set, server-side validation and limits, IT edit reasons, the filed lock, the upload pipeline for drawings and documents) are built and tested. Phase 3 adds the worker controls (read-only VISUAL and M-Files reads, stack guard, advisory locks, redacted errors, audited system writes). No requirement ID closes in phase 3. Phase 4 completes the upload pipeline (a kind per upload with its own types and limit, XLSX and CSV by content, the optional ClamAV scan, the assets volume quota alert) and adds the warehouse rules (the heat gate on release, closed hold and short reasons, receive by custody). Phase 4 closes D5. Phase 5 adds the shop rules (the server as the clock of record, server-assigned mark numbers under the packet row lock, insert-only change-order verifications with a signature, VOID sheets never edited). Phase 6 adds the Q.C. rules (server gates on accept, block sign and sign-off, NCR numbers checked by pattern, Q.C. documents never deleted, the append-only ITP log, server-verified signatures on every Q.C. and ITP sign-off). No requirement ID closes in phase 6. Every other control names the phase that builds it.
Contents: Contents:
@@ -109,6 +109,18 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
- A borrowed heat map is read-only. Nothing on the sibling packet can be written through the borrowing packet. - A borrowed heat map is read-only. Nothing on the sibling packet can be written through the borrowing packet.
- Presence over SSE carries display names, never other personal data. Each entry is the user id, display name, screen and packet id. - Presence over SSE carries display names, never other personal data. Each entry is the user id, display name, screen and packet id.
### Q.C. rules (phase 6)
- Accept is refused on the server until the Q.C. record is complete (`qc_missing_empty`: every check item, a visual result on every weld and none rejected, an acceptable NDE result when a method is set, the inspector name). The screen only shows the reason.
- A block signature is refused until every line of the block is initialed or N/A. The sign-off is refused until every block is signed. Both are server rules. A signed-off ITP refuses every write but the reopen, which needs a Q.C. Supervisor, or an admin with an IT reason, and a reason.
- An ITP line rejected without an NCR number cannot be approved (server check). Line initials and block signatures can be removed only by the person who made them, or an admin.
- NCR numbers are checked against the pattern `NCR-YYYY-NNNN` on the server. Checking them against pesco-ncr is reserved for a later phase (`domain/ncr.py`, `validate_ncr`). The NCR link opens in a new tab with `rel="noopener noreferrer"`.
- Documents are never deleted. A packet document is taken out of the data book with a reason and the row is kept. A Q.C. folder document is replaced by a new revision; the old row stays and points at the new one. Packet document kinds and Q.C. document types are closed sets, checked on the server and by database CHECKs. Every document file is a PDF, PNG or JPEG, typed by content.
- `itp_log` is append-only, like `packet_log`: the app role has SELECT and INSERT only, and a trigger refuses updates, deletes and truncate. `itp_rejects` stays append-only, so every reject and its NCR number survive a later approval.
- Signatures verified on the server, with a content hash: Q.C. accept, Q.C. accept and route, Q.C. reject, the ITP block signature and the ITP sign-off.
- The ITP and the Q.C. folder are checked with `check_scoped()`: the same system, admin bypass, who and precondition steps as a packet write. An admin outside Q.C. and Clerical needs an IT reason, recorded in `itp_log` and the audit row.
- Every ITP write and every Q.C. folder document writes an audit row (`itp.<action>`, `qc_doc.add`, `qc_doc.revise`).
### Worker (phase 3) ### Worker (phase 3)
- The worker publishes no port and joins no proxy network. It reaches out to VISUAL and M-Files only; nothing reaches in. Admin and Clerical reach it only through rows in PostgreSQL (`job_requests`, the `feed` settings row) and NOTIFY. - The worker publishes no port and joins no proxy network. It reaches out to VISUAL and M-Files only; nothing reaches in. Admin and Clerical reach it only through rows in PostgreSQL (`job_requests`, the `feed` settings row) and NOTIFY.
+27
View File
@@ -10,6 +10,9 @@ import { SignInPage } from "./pages/SignInPage";
import { AccountPage } from "./screens/account/AccountPage"; import { AccountPage } from "./screens/account/AccountPage";
import { ProjectPage } from "./screens/clerical/ProjectPage"; import { ProjectPage } from "./screens/clerical/ProjectPage";
import { UnitPage } from "./screens/clerical/UnitPage"; import { UnitPage } from "./screens/clerical/UnitPage";
import { ItpPage } from "./screens/qc/ItpPage";
import { QcProjectFolder } from "./screens/qc/QcProjectFolder";
import { QcUnitFolder } from "./screens/qc/QcUnitFolder";
import { RequireScreen, ScreenRoute } from "./screens/ScreenRoute"; import { RequireScreen, ScreenRoute } from "./screens/ScreenRoute";
/** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */ /** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */
@@ -39,6 +42,30 @@ export function AppRoutes() {
</RequireScreen> </RequireScreen>
} }
/> />
<Route
path="qc/projects/:projectId"
element={
<RequireScreen screen="qc">
<QcProjectFolder />
</RequireScreen>
}
/>
<Route
path="qc/units/:unitId"
element={
<RequireScreen screen="qc">
<QcUnitFolder />
</RequireScreen>
}
/>
<Route
path="qc/units/:unitId/itp"
element={
<RequireScreen screen="qc">
<ItpPage />
</RequireScreen>
}
/>
<Route path="packets/:id" element={<PacketPage />} /> <Route path="packets/:id" element={<PacketPage />} />
</Route> </Route>
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
+16 -3
View File
@@ -16,6 +16,7 @@ import type {
PacketHeader, PacketHeader,
PacketListQuery, PacketListQuery,
PacketRead, PacketRead,
QcFields,
ReceiptBody, ReceiptBody,
ShortBody, ShortBody,
StageFields, StageFields,
@@ -43,10 +44,12 @@ export const packetsApi = {
attachPages: (id: number, body: AttachPagesBody) => api.post<PacketRead>(`${base(id)}/pages`, body), attachPages: (id: number, body: AttachPagesBody) => api.post<PacketRead>(`${base(id)}/pages`, body),
removePages: (id: number, n: number, body: { reason?: string } & Guarded = {}) => removePages: (id: number, n: number, body: { reason?: string } & Guarded = {}) =>
api.delWithBody<PacketRead>(`${base(id)}/pages/${n}`, body), api.delWithBody<PacketRead>(`${base(id)}/pages/${n}`, body),
addDoc: (id: number, body: { asset_id: string; kind: string; title: string } & Guarded) => /** Phase 6: kind from the closed set; the title defaults to the kind label on the server. */
addDoc: (id: number, body: { asset_id: string; kind: string; title?: string; note?: string } & Guarded) =>
api.post<PacketRead>(`${base(id)}/docs`, body), api.post<PacketRead>(`${base(id)}/docs`, body),
removeDoc: (id: number, docId: number, extra: Guarded = {}) => /** Takes a document out of the data book. The row stays, with the reason. */
api.delWithBody<PacketRead>(`${base(id)}/docs/${docId}`, extra), removeDoc: (id: number, docId: number, body: { reason: string } & Guarded) =>
api.delWithBody<PacketRead>(`${base(id)}/docs/${docId}`, body),
move: (id: number, body: { unit_id: number; reason?: string } & Guarded) => api.post<PacketRead>(`${base(id)}/move`, body), move: (id: number, body: { unit_id: number; reason?: string } & Guarded) => api.post<PacketRead>(`${base(id)}/move`, body),
remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody<PacketRead | null>(base(id), body), remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody<PacketRead | null>(base(id), body),
setVclosed: (id: number, closed: boolean, extra: Guarded = {}) => setVclosed: (id: number, closed: boolean, extra: Guarded = {}) =>
@@ -114,6 +117,16 @@ export const packetsApi = {
clock: (id: number, op: "start" | "pause" | "resume", g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/stage/clock`, { op, ...g }), clock: (id: number, op: "start" | "pause" | "resume", g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/stage/clock`, { op, ...g }),
coVerify: (id: number, no: number, body: { dept: string; note?: string } & Guarded) => coVerify: (id: number, no: number, body: { dept: string; note?: string } & Guarded) =>
api.post<PacketRead>(`${base(id)}/change-orders/${no}/verify`, body), api.post<PacketRead>(`${base(id)}/change-orders/${no}/verify`, body),
/* Phase 6: the Q.C. inspection. Every answer is the packet. */
/** One check of the open inspection. */
qcCheck: (id: number, key: string, done: boolean, g: Guarded = {}) =>
api.put<PacketRead>(`${base(id)}/qc/checks/${encodeURIComponent(key)}`, { done, ...g }),
/** The visual exam of one weld. result "" clears it. */
qcWeld: (id: number, no: number, body: { result?: "accept" | "reject" | ""; note?: string }, g: Guarded = {}) =>
api.put<PacketRead>(`${base(id)}/qc/welds/${no}`, { ...body, ...g }),
/** The scalar inspection fields with a field-level base: 409 field_conflict when someone changed one first. */
qcPatch: (id: number, body: FieldCas<QcFields>) => api.patch<PacketRead>(`${base(id)}/qc`, body),
}; };
/** GET /api/departments/{code}/people: the welder picker and the weld sheet's stamp. */ /** GET /api/departments/{code}/people: the welder picker and the weld sheet's stamp. */
+436
View File
@@ -0,0 +1,436 @@
// The Q.C. home, the Q.C. folders and the digital ITP (phase 6 contract, api/qc.py and api/itp.py).
// These types belong to the Q.C. screens. The packet page types stay in types.ts.
import { api, request } from "./client";
import { qs } from "./query";
import type { Guarded, PacketCard, ProjectStatus, SignBody } from "./types";
/** A person on a record. */
export interface QcWho {
id: string;
display_name: string;
emp_no?: string | null;
}
/* ---------- Q.C. home ---------- */
export interface QcCardInfo {
location: string | null;
sent_at: string | null;
inspector: string | null;
result: string | null;
}
/** A board card: the list card plus the inspection facts and the open ITP hold count. */
export interface QcCard extends PacketCard {
qc?: QcCardInfo | null;
itp_hold_count?: number | null;
}
export interface QcKpis {
waiting: number;
accepted_7d: number;
rejected_7d: number;
}
export const QC_COLUMNS = ["ready", "accepted", "rejected"] as const;
export type QcColumn = (typeof QC_COLUMNS)[number];
/** One project on the folder grid. */
export interface QcFolderCard {
id: number;
number: string;
name: string | null;
customer: string | null;
po: string | null;
status: ProjectStatus;
units: number;
docs: number;
units_without_itp: number;
}
export interface QcHome {
kpis: QcKpis;
columns: Record<QcColumn, QcCard[]>;
folders: QcFolderCard[];
}
export interface QcHomeQuery {
project_id?: number;
unit_id?: number;
}
/* ---------- Q.C. folders ---------- */
/** A Q.C. folder document. Nothing is deleted; a new revision supersedes the old row. */
export interface QcDoc {
id: number;
project_id: number | null;
unit_id: number | null;
type: string;
title: string | null;
rev: string | null;
note: string | null;
asset_id: string;
url: string;
file_name: string | null;
size: number | null;
auto: boolean;
added_at: string;
added_by: QcWho | null;
superseded_by: number | null;
superseded_at: string | null;
superseded_by_user: QcWho | null;
}
/** The unit's ITP in short. */
export interface ItpSummary {
id: number;
no: string;
status: ItpStatus;
done: number;
total: number;
holds: number;
}
export interface QcFolderProject {
id: number;
number: string;
name: string | null;
customer: string | null;
po: string | null;
status: ProjectStatus;
}
export interface QcFolderUnitRow {
id: number;
number: string;
lot: string;
name: string | null;
status: ProjectStatus;
docs: number;
itp: ItpSummary | null;
has_itp_doc: boolean;
}
export interface QcProjectFolder {
project: QcFolderProject;
docs: QcDoc[];
superseded: QcDoc[];
units: QcFolderUnitRow[];
}
export interface QcUnitFolder {
unit: { id: number; number: string; lot: string; name: string | null; status: ProjectStatus; po: string | null };
project: QcFolderProject;
docs: QcDoc[];
inherited: QcDoc[];
superseded: QcDoc[];
itp: ItpSummary | null;
no_itp: boolean;
}
export type QcDocBody = ({ project_id: number; unit_id?: never } | { unit_id: number; project_id?: never }) & {
type: string;
title?: string;
rev?: string;
note?: string;
asset_id: string;
replaces?: number;
} & Guarded;
/* ---------- Digital ITP ---------- */
export type ItpStatus = "open" | "complete";
export type ItpPageCode = "WP" | "C" | "FA";
export interface ItpItem {
n: string;
t: string;
code: string;
cust: string;
crit: string;
}
export interface ItpSection {
n: string;
t: string;
page: ItpPageCode | string;
sig?: boolean;
final?: boolean;
items: ItpItem[];
}
export interface ItpTemplate {
ver: string;
legend: string;
sections: ItpSection[];
}
export interface ItpReject {
at: string;
by: QcWho | null;
ncr: string | null;
note: string | null;
kind: "reject" | "ncr";
}
export interface ItpLine {
key: string;
result: "approve" | "reject" | "na" | null;
na: boolean;
by: QcWho | null;
initials: string | null;
at: string | null;
ncr: string | null;
/** Rejected, no NCR yet. */
ncr_missing: boolean;
note: string | null;
cust_code: string | null;
cust_by: string | null;
cust_at: string | null;
cust_rec: QcWho | null;
rejects: ItpReject[];
row_version: number;
}
export interface ItpBlock {
key: string;
sections: string[];
/** "Sections 1–3" or "Section 4". */
scope: string;
final: boolean;
ready: boolean;
all_na: boolean;
signed: boolean;
signed_at: string | null;
signed_by: QcWho | null;
qn: string | null;
remarks: string | null;
cust_name: string | null;
cust_at: string | null;
cust_by: QcWho | null;
row_version: number;
}
export interface ItpHoldRow {
n: string;
t: string;
page: string;
codes: string[];
rejected: boolean;
ncr: string | null;
}
export interface ItpLogRow {
id: number;
at: string;
by: QcWho | null;
by_name: string | null;
action: string;
note: string | null;
signed: boolean;
}
export interface ItpHeader {
client: string;
pname: string;
pno: string;
po: string;
unit: string;
}
export interface Itp {
id: number;
unit_id: number;
no: string;
hdr: ItpHeader;
status: ItpStatus;
version: number;
template: ItpTemplate;
pages: Array<{ code: string; name: string }>;
lines: Record<string, ItpLine>;
blocks: ItpBlock[];
progress: { done: number; total: number; holds: number };
holds: ItpHoldRow[];
signoff: { at: string; by: QcWho | null; asset_id: string | null } | null;
reopen: { at: string; by: QcWho | null; reason: string } | null;
created_at: string;
created_by: QcWho | null;
can: { edit: boolean; sign_off: boolean; reopen: boolean };
log: ItpLogRow[];
}
/** GET /api/units/{id}/itp when the unit has none. */
export interface NoItp {
itp: null;
unit: { id: number; number: string };
}
export type HeaderField = "no" | keyof ItpHeader;
export interface ItpPatch {
set: Partial<Record<HeaderField, string>>;
base: Partial<Record<HeaderField, string>>;
}
export interface LinePatch {
na?: boolean;
cust_code?: string;
cust_by?: string;
cust_at?: string | null;
note?: string;
}
export interface BlockPatch {
qn?: string;
remarks?: string;
cust_name?: string;
cust_at?: string;
cust_clear?: true;
}
/* ---------- normalising ---------- */
function list<T>(raw: unknown): T[] {
return Array.isArray(raw) ? (raw as T[]) : [];
}
export function normaliseHome(raw: Partial<QcHome> | null): QcHome {
const cols = (raw?.columns ?? {}) as Partial<Record<QcColumn, unknown>>;
const columns = Object.fromEntries(QC_COLUMNS.map((c) => [c, list<QcCard>(cols[c])])) as QcHome["columns"];
return {
kpis: { waiting: 0, accepted_7d: 0, rejected_7d: 0, ...(raw?.kpis ?? {}) },
columns,
folders: list<QcFolderCard>(raw?.folders),
};
}
function normaliseProjectFolder(raw: QcProjectFolder): QcProjectFolder {
return { ...raw, docs: list(raw.docs), superseded: list(raw.superseded), units: list(raw.units) };
}
function normaliseUnitFolder(raw: QcUnitFolder): QcUnitFolder {
return { ...raw, docs: list(raw.docs), inherited: list(raw.inherited), superseded: list(raw.superseded), itp: raw.itp ?? null };
}
/** Every list present, so the screen never trips on a missing field. */
export function normaliseItp(raw: Itp): Itp {
const lines: Record<string, ItpLine> = {};
for (const [k, l] of Object.entries(raw.lines ?? {})) lines[k] = { ...l, rejects: list(l.rejects) };
return {
...raw,
hdr: { client: "", pname: "", pno: "", po: "", unit: "", ...((raw.hdr ?? {}) as Partial<ItpHeader>) },
template: { ...raw.template, sections: list(raw.template?.sections) },
pages: list(raw.pages),
lines,
blocks: list<ItpBlock>(raw.blocks).map((b) => ({ ...b, sections: list(b.sections) })),
progress: { done: 0, total: 0, holds: 0, ...((raw.progress ?? {}) as Partial<Itp["progress"]>) },
holds: list(raw.holds),
log: list(raw.log),
can: { edit: false, sign_off: false, reopen: false, ...((raw.can ?? {}) as Partial<Itp["can"]>) },
};
}
function isItp(raw: Itp | NoItp | null): raw is Itp {
return !!raw && "id" in raw && typeof (raw as Itp).id === "number";
}
/* ---------- calls ---------- */
const enc = encodeURIComponent;
export async function getQcHome(q: QcHomeQuery = {}, signal?: AbortSignal): Promise<QcHome> {
return normaliseHome(await api.get<Partial<QcHome> | null>(`/api/qc/home${qs({ ...q })}`, signal));
}
export async function getFolders(show: "active" | "all" = "active", signal?: AbortSignal): Promise<QcFolderCard[]> {
const raw = await api.get<unknown>(`/api/qc/folders${qs({ show })}`, signal);
if (Array.isArray(raw)) return raw as QcFolderCard[];
return list<QcFolderCard>((raw as { folders?: unknown } | null)?.folders);
}
export async function getProjectFolder(id: number, signal?: AbortSignal): Promise<QcProjectFolder> {
return normaliseProjectFolder(await api.get<QcProjectFolder>(`/api/qc/folders/projects/${id}`, signal));
}
export async function getUnitFolder(id: number, signal?: AbortSignal): Promise<QcUnitFolder> {
return normaliseUnitFolder(await api.get<QcUnitFolder>(`/api/qc/folders/units/${id}`, signal));
}
/** Answers the folder read of the scope the document went into. */
export async function addQcDoc(body: QcDocBody): Promise<QcProjectFolder | QcUnitFolder> {
const raw = await api.post<QcProjectFolder | QcUnitFolder>("/api/qc/docs", body);
return "unit" in raw ? normaliseUnitFolder(raw) : normaliseProjectFolder(raw);
}
/** The unit's ITP, or null when it has none yet. */
export async function getItp(unitId: number, signal?: AbortSignal): Promise<Itp | null> {
const raw = await api.get<Itp | NoItp | null>(`/api/units/${unitId}/itp`, signal);
return isItp(raw) ? normaliseItp(raw) : null;
}
const itp = async (p: Promise<Itp>) => normaliseItp(await p);
export const startItp = (unitId: number, g: Guarded = {}) => itp(api.post<Itp>(`/api/units/${unitId}/itp`, { ...g }));
export const patchItp = (id: number, body: ItpPatch, g: Guarded = {}) => itp(api.patch<Itp>(`/api/itps/${id}`, { ...body, ...g }));
/** Approve (initial) a line. `rowVersion` makes it a compare-and-set. */
export const signLine = (id: number, key: string, rowVersion?: number, g: Guarded = {}) =>
itp(
request<Itp>(`/api/itps/${id}/lines/${enc(key)}/sign`, {
method: "POST",
body: { ...g },
...(rowVersion !== undefined ? { headers: { "If-Match": String(rowVersion) } } : {}),
}),
);
export const unsignLine = (id: number, key: string, g: Guarded = {}) => itp(api.post<Itp>(`/api/itps/${id}/lines/${enc(key)}/unsign`, { ...g }));
export const patchLine = (id: number, key: string, body: LinePatch, g: Guarded = {}) =>
itp(api.patch<Itp>(`/api/itps/${id}/lines/${enc(key)}`, { ...body, ...g }));
export const sectionNa = (id: number, n: string, na: boolean, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/sections/${enc(n)}/na`, { na, ...g }));
export const rejectLine = (id: number, key: string, body: { ncr?: string; note?: string }, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/lines/${enc(key)}/reject`, { ...body, ...g }));
export const enterNcr = (id: number, key: string, ncr: string, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/lines/${enc(key)}/ncr`, { ncr, ...g }));
export const patchBlock = (id: number, key: string, body: BlockPatch, g: Guarded = {}) =>
itp(api.patch<Itp>(`/api/itps/${id}/blocks/${enc(key)}`, { ...body, ...g }));
export const signBlock = (id: number, key: string, sign: SignBody | undefined, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/blocks/${enc(key)}/sign`, { ...g, ...(sign ? { sign } : {}) }));
export const unsignBlock = (id: number, key: string, g: Guarded = {}) =>
itp(request<Itp>(`/api/itps/${id}/blocks/${enc(key)}/sign`, { method: "DELETE", body: { ...g } }));
export const signOff = (id: number, sign: SignBody | undefined, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/signoff`, { ...g, ...(sign ? { sign } : {}) }));
export const reopenItp = (id: number, body: { reason: string; it_reason?: string }) => itp(api.post<Itp>(`/api/itps/${id}/reopen`, body));
export const qcApi = {
getQcHome,
getFolders,
getProjectFolder,
getUnitFolder,
addQcDoc,
getItp,
startItp,
patchItp,
signLine,
unsignLine,
patchLine,
sectionNa,
rejectLine,
enterNcr,
patchBlock,
signBlock,
unsignBlock,
signOff,
reopenItp,
};
+144 -3
View File
@@ -293,6 +293,137 @@ export interface PacketDoc {
added_at: string | null; added_at: string | null;
added_by: WhoRef | null; added_by: WhoRef | null;
url: string; url: string;
/** Phase 6: the kind's label from the closed set. */
kind_label?: string;
note?: string | null;
file_name?: string | null;
size?: number | null;
/** Phase 6: a document taken out of the data book. The row stays as a record. */
removed?: { at: string; by: WhoRef | null; reason: string | null } | null;
}
/* ---------- Phase 6: Q.C. on the packet ---------- */
/** One Q.C. check of the open inspection. */
export interface QcCheckEntry {
done: boolean;
at?: string | null;
by?: string | null;
by_name?: string | null;
stamp?: string | null;
}
/** The visual exam of one weld, keyed by the weld number as text. An empty result means cleared. */
export interface QcWeldExam {
result?: "accept" | "reject" | "" | null;
note?: string | null;
at?: string | null;
by?: string | null;
by_name?: string | null;
}
/** The open Q.C. inspection (`qc` on the packet read). */
export interface QcInspection {
id: number;
sent_at: string | null;
sent_by?: WhoRef | null;
book: boolean | null;
spool: boolean | null;
location: string | null;
sent_note: string | null;
nde_method: string | null;
nde_result: string | null;
nde_report: string | null;
hardness: string | null;
inspector: string | null;
stamp: string | null;
notes: string | null;
result: "accepted" | "rejected" | null;
result_at: string | null;
result_note: string | null;
result_by?: WhoRef | null;
row_version: number;
checks: Record<string, QcCheckEntry>;
weld_exam: Record<string, QcWeldExam>;
ncr?: string | null;
/** The packet's WPS matches /NACE/i. */
nace_label?: boolean;
/** What Q.C. still has to record before an accept (the server's qc_missing). */
missing?: string[];
}
/** One closed inspection (`qc_history[]` and `qc_last`). */
export interface QcHistoryRow {
id: number;
at: string | null;
result: "accepted" | "rejected" | string | null;
inspector: string | null;
note: string | null;
dept_code: string | null;
ncr?: string | null;
rejected_welds?: string[];
by?: WhoRef | null;
location?: string | null;
sent_at?: string | null;
}
/** One Q.C. check item with the old qcTab hint. */
export interface QcItem {
group: string;
key: string;
text: string;
hint?: string | null;
}
/** A current Q.C. folder document of the packet's unit or project. */
export interface QcFolderDoc {
id: number;
type: string;
title: string | null;
rev: string | null;
url: string;
scope: "unit" | "project";
added_at: string | null;
}
/** The unit's ITP summary. */
export interface ItpSummary {
id: number;
no: string | null;
status: "open" | "complete" | string;
done: number;
total: number;
holds: number;
}
/** One open hold, witness or rejected point on the packet's ITP pages. */
export interface ItpHoldItem {
n: string;
t: string;
codes: string[];
rejected: boolean;
ncr: string | null;
}
/** The open ITP hold points for the packet's department (a warning only). */
export interface ItpHold {
no: string | null;
count: number;
rejected: boolean;
/** The department's page codes, or null for every page. */
pages: string[] | null;
items: ItpHoldItem[];
}
/** PATCH /api/packets/{id}/qc: the scalar inspection fields. */
export interface QcFields {
inspector: string | null;
stamp: string | null;
nde_method: string | null;
nde_result: string | null;
nde_report: string | null;
hardness: string | null;
notes: string | null;
} }
/** GET /api/packets/{id} and every packet mutation answer. Children the phase 2 screens do not use stay loose. */ /** GET /api/packets/{id} and every packet mutation answer. Children the phase 2 screens do not use stay loose. */
@@ -343,8 +474,8 @@ export interface PacketRead {
stage: Stage | null; stage: Stage | null;
stages: Stage[]; stages: Stage[];
wh: WhInfo | null; wh: WhInfo | null;
qc: Record<string, unknown> | null; qc: QcInspection | null;
qc_history: unknown[]; qc_history: QcHistoryRow[];
change_orders: ChangeOrder[]; change_orders: ChangeOrder[];
open_cos: number[]; open_cos: number[];
open_shorts: number; open_shorts: number;
@@ -354,7 +485,7 @@ export interface PacketRead {
blocked: Record<string, string[]>; blocked: Record<string, string[]>;
holder: string; holder: string;
checklist_items: Array<{ group: string; key: string; text: string }>; checklist_items: Array<{ group: string; key: string; text: string }>;
qc_items: unknown[]; qc_items: QcItem[];
flag: string | null; flag: string | null;
/** Phase 5: the database now(), to tick the clock from. */ /** Phase 5: the database now(), to tick the clock from. */
server_now?: string | null; server_now?: string | null;
@@ -364,6 +495,14 @@ export interface PacketRead {
labor?: Labor | null; labor?: Labor | null;
/** Phase 5: the borrowed heat map of a sibling packet, read-only. */ /** Phase 5: the borrowed heat map of a sibling packet, read-only. */
heat_src?: HeatSrc | null; heat_src?: HeatSrc | null;
/** Phase 6: the newest closed inspection, or null. */
qc_last?: QcHistoryRow | null;
/** Phase 6: the current Q.C. folder documents of the unit and the project. */
qc_folder_docs?: QcFolderDoc[];
/** Phase 6: the unit's ITP summary. */
itp?: ItpSummary | null;
/** Phase 6: open ITP hold points for this packet's department. */
itp_hold?: ItpHold | null;
} }
/* ---------- Phase 5: the shop on the packet ---------- */ /* ---------- Phase 5: the shop on the packet ---------- */
@@ -585,6 +724,8 @@ export interface TransitionBody {
spool?: boolean; spool?: boolean;
location?: string; location?: string;
for_sup_id?: string; for_sup_id?: string;
/** Phase 6: QC_REJECT. An NCR number like NCR-2026-0021. */
ncr?: string;
sign?: SignBody; sign?: SignBody;
it_reason?: string; it_reason?: string;
} }
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import type { ItpHold } from "../api/types";
import { makePacket } from "../test/packets";
import { DOC_KINDS, docKindLabel, historyLine, holdItemsText, holdItemText, holdText, isNcr, ncrUrl, NDE_METHODS, NDE_RESULTS, normNcr, noteWithoutNcr, plural, rejectedWelds, showsQcRejected } from "./qc";
const hold = (over: Partial<ItpHold> = {}): ItpHold => ({
no: "L073028-ITP",
count: 2,
rejected: false,
pages: ["WP"],
items: [
{ n: "3.4", t: "Fit-up check", codes: ["W"], rejected: false, ncr: null },
{ n: "4.2", t: "Prior to vessel closing", codes: ["H", "W"], rejected: false, ncr: null },
],
...over,
});
describe("lib/qc", () => {
it("has the closed lists", () => {
expect(NDE_METHODS).toEqual(["None required", "RT", "UT", "MT", "PT"]);
expect(NDE_RESULTS).toEqual(["", "Acceptable", "Rejectable"]);
expect(DOC_KINDS.map(([k]) => k)).toEqual(["nde", "hydro", "dimensional", "pmi", "coating", "coc", "customer", "ncr", "other"]);
expect(docKindLabel("nde")).toBe("NDE report (RT / UT / MT / PT)");
expect(docKindLabel("mystery")).toBe("mystery");
});
it("checks and links NCR numbers", () => {
expect(isNcr("NCR-2026-0021")).toBe(true);
expect(isNcr(" ncr-2026-0021 ")).toBe(true);
expect(isNcr("NCR-26-21")).toBe(false);
expect(isNcr("")).toBe(false);
expect(normNcr(" ncr-2026-0021 ")).toBe("NCR-2026-0021");
expect(ncrUrl("NCR-2026-0021")).toBe("https://ncr.pescoinc.biz/ncrs/NCR-2026-0021");
});
it("lists rejected welds by number with their notes", () => {
expect(rejectedWelds({ "10": { result: "reject" }, "3": { result: "reject", note: " undercut " }, "2": { result: "accept" }, "1": { result: "" } })).toEqual([
{ id: "W3", note: "undercut" },
{ id: "W10", note: null },
]);
expect(rejectedWelds(null)).toEqual([]);
});
it("builds the hold callout text with and without pages", () => {
expect(holdText(hold())).toBe("ITP L073028-ITP: 2 open hold / witness points for this stage.");
expect(holdText(hold({ pages: null }))).toBe("ITP L073028-ITP: 2 open hold / witness points.");
expect(holdText(hold({ rejected: true }))).toBe("ITP L073028-ITP: 2 open hold / witness or rejected points for this stage.");
expect(holdText(hold({ count: 1 }))).toBe("ITP L073028-ITP: 1 open hold / witness point for this stage.");
});
it("names the points, the rejected ones with their NCR", () => {
expect(holdItemText({ n: "4.2", t: "Prior to vessel closing", codes: ["H"], rejected: false, ncr: null })).toBe("4.2 Prior to vessel closing (H)");
expect(holdItemText({ n: "1.3", t: "Fit-up", codes: ["W"], rejected: true, ncr: "NCR-2026-0021" })).toBe("1.3 Fit-up (rejected, NCR NCR-2026-0021)");
expect(holdItemText({ n: "1.3", t: "Fit-up", codes: ["W"], rejected: true, ncr: null })).toBe("1.3 Fit-up (rejected, NCR pending)");
expect(holdItemsText(hold())).toBe("3.4 Fit-up check (W) · 4.2 Prior to vessel closing (H, W)");
const many = hold({ count: 6, items: Array.from({ length: 6 }, (_, i) => ({ n: `5.${i + 1}`, t: "Point", codes: ["W"], rejected: false, ncr: null })) });
expect(holdItemsText(many)).toBe("5.1 Point (W) · 5.2 Point (W) · 5.3 Point (W) · 5.4 Point (W) · and 2 more");
});
it("takes the NCR off the front of a reject note", () => {
expect(noteWithoutNcr("NCR-2026-0021 · undercut at W1", "NCR-2026-0021")).toBe("undercut at W1");
expect(noteWithoutNcr("NCR-2026-0021", "NCR-2026-0021")).toBe("");
expect(noteWithoutNcr("undercut", null)).toBe("undercut");
expect(plural(1, "weld")).toBe("1 weld");
expect(plural(3, "weld")).toBe("3 welds");
});
it("builds the earlier inspection line and knows when the rejected callout shows", () => {
const h = { id: 1, at: null, result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1", "W3"] };
expect(historyLine(h)).toBe("-: REJECTED by Quinn QC: undercut · NCR-2026-0021 · welds W1, W3");
expect(historyLine({ ...h, result: "accepted", note: null, ncr: null, rejected_welds: [] })).toBe("-: ACCEPTED by Quinn QC");
expect(showsQcRejected(makePacket({ status: "review", qc_last: h }))).toBe(true);
expect(showsQcRejected(makePacket({ status: "qc", qc_last: h }))).toBe(false);
expect(showsQcRejected(makePacket({ status: "assigned", qc_last: { ...h, result: "accepted" } }))).toBe(false);
});
});
+112
View File
@@ -0,0 +1,112 @@
// Q.C. on the packet: the closed lists, NCR numbers and the texts the packet page builds. Pure functions.
import type { ItpHold, ItpHoldItem, PacketRead, QcHistoryRow, QcWeldExam } from "../api/types";
import { fmtTime } from "./format";
/** The NDE methods (the old NDE_METHODS). */
export const NDE_METHODS = ["None required", "RT", "UT", "MT", "PT"] as const;
export const NDE_NONE = "None required";
/** The NDE results. Empty means no result yet. */
export const NDE_RESULTS = ["", "Acceptable", "Rejectable"] as const;
/** Packet document kinds and their labels (the old PDOC_TYPES), in the old order. */
export const DOC_KINDS: ReadonlyArray<readonly [string, string]> = [
["nde", "NDE report (RT / UT / MT / PT)"],
["hydro", "Hydrotest report"],
["dimensional", "Dimensional report"],
["pmi", "PMI report"],
["coating", "Coating / paint report"],
["coc", "Certificate of conformance"],
["customer", "Customer inspection report"],
["ncr", "NCR"],
["other", "Other"],
];
/** The label of a document kind. An unknown kind shows as it is. */
export function docKindLabel(kind: string): string {
return DOC_KINDS.find(([k]) => k === kind)?.[1] ?? kind;
}
/** The accept routes besides the shop departments. The en dash is the old text. */
export const ROUTE_DATABOOK = "Clerical – Data Book";
export const ROUTE_SHIPPING = "Shipping";
const RX_NCR = /^NCR-\d{4}-\d{4}$/;
/** "NCR-2026-0021" from " ncr-2026-0021 ". The server does the same. */
export function normNcr(text: string): string {
return text.trim().toUpperCase();
}
/** A well-formed NCR number, after trimming and upper-casing. */
export function isNcr(text: string): boolean {
return RX_NCR.test(normNcr(text));
}
/** The NCR's page in pesco-ncr. */
export function ncrUrl(ncr: string): string {
return `https://ncr.pescoinc.biz/ncrs/${encodeURIComponent(normNcr(ncr))}`;
}
/** "W3" from 3. */
export function weldId(no: number | string): string {
return `W${no}`;
}
/** The rejected welds of a visual exam, by weld number: [{id: "W1", note: "undercut"}, ...]. */
export function rejectedWelds(exam: Record<string, QcWeldExam> | null | undefined): Array<{ id: string; note: string | null }> {
return Object.entries(exam ?? {})
.filter(([, v]) => v?.result === "reject")
.map(([k, v]) => ({ no: Number(k), id: weldId(k), note: v.note?.trim() || null }))
.sort((a, b) => (Number.isFinite(a.no) && Number.isFinite(b.no) ? a.no - b.no : a.id.localeCompare(b.id)))
.map(({ id, note }) => ({ id, note }));
}
/** "2 welds", "1 weld". */
export function plural(n: number, word: string): string {
return `${n} ${word}${n === 1 ? "" : "s"}`;
}
/** The old itpHoldNote head: "ITP L073028-ITP: 2 open hold / witness points for this stage." */
export function holdText(hold: ItpHold): string {
const n = hold.count;
return `ITP ${hold.no ?? ""}: ${n} open hold / witness ${hold.rejected ? "or rejected " : ""}point${n === 1 ? "" : "s"}${hold.pages ? " for this stage" : ""}.`;
}
/** "4.2 Prior to vessel closing (H)" or "(rejected, NCR NCR-2026-0021)" or "(rejected, NCR pending)". */
export function holdItemText(i: ItpHoldItem): string {
const why = i.rejected ? `rejected, NCR ${i.ncr || "pending"}` : i.codes.join(", ");
return `${i.n} ${i.t} (${why})`;
}
/** The first four points joined by " · ", then "· and N more". */
export function holdItemsText(hold: ItpHold, show = 4): string {
const head = hold.items.slice(0, show).map(holdItemText).join(" · ");
const more = hold.items.length - show;
return more > 0 ? `${head} · and ${more} more` : head;
}
/** A reject note without the NCR number in front: the server stores "NCR-2026-0021 · reason". */
export function noteWithoutNcr(note: string | null | undefined, ncr: string | null | undefined): string {
const t = (note ?? "").trim();
if (!ncr) return t;
if (t === ncr) return "";
const lead = `${ncr} · `;
return t.startsWith(lead) ? t.slice(lead.length) : t;
}
/** "<time>: REJECTED by Quinn QC: note · NCR-2026-0021 · welds W1, W3". */
export function historyLine(h: QcHistoryRow): string {
const who = h.inspector || h.by?.display_name || "";
const parts = [noteWithoutNcr(h.note, h.ncr), h.ncr, h.rejected_welds?.length ? `welds ${h.rejected_welds.join(", ")}` : null].filter(
(x): x is string => !!x,
);
return `${fmtTime(h.at)}: ${String(h.result ?? "").toUpperCase()}${who ? ` by ${who}` : ""}${parts.length ? `: ${parts.join(" · ")}` : ""}`;
}
const REJECTED_SHOWN_ON = new Set(["review", "assigned", "in_progress"]);
/** True when the packet shows the "Q.C. rejected" callout: back in the shop after a reject. */
export function showsQcRejected(p: PacketRead): boolean {
return REJECTED_SHOWN_ON.has(p.status) && p.qc_last?.result === "rejected";
}
+27
View File
@@ -0,0 +1,27 @@
import { Link } from "react-router";
import type { PacketRead } from "../api/types";
import { holdItemsText, holdText } from "../lib/qc";
/**
* The open ITP hold, witness or rejected points for this packet's department (the old itpHoldNote). A warning
* only: nothing is blocked by it. Users with the Q.C. screen get a link to the ITP.
*/
export function HoldCallout({ p, qcScreen }: { p: PacketRead; qcScreen: boolean }) {
const hold = p.itp_hold;
if (!hold || p.status === "filed" || !hold.count) return null;
const items = holdItemsText(hold);
return (
<div className="callout warn itphold" role="note">
<b>{holdText(hold)}</b>
{items ? ` ${items}.` : ""} Check with Q.C. before moving past these points.
{qcScreen ? (
<>
{" "}
<Link className="lnk" to={`/qc/units/${p.unit.id}/itp`}>
Open ITP
</Link>
</>
) : null}
</div>
);
}
+18 -6
View File
@@ -20,13 +20,18 @@ import { ChangesTab } from "./ChangesTab";
import { ChecklistTab } from "./ChecklistTab"; import { ChecklistTab } from "./ChecklistTab";
import { HeatMapTab } from "./HeatMapTab"; import { HeatMapTab } from "./HeatMapTab";
import { ShopBar } from "./ShopActions"; import { ShopBar } from "./ShopActions";
import { HoldCallout } from "./HoldCallout";
import { QcBar } from "./QcActions";
import { QcRejectedCallout } from "./QcRejectedCallout";
import { showsQcRejected } from "../lib/qc";
import { QcTab } from "./QcTab";
import { WeldMapTab } from "./WeldMapTab"; import { WeldMapTab } from "./WeldMapTab";
import type { Write } from "../warehouse/PullTable"; import type { Write } from "../warehouse/PullTable";
import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whStage } from "../warehouse/stages"; import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whStage } from "../warehouse/stages";
import { useWhMove } from "../warehouse/useWhMove"; import { useWhMove } from "../warehouse/useWhMove";
type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "workorders" | "changes" | "docs" | "history"; type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "qc" | "workorders" | "changes" | "docs" | "history";
const TABS: readonly Tab[] = ["drawing", "heat", "materials", "checklist", "welds", "workorders", "changes", "docs", "history"]; const TABS: readonly Tab[] = ["drawing", "heat", "materials", "checklist", "welds", "qc", "workorders", "changes", "docs", "history"];
function isTab(v: string | null): v is Tab { function isTab(v: string | null): v is Tab {
return v !== null && (TABS as readonly string[]).includes(v); return v !== null && (TABS as readonly string[]).includes(v);
} }
@@ -317,7 +322,9 @@ export function PacketPage() {
const drawingPages = p.pages.filter((x) => x.type === "drawing"); const drawingPages = p.pages.filter((x) => x.type === "drawing");
const review = coToReview(p); const review = coToReview(p);
const shownTab: Tab = tab === "changes" && !(p.change_orders ?? []).length ? "materials" : (tab ?? (drawingPages.length ? "drawing" : "materials")); const hasQc = !!p.qc || p.qc_history.length > 0;
const hidden = (tab === "changes" && !(p.change_orders ?? []).length) || (tab === "qc" && !hasQc);
const shownTab: Tab = hidden ? "materials" : (tab ?? (drawingPages.length ? "drawing" : "materials"));
// The warehouse short-parts exception and the data book date are not edits of the packet itself. // The warehouse short-parts exception and the data book date are not edits of the packet itself.
const passive = new Set(["wh_receive_short", "set_databook_at"]); const passive = new Set(["wh_receive_short", "set_databook_at"]);
const viewOnly = const viewOnly =
@@ -354,9 +361,10 @@ export function PacketPage() {
{ id: "materials", label: "Materials", count: p.bom.length }, { id: "materials", label: "Materials", count: p.bom.length },
{ id: "checklist", label: "Checklist", ...(p.stage ? { count: ckOpen } : {}) }, { id: "checklist", label: "Checklist", ...(p.stage ? { count: ckOpen } : {}) },
{ id: "welds", label: "Weld map", count: p.welds.length }, { id: "welds", label: "Weld map", count: p.welds.length },
...(hasQc ? [{ id: "qc" as const, label: "Q.C." }] : []),
{ id: "workorders", label: "Work orders", count: p.work_orders.length }, { id: "workorders", label: "Work orders", count: p.work_orders.length },
...(cos ? [{ id: "changes" as const, label: "Changes", count: cos }] : []), ...(cos ? [{ id: "changes" as const, label: "Changes", count: cos }] : []),
{ id: "docs", label: "Documents", count: p.docs.length }, { id: "docs", label: "Documents", count: p.docs.filter((d) => !d.removed).length },
{ id: "history", label: "History" }, { id: "history", label: "History" },
]; ];
const onTabError = (text: string) => setMsg({ ok: false, text }); const onTabError = (text: string) => setMsg({ ok: false, text });
@@ -463,7 +471,8 @@ export function PacketPage() {
{p.removed.by ? ` by ${p.removed.by.display_name}` : ""}. {p.removed.reason ?? ""} {p.removed.by ? ` by ${p.removed.by.display_name}` : ""}. {p.removed.reason ?? ""}
</div> </div>
) : null} ) : null}
{p.return_note ? ( <QcRejectedCallout p={p} />
{p.return_note && !(showsQcRejected(p) && p.return_note === p.qc_last?.note) ? (
<div className="callout warn"> <div className="callout warn">
<b>Returned:</b> {p.return_note} <b>Returned:</b> {p.return_note}
</div> </div>
@@ -652,6 +661,8 @@ export function PacketPage() {
) : null} ) : null}
<ShopBar p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} goTab={(t) => setTab(t)} /> <ShopBar p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} goTab={(t) => setTab(t)} />
<QcBar p={p} busy={busy} mutate={mutate} sub={job} goQc={() => setTab("qc")} />
<HoldCallout p={p} qcScreen={me?.screens.includes("qc") ?? false} />
<div className="tabs" role="tablist" aria-label="Packet"> <div className="tabs" role="tablist" aria-label="Packet">
{tabs.map((t) => ( {tabs.map((t) => (
@@ -692,9 +703,10 @@ export function PacketPage() {
{shownTab === "welds" ? ( {shownTab === "welds" ? (
<WeldMapTab key={p.id} p={p} write={write} mutate={mutate} reload={reload} askReason={askReason} sub={job} myId={me?.user.id ?? null} onError={onTabError} /> <WeldMapTab key={p.id} p={p} write={write} mutate={mutate} reload={reload} askReason={askReason} sub={job} myId={me?.user.id ?? null} onError={onTabError} />
) : null} ) : null}
{shownTab === "qc" ? <QcTab p={p} write={write} mutate={mutate} reload={reload} onError={onTabError} /> : null}
{shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null} {shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null}
{shownTab === "changes" ? <ChangesTab p={p} roles={me?.roles ?? []} sub={job} mutate={mutate} busy={busy} askReason={askReason} /> : null} {shownTab === "changes" ? <ChangesTab p={p} roles={me?.roles ?? []} sub={job} mutate={mutate} busy={busy} askReason={askReason} /> : null}
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} /> : null} {shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} write={write} askReason={askReason} sub={job} /> : null}
{shownTab === "history" ? <HistoryTab p={p} /> : null} {shownTab === "history" ? <HistoryTab p={p} /> : null}
</div> </div>
+170
View File
@@ -0,0 +1,170 @@
import { useState, type FormEvent } from "react";
import type { PacketRead, RouteKind } from "../api/types";
import { Sheet } from "../components/Sheet";
import { isNcr, normNcr, ROUTE_DATABOOK, ROUTE_SHIPPING } from "../lib/qc";
import { useShopDepartments } from "../lib/useDepartments";
import { makeStep } from "./shopUtil";
import type { Mutate } from "./tabs";
/** Signature meanings, as the rules state them. */
export const SIGN_QC_ACCEPT = "Q.C. accept";
export const SIGN_QC_ACCEPT_ROUTE = "Q.C. accept and route";
export const SIGN_QC_REJECT = "Q.C. reject";
/** The precondition message the bar shows as the list of links instead. */
const MISSING_PREFIX = "Q.C. still needs";
const REJECT_MIN = 8;
/** The reject sheet: a reason, an NCR number, or both. */
export function RejectSheet({
sub,
onCancel,
onReject,
}: {
sub: string;
onCancel: () => void;
onReject: (body: { reason?: string; ncr?: string }) => void;
}) {
const [reason, setReason] = useState("");
const [ncr, setNcr] = useState("");
const r = reason.trim();
const n = ncr.trim();
const ncrOk = isNcr(n);
const ok = r.length >= REJECT_MIN || ncrOk;
const submit = (e: FormEvent) => {
e.preventDefault();
if (!ok) return;
onReject({ ...(r ? { reason: r } : {}), ...(ncrOk ? { ncr: normNcr(n) } : {}) });
};
return (
<Sheet title="Reject" sub={sub} onClose={onCancel}>
<div className="small">Give a reason, an NCR number, or both. The supervisor sees it at the top of the packet.</div>
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
Reason / what failed
<textarea aria-label="Reason / what failed" className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
</label>
<label className="f">
NCR number
<input aria-label="NCR number" className="in w220 mono" value={ncr} maxLength={20} placeholder="NCR-2026-0021" onChange={(e) => setNcr(e.target.value)} />
</label>
{n && !ncrOk ? <div className="small pinktxt">Enter an NCR number like NCR-2026-0021.</div> : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pink" type="submit" disabled={!ok}>
Reject to supervisor
</button>
</div>
</form>
</Sheet>
);
}
const has = (p: PacketRead, a: string) => p.can.includes(a) || !!p.blocked[a];
/**
* The Q.C. bar on a packet at Q.C.: what is still needed (links to the Q.C. tab), the route, Accept and Reject.
* The server decides; `can` and `blocked` only shape the buttons. Accept and reject are signed.
*/
export function QcBar({ p, busy, mutate, sub, goQc }: { p: PacketRead; busy: boolean; mutate: Mutate; sub: string; goQc: () => void }) {
const depts = useShopDepartments().filter((d) => d.code !== p.dept.code);
const [route, setRoute] = useState<string>("databook");
const [rejecting, setRejecting] = useState(false);
if (p.status !== "qc") return null;
if (!has(p, "qc_accept") && !has(p, "qc_accept_and_route") && !has(p, "qc_reject")) return null;
const step = makeStep(p, mutate);
const toDept = route.startsWith("DEPT:") ? route.slice(5) : null;
const action = toDept ? "qc_accept_and_route" : "qc_accept";
const canAccept = p.can.includes(action);
const blocked = canAccept ? [] : (p.blocked[action] ?? p.blocked.qc_accept ?? []);
const reason = blocked.join(" ");
const missing = p.qc?.missing ?? [];
const listed = blocked.some((m) => m.startsWith(MISSING_PREFIX)) && missing.length > 0;
const other = listed ? blocked.filter((m) => !m.startsWith(MISSING_PREFIX)) : blocked;
const accept = () => {
if (toDept) void step("qc_accept_and_route", { dept: toDept }, { sign: SIGN_QC_ACCEPT_ROUTE, ok: "Q.C. accepted. Sent to the department." });
else void step("qc_accept", { routed_to: route as RouteKind }, { sign: SIGN_QC_ACCEPT, ok: "Q.C. accepted." });
};
return (
<section className="panel actbox qcbar" aria-label="Q.C. inspection">
<div>
{listed ? (
<span data-testid="qc-missing">
<b>Still needed:</b>{" "}
{missing.map((m, i) => (
<span key={m}>
{i ? " · " : ""}
<button className="lnk" type="button" onClick={goQc}>
{m}
</button>
</span>
))}
</span>
) : canAccept ? (
<>
<b>Everything is recorded.</b> Pick where it goes and accept, or reject it back to the supervisor.
</>
) : (
<>
<b>Inspect the spool and the data book package.</b> Record the checks, the visual exam and NDE on the Q.C. tab.
</>
)}
</div>
{other.length ? <div className="why small">{other.join(" ")}</div> : null}
<div className="acts center">
{has(p, "qc_accept") || has(p, "qc_accept_and_route") ? (
<>
<label className="f qcroute">
Route
<select aria-label="Route" className="in" value={route} onChange={(e) => setRoute(e.target.value)}>
<option value="databook">{ROUTE_DATABOOK}</option>
{depts.map((d) => (
<option key={d.code} value={`DEPT:${d.code}`}>
{d.code} {d.name}
</option>
))}
<option value="shipping">{ROUTE_SHIPPING}</option>
</select>
</label>
<div className="act">
<button className="btn pri" type="button" disabled={!canAccept || busy} title={!canAccept ? reason : undefined} onClick={accept}>
Accept
</button>
</div>
</>
) : null}
{has(p, "qc_reject") ? (
<div className="act">
<button
className="btn pink"
type="button"
disabled={!p.can.includes("qc_reject") || busy}
title={!p.can.includes("qc_reject") ? (p.blocked.qc_reject ?? []).join(" ") : undefined}
onClick={() => setRejecting(true)}
>
Reject
</button>
</div>
) : null}
</div>
{rejecting ? (
<RejectSheet
sub={sub}
onCancel={() => setRejecting(false)}
onReject={(body) => {
setRejecting(false);
void step("qc_reject", body, { sign: SIGN_QC_REJECT, ok: "Q.C. rejected. It is back with the supervisor." });
}}
/>
) : null}
</section>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { PacketRead } from "../api/types";
import { fmtTime } from "../lib/format";
import { ncrUrl, noteWithoutNcr, showsQcRejected } from "../lib/qc";
/** "Q.C. rejected <time> by Quinn QC: <note>", the NCR link and the rejected welds. */
export function QcRejectedCallout({ p }: { p: PacketRead }) {
const h = p.qc_last;
if (!h || !showsQcRejected(p)) return null;
const who = h.inspector || h.by?.display_name || "";
const note = noteWithoutNcr(h.note, h.ncr);
const welds = h.rejected_welds ?? [];
return (
<div className="callout warn qcrej" role="note">
<b>
Q.C. rejected {fmtTime(h.at)}
{who ? ` by ${who}` : ""}:
</b>
{note ? ` ${note}` : ""}
{h.ncr ? (
<>
{" "}
<a className="lnk" href={ncrUrl(h.ncr)} target="_blank" rel="noopener noreferrer">
NCR {h.ncr}
</a>
</>
) : null}
{welds.length ? <div className="small mt4">Rejected welds: {welds.join(", ")}</div> : null}
</div>
);
}
+406
View File
@@ -0,0 +1,406 @@
import { useRef, useState } from "react";
import { ApiError } from "../api/client";
import { packetsApi } from "../api/packets";
import type { PacketRead, QcFields, QcHistoryRow, QcInspection, QcItem, Weld } from "../api/types";
import { errorText, fmtTime } from "../lib/format";
import { historyLine, NDE_METHODS, NDE_NONE, NDE_RESULTS, plural } from "../lib/qc";
import type { Write } from "../warehouse/PullTable";
import type { Mutate } from "./tabs";
type TextField = Exclude<keyof QcFields, "nde_method" | "nde_result">;
/** The earlier inspections, newest last as the old build listed them. */
function Earlier({ rows }: { rows: QcHistoryRow[] }) {
if (!rows.length) return null;
return (
<section className="sec" aria-label="Earlier inspections">
<div className="sec-h">
<h2>Earlier inspections</h2>
</div>
<div className="stack tight">
{rows.map((h) => (
<div key={h.id} className="small">
{historyLine(h)}
</div>
))}
</div>
</section>
);
}
function groupsOf(items: readonly QcItem[]): Array<{ group: string; items: QcItem[] }> {
const out: Array<{ group: string; items: QcItem[] }> = [];
for (const it of items) {
const g = out.find((x) => x.group === it.group);
if (g) g.items.push(it);
else out.push({ group: it.group, items: [it] });
}
return out;
}
function weldDetail(w: Weld): string {
const parts = [
w.items.length ? `items ${w.items.join(" + ")}` : null,
w.wo_base ? `${w.wo_base}${w.piece ? ` #${w.piece}` : ""}` : null,
w.process,
w.stamp,
].filter((x): x is string => !!x);
return parts.join(" · ");
}
/**
* The Q.C. inspection (the old qcTab): the supervisor's send-off, the inspector, the ten checks, the visual exam
* per weld, NDE and hardness, notes, and the earlier inspections. Editable only with `edit_qc`. Each check and
* each weld is its own PUT; the other fields save on blur with a field-level base.
*/
export function QcTab({
p,
write,
mutate,
reload,
onError,
}: {
p: PacketRead;
write: Write;
mutate: Mutate;
reload: () => Promise<void>;
onError: (text: string) => void;
}) {
const q = p.qc;
if (!q) {
return (
<div className="stack">
<div className="empty slim">No open Q.C. inspection.</div>
<Earlier rows={p.qc_history} />
</div>
);
}
return <Inspection key={q.id} p={p} q={q} write={write} mutate={mutate} reload={reload} onError={onError} />;
}
function Inspection({
p,
q,
write,
mutate,
reload,
onError,
}: {
p: PacketRead;
q: QcInspection;
write: Write;
mutate: Mutate;
reload: () => Promise<void>;
onError: (text: string) => void;
}) {
const ed = p.can.includes("edit_qc");
const [pending, setPending] = useState<Record<string, boolean>>({});
const [vtPending, setVtPending] = useState<Record<string, boolean>>({});
const [draft, setDraft] = useState<Partial<Record<TextField, string>>>({});
const [notes, setNotes] = useState<Record<string, string>>({});
const saving = useRef(new Set<string>());
const fail = async (err: unknown) => {
if (err instanceof ApiError && (err.status === 409 || err.status === 412)) await reload();
onError(errorText(err));
};
const tick = async (key: string, done: boolean) => {
setPending((o) => ({ ...o, [key]: done }));
const r = await write((g) => packetsApi.qcCheck(p.id, key, done, g));
setPending((o) => {
const n = { ...o };
delete n[key];
return n;
});
if (!r.ok && r.err !== undefined) await fail(r.err);
};
const exam = async (no: number, body: { result?: "accept" | "reject" | ""; note?: string }) => {
const k = String(no);
setVtPending((o) => ({ ...o, [k]: true }));
const r = await write((g) => packetsApi.qcWeld(p.id, no, body, g));
setVtPending((o) => {
const n = { ...o };
delete n[k];
return n;
});
if (!r.ok && r.err !== undefined) await fail(r.err);
return r.ok;
};
const saveNote = async (no: number) => {
const k = String(no);
const now = notes[k];
if (now === undefined) return;
const was = q.weld_exam[k]?.note ?? "";
if (now.trim() !== was.trim()) await exam(no, { note: now.trim() });
setNotes((o) => {
const n = { ...o };
delete n[k];
return n;
});
};
const server = (k: keyof QcFields) => q[k] ?? "";
const shown = (k: TextField) => draft[k] ?? server(k);
const setField = async (k: keyof QcFields, value: string) => {
if (saving.current.has(k)) return;
const was = q[k] ?? null;
const now = value.trim() || null;
if (now === was) return;
saving.current.add(k);
try {
await mutate((g) => packetsApi.qcPatch(p.id, { set: { [k]: now }, base: { [k]: was }, ...g }));
} finally {
saving.current.delete(k);
}
};
const saveText = async (k: TextField) => {
const now = draft[k];
if (now === undefined) return;
await setField(k, now);
// On a conflict the page reloads; the field then shows the value someone else saved.
setDraft((d) => ({ ...d, [k]: undefined }));
};
const textInput = (k: TextField, label: string, ph: string, max: number, cls = "in") => (
<label className="f" key={k}>
{label}
<input
aria-label={label}
className={cls}
value={shown(k)}
maxLength={max}
placeholder={ph}
onChange={(e) => setDraft((d) => ({ ...d, [k]: e.target.value }))}
onBlur={() => void saveText(k)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void saveText(k);
}
}}
/>
</label>
);
const groups = groupsOf(p.qc_items);
const welds = p.welds;
const method = q.nde_method || NDE_NONE;
const hardLabel = q.nace_label ? "Hardness (NACE)" : "Hardness";
return (
<div className="stack qctab">
<div className="callout">
<b>Sent {fmtTime(q.sent_at)}</b>: data book package {q.book ? "ready" : "not confirmed"}, spool {q.spool ? "ready" : "not confirmed"}. Spool location:{" "}
<b>{q.location || "-"}</b>
{q.sent_note ? <div className="small mt4">Note: {q.sent_note}</div> : null}
</div>
<div className="frow qcinsp">
{ed ? (
<>
{textInput("inspector", "Inspector", "Name", 80)}
{textInput("stamp", "Stamp / ID", "ID", 20)}
</>
) : (
<div className="small">
<span className="muted">Inspector: </span>
<b className="near">
{q.inspector || "-"}
{q.stamp ? ` [${q.stamp}]` : ""}
</b>
</div>
)}
</div>
{groups.map((g) => (
<section className="sec" key={g.group} aria-label={g.group}>
<div className="sec-h">
<h2>{g.group}</h2>
</div>
<div className="ck">
{g.items.map((i) => {
const c = q.checks[i.key];
const done = pending[i.key] ?? !!c?.done;
const by = c?.done ? [c.stamp || c.by_name, fmtTime(c.at)].filter(Boolean).join(" · ") : "";
return (
<label key={i.key} className={`ck-item${done ? " done" : ""}`} aria-disabled={!ed}>
<input
type="checkbox"
aria-label={i.text}
checked={done}
disabled={!ed || pending[i.key] !== undefined}
onChange={(e) => void tick(i.key, e.target.checked)}
/>
<span className="t">
{i.text}
{i.hint ? <span className="hint small muted mono">{i.hint}</span> : null}
</span>
<span className="by">{by}</span>
</label>
);
})}
</div>
</section>
))}
<section className="sec" aria-label="Visual weld inspection">
<div className="sec-h">
<h2>Visual weld inspection</h2>
<span className="ct">{plural(welds.length, "weld")} · open the Weld map tab to find them</span>
</div>
<div className="tw">
<table>
<thead>
<tr>
<th>Weld</th>
<th>Detail</th>
<th>Result</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{welds.length ? (
welds.map((w) => {
const k = String(w.weld_no);
const v = q.weld_exam[k] ?? {};
const res = v.result || "";
const busy = !!vtPending[k];
return (
<tr key={k} aria-label={`Weld W${w.weld_no}`}>
<td>
<span className="balloon pinkb">{w.weld_no}</span>
</td>
<td>
{[w.type, w.size].filter(Boolean).join(" ")}
<div className="sub">{weldDetail(w)}</div>
</td>
<td>
{ed ? (
<div className="vtbs">
<button
type="button"
className="vtb acc"
aria-pressed={res === "accept"}
disabled={busy}
onClick={() => void exam(w.weld_no, { result: res === "accept" ? "" : "accept" })}
>
Accept
</button>
<button
type="button"
className="vtb rej"
aria-pressed={res === "reject"}
disabled={busy}
onClick={() => void exam(w.weld_no, { result: res === "reject" ? "" : "reject" })}
>
Reject
</button>
</div>
) : res ? (
<span className={`pill ${res === "accept" ? "st-in_progress" : "st-review"}`}>{res}</span>
) : (
<span className="muted small">-</span>
)}
</td>
<td>
{ed ? (
<input
className="in"
aria-label={`W${w.weld_no} note`}
value={notes[k] ?? v.note ?? ""}
maxLength={300}
placeholder={res === "reject" ? "Defect (undercut, porosity…)" : "Note"}
onChange={(e) => setNotes((o) => ({ ...o, [k]: e.target.value }))}
onBlur={() => void saveNote(w.weld_no)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void saveNote(w.weld_no);
}
}}
/>
) : (
<span className="small">{v.note ?? ""}</span>
)}
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={4} className="muted">
No welds on the weld map.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<section className="sec" aria-label="NDE & testing">
<div className="sec-h">
<h2>NDE &amp; testing</h2>
</div>
{ed ? (
<>
<div className="frow qcnde">
<label className="f">
NDE method
<select aria-label="NDE method" className="in" value={method} onChange={(e) => void setField("nde_method", e.target.value)}>
{NDE_METHODS.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</label>
<label className="f">
NDE result
<select aria-label="NDE result" className="in" value={q.nde_result ?? ""} onChange={(e) => void setField("nde_result", e.target.value)}>
{NDE_RESULTS.map((m) => (
<option key={m} value={m}>
{m || "-"}
</option>
))}
</select>
</label>
{textInput("nde_report", "Report no.", "Optional", 60)}
{textInput("hardness", hardLabel, "e.g. 198 HB", 40)}
</div>
<label className="f qcnotes">
Inspection notes
<textarea
aria-label="Inspection notes"
className="in"
value={shown("notes")}
maxLength={2000}
onChange={(e) => setDraft((d) => ({ ...d, notes: e.target.value }))}
onBlur={() => void saveText("notes")}
/>
</label>
</>
) : (
<div className="stack tight">
<div className="small">
<span className="muted">NDE: </span>
{[method, q.nde_result, q.nde_report].filter(Boolean).join(" · ")}
</div>
<div className="small">
<span className="muted">{hardLabel}: </span>
{q.hardness || "-"}
</div>
{q.notes ? <div className="small">{q.notes}</div> : null}
</div>
)}
</section>
<Earlier rows={p.qc_history} />
</div>
);
}
+3 -4
View File
@@ -291,16 +291,15 @@ function ReviewBox({
const depts = useShopDepartments().filter((d) => d.code !== p.dept.code); const depts = useShopDepartments().filter((d) => d.code !== p.dept.code);
const [book, setBook] = useState(false); const [book, setBook] = useState(false);
const [spool, setSpool] = useState(false); const [spool, setSpool] = useState(false);
const [loc, setLoc] = useState( // After a Q.C. reject the inspection is closed into qc_last; the open inspection (qc) is null.
String((p.qc as { location?: string } | null)?.location ?? ""), const [loc, setLoc] = useState(String(p.qc?.location ?? p.qc_last?.location ?? ""));
);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [ret, setRet] = useState(""); const [ret, setRet] = useState("");
const [route, setRoute] = useState(""); const [route, setRoute] = useState("");
const routeTo = depts.some((d) => d.code === route) const routeTo = depts.some((d) => d.code === route)
? route ? route
: (depts[0]?.code ?? ""); : (depts[0]?.code ?? "");
const rejected = (p.qc as { result?: string } | null)?.result === "rejected"; const rejected = p.qc_last?.result === "rejected";
const ready = book && spool && !!loc.trim(); const ready = book && spool && !!loc.trim();
const returnIt = async () => { const returnIt = async () => {
+316
View File
@@ -0,0 +1,316 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { PacketRead, QcInspection } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch } from "../test/fetchMock";
import { makePacket } from "../test/packets";
const DEPTS = [...DEPARTMENTS, { code: "09", slug: "Hydro", name: "Hydro", kind: "shop", stations: [] }];
const ITEMS = [
{ group: "Data book package", key: "db_heats", text: "Heat number recorded for every BOM item", hint: "1/2 recorded" },
{ group: "Physical spool", key: "ph_dim", text: "Dimensions verified against CI-DWG-1 Rev 0", hint: null },
];
function inspection(over: Partial<QcInspection> = {}): QcInspection {
return {
id: 9,
sent_at: "2026-10-17T08:00:00Z",
book: true,
spool: true,
location: "Bay 2",
sent_note: "Rush",
nde_method: "None required",
nde_result: null,
nde_report: null,
hardness: null,
inspector: null,
stamp: null,
notes: null,
result: null,
result_at: null,
result_note: null,
row_version: 1,
checks: {},
weld_exam: {},
ncr: null,
nace_label: false,
missing: ["2 inspection items", "1 weld without a visual result", "inspector name"],
...over,
};
}
function qcPacket(over: Partial<PacketRead> = {}): PacketRead {
return makePacket({
status: "qc",
holder: "Q.C.",
qc: inspection(),
qc_items: ITEMS,
welds: [
{ weld_no: 1, page: 1, fx: 0.1, fy: 0.1, wo_base: "L999001", piece: 1, items: [1, 2], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75", at: null, row_version: 1, welder: null },
],
can: ["edit_qc", "qc_reject", "doc_add", "doc_remove"],
blocked: { qc_accept: ["Q.C. still needs: 2 inspection items, 1 weld without a visual result, inspector name."], qc_accept_and_route: ["Q.C. still needs: 2 inspection items."] },
...over,
});
}
function base(packet: PacketRead, roles = ["qc"], screens = ["qc"]) {
return {
"/api/auth/me": { body: makeMe({ roles, screens, user: { id: "quinn", display_name: "Quinn QC" } }) },
"/api/departments": { body: DEPTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
};
}
async function sign(secret = "pw-1") {
const sheet = await screen.findByRole("dialog", { name: "Sign" });
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: secret } });
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
}
describe("Q.C. tab", () => {
it("each tick PUTs its key; the weld toggles PUT the result", async () => {
const p = qcPacket();
const after = qcPacket({ version: 4, qc: inspection({ checks: { db_heats: { done: true, at: "2026-10-17T09:00:00Z", stamp: "Q1" } } }) });
const fetch = mockFetch({
...base(p),
"PUT /api/packets/1/qc/checks/db_heats": { body: after },
"PUT /api/packets/1/qc/welds/1": { body: qcPacket({ version: 5, qc: inspection({ weld_exam: { "1": { result: "accept" } } }) }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Q\.C\./ }));
expect(screen.getByText(/data book package ready, spool ready/)).toHaveTextContent("Spool location: Bay 2");
expect(screen.getByText("1/2 recorded")).toBeInTheDocument();
const box = screen.getByRole("checkbox", { name: "Heat number recorded for every BOM item" });
fireEvent.click(box);
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Heat number recorded for every BOM item" })).toBeChecked());
expect(callsTo(fetch, "PUT", "/api/packets/1/qc/checks/db_heats")).toEqual([{ done: true }]);
expect(screen.getByText(/^Q1 · /)).toBeInTheDocument();
const row = screen.getByRole("row", { name: "Weld W1" });
expect(within(row).getByText("items 1 + 2 · L999001 #1 · GMAW · 75")).toBeInTheDocument();
fireEvent.click(within(row).getByRole("button", { name: "Accept" }));
await waitFor(() => expect(within(screen.getByRole("row", { name: "Weld W1" })).getByRole("button", { name: "Accept" })).toHaveAttribute("aria-pressed", "true"));
expect(callsTo(fetch, "PUT", "/api/packets/1/qc/welds/1")).toEqual([{ result: "accept" }]);
expect(screen.getByText("1 weld · open the Weld map tab to find them")).toBeInTheDocument();
});
it("saves the inspector on blur with its base", async () => {
const fetch = mockFetch({ ...base(qcPacket()), "PATCH /api/packets/1/qc": { body: qcPacket({ version: 4, qc: inspection({ inspector: "Quinn QC" }) }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Q\.C\./ }));
const f = screen.getByLabelText("Inspector");
fireEvent.change(f, { target: { value: "Quinn QC" } });
fireEvent.blur(f);
await waitFor(() => expect(callsTo(fetch, "PATCH", "/api/packets/1/qc")).toEqual([{ set: { inspector: "Quinn QC" }, base: { inspector: null } }]));
});
it("is read-only without edit_qc and lists earlier inspections", async () => {
const p = qcPacket({
can: [],
blocked: {},
qc_history: [{ id: 1, at: "2026-10-16T08:00:00Z", result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1", "W3"] }],
});
mockFetch(base(p, ["shop:03:supervisor"], ["supervisor"]));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Q\.C\./ }));
for (const box of screen.getAllByRole("checkbox")) expect(box).toBeDisabled();
expect(screen.queryByRole("button", { name: "Accept" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("Inspector")).not.toBeInTheDocument();
expect(screen.getByText(/REJECTED by Quinn QC: undercut · NCR-2026-0021 · welds W1, W3$/)).toBeInTheDocument();
});
it("is not shown without an inspection or history", async () => {
mockFetch(base(makePacket()));
renderApp("/packets/1");
await screen.findByRole("tab", { name: /^Materials/ });
expect(screen.queryByRole("tab", { name: /^Q\.C\./ })).not.toBeInTheDocument();
});
});
describe("Q.C. bar", () => {
it("lists what is still needed and keeps Accept disabled", async () => {
mockFetch(base(qcPacket()));
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
expect(within(bar).getByTestId("qc-missing")).toHaveTextContent("Still needed: 2 inspection items · 1 weld without a visual result · inspector name");
const accept = within(bar).getByRole("button", { name: "Accept" });
expect(accept).toBeDisabled();
expect(accept).toHaveAttribute("title", "Q.C. still needs: 2 inspection items, 1 weld without a visual result, inspector name.");
const route = within(bar).getByLabelText("Route");
expect(within(route).getAllByRole("option").map((o) => o.textContent)).toEqual(["Clerical – Data Book", "09 Hydro", "14 Electrical", "Shipping"]);
fireEvent.click(within(bar).getByRole("button", { name: "inspector name" }));
expect(await screen.findByLabelText("Inspector")).toBeInTheDocument();
});
it("accepts to the data book after the signature", async () => {
const p = qcPacket({ qc: inspection({ missing: [] }), can: ["edit_qc", "qc_accept", "qc_accept_and_route", "qc_reject"], blocked: {} });
const fetch = mockFetch({
...base(p),
"POST /api/packets/1/transitions": { body: makePacket({ status: "complete", version: 6, routed_to: "databook", can: [], blocked: {}, holder: "Clerical" }) },
});
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
expect(within(bar).queryByTestId("qc-missing")).not.toBeInTheDocument();
fireEvent.click(within(bar).getByRole("button", { name: "Accept" }));
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. accept")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "qc_accept", expect: { status: "qc", dept: "03" }, routed_to: "databook", sign: { factor: "password", secret: "pw-1" } },
]);
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Routed"));
});
it("accepts and routes to a department", async () => {
const p = qcPacket({ qc: inspection({ missing: [] }), can: ["qc_accept", "qc_accept_and_route", "qc_reject"], blocked: {} });
const fetch = mockFetch({ ...base(p), "POST /api/packets/1/transitions": { body: makePacket({ status: "ready", version: 6, can: [], blocked: {} }) } });
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
fireEvent.change(within(bar).getByLabelText("Route"), { target: { value: "DEPT:09" } });
fireEvent.click(within(bar).getByRole("button", { name: "Accept" }));
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. accept and route")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "qc_accept_and_route", expect: { status: "qc", dept: "03" }, dept: "09", sign: { factor: "password", secret: "pw-1" } },
]);
});
it("the reject sheet enables on an NCR alone and sends it upper-cased", async () => {
const fetch = mockFetch({ ...base(qcPacket()), "POST /api/packets/1/transitions": { body: makePacket({ status: "review", version: 6, can: [], blocked: {} }) } });
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
fireEvent.click(within(bar).getByRole("button", { name: "Reject" }));
const sheet = await screen.findByRole("dialog", { name: "Reject" });
expect(within(sheet).getByText("Give a reason, an NCR number, or both. The supervisor sees it at the top of the packet.")).toBeInTheDocument();
const submit = within(sheet).getByRole("button", { name: "Reject to supervisor" });
expect(submit).toBeDisabled();
fireEvent.change(within(sheet).getByLabelText("Reason / what failed"), { target: { value: "short" } });
expect(submit).toBeDisabled();
const ncr = within(sheet).getByLabelText("NCR number");
expect(ncr).toHaveAttribute("placeholder", "NCR-2026-0021");
fireEvent.change(within(sheet).getByLabelText("Reason / what failed"), { target: { value: "" } });
fireEvent.change(ncr, { target: { value: "ncr-2026-0021" } });
expect(submit).toBeEnabled();
fireEvent.click(submit);
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. reject")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "qc_reject", expect: { status: "qc", dept: "03" }, ncr: "NCR-2026-0021", sign: { factor: "password", secret: "pw-1" } },
]);
});
});
describe("Callouts", () => {
const hold = {
no: "L999001-ITP",
count: 5,
rejected: true,
pages: ["WP"] as string[] | null,
items: [
{ n: "3.4", t: "Fit-up", codes: ["W"], rejected: false, ncr: null },
{ n: "4.2", t: "Prior to vessel closing", codes: ["H"], rejected: false, ncr: null },
{ n: "4.4", t: "Hydro", codes: ["H"], rejected: true, ncr: "NCR-2026-0021" },
{ n: "4.5", t: "Final", codes: ["H"], rejected: true, ncr: null },
{ n: "5.1", t: "Paint", codes: ["W"], rejected: false, ncr: null },
],
};
it("the hold callout names the points and links Q.C. users to the ITP", async () => {
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, itp_hold: hold })));
renderApp("/packets/1");
const note = await screen.findByText(/^ITP L999001-ITP:/);
expect(note).toHaveTextContent("ITP L999001-ITP: 5 open hold / witness or rejected points for this stage.");
const box = note.closest(".itphold");
expect(box).toHaveTextContent(
"3.4 Fit-up (W) · 4.2 Prior to vessel closing (H) · 4.4 Hydro (rejected, NCR NCR-2026-0021) · 4.5 Final (rejected, NCR pending) · and 1 more. Check with Q.C. before moving past these points.",
);
expect(screen.getByRole("link", { name: "Open ITP" })).toHaveAttribute("href", "/qc/units/11/itp");
});
it("the hold callout without pages says nothing about the stage, and no link without the qc screen", async () => {
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, itp_hold: { ...hold, pages: null, rejected: false, count: 1, items: hold.items.slice(0, 1) } }), ["shop:03:supervisor"], ["supervisor"]));
renderApp("/packets/1");
expect(await screen.findByText(/^ITP L999001-ITP:/)).toHaveTextContent(/^ITP L999001-ITP: 1 open hold \/ witness point\.$/);
expect(screen.queryByRole("link", { name: "Open ITP" })).not.toBeInTheDocument();
});
it("the Q.C. rejected callout shows the note, the NCR link and the welds", async () => {
const p = makePacket({
status: "review",
can: [],
blocked: {},
return_note: "NCR-2026-0021 · undercut at W1",
qc_history: [{ id: 1, at: "2026-10-16T08:00:00Z", result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut at W1", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1"] }],
qc_last: { id: 1, at: "2026-10-16T08:00:00Z", result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut at W1", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1"] },
});
mockFetch(base(p, ["shop:03:supervisor"], ["supervisor"]));
renderApp("/packets/1");
const note = await screen.findByText(/^Q\.C\. rejected/);
const box = note.closest(".callout") as HTMLElement;
expect(box).toHaveTextContent(/by Quinn QC: undercut at W1 NCR NCR-2026-0021Rejected welds: W1$/);
const link = within(box).getByRole("link", { name: "NCR NCR-2026-0021" });
expect(link).toHaveAttribute("href", "https://ncr.pescoinc.biz/ncrs/NCR-2026-0021");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
expect(screen.queryByText(/^Returned:/)).not.toBeInTheDocument();
});
});
describe("Documents tab", () => {
const docs = [
{ id: 4, asset_id: "a4", kind: "nde", kind_label: "NDE report (RT / UT / MT / PT)", title: "RT report W1-W4", note: "RPT-2210, accept", file_name: "rt.pdf", size: 900, added_at: "2026-10-17T08:00:00Z", added_by: { id: "quinn", display_name: "Quinn QC" }, url: "/_blob/a4", removed: null },
{ id: 5, asset_id: "a5", kind: "other", kind_label: "Other", title: "Old note", file_name: "o.pdf", added_at: "2026-10-16T08:00:00Z", added_by: null, url: "/_blob/a5", removed: { at: "2026-10-16T09:00:00Z", by: { id: "quinn", display_name: "Quinn QC" }, reason: "wrong packet" } },
];
it("takes a document out with the reason, and lists the taken-out rows", async () => {
const p = qcPacket({ docs, qc_folder_docs: [{ id: 1, type: "WPS / PQR", title: "WPS-101", rev: "2", url: "/_blob/w1", scope: "project", added_at: null }] });
const fetch = mockFetch({ ...base(p), "DELETE /api/packets/1/docs/4": { body: qcPacket({ version: 4 }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
expect(screen.getByRole("tab", { name: /^Documents/ })).toHaveTextContent("Documents1");
const sec = screen.getByRole("region", { name: "Documents for the data book" });
expect(within(sec).getByText("1 on this packet")).toBeInTheDocument();
expect(within(sec).getByText(/rt\.pdf · added by Quinn QC .* · RPT-2210, accept/)).toBeInTheDocument();
expect(within(sec).getByRole("button", { name: "Add document" })).toBeInTheDocument();
fireEvent.click(within(sec).getByRole("button", { name: "Show 1 taken out" }));
expect(within(sec).getByText(/Taken out of the data book by Quinn QC .*: wrong packet/)).toBeInTheDocument();
const folder = screen.getByRole("region", { name: "Also in the data book from the Q.C. folder" });
expect(within(folder).getByText("from the project")).toBeInTheDocument();
fireEvent.click(within(sec).getByRole("button", { name: "Take out of data book" }));
const sheet = await screen.findByRole("dialog", { name: "Take out of the data book" });
fireEvent.change(within(sheet).getByLabelText("Reason"), { target: { value: "wrong packet" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Take out" }));
await waitFor(() => expect(callsTo(fetch, "DELETE", "/api/packets/1/docs/4")).toEqual([{ reason: "wrong packet" }]));
});
it("adds a document: uploads, then posts the kind, title and note", async () => {
const fetch = mockFetch({
...base(qcPacket()),
"POST /api/assets": { status: 201, body: { id: "a9", mime: "application/pdf", size: 9, sha256: "00", pages: 1, original_name: "rt.pdf", url: "/_blob/a9" } },
"POST /api/packets/1/docs": { body: qcPacket({ version: 4, docs }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
fireEvent.click(screen.getByRole("button", { name: "Add document" }));
const sheet = await screen.findByRole("dialog", { name: "Add a document" });
expect(within(within(sheet).getByLabelText("Type")).getAllByRole("option")).toHaveLength(9);
fireEvent.change(within(sheet).getByLabelText("Title"), { target: { value: "RT report W1-W4" } });
fireEvent.change(within(sheet).getByLabelText("Note (optional)"), { target: { value: "RPT-2210, accept" } });
const file = new File(["%PDF-1.4\n"], "rt.pdf", { type: "application/pdf" });
fireEvent.change(within(sheet).getByLabelText("File (PDF, PNG or JPG, up to 25 MB)"), { target: { files: [file] } });
fireEvent.click(within(sheet).getByRole("button", { name: "Add to packet" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Add a document" })).not.toBeInTheDocument());
expect(callsTo(fetch, "POST", "/api/packets/1/docs")).toEqual([{ asset_id: "a9", kind: "nde", title: "RT report W1-W4", note: "RPT-2210, accept" }]);
});
it("shows no add button without doc_add and an empty folder text", async () => {
mockFetch(base(qcPacket({ can: [], blocked: {} }), ["warehouse"], ["warehouse"]));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
expect(screen.queryByRole("button", { name: "Add document" })).not.toBeInTheDocument();
expect(screen.getByText("No ITP or Q.C. folder documents for this unit yet. Q.C. adds them under Project folders on the Q.C. screen.")).toBeInTheDocument();
});
});
+257 -107
View File
@@ -2,8 +2,11 @@ import QRCode from "qrcode";
import { useEffect, useRef, useState, type FormEvent } from "react"; import { useEffect, useRef, useState, type FormEvent } from "react";
import { assetsApi } from "../api/assets"; import { assetsApi } from "../api/assets";
import { packetsApi } from "../api/packets"; import { packetsApi } from "../api/packets";
import type { BomLine, Guarded, PacketRead, WorkOrder } from "../api/types"; import type { BomLine, Guarded, LogRow, PacketDoc, PacketRead, QcHistoryRow, WorkOrder } from "../api/types";
import type { ReasonOptions } from "../components/ReasonSheet";
import { Sheet } from "../components/Sheet";
import { errorText, fmtTime } from "../lib/format"; import { errorText, fmtTime } from "../lib/format";
import { DOC_KINDS, docKindLabel, ncrUrl, noteWithoutNcr } from "../lib/qc";
import { useLoad } from "../lib/useLoad"; import { useLoad } from "../lib/useLoad";
import { MtrViewer } from "../warehouse/MtrViewer"; import { MtrViewer } from "../warehouse/MtrViewer";
import { PullTable, ShortChip, type Write } from "../warehouse/PullTable"; import { PullTable, ShortChip, type Write } from "../warehouse/PullTable";
@@ -331,124 +334,49 @@ export function WorkOrdersTab({ p }: { p: PacketRead }) {
/* ---------- Documents ---------- */ /* ---------- Documents ---------- */
const DOC_KINDS: ReadonlyArray<[string, string]> = [ type AskReason = (opts: ReasonOptions) => Promise<string | null>;
["mtr", "Material test report"],
["nde", "NDE report"],
["procedure", "Procedure (WPS / PQR)"],
["photo", "Photo"],
["other", "Other"],
];
const kindLabel = (k: string) => DOC_KINDS.find(([v]) => v === k)?.[1] ?? k; const openBlob = (url: string) => {
window.open(url, "_blank", "noopener,noreferrer");
};
/** Documents attached to the packet. Clerical and Q.C. add and remove them. */ /** The add sheet: type, title, note and the file. Uploads, then adds; errors stay in the sheet. */
export function DocumentsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) { function AddDocSheet({ p, sub, write, onDone, onCancel }: { p: PacketRead; sub: string; write: Write; onDone: () => void; onCancel: () => void }) {
const canAdd = p.can.includes("doc_add"); const [kind, setKind] = useState(DOC_KINDS[0]?.[0] ?? "other");
const canRemove = p.can.includes("doc_remove");
const fileRef = useRef<HTMLInputElement>(null);
const [kind, setKind] = useState("mtr");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [note, setNote] = useState("");
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false); const [working, setWorking] = useState(false);
const [confirm, setConfirm] = useState<number | null>(null);
const add = async (e: FormEvent) => { const add = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
if (!file) { if (!file) {
setError("Choose a file."); setError("Choose the file to add.");
return; return;
} }
setUploading(true); setWorking(true);
try { try {
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "document" }); const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "document" });
const ok = await mutate((g) => packetsApi.addDoc(p.id, { asset_id: asset.id, kind, title: title.trim() || file.name, ...g }), { ok: "Document added" }); const t = title.trim();
if (ok) { const n = note.trim();
setTitle(""); const r = await write((g) => packetsApi.addDoc(p.id, { asset_id: asset.id, kind, ...(t ? { title: t } : {}), ...(n ? { note: n } : {}), ...g }));
setFile(null); if (r.ok) onDone();
if (fileRef.current) fileRef.current.value = ""; else if (r.err !== undefined) setError(errorText(r.err));
}
} catch (err) { } catch (err) {
setError(errorText(err)); setError(errorText(err));
} finally { } finally {
setUploading(false); setWorking(false);
} }
}; };
return ( return (
<div className="stack"> <Sheet title="Add a document" sub={sub} onClose={onCancel}>
{p.docs.length ? ( <form className="stack" onSubmit={add} noValidate>
<div className="tw">
<table>
<thead>
<tr>
<th>Title</th>
<th>Kind</th>
<th>Added</th>
<th>By</th>
<th />
</tr>
</thead>
<tbody>
{p.docs.map((d) => (
<tr key={d.id}>
<td>
<a className="lnk" href={d.url} target="_blank" rel="noopener noreferrer">
{d.title || "Untitled"}
</a>
</td>
<td className="small">{kindLabel(d.kind)}</td>
<td className="small mono">{fmtTime(d.added_at)}</td>
<td className="small">{d.added_by?.display_name ?? "-"}</td>
<td className="nowrap">
{canRemove ? (
confirm === d.id ? (
<>
<button
className="btn sm rejb"
type="button"
disabled={busy}
onClick={() => void mutate((g) => packetsApi.removeDoc(p.id, d.id, g), { ok: "Document removed" }).then(() => setConfirm(null))}
>
Confirm remove
</button>{" "}
<button className="btn sm ghost" type="button" onClick={() => setConfirm(null)}>
Cancel
</button>
</>
) : (
<button className="btn sm ghost" type="button" onClick={() => setConfirm(d.id)}>
Remove
</button>
)
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="empty slim">No documents on this packet.</div>
)}
{canAdd ? (
<form className="panel stack" onSubmit={add} noValidate aria-labelledby="docadd-h">
<h3 id="docadd-h">Add a document</h3>
<div className="frow">
<label className="f"> <label className="f">
File (PDF, PNG or JPEG) Type
<input <select aria-label="Type" className="in" value={kind} onChange={(e) => setKind(e.target.value)}>
ref={fileRef}
className="in"
type="file"
accept="application/pdf,image/png,image/jpeg"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
<label className="f">
Kind
<select className="in" value={kind} onChange={(e) => setKind(e.target.value)}>
{DOC_KINDS.map(([v, l]) => ( {DOC_KINDS.map(([v, l]) => (
<option key={v} value={v}> <option key={v} value={v}>
{l} {l}
@@ -458,34 +386,256 @@ export function DocumentsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutat
</label> </label>
<label className="f"> <label className="f">
Title Title
<input className="in" value={title} maxLength={160} onChange={(e) => setTitle(e.target.value)} /> <input aria-label="Title" className="in" value={title} maxLength={140} placeholder="e.g. RT report, welds W1-W6" onChange={(e) => setTitle(e.target.value)} />
</label>
<label className="f">
Note (optional)
<input aria-label="Note (optional)" className="in" value={note} maxLength={300} placeholder="Report no., inspector, result" onChange={(e) => setNote(e.target.value)} />
</label>
<label className="f">
File (PDF, PNG or JPG, up to 25 MB)
<input
aria-label="File (PDF, PNG or JPG, up to 25 MB)"
className="in"
type="file"
accept="application/pdf,image/png,image/jpeg"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label> </label>
</div>
{error ? ( {error ? (
<div className="callout warn flush" role="alert"> <div className="callout warn flush" role="alert">
{error} {error}
</div> </div>
) : null} ) : null}
<div> <div className="sheet-acts">
<button className="btn pri" type="submit" disabled={busy || uploading}> <button className="btn ghost" type="button" onClick={onCancel}>
{uploading ? "Uploading" : "Add document"} Cancel
</button>
<button className="btn pri" type="submit" disabled={working}>
{working ? "Uploading" : "Add to packet"}
</button> </button>
</div> </div>
</form> </form>
</Sheet>
);
}
function DocRow({ d, old, canRemove, busy, onOut }: { d: PacketDoc; old: boolean; canRemove: boolean; busy: boolean; onOut: () => void }) {
const label = d.kind_label || docKindLabel(d.kind);
const meta = [d.file_name || null, `added by ${d.added_by?.display_name ?? "-"} ${fmtTime(d.added_at)}`, d.note || null].filter((x): x is string => !!x);
return (
<div className={`qcdoc${old ? " old" : ""}`}>
<div className="m">
<div>
<span className="pill st-ready">{label}</span> <b>{d.title || label}</b>
</div>
<div className="small muted">{meta.join(" · ")}</div>
{old && d.removed ? (
<div className="small due soon">
Taken out of the data book by {d.removed.by?.display_name ?? "-"} {fmtTime(d.removed.at)}: {d.removed.reason ?? ""}
</div>
) : null} ) : null}
</div> </div>
<div className="a">
<button className="btn sm" type="button" onClick={() => openBlob(d.url)}>
View
</button>
{!old && canRemove ? (
<button className="btn sm ghost" type="button" disabled={busy} onClick={onOut}>
Take out of data book
</button>
) : null}
</div>
</div>
);
}
/**
* The documents for the data book (the old pdocsTab): reports Q.C. and Clerical add. Nothing is deleted; a take-out
* keeps the row with the reason. Below them the unit's and the project's Q.C. folder documents, read-only.
*/
export function DocumentsTab({
p,
mutate,
busy,
write,
askReason,
sub,
}: {
p: PacketRead;
mutate: Mutate;
busy: boolean;
write: Write;
askReason: AskReason;
sub: string;
}) {
const canAdd = p.can.includes("doc_add");
const canRemove = p.can.includes("doc_remove");
const [adding, setAdding] = useState(false);
const [showOld, setShowOld] = useState(false);
const live = p.docs.filter((d) => !d.removed);
const gone = p.docs.filter((d) => !!d.removed);
const folder = p.qc_folder_docs ?? [];
const takeOut = async (d: PacketDoc) => {
const reason = await askReason({
title: "Take out of the data book",
sub: d.title || docKindLabel(d.kind),
note: "The file and this record are kept and shown under taken out. Say why.",
placeholder: "e.g. wrong packet, superseded by a new report",
min: 3,
max: 200,
danger: true,
submitLabel: "Take out",
});
if (reason === null) return;
await mutate((g) => packetsApi.removeDoc(p.id, d.id, { reason, ...g }), { ok: "Taken out of the data book." });
};
return (
<div className="stack">
<section className="sec" aria-label="Documents for the data book">
<div className="sec-h">
<h2>Documents for the data book</h2>
<span className="ct">{live.length} on this packet</span>
<span className="grow" />
{canAdd ? (
<button className="btn pri sm" type="button" disabled={busy} onClick={() => setAdding(true)}>
<span aria-hidden="true">+ </span>Add document
</button>
) : null}
</div>
<div className="small muted mb8">
NDE, hydrotest, dimensional, PMI and coating reports, certificates, customer inspection reports and NCRs. They go into the data book export with the unit&apos;s
ITP and Q.C. folder documents. Drawings are never added here. They only come from M-Files.
</div>
<div className="qcdocs">
{live.length ? (
live.map((d) => <DocRow key={d.id} d={d} old={false} canRemove={canRemove} busy={busy} onOut={() => void takeOut(d)} />)
) : (
<div className="empty slim">No documents added to this packet yet.{canAdd ? " Use Add document." : " Q.C. or Clerical add them."}</div>
)}
</div>
{gone.length ? (
<div className="stack tight mt8">
<div>
<button className="lnk small" type="button" aria-expanded={showOld} onClick={() => setShowOld((v) => !v)}>
{showOld ? "Hide" : "Show"} {gone.length} taken out
</button>
</div>
{showOld ? (
<div className="qcdocs">
{gone.map((d) => (
<DocRow key={d.id} d={d} old canRemove={false} busy={busy} onOut={() => undefined} />
))}
</div>
) : null}
</div>
) : null}
</section>
<section className="sec" aria-label="Also in the data book from the Q.C. folder">
<div className="sec-h">
<h2>Also in the data book from the Q.C. folder</h2>
<span className="ct">{p.unit.number}</span>
</div>
<div className="qcdocs">
{folder.length ? (
folder.map((d) => (
<div key={d.id} className="qcdoc">
<div className="m">
<span className="pill st-ready">{d.type}</span> <b>{d.title || d.type}</b>
{d.rev ? ` Rev ${d.rev}` : ""} <span className="small muted">{d.scope === "project" ? "from the project" : "this unit"}</span>
</div>
<div className="a">
<button className="btn sm" type="button" onClick={() => openBlob(d.url)}>
View
</button>
</div>
</div>
))
) : (
<div className="empty slim">No ITP or Q.C. folder documents for this unit yet. Q.C. adds them under Project folders on the Q.C. screen.</div>
)}
</div>
</section>
{adding ? <AddDocSheet p={p} sub={sub} write={write} onDone={() => setAdding(false)} onCancel={() => setAdding(false)} /> : null}
</div>
); );
} }
/* ---------- History ---------- */ /* ---------- History ---------- */
/** GET /log, newest first. IT edits and signatures are marked. */ /** GET /log, newest first. IT edits and signatures are marked. The closed Q.C. inspections sit above the log. */
export function HistoryTab({ p }: { p: PacketRead }) { export function HistoryTab({ p }: { p: PacketRead }) {
const [load] = useLoad((s) => packetsApi.log(p.id, { limit: 300 }, s), `${p.id}:${p.version}`); const [load] = useLoad((s) => packetsApi.log(p.id, { limit: 300 }, s), `${p.id}:${p.version}`);
if (load.state === "loading") return <div className="empty slim">Loading</div>; return (
if (load.state === "error") return <div className="callout warn">{load.message}</div>; <div className="stack">
const rows = load.data; <QcInspections rows={p.qc_history} />
if (!rows.length) return <div className="empty slim">Nothing recorded yet.</div>; {load.state === "loading" ? (
<div className="empty slim">Loading</div>
) : load.state === "error" ? (
<div className="callout warn">{load.message}</div>
) : load.data.length ? (
<LogTable rows={load.data} />
) : (
<div className="empty slim">Nothing recorded yet.</div>
)}
</div>
);
}
/** One row per closed Q.C. inspection, oldest first. */
function QcInspections({ rows }: { rows: QcHistoryRow[] }) {
if (!rows.length) return null;
return (
<section className="sec" aria-label="Q.C. inspections">
<div className="sec-h">
<h2>Q.C. inspections</h2>
<span className="ct">{rows.length}</span>
</div>
<div className="qcdocs">
{rows.map((h) => {
const ok = h.result === "accepted";
const who = h.inspector || h.by?.display_name || "";
const note = noteWithoutNcr(h.note, h.ncr);
const welds = h.rejected_welds ?? [];
const tail = [welds.length ? `Rejected welds: ${welds.join(", ")}` : null, h.location ? `Spool at ${h.location}` : null].filter((x): x is string => !!x);
return (
<div key={h.id} className="qcdoc qchist">
<div className="m">
<div>
<span className={`pill ${ok ? "st-in_progress" : "st-review"}`}>{String(h.result ?? "").toUpperCase()}</span>{" "}
<span className="small mono">
{h.sent_at ? `${fmtTime(h.sent_at)} → ` : ""}
{fmtTime(h.at)}
</span>
{who ? <b> {who}</b> : null}
{h.dept_code ? <span className="small muted"> · {h.dept_code}</span> : null}
</div>
{note ? <div className="small">{note}</div> : null}
{h.ncr || tail.length ? (
<div className="small muted">
{h.ncr ? (
<a className="lnk" href={ncrUrl(h.ncr)} target="_blank" rel="noopener noreferrer">
NCR {h.ncr}
</a>
) : null}
{h.ncr && tail.length ? " · " : ""}
{tail.join(" · ")}
</div>
) : null}
</div>
</div>
);
})}
</div>
</section>
);
}
function LogTable({ rows }: { rows: LogRow[] }) {
return ( return (
<div className="tw"> <div className="tw">
<table> <table>
+2
View File
@@ -5,6 +5,7 @@ import { useAuth } from "../auth/useAuth";
import { AdminPage } from "./admin/AdminPage"; import { AdminPage } from "./admin/AdminPage";
import { ClericalHome } from "./clerical/ClericalHome"; import { ClericalHome } from "./clerical/ClericalHome";
import { PlaceholderScreen } from "./PlaceholderScreen"; import { PlaceholderScreen } from "./PlaceholderScreen";
import { QcHome } from "./qc/QcHome";
import { StallHome } from "./stall/StallHome"; import { StallHome } from "./stall/StallHome";
import { SupervisorHome } from "./supervisor/SupervisorHome"; import { SupervisorHome } from "./supervisor/SupervisorHome";
import { WarehouseHome } from "./warehouse/WarehouseHome"; import { WarehouseHome } from "./warehouse/WarehouseHome";
@@ -29,5 +30,6 @@ export function ScreenRoute() {
if (screen === "warehouse") return <WarehouseHome />; if (screen === "warehouse") return <WarehouseHome />;
if (screen === "supervisor") return <SupervisorHome />; if (screen === "supervisor") return <SupervisorHome />;
if (screen === "stall") return <StallHome />; if (screen === "stall") return <StallHome />;
if (screen === "qc") return <QcHome />;
return <PlaceholderScreen key={screen} screen={screen} />; return <PlaceholderScreen key={screen} screen={screen} />;
} }
+226
View File
@@ -0,0 +1,226 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { Itp } from "../../api/qc";
import { DEPARTMENTS, renderApp } from "../../test/auth";
import { callsTo, mockFetch, type Route } from "../../test/fetchMock";
import { makeItp, makeUnitFolder, QUINN } from "./fixtures";
function base(itp: Itp = makeItp(), extra: Record<string, Route> = {}) {
return {
"/api/auth/me": { body: QUINN },
"/api/departments": { body: DEPARTMENTS },
"/api/units/12/itp": { body: itp },
"/api/qc/folders/units/12": { body: makeUnitFolder() },
...extra,
};
}
const approved = (itp: Itp, key: string): Itp => ({
...itp,
version: itp.version + 1,
lines: { ...itp.lines, [key]: { ...itp.lines[key]!, result: "approve", by: { id: "u-quinn", display_name: "Quinn QC" }, initials: "QQ", at: "2026-10-17T10:00:00Z", row_version: 2 } },
});
describe("ItpPage", () => {
it("renders the head, the header fields, the pages, the sections and the rows", async () => {
mockFetch(base());
renderApp("/qc/units/12/itp");
expect(await screen.findByRole("heading", { name: "L999002-ITP" })).toBeInTheDocument();
expect(screen.getByText("Q.C. · Inspection and Test Plan · Open")).toBeInTheDocument();
expect(screen.getByText("1 of 5 lines signed or N/A · 1 open hold / witness point · template ITP_Generation_Revised_2")).toBeInTheDocument();
const crumbs = screen.getByRole("navigation", { name: "Where you are" });
expect(await within(crumbs).findByRole("link", { name: "L999" })).toHaveAttribute("href", "/qc/projects/7");
expect(within(crumbs).getByRole("link", { name: "L999002" })).toHaveAttribute("href", "/qc/units/12");
expect(screen.getByLabelText("Inspection plan no.")).toHaveValue("L999002-ITP");
expect(screen.getByLabelText("Client")).toHaveValue("Test Co");
expect(screen.getByLabelText("Unit no.")).toHaveValue("L999002");
expect(screen.getByText("1 of 3 · WP")).toBeInTheDocument();
expect(screen.getByText("3 of 3 · FA")).toBeInTheDocument();
const sec1 = screen.getByRole("region", { name: "1. GENERAL FABRICATION and DATA" });
expect(within(sec1).getAllByRole("group")).toHaveLength(2);
const l12 = within(sec1).getByRole("group", { name: "Line 1.2" });
expect(l12).toHaveClass("itprow", "hold");
expect(within(l12).getByRole("button", { name: "Approve" })).toBeEnabled();
expect(within(l12).getByRole("button", { name: "Reject" })).toBeInTheDocument();
expect(within(sec1).getByRole("button", { name: "Section N/A" })).toBeInTheDocument();
const l31 = screen.getByRole("group", { name: "Line 3.1" });
expect(l31).toHaveClass("na");
expect(l31).toHaveTextContent("N/A");
expect(within(screen.getByRole("region", { name: "3. CODE" })).getByRole("button", { name: "Section applies" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Sign off ITP" })).not.toBeInTheDocument();
});
it("approves a line with a POST sign and the row version, and shows the initials", async () => {
const fetch = mockFetch(base(makeItp(), { "POST /api/itps/5/lines/1.1/sign": { body: approved(makeItp(), "1.1") } }));
renderApp("/qc/units/12/itp");
const row = await screen.findByRole("group", { name: "Line 1.1" });
fireEvent.click(within(row).getByRole("button", { name: "Approve" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/itps/5/lines/1.1/sign")).toHaveLength(1));
const call = fetch.mock.calls.find(([u]) => String(u) === "/api/itps/5/lines/1.1/sign");
expect((call?.[1]?.headers as Record<string, string>)["If-Match"]).toBe("1");
const after = await screen.findByRole("group", { name: "Line 1.1" });
expect(await within(after).findByText("QQ")).toHaveClass("ini");
expect(within(after).getByText(/^Approved /)).toBeInTheDocument();
expect(within(after).getByRole("button", { name: "undo" })).toBeInTheDocument();
expect(within(after).queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
expect(within(screen.getByRole("region", { name: "1. GENERAL FABRICATION and DATA" })).queryByRole("button", { name: "Section N/A" })).not.toBeInTheDocument();
});
it("marks a section N/A", async () => {
const fetch = mockFetch(base(makeItp(), { "POST /api/itps/5/sections/4/na": { body: makeItp({ version: 4 }) } }));
renderApp("/qc/units/12/itp");
const sec = await screen.findByRole("region", { name: "4. FINAL" });
fireEvent.click(within(sec).getByRole("button", { name: "Section N/A" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/itps/5/sections/4/na")).toEqual([{ na: true }]));
});
it("disables the block button until ready, and signs a ready block with the signature sheet", async () => {
const signed = makeItp({ version: 4 });
signed.blocks = signed.blocks.map((b) => (b.key === "3" ? { ...b, signed: true, signed_at: "2026-10-17T10:00:00Z", signed_by: { id: "u-quinn", display_name: "Quinn QC" } } : b));
const fetch = mockFetch(base(makeItp(), { "POST /api/itps/5/blocks/3/sign": { body: signed } }));
renderApp("/qc/units/12/itp");
const b2 = await screen.findByRole("group", { name: "ITP block 2" });
expect(b2).toHaveTextContent("PESCO Q.C. signature · Sections 1–2");
expect(within(b2).getByRole("button", { name: "Sign section" })).toBeDisabled();
expect(b2).toHaveTextContent("every line must be initialed or N/A first");
expect(b2).toHaveTextContent("Customer complete");
expect(screen.getByRole("group", { name: "ITP block 4" })).toHaveTextContent("COP complete");
const b3 = screen.getByRole("group", { name: "ITP block 3" });
fireEvent.click(within(b3).getByRole("button", { name: "Sign (all N/A)" }));
const dlg = await screen.findByRole("dialog", { name: "Sign" });
expect(dlg).toHaveTextContent("Sign an ITP block");
fireEvent.change(within(dlg).getByLabelText("Password"), { target: { value: "secret-1" } });
fireEvent.click(within(dlg).getByRole("button", { name: "Sign" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/itps/5/blocks/3/sign")).toHaveLength(1));
expect(callsTo(fetch, "POST", "/api/itps/5/blocks/3/sign")[0]).toEqual({ sign: { factor: "password", secret: "secret-1" } });
const after = await screen.findByRole("group", { name: "ITP block 3" });
await waitFor(() => expect(after).toHaveTextContent("Quinn QC"));
expect(within(after).getByRole("button", { name: "undo" })).toBeInTheDocument();
});
it("enables Sign off ITP when the server says so and signs off with the meaning", async () => {
mockFetch(base(makeItp({ can: { edit: true, sign_off: true, reopen: false } })));
renderApp("/qc/units/12/itp");
fireEvent.click(await screen.findByRole("button", { name: "Sign off ITP" }));
const dlg = await screen.findByRole("dialog", { name: "Sign" });
expect(dlg).toHaveTextContent("Sign off the ITP");
});
it("rejects a line, keeps Approve locked until the NCR, and saves the NCR", async () => {
const rejected = makeItp({ version: 4 });
rejected.lines["1.1"] = {
...rejected.lines["1.1"]!,
result: "reject",
ncr_missing: true,
rejects: [{ at: "2026-10-17T10:00:00Z", by: { id: "u-quinn", display_name: "Quinn QC" }, ncr: null, note: "undercut", kind: "reject" }],
};
const fetch = mockFetch(
base(makeItp(), {
"POST /api/itps/5/lines/1.1/reject": { body: rejected },
"POST /api/itps/5/lines/1.1/ncr": { status: 422, body: { detail: "Enter an NCR number like NCR-2026-0021.", code: "invalid_ncr" } },
}),
);
renderApp("/qc/units/12/itp");
fireEvent.click(within(await screen.findByRole("group", { name: "Line 1.1" })).getByRole("button", { name: "Reject" }));
const dlg = screen.getByRole("dialog", { name: "Reject check point" });
expect(within(dlg).getByLabelText("NCR #")).toHaveAttribute("placeholder", "NCR number (can be added later)");
expect(dlg).toHaveTextContent("The line can't be approved until its NCR # is entered.");
fireEvent.change(within(dlg).getByLabelText("Reason / what failed"), { target: { value: "undercut" } });
fireEvent.click(within(dlg).getByRole("button", { name: "Reject" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/itps/5/lines/1.1/reject")).toEqual([{ note: "undercut" }]));
const row = await screen.findByRole("group", { name: "Line 1.1" });
await waitFor(() => expect(row).toHaveClass("rej"));
expect(row).toHaveTextContent(/Rejected .* · NCR # needed/);
const approve = within(row).getByRole("button", { name: "Approve" });
expect(approve).toBeDisabled();
expect(approve).toHaveAttribute("title", "Enter the NCR # first");
expect(within(row).queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
fireEvent.click(within(row).getByRole("button", { name: "Enter NCR #" }));
const ncr = screen.getByRole("dialog", { name: /^NCR # for 1\.1/ });
fireEvent.change(within(ncr).getByLabelText("NCR #"), { target: { value: "bad" } });
fireEvent.click(within(ncr).getByRole("button", { name: "Save NCR #" }));
expect(await within(ncr).findByRole("alert")).toHaveTextContent("Enter an NCR number like NCR-2026-0021.");
expect(callsTo(fetch, "POST", "/api/itps/5/lines/1.1/ncr")).toEqual([{ ncr: "bad" }]);
});
it("saves the line details", async () => {
const fetch = mockFetch(base(makeItp(), { "PATCH /api/itps/5/lines/1.2": { body: makeItp({ version: 4 }) } }));
renderApp("/qc/units/12/itp");
fireEvent.click(await screen.findByRole("button", { name: "Line 1.2 details" }));
const dlg = screen.getByRole("dialog", { name: "ITP line" });
expect(within(dlg).getByLabelText("Customer code")).toHaveDisplayValue("as template (W)");
expect(within(dlg).getByLabelText(/^Not applicable to this unit/)).not.toBeChecked();
fireEvent.change(within(dlg).getByLabelText("Customer witness / reviewer"), { target: { value: "Rick Inspector" } });
fireEvent.change(within(dlg).getByLabelText("Date"), { target: { value: "2026-10-17" } });
fireEvent.change(within(dlg).getByLabelText("Note"), { target: { value: "witnessed" } });
fireEvent.click(within(dlg).getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(callsTo(fetch, "PATCH", "/api/itps/5/lines/1.2")).toEqual([{ cust_by: "Rick Inspector", cust_at: "2026-10-17", note: "witnessed" }]),
);
await waitFor(() => expect(screen.queryByRole("dialog", { name: "ITP line" })).not.toBeInTheDocument());
});
it("saves a header field on blur with set and base", async () => {
const fetch = mockFetch(base(makeItp(), { "PATCH /api/itps/5": { body: makeItp({ version: 4, hdr: { ...makeItp().hdr, client: "ACME" } }) } }));
renderApp("/qc/units/12/itp");
const client = await screen.findByLabelText("Client");
fireEvent.focus(client);
fireEvent.change(client, { target: { value: "ACME" } });
fireEvent.blur(client);
await waitFor(() => expect(callsTo(fetch, "PATCH", "/api/itps/5")).toEqual([{ set: { client: "ACME" }, base: { client: "Test Co" } }]));
});
it("reloads on a 409 and shows the server's text", async () => {
let gets = 0;
const fetch = mockFetch(
base(makeItp(), {
"GET /api/units/12/itp": () => {
gets += 1;
return { body: gets === 1 ? makeItp() : approved(makeItp(), "1.1") };
},
"POST /api/itps/5/lines/1.1/sign": { status: 409, body: { detail: "This line is already initialed.", code: "already_signed" } },
}),
);
renderApp("/qc/units/12/itp");
fireEvent.click(within(await screen.findByRole("group", { name: "Line 1.1" })).getByRole("button", { name: "Approve" }));
expect(await screen.findByRole("alert")).toHaveTextContent("This line is already initialed.");
await waitFor(() => expect(gets).toBe(2));
expect(await within(screen.getByRole("group", { name: "Line 1.1" })).findByText("QQ")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/itps/5/lines/1.1/sign")).toHaveLength(1);
});
it("is read-only on a signed-off ITP and shows the callout", async () => {
mockFetch(
base(
makeItp({
status: "complete",
can: { edit: false, sign_off: false, reopen: true },
signoff: { at: "2026-10-17T12:00:00Z", by: { id: "u-quinn", display_name: "Quinn QC" }, asset_id: null },
}),
),
);
renderApp("/qc/units/12/itp");
expect(await screen.findByText("Q.C. · Inspection and Test Plan · Signed off")).toBeInTheDocument();
expect(screen.getByRole("note")).toHaveTextContent(
/^Signed off .* by Quinn QC\. The ITP is locked; a signed PDF copy will be filed when the ITP PDF is built\.$/,
);
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /details$/ })).not.toBeInTheDocument();
expect(screen.getByLabelText("Client")).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Reopen" }));
expect(screen.getByRole("dialog", { name: "Reopen ITP" })).toBeInTheDocument();
});
it("shows the log behind the toggle", async () => {
mockFetch(base());
renderApp("/qc/units/12/itp");
fireEvent.click(await screen.findByRole("button", { name: "Show log" }));
expect(screen.getByText(/· Quinn QC · ITP started · L999002-ITP · template ITP_Generation_Revised_2$/)).toBeInTheDocument();
});
});
+775
View File
@@ -0,0 +1,775 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Link, useParams } from "react-router";
import { ApiError } from "../../api/client";
import {
enterNcr,
getItp,
getUnitFolder,
patchBlock,
patchItp,
patchLine,
rejectLine,
reopenItp,
sectionNa,
signBlock,
signLine,
signOff,
unsignBlock,
unsignLine,
type HeaderField,
type Itp,
type ItpBlock,
type ItpItem,
type ItpLine,
type LinePatch,
type QcWho,
} from "../../api/qc";
import type { Guarded } from "../../api/types";
import { useAuth } from "../../auth/useAuth";
import { Sheet } from "../../components/Sheet";
import { useGuard, type RunOptions } from "../../components/useGuard";
import { useChanged } from "../../lib/changes";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { fmtQcDate } from "./folders";
import {
blockScope,
initials,
isApproved,
isNa,
isOpenHold,
isRejected,
ITP_CUST_CODES,
ITP_PAGE_ORDER,
ITP_PAGES,
lastReject,
lineOf,
progressText,
sectionsOf,
todayIso,
} from "./itpView";
/** What a write came to. A 409 reloads the ITP and shows the server's text. */
type Outcome = "ok" | "cancel" | "conflict" | { error: string };
type LineSheetState = { kind: "line" | "reject" | "ncr"; key: string } | null;
/** An input saved on blur when its value changed. */
function BlurInput({
label,
value,
disabled,
maxLength,
required = false,
onSave,
}: {
label: string;
value: string;
disabled: boolean;
maxLength: number;
/** An empty value goes back to the saved one. */
required?: boolean;
onSave: (next: string, prev: string) => Promise<boolean>;
}) {
const [v, setV] = useState(value);
const focused = useRef(false);
useEffect(() => {
if (!focused.current) setV(value);
}, [value]);
const blur = async () => {
focused.current = false;
const next = v.trim();
if (next === value || (required && !next)) {
setV(value);
return;
}
const ok = await onSave(next, value);
if (!ok) setV(value);
};
return (
<label className="f">
{label}
<input
className="in"
aria-label={label}
value={v}
disabled={disabled}
maxLength={maxLength}
onFocus={() => {
focused.current = true;
}}
onChange={(e) => setV(e.target.value)}
onBlur={() => void blur()}
/>
</label>
);
}
/** The line details sheet (the old openItpLine): N/A, the customer code and witness, the note. */
function LineSheet({ item, line, onSave, onCancel }: { item: ItpItem; line: ItpLine; onSave: (p: LinePatch) => Promise<Outcome>; onCancel: () => void }) {
const approved = isApproved(line);
const rej = isRejected(line);
const [na, setNa] = useState(isNa(line));
const [cc, setCc] = useState(line.cust_code ?? "");
const [cby, setCby] = useState(line.cust_by ?? "");
const [cat, setCat] = useState(line.cust_at ? line.cust_at.slice(0, 10) : todayIso());
const [note, setNote] = useState(line.note ?? "");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
const p: LinePatch = {};
if (!approved && !rej && na !== isNa(line)) p.na = na;
if (cc !== (line.cust_code ?? "")) p.cust_code = cc;
const by = cby.trim();
if (by !== (line.cust_by ?? "")) p.cust_by = by;
if (by && (p.cust_by !== undefined || cat !== (line.cust_at ?? "").slice(0, 10))) p.cust_at = cat || null;
const n = note.trim();
if (n !== (line.note ?? "")) p.note = n;
if (!Object.keys(p).length) {
onCancel();
return;
}
setBusy(true);
setErr(null);
const o = await onSave(p);
if (typeof o === "object") {
setErr(o.error);
setBusy(false);
} else if (o === "cancel") setBusy(false);
};
return (
<Sheet title="ITP line" sub={`${item.n} ${item.t}`} onClose={onCancel} wide>
<div className="small muted">{item.crit}</div>
<div className="small">
PESCO code <b>{item.code || "-"}</b> · customer code on the template <b>{item.cust || "-"}</b>
</div>
<form className="stack" onSubmit={submit} noValidate>
<label className="itpck">
<input type="checkbox" checked={na} disabled={approved || rej} onChange={(e) => setNa(e.target.checked)} />
Not applicable to this unit{approved ? " (undo the approval first)" : rej ? " (line is rejected)" : ""}
</label>
<div className="frow">
<label className="f">
Customer code
<select className="in" aria-label="Customer code" value={cc} onChange={(e) => setCc(e.target.value)}>
<option value="">as template ({item.cust || "-"})</option>
{ITP_CUST_CODES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<label className="f">
Customer witness / reviewer
<input
className="in"
aria-label="Customer witness / reviewer"
maxLength={80}
placeholder="Name (leave blank if not witnessed)"
value={cby}
onChange={(e) => setCby(e.target.value)}
/>
</label>
<label className="f">
Date
<input className="in" aria-label="Date" type="date" value={cat} onChange={(e) => setCat(e.target.value)} />
</label>
</div>
<label className="f">
Note
<textarea className="in" aria-label="Note" rows={2} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
</label>
{err ? (
<div className="callout warn flush" role="alert">
{err}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
Save
</button>
</div>
</form>
</Sheet>
);
}
/** The reject sheet, or the NCR sheet for a reject without its number (the old openItpReject). */
function RejectSheet({
item,
line,
ncrOnly,
onSave,
onCancel,
}: {
item: ItpItem;
line: ItpLine;
ncrOnly: boolean;
onSave: (ncr: string, note: string) => Promise<Outcome>;
onCancel: () => void;
}) {
const [ncr, setNcr] = useState("");
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const lr = lastReject(line);
const submit = async (e: FormEvent) => {
e.preventDefault();
const n = ncr.trim();
if (ncrOnly && !n) {
setErr("Enter the NCR #.");
return;
}
setBusy(true);
setErr(null);
const o = await onSave(n, note.trim());
if (typeof o === "object") {
setErr(o.error);
setBusy(false);
} else if (o === "cancel") setBusy(false);
};
return (
<Sheet title={ncrOnly ? `NCR # for ${item.n} ${item.t}` : "Reject check point"} sub={ncrOnly ? undefined : `${item.n} ${item.t}`} onClose={onCancel}>
{ncrOnly && lr ? (
<div className="small muted">
Rejected {fmtQcDate(lr.at)}
{lr.by ? ` by ${lr.by.display_name}` : ""}
{lr.note ? `: ${lr.note}` : ""}
</div>
) : (
<div className="small muted">{item.crit}</div>
)}
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
NCR #
<input
className="in"
aria-label="NCR #"
maxLength={40}
autoComplete="off"
placeholder={ncrOnly ? "NCR number" : "NCR number (can be added later)"}
value={ncr}
onChange={(e) => setNcr(e.target.value)}
/>
</label>
{ncrOnly ? null : (
<label className="f">
Reason / what failed
<textarea className="in" aria-label="Reason / what failed" rows={2} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
</label>
)}
<div className="small muted">The line can't be approved until its NCR # is entered.</div>
{err ? (
<div className="callout warn flush" role="alert">
{err}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className={ncrOnly ? "btn pri" : "btn rejb"} type="submit" disabled={busy}>
{ncrOnly ? "Save NCR #" : "Reject"}
</button>
</div>
</form>
</Sheet>
);
}
/** The customer complete inputs on a block. */
function CustRecord({ onRecord }: { onRecord: (name: string, date: string) => Promise<boolean> }) {
const [name, setName] = useState("");
const [date, setDate] = useState(todayIso());
const [busy, setBusy] = useState(false);
const record = async () => {
setBusy(true);
const ok = await onRecord(name.trim(), date);
setBusy(false);
if (ok) setName("");
};
return (
<span className="custrec">
<input className="in" aria-label="Customer rep name" placeholder="Customer rep name" maxLength={80} value={name} onChange={(e) => setName(e.target.value)} />
<input className="in" aria-label="Customer complete date" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
<button type="button" className="btn sm" disabled={busy || !name.trim()} onClick={() => void record()}>
Record
</button>
</span>
);
}
/** /qc/units/:unitId/itp (the old itpView). */
export function ItpPage() {
const { unitId } = useParams();
const uid = Number(unitId);
const { me } = useAuth();
const roles = me?.roles ?? [];
const isAdmin = roles.includes("admin");
const guard = useGuard();
const [load, reload] = useLoad((s) => getItp(uid, s), String(uid));
const [folderLoad] = useLoad((s) => getUnitFolder(uid, s), String(uid));
const [fresh, setFresh] = useState<Itp | null>(null);
const [sheet, setSheet] = useState<LineSheetState>(null);
const [showLog, setShowLog] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
useChanged(reload, { delayMs: 500, filter: (ev) => ev.kind === "itp" && Number(ev.unit_id) === uid });
useEffect(() => {
if (!msg) return;
const t = window.setTimeout(() => setMsg(null), 8000);
return () => window.clearTimeout(t);
}, [msg]);
const loaded = load.state === "ok" ? load.data : null;
// A write's answer is the newest copy until a reload brings one as new or newer.
const r = fresh && fresh.unit_id === uid && (!loaded || fresh.version > loaded.version) ? fresh : loaded;
const folder = folderLoad.state === "ok" ? folderLoad.data : null;
const projectId = folder?.project.id;
const projectNo = folder?.project.number ?? r?.hdr.pno ?? "";
const unitNo = folder?.unit.number ?? r?.hdr.unit ?? "";
const exec = async (fn: (g: Guarded) => Promise<Itp>, opts?: RunOptions): Promise<Outcome> => {
setMsg(null);
try {
const out = await guard.run(fn, opts);
if (out === undefined) return "cancel";
setFresh(out);
return "ok";
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
reload();
setSheet(null);
setMsg(errorText(err));
return "conflict";
}
return { error: errorText(err) };
}
};
const write = async (fn: (g: Guarded) => Promise<Itp>, opts?: RunOptions): Promise<boolean> => {
const o = await exec(fn, opts);
if (typeof o === "object") setMsg(o.error);
return o === "ok";
};
const back = (
<Link className="back" to={`/qc/units/${uid}`}>
← Back
</Link>
);
const crumbs = (
<nav className="crumbs" aria-label="Where you are">
<Link className="back" to="/qc">
All project folders
</Link>
<span>›</span>
{projectId ? (
<Link className="back" to={`/qc/projects/${projectId}`}>
{projectNo}
</Link>
) : (
<span>{projectNo}</span>
)}
<span>›</span>
<Link className="back" to={`/qc/units/${uid}`}>
{unitNo || "Unit"}
</Link>
<span>›</span>
<b>ITP</b>
</nav>
);
if (!r) {
return (
<>
{back}
{crumbs}
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
{load.state === "ok" ? (
<div className="itpcard">
<div className="grow">
<b>No digital ITP yet.</b>
<div className="small muted">Start it from the unit's Q.C. folder.</div>
</div>
<Link className="btn" to={`/qc/units/${uid}`}>
Open the unit folder
</Link>
</div>
) : null}
</>
);
}
const id = r.id;
const complete = r.status === "complete";
const ed = r.can.edit && !complete;
const mayUndo = (who: QcWho | null) => ed && (isAdmin || (!!who && who.id === me?.user.id));
const sections = r.template.sections;
const pages = r.pages.length
? r.pages
: ITP_PAGE_ORDER.filter((c) => sectionsOf(sections, c).length).map((c) => ({ code: c, name: ITP_PAGES[c] ?? c }));
const blockAt = new Map<string, ItpBlock>(r.blocks.map((b) => [b.key, b]));
const hdr = r.hdr;
const saveHdr = (field: HeaderField) => (next: string, prev: string) => write((g) => patchItp(id, { set: { [field]: next }, base: { [field]: prev } }, g));
const sheetItem = sheet ? sections.flatMap((s) => s.items).find((i) => i.n === sheet.key) : undefined;
const reopen = async () => {
const reason = await guard.askReason({ title: "Reopen ITP", sub: r.no, label: "Reason", min: 8, max: 300, submitLabel: "Reopen" });
if (reason === null) return;
await write((g) => reopenItp(id, { reason, ...(g.it_reason ? { it_reason: g.it_reason } : {}) }));
};
return (
<>
{back}
{crumbs}
<div className="dhead">
<div className="grow0" style={{ minWidth: 0 }}>
<div className="eyebrow">Q.C. · Inspection and Test Plan · {complete ? "Signed off" : "Open"}</div>
<h1>{r.no}</h1>
<div className="small muted">
{progressText({ ...r.progress, status: r.status })} · template {r.template.ver}
</div>
</div>
<div className="pacts">
{r.can.sign_off ? (
<button className="btn pri" type="button" onClick={() => void write((g) => signOff(id, g.sign, g), { sign: "Sign off the ITP", sub: r.no })}>
Sign off ITP
</button>
) : null}
{r.can.reopen ? (
<button className="btn ghost" type="button" onClick={() => void reopen()}>
Reopen
</button>
) : null}
</div>
</div>
{complete && r.signoff ? (
<div className="callout mb10" role="note">
Signed off {fmtQcDate(r.signoff.at)} by {r.signoff.by?.display_name ?? ""}. The ITP is locked; a signed PDF copy{" "}
{r.signoff.asset_id ? "is filed in this unit's documents." : "will be filed when the ITP PDF is built."}
</div>
) : null}
<section className="sec" aria-label="ITP header">
<div className="itphdr">
<BlurInput label="Inspection plan no." value={r.no} disabled={!ed} maxLength={120} required onSave={saveHdr("no")} />
<BlurInput label="Client" value={hdr.client} disabled={!ed} maxLength={120} onSave={saveHdr("client")} />
<BlurInput label="Project name" value={hdr.pname} disabled={!ed} maxLength={120} onSave={saveHdr("pname")} />
<BlurInput label="Project no." value={hdr.pno} disabled={!ed} maxLength={120} onSave={saveHdr("pno")} />
<BlurInput label="Purchase order" value={hdr.po} disabled={!ed} maxLength={120} onSave={saveHdr("po")} />
<BlurInput label="Unit no." value={hdr.unit} disabled={!ed} maxLength={120} onSave={saveHdr("unit")} />
</div>
<div className="small muted mt8">
<b>Codes:</b> {r.template.legend}
</div>
</section>
{pages.map((pg, pi) => {
const secs = sectionsOf(sections, pg.code);
if (!secs.length) return null;
return (
<div key={pg.code} className="itppagewrap">
<div className="itppage">
<span>
{pi + 1} of {pages.length} · {pg.code}
</span>{" "}
{pg.name}
</div>
{secs.map((s) => {
const lines = s.items.map((i) => lineOf(r, i.n));
const anyApproved = lines.some(isApproved);
const allNa = lines.length > 0 && lines.every(isNa);
const b = blockAt.get(s.n);
const hid = `itpsec-${s.n}`;
return (
<div key={s.n}>
<section className="sec itpsec" aria-labelledby={hid}>
<div className="sec-h">
<h2 id={hid}>
{s.n}. {s.t}
</h2>
<span className="grow" />
{ed && !anyApproved ? (
<button type="button" className="btn sm ghost" onClick={() => void write((g) => sectionNa(id, s.n, !allNa, g))}>
{allNa ? "Section applies" : "Section N/A"}
</button>
) : null}
</div>
<div className="itprows">
<div className="itprow hd" aria-hidden="true">
<span>No.</span>
<span>Inspection item · acceptance</span>
<span>PESCO</span>
<span>Q.C. approval</span>
<span>Customer</span>
<span />
</div>
{s.items.map((i) => {
const l = lineOf(r, i.n);
const na = isNa(l);
const approved = isApproved(l);
const rej = isRejected(l);
const lr = lastReject(l);
const ncr = l.ncr ?? lr?.ncr ?? null;
const rejTxt =
lr || rej ? (
<span className="rejtag">
{rej ? "Rejected" : "Was rejected"} {fmtQcDate(lr?.at)} ·{" "}
{ncr ? (
<>
NCR <b className="mono">{ncr}</b>
</>
) : (
<b>NCR # needed</b>
)}
</span>
) : null;
const cls = ["itprow", na ? "na" : "", isOpenHold(i, l) ? "hold" : "", rej ? "rej" : ""].filter(Boolean).join(" ");
let qc;
if (na) qc = <span className="muted">N/A</span>;
else if (approved)
qc = (
<>
<span className="ini" title={l.by?.display_name ?? undefined}>
{l.initials || initials(l.by?.display_name)}
</span>
<span className="small muted">Approved {fmtQcDate(l.at)}</span>
{mayUndo(l.by) ? (
<button type="button" className="lnk small" onClick={() => void write((g) => unsignLine(id, i.n, g))}>
undo
</button>
) : null}
{rejTxt}
</>
);
else if (ed)
qc = (
<>
{rejTxt}
<span className="apr">
<button
type="button"
className="btn sm pri"
disabled={l.ncr_missing}
title={l.ncr_missing ? "Enter the NCR # first" : undefined}
onClick={() => void write((g) => signLine(id, i.n, r.lines[i.n]?.row_version, g))}
>
Approve
</button>
{rej ? null : (
<button type="button" className="btn sm rejb" onClick={() => setSheet({ kind: "reject", key: i.n })}>
Reject
</button>
)}
{l.ncr_missing ? (
<button type="button" className="btn sm" onClick={() => setSheet({ kind: "ncr", key: i.n })}>
Enter NCR #
</button>
) : null}
</span>
</>
);
else qc = rejTxt ?? <span className="muted small">open</span>;
return (
<div key={i.n} className={cls} role="group" aria-label={`Line ${i.n}`}>
<span className="n mono">{i.n}</span>
<div className="t">
<b>{i.t}</b>
<div className="small muted">{i.crit}</div>
{l.note ? <div className="small">Note: {l.note}</div> : null}
</div>
<span className="cd" title="PESCO inspection code">
{i.code || "-"}
</span>
<div className="qc">{qc}</div>
<div className="cu" title="Customer code">
<span className="cd">{l.cust_code || i.cust || "-"}</span>
{l.cust_by ? (
<span className="small muted">
{l.cust_by} · {fmtQcDate(l.cust_at)}
</span>
) : null}
</div>
{ed ? (
<button type="button" className="btn sm ghost mb" aria-label={`Line ${i.n} details`} onClick={() => setSheet({ kind: "line", key: i.n })}>
⋯
</button>
) : (
<span />
)}
</div>
);
})}
</div>
</section>
{b ? <BlockView itpId={id} b={b} ed={ed} mayUndo={mayUndo} write={write} /> : null}
</div>
);
})}
</div>
);
})}
<section className="sec" aria-labelledby="itplog-h">
<div className="sec-h">
<h2 id="itplog-h">ITP log</h2>
<span className="ct">
{r.log.length} entr{r.log.length === 1 ? "y" : "ies"}
</span>
<span className="grow" />
<button type="button" className="btn sm ghost" aria-expanded={showLog} onClick={() => setShowLog((v) => !v)}>
{showLog ? "Hide log" : "Show log"}
</button>
</div>
{showLog ? (
r.log.length ? (
<ol className="itplog">
{r.log.map((e) => (
<li key={e.id} className="small">
{[fmtTime(e.at), e.by_name || e.by?.display_name || "", e.action, e.note ?? ""].filter(Boolean).join(" · ")}
</li>
))}
</ol>
) : (
<div className="empty slim">Nothing logged yet.</div>
)
) : null}
</section>
{sheet && sheetItem && sheet.kind === "line" ? (
<LineSheet
item={sheetItem}
line={lineOf(r, sheetItem.n)}
onCancel={() => setSheet(null)}
onSave={async (p) => {
const o = await exec((g) => patchLine(id, sheetItem.n, p, g));
if (o === "ok") setSheet(null);
return o;
}}
/>
) : null}
{sheet && sheetItem && sheet.kind !== "line" ? (
<RejectSheet
item={sheetItem}
line={lineOf(r, sheetItem.n)}
ncrOnly={sheet.kind === "ncr"}
onCancel={() => setSheet(null)}
onSave={async (ncr, note) => {
const o = await exec((g) =>
sheet.kind === "ncr"
? enterNcr(id, sheetItem.n, ncr, g)
: rejectLine(id, sheetItem.n, { ...(ncr ? { ncr } : {}), ...(note ? { note } : {}) }, g),
);
if (o === "ok") setSheet(null);
return o;
}}
/>
) : null}
{guard.sheet}
{msg ? (
<div className="callout warn itpmsg" role="alert">
{msg}{" "}
<button type="button" className="lnk small" onClick={() => setMsg(null)}>
Dismiss
</button>
</div>
) : null}
</>
);
}
/** The signature block after a signing section (the old itpBlockHtml). */
function BlockView({
itpId,
b,
ed,
mayUndo,
write,
}: {
itpId: number;
b: ItpBlock;
ed: boolean;
mayUndo: (who: QcWho | null) => boolean;
write: (fn: (g: Guarded) => Promise<Itp>, opts?: RunOptions) => Promise<boolean>;
}) {
const scope = b.scope || blockScope(b.sections);
return (
<div className="itpblock" role="group" aria-label={`ITP block ${b.key}`}>
<div className="frow">
<BlurInput label="QN #" value={b.qn ?? ""} disabled={!ed} maxLength={40} onSave={(v) => write((g) => patchBlock(itpId, b.key, { qn: v }, g))} />
<BlurInput label="Remarks / notes" value={b.remarks ?? ""} disabled={!ed} maxLength={400} onSave={(v) => write((g) => patchBlock(itpId, b.key, { remarks: v }, g))} />
</div>
<div className="sigs">
<div>
<div className="eyebrow">PESCO Q.C. signature · {scope}</div>
{b.signed ? (
<>
<b>{b.signed_by?.display_name ?? ""}</b> {b.signed_by?.emp_no ? <span className="mono small">{b.signed_by.emp_no}</span> : null}{" "}
<span className="small muted">{fmtQcDate(b.signed_at)} · signed</span>
{mayUndo(b.signed_by) ? (
<>
{" "}
<button type="button" className="lnk small" onClick={() => void write((g) => unsignBlock(itpId, b.key, g))}>
undo
</button>
</>
) : null}
</>
) : ed ? (
<>
<button
type="button"
className={b.ready ? "btn sm pri" : "btn sm"}
disabled={!b.ready}
onClick={() => void write((g) => signBlock(itpId, b.key, g.sign, g), { sign: "Sign an ITP block", sub: scope })}
>
{b.all_na ? "Sign (all N/A)" : "Sign section"}
</button>
{b.ready ? null : <span className="small muted"> every line must be initialed or N/A first</span>}
</>
) : (
<span className="muted small">not signed</span>
)}
</div>
<div>
<div className="eyebrow">{b.final ? "COP complete" : "Customer complete"}</div>
{b.cust_name ? (
<>
<b>{b.cust_name}</b>{" "}
<span className="small muted">
{fmtQcDate(b.cust_at)} · recorded by {b.cust_by?.display_name ?? ""}
</span>
{ed ? (
<>
{" "}
<button type="button" className="lnk small" onClick={() => void write((g) => patchBlock(itpId, b.key, { cust_clear: true }, g))}>
clear
</button>
</>
) : null}
</>
) : ed ? (
<CustRecord onRecord={(name, date) => write((g) => patchBlock(itpId, b.key, { cust_name: name, ...(date ? { cust_at: date } : {}) }, g))} />
) : (
<span className="muted small">-</span>
)}
</div>
</div>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { useState, type FormEvent } from "react";
import { assetsApi } from "../../api/assets";
import { addQcDoc, type QcDoc, type QcProjectFolder, type QcUnitFolder } from "../../api/qc";
import { Sheet } from "../../components/Sheet";
import { useGuard } from "../../components/useGuard";
import { errorText } from "../../lib/format";
import { QC_TYPES } from "./folders";
const MAX_BYTES = 25 * 1024 * 1024;
export interface QcDocTarget {
scope: "unit" | "project";
id: number;
number: string;
}
interface QcDocSheetProps {
target: QcDocTarget;
/** The document a new revision replaces. */
replaces?: QcDoc | null;
onDone: (folder: QcProjectFolder | QcUnitFolder, text: string) => void;
onCancel: () => void;
}
/** Add a Q.C. folder document, or file a new revision of one (the old openQcDocSheet). */
export function QcDocSheet({ target, replaces = null, onDone, onCancel }: QcDocSheetProps) {
const guard = useGuard();
const r = replaces;
const [type, setType] = useState<string>(r?.type ?? "ITP");
const [title, setTitle] = useState(r?.title ?? "");
const [rev, setRev] = useState("");
const [note, setNote] = useState("");
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
if (busy) return;
if (!file) {
setMsg("Choose the file to add.");
return;
}
if (file.size > MAX_BYTES) {
setMsg("The file is over 25 MB.");
return;
}
setBusy(true);
setMsg(null);
try {
const up = await assetsApi.upload(file, { purpose: "document" });
const scope = target.scope === "unit" ? { unit_id: target.id } : { project_id: target.id };
const body = {
...scope,
type,
asset_id: up.id,
...(title.trim() ? { title: title.trim() } : {}),
...(rev.trim() ? { rev: rev.trim() } : {}),
...(note.trim() ? { note: note.trim() } : {}),
...(r ? { replaces: r.id } : {}),
};
const folder = await guard.run((g) => addQcDoc({ ...body, ...g }), { sub: `${target.scope} ${target.number}` });
if (folder === undefined) {
setBusy(false);
return;
}
onDone(folder, r ? "New revision filed." : `Added to ${target.number}.`);
} catch (err) {
setBusy(false);
setMsg(errorText(err));
}
};
return (
<>
<Sheet title={r ? "New revision" : "Add document"} sub={`${target.scope} ${target.number}`} onClose={onCancel} wide>
{r ? (
<div className="small muted">
Replaces <b>{r.title || r.file_name || "Document"}</b>
{r.rev ? ` Rev ${r.rev}` : ""}. The old one stays on file under Superseded.
</div>
) : null}
<form className="stack" onSubmit={submit} noValidate>
<div className="frow">
<label className="f">
Type
<select className="in" aria-label="Type" value={type} onChange={(e) => setType(e.target.value)}>
{QC_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
<label className="f">
Title
<input className="in" aria-label="Title" maxLength={140} placeholder="e.g. Inspection & Test Plan" value={title} onChange={(e) => setTitle(e.target.value)} />
</label>
<label className="f">
Rev
<input className="in" aria-label="Rev" maxLength={12} placeholder={r?.rev ? `was ${r.rev}` : "0"} value={rev} onChange={(e) => setRev(e.target.value)} />
</label>
</div>
<label className="f">
File (PDF, PNG or JPG, up to 25 MB)
<input className="in" type="file" accept="application/pdf,image/png,image/jpeg" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</label>
<label className="f">
Note
<textarea className="in" aria-label="Note" rows={2} maxLength={600} placeholder="Optional" value={note} onChange={(e) => setNote(e.target.value)} />
</label>
{msg ? (
<div className="callout warn flush" role="alert">
{msg}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
{busy ? "Uploading" : r ? "File new revision" : "Add to folder"}
</button>
</div>
</form>
</Sheet>
{guard.sheet}
</>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
import { makeDoc, makeProjectFolder, makeUnitFolder, QUINN } from "./fixtures";
function base(me = QUINN) {
return {
"/api/auth/me": { body: me },
"/api/departments": { body: DEPARTMENTS },
"/api/qc/folders/projects/7": { body: makeProjectFolder() },
"/api/qc/folders/units/12": { body: makeUnitFolder() },
};
}
describe("QcProjectFolder", () => {
it("shows the head, the documents, the unit tiles and the superseded rows", async () => {
mockFetch(base());
renderApp("/qc/projects/7");
expect(await screen.findByRole("heading", { name: "L999 — CI project" })).toBeInTheDocument();
expect(screen.getByText("Q.C. folder · Project · Open")).toBeInTheDocument();
expect(screen.getByText("Test Co · PO 5541")).toBeInTheDocument();
const crumbs = screen.getByRole("navigation", { name: "Where you are" });
expect(within(crumbs).getByRole("link", { name: "All project folders" })).toHaveAttribute("href", "/qc");
const docs = screen.getByRole("list", { name: "Project documents" });
const row = within(docs).getByRole("listitem");
expect(row).toHaveTextContent("WPS / PQR");
expect(row).toHaveTextContent("WPS-101 GTAW/SMAW");
expect(row).toHaveTextContent("Rev 2");
expect(row).toHaveTextContent(/wps\.pdf · added .* by Quinn QC/);
expect(within(row).getByRole("button", { name: "View" })).toBeInTheDocument();
expect(within(row).getByRole("button", { name: "New revision" })).toBeInTheDocument();
const units = screen.getByRole("region", { name: "Unit folders" });
expect(within(units).getByRole("link", { name: /L999001/ })).toHaveTextContent("ITP 12/68");
expect(within(units).getByText("ITP 12/68")).toHaveClass("due", "soon");
expect(within(units).getByRole("link", { name: /L999002/ })).toHaveTextContent("no ITP");
expect(within(units).getByRole("link", { name: /L999003/ })).toHaveTextContent("ITP ✓");
expect(within(units).getByRole("link", { name: /L999002/ })).toHaveAttribute("href", "/qc/units/12");
const old = screen.getByRole("region", { name: "Superseded" });
expect(within(old).queryByText("WPS-101 old")).not.toBeInTheDocument();
fireEvent.click(within(old).getByRole("button", { name: "Show" }));
expect(within(old).getByText("WPS-101 old")).toBeInTheDocument();
expect(within(old).getByRole("listitem")).toHaveTextContent(/superseded .* by Quinn QC/);
expect(within(old).getByRole("button", { name: "Hide" })).toBeInTheDocument();
});
it("files a new revision: upload with purpose document, then the post with replaces", async () => {
const after = makeProjectFolder({ docs: [makeDoc({ id: 2, rev: "3" })] });
const fetch = mockFetch({
...base(),
"POST /api/assets": { body: { id: "a2", mime: "application/pdf", size: 10, sha256: "x", pages: 1, original_name: "wps3.pdf", url: "/_blob/a2" } },
"POST /api/qc/docs": { body: after },
});
renderApp("/qc/projects/7");
const row = within(await screen.findByRole("list", { name: "Project documents" })).getByRole("listitem");
fireEvent.click(within(row).getByRole("button", { name: "New revision" }));
const dlg = screen.getByRole("dialog", { name: "New revision" });
expect(dlg).toHaveTextContent("project L999");
expect(dlg).toHaveTextContent("Replaces WPS-101 GTAW/SMAW Rev 2. The old one stays on file under Superseded.");
expect(within(dlg).getByLabelText("Type")).toHaveValue("WPS / PQR");
expect(within(dlg).getByLabelText("Rev")).toHaveAttribute("placeholder", "was 2");
fireEvent.change(within(dlg).getByLabelText("Rev"), { target: { value: "3" } });
const file = new File(["%PDF-1.4"], "wps3.pdf", { type: "application/pdf" });
fireEvent.change(within(dlg).getByLabelText("File (PDF, PNG or JPG, up to 25 MB)"), { target: { files: [file] } });
fireEvent.click(within(dlg).getByRole("button", { name: "File new revision" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/qc/docs")).toHaveLength(1));
const upload = fetch.mock.calls.find(([u, init]) => String(u) === "/api/assets" && init?.method === "POST");
expect((upload?.[1]?.body as FormData).get("purpose")).toBe("document");
expect(callsTo(fetch, "POST", "/api/qc/docs")[0]).toEqual({
project_id: 7,
type: "WPS / PQR",
asset_id: "a2",
title: "WPS-101 GTAW/SMAW",
rev: "3",
replaces: 1,
});
await waitFor(() => expect(screen.queryByRole("dialog", { name: "New revision" })).not.toBeInTheDocument());
expect(screen.getByText("New revision filed.")).toBeInTheDocument();
expect(screen.getByText("Rev 3")).toBeInTheDocument();
});
it("shows the server's message for a wrong file type", async () => {
mockFetch({
...base(),
"POST /api/assets": { body: { id: "a3", mime: "text/plain", size: 10, sha256: "x", pages: null, original_name: "a.txt", url: "/_blob/a3" } },
"POST /api/qc/docs": { status: 422, body: { detail: "Documents must be a PDF, PNG or JPEG file.", code: "invalid_asset" } },
});
renderApp("/qc/projects/7");
fireEvent.click(await screen.findByRole("button", { name: "+ Add document" }));
const dlg = screen.getByRole("dialog", { name: "Add document" });
expect(within(dlg).getByLabelText("Type")).toHaveValue("ITP");
fireEvent.change(within(dlg).getByLabelText("File (PDF, PNG or JPG, up to 25 MB)"), { target: { files: [new File(["x"], "a.txt", { type: "text/plain" })] } });
fireEvent.click(within(dlg).getByRole("button", { name: "Add to folder" }));
expect(await within(dlg).findByRole("alert")).toHaveTextContent("Documents must be a PDF, PNG or JPEG file.");
});
it("hides Add document from a user who cannot add", async () => {
mockFetch(base(makeMe({ roles: ["pm"], screens: ["qc"] })));
renderApp("/qc/projects/7");
await screen.findByRole("heading", { name: "L999 — CI project" });
expect(screen.queryByRole("button", { name: "+ Add document" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "New revision" })).not.toBeInTheDocument();
});
});
describe("QcUnitFolder", () => {
it("shows the no ITP warning, the start card and the project documents", async () => {
mockFetch(base());
renderApp("/qc/units/12");
expect(await screen.findByRole("heading", { name: "L999002" })).toBeInTheDocument();
expect(screen.getByText("Q.C. folder · Unit · Open")).toBeInTheDocument();
expect(screen.getByText("Unit of L999 · CI project")).toBeInTheDocument();
expect(screen.getByText("No ITP on file for this unit yet.")).toBeInTheDocument();
expect(screen.getByText("No digital ITP yet.")).toBeInTheDocument();
const crumbs = screen.getByRole("navigation", { name: "Where you are" });
expect(within(crumbs).getByRole("link", { name: "L999" })).toHaveAttribute("href", "/qc/projects/7");
const inh = screen.getByRole("list", { name: "From project L999" });
expect(within(inh).getByRole("listitem")).toHaveTextContent("WPS-101 GTAW/SMAW");
expect(within(inh).queryByRole("button", { name: "New revision" })).not.toBeInTheDocument();
expect(screen.getByText('No documents yet. Use "Add document" to file the ITP or other Q.C. paperwork for this unit.')).toBeInTheDocument();
});
it("starts the ITP and opens it", async () => {
const fetch = mockFetch({ ...base(), "POST /api/units/12/itp": { body: { id: 5 } }, "/api/units/12/itp": { body: { itp: null, unit: { id: 12, number: "L999002" } } } });
renderApp("/qc/units/12");
fireEvent.click(await screen.findByRole("button", { name: "Start ITP from template" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/units/12/itp")).toHaveLength(1));
await waitFor(() => expect(callsTo(fetch, "GET", "/api/units/12/itp")).toHaveLength(1));
expect(await screen.findByText("ITP")).toBeInTheDocument();
});
it("shows the open ITP card with its progress", async () => {
mockFetch({
...base(),
"/api/qc/folders/units/12": { body: makeUnitFolder({ no_itp: false, itp: { id: 5, no: "L999002-ITP", status: "open", done: 12, total: 68, holds: 2 } }) },
});
renderApp("/qc/units/12");
const card = await screen.findByRole("link", { name: /Open ITP/ });
expect(card).toHaveAttribute("href", "/qc/units/12/itp");
expect(card).toHaveTextContent("L999002-ITP");
expect(card).toHaveTextContent("Open");
expect(card).toHaveTextContent("12 of 68 lines signed or N/A · 2 open hold / witness points");
expect(screen.queryByText("No ITP on file for this unit yet.")).not.toBeInTheDocument();
});
});
+77
View File
@@ -0,0 +1,77 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { mockFetch } from "../../test/fetchMock";
import { makeHome, QUINN } from "./fixtures";
function base(home = makeHome(), me = QUINN) {
return {
"/api/auth/me": { body: me },
"/api/departments": { body: DEPARTMENTS },
"/api/qc/home": { body: home },
"/api/projects": { body: [] },
};
}
describe("QcHome", () => {
it("shows the page head, the KPIs, the three boards and the folder grid", async () => {
mockFetch(base());
renderApp("/qc");
expect(await screen.findByRole("heading", { name: "Inspect the spool and the data book" })).toBeInTheDocument();
expect(screen.getByText("Q.C. · Inspection")).toBeInTheDocument();
await screen.findByRole("heading", { name: /^Ready for inspection/ });
expect(screen.getByLabelText("Waiting for inspection: 1")).toHaveClass("hot");
expect(screen.getByLabelText("Accepted last 7 days: 2")).not.toHaveClass("hot");
expect(screen.getByLabelText("Rejected last 7 days: 0")).toHaveClass("zero");
expect(screen.getByRole("heading", { name: /^Accepted/ })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /^Rejected — back with supervisor/ })).toBeInTheDocument();
expect(screen.getByText("No open rejections.")).toBeInTheDocument();
const waiting = screen.getByRole("link", { name: /^L999001 · WELD-PART/ });
expect(waiting).toHaveAttribute("href", "/packets/1");
expect(within(waiting).getByText("Spool at Bay 2")).toBeInTheDocument();
expect(within(waiting).getByText("ITP: 2 open hold / witness points")).toBeInTheDocument();
const folders = screen.getByRole("region", { name: "Project folders" });
const card = within(folders).getByRole("link", { name: /L999/ });
expect(card).toHaveAttribute("href", "/qc/projects/7");
expect(card).toHaveClass("pcard");
expect(card).toHaveTextContent("2 units · 3 documents");
expect(card).toHaveTextContent("1 unit without an ITP");
expect(card).toHaveTextContent("PO 5541");
});
it("filters the boards on the client", async () => {
mockFetch(base());
renderApp("/qc");
await screen.findByRole("link", { name: /^L999002/ });
fireEvent.change(screen.getByLabelText("Find a job"), { target: { value: "elbow" } });
expect(screen.queryByRole("link", { name: /^L999001 · WELD-PART/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: /^L999002/ })).toBeInTheDocument();
expect(screen.getByText("Nothing here matches the filters.")).toBeInTheDocument();
});
it("shows all folders on All, and the empty texts", async () => {
const fetch = mockFetch({
...base(makeHome({ folders: [], columns: { ready: [], accepted: [], rejected: [] } })),
"/api/qc/folders": { body: [] },
});
renderApp("/qc");
expect(await screen.findByText("Supervisors send packets here when the data book package and the spool are ready.")).toBeInTheDocument();
expect(screen.getByText("Nothing accepted yet.")).toBeInTheDocument();
expect(screen.getByText("No projects yet. Folders appear here as soon as Clerical sets up a project and its units.")).toBeInTheDocument();
const chips = screen.getByRole("group", { name: "Show folders" });
expect(within(chips).getByRole("button", { name: "Active" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(within(chips).getByRole("button", { name: "All" }));
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/qc/folders?show=all")).toBe(true));
expect(within(chips).getByRole("button", { name: "All" })).toHaveAttribute("aria-pressed", "true");
});
it("sends a user without the screen to their own screen", async () => {
mockFetch(base(makeHome(), makeMe({ roles: ["warehouse"], screens: ["warehouse"] })));
renderApp("/qc/projects/7");
await waitFor(() => expect(screen.queryByText("Q.C. folder · Project · Open")).not.toBeInTheDocument());
});
});
+171
View File
@@ -0,0 +1,171 @@
import { useId, useState } from "react";
import { Link } from "react-router";
import { getFolders, getQcHome, type QcCard, type QcColumn, type QcFolderCard } from "../../api/qc";
import type { ColumnDef } from "../../components/Board";
import { Card } from "../../components/Card";
import { FindBar } from "../../components/FindBar";
import { Kpis } from "../../components/Kpis";
import type { UnitPick } from "../../components/PickUnit";
import { useChanged } from "../../lib/changes";
import { useLoad } from "../../lib/useLoad";
import { ProjectPill } from "../clerical/common";
import { matchCard, plural } from "./folders";
const COLUMNS: ReadonlyArray<{ key: QcColumn; title: string; empty: string }> = [
{ key: "ready", title: "Ready for inspection", empty: "Supervisors send packets here when the data book package and the spool are ready." },
{ key: "accepted", title: "Accepted", empty: "Nothing accepted yet." },
{ key: "rejected", title: "Rejected — back with supervisor", empty: "No open rejections." },
];
type FolderShow = "active" | "all";
function FolderCard({ f }: { f: QcFolderCard }) {
return (
<Link className="card pcard" to={`/qc/projects/${f.id}`}>
<div className="r1">
<span className="job">{f.number}</span>
<ProjectPill status={f.status} />
</div>
<div className="tt">{f.name || "-"}</div>
<div className="small muted">
{f.customer ?? ""}
{f.po ? (
<>
{f.customer ? " · " : ""}PO <span className="mono">{f.po}</span>
</>
) : null}
</div>
<div className="small muted">
{plural(f.units, "unit")} · {plural(f.docs, "document")}
</div>
{f.units_without_itp ? <div className="small due soon">{plural(f.units_without_itp, "unit")} without an ITP</div> : null}
</Link>
);
}
/** The project folders: Active from the home answer, All on its own call. */
function FoldersSection({ active }: { active: QcFolderCard[] }) {
const id = useId();
const [show, setShow] = useState<FolderShow>("active");
const [allLoad, reloadAll] = useLoad((s) => (show === "all" ? getFolders("all", s) : Promise.resolve(null)), show);
useChanged(() => {
if (show === "all") reloadAll();
});
const list = show === "all" ? (allLoad.state === "ok" ? (allLoad.data ?? []) : null) : active;
return (
<section className="sec qcfolders" aria-labelledby={id}>
<div className="sec-h">
<h2 id={id}>Project folders</h2>
<span className="ct">Q.C. documents for each project and unit: ITP, WPS/PQR, procedures</span>
<span className="grow" />
<div className="chips" role="group" aria-label="Show folders">
{(
[
["active", "Active"],
["all", "All"],
] as const
).map(([k, l]) => (
<button key={k} type="button" className="chip sm" aria-pressed={show === k} onClick={() => setShow(k)}>
{l}
</button>
))}
</div>
</div>
{show === "all" && allLoad.state === "error" ? <div className="callout warn">{allLoad.message}</div> : null}
{list === null ? (
allLoad.state === "loading" ? <div className="empty slim">Loading</div> : null
) : list.length ? (
<div className="projgrid">
{list.map((f) => (
<FolderCard key={f.id} f={f} />
))}
</div>
) : (
<div className="empty">No projects yet. Folders appear here as soon as Clerical sets up a project and its units.</div>
)}
</section>
);
}
function cardExtra(col: QcColumn, c: QcCard) {
const loc = col === "ready" ? c.qc?.location : null;
const holds = c.itp_hold_count ?? 0;
if (!loc && !holds) return undefined;
return (
<>
{loc ? <div className="small near">Spool at {loc}</div> : null}
{holds ? <div className="small due soon">ITP: {holds} open hold / witness point{holds === 1 ? "" : "s"}</div> : null}
</>
);
}
/** /qc (the old homeQC): the numbers, the three boards and the project folders. */
export function QcHome() {
const [q, setQ] = useState("");
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
const projectId = pick.project?.id;
const unitId = pick.unit?.id;
const [load, reload] = useLoad(
(s) => getQcHome(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}, s),
`${projectId ?? ""}|${unitId ?? ""}`,
);
useChanged(reload, { delayMs: 500 });
const data = load.state === "ok" ? load.data : null;
const k = data?.kpis;
const columns = COLUMNS.map((c) => {
const all = data?.columns[c.key] ?? [];
const shown = all.filter((x) => matchCard(x, q));
return { ...c, cards: shown, empty: all.length && !shown.length ? "Nothing here matches the filters." : c.empty };
});
const count = data ? columns.reduce((n, c) => n + c.cards.length, 0) : null;
return (
<>
<header className="phead">
<div style={{ minWidth: 0 }}>
<div className="eyebrow">Q.C. · Inspection</div>
<h1>Inspect the spool and the data book</h1>
</div>
</header>
<Kpis
items={[
{ n: k?.waiting ?? 0, label: "Waiting for inspection", alert: true },
{ n: k?.accepted_7d ?? 0, label: "Accepted last 7 days" },
{ n: k?.rejected_7d ?? 0, label: "Rejected last 7 days", alert: true },
]}
/>
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} count={count} />
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
{data ? (
<div className="board">
{columns.map((c) => (
<QcColumnView key={c.key} title={c.title} col={c.key} cards={c.cards} empty={c.empty} />
))}
</div>
) : null}
{data ? <FoldersSection active={data.folders} /> : null}
</>
);
}
/** The shared Column, with the Q.C. lines under each card. */
function QcColumnView({ title, col, cards, empty }: { title: string; col: QcColumn; cards: QcCard[]; empty: ColumnDef["empty"] }) {
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
return (
<section className="col" aria-labelledby={id}>
<h3 className="col-h" id={id}>
{title}
<span className="c">{cards.length}</span>
</h3>
{cards.length ? cards.map((p) => <Card key={p.id} p={p} extra={cardExtra(col, p)} />) : <div className="empty">{empty}</div>}
</section>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router";
import { getProjectFolder, type QcDoc, type QcProjectFolder as Folder, type QcUnitFolder } from "../../api/qc";
import { useAuth } from "../../auth/useAuth";
import { useChanged } from "../../lib/changes";
import { PROJECT_STATUS_LABEL } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { ProjectPill } from "../clerical/common";
import { canQcDocs, itpTile, plural, useToast } from "./folders";
import { DocList, FolderSection, SupersededSection, Toast } from "./parts";
import { QcDocSheet, type QcDocTarget } from "./QcDocSheet";
/** /qc/projects/:projectId (the old qcFolderView for a project). */
export function QcProjectFolder() {
const { projectId } = useParams();
const id = Number(projectId);
const { me } = useAuth();
const roles = me?.roles ?? [];
const [load, reload] = useLoad((s) => getProjectFolder(id, s), String(id));
const [data, setData] = useState<Folder | null>(null);
const [sheet, setSheet] = useState<{ replaces: QcDoc | null } | null>(null);
const [toast, setToast] = useToast();
useChanged(reload, { delayMs: 500 });
useEffect(() => {
if (load.state === "ok") setData(load.data);
}, [load]);
const back = (
<Link className="back" to="/qc">
← Back
</Link>
);
if (!data) {
if (load.state === "error")
return (
<>
{back}
<div className="callout warn">{load.message}</div>
</>
);
return (
<>
{back}
<div className="empty slim">Loading</div>
</>
);
}
const p = data.project;
const canAdd = canQcDocs(roles);
const projectHasItp = data.docs.some((d) => d.type === "ITP");
const target: QcDocTarget = { scope: "project", id: p.id, number: p.number };
const units = [...data.units].sort((a, b) => a.number.localeCompare(b.number, undefined, { numeric: true }));
return (
<>
{back}
<nav className="crumbs" aria-label="Where you are">
<Link className="back" to="/qc">
All project folders
</Link>
<span>›</span>
<b>{p.number}</b>
</nav>
<div className="dhead">
<div className="grow0" style={{ minWidth: 0 }}>
<div className="eyebrow">Q.C. folder · Project · {PROJECT_STATUS_LABEL[p.status] ?? p.status}</div>
<h1>
{p.number}
{p.name ? ` — ${p.name}` : ""}
</h1>
<div className="small muted">{[p.customer, p.po ? `PO ${p.po}` : ""].filter(Boolean).join(" · ")}</div>
</div>
<div className="pacts">
{canAdd ? (
<button className="btn pri" type="button" onClick={() => setSheet({ replaces: null })}>
+ Add document
</button>
) : null}
</div>
</div>
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
<FolderSection title="Project documents" ct="cover every unit of this project">
{data.docs.length ? (
<DocList docs={data.docs} label="Project documents" onRevise={canAdd ? (d) => setSheet({ replaces: d }) : undefined} />
) : (
<div className="empty">No documents yet.{canAdd ? ' Use "Add document" to file the ITP or other Q.C. paperwork for this project.' : ""}</div>
)}
</FolderSection>
<FolderSection title="Unit folders" ct={String(units.length)}>
{units.length ? (
<div className="qcunits">
{units.map((u) => {
const tile = itpTile(u, projectHasItp);
return (
<Link key={u.id} className="card qcunit" to={`/qc/units/${u.id}`}>
<div className="r1">
<span className="job">{u.number}</span>
<ProjectPill status={u.status} />
</div>
<div className="qm">
<span>{plural(u.docs, "doc")}</span>
{tile ? <span className={tile.cls || undefined}>{tile.text}</span> : null}
</div>
</Link>
);
})}
</div>
) : (
<div className="empty">No units yet. They appear here when Clerical adds them.</div>
)}
</FolderSection>
<SupersededSection docs={data.superseded} />
{sheet ? (
<QcDocSheet
target={target}
replaces={sheet.replaces}
onCancel={() => setSheet(null)}
onDone={(folder: Folder | QcUnitFolder, text) => {
setSheet(null);
if ("units" in folder) setData(folder);
else reload();
setToast(text);
}}
/>
) : null}
<Toast text={toast} />
</>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { getUnitFolder, startItp, type ItpSummary, type QcDoc, type QcProjectFolder, type QcUnitFolder as Folder } from "../../api/qc";
import { useAuth } from "../../auth/useAuth";
import { useGuard } from "../../components/useGuard";
import { useChanged } from "../../lib/changes";
import { errorText } from "../../lib/format";
import { PROJECT_STATUS_LABEL } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { canItp, canQcDocs, useToast } from "./folders";
import { progressText } from "./itpView";
import { DocList, FolderSection, SupersededSection, Toast } from "./parts";
import { QcDocSheet, type QcDocTarget } from "./QcDocSheet";
/** The old itpFolderCard: start the ITP, or open it with its progress. */
function ItpCard({ unitId, itp, canStart, onStart, busy }: { unitId: number; itp: ItpSummary | null; canStart: boolean; onStart: () => void; busy: boolean }) {
if (!itp)
return (
<div className="itpcard">
<div className="grow">
<b>No digital ITP yet.</b>
<div className="small muted">
Start it from the Q.C. template. The header fills in from this project and unit. Mark lines N/A that don't apply to this unit.
</div>
</div>
{canStart ? (
<button className="btn pri" type="button" disabled={busy} onClick={onStart}>
Start ITP from template
</button>
) : null}
</div>
);
const pct = Math.round((itp.done / Math.max(1, itp.total)) * 100);
const done = itp.status === "complete";
return (
<Link className="itpcard card" to={`/qc/units/${unitId}/itp`}>
<div className="grow">
<div className="r1">
<span className="job">{itp.no}</span>
{done ? <span className="pill st-in_progress">Signed off</span> : <span className="pill st-review">Open</span>}
</div>
<div className="bar" role="progressbar" aria-label="ITP progress" aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}>
<i style={{ width: `${pct}%` }} />
</div>
<div className={`small ${!done && itp.holds ? "due soon" : "muted"}`}>{progressText(itp)}</div>
</div>
<span className="btn sm">Open ITP</span>
</Link>
);
}
/** /qc/units/:unitId: the unit's Q.C. folder, its ITP and the project's documents. */
export function QcUnitFolder() {
const { unitId } = useParams();
const id = Number(unitId);
const { me } = useAuth();
const roles = me?.roles ?? [];
const navigate = useNavigate();
const guard = useGuard();
const [load, reload] = useLoad((s) => getUnitFolder(id, s), String(id));
const [data, setData] = useState<Folder | null>(null);
const [sheet, setSheet] = useState<{ replaces: QcDoc | null } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useToast();
useChanged(reload, { delayMs: 500 });
useEffect(() => {
if (load.state === "ok") setData(load.data);
}, [load]);
const back = (
<Link className="back" to={data ? `/qc/projects/${data.project.id}` : "/qc"}>
← Back
</Link>
);
if (!data) {
return (
<>
{back}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : <div className="empty slim">Loading</div>}
</>
);
}
const u = data.unit;
const t = data.project;
const canAdd = canQcDocs(roles);
const target: QcDocTarget = { scope: "unit", id: u.id, number: u.number };
const start = async () => {
setBusy(true);
setError(null);
try {
const r = await guard.run((g) => startItp(u.id, g), { sub: u.number });
if (r) navigate(`/qc/units/${u.id}/itp`);
else setBusy(false);
} catch (err) {
setBusy(false);
setError(errorText(err));
reload();
}
};
return (
<>
{back}
<nav className="crumbs" aria-label="Where you are">
<Link className="back" to="/qc">
All project folders
</Link>
<span>›</span>
<Link className="back" to={`/qc/projects/${t.id}`}>
{t.number}
</Link>
<span>›</span>
<b>{u.number}</b>
</nav>
<div className="dhead">
<div className="grow0" style={{ minWidth: 0 }}>
<div className="eyebrow">Q.C. folder · Unit · {PROJECT_STATUS_LABEL[u.status] ?? u.status}</div>
<h1>{u.number}</h1>
<div className="small muted">
Unit of {t.number}
{t.name ? ` · ${t.name}` : ""}
</div>
</div>
<div className="pacts">
{canAdd ? (
<button className="btn pri" type="button" onClick={() => setSheet({ replaces: null })}>
+ Add document
</button>
) : null}
</div>
</div>
{data.no_itp ? <div className="callout warn mb10">No ITP on file for this unit yet.</div> : null}
{error ? (
<div className="callout warn" role="alert">
{error}
</div>
) : null}
<FolderSection title="Inspection and Test Plan">
<ItpCard unitId={u.id} itp={data.itp} canStart={canItp(roles)} onStart={() => void start()} busy={busy} />
</FolderSection>
<FolderSection title="Unit documents" ct="for this unit only">
{data.docs.length ? (
<DocList docs={data.docs} label="Unit documents" onRevise={canAdd ? (d) => setSheet({ replaces: d }) : undefined} />
) : (
<div className="empty">No documents yet.{canAdd ? ' Use "Add document" to file the ITP or other Q.C. paperwork for this unit.' : ""}</div>
)}
</FolderSection>
<FolderSection title={`From project ${t.number}`} ct="apply to every unit">
{data.inherited.length ? <DocList docs={data.inherited} label={`From project ${t.number}`} /> : <div className="empty">No project-wide documents.</div>}
</FolderSection>
<SupersededSection docs={data.superseded} />
{sheet ? (
<QcDocSheet
target={target}
replaces={sheet.replaces}
onCancel={() => setSheet(null)}
onDone={(folder: Folder | QcProjectFolder, text) => {
setSheet(null);
if ("unit" in folder) setData(folder);
else reload();
setToast(text);
}}
/>
) : null}
{guard.sheet}
<Toast text={toast} />
</>
);
}
+161
View File
@@ -0,0 +1,161 @@
// Synthetic Q.C. answers for the screen tests. No real job data.
import type { Itp, ItpBlock, ItpLine, QcDoc, QcHome, QcProjectFolder, QcUnitFolder } from "../../api/qc";
import { makeMe } from "../../test/auth";
import { makeCard } from "../../test/packets";
import { emptyLine } from "./itpView";
export const QUINN = makeMe({
roles: ["qc"],
screens: ["qc"],
user: { id: "u-quinn", sam_account: "qc.quinn", display_name: "Quinn QC", emp_no: "05001" },
});
export function makeHome(over: Partial<QcHome> = {}): QcHome {
return {
kpis: { waiting: 1, accepted_7d: 2, rejected_7d: 0 },
columns: {
ready: [
{
...makeCard({ id: 1, status: "qc", unit: { id: 11, number: "L999001" } }),
qc: { location: "Bay 2", sent_at: "2026-10-17T08:00:00Z", inspector: null, result: null },
itp_hold_count: 2,
},
],
accepted: [{ ...makeCard({ id: 2, status: "complete", routed_to: "databook", unit: { id: 12, number: "L999002" }, title: "Elbow" }) }],
rejected: [],
},
folders: [
{ id: 7, number: "L999", name: "CI project", customer: "Test Co", po: "5541", status: "open", units: 2, docs: 3, units_without_itp: 1 },
],
...over,
};
}
export function makeDoc(over: Partial<QcDoc> = {}): QcDoc {
return {
id: 1,
project_id: 7,
unit_id: null,
type: "WPS / PQR",
title: "WPS-101 GTAW/SMAW",
rev: "2",
note: null,
asset_id: "a1",
url: "/_blob/a1",
file_name: "wps.pdf",
size: 1000,
auto: false,
added_at: "2026-10-17T08:00:00Z",
added_by: { id: "u-quinn", display_name: "Quinn QC" },
superseded_by: null,
superseded_at: null,
superseded_by_user: null,
...over,
};
}
const PROJECT = { id: 7, number: "L999", name: "CI project", customer: "Test Co", po: "5541", status: "open" as const };
export function makeProjectFolder(over: Partial<QcProjectFolder> = {}): QcProjectFolder {
return {
project: PROJECT,
docs: [makeDoc()],
superseded: [makeDoc({ id: 9, title: "WPS-101 old", rev: "1", superseded_by: 1, superseded_at: "2026-10-17T09:00:00Z", superseded_by_user: { id: "u-quinn", display_name: "Quinn QC" } })],
units: [
{ id: 11, number: "L999001", lot: "001", name: null, status: "open", docs: 1, itp: { id: 5, no: "L999001-ITP", status: "open", done: 12, total: 68, holds: 2 }, has_itp_doc: false },
{ id: 12, number: "L999002", lot: "002", name: null, status: "open", docs: 0, itp: null, has_itp_doc: false },
{ id: 13, number: "L999003", lot: "003", name: null, status: "open", docs: 0, itp: { id: 6, no: "L999003-ITP", status: "complete", done: 68, total: 68, holds: 0 }, has_itp_doc: true },
],
...over,
};
}
export function makeUnitFolder(over: Partial<QcUnitFolder> = {}): QcUnitFolder {
return {
unit: { id: 12, number: "L999002", lot: "002", name: null, status: "open", po: null },
project: PROJECT,
docs: [],
inherited: [makeDoc()],
superseded: [],
itp: null,
no_itp: true,
...over,
};
}
function line(key: string, over: Partial<ItpLine> = {}): ItpLine {
return { ...emptyLine(key), row_version: 1, ...over };
}
function block(key: string, sections: string[], over: Partial<ItpBlock> = {}): ItpBlock {
return {
key,
sections,
scope: sections.length > 1 ? `Sections ${sections[0]}–${sections[sections.length - 1]}` : `Section ${key}`,
final: false,
ready: false,
all_na: false,
signed: false,
signed_at: null,
signed_by: null,
qn: null,
remarks: null,
cust_name: null,
cust_at: null,
cust_by: null,
row_version: 1,
...over,
};
}
/** Four sections on three pages; blocks 2 (sections 1 to 2), 3 and 4 (final). */
export function makeItp(over: Partial<Itp> = {}): Itp {
return {
id: 5,
unit_id: 12,
no: "L999002-ITP",
hdr: { client: "Test Co", pname: "CI project", pno: "L999", po: "5541", unit: "L999002" },
status: "open",
version: 3,
template: {
ver: "ITP_Generation_Revised_2",
legend: "O = Observe, R = Review, H = Hold",
sections: [
{
n: "1",
t: "GENERAL FABRICATION and DATA",
page: "WP",
items: [
{ n: "1.1", t: "Material review", code: "R", cust: "R", crit: "MTRs match" },
{ n: "1.2", t: "Fit-up", code: "H", cust: "W", crit: "Per drawing" },
],
},
{ n: "2", t: "WELDING", page: "WP", sig: true, items: [{ n: "2.1", t: "Visual weld exam", code: "I", cust: "R", crit: "No defects" }] },
{ n: "3", t: "CODE", page: "C", sig: true, items: [{ n: "3.1", t: "Hydrotest", code: "W", cust: "W", crit: "Hold 30 min" }] },
{ n: "4", t: "FINAL", page: "FA", sig: true, final: true, items: [{ n: "4.1", t: "Final review", code: "R", cust: "R", crit: "All done" }] },
],
},
pages: [
{ code: "WP", name: "Weld Parts" },
{ code: "C", name: "Code" },
{ code: "FA", name: "Final Assembly" },
],
lines: {
"1.1": line("1.1"),
"1.2": line("1.2"),
"2.1": line("2.1"),
"3.1": line("3.1", { na: true, result: "na" }),
"4.1": line("4.1"),
},
blocks: [block("2", ["1", "2"]), block("3", ["3"], { ready: true, all_na: true }), block("4", ["4"], { final: true })],
progress: { done: 1, total: 5, holds: 1 },
holds: [{ n: "1.2", t: "Fit-up", page: "WP", codes: ["H", "W"], rejected: false, ncr: null }],
signoff: null,
reopen: null,
created_at: "2026-10-17T08:00:00Z",
created_by: { id: "u-quinn", display_name: "Quinn QC" },
can: { edit: true, sign_off: false, reopen: false },
log: [{ id: 1, at: "2026-10-17T08:00:00Z", by: { id: "u-quinn", display_name: "Quinn QC" }, by_name: "Quinn QC", action: "ITP started", note: "L999002-ITP · template ITP_Generation_Revised_2", signed: false }],
...over,
};
}
+82
View File
@@ -0,0 +1,82 @@
// Q.C. folder helpers: the document types, who may add, dates, the card search, the unit tile text and the toast.
import { useEffect, useState } from "react";
import type { ItpSummary, QcCard, QcDoc, QcFolderUnitRow } from "../../api/qc";
import { jobName } from "../../lib/packets";
/** The closed set of Q.C. folder document types (the old QC_TYPES). */
export const QC_TYPES = [
"ITP",
"WPS / PQR",
"NDE procedure",
"Hydrotest procedure",
"PMI / material",
"Calibration certificate",
"Customer spec",
"Other",
] as const;
/** Q.C. and Clerical add folder documents (QC_DOC_ADD, CL_OR_QC). Admin can, with an IT reason. */
export function canQcDocs(roles: readonly string[]): boolean {
return roles.some((r) => ["qc", "qc_supervisor", "clerical", "clerical_supervisor", "admin"].includes(r));
}
/** Q.C. starts and works the ITP. Admin can, with an IT reason. */
export function canItp(roles: readonly string[]): boolean {
return roles.some((r) => r === "qc" || r === "qc_supervisor" || r === "admin");
}
/** "Oct 17, 2026" (the old fmtQcDate), or empty. */
export function fmtQcDate(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(/^\d{4}-\d{2}-\d{2}$/.test(iso) ? `${iso}T12:00:00` : iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
/** "WPS-101.pdf · added Oct 17, 2026 by Quinn QC", plus the superseded part on an old row. */
export function docMeta(d: QcDoc): string {
const parts = [d.file_name ?? "", `added ${fmtQcDate(d.added_at)}${d.added_by ? ` by ${d.added_by.display_name}` : ""}`];
if (d.superseded_by !== null && d.superseded_by !== undefined) {
parts.push(`superseded ${fmtQcDate(d.superseded_at)}${d.superseded_by_user ? ` by ${d.superseded_by_user.display_name}` : ""}`);
}
return parts.filter(Boolean).join(" · ");
}
/** The unit tile's ITP text and colour: "ITP ✓", "ITP 12/68" (amber with holds), "no ITP" (amber), or null. */
export function itpTile(u: Pick<QcFolderUnitRow, "itp" | "has_itp_doc" | "status">, projectHasItpDoc = false): { text: string; cls: string } | null {
const r: ItpSummary | null = u.itp;
if (r) {
if (r.status === "complete") return { text: "ITP ✓", cls: "ok" };
return { text: `ITP ${r.done}/${r.total}`, cls: r.holds ? "due soon" : "" };
}
if (u.has_itp_doc || projectHasItpDoc) return { text: "ITP ✓", cls: "ok" };
if (u.status !== "closed") return { text: "no ITP", cls: "due soon" };
return null;
}
/** "1 unit", "2 units". */
export function plural(n: number, word: string): string {
return `${n} ${word}${n === 1 ? "" : "s"}`;
}
/** Whether a board card matches the find box: unit, op, title, drawing, customer, project or location. */
export function matchCard(c: QcCard, q: string): boolean {
const t = q.trim().toLowerCase();
if (!t) return true;
const hay = [jobName(c), c.title, c.drawing, c.customer, c.project?.number, c.project?.name, c.qc?.location, c.qc?.inspector]
.filter(Boolean)
.join(" ")
.toLowerCase();
return t.split(/\s+/).every((w) => hay.includes(w));
}
/** A toast text that clears itself after a moment. */
export function useToast(): [string | null, (t: string | null) => void] {
const [toast, setToast] = useState<string | null>(null);
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 2600);
return () => window.clearTimeout(t);
}, [toast]);
return [toast, setToast];
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { itpTile } from "./folders";
import { blockScope, emptyLine, holdLabel, initials, isDone, isHoldCode, isOpenHold, progressText } from "./itpView";
describe("itpView helpers", () => {
it("initials: first letters, upper case, at most three", () => {
expect(initials("Quinn QC")).toBe("QQ");
expect(initials("mary ann de la cruz")).toBe("MAD");
expect(initials("")).toBe("");
expect(initials(null)).toBe("");
});
it("blockScope", () => {
expect(blockScope(["1", "2", "3"])).toBe("Sections 1–3");
expect(blockScope(["4"])).toBe("Section 4");
});
it("isHoldCode: H or W alone or after a slash", () => {
expect(isHoldCode("H")).toBe(true);
expect(isHoldCode("W")).toBe(true);
expect(isHoldCode("R/W")).toBe(true);
expect(isHoldCode("R/AU")).toBe(false);
expect(isHoldCode("NA")).toBe(false);
expect(isHoldCode("")).toBe(false);
});
it("holdLabel", () => {
expect(holdLabel({ n: "4.2", t: "Prior to vessel closing", codes: ["H"], rejected: false, ncr: null })).toBe("4.2 Prior to vessel closing (H)");
expect(holdLabel({ n: "4.2", t: "Prior to vessel closing", codes: ["H", "R"], rejected: true, ncr: "NCR-2026-0021" })).toBe(
"4.2 Prior to vessel closing (rejected, NCR NCR-2026-0021)",
);
expect(holdLabel({ n: "4.2", t: "X", codes: [], rejected: true, ncr: null })).toBe("4.2 X (rejected, NCR pending)");
});
it("progressText", () => {
expect(progressText({ done: 12, total: 68, holds: 2, status: "open" })).toBe("12 of 68 lines signed or N/A · 2 open hold / witness points");
expect(progressText({ done: 12, total: 68, holds: 1, status: "open" })).toBe("12 of 68 lines signed or N/A · 1 open hold / witness point");
expect(progressText({ done: 68, total: 68, holds: 0, status: "open" })).toBe("68 of 68 lines signed or N/A");
expect(progressText({ done: 60, total: 68, holds: 3, status: "complete" })).toBe("60 of 68 lines signed or N/A");
});
it("done and open holds", () => {
const item = { n: "1.2", t: "Fit-up", code: "R", cust: "W", crit: "" };
const l = emptyLine("1.2");
expect(isDone(l)).toBe(false);
expect(isOpenHold(item, l)).toBe(true);
expect(isOpenHold(item, { ...l, cust_code: "R" })).toBe(false);
expect(isOpenHold(item, { ...l, result: "approve" })).toBe(false);
expect(isDone({ ...l, na: true })).toBe(true);
});
it("itpTile", () => {
const itp = { id: 1, no: "X-ITP", status: "open" as const, done: 12, total: 68, holds: 2 };
expect(itpTile({ itp, has_itp_doc: false, status: "open" })).toEqual({ text: "ITP 12/68", cls: "due soon" });
expect(itpTile({ itp: { ...itp, holds: 0 }, has_itp_doc: false, status: "open" })).toEqual({ text: "ITP 12/68", cls: "" });
expect(itpTile({ itp: { ...itp, status: "complete" }, has_itp_doc: false, status: "open" })).toEqual({ text: "ITP ✓", cls: "ok" });
expect(itpTile({ itp: null, has_itp_doc: true, status: "open" })).toEqual({ text: "ITP ✓", cls: "ok" });
expect(itpTile({ itp: null, has_itp_doc: false, status: "open" }, true)).toEqual({ text: "ITP ✓", cls: "ok" });
expect(itpTile({ itp: null, has_itp_doc: false, status: "open" })).toEqual({ text: "no ITP", cls: "due soon" });
expect(itpTile({ itp: null, has_itp_doc: false, status: "closed" })).toBeNull();
});
});
+109
View File
@@ -0,0 +1,109 @@
// Pure helpers for the ITP screen. The server decides every rule; these only shape the page.
import type { Itp, ItpHoldRow, ItpItem, ItpLine, ItpSection, ItpSummary } from "../../api/qc";
/** Page names and order (the old ITP_PAGES and ITP_PAGE_ORDER). */
export const ITP_PAGES: Record<string, string> = { WP: "Weld Parts", C: "Code", FA: "Final Assembly" };
export const ITP_PAGE_ORDER = ["WP", "C", "FA"] as const;
/** Customer codes (the old ITP_CUST_CODES). */
export const ITP_CUST_CODES = ["R", "R/AU", "AU", "W", "H", "NA"] as const;
/** A hold or witness code: H or W, alone or after a slash. */
export function isHoldCode(code: string | null | undefined): boolean {
return /(^|\/)(H|W)$/.test(String(code ?? ""));
}
/** The first letter of each word of the name, upper case, at most three (the old initials()). */
export function initials(name: string | null | undefined): string {
return String(name ?? "")
.split(/\s+/)
.filter(Boolean)
.map((w) => (w[0] ?? "").toUpperCase())
.join("")
.slice(0, 3);
}
/** "Sections 1–3" or "Section 4". */
export function blockScope(sections: readonly string[]): string {
if (sections.length > 1) return `Sections ${sections[0] ?? ""}–${sections[sections.length - 1] ?? ""}`;
return `Section ${sections[0] ?? ""}`;
}
/** "4.2 Prior to vessel closing (H)", "(rejected, NCR NCR-2026-0021)" or "(rejected, NCR pending)". */
export function holdLabel(h: Pick<ItpHoldRow, "n" | "t" | "codes" | "rejected" | "ncr">): string {
const why = h.rejected ? `rejected, NCR ${h.ncr || "pending"}` : h.codes.filter(isHoldCode).join(", ");
return `${h.n} ${h.t} (${why})`;
}
/** "12 of 68 lines signed or N/A · 2 open hold / witness points". Holds only while the ITP is open. */
export function progressText(p: Pick<ItpSummary, "done" | "total" | "holds" | "status">): string {
const base = `${p.done} of ${p.total} lines signed or N/A`;
if (p.status === "complete" || !p.holds) return base;
return `${base} · ${p.holds} open hold / witness point${p.holds === 1 ? "" : "s"}`;
}
/** An empty line, for keys the server has no row for yet. */
export function emptyLine(key: string): ItpLine {
return {
key,
result: null,
na: false,
by: null,
initials: null,
at: null,
ncr: null,
ncr_missing: false,
note: null,
cust_code: null,
cust_by: null,
cust_at: null,
cust_rec: null,
rejects: [],
row_version: 0,
};
}
export function lineOf(r: Pick<Itp, "lines">, key: string): ItpLine {
return r.lines[key] ?? emptyLine(key);
}
export function isNa(l: ItpLine): boolean {
return l.na || l.result === "na";
}
export function isApproved(l: ItpLine): boolean {
return l.result === "approve";
}
/** Done: N/A or approved. */
export function isDone(l: ItpLine): boolean {
return isNa(l) || isApproved(l);
}
/** An open reject: rejected and not approved since. */
export function isRejected(l: ItpLine): boolean {
return l.result === "reject";
}
/** A hold point not yet done: H or W on the PESCO code or the customer code (the line's own, else the template's). */
export function isOpenHold(item: ItpItem, l: ItpLine): boolean {
return (isHoldCode(item.code) || isHoldCode(l.cust_code || item.cust)) && !isDone(l);
}
/** The newest reject entry (kind reject), if any. */
export function lastReject(l: ItpLine) {
const rs = l.rejects.filter((x) => x.kind !== "ncr");
return rs[rs.length - 1] ?? null;
}
/** The sections of a page, in template order. */
export function sectionsOf(sections: readonly ItpSection[], page: string): ItpSection[] {
return sections.filter((s) => s.page === page);
}
/** Today as a date input value: "2026-10-17". */
export function todayIso(now: Date = new Date()): string {
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${now.getFullYear()}-${m}-${d}`;
}
+79
View File
@@ -0,0 +1,79 @@
import { useId, useState, type ReactNode } from "react";
import type { QcDoc } from "../../api/qc";
import { docMeta } from "./folders";
/** The folder documents as .qcdoc rows: type tag, title, rev, file and who, note, View and New revision. */
export function DocList({ docs, label, old = false, onRevise }: { docs: QcDoc[]; label: string; old?: boolean; onRevise?: ((d: QcDoc) => void) | undefined }) {
return (
<ul className="qcdocs" aria-label={label}>
{docs.map((d) => (
<li key={d.id} className={old ? "qcdoc old" : "qcdoc"}>
<span className="tag">{d.type || "Other"}</span>
<div className="m">
<b>{d.title || d.file_name || "Document"}</b>
{d.rev ? <span className="muted small"> Rev {d.rev}</span> : null}
<div className="small muted">{docMeta(d)}</div>
{d.note ? <div className="small">{d.note}</div> : null}
</div>
<div className="a">
<button type="button" className="btn sm" onClick={() => window.open(d.url, "_blank", "noopener")}>
View
</button>
{onRevise ? (
<button type="button" className="btn sm ghost" onClick={() => onRevise(d)}>
New revision
</button>
) : null}
</div>
</li>
))}
</ul>
);
}
/** A section with an h2, the count or note beside it, and its body. */
export function FolderSection({ title, ct, extra, children }: { title: string; ct?: string; extra?: ReactNode; children: ReactNode }) {
const id = useId();
return (
<section className="sec" aria-labelledby={id}>
<div className="sec-h">
<h2 id={id}>{title}</h2>
{ct ? <span className="ct">{ct}</span> : null}
{extra ? (
<>
<span className="grow" />
{extra}
</>
) : null}
</div>
{children}
</section>
);
}
/** The Superseded section: count, Show / Hide, the old rows. */
export function SupersededSection({ docs }: { docs: QcDoc[] }) {
const [open, setOpen] = useState(false);
if (!docs.length) return null;
return (
<FolderSection
title="Superseded"
ct={String(docs.length)}
extra={
<button type="button" className="btn sm ghost" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
{open ? "Hide" : "Show"}
</button>
}
>
{open ? <DocList docs={docs} label="Superseded documents" old /> : null}
</FolderSection>
);
}
export function Toast({ text }: { text: string | null }) {
return text ? (
<div className="toast" role="status">
{text}
</div>
) : null;
}
+74
View File
@@ -559,3 +559,77 @@ body.fs-view .toast { z-index: 99; }
.mb10 { margin-bottom: 10px; } .mb10 { margin-bottom: 10px; }
.mt8 { margin-top: 8px; } .mt8 { margin-top: 8px; }
.mt10 { margin-top: 10px; } .mt10 { margin-top: 10px; }
/* Phase 6: packet page. From .vtb, .qcdoc, .qcdocs, .itphold and the qcTab layout of the old build. */
.vtbs { display: flex; gap: 6px; }
.vtb { border: 2px solid var(--line2); background: var(--panel); color: var(--body); border-radius: 8px; padding: 8px 14px; font-weight: 700; cursor: pointer; min-height: 42px; }
.vtb:disabled { opacity: .6; cursor: default; }
.vtb.acc[aria-pressed="true"] { background: var(--green); border-color: var(--green); color: var(--on-green); }
.vtb.rej[aria-pressed="true"] { background: var(--pink); border-color: var(--pink); color: var(--on-pink); }
.qctab .ck-item .t .hint { display: block; margin-top: 2px; font-weight: 400; }
.qcinsp { max-width: 720px; }
.qcnde { max-width: 900px; }
.qcnotes { margin-top: 12px; max-width: 900px; }
.qcroute { flex: 0 1 260px; }
.qcbar .acts { align-items: flex-end; }
.qcrej a.lnk, .itphold a.lnk { font-weight: 600; }
.itphold b { color: var(--amber); }
.qcdocs { display: grid; gap: 8px; }
.qcdoc { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
.qcdoc.old { opacity: .7; }
.qcdoc .m { flex: 1 1 240px; min-width: 0; }
.qcdoc .a { display: flex; gap: 8px; flex-wrap: wrap; }
.qchist .pill { margin-right: 4px; }
input.in.w220 { width: 220px; max-width: 100%; }
.mt4 { margin-top: 4px; }
/* Phase 6: Q.C. screens. Ported from the old .qcunits, .qcunit, .qcdocs, .qcdoc, .itpcard, .itphdr, .itppage,
.itpsec, .itprows, .itprow, .itpblock, .sigs, .ini, .rejtag, .custrec blocks, plus a few new helpers. */
.qcfolders { margin-top: 22px; }
.qcunits { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
.qcunit { gap: 8px; padding: 10px 12px; align-items: stretch; text-align: left; }
.qcunit .job { font-size: 17px; }
.qcunit .qm { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--muted); }
.qcunit .qm .ok, .qcdoc .ok { color: var(--green); }
.qcdocs { display: grid; gap: 8px; list-style: none; margin: 0; padding: 0; }
.qcdoc { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
.qcdoc.old { opacity: .7; }
.qcdoc .tag { font-size: 12px; font-weight: 700; padding: 3px 8px; border-radius: 6px; background: var(--panel2); border: 1px solid var(--line2); white-space: nowrap; }
.qcdoc .m { flex: 1 1 240px; min-width: 0; }
.qcdoc .a { display: flex; gap: 8px; flex-wrap: wrap; }
.itpcard { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; padding: 14px 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
a.itpcard { flex-direction: row; text-align: left; }
.itpcard .grow { flex: 1 1 260px; min-width: 0; }
.itpcard .bar { background: var(--panel2); margin: 8px 0 6px; }
.itphdr { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 10px; }
.itppage { margin: 26px 0 4px; font-family: var(--f-disp); font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: var(--cyan); }
.itppage span { color: var(--muted); margin-right: 6px; }
.itpsec .sec-h h2 { font-size: 17px; }
.itprows { display: grid; gap: 6px; }
.itprow { display: grid; grid-template-columns: 42px minmax(0, 1fr) 44px 210px 130px 44px; gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
.itprow.hold { border-left: 3px solid var(--amber); }
.itprow.rej { border-left: 3px solid var(--red); }
.itprow .rejtag { font-size: 12px; color: var(--red); flex-basis: 100%; }
.itprow .apr { display: flex; gap: 6px; flex-wrap: wrap; }
.itprow.na { opacity: .55; }
.itprow.na .t b { text-decoration: line-through; }
.itprow .cd { display: inline-block; min-width: 34px; text-align: center; font-weight: 700; font-size: 12px; padding: 3px 6px; border-radius: 6px; background: var(--panel2); border: 1px solid var(--line2); }
.itprow .qc, .itprow .cu { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
.itprow.hd { background: none; border: 0; padding: 0 12px; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.itprow .ini { font-family: var(--f-disp); font-weight: 700; font-size: 16px; color: var(--green); }
.itpblock { margin: 8px 0 18px; padding: 12px 14px; border: 1px dashed var(--line2); border-radius: 10px; }
.itpblock .sigs { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; margin-top: 8px; }
.itpblock .custrec { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.itpblock .custrec .in { max-width: 200px; }
@media (max-width: 760px) {
.itprow.hd { display: none; }
.itprow { grid-template-columns: 38px minmax(0, 1fr) 40px; }
.itprow .qc { grid-column: 2; grid-row: 2; }
.itprow .cu { grid-column: 2 / 4; grid-row: 3; }
.itprow .mb { grid-column: 3; grid-row: 2; }
}
/* New: the line sheet's N/A checkbox row, the ITP log list and the write message pinned to the bottom. */
.itpck { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--near); }
.itpck input { width: 20px; height: 20px; }
.itplog { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; color: var(--body); }
.itpmsg { position: fixed; left: 50%; bottom: calc(20px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); z-index: 60; max-width: calc(100% - 32px); box-shadow: 0 6px 20px rgba(0, 0, 0, .4); }
+744
View File
@@ -0,0 +1,744 @@
"""The digital ITP: read, start, header, lines, sections, blocks, sign-off and reopen.
Reads need any session. Every write runs policy.engine.check_scoped with its action, then locks the ITP row; the
answer is the full ITP read. ITP writes never look at a packet: a filed packet in the unit does not lock the ITP.
"""
from __future__ import annotations
from datetime import date
from typing import Any
from fastapi import APIRouter, Depends, Request
from psycopg.types.json import Jsonb
from pydantic import BaseModel, Field
from .. import policy
from ..domain import itp as dom
from ..domain import ncr as ncrs
from ..domain import packets
from ..domain.itp_template import ITP_TEMPLATE
from ..domain.pipeline import SignBody, apply_field_cas, jsonable, parse_if_match
from ..text import LIMITS, clean_opt, clean_text
from .deps import Db, Principal, current_principal, get_db
from .errors import ApiError
from .packets import WRITE
router = APIRouter(tags=["itp"])
A = policy.Action
HDR_MAX = 120
CUST_BY_MAX = 80
NOTE_MAX = 500
QN_MAX = 40
REMARKS_MAX = 400
CUST_NAME_MAX = 80
HDR_FIELDS = ("no", *dom.HDR_KEYS)
MSG_NO_LINE = "No such ITP line."
MSG_NO_SECTION = "No such ITP section."
MSG_NO_BLOCK = "No such ITP block."
# ---------------------------------------------------------------- bodies
class It(BaseModel):
it_reason: str | None = Field(default=None, max_length=1000)
class HeaderBody(It):
set: dict[str, str | None] = Field(max_length=10)
base: dict[str, str | None] = Field(default_factory=dict, max_length=10)
class LineBody(It):
na: bool | None = None
cust_code: str | None = Field(default=None, max_length=20)
cust_by: str | None = Field(default=None, max_length=500)
cust_at: date | None = None
note: str | None = Field(default=None, max_length=2000)
class SectionNaBody(It):
na: bool
class RejectBody(It):
ncr: str | None = Field(default=None, max_length=100)
note: str | None = Field(default=None, max_length=2000)
class NcrBody(It):
ncr: str = Field(max_length=100)
class BlockBody(It):
qn: str | None = Field(default=None, max_length=500)
remarks: str | None = Field(default=None, max_length=2000)
cust_name: str | None = Field(default=None, max_length=500)
cust_at: date | None = None
cust_clear: bool | None = None
class SignBodyOnly(It):
sign: SignBody | None = None
class ReopenBody(It):
reason: str | None = Field(default=None, max_length=2000)
# ---------------------------------------------------------------- helpers
def _pp(d: Db, p: Principal) -> policy.Principal:
return packets.policy_principal_for(d.conn, p)
def _answer(d: Db, p: Principal, itp_id: int) -> dict[str, Any]:
row = dom.load_row(d.conn, itp_id)
if row is None:
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
return dom.read(d.conn, row, _pp(d, p))
def _item(w: dom.ItpWrite, key: str) -> dict[str, Any]:
it = next((i for i in dom.items(w.itp["template"] or {}) if str(i["n"]) == key), None)
if it is None:
raise ApiError(404, "not_found", MSG_NO_LINE)
return it
def _line(w: dom.ItpWrite, key: str) -> dict[str, Any]:
_item(w, key)
row = packets.one(
w.conn, "SELECT * FROM itp_lines WHERE itp_id = %s AND key = %s FOR UPDATE", (w.id, key)
) or packets.one(
w.conn,
"INSERT INTO itp_lines (itp_id, key) VALUES (%s, %s) RETURNING *",
(w.id, key),
)
assert row is not None
return row
def _update_line(w: dom.ItpWrite, line_id: int, values: dict[str, Any]) -> None:
sets = ", ".join(f"{k} = %s" for k in values)
w.conn.execute(
f"UPDATE itp_lines SET {sets}, row_version = row_version + 1 WHERE id = %s", # noqa: S608 (fixed field names)
(*values.values(), line_id),
)
def _block(w: dom.ItpWrite, key: str) -> tuple[dict[str, Any], dict[str, Any]]:
b = next((x for x in dom.blocks(w.itp["template"] or {}) if x["key"] == key), None)
if b is None:
raise ApiError(404, "not_found", MSG_NO_BLOCK)
row = packets.one(
w.conn, "SELECT * FROM itp_blocks WHERE itp_id = %s AND block_key = %s FOR UPDATE", (w.id, key)
) or packets.one(
w.conn,
"INSERT INTO itp_blocks (itp_id, block_key) VALUES (%s, %s) RETURNING *",
(w.id, key),
)
assert row is not None
return b, row
def _update_block(w: dom.ItpWrite, block_id: int, values: dict[str, Any]) -> None:
sets = ", ".join(f"{k} = %s" for k in values)
w.conn.execute(
f"UPDATE itp_blocks SET {sets}, row_version = row_version + 1 WHERE id = %s", # noqa: S608 (fixed field names)
(*values.values(), block_id),
)
def _lines(w: dom.ItpWrite) -> dict[str, dict[str, Any]]:
return dom.load_lines(w.conn, w.id)
def _is_admin(p: Principal) -> bool:
return bool(p.is_admin)
def _same_user(a: Any, b: Any) -> bool:
return a is not None and b is not None and str(a) == str(b)
# ---------------------------------------------------------------- read and start
@router.get("/api/units/{unit_id}/itp")
def get_itp(
unit_id: int,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The unit's ITP, or {"itp": null, "unit": {id, number}} when it has none. 404 for an unknown unit."""
conn = d.conn
unit = packets.one(conn, "SELECT id, number FROM v_units WHERE id = %s AND removed_at IS NULL", (unit_id,))
if unit is None:
raise ApiError(404, "not_found", "No such unit.")
row = dom.row_for_unit(conn, unit_id)
if row is None:
return {"itp": None, "unit": {"id": unit["id"], "number": unit["number"]}}
return dom.read(conn, row, _pp(d, p))
@router.post("/api/units/{unit_id}/itp", dependencies=WRITE)
def start_itp(
unit_id: int,
request: Request,
body: It | None = None,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Start the unit's ITP from the template: the template is frozen, the header filled from the unit and project."""
body = body or It()
conn = d.conn
pp = _pp(d, p)
decision, it_kept = dom.scoped_decision(p, pp, A.ITP_START, it_reason=body.it_reason)
with conn.transaction():
unit = packets.one(
conn,
"SELECT u.id, u.po, u.lot, p.number AS pno, p.name AS pname, p.customer, p.po AS project_po"
" FROM units u JOIN projects p ON p.id = u.project_id"
" WHERE u.id = %s AND u.removed_at IS NULL FOR UPDATE OF u",
(unit_id,),
)
if unit is None:
raise ApiError(404, "not_found", "No such unit.")
if dom.row_for_unit(conn, unit_id) is not None:
raise ApiError(409, "itp_exists", "This unit already has an ITP.")
number = f"{unit['pno']}{unit['lot']}"
no = f"{number}-ITP"
hdr = {
"client": unit["customer"] or "",
"pname": unit["pname"] or "",
"pno": unit["pno"] or "",
"po": unit["po"] or unit["project_po"] or "",
"unit": number,
}
template = jsonable(ITP_TEMPLATE)
itp_id = int(
conn.execute(
"INSERT INTO itps (unit_id, template, status, no, hdr, created_by) VALUES (%s, %s, 'open', %s, %s, %s)"
" RETURNING id",
(unit_id, Jsonb(template), no, Jsonb(hdr), p.user_id),
).fetchone()[0]
)
keys = [str(i["n"]) for i in dom.items(template)]
with conn.cursor() as cur:
cur.executemany("INSERT INTO itp_lines (itp_id, key) VALUES (%s, %s)", [(itp_id, k) for k in keys])
cur.executemany(
"INSERT INTO itp_blocks (itp_id, block_key) VALUES (%s, %s)",
[(itp_id, b["key"]) for b in dom.blocks(template)],
)
row = dom.load_row(conn, itp_id, lock=True)
assert row is not None
w = dom.ItpWrite(
conn=conn,
principal=p,
policy_principal=pp,
itp=row,
action=A.ITP_START,
decision=decision,
request=request,
it_reason=it_kept,
version=int(row["version"]),
)
w.log("ITP started", f"{no} · template {template.get('ver')}")
dom._finish(w)
return _answer(d, p, itp_id)
# ---------------------------------------------------------------- header
@router.patch("/api/itps/{itp_id}", dependencies=WRITE)
def patch_header(
itp_id: int,
body: HeaderBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The plan number and header fields, with field-level compare-and-set (409 field_conflict)."""
bad = sorted(k for k in body.set if k not in HDR_FIELDS)
if bad:
raise ApiError(422, "invalid_field", f"Not an ITP header field: {', '.join(bad)}.")
values = {k: clean_text(v, HDR_MAX) for k, v in body.set.items()}
base = {k: clean_text(v, HDR_MAX) for k, v in body.base.items() if k in HDR_FIELDS}
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
hdr = dict(w.itp["hdr"] or {})
current = {"no": w.itp["no"] or "", **{k: hdr.get(k) or "" for k in dom.HDR_KEYS}}
changes = apply_field_cas(current, values, base)
if "no" in changes and not changes["no"]:
raise ApiError(422, "invalid_field", "The plan number cannot be empty.")
if not changes:
return _answer(d, p, itp_id)
for k in HDR_FIELDS:
if k not in changes:
continue
if k == "no":
w.log("ITP plan no. changed", changes[k])
else:
hdr[k] = changes[k]
w.log("ITP header changed", f"{k}: {changes[k]}")
w.conn.execute(
"UPDATE itps SET no = %s, hdr = %s WHERE id = %s", (changes.get("no", w.itp["no"]), Jsonb(hdr), itp_id)
)
return _answer(d, p, itp_id)
# ---------------------------------------------------------------- lines
@router.post("/api/itps/{itp_id}/lines/{key}/sign", dependencies=WRITE)
def sign_line(
itp_id: int,
key: str,
request: Request,
body: It | None = None,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Approve a line (Q.C. initials). Optional If-Match is the line's row_version."""
body = body or It()
if_match = parse_if_match(request)
with dom.itp_write(d, p, itp_id, A.ITP_LINE_SIGN, request=request, it_reason=body.it_reason) as w:
line = _line(w, key)
if if_match is not None and int(line["row_version"]) != if_match:
raise ApiError(
409,
"row_conflict",
"Someone else changed this line. Reload and try again.",
extra={"current": {"row_version": line["row_version"], "result": line["result"]}},
)
if line["result"] == "approve":
raise ApiError(409, "already_signed", "This line is already approved.")
if line["result"] == "na":
raise ApiError(409, "line_na", "This line is marked N/A.")
rejected = line["result"] == "reject"
if rejected and not line["ncr"]:
raise ApiError(409, "ncr_required", "Enter the NCR # before approving this line.")
name = p.user["display_name"]
w.conn.execute(
"UPDATE itp_lines SET result = 'approve', by = %s, at = now(), initials = %s,"
" row_version = row_version + 1 WHERE id = %s",
(p.user_id, dom.initials(name), line["id"]),
)
w.log(f"ITP line {key} approved", f"after NCR {line['ncr']}" if rejected else None)
return _answer(d, p, itp_id)
@router.post("/api/itps/{itp_id}/lines/{key}/unsign", dependencies=WRITE)
def unsign_line(
itp_id: int,
key: str,
request: Request,
body: It | None = None,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Remove an approval: the inspector who initialed it, or an admin, while the line's block is not signed."""
body = body or It()
with dom.itp_write(d, p, itp_id, A.ITP_LINE_UNSIGN, request=request, it_reason=body.it_reason) as w:
line = _line(w, key)
if line["result"] != "approve":
raise ApiError(409, "not_signed", "This line is not approved.")
if not (_same_user(line["by"], p.user_id) or _is_admin(p)):
raise ApiError(403, "forbidden", "Only the inspector who initialed this line, or an admin, can remove it.")
blk = dom.block_of_line(w.itp["template"] or {}, key)
if blk is not None:
brow = packets.one(
w.conn, "SELECT signed_at FROM itp_blocks WHERE itp_id = %s AND block_key = %s", (w.id, blk["key"])
)
if brow is not None and brow["signed_at"] is not None:
raise ApiError(409, "block_signed", "Undo the section signature first.")
who = packets.People(w.conn, [line["by"]]).name(line["by"]) or ""
was = f"was {who} {dom.date_text(line['at'], w.tz)}".strip()
w.conn.execute(
"UPDATE itp_lines SET result = NULL, by = NULL, at = NULL, initials = NULL,"
" row_version = row_version + 1 WHERE id = %s",
(line["id"],),
)
w.log(f"ITP line {key} initial removed", was)
return _answer(d, p, itp_id)
@router.patch("/api/itps/{itp_id}/lines/{key}", dependencies=WRITE)
def patch_line(
itp_id: int,
key: str,
body: LineBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""N/A, the customer code and witness, and the note of a line."""
given = body.model_fields_set
cc: str | None = None
if "cust_code" in given:
cc = clean_text(body.cust_code, 20).upper()
if cc and cc not in dom.CUST_CODES:
raise ApiError(422, "invalid_field", "Pick a customer code from the list.")
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
line = _line(w, key)
values: dict[str, Any] = {}
parts: list[str] = []
if "na" in given and body.na is not None and body.na != (line["result"] == "na"):
if body.na:
if line["result"] == "approve":
raise ApiError(409, "already_signed", "Undo the approval first.")
if line["result"] == "reject":
raise ApiError(409, "line_rejected", "This line is rejected.")
values["result"] = "na"
parts.append("marked N/A")
else:
values["result"] = None
parts.append("applies again")
if "cust_code" in given and (cc or None) != (line["cust_code"] or None):
values["cust_code"] = cc or None
parts.append(f"customer code {cc}" if cc else "customer code as template")
if "cust_by" in given:
who = clean_opt(body.cust_by, CUST_BY_MAX)
if who != (line["cust_by"] or None):
values["cust_by"] = who
parts.append(f"customer {who}" if who else "customer cleared")
if who:
values["cust_at"] = body.cust_at or dom.today(w.tz)
values["cust_rec"] = p.user_id
values["cust_rec_at"] = w.conn.execute("SELECT now()").fetchone()[0]
else:
values.update({"cust_at": None, "cust_rec": None, "cust_rec_at": None})
elif who and "cust_at" in given and body.cust_at and body.cust_at != line["cust_at"]:
values["cust_at"] = body.cust_at
parts.append(f"customer date {dom.date_text(body.cust_at)}")
elif "cust_at" in given and line["cust_by"] and body.cust_at and body.cust_at != line["cust_at"]:
values["cust_at"] = body.cust_at
parts.append(f"customer date {dom.date_text(body.cust_at)}")
if "note" in given:
note = clean_opt(body.note, NOTE_MAX)
if note != (line["note"] or None):
values["note"] = note
parts.append("note")
if not values:
return _answer(d, p, itp_id)
_update_line(w, line["id"], values)
w.log(f"ITP line {key} updated", ", ".join(parts))
return _answer(d, p, itp_id)
@router.post("/api/itps/{itp_id}/sections/{n}/na", dependencies=WRITE)
def section_na(
itp_id: int,
n: str,
body: SectionNaBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Mark every line of a section N/A, or make it apply again. Refused once a line of it is approved."""
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
sec = next((s for s in (w.itp["template"] or {}).get("sections") or [] if str(s.get("n")) == n), None)
if sec is None:
raise ApiError(404, "not_found", MSG_NO_SECTION)
rows = [_line(w, str(i["n"])) for i in sec.get("items") or []]
if any(r["result"] == "approve" for r in rows):
raise ApiError(409, "already_signed", "A line of this section is approved. Undo it first.")
if body.na and any(r["result"] == "reject" for r in rows):
raise ApiError(409, "line_rejected", "A line of this section is rejected.")
want = "na" if body.na else None
todo = [r for r in rows if (r["result"] == "na") != body.na]
if not todo:
return _answer(d, p, itp_id)
for r in todo:
_update_line(w, r["id"], {"result": want})
w.log(f"ITP section {n} {'marked N/A' if body.na else 'applies again'}", str(sec.get("t") or ""))
return _answer(d, p, itp_id)
@router.post("/api/itps/{itp_id}/lines/{key}/reject", dependencies=WRITE)
def reject_line(
itp_id: int,
key: str,
body: RejectBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Reject a line, with its NCR number now or later. The line cannot be approved until the NCR is entered."""
ncr = ncrs.validate_ncr(body.ncr) if clean_text(body.ncr, 100) else None
note = clean_opt(body.note, NOTE_MAX)
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
line = _line(w, key)
if line["result"] == "approve":
raise ApiError(409, "already_signed", "This line is approved. Undo the approval first.")
if line["result"] == "na":
raise ApiError(409, "line_na", "This line is marked N/A.")
if line["result"] == "reject":
raise ApiError(409, "line_rejected", "This line is already rejected.")
_update_line(w, line["id"], {"result": "reject", "ncr": ncr})
w.conn.execute(
"INSERT INTO itp_rejects (itp_id, line_key, ncr, note, by, kind) VALUES (%s, %s, %s, %s, %s, 'reject')",
(w.id, key, ncr, note, p.user_id),
)
head = f"NCR {ncr}" if ncr else "NCR # pending"
w.log(f"ITP line {key} rejected", f"{head} · {note}" if note else head)
return _answer(d, p, itp_id)
@router.post("/api/itps/{itp_id}/lines/{key}/ncr", dependencies=WRITE)
def enter_ncr(
itp_id: int,
key: str,
body: NcrBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The NCR number of an open reject that has none yet."""
ncr = ncrs.validate_ncr(body.ncr)
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
line = _line(w, key)
if line["result"] != "reject" or line["ncr"]:
raise ApiError(409, "no_open_reject", "This line has no open reject waiting for an NCR #.")
_update_line(w, line["id"], {"ncr": ncr})
w.conn.execute(
"INSERT INTO itp_rejects (itp_id, line_key, ncr, by, kind) VALUES (%s, %s, %s, %s, 'ncr')",
(w.id, key, ncr, p.user_id),
)
w.log(f"ITP line {key} NCR number entered", f"NCR {ncr}")
return _answer(d, p, itp_id)
# ---------------------------------------------------------------- blocks
@router.patch("/api/itps/{itp_id}/blocks/{key}", dependencies=WRITE)
def patch_block(
itp_id: int,
key: str,
body: BlockBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""QN #, remarks and the customer complete record of a block. No signature."""
given = body.model_fields_set
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
_b, row = _block(w, key)
values: dict[str, Any] = {}
if "qn" in given:
qn = clean_opt(body.qn, QN_MAX)
if qn != (row["qn"] or None):
values["qn"] = qn
w.log(f"ITP block {key} QN # changed", qn or "")
if "remarks" in given:
rem = clean_opt(body.remarks, REMARKS_MAX)
if rem != (row["remarks"] or None):
values["remarks"] = rem
w.log(f"ITP block {key} remarks changed", rem or "")
if body.cust_clear:
if row["cust_name"] or row["cust_at"]:
values.update({"cust_name": None, "cust_at": None, "cust_by": None, "cust_rec_at": None})
w.log(f"ITP block {key} customer complete cleared", row["cust_name"] or "")
elif "cust_name" in given:
name = clean_opt(body.cust_name, CUST_NAME_MAX)
if name is None:
raise ApiError(422, "invalid_field", "Enter the customer rep's name.")
at = body.cust_at or dom.today(w.tz)
if name != row["cust_name"] or at != row["cust_at"]:
values.update(
{
"cust_name": name,
"cust_at": at,
"cust_by": p.user_id,
"cust_rec_at": w.conn.execute("SELECT now()").fetchone()[0],
}
)
w.log(f"ITP block {key} customer complete recorded", name)
if not values:
return _answer(d, p, itp_id)
_update_block(w, row["id"], values)
return _answer(d, p, itp_id)
@router.post("/api/itps/{itp_id}/blocks/{key}/sign", dependencies=WRITE)
def sign_block(
itp_id: int,
key: str,
body: SignBodyOnly,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Sign a block of sections. Every line in it must be approved or N/A."""
conn = d.conn
row0 = dom.load_row(conn, itp_id)
if row0 is None:
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
# Who first, then the ready and unsigned checks before the signature, so a refused step costs no PIN attempt.
# Both run again under the lock below.
dom.scoped_decision(p, _pp(d, p), A.ITP_BLOCK_SIGN, it_reason=body.it_reason)
_block_gate(conn, row0, key)
with dom.itp_write(
d,
p,
itp_id,
A.ITP_BLOCK_SIGN,
request=request,
it_reason=body.it_reason,
sign=body.sign,
sign_extra={"block_key": key},
) as w:
b, row = _block(w, key)
_block_gate(conn, w.itp, key)
_update_block(
w,
row["id"],
{
"signed_at": conn.execute("SELECT now()").fetchone()[0],
"signed_by": p.user_id,
"signature_id": w.signature.id if w.signature else None,
},
)
w.log(f"ITP block {key} signed", dom.block_scope(b))
return _answer(d, p, itp_id)
def _block_gate(conn: Any, itp_row: dict[str, Any], key: str) -> None:
if str(itp_row["status"]) == "complete":
raise ApiError(409, "itp_complete", dom.MSG_COMPLETE)
b = next((x for x in dom.blocks(itp_row["template"] or {}) if x["key"] == key), None)
if b is None:
raise ApiError(404, "not_found", MSG_NO_BLOCK)
brow = packets.one(
conn, "SELECT signed_at FROM itp_blocks WHERE itp_id = %s AND block_key = %s", (itp_row["id"], key)
)
if brow is not None and brow["signed_at"] is not None:
raise ApiError(409, "already_signed", "This block is already signed.")
if not dom.block_ready(b, dom.load_lines(conn, int(itp_row["id"]))):
raise ApiError(409, "block_not_ready", "Every line must be initialed or N/A first.")
@router.delete("/api/itps/{itp_id}/blocks/{key}/sign", dependencies=WRITE)
def unsign_block(
itp_id: int,
key: str,
request: Request,
body: It | None = None,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Remove a block signature: the signer or an admin, while the ITP is open. The block's fields stay."""
body = body or It()
with dom.itp_write(d, p, itp_id, A.ITP_BLOCK_UNSIGN, request=request, it_reason=body.it_reason) as w:
_b, row = _block(w, key)
if row["signed_at"] is None:
raise ApiError(409, "not_signed", "This block is not signed.")
if not (_same_user(row["signed_by"], p.user_id) or _is_admin(p)):
raise ApiError(403, "forbidden", "Only the inspector who signed this block, or an admin, can remove it.")
_update_block(w, row["id"], {"signed_at": None, "signed_by": None, "signature_id": None})
w.log(f"ITP block {key} signature removed")
return _answer(d, p, itp_id)
# ---------------------------------------------------------------- sign-off and reopen
@router.post("/api/itps/{itp_id}/signoff", dependencies=WRITE)
def sign_off(
itp_id: int,
body: SignBodyOnly,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Sign the ITP off: every block signed. The ITP locks; the PDF hook (phase 7) may file a copy."""
from ..pdf import itp as itp_pdf
conn = d.conn
row0 = dom.load_row(conn, itp_id)
if row0 is None:
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
dom.scoped_decision(p, _pp(d, p), A.ITP_SIGN_OFF, it_reason=body.it_reason)
_signoff_gate(conn, row0)
with dom.itp_write(d, p, itp_id, A.ITP_SIGN_OFF, request=request, it_reason=body.it_reason, sign=body.sign) as w:
_signoff_gate(conn, w.itp)
conn.execute(
"UPDATE itps SET status = 'complete', signoff_at = now(), signoff_by = %s, signoff_signature_id = %s"
" WHERE id = %s",
(p.user_id, w.signature.id if w.signature else None, itp_id),
)
row = dom.load_row(conn, itp_id)
assert row is not None
asset_id = itp_pdf.file_signed_itp_pdf(conn, row, p.actor)
if asset_id is not None:
_file_auto_doc(w, row, asset_id)
w.log("ITP signed off", "PDF filed")
else:
w.log("ITP signed off", "PDF filing reserved for phase 7")
return _answer(d, p, itp_id)
def _signoff_gate(conn: Any, itp_row: dict[str, Any]) -> None:
if str(itp_row["status"]) == "complete":
raise ApiError(409, "itp_complete", dom.MSG_COMPLETE)
keys = [b["key"] for b in dom.blocks(itp_row["template"] or {})]
signed = {
r[0]
for r in conn.execute(
"SELECT block_key FROM itp_blocks WHERE itp_id = %s AND signed_at IS NOT NULL", (itp_row["id"],)
)
}
if not keys or any(k not in signed for k in keys):
raise ApiError(409, "blocks_unsigned", "Every section must be signed first.")
AUTO_NOTE = "Filed automatically when the digital ITP was signed off."
def _file_auto_doc(w: dom.ItpWrite, row: dict[str, Any], asset_id: Any) -> None:
"""Store the signed PDF on the ITP and file it in the unit's Q.C. folder, superseding the earlier automatic copy."""
conn = w.conn
conn.execute("UPDATE itps SET signoff_asset_id = %s WHERE id = %s", (asset_id, row["id"]))
new_id = conn.execute(
"INSERT INTO qc_docs (unit_id, type, title, rev, note, asset_id, auto, added_by)"
" VALUES (%s, 'ITP', %s, NULL, %s, %s, true, %s) RETURNING id",
(row["unit_id"], f"{row['no']} — signed off", AUTO_NOTE, asset_id, w.user_id),
).fetchone()[0]
conn.execute(
"UPDATE qc_docs SET superseded_by = %s, superseded_at = now(), superseded_by_user = %s"
" WHERE unit_id = %s AND type = 'ITP' AND auto AND superseded_by IS NULL AND id <> %s",
(new_id, w.user_id, row["unit_id"], new_id),
)
@router.post("/api/itps/{itp_id}/reopen", dependencies=WRITE)
def reopen(
itp_id: int,
body: ReopenBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Reopen a signed-off ITP: a Q.C. supervisor, or an admin (the reason serves as the IT reason when none is given).
The blocks and lines keep their signatures."""
reason = clean_opt(body.reason, LIMITS["reason"])
it_reason = body.it_reason or (body.reason if p.is_admin else None)
extra = {"reason": reason} if reason else {}
with dom.itp_write(
d, p, itp_id, A.ITP_REOPEN, request=request, extra=extra, it_reason=it_reason, allow_complete=True
) as w:
if str(w.itp["status"]) != "complete":
raise ApiError(409, "itp_open", "The ITP is open already.")
w.conn.execute(
"UPDATE itps SET status = 'open', reopen_at = now(), reopen_by = %s, reopen_reason = %s WHERE id = %s",
(p.user_id, reason, itp_id),
)
w.log("ITP reopened", reason)
return _answer(d, p, itp_id)
+189 -21
View File
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
from .. import policy from .. import policy
from ..domain import checklists, packets from ..domain import checklists, packets
from ..domain import qc as qc_dom
from ..domain.children import NEVER_VERIFY, active_depts, co_touched, current_wh, update_row, wh_move from ..domain.children import NEVER_VERIFY, active_depts, co_touched, current_wh, update_row, wh_move
from ..domain.pipeline import ( from ..domain.pipeline import (
SignBody, SignBody,
@@ -260,16 +261,29 @@ class WeldExamIn(BaseModel):
class QcBody(It): class QcBody(It):
"""`{set, base}` for field-level compare-and-set of the scalar fields, or the phase 2 flat body with If-Match on
the inspection's row_version (checks and weld_exam merge per key)."""
checks: dict[str, bool] | None = Field(default=None, max_length=50) checks: dict[str, bool] | None = Field(default=None, max_length=50)
weld_exam: dict[str, WeldExamIn] | None = Field(default=None, max_length=2000) weld_exam: dict[str, WeldExamIn] | None = Field(default=None, max_length=2000)
nde_method: str | None = Field(default=None, max_length=500) nde_method: str | None = Field(default=None, max_length=500)
nde_result: str | None = Field(default=None, max_length=500) nde_result: str | None = Field(default=None, max_length=500)
nde_report: str | None = Field(default=None, max_length=500) nde_report: str | None = Field(default=None, max_length=500)
hardness: str | None = Field(default=None, max_length=500) hardness: str | None = Field(default=None, max_length=500)
nace: bool | None = None
inspector: str | None = Field(default=None, max_length=500) inspector: str | None = Field(default=None, max_length=500)
stamp: str | None = Field(default=None, max_length=40) stamp: str | None = Field(default=None, max_length=40)
notes: str | None = Field(default=None, max_length=5000) notes: str | None = Field(default=None, max_length=5000)
set: dict[str, str | None] | None = Field(default=None, max_length=10)
base: dict[str, str | None] | None = Field(default=None, max_length=10)
class QcCheckBody(It):
done: bool
class QcWeldBody(It):
result: Literal["accept", "reject", ""] | None = None
note: str | None = Field(default=None, max_length=2000)
class CoItemIn(BaseModel): class CoItemIn(BaseModel):
@@ -1457,6 +1471,143 @@ def set_wh_stage(
# ---------------------------------------------------------------- Q.C. # ---------------------------------------------------------------- Q.C.
QC_FIELDS = ("inspector", "stamp", "nde_method", "nde_result", "nde_report", "hardness", "notes")
QC_NOTE_NAMES = {"inspector": "inspector", "stamp": "stamp", "hardness": "hardness", "nde_report": "report"}
def _open_inspection(conn: Any, packet_id: int) -> dict[str, Any]:
insp = packets.one(conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
if insp is None:
raise ApiError(409, "no_inspection", MSG_NO_INSPECTION)
return insp
def _qc_value(key: str, value: Any) -> Any:
"""One scalar Q.C. field, cleaned and checked."""
if key == "notes":
return clean_opt(value, LIMITS["notes"])
if key == "stamp":
return _stamp(value)
text = clean_opt(value, QC_FIELD_MAX)
if key == "nde_method" and text is not None and text not in qc_dom.NDE_METHODS:
raise ApiError(422, "invalid_field", "Pick an NDE method from the list.")
if key == "nde_result" and text is not None and text not in qc_dom.NDE_RESULTS:
raise ApiError(422, "invalid_field", "Pick an NDE result from the list.")
return text
def _qc_logs(w: Write, changes: dict[str, Any]) -> None:
"""The log lines of a Q.C. field change: "NDE method: RT", "NDE result: Acceptable", then "Q.C. record changed"
with the other fields. A method set to None required clears the result and report without a line of their own."""
none = changes.get("nde_method") == qc_dom.NDE_NONE
lines: list[tuple[str, str | None]] = []
if "nde_method" in changes:
lines.append((f"NDE method: {changes['nde_method'] or '-'}", None))
if "nde_result" in changes and not (none and not changes["nde_result"]):
lines.append((f"NDE result: {changes['nde_result'] or '-'}", None))
parts: list[str] = []
for k in ("inspector", "stamp", "hardness", "nde_report", "notes"):
if k not in changes or (k == "nde_report" and none and not changes[k]):
continue
v = changes[k]
if k == "notes":
parts.append("notes changed" if v else "notes cleared")
else:
parts.append(f"{QC_NOTE_NAMES[k]} {v}" if v else f"{QC_NOTE_NAMES[k]} cleared")
if parts:
lines.append(("Q.C. record changed", " · ".join(parts)))
if not lines:
lines.append(("Q.C. record changed", None))
w.log(*lines[0])
for more in lines[1:]:
w.log_also(*more)
@router.put("/{packet_id}/qc/checks/{key}", dependencies=WRITE)
def put_qc_check(
packet_id: int,
key: str,
body: QcCheckBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Tick or untick one Q.C. check. Per-key writes never conflict, so there is no If-Match."""
pp = _pp(d, p)
conn = d.conn
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
items = w.children.qc_items if w.children is not None and w.children.qc_items else None
if key not in checklists.qc_keys(items):
raise ApiError(422, "invalid_key", "That is not a Q.C. check item.")
insp = _open_inspection(conn, packet_id)
if checklists.is_done((insp["checks"] or {}).get(key)) == body.done:
w.skip()
return _read(d, pp, packet_id)
if body.done:
conn.execute(
"UPDATE qc_inspections SET checks = jsonb_set(checks, ARRAY[%s]::text[],"
" jsonb_build_object('done', true, 'at', now(), 'by', %s::text, 'stamp', %s::text)),"
" row_version = row_version + 1 WHERE id = %s",
(key, str(w.user_id), insp["stamp"], insp["id"]),
)
else:
conn.execute(
"UPDATE qc_inspections SET checks = jsonb_set(checks, ARRAY[%s]::text[], %s),"
" row_version = row_version + 1 WHERE id = %s",
(key, Jsonb({"done": False}), insp["id"]),
)
pk = w.packet
texts = checklists.qc_items_for(pk["drawing"], pk["rev"], pk["wps"], pk["welder_stamp"], items)
text = next((i["text"] for i in texts if i["key"] == key), key)
w.changed(f"qc.checks.{key}")
w.log(("Q.C. checked: " if body.done else "Q.C. unchecked: ") + text)
return _read(d, pp, packet_id)
@router.put("/{packet_id}/qc/welds/{no}", dependencies=WRITE)
def put_qc_weld(
packet_id: int,
no: int,
body: QcWeldBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The visual exam of one weld: accept, reject or cleared (empty), and a note."""
given = body.model_fields_set
pp = _pp(d, p)
conn = d.conn
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
live = conn.execute(
"SELECT 1 FROM welds WHERE packet_id = %s AND weld_no = %s AND removed_at IS NULL", (packet_id, no)
).fetchone()
if live is None:
raise ApiError(422, "invalid_weld", "No such weld on this packet.")
insp = _open_inspection(conn, packet_id)
k = str(no)
cur = dict((insp["weld_exam"] or {}).get(k) or {})
result = (body.result or "") if "result" in given else str(cur.get("result") or "")
note = clean_text(body.note, LIMITS["note"]) if "note" in given else str(cur.get("note") or "")
result_changed = result != str(cur.get("result") or "")
note_changed = note != str(cur.get("note") or "")
if not result_changed and not note_changed:
w.skip()
return _read(d, pp, packet_id)
conn.execute(
"UPDATE qc_inspections SET weld_exam = jsonb_set(weld_exam, ARRAY[%s]::text[],"
" %s::jsonb || jsonb_build_object('at', now(), 'by', %s::text)), row_version = row_version + 1"
" WHERE id = %s",
(k, Jsonb({"result": result, "note": note}), str(w.user_id), insp["id"]),
)
label = qc_dom.weld_label(no)
w.changed(f"qc.weld_exam.{k}")
if result_changed:
w.log(f"VT {result} · {label}" if result else f"VT cleared · {label}", note if result and note else None)
else:
w.log("Q.C. record changed", f"{label} note")
return _read(d, pp, packet_id)
@router.patch("/{packet_id}/qc", dependencies=WRITE) @router.patch("/{packet_id}/qc", dependencies=WRITE)
def patch_qc( def patch_qc(
packet_id: int, packet_id: int,
@@ -1465,36 +1616,50 @@ def patch_qc(
p: Principal = Depends(current_principal), # noqa: B008 p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008 d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]: ) -> dict[str, Any]:
"""The open inspection. checks and weld_exam merge per key; If-Match is the inspection's row_version.""" """The open inspection's fields.
if_match = parse_if_match(request)
`{set, base}`: inspector, stamp, nde_method, nde_result, nde_report, hardness and notes with field-level
compare-and-set (409 field_conflict with `current`). The phase 2 flat body with If-Match (the inspection's
row_version) still works and merges checks and weld_exam per key. Setting the NDE method to None required clears
the result and the report. The NACE flag is not written: the read model computes it from the WPS.
"""
cas = body.set is not None
values: dict[str, Any] = {}
base: dict[str, Any] = {}
if_match: int | None = None
if cas:
assert body.set is not None
bad = sorted(k for k in body.set if k not in QC_FIELDS)
if bad:
raise ApiError(422, "invalid_field", f"Not a Q.C. field: {', '.join(bad)}.")
values = {k: _qc_value(k, v) for k, v in body.set.items()}
for k, v in (body.base or {}).items():
if k in QC_FIELDS:
base[k] = clean_opt(v, LIMITS["notes"] if k == "notes" else QC_FIELD_MAX)
else:
given = body.model_fields_set given = body.model_fields_set
if body.checks: if body.checks:
bad = sorted(set(body.checks) - set(checklists.qc_keys())) bad = sorted(set(body.checks) - set(checklists.qc_keys(checklists.load_qc_items(d.conn))))
if bad: if bad:
raise ApiError(422, "invalid_key", f"Not a Q.C. item: {', '.join(bad)}.") raise ApiError(422, "invalid_key", f"Not a Q.C. item: {', '.join(bad)}.")
values: dict[str, Any] = {} values = {k: _qc_value(k, getattr(body, k)) for k in QC_FIELDS if k in given}
for k in ("nde_method", "nde_result", "nde_report", "hardness", "inspector"): if_match = parse_if_match(request)
if k in given: if values.get("nde_method") == qc_dom.NDE_NONE:
values[k] = clean_opt(getattr(body, k), QC_FIELD_MAX) values["nde_result"] = None
if "notes" in given: values["nde_report"] = None
values["notes"] = clean_opt(body.notes, LIMITS["notes"])
if "stamp" in given:
values["stamp"] = _stamp(body.stamp)
if "nace" in given:
values["nace"] = body.nace
pp = _pp(d, p) pp = _pp(d, p)
conn = d.conn conn = d.conn
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w: with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
insp = packets.one( insp = _open_inspection(conn, packet_id)
conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (packet_id,) if cas:
) changes = apply_field_cas({k: insp[k] for k in QC_FIELDS}, values, base)
if insp is None: else:
raise ApiError(409, "no_inspection", MSG_NO_INSPECTION)
check_row_version(insp, if_match) check_row_version(insp, if_match)
changes = _diff(insp, values) changes = _diff(insp, values)
scalar = dict(changes)
at = conn.execute("SELECT now()").fetchone()[0].isoformat() at = conn.execute("SELECT now()").fetchone()[0].isoformat()
by = str(w.user_id) by = str(w.user_id)
if body.checks: if not cas and body.checks:
stamp = values.get("stamp", insp["stamp"]) stamp = values.get("stamp", insp["stamp"])
checks = dict(insp["checks"] or {}) checks = dict(insp["checks"] or {})
for k, done in body.checks.items(): for k, done in body.checks.items():
@@ -1503,7 +1668,7 @@ def patch_qc(
checks[k] = {"done": True, "at": at, "by": by, "stamp": stamp} if done else {"done": False} checks[k] = {"done": True, "at": at, "by": by, "stamp": stamp} if done else {"done": False}
if checks != (insp["checks"] or {}): if checks != (insp["checks"] or {}):
changes["checks"] = Jsonb(checks) changes["checks"] = Jsonb(checks)
if body.weld_exam: if not cas and body.weld_exam:
live = { live = {
str(r[0]) str(r[0])
for r in conn.execute( for r in conn.execute(
@@ -1531,6 +1696,9 @@ def patch_qc(
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
update_row(conn, "qc_inspections", insp["id"], changes) update_row(conn, "qc_inspections", insp["id"], changes)
w.changed(*[f"qc.{k}" for k in changes]) w.changed(*[f"qc.{k}" for k in changes])
if scalar:
_qc_logs(w, scalar)
else:
w.log("Q.C. record changed") w.log("Q.C. record changed")
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
+50 -15
View File
@@ -17,7 +17,9 @@ from pydantic import BaseModel, Field
from .. import audit, events, policy from .. import audit, events, policy
from ..domain import assets as asset_store from ..domain import assets as asset_store
from ..domain import ncr as ncrs
from ..domain import packets, shop from ..domain import packets, shop
from ..domain import qc as qc_dom
from ..domain.pipeline import ( from ..domain.pipeline import (
Expect, Expect,
SignBody, SignBody,
@@ -110,6 +112,7 @@ class TransitionBody(BaseModel):
spool: bool | None = None spool: bool | None = None
location: str | None = Field(default=None, max_length=500) location: str | None = Field(default=None, max_length=500)
for_sup_id: uuid.UUID | None = None for_sup_id: uuid.UUID | None = None
ncr: str | None = Field(default=None, max_length=100)
sign: SignBody | None = None sign: SignBody | None = None
it_reason: str | None = Field(default=None, max_length=1000) it_reason: str | None = Field(default=None, max_length=1000)
@@ -137,6 +140,7 @@ class DocBody(BaseModel):
asset_id: uuid.UUID asset_id: uuid.UUID
kind: str = Field(min_length=1, max_length=100) kind: str = Field(min_length=1, max_length=100)
title: str | None = Field(default=None, max_length=500) title: str | None = Field(default=None, max_length=500)
note: str | None = Field(default=None, max_length=2000)
it_reason: str | None = Field(default=None, max_length=1000) it_reason: str | None = Field(default=None, max_length=1000)
@@ -494,7 +498,7 @@ TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
A.RETURN_TO_STATION: ("reason",), A.RETURN_TO_STATION: ("reason",),
A.SEND_TO_QC: ("book", "spool", "location", "note"), A.SEND_TO_QC: ("book", "spool", "location", "note"),
A.ROUTE_FROM_REVIEW: ("dept", "note"), A.ROUTE_FROM_REVIEW: ("dept", "note"),
A.QC_REJECT: ("reason",), A.QC_REJECT: ("reason", "ncr"),
A.QC_ACCEPT: ("routed_to", "note"), A.QC_ACCEPT: ("routed_to", "note"),
A.QC_ACCEPT_AND_ROUTE: ("dept", "note"), A.QC_ACCEPT_AND_ROUTE: ("dept", "note"),
A.FILE: ("note",), A.FILE: ("note",),
@@ -534,6 +538,8 @@ def transition(
extra["dept"] = shop_dept(conn, extra["dept"]) extra["dept"] = shop_dept(conn, extra["dept"])
if "for_sup_id" in extra: if "for_sup_id" in extra:
extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor") extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor")
if action is A.QC_REJECT:
_reject_reason(extra)
if "stamp" in extra: if "stamp" in extra:
# The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs. # The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs.
stamp = clean_text(extra["stamp"], 40) stamp = clean_text(extra["stamp"], 40)
@@ -570,6 +576,21 @@ def transition(
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
def _reject_reason(extra: dict[str, Any]) -> None:
"""A Q.C. reject takes a reason, an NCR number, or both. The NCR stands in for the reason text: the reason the
policy checks is "NCR-2026-0021" alone, or "NCR-2026-0021 · <reason>". Without an NCR the reason needs its usual
8 characters."""
text = clean_text(extra.pop("reason", None), LIMITS["reason"])
raw = clean_text(extra.pop("ncr", None), 100)
ncr = ncrs.validate_ncr(raw) if raw else None
if ncr:
extra["ncr"] = ncr
extra["reason"] = f"{ncr} · {text}" if text else ncr
elif text:
extra["reason"] = text
extra["reason_text"] = text
@router.post("/{packet_id}/reopen", dependencies=WRITE) @router.post("/{packet_id}/reopen", dependencies=WRITE)
def reopen( def reopen(
packet_id: int, packet_id: int,
@@ -750,27 +771,35 @@ def add_doc(
p: Principal = Depends(current_principal), # noqa: B008 p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008 d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]: ) -> dict[str, Any]:
"""A report for the data book (NDE, hydrotest, certificates, NCRs). Never a drawing: drawings come from M-Files."""
conn = d.conn conn = d.conn
kind = clean_text(body.kind, 40)
if kind not in qc_dom.DOC_KINDS:
raise ApiError(422, "invalid_kind", "Pick a document type from the list.")
asset = asset_store.get(conn, body.asset_id) asset = asset_store.get(conn, body.asset_id)
if asset is None: if asset is None:
raise ApiError(404, "not_found", "No such file.") raise ApiError(404, "not_found", "No such file.")
kind = clean_text(body.kind, 40) if asset["mime"] not in qc_dom.DOC_MIMES:
if not kind: raise ApiError(422, "invalid_asset", "The file must be a PDF, PNG or JPEG.")
raise ApiError(422, "invalid_request", "Say what kind of document this is.") label = qc_dom.DOC_KINDS[kind]
title = clean_opt(body.title, LIMITS["doc_title"]) title = clean_opt(body.title, LIMITS["doc_title"]) or label
note = clean_opt(body.note, qc_dom.DOC_NOTE_MAX)
pp = _pp(d, p) pp = _pp(d, p)
with packet_write(d, p, packet_id, A.DOC_ADD, request=request, it_reason=body.it_reason, policy_principal=pp) as w: with packet_write(d, p, packet_id, A.DOC_ADD, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
doc_id = conn.execute( doc_id = conn.execute(
"INSERT INTO packet_docs (packet_id, asset_id, kind, title, added_by) VALUES (%s, %s, %s, %s, %s)" "INSERT INTO packet_docs (packet_id, asset_id, kind, title, note, added_by)"
" RETURNING id", " VALUES (%s, %s, %s, %s, %s, %s) RETURNING id",
(packet_id, asset["id"], kind, title, p.user_id), (packet_id, asset["id"], kind, title, note, p.user_id),
).fetchone()[0] ).fetchone()[0]
w.changed("docs") w.changed("docs")
w.note = title or kind w.log("Document added", " · ".join(x for x in (label, title, asset.get("original_name")) if x))
w.detail.update({"doc_id": int(doc_id), "asset_id": str(asset["id"]), "kind": kind}) w.detail.update({"doc_id": int(doc_id), "asset_id": str(asset["id"]), "kind": kind})
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
DOC_REASON_MIN = 3
@router.delete("/{packet_id}/docs/{doc_id}", dependencies=WRITE) @router.delete("/{packet_id}/docs/{doc_id}", dependencies=WRITE)
def remove_doc( def remove_doc(
packet_id: int, packet_id: int,
@@ -780,23 +809,29 @@ def remove_doc(
p: Principal = Depends(current_principal), # noqa: B008 p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008 d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Take a document out of the data book. The row and the file stay, with who, when and why."""
body = body or ReasonBody() body = body or ReasonBody()
conn = d.conn conn = d.conn
pp = _pp(d, p) pp = _pp(d, p)
with packet_write( with packet_write(
d, p, packet_id, A.DOC_REMOVE, request=request, it_reason=body.it_reason, policy_principal=pp d, p, packet_id, A.DOC_REMOVE, request=request, it_reason=body.it_reason, policy_principal=pp
) as w: ) as w:
reason = clean_opt(body.reason, LIMITS["reason"]) reason = clean_text(body.reason, LIMITS["reason"])
doc = packets.one( doc = packets.one(
conn, conn, "SELECT * FROM packet_docs WHERE id = %s AND packet_id = %s FOR UPDATE", (doc_id, packet_id)
"UPDATE packet_docs SET removed_at = now(), removed_by = %s, removed_reason = %s"
" WHERE id = %s AND packet_id = %s AND removed_at IS NULL RETURNING title, kind",
(p.user_id, reason, doc_id, packet_id),
) )
if doc is None: if doc is None:
raise ApiError(404, "not_found", "No such document.") raise ApiError(404, "not_found", "No such document.")
if doc["removed_at"] is not None:
raise ApiError(409, "already_removed", "This document is already out of the data book.")
if len(reason) < DOC_REASON_MIN:
raise ApiError(400, "reason_required", "Say why it comes out of the data book.")
conn.execute(
"UPDATE packet_docs SET removed_at = now(), removed_by = %s, removed_reason = %s WHERE id = %s",
(p.user_id, reason, doc_id),
)
w.changed("docs") w.changed("docs")
w.note = ", ".join(x for x in (doc["title"] or doc["kind"], reason) if x) w.log("Document taken out of the data book", " · ".join(x for x in (doc["title"] or doc["kind"], reason) if x))
w.detail["doc_id"] = doc_id w.detail["doc_id"] = doc_id
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
+183
View File
@@ -0,0 +1,183 @@
"""The Q.C. screen: the home board and the project and unit folders with their documents.
Any signed-in session reads. Filing a folder document is QC_DOC_ADD (Clerical or Q.C.; admin with an IT reason),
decided by policy.engine.check_scoped because a folder belongs to a project or a unit, not a packet. Screens read
PostgreSQL only.
"""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from .. import audit, events, policy
from ..domain import assets as asset_store
from ..domain import itp as itp_dom
from ..domain import packets, qcdocs
from ..domain import qc as dom
from ..text import clean_opt, clean_text
from .deps import Db, Principal, current_principal, get_db
from .errors import ApiError
from .packets import WRITE
router = APIRouter(prefix="/api/qc", tags=["qc"])
A = policy.Action
class QcDocBody(BaseModel):
project_id: int | None = None
unit_id: int | None = None
type: str = Field(min_length=1, max_length=100)
title: str | None = Field(default=None, max_length=1000)
rev: str | None = Field(default=None, max_length=100)
note: str | None = Field(default=None, max_length=5000)
asset_id: uuid.UUID
replaces: int | None = None
it_reason: str | None = Field(default=None, max_length=1000)
@router.get("/home")
def home(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
) -> dict[str, Any]:
conn = d.conn
return dom.home(conn, packets.policy_principal_for(conn, p), project_id=project_id, unit_id=unit_id)
@router.get("/folders")
def folders(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
show: str = Query("active", pattern="^(active|all)$"),
) -> list[dict[str, Any]]:
return qcdocs.folders(d.conn, show)
@router.get("/folders/projects/{project_id}")
def project_folder(
project_id: int,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
out = qcdocs.project_folder(d.conn, project_id)
if out is None:
raise ApiError(404, "not_found", "No such project.")
return out
@router.get("/folders/units/{unit_id}")
def unit_folder(
unit_id: int,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
out = qcdocs.unit_folder(d.conn, unit_id)
if out is None:
raise ApiError(404, "not_found", "No such unit.")
return out
@router.post("/docs", dependencies=WRITE)
def add_doc(
body: QcDocBody,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""File a document in a project or unit folder, or a new revision of one (`replaces`). Nothing is deleted: the
old revision points at the new one. The answer is the folder read."""
conn = d.conn
if (body.project_id is None) == (body.unit_id is None):
raise ApiError(422, "invalid_request", "File the document in one project or one unit.")
doc_type = clean_text(body.type, 100)
if doc_type not in qcdocs.QC_TYPES:
raise ApiError(422, "invalid_type", "Pick a document type from the list.")
asset = asset_store.get(conn, body.asset_id)
if asset is None:
raise ApiError(404, "not_found", "No such file.")
if asset["mime"] not in qcdocs.DOC_MIMES:
raise ApiError(422, "invalid_asset", "The file must be a PDF, PNG or JPEG.")
title = clean_opt(body.title, qcdocs.TITLE_MAX) or doc_type
rev = clean_opt(body.rev, qcdocs.REV_MAX)
note = clean_opt(body.note, qcdocs.NOTE_MAX)
pp = packets.policy_principal_for(conn, p)
decision, it_kept = itp_dom.scoped_decision(p, pp, A.QC_DOC_ADD, it_reason=body.it_reason)
with conn.transaction():
if body.unit_id is not None:
folder = packets.one(
conn,
"SELECT u.id, pr.number || u.lot AS number FROM units u JOIN projects pr ON pr.id = u.project_id"
" WHERE u.id = %s AND u.removed_at IS NULL FOR UPDATE OF u",
(body.unit_id,),
)
scope_col = "unit_id"
else:
folder = packets.one(
conn,
"SELECT id, number FROM projects WHERE id = %s AND removed_at IS NULL FOR UPDATE",
(body.project_id,),
)
scope_col = "project_id"
if folder is None:
raise ApiError(404, "not_found", "No such unit." if body.unit_id is not None else "No such project.")
old = None
if body.replaces is not None:
old = packets.one(conn, "SELECT * FROM qc_docs WHERE id = %s FOR UPDATE", (body.replaces,))
if old is None or old[scope_col] != folder["id"]:
raise ApiError(422, "invalid_replace", "That document is not in this folder.")
if old["superseded_by"] is not None:
raise ApiError(409, "already_superseded", "That document already has a newer revision.")
new_id = int(
conn.execute(
"INSERT INTO qc_docs (project_id, unit_id, type, title, rev, note, asset_id, added_by)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
(body.project_id, body.unit_id, doc_type, title, rev, note, asset["id"], p.user_id),
).fetchone()[0]
)
if old is not None:
conn.execute(
"UPDATE qc_docs SET superseded_by = %s, superseded_at = now(), superseded_by_user = %s WHERE id = %s",
(new_id, p.user_id, old["id"]),
)
title_rev = f"{title} Rev {rev}" if rev else title
text_note = " · ".join(x for x in (folder["number"], doc_type, title_rev, asset.get("original_name")) if x)
revise = old is not None
detail: dict[str, Any] = {
"text": "Q.C. document revised" if revise else "Q.C. document added",
"note": text_note,
"doc_id": new_id,
"project_id": body.project_id,
"unit_id": body.unit_id,
}
if revise:
detail["replaces"] = old["id"] if old else None
audit.append(
conn,
action="qc_doc.revise" if revise else "qc_doc.add",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="qc_doc",
subject_id=new_id,
detail=detail,
it_reason=it_kept,
ip=p.ip,
)
events.publish(
conn,
{"event": "changed", "kind": "qc_doc", "project_id": body.project_id, "unit_id": body.unit_id},
)
del decision
if body.unit_id is not None:
out = qcdocs.unit_folder(conn, body.unit_id)
else:
assert body.project_id is not None
out = qcdocs.project_folder(conn, body.project_id)
assert out is not None
return out
+75 -23
View File
@@ -1,7 +1,8 @@
"""The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing(). """The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing().
Weld departments get the welding list, the others the plain list. The drawing, rev and WPS are filled into the text. Weld departments get the welding list, the others the plain list. The drawing, rev and WPS are filled into the text.
The lists live in the settings row `checklist_templates` (migration 0006); the defaults here are the fallback. The lists live in the settings rows `checklist_templates` (migration 0006) and `qc_check_items` (migration 0007); the
defaults here are the fallback.
The keys are the old keys, so a checklist stored as {key: {done, at, by, stamp}} reads the same. The keys are the old keys, so a checklist stored as {key: {done, at, by, stamp}} reads the same.
""" """
@@ -115,32 +116,79 @@ def checklist_for(
return [_item(i["group"], i["key"], _fill(i["text"], drawing, rev, wps)) for i in items] return [_item(i["group"], i["key"], _fill(i["text"], drawing, rev, wps)) for i in items]
def qc_items_for( QC_SETTINGS_KEY = "qc_check_items"
drawing: str | None, rev: str | None, wps: str | None, welder_stamp: str | None RX_QC_PLACEHOLDER = re.compile(r"\{(drawing|rev|wps|stamp)\}")
) -> list[dict[str, str]]: G_BOOK, G_SPOOL = "Data book package", "Physical spool"
"""The Q.C. inspection items, as a flat list of {group, key, text}."""
g1, g2 = "Data book package", "Physical spool" # The Q.C. check items, with {wps}, {stamp}, {drawing} and {rev} placeholders. The settings row `qc_check_items`
stamp = f"[{welder_stamp}] " if welder_stamp else "" # (migration 0007) holds the same list; this is the fallback when that row is missing or does not parse.
return [ QC_DEFAULT_ITEMS: list[dict[str, str]] = [
_item(g1, "db_heats", "Heat number recorded for every BOM item"), _item(G_BOOK, "db_heats", "Heat number recorded for every BOM item"),
_item(g1, "db_mtr", "MTRs on file and match the heat numbers"), _item(G_BOOK, "db_mtr", "MTRs on file and match the heat numbers"),
_item(g1, "db_weldmap", "Weld map complete: every weld identified"), _item(G_BOOK, "db_weldmap", "Weld map complete: every weld identified"),
_item(g1, "db_wps", f"Welds made to {wps or 'the WPS'}; welder {stamp}qualified"), _item(G_BOOK, "db_wps", "Welds made to {wps}; welder {stamp}qualified"),
_item(g1, "db_ck", "Stall checklist and weld record complete"), _item(G_BOOK, "db_ck", "Stall checklist and weld record complete"),
_item(g2, "ph_dim", f"Dimensions verified against {drawing or 'the drawing'} Rev {rev or NO_REV}"), _item(G_SPOOL, "ph_dim", "Dimensions verified against {drawing} Rev {rev}"),
_item(g2, "ph_heat", "Heat numbers on material match the heat map"), _item(G_SPOOL, "ph_heat", "Heat numbers on material match the heat map"),
_item(g2, "ph_stamp", "Welds stamped with welder ID"), _item(G_SPOOL, "ph_stamp", "Welds stamped with welder ID"),
_item(g2, "ph_clean", "Clean: no slag, spatter or arc strikes"), _item(G_SPOOL, "ph_clean", "Clean: no slag, spatter or arc strikes"),
_item(g2, "ph_qty", "Quantity matches the work orders"), _item(G_SPOOL, "ph_qty", "Quantity matches the work orders"),
] ]
def parse_qc_items(value: Any) -> list[dict[str, str]] | None:
"""The `qc_check_items` settings value ({"groups": [{"group", "items": [{"key", "text"}]}]}) as a flat list of
{group, key, text}. None when it is malformed."""
if not isinstance(value, Mapping) or not isinstance(value.get("groups"), list):
return None
flat: list[dict[str, Any]] = []
for g in value["groups"]:
if not isinstance(g, Mapping) or not isinstance(g.get("items"), list):
return None
flat += [{**i, "group": g.get("group")} if isinstance(i, Mapping) else i for i in g["items"]]
return _valid_list(flat)
def load_qc_items(conn: Any) -> list[dict[str, str]]:
"""The Q.C. check items from settings, else the defaults in code (with a warning when the row is bad)."""
row = conn.execute("SELECT value FROM settings WHERE key = %s", (QC_SETTINGS_KEY,)).fetchone()
if row is None:
return QC_DEFAULT_ITEMS
parsed = parse_qc_items(row[0])
if parsed is None:
log.warning("settings %s does not parse; using the default Q.C. items", QC_SETTINGS_KEY)
return QC_DEFAULT_ITEMS
return parsed
def qc_fill(text: str, drawing: str | None, rev: str | None, wps: str | None, welder_stamp: str | None) -> str:
values = {
"drawing": drawing or "the drawing",
"rev": rev or NO_REV,
"wps": wps or "the WPS",
"stamp": f"[{welder_stamp}] " if welder_stamp else "",
}
return " ".join(RX_QC_PLACEHOLDER.sub(lambda m: values[m.group(1)], text).split())
def qc_items_for(
drawing: str | None,
rev: str | None,
wps: str | None,
welder_stamp: str | None,
items: Iterable[Mapping[str, str]] | None = None,
) -> list[dict[str, str]]:
"""The Q.C. inspection items, as a flat list of {group, key, text} with the packet's values filled in."""
src = list(items) if items is not None else QC_DEFAULT_ITEMS
return [_item(i["group"], i["key"], qc_fill(i["text"], drawing, rev, wps, welder_stamp)) for i in src]
def checklist_keys(weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None) -> list[str]: def checklist_keys(weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None) -> list[str]:
return [i["key"] for i in checklist_for(weld, None, None, None, templates)] return [i["key"] for i in checklist_for(weld, None, None, None, templates)]
def qc_keys() -> list[str]: def qc_keys(items: Iterable[Mapping[str, str]] | None = None) -> list[str]:
return [i["key"] for i in qc_items_for(None, None, None, None)] return [i["key"] for i in (items if items is not None else QC_DEFAULT_ITEMS)]
def is_done(value: Any) -> bool: def is_done(value: Any) -> bool:
@@ -163,19 +211,23 @@ def _plural(n: int, word: str) -> str:
def qc_missing( def qc_missing(
packet: Mapping[str, Any], qc: Mapping[str, Any] | None, welds: Iterable[Mapping[str, Any]] packet: Mapping[str, Any],
qc: Mapping[str, Any] | None,
welds: Iterable[Mapping[str, Any]],
items: Iterable[Mapping[str, str]] | None = None,
) -> list[str]: ) -> list[str]:
"""What Q.C. still has to record before an accept. The old qcMissing(). """What Q.C. still has to record before an accept. The old qcMissing().
`packet` is unused today (the item list does not depend on it for the keys) but kept for the old signature. `packet` is unused today (the item list does not depend on it for the keys) but kept for the old signature.
`qc` is the open inspection row (checks, weld_exam, nde_method, nde_result, inspector) or None. `qc` is the open inspection row (checks, weld_exam, nde_method, nde_result, inspector) or None.
`welds` are the live weld rows (weld_no). `welds` are the live weld rows (weld_no). `items` are the loaded Q.C. items (load_qc_items); None means the
defaults.
""" """
del packet del packet
q = qc or {} q = qc or {}
checks = q.get("checks") or {} checks = q.get("checks") or {}
missing: list[str] = [] missing: list[str] = []
open_items = sum(1 for k in qc_keys() if not is_done(checks.get(k))) open_items = sum(1 for k in qc_keys(items) if not is_done(checks.get(k)))
if open_items: if open_items:
missing.append(_plural(open_items, "inspection item")) missing.append(_plural(open_items, "inspection item"))
exam = q.get("weld_exam") or {} exam = q.get("weld_exam") or {}
+597
View File
@@ -0,0 +1,597 @@
"""The digital ITP (Inspection and Test Plan), one per unit.
Starting an ITP freezes a copy of the Q.C. template (domain/itp_template.py) into itps.template, so a later template
change never alters an ITP in use. Q.C. initials each line (approve), marks lines N/A, rejects a line with an NCR
number (now or later), records the customer's code and witness, signs each block of sections with a signature, and
signs the ITP off with a signature. Open hold (H) and witness (W) points, and rejected lines, show as a warning on the
unit's packets for the department's pages. Nothing is blocked by them.
The pure functions here (items, is_done, is_hold_code, open_holds, blocks, block_ready, progress, dept_pages,
holds_for_packet) port the old itp.js helpers. They take the frozen template and the lines as a mapping of line key to
a row with `result` ("approve", "reject", "na" or None), `cust_code` and `ncr`.
Writes go through `itp_write`: check_scoped with the action, then the ITP row locked FOR UPDATE, the complete lock,
the signature when the rule needs one, then the version bump, the itp_log rows, the audit rows and the NOTIFY. ITP
writes never look at a packet's status: a filed packet in the unit does not lock the unit's ITP.
"""
from __future__ import annotations
import re
from collections.abc import Iterable, Iterator, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from .. import audit, events, policy
from ..api import deps
from ..api.errors import ApiError
from ..auth import signatures
from ..auth.signatures import Signature
from ..policy.actions import SCOPED_LABELS
from ..policy.engine import check_scoped
from ..text import LIMITS, clean_opt, reason_ok
from . import packets
A = policy.Action
PAGE_NAMES: dict[str, str] = {"WP": "Weld Parts", "C": "Code", "FA": "Final Assembly"}
PAGE_ORDER: tuple[str, ...] = ("WP", "C", "FA")
# The pages each shop department works to (the old ITP_DEPT_PAGES). A support department or an unknown code sees all.
DEPT_PAGES: dict[str, tuple[str, ...]] = {
"01": ("WP",),
"02": ("WP",),
"03": ("WP",),
"04": ("WP",),
"05": ("WP",),
"10": ("WP",),
"06": ("C",),
"07": ("C",),
"13": ("C",),
"09": ("WP", "C"),
"08": ("FA",),
"11": ("FA",),
"12": ("FA",),
"14": ("FA",),
}
CUST_CODES: tuple[str, ...] = ("R", "R/AU", "AU", "W", "H", "NA")
RX_HOLD = re.compile(r"(^|/)(H|W)$")
LOG_MAX = 600
MSG_COMPLETE = "The ITP is signed off and locked. An admin can reopen it."
MSG_NO_ITP = "No such ITP."
# ---------------------------------------------------------------- pure functions over the template and lines
def items(template: Mapping[str, Any]) -> list[dict[str, Any]]:
"""Every line of the template in order, with its section number (`sec`) and page."""
out: list[dict[str, Any]] = []
for s in template.get("sections") or []:
for i in s.get("items") or []:
out.append({**i, "sec": s.get("n"), "page": s.get("page")})
return out
def _line(lines: Mapping[str, Any], key: str) -> Mapping[str, Any]:
v = lines.get(key)
return v if isinstance(v, Mapping) else {}
def is_done(line: Mapping[str, Any] | None) -> bool:
"""A line is done when it is N/A or approved."""
return bool(line) and (line or {}).get("result") in ("approve", "na")
def is_rejected(line: Mapping[str, Any] | None) -> bool:
"""An open reject: the line is rejected and not approved since."""
return bool(line) and (line or {}).get("result") == "reject"
def is_hold_code(code: Any) -> bool:
"""H or W, alone or after a slash (R/H). The old isHoldCode."""
return bool(RX_HOLD.search(str(code or "")))
def cust_code(item: Mapping[str, Any], line: Mapping[str, Any] | None) -> str:
"""The customer code of a line: its own when set, else the template's."""
own = (line or {}).get("cust_code")
return str(own) if own else str(item.get("cust") or "")
def hold_codes(item: Mapping[str, Any], line: Mapping[str, Any] | None) -> list[str]:
return [c for c in (str(item.get("code") or ""), cust_code(item, line)) if is_hold_code(c)]
def open_holds(
template: Mapping[str, Any], lines: Mapping[str, Any], pages: Sequence[str] | None = None
) -> list[dict[str, Any]]:
"""The hold, witness or rejected points not done yet, on the given pages (None: every page)."""
out: list[dict[str, Any]] = []
for i in items(template):
if pages is not None and i["page"] not in pages:
continue
line = _line(lines, str(i["n"]))
codes = hold_codes(i, line)
rej = is_rejected(line)
if (codes or rej) and not is_done(line):
out.append(
{
"n": i["n"],
"t": i["t"],
"page": i["page"],
"codes": codes,
"rejected": rej,
"ncr": line.get("ncr") if rej else None,
}
)
return out
def blocks(template: Mapping[str, Any]) -> list[dict[str, Any]]:
"""The signature blocks: one closes at every section with `sig` and covers the sections since the last block."""
out: list[dict[str, Any]] = []
acc: list[Mapping[str, Any]] = []
for s in template.get("sections") or []:
acc.append(s)
if s.get("sig"):
out.append({"key": str(s["n"]), "sections": acc, "final": bool(s.get("final"))})
acc = []
if acc:
out.append({"key": str(acc[-1]["n"]), "sections": acc, "final": True})
return out
def block_scope(block: Mapping[str, Any]) -> str:
secs = block["sections"]
if len(secs) > 1:
return f"Sections {secs[0]['n']}–{secs[-1]['n']}"
return f"Section {block['key']}"
def block_of_line(template: Mapping[str, Any], key: str) -> dict[str, Any] | None:
for b in blocks(template):
if any(str(i.get("n")) == key for s in b["sections"] for i in s.get("items") or []):
return b
return None
def _block_lines(block: Mapping[str, Any]) -> list[str]:
return [str(i.get("n")) for s in block["sections"] for i in s.get("items") or []]
def block_ready(block: Mapping[str, Any], lines: Mapping[str, Any]) -> bool:
"""Every line of the block is done (approved or N/A)."""
return all(is_done(_line(lines, k)) for k in _block_lines(block))
def block_all_na(block: Mapping[str, Any], lines: Mapping[str, Any]) -> bool:
return all(_line(lines, k).get("result") == "na" for k in _block_lines(block))
def progress(template: Mapping[str, Any], lines: Mapping[str, Any]) -> dict[str, int]:
it = items(template)
return {
"done": sum(1 for i in it if is_done(_line(lines, str(i["n"])))),
"total": len(it),
"holds": len(open_holds(template, lines)),
}
def dept_pages(code: Any) -> list[str] | None:
"""The pages a department works to, or None for a support department or an unknown code (every page)."""
pages = DEPT_PAGES.get(str(code or ""))
return list(pages) if pages is not None else None
def holds_for_packet(
itp_row: Mapping[str, Any] | None, lines: Mapping[str, Any], dept_code: Any
) -> dict[str, Any] | None:
"""The packet's ITP warning: open hold, witness or rejected points on its department's pages, while the unit's
ITP is open. None when there is none."""
if itp_row is None or str(itp_row.get("status")) == "complete":
return None
pages = dept_pages(dept_code)
hs = open_holds(itp_row["template"] or {}, lines, pages)
if not hs:
return None
return {
"no": itp_row.get("no"),
"count": len(hs),
"rejected": any(h["rejected"] for h in hs),
"pages": pages,
"items": [{k: h[k] for k in ("n", "t", "codes", "rejected", "ncr")} for h in hs],
}
def initials(name: Any) -> str:
"""The first letter of each word of a name, upper case, at most three. The old initials()."""
return "".join(w[0].upper() for w in str(name or "").split() if w)[:3]
def site_zone(tz: str | None) -> ZoneInfo:
try:
return ZoneInfo(tz or "America/Denver")
except (ZoneInfoNotFoundError, ValueError):
return ZoneInfo("America/Denver")
def date_text(value: Any, tz: str | None = None) -> str:
"""M/D/YYYY in the site time zone (10/17/2026)."""
if value is None:
return ""
if isinstance(value, datetime):
d = value.astimezone(site_zone(tz)).date() if value.tzinfo else value.date()
elif isinstance(value, date):
d = value
else:
return str(value)
return f"{d.month}/{d.day}/{d.year}"
def today(tz: str | None = None) -> date:
return datetime.now(site_zone(tz)).date()
# ---------------------------------------------------------------- loading
def load_row(conn: Any, itp_id: int, *, lock: bool = False) -> dict[str, Any] | None:
sql = "SELECT *, status::text AS status FROM itps WHERE id = %s"
if lock:
sql += " FOR UPDATE"
return packets.one(conn, sql, (itp_id,))
def row_for_unit(conn: Any, unit_id: int) -> dict[str, Any] | None:
return packets.one(conn, "SELECT *, status::text AS status FROM itps WHERE unit_id = %s", (unit_id,))
def load_lines(conn: Any, itp_id: int) -> dict[str, dict[str, Any]]:
return {r["key"]: r for r in packets.rows(conn, "SELECT * FROM itp_lines WHERE itp_id = %s", (itp_id,))}
def load_blocks(conn: Any, itp_id: int) -> dict[str, dict[str, Any]]:
return {r["block_key"]: r for r in packets.rows(conn, "SELECT * FROM itp_blocks WHERE itp_id = %s", (itp_id,))}
def summary(itp_row: Mapping[str, Any], lines: Mapping[str, Any]) -> dict[str, Any]:
pr = progress(itp_row["template"] or {}, lines)
return {
"id": itp_row["id"],
"no": itp_row["no"],
"status": str(itp_row["status"]),
"done": pr["done"],
"total": pr["total"],
"holds": pr["holds"],
}
def unit_itps(conn: Any, unit_ids: Iterable[int]) -> dict[int, tuple[dict[str, Any], dict[str, dict[str, Any]]]]:
"""The ITP row and its lines per unit, for the given units, in two queries."""
ids = sorted({int(u) for u in unit_ids})
if not ids:
return {}
itps = packets.rows(conn, "SELECT *, status::text AS status FROM itps WHERE unit_id = ANY(%s)", (ids,))
if not itps:
return {}
lines: dict[int, dict[str, dict[str, Any]]] = {r["id"]: {} for r in itps}
for ln in packets.rows(
conn,
"SELECT itp_id, key, result, cust_code, ncr FROM itp_lines WHERE itp_id = ANY(%s)",
([r["id"] for r in itps],),
):
lines[ln["itp_id"]][ln["key"]] = ln
return {int(r["unit_id"]): (r, lines[r["id"]]) for r in itps}
def summaries(conn: Any, unit_ids: Iterable[int]) -> dict[int, dict[str, Any]]:
return {u: summary(row, lines) for u, (row, lines) in unit_itps(conn, unit_ids).items()}
# ---------------------------------------------------------------- the read
def can_of(pp: policy.Principal, itp_row: Mapping[str, Any], blocks_signed: bool) -> dict[str, bool]:
open_ = str(itp_row["status"]) != "complete"
edit = open_ and check_scoped(pp, A.ITP_EDIT_LINE, assume_it_reason=True).ok
sign_off = open_ and blocks_signed and check_scoped(pp, A.ITP_SIGN_OFF, assume_it_reason=True).ok
reopen = (not open_) and check_scoped(pp, A.ITP_REOPEN, extra={"reason": "x" * 8}, assume_it_reason=True).ok
return {"edit": edit, "sign_off": sign_off, "reopen": reopen}
def read(conn: Any, itp_row: Mapping[str, Any], pp: policy.Principal) -> dict[str, Any]:
"""The full ITP answer (GET /api/units/{unit_id}/itp and every ITP write)."""
iid = itp_row["id"]
template = itp_row["template"] or {}
lines = load_lines(conn, iid)
brows = load_blocks(conn, iid)
rejects: dict[str, list[dict[str, Any]]] = {}
for r in packets.rows(conn, "SELECT * FROM itp_rejects WHERE itp_id = %s ORDER BY id", (iid,)):
rejects.setdefault(r["line_key"], []).append(r)
log_rows = packets.rows(conn, "SELECT * FROM itp_log WHERE itp_id = %s ORDER BY id DESC LIMIT %s", (iid, LOG_MAX))
ids: list[Any] = [itp_row["signoff_by"], itp_row["reopen_by"], itp_row["created_by"]]
for ln in lines.values():
ids += [ln["by"], ln["cust_rec"]]
for b in brows.values():
ids += [b["signed_by"], b["cust_by"]]
for rs in rejects.values():
ids += [r["by"] for r in rs]
ids += [r["by"] for r in log_rows]
people = packets.People(conn, ids)
unit = packets.one(
conn,
"SELECT u.id, u.number, u.project_id, u.project_number FROM v_units u WHERE u.id = %s",
(itp_row["unit_id"],),
)
out_lines: dict[str, Any] = {}
for i in items(template):
key = str(i["n"])
ln = lines.get(key) or {}
result = ln.get("result")
out_lines[key] = {
"key": key,
"result": result,
"na": result == "na",
"by": people.ref(ln.get("by")) if result == "approve" else None,
"initials": ln.get("initials") if result == "approve" else None,
"at": ln.get("at") if result == "approve" else None,
"ncr": ln.get("ncr"),
"ncr_missing": result == "reject" and not ln.get("ncr"),
"note": ln.get("note"),
"cust_code": ln.get("cust_code"),
"cust_by": ln.get("cust_by"),
"cust_at": ln.get("cust_at"),
"cust_rec": people.ref(ln.get("cust_rec")),
"rejects": [
{"at": r["at"], "by": people.ref(r["by"]), "ncr": r["ncr"], "note": r["note"], "kind": r["kind"]}
for r in rejects.get(key, [])
],
"row_version": ln.get("row_version", 0),
}
out_blocks: list[dict[str, Any]] = []
for b in blocks(template):
br = brows.get(b["key"]) or {}
out_blocks.append(
{
"key": b["key"],
"sections": [str(s["n"]) for s in b["sections"]],
"scope": block_scope(b),
"final": b["final"],
"ready": block_ready(b, lines),
"all_na": block_all_na(b, lines),
"signed": br.get("signed_at") is not None,
"signed_at": br.get("signed_at"),
"signed_by": people.ref(br.get("signed_by")) if br.get("signed_at") is not None else None,
"qn": br.get("qn"),
"remarks": br.get("remarks"),
"cust_name": br.get("cust_name"),
"cust_at": br.get("cust_at"),
"cust_by": people.ref(br.get("cust_by")),
"row_version": br.get("row_version", 0),
}
)
all_signed = bool(out_blocks) and all(b["signed"] for b in out_blocks)
pages_present = {s.get("page") for s in template.get("sections") or []}
status = str(itp_row["status"])
return {
"id": iid,
"unit_id": itp_row["unit_id"],
"unit": None
if unit is None
else {
"id": unit["id"],
"number": unit["number"],
"project": {"id": unit["project_id"], "number": unit["project_number"]},
},
"no": itp_row["no"],
"hdr": {k: (itp_row["hdr"] or {}).get(k) for k in HDR_KEYS},
"status": "complete" if status == "complete" else "open",
"version": itp_row["version"],
"template": template,
"pages": [{"code": c, "name": PAGE_NAMES[c]} for c in PAGE_ORDER if c in pages_present],
"lines": out_lines,
"blocks": out_blocks,
"progress": progress(template, lines),
"holds": open_holds(template, lines),
"signoff": None
if itp_row["signoff_at"] is None
else {
"at": itp_row["signoff_at"],
"by": people.ref(itp_row["signoff_by"]),
"asset_id": packets.uid_text(itp_row["signoff_asset_id"]),
},
"reopen": None
if itp_row["reopen_at"] is None
else {"at": itp_row["reopen_at"], "by": people.ref(itp_row["reopen_by"]), "reason": itp_row["reopen_reason"]},
"created_at": itp_row["created_at"],
"created_by": people.ref(itp_row["created_by"]),
"can": can_of(pp, itp_row, all_signed),
"log": [
{
"id": r["id"],
"at": r["at"],
"by": people.ref(r["by"]),
"by_name": r["by_name"],
"action": r["action"],
"note": r["note"],
"signed": r["signature_id"] is not None,
}
for r in log_rows
],
}
HDR_KEYS: tuple[str, ...] = ("client", "pname", "pno", "po", "unit")
# ---------------------------------------------------------------- the write
@dataclass
class ItpWrite:
conn: Any
principal: deps.Principal
policy_principal: policy.Principal
itp: dict[str, Any]
action: policy.Action
decision: policy.Decision
request: Any
it_reason: str | None # the cleaned IT reason, kept only when the admin bypass was used
signature: Signature | None = None
logs: list[tuple[str, str | None]] = field(default_factory=list)
version: int = 0
@property
def id(self) -> int:
return int(self.itp["id"])
@property
def user_id(self) -> Any:
return self.principal.user_id
@property
def tz(self) -> str:
return str(deps.settings_of(self.request).site_tz)
def log(self, action: str, note: str | None = None) -> None:
"""One itp_log row (and audit row) for this write. A write that logs nothing changes nothing."""
self.logs.append((action, note))
def refuse(decision: policy.Decision) -> None:
if decision.ok:
return
status = 400 if decision.code in ("it_reason_required", "reason_required") else 403
raise ApiError(status, decision.code, decision.why)
def scoped_decision(
p: deps.Principal,
pp: policy.Principal,
action: policy.Action,
*,
extra: Mapping[str, Any] | None = None,
it_reason: str | None = None,
) -> tuple[policy.Decision, str | None]:
"""check_scoped, raising the refusal. Returns the decision and the IT reason to record (bypass only)."""
del p
decision = check_scoped(pp, action, extra=extra, it_reason=it_reason)
refuse(decision)
return decision, (reason_ok(it_reason) if decision.admin_bypass else None)
@contextmanager
def itp_write(
d: deps.Db,
p: deps.Principal,
itp_id: int,
action: policy.Action,
*,
request: Any,
extra: Mapping[str, Any] | None = None,
it_reason: str | None = None,
sign: Any = None,
allow_complete: bool = False,
sign_extra: Mapping[str, Any] | None = None,
) -> Iterator[ItpWrite]:
"""One ITP write, in one transaction. See the module docstring for the order of steps.
A wrong signature secret commits the step-up failure records and then raises, like the packet pipeline.
"""
conn = d.conn
pp = packets.policy_principal_for(conn, p)
decision, it_kept = scoped_decision(p, pp, action, extra=extra, it_reason=it_reason)
failure: ApiError | None = None
with conn.transaction():
row = load_row(conn, itp_id, lock=True)
if row is None:
raise ApiError(404, "not_found", MSG_NO_ITP)
if str(row["status"]) == "complete" and not allow_complete:
raise ApiError(409, "itp_complete", MSG_COMPLETE)
w = ItpWrite(
conn=conn,
principal=p,
policy_principal=pp,
itp=row,
action=action,
decision=decision,
request=request,
it_reason=it_kept,
version=int(row["version"]),
)
if decision.signature:
if sign is None:
raise ApiError(
400, "signature_required", "This step needs your signature.", extra={"meaning": decision.signature}
)
try:
w.signature = signatures.sign(
conn,
deps.directory_of(request),
p.actor,
factor=sign.factor,
secret=sign.secret,
action=f"itp.{action}",
meaning=decision.signature,
packet_id=None,
subject_type="itp",
subject_id=itp_id,
content={
"action": str(action),
"itp_id": itp_id,
"version": int(row["version"]),
**(sign_extra or {}),
},
ip=p.ip,
settings=deps.settings_of(request),
)
except ApiError as e:
failure = e
if failure is None:
yield w
_finish(w)
if failure is not None:
raise failure
def _finish(w: ItpWrite) -> None:
if not w.logs:
return
conn = w.conn
w.version = int(
conn.execute("UPDATE itps SET version = version + 1 WHERE id = %s RETURNING version", (w.id,)).fetchone()[0]
)
sig_id = w.signature.id if w.signature else None
name = w.principal.user["display_name"]
for n, (action, note) in enumerate(w.logs):
text = clean_opt(note, LIMITS["note"]) if note else None
sid = sig_id if n == 0 else None
conn.execute(
"INSERT INTO itp_log (itp_id, by, by_name, action, note, signature_id, it_reason)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
(w.id, w.user_id, name, action, text, sid, w.it_reason),
)
detail: dict[str, Any] = {"text": action, "note": text, "unit_id": w.itp["unit_id"]}
if sid:
detail["signature_id"] = sid
audit.append(
conn,
action=f"itp.{w.action}",
user_id=w.user_id,
session_id=w.principal.session_id,
role_snapshot=w.principal.role_snapshot,
subject_type="itp",
subject_id=w.id,
detail=detail,
it_reason=w.it_reason,
ip=w.principal.ip,
)
events.publish(conn, {"event": "changed", "kind": "itp", "unit_id": w.itp["unit_id"], "itp_id": w.id})
def label(action: policy.Action) -> str:
return SCOPED_LABELS.get(action, str(action))
+659
View File
@@ -0,0 +1,659 @@
"""The Q.C. ITP template, ITP_Generation_Revised_2.
Copied verbatim from the old build's src/js/itp.js line 4 (ITP_TMPL), which Q.C. took from the workbook
ITP_Generation_Revised_2.xlsm (COP Template sheet). Starting an ITP freezes a copy of it into itps.template, so a
later change here never alters an ITP already in use.
Sections: n, t (title), page (WP Weld Parts, C Code, FA Final Assembly), sig (a QN # / remarks / PESCO Q.C. signature
block follows the section), final (the last block, COP complete) and items. Items: n, t (title), code (the PESCO
code), cust (the customer code), crit (acceptance criteria).
"""
from __future__ import annotations
from typing import Any
ITP_TEMPLATE: dict[str, Any] = {
"ver": "ITP_Generation_Revised_2",
"legend": "O = Observe R = Review A = Approve I = Inspect AU = Audit W = Witness (Hold) "
"H = Hold NA = Not Applicable",
"sections": [
{
"n": "1",
"t": "GENERAL FABRICATION and DATA",
"page": "WP",
"sig": False,
"items": [
{
"n": "1.1",
"t": "Material Test Reports",
"code": "R",
"cust": "R",
"crit": "MTR chemistry and mechanical properties meet PO/material spec; heat "
"numbers on MTR match material received. (API Q1 5.5.3, 5.6.5 / ISO 9001 "
"8.4.2, 8.5.2)",
},
{
"n": "1.2",
"t": "Welding Procedures",
"code": "A",
"cust": "R",
"crit": "WPS/PQR qualified per ASME IX and approved before use; current revision "
"matches the weld map assignment. (API Q1 5.6.3 / ISO 9001 8.5.1)",
},
{
"n": "1.3",
"t": "Welder Qualifications Records",
"code": "A",
"cust": "R",
"crit": "Welder current on the qualified welder log for the process, position and "
"material used; qualification not expired. (API Q1 4.3.2 / ISO 9001 7.2)",
},
{
"n": "1.4",
"t": "QA/QC Manual",
"code": "A",
"cust": "R",
"crit": "Manual reflects current API Q1 / ISO 9001 requirements and is the "
"controlled, released revision. (API Q1 4.4 / ISO 9001 7.5.3)",
},
{
"n": "1.5",
"t": "Weld Map(s)/Fabrication Drawings",
"code": "A",
"cust": "R",
"crit": "Released revision used; as-built weld numbers, WPS and welder ID recorded "
"on the map before the final documentation package is assembled. (API Q1 "
"5.6.3, 5.6.5 / ISO 9001 8.5.2)",
},
{
"n": "1.6",
"t": "Radiographic Testing and Reports",
"code": "R",
"cust": "R",
"crit": "RT reports reference weld map location and heat number; results accepted "
"to governing code/spec; technician currently certified. (API Q1 5.6.9.4 / "
"ISO 9001 7.1.5, 8.5.2)",
},
{
"n": "1.7",
"t": "Ultrasonic Testing and Reports",
"code": "R",
"cust": "R",
"crit": "UT reports reference weld map location and heat number; results accepted "
"to governing code/spec; technician currently certified. (API Q1 5.6.9.4 / "
"ISO 9001 7.1.5, 8.5.2)",
},
{
"n": "1.8",
"t": "Hydrostatic Testing and Reports",
"code": "A",
"cust": "R",
"crit": "Test pressure and hold time meet code/spec; no leaks or pressure drop; "
"gauges in current calibration. (API Q1 5.6.9.3, 5.8)",
},
{
"n": "1.9",
"t": "Pneumatic Testing and Reports",
"code": "R",
"cust": "R",
"crit": "Used only where hydrotest is not permitted by code/client spec; test "
"pressure and hold time meet code/spec; mark NA if hydrotest performed.",
},
{
"n": "1.10",
"t": "Insulation Application and Test Reports",
"code": "O",
"cust": "R",
"crit": "Insulation type and thickness per spec; test reports (where required) "
"meet the acceptance values in the spec.",
},
{
"n": "1.11",
"t": "Nonconformance Reports (NCR/QN Log)",
"code": "R",
"cust": "R",
"crit": "All QNs/NCRs from this build are dispositioned and closed, or carried to "
"the customer as an open item before shipment; concessions are "
"customer-approved. (API Q1 5.9 / ISO 9001 8.7)",
},
{
"n": "1.12",
"t": "Export Packing Plan",
"code": "R",
"cust": "R",
"crit": "Packing plan meets PO, ISPM-15 and destination country requirements.",
},
{
"n": "1.13",
"t": 'Certified "As-Built" Data and Drawings',
"code": "A",
"cust": "R",
"crit": "As-built drawings match the final fabricated configuration; all field "
"changes/redlines incorporated and approved.",
},
{
"n": "1.14",
"t": "Nameplate/Tagging Etching/Stenciling",
"code": "R",
"cust": "R",
"crit": "Nameplate data matches the approved drawing/data sheet and code stamp (if "
"applicable); stamped/etched by a qualified person.",
},
],
},
{
"n": "2",
"t": "STRUCTURAL",
"page": "WP",
"sig": False,
"items": [
{
"n": "2.1",
"t": "Structural Fit-up/Dimensional Check prior to Weldout",
"code": "I",
"cust": "R/AU",
"crit": "Fit-up dimensions per approved drawing tolerance; heat numbers "
"transferred from mill cert to cut members; welder ID/WPS recorded against "
"the weld map before weld-out. (API Q1 5.6.5 / ISO 9001 8.5.2)",
},
{
"n": "2.2",
"t": "Post Welding Dimensional Check",
"code": "I",
"cust": "R/AU",
"crit": "Final dimensions within drawing tolerance; visual weld quality meets AWS "
"D1.1 (or governing code) acceptance criteria.",
},
{
"n": "2.3",
"t": "Interface Structural and ICP Dimensional Locations",
"code": "I",
"cust": "R/AU",
"crit": "Interface/ICP dimensional locations within drawing tolerance; verified "
"and recorded before release to the next department.",
},
],
},
{
"n": "3",
"t": "PIPING",
"page": "WP",
"sig": True,
"items": [
{
"n": "3.1",
"t": "Visual Quality Inspection",
"code": "O",
"cust": "R/AU",
"crit": "Welds free of surface defects (undercut, porosity, cracking) per code "
"acceptance criteria; weld map and heat numbers current.",
},
{
"n": "3.2",
"t": "Post Welding Dimensional Check",
"code": "O",
"cust": "R/AU",
"crit": "Dimensions and alignment per isometric/drawing tolerance.",
},
{
"n": "3.3",
"t": "Ultrasonic, Radiographic and Magnetic Particle Testing and Reports",
"code": "R",
"cust": "R/AU",
"crit": "NDE method and extent per code/spec; results reference weld map location "
"and welder ID; NDE technician currently certified. (API Q1 5.6.9.4 / ISO "
"9001 8.5.2)",
},
{
"n": "3.4",
"t": "Final inspection/Hydrostatic test",
"code": "A",
"cust": "W",
"crit": "Test pressure and hold time per code/spec; no leaks or pressure drop; "
"witnessed where required by the PO. (API Q1 5.6.9.3)",
},
],
},
{
"n": "4",
"t": "VESSELS",
"page": "C",
"sig": True,
"items": [
{
"n": "4.1",
"t": "Seam/Joint Fit-up and Dimensional Check prior to Weldout",
"code": "O",
"cust": "R/AU",
"crit": "Fit-up/gap/alignment per WPS and drawing; heat numbers transferred to "
"shell/head components; welder ID/WPS recorded against the weld map.",
},
{
"n": "4.2",
"t": "Prior to vessel closing Heads Fit-up and Weldout (Internal Inspection)",
"code": "O",
"cust": "H",
"crit": "Internal surfaces, welds and attachments accepted before the vessel "
"becomes inaccessible. Hold point justified: internal access is "
"permanently lost after closure.",
},
{
"n": "4.3",
"t": "Prior to Stress Relieving",
"code": "O",
"cust": "R/AU",
"crit": "Vessel meets drawing configuration and code requirements before heat "
"treatment; PWHT procedure/chart approved.",
},
{
"n": "4.4",
"t": "Final Inspection/Pre-hydrotest",
"code": "O",
"cust": "H",
"crit": "All welds, NDE and dimensional checks complete and accepted before the "
"vessel is pressurized. Hold point justified: an unverified vessel "
"presents a personnel-safety risk under test pressure.",
},
{
"n": "4.5",
"t": "Hydrostatic Testing and Reports",
"code": "A",
"cust": "H",
"crit": "Test pressure and hold time per ASME code/spec; no leaks, no permanent "
"deformation; customer witness where required by the PO. (API Q1 5.6.9.3)",
},
{
"n": "4.6",
"t": "Ultrasonic, Radiographic and Magnetic Particle Testing and Reports",
"code": "R",
"cust": "R/AU",
"crit": "NDE method/extent per code; results reference weld map location, heat "
"number and welder ID. (API Q1 5.6.9.4 / ISO 9001 8.5.2)",
},
],
},
{
"n": "5",
"t": "INTERNAL COATING",
"page": "C",
"sig": True,
"items": [
{
"n": "5.1",
"t": "Post Surface Preparation/Blasting",
"code": "W",
"cust": "W",
"crit": "Surface profile and cleanliness (e.g., SSPC-SP/NACE) meet the coating "
"spec. Witness point justified: surface condition cannot be re-verified "
"once coated.",
},
{
"n": "5.2",
"t": "Post Application of Primer Coat",
"code": "W",
"cust": "R/AU",
"crit": "DFT within spec range; no holidays, runs or contamination before overcoat.",
},
{
"n": "5.3",
"t": "Post Application of Intermediate Coat(s)",
"code": "W",
"cust": "R/AU",
"crit": "Cumulative DFT within spec range; recoat window per data sheet met.",
},
{
"n": "5.4",
"t": "Post Application of Top Coat",
"code": "A",
"cust": "R/AU",
"crit": "Total DFT, color and finish per coating spec/data sheet.",
},
{
"n": "5.5",
"t": "Holiday Testing",
"code": "H",
"cust": "H",
"crit": "Holiday (spark) test at voltage per NACE SP0188/coating data sheet; zero "
"holidays before service. Hold point justified: final acceptance test for "
"lining integrity.",
},
],
},
{
"n": "6",
"t": "EXTERNAL COATING",
"page": "C",
"sig": True,
"items": [
{
"n": "6.1",
"t": "Post Surface Preparation/Blasting",
"code": "W",
"cust": "W",
"crit": "Surface profile and cleanliness meet the coating spec. Witness point "
"justified: surface condition cannot be re-verified once coated.",
},
{
"n": "6.2",
"t": "Post Application of Primer Coat",
"code": "W",
"cust": "R/AU",
"crit": "DFT within spec range; no holidays, runs or contamination before overcoat.",
},
{
"n": "6.3",
"t": "Post Application of Intermediate Coat(s)",
"code": "W",
"cust": "R/AU",
"crit": "Cumulative DFT within spec range; recoat window per data sheet met.",
},
{
"n": "6.4",
"t": "Post Application of Top Coat",
"code": "A",
"cust": "R/AU",
"crit": "Total DFT, color and gloss per coating spec/data sheet.",
},
{
"n": "6.5",
"t": "Final External Coating Inspection",
"code": "A",
"cust": "R/AU",
"crit": "Visual inspection (holidays, runs, mechanical damage) and DFT survey meet "
"spec; touch-ups accepted before release.",
},
{
"n": "6.6",
"t": "Holiday Testing Under Insulation (If Applicable)",
"code": "H",
"cust": "H",
"crit": "Low-voltage holiday test per spec where insulation is applied over "
"coating. Hold point justified: coating is inaccessible once insulated. "
"Mark NA if not insulated.",
},
],
},
{
"n": "7",
"t": "INSTRUMENTATION, ELECTRICAL and CONTROLS",
"page": "FA",
"sig": True,
"items": [
{
"n": "7.1",
"t": "Verify Component Parts and Model Numbers to Specs/Data Sheets",
"code": "R",
"cust": "R/AU",
"crit": "Model, range, materials and area classification match the approved "
"instrument data sheet, cross-checked to the instrument index.",
},
{
"n": "7.2",
"t": "Contractor's Inspection Records",
"code": "R",
"cust": "R",
"crit": "Sub-tier inspection records complete and signed; sub-tier NCRs closed "
"before acceptance. (API Q1 5.6 / ISO 9001 8.4)",
},
{
"n": "7.3",
"t": "Calibration Verification – Test/Measuring Equipment Used",
"code": "R",
"cust": "R",
"crit": "Megger, torque wrench and other test/measuring equipment used for "
"acceptance are within current calibration; cal-due dates recorded on the "
"inspection record. (API Q1 5.8 / ISO 9001 7.1.5)",
},
{
"n": "7.4",
"t": "Verify Grounding and Design Documents/Specs",
"code": "R",
"cust": "R/AU",
"crit": "Grounding installed per design/one-line drawing and NEC Article 250; "
"ground resistance recorded.",
},
{
"n": "7.5",
"t": "Megger / Insulation Resistance and Continuity Check of Cables",
"code": "W",
"cust": "R/AU",
"crit": "Continuity end-to-end, cores correctly identified, insulation resistance "
"within limits. (Ref: NETA ATS; cable test schedule)",
},
{
"n": "7.6",
"t": "Static checks of control switches (as required)",
"code": "O",
"cust": "R/AU",
"crit": "Switch actuation and fail-safe position verified against the loop "
"diagram; set points and fail-safe action per data sheet. (Static check "
"record)",
},
{
"n": "7.7",
"t": "Verify Nameplate Layout, Lettering and Size",
"code": "I",
"cust": "R/AU",
"crit": "Text, size, material and mounting per client labeling spec. (Nameplate checklist)",
},
{
"n": "7.8",
"t": "Verify Wire Size, Terminations and Tagging",
"code": "I",
"cust": "R/AU",
"crit": "Wire size per load and NEC Art. 310; terminations torqued; tags match "
"wiring drawings. (Termination check sheet)",
},
{
"n": "7.9",
"t": "Electrical Motor Testing",
"code": "W",
"cust": "R/AU",
"crit": "IR, winding resistance and rotation within NEMA MG-1/NETA ATS limits. (Motor test report)",
},
{
"n": "7.10",
"t": "Instrument loop checks",
"code": "I",
"cust": "R/AU",
"crit": "Signal proven at 0/50/100% of range; alarms and trips actuate at set "
"point per loop diagram/C&E. (Signed loop check sheets)",
},
{
"n": "7.11",
"t": "Factory Acceptance Test (FAT)",
"code": "W",
"cust": "W",
"crit": "All FAT steps pass per the approved FAT procedure; A-items cleared before "
"shipment. (API Q1 5.6.9.3 / signed FAT report)",
},
],
},
{
"n": "8",
"t": "FINAL ASSEMBLY",
"page": "FA",
"sig": True,
"items": [
{
"n": "8.1",
"t": "Equipment Assembly, Mounting and/or Alignment",
"code": "I",
"cust": "R/AU",
"crit": "Alignment within vendor/GA drawing tolerance; mounting hardware and "
"anchor bolts torqued to spec. (Alignment record)",
},
{
"n": "8.2",
"t": "Functional Testing and Reports",
"code": "O",
"cust": "R/AU",
"crit": "All functions operate per the approved functional test procedure with no "
"defects. (Functional test report)",
},
{
"n": "8.3",
"t": "Full Operation Testing and Reports (PRESSURE UP CONNECTIONS)",
"code": "O",
"cust": "R/AU",
"crit": "System holds test pressure with zero leaks at all connections per the "
"approved test procedure. (ASME B31.3 §345 / operational test report)",
},
{
"n": "8.4",
"t": "Stud Bolt & Nut Torqueing",
"code": "I",
"cust": "R/AU",
"crit": "Torqued in pattern to the specified value with a calibrated wrench. (ASME "
"PCC-1 / torque record)",
},
{
"n": "8.5",
"t": "Stud Bolt Length & Grade",
"code": "I",
"cust": "R/AU",
"crit": "Correct grade marks; minimum 2 threads past the nut. (ASME B16.5, "
"A193/A194 / bolting checklist)",
},
{
"n": "8.6",
"t": "Relief Valve(s) Set Pressure",
"code": "I",
"cust": "R/AU",
"crit": "Set pressure verified per vendor data sheet / ASME Section VIII (or API "
"526 for PRVs) on a calibrated test bench; seal/wire intact after test. "
"(PRV test/certification record)",
},
{
"n": "8.7",
"t": "Weir Nipple Installation",
"code": "O",
"cust": "R/AU",
"crit": "Weir nipple size and orientation per P&ID and vendor requirement; "
"installed per manufacturer instructions.",
},
{
"n": "8.8",
"t": "Pressure Gauge Inspection",
"code": "O",
"cust": "R/AU",
"crit": "Gauge range 25–75% of span at normal operating pressure; gauge in current "
"calibration. (ASME B40.100 / gauge list + certs)",
},
{
"n": "8.9",
"t": "Regulator(s) Set Pressure",
"code": "I",
"cust": "R/AU",
"crit": "Outlet pressure within the stated tolerance of the vendor data sheet. (Set-point record)",
},
{
"n": "8.10",
"t": "Pressure Controller(s) Set Pressure",
"code": "I",
"cust": "R/AU",
"crit": "Set point and control action per the approved instrument data sheet. (Set-point record)",
},
{
"n": "8.11",
"t": "Flow Direction of all Valves, etc.",
"code": "I",
"cust": "R/AU",
"crit": "Flow arrows and valve orientation match the approved P&ID. (P&ID walk-down checklist)",
},
{
"n": "8.12",
"t": "Gasket Specification",
"code": "I",
"cust": "R/AU",
"crit": "Gasket type, material, rating and size per spec; never reused. (ASME "
"B16.20/B16.21 / gasket verification record)",
},
{
"n": "8.13",
"t": "Valve Actuation",
"code": "O",
"cust": "R/AU",
"crit": "Full stroke, travel time and fail position per the vendor manual/C&E "
"matrix. (Valve stroke record)",
},
{
"n": "8.14",
"t": "Nonconformance Check – Final Assembly",
"code": "R",
"cust": "R/AU",
"crit": "No open QNs/NCRs from the assembly stage before release to Paint/Final "
"Inspection. (API Q1 5.9)",
},
],
},
{
"n": "9",
"t": "FINAL INSPECTION/ SHIPPING PREPARATION",
"page": "FA",
"sig": True,
"items": [
{
"n": "9.1",
"t": "Verify Tagging/Markings are complete",
"code": "I",
"cust": "R/AU",
"crit": "Everything tagged per the index; legible and permanent. (Client tagging "
"spec, UG-116 / tagging checklist)",
},
{
"n": "9.2",
"t": "Flange Face Protection",
"code": "I",
"cust": "R/AU",
"crit": "All flanges protected, threaded connections plugged; lifting points and "
"shipping restraints verified per the packing plan. (API Q1 5.6.8; PO "
"packing spec / ISO 9001 8.5.4 / preservation checklist)",
},
{
"n": "9.3",
"t": "Verify Punch List Items are complete",
"code": "I",
"cust": "R/AU",
"crit": "All A-items closed; B/C items agreed in writing with the customer. "
"(Client punch list procedure / signed punch list)",
},
{
"n": "9.4",
"t": "Packing of Ship Loose Items",
"code": "I",
"cust": "R/AU",
"crit": "Ship-loose items tagged, protected and listed on the packing list. "
"(Approved packing plan, ISPM-15 / signed packing list)",
},
{
"n": "9.5",
"t": "Final Pre-shipping Inspection",
"code": "A",
"cust": "R/AU",
"crit": "Every ITP line signed off; no open holds or QNs. (API Q1 5.6.9.3, 5.7 / "
"final inspection report)",
},
{
"n": "9.6",
"t": "RELEASE TO SHIP (RTS)",
"code": "A",
"cust": "H",
"crit": "Written release issued by PESCO QA and the client; QN register closed "
"with all concessions customer-approved before shipment. (API Q1 5.7, 5.9 "
"/ ISO 9001 8.6, 8.7 / signed RTS certificate)",
},
{
"n": "9.7",
"t": "DATABOOK SIGNED OFF BY QA",
"code": "R",
"cust": "H",
"crit": "Data book complete and indexed per PO requirements. (API Q1 4.5 / ISO "
"9001 7.5.3 / signed data book)",
},
],
"final": True,
},
],
}
+34
View File
@@ -0,0 +1,34 @@
"""NCR numbers: the form check and the link to PESCO's NCR system (pesco-ncr).
An NCR number reads NCR-YYYY-NNNN (NCR-2026-0021). Q.C. gives one on a packet reject and on a rejected ITP line.
"""
from __future__ import annotations
import re
from typing import Any
from ..api.errors import ApiError
from ..text import LIMITS, clean_text
RX_NCR = re.compile(r"^NCR-\d{4}-\d{4}$")
NCR_URL = "https://ncr.pescoinc.biz/ncrs/"
MSG_INVALID = "Enter an NCR number like NCR-2026-0021."
NCR_MAX = LIMITS["ncr"]
def validate_ncr(text: Any) -> str:
"""The normalised NCR number (trimmed, upper case), or 422 invalid_ncr.
This is the hook for checking the number against pesco-ncr, which is a later phase. Today it accepts any
well-formed number and does not look it up.
"""
value = clean_text(text, NCR_MAX).upper()
if not RX_NCR.match(value):
raise ApiError(422, "invalid_ncr", MSG_INVALID)
return value
def ncr_url(number: str) -> str:
"""The pesco-ncr page of an NCR number."""
return NCR_URL + number
+143 -24
View File
@@ -43,6 +43,10 @@ SEAL_EXCLUDE = (
"target_hrs", "target_hrs",
"labor", "labor",
"heat_src", "heat_src",
"qc_last",
"qc_folder_docs",
"itp",
"itp_hold",
) )
@@ -242,6 +246,7 @@ class Children:
dept_names: dict[str, str] = field(default_factory=dict) dept_names: dict[str, str] = field(default_factory=dict)
dept_labels: dict[str, str] = field(default_factory=dict) dept_labels: dict[str, str] = field(default_factory=dict)
templates: dict[str, list[dict[str, str]]] = field(default_factory=dict) templates: dict[str, list[dict[str, str]]] = field(default_factory=dict)
qc_items: list[dict[str, str]] = field(default_factory=list)
@property @property
def open_stage(self) -> dict[str, Any] | None: def open_stage(self) -> dict[str, Any] | None:
@@ -264,6 +269,7 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
ch.dept_names[code] = name ch.dept_names[code] = name
ch.dept_labels[code] = dept_label(code, name, kind) ch.dept_labels[code] = dept_label(code, name, kind)
ch.templates = checklists.load_templates(conn) ch.templates = checklists.load_templates(conn)
ch.qc_items = checklists.load_qc_items(conn)
ch.pages = rows(conn, "SELECT * FROM packet_pages WHERE packet_id = %s ORDER BY n", (pid,)) ch.pages = rows(conn, "SELECT * FROM packet_pages WHERE packet_id = %s ORDER BY n", (pid,))
ch.bom = rows(conn, "SELECT * FROM bom_lines WHERE packet_id = %s ORDER BY item", (pid,)) ch.bom = rows(conn, "SELECT * FROM bom_lines WHERE packet_id = %s ORDER BY item", (pid,))
shorts = rows(conn, "SELECT * FROM bom_shorts WHERE packet_id = %s AND closed_at IS NULL", (pid,)) shorts = rows(conn, "SELECT * FROM bom_shorts WHERE packet_id = %s AND closed_at IS NULL", (pid,))
@@ -311,8 +317,12 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
ch.co_required.setdefault(r["co_id"], []).append(r["dept_code"]) ch.co_required.setdefault(r["co_id"], []).append(r["dept_code"])
for r in rows(conn, "SELECT * FROM co_verifications WHERE co_id = ANY(%s) ORDER BY at", (co_ids,)): for r in rows(conn, "SELECT * FROM co_verifications WHERE co_id = ANY(%s) ORDER BY at", (co_ids,)):
ch.co_verified.setdefault(r["co_id"], []).append(r) ch.co_verified.setdefault(r["co_id"], []).append(r)
# Every document, the ones taken out of the data book too (a removed row is a record).
ch.docs = rows( ch.docs = rows(
conn, "SELECT * FROM packet_docs WHERE packet_id = %s AND removed_at IS NULL ORDER BY added_at, id", (pid,) conn,
"SELECT d.*, a.original_name AS file_name, a.size AS file_size FROM packet_docs d"
" JOIN assets a ON a.id = d.asset_id WHERE d.packet_id = %s ORDER BY d.added_at, d.id",
(pid,),
) )
ch.seal = one(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pid,)) ch.seal = one(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pid,))
return ch return ch
@@ -370,7 +380,7 @@ def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
checklist_complete=stage is not None checklist_complete=stage is not None
and checklists.checklist_complete(stage["checklist"], weld, ch.templates or None), and checklists.checklist_complete(stage["checklist"], weld, ch.templates or None),
filler_set=stage is not None and bool((stage["filler"] or "").strip()), filler_set=stage is not None and bool((stage["filler"] or "").strip()),
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds)), qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds, ch.qc_items or None)),
has_qc_sent=ch.qc is not None, has_qc_sent=ch.qc is not None,
) )
@@ -485,7 +495,10 @@ def _stage(
return out return out
def _qc(q: Mapping[str, Any], people: People) -> dict[str, Any]: RX_NACE = re.compile(r"NACE", re.IGNORECASE)
def _qc(q: Mapping[str, Any], people: People, row: Mapping[str, Any], missing: list[str]) -> dict[str, Any]:
keys = ( keys = (
"id", "id",
"sent_at", "sent_at",
@@ -504,16 +517,57 @@ def _qc(q: Mapping[str, Any], people: People) -> dict[str, Any]:
"result", "result",
"result_at", "result_at",
"result_note", "result_note",
"ncr",
"row_version", "row_version",
) )
out = {k: q[k] for k in keys} out = {k: q.get(k) for k in keys}
out["sent_by"] = people.ref(q["sent_by"]) out["sent_by"] = people.ref(q["sent_by"])
out["result_by"] = people.ref(q["result_by"]) out["result_by"] = people.ref(q["result_by"])
out["checks"] = q["checks"] or {} out["checks"] = _checklist(q["checks"], people) # entries gain by_name
out["weld_exam"] = q["weld_exam"] or {} out["weld_exam"] = _checklist(q["weld_exam"], people)
out["nace_label"] = bool(RX_NACE.search(str(row.get("wps") or "")))
out["missing"] = missing
return out return out
def _entry_ids(value: Any) -> list[Any]:
return [as_uuid(v.get("by")) for v in (value or {}).values() if isinstance(v, Mapping)]
def qc_history_row(h: Mapping[str, Any], people: People) -> dict[str, Any]:
"""A closed inspection: the qc_history row with the parts of its snapshot the screens show. `weld_exam` keeps
each weld's result and note so the screens can show the defect next to a rejected weld."""
from .qc import rejected_welds
snap = h.get("snapshot")
if not isinstance(snap, Mapping):
snap = {}
raw = snap.get("weld_exam")
exam: Mapping[str, Any] = raw if isinstance(raw, Mapping) else {}
return {
"id": h["id"],
"at": h["at"],
"result": h["result"],
"inspector": h["inspector"],
"note": h["note"],
"dept_code": h["dept_code"],
"ncr": snap.get("ncr"),
"rejected_welds": rejected_welds(exam),
"weld_exam": {
str(k): {"result": v.get("result"), "note": v.get("note")}
for k, v in exam.items()
if isinstance(v, Mapping)
},
"by": people.ref(as_uuid(snap.get("result_by"))),
"location": snap.get("location"),
"sent_at": snap.get("sent_at"),
}
# Keys phase 6 added to qc_history rows. Seals made before phase 6 do not cover them, so the seal leaves them out.
SEAL_QC_HISTORY = ("ncr", "rejected_welds", "weld_exam", "by", "location", "sent_at")
def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]: def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]:
ids: list[Any] = [row["welder_id"], row["for_sup_id"], row["vclosed_by"], row["removed_by"]] ids: list[Any] = [row["welder_id"], row["for_sup_id"], row["vclosed_by"], row["removed_by"]]
ids += [w["welder_id"] for w in ch.welds] + [w["by"] for w in ch.welds] ids += [w["welder_id"] for w in ch.welds] + [w["by"] for w in ch.welds]
@@ -527,7 +581,12 @@ def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]:
ids.append(ch.wh["by"]) ids.append(ch.wh["by"])
if ch.qc: if ch.qc:
ids += [ch.qc["sent_by"], ch.qc["result_by"]] ids += [ch.qc["sent_by"], ch.qc["result_by"]]
ids += [d["added_by"] for d in ch.docs] ids += _entry_ids(ch.qc["checks"]) + _entry_ids(ch.qc["weld_exam"])
for h in ch.qc_history:
snap = h.get("snapshot")
if isinstance(snap, Mapping):
ids.append(as_uuid(snap.get("result_by")))
ids += [d["added_by"] for d in ch.docs] + [d["removed_by"] for d in ch.docs]
for c in ch.change_orders: for c in ch.change_orders:
ids += [c["issued_by"], c["approved_by"], c["rejected_by"]] ids += [c["issued_by"], c["approved_by"], c["rejected_by"]]
for vs in ch.co_verified.values(): for vs in ch.co_verified.values():
@@ -723,30 +782,18 @@ def build(
"auto": ch.wh["auto"], "auto": ch.wh["auto"],
} }
) )
out["qc"] = _qc(ch.qc, people) if ch.qc else None out["qc"] = _qc(ch.qc, people, row, list(state.qc_missing)) if ch.qc else None
out["qc_history"] = [ out["qc_history"] = [qc_history_row(h, people) for h in ch.qc_history]
{k: h[k] for k in ("id", "at", "result", "inspector", "note", "dept_code")} for h in ch.qc_history out["qc_last"] = out["qc_history"][-1] if out["qc_history"] else None
]
out["change_orders"] = [_co(c, ch, people) for c in ch.change_orders] out["change_orders"] = [_co(c, ch, people) for c in ch.change_orders]
out["docs"] = [ out["docs"] = [_doc(d, people, seal=seal) for d in ch.docs]
{
"id": d["id"],
"asset_id": str(d["asset_id"]),
"kind": d["kind"],
"title": d["title"],
"added_at": d["added_at"],
"added_by": people.ref(d["added_by"]),
"url": f"/_blob/{d['asset_id']}",
}
for d in ch.docs
]
out["seal"] = ( out["seal"] = (
None None
if ch.seal is None if ch.seal is None
else {"sealed_at": ch.seal["sealed_at"], "key_id": ch.seal["key_id"], "broken_at": ch.seal["broken_at"]} else {"sealed_at": ch.seal["sealed_at"], "key_id": ch.seal["key_id"], "broken_at": ch.seal["broken_at"]}
) )
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"], ch.templates) out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"], ch.templates)
out["qc_items"] = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"]) out["qc_items"] = qc_items_view(row, ch)
out["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"], row["visual"]) out["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"], row["visual"])
if not seal: if not seal:
out["visual_state"] = visual_state(conn, row) out["visual_state"] = visual_state(conn, row)
@@ -754,6 +801,7 @@ def build(
from . import shop # shop builds on this module from . import shop # shop builds on this module
out["heat_src"] = shop.heat_src(conn, row, ch) out["heat_src"] = shop.heat_src(conn, row, ch)
out.update(qc_extras(conn, row))
if pp is not None: if pp is not None:
allowed, blocked = policy.can(pp, state) allowed, blocked = policy.can(pp, state)
out["can"] = [str(a) for a in allowed] out["can"] = [str(a) for a in allowed]
@@ -786,6 +834,9 @@ def _seal_strip(out: dict[str, Any]) -> None:
for r in out.get(key) or []: for r in out.get(key) or []:
for n in names: for n in names:
r.pop(n, None) r.pop(n, None)
for h in out.get("qc_history") or []:
for n in SEAL_QC_HISTORY:
h.pop(n, None)
for co in out.get("change_orders") or []: for co in out.get("change_orders") or []:
for v in (co.get("verified") or {}).values(): for v in (co.get("verified") or {}).values():
v.pop("signed", None) v.pop("signed", None)
@@ -803,6 +854,74 @@ def _seal_strip(out: dict[str, Any]) -> None:
v.pop("by_name", None) v.pop("by_name", None)
def _doc(d: Mapping[str, Any], people: People, *, seal: bool) -> dict[str, Any]:
"""A packet document. Seal mode keeps the phase 2 keys, plus the note and the take-out when they are set, so a
seal made before phase 6 still verifies for a packet whose documents were never taken out."""
from .qc import DOC_KINDS
removed = (
None
if d["removed_at"] is None
else {"at": d["removed_at"], "by": people.ref(d["removed_by"]), "reason": d["removed_reason"]}
)
out: dict[str, Any] = {
"id": d["id"],
"asset_id": str(d["asset_id"]),
"kind": d["kind"],
"title": d["title"],
"added_at": d["added_at"],
"added_by": people.ref(d["added_by"]),
"url": f"/_blob/{d['asset_id']}",
}
if seal:
if d.get("note"):
out["note"] = d["note"]
if removed is not None:
out["removed"] = removed
return out
out["kind_label"] = DOC_KINDS.get(str(d["kind"]), str(d["kind"]))
out["note"] = d.get("note")
out["file_name"] = d.get("file_name")
out["size"] = d.get("file_size")
out["removed"] = removed
return out
def qc_items_view(row: Mapping[str, Any], ch: Children) -> list[dict[str, Any]]:
"""The Q.C. items with the old qcTab hints: heats and MTRs over the BOM lines, the welds on the map, and the open
(or last) stage's checklist progress with its filler."""
items = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"], ch.qc_items or None)
nb = len(ch.bom)
heats = sum(1 for b in ch.bom if str(b.get("heat") or "").strip() or b.get("na"))
mtrs = sum(1 for b in ch.bom if b.get("mtr_asset_id"))
stage = ch.open_stage or (ch.stages[-1] if ch.stages else None)
keys = checklists.checklist_keys(bool(ch.dept.get("weld")), ch.templates or None)
ck = (stage or {}).get("checklist") or {}
done = sum(1 for k in keys if checklists.is_done(ck.get(k)))
filler = str((stage or {}).get("filler") or "").strip()
hints = {
"db_heats": f"{heats}/{nb} recorded",
"db_mtr": f"{mtrs}/{nb} MTRs attached",
"db_weldmap": f"{len(ch.welds)} welds on map",
"db_ck": f"checklist {done}/{len(keys)}" + (f" · filler {filler}" if filler else ""),
}
return [{**i, "hint": hints.get(i["key"])} for i in items]
def qc_extras(conn: Any, row: Mapping[str, Any]) -> dict[str, Any]:
"""The unit's Q.C. folder documents, its ITP summary and the packet's ITP hold warning. Outside the seal."""
from . import itp, qcdocs
unit_id = int(row["unit_id"])
r = conn.execute("SELECT project_id FROM units WHERE id = %s", (unit_id,)).fetchone()
found = itp.unit_itps(conn, [unit_id]).get(unit_id)
return {
"qc_folder_docs": qcdocs.packet_folder_docs(conn, unit_id, int(r[0]) if r else None),
"itp": itp.summary(found[0], found[1]) if found else None,
"itp_hold": itp.holds_for_packet(found[0], found[1], row["dept_code"]) if found else None,
}
def target_hrs(work_orders: Iterable[Mapping[str, Any]]) -> float: def target_hrs(work_orders: Iterable[Mapping[str, Any]]) -> float:
"""The packet's target hours: the sum of its work orders' target hours (0 when none).""" """The packet's target hours: the sum of its work orders' target hours (0 when none)."""
total = 0.0 total = 0.0
+10 -1
View File
@@ -35,6 +35,7 @@ from ..policy.engine import MSG_FILED
from ..settings import SecretFileError from ..settings import SecretFileError
from ..text import LIMITS, clean_opt, reason_ok from ..text import LIMITS, clean_opt, reason_ok
from . import packets from . import packets
from . import qc as qc_dom
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -640,6 +641,7 @@ def _shop_texts(w: Write, a: policy.Action, to_dept: str, stamp: str | None = No
SHOP_TEXTS = frozenset( SHOP_TEXTS = frozenset(
{A.ASSIGN, A.UNASSIGN, A.PULL_BACK, A.START, A.SUBMIT, A.RETURN_TO_STATION, A.SEND_TO_QC, A.ROUTE_FROM_REVIEW} {A.ASSIGN, A.UNASSIGN, A.PULL_BACK, A.START, A.SUBMIT, A.RETURN_TO_STATION, A.SEND_TO_QC, A.ROUTE_FROM_REVIEW}
) )
QC_TEXTS = frozenset({A.QC_ACCEPT, A.QC_ACCEPT_AND_ROUTE, A.QC_REJECT})
def apply_transition(w: Write) -> None: def apply_transition(w: Write) -> None:
@@ -661,6 +663,8 @@ def apply_transition(w: Write) -> None:
stamp: str | None = None stamp: str | None = None
if a in SHOP_TEXTS and a is not A.ASSIGN and a is not A.SUBMIT: if a in SHOP_TEXTS and a is not A.ASSIGN and a is not A.SUBMIT:
_shop_texts(w, a, to_dept) # before the side effects clear the station _shop_texts(w, a, to_dept) # before the side effects clear the station
if a in QC_TEXTS:
qc_dom.qc_texts(w, a, to_dept) # before the side effects close the inspection
if a in (A.SEND_TO_WAREHOUSE, A.VIS_AUTO_RELEASE): if a in (A.SEND_TO_WAREHOUSE, A.VIS_AUTO_RELEASE):
_set_packet(w, "wh_skipped = false, wh_sent_at = now(), return_note = NULL") _set_packet(w, "wh_skipped = false, wh_sent_at = now(), return_note = NULL")
@@ -748,7 +752,12 @@ def apply_transition(w: Write) -> None:
_open_stage(w, to_dept) _open_stage(w, to_dept)
_set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL, return_note = NULL, routed_to = NULL") _set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL, return_note = NULL, routed_to = NULL")
elif a is A.QC_REJECT: elif a is A.QC_REJECT:
_set_result(w, _open_inspection(w), "rejected", reason) # The reject closes the inspection into qc_history at once; the next SEND_TO_QC opens a fresh one.
insp = _open_inspection(w)
if insp is not None:
w.conn.execute("UPDATE qc_inspections SET ncr = %s WHERE id = %s", (ex.get("ncr") or None, insp["id"]))
insp = _set_result(w, insp, "rejected", reason)
_close_inspection(w, insp, from_dept)
_set_packet(w, "return_note = %s", (reason,)) _set_packet(w, "return_note = %s", (reason,))
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)): if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
_open_stage(w, from_dept) # a draft sent straight to Q.C. has no stage yet _open_stage(w, from_dept) # a draft sent straight to Q.C. has no stage yet
+210
View File
@@ -0,0 +1,210 @@
"""Q.C.: the home board, the accept and reject log texts, and the packet document kinds.
The home board ports the old homeQC: packets waiting for inspection, the latest accepted, and the rejected ones back
with their supervisor, plus the project folders. The texts port the old qcAccept and qcReject handlers.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from .. import policy
from ..text import LIMITS
from . import itp, packets, qcdocs
A = policy.Action
# Packet document kinds (the old PDOC_TYPES): kind -> label.
DOC_KINDS: dict[str, str] = {
"nde": "NDE report (RT / UT / MT / PT)",
"hydro": "Hydrotest report",
"dimensional": "Dimensional report",
"pmi": "PMI report",
"coating": "Coating / paint report",
"coc": "Certificate of conformance",
"customer": "Customer inspection report",
"ncr": "NCR",
"other": "Other",
}
DOC_MIMES = qcdocs.DOC_MIMES
DOC_NOTE_MAX = LIMITS["doc_note"]
NDE_METHODS: tuple[str, ...] = ("None required", "RT", "UT", "MT", "PT")
NDE_NONE = "None required"
NDE_RESULTS: tuple[str, ...] = ("", "Acceptable", "Rejectable")
# The accept routes (the old ROUTES). The en dash is the old text.
ROUTE_TEXTS = {"databook": "Clerical – Data Book", "shipping": "Shipping"}
ACCEPTED_ON_BOARD = 8
# ---------------------------------------------------------------- texts
def weld_label(no: Any) -> str:
return f"W{no}"
def rejected_welds(exam: Mapping[str, Any] | None) -> list[str]:
"""The weld ids with a reject result, sorted by number: ["W1", "W3"]."""
nos: list[int] = []
for k, v in (exam or {}).items():
if isinstance(v, Mapping) and str(v.get("result") or "").lower() == "reject":
try:
nos.append(int(k))
except (TypeError, ValueError):
continue
return [weld_label(n) for n in sorted(nos)]
def inspector_text(insp: Mapping[str, Any] | None) -> str:
q = insp or {}
who = str(q.get("inspector") or "").strip()
stamp = str(q.get("stamp") or "").strip()
return f"{who} [{stamp}]" if stamp else who
def qc_texts(w: Any, a: policy.Action, to_dept: str) -> None:
"""The packet_log label and note of a Q.C. accept or reject. Called before the side effects close the inspection.
`w` is the pipeline Write; its extra holds `reason_text` and `ncr` for a reject.
"""
insp = packets.one(w.conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
note = str(w.extra.get("note") or "").strip() or None
if a is A.QC_REJECT:
exam = (insp or {}).get("weld_exam") or {}
parts = [str(w.extra.get("ncr") or ""), str(w.extra.get("reason_text") or "")]
welds = rejected_welds(exam)
if welds:
parts.append("welds " + ", ".join(welds))
w.log("Q.C. rejected → supervisor", " · ".join(x for x in parts if x) or None)
return
if a is A.QC_ACCEPT_AND_ROUTE:
labels = getattr(w.children, "dept_labels", None) or packets.dept_labels(w.conn)
text = " · ".join(x for x in (inspector_text(insp), f"from {labels.get(w.from_dept, w.from_dept)}", note) if x)
w.log(f"Q.C. accepted → {labels.get(to_dept, to_dept)}", text)
return
if a is A.QC_ACCEPT:
n = len(w.children.welds) if w.children is not None else 0
parts = [inspector_text(insp), f"{n} welds VT accepted"]
method = str((insp or {}).get("nde_method") or "").strip()
if method and method != NDE_NONE:
parts.append(f"{method} {(insp or {}).get('nde_result') or ''}".strip())
if note:
parts.append(note)
route = ROUTE_TEXTS.get(str(w.extra.get("routed_to")), str(w.extra.get("routed_to")))
w.log(f"Q.C. accepted → {route}", " · ".join(x for x in parts if x))
# ---------------------------------------------------------------- the home board
def _last_results(conn: Any, packet_ids: list[int]) -> dict[int, dict[str, Any]]:
if not packet_ids:
return {}
rows = packets.rows(
conn,
"SELECT DISTINCT ON (packet_id) * FROM qc_history WHERE packet_id = ANY(%s) ORDER BY packet_id, id DESC",
(packet_ids,),
)
return {int(r["packet_id"]): r for r in rows}
def _scope_where(project_id: int | None, unit_id: int | None) -> tuple[str, list[Any]]:
"""Fixed SQL fragments over the packets alias p, and their parameters."""
where, params = "", []
if unit_id is not None:
where += " AND p.unit_id = %s"
params.append(unit_id)
if project_id is not None:
where += " AND p.unit_id IN (SELECT id FROM units WHERE project_id = %s)"
params.append(project_id)
return where, params
LAST_SQL = (
"SELECT p.id FROM packets p JOIN LATERAL (SELECT h.result, h.at, h.id FROM qc_history h"
" WHERE h.packet_id = p.id ORDER BY h.id DESC LIMIT 1) h ON true"
" WHERE p.removed_at IS NULL AND p.status::text = ANY(%s) AND h.result = %s"
)
COUNT_SQL = (
"SELECT count(DISTINCT l.packet_id) FROM packet_log l JOIN packets p ON p.id = l.packet_id"
" WHERE l.at > now() - interval '7 days' AND l.action LIKE %s AND p.removed_at IS NULL"
)
def _ids_with_last(conn: Any, statuses: tuple[str, ...], result: str, where: str, params: list[Any]) -> list[int]:
"""Packets in the statuses whose newest qc_history result is `result`, newest result first."""
sql = LAST_SQL + where + " ORDER BY h.id DESC" # where: fixed fragments from _scope_where
rows = conn.execute(sql, (list(statuses), result, *params)).fetchall()
return [int(r[0]) for r in rows]
def _count_7d(conn: Any, prefix: str, where: str, params: list[Any]) -> int:
row = conn.execute(COUNT_SQL + where, (prefix + "%", *params)).fetchone()
return int(row[0] or 0)
def home(
conn: Any, pp: policy.Principal, *, project_id: int | None = None, unit_id: int | None = None
) -> dict[str, Any]:
"""GET /api/qc/home: the KPIs, the three boards and the active project folders."""
filters: dict[str, Any] = {"project_id": project_id, "unit_id": unit_id}
ready = sorted(
packets.list_cards(conn, pp, {**filters, "status": "qc"}),
key=lambda c: (c["status_at"], c["id"]),
)
where, params = _scope_where(project_id, unit_id)
acc_ids = _ids_with_last(conn, ("complete", "filed"), "accepted", where, params)[:ACCEPTED_ON_BOARD]
rej_ids = _ids_with_last(conn, ("review",), "rejected", where, params)
by_id = {c["id"]: c for c in packets.list_cards(conn, pp, {"ids": acc_ids + rej_ids})} if acc_ids or rej_ids else {}
accepted = [by_id[i] for i in acc_ids if i in by_id]
rejected = [by_id[i] for i in rej_ids if i in by_id]
_add_qc(conn, ready + accepted + rejected)
return {
"kpis": {
"waiting": len(ready),
"accepted_7d": _count_7d(conn, "Q.C. accepted", where, params),
"rejected_7d": _count_7d(conn, "Q.C. rejected", where, params),
},
"columns": {"ready": ready, "accepted": accepted, "rejected": rejected},
"folders": qcdocs.folders(conn, "active"),
}
def _add_qc(conn: Any, cards: list[dict[str, Any]]) -> None:
"""Each card gains `qc` (the open inspection's location and sent time, or the last result) and itp_hold_count."""
if not cards:
return
ids = [int(c["id"]) for c in cards]
open_rows = {
int(r["packet_id"]): r
for r in packets.rows(
conn,
"SELECT packet_id, location, sent_at, inspector, result FROM qc_inspections"
" WHERE packet_id = ANY(%s) AND closed_at IS NULL",
(ids,),
)
}
last = _last_results(conn, ids)
units = itp.unit_itps(conn, [int(c["unit"]["id"]) for c in cards])
for c in cards:
pid = int(c["id"])
o = open_rows.get(pid)
if o is not None:
c["qc"] = {k: o[k] for k in ("location", "sent_at", "inspector", "result")}
elif pid in last:
h = last[pid]
snap = h.get("snapshot") or {}
c["qc"] = {
"location": snap.get("location"),
"sent_at": snap.get("sent_at"),
"inspector": h.get("inspector"),
"result": h.get("result"),
}
else:
c["qc"] = None
u = units.get(int(c["unit"]["id"]))
hold = itp.holds_for_packet(u[0], u[1], c["dept"]["code"]) if u else None
c["itp_hold_count"] = hold["count"] if hold else 0
+198
View File
@@ -0,0 +1,198 @@
"""Q.C. folders: the documents Q.C. files for a project or a unit (ITP, WPS / PQR, procedures, certificates).
The folders are the projects and units Clerical sets up; nothing is created on the Q.C. side. A project document
covers every unit of the project (a unit folder lists it as inherited). Nothing is ever deleted: a new revision is
a new row, and the old row points at it (superseded_by, superseded_at, superseded_by_user) and stays readable under
"Superseded". The old build's qcfolders.js.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any
from ..text import LIMITS
from . import itp, packets
# The document types (the old QC_TYPES), stored as the label itself.
QC_TYPES: tuple[str, ...] = (
"ITP",
"WPS / PQR",
"NDE procedure",
"Hydrotest procedure",
"PMI / material",
"Calibration certificate",
"Customer spec",
"Other",
)
DOC_MIMES = frozenset({"application/pdf", "image/png", "image/jpeg"})
TITLE_MAX = LIMITS["qc_doc_title"]
REV_MAX = LIMITS["qc_doc_rev"]
NOTE_MAX = LIMITS["qc_doc_note"]
DOC_SQL = """
SELECT d.*, a.original_name AS file_name, a.size AS file_size
FROM qc_docs d JOIN assets a ON a.id = d.asset_id
"""
PROJECT_SQL = (
"SELECT id, number, name, customer, po, status::text AS status FROM projects WHERE id = %s AND removed_at IS NULL"
)
def doc_rows(conn: Any, where: str, params: Any = ()) -> list[dict[str, Any]]:
return packets.rows(conn, DOC_SQL + " WHERE " + where + " ORDER BY d.added_at, d.id", params)
def shape(rows: Iterable[Mapping[str, Any]], conn: Any) -> list[dict[str, Any]]:
rows = list(rows)
people = packets.People(conn, [r["added_by"] for r in rows] + [r["superseded_by_user"] for r in rows])
return [
{
"id": r["id"],
"project_id": r["project_id"],
"unit_id": r["unit_id"],
"type": r["type"],
"title": r["title"],
"rev": r["rev"],
"note": r["note"],
"asset_id": str(r["asset_id"]),
"url": f"/_blob/{r['asset_id']}",
"file_name": r["file_name"],
"size": r["file_size"],
"auto": r["auto"],
"added_at": r["added_at"],
"added_by": people.ref(r["added_by"]),
"superseded_by": r["superseded_by"],
"superseded_at": r["superseded_at"],
"superseded_by_user": people.ref(r["superseded_by_user"]),
}
for r in rows
]
def _split(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
current = [r for r in rows if r["superseded_by"] is None]
old = sorted((r for r in rows if r["superseded_by"] is not None), key=lambda r: r["superseded_at"], reverse=True)
return current, old
def project_ref(conn: Any, project_id: int) -> dict[str, Any] | None:
return packets.one(conn, PROJECT_SQL, (project_id,))
def _has_itp_doc(rows: Iterable[Mapping[str, Any]]) -> bool:
return any(r["type"] == "ITP" and r["superseded_by"] is None for r in rows)
def project_folder(conn: Any, project_id: int) -> dict[str, Any] | None:
"""GET /api/qc/folders/projects/{id}, or None for an unknown project."""
project = project_ref(conn, project_id)
if project is None:
return None
current, old = _split(doc_rows(conn, "d.project_id = %s", (project_id,)))
units = packets.rows(
conn,
"SELECT id, number, lot, name, status::text AS status FROM v_units"
" WHERE project_id = %s AND removed_at IS NULL ORDER BY lot",
(project_id,),
)
unit_docs: dict[int, list[dict[str, Any]]] = {}
if units:
for r in doc_rows(conn, "d.unit_id = ANY(%s) AND d.superseded_by IS NULL", ([u["id"] for u in units],)):
unit_docs.setdefault(r["unit_id"], []).append(r)
sums = itp.summaries(conn, [u["id"] for u in units])
return {
"project": project,
"docs": shape(current, conn),
"superseded": shape(old, conn),
"units": [
{
"id": u["id"],
"number": u["number"],
"lot": u["lot"],
"name": u["name"],
"status": u["status"],
"docs": len(unit_docs.get(u["id"], [])),
"itp": sums.get(u["id"]),
"has_itp_doc": _has_itp_doc(unit_docs.get(u["id"], [])),
}
for u in units
],
}
def unit_folder(conn: Any, unit_id: int) -> dict[str, Any] | None:
"""GET /api/qc/folders/units/{id}, or None for an unknown unit."""
unit = packets.one(
conn,
"SELECT id, number, lot, name, status::text AS status, po, project_id FROM v_units"
" WHERE id = %s AND removed_at IS NULL",
(unit_id,),
)
if unit is None:
return None
project = project_ref(conn, unit["project_id"])
if project is None:
return None
current, old = _split(doc_rows(conn, "d.unit_id = %s", (unit_id,)))
inherited = doc_rows(conn, "d.project_id = %s AND d.superseded_by IS NULL", (project["id"],))
summary = itp.summaries(conn, [unit_id]).get(unit_id)
open_unit = unit["status"] != "closed" and project["status"] != "closed"
no_itp = open_unit and summary is None and not _has_itp_doc(current) and not _has_itp_doc(inherited)
return {
"unit": {k: unit[k] for k in ("id", "number", "lot", "name", "status", "po")},
"project": project,
"docs": shape(current, conn),
"inherited": shape(inherited, conn),
"superseded": shape(old, conn),
"itp": summary,
"no_itp": no_itp,
}
FOLDERS_SQL = """
SELECT p.id, p.number, p.name, p.customer, p.po, p.status::text AS status,
(SELECT count(*) FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL) AS units,
(SELECT count(*) FROM qc_docs d WHERE d.superseded_by IS NULL AND (d.project_id = p.id OR d.unit_id IN
(SELECT u.id FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL))) AS docs,
CASE WHEN p.status = 'closed' OR EXISTS (SELECT 1 FROM qc_docs d WHERE d.project_id = p.id
AND d.type = 'ITP' AND d.superseded_by IS NULL) THEN 0
ELSE (SELECT count(*) FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL
AND u.status <> 'closed'
AND NOT EXISTS (SELECT 1 FROM itps i WHERE i.unit_id = u.id)
AND NOT EXISTS (SELECT 1 FROM qc_docs d WHERE d.unit_id = u.id AND d.type = 'ITP'
AND d.superseded_by IS NULL)) END AS units_without_itp
FROM projects p
WHERE p.removed_at IS NULL
"""
def folders(conn: Any, show: str = "active") -> list[dict[str, Any]]:
"""GET /api/qc/folders: one row per live project, by number. `active` leaves out closed projects."""
sql = FOLDERS_SQL + (" AND p.status <> 'closed'" if show != "all" else "") + " ORDER BY p.number"
return [
{**r, "units": int(r["units"]), "docs": int(r["docs"]), "units_without_itp": int(r["units_without_itp"])}
for r in packets.rows(conn, sql)
]
def packet_folder_docs(conn: Any, unit_id: int, project_id: int | None) -> list[dict[str, Any]]:
"""The current Q.C. folder documents of a packet's unit and of its project, unit first."""
out: list[dict[str, Any]] = []
for scope, where, ident in (("unit", "d.unit_id = %s", unit_id), ("project", "d.project_id = %s", project_id)):
if ident is None:
continue
for r in doc_rows(conn, where + " AND d.superseded_by IS NULL", (ident,)):
out.append(
{
"id": r["id"],
"type": r["type"],
"title": r["title"],
"rev": r["rev"],
"url": f"/_blob/{r['asset_id']}",
"scope": scope,
"added_at": r["added_at"],
}
)
return out
+4
View File
@@ -101,10 +101,12 @@ def create_app(
auth, auth,
departments, departments,
feed_settings, feed_settings,
itp,
jobs, jobs,
packet_children, packet_children,
packets, packets,
projects, projects,
qc,
shop, shop,
units, units,
warehouse, warehouse,
@@ -127,6 +129,8 @@ def create_app(
feed_settings, feed_settings,
warehouse, warehouse,
shop, shop,
qc,
itp,
): ):
app.include_router(module.router) app.include_router(module.router)
app.include_router(assets.blob_router) app.include_router(assets.blob_router)
+107
View File
@@ -0,0 +1,107 @@
-- 0007_qc: the Q.C. phase. The inspection's NCR, packet document notes and kinds, Q.C. folder metadata, the ITP
-- header, line and block details, the ITP log, and the Q.C. check items as a settings row. Runs as ps_migrate, in
-- one transaction like every migration file.
-- The NCR number a Q.C. reject gave (NCR-2026-0021), kept on the inspection and in its qc_history snapshot.
ALTER TABLE qc_inspections ADD COLUMN ncr text;
-- Packet documents: an optional note, and the kind is a closed set (the old PDOC_TYPES).
ALTER TABLE packet_docs ADD COLUMN note text;
ALTER TABLE packet_docs ADD CONSTRAINT packet_docs_kind
CHECK (kind IN ('nde', 'hydro', 'dimensional', 'pmi', 'coating', 'coc', 'customer', 'ncr', 'other'));
-- Q.C. folder documents belong to exactly one project or one unit. A new revision files a new row and points the old
-- one at it (superseded_by, superseded_at, superseded_by_user). Nothing is deleted.
ALTER TABLE qc_docs
ADD COLUMN title text,
ADD COLUMN note text,
ADD COLUMN superseded_at timestamptz,
ADD COLUMN superseded_by_user uuid REFERENCES users (id);
ALTER TABLE qc_docs ADD CONSTRAINT qc_docs_scope CHECK ((project_id IS NULL) <> (unit_id IS NULL));
ALTER TABLE qc_docs ADD CONSTRAINT qc_docs_type
CHECK (type IN ('ITP', 'WPS / PQR', 'NDE procedure', 'Hydrotest procedure', 'PMI / material',
'Calibration certificate', 'Customer spec', 'Other'));
CREATE INDEX qc_docs_project ON qc_docs (project_id) WHERE superseded_by IS NULL;
CREATE INDEX qc_docs_unit ON qc_docs (unit_id) WHERE superseded_by IS NULL;
-- An ITP is open or complete (signed off). 'signed' and 'reopened' stay in the enum, unused. The new value is not
-- used in this migration, so adding it inside the transaction is fine (PostgreSQL 12 and later).
ALTER TYPE itp_status ADD VALUE IF NOT EXISTS 'complete';
-- The ITP number (<unit number>-ITP) and header (client, pname, pno, po, unit) as Q.C. may edit them.
ALTER TABLE itps
ADD COLUMN no text,
ADD COLUMN hdr jsonb NOT NULL DEFAULT '{}',
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN created_by uuid REFERENCES users (id);
-- A line: Q.C.'s initials, and the customer's code, witness name and date with who recorded them.
ALTER TABLE itp_lines
ADD COLUMN initials text,
ADD COLUMN cust_code text,
ADD COLUMN cust_by text,
ADD COLUMN cust_at date,
ADD COLUMN cust_rec uuid REFERENCES users (id),
ADD COLUMN cust_rec_at timestamptz;
-- A reject may come before its NCR number. kind 'reject' is the rejection; kind 'ncr' records the number entered later.
ALTER TABLE itp_rejects ALTER COLUMN ncr DROP NOT NULL;
ALTER TABLE itp_rejects ADD COLUMN kind text NOT NULL DEFAULT 'reject' CHECK (kind IN ('reject', 'ncr'));
-- A block row exists before it is signed: QN #, remarks and the customer complete record live on it.
ALTER TABLE itp_blocks ALTER COLUMN signed_at DROP NOT NULL, ALTER COLUMN signed_at DROP DEFAULT;
ALTER TABLE itp_blocks
ADD COLUMN qn text,
ADD COLUMN remarks text,
ADD COLUMN cust_name text,
ADD COLUMN cust_at date,
ADD COLUMN cust_by uuid REFERENCES users (id),
ADD COLUMN cust_rec_at timestamptz,
ADD COLUMN row_version int NOT NULL DEFAULT 1;
-- The ITP's own history, append-only like packet_log.
CREATE TABLE itp_log (
id bigserial PRIMARY KEY,
itp_id bigint NOT NULL REFERENCES itps (id),
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id),
by_name text,
action text NOT NULL,
note text,
signature_id bigint REFERENCES signatures (id),
it_reason text
);
CREATE INDEX itp_log_itp ON itp_log (itp_id, id);
CREATE TRIGGER itp_log_append_only
BEFORE UPDATE OR DELETE ON itp_log
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER itp_log_no_truncate
BEFORE TRUNCATE ON itp_log
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
-- Explicit, after the default privileges: the app may only read and append the ITP log.
REVOKE UPDATE, DELETE, TRUNCATE ON itp_log FROM ps_app;
GRANT SELECT, INSERT ON itp_log TO ps_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
-- The Q.C. check items (the old qcChecksFor). {wps}, {stamp} ("[75] " or empty), {drawing} and {rev} are filled in
-- from the packet. The code keeps the same list as its fallback when this row is missing or does not parse.
INSERT INTO settings (key, value) VALUES ('qc_check_items', '{
"groups": [
{"group": "Data book package", "items": [
{"key": "db_heats", "text": "Heat number recorded for every BOM item"},
{"key": "db_mtr", "text": "MTRs on file and match the heat numbers"},
{"key": "db_weldmap", "text": "Weld map complete: every weld identified"},
{"key": "db_wps", "text": "Welds made to {wps}; welder {stamp}qualified"},
{"key": "db_ck", "text": "Stall checklist and weld record complete"}
]},
{"group": "Physical spool", "items": [
{"key": "ph_dim", "text": "Dimensions verified against {drawing} Rev {rev}"},
{"key": "ph_heat", "text": "Heat numbers on material match the heat map"},
{"key": "ph_stamp", "text": "Welds stamped with welder ID"},
{"key": "ph_clean", "text": "Clean: no slag, spatter or arc strikes"},
{"key": "ph_qty", "text": "Quantity matches the work orders"}
]}
]
}'::jsonb) ON CONFLICT (key) DO NOTHING;
+2
View File
@@ -0,0 +1,2 @@
"""PDF output: the data book, the traveler, the ITP, prints and stamps. Phase 7 builds them; phase 6 holds the ITP
filing hook only."""
+17
View File
@@ -0,0 +1,17 @@
"""The signed ITP PDF. Phase 7 builds it; until then the hook files nothing.
`file_signed_itp_pdf` is called inside the sign-off transaction, after the ITP row is set complete. When it returns an
asset id, the sign-off stores it as `signoff_asset_id` and files an automatic ITP document in the unit's Q.C. folder.
"""
from __future__ import annotations
import uuid
from collections.abc import Mapping
from typing import Any
def file_signed_itp_pdf(conn: Any, itp_row: Mapping[str, Any], actor: Any) -> uuid.UUID | None:
"""Build and store the signed ITP PDF, and return its asset id. Phase 7 implements it; today it returns None."""
del conn, itp_row, actor
return None
+44
View File
@@ -91,6 +91,11 @@ class Action(StrEnum):
CO_REJECT = "co_reject" CO_REJECT = "co_reject"
CO_HOLD_TOGGLE = "co_hold_toggle" CO_HOLD_TOGGLE = "co_hold_toggle"
CO_VERIFY = "co_verify" CO_VERIFY = "co_verify"
QC_DOC_ADD = "qc_doc_add"
ITP_START = "itp_start"
ITP_LINE_SIGN = "itp_line_sign"
ITP_LINE_UNSIGN = "itp_line_unsign"
ITP_BLOCK_UNSIGN = "itp_block_unsign"
ITP_EDIT_LINE = "itp_edit_line" ITP_EDIT_LINE = "itp_edit_line"
ITP_BLOCK_SIGN = "itp_block_sign" ITP_BLOCK_SIGN = "itp_block_sign"
ITP_SIGN_OFF = "itp_sign_off" ITP_SIGN_OFF = "itp_sign_off"
@@ -270,6 +275,12 @@ RULES: dict[Action, Rule] = {
), ),
A.CO_HOLD_TOGGLE: Rule(Who.CL, ANY, None), A.CO_HOLD_TOGGLE: Rule(Who.CL, ANY, None),
A.CO_VERIFY: Rule(Who.CO_VERIFIER, ANY, None, signature="Verify a change order for the department"), A.CO_VERIFY: Rule(Who.CO_VERIFIER, ANY, None, signature="Verify a change order for the department"),
# Q.C. folder documents and the ITP belong to a project or a unit, not a packet: engine.check_scoped decides them.
A.QC_DOC_ADD: Rule(Who.CL_OR_QC, ANY, None),
A.ITP_START: Rule(Who.QC, ANY, None),
A.ITP_LINE_SIGN: Rule(Who.QC, ANY, None),
A.ITP_LINE_UNSIGN: Rule(Who.QC, ANY, None),
A.ITP_BLOCK_UNSIGN: Rule(Who.QC, ANY, None),
A.ITP_EDIT_LINE: Rule(Who.QC, ANY, None), A.ITP_EDIT_LINE: Rule(Who.QC, ANY, None),
A.ITP_BLOCK_SIGN: Rule(Who.QC, ANY, None, signature="Sign an ITP block"), A.ITP_BLOCK_SIGN: Rule(Who.QC, ANY, None, signature="Sign an ITP block"),
A.ITP_SIGN_OFF: Rule(Who.QC, ANY, None, signature="Sign off the ITP"), A.ITP_SIGN_OFF: Rule(Who.QC, ANY, None, signature="Sign off the ITP"),
@@ -313,8 +324,41 @@ EDIT_WHAT: dict[Action, str] = {
A.CO_REJECT: "change orders", A.CO_REJECT: "change orders",
A.CO_HOLD_TOGGLE: "change orders", A.CO_HOLD_TOGGLE: "change orders",
A.CO_VERIFY: "change orders", A.CO_VERIFY: "change orders",
A.QC_DOC_ADD: "the Q.C. folder",
A.ITP_START: "the ITP",
A.ITP_LINE_SIGN: "the ITP",
A.ITP_LINE_UNSIGN: "the ITP",
A.ITP_BLOCK_UNSIGN: "the ITP",
A.ITP_EDIT_LINE: "the ITP", A.ITP_EDIT_LINE: "the ITP",
A.ITP_BLOCK_SIGN: "the ITP", A.ITP_BLOCK_SIGN: "the ITP",
A.ITP_SIGN_OFF: "the ITP", A.ITP_SIGN_OFF: "the ITP",
A.ITP_REOPEN: "the ITP", A.ITP_REOPEN: "the ITP",
} }
# The actions on a project or unit rather than a packet (check them with engine.check_scoped).
SCOPED: frozenset[Action] = frozenset(
{
A.QC_DOC_ADD,
A.ITP_START,
A.ITP_LINE_SIGN,
A.ITP_LINE_UNSIGN,
A.ITP_EDIT_LINE,
A.ITP_BLOCK_SIGN,
A.ITP_BLOCK_UNSIGN,
A.ITP_SIGN_OFF,
A.ITP_REOPEN,
}
)
# Their labels (the audit and log texts name the event more exactly).
SCOPED_LABELS: dict[Action, str] = {
A.QC_DOC_ADD: "Q.C. document added",
A.ITP_START: "ITP started",
A.ITP_LINE_SIGN: "ITP line approved",
A.ITP_LINE_UNSIGN: "ITP line initial removed",
A.ITP_BLOCK_UNSIGN: "ITP block signature removed",
A.ITP_EDIT_LINE: "ITP changed",
A.ITP_BLOCK_SIGN: "ITP block signed",
A.ITP_SIGN_OFF: "ITP signed off",
A.ITP_REOPEN: "ITP reopened",
}
+62
View File
@@ -10,6 +10,9 @@ Order of checks, exactly:
6. filed lock: only SET_DATABOOK_AT and ADMIN_REOPEN, Admin included. 6. filed lock: only SET_DATABOOK_AT and ADMIN_REOPEN, Admin included.
7. who and custody. 7. who and custody.
8. preconditions in rule order, never bypassed. 8. preconditions in rule order, never bypassed.
`check_scoped` decides the actions on a project or unit (Q.C. folder documents, the ITP) the same way without a
packet: system, admin bypass, who, then the input preconditions. There is no status, CO hold, filed lock or custody.
""" """
from __future__ import annotations from __future__ import annotations
@@ -37,6 +40,24 @@ MSG_FILED = "This packet is filed and locked. IT can reopen it if a correction i
MSG_FORBIDDEN_STEP = "Your access doesn't allow this step." MSG_FORBIDDEN_STEP = "Your access doesn't allow this step."
MSG_NOT_WH_SKIPPED = "This packet came through the warehouse. Send it back to the warehouse instead." MSG_NOT_WH_SKIPPED = "This packet came through the warehouse. Send it back to the warehouse instead."
MSG_NOT_DATABOOK = "Only packets routed to the data book are closed in VISUAL here." MSG_NOT_DATABOOK = "Only packets routed to the data book are closed in VISUAL here."
MSG_FORBIDDEN_SCOPED = "Your access doesn't allow this."
# The packet view check_scoped hands the who predicates. Only predicates that ignore the packet (CL, QC, QC_SUP,
# CL_OR_QC and the like) can pass with it; HOLDER and the department predicates fail.
_NO_PACKET = PacketState(
id=0,
status="",
dept="",
station_id=None,
welder_id=None,
wh_skipped=False,
routed_to=None,
vclosed=False,
removed=False,
source_kind="hand",
weld_dept=False,
co_hold=False,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -181,3 +202,44 @@ def can(
elif d.reasons: elif d.reasons:
blocked[action] = list(d.reasons) blocked[action] = list(d.reasons)
return allowed, blocked return allowed, blocked
def check_scoped(
principal: Principal,
action: Action,
*,
extra: Mapping[str, Any] | None = None,
it_reason: str | None = None,
assume_it_reason: bool = False,
) -> Decision:
"""Decide an action on a project or unit (no packet): the steps of check() that do not need one.
System principals fail `forbidden` (a system-only rule fails `system_only` for people). An admin the rule does not
allow passes with an IT reason of at least 8 characters (`admin_bypass`), else `it_reason_required`. Then who
(`forbidden`), then the input preconditions in rule order (`reason_given` fails `reason_required`). Custody is not
checked: nobody holds a project or unit. `assume_it_reason` is the read mode for an admin (what the screen offers).
"""
rule = RULES[action]
ex = extra or EMPTY
if rule.system_only and principal.system is None:
return Decision(False, "system_only", MSG_SYSTEM_ONLY)
if principal.system is not None:
return Decision(False, "forbidden", MSG_SYSTEM_FORBIDDEN)
who_ok = allows(rule.who, principal, _NO_PACKET, ex)
bypass = False
if not who_ok and principal.admin:
if assume_it_reason or reason_ok(it_reason) is not None:
bypass = True
else:
return Decision(False, "it_reason_required", MSG_IT_REASON)
if not who_ok and not bypass:
return Decision(False, "forbidden", MSG_FORBIDDEN_SCOPED)
for name in rule.preconditions:
if name not in INPUT_ONLY:
continue # a state precondition needs a packet; no scoped rule has one
failed = PRECONDITIONS[name](_NO_PACKET, ex)
if failed is None:
continue
code = "reason_required" if rule.reason_required and name == "reason_given" else "precondition"
return Decision(False, code, failed, admin_bypass=bypass)
return Decision(True, admin_bypass=bypass, signature=rule.signature)
+6
View File
@@ -52,6 +52,12 @@ LIMITS: dict[str, int] = {
"filler": 40, "filler": 40,
"preheat": 40, "preheat": 40,
"weld_text": 40, "weld_text": 40,
"doc_note": 300,
"qc_doc_title": 140,
"qc_doc_rev": 12,
"qc_doc_note": 600,
"ncr": 40,
"itp_field": 120,
} }
REASON_MIN = 8 REASON_MIN = 8
MAX_BODY_BYTES = 200_000 MAX_BODY_BYTES = 200_000
+541
View File
@@ -0,0 +1,541 @@
import { expect, test, type Page } from "@playwright/test";
import { PIN } from "./helpers";
import {
L as SL,
actor,
api,
closeAll,
packet,
packetTab,
pullAndRelease,
setPin,
sign,
signSheet,
signWithPassword,
signWithPin,
statusOf,
stepApi,
userId,
type Actor,
} from "./shophelpers";
import {
L,
NCR,
TEMPLATE_ITEMS,
atQcApi,
blockSign,
cardOf,
column,
enabledApprove,
itpBlock,
itpRow,
itpSection,
logActions,
makeQcDraft,
makeQcProject,
part,
pdfFile,
qcBar,
qcChecks,
textFile,
type Crew,
type QcPacket,
type QcProject,
} from "./qchelpers";
import type { Json } from "./whhelpers";
// Phase 6 Q.C. scenarios on the CI stack, modelled on the old tests/ps titp, tqcf and tpdocs, plus the accept and
// reject flows of the old qcTab. clerk.clara makes a project with two units; the crew (wh.wade, sup.wes,
// weld.andy) takes two packets of unit 1 to Q.C. through the API. Accept: qc.quinn inspects on the Q.C. tab and
// accepts to the data book with her PIN. Reject: one weld rejected, an NCR number only, her password; sup.wes sees
// the callout and sends it to Q.C. again. tqcf: project and unit folders, a new revision, a refused file, wh.wade
// has no Q.C. screen. tpdocs: a packet document added and taken out. titp: the digital ITP on unit 2 with the hold
// callout on a 03 and an 11 packet, every line and block signed, sign-off, and an admin reopen.
// Every UI label used here is in L (qchelpers.ts).
const RUN = Date.now().toString(36).toUpperCase().slice(-5);
const PROJECT = `Q6${RUN}`;
const T = {
accept: `QC accept spool ${RUN}`,
reject: `QC reject spool ${RUN}`,
wp: `ITP weld spool ${RUN}`,
fa: `ITP final spool ${RUN}`,
};
let proj: QcProject;
const P: Partial<Record<keyof typeof T, QcPacket>> = {};
function pk(k: keyof typeof T): QcPacket {
const p = P[k];
if (!p) throw new Error(`packet ${k} was not made`);
return p;
}
async function open(page: Page, id: number, tabName?: RegExp): Promise<void> {
await page.goto(`/packets/${id}`);
await expect(page.getByRole("heading").first()).toBeVisible();
if (tabName) await packetTab(page, tabName).click();
}
async function openQcHome(page: Page): Promise<void> {
await page.goto("/qc");
await expect(page.getByRole("heading", { name: L.homeHead })).toBeVisible();
}
test.describe.serial("qc", () => {
test.setTimeout(300_000);
test("setup: a project with two units, two packets of unit 1 at Q.C., qc.quinn's PIN", async ({ browser }) => {
const clara = await actor(browser, "clerk.clara");
const wade = await actor(browser, "wh.wade");
const wes = await actor(browser, "sup.wes");
const andy = await actor(browser, "weld.andy");
const quinn = await actor(browser, "qc.quinn");
const andyId = await userId(andy, "weld.andy");
const crew: Crew = { clara, wade, wes, andy, andyId };
proj = await makeQcProject(clara, PROJECT);
P.accept = await atQcApi(crew, proj.units[0], T.accept, "Stall 2", "Bay 2");
P.reject = await atQcApi(crew, proj.units[0], T.reject, "Stall 3", "Bay 2");
// unit 2: a 03 packet released to the shop, and a draft released straight to 11 Final Assembly - North
const wp = await makeQcDraft(clara, proj.units[1], T.wp);
await pullAndRelease(wade, wp.id);
P.wp = wp;
const fa = await makeQcDraft(clara, proj.units[1], T.fa, { dept: "11", stay: true });
const rel = await stepApi(clara, fa.id, "release_to_shop", { dept: "11" });
expect(rel.status).toBe("ready");
P.fa = fa;
await setPin(quinn, PIN);
await closeAll(clara, wade, wes, andy, quinn);
});
test("accept: inspect on the Q.C. tab and accept to the data book with the PIN", async ({ browser }) => {
const id = pk("accept").id;
const quinn = await actor(browser, "qc.quinn");
const page = quinn.page;
// the Q.C. screen lists it as waiting, with the spool location
await openQcHome(page);
await expect(page.getByText(L.homeEyebrow).first()).toBeVisible();
for (const k of [L.kpiWaiting, L.kpiAccepted, L.kpiRejected])
await expect(page.getByText(k).first()).toBeVisible();
const ready = column(page, L.colReady);
const card = cardOf(ready, id);
await expect(card).toBeVisible();
await expect(card).toContainText(L.spoolAt("Bay 2"));
await card.click();
await expect(page).toHaveURL(new RegExp(`/packets/${id}`));
// the Q.C. bar lists what is missing and Accept is disabled
const bar = qcBar(page);
await expect(bar).toBeVisible();
const missing = bar.getByTestId(L.qcMissing);
await expect(missing).toContainText(L.stillNeeded);
await expect(missing).toContainText("10 inspection items");
await expect(missing).toContainText("2 welds without a visual result");
await expect(missing).toContainText("inspector name");
const acceptBtn = bar.getByRole("button", { name: L.accept, exact: true });
await expect(acceptBtn).toBeDisabled();
// the Q.C. tab: inspector and stamp first, so the ticks carry the stamp
await packetTab(page, L.tabQc).click();
await page.getByLabel(L.inspector, { exact: true }).fill("Quinn QC");
await page.getByLabel(L.stamp, { exact: true }).fill("Q1");
await page.getByLabel(L.stamp, { exact: true }).blur();
await expect.poll(async () => (await packet(quinn, id)).qc.stamp).toBe("Q1");
const boxes = qcChecks(page);
await expect(boxes).toHaveCount(10);
for (let i = 0; i < 10; i++) {
await boxes.nth(i).check();
await expect(boxes.nth(i)).toBeChecked();
}
await expect(page.getByText(L.stampTick).first()).toBeVisible();
// both welds accepted
const vt = part(page, L.vtSection);
for (const no of [1, 2]) {
const btn = vt.getByRole("row", { name: L.weldRow(no) }).getByRole("button", { name: L.vtAccept, exact: true });
await btn.click();
await expect(btn).toHaveAttribute("aria-pressed", "true");
}
// NDE: RT, Acceptable
await page.getByLabel(L.ndeMethod, { exact: true }).selectOption("RT");
await page.getByLabel(L.ndeResult, { exact: true }).selectOption("Acceptable");
await expect.poll(async () => (await packet(quinn, id)).qc.nde_result).toBe("Acceptable");
// Accept is enabled; route to the data book; sign with the PIN
await expect(missing).toBeHidden();
await expect(acceptBtn).toBeEnabled();
await bar.getByLabel(L.route, { exact: true }).selectOption({ label: L.routeDataBook });
await acceptBtn.click();
await signWithPin(page, PIN);
await expect(signSheet(page)).toBeHidden();
await expect.poll(() => statusOf(quinn, id)).toBe("complete");
await expect(page.getByTestId(L.statusPill)).toContainText(L.statusRouted);
const after = await packet(quinn, id);
expect(after.routed_to).toBe("databook");
// History has the accept text
await packetTab(page, L.tabHistory).click();
await expect(page.getByText(`Q.C. accepted → ${L.routeDataBook}`).first()).toBeVisible();
expect(await logActions(quinn, id)).toContain(`Q.C. accepted → ${L.routeDataBook}`);
// the Q.C. screen's Accepted column has it
await openQcHome(page);
await expect(cardOf(column(page, L.colAccepted), id)).toBeVisible();
await closeAll(quinn);
});
test("reject: one weld rejected, an NCR number only, the password; the supervisor sends it again", async ({
browser,
}) => {
const id = pk("reject").id;
const quinn = await actor(browser, "qc.quinn");
const page = quinn.page;
await open(page, id, L.tabQc);
// weld 1 rejected with a note
const w1 = part(page, L.vtSection).getByRole("row", { name: L.weldRow(1) });
await w1.getByRole("button", { name: L.vtReject, exact: true }).click();
const defect = w1.getByLabel(L.weldNote(1));
await expect(defect).toHaveAttribute("placeholder", L.defectNote);
await defect.fill("undercut");
await defect.blur();
await expect
.poll(async () => {
const exam = (await packet(quinn, id)).qc.weld_exam as Json;
return Object.values(exam).map((e) => `${(e as Json).result}:${(e as Json).note ?? ""}`);
})
.toContain("reject:undercut");
// the reject sheet: enabled on an NCR number alone
const bar = qcBar(page);
await bar.getByRole("button", { name: L.reject, exact: true }).click();
const sheet = page.getByRole("dialog", { name: L.rejectSheet, exact: true });
await expect(sheet).toBeVisible();
const submitReject = sheet.getByRole("button", { name: L.rejectSubmit });
await expect(submitReject).toBeDisabled();
await sheet.getByLabel(L.ncrNumber, { exact: true }).fill(NCR);
await expect(submitReject).toBeEnabled();
await submitReject.click();
await signWithPassword(page, "qc.quinn");
await expect.poll(() => statusOf(quinn, id)).toBe("review");
await expect(page.getByTestId(L.statusPill)).toContainText(L.statusReview);
const after = await packet(quinn, id);
expect(after.qc).toBeNull();
expect(after.qc_last.result).toBe("rejected");
expect(after.qc_last.ncr).toBe(NCR);
// the Q.C. screen's rejected column has it
await openQcHome(page);
await expect(cardOf(column(page, L.colRejected), id)).toBeVisible();
// sup.wes sees the callout with the NCR link and the rejected weld
const wes = await actor(browser, "sup.wes");
await open(wes.page, id);
const callout = wes.page.getByRole("note").filter({ hasText: L.qcRejected }).first();
await expect(callout).toBeVisible();
const link = callout.getByRole("link", { name: new RegExp(NCR) });
await expect(link).toHaveAttribute("href", new RegExp(`/ncrs/${NCR}$`));
await expect(link).toHaveAttribute("rel", /noopener/);
await expect(callout.getByText(L.rejectedWelds("W1"), { exact: false })).toBeVisible();
// the review box sends it to Q.C. again (password: sup.wes has no PIN)
await wes.page.getByLabel(SL.bookReady).check();
await wes.page.getByLabel(SL.spoolReady).check();
await wes.page.getByLabel(SL.spoolLocation).fill("Bay 4");
await wes.page.getByRole("button", { name: SL.sendToQc, exact: true }).click();
await signWithPassword(wes.page, "sup.wes");
await expect.poll(() => statusOf(wes, id)).toBe("qc");
// a fresh empty inspection; the first one is under Earlier inspections
await open(page, id, L.tabQc);
const boxes = qcChecks(page);
await expect(boxes).toHaveCount(10);
for (let i = 0; i < 10; i++) await expect(boxes.nth(i)).not.toBeChecked();
const earlier = part(page, L.earlier);
await expect(earlier).toBeVisible();
await expect(earlier).toContainText(L.rejectedPill);
await expect(earlier).toContainText(NCR);
await closeAll(quinn, wes);
});
test("tqcf: project and unit folders, a new revision, a refused file, no Q.C. screen for wh.wade", async ({
browser,
}) => {
const quinn = await actor(browser, "qc.quinn");
const page = quinn.page;
const unit1 = proj.units[0];
// the home's folder card
await openQcHome(page);
const folderCard = part(page, L.folders).locator(L.pcard).filter({ hasText: PROJECT }).first();
await expect(folderCard).toBeVisible();
await expect(folderCard).toContainText(L.folderUnits(2));
await folderCard.click();
await expect(page).toHaveURL(new RegExp(`/qc/projects/${proj.id}`));
await expect(page.getByText(L.allFolders).first()).toBeVisible();
// a project WPS
await page.getByRole("button", { name: L.addQcDoc }).first().click();
let sheet = page.getByRole("dialog", { name: L.qcDocSheetAdd });
await expect(sheet).toBeVisible();
await sheet.getByLabel(L.qdType, { exact: true }).selectOption("WPS / PQR");
await sheet.getByLabel(L.qdTitle, { exact: true }).fill("WPS-101 GTAW/SMAW");
await sheet.getByLabel(L.qdRev, { exact: true }).fill("2");
await sheet.getByLabel(L.qdFile).setInputFiles(pdfFile("wps.pdf"));
await sheet.getByRole("button", { name: L.addToFolder }).click();
await expect(sheet).toBeHidden();
const projectDocs = part(page, L.projectDocs);
await expect(projectDocs.getByText("WPS-101 GTAW/SMAW").first()).toBeVisible();
await expect(projectDocs.getByText("Rev 2").first()).toBeVisible();
// unit 1: no ITP yet; add an ITP document
await page.locator(L.qcUnit).filter({ hasText: String(unit1.number) }).first().click();
await expect(page).toHaveURL(new RegExp(`/qc/units/${unit1.id}`));
await expect(page.getByText(L.noItpCallout)).toBeVisible();
await page.getByRole("button", { name: L.addQcDoc }).first().click();
sheet = page.getByRole("dialog", { name: L.qcDocSheetAdd });
await expect(sheet.getByLabel(L.qdType, { exact: true })).toHaveValue("ITP");
await sheet.getByLabel(L.qdTitle, { exact: true }).fill(`ITP ${unit1.number}`);
await sheet.getByLabel(L.qdFile).setInputFiles(pdfFile("itp.pdf"));
await sheet.getByRole("button", { name: L.addToFolder }).click();
await expect(sheet).toBeHidden();
const unitDocs = part(page, L.unitDocs);
await expect(unitDocs.getByText(`ITP ${unit1.number}`).first()).toBeVisible();
await expect(page.getByText(L.noItpCallout)).toBeHidden();
// the project's WPS is listed as inherited
await expect(part(page, L.fromProject(PROJECT)).getByText("WPS-101 GTAW/SMAW").first()).toBeVisible();
// a text file is refused with the server's message
await page.getByRole("button", { name: L.addQcDoc }).first().click();
sheet = page.getByRole("dialog", { name: L.qcDocSheetAdd });
await sheet.getByLabel(L.qdFile).setInputFiles(textFile());
await sheet.getByRole("button", { name: L.addToFolder }).click();
await expect(sheet.getByRole("alert")).toContainText(L.badFile);
await page.keyboard.press("Escape");
await expect(sheet).toBeHidden();
// a new revision of the ITP document
await unitDocs.getByRole("button", { name: L.newRevision }).first().click();
sheet = page.getByRole("dialog", { name: L.qcDocSheetRev });
await expect(sheet).toBeVisible();
await expect(sheet).toContainText("The old one stays on file under Superseded.");
await sheet.getByLabel(L.qdRev, { exact: true }).fill("1");
await sheet.getByLabel(L.qdFile).setInputFiles(pdfFile("itp-r1.pdf"));
await sheet.getByRole("button", { name: L.fileNewRev }).click();
await expect(sheet).toBeHidden();
await expect(unitDocs.locator(L.qcDocRow)).toHaveCount(1);
await expect(unitDocs.getByText("Rev 1").first()).toBeVisible();
const old = part(page, L.superseded);
await old.getByRole("button", { name: L.show, exact: true }).click();
await expect(old.getByText(`ITP ${unit1.number}`).first()).toBeVisible();
await expect(old.getByText(/superseded .* by /i).first()).toBeVisible();
// back to the project, back to home
await page.getByRole("button", { name: L.back }).or(page.getByRole("link", { name: L.back })).first().click();
await expect(page).toHaveURL(new RegExp(`/qc/projects/${proj.id}`));
await expect(page.locator(L.qcUnit)).toHaveCount(2);
await page.getByRole("button", { name: L.back }).or(page.getByRole("link", { name: L.back })).first().click();
await expect(page).toHaveURL(/\/qc\/?$/);
// a packet of unit 1 lists both documents in its Documents tab
await open(page, pk("accept").id, L.tabDocs);
const fromFolder = part(page, L.alsoInDataBook);
await expect(fromFolder.getByText("WPS-101 GTAW/SMAW").first()).toBeVisible();
await expect(fromFolder.getByText(`ITP ${unit1.number}`).first()).toBeVisible();
// wh.wade has no Q.C. screen
const wade = await actor(browser, "wh.wade");
await wade.page.goto("/qc");
await expect(wade.page).not.toHaveURL(/\/qc(\/|$)/);
await closeAll(quinn, wade);
});
test("tpdocs: a packet document added, refused, seen read-only, taken out", async ({ browser }) => {
const id = pk("reject").id;
const quinn = await actor(browser, "qc.quinn");
const page = quinn.page;
await open(page, id, L.tabDocs);
await expect(part(page, L.docsSection)).toBeVisible();
await page.getByRole("button", { name: L.addDocument }).click();
let sheet = page.getByRole("dialog", { name: L.addDocSheet });
await expect(sheet).toBeVisible();
await sheet.getByLabel(L.docType, { exact: true }).selectOption({ label: L.ndeKind });
await sheet.getByLabel(L.docTitle, { exact: true }).fill("RT report W1-W4");
await sheet.getByLabel(L.docNote).fill("RPT-2210, accept");
await sheet.getByLabel(L.docFile).setInputFiles(pdfFile("rt.pdf"));
await sheet.getByRole("button", { name: L.addToPacket }).click();
await expect(sheet).toBeHidden();
const docs = part(page, L.docsSection);
await expect(docs.getByText("RT report W1-W4").first()).toBeVisible();
expect(await logActions(quinn, id)).toContain("Document added");
await packetTab(page, L.tabHistory).click();
await expect(page.getByText("Document added").first()).toBeVisible();
// a text file is refused
await packetTab(page, L.tabDocs).click();
await page.getByRole("button", { name: L.addDocument }).click();
sheet = page.getByRole("dialog", { name: L.addDocSheet });
await sheet.getByLabel(L.docFile).setInputFiles(textFile());
await sheet.getByRole("button", { name: L.addToPacket }).click();
await expect(sheet.getByRole("alert")).toContainText(L.badFile);
await page.keyboard.press("Escape");
await expect(sheet).toBeHidden();
// wh.wade sees the row and no Add document
const wade = await actor(browser, "wh.wade");
await open(wade.page, id, L.tabDocs);
await expect(part(wade.page, L.docsSection).getByText("RT report W1-W4").first()).toBeVisible();
await expect(wade.page.getByRole("button", { name: L.addDocument })).toHaveCount(0);
await expect(wade.page.getByRole("button", { name: L.takeOut })).toHaveCount(0);
// qc.quinn takes it out with a reason
await docs.getByRole("button", { name: L.takeOut }).first().click();
const out = page.getByRole("dialog", { name: L.takeOutSheet });
await expect(out).toBeVisible();
await out.getByLabel(L.takeOutReason, { exact: true }).fill("wrong packet");
await out.getByRole("button", { name: L.takeOutSubmit, exact: true }).click();
await expect(out).toBeHidden();
const toggle = page.getByRole("button", { name: L.showTakenOut(1) });
await expect(toggle).toBeVisible();
await toggle.click();
await expect(page.getByText(L.takenOutBy).first()).toContainText("wrong packet");
const p = await packet(quinn, id);
const row = (p.docs as Json[]).find((d) => d.title === "RT report W1-W4") as Json;
expect(row.removed.reason).toBe("wrong packet");
await packetTab(page, L.tabHistory).click();
await expect(page.getByText("Document taken out of the data book").first()).toBeVisible();
await closeAll(quinn, wade);
});
test("titp: the digital ITP, the hold callout, every block signed, sign-off, reopen", async ({ browser }) => {
const unit2 = proj.units[1];
const quinn = await actor(browser, "qc.quinn");
const page = quinn.page;
// start from the unit folder
await page.goto(`/qc/units/${unit2.id}`);
await expect(page.getByText(L.noItp)).toBeVisible();
await page.getByRole("button", { name: L.startItp }).click();
await expect(page).toHaveURL(new RegExp(`/qc/units/${unit2.id}/itp`));
const no = `${unit2.number}-ITP`;
await expect(page.getByRole("heading", { level: 1 })).toHaveText(no);
await expect(page.locator(L.itpRow)).toHaveCount(TEMPLATE_ITEMS);
const banners = page.locator(L.itpPage);
await expect(banners).toHaveCount(3);
for (let i = 0; i < 3; i++) await expect(banners.nth(i)).toContainText(L.pageBanners[i]);
await expect(page.getByLabel(L.hdr.no, { exact: true })).toHaveValue(no);
await expect(page.getByLabel(L.hdr.client, { exact: true })).toHaveValue("ACME");
await expect(page.getByLabel(L.hdr.pname, { exact: true })).toHaveValue("Separator skids");
await expect(page.getByLabel(L.hdr.pno, { exact: true })).toHaveValue(PROJECT);
await expect(page.getByLabel(L.hdr.po, { exact: true })).toHaveValue("5541");
await expect(page.getByLabel(L.hdr.unit, { exact: true })).toHaveValue(String(unit2.number));
// approve 1.1: the initials badge shows
await itpRow(page, "1.1").getByRole("button", { name: L.approve, exact: true }).click();
await expect(itpRow(page, "1.1").locator(L.initials)).toHaveText("QQ");
// section 5 N/A: its rows get the na class
await itpSection(page, "5").getByRole("button", { name: L.sectionNa }).click();
await expect(itpSection(page, "5").locator(`${L.itpRow}.na`)).toHaveCount(5);
// line 1.2 details: a customer witness and a note
await page.getByRole("button", { name: L.lineDetails("1.2") }).click();
const lineSheet = page.getByRole("dialog", { name: L.lineSheet });
await expect(lineSheet).toBeVisible();
await lineSheet.getByLabel(L.custWitness).fill("Rick Inspector");
await lineSheet.getByLabel(L.lineNote, { exact: true }).fill("Reviewed WPS-101 R2");
await lineSheet.getByRole("button", { name: L.save, exact: true }).click();
await expect(lineSheet).toBeHidden();
await expect(itpRow(page, "1.2")).toContainText("Rick Inspector");
// block 5 (all N/A) can be signed; block 3 cannot yet
await expect(itpBlock(page, "5")).toContainText(L.blockScope("Section 5"));
await expect(itpBlock(page, "3")).toContainText(L.blockScope("Sections 1–3"));
await expect(blockSign(page, "5")).toBeEnabled();
await expect(blockSign(page, "5")).toHaveText(L.signAllNa);
await expect(blockSign(page, "3")).toBeDisabled();
await blockSign(page, "5").click();
await signWithPin(page, PIN);
await expect(signSheet(page)).toBeHidden();
await expect(itpBlock(page, "5")).toContainText("Quinn QC");
// sup.wes on the 03 packet of unit 2: WP points only; the 11 packet: FA points
const wes = await actor(browser, "sup.wes");
await open(wes.page, pk("wp").id);
const hold = wes.page.locator(L.holdCallout);
await expect(hold).toBeVisible();
await expect(hold).toContainText(`ITP ${no}`);
await expect(hold).toContainText("3.4");
await expect(hold).not.toContainText("4.2");
await expect(hold).toContainText(L.holdAdvice);
await open(wes.page, pk("fa").id);
await expect(wes.page.locator(L.holdCallout)).toContainText("9.6");
await expect(wes.page.locator(L.holdCallout)).not.toContainText("3.4");
// qc.quinn approves every remaining line
const approve = enabledApprove(page);
for (let k = 0; k < TEMPLATE_ITEMS + 5; k++) {
const n = await approve.count();
if (!n) break;
await approve.first().click();
await expect(approve).toHaveCount(n - 1);
}
await expect(approve).toHaveCount(0);
// the customer on block 3, then blocks 3, 4, 6, 7, 8, 9
const b3 = itpBlock(page, "3");
await b3.getByLabel(L.custRepName).fill("Rick Inspector");
await b3.getByRole("button", { name: L.record, exact: true }).click();
await expect(b3).toContainText("recorded by");
for (const key of ["3", "4", "6", "7", "8", "9"]) {
await expect(blockSign(page, key)).toBeEnabled();
await blockSign(page, key).click();
await signWithPin(page, PIN);
await expect(signSheet(page)).toBeHidden();
await expect(blockSign(page, key)).toHaveCount(0);
}
// sign off with the PIN
const signOffBtn = page.getByRole("button", { name: L.signOff });
await expect(signOffBtn).toBeVisible();
await signOffBtn.click();
await signWithPin(page, PIN);
await expect(signSheet(page)).toBeHidden();
await expect(page.getByRole("note").filter({ hasText: L.signedOffCallout }).first()).toBeVisible();
await expect(page.locator(L.itpRow).getByRole("button", { name: L.approve, exact: true })).toHaveCount(0);
await expect(page.getByLabel(L.hdr.client, { exact: true })).toBeDisabled();
const itp = await api(quinn, "get", `/api/units/${unit2.id}/itp`);
expect(itp.status).toBe("complete");
// the hold callout on the 03 packet is gone
await open(wes.page, pk("wp").id);
await expect(wes.page.locator(L.holdCallout)).toHaveCount(0);
// the project folder's unit tile shows ITP ✓
await page.goto(`/qc/projects/${proj.id}`);
await expect(page.locator(L.qcUnit).filter({ hasText: String(unit2.number) })).toContainText(L.itpTick);
// admin.ann reopens with a reason; the page is editable again
const ann = await actor(browser, "admin.ann");
await ann.page.goto(`/qc/units/${unit2.id}/itp`);
await ann.page.getByRole("button", { name: L.reopen, exact: true }).click();
const reopen = ann.page.getByRole("dialog", { name: L.reopenSheet });
await expect(reopen).toBeVisible();
await reopen.getByLabel(L.reopenReason, { exact: true }).fill("Customer asked to re-inspect 4.2");
await reopen.getByRole("button", { name: new RegExp(`^${L.reopen}`) }).click();
await expect(reopen).toBeHidden();
await expect.poll(async () => (await api(ann, "get", `/api/units/${unit2.id}/itp`)).status).toBe("open");
await page.goto(`/qc/units/${unit2.id}/itp`); // quinn's page was on the project folder
await expect(page.getByLabel(L.hdr.client, { exact: true })).toBeEnabled();
await closeAll(quinn, wes, ann);
});
test("api: the signature and the step body used here still answer as the contract says", async ({ browser }) => {
// A guard for the helpers: a reject without a reason or an NCR is refused before any signature is asked.
const quinn: Actor = await actor(browser, "qc.quinn");
const p = await packet(quinn, pk("reject").id);
expect(p.status).toBe("qc");
await stepApi(quinn, p.id, "qc_reject", { sign: sign("qc.quinn") }, 400);
await closeAll(quinn);
});
});
+383
View File
@@ -0,0 +1,383 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { section } from "./helpers";
import {
api,
assignApi,
finishStageApi,
pullAndRelease,
sign,
stepApi,
type Actor,
} from "./shophelpers";
import { onePagePdf, writeHeaders, type Json } from "./whhelpers";
// Helpers for the phase 6 Q.C. scenarios: a project with two units, packets taken to status qc through the API,
// and the UI labels the Q.C. spec depends on (L). The labels are the texts of the phase 6 design contract
// (Frontend B and C). The main session adjusts them here in one place when the components differ.
export const L = {
// Q.C. home (screens/qc/QcHome.tsx)
homeEyebrow: "Q.C. · Inspection",
homeHead: "Inspect the spool and the data book",
kpiWaiting: "Waiting for inspection",
kpiAccepted: "Accepted last 7 days",
kpiRejected: "Rejected last 7 days",
colReady: "Ready for inspection",
colAccepted: "Accepted",
colRejected: "Rejected — back with supervisor", // the old heading text, em dash kept
spoolAt: (loc: string) => `Spool at ${loc}`,
findJob: "Find a job",
folders: "Project folders",
chipActive: "Active",
chipAll: "All",
folderUnits: (n: number) => new RegExp(`${n} units?\\b`),
// packet page: tabs, status, callouts
tabQc: /^Q\.C\./,
tabDocs: /^Documents/,
tabHistory: /^History/,
statusPill: "status-pill", // testid (phase 5)
statusRouted: /Routed|Complete/, // the contract says "Routed"; the status label of complete is "Complete"
statusReview: "Supervisor review",
qcRejected: /^Q\.C\. rejected/,
rejectedWelds: (w: string) => `Rejected welds: ${w}`,
holdCallout: ".itphold", // .callout.warn.itphold, role note
holdAdvice: "Check with Q.C. before moving past these points.",
openItp: "Open ITP",
// the Q.C. bar (packet/QcActions.tsx)
qcBar: "Q.C. inspection", // region name
qcMissing: "qc-missing", // testid
stillNeeded: /^Still needed/,
route: "Route",
routeDataBook: "Clerical – Data Book", // the en dash is the old text
routeShipping: "Shipping",
accept: "Accept",
reject: "Reject",
rejectSheet: "Reject",
rejectReason: "Reason / what failed",
ncrNumber: "NCR number",
rejectSubmit: "Reject to supervisor",
// the Q.C. tab (packet/QcTab.tsx)
inspector: "Inspector",
stamp: "Stamp / ID",
checkItem: ".ck-item",
// the ten check boxes are named by their item text (B: aria-label is the text)
checkBoxes:
/^(Heat number recorded|MTRs on file|Weld map complete|Welds made to|Stall checklist|Dimensions verified|Heat numbers on material|Welds stamped|Clean: no slag|Quantity matches)/,
stampTick: /^Q1 · /,
vtSection: "Visual weld inspection", // region
weldRow: (no: number) => `Weld W${no}`, // role row
weldNote: (no: number) => `W${no} note`,
vtAccept: "Accept",
vtReject: "Reject",
defectNote: "Defect (undercut, porosity…)", // placeholder while rejected
ndeSection: /^NDE & testing/,
ndeMethod: "NDE method",
ndeResult: "NDE result",
reportNo: "Report no.",
inspectionNotes: "Inspection notes",
earlier: "Earlier inspections", // region
rejectedPill: "REJECTED",
// the Documents tab (packet/tabs.tsx)
docsSection: "Documents for the data book", // region
addDocument: /Add document$/, // an aria-hidden + comes first
addDocSheet: "Add a document",
docType: "Type",
docTitle: "Title",
docNote: "Note (optional)",
docFile: "File (PDF, PNG or JPG, up to 25 MB)",
addToPacket: "Add to packet",
view: "View",
takeOut: "Take out of data book",
takeOutSheet: "Take out of the data book",
takeOutReason: "Reason",
takeOutSubmit: "Take out",
showTakenOut: (n: number) => `Show ${n} taken out`,
takenOutBy: /^Taken out of the data book by/,
alsoInDataBook: "Also in the data book from the Q.C. folder", // region
ndeKind: "NDE report (RT / UT / MT / PT)",
badFile: "Only PDF, PNG and JPEG files can be stored.", // the upload's 415 text
// Q.C. folders (screens/qc/QcProjectFolder.tsx, QcUnitFolder.tsx, QcDocSheet.tsx)
back: /Back$/, // "← Back"
allFolders: "All project folders",
addQcDoc: /Add document$/, // "+ Add document"
qcDocSheetAdd: "Add document",
qcDocSheetRev: "New revision",
qdType: "Type",
qdTitle: "Title",
qdRev: "Rev",
qdFile: "File (PDF, PNG or JPG, up to 25 MB)",
qdNote: "Note",
addToFolder: "Add to folder",
fileNewRev: "File new revision",
newRevision: "New revision",
projectDocs: "Project documents", // region and list
unitFolders: /^Unit folders/,
unitDocs: "Unit documents", // region and list
fromProject: (n: string) => `From project ${n}`, // region
superseded: /^Superseded/, // region (the count follows)
show: "Show",
hide: "Hide",
noItpCallout: "No ITP on file for this unit yet.",
qcDocRow: ".qcdoc", // one document row (listitem)
qcUnit: ".qcunit",
pcard: ".pcard", // inside the region Project folders
itpTick: "ITP ✓",
// the ITP (screens/qc/ItpPage.tsx)
noItp: "No digital ITP yet.",
startItp: "Start ITP from template",
itpRow: ".itprow:not(.hd)", // the item rows; each section also has one .itprow.hd header row
itpPage: ".itppage",
itpSec: ".itpsec",
itpBlock: ".itpblock",
lineGroup: (n: string) => `Line ${n}`, // role group, exact
sectionRegion: (n: string) => new RegExp(`^${n}\\. `), // region "5. INTERNAL COATING"
blockGroup: (key: string) => `ITP block ${key}`, // role group
approve: "Approve",
sectionNa: "Section N/A",
sectionApplies: "Section applies",
lineDetails: (n: string) => `Line ${n} details`,
lineSheet: "ITP line",
custWitness: "Customer witness / reviewer",
lineNote: "Note",
save: "Save",
signSection: /^Sign section/,
signAllNa: /^Sign \(all N\/A\)/,
custRepName: "Customer rep name",
record: "Record",
signOff: "Sign off ITP",
reopen: "Reopen",
reopenSheet: "Reopen ITP",
reopenReason: "Reason",
signedOffCallout: /^Signed off .* by /,
initials: ".ini",
hdr: {
no: "Inspection plan no.",
client: "Client",
pname: "Project name",
pno: "Project no.",
po: "Purchase order",
unit: "Unit no.",
},
pageBanners: [/1 of 3 · WP Weld Parts/, /2 of 3 · C Code/, /3 of 3 · FA Final Assembly/],
blockScope: (scope: string) => `PESCO Q.C. signature · ${scope}`,
// shared (phase 5 review box, signature sheet)
bookReady: "Data book package ready",
spoolReady: "Spool ready for inspection",
spoolLocation: "Spool location",
sendToQc: "Send to Q.C.",
} as const;
export const NCR = "NCR-2026-0021";
export const TEMPLATE_ITEMS = 70; // counted from src/js/itp.js ITP_TMPL
export interface QcProject {
id: number;
number: string;
units: Json[]; // two units: [0] for the inspections and documents, [1] for the ITP
}
export interface QcPacket {
id: number;
title: string;
unitNumber: string;
}
/** As clerk.clara: a project with a customer and a PO, and two units. */
export async function makeQcProject(
clara: Actor,
number: string,
name = "Separator skids",
): Promise<QcProject> {
const project = await api(
clara,
"post",
"/api/projects",
{ number, name, customer: "ACME", po: "5541" },
{ status: 201 },
);
const units = (await api(
clara,
"post",
`/api/projects/${project.id}/units`,
{ count: 2 },
{ status: 201 },
)) as unknown as Json[];
expect(units.length).toBe(2);
return { id: Number(project.id), number: String(project.number), units };
}
/**
* As clerk.clara: a hand draft on `unit` in `dept` with two work orders and three BOM lines, a one-page PDF
* drawing attached with her password, the heat map answered. Sent to the warehouse unless `stay` is set.
*/
export async function makeQcDraft(
clara: Actor,
unit: Json,
title: string,
opts: { dept?: string; stay?: boolean } = {},
): Promise<QcPacket> {
const tag = `${unit.number ?? unit.id}`.slice(-8);
const draft = await api(
clara,
"post",
"/api/packets",
{
unit_id: unit.id,
dept_code: opts.dept ?? "03",
op: "WELD-PART",
title,
customer: "ACME",
drawing: `D-${tag}`,
rev: "A",
wps: "WPS-11",
work_orders: [
{ wo_base: `W${tag}`.slice(0, 20), lot: "001", split: "0", sub: "0", qty: 2, target_hrs: 2 },
{ wo_base: `X${tag}`.slice(0, 20), lot: "001", split: "0", sub: "0", qty: 1, target_hrs: 2.5 },
],
bom: [
{ item: 1, qty: 1, part_id: "PIPE-4", description: "4in pipe SCH40" },
{ item: 2, qty: 2, part_id: "FLG-4", description: "4in flange" },
{ item: 3, qty: 1, part_id: "STUD-58", description: "5/8 stud bolt" },
],
},
{ status: 201 },
);
const asset = await uploadPdf(clara, `${tag}.pdf`, "drawing");
await api(clara, "post", `/api/packets/${draft.id}/pages`, {
asset_id: asset.id,
type: "drawing",
sign: sign("clerk.clara"),
});
await api(clara, "patch", `/api/packets/${draft.id}`, { set: { heat_map: true }, base: {} });
if (!opts.stay) {
const sent = await stepApi(clara, Number(draft.id), "send_to_warehouse");
expect(sent.status).toBe("warehouse");
}
return { id: Number(draft.id), title, unitNumber: String(unit.number ?? "") };
}
/** Upload a one-page PDF with a purpose (document by default). */
export async function uploadPdf(a: Actor, name: string, purpose = "document"): Promise<Json> {
const up = await a.page.request.post("/api/assets", {
headers: writeHeaders(a.page),
multipart: {
file: { name, mimeType: "application/pdf", buffer: onePagePdf() },
purpose,
},
});
expect(up.status(), await up.text()).toBe(201);
return (await up.json()) as Json;
}
export interface Crew {
clara: Actor;
wade: Actor;
wes: Actor;
andy: Actor;
andyId: string;
}
/**
* Take a packet to status qc through the API: a draft on `unit` in 03, pulled and released by wh.wade, assigned by
* sup.wes to `stall` and weld.andy, started, two welds and the checklist, submitted, sent to Q.C. at `location`.
*/
export async function atQcApi(
crew: Crew,
unit: Json,
title: string,
stall = "Stall 2",
location = "Bay 2",
): Promise<QcPacket> {
const p = await makeQcDraft(crew.clara, unit, title);
await pullAndRelease(crew.wade, p.id);
await assignApi(crew.wes, p.id, stall, crew.andyId);
await stepApi(crew.andy, p.id, "start");
await finishStageApi(crew.andy, p.id, 2);
const sub = await stepApi(crew.andy, p.id, "submit", { sign: sign("weld.andy") });
expect(sub.status).toBe("review");
const qc = await stepApi(crew.wes, p.id, "send_to_qc", {
book: true,
spool: true,
location,
sign: sign("sup.wes"),
});
expect(qc.status).toBe("qc");
return p;
}
/** A board column of the Q.C. home by its heading. */
export function column(page: Page, heading: string): Locator {
return page
.getByRole("region", { name: new RegExp(`^${esc(heading)}`) })
.or(section(page, new RegExp(`^${esc(heading)}`)))
.first();
}
/** The Q.C. bar on a packet at Q.C. */
export function qcBar(page: Page): Locator {
return page.getByRole("region", { name: L.qcBar });
}
/** A region by its accessible name (the Q.C. tab and Documents tab parts, the folder sections). */
export function part(page: Page, name: string | RegExp): Locator {
return page.getByRole("region", { name }).first();
}
/** A packet card by its link. */
export function cardOf(scope: Locator, id: number): Locator {
return scope.locator(`a[href="/packets/${id}"]`).first();
}
/** The ten Q.C. check boxes. */
export function qcChecks(page: Page): Locator {
return page.getByRole("checkbox", { name: L.checkBoxes });
}
/** One ITP row by its line number (role group `Line 1.1`, exact: `Line 1.1` is inside `Line 1.10`). */
export function itpRow(page: Page, n: string): Locator {
return page.getByRole("group", { name: L.lineGroup(n), exact: true });
}
/** One ITP section by its number (region `5. INTERNAL COATING`). */
export function itpSection(page: Page, n: string): Locator {
return page.getByRole("region", { name: L.sectionRegion(n) }).first();
}
/** One ITP signature block by its key (group `ITP block 3`). */
export function itpBlock(page: Page, key: string): Locator {
return page.getByRole("group", { name: L.blockGroup(key), exact: true });
}
/** The block's sign button (`Sign section` or `Sign (all N/A)`). */
export function blockSign(page: Page, key: string): Locator {
const b = itpBlock(page, key);
return b
.getByRole("button", { name: L.signSection })
.or(b.getByRole("button", { name: L.signAllNa }))
.first();
}
/** Enabled Approve buttons on the ITP page. */
export function enabledApprove(page: Page): Locator {
return page.locator(`${L.itpRow} button:not([disabled])`, { hasText: /^Approve$/ });
}
export function esc(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Text file bytes, for the refused-file checks. */
export function textFile(name = "notes.txt"): { name: string; mimeType: string; buffer: Buffer } {
return { name, mimeType: "text/plain", buffer: Buffer.from("hello from the CI\n") };
}
/** A PDF file for an input[type=file]. */
export function pdfFile(name: string): { name: string; mimeType: string; buffer: Buffer } {
return { name, mimeType: "application/pdf", buffer: onePagePdf() };
}
/** The packet log actions, newest first. */
export async function logActions(a: Actor, id: number): Promise<string[]> {
const rows = (await api(a, "get", `/api/packets/${id}/log`)) as unknown as Json[];
return rows.map((r) => String(r.action));
}
+504
View File
@@ -0,0 +1,504 @@
"""Helpers for the phase 6 Q.C. tests: a packet brought to status qc with two welds, the Q.C. inspection endpoints,
accept and reject, the digital ITP, and Q.C. folder documents.
Built on shophelpers (imported, never edited). The packet is the shop packet: a hand draft in 03 (drawing D-5001
rev B, WPS-11, three BOM lines with heats, two work orders), assigned to weld.andy, two welds, submitted, and sent
to Q.C. by sup.wes with the book and the spool confirmed at `Bay 2`.
"""
from __future__ import annotations
import importlib
import uuid
from collections.abc import Iterator
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
import pytest
from fakedir import FakeDirectory, FakeUser
from fastapi.testclient import TestClient
from pkthelpers import step
from shophelpers import (
SIGN,
USERS,
Shop,
finish_stage,
in_progress,
ok,
open_shop,
submit,
)
from packetstation.settings import Settings
NCR = "NCR-2026-0021"
NCR2 = "NCR-2026-0022"
NCR_URL = "https://ncr.pescoinc.biz/ncrs/"
MSG_NCR = "Enter an NCR number like NCR-2026-0021."
MSG_ITP_COMPLETE = "The ITP is signed off and locked. An admin can reopen it."
MSG_NCR_REQUIRED = "Enter the NCR # before approving this line."
MSG_UNSIGN = "Only the inspector who initialed this line, or an admin, can remove it."
MSG_BLOCK_SIGNED = "Undo the section signature first."
MSG_BLOCK_NOT_READY = "Every line must be initialed or N/A first."
MSG_BLOCKS_UNSIGNED = "Every section must be signed first."
MSG_DOC_KIND = "Pick a document type from the list."
MSG_TAKE_OUT = "Say why it comes out of the data book."
MSG_NDE = "Pick an NDE method from the list."
ROUTE_DB = "Clerical – Data Book" # the en dash is the old text
TEMPLATE_VER = "ITP_Generation_Revised_2"
QC_KEYS = (
"db_heats",
"db_mtr",
"db_weldmap",
"db_wps",
"db_ck",
"ph_dim",
"ph_heat",
"ph_stamp",
"ph_clean",
"ph_qty",
)
DOC_KINDS = {
"nde": "NDE report (RT / UT / MT / PT)",
"hydro": "Hydrotest report",
"dimensional": "Dimensional report",
"pmi": "PMI report",
"coating": "Coating / paint report",
"coc": "Certificate of conformance",
"customer": "Customer inspection report",
"ncr": "NCR",
"other": "Other",
}
QC_TYPES = (
"ITP",
"WPS / PQR",
"NDE procedure",
"Hydrotest procedure",
"PMI / material",
"Calibration certificate",
"Customer spec",
"Other",
)
# The template, counted from src/js/itp.js line 4 (ITP_TMPL): 70 items in 9 sections.
TEMPLATE_ITEMS = 70
SECTION_SIZES = {"1": 14, "2": 3, "3": 4, "4": 6, "5": 5, "6": 6, "7": 11, "8": 14, "9": 7}
SECTION_PAGE = {"1": "WP", "2": "WP", "3": "WP", "4": "C", "5": "C", "6": "C", "7": "FA", "8": "FA", "9": "FA"}
SECTION_TITLE_5 = "INTERNAL COATING"
# Hold (H) and witness (W) lines on the PESCO or the template customer code, by page.
HOLDS = {
"WP": ("3.4",),
"C": ("4.2", "4.4", "4.5", "5.1", "5.2", "5.3", "5.5", "6.1", "6.2", "6.3", "6.6"),
"FA": ("7.5", "7.9", "7.11", "9.6", "9.7"),
}
ALL_HOLDS = HOLDS["WP"] + HOLDS["C"] + HOLDS["FA"]
BLOCKS = {
"3": ["1", "2", "3"],
"4": ["4"],
"5": ["5"],
"6": ["6"],
"7": ["7"],
"8": ["8"],
"9": ["9"],
}
DEPT_PAGES = {
"01": ["WP"],
"02": ["WP"],
"03": ["WP"],
"04": ["WP"],
"05": ["WP"],
"10": ["WP"],
"06": ["C"],
"07": ["C"],
"13": ["C"],
"09": ["WP", "C"],
"08": ["FA"],
"11": ["FA"],
"12": ["FA"],
"14": ["FA"],
}
# Two extra people for the "another Q.C. user" and Q.C. supervisor cases. FakeDirectory takes a user list, so a
# test module adds them through its own fake_dir fixture; fakedir.py stays as it is.
QC_SUE = FakeUser("qc.sue", "Sue Second", "5002", ["PS-QC"])
QC_SAM = FakeUser("qcsup.sam", "Sam Qcsup", "5003", ["PS-QC-Supervisor"])
QC_USERS = (*USERS, "qc.sue", "qcsup.sam")
def qc_directory() -> FakeDirectory:
d = FakeDirectory()
d.users.extend(
[
FakeUser(QC_SUE.sam, QC_SUE.display, QC_SUE.emp_no, list(QC_SUE.groups)),
FakeUser(QC_SAM.sam, QC_SAM.display, QC_SAM.emp_no, list(QC_SAM.groups)),
]
)
return d
def open_qc_shop(settings: Settings, fake_dir: FakeDirectory) -> Iterator[Shop]:
"""open_shop, plus the two extra Q.C. people signed in once when the directory has them."""
for shop in open_shop(settings, fake_dir):
for sam in ("qc.sue", "qcsup.sam"):
if any(u.sam == sam for u in fake_dir.users):
shop(sam)
yield shop
def today_us() -> str:
"""M/D/YYYY in America/Denver, the date form of the ITP notes."""
d = datetime.now(ZoneInfo("America/Denver"))
return f"{d.month}/{d.day}/{d.year}"
def today_iso() -> str:
return datetime.now(ZoneInfo("America/Denver")).date().isoformat()
def ok_any(r: Any) -> dict[str, Any]:
"""200 or 201 (the contract does not fix the create code)."""
assert r.status_code in (200, 201), r.text
out: dict[str, Any] = r.json()
return out
def g(obj: Any, key: str) -> Any:
"""A field of a dict or an object (the pure helpers may return either)."""
return obj[key] if isinstance(obj, dict) else getattr(obj, key)
def delete_json(c: TestClient, url: str, body: dict[str, Any] | None = None, **kw: Any) -> Any:
return c.request("DELETE", url, json=body, **kw)
# ---------------------------------------------------------------- the packet
def to_qc(shop: Shop, pkt: dict[str, Any], location: str = "Bay 2", by: str = "sup.wes", **kw: Any) -> dict[str, Any]:
out = ok(step(shop(by), pkt, "send_to_qc", book=True, spool=True, location=location, sign=SIGN, **kw))
assert out["status"] == "qc", out["status"]
return out
def at_qc(shop: Shop, unit_id: int | None = None, welds: int = 2, **kw: Any) -> dict[str, Any]:
"""A shop packet with `welds` welds, submitted, and sent to Q.C. at Bay 2 by sup.wes."""
pkt = in_progress(shop, unit_id, **kw)
pkt = finish_stage(shop, pkt, welds=welds)
pkt = submit(shop, pkt)
return to_qc(shop, pkt)
def live_welds(pkt: dict[str, Any]) -> list[int]:
return sorted(int(w["weld_no"]) for w in pkt["welds"] if not w.get("removed_at") and not w.get("removed"))
def qc_check(c: TestClient, pid: int, key: str, done: bool = True) -> Any:
return c.put(f"/api/packets/{pid}/qc/checks/{key}", json={"done": done})
def qc_weld(c: TestClient, pid: int, no: int, **body: Any) -> Any:
return c.put(f"/api/packets/{pid}/qc/welds/{no}", json=body)
def qc_patch(c: TestClient, pid: int, set_: dict[str, Any], base: dict[str, Any] | None = None, **kw: Any) -> Any:
return c.patch(f"/api/packets/{pid}/qc", json={"set": set_, "base": base or {}, **kw})
def finish_inspection(
shop: Shop,
pkt: dict[str, Any],
by: str = "qc.quinn",
inspector: str = "Quinn QC",
stamp: str = "Q1",
nde: tuple[str, str] | None = None,
) -> dict[str, Any]:
"""Every check ticked, every live weld accepted, the inspector and stamp set (and the NDE when given)."""
c = shop(by)
pid = pkt["id"]
fields: dict[str, Any] = {"inspector": inspector, "stamp": stamp}
pkt = ok(qc_patch(c, pid, fields))
for k in QC_KEYS:
pkt = ok(qc_check(c, pid, k))
for no in live_welds(pkt):
pkt = ok(qc_weld(c, pid, no, result="accept"))
if nde is not None:
pkt = ok(qc_patch(c, pid, {"nde_method": nde[0]}))
pkt = ok(qc_patch(c, pid, {"nde_result": nde[1]}))
assert pkt["qc"]["missing"] == [], pkt["qc"]["missing"]
return pkt
def accept(shop: Shop, pkt: dict[str, Any], routed_to: str = "databook", by: str = "qc.quinn") -> Any:
return step(shop(by), pkt, "qc_accept", routed_to=routed_to, sign=SIGN)
def accept_route(shop: Shop, pkt: dict[str, Any], dept: str = "09", by: str = "qc.quinn") -> Any:
return step(shop(by), pkt, "qc_accept_and_route", dept=dept, sign=SIGN)
def reject(
shop: Shop, pkt: dict[str, Any], reason: str | None = None, ncr: str | None = None, by: str = "qc.quinn"
) -> Any:
body: dict[str, Any] = {"sign": SIGN}
if reason is not None:
body["reason"] = reason
if ncr is not None:
body["ncr"] = ncr
return step(shop(by), pkt, "qc_reject", **body)
def accepted_packet(shop: Shop, unit_id: int | None = None, routed_to: str = "databook") -> dict[str, Any]:
pkt = finish_inspection(shop, at_qc(shop, unit_id))
out = ok(accept(shop, pkt, routed_to))
assert out["status"] == "complete"
return out
def filed_packet(shop: Shop, unit_id: int | None = None) -> dict[str, Any]:
"""Accepted to the data book, closed in VISUAL and filed by clerk.clara."""
pkt = accepted_packet(shop, unit_id)
c = shop("clerk.clara")
pkt = ok(c.post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True}))
out = ok(step(c, pkt, "file", sign=SIGN))
assert out["status"] == "filed"
return out
# ---------------------------------------------------------------- assets and documents
def unique_pdf() -> bytes:
"""A one-page PDF with its own bytes. The asset store returns the existing row for the same bytes (and its
original name), so each upload that names a file needs bytes of its own."""
import io
import secrets
from pypdf import PdfWriter
w = PdfWriter()
w.add_blank_page(width=612, height=792)
w.add_metadata({"/Title": secrets.token_hex(8)})
buf = io.BytesIO()
w.write(buf)
return buf.getvalue()
def upload_doc(
c: TestClient,
name: str = "doc.pdf",
data: bytes | None = None,
mime: str = "application/pdf",
purpose: str = "document",
) -> dict[str, Any]:
r = c.post(
"/api/assets", files={"file": (name, unique_pdf() if data is None else data, mime)}, data={"purpose": purpose}
)
assert r.status_code == 201, r.text
out: dict[str, Any] = r.json()
return out
def csv_asset(c: TestClient) -> dict[str, Any]:
"""A stored CSV (through the BOM purpose), to try as a document."""
return upload_doc(c, "parts.csv", b"item,qty,part\n1,2,PIPE-4\n2,1,FLG-4\n", "text/csv", purpose="bom")
def add_packet_doc(
c: TestClient,
pid: int,
kind: str = "nde",
title: str | None = "RT report W1-W4",
note: str | None = None,
file_name: str = "rt.pdf",
asset_id: str | None = None,
**kw: Any,
) -> Any:
if asset_id is None:
asset_id = upload_doc(c, file_name)["id"]
body: dict[str, Any] = {"asset_id": asset_id, "kind": kind, **kw}
if title is not None:
body["title"] = title
if note is not None:
body["note"] = note
return c.post(f"/api/packets/{pid}/docs", json=body)
def take_out(c: TestClient, pid: int, doc_id: int, reason: str | None = "wrong packet") -> Any:
return delete_json(c, f"/api/packets/{pid}/docs/{doc_id}", {} if reason is None else {"reason": reason})
def make_qc_doc(
shop: Shop,
*,
unit_id: int | None = None,
project_id: int | None = None,
type: str = "ITP",
title: str | None = None,
rev: str | None = None,
note: str | None = None,
replaces: int | None = None,
by: str = "qc.quinn",
file_name: str = "doc.pdf",
asset_id: str | None = None,
**kw: Any,
) -> Any:
"""POST /api/qc/docs with a fresh tiny PDF (purpose document). Returns the response."""
c = shop(by)
if asset_id is None:
asset_id = upload_doc(c, file_name)["id"]
body: dict[str, Any] = {"type": type, "asset_id": asset_id, **kw}
for k, v in (
("unit_id", unit_id),
("project_id", project_id),
("title", title),
("rev", rev),
("note", note),
("replaces", replaces),
):
if v is not None:
body[k] = v
return c.post("/api/qc/docs", json=body)
def folder_unit(c: TestClient, unit_id: int) -> dict[str, Any]:
return ok(c.get(f"/api/qc/folders/units/{unit_id}"))
def folder_project(c: TestClient, project_id: int) -> dict[str, Any]:
return ok(c.get(f"/api/qc/folders/projects/{project_id}"))
# ---------------------------------------------------------------- the ITP
def get_itp(c: TestClient, unit_id: int) -> dict[str, Any]:
return ok(c.get(f"/api/units/{unit_id}/itp"))
def start_itp(shop: Shop, unit_id: int, by: str = "qc.quinn") -> dict[str, Any]:
itp = ok_any(shop(by).post(f"/api/units/{unit_id}/itp", json={}))
assert itp["status"] == "open", itp
return itp
def item_keys(itp: dict[str, Any]) -> list[str]:
return [str(i["n"]) for s in itp["template"]["sections"] for i in s["items"]]
def section_keys(itp: dict[str, Any], n: str) -> list[str]:
sec = next(s for s in itp["template"]["sections"] if str(s["n"]) == n)
return [str(i["n"]) for i in sec["items"]]
def line(itp: dict[str, Any], key: str) -> dict[str, Any]:
out: dict[str, Any] = itp["lines"][key]
return out
def block(itp: dict[str, Any], key: str) -> dict[str, Any]:
return next(b for b in itp["blocks"] if str(b["key"]) == key)
def is_done(ln: dict[str, Any]) -> bool:
return bool(ln.get("na")) or ln.get("result") in ("approve", "na")
def sign_line(c: TestClient, itp: dict[str, Any], key: str, if_match: int | None = None, **body: Any) -> Any:
headers = {} if if_match is None else {"If-Match": str(if_match)}
return c.post(f"/api/itps/{itp['id']}/lines/{key}/sign", json=body, headers=headers)
def unsign_line(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
return c.post(f"/api/itps/{itp['id']}/lines/{key}/unsign", json=body)
def patch_line(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
return c.patch(f"/api/itps/{itp['id']}/lines/{key}", json=body)
def section_na(c: TestClient, itp: dict[str, Any], n: str, na: bool = True) -> Any:
return c.post(f"/api/itps/{itp['id']}/sections/{n}/na", json={"na": na})
def reject_line(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
return c.post(f"/api/itps/{itp['id']}/lines/{key}/reject", json=body)
def enter_ncr(c: TestClient, itp: dict[str, Any], key: str, ncr: str) -> Any:
return c.post(f"/api/itps/{itp['id']}/lines/{key}/ncr", json={"ncr": ncr})
def patch_block(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
return c.patch(f"/api/itps/{itp['id']}/blocks/{key}", json=body)
def sign_block(c: TestClient, itp: dict[str, Any], key: str, sign: dict[str, Any] | None = SIGN, **body: Any) -> Any:
if sign is not None:
body["sign"] = sign
return c.post(f"/api/itps/{itp['id']}/blocks/{key}/sign", json=body)
def unsign_block(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
return delete_json(c, f"/api/itps/{itp['id']}/blocks/{key}/sign", body)
def sign_off(c: TestClient, itp: dict[str, Any], sign: dict[str, Any] | None = SIGN, **body: Any) -> Any:
if sign is not None:
body["sign"] = sign
return c.post(f"/api/itps/{itp['id']}/signoff", json=body)
def reopen_itp(c: TestClient, itp: dict[str, Any], reason: str, **body: Any) -> Any:
return c.post(f"/api/itps/{itp['id']}/reopen", json={"reason": reason, **body})
def patch_itp(c: TestClient, itp: dict[str, Any], set_: dict[str, Any], base: dict[str, Any] | None = None) -> Any:
return c.patch(f"/api/itps/{itp['id']}", json={"set": set_, "base": base or {}})
def approve_all(shop: Shop, itp: dict[str, Any], by: str = "qc.quinn", keys: list[str] | None = None) -> dict[str, Any]:
"""Approve every line that is still open (not N/A, not approved). A rejected line needs its NCR first."""
c = shop(by)
for key in keys if keys is not None else item_keys(itp):
ln = line(itp, key)
if is_done(ln):
continue
itp = ok(sign_line(c, itp, key))
return itp
def na_lines(shop: Shop, itp: dict[str, Any], keys: list[str], by: str = "qc.quinn") -> dict[str, Any]:
c = shop(by)
for key in keys:
itp = ok(patch_line(c, itp, key, na=True))
return itp
def sign_all_blocks(shop: Shop, itp: dict[str, Any], by: str = "qc.quinn") -> dict[str, Any]:
c = shop(by)
for b in list(itp["blocks"]):
if not block(itp, str(b["key"]))["signed"]:
itp = ok(sign_block(c, itp, str(b["key"])))
return itp
def itp_log(itp: dict[str, Any]) -> list[tuple[str, str | None]]:
"""(action, note) newest first."""
return [(r["action"], r["note"]) for r in itp["log"]]
def patch_hook(monkeypatch: pytest.MonkeyPatch, fn: Any) -> None:
"""Replace pdf.itp.file_signed_itp_pdf, also where a module imported it by name."""
for name in ("packetstation.pdf.itp", "packetstation.api.itp", "packetstation.domain.itp"):
try:
mod = importlib.import_module(name)
except ImportError:
continue
if hasattr(mod, "file_signed_itp_pdf"):
monkeypatch.setattr(mod, "file_signed_itp_pdf", fn)
def as_uuid(value: Any) -> uuid.UUID:
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
+550
View File
@@ -0,0 +1,550 @@
"""Phase 6 digital ITP through the API: start from the frozen template, line initials with their compare-and-set,
unsign by the same inspector or an admin, N/A on a line and a section, customer codes and witnesses, reject and the
NCR, block fields and signatures, sign-off with the PDF hook, the lock after sign-off, reopen, and the log texts.
The directory adds two people to the fakedir roster through this module's fixture: qc.sue (a second Q.C. user) and
qcsup.sam (a Q.C. supervisor). fakedir.py itself has neither."""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from typing import Any
import pytest
from pshelpers import needs_pg
from qchelpers import (
MSG_BLOCK_NOT_READY,
MSG_BLOCK_SIGNED,
MSG_BLOCKS_UNSIGNED,
MSG_ITP_COMPLETE,
MSG_NCR,
MSG_NCR_REQUIRED,
MSG_UNSIGN,
NCR,
NCR2,
SECTION_SIZES,
SECTION_TITLE_5,
TEMPLATE_ITEMS,
TEMPLATE_VER,
approve_all,
block,
enter_ncr,
get_itp,
item_keys,
itp_log,
line,
na_lines,
open_qc_shop,
patch_block,
patch_hook,
patch_itp,
patch_line,
qc_directory,
reject_line,
reopen_itp,
section_keys,
section_na,
sign_all_blocks,
sign_block,
sign_line,
sign_off,
start_itp,
today_iso,
today_us,
unsign_block,
unsign_line,
upload_doc,
)
from shophelpers import IT, Shop, all_rows, make_unit, ok, one, refused
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def qc_shop(pg_settings: Settings, dev_visual: None) -> Iterator[Shop]:
"""A Shop on a directory that also has qc.sue (Q.C.) and qcsup.sam (Q.C. supervisor)."""
yield from open_qc_shop(pg_settings, qc_directory())
def numbers(shop: Shop, unit_id: int) -> tuple[str, str]:
row = one(
shop.settings,
"SELECT p.number, p.number || u.lot FROM units u JOIN projects p ON p.id = u.project_id WHERE u.id = %s",
(unit_id,),
)
return str(row[0]), str(row[1])
def new_itp(shop: Shop) -> tuple[int, dict[str, Any]]:
_, unit_id = make_unit(shop.settings)
return unit_id, start_itp(shop, unit_id)
def done_section(shop: Shop, itp: dict[str, Any], n: str) -> dict[str, Any]:
"""The first line of section n approved, the rest N/A: its block is ready."""
keys = section_keys(itp, n)
itp = ok(sign_line(shop("qc.quinn"), itp, keys[0]))
return na_lines(shop, itp, keys[1:])
# ================================================================ start
def test_start(qc_shop: Shop) -> None:
shop = qc_shop
from packetstation.domain.itp_template import ITP_TEMPLATE
project_id, unit_id = make_unit(shop.settings)
pno, uno = numbers(shop, unit_id)
empty = get_itp(shop("qc.quinn"), unit_id)
assert empty["itp"] is None and empty["unit"]["id"] == unit_id and empty["unit"]["number"] == uno
assert shop("qc.quinn").get("/api/units/999999/itp").status_code == 404
refused(shop("wh.wade").post(f"/api/units/{unit_id}/itp", json={}), 403, "forbidden")
refused(shop("clerk.clara").post(f"/api/units/{unit_id}/itp", json={}), 403, "forbidden")
itp = start_itp(shop, unit_id)
assert itp["unit_id"] == unit_id and itp["no"] == f"{uno}-ITP" and itp["status"] == "open"
assert itp["hdr"] == {"client": "Acme Refining", "pname": "Shop project", "pno": pno, "po": "PO-55", "unit": uno}
assert itp["template"] == ITP_TEMPLATE and itp["template"]["ver"] == TEMPLATE_VER
assert len(item_keys(itp)) == TEMPLATE_ITEMS
assert {str(s["n"]): len(s["items"]) for s in itp["template"]["sections"]} == SECTION_SIZES
assert itp["pages"] == [
{"code": "WP", "name": "Weld Parts"},
{"code": "C", "name": "Code"},
{"code": "FA", "name": "Final Assembly"},
]
assert set(itp["lines"]) == set(item_keys(itp))
assert all(ln["result"] is None and ln["na"] is False for ln in itp["lines"].values())
assert itp["progress"] == {"done": 0, "total": TEMPLATE_ITEMS, "holds": 17}
assert len(itp["holds"]) == 17
assert [b["key"] for b in itp["blocks"]] == ["3", "4", "5", "6", "7", "8", "9"]
assert block(itp, "3")["sections"] == ["1", "2", "3"] and block(itp, "3")["scope"] == "Sections 1–3"
assert block(itp, "4")["scope"] == "Section 4" and block(itp, "9")["final"] is True
assert not any(b["signed"] or b["ready"] for b in itp["blocks"])
assert itp["signoff"] is None and itp["reopen"] is None
assert itp["created_by"]["display_name"] == "Quinn QC" and itp["created_at"]
assert itp["can"] == {"edit": True, "sign_off": False, "reopen": False}
assert itp_log(itp) == [("ITP started", f"{uno}-ITP · template {TEMPLATE_VER}")]
# the template is frozen in the row
row = one(shop.settings, "SELECT template, no FROM itps WHERE id = %s", (itp["id"],))
assert row[0] == ITP_TEMPLATE and row[1] == f"{uno}-ITP"
# a second start
refused(shop("qc.quinn").post(f"/api/units/{unit_id}/itp", json={}), 409, "itp_exists")
# other people read it, without edit rights
other = get_itp(shop("wh.wade"), unit_id)
assert other["id"] == itp["id"] and other["can"]["edit"] is False
# the audit row
au = one(shop.settings, "SELECT subject_type, subject_id, detail FROM audit_events WHERE action = 'itp.itp_start'")
assert au is not None and au[0] == "itp" and str(au[1]) == str(itp["id"])
assert au[2]["text"] == "ITP started" and au[2]["unit_id"] == unit_id
del project_id
def test_header_fields(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
v = itp["version"]
itp = ok(patch_itp(q, itp, {"client": "ACME"}, {"client": "Acme Refining"}))
assert itp["hdr"]["client"] == "ACME" and itp["version"] == v + 1
assert itp_log(itp)[0] == ("ITP header changed", "client: ACME")
itp = ok(patch_itp(q, itp, {"no": "L9-ITP-B"}))
assert itp["no"] == "L9-ITP-B" and itp_log(itp)[0][0] == "ITP plan no. changed"
r = refused(patch_itp(q, itp, {"client": "Other"}, {"client": "Acme Refining"}), 409, "field_conflict")
assert r["current"] == {"client": "ACME"}
refused(patch_itp(shop("clerk.clara"), itp, {"client": "X"}), 403, "forbidden")
# ================================================================ lines
def test_line_sign_and_cas(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(sign_line(q, itp, "1.1"))
ln = line(itp, "1.1")
assert ln["result"] == "approve" and ln["by"]["display_name"] == "Quinn QC" and ln["initials"] == "QQ"
assert ln["at"] and itp["progress"]["done"] == 1
assert itp_log(itp)[0][0] == "ITP line 1.1 approved" and not itp_log(itp)[0][1]
refused(sign_line(q, itp, "1.1"), 409, "already_signed")
# a stale If-Match
rv = line(itp, "1.2")["row_version"]
refused(sign_line(q, itp, "1.2", if_match=rv + 5), 409, "row_conflict")
itp = ok(sign_line(q, itp, "1.2", if_match=rv))
assert line(itp, "1.2")["row_version"] > rv
# another Q.C. user signs with their own initials
itp = ok(sign_line(shop("qc.sue"), itp, "1.3"))
assert line(itp, "1.3")["initials"] == "SS" and line(itp, "1.3")["by"]["display_name"] == "Sue Second"
refused(sign_line(shop("sup.wes"), itp, "1.4"), 403, "forbidden")
# a key that is not in the template
assert sign_line(q, itp, "9.99").status_code in (404, 422)
def test_line_unsign(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(sign_line(q, itp, "1.1"))
# another Q.C. user may not remove it
refused(unsign_line(shop("qc.sue"), itp, "1.1"), 403, "forbidden", MSG_UNSIGN)
itp = ok(unsign_line(q, itp, "1.1"))
assert line(itp, "1.1")["result"] is None and line(itp, "1.1")["by"] is None
assert itp_log(itp)[0] == ("ITP line 1.1 initial removed", f"was Quinn QC {today_us()}")
# an admin, with an IT reason
itp = ok(sign_line(q, itp, "1.1"))
refused(unsign_line(shop("admin.ann"), itp, "1.1"), 400, "it_reason_required")
itp = ok(unsign_line(shop("admin.ann"), itp, "1.1", it_reason=IT))
assert line(itp, "1.1")["result"] is None
log_row = itp["log"][0]
assert log_row["by"]["display_name"] == "Ann Admin"
# refused once the block is signed
itp = done_section(shop, itp, "4")
first = section_keys(itp, "4")[0]
itp = ok(sign_block(q, itp, "4"))
refused(unsign_line(q, itp, first), 409, "block_signed", MSG_BLOCK_SIGNED)
# an open line has nothing to remove
r = unsign_line(q, itp, "1.5")
assert r.status_code in (404, 409, 422), r.text
def test_line_na_and_section_na(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(patch_line(q, itp, "1.2", na=True))
assert line(itp, "1.2")["na"] is True and itp["progress"]["done"] == 1
assert itp_log(itp)[0] == ("ITP line 1.2 updated", "marked N/A")
refused(sign_line(q, itp, "1.2"), 409, "line_na")
itp = ok(patch_line(q, itp, "1.2", na=False))
assert line(itp, "1.2")["na"] is False and itp_log(itp)[0] == ("ITP line 1.2 updated", "applies again")
itp = ok(sign_line(q, itp, "1.1"))
refused(patch_line(q, itp, "1.1", na=True), 409, "already_signed")
# a whole section
v = itp["version"]
itp = ok(section_na(q, itp, "5"))
assert all(line(itp, k)["na"] for k in section_keys(itp, "5"))
assert itp_log(itp)[0] == ("ITP section 5 marked N/A", SECTION_TITLE_5) and itp["version"] > v
assert block(itp, "5")["ready"] is True and block(itp, "5")["all_na"] is True
itp = ok(section_na(q, itp, "5", na=False))
assert not any(line(itp, k)["na"] for k in section_keys(itp, "5"))
assert itp_log(itp)[0] == ("ITP section 5 applies again", SECTION_TITLE_5)
# refused once a line of the section is approved
refused(section_na(q, itp, "1"), 409, "already_signed")
refused(section_na(shop("clerk.clara"), itp, "5"), 403, "forbidden")
def test_line_customer_and_note(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
refused(patch_line(q, itp, "1.3", cust_code="X"), 422, "invalid_field")
itp = ok(patch_line(q, itp, "1.3", cust_code="W"))
assert line(itp, "1.3")["cust_code"] == "W"
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer code W")
itp = ok(patch_line(q, itp, "1.3", cust_code=""))
assert not line(itp, "1.3")["cust_code"]
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer code as template")
itp = ok(patch_line(q, itp, "1.3", cust_by="Rick Inspector"))
ln = line(itp, "1.3")
assert ln["cust_by"] == "Rick Inspector" and ln["cust_at"] == today_iso()
assert ln["cust_rec"]["display_name"] == "Quinn QC"
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer Rick Inspector")
itp = ok(patch_line(q, itp, "1.3", cust_by="Rick Inspector", cust_at="2026-10-01"))
assert line(itp, "1.3")["cust_at"] == "2026-10-01"
itp = ok(patch_line(q, itp, "1.3", cust_by=""))
assert not line(itp, "1.3")["cust_by"] and line(itp, "1.3")["cust_at"] is None
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer cleared")
itp = ok(patch_line(q, itp, "1.3", cust_code="H", note="Reviewed WPS-101 R2"))
assert line(itp, "1.3")["note"] == "Reviewed WPS-101 R2"
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer code H, note")
for code in ("R", "R/AU", "AU", "W", "H", "NA"):
assert ok(patch_line(q, itp, "1.4", cust_code=code))["lines"]["1.4"]["cust_code"] == code
# nothing changed: no log, no version bump
itp = ok(patch_line(q, itp, "1.4", cust_code="NA"))
before = (len(itp["log"]), itp["version"])
itp = ok(patch_line(q, itp, "1.4", cust_code="NA"))
assert (len(itp["log"]), itp["version"]) == before
# limits
r = patch_line(q, itp, "1.5", note="x" * 501)
assert r.status_code == 422 or len(r.json()["lines"]["1.5"]["note"]) <= 500, r.text
def test_reject_and_ncr(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
# reject without an NCR
itp = ok(reject_line(q, itp, "1.4", note="Manual is rev C"))
ln = line(itp, "1.4")
assert ln["result"] == "reject" and ln["ncr"] is None and ln["ncr_missing"] is True
assert [r["kind"] for r in ln["rejects"]] == ["reject"] and ln["rejects"][0]["note"] == "Manual is rev C"
assert itp_log(itp)[0] == ("ITP line 1.4 rejected", "NCR # pending · Manual is rev C")
assert any(h["n"] == "1.4" and h["rejected"] for h in itp["holds"])
refused(sign_line(q, itp, "1.4"), 409, "ncr_required", MSG_NCR_REQUIRED)
refused(patch_line(q, itp, "1.4", na=True), 409, "line_rejected")
r = reject_line(q, itp, "1.4", note="again")
assert r.status_code == 409, r.text
# the NCR later
refused(enter_ncr(q, itp, "1.4", "NCR-26-1"), 422, "invalid_ncr", MSG_NCR)
itp = ok(enter_ncr(q, itp, "1.4", " ncr-2026-0021"))
ln = line(itp, "1.4")
assert ln["ncr"] == NCR and ln["ncr_missing"] is False
assert [r["kind"] for r in ln["rejects"]] == ["reject", "ncr"]
assert itp_log(itp)[0] == ("ITP line 1.4 NCR number entered", f"NCR {NCR}")
refused(enter_ncr(q, itp, "1.4", NCR2), 409, "no_open_reject")
refused(enter_ncr(q, itp, "1.5", NCR2), 409, "no_open_reject")
itp = ok(sign_line(q, itp, "1.4"))
assert line(itp, "1.4")["result"] == "approve"
assert itp_log(itp)[0] == ("ITP line 1.4 approved", f"after NCR {NCR}")
assert not any(h["n"] == "1.4" for h in itp["holds"])
# the reject history survives the approve
assert len(line(itp, "1.4")["rejects"]) == 2
rows = all_rows(shop.settings, "SELECT kind, ncr FROM itp_rejects WHERE itp_id = %s ORDER BY id", (itp["id"],))
assert [tuple(r) for r in rows] == [("reject", None), ("ncr", NCR)]
# reject with the NCR at once
itp = ok(reject_line(q, itp, "1.5", ncr="ncr-2026-0022", note="Map missing W4"))
assert line(itp, "1.5")["ncr"] == NCR2 and line(itp, "1.5")["ncr_missing"] is False
assert itp_log(itp)[0] == ("ITP line 1.5 rejected", f"NCR {NCR2} · Map missing W4")
itp = ok(sign_line(q, itp, "1.5"))
assert itp_log(itp)[0] == ("ITP line 1.5 approved", f"after NCR {NCR2}")
# refused on an approved line and on an N/A line
assert reject_line(q, itp, "1.5", note="x").status_code == 409
itp = ok(patch_line(q, itp, "1.6", na=True))
assert reject_line(q, itp, "1.6", note="x").status_code == 409
refused(reject_line(q, itp, "1.7", ncr="bad one"), 422, "invalid_ncr")
# ================================================================ blocks
def test_block_fields(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(patch_block(q, itp, "3", qn="QN-17"))
assert block(itp, "3")["qn"] == "QN-17" and itp_log(itp)[0] == ("ITP block 3 QN # changed", "QN-17")
itp = ok(patch_block(q, itp, "3", remarks="Fit-up per drawing"))
assert itp_log(itp)[0] == ("ITP block 3 remarks changed", "Fit-up per drawing")
itp = ok(patch_block(q, itp, "3", cust_name="Rick Inspector"))
b = block(itp, "3")
assert b["cust_name"] == "Rick Inspector" and b["cust_at"] == today_iso()
assert b["cust_by"]["display_name"] == "Quinn QC" and b["signed"] is False
assert itp_log(itp)[0] == ("ITP block 3 customer complete recorded", "Rick Inspector")
itp = ok(patch_block(q, itp, "3", cust_clear=True))
b = block(itp, "3")
assert not b["cust_name"] and b["cust_at"] is None and b["cust_by"] is None
assert itp_log(itp)[0][0] == "ITP block 3 customer complete cleared"
assert b["qn"] == "QN-17"
refused(patch_block(shop("sup.wes"), itp, "3", qn="QN-1"), 403, "forbidden")
def test_block_sign_gate(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
refused(sign_block(q, itp, "4"), 409, "block_not_ready", MSG_BLOCK_NOT_READY)
itp = done_section(shop, itp, "4")
assert block(itp, "4")["ready"] is True and block(itp, "4")["all_na"] is False
r = refused(sign_block(q, itp, "4", sign=None), 400, "signature_required")
assert r.get("meaning") == "Sign an ITP block"
r = sign_block(q, itp, "4", sign={"factor": "password", "secret": "wrong-one"})
assert 400 <= r.status_code < 500, r.text
itp = ok(sign_block(q, itp, "4"))
b = block(itp, "4")
assert b["signed"] is True and b["signed_by"]["display_name"] == "Quinn QC" and b["signed_at"]
assert itp["log"][0]["action"] == "ITP block 4 signed" and itp["log"][0]["note"] == "Section 4"
assert itp["log"][0]["signed"] is True
refused(sign_block(q, itp, "4"), 409, "already_signed")
sig = one(
shop.settings, "SELECT subject_type, subject_id, packet_id, meaning FROM signatures ORDER BY id DESC LIMIT 1"
)
assert (sig[0], str(sig[1]), sig[2], sig[3]) == ("itp", str(itp["id"]), None, "Sign an ITP block")
# a block of all N/A lines signs
itp = ok(section_na(q, itp, "5"))
itp = ok(sign_block(q, itp, "5"))
assert block(itp, "5")["signed"] is True and block(itp, "5")["all_na"] is True
# block 3 covers sections 1 to 3
itp = ok(section_na(q, itp, "2"))
itp = ok(section_na(q, itp, "3"))
assert block(itp, "3")["ready"] is False
itp = ok(section_na(q, itp, "1"))
assert block(itp, "3")["ready"] is True
itp = ok(sign_block(q, itp, "3"))
assert itp["log"][0]["note"] == "Sections 1–3"
refused(sign_block(shop("sup.wes"), itp, "6"), 403, "forbidden")
def test_block_unsign(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = done_section(shop, itp, "4")
itp = ok(patch_block(q, itp, "4", qn="QN-4"))
itp = ok(sign_block(q, itp, "4"))
refused(unsign_block(shop("qc.sue"), itp, "4"), 403, "forbidden")
itp = ok(unsign_block(q, itp, "4"))
b = block(itp, "4")
assert b["signed"] is False and b["signed_by"] is None and b["signed_at"] is None and b["qn"] == "QN-4"
assert itp_log(itp)[0][0] == "ITP block 4 signature removed"
# an admin may, with an IT reason
itp = ok(sign_block(q, itp, "4"))
itp = ok(unsign_block(shop("admin.ann"), itp, "4", it_reason=IT))
assert block(itp, "4")["signed"] is False
# the line can be unsigned again now
itp = ok(unsign_line(q, itp, section_keys(itp, "4")[0]))
# ================================================================ sign-off, lock, reopen
def finish_all(shop: Shop, itp: dict[str, Any]) -> dict[str, Any]:
return sign_all_blocks(shop, approve_all(shop, itp))
def test_signoff_without_pdf(qc_shop: Shop) -> None:
shop = qc_shop
unit_id, itp = new_itp(shop)
q = shop("qc.quinn")
refused(sign_off(q, itp), 409, "blocks_unsigned", MSG_BLOCKS_UNSIGNED)
itp = approve_all(shop, itp)
assert itp["progress"] == {"done": TEMPLATE_ITEMS, "total": TEMPLATE_ITEMS, "holds": 0}
assert itp["holds"] == [] and itp["can"]["sign_off"] is False
blocks = [str(b["key"]) for b in itp["blocks"]]
for key in blocks[:-1]:
itp = ok(sign_block(q, itp, key))
refused(sign_off(q, itp), 409, "blocks_unsigned")
itp = ok(sign_block(q, itp, blocks[-1]))
assert itp["can"]["sign_off"] is True
r = refused(sign_off(q, itp, sign=None), 400, "signature_required")
assert r.get("meaning") == "Sign off the ITP"
refused(sign_off(shop("clerk.clara"), itp), 403, "forbidden")
itp = ok(sign_off(q, itp))
assert itp["status"] == "complete" and itp["can"] == {"edit": False, "sign_off": False, "reopen": False}
assert itp["signoff"]["by"]["display_name"] == "Quinn QC" and itp["signoff"]["at"]
assert itp["signoff"]["asset_id"] is None
assert itp_log(itp)[0] == ("ITP signed off", "PDF filing reserved for phase 7") and itp["log"][0]["signed"]
row = one(
shop.settings, "SELECT signoff_asset_id, signoff_signature_id IS NOT NULL FROM itps WHERE id = %s", (itp["id"],)
)
assert tuple(row) == (None, True)
assert all_rows(shop.settings, "SELECT count(*) FROM qc_docs WHERE unit_id = %s", (unit_id,))[0][0] == 0
# the admin may reopen; qc.quinn and qc.sue may not
assert get_itp(shop("admin.ann"), unit_id)["can"]["reopen"] is True
assert get_itp(q, unit_id)["can"]["reopen"] is False
def test_locked_after_signoff_and_reopen(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(sign_off(q, finish_all(shop, itp)))
first = section_keys(itp, "1")[0]
for r in (
patch_itp(q, itp, {"client": "X"}),
sign_line(q, itp, first),
unsign_line(q, itp, first),
patch_line(q, itp, first, note="late"),
section_na(q, itp, "5"),
reject_line(q, itp, first, ncr=NCR),
enter_ncr(q, itp, first, NCR),
patch_block(q, itp, "3", qn="QN-9"),
sign_block(q, itp, "3"),
unsign_block(q, itp, "3"),
sign_off(q, itp),
):
refused(r, 409, "itp_complete", MSG_ITP_COMPLETE)
# reopen: Q.C. users may not; the Q.C. supervisor and the admin may
refused(reopen_itp(q, itp, "Customer asked for 4.2 again"), 403, "forbidden")
refused(reopen_itp(shop("qc.sue"), itp, "Customer asked for 4.2 again"), 403, "forbidden")
r = reopen_itp(shop("admin.ann"), itp, "short")
assert r.status_code == 400 and r.json()["code"] in ("reason_required", "it_reason_required"), r.text
itp = ok(reopen_itp(shop("admin.ann"), itp, "Customer asked for 4.2 again"))
assert itp["status"] == "open" and itp["reopen"]["reason"] == "Customer asked for 4.2 again"
assert itp["reopen"]["by"]["display_name"] == "Ann Admin" and itp["reopen"]["at"]
assert itp_log(itp)[0] == ("ITP reopened", "Customer asked for 4.2 again")
au = one(shop.settings, "SELECT it_reason FROM audit_events WHERE action = 'itp.itp_reopen'")
assert au is not None and au[0] == "Customer asked for 4.2 again"
# the signatures stay; the inspector undoes what must change
assert all(b["signed"] for b in itp["blocks"]) and itp["can"]["sign_off"] is True
refused(reopen_itp(shop("admin.ann"), itp, "Customer asked again"), 409, "itp_open")
itp = ok(patch_itp(q, itp, {"client": "ACME"}))
# a Q.C. supervisor reopens too
itp = ok(sign_off(q, itp))
itp = ok(reopen_itp(shop("qcsup.sam"), itp, "Re-inspect the nozzle welds"))
assert itp["status"] == "open" and itp["reopen"]["by"]["display_name"] == "Sam Qcsup"
def test_signoff_with_pdf_hook(qc_shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
shop = qc_shop
unit_id, itp = new_itp(shop)
q = shop("qc.quinn")
calls: list[Any] = []
asset_ids = [upload_doc(q, "itp-1.pdf")["id"], upload_doc(q, "itp-2.pdf")["id"]]
def fake_hook(conn: Any, itp_row: Any, actor: Any) -> uuid.UUID:
calls.append(itp_row)
return uuid.UUID(asset_ids[len(calls) - 1])
patch_hook(monkeypatch, fake_hook)
itp = ok(sign_off(q, finish_all(shop, itp)))
assert len(calls) == 1
assert itp["signoff"]["asset_id"] == asset_ids[0]
assert itp_log(itp)[0] == ("ITP signed off", "PDF filed")
row = one(shop.settings, "SELECT signoff_asset_id FROM itps WHERE id = %s", (itp["id"],))
assert str(row[0]) == asset_ids[0]
docs = all_rows(
shop.settings,
"SELECT id, type, title, coalesce(rev, ''), note, auto, superseded_by, asset_id"
" FROM qc_docs WHERE unit_id = %s",
(unit_id,),
)
assert len(docs) == 1
d = docs[0]
assert (d[1], d[2], d[3], d[5], d[6]) == ("ITP", f"{itp['no']} — signed off", "", True, None)
assert d[4] == "Filed automatically when the digital ITP was signed off." and str(d[7]) == asset_ids[0]
# a second sign-off (after a reopen) supersedes the earlier auto document
itp = ok(reopen_itp(shop("admin.ann"), itp, "Customer asked for 4.2 again"))
itp = ok(sign_off(q, itp))
assert itp["signoff"]["asset_id"] == asset_ids[1]
docs = all_rows(
shop.settings, "SELECT id, superseded_by, asset_id FROM qc_docs WHERE unit_id = %s ORDER BY id", (unit_id,)
)
assert len(docs) == 2 and docs[0][1] == docs[1][0] and docs[1][1] is None
def test_log_order_and_filed_packet(qc_shop: Shop) -> None:
"""The ITP belongs to the unit: a filed packet in the unit does not stop any ITP write. The log is newest
first, at most 600 rows, and every write wrote one row."""
shop = qc_shop
from qchelpers import filed_packet
_, unit_id = make_unit(shop.settings)
filed_packet(shop, unit_id)
itp = start_itp(shop, unit_id)
q = shop("qc.quinn")
itp = ok(sign_line(q, itp, "1.1"))
itp = ok(patch_line(q, itp, "1.2", na=True))
itp = ok(reject_line(q, itp, "1.3", ncr=NCR))
itp = ok(patch_block(q, itp, "3", qn="QN-1"))
itp = ok(patch_itp(q, itp, {"po": "PO-99"}))
assert [a for a, _ in itp_log(itp)] == [
"ITP header changed",
"ITP block 3 QN # changed",
"ITP line 1.3 rejected",
"ITP line 1.2 updated",
"ITP line 1.1 approved",
"ITP started",
]
ids = [r["id"] for r in itp["log"]]
assert ids == sorted(ids, reverse=True)
assert all(r["by"]["display_name"] == "Quinn QC" and r["by_name"] == "Quinn QC" for r in itp["log"])
count = all_rows(shop.settings, "SELECT count(*) FROM itp_log WHERE itp_id = %s", (itp["id"],))[0][0]
assert count == 6
# the rest of the ITP still works next to the filed packet: unsign, sign a block, sign off
itp = ok(unsign_line(q, itp, "1.1"))
itp = ok(sign_off(q, finish_all(shop, itp)))
assert itp["status"] == "complete"
+242
View File
@@ -0,0 +1,242 @@
"""Phase 6 ITP hold points: the pure functions in domain/itp.py (hold codes, blocks, department pages, open holds),
and the packet read model's `itp_hold` filtered by the packet's department page group. The hold is a warning only:
no precondition reads it, so `blocked` never mentions it."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from pshelpers import needs_pg
from qchelpers import (
ALL_HOLDS,
BLOCKS,
DEPT_PAGES,
HOLDS,
NCR,
g,
patch_line,
reject_line,
sign_line,
start_itp,
)
from shophelpers import Shop, draft, fresh, make_unit, ok, open_shop, ready_packet, ready_skipped, run
from packetstation.settings import Settings
SHOP_CODES = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14"]
def itpd() -> Any:
from packetstation.domain import itp
return itp
def template() -> dict[str, Any]:
from packetstation.domain.itp_template import ITP_TEMPLATE
out: dict[str, Any] = ITP_TEMPLATE
return out
def hold_keys(holds: Any) -> list[str]:
return [str(g(h, "n")) for h in holds]
# ================================================================ pure
@pytest.mark.parametrize(
("code", "hold"),
[
("H", True),
("W", True),
("R/W", True),
("R/H", True),
("AU/W", True),
("R/AU", False),
("NA", False),
("R", False),
("A", False),
("AU", False),
("O", False),
("I", False),
("WH", False),
("H/R", False),
("", False),
],
)
def test_is_hold_code(code: str, hold: bool) -> None:
assert bool(itpd().is_hold_code(code)) is hold
def test_template_shape() -> None:
t = template()
assert t["ver"] == "ITP_Generation_Revised_2"
assert "O = Observe" in t["legend"] and "NA = Not Applicable" in t["legend"]
assert [str(s["n"]) for s in t["sections"]] == [str(n) for n in range(1, 10)]
assert [s["page"] for s in t["sections"]] == ["WP"] * 3 + ["C"] * 3 + ["FA"] * 3
assert [str(s["n"]) for s in t["sections"] if s.get("sig")] == ["3", "4", "5", "6", "7", "8", "9"]
assert [str(s["n"]) for s in t["sections"] if s.get("final")] == ["9"]
items = list(itpd().items(t))
assert len(items) == 70
codes = {str(i["n"]): (i["code"], i["cust"]) for s in t["sections"] for i in s["items"]}
assert codes["3.4"] == ("A", "W") and codes["4.2"] == ("O", "H") and codes["9.7"] == ("R", "H")
def test_blocks() -> None:
out = list(itpd().blocks(template()))
assert [str(g(b, "key")) for b in out] == list(BLOCKS)
# sections may be numbers or the section dicts themselves (the server gives the dicts)
def sec_no(s: Any) -> str:
return str(s["n"]) if isinstance(s, dict) else str(s)
assert {str(g(b, "key")): [sec_no(s) for s in g(b, "sections")] for b in out} == BLOCKS
assert [str(g(b, "key")) for b in out if g(b, "final")] == ["9"]
@pytest.mark.parametrize("code", SHOP_CODES)
def test_dept_pages_shop(code: str) -> None:
assert list(itpd().dept_pages(code)) == DEPT_PAGES[code]
@pytest.mark.parametrize("code", ["CL", "WH", "QC", "PM", "ZZ", ""])
def test_dept_pages_support_or_unknown(code: str) -> None:
assert itpd().dept_pages(code) is None
def test_open_holds_from_the_template() -> None:
t = template()
holds = itpd().open_holds(t, {})
assert sorted(hold_keys(holds), key=_key) == sorted(ALL_HOLDS, key=_key)
assert len(holds) == 17
for page, keys in HOLDS.items():
assert sorted(hold_keys(itpd().open_holds(t, {}, pages=[page])), key=_key) == sorted(keys, key=_key), page
wp_c = itpd().open_holds(t, {}, pages=["WP", "C"])
assert len(wp_c) == 12
def test_open_holds_drop_done_lines_and_add_rejected() -> None:
t = template()
lines = {
"3.4": {"key": "3.4", "result": "approve", "na": False},
"4.2": {"key": "4.2", "result": "na", "na": True},
"1.1": {"key": "1.1", "result": "reject", "ncr": None, "na": False},
"1.2": {"key": "1.2", "result": "reject", "ncr": NCR, "na": False},
"1.3": {"key": "1.3", "result": None, "cust_code": "H", "na": False},
"4.4": {"key": "4.4", "result": None, "cust_code": "R", "na": False}, # a line code R: 4.4's PESCO code is O
}
keys = hold_keys(itpd().open_holds(t, lines))
assert "3.4" not in keys and "4.2" not in keys
assert {"1.1", "1.2", "1.3"} <= set(keys)
# 4.4: the template customer code is H; the line's own code R replaces it, and the PESCO code is O
assert "4.4" not in keys
assert len(keys) == 17 - 3 + 3
wp = itpd().open_holds(t, lines, pages=["WP"])
assert sorted(hold_keys(wp), key=_key) == ["1.1", "1.2", "1.3"]
rej = {str(g(h, "n")): h for h in wp}
assert g(rej["1.1"], "rejected") is True and g(rej["1.1"], "ncr") is None
assert g(rej["1.2"], "ncr") == NCR and g(rej["1.3"], "rejected") is False
def _key(n: str) -> tuple[int, ...]:
return tuple(int(x) for x in n.split("."))
# ================================================================ through the API
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def _hold(shop: Shop, pid: int, user: str = "sup.wes") -> Any:
return fresh(shop(user), pid)["itp_hold"]
@needs_pg
def test_itp_hold_by_department(shop: Shop) -> None:
_, unit_id = make_unit(shop.settings)
in_03 = ready_packet(shop, unit_id)
assert _hold(shop, in_03["id"]) is None # no ITP yet
itp = start_itp(shop, unit_id)
in_11 = ready_skipped(shop, unit_id, dept="11")
in_09 = ready_skipped(shop, unit_id, dept="09")
support = draft(shop, unit_id)
run(shop.settings, "UPDATE packets SET dept_code = 'QC' WHERE id = %s", (support["id"],))
h = _hold(shop, in_03["id"])
assert h["no"] == itp["no"] and h["pages"] == ["WP"] and h["count"] == 1 and h["rejected"] is False
assert hold_keys(h["items"]) == ["3.4"]
item = h["items"][0]
assert "W" in item["codes"] and item["t"] and item["rejected"] is False and item["ncr"] is None
h = _hold(shop, in_11["id"])
assert h["pages"] == ["FA"] and sorted(hold_keys(h["items"]), key=_key) == list(HOLDS["FA"])
assert "9.6" in hold_keys(h["items"]) and "4.2" not in hold_keys(h["items"])
h = _hold(shop, in_09["id"])
assert h["pages"] == ["WP", "C"] and h["count"] == 12
h = _hold(shop, support["id"], "clerk.clara")
assert h["pages"] is None and h["count"] == 17 and len(h["items"]) == 17
# the summary on the packet
s = fresh(shop("sup.wes"), in_03["id"])["itp"]
assert s["id"] == itp["id"] and s["no"] == itp["no"] and s["status"] == "open"
assert s["done"] == 0 and s["total"] == 70 and s["holds"] == 17
# a warning only: nothing in blocked names the ITP
for pid in (in_03["id"], in_11["id"]):
for user in ("sup.wes", "lead.lou", "qc.quinn", "clerk.clara", "wh.wade"):
blocked = fresh(shop(user), pid)["blocked"]
assert not any("ITP" in why for reasons in blocked.values() for why in reasons), (user, blocked)
@needs_pg
def test_itp_hold_follows_the_lines(shop: Shop) -> None:
_, unit_id = make_unit(shop.settings)
in_03 = ready_packet(shop, unit_id)
itp = start_itp(shop, unit_id)
q = shop("qc.quinn")
# approving the only WP hold clears the 03 packet's hold
itp = ok(sign_line(q, itp, "3.4"))
assert _hold(shop, in_03["id"]) is None
assert fresh(shop("sup.wes"), in_03["id"])["itp"]["holds"] == 16
# a rejected WP line comes back as a rejected point, with its NCR when given
itp = ok(reject_line(q, itp, "1.1", note="MTR heat does not match"))
h = _hold(shop, in_03["id"])
assert h["count"] == 1 and h["rejected"] is True
assert h["items"][0]["n"] == "1.1" and h["items"][0]["rejected"] is True and h["items"][0]["ncr"] is None
itp = ok(reject_line(q, itp, "1.2", ncr=NCR, note="WPS rev"))
h = _hold(shop, in_03["id"])
assert h["count"] == 2 and next(i for i in h["items"] if i["n"] == "1.2")["ncr"] == NCR
# a customer hold code on a WP line adds a point; N/A takes a point away
itp = ok(patch_line(q, itp, "2.1", cust_code="H"))
assert _hold(shop, in_03["id"])["count"] == 3
itp = ok(patch_line(q, itp, "2.1", na=True))
assert _hold(shop, in_03["id"])["count"] == 2
# the ITP read lists every open point, unfiltered
assert itp["progress"]["holds"] == len(itp["holds"])
assert {"1.1", "1.2", "4.2", "9.7"} <= set(hold_keys(itp["holds"]))
# once the ITP is complete the hold is gone
run(shop.settings, "UPDATE itps SET status = 'complete' WHERE id = %s", (itp["id"],))
assert _hold(shop, in_03["id"]) is None
run(shop.settings, "UPDATE itps SET status = 'open' WHERE id = %s", (itp["id"],))
assert _hold(shop, in_03["id"])["count"] == 2
@needs_pg
def test_itp_hold_count_on_the_qc_home(shop: Shop) -> None:
from qchelpers import at_qc
_, unit_id = make_unit(shop.settings)
start_itp(shop, unit_id)
pkt = at_qc(shop, unit_id)
home = ok(shop("qc.quinn").get("/api/qc/home"))
card = next(c for c in home["columns"]["ready"] if c["id"] == pkt["id"])
# dept 03 sees the WP page: 3.4
assert card["itp_hold_count"] == 1
+2 -2
View File
@@ -464,7 +464,7 @@ def test_work_orders_put_and_docs(pg_client2: TestClient, unit: tuple[int, int])
wo = r.json()["work_orders"] wo = r.json()["work_orders"]
assert [(w["wo_base"], w["lot"], w["sub"]) for w in wo] == [("0070001", "7A", "01")] assert [(w["wo_base"], w["lot"], w["sub"]) for w in wo] == [("0070001", "7A", "01")]
a = upload(pg_client2, tiny_pdf(1)).json() a = upload(pg_client2, tiny_pdf(1)).json()
r = pg_client2.post(f"/api/packets/{pkt['id']}/docs", json={"asset_id": a["id"], "kind": "mtr", "title": "MTR 1"}) r = pg_client2.post(f"/api/packets/{pkt['id']}/docs", json={"asset_id": a["id"], "kind": "other", "title": "MTR 1"})
assert r.status_code == 200 and r.json()["docs"][0]["title"] == "MTR 1" assert r.status_code == 200 and r.json()["docs"][0]["title"] == "MTR 1"
doc_id = r.json()["docs"][0]["id"] doc_id = r.json()["docs"][0]["id"]
as_user(pg_client2, "wh.wade") as_user(pg_client2, "wh.wade")
@@ -472,7 +472,7 @@ def test_work_orders_put_and_docs(pg_client2: TestClient, unit: tuple[int, int])
assert r.status_code == 403 assert r.status_code == 403
as_user(pg_client2, "qc.quinn") as_user(pg_client2, "qc.quinn")
r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}/docs/{doc_id}", json={"reason": "wrong file"}) r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}/docs/{doc_id}", json={"reason": "wrong file"})
assert r.status_code == 200 and r.json()["docs"] == [] assert r.status_code == 200 and r.json()["docs"][0]["removed"]["reason"] == "wrong file"
def test_seal_detects_tampering(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None: def test_seal_detects_tampering(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
+553
View File
@@ -0,0 +1,553 @@
"""Phase 6 Q.C. policy: the packet Q.C. actions (QC_ACCEPT, QC_ACCEPT_AND_ROUTE, QC_REJECT, EDIT_QC, DOC_ADD,
DOC_REMOVE) as every test user from every status the helpers reach, the scoped actions (QC_DOC_ADD and the ITP
actions) through `check_scoped`, and the qc_missing_empty gate one reason at a time.
The expectations come from an oracle written from the phase 6 contract (who, from-status, CO hold, filed lock,
preconditions), not from the engine. Part one runs the pure policy; part two runs the API."""
from __future__ import annotations
import dataclasses
import itertools
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import needs_pg
from qchelpers import (
QC_KEYS,
accepted_packet,
add_packet_doc,
at_qc,
filed_packet,
finish_inspection,
qc_check,
qc_patch,
qc_weld,
take_out,
upload_doc,
)
from shophelpers import (
IT,
SIGN,
USERS,
Shop,
draft,
fresh,
in_progress,
in_review,
issue_co,
ok,
open_shop,
ready_packet,
refused,
to_station,
to_warehouse,
)
from packetstation.policy import SYSTEM_FEED, Action, PacketState, Principal, can, check, principal_from_roles
from packetstation.settings import Settings
ANDY = "00000000-0000-0000-0000-00000000a001"
REASON = "Weld 2 undercut too deep"
NCR = "NCR-2026-0021"
PACKET_ACTIONS = ("qc_accept", "qc_accept_and_route", "qc_reject", "edit_qc", "doc_add", "doc_remove")
TRANSITIONS = ("qc_accept", "qc_accept_and_route", "qc_reject")
FROM: dict[str, frozenset[str]] = {
"qc_accept": frozenset({"qc"}),
"qc_accept_and_route": frozenset({"qc"}),
"qc_reject": frozenset({"qc"}),
"edit_qc": frozenset({"qc"}),
"doc_add": frozenset(), # any status; the filed lock applies
"doc_remove": frozenset(),
}
WHO = {
"qc_accept": "QC",
"qc_accept_and_route": "QC",
"qc_reject": "QC",
"edit_qc": "QC",
"doc_add": "CL_OR_QC",
"doc_remove": "CL_OR_QC",
}
CO_HELD = frozenset({"qc_accept", "qc_accept_and_route"})
GATED = frozenset({"qc_accept", "qc_accept_and_route"}) # qc_missing_empty
SIGNED = {
"qc_accept": "Q.C. accept",
"qc_accept_and_route": "Q.C. accept and route",
"qc_reject": "Q.C. reject",
}
ROLES = {
"admin.ann": ["admin"],
"clerk.clara": ["clerical"],
"clerk.leo": ["clerical_supervisor"],
"wh.wade": ["warehouse"],
"qc.quinn": ["qc"],
"pm.paula": ["pm"],
"weld.andy": ["shop:03:worker"],
"lead.lou": ["shop:03:lead"],
"sup.wes": ["shop:03:supervisor"],
"multi.mia": ["clerical", "shop:06:supervisor"],
"both.bo": ["shop:03:worker", "shop:03:supervisor"],
"dup.dave": ["shop:03:worker"],
}
QC_PEOPLE = frozenset({"qc.quinn"})
CL_OR_QC_PEOPLE = frozenset({"clerk.clara", "clerk.leo", "multi.mia", "qc.quinn"})
@dataclasses.dataclass(frozen=True)
class Case:
name: str
status: str
qc_done: bool = False # the open inspection has nothing missing
CASES = {
"draft": Case("draft", "draft"),
"warehouse": Case("warehouse", "warehouse"),
"ready": Case("ready", "ready"),
"assigned": Case("assigned", "assigned"),
"in_progress": Case("in_progress", "in_progress"),
"review": Case("review", "review"),
"qc": Case("qc", "qc"),
"qc_done": Case("qc_done", "qc", qc_done=True),
"complete": Case("complete", "complete"),
"filed": Case("filed", "filed"),
}
def who_ok(user: str, action: str) -> bool:
if WHO[action] == "QC":
return user in QC_PEOPLE
return user in CL_OR_QC_PEOPLE
def expected(user: str, case: Case, action: str, *, it_reason: bool = False, co_hold: bool = False) -> tuple[int, str]:
"""The answer with every request input given (route, dept, reason, signature). The order is the contract's:
status, CO hold, admin bypass, filed lock, who, preconditions."""
if FROM[action] and case.status not in FROM[action]:
return 403, "wrong_status"
if co_hold and action in CO_HELD:
return 403, "co_hold"
allowed = who_ok(user, action)
if not allowed and user == "admin.ann":
if not it_reason:
return 400, "it_reason_required"
allowed = True
if case.status == "filed":
return 403, "filed"
if not allowed:
return 403, "forbidden"
if action in GATED and not case.qc_done:
return 403, "precondition"
return 200, ""
def in_can(user: str, case: Case, action: str, **kw: Any) -> bool:
return expected(user, case, action, it_reason=True, **kw)[0] == 200
def in_blocked(user: str, case: Case, action: str, **kw: Any) -> bool:
"""`blocked`: status, who and the filed lock pass, but qc_missing_empty or the CO hold fails."""
code = expected(user, case, action, it_reason=True, **kw)[1]
if code == "co_hold":
return who_ok(user, action) or user == "admin.ann"
return code == "precondition"
# ================================================================ part one: the pure policy
MISSING = ("10 inspection items", "2 welds without a visual result", "inspector name")
def P(user: str) -> Principal:
uid = ANDY if user == "weld.andy" else f"00000000-0000-0000-0000-{abs(hash(user)) % 10**12:012d}"
return principal_from_roles(uid, ROLES[user])
def state(case: Case, **kw: Any) -> PacketState:
active = case.status in ("assigned", "in_progress", "review")
base = PacketState(
id=1,
status=case.status,
dept="03",
station_id=101 if active else None,
welder_id=ANDY if active else None,
wh_skipped=False,
routed_to="databook" if case.status in ("complete", "filed") else None,
vclosed=case.status == "filed",
removed=False,
source_kind="hand",
weld_dept=True,
co_hold=False,
drawing_attached=True,
heat_map=True,
bom_total=3,
bom_ready=3,
welds=2,
checklist_complete=True,
filler_set=True,
qc_missing=() if case.qc_done or case.status != "qc" else MISSING,
has_qc_sent=case.status == "qc",
)
return dataclasses.replace(base, **kw)
EXTRA: dict[str, dict[str, Any]] = {
"qc_accept": {"routed_to": "databook"},
"qc_accept_and_route": {"dept": "09"},
"qc_reject": {"reason": REASON},
}
@pytest.mark.parametrize(("user", "case", "action"), list(itertools.product(ROLES, CASES, PACKET_ACTIONS)))
def test_policy_matrix(user: str, case: str, action: str) -> None:
c = CASES[case]
s = state(c)
d = check(P(user), s, Action(action), extra=EXTRA.get(action, {}))
want = expected(user, c, action)
assert ("" if d.ok else d.code) == want[1], (user, case, action, d)
if d.ok and action in SIGNED:
assert d.signature == SIGNED[action]
allowed, blocked = can(P(user), s)
assert (Action(action) in allowed) == in_can(user, c, action), (user, case, action, allowed)
assert (Action(action) in blocked) == in_blocked(user, c, action), (user, case, action, blocked)
if Action(action) in blocked and action in GATED:
assert blocked[Action(action)] == ["Q.C. still needs: " + ", ".join(MISSING) + "."]
@pytest.mark.parametrize("user", list(ROLES))
@pytest.mark.parametrize("case", ["qc", "qc_done"])
def test_policy_co_hold_on_accept_not_reject(user: str, case: str) -> None:
c = CASES[case]
why = "Change order CO-1 must be verified first by: 03 Weld Parts."
s = state(c, co_hold=True, co_hold_why=why)
allowed, blocked = can(P(user), s)
for action in PACKET_ACTIONS:
d = check(P(user), s, Action(action), extra=EXTRA.get(action, {}))
want = expected(user, c, action, co_hold=True)
assert ("" if d.ok else d.code) == want[1], (user, case, action, d)
if want[1] == "co_hold":
assert d.why == why
assert (Action(action) in allowed) == in_can(user, c, action, co_hold=True), (user, case, action)
assert (Action(action) in blocked) == in_blocked(user, c, action, co_hold=True), (user, case, action)
if Action(action) in blocked and action in CO_HELD:
assert blocked[Action(action)][0] == why
if case == "qc":
assert blocked[Action(action)][1] == "Q.C. still needs: " + ", ".join(MISSING) + "."
def test_policy_reject_needs_a_reason() -> None:
s = state(CASES["qc"])
for extra in ({}, {"reason": ""}, {"reason": "bad"}):
d = check(P("qc.quinn"), s, Action.QC_REJECT, extra=extra)
assert d.code == "reason_required", (extra, d)
assert check(P("qc.quinn"), s, Action.QC_REJECT, extra={"reason": NCR}).ok
# the accept routes are inputs
assert check(P("qc.quinn"), state(CASES["qc_done"]), Action.QC_ACCEPT, extra={}).code == "precondition"
d = check(P("qc.quinn"), state(CASES["qc_done"]), Action.QC_ACCEPT_AND_ROUTE, extra={"dept": "03"})
assert d.code == "precondition"
# ---------------------------------------------------------------- scoped actions (no packet)
SCOPED: dict[str, str] = {
"qc_doc_add": "CL_OR_QC",
"itp_start": "QC",
"itp_line_sign": "QC",
"itp_line_unsign": "QC",
"itp_edit_line": "QC",
"itp_block_sign": "QC",
"itp_block_unsign": "QC",
"itp_sign_off": "QC",
"itp_reopen": "QC_SUP",
}
SCOPED_SIGNED = {"itp_block_sign": "Sign an ITP block", "itp_sign_off": "Sign off the ITP"}
SCOPED_ROLES = {**ROLES, "qc.sup": ["qc_supervisor"]}
def scoped_who(user: str, who: str) -> bool:
if who == "QC":
return user in ("qc.quinn", "qc.sup")
if who == "QC_SUP":
return user == "qc.sup"
return user in CL_OR_QC_PEOPLE or user == "qc.sup"
def scoped_expected(user: str, action: str, *, it_reason: bool, reason: bool = True) -> tuple[bool, str, bool]:
"""(ok, code, admin_bypass) for check_scoped."""
allowed = scoped_who(user, SCOPED[action])
bypass = False
if not allowed and user == "admin.ann":
if not it_reason:
return False, "it_reason_required", False
allowed, bypass = True, True
if not allowed:
return False, "forbidden", False
if action == "itp_reopen" and not reason:
return False, "reason_required", bypass
return True, "", bypass
def _check_scoped() -> Any:
from packetstation.policy.engine import check_scoped
return check_scoped
def SP(user: str) -> Principal:
return principal_from_roles(f"00000000-0000-0000-0000-{abs(hash(user)) % 10**12:012d}", SCOPED_ROLES[user])
@pytest.mark.parametrize(("user", "action"), list(itertools.product(SCOPED_ROLES, SCOPED)))
@pytest.mark.parametrize("it", [False, True])
def test_check_scoped(user: str, action: str, it: bool) -> None:
check_scoped = _check_scoped()
extra = {"reason": "Customer asked to re-inspect 4.2"} if action == "itp_reopen" else {}
d = check_scoped(SP(user), Action(action), extra=extra, it_reason=IT if it else None)
want = scoped_expected(user, action, it_reason=it)
assert (d.ok, "" if d.ok else d.code) == want[:2], (user, action, it, d)
if d.ok:
assert d.admin_bypass == want[2], (user, action, d)
if action in SCOPED_SIGNED:
assert d.signature == SCOPED_SIGNED[action]
def test_check_scoped_special_cases() -> None:
check_scoped = _check_scoped()
# the system principal never passes a people action
for action in SCOPED:
d = check_scoped(SYSTEM_FEED, Action(action), extra={"reason": "Customer asked again"})
assert d.code == "forbidden", (action, d)
# admin: an IT reason shorter than 8 characters is no IT reason
d = check_scoped(SP("admin.ann"), Action("itp_start"), it_reason="short")
assert d.code == "it_reason_required"
d = check_scoped(SP("admin.ann"), Action("itp_start"), it_reason=IT)
assert d.ok and d.admin_bypass
# reopen: the reason is required for the Q.C. supervisor and for the admin
d = check_scoped(SP("qc.sup"), Action("itp_reopen"), extra={})
assert d.code == "reason_required"
d = check_scoped(SP("admin.ann"), Action("itp_reopen"), extra={"reason": "no"}, it_reason=IT)
assert d.code == "reason_required"
d = check_scoped(SP("qc.quinn"), Action("itp_reopen"), extra={"reason": "Customer asked again"})
assert d.code == "forbidden"
# PM reads only
for action in SCOPED:
assert check_scoped(SP("pm.paula"), Action(action), extra={"reason": "Customer asked"}).code == "forbidden"
def test_scoped_actions_exist_with_labels() -> None:
for name in ("qc_doc_add", "itp_start", "itp_line_sign", "itp_line_unsign", "itp_block_unsign"):
a = Action(name)
assert a.value == name
from packetstation.policy.actions import EDIT_WHAT, RULES
for name in SCOPED:
rule = RULES[Action(name)]
assert rule.to_status is None and not rule.system_only, name
assert name in {str(a) for a in EDIT_WHAT}, name
assert EDIT_WHAT[Action("qc_doc_add")] == "the Q.C. folder"
assert EDIT_WHAT[Action("itp_start")] == "the ITP"
# ================================================================ part two: the API
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def body_for(action: str) -> dict[str, Any]:
return {
"qc_accept": {"routed_to": "databook", "sign": SIGN},
"qc_accept_and_route": {"dept": "09", "sign": SIGN},
"qc_reject": {"reason": REASON, "sign": SIGN},
}[action]
def build(shop: Shop, case: str) -> dict[str, Any]:
"""A packet in the case's status with one packet document (by clerk.clara), made through the API."""
if case == "filed":
pkt = accepted_packet(shop)
ok(add_packet_doc(shop("clerk.clara"), pkt["id"]))
c = shop("clerk.clara")
pkt = ok(c.post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True}))
return ok(step(c, pkt, "file", sign=SIGN))
if case == "draft":
pkt = draft(shop)
elif case == "warehouse":
pkt = to_warehouse(shop, draft(shop))
elif case == "ready":
pkt = ready_packet(shop)
elif case == "assigned":
pkt = to_station(shop, ready_packet(shop))
elif case == "in_progress":
pkt = in_progress(shop)
elif case == "review":
pkt = in_review(shop)
elif case == "qc":
pkt = at_qc(shop)
elif case == "qc_done":
pkt = finish_inspection(shop, at_qc(shop))
elif case == "complete":
pkt = accepted_packet(shop)
else:
raise AssertionError(case)
return ok(add_packet_doc(shop("clerk.clara"), pkt["id"]))
def live_doc(pkt: dict[str, Any]) -> int:
return int(next(d for d in pkt["docs"] if not d.get("removed"))["id"])
@needs_pg
@pytest.mark.parametrize("case", list(CASES))
def test_api_matrix(shop: Shop, case: str) -> None:
"""Every refused Q.C. step answers the oracle's code and changes nothing; `can` and `blocked` agree for every
user. Allowed steps are not sent here (test_qc_api and test_qc_docs send them)."""
c = CASES[case]
pkt = build(shop, case)
assert pkt["status"] == c.status
# Building a filed packet takes minutes through the tunnel; sessions idle that long lock. Sign in afresh.
shop.clients.clear()
doc_id = live_doc(pkt)
asset = upload_doc(shop("clerk.clara"))["id"]
for user in USERS:
mine = fresh(shop(user), pkt["id"])
for action in PACKET_ACTIONS:
assert (action in mine["can"]) == in_can(user, c, action), (user, case, action, mine["can"])
assert (action in mine["blocked"]) == in_blocked(user, c, action), (user, case, action, mine["blocked"])
want = expected(user, c, action)
if c.status == "filed":
# The write pipeline answers `filed` before it runs the policy (domain/pipeline.packet_write), so
# through the API every write on a filed packet is 403 filed, whatever the engine order says.
want = (403, "filed")
if want[0] == 200:
continue
if action in TRANSITIONS:
r = step(shop(user), pkt, action, **body_for(action))
elif action == "doc_add":
r = add_packet_doc(shop(user), pkt["id"], asset_id=asset)
elif action == "doc_remove":
r = take_out(shop(user), pkt["id"], doc_id)
else:
r = qc_patch(shop(user), pkt["id"], {"notes": "Looks fine"})
if c.status != "qc" and r.status_code == 409:
# no open inspection: the contract does not fix whether this or wrong_status answers first
assert r.json()["code"] == "no_inspection", r.text
continue
assert (r.status_code, r.json().get("code")) == want, (user, case, action, r.text)
if want[1] == "precondition" and action in GATED:
assert r.json()["detail"].startswith("Q.C. still needs: ")
assert mine["blocked"][action] == [r.json()["detail"]]
after = fresh(shop("admin.ann"), pkt["id"])
assert (after["status"], after["version"]) == (pkt["status"], pkt["version"])
@needs_pg
def test_api_admin_with_it_reason(shop: Shop) -> None:
"""The admin passes who with an IT reason but never the Q.C. gate or the filed lock."""
pkt = at_qc(shop)
ann = shop("admin.ann")
r = refused(step(ann, pkt, "qc_accept", routed_to="databook", sign=SIGN, it_reason=IT), 403, "precondition")
assert r["detail"].startswith("Q.C. still needs: ")
out = ok(qc_patch(ann, pkt["id"], {"notes": "Fixed by IT"}, it_reason=IT))
assert out["qc"]["notes"] == "Fixed by IT"
out = ok(add_packet_doc(ann, pkt["id"], it_reason=IT))
refused(add_packet_doc(ann, pkt["id"]), 400, "it_reason_required")
out = ok(step(ann, out, "qc_reject", reason=REASON, sign=SIGN, it_reason=IT))
assert out["status"] == "review"
@needs_pg
def test_api_signature_required(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop))
q = shop("qc.quinn")
for action, body in (
("qc_accept", {"routed_to": "databook"}),
("qc_accept_and_route", {"dept": "09"}),
("qc_reject", {"reason": REASON}),
):
r = refused(step(q, pkt, action, **body), 400, "signature_required")
assert r.get("meaning") == SIGNED[action], r
assert fresh(q, pkt["id"])["status"] == "qc"
def _missing(pkt: dict[str, Any]) -> list[str]:
out: list[str] = pkt["qc"]["missing"]
return out
def _gate(shop: Shop, pkt: dict[str, Any], parts: list[str]) -> None:
"""The read model, the blocked list and a refused accept all say the same thing."""
msg = "Q.C. still needs: " + ", ".join(parts) + "."
mine = fresh(shop("qc.quinn"), pkt["id"])
assert _missing(mine) == parts
assert "qc_accept" not in mine["can"]
assert mine["blocked"]["qc_accept"] == [msg]
assert mine["blocked"]["qc_accept_and_route"] == [msg]
r = refused(step(shop("qc.quinn"), mine, "qc_accept", routed_to="databook", sign=SIGN), 403, "precondition")
assert r["detail"] == msg
@needs_pg
def test_api_qc_missing_one_by_one(shop: Shop) -> None:
pkt = at_qc(shop)
q = shop("qc.quinn")
pid = pkt["id"]
welds = sorted(int(w["weld_no"]) for w in pkt["welds"])
assert len(welds) == 2
_gate(shop, pkt, ["10 inspection items", "2 welds without a visual result", "inspector name"])
for k in QC_KEYS[:7]:
ok(qc_check(q, pid, k))
_gate(shop, pkt, ["3 inspection items", "2 welds without a visual result", "inspector name"])
for k in QC_KEYS[7:9]:
ok(qc_check(q, pid, k))
_gate(shop, pkt, ["1 inspection item", "2 welds without a visual result", "inspector name"])
ok(qc_check(q, pid, QC_KEYS[9]))
_gate(shop, pkt, ["2 welds without a visual result", "inspector name"])
ok(qc_weld(q, pid, welds[0], result="accept"))
_gate(shop, pkt, ["1 weld without a visual result", "inspector name"])
ok(qc_weld(q, pid, welds[1], result="reject", note="undercut"))
_gate(shop, pkt, ["a rejected weld (reject the packet instead)", "inspector name"])
ok(qc_weld(q, pid, welds[1], result="accept"))
_gate(shop, pkt, ["inspector name"])
ok(qc_patch(q, pid, {"nde_method": "RT"}))
_gate(shop, pkt, ["NDE result", "inspector name"])
ok(qc_patch(q, pid, {"nde_result": "Rejectable"}))
_gate(shop, pkt, ["NDE result", "inspector name"])
ok(qc_patch(q, pid, {"nde_result": "Acceptable"}))
_gate(shop, pkt, ["inspector name"])
out = ok(qc_patch(q, pid, {"inspector": "Quinn QC"}))
assert _missing(out) == []
assert "qc_accept" in out["can"] and "qc_accept_and_route" in out["can"]
assert "qc_accept" not in out["blocked"]
# ticking one key off again brings the gate back
ok(qc_check(q, pid, "ph_qty", done=False))
_gate(shop, pkt, ["1 inspection item"])
@needs_pg
def test_api_co_hold_blocks_accept_not_reject(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop))
pkt = issue_co(shop, pkt, ["03"])
mine = fresh(shop("qc.quinn"), pkt["id"])
held = "Change order CO-1 must be verified first by: 03 Weld Parts."
assert mine["blocked"]["qc_accept"] == [held]
assert "qc_reject" in mine["can"]
refused(step(shop("qc.quinn"), mine, "qc_accept", routed_to="databook", sign=SIGN), 403, "co_hold", held)
refused(step(shop("qc.quinn"), mine, "qc_accept_and_route", dept="09", sign=SIGN), 403, "co_hold", held)
out = ok(step(shop("qc.quinn"), mine, "qc_reject", ncr=NCR, sign=SIGN))
assert out["status"] == "review"
@needs_pg
def test_api_filed_lock_on_documents(shop: Shop) -> None:
pkt = filed_packet(shop)
for user in ("qc.quinn", "clerk.clara"):
refused(add_packet_doc(shop(user), pkt["id"]), 403, "filed")
refused(add_packet_doc(shop("admin.ann"), pkt["id"], it_reason=IT), 403, "filed")
+386
View File
@@ -0,0 +1,386 @@
"""Phase 6 Q.C. inspection API: the check and weld PUTs, the field CAS PATCH, the qc_items hints, nace_label, accept
to the data book, to shipping and to a department, reject with a reason, an NCR or both, the second inspection
after a reject, and the Q.C. home board. Every log text is the contract's, exactly."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import needs_pg
from qchelpers import (
NCR,
NCR_URL,
QC_KEYS,
ROUTE_DB,
accept,
accept_route,
at_qc,
finish_inspection,
qc_check,
qc_patch,
qc_weld,
reject,
to_qc,
)
from shophelpers import (
SIGN,
Shop,
all_rows,
fresh,
issue_co,
last_log,
log_rows,
make_unit,
ok,
one,
open_shop,
refused,
run,
set_stamp,
)
from packetstation.settings import Settings
pytestmark = needs_pg
REASON = "Weld 2 undercut too deep"
ITEM_TEXT = {
"db_heats": "Heat number recorded for every BOM item",
"db_mtr": "MTRs on file and match the heat numbers",
"db_weldmap": "Weld map complete: every weld identified",
"db_wps": "Welds made to WPS-11; welder [75] qualified",
"db_ck": "Stall checklist and weld record complete",
"ph_dim": "Dimensions verified against D-5001 Rev B",
"ph_heat": "Heat numbers on material match the heat map",
"ph_stamp": "Welds stamped with welder ID",
"ph_clean": "Clean: no slag, spatter or arc strikes",
"ph_qty": "Quantity matches the work orders",
}
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def actions(shop: Shop, pid: int) -> list[str]:
return [r["action"] for r in log_rows(shop("qc.quinn"), pid)]
# ================================================================ checks, welds, fields
def test_check_put_per_key(shop: Shop) -> None:
set_stamp(shop.settings, "weld.andy", "75")
pkt = at_qc(shop)
q = shop("qc.quinn")
pid = pkt["id"]
ok(qc_patch(q, pid, {"stamp": "Q1"}))
# the item texts carry the drawing, the rev, the WPS and the welder stamp
texts = {i["key"]: i["text"] for i in fresh(q, pid)["qc_items"]}
assert texts == ITEM_TEXT
for key in QC_KEYS:
before = len(log_rows(q, pid))
out = ok(qc_check(q, pid, key))
entry = out["qc"]["checks"][key]
assert entry["done"] is True and entry["at"] and entry["by"], entry
assert entry["stamp"] == "Q1" and entry["by_name"] == "Quinn QC", entry
log = last_log(q, pid)
assert log["action"] == f"Q.C. checked: {ITEM_TEXT[key]}" and not log["note"], log
assert len(log_rows(q, pid)) == before + 1
# a tick that changes nothing writes nothing
before = len(log_rows(q, pid))
ok(qc_check(q, pid, "ph_dim"))
assert len(log_rows(q, pid)) == before
# untick
out = ok(qc_check(q, pid, "ph_dim", done=False))
assert out["qc"]["checks"]["ph_dim"].get("done") is False
assert last_log(q, pid)["action"] == "Q.C. unchecked: Dimensions verified against D-5001 Rev B"
# an unknown key
refused(qc_check(q, pid, "ph_paint"), 422, "invalid_key")
# Q.C. only
refused(qc_check(shop("sup.wes"), pid, "ph_dim"), 403, "forbidden")
def test_check_put_without_inspection(shop: Shop) -> None:
pkt = at_qc(shop)
out = ok(reject(shop, pkt, REASON))
assert out["qc"] is None
# back with the supervisor: no inspection is open
r = qc_check(shop("qc.quinn"), pkt["id"], "ph_dim")
assert r.status_code in (403, 409), r.text
assert r.json()["code"] in ("wrong_status", "no_inspection"), r.text
def test_weld_put(shop: Shop) -> None:
pkt = at_qc(shop)
q = shop("qc.quinn")
pid = pkt["id"]
w1, w2 = sorted(int(w["weld_no"]) for w in pkt["welds"])
out = ok(qc_weld(q, pid, w1, result="accept"))
e = out["qc"]["weld_exam"][str(w1)]
assert e["result"] == "accept" and e["at"] and e["by"] and e["by_name"] == "Quinn QC", e
assert last_log(q, pid)["action"] == f"VT accept · W{w1}" and not last_log(q, pid)["note"]
out = ok(qc_weld(q, pid, w2, result="reject", note="undercut"))
assert out["qc"]["weld_exam"][str(w2)]["note"] == "undercut"
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == (f"VT reject · W{w2}", "undercut")
# a note-only change
ok(qc_weld(q, pid, w2, note="undercut 1/32"))
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == ("Q.C. record changed", f"W{w2} note")
# cleared
out = ok(qc_weld(q, pid, w2, result=""))
assert not out["qc"]["weld_exam"].get(str(w2), {}).get("result")
assert last_log(q, pid)["action"] == f"VT cleared · W{w2}"
# a weld that is not on the packet
refused(qc_weld(q, pid, 99, result="accept"), 422, "invalid_weld")
refused(qc_weld(shop("clerk.clara"), pid, w1, result="reject"), 403, "forbidden")
def test_field_cas_patch(shop: Shop) -> None:
pkt = at_qc(shop)
q = shop("qc.quinn")
pid = pkt["id"]
out = ok(qc_patch(q, pid, {"inspector": "Quinn QC", "stamp": "Q1"}, {"inspector": None, "stamp": None}))
assert out["qc"]["inspector"] == "Quinn QC" and out["qc"]["stamp"] == "Q1"
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == (
"Q.C. record changed",
"inspector Quinn QC · stamp Q1",
)
# a stale base
r = refused(qc_patch(q, pid, {"inspector": "Q. Quinn"}, {"inspector": "Somebody"}), 409, "field_conflict")
assert r["current"] == {"inspector": "Quinn QC"}
# NDE method and result validation
r = refused(qc_patch(q, pid, {"nde_method": "XR"}), 422, "invalid_field")
assert r["detail"] == "Pick an NDE method from the list."
refused(qc_patch(q, pid, {"nde_result": "Maybe"}), 422, "invalid_field")
ok(qc_patch(q, pid, {"nde_method": "RT"}))
assert last_log(q, pid)["action"] == "NDE method: RT"
out = ok(qc_patch(q, pid, {"nde_result": "Acceptable"}))
assert out["qc"]["nde_result"] == "Acceptable"
assert last_log(q, pid)["action"] == "NDE result: Acceptable"
out = ok(qc_patch(q, pid, {"hardness": "198 HB", "nde_report": "RPT-1"}))
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == (
"Q.C. record changed",
"hardness 198 HB · report RPT-1",
)
ok(qc_patch(q, pid, {"notes": "Fit-up good"}))
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == ("Q.C. record changed", "notes changed")
ok(qc_patch(q, pid, {"nde_result": ""}))
assert last_log(q, pid)["action"] == "NDE result: -"
ok(qc_patch(q, pid, {"nde_result": "Acceptable"}))
# None required clears the result and the report
out = ok(qc_patch(q, pid, {"nde_method": "None required"}))
assert out["qc"]["nde_method"] == "None required"
assert not out["qc"]["nde_result"] and not out["qc"]["nde_report"]
assert "NDE method: None required" in actions(shop, pid)
for method in ("UT", "MT", "PT"):
assert ok(qc_patch(q, pid, {"nde_method": method}))["qc"]["nde_method"] == method
# the old flat body with If-Match still merges checks (phase 2), or the new endpoints replaced it
cur = fresh(q, pid)["qc"]
r = q.patch(
f"/api/packets/{pid}/qc",
json={"checks": {"ph_qty": {"done": True}}},
headers={"If-Match": str(cur["row_version"])},
)
assert r.status_code < 500, r.text
# the nace field is computed, not written
r = qc_patch(q, pid, {"nace": True})
assert r.status_code < 500, r.text
assert fresh(q, pid)["qc"]["nace_label"] is False
def test_qc_items_hints_and_nace_label(shop: Shop) -> None:
pkt = at_qc(shop)
q = shop("qc.quinn")
items = fresh(q, pkt["id"])["qc_items"]
assert [i["key"] for i in items] == list(QC_KEYS)
assert [i["group"] for i in items] == ["Data book package"] * 5 + ["Physical spool"] * 5
hints = {i["key"]: i["hint"] for i in items}
assert hints["db_heats"] == "3/3 recorded"
assert hints["db_mtr"] == "0/3 MTRs attached"
assert hints["db_weldmap"] == "2 welds on map"
assert hints["db_ck"] == "checklist 9/9 · filler ER70S-6"
assert all(hints[k] is None for k in QC_KEYS[5:]), hints
assert fresh(q, pkt["id"])["qc"]["nace_label"] is False
run(shop.settings, "UPDATE packets SET wps = 'WPS-12 nace sour' WHERE id = %s", (pkt["id"],))
assert fresh(q, pkt["id"])["qc"]["nace_label"] is True
# ================================================================ accept
def _snapshot(shop: Shop, pid: int) -> Any:
row = one(
shop.settings,
"SELECT qc_snapshot FROM packet_stages WHERE packet_id = %s AND closed_at IS NOT NULL ORDER BY id DESC LIMIT 1",
(pid,),
)
assert row is not None
return row[0]
def test_accept_to_data_book(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop), nde=("RT", "Acceptable"))
out = ok(accept(shop, pkt, "databook"))
assert out["status"] == "complete" and out["routed_to"] == "databook" and out["qc"] is None
assert out["stage"] is None
h = out["qc_history"][-1]
assert h["result"] == "accepted" and h["inspector"] == "Quinn QC"
assert h["by"]["display_name"] == "Quinn QC" and h["location"] == "Bay 2" and h["sent_at"]
assert h["rejected_welds"] == []
assert out["qc_last"]["id"] == h["id"] and out["qc_last"]["result"] == "accepted"
snap = _snapshot(shop, pkt["id"])
assert snap and snap.get("inspector") == "Quinn QC" and snap.get("result") == "accepted", snap
log = last_log(shop("qc.quinn"), pkt["id"])
assert log["action"] == f"Q.C. accepted → {ROUTE_DB}"
assert log["note"] == "Quinn QC [Q1] · 2 welds VT accepted · RT Acceptable"
assert log["signed"] is True
def test_accept_to_shipping(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop))
out = ok(accept(shop, pkt, "shipping"))
assert out["status"] == "complete" and out["routed_to"] == "shipping"
log = last_log(shop("qc.quinn"), pkt["id"])
assert (log["action"], log["note"]) == ("Q.C. accepted → Shipping", "Quinn QC [Q1] · 2 welds VT accepted")
def test_accept_note_without_stamp(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop, welds=1), stamp="")
ok(accept(shop, pkt, "shipping"))
log = last_log(shop("qc.quinn"), pkt["id"])
assert log["note"] == "Quinn QC · 1 weld VT accepted" or log["note"] == "Quinn QC · 1 welds VT accepted", log
def test_accept_and_route_to_hydro(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop))
refused(accept_route(shop, pkt, "03"), 403, "precondition")
out = ok(accept_route(shop, pkt, "09"))
assert out["status"] == "ready" and out["dept"]["code"] == "09"
assert out["stage"] is not None and out["stage"]["dept_code"] == "09"
assert out["qc"] is None and out["qc_last"]["result"] == "accepted"
log = last_log(shop("qc.quinn"), pkt["id"])
assert (log["action"], log["note"]) == ("Q.C. accepted → 09 Hydro", "Quinn QC [Q1] · from 03 Weld Parts")
# ================================================================ reject
def _rejected(shop: Shop, pkt: dict[str, Any], out: dict[str, Any], note: str, return_note: str) -> None:
assert out["status"] == "review" and out["qc"] is None
last = out["qc_last"]
assert last["result"] == "rejected"
assert out["return_note"] == return_note
log = last_log(shop("sup.wes"), pkt["id"])
assert (log["action"], log["note"]) == ("Q.C. rejected → supervisor", note)
assert log["signed"] is True
def test_reject_with_reason_only(shop: Shop) -> None:
pkt = at_qc(shop)
w2 = max(int(w["weld_no"]) for w in pkt["welds"])
ok(qc_weld(shop("qc.quinn"), pkt["id"], w2, result="reject", note="undercut"))
out = ok(reject(shop, pkt, REASON))
_rejected(shop, pkt, out, f"{REASON} · welds W{w2}", REASON)
assert out["qc_last"]["ncr"] is None and out["qc_last"]["rejected_welds"] == [f"W{w2}"]
def test_reject_with_ncr_only(shop: Shop) -> None:
pkt = at_qc(shop)
q = shop("qc.quinn")
refused(reject(shop, pkt, ncr="NCR-26-1"), 422, "invalid_ncr", "Enter an NCR number like NCR-2026-0021.")
refused(reject(shop, pkt), 400, "reason_required")
refused(reject(shop, pkt, "bad"), 400, "reason_required")
assert fresh(q, pkt["id"])["status"] == "qc"
out = ok(reject(shop, pkt, ncr=" ncr-2026-0021 "))
_rejected(shop, pkt, out, NCR, NCR)
assert out["qc_last"]["ncr"] == NCR and out["qc_last"]["rejected_welds"] == []
row = one(
shop.settings, "SELECT ncr FROM qc_inspections WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pkt["id"],)
)
assert row[0] == NCR
assert NCR_URL.endswith("/ncrs/")
def test_reject_with_both_then_send_again(shop: Shop) -> None:
pkt = at_qc(shop)
q = shop("qc.quinn")
w1, w2 = sorted(int(w["weld_no"]) for w in pkt["welds"])
ok(qc_check(q, pkt["id"], "ph_dim"))
ok(qc_weld(q, pkt["id"], w2, result="reject", note="porosity"))
ok(qc_weld(q, pkt["id"], w1, result="reject", note="undercut"))
out = ok(reject(shop, pkt, "Weld 2 undercut", ncr=NCR))
_rejected(shop, pkt, out, f"{NCR} · Weld 2 undercut · welds W{w1}, W{w2}", f"{NCR} · Weld 2 undercut")
first = out["qc_last"]
assert first["ncr"] == NCR and first["rejected_welds"] == [f"W{w1}", f"W{w2}"]
assert first["by"]["display_name"] == "Quinn QC" and first["location"] == "Bay 2"
assert len(out["qc_history"]) == 1
# the supervisor sees it and sends it to Q.C. again: a fresh empty inspection
wes = fresh(shop("sup.wes"), pkt["id"])
assert wes["qc_last"]["result"] == "rejected"
again = to_qc(shop, wes, location="Bay 4")
assert again["qc"]["checks"] == {} and again["qc"]["weld_exam"] == {}
assert again["qc"]["location"] == "Bay 4" and again["qc"]["ncr"] is None
assert len(again["qc_history"]) == 1 and again["qc_history"][0]["ncr"] == NCR
assert again["qc_last"]["id"] == first["id"]
rows = all_rows(shop.settings, "SELECT count(*) FROM qc_inspections WHERE packet_id = %s", (pkt["id"],))
assert rows[0][0] == 2
def test_co_hold_blocks_accept_but_not_reject(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop))
pkt = issue_co(shop, pkt, ["03"])
refused(accept(shop, pkt), 403, "co_hold")
out = ok(reject(shop, fresh(shop("qc.quinn"), pkt["id"]), REASON))
assert out["status"] == "review"
# ================================================================ the Q.C. home
def _ids(cards: list[dict[str, Any]]) -> list[int]:
return [int(c["id"]) for c in cards]
def test_qc_home(shop: Shop) -> None:
q = shop("qc.quinn")
_, unit_a = make_unit(shop.settings)
_, unit_b = make_unit(shop.settings)
waiting = at_qc(shop, unit_a)
to_reject = at_qc(shop, unit_b)
home = ok(q.get("/api/qc/home"))
assert set(home) >= {"kpis", "columns", "folders"}
assert home["kpis"] == {"waiting": 2, "accepted_7d": 0, "rejected_7d": 0}
assert _ids(home["columns"]["ready"]) == [waiting["id"], to_reject["id"]] # oldest status_at first
card = home["columns"]["ready"][0]
assert card["qc"]["location"] == "Bay 2" and card["qc"]["sent_at"]
assert "itp_hold_count" in card
assert home["columns"]["accepted"] == [] and home["columns"]["rejected"] == []
ok(reject(shop, to_reject, ncr=NCR))
accepted = ok(accept(shop, finish_inspection(shop, waiting), "databook"))
home = ok(q.get("/api/qc/home"))
assert home["kpis"] == {"waiting": 0, "accepted_7d": 1, "rejected_7d": 1}
assert _ids(home["columns"]["accepted"]) == [accepted["id"]]
assert _ids(home["columns"]["rejected"]) == [to_reject["id"]]
assert home["columns"]["rejected"][0]["qc"]["result"] == "rejected"
assert home["columns"]["accepted"][0]["qc"]["result"] == "accepted"
# the unit filter narrows the request
narrowed = ok(q.get(f"/api/qc/home?unit_id={unit_b}"))
assert _ids(narrowed["columns"]["rejected"]) == [to_reject["id"]] and narrowed["columns"]["accepted"] == []
# pm.paula reads it; a session is needed
assert ok(shop("pm.paula").get("/api/qc/home"))["kpis"]["accepted_7d"] == 1
assert ok(shop("wh.wade").get("/api/qc/home"))["kpis"]["rejected_7d"] == 1
def test_transition_stale_expect(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop))
ok(reject(shop, pkt, REASON))
r = refused(step(shop("qc.quinn"), pkt, "qc_accept", routed_to="databook", sign=SIGN), 409, "stale_step")
assert r["current"]["status"] == "review"
+334
View File
@@ -0,0 +1,334 @@
"""Phase 6 documents: packet documents for the data book (add, the closed kind set, the file type, the filed lock,
take out with a reason and the row kept) and Q.C. folder documents on a unit or a project (add, the closed type
set, who may add, new revisions that supersede and never delete, the folder reads and counts, the audit texts)."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from pshelpers import needs_pg
from qchelpers import (
DOC_KINDS,
MSG_DOC_KIND,
MSG_TAKE_OUT,
QC_TYPES,
add_packet_doc,
csv_asset,
filed_packet,
folder_project,
folder_unit,
make_qc_doc,
start_itp,
take_out,
)
from shophelpers import (
IT,
Shop,
all_rows,
draft,
fresh,
last_log,
make_unit,
ok,
one,
open_shop,
refused,
run,
)
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def numbers(shop: Shop, unit_id: int) -> tuple[str, str]:
"""(project number, unit number) of a unit."""
row = one(
shop.settings,
"SELECT p.number, p.number || u.lot FROM units u JOIN projects p ON p.id = u.project_id WHERE u.id = %s",
(unit_id,),
)
return str(row[0]), str(row[1])
def add_unit(shop: Shop, project_id: int, lot: str) -> int:
row = one(
shop.settings,
"INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, 'Unit two', 'open') RETURNING id",
(project_id, lot),
)
return int(row[0])
def doc_of(pkt: dict[str, Any], doc_id: int) -> dict[str, Any]:
return next(d for d in pkt["docs"] if int(d["id"]) == doc_id)
# ================================================================ packet documents
def test_packet_doc_add(shop: Shop) -> None:
pkt = draft(shop)
q = shop("qc.quinn")
out = ok(add_packet_doc(q, pkt["id"], "nde", "RT report W1-W4", "RPT-2210, accept", "rt.pdf"))
d = out["docs"][-1]
assert d["kind"] == "nde" and d["kind_label"] == DOC_KINDS["nde"]
assert d["title"] == "RT report W1-W4" and d["note"] == "RPT-2210, accept"
assert d["file_name"] == "rt.pdf" and d["size"] > 0 and d["removed"] is None
log = last_log(q, pkt["id"])
assert (log["action"], log["note"]) == (
"Document added",
"NDE report (RT / UT / MT / PT) · RT report W1-W4 · rt.pdf",
)
# clerk.clara adds one with no title: the title is the kind label
out = ok(add_packet_doc(shop("clerk.clara"), pkt["id"], "hydro", None, None, "hydro.pdf"))
d = out["docs"][-1]
assert d["title"] == "Hydrotest report" and d["kind_label"] == "Hydrotest report"
assert last_log(q, pkt["id"])["note"] == "Hydrotest report · Hydrotest report · hydro.pdf"
# every kind of the closed set is accepted
for kind, label in DOC_KINDS.items():
out = ok(add_packet_doc(q, pkt["id"], kind, f"Doc {kind}"))
assert out["docs"][-1]["kind_label"] == label
assert [d["kind"] for d in out["docs"]][:2] == ["nde", "hydro"] # ordered added_at, id
def test_packet_doc_refusals(shop: Shop) -> None:
pkt = draft(shop)
q = shop("qc.quinn")
refused(add_packet_doc(q, pkt["id"], "xray"), 422, "invalid_kind", MSG_DOC_KIND)
refused(add_packet_doc(q, pkt["id"], asset_id=csv_asset(q)["id"]), 422, "invalid_asset")
refused(add_packet_doc(shop("wh.wade"), pkt["id"]), 403, "forbidden")
refused(add_packet_doc(shop("sup.wes"), pkt["id"]), 403, "forbidden")
assert fresh(q, pkt["id"])["docs"] == []
# over the note limit: refused, or cut to the limit (the contract gives the limit, not the answer)
r = add_packet_doc(q, pkt["id"], note="x" * 301)
assert r.status_code == 422 or len(r.json()["docs"][-1]["note"]) <= 300, r.text
def test_packet_doc_note_limit(shop: Shop) -> None:
pkt = draft(shop)
r = add_packet_doc(shop("qc.quinn"), pkt["id"], note="x" * 300)
if r.status_code == 200:
assert len(r.json()["docs"][-1]["note"]) == 300
else:
assert r.status_code == 422, r.text
def test_packet_doc_filed(shop: Shop) -> None:
pkt = filed_packet(shop)
refused(add_packet_doc(shop("qc.quinn"), pkt["id"]), 403, "filed")
refused(add_packet_doc(shop("clerk.clara"), pkt["id"]), 403, "filed")
def test_packet_doc_take_out(shop: Shop) -> None:
pkt = draft(shop)
q = shop("qc.quinn")
out = ok(add_packet_doc(q, pkt["id"], "nde", "RT report W1-W4", "RPT-2210, accept"))
doc_id = int(out["docs"][-1]["id"])
for reason in (None, "", "ab"):
refused(take_out(q, pkt["id"], doc_id, reason), 400, "reason_required", MSG_TAKE_OUT)
refused(take_out(shop("wh.wade"), pkt["id"], doc_id), 403, "forbidden")
out = ok(take_out(shop("clerk.clara"), pkt["id"], doc_id, "wrong packet"))
d = doc_of(out, doc_id)
assert d["removed"]["reason"] == "wrong packet" and d["removed"]["at"]
assert d["removed"]["by"]["display_name"] == "Clara Clerk"
log = last_log(q, pkt["id"])
assert (log["action"], log["note"]) == ("Document taken out of the data book", "RT report W1-W4 · wrong packet")
refused(take_out(q, pkt["id"], doc_id, "again please"), 409, "already_removed")
row = one(shop.settings, "SELECT removed_reason, removed_by IS NOT NULL FROM packet_docs WHERE id = %s", (doc_id,))
assert tuple(row) == ("wrong packet", True)
# nothing is deleted
assert all_rows(shop.settings, "SELECT count(*) FROM packet_docs WHERE packet_id = %s", (pkt["id"],))[0][0] == 1
def test_qc_folder_docs_on_the_packet(shop: Shop) -> None:
project_id, unit_id = make_unit(shop.settings)
pkt = draft(shop, unit_id)
assert fresh(shop("qc.quinn"), pkt["id"])["qc_folder_docs"] == []
ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP unit one"))
ok(make_qc_doc(shop, project_id=project_id, type="WPS / PQR", title="WPS-101 GTAW/SMAW", rev="2"))
docs = fresh(shop("wh.wade"), pkt["id"])["qc_folder_docs"]
by_scope = {d["scope"]: d for d in docs}
assert set(by_scope) == {"unit", "project"}
assert by_scope["unit"]["type"] == "ITP" and by_scope["unit"]["title"] == "ITP unit one"
assert by_scope["project"]["rev"] == "2" and by_scope["project"]["url"].startswith("/_blob/")
# ================================================================ Q.C. folder documents
def test_qc_doc_add_unit_and_project(shop: Shop) -> None:
project_id, unit_id = make_unit(shop.settings)
pno, uno = numbers(shop, unit_id)
out = ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP unit one", file_name="itp.pdf"))
assert out["unit"]["id"] == unit_id and out["project"]["id"] == project_id
d = out["docs"][0]
assert d["type"] == "ITP" and d["unit_id"] == unit_id and d["project_id"] is None
assert d["file_name"] == "itp.pdf" and d["url"] == f"/_blob/{d['asset_id']}" and d["auto"] is False
assert d["added_by"]["display_name"] == "Quinn QC" and d["superseded_by"] is None
out = ok(
make_qc_doc(
shop, project_id=project_id, type="WPS / PQR", title="WPS-101 GTAW/SMAW", rev="2", file_name="wps.pdf"
)
)
assert out["project"]["id"] == project_id and "units" in out
assert [x["title"] for x in out["docs"]] == ["WPS-101 GTAW/SMAW"]
# the audit rows and their texts
rows = all_rows(
shop.settings, "SELECT action, subject_type, detail FROM audit_events WHERE action LIKE 'qc_doc.%%' ORDER BY id"
)
assert [r[0] for r in rows] == ["qc_doc.add", "qc_doc.add"]
assert rows[0][2]["text"] == "Q.C. document added"
assert rows[0][2]["note"] == f"{uno} · ITP · ITP unit one · itp.pdf"
assert rows[1][2]["note"] == f"{pno} · WPS / PQR · WPS-101 GTAW/SMAW Rev 2 · wps.pdf"
# the title defaults to the type
out = ok(make_qc_doc(shop, unit_id=unit_id, type="NDE procedure"))
assert out["docs"][-1]["title"] == "NDE procedure"
for t in QC_TYPES:
assert ok(make_qc_doc(shop, unit_id=unit_id, type=t))["docs"][-1]["type"] == t
def test_qc_doc_refusals(shop: Shop) -> None:
project_id, unit_id = make_unit(shop.settings)
refused(make_qc_doc(shop, unit_id=unit_id, project_id=project_id), 422, "invalid_request")
refused(make_qc_doc(shop), 422, "invalid_request")
refused(
make_qc_doc(shop, unit_id=unit_id, type="Drawing"), 422, "invalid_type", "Pick a document type from the list."
)
refused(make_qc_doc(shop, unit_id=unit_id, asset_id=csv_asset(shop("qc.quinn"))["id"]), 422, "invalid_asset")
refused(make_qc_doc(shop, unit_id=unit_id, by="wh.wade"), 403, "forbidden")
refused(make_qc_doc(shop, unit_id=unit_id, by="weld.andy"), 403, "forbidden")
refused(make_qc_doc(shop, unit_id=unit_id, by="pm.paula"), 403, "forbidden")
refused(make_qc_doc(shop, unit_id=unit_id, by="admin.ann"), 400, "it_reason_required")
assert folder_unit(shop("qc.quinn"), unit_id)["docs"] == []
# who may add: Clerical, Q.C., and an admin with an IT reason
ok(make_qc_doc(shop, unit_id=unit_id, by="clerk.clara"))
ok(make_qc_doc(shop, unit_id=unit_id, by="multi.mia"))
out = ok(make_qc_doc(shop, unit_id=unit_id, by="admin.ann", it_reason=IT))
assert len(out["docs"]) == 3
row = one(shop.settings, "SELECT it_reason FROM audit_events WHERE action = 'qc_doc.add' ORDER BY id DESC LIMIT 1")
assert row[0] == IT
# over the limits (title 140, rev 12): refused, or cut to the limit
for field, n in (("title", 140), ("rev", 12)):
r = make_qc_doc(shop, unit_id=unit_id, **{field: "x" * (n + 1)})
assert r.status_code == 422 or len(r.json()["docs"][-1][field]) <= n, (field, r.text)
def test_qc_doc_new_revision(shop: Shop) -> None:
project_id, unit_id = make_unit(shop.settings)
other_unit = add_unit(shop, project_id, "002")
uno = numbers(shop, unit_id)[1]
first = ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP L1", rev="0"))["docs"][0]
# a revision from another folder or of a missing row is refused
refused(make_qc_doc(shop, unit_id=other_unit, replaces=first["id"]), 422, "invalid_replace")
refused(make_qc_doc(shop, project_id=project_id, replaces=first["id"]), 422, "invalid_replace")
refused(make_qc_doc(shop, unit_id=unit_id, replaces=999999), 422, "invalid_replace")
out = ok(
make_qc_doc(
shop, unit_id=unit_id, type="ITP", title="ITP L1", rev="1", replaces=first["id"], file_name="itp1.pdf"
)
)
assert [d["rev"] for d in out["docs"]] == ["1"]
assert [d["id"] for d in out["superseded"]] == [first["id"]]
old = out["superseded"][0]
new_id = out["docs"][0]["id"]
assert old["superseded_by"] == new_id and old["superseded_at"]
assert old["superseded_by_user"]["display_name"] == "Quinn QC"
# a second revision of the same old row
refused(make_qc_doc(shop, unit_id=unit_id, rev="1b", replaces=first["id"]), 409, "already_superseded")
# the new one can be revised again; superseded is newest first
out = ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP L1", rev="2", replaces=new_id, by="clerk.clara"))
assert [d["rev"] for d in out["docs"]] == ["2"]
assert [d["rev"] for d in out["superseded"]] == ["1", "0"]
# nothing is ever deleted
assert all_rows(shop.settings, "SELECT count(*) FROM qc_docs WHERE unit_id = %s", (unit_id,))[0][0] == 3
rows = all_rows(shop.settings, "SELECT action, detail FROM audit_events WHERE action LIKE 'qc_doc.%%' ORDER BY id")
assert [r[0] for r in rows] == ["qc_doc.add", "qc_doc.revise", "qc_doc.revise"]
assert rows[1][1]["text"] == "Q.C. document revised"
assert rows[1][1]["note"] == f"{uno} · ITP · ITP L1 Rev 1 · itp1.pdf"
def test_qc_folders_and_counts(shop: Shop) -> None:
q = shop("qc.quinn")
project_id, unit_1 = make_unit(shop.settings)
unit_2 = add_unit(shop, project_id, "002")
closed_id, _ = make_unit(shop.settings)
run(shop.settings, "UPDATE projects SET status = 'closed' WHERE id = %s", (closed_id,))
def row(show: str, pid: int) -> dict[str, Any] | None:
rows = ok(q.get(f"/api/qc/folders?show={show}"))
return next((r for r in rows if r["id"] == pid), None)
r = row("active", project_id)
assert r is not None and r["units"] == 2 and r["docs"] == 0 and r["units_without_itp"] == 2
assert {"number", "name", "customer", "po", "status"} <= set(r)
assert row("active", closed_id) is None
closed = row("all", closed_id)
assert closed is not None and closed["units_without_itp"] == 0 and closed["status"] == "closed"
# an ITP document on unit 1, then a digital ITP on unit 2
ok(make_qc_doc(shop, unit_id=unit_1, type="ITP"))
r = row("active", project_id)
assert r is not None and r["units_without_itp"] == 1 and r["docs"] == 1
start_itp(shop, unit_2)
r = row("active", project_id)
assert r is not None and r["units_without_itp"] == 0
# a project document counts too; a WPS is not an ITP
ok(make_qc_doc(shop, project_id=project_id, type="WPS / PQR"))
r = row("active", project_id)
assert r is not None and r["docs"] == 2 and r["units_without_itp"] == 0
# the home lists the active folders
home = ok(q.get("/api/qc/home"))
assert any(f["id"] == project_id for f in home["folders"])
assert not any(f["id"] == closed_id for f in home["folders"])
numbers_in_order = [f["number"] for f in ok(q.get("/api/qc/folders?show=all"))]
assert numbers_in_order == sorted(numbers_in_order)
def test_project_itp_document_covers_every_unit(shop: Shop) -> None:
project_id, unit_1 = make_unit(shop.settings)
add_unit(shop, project_id, "002")
ok(make_qc_doc(shop, project_id=project_id, type="ITP", title="Project ITP"))
rows = ok(shop("qc.quinn").get("/api/qc/folders?show=active"))
r = next(x for x in rows if x["id"] == project_id)
assert r["units_without_itp"] == 0
assert folder_unit(shop("qc.quinn"), unit_1)["no_itp"] is False
def test_project_and_unit_folder_reads(shop: Shop) -> None:
q = shop("qc.quinn")
project_id, unit_1 = make_unit(shop.settings)
unit_2 = add_unit(shop, project_id, "002")
pno, uno = numbers(shop, unit_1)
ok(make_qc_doc(shop, project_id=project_id, type="WPS / PQR", title="WPS-101", rev="2"))
ok(make_qc_doc(shop, unit_id=unit_1, type="Customer spec", title="Spec A"))
itp = start_itp(shop, unit_2)
pf = folder_project(q, project_id)
assert pf["project"]["number"] == pno and pf["project"]["customer"] == "Acme Refining"
assert [d["title"] for d in pf["docs"]] == ["WPS-101"] and pf["superseded"] == []
units = {u["id"]: u for u in pf["units"]}
assert set(units) == {unit_1, unit_2}
assert units[unit_1]["number"] == uno and units[unit_1]["docs"] == 1 and units[unit_1]["itp"] is None
assert units[unit_1]["has_itp_doc"] is False
assert units[unit_2]["itp"]["id"] == itp["id"] and units[unit_2]["itp"]["no"] == itp["no"]
assert units[unit_2]["itp"]["total"] == 70 and units[unit_2]["itp"]["done"] == 0
uf = folder_unit(q, unit_1)
assert uf["unit"]["number"] == uno and uf["project"]["id"] == project_id
assert [d["title"] for d in uf["docs"]] == ["Spec A"]
assert [d["title"] for d in uf["inherited"]] == ["WPS-101"]
assert uf["itp"] is None and uf["no_itp"] is True
uf2 = folder_unit(q, unit_2)
assert uf2["itp"]["status"] == "open" and uf2["no_itp"] is False
# any session reads; unknown ids are 404
assert shop("wh.wade").get(f"/api/qc/folders/units/{unit_1}").status_code == 200
assert q.get("/api/qc/folders/units/999999").status_code == 404
assert q.get("/api/qc/folders/projects/999999").status_code == 404
+86 -4
View File
@@ -43,7 +43,7 @@ ENUMS = {
"lookup_failed", "lookup_failed",
"hand", "hand",
], ],
"itp_status": ["open", "signed", "reopened"], "itp_status": ["open", "signed", "reopened", "complete"],
} }
# One minimal INSERT per append-only table, with ids from seed(). # One minimal INSERT per append-only table, with ids from seed().
@@ -63,6 +63,7 @@ APPEND_INSERTS = {
"INSERT INTO packet_log (packet_id, action, by) VALUES (%(packet)s, 'Packet created by hand', %(user)s)" "INSERT INTO packet_log (packet_id, action, by) VALUES (%(packet)s, 'Packet created by hand', %(user)s)"
), ),
"itp_rejects": "INSERT INTO itp_rejects (itp_id, line_key, ncr) VALUES (%(itp)s, 'k1', 'NCR-1')", "itp_rejects": "INSERT INTO itp_rejects (itp_id, line_key, ncr) VALUES (%(itp)s, 'k1', 'NCR-1')",
"itp_log": "INSERT INTO itp_log (itp_id, action, by) VALUES (%(itp)s, 'ITP started', %(user)s)",
"co_verifications": ( "co_verifications": (
"INSERT INTO co_verifications (co_id, dept_code, by, signature_id) VALUES (%(co)s, 'WH', %(user)s, %(sig)s)" "INSERT INTO co_verifications (co_id, dept_code, by, signature_id) VALUES (%(co)s, 'WH', %(user)s, %(sig)s)"
), ),
@@ -347,9 +348,8 @@ def test_app_can_write_every_table_and_read_every_view(pg_settings: Settings) ->
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by) " "INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by) "
"VALUES (%(packet)s, '\\x01', 'abcd1234', %(user)s)", "VALUES (%(packet)s, '\\x01', 'abcd1234', %(user)s)",
"INSERT INTO packet_visual_state (packet_id) VALUES (%(packet)s)", "INSERT INTO packet_visual_state (packet_id) VALUES (%(packet)s)",
"INSERT INTO packet_docs (packet_id, asset_id, kind, title) VALUES (%(packet)s, %(asset)s, 'mtr', 'MTR')", "INSERT INTO packet_docs (packet_id, asset_id, kind, title) VALUES (%(packet)s, %(asset)s, 'nde', 'RT')",
"INSERT INTO qc_docs (project_id, unit_id, type, asset_id) " "INSERT INTO qc_docs (unit_id, type, asset_id) VALUES (%(unit)s, 'WPS / PQR', %(asset)s)",
"VALUES (%(project)s, %(unit)s, 'wps', %(asset)s)",
"INSERT INTO itp_lines (itp_id, key, result, by) VALUES (%(itp)s, 'k1', 'approve', %(user)s)", "INSERT INTO itp_lines (itp_id, key, result, by) VALUES (%(itp)s, 'k1', 'approve', %(user)s)",
"INSERT INTO itp_blocks (itp_id, block_key, signed_by, signature_id) " "INSERT INTO itp_blocks (itp_id, block_key, signed_by, signature_id) "
"VALUES (%(itp)s, 'b1', %(user)s, %(sig)s)", "VALUES (%(itp)s, 'b1', %(user)s, %(sig)s)",
@@ -527,3 +527,85 @@ def test_shop_columns_and_checklist_templates(pg_settings: Settings) -> None:
assert [i["key"] for i in value["weld"]] == ["dwg", "heat", "wps", "fit", "pre", "vt", "stamp", "clean", "qty"] assert [i["key"] for i in value["weld"]] == ["dwg", "heat", "wps", "fit", "pre", "vt", "stamp", "clean", "qty"]
assert [i["key"] for i in value["plain"]] == ["dwg", "prev", "mat", "work", "insp", "qty"] assert [i["key"] for i in value["plain"]] == ["dwg", "prev", "mat", "work", "insp", "qty"]
assert value["weld"][0]["text"] == "Drawing {drawing} Rev {rev} matches the packet" assert value["weld"][0]["text"] == "Drawing {drawing} Rev {rev} matches the packet"
def test_qc_columns_checks_and_items(pg_settings: Settings) -> None:
"""0007: the inspection NCR, document notes and kinds, Q.C. folder scope and types, the ITP details, the ITP log
and the Q.C. check items row."""
with db.connect_as("migrate", pg_settings, autocommit=True) as conn:
names = [r[0] for r in conn.execute("SELECT name FROM schema_migrations ORDER BY name").fetchall()]
assert "0007_qc.sql" in names
with app_conn(pg_settings) as conn:
for t, c, kind in (
("qc_inspections", "ncr", "text"),
("packet_docs", "note", "text"),
("qc_docs", "title", "text"),
("qc_docs", "note", "text"),
("qc_docs", "superseded_at", "timestamp with time zone"),
("qc_docs", "superseded_by_user", "uuid"),
("itps", "no", "text"),
("itps", "hdr", "jsonb"),
("itps", "created_at", "timestamp with time zone"),
("itps", "created_by", "uuid"),
("itp_lines", "initials", "text"),
("itp_lines", "cust_code", "text"),
("itp_lines", "cust_by", "text"),
("itp_lines", "cust_at", "date"),
("itp_lines", "cust_rec", "uuid"),
("itp_lines", "cust_rec_at", "timestamp with time zone"),
("itp_rejects", "kind", "text"),
("itp_blocks", "qn", "text"),
("itp_blocks", "remarks", "text"),
("itp_blocks", "cust_name", "text"),
("itp_blocks", "cust_at", "date"),
("itp_blocks", "cust_by", "uuid"),
("itp_blocks", "cust_rec_at", "timestamp with time zone"),
("itp_blocks", "row_version", "integer"),
("itp_log", "it_reason", "text"),
):
got = one(
conn,
"SELECT data_type FROM information_schema.columns WHERE table_name = %s AND column_name = %s",
(t, c),
)
assert got == kind, (t, c)
ids = seed(conn)
ids["asset"] = one(
conn,
"INSERT INTO assets (sha256, size, mime, storage_key, pages) VALUES ('\\x00', 10, 'application/pdf',"
" 'ab/q7', 1) RETURNING id",
)
with pytest.raises(psycopg.errors.CheckViolation), conn.transaction():
conn.execute(
"INSERT INTO packet_docs (packet_id, asset_id, kind) VALUES (%(packet)s, %(asset)s, 'mtr')", ids
)
with pytest.raises(psycopg.errors.CheckViolation), conn.transaction():
conn.execute(
"INSERT INTO qc_docs (project_id, unit_id, type, asset_id) VALUES (%(project)s, %(unit)s, 'ITP',"
" %(asset)s)",
ids,
)
with pytest.raises(psycopg.errors.CheckViolation), conn.transaction():
conn.execute("INSERT INTO qc_docs (unit_id, type, asset_id) VALUES (%(unit)s, 'wps', %(asset)s)", ids)
conn.execute("INSERT INTO qc_docs (project_id, type, asset_id) VALUES (%(project)s, 'ITP', %(asset)s)", ids)
# A reject may come before its NCR number; a block row exists before it is signed.
conn.execute("INSERT INTO itp_rejects (itp_id, line_key) VALUES (%(itp)s, '1.1')", ids)
assert one(conn, "SELECT kind FROM itp_rejects WHERE itp_id = %(itp)s", ids) == "reject"
conn.execute("INSERT INTO itp_blocks (itp_id, block_key) VALUES (%(itp)s, '3')", ids)
assert one(conn, "SELECT signed_at FROM itp_blocks WHERE itp_id = %(itp)s", ids) is None
conn.execute("UPDATE itps SET status = 'complete' WHERE id = %(itp)s", ids)
value = one(conn, "SELECT value FROM settings WHERE key = 'qc_check_items'")
assert [g["group"] for g in value["groups"]] == ["Data book package", "Physical spool"]
keys = [i["key"] for g in value["groups"] for i in g["items"]]
assert keys == [
"db_heats",
"db_mtr",
"db_weldmap",
"db_wps",
"db_ck",
"ph_dim",
"ph_heat",
"ph_stamp",
"ph_clean",
"ph_qty",
]