Phase 7: close, file, PDFs and prints
ci / frontend (push) Successful in 40s
ci / server (push) Successful in 10m56s
ci / image (push) Successful in 48s
ci / e2e (push) Successful in 2m44s

Clerical close and file: the VISUAL observed state beside the close confirm, a 409 with a second confirm when the
feed still shows work orders open, filing with a signature and the HMAC seal, the data book built on the server
and stored on the seal in the filing transaction, admin reopen keeping the broken seal with its data book.

Server PDFs with reportlab, pypdf and pypdfium2: the manufacturing data book page for page after the old build
(cover, index, drawings with VOID stamps, traceability and heat map rasters, weld maps and welding records, Q.C.
inspections, Q.C. and test documents, MTRs, record history), the job traveler with work order QR codes, the ITP
PDF filed automatically at sign-off, every print kind with the PRINTED COPY stamp, the controlled-copy stamp
signed with the seal key, PDF metadata, the traceability CSV with the formula guard, and the append-only
print_log with an audit row for every print and export (migration 0008).

Frontend: the close and file panel, the print menu and its entry points, the printed copies list on the History
tab, the ITP PDF buttons. Tests: server (close, file, reopen, seal, the builders, prints, CSV, stamps), frontend,
and the Playwright close and print scenarios. Docs updated; SECURITY closes M2, S6, S7 and S8.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-17 23:49:14 -06:00
co-authored by Claude Fable 5.1
parent 00531cb329
commit 904437153d
59 changed files with 8226 additions and 136 deletions
+14
View File
@@ -11,6 +11,20 @@ All notable changes to this project are recorded here. The format follows [Keep
### Added ### Added
- Phase 7: close, file, PDFs and prints. The "Close and file" panel on the packet page from status complete on: "Back from the shop, ready to close." with what the feed last saw in VISUAL (the job closed since a date, the work orders still open, or not checked yet), Job closed in VISUAL and Not closed in VISUAL, move back; "Ready for the data book." with File in the data book (PIN signature); "Filed in the data book" with the seal line (Seal intact, Seal does not match the record, or Seal unknown) and Reopen (IT). The packet read carries `vclose_check` (the feed's observed and closed times, the open work orders as `wo_base/lot` text, and whether it agrees with the Clerical confirm). Confirming the close while the feed shows open work orders adds them to the log note.
- Filing past open work orders: when the feed still shows a work order open in VISUAL, filing answers 409 `open_work_orders` with the list and the screen asks "File with open work orders?" before it sends `confirm_open_wos`. A warning, not a block, on both routes; checked before the signature, so a cancelled confirm costs no PIN attempt. The seal keeps the work orders and the log note says "Filed with work orders open in VISUAL: ...". A hand packet has no warning.
- The data book filed at filing: built on the server in the filing transaction, stored as an asset on the seal with its page count, logged as "Data book filed · N pages". A data book that cannot be built fails the filing (500 `databook_failed`, nothing filed); a part that cannot be read becomes a warning row in the index. Page for page after the old build: the cover (title, figures, equipment and design data, certification, signature lines), the index with page numbers, then 1 Drawings (live sheets at their own size, superseded sheets stamped VOID), 2 Material traceability (BOM and heat numbers, shorts and receipts, open items, the heat map rasters), 3 Welding record (the weld map rasters, a record per stage with the weld log and the station checklist), 4 Q.C. inspection (one set per inspection with checks, visual weld examination, NDE and testing, earlier inspections), 5 Q.C. and test documents (the unit's and project's Q.C. folder documents, ITP first, and the packet documents), 6 Material test reports, 7 Record history (change orders and every packet log row). Before filing, the data book can be viewed or downloaded from status complete on, stamped "not yet filed".
- The seal and reopen: the seal is HMAC-SHA256 with the server seal key. A reopen by IT (reason and signature) marks the seal broken with who, when and why, and keeps it with its data book; filing again writes a second seal. The packet read shows the newest seal with its data book (`seal.databook`) and `seals`, the count. Migration 0008 adds `databook_asset_id`, `databook_pages` and `open_wos` to `packet_seals`.
- The job traveler with QR codes: job information, a QR box per work order for clocking on at the labor kiosk (`*%2{wo_base}${lot}${split}${sub}${seq}%*`, the ID parts as text), the BOM and heat numbers with blanks to fill, the checklist with blanks to stamp, the weld log, the weld record, the sign-offs and a shop notes box. The Work orders tab shows the QR text and links to the traveler.
- The ITP PDF: one run of pages per page group (Weld Parts, Code, Final Assembly) with the header, the codes legend, each section and its items, the block tables and the sign-off line. The ITP sign-off now builds and files the signed PDF and the automatic `{no} — signed off` folder document in the same transaction (500 `itp_pdf_failed` when it cannot, nothing signed off); the ITP page has ITP (PDF), Download, Print and Signed PDF.
- The traceability CSV as the old export (header rows, BOM with shorts and receipts, welds with the heats they join, the checklist, the Q.C. inspection), with the formula guard: a cell that starts with `=`, `+`, `-`, `@`, a tab or a CR gets a leading `'`; quotes doubled; CRLF; UTF-8 without a BOM.
- Prints: the Print menu (Job traveler, Drawing with heat map, Drawing with weld map, Drawing (no markup), Superseded drawings (VOID), Data book) in the packet header and the close panel, Print this sheet on the drawing and map tabs, Print per MTR and per document, Print traveler on the Work orders tab. A print opens as a PDF in a new tab with "PRINTED COPY - {what} - printed by Name (emp) {UTC time} - {project} Lot {unit} dwg {drawing} R{rev} - verify revision before use" on every page. The old "Original packet PDF" kind is gone.
- The print log: every print and export writes an append-only `print_log` row (who, when, what, how many pages, the stamp and its signature) and an audit row `print` or `export`. The History tab lists them under "Printed copies and exports".
- The controlled-copy stamp on every exported PDF ("Controlled copy - exported by Name (emp) {UTC time}", the seal's first 12 hex or "not yet filed") and on prints carries a 16-hex signature made with the seal key, so a copy can be checked against the server later (`verify_stamp`; the Admin screen comes in phase 9). PDF metadata: title, subject, keywords with the stamp signature, Author PESCO, Producer Packet Station. Every text in a PDF is cleaned and escaped.
- New endpoints: `GET /api/packets/{id}/pdf/databook`, `GET /api/packets/{id}/pdf/traveler`, `GET /api/packets/{id}/export/traceability.csv`, `GET /api/packets/{id}/print/{kind}` with `?sheet=` and `?asset=`, `GET /api/packets/{id}/prints`, `GET /api/itps/{id}/pdf`, `GET /api/itps/{id}/print`; `confirm_open_wos` on the file transition. Every role that can open the packet may read them, PM included. New dependencies: reportlab 5.0.1 and Pillow 13.0.0.
- Tests: close and file as every role and status against the policy oracle, the open work order confirm, the filed seal with its data book, reopen and refile, a failed data book build, the data book sections and index, VOID pages, warning rows, stamps and metadata, the traveler, the ITP PDF and the sign-off filing, every print kind and the print log, the CSV formula guard, the stamp signature; frontend tests for the close and file panel, the print menu, the prints list and the ITP buttons; a Playwright scenario for close, the PDFs and CSV, prints, filing with the seal, reopen and refile, and the signed ITP PDF.
- docs/SCHEMA.md, POLICY.md and SECURITY.md updated (M2, S6, S7 and S8 closed); PLAN.md open questions 32 to 37.
- 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). - 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. 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. - 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.
+6 -4
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 6 done: Q.C. inspection, accept and reject, Q.C. project folders, packet documents and the digital ITP.** **Rebuild in progress. Phase 7 done: close in VISUAL, file with the HMAC seal and the data book, reopen, the server-built PDFs (data book, traveler, ITP), the traceability CSV, and stamped prints with the print log.**
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:. 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. Clerical confirms the job closed in VISUAL (the feed's closed state and any open work orders shown beside it) and files the packet with a signature. Filing seals the packet with an HMAC and builds the manufacturing data book on the server, stored on the seal; filing past open work orders needs a second confirmation. IT can reopen a filed packet with a reason and a signature, which keeps the broken seal and its data book. Anyone who can open a packet can view or download the data book, the job traveler with the work order QR codes, the ITP PDF and the traceability CSV, and print a stamped copy of the traveler, the drawings with the heat or weld map, the drawings without markup, the VOID sheets, the data book or any document. Every copy carries a stamp signed with the seal key, and every print and export is logged. The ITP sign-off files the signed ITP PDF. 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? |
|---|---| |---|---|
@@ -20,7 +20,7 @@ People can sign in, set up projects and units, make hand drafts, attach a drawin
| 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. screen, inspection, Q.C. folders, packet documents, digital ITP, hold-point warnings | Yes (phase 6, verified in the CI stack on docker-1) | | 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 | | Close and file, HMAC seal with the data book, reopen, data book, traveler and ITP PDFs, traceability CSV, prints and the print log | Yes (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) |
@@ -87,6 +87,8 @@ Phase 3 adds the worker (`packetstation worker`: APScheduler, the `visual-feed`,
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)". 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)".
Phase 7 adds the close and file panel, `confirm_open_wos` on the file transition (409 `open_work_orders` without it), the data book built and stored on the seal at filing, `GET /api/packets/{id}/pdf/databook`, `GET /api/packets/{id}/pdf/traveler`, `GET /api/packets/{id}/export/traceability.csv`, `GET /api/packets/{id}/print/{kind}` (`traveler`, `map-h`, `map-w`, `clean`, `void`, `databook`, `doc`), `GET /api/packets/{id}/prints`, `GET /api/itps/{id}/pdf` and `GET /api/itps/{id}/print`, and the `print_log` table (migration 0008). The PDFs are built on the server with reportlab 5.0.1, pypdf and pypdfium2; the heat and weld map rasters use Pillow 13.0.0. Both are pinned in `pyproject.toml` and `server/requirements.txt`, and need no system package or font in the image. See [docs/POLICY.md](docs/POLICY.md) "Phase 7: close, file, PDFs and prints".
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
@@ -106,7 +108,7 @@ Server tests that need PostgreSQL run when `TEST_PG_SUPERUSER_URL` is set; the o
- [docs/DEPLOY.md](docs/DEPLOY.md): host, stacks, secrets, NPM proxy host, doctor, backups. - [docs/DEPLOY.md](docs/DEPLOY.md): host, stacks, secrets, NPM proxy host, doctor, backups.
- [docs/FEED.md](docs/FEED.md): the worker, the VISUAL feed, the packet rules, the fingerprint, the drawing decision table, the flags and the feed settings. - [docs/FEED.md](docs/FEED.md): the worker, the VISUAL feed, the packet rules, the fingerprint, the drawing decision table, the flags and the feed settings.
- [docs/POLICY.md](docs/POLICY.md): who may do what, the preconditions, the order of checks, concurrency, and the tightenings against the old build. - [docs/POLICY.md](docs/POLICY.md): who may do what, the preconditions, the order of checks, concurrency, and the tightenings against the old build.
- [docs/SCHEMA.md](docs/SCHEMA.md): the database schema (phases 1 to 3). - [docs/SCHEMA.md](docs/SCHEMA.md): the database schema (phases 1 to 7).
- [docs/GO-LIVE.md](docs/GO-LIVE.md): go-live procedure (phase 10). - [docs/GO-LIVE.md](docs/GO-LIVE.md): go-live procedure (phase 10).
- [docs/PAPER-FALLBACK.md](docs/PAPER-FALLBACK.md): what the shop does when the server is down (phase 10). - [docs/PAPER-FALLBACK.md](docs/PAPER-FALLBACK.md): what the shop does when the server is down (phase 10).
- [CHANGELOG.md](CHANGELOG.md): what changed. - [CHANGELOG.md](CHANGELOG.md): what changed.
+13 -3
View File
@@ -216,7 +216,7 @@ Conventions: identity bigints except users (uuid = AD objectGUID); Postgres enum
Need an answer before the named phase. Defaults apply if none arrives in time. Need an answer before the named phase. Defaults apply if none arrives in time.
1. **Station timer** (Seth, before phase 5): keep, cross-check against labor tickets, or drop. Default: keep the timer, show ticket hours beside it. Phase 5 (2026-10-16) applied the default: the station timer is the clock of record and the VISUAL ticket hours are shown beside it. 1. **Station timer** (Seth, before phase 5): keep, cross-check against labor tickets, or drop. Default: keep the timer, show ticket hours beside it. Phase 5 (2026-10-16) applied the default: the station timer is the clock of record and the VISUAL ticket hours are shown beside it.
2. **PDFs on the server** (confirm before phase 7): reportlab + pypdf + pypdfium2. Default: yes. 2. **PDFs on the server** (confirm before phase 7): reportlab + pypdf + pypdfium2. Default: yes. Phase 7 (2026-10-17) applied the default: reportlab 5.0.1, pypdf and pypdfium2, with Pillow 13.0.0 for the rasters.
3. **Frontend stack** (confirm before phase 1): TypeScript + React + Vite. Default: yes. Plain JavaScript would not be faster for parity: the old 7,000 lines are screen code that must be rewritten either way, and typed API models cut rework. 3. **Frontend stack** (confirm before phase 1): TypeScript + React + Vite. Default: yes. Plain JavaScript would not be faster for parity: the old 7,000 lines are screen code that must be rewritten either way, and typed API models cut rework.
4. **PIN defaults** (confirm): 6 to 12 digits, five attempts, 10-hour shop session, device cookie. Genuinely open (Seth and Q.C., before phase 6): whether Q.C. accept and filing require the domain password even with a PIN set. Default: PIN is enough. IT: device certificate later. 4. **PIN defaults** (confirm): 6 to 12 digits, five attempts, 10-hour shop session, device cookie. Genuinely open (Seth and Q.C., before phase 6): whether Q.C. accept and filing require the domain password even with a PIN set. Default: PIN is enough. IT: device certificate later.
5. **Several open orders on a project** (before phase 3): which order a packet shows. Proposed rule: the open order whose line's top job is the packet's work order base, else the newest open order, else the newest closed one; the packet shows "via base" or "via project" and Clerical can override. Needs Seth's view. Phase 3 (2026-10-16) built the default in one function (`feed/orders.py`): a Clerical pin, else the base's own linked order, else the project order whose top job is the unit, else the project's first open order, else its newest; the source (`via`) is stored on the project and unit and shown on the project page, which lists every open order. Seth may change the rule later in that one place. 5. **Several open orders on a project** (before phase 3): which order a packet shows. Proposed rule: the open order whose line's top job is the packet's work order base, else the newest open order, else the newest closed one; the packet shows "via base" or "via project" and Clerical can override. Needs Seth's view. Phase 3 (2026-10-16) built the default in one function (`feed/orders.py`): a Clerical pin, else the base's own linked order, else the project order whose top job is the unit, else the project's first open order, else its newest; the source (`via`) is stored on the project and unit and shown on the project page, which lists every open order. Seth may change the rule later in that one place.
@@ -257,12 +257,22 @@ From phase 6 (2026-10-17), for Seth and Q.C. to veto:
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. 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. 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. 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. 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. Phase 7 (2026-10-17) built the hook: the sign-off files the signed ITP PDF and the automatic ITP document in the same transaction, and a PDF that cannot be built fails the sign-off.
From phase 7 (2026-10-17), for Seth and Clerical to veto:
32. Prints: the old build pushed page images into the browser's print dialog and offered the same PDF when the dialog was blocked. Default: a print opens as a stamped PDF in a new browser tab, and the person prints it from the PDF viewer (Ctrl+P). One path is simpler and prints the same stamped pages. Phase 7 (2026-10-17) applied the default.
33. When the data book is built: default on the server when the packet is filed, inside the filing transaction, stored as an asset on the seal. A data book that cannot be built fails the filing, so nothing is filed half way. Before filing, the data book can be viewed or downloaded at any time from status complete on; that copy is stamped "not yet filed". Phase 7 (2026-10-17) applied the default.
34. Who may read the PDFs and the CSV: default every signed-in role that can open a packet, PM included. Every print and every export is logged with who, when, what and how many pages, in the append-only `print_log` and in the audit. Phase 7 (2026-10-17) applied the default.
35. The old "Original packet PDF" print kind: the rebuild has no VISUAL packet PDF. The drawing pages come from M-Files or a hand upload, and "Drawing (no markup)" prints them as attached. Default: the kind is gone. Phase 7 (2026-10-17) applied the default.
36. Filing while the feed still shows a work order open in VISUAL: default a second confirmation (`confirm_open_wos`), a warning and not a block, on both routes. The work orders are kept on the seal and in the log note. A hand packet has no feed state and no warning. Phase 7 (2026-10-17) applied the default.
37. The controlled-copy stamp carries a short signature made with the seal key, so a printed or exported copy can be checked against the server later. Default: phase 7 ships the signature, the check function and the print log; phase 9 adds the Admin screen that checks a copy. Phase 7 (2026-10-17) applied the default.
## 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.
- Phase 3: a recorded DEV feed run compared with the packets the old build made for the same window (counts of drafts, units, projects; same keys), run from the developer machine against VISUAL DEV only. - Phase 3: a recorded DEV feed run compared with the packets the old build made for the same window (counts of drafts, units, projects; same keys), run from the developer machine against VISUAL DEV only.
- Phase 7: the data book of a synthetic filed packet compared page by page with a data book exported from the old build on the same fixture. - Phase 7: server tests for close and file against the policy oracle, the open work order confirm, the seal with its data book, reopen and refile, a failed data book build, the data book sections and index, the traveler, the ITP PDF, every print kind with its stamp and `print_log` row, the CSV formula guard and the stamp signature; frontend tests for the close and file panel, the print menu, the prints list and the ITP buttons; the Playwright close scenario (close, PDFs and CSV, prints, file with the seal, reopen and refile, the signed ITP PDF) in the CI stack on docker-1.
- Phase 8: the data book of a synthetic filed packet compared page by page with a data book exported from the old build on the same fixture (moved from phase 7).
- Phase 9: restore rehearsal on the test stack from a pg_dump and an assets copy. - Phase 9: restore rehearsal on the test stack from a pg_dump and an assets copy.
- Phase 10: the full 38-scenario bench green in Gitea Actions. - Phase 10: the full 38-scenario bench green in Gitea Actions.
+62 -3
View File
@@ -211,7 +211,7 @@ The request carries `sign: {factor, secret}`. The write pipeline calls the phase
- **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".
- **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. - **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. - **Append-only tables** never conflict: packet_transitions, packet_log, qc_history, short_receipts, project_renumbers, itp_rejects, itp_log, print_log and co_verifications. Triggers refuse UPDATE, DELETE and TRUNCATE, and the app role has no grant for them.
## Reads: can, blocked and holder ## Reads: can, blocked and holder
@@ -556,7 +556,7 @@ The itp_edit_line writes:
- 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. - 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. - 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. A write to a complete ITP, other than the reopen, is 409 `itp_complete` "The ITP is signed off and locked. An admin can reopen it." The sign-off sets status `complete` and calls `pdf.itp.file_signed_itp_pdf()`, which builds the signed ITP PDF (phase 7). The sign-off sets `signoff_asset_id` and files a `qc_docs` row on the unit (type `ITP`, `auto`), superseding any earlier automatic ITP document of the unit. A PDF that cannot be built answers 500 `itp_pdf_failed` and nothing is signed off. A reopen sets status `open` with the reason. Block and line signatures stay; the inspector undoes what must change.
ITP actions never look at a packet. A filed packet in the unit does not lock the ITP, because the ITP belongs to the unit (the phase 5 follow-up, open question 28). 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).
@@ -618,11 +618,70 @@ The `action` and `note` of `itp_log` (and the audit row's `detail.text`) for eac
| Block signed | `ITP block 3 signed` | `Sections 1–3` or `Section 4` | | Block signed | `ITP block 3 signed` | `Sections 1–3` or `Section 4` |
| Block unsigned | `ITP block 3 signature removed` | | | 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) | | 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` | | Sign-off | `ITP signed off` | `PDF filed` |
| Reopen | `ITP reopened` | the reason | | 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`. 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`.
## Phase 7: close, file, PDFs and prints
Nothing in the rule table changes in phase 7. FILE, ADMIN_REOPEN, SET_VCLOSED and SET_DATABOOK_AT keep their rows.
### Close in VISUAL
SET_VCLOSED is Clerical, in status complete, only when the packet is routed to the data book (`POST /api/packets/{id}/vclosed` `{closed}`). Confirming sets `vclosed_at` and `vclosed_by`. Taking it back clears both ("Not closed in VISUAL, move back"). A packet routed to Shipping files without it.
The packet read carries `vclose_check`, outside the seal. It is null for a hand packet. For a feed packet: `observed_at` and `closed_at` from the feed's VISUAL state, `open_wos` (the legs whose status is not `C` and not cancelled, as `wo_base/lot` text, never cast), and `agrees` (true when the Clerical confirm and the feed's closed state say the same). The feed state is a hint. Clerical's confirm is the record, and the precondition `vclosed_if_databook` reads only the confirm.
### Filing
FILE is Clerical, from complete, held by an open change order, signed ("File in the data book"), with the precondition `vclosed_if_databook` ("Close the job in VISUAL first.").
When `vclose_check.open_wos` is not empty, the transition must carry `confirm_open_wos: true`. Without it the answer is 409 `open_work_orders` "VISUAL still shows 2 work orders open on this packet: L489052/1, L489052/2. File anyway?" with `work_orders` in the body. It is a warning, not a block, on both routes. The check runs after the policy and before the signature, so a cancelled confirm costs no PIN attempt. With the confirm, the seal keeps the work orders in `open_wos` and the log note says so. A hand packet has no feed state and no warning.
In one transaction, filing sets status filed and `databook_at`, writes the seal, builds the data book, stores it as an asset on the seal, and writes the log and audit rows. A data book that cannot be built answers 500 `databook_failed` "The data book could not be built: <reason>. Nothing was filed." and nothing is written. A part that cannot be read (a missing file, an unreadable MTR) does not fail the filing: it becomes a warning row in the data book index.
### The filed lock
A filed packet refuses every write except SET_DATABOOK_AT and ADMIN_REOPEN, Admin included (code `filed`). Reads, PDFs, the CSV and prints stay open.
### The seal and reopen
The seal is HMAC-SHA256 with the server seal key over the packet's read model in seal mode. `GET /api/packets/{id}/seal` rebuilds the content and answers `{seal, valid, seals}`. `valid` is null when the key is not available.
ADMIN_REOPEN is Admin, from filed, with a reason of at least 8 characters and a signature ("Reopen a filed packet"). The packet goes back to complete. The seal is marked broken with `broken_at`, `broken_by` and `broken_reason`. The row and its data book are kept: they are the record of what was filed. Filing again writes a second seal with its own data book. The packet read shows the newest seal, broken or not, with its data book, and `seals`, the number of seal rows.
### PDFs, the CSV and prints
Every read below needs a signed-in session and a packet that exists and is not removed (404 `not_found`). Every role that can open the packet may read them, PM included. No policy action guards them, and the filed lock does not apply. Each one writes a `print_log` row and an audit row after the bytes are built.
| Endpoint | What | When | Log kind | Audit |
|---|---|---|---|---|
| `GET /api/packets/{id}/pdf/databook` | The filed data book as stored, or one built now and stamped "not yet filed" | complete or filed, else 409 `wrong_status` "The data book is built once the packet is back from Q.C." | `databook-export` | `export` |
| `GET /api/packets/{id}/pdf/traveler` | The job traveler with the work order QR codes | any status | `traveler` | `export` |
| `GET /api/packets/{id}/export/traceability.csv` | The traceability CSV, with the formula guard | any status | `csv` | `export` |
| `GET /api/packets/{id}/print/{kind}` | A printed copy: `traveler`, `map-h`, `map-w`, `clean`, `void`, `databook`, `doc` | `databook` from complete on (409); `void` only with void pages (404 `nothing_to_print` "Nothing to print."); `doc` only for an asset of the packet, its unit or project folder, or its filed data book (404) | the kind | `print` |
| `GET /api/packets/{id}/prints` | The print log of the packet, newest first, 200 rows | any status | | |
| `GET /api/itps/{id}/pdf` | The ITP PDF built now, stamped signed off or open | any status | `itp-pdf` | `export` |
| `GET /api/itps/{id}/print` | The ITP PDF stamped PRINTED COPY | any status | `itp` | `print` |
`?download=1` serves the file as an attachment. `?sheet=n` limits `map-h`, `map-w` and `clean` to packet page n. Prints and exports write no `packet_log` row, because they change nothing on the packet. The History tab lists them from `GET .../prints`.
### Log texts
The `action` and `note` of `packet_log` for close, file and reopen.
| Write | action | note |
|---|---|---|
| set_vclosed, confirm | `Job closed in VISUAL` | the packet's work order bases, `L489052, L489053`, plus ` · VISUAL still shows open: L489052/1` when the feed shows open legs |
| set_vclosed, taken back | `Moved back to ready to close` | |
| file | `Filed in the data book` | the note given with the step, plus `Filed with work orders open in VISUAL: L489052/1, L489052/2` when confirmed past open work orders, joined with ` · ` |
| file, the data book | `Data book filed` | `N pages` |
| admin_reopen | `Reopened by IT` | the reason |
| set_databook_at | `Data book date set` | |
The audit gains `print` (detail `{kind, what, pages, stamp_sig}`, plus `sheet` and `asset_id` when given) and `export` (detail `{kind, what, pages, stamp_sig}`, kind `databook`, `traveler`, `csv` or `itp`). FILE, ADMIN_REOPEN and SET_VCLOSED write their audit rows through the write pipeline as before.
## 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.
+39 -4
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 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". Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 5 adds the shop columns, the checklist templates and the clock rules (migration 0006). Phase 6 adds the Q.C. columns, the Q.C. check items, the Q.C. folder metadata, the ITP header, line and block details and the ITP log (migration 0007). Phase 7 adds the print log and the data book on the seal (migration 0008). Phase 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
@@ -103,7 +103,7 @@ What the warehouse columns mean in practice (phase 4):
- `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`, `ncr` (0007). At most one open inspection per packet. See "The Q.C. inspection". - `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. Migration 0008 adds the filed data book (`databook_asset_id`, `databook_pages`) and `open_wos`. See "Close and file (phase 7, migration 0008)".
- `packet_log`: append-only. The user-visible history: `at`, `by`, `by_name` and `role` (snapshots), `action`, `note`, `it_reason`, `signature_id`, `transition_id`. Indexed on (`packet_id`, `id`). - `packet_log`: append-only. The user-visible history: `at`, `by`, `by_name` and `role` (snapshots), `action`, `note`, `it_reason`, `signature_id`, `transition_id`. Indexed on (`packet_id`, `id`).
- `packet_visual_state`: one row per packet, written by the worker. `legs` jsonb, `ticket_hours`, `order_ref`, `observed_at`. - `packet_visual_state`: one row per packet, written by the worker. `legs` jsonb, `ticket_hours`, `order_ref`, `observed_at`.
@@ -234,6 +234,41 @@ The texts may hold `{wps}` (else `the WPS`), `{stamp}` (`[75] ` with a trailing
- 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`. - 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). - A `qc_history` row keeps `at`, `result`, `inspector`, `note` (the result note) and `dept_code` as columns, and the full inspection row in `snapshot` (checks, weld exam, NDE fields, `ncr`, `result_by`, location, sent time).
## Close and file (phase 7, migration 0008)
### Column additions
- `packet_seals`: `databook_asset_id` (uuid, references `assets`), the data book PDF built and stored when the packet was filed. `databook_pages` (int), its page count. `open_wos` (text[]), the work orders the feed showed open in VISUAL when Clerical filed anyway, as `wo_base/lot` text (`L489052/1`), or null.
### The print log
`print_log` (new, append-only): one row per print or export.
| Column | Meaning |
|---|---|
| `id` | bigserial |
| `at` | when, default `now()` |
| `by`, `by_name` | the user and a snapshot of the name |
| `session_id` | the session that asked |
| `packet_id`, `unit_id`, `itp_id` | what was printed; `unit_id` and `itp_id` for an ITP |
| `kind` | `traveler`, `map-h`, `map-w`, `clean`, `void`, `databook`, `doc`, `itp`, `csv`, `databook-export`, `itp-pdf` |
| `what` | the human label as printed: `Job traveler`, `Drawing with heat map (sheet 2)`, `MTR - item 3` |
| `pages` | the page count |
| `asset_id` | the document printed for kind `doc`; the filed data book for `databook` |
| `stamp` | the stamp line as printed |
| `stamp_sig` | the 16 hex characters signed into the stamp |
| `ip` | the client address |
Indexed on (`packet_id`, `id`) and (`itp_id`, `id`). `GET /api/packets/{id}/prints` reads it newest first. Prints and exports write no `packet_log` row. They are not packet changes.
### The seal and the data book
- FILE writes the `packet_seals` row, then builds the data book in the same transaction, stores it as an `assets` row (`source` `databook`, `source_ref` `{packet_id, seal_id}`) and sets `databook_asset_id` and `databook_pages` on the seal. A data book that cannot be built fails the filing. Nothing is filed half way.
- ADMIN_REOPEN sets `broken_at`, `broken_by` and `broken_reason` on the unbroken seal. The row and its data book stay. They are the record of what was filed.
- Filing again after a reopen inserts a second seal with its own data book. The unique partial index allows one unbroken seal per packet, so the broken rows stay beside it.
- The packet read shows the newest seal row, broken or not, with its data book, and `seals` (how many rows the packet has).
- The signed ITP PDF filed at sign-off is an `assets` row with `source` `itp_signoff`, stored on `itps.signoff_asset_id`.
## 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.
@@ -287,7 +322,7 @@ A unit folder shows its own current rows and inherits the project's current rows
### The ITP ### 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). - `itps`: one per unit (`unit_id` unique). `template` jsonb is a copy of the code template `ITP_Generation_Revised_2`, frozen when the ITP is started, so a later template change never moves an existing ITP. `no`, `hdr`, `status` (`open` or `complete`), `signoff_at`, `signoff_by`, `signoff_signature_id`, `signoff_asset_id` (the signed PDF, set at sign-off since phase 7), `reopen_at`, `reopen_by`, `reopen_reason`, `created_at`, `created_by`, `version` (bumped by every ITP write).
- `itp_lines`: one row per template item, all made when the ITP is started, so every line has a `row_version` from the first read. `key` is the item number (`1.3`, unique per ITP). `result` is null (open), `approve`, `reject` or `na` (N/A is stored as the result). `by`, `at` and `initials` are the approval; removing it clears those and `result` and keeps `ncr` and the reject history. `ncr` is the NCR of the latest reject, `note` the line note. `cust_code`, `cust_by`, `cust_at`, `cust_rec`, `cust_rec_at` are the customer entry. `row_version` is for the If-Match on approval. A line is done when it is N/A or approved. - `itp_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_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_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.
@@ -297,7 +332,7 @@ A unit folder shows its own current rows and inherits the project's current rows
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`, `itp_log` (0007), `co_verifications`. `audit_events`, `signin_attempts`, `signatures`, `project_renumbers`, `short_receipts`, `packet_transitions`, `qc_history`, `packet_log`, `itp_rejects`, `itp_log` (0007), `print_log` (0008), `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`.
+27 -9
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 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. Status: phase 7. 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. Phase 7 adds the close and file controls (the HMAC seal with the data book filed on it, the controlled-copy and print stamps signed with the seal key, `print_log` and audit rows for every print and export, the CSV formula guard, PDF metadata, escaping in the PDFs). Phase 7 closes M2, S6, S7 and S8. Every other control names the phase that builds it.
Contents: Contents:
@@ -21,10 +21,10 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
| 3 | Employee ID + PIN sign-in | Replaced by AD sign-in with the domain password, plus an optional Packet Station PIN checked on the server, for registered shop devices only. | 1 | | 3 | Employee ID + PIN sign-in | Replaced by AD sign-in with the domain password, plus an optional Packet Station PIN checked on the server, for registered shop devices only. | 1 |
| 4 | Department and level screens | A static role-to-screen map in the browser, plus server policy on every request. The browser only decides what to show. | 1, 2 | | 4 | Department and level screens | A static role-to-screen map in the browser, plus server policy on every request. The browser only decides what to show. | 1, 2 |
| 5 | Per-PC access log | Replaced by the server audit. Each row carries the AD account, session, client IP and user agent. No per-PC browser ids. | 1 | | 5 | Per-PC access log | Replaced by the server audit. Each row carries the AD account, session, client IP and user agent. No per-PC browser ids. | 1 |
| 6 | Filing seals and filed-packet lock | HMAC seals computed on the server with a server key. The filed lock is a server policy rule. | 7 | | 6 | Filing seals and filed-packet lock | HMAC seals computed on the server with a server key. The filed lock is a server policy rule. Built. | 2, 7 |
| 7 | Record integrity check | A worker job (seals, asset hashes, audit hash chain) with an alert. | 9 | | 7 | Record integrity check | A worker job (seals, asset hashes, audit hash chain) with an alert. | 9 |
| 8 | Self-hosted libraries | Kept: no CDN. The CSP allows nothing external. | 0 | | 8 | Self-hosted libraries | Kept: no CDN. The CSP allows nothing external. | 0 |
| 9 | Output escaping | React escapes output by default. The PDF export escapes on the server. | 1, 7 | | 9 | Output escaping | React escapes output by default. The PDF builders clean and escape every text on the server. Built. | 1, 7 |
| 10 | Central permission policy | Server policy, checked on every write, including writes that do not come from the screens. Built. | 2 | | 10 | Central permission policy | Server policy, checked on every write, including writes that do not come from the screens. Built. | 2 |
| 11 | PIN sign-off (electronic signature) | Server-verified signature with the PIN or the domain password, stored with who, when, what, factor and a content hash. | 1 | | 11 | PIN sign-off (electronic signature) | Server-verified signature with the PIN or the domain password, stored with who, when, what, factor and a content hash. | 1 |
| 12 | IT edit reasons | Kept. Admin writes need a reason and are marked as IT edits in the packet history and the audit. | 2 | | 12 | IT edit reasons | Kept. Admin writes need a reason and are marked as IT edits in the packet history and the audit. | 2 |
@@ -32,8 +32,8 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
| 14 | Input and upload validation | On the server: field limits on every write, closed reason sets, uploads typed by content per kind (drawing PDF; MTR and document PDF, PNG or JPEG; BOM XLSX or CSV), a size limit per kind (25 MB, BOM 5 MB), sanitised names, an optional ClamAV scan before anything is stored, and a quota alert on the assets volume. Built. | 2, 4 | | 14 | Input and upload validation | On the server: field limits on every write, closed reason sets, uploads typed by content per kind (drawing PDF; MTR and document PDF, PNG or JPEG; BOM XLSX or CSV), a size limit per kind (25 MB, BOM 5 MB), sanitised names, an optional ClamAV scan before anything is stored, and a quota alert on the assets volume. Built. | 2, 4 |
| 15 | Second-admin approval | Superseded. Roles come only from AD groups and the group-to-role map is derived, so there is nothing to grant in the app. | 1 | | 15 | Second-admin approval | Superseded. Roles come only from AD groups and the group-to-role map is derived, so there is nothing to grant in the app. | 1 |
| 16 | Security alerts and automatic integrity check | Worker jobs and the Notifications tab (email through app-03). | 9 | | 16 | Security alerts and automatic integrity check | Worker jobs and the Notifications tab (email through app-03). | 9 |
| 17 | Controlled-copy stamp on data books | Applied to server-built PDFs and signed with the seal key. | 7 | | 17 | Controlled-copy stamp on data books | Applied to server-built PDFs and signed with the seal key. Built, phase 7. | 7 |
| 18 | CSV formula guard | Applied to every server export. | 7 | | 18 | CSV formula guard | Applied to every server export. Built, phase 7. | 7 |
| 19 | Update lock and stale-version block | Replaced by a build version endpoint. A screen on an older build shows a banner and reloads. | 0 (endpoint), 10 (banner) | | 19 | Update lock and stale-version block | Replaced by a build version endpoint. A screen on an older build shows a banner and reloads. | 0 (endpoint), 10 (banner) |
| 20 | VISUAL helper write guard | Dropped. There is no helper. VisualKit and MFilesKit are read-only by design and connect as read-only accounts. | 3 | | 20 | VISUAL helper write guard | Dropped. There is no helper. VisualKit and MFilesKit are read-only by design and connect as read-only accounts. | 3 |
@@ -146,9 +146,25 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
### Seals (phase 7) ### Seals (phase 7)
- Filing writes an HMAC seal with the server seal key. A reopen by Admin needs a reason and keeps the old seal in history. - Filing writes an HMAC-SHA256 seal over the packet's read model in seal mode, with the server seal key (`SEAL_KEY_FILE`, a Docker secret) and its `key_id`. Nobody without the key can recompute it (M2). `GET /api/packets/{id}/seal` rebuilds the content and says whether the seal still matches.
- The data book is built on the server inside the filing transaction and stored as an asset on the seal. A data book that cannot be built fails the filing; nothing is filed half way.
- Filing while the feed shows a work order open in VISUAL needs a second confirmation. The work orders are kept on the seal and in the packet log.
- A reopen by Admin needs a reason and a signature. It marks the seal broken with who, when and why, and keeps the row and its data book. Filing again writes a new seal beside it.
- The integrity job checks seals and asset hashes (phase 9). - The integrity job checks seals and asset hashes (phase 9).
### PDFs, prints and exports (phase 7)
- Every PDF and the CSV are built on the server. The browser only opens the URL. Reads need a signed-in session; every role that can open the packet may read them, PM included.
- Controlled-copy stamp (S7): every exported page carries `Controlled copy - exported by Name (emp) <UTC time>`, the seal's first 12 hex or `not yet filed`, and ` - stamp <sig>`. The filed data book is the stored copy, served as it was built.
- Printed-copy stamp (S8): every printed page carries `PRINTED COPY - <what> - printed by Name (emp) <UTC time> - <project> Lot <unit> dwg <drawing> R<rev> - verify revision before use` and the stamp signature, in a white box at the bottom of the page.
- Stamp signature: 16 hex of HMAC-SHA256 with the seal key over the packet, the kind (a printed copy signs `print:<kind>`, so a print and an export of the same thing in the same minute never share a signature), the seal hash, the user and the time. A copy can be checked against the server later with `verify_stamp`; the Admin screen for it comes in phase 9.
- Logging (S7, S8): every print and export writes one `print_log` row (who, when, session, packet, unit or ITP, kind, what, pages, the stamp line, the stamp signature, the client address) and one audit row (`print` or `export`). `print_log` is append-only: the app role has SELECT and INSERT only, and a trigger refuses updates, deletes and truncate.
- CSV formula guard (S6): a cell that starts with `=`, `+`, `-`, `@`, a tab or a CR gets a leading `'`. A cell with a quote, a comma or a line break is quoted with the quotes doubled.
- PDF metadata: title, subject and keywords name the packet and the stamp signature; Author is PESCO and Producer is Packet Station.
- Output escaping (S5): every text drawn into a PDF goes through `clean` (control characters removed, characters outside Latin-1 replaced) and is XML-escaped where a Paragraph renders it, so data from VISUAL, M-Files or a person cannot inject markup.
- Responses carry `Content-Disposition` (inline, or attachment with `?download=1`), `X-Content-Type-Options: nosniff` and `Cache-Control: no-store`. A print of a document checks that the asset belongs to the packet, its unit or project folder, or its filed data book; anything else is 404.
- The signed ITP PDF is built and filed in the sign-off transaction. A PDF that cannot be built fails the sign-off.
### Headers (phase 0) ### Headers (phase 0)
- The app sets HSTS, CSP and the other security headers itself. NPM's own HSTS is off. - The app sets HSTS, CSP and the other security headers itself. NPM's own HSTS is off.
@@ -185,15 +201,17 @@ The IDs come from Part 3 of the old SECURITY.md. The status follows the approved
| D5 | Closed, phase 4 (optional ClamAV: a `clamav` compose profile; when `CLAMAV_HOST` is set every upload and every stored drawing is scanned before it is kept, a hit is refused and audited, and an unreachable scanner refuses uploads; off when the host is not set) | | D5 | Closed, phase 4 (optional ClamAV: a `clamav` compose profile; when `CLAMAV_HOST` is set every upload and every stored drawing is scanned before it is kept, a hit is refused and audited, and an unreachable scanner refuses uploads; off when the host is not set) |
| D6, D7 | Deferred (retention period and customer copy need a decision from Seth and Q.C.) | | D6, D7 | Deferred (retention period and customer copy need a decision from Seth and Q.C.) |
| M1 | Closed, phase 2 (append-only audit and packet log written only by the server; every packet write carries both) | | M1 | Closed, phase 2 (append-only audit and packet log written only by the server; every packet write carries both) |
| M2 | Closed, phase 7 | | M2 | Closed, phase 7 (filing seal is HMAC-SHA256 with the server seal key from a Docker secret; a reopen keeps the broken seal and its data book) |
| M3 | Closed, phase 9 | | M3 | Closed, phase 9 |
| M4, M5 | Deferred (logs to stdout now; SIEM shipping is IT's) | | M4, M5 | Deferred (logs to stdout now; SIEM shipping is IT's) |
| S1 | Closed, phase 0, tuned phase 10 | | S1 | Closed, phase 0, tuned phase 10 |
| S2 | Closed, phase 1 | | S2 | Closed, phase 1 |
| S3 | Closed, phase 9 (CI scanning) | | S3 | Closed, phase 9 (CI scanning) |
| S4 | Deferred to IT (pentest before go-live) | | S4 | Deferred to IT (pentest before go-live) |
| S5 | Closed, phase 2 (React escapes output; server messages are plain text; PDF export escaping comes with phase 7) | | S5 | Closed, phase 2 (React escapes output; server messages are plain text); phase 7 adds escaping in the PDF builders |
| S6, S7, S8 | Closed, phase 7. PLAN.md lists S8 under phase 5, but the print stamps come with the PDFs in phase 7, so S8 closes there. | | S6 | Closed, phase 7 (the CSV formula guard on every export) |
| S7 | Closed, phase 7 (the controlled-copy stamp on every exported PDF, signed with the seal key; PDF metadata; every export logged in `print_log` and the audit) |
| S8 | Closed, phase 7 (the PRINTED COPY stamp with "verify revision before use" on every printed page, signed with the seal key; every print logged in `print_log` and the audit). PLAN.md lists S8 under phase 5, but the print stamps come with the PDFs in phase 7, so S8 closes there. |
| O1, O2 | Closed, phase 9 (pg_dump timer plus PBS; one rehearsed restore); quarterly restore IT's | | O1, O2 | Closed, phase 9 (pg_dump timer plus PBS; one rehearsed restore); quarterly restore IT's |
| O3, O4 | Closed, phase 10 | | O3, O4 | Closed, phase 10 |
| O5 | Superseded (no migration; M: retired read-only) | | O5 | Superseded (no migration; M: retired read-only) |
+22
View File
@@ -16,8 +16,11 @@ import type {
PacketHeader, PacketHeader,
PacketListQuery, PacketListQuery,
PacketRead, PacketRead,
PrintKind,
PrintRow,
QcFields, QcFields,
ReceiptBody, ReceiptBody,
SealView,
ShortBody, ShortBody,
StageFields, StageFields,
TransitionBody, TransitionBody,
@@ -127,6 +130,25 @@ export const packetsApi = {
api.put<PacketRead>(`${base(id)}/qc/welds/${no}`, { ...body, ...g }), 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. */ /** 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), qcPatch: (id: number, body: FieldCas<QcFields>) => api.patch<PacketRead>(`${base(id)}/qc`, body),
/* Phase 7: close, file, PDFs and prints. */
/** ADMIN_REOPEN: filed back to complete. The reason and the signature are required. */
reopen: (id: number, body: { reason: string } & Guarded) => api.post<PacketRead>(`${base(id)}/reopen`, body),
/** The newest seal, whether it still matches the record (null when the key is not available), and every seal row. */
seal: async (id: number, signal?: AbortSignal): Promise<SealView> => {
const raw = await api.get<Partial<SealView> | null>(`${base(id)}/seal`, signal);
return { seal: raw?.seal ?? null, valid: raw?.valid ?? null, seals: Array.isArray(raw?.seals) ? raw.seals : [] };
},
/** Printed copies and exports of this packet, newest first. */
prints: async (id: number, signal?: AbortSignal): Promise<PrintRow[]> =>
asList<PrintRow>(await api.get<unknown>(`${base(id)}/prints`, signal), "prints"),
/** The data book or the job traveler as a PDF. `download` asks for an attachment. */
pdfUrl: (id: number, kind: "databook" | "traveler", download = false) => `${base(id)}/pdf/${kind}${download ? "?download=1" : ""}`,
/** The traceability CSV. */
csvUrl: (id: number) => `${base(id)}/export/traceability.csv`,
/** A printed copy: the stamped PDF of one print kind. `sheet` limits a map or clean print to one page; `asset` names the document for kind doc. */
printUrl: (id: number, kind: PrintKind, opts: { sheet?: number | undefined; asset?: string | undefined } = {}) =>
`${base(id)}/print/${kind}${qs({ sheet: opts.sheet, asset: opts.asset })}`,
}; };
/** 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. */
+8
View File
@@ -413,6 +413,12 @@ export const signOff = (id: number, sign: SignBody | undefined, g: Guarded = {})
export const reopenItp = (id: number, body: { reason: string; it_reason?: string }) => itp(api.post<Itp>(`/api/itps/${id}/reopen`, body)); export const reopenItp = (id: number, body: { reason: string; it_reason?: string }) => itp(api.post<Itp>(`/api/itps/${id}/reopen`, body));
/** Phase 7: the ITP as a PDF built now. `download` asks for an attachment. */
export const itpPdfUrl = (id: number, download = false) => `/api/itps/${id}/pdf${download ? "?download=1" : ""}`;
/** Phase 7: the ITP PDF stamped PRINTED COPY. */
export const itpPrintUrl = (id: number) => `/api/itps/${id}/print`;
export const qcApi = { export const qcApi = {
getQcHome, getQcHome,
getFolders, getFolders,
@@ -433,4 +439,6 @@ export const qcApi = {
unsignBlock, unsignBlock,
signOff, signOff,
reopenItp, reopenItp,
itpPdfUrl,
itpPrintUrl,
}; };
+69 -1
View File
@@ -384,6 +384,8 @@ export interface QcFolderDoc {
url: string; url: string;
scope: "unit" | "project"; scope: "unit" | "project";
added_at: string | null; added_at: string | null;
/** Not sent today; the asset id is read from `url` when it is missing. */
asset_id?: string;
} }
/** The unit's ITP summary. */ /** The unit's ITP summary. */
@@ -480,7 +482,12 @@ export interface PacketRead {
open_cos: number[]; open_cos: number[];
open_shorts: number; open_shorts: number;
docs: PacketDoc[]; docs: PacketDoc[];
seal: { sealed_at: string; key_id: string; broken_at: string | null } | null; /** Phase 7: the newest seal row, broken or not, with the data book filed on it. */
seal: PacketSeal | null;
/** Phase 7: how many seal rows the packet has (2 after a reopen and a refile). */
seals?: number;
/** Phase 7: what the feed saw in VISUAL against the Clerical close. Null for a hand packet. */
vclose_check?: VcloseCheck | null;
can: string[]; can: string[];
blocked: Record<string, string[]>; blocked: Record<string, string[]>;
holder: string; holder: string;
@@ -505,6 +512,65 @@ export interface PacketRead {
itp_hold?: ItpHold | null; itp_hold?: ItpHold | null;
} }
/* ---------- Phase 7: close, file, PDFs and prints ---------- */
/** The data book stored on a seal. */
export interface SealDatabook {
asset_id: string;
/** "/_blob/<id>", inline. */
url: string;
/** "/_blob/<id>?download=1", as an attachment. */
download_url: string;
pages: number | null;
size: number | null;
file_name: string | null;
}
/** PacketRead.seal and the rows of GET /api/packets/{id}/seal. */
export interface PacketSeal {
id: number;
sealed_at: string;
sealed_by: WhoRef | null;
key_id: string;
signed: boolean;
broken_at: string | null;
broken_by: WhoRef | null;
broken_reason: string | null;
/** Work orders the feed showed open in VISUAL at filing, or null. */
open_wos: string[] | null;
databook: SealDatabook | null;
}
/** PacketRead.vclose_check. */
export interface VcloseCheck {
observed_at: string | null;
closed_at: string | null;
/** "wo_base/lot" of every leg not closed and not cancelled. Text, never numbers. */
open_wos: string[];
/** True when the Clerical close and the feed's closed_at say the same. */
agrees: boolean;
}
/** GET /api/packets/{id}/seal. `valid` is null when the seal key is not available. */
export interface SealView {
seal: PacketSeal | null;
valid: boolean | null;
seals: PacketSeal[];
}
/** The print kinds of GET /api/packets/{id}/print/{kind}. */
export type PrintKind = "traveler" | "map-h" | "map-w" | "clean" | "void" | "databook" | "doc";
/** One row of GET /api/packets/{id}/prints. */
export interface PrintRow {
id: number;
at: string;
by: WhoRef | null;
kind: string;
what: string;
pages: number | null;
}
/* ---------- Phase 5: the shop on the packet ---------- */ /* ---------- Phase 5: the shop on the packet ---------- */
export interface Weld { export interface Weld {
@@ -726,6 +792,8 @@ export interface TransitionBody {
for_sup_id?: string; for_sup_id?: string;
/** Phase 6: QC_REJECT. An NCR number like NCR-2026-0021. */ /** Phase 6: QC_REJECT. An NCR number like NCR-2026-0021. */
ncr?: string; ncr?: string;
/** Phase 7: FILE. Files although the feed still shows work orders open in VISUAL. */
confirm_open_wos?: boolean;
sign?: SignBody; sign?: SignBody;
it_reason?: string; it_reason?: string;
} }
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import type { PacketRead } from "../api/types";
/** One step button. Hidden when the user can neither do it nor is blocked from it. Blocked shows the reason under it. */
export function Act({
p,
action,
busy,
onClick,
className = "btn",
children,
}: {
p: PacketRead;
action: string;
busy: boolean;
onClick: () => void;
className?: string;
children: ReactNode;
}) {
const can = p.can.includes(action);
const why = p.blocked[action];
if (!can && !why) return null;
const reason = why?.join(" ") ?? "";
return (
<div className="act">
<button className={className} type="button" disabled={!can || busy} title={!can ? reason : undefined} onClick={onClick}>
{children}
</button>
{!can && reason ? <div className="why small">{reason}</div> : null}
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { useState } from "react";
import { errorCode } from "../api/client";
import { packetsApi } from "../api/packets";
import type { PacketRead } from "../api/types";
import type { ReasonOptions } from "../components/ReasonSheet";
import { Sheet } from "../components/Sheet";
import { errorText, fmtTime } from "../lib/format";
import { useLoad } from "../lib/useLoad";
import { Act } from "./Act";
import { openTab, showsCloseFile, SIGN_REOPEN, visualLine } from "./closeUtil";
import type { Mutate } from "./tabs";
/** The seal line of a filed packet, from GET /seal. */
function SealLine({ p }: { p: PacketRead }) {
const [load] = useLoad((s) => packetsApi.seal(p.id, s), `${p.id}:${p.version}`);
if (load.state === "loading") return <div className="small muted">Checking the seal</div>;
if (load.state === "error") return <div className="small muted">The seal could not be checked. {load.message}</div>;
const key = load.data.seal?.key_id ?? p.seal?.key_id ?? "";
if (load.data.valid === true) {
return (
<div>
<span className="pill st-ok" data-testid="seal-state">
Seal intact (key {key})
</span>
</div>
);
}
if (load.data.valid === false) {
return (
<div className="callout warn flush" role="alert" data-testid="seal-state">
<b>Seal does not match the record.</b> The filed record changed after it was sealed. Tell IT.
</div>
);
}
return (
<div className="small muted" data-testid="seal-state">
Seal unknown (key not available)
</div>
);
}
/**
* The Clerical close and file panel, from status complete on. Ready to close (databook route, not confirmed closed),
* ready for the data book, and filed (read-only for everyone but the IT reopen).
*/
export function CloseFile({
p,
busy,
mutate,
askReason,
sub,
onPrint,
}: {
p: PacketRead;
busy: boolean;
mutate: Mutate;
askReason: (opts: ReasonOptions) => Promise<string | null>;
sub: string;
onPrint: () => void;
}) {
const [confirm, setConfirm] = useState<string | null>(null);
if (!showsCloseFile(p)) return null;
const filed = p.status === "filed";
const databookRoute = p.routed_to === "databook";
const readyToClose = !filed && databookRoute && !p.vclosed;
const check = p.vclose_check ?? null;
const vline = visualLine(check);
const seal = p.seal;
const book = filed && seal?.databook ? seal.databook : null;
const viewUrl = book?.url ?? packetsApi.pdfUrl(p.id, "databook");
const downloadUrl = book?.download_url ?? packetsApi.pdfUrl(p.id, "databook", true);
/** FILE. A 409 open_work_orders opens the confirm; File anyway sends it again with confirm_open_wos. */
const fileIt = async (confirmed: boolean) => {
const hit: { message?: string } = {};
await mutate(
async (g) => {
try {
return await packetsApi.transition(p.id, {
action: "file",
expect: { status: p.status, dept: p.dept.code },
...(confirmed ? { confirm_open_wos: true } : {}),
...g,
});
} catch (err) {
if (!confirmed && errorCode(err) === "open_work_orders") {
hit.message = errorText(err);
return undefined;
}
throw err;
}
},
{ ok: "Filed in the data book." },
);
if (hit.message !== undefined) setConfirm(hit.message);
};
const reopen = async () => {
const reason = await askReason({
title: "Reopen a filed packet",
sub,
note: "The packet goes back to complete. The seal is kept as broken, with your reason.",
min: 8,
max: 300,
danger: true,
submitLabel: "Reopen",
});
if (reason === null) return;
await mutate((g) => packetsApi.reopen(p.id, { reason, ...g }), { sign: SIGN_REOPEN, ok: "Reopened. The earlier seal is kept as broken." });
};
const exports = (
<>
<div className="act">
<button className={filed ? "btn pri" : "btn"} type="button" onClick={() => openTab(viewUrl)}>
Data book (PDF)
</button>
</div>
<div className="act">
<a className="btn sm ghost" href={downloadUrl} download>
Download
</a>
</div>
<div className="act">
<a className="btn" href={packetsApi.csvUrl(p.id)} download>
Traceability (CSV)
</a>
</div>
<div className="act">
<button className="btn" type="button" onClick={onPrint}>
Print…
</button>
</div>
</>
);
const reopened =
!filed && seal?.broken_at ? (
<div className="small" data-testid="reopened">
Reopened by {seal.broken_by?.display_name ?? "IT"} {fmtTime(seal.broken_at)}: {seal.broken_reason ?? ""}. The earlier seal is kept as broken.
</div>
) : null;
return (
<section className="panel actbox" aria-label="Close and file">
{filed ? (
<>
<div>
<b>Filed in the data book</b> {fmtTime(seal?.sealed_at ?? p.databook_at ?? p.status_at)} by {seal?.sealed_by?.display_name ?? "Clerical"}.
{book?.pages ? ` The data book has ${book.pages} page${book.pages === 1 ? "" : "s"}.` : ""}
</div>
<SealLine p={p} />
{seal?.open_wos?.length ? <div className="small due soon">Filed with work orders open in VISUAL: {seal.open_wos.join(", ")}.</div> : null}
<div className="acts">
{exports}
<Act p={p} action="admin_reopen" busy={busy} className="btn ghost" onClick={() => void reopen()}>
Reopen (IT)
</Act>
</div>
</>
) : readyToClose ? (
<>
<div>
<b>Back from the shop, ready to close.</b> Close the job in VISUAL, then click below. The packet moves on to the data book.
</div>
{vline ? (
<div className="small" data-testid="visual-line">
{vline}
</div>
) : null}
{reopened}
<div className="acts">
<Act p={p} action="set_vclosed" busy={busy} className="btn pri" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}>
Job closed in VISUAL
</Act>
<Act p={p} action="file" busy={busy} onClick={() => void fileIt(false)}>
File in the data book
</Act>
{exports}
</div>
</>
) : (
<>
<div>
<b>Ready for the data book.</b>
{databookRoute && p.vclosed ? ` Job closed in VISUAL ${fmtTime(p.vclosed.at)} by ${p.vclosed.by?.display_name ?? "Clerical"}.` : ""} Check the data book, then file it.
</div>
{(check?.open_wos ?? []).length > 0 && vline ? (
<div className="callout warn flush" data-testid="visual-line">
{vline}
</div>
) : null}
{reopened}
<div className="acts">
<Act p={p} action="file" busy={busy} className="btn pri" onClick={() => void fileIt(false)}>
File in the data book
</Act>
{exports}
{databookRoute ? (
<Act p={p} action="set_vclosed" busy={busy} className="btn ghost" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, false, g))}>
Not closed in VISUAL, move back
</Act>
) : null}
</div>
</>
)}
{confirm !== null ? (
<Sheet title="File with open work orders?" sub={sub} onClose={() => setConfirm(null)}>
<p>{confirm}</p>
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={() => setConfirm(null)}>
Cancel
</button>
<button
className="btn pri"
type="button"
disabled={busy}
onClick={() => {
setConfirm(null);
void fileIt(true);
}}
>
File anyway
</button>
</div>
</Sheet>
) : null}
</section>
);
}
+10 -2
View File
@@ -4,8 +4,11 @@ import { openPdf } from "../lib/pdf";
const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]; const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
/** The drawing pages, one at a time, fitted to the width. Zoom buttons and a page list. No marking yet. */ /**
export function DrawingViewer({ pages }: { pages: PacketPage[] }) { * The drawing pages, one at a time, fitted to the width. Zoom buttons and a page list. No marking yet.
* `onPrintSheet` adds "Print this sheet" for the shown page (its packet page number).
*/
export function DrawingViewer({ pages, onPrintSheet }: { pages: PacketPage[]; onPrintSheet?: (n: number) => void }) {
const [sel, setSel] = useState(0); const [sel, setSel] = useState(0);
const [zi, setZi] = useState(2); const [zi, setZi] = useState(2);
const [width, setWidth] = useState(0); const [width, setWidth] = useState(0);
@@ -84,6 +87,11 @@ export function DrawingViewer({ pages }: { pages: PacketPage[] }) {
</div> </div>
<span className="small muted mono">{Math.round(zoom * 100)}%</span> <span className="small muted mono">{Math.round(zoom * 100)}%</span>
<span className="grow" /> <span className="grow" />
{onPrintSheet ? (
<button className="btn sm" type="button" onClick={() => onPrintSheet(page.n)}>
Print this sheet
</button>
) : null}
<a className="btn sm ghost" href={page.url} target="_blank" rel="noopener noreferrer"> <a className="btn sm ghost" href={page.url} target="_blank" rel="noopener noreferrer">
Open in a new tab Open in a new tab
</a> </a>
+6
View File
@@ -21,6 +21,7 @@ import { errorText } from "../lib/format";
import { voidText } from "../lib/voidStamp"; import { voidText } from "../lib/voidStamp";
import type { Write, WriteResult } from "../warehouse/PullTable"; import type { Write, WriteResult } from "../warehouse/PullTable";
import { MapViewer } from "./MapViewer"; import { MapViewer } from "./MapViewer";
import { openPrint } from "./closeUtil";
import { import {
HeatTableInner, HeatTableInner,
HeatTableOnDrawing, HeatTableOnDrawing,
@@ -327,6 +328,11 @@ export function HeatMapTab({
: "No heat table on this sheet"} : "No heat table on this sheet"}
</span> </span>
) : null} ) : null}
{!p.removed ? (
<button className="btn sm" type="button" onClick={() => openPrint(p.id, "map-h", { sheet: page.n })}>
Print this sheet
</button>
) : null}
</div> </div>
<div className="dv"> <div className="dv">
<MapViewer <MapViewer
+14 -65
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router"; import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { assetsApi } from "../api/assets"; import { assetsApi } from "../api/assets";
import { ApiError, errorCode } from "../api/client"; import { ApiError, errorCode } from "../api/client";
@@ -11,7 +11,11 @@ import { useGuard } from "../components/useGuard";
import { useChanged } from "../lib/changes"; import { useChanged } from "../lib/changes";
import { errorText } from "../lib/format"; import { errorText } from "../lib/format";
import { dueInfo, flagClass, jobName, pillClass, statusLabel, targetHours } from "../lib/packets"; import { dueInfo, flagClass, jobName, pillClass, statusLabel, targetHours } from "../lib/packets";
import { Act } from "./Act";
import { CloseFile } from "./CloseFile";
import { DrawingViewer } from "./DrawingViewer"; import { DrawingViewer } from "./DrawingViewer";
import { openPrint } from "./closeUtil";
import { PrintMenu } from "./PrintMenu";
import { MoveSheet, SendToDeptSheet } from "./sheets"; import { MoveSheet, SendToDeptSheet } from "./sheets";
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs"; import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
import { coSummary, coToReview } from "../lib/changeOrders"; import { coSummary, coToReview } from "../lib/changeOrders";
@@ -40,37 +44,6 @@ const TOAST_MS = 5000;
/** The rule's signature meaning for attaching a drawing by hand. */ /** The rule's signature meaning for attaching a drawing by hand. */
const SIGN_ATTACH = "Attach a drawing by hand"; const SIGN_ATTACH = "Attach a drawing by hand";
const SIGN_FILE = "File in the data book";
/** One step button. Hidden when the user can neither do it nor is blocked from it. Blocked shows the reason under it. */
function Act({
p,
action,
busy,
onClick,
className = "btn",
children,
}: {
p: PacketRead;
action: string;
busy: boolean;
onClick: () => void;
className?: string;
children: ReactNode;
}) {
const can = p.can.includes(action);
const why = p.blocked[action];
if (!can && !why) return null;
const reason = why?.join(" ") ?? "";
return (
<div className="act">
<button className={className} type="button" disabled={!can || busy} title={!can ? reason : undefined} onClick={onClick}>
{children}
</button>
{!can && reason ? <div className="why small">{reason}</div> : null}
</div>
);
}
/** Title, customer, WPS, drawing and rev, edited in place with a field-level base. */ /** Title, customer, WPS, drawing and rev, edited in place with a field-level base. */
function HeaderEdit({ p, mutate, busy, onDone }: { p: PacketRead; mutate: Mutate; busy: boolean; onDone: () => void }) { function HeaderEdit({ p, mutate, busy, onDone }: { p: PacketRead; mutate: Mutate; busy: boolean; onDone: () => void }) {
@@ -139,7 +112,7 @@ export function PacketPage() {
const [search] = useSearchParams(); const [search] = useSearchParams();
const wantTab = search.get("tab"); const wantTab = search.get("tab");
const [tab, setTab] = useState<Tab | null>(isTab(wantTab) ? wantTab : null); const [tab, setTab] = useState<Tab | null>(isTab(wantTab) ? wantTab : null);
const [sheet, setSheet] = useState<"dept" | "move" | null>(null); const [sheet, setSheet] = useState<"dept" | "move" | "print" | null>(null);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [toast, setToast] = useState<string | null>(null); const [toast, setToast] = useState<string | null>(null);
const others = usePacketPresence(Number.isFinite(id) ? id : null); const others = usePacketPresence(Number.isFinite(id) ? id : null);
@@ -318,8 +291,6 @@ export function PacketPage() {
await mutate((g) => packetsApi.removePages(p.id, n, g), { ok: "Drawing removed." }); await mutate((g) => packetsApi.removePages(p.id, n, g), { ok: "Drawing removed." });
}; };
const fileIt = () => mutate((g) => packetsApi.transition(p.id, { action: "file", expect: { status: p.status, dept: p.dept.code }, ...g }), { sign: SIGN_FILE, ok: "Filed in the data book." });
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 hasQc = !!p.qc || p.qc_history.length > 0; const hasQc = !!p.qc || p.qc_history.length > 0;
@@ -461,6 +432,11 @@ export function PacketPage() {
Move Move
</button> </button>
) : null} ) : null}
{!p.removed ? (
<button className="btn sm" type="button" onClick={() => setSheet("print")}>
Print…
</button>
) : null}
</div> </div>
</div> </div>
@@ -630,35 +606,7 @@ export function PacketPage() {
</section> </section>
) : null} ) : null}
{!draft && (can("set_vclosed") || can("file") || p.blocked.file) ? ( <CloseFile p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} onPrint={() => setSheet("print")} />
<section className="panel actbox" aria-label="Close and file">
<div>
{p.vclosed ? (
<>
<b>Ready for the data book.</b> Job closed in VISUAL by {p.vclosed.by?.display_name ?? "Clerical"}. Mark it filed when the data book is done.
</>
) : (
<>
<b>Back from the shop, ready to close.</b> Close the job in VISUAL, then click below. The packet moves on to the data book.
</>
)}
</div>
<div className="acts">
{!p.vclosed ? (
<Act p={p} action="set_vclosed" busy={busy} className="btn pri" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}>
Job closed in VISUAL
</Act>
) : (
<Act p={p} action="set_vclosed" busy={busy} className="btn ghost" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, false, g))}>
Not closed in VISUAL, move back
</Act>
)}
<Act p={p} action="file" busy={busy} onClick={() => void fileIt()}>
Mark filed
</Act>
</div>
</section>
) : 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")} /> <QcBar p={p} busy={busy} mutate={mutate} sub={job} goQc={() => setTab("qc")} />
@@ -694,7 +642,7 @@ export function PacketPage() {
</button> </button>
</div> </div>
) : null} ) : null}
<DrawingViewer pages={drawingPages} /> <DrawingViewer pages={drawingPages} {...(p.removed ? {} : { onPrintSheet: (n: number) => openPrint(p.id, "clean", { sheet: n }) })} />
</div> </div>
) : null} ) : null}
{shownTab === "heat" ? <HeatMapTab key={p.id} p={p} write={write} reload={reload} sub={job} onError={onTabError} /> : null} {shownTab === "heat" ? <HeatMapTab key={p.id} p={p} write={write} reload={reload} sub={job} onError={onTabError} /> : null}
@@ -732,6 +680,7 @@ export function PacketPage() {
}} }}
/> />
) : null} ) : null}
{sheet === "print" ? <PrintMenu p={p} sub={job} onClose={() => setSheet(null)} /> : null}
{wh.sheet} {wh.sheet}
{guardSheet} {guardSheet}
{toast ? ( {toast ? (
+192
View File
@@ -0,0 +1,192 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PacketPage, PacketRead } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { mockFetch, type Route } from "../test/fetchMock";
import { makePacket } from "../test/packets";
import { kioskCode, printRows } from "./closeUtil";
function page(n: number, over: Partial<PacketPage> = {}): PacketPage {
return { n, type: "drawing", asset_id: `as-${n}`, pn: n, dwg: "CI-DWG-1", rev: "0", void: null, url: `/_blob/as-${n}`, ...over };
}
const VOID = { co_id: 1, co_no: 1, at: "2026-10-16T08:00:00Z", new_n: 3 };
function base(packet: PacketRead, extra: Record<string, Route> = {}) {
return {
"/api/auth/me": { body: makeMe() },
"/api/departments": { body: DEPARTMENTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
...extra,
};
}
const rowNames = () =>
within(screen.getByRole("dialog", { name: "Print" }))
.getAllByRole("button")
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
afterEach(() => {
vi.restoreAllMocks();
});
describe("printRows", () => {
it("one sheet, a draft: four rows, no void, no data book", () => {
const rows = printRows(makePacket({ pages: [page(1)] }));
expect(rows.map((r) => r.label)).toEqual(["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)"]);
expect(rows[1]?.desc).toBe("The drawing with its heat table");
});
it("two live sheets and a void one, complete: the void and data book rows", () => {
const rows = printRows(makePacket({ status: "complete", pages: [page(1, { void: VOID }), page(2), page(3)] }));
expect(rows.map((r) => r.kind)).toEqual(["traveler", "map-h", "map-w", "clean", "void", "databook"]);
expect(rows[1]?.desc).toBe("All 2 drawing sheets with heat tables");
expect(rows[4]?.desc).toBe("1 replaced drawing sheet with their weld marks, stamped VOID");
expect(rows[5]?.desc).toBe("The full manufacturing data book (same as the PDF export)");
});
it("filed keeps the data book row", () => {
expect(printRows(makePacket({ status: "filed" })).map((r) => r.kind)).toContain("databook");
});
});
describe("Print menu", () => {
it("opens from the header, lists the rows, and a row opens its print in a new tab", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetch(base(makePacket({ pages: [page(1)] })));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Print…" }));
const sheet = await screen.findByRole("dialog", { name: "Print" });
expect(within(sheet).getByText("L999001 · WELD-PART")).toBeInTheDocument();
expect(rowNames()).toEqual(["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)", "Cancel"]);
expect(within(sheet).getByRole("button", { name: "Job traveler" })).toHaveAccessibleDescription(
"Job info, work order QR codes for clocking on, BOM and heat numbers, checklist with blanks to stamp, welds, sign-offs, notes",
);
expect(sheet).toHaveTextContent('Every printed page is stamped with who printed it and when, plus "verify revision before use". Printing is recorded.');
fireEvent.click(within(sheet).getByRole("button", { name: "Drawing with weld map" }));
expect(open).toHaveBeenCalledWith("/api/packets/1/print/map-w", "_blank", "noopener");
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Print" })).not.toBeInTheDocument());
});
it("shows the void and data book rows on a complete packet and opens the data book print", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetch(base(makePacket({ status: "complete", routed_to: "shipping", can: [], blocked: {}, pages: [page(1, { void: VOID }), page(3)] })));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Print…" }));
await screen.findByRole("dialog", { name: "Print" });
expect(rowNames()).toEqual(["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)", "Superseded drawings (VOID)", "Data book", "Cancel"]);
fireEvent.click(screen.getByRole("button", { name: "Data book" }));
expect(open).toHaveBeenCalledWith("/api/packets/1/print/databook", "_blank", "noopener");
});
it("Cancel closes it without opening anything", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetch(base(makePacket()));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Print…" }));
fireEvent.click(within(await screen.findByRole("dialog", { name: "Print" })).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Print" })).not.toBeInTheDocument());
expect(open).not.toHaveBeenCalled();
});
it("is not offered on a removed packet", async () => {
mockFetch(base(makePacket({ removed: { at: "2026-10-16T08:00:00Z", by: null, reason: "duplicate" }, can: [], blocked: {} })));
renderApp("/packets/1");
await screen.findByRole("tab", { name: /^Materials/ });
expect(screen.queryByRole("button", { name: "Print…" })).not.toBeInTheDocument();
});
});
describe("Print entry points on the tabs", () => {
it("the Work orders tab shows the kiosk codes, Print traveler and the traveler PDF", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
// jsdom has no canvas; the QR falls back to "No QR" and the code text stays.
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
const wo = { id: 1, wo_base: "L999001", lot: "7A", split: "", sub: "0", seq: 10, op: "WELD", qty: 2, target_hrs: 3, row_version: 1 };
expect(kioskCode(wo)).toBe("*%2L999001$7A$0$0$10%*");
mockFetch(base(makePacket({ work_orders: [wo] })));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Work orders/ }));
expect(screen.getByText(/^Scan a code at the labor kiosk to clock on in Visual\./)).toHaveTextContent(
"Scan a code at the labor kiosk to clock on in Visual. Codes are rebuilt from the packet (WO · lot · split · sub · seq), same as the printed overlay.",
);
expect(screen.getByTestId("kiosk-code")).toHaveTextContent("*%2L999001$7A$0$0$10%*");
fireEvent.click(screen.getByRole("button", { name: "Print traveler" }));
expect(open).toHaveBeenCalledWith("/api/packets/1/print/traveler", "_blank", "noopener");
const pdf = screen.getByRole("link", { name: "Traveler (PDF)" });
expect(pdf).toHaveAttribute("href", "/api/packets/1/pdf/traveler");
expect(pdf).toHaveAttribute("target", "_blank");
});
it("the Materials tab prints an MTR", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
const line = { item: 3, qty: 1, part_id: "P-1", description: "Pipe", dwg_ref: null, length: null, material: "A106", heat: "H123", mtr_asset_id: "mtr-3" };
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, bom: [line] })));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Materials/ }));
fireEvent.click(screen.getByRole("button", { name: "Print" }));
expect(open).toHaveBeenCalledWith("/api/packets/1/print/doc?asset=mtr-3", "_blank", "noopener");
});
it("the Documents tab prints a packet document and a folder document", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
const docs = [
{ id: 4, asset_id: "a4", kind: "nde", kind_label: "NDE report", title: "RT report", added_at: null, added_by: null, url: "/_blob/a4", removed: null },
];
const folder = [{ id: 1, type: "ITP", title: "L999001-ITP", rev: null, url: "/_blob/f1", scope: "unit" as const, added_at: null }];
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, docs, qc_folder_docs: folder })));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
fireEvent.click(within(screen.getByRole("region", { name: "Documents for the data book" })).getByRole("button", { name: "Print" }));
expect(open).toHaveBeenLastCalledWith("/api/packets/1/print/doc?asset=a4", "_blank", "noopener");
fireEvent.click(within(screen.getByRole("region", { name: "Also in the data book from the Q.C. folder" })).getByRole("button", { name: "Print" }));
expect(open).toHaveBeenLastCalledWith("/api/packets/1/print/doc?asset=f1", "_blank", "noopener");
});
it("the Drawing tab prints the shown sheet", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, pages: [page(1), page(2)] })));
renderApp("/packets/1");
await screen.findByRole("tab", { name: /^Drawing/ });
fireEvent.click(screen.getByRole("button", { name: /^p\.2/ }));
fireEvent.click(screen.getByRole("button", { name: "Print this sheet" }));
expect(open).toHaveBeenCalledWith("/api/packets/1/print/clean?sheet=2", "_blank", "noopener");
});
it.each([
["Weld map", "map-w"],
["Heat map", "map-h"],
])("the %s tab prints the shown sheet", async (tab, kind) => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, pages: [page(1), page(2)] })));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: new RegExp(`^${tab}`) }));
fireEvent.click(within(screen.getByRole("group", { name: "Sheets" })).getAllByRole("button")[1]!);
fireEvent.click(screen.getByRole("button", { name: "Print this sheet" }));
expect(open).toHaveBeenCalledWith(`/api/packets/1/print/${kind}?sheet=2`, "_blank", "noopener");
});
});
describe("History tab", () => {
it("lists the printed copies and exports", async () => {
const prints = [
{ id: 2, at: "2026-10-17T10:00:00Z", by: { id: "u1", display_name: "Clara Clerk" }, kind: "traveler", what: "Job traveler", pages: 2 },
{ id: 1, at: "2026-10-17T09:00:00Z", by: null, kind: "csv", what: "Traceability CSV", pages: null },
];
mockFetch(base(makePacket(), { "/api/packets/1/prints": { body: { prints } } }));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: "History" }));
const sec = await screen.findByRole("region", { name: "Printed copies and exports" });
const items = await within(sec).findAllByRole("listitem");
expect(items.map((i) => i.textContent)).toEqual([expect.stringMatching(/ · Job traveler · 2 pages · Clara Clerk$/), expect.stringMatching(/ · Traceability CSV$/)]);
});
it("says when nothing was printed", async () => {
mockFetch(base(makePacket(), { "/api/packets/1/prints": { body: { prints: [] } } }));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: "History" }));
const sec = await screen.findByRole("region", { name: "Printed copies and exports" });
expect(await within(sec).findByText("Nothing printed yet.")).toBeInTheDocument();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { useId } from "react";
import type { PacketRead } from "../api/types";
import { Sheet } from "../components/Sheet";
import { openPrint, printRows } from "./closeUtil";
/** The print menu: one row per print kind. A row opens the stamped PDF in a new tab and closes the sheet. */
export function PrintMenu({ p, sub, onClose }: { p: PacketRead; sub: string; onClose: () => void }) {
const base = useId();
const rows = printRows(p);
return (
<Sheet title="Print" sub={sub} onClose={onClose}>
<div className="ck">
{rows.map((r) => (
<button
key={r.kind}
type="button"
className="ck-item prow"
aria-label={r.label}
aria-describedby={`${base}-${r.kind}`}
onClick={() => {
openPrint(p.id, r.kind);
onClose();
}}
>
<span className="t">
<b>{r.label}</b>
<span className="small muted" id={`${base}-${r.kind}`}>
{r.desc}
</span>
</span>
</button>
))}
</div>
<div className="small muted">
Every printed page is stamped with who printed it and when, plus &quot;verify revision before use&quot;. Printing is recorded.
</div>
<div className="sheet-acts start">
<button className="btn ghost" type="button" onClick={onClose}>
Cancel
</button>
</div>
</Sheet>
);
}
+6
View File
@@ -8,6 +8,7 @@ import { voidText } from "../lib/voidStamp";
import { weldId, weldSummary } from "../lib/weldDefaults"; import { weldId, weldSummary } from "../lib/weldDefaults";
import type { Write, WriteResult } from "../warehouse/PullTable"; import type { Write, WriteResult } from "../warehouse/PullTable";
import { MapViewer } from "./MapViewer"; import { MapViewer } from "./MapViewer";
import { openPrint } from "./closeUtil";
import { WeldMark, type MarkMove } from "./marks"; import { WeldMark, type MarkMove } from "./marks";
import { landingPage, pageLabel, usePeople, viewPages } from "./shopUtil"; import { landingPage, pageLabel, usePeople, viewPages } from "./shopUtil";
import { useSerial } from "./useSerial"; import { useSerial } from "./useSerial";
@@ -167,6 +168,11 @@ export function WeldMapTab({
{p.welds.length} weld{p.welds.length === 1 ? "" : "s"} recorded {p.welds.length} weld{p.welds.length === 1 ? "" : "s"} recorded
</span> </span>
) : null} ) : null}
{!p.removed ? (
<button className="btn sm" type="button" onClick={() => openPrint(p.id, "map-w", { sheet: page.n })}>
Print this sheet
</button>
) : null}
</div> </div>
<div className="dv"> <div className="dv">
<MapViewer <MapViewer
+96
View File
@@ -0,0 +1,96 @@
// Phase 7 helpers the close panel, the print menu and the tabs share: opening PDFs, the print rows, the kiosk
// code, the VISUAL observed line.
import { packetsApi } from "../api/packets";
import type { PacketRead, PrintKind, PrintRow, VcloseCheck, WorkOrder } from "../api/types";
import { fmtTime } from "../lib/format";
/** The rule's signature meanings for FILE and ADMIN_REOPEN. */
export const SIGN_FILE = "File in the data book";
export const SIGN_REOPEN = "Reopen a filed packet";
/** Opens a PDF (or any server URL) in a new tab. Every PDF view and print in the packet goes through here. */
export function openTab(url: string): void {
window.open(url, "_blank", "noopener");
}
/** Opens a printed copy of one kind. The server stamps it and records the print. */
export function openPrint(id: number, kind: PrintKind, opts: { sheet?: number | undefined; asset?: string | undefined } = {}): void {
openTab(packetsApi.printUrl(id, kind, opts));
}
/** The asset id of a "/_blob/<id>" URL, for documents whose answer has no asset_id. */
export function assetOfUrl(url: string): string | undefined {
const m = /\/_blob\/([^/?#]+)/.exec(url);
return m?.[1] ? decodeURIComponent(m[1]) : undefined;
}
export interface PrintRowDef {
kind: Exclude<PrintKind, "doc">;
label: string;
desc: string;
}
/** The rows of the print menu for this packet, as the old openPrintMenu. */
export function printRows(p: PacketRead): PrintRowDef[] {
const drawings = p.pages.filter((x) => x.type === "drawing");
const live = drawings.filter((x) => !x.void).length;
const voids = drawings.filter((x) => !!x.void).length;
const one = live === 1;
const rows: PrintRowDef[] = [
{
kind: "traveler",
label: "Job traveler",
desc: "Job info, work order QR codes for clocking on, BOM and heat numbers, checklist with blanks to stamp, welds, sign-offs, notes",
},
{ kind: "map-h", label: "Drawing with heat map", desc: one ? "The drawing with its heat table" : `All ${live} drawing sheets with heat tables` },
{ kind: "map-w", label: "Drawing with weld map", desc: one ? "The drawing with weld locations" : `All ${live} drawing sheets with weld locations` },
{ kind: "clean", label: "Drawing (no markup)", desc: "The approved drawing without markup" },
];
if (voids) {
rows.push({
kind: "void",
label: "Superseded drawings (VOID)",
desc: `${voids} replaced drawing sheet${voids === 1 ? "" : "s"} with their weld marks, stamped VOID`,
});
}
if (p.status === "complete" || p.status === "filed") {
rows.push({ kind: "databook", label: "Data book", desc: "The full manufacturing data book (same as the PDF export)" });
}
return rows;
}
/**
* The labor kiosk code of a work order, as the old qrText: `*%2{wo}${lot}${split}${sub}${seq}%*`. The columns stay
* text (never cast); an empty split or sub is "0".
*/
export function kioskCode(w: Pick<WorkOrder, "wo_base" | "lot" | "split" | "sub" | "seq">): string {
const seq = w.seq === null || w.seq === undefined ? "" : String(w.seq);
return `*%2${w.wo_base}$${w.lot}$${w.split || "0"}$${w.sub || "0"}$${seq}%*`;
}
/** "{date} · {what} · {pages} pages · {name}". */
export function printLineText(r: PrintRow): string {
const pages = r.pages === null || r.pages === undefined ? null : `${r.pages} page${r.pages === 1 ? "" : "s"}`;
return [fmtTime(r.at), r.what, pages, r.by?.display_name ?? null].filter((x): x is string => !!x).join(" · ");
}
/** The VISUAL observed state as one line, or null when there is nothing to say (a hand packet). */
export function visualLine(c: VcloseCheck | null | undefined): string | null {
if (!c) return null;
const open = c.open_wos ?? [];
if (c.closed_at) return `VISUAL shows the job closed since ${fmtTime(c.closed_at)}.`;
if (open.length) return `VISUAL still shows ${open.length} work order${open.length === 1 ? "" : "s"} open: ${open.join(", ")}.`;
if (!c.observed_at) return "The feed has not checked VISUAL yet.";
return null;
}
/**
* True when the close and file panel shows for this packet and user: filed (read-only for everyone), or complete for
* whoever may close, file or reopen, and for everyone after a reopen (the broken seal line).
*/
export function showsCloseFile(p: PacketRead): boolean {
if (p.removed) return false;
if (p.status === "filed") return true;
if (p.status !== "complete") return false;
return !!p.seal?.broken_at || p.can.includes("set_vclosed") || p.can.includes("file") || !!p.blocked.file || p.can.includes("admin_reopen");
}
+269
View File
@@ -0,0 +1,269 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PacketRead, PacketSeal, SealView, VcloseCheck } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch, type Route } from "../test/fetchMock";
import { makePacket } from "../test/packets";
const CLARA = { id: "11111111-1111-1111-1111-111111111111", display_name: "Clara Clerk" };
const ANN = { id: "22222222-2222-2222-2222-222222222222", display_name: "Ann Admin" };
function check(over: Partial<VcloseCheck> = {}): VcloseCheck {
return { observed_at: "2026-10-17T07:00:00Z", closed_at: null, open_wos: [], agrees: true, ...over };
}
function seal(over: Partial<PacketSeal> = {}): PacketSeal {
return {
id: 4,
sealed_at: "2026-10-17T10:00:00Z",
sealed_by: CLARA,
key_id: "k1",
signed: true,
broken_at: null,
broken_by: null,
broken_reason: null,
open_wos: null,
databook: { asset_id: "a-db", url: "/_blob/a-db", download_url: "/_blob/a-db?download=1", pages: 14, size: 90000, file_name: "L999-LotL999001_CI-DWG-1_Manufacturing_Data_Book.pdf" },
...over,
};
}
/** Complete, routed to the data book, not confirmed closed in VISUAL. */
function toClose(over: Partial<PacketRead> = {}): PacketRead {
return makePacket({
status: "complete",
routed_to: "databook",
holder: "Clerical",
source_kind: "visual",
can: ["set_vclosed"],
blocked: { file: ["Close the job in VISUAL first."] },
vclose_check: check(),
...over,
});
}
/** Complete and confirmed closed: ready for the data book. */
function toFile(over: Partial<PacketRead> = {}): PacketRead {
return toClose({ vclosed: { at: "2026-10-17T09:00:00Z", by: CLARA }, can: ["file", "set_vclosed"], blocked: {}, ...over });
}
function filed(over: Partial<PacketRead> = {}): PacketRead {
return toFile({ status: "filed", version: 9, databook_at: "2026-10-17T10:00:00Z", seal: seal(), seals: 1, can: [], blocked: {}, ...over });
}
function base(packet: PacketRead, extra: Record<string, Route> = {}, roles = ["clerical"]) {
return {
"/api/auth/me": { body: makeMe({ roles }) },
"/api/departments": { body: DEPARTMENTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
...extra,
};
}
function sealView(valid: boolean | null, s: PacketSeal = seal()): SealView {
return { seal: s, valid, seals: [s] };
}
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());
}
const panel = () => screen.findByRole("region", { name: "Close and file" });
afterEach(() => {
vi.restoreAllMocks();
});
describe("Close and file: ready to close", () => {
it("shows the state, the exports, and confirms the close", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
const fetch = mockFetch(base(toClose(), { "POST /api/packets/1/vclosed": { body: toFile({ version: 4 }) } }));
renderApp("/packets/1");
const box = await panel();
expect(box).toHaveTextContent("Back from the shop, ready to close.");
const file = within(box).getByRole("button", { name: "File in the data book" });
expect(file).toBeDisabled();
expect(within(box).getByText("Close the job in VISUAL first.")).toHaveClass("why");
fireEvent.click(within(box).getByRole("button", { name: "Data book (PDF)" }));
expect(open).toHaveBeenCalledWith("/api/packets/1/pdf/databook", "_blank", "noopener");
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("href", "/api/packets/1/pdf/databook?download=1");
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("download");
expect(within(box).getByRole("link", { name: "Traceability (CSV)" })).toHaveAttribute("href", "/api/packets/1/export/traceability.csv");
fireEvent.click(within(box).getByRole("button", { name: "Print…" }));
expect(await screen.findByRole("dialog", { name: "Print" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
fireEvent.click(within(box).getByRole("button", { name: "Job closed in VISUAL" }));
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book."));
expect(callsTo(fetch, "POST", "/api/packets/1/vclosed")).toEqual([{ closed: true }]);
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent(/Job closed in VISUAL .* by Clara Clerk\./);
});
it.each([
["the feed saw the job closed", check({ closed_at: "2026-10-17T06:00:00Z", agrees: false }), /^VISUAL shows the job closed since .+\.$/],
["work orders still open", check({ open_wos: ["L999001/001", "L999001/002"] }), /^VISUAL still shows 2 work orders open: L999001\/001, L999001\/002\.$/],
["one work order open", check({ open_wos: ["L999001/001"] }), /^VISUAL still shows 1 work order open: L999001\/001\.$/],
["the feed has not looked", check({ observed_at: null }), /^The feed has not checked VISUAL yet\.$/],
])("the VISUAL line when %s", async (_name, c, text) => {
mockFetch(base(toClose({ vclose_check: c })));
renderApp("/packets/1");
const box = await panel();
expect(within(box).getByTestId("visual-line")).toHaveTextContent(text);
});
it("a hand packet has no VISUAL line", async () => {
mockFetch(base(toClose({ source_kind: "hand", vclose_check: null })));
renderApp("/packets/1");
const box = await panel();
expect(box).toHaveTextContent("Back from the shop, ready to close.");
expect(within(box).queryByTestId("visual-line")).not.toBeInTheDocument();
});
it("is not shown before complete or to a role with nothing to do", async () => {
mockFetch(base(toClose({ can: [], blocked: {} }), {}, ["warehouse"]));
renderApp("/packets/1");
await screen.findByRole("tab", { name: /^Materials/ });
expect(screen.queryByRole("region", { name: "Close and file" })).not.toBeInTheDocument();
});
});
describe("Close and file: ready for the data book", () => {
it("files with the signature", async () => {
const fetch = mockFetch(
base(toFile(), {
"POST /api/packets/1/transitions": (req) =>
(req.body as { sign?: unknown }).sign
? { body: filed() }
: { status: 400, body: { code: "signature_required", detail: "This step needs your signature.", meaning: "File in the data book" } },
"/api/packets/1/seal": { body: sealView(true) },
}),
);
renderApp("/packets/1");
const box = await panel();
expect(box).toHaveTextContent("Ready for the data book.");
expect(within(box).getByRole("button", { name: "Not closed in VISUAL, move back" })).toBeEnabled();
fireEvent.click(within(box).getByRole("button", { name: "File in the data book" }));
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("File in the data book")).toBeInTheDocument();
await sign();
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed in the data book"));
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "file", expect: { status: "complete", dept: "03" } },
{ action: "file", expect: { status: "complete", dept: "03" }, sign: { factor: "password", secret: "pw-1" } },
]);
});
it("warns in a callout when VISUAL still shows work orders open", async () => {
mockFetch(base(toFile({ vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) })));
renderApp("/packets/1");
const box = await panel();
const line = within(box).getByTestId("visual-line");
expect(line).toHaveClass("callout", "warn");
expect(line).toHaveTextContent("VISUAL still shows 1 work order open: L999001/001.");
});
it("asks on 409 open_work_orders and sends it again with confirm_open_wos", async () => {
const msg = "VISUAL still shows 1 work order open on this packet: L999001/001. File anyway?";
const fetch = mockFetch(
base(toFile({ vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) }), {
"POST /api/packets/1/transitions": (req) => {
const b = req.body as { sign?: unknown; confirm_open_wos?: boolean };
if (!b.confirm_open_wos) return { status: 409, body: { code: "open_work_orders", detail: msg, work_orders: ["L999001/001"] } };
if (!b.sign) return { status: 400, body: { code: "signature_required", detail: "This step needs your signature.", meaning: "File in the data book" } };
return { body: filed({ seal: seal({ open_wos: ["L999001/001"] }) }) };
},
"/api/packets/1/seal": { body: sealView(true, seal({ open_wos: ["L999001/001"] })) },
}),
);
renderApp("/packets/1");
fireEvent.click(within(await panel()).getByRole("button", { name: "File in the data book" }));
const sheet = await screen.findByRole("dialog", { name: "File with open work orders?" });
expect(within(sheet).getByText(msg)).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
fireEvent.click(within(sheet).getByRole("button", { name: "File anyway" }));
await sign();
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed in the data book"));
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "file", expect: { status: "complete", dept: "03" } },
{ action: "file", expect: { status: "complete", dept: "03" }, confirm_open_wos: true },
{ action: "file", expect: { status: "complete", dept: "03" }, confirm_open_wos: true, sign: { factor: "password", secret: "pw-1" } },
]);
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed with work orders open in VISUAL: L999001/001.");
});
it("Cancel on the confirm sends nothing more", async () => {
const fetch = mockFetch(
base(toFile(), {
"POST /api/packets/1/transitions": { status: 409, body: { code: "open_work_orders", detail: "VISUAL still shows 1 work order open.", work_orders: ["L999001/001"] } },
}),
);
renderApp("/packets/1");
fireEvent.click(within(await panel()).getByRole("button", { name: "File in the data book" }));
const sheet = await screen.findByRole("dialog", { name: "File with open work orders?" });
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "File with open work orders?" })).not.toBeInTheDocument());
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toHaveLength(1);
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book.");
});
});
describe("Close and file: filed", () => {
it("shows the seal intact and opens the filed data book", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(true) } }));
renderApp("/packets/1");
const box = await panel();
expect(box).toHaveTextContent(/Filed in the data book .* by Clara Clerk\. The data book has 14 pages\./);
expect(await within(box).findByText("Seal intact (key k1)")).toHaveClass("pill", "st-ok");
fireEvent.click(within(box).getByRole("button", { name: "Data book (PDF)" }));
expect(open).toHaveBeenCalledWith("/_blob/a-db", "_blank", "noopener");
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("href", "/_blob/a-db?download=1");
expect(within(box).getByRole("link", { name: "Traceability (CSV)" })).toBeInTheDocument();
expect(within(box).getByRole("button", { name: "Print…" })).toBeInTheDocument();
expect(within(box).queryByRole("button", { name: "Reopen (IT)" })).not.toBeInTheDocument();
expect(within(box).queryByRole("button", { name: "File in the data book" })).not.toBeInTheDocument();
});
it("says when the seal does not match", async () => {
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(false) } }));
renderApp("/packets/1");
const box = await panel();
const state = await within(box).findByTestId("seal-state");
expect(state).toHaveClass("callout", "warn");
expect(state).toHaveTextContent("Seal does not match the record.");
});
it("says when the seal cannot be checked", async () => {
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(null) } }));
renderApp("/packets/1");
const box = await panel();
expect(await within(box).findByTestId("seal-state")).toHaveTextContent("Seal unknown (key not available)");
});
it("IT reopens with the reason and the signature; the panel keeps the broken seal line", async () => {
const reopened = toFile({
version: 10,
can: ["admin_reopen"],
seal: seal({ broken_at: "2026-10-17T11:00:00Z", broken_by: ANN, broken_reason: "wrong MTR filed" }),
seals: 1,
});
const fetch = mockFetch(
base(filed({ can: ["admin_reopen"] }), { "/api/packets/1/seal": { body: sealView(true) }, "POST /api/packets/1/reopen": { body: reopened } }, ["admin"]),
);
renderApp("/packets/1");
const box = await panel();
fireEvent.click(within(box).getByRole("button", { name: "Reopen (IT)" }));
const sheet = await screen.findByRole("dialog", { name: "Reopen a filed packet" });
fireEvent.change(within(sheet).getByLabelText("Reason"), { target: { value: "wrong MTR filed" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Reopen" }));
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Reopen a filed packet")).toBeInTheDocument();
await sign();
await waitFor(() => expect(screen.getByTestId("reopened")).toHaveTextContent(/^Reopened by Ann Admin .+: wrong MTR filed\. The earlier seal is kept as broken\.$/));
expect(callsTo(fetch, "POST", "/api/packets/1/reopen")).toEqual([{ reason: "wrong MTR filed", sign: { factor: "password", secret: "pw-1" } }]);
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book.");
});
});
+112 -8
View File
@@ -2,7 +2,7 @@ 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, LogRow, PacketDoc, PacketRead, QcHistoryRow, WorkOrder } from "../api/types"; import type { BomLine, Guarded, LogRow, PacketDoc, PacketRead, PrintRow, QcHistoryRow, WorkOrder } from "../api/types";
import type { ReasonOptions } from "../components/ReasonSheet"; import type { ReasonOptions } from "../components/ReasonSheet";
import { Sheet } from "../components/Sheet"; import { Sheet } from "../components/Sheet";
import { errorText, fmtTime } from "../lib/format"; import { errorText, fmtTime } from "../lib/format";
@@ -11,6 +11,7 @@ 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";
import { ShortSheet } from "../warehouse/ShortSheet"; import { ShortSheet } from "../warehouse/ShortSheet";
import { assetOfUrl, kioskCode, openPrint, printLineText } from "./closeUtil";
export type Mutate = ( export type Mutate = (
fn: (g: Guarded) => Promise<PacketRead | null | undefined>, fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
@@ -209,6 +210,19 @@ export function MaterialsTab({
<button type="button" className="lnk small" onClick={() => setViewFor(b.item)}> <button type="button" className="lnk small" onClick={() => setViewFor(b.item)}>
MTR MTR
</button> </button>
{!p.removed ? (
<>
{" "}
<button
type="button"
className="btn sm ghost"
title={`Print the MTR of item ${b.item}`}
onClick={() => openPrint(p.id, "doc", { asset: b.mtr_asset_id ?? undefined })}
>
Print
</button>
</>
) : null}
</> </>
) : null} ) : null}
</td> </td>
@@ -279,11 +293,28 @@ function Qr({ text }: { text: string }) {
); );
} }
/** The work orders, and one QR code per work order key. */ /** The work orders, the kiosk QR code of each (as the old ticketsTab), and the traveler print. */
export function WorkOrdersTab({ p }: { p: PacketRead }) { export function WorkOrdersTab({ p }: { p: PacketRead }) {
if (!p.work_orders.length) return <div className="empty">No work orders on this packet.</div>; if (!p.work_orders.length) return <div className="empty">No work orders on this packet.</div>;
return ( return (
<div className="stack"> <div className="stack">
<div className="callout flush">
<div className="acts center">
<span className="grow">
Scan a code at the labor kiosk to clock on in Visual. Codes are rebuilt from the packet (WO · lot · split · sub · seq), same as the printed overlay.
</span>
{!p.removed ? (
<>
<button className="btn" type="button" onClick={() => openPrint(p.id, "traveler")}>
Print traveler
</button>
<a className="btn ghost" href={packetsApi.pdfUrl(p.id, "traveler")} target="_blank" rel="noopener">
Traveler (PDF)
</a>
</>
) : null}
</div>
</div>
<div className="tw"> <div className="tw">
<table> <table>
<thead> <thead>
@@ -316,13 +347,21 @@ export function WorkOrdersTab({ p }: { p: PacketRead }) {
</div> </div>
<div className="qrs"> <div className="qrs">
{p.work_orders.map((w) => { {p.work_orders.map((w) => {
const k = woKey(w); const code = kioskCode(w);
return ( return (
<div key={w.id ?? k} className="qr"> <div key={w.id ?? woKey(w)} className="qr">
<Qr text={k} /> <Qr text={code} />
<div> <div>
<div className="wo">{w.wo_base}</div> <div className="wo">{w.wo_base}</div>
<div className="mono small">{k}</div> <div className="small">
Lot <b className="mono">{w.lot}</b> · Seq <b className="mono">{show(w.seq)}</b> {w.op ?? ""}
</div>
<div className="small">
Target <b className="mono">{show(w.target_hrs)} h</b> · Qty <b className="mono">{show(w.qty)}</b>
</div>
<div className="mono small" data-testid="kiosk-code">
{code}
</div>
</div> </div>
</div> </div>
); );
@@ -420,7 +459,21 @@ function AddDocSheet({ p, sub, write, onDone, onCancel }: { p: PacketRead; sub:
); );
} }
function DocRow({ d, old, canRemove, busy, onOut }: { d: PacketDoc; old: boolean; canRemove: boolean; busy: boolean; onOut: () => void }) { function DocRow({
d,
old,
canRemove,
busy,
onOut,
onPrint,
}: {
d: PacketDoc;
old: boolean;
canRemove: boolean;
busy: boolean;
onOut: () => void;
onPrint?: (() => void) | undefined;
}) {
const label = d.kind_label || docKindLabel(d.kind); 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); 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 ( return (
@@ -440,6 +493,11 @@ function DocRow({ d, old, canRemove, busy, onOut }: { d: PacketDoc; old: boolean
<button className="btn sm" type="button" onClick={() => openBlob(d.url)}> <button className="btn sm" type="button" onClick={() => openBlob(d.url)}>
View View
</button> </button>
{!old && onPrint ? (
<button className="btn sm" type="button" onClick={onPrint}>
Print
</button>
) : null}
{!old && canRemove ? ( {!old && canRemove ? (
<button className="btn sm ghost" type="button" disabled={busy} onClick={onOut}> <button className="btn sm ghost" type="button" disabled={busy} onClick={onOut}>
Take out of data book Take out of data book
@@ -511,7 +569,17 @@ export function DocumentsTab({
</div> </div>
<div className="qcdocs"> <div className="qcdocs">
{live.length ? ( {live.length ? (
live.map((d) => <DocRow key={d.id} d={d} old={false} canRemove={canRemove} busy={busy} onOut={() => void takeOut(d)} />) live.map((d) => (
<DocRow
key={d.id}
d={d}
old={false}
canRemove={canRemove}
busy={busy}
onOut={() => void takeOut(d)}
onPrint={p.removed ? undefined : () => openPrint(p.id, "doc", { asset: d.asset_id })}
/>
))
) : ( ) : (
<div className="empty slim">No documents added to this packet yet.{canAdd ? " Use Add document." : " Q.C. or Clerical add them."}</div> <div className="empty slim">No documents added to this packet yet.{canAdd ? " Use Add document." : " Q.C. or Clerical add them."}</div>
)} )}
@@ -551,6 +619,11 @@ export function DocumentsTab({
<button className="btn sm" type="button" onClick={() => openBlob(d.url)}> <button className="btn sm" type="button" onClick={() => openBlob(d.url)}>
View View
</button> </button>
{!p.removed ? (
<button className="btn sm" type="button" onClick={() => openPrint(p.id, "doc", { asset: d.asset_id ?? assetOfUrl(d.url) })}>
Print
</button>
) : null}
</div> </div>
</div> </div>
)) ))
@@ -582,10 +655,41 @@ export function HistoryTab({ p }: { p: PacketRead }) {
) : ( ) : (
<div className="empty slim">Nothing recorded yet.</div> <div className="empty slim">Nothing recorded yet.</div>
)} )}
<PrintsList p={p} />
</div> </div>
); );
} }
/** GET /prints: the printed copies and exports of this packet, newest first. */
function PrintsList({ p }: { p: PacketRead }) {
const [load] = useLoad((s) => packetsApi.prints(p.id, s), `${p.id}:${p.version}`);
return (
<section className="sec" aria-label="Printed copies and exports">
<div className="sec-h">
<h2>Printed copies and exports</h2>
{load.state === "ok" ? <span className="ct">{load.data.length}</span> : null}
</div>
{load.state === "loading" ? (
<div className="empty slim">Loading</div>
) : load.state === "error" ? (
<div className="callout warn">{load.message}</div>
) : load.data.length ? (
<ul className="prints">
{load.data.map((r) => (
<PrintLine key={r.id} r={r} />
))}
</ul>
) : (
<div className="empty slim">Nothing printed yet.</div>
)}
</section>
);
}
function PrintLine({ r }: { r: PrintRow }) {
return <li className="small">{printLineText(r)}</li>;
}
/** One row per closed Q.C. inspection, oldest first. */ /** One row per closed Q.C. inspection, oldest first. */
function QcInspections({ rows }: { rows: QcHistoryRow[] }) { function QcInspections({ rows }: { rows: QcHistoryRow[] }) {
if (!rows.length) return null; if (!rows.length) return null;
+21 -1
View File
@@ -5,6 +5,8 @@ import {
enterNcr, enterNcr,
getItp, getItp,
getUnitFolder, getUnitFolder,
itpPdfUrl,
itpPrintUrl,
patchBlock, patchBlock,
patchItp, patchItp,
patchLine, patchLine,
@@ -445,6 +447,15 @@ export function ItpPage() {
</div> </div>
</div> </div>
<div className="pacts"> <div className="pacts">
<button className="btn sm" type="button" onClick={() => window.open(itpPdfUrl(id), "_blank", "noopener")}>
ITP (PDF)
</button>
<a className="btn sm ghost" href={itpPdfUrl(id, true)} download>
Download
</a>
<button className="btn sm" type="button" onClick={() => window.open(itpPrintUrl(id), "_blank", "noopener")}>
Print
</button>
{r.can.sign_off ? ( {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 })}> <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 Sign off ITP
@@ -461,7 +472,16 @@ export function ItpPage() {
{complete && r.signoff ? ( {complete && r.signoff ? (
<div className="callout mb10" role="note"> <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{" "} 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."} {r.signoff.asset_id ? (
<>
is filed in this unit&apos;s documents.{" "}
<a className="lnk" href={`/_blob/${encodeURIComponent(r.signoff.asset_id)}`} target="_blank" rel="noopener">
Signed PDF
</a>
</>
) : (
"will be filed when the ITP PDF is built."
)}
</div> </div>
) : null} ) : null}
+52
View File
@@ -0,0 +1,52 @@
import { fireEvent, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Itp } from "../../api/qc";
import { DEPARTMENTS, renderApp } from "../../test/auth";
import { mockFetch } from "../../test/fetchMock";
import { makeItp, makeUnitFolder, QUINN } from "./fixtures";
function base(itp: Itp) {
return {
"/api/auth/me": { body: QUINN },
"/api/departments": { body: DEPARTMENTS },
"/api/units/12/itp": { body: itp },
"/api/qc/folders/units/12": { body: makeUnitFolder() },
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("ItpPage PDF and print", () => {
it("opens the ITP PDF, offers the download, and opens the printed copy", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetch(base(makeItp()));
renderApp("/qc/units/12/itp");
fireEvent.click(await screen.findByRole("button", { name: "ITP (PDF)" }));
expect(open).toHaveBeenLastCalledWith("/api/itps/5/pdf", "_blank", "noopener");
const dl = screen.getByRole("link", { name: "Download" });
expect(dl).toHaveAttribute("href", "/api/itps/5/pdf?download=1");
expect(dl).toHaveAttribute("download");
fireEvent.click(screen.getByRole("button", { name: "Print" }));
expect(open).toHaveBeenLastCalledWith("/api/itps/5/print", "_blank", "noopener");
expect(screen.queryByRole("link", { name: "Signed PDF" })).not.toBeInTheDocument();
});
it("links the signed PDF on a signed-off ITP", async () => {
mockFetch(
base(
makeItp({
status: "complete",
can: { edit: false, sign_off: false, reopen: false },
signoff: { at: "2026-10-17T12:00:00Z", by: { id: "u-quinn", display_name: "Quinn QC" }, asset_id: "a-signed" },
}),
),
);
renderApp("/qc/units/12/itp");
const link = await screen.findByRole("link", { name: "Signed PDF" });
expect(link).toHaveAttribute("href", "/_blob/a-signed");
expect(link).toHaveAttribute("target", "_blank");
expect(screen.getByRole("note")).toHaveTextContent(/a signed PDF copy is filed in this unit's documents\. Signed PDF$/);
});
});
+6
View File
@@ -633,3 +633,9 @@ a.itpcard { flex-direction: row; text-align: left; }
.itpck input { width: 20px; height: 20px; } .itpck input { width: 20px; height: 20px; }
.itplog { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; color: var(--body); } .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); } .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); }
/* Phase 7: a print menu row is a whole-width button in the .ck list. */
.ck-item.prow { width: 100%; text-align: left; font: inherit; color: inherit; }
.ck-item.prow .t .small { display: block; margin-top: 2px; font-weight: 400; }
.prints { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.callout .acts .grow { min-width: 240px; }
+2
View File
@@ -26,6 +26,8 @@ dependencies = [
"python-multipart==0.0.32", "python-multipart==0.0.32",
"pypdfium2==5.14.0", "pypdfium2==5.14.0",
"openpyxl==3.1.5", "openpyxl==3.1.5",
"reportlab==5.0.1",
"pillow==13.0.0",
"visualkit==0.3.0", "visualkit==0.3.0",
"mfileskit==0.1.2", "mfileskit==0.1.2",
] ]
+98 -5
View File
@@ -6,10 +6,11 @@ answer is the full ITP read. ITP writes never look at a packet: a filed packet i
from __future__ import annotations from __future__ import annotations
import logging
from datetime import date from datetime import date
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Query, Request, Response
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -17,12 +18,16 @@ from .. import policy
from ..domain import itp as dom from ..domain import itp as dom
from ..domain import ncr as ncrs from ..domain import ncr as ncrs
from ..domain import packets from ..domain import packets
from ..domain import prints as prints_dom
from ..domain.itp_template import ITP_TEMPLATE from ..domain.itp_template import ITP_TEMPLATE
from ..domain.pipeline import SignBody, apply_field_cas, jsonable, parse_if_match from ..domain.pipeline import SignBody, apply_field_cas, jsonable, parse_if_match
from ..text import LIMITS, clean_opt, clean_text from ..text import LIMITS, clean_opt, clean_text
from .deps import Db, Principal, current_principal, get_db from .deps import Db, Principal, current_principal, get_db, settings_of
from .errors import ApiError from .errors import ApiError
from .packets import WRITE from .packets import WRITE
from .prints import file_response, now_utc, pdf_failed, stamp_of
log = logging.getLogger(__name__)
router = APIRouter(tags=["itp"]) router = APIRouter(tags=["itp"])
@@ -658,8 +663,10 @@ def sign_off(
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]:
"""Sign the ITP off: every block signed. The ITP locks; the PDF hook (phase 7) may file a copy.""" """Sign the ITP off: every block signed. The ITP locks and the signed PDF is filed in the unit's Q.C. folder.
A PDF that cannot be built answers 500 itp_pdf_failed and nothing is signed off."""
from ..pdf import itp as itp_pdf from ..pdf import itp as itp_pdf
from ..pdf.style import PdfError
conn = d.conn conn = d.conn
row0 = dom.load_row(conn, itp_id) row0 = dom.load_row(conn, itp_id)
@@ -676,12 +683,19 @@ def sign_off(
) )
row = dom.load_row(conn, itp_id) row = dom.load_row(conn, itp_id)
assert row is not None assert row is not None
asset_id = itp_pdf.file_signed_itp_pdf(conn, row, p.actor) try:
asset_id = itp_pdf.file_signed_itp_pdf(conn, row, p.actor, settings=settings_of(request))
except PdfError as e:
log.warning("signed ITP PDF for ITP %s failed: %s", itp_id, e)
text = " ".join(str(e).split()).rstrip(".")[:300] or "an unknown error"
raise ApiError(
500, "itp_pdf_failed", f"The signed ITP PDF could not be built: {text}. The ITP was not signed off."
) from None
if asset_id is not None: if asset_id is not None:
_file_auto_doc(w, row, asset_id) _file_auto_doc(w, row, asset_id)
w.log("ITP signed off", "PDF filed") w.log("ITP signed off", "PDF filed")
else: else:
w.log("ITP signed off", "PDF filing reserved for phase 7") w.log("ITP signed off")
return _answer(d, p, itp_id) return _answer(d, p, itp_id)
@@ -742,3 +756,82 @@ def reopen(
) )
w.log("ITP reopened", reason) w.log("ITP reopened", reason)
return _answer(d, p, itp_id) return _answer(d, p, itp_id)
# ---------------------------------------------------------------- the ITP PDF and its printed copy
def _itp_row(d: Db, 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 row
@router.get("/api/itps/{itp_id}/pdf")
def itp_pdf_file(
itp_id: int,
request: Request,
download: int = Query(0, ge=0, le=1),
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
"""The ITP PDF built now, open or complete; the stamp says which. Logged as an export."""
from ..pdf import itp as itp_pdf
from ..pdf.style import PdfError
conn = d.conn
row = _itp_row(d, itp_id)
try:
built = itp_pdf.build(conn, settings_of(request), row, who=prints_dom.who_of(p), now=now_utc())
except PdfError as e:
raise pdf_failed(e) from None
stamp, sig = stamp_of(built)
prints_dom.record(
conn,
p,
action="export",
kind="itp-pdf",
what=f"ITP {row['no'] or ''}".strip(),
pages=built.pages,
unit_id=row["unit_id"],
itp_id=itp_id,
stamp=stamp,
stamp_sig=sig,
)
return file_response(built.bytes, prints_dom.itp_name(row["no"]), download=bool(download))
@router.get("/api/itps/{itp_id}/print")
def itp_print(
itp_id: int,
request: Request,
download: int = Query(0, ge=0, le=1),
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
"""The ITP PDF stamped PRINTED COPY. Logged as a print."""
from ..pdf import prints as print_pdf
from ..pdf.style import PdfError
conn = d.conn
row = _itp_row(d, itp_id)
try:
built = print_pdf.print_itp(conn, settings_of(request), row, who=prints_dom.who_of(p), now=now_utc())
except PdfError as e:
raise pdf_failed(e) from None
stamp, sig = stamp_of(built)
what = str(getattr(built, "what", None) or f"ITP {row['no'] or ''}".strip())
prints_dom.record(
conn,
p,
action="print",
kind="itp",
what=what,
pages=built.pages,
unit_id=row["unit_id"],
itp_id=itp_id,
stamp=stamp,
stamp_sig=sig,
)
return file_response(built.bytes, prints_dom.itp_name(row["no"]), download=bool(download))
+5 -2
View File
@@ -113,6 +113,7 @@ class TransitionBody(BaseModel):
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) ncr: str | None = Field(default=None, max_length=100)
confirm_open_wos: bool | None = None
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)
@@ -501,7 +502,7 @@ TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
A.QC_REJECT: ("reason", "ncr"), 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", "confirm_open_wos"),
A.ADMIN_REOPEN: ("reason",), A.ADMIN_REOPEN: ("reason",),
} }
DEPT_ACTIONS = (A.RELEASE_TO_SHOP, A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE) DEPT_ACTIONS = (A.RELEASE_TO_SHOP, A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE)
@@ -918,7 +919,9 @@ def set_vclosed(
"SELECT DISTINCT wo_base FROM packet_work_orders WHERE packet_id = %s ORDER BY 1", (packet_id,) "SELECT DISTINCT wo_base FROM packet_work_orders WHERE packet_id = %s ORDER BY 1", (packet_id,)
) )
] ]
w.note = ", ".join(wos) or None still_open = packets.packet_open_wos(d.conn, w.packet)
suffix = "VISUAL still shows open: " + ", ".join(still_open) if still_open else None
w.note = " · ".join(x for x in (", ".join(wos), suffix) if x) or None
else: else:
d.conn.execute("UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL WHERE id = %s", (packet_id,)) d.conn.execute("UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL WHERE id = %s", (packet_id,))
w.changed("vclosed") w.changed("vclosed")
+346
View File
@@ -0,0 +1,346 @@
"""Printed copies and exports of a packet: the data book and traveler PDFs, the traceability CSV, the print kinds and
the print log.
Reads only: any signed-in role that can open the packet may view and download them (the PM role too). Each answer
writes one print_log row and one audit row (`print` or `export`) after the bytes were built. A removed or unknown
packet is 404. The PDF builders live in packetstation.pdf and are imported when a request needs them.
"""
from __future__ import annotations
import logging
import uuid
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, Query, Request, Response
from ..domain import assets as asset_store
from ..domain import packets
from ..domain import prints as prints_dom
from .deps import Db, Principal, current_principal, get_db, settings_of
from .errors import ApiError
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/packets", tags=["prints"])
PDF = "application/pdf"
CSV = "text/csv; charset=utf-8"
PRINT_KINDS = ("traveler", "map-h", "map-w", "clean", "void", "databook", "doc")
SHEET_KINDS = ("map-h", "map-w", "clean")
WHAT = {
"traveler": "Job traveler",
"map-h": "Drawing with heat map",
"map-w": "Drawing with weld map",
"clean": "Drawing (no markup)",
"void": "Superseded drawings (VOID)",
"databook": "Data book",
}
BOOK_STATUSES = ("complete", "filed")
MSG_BOOK_STATUS = "The data book is built once the packet is back from Q.C."
MSG_NOTHING = "Nothing to print."
MSG_PDF = "The PDF could not be built: {reason}."
# ---------------------------------------------------------------- shared helpers (api/itp.py uses them too)
def file_response(data: bytes, filename: str, *, download: bool, media_type: str = PDF) -> Response:
"""The bytes with Content-Disposition (inline unless download), nosniff and no-store."""
return Response(
content=data,
media_type=media_type,
headers={
"Content-Disposition": prints_dom.disposition(filename, download),
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
},
)
def pdf_failed(e: BaseException) -> ApiError:
text = " ".join(str(e).split()).rstrip(".")[:300] or "an unknown error"
return ApiError(500, "pdf_failed", MSG_PDF.format(reason=text))
def now_utc() -> datetime:
return datetime.now(UTC)
def stamp_of(built: Any) -> tuple[str | None, str | None]:
"""The stamp line and its signature, when the builder reports them."""
stamp = getattr(built, "stamp", None) or None
sig = getattr(built, "stamp_sig", None) or None
return (None if stamp is None else str(stamp)), (None if sig is None else str(sig))
def _packet(conn: Any, packet_id: int) -> dict[str, Any]:
"""The packets row; 404 for an unknown or removed packet."""
row = packets.load_row(conn, packet_id)
if row is None or row["removed_at"] is not None:
raise ApiError(404, "not_found", "No such packet.")
return row
def _read(conn: Any, packet_id: int) -> dict[str, Any]:
return packets.read_packet(conn, packet_id, None)
def _filed_book(conn: Any, packet_id: int) -> dict[str, Any] | None:
"""The unbroken seal's stored data book asset row, with the page count, or None."""
s = packets.one(
conn,
"SELECT databook_asset_id, databook_pages FROM packet_seals WHERE packet_id = %s AND broken_at IS NULL"
" AND databook_asset_id IS NOT NULL ORDER BY id DESC LIMIT 1",
(packet_id,),
)
if s is None:
return None
a = asset_store.get(conn, s["databook_asset_id"])
if a is None:
return None
return {**a, "pages": s["databook_pages"]}
def _asset_bytes(request: Request, row: dict[str, Any]) -> bytes:
return asset_store.open_blob(settings_of(request), row).read_bytes()
def _who(p: Principal) -> Any:
return prints_dom.who_of(p)
# ---------------------------------------------------------------- exports
@router.get("/{packet_id}/pdf/databook")
def databook_pdf(
packet_id: int,
request: Request,
download: int = Query(0, ge=0, le=1),
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
"""The filed data book as stored, or (before filing) one built now and stamped "not yet filed"."""
conn = d.conn
row = _packet(conn, packet_id)
if row["status"] not in BOOK_STATUSES:
raise ApiError(409, "wrong_status", MSG_BOOK_STATUS)
read = _read(conn, packet_id)
name = prints_dom.databook_name(read)
book = _filed_book(conn, packet_id) if row["status"] == "filed" else None
if book is not None:
data = _asset_bytes(request, book)
pages = book["pages"]
stamp, sig = None, None
asset_id: Any = book["id"]
else:
from ..pdf import databook
from ..pdf.style import PdfError
try:
built = databook.build(conn, settings_of(request), packet_id, who=_who(p), now=now_utc(), mode="preview")
except PdfError as e:
raise pdf_failed(e) from None
data, pages, asset_id = built.bytes, built.pages, None
stamp, sig = stamp_of(built)
prints_dom.record(
conn,
p,
action="export",
kind="databook-export",
what="Data book" if book is not None else "Data book (not yet filed)",
pages=pages,
packet_id=packet_id,
unit_id=row["unit_id"],
asset_id=asset_id,
stamp=stamp,
stamp_sig=sig,
)
return file_response(data, name, download=bool(download))
@router.get("/{packet_id}/pdf/traveler")
def traveler_pdf(
packet_id: int,
request: Request,
download: int = Query(0, ge=0, le=1),
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
"""The job traveler with its work order QR codes, stamped as a controlled copy."""
from ..pdf import traveler
from ..pdf.style import PdfError
conn = d.conn
row = _packet(conn, packet_id)
try:
built = traveler.build(conn, settings_of(request), packet_id, who=_who(p), now=now_utc())
except PdfError as e:
raise pdf_failed(e) from None
stamp, sig = stamp_of(built)
read = _read(conn, packet_id)
prints_dom.record(
conn,
p,
action="export",
kind="traveler",
what="Job traveler",
pages=built.pages,
packet_id=packet_id,
unit_id=row["unit_id"],
stamp=stamp,
stamp_sig=sig,
)
return file_response(built.bytes, prints_dom.traveler_name(read), download=bool(download))
@router.get("/{packet_id}/export/traceability.csv")
def traceability_csv(
packet_id: int,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
"""The weld and heat traceability record as CSV (always a download)."""
from ..pdf import tracecsv
conn = d.conn
row = _packet(conn, packet_id)
text, read = tracecsv.build(conn, settings_of(request), packet_id)
prints_dom.record(
conn,
p,
action="export",
kind="csv",
what="Traceability record (CSV)",
pages=None,
packet_id=packet_id,
unit_id=row["unit_id"],
)
return file_response(text.encode("utf-8"), prints_dom.csv_name(read), download=True, media_type=CSV)
# ---------------------------------------------------------------- prints
def _doc_label(conn: Any, packet_id: int, unit_id: int, asset_id: uuid.UUID) -> str | None:
"""The label of a document this packet may print, or None when the asset is not one of its documents."""
for b in packets.rows(
conn,
"SELECT item, heat FROM bom_lines WHERE packet_id = %s AND mtr_asset_id = %s ORDER BY item",
(packet_id, asset_id),
):
return " ".join(x for x in (f"MTR - item {b['item']}", b["heat"] or "") if x)
r = packets.one(
conn,
"SELECT title FROM packet_docs WHERE packet_id = %s AND asset_id = %s ORDER BY id DESC LIMIT 1",
(packet_id, asset_id),
)
if r is not None:
return str(r["title"] or "Document")
r = packets.one(
conn,
"SELECT d.type, d.title FROM qc_docs d JOIN units u ON u.id = %s"
" WHERE d.asset_id = %s AND (d.unit_id = u.id OR d.project_id = u.project_id) ORDER BY d.id DESC LIMIT 1",
(unit_id, asset_id),
)
if r is not None:
return " - ".join(str(x) for x in (r["type"], r["title"]) if x)
r = packets.one(
conn,
"SELECT id FROM packet_seals WHERE packet_id = %s AND databook_asset_id = %s LIMIT 1",
(packet_id, asset_id),
)
if r is not None:
return "Data book"
return None
def _check_sheet(read: Mapping[str, Any], sheet: int) -> None:
if not any(int(pg["n"]) == sheet and pg.get("type") == "drawing" for pg in read.get("pages") or []):
raise ApiError(404, "not_found", "No such drawing sheet.")
@router.get("/{packet_id}/print/{kind}")
def print_copy(
packet_id: int,
kind: str,
request: Request,
sheet: int | None = Query(None, ge=1, le=100_000),
asset: uuid.UUID | None = Query(None), # noqa: B008
download: int = Query(0, ge=0, le=1),
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
"""A PRINTED COPY: the pages stamped with who printed them and when, and "verify revision before use"."""
if kind not in PRINT_KINDS:
raise ApiError(404, "not_found", "No such print.")
conn = d.conn
row = _packet(conn, packet_id)
read = _read(conn, packet_id)
if kind not in SHEET_KINDS:
sheet = None
what = WHAT.get(kind, "")
asset_id: uuid.UUID | None = None
if kind == "doc":
label = _doc_label(conn, packet_id, int(row["unit_id"]), asset) if asset is not None else None
if asset is None or label is None:
raise ApiError(404, "not_found", "No such document on this packet.")
what, asset_id = label, asset
elif kind == "databook" and row["status"] not in BOOK_STATUSES:
raise ApiError(409, "wrong_status", MSG_BOOK_STATUS)
elif kind == "void" and not any((pg.get("void") is not None) for pg in read.get("pages") or []):
raise ApiError(404, "nothing_to_print", MSG_NOTHING)
if sheet is not None:
_check_sheet(read, sheet)
what = f"{what} (sheet {sheet})"
if kind == "databook" and row["status"] == "filed":
book = _filed_book(conn, packet_id)
asset_id = None if book is None else book["id"]
from ..pdf import prints as print_pdf
from ..pdf.style import NothingToPrint, PdfError
try:
built = print_pdf.build_print(
conn, settings_of(request), packet_id, kind, who=_who(p), now=now_utc(), sheet=sheet, asset_id=asset_id
)
except NothingToPrint:
raise ApiError(404, "nothing_to_print", MSG_NOTHING) from None
except PdfError as e:
raise pdf_failed(e) from None
what = str(getattr(built, "what", None) or what)
stamp, sig = stamp_of(built)
detail: dict[str, Any] = {}
if sheet is not None:
detail["sheet"] = sheet
prints_dom.record(
conn,
p,
action="print",
kind=kind,
what=what,
pages=built.pages,
packet_id=packet_id,
unit_id=row["unit_id"],
asset_id=asset_id,
stamp=stamp,
stamp_sig=sig,
detail=detail,
)
return file_response(built.bytes, prints_dom.print_name(read, what), download=bool(download))
@router.get("/{packet_id}/prints")
def list_prints(
packet_id: int,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The packet's printed copies and exports, newest first (at most 200)."""
_packet(d.conn, packet_id)
return {"prints": prints_dom.list_prints(d.conn, packet_id)}
+93 -5
View File
@@ -47,6 +47,9 @@ SEAL_EXCLUDE = (
"qc_folder_docs", "qc_folder_docs",
"itp", "itp",
"itp_hold", "itp_hold",
"seals",
"vclose_check",
"prints",
) )
@@ -676,6 +679,92 @@ def visual_state(conn: Any, row: Mapping[str, Any]) -> dict[str, Any] | None:
} }
# Leg status codes the feed records that mean the work order is no longer open in VISUAL: closed and cancelled.
LEG_DONE = ("C", "X")
def open_wos(vs: Mapping[str, Any] | None) -> list[str]:
"""The work orders the feed last saw open in VISUAL, as "wo_base/lot" text, in leg order without repeats.
A leg is open when the feed recorded a status for it and that status is not closed (C) or cancelled (X). A leg
with no recorded status is left out: the feed has not seen it. The IDs stay text exactly as stored."""
out: list[str] = []
for leg in (vs or {}).get("legs") or []:
if not isinstance(leg, Mapping):
continue
st = str(leg.get("st") or leg.get("status") or "").strip().upper()
if not st or st in LEG_DONE:
continue
parts = str(leg.get("key") or "").split("/")
if len(parts) >= 4:
base, lot = "/".join(parts[:-3]), parts[-3]
else:
base, lot = parts[0], (parts[1] if len(parts) > 1 else "")
if not base:
continue
text = f"{base}/{lot}" if lot else base
if text not in out:
out.append(text)
return out
def vclose_check(row: Mapping[str, Any], vs: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""What the feed says about closing the job in VISUAL, next to the Clerical confirm. None for a hand packet.
`agrees` is true when the Clerical confirm (vclosed) and the feed's closed time are both set or both unset."""
if vs is None:
return None
closed_at = vs.get("closed_at")
return {
"observed_at": vs.get("observed_at"),
"closed_at": closed_at,
"open_wos": open_wos(vs),
"agrees": (row.get("vclosed_at") is not None) == (closed_at is not None),
}
def packet_open_wos(conn: Any, row: Mapping[str, Any]) -> list[str]:
"""The open work orders of `vclose_check` for one packets row (empty for a hand packet)."""
return open_wos(visual_state(conn, row))
def seal_count(conn: Any, packet_id: int) -> int:
r = conn.execute("SELECT count(*) FROM packet_seals WHERE packet_id = %s", (packet_id,)).fetchone()
return int(r[0]) if r else 0
def seal_view(conn: Any, s: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""The newest seal row, broken or not, with who sealed it and the data book filed with it."""
if s is None:
return None
people = People(conn, [s["sealed_by"], s["broken_by"]])
book: dict[str, Any] | None = None
aid = s.get("databook_asset_id")
if aid is not None:
a = one(conn, "SELECT id, size, original_name FROM assets WHERE id = %s", (aid,))
book = {
"asset_id": str(aid),
"url": f"/_blob/{aid}",
"download_url": f"/_blob/{aid}?download=1",
"pages": s.get("databook_pages"),
"size": None if a is None else a["size"],
"file_name": None if a is None else a["original_name"],
}
wos = s.get("open_wos")
return {
"id": s["id"],
"sealed_at": s["sealed_at"],
"sealed_by": people.ref(s["sealed_by"]),
"key_id": s["key_id"],
"signed": s["signature_id"] is not None,
"broken_at": s["broken_at"],
"broken_by": people.ref(s["broken_by"]),
"broken_reason": s["broken_reason"],
"open_wos": None if wos is None else list(wos),
"databook": book,
}
def build( def build(
conn: Any, conn: Any,
row: Mapping[str, Any], row: Mapping[str, Any],
@@ -787,16 +876,15 @@ def build(
out["qc_last"] = out["qc_history"][-1] if out["qc_history"] else None 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"] = [_doc(d, people, seal=seal) for d in ch.docs] out["docs"] = [_doc(d, people, seal=seal) for d in ch.docs]
out["seal"] = ( if not seal:
None out["seal"] = seal_view(conn, ch.seal)
if ch.seal is None out["seals"] = seal_count(conn, row["id"]) if ch.seal is not None else 0
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"] = qc_items_view(row, ch) 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)
out["vclose_check"] = vclose_check(row, out["visual_state"])
if include_children: if include_children:
from . import shop # shop builds on this module from . import shop # shop builds on this module
+112 -8
View File
@@ -14,12 +14,16 @@ Child-row endpoints build on the same pieces: `check_row_version`, `next_counter
from __future__ import annotations from __future__ import annotations
import contextlib
import hashlib import hashlib
import hmac import hmac
import logging import logging
import os
import tempfile
from collections.abc import Iterator, Mapping, Sequence from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from fastapi import Request from fastapi import Request
@@ -45,6 +49,16 @@ MSG_STALE = "Someone else already took this step."
MSG_VERSION = "The packet changed since you opened it. Reload and try again." MSG_VERSION = "The packet changed since you opened it. Reload and try again."
MSG_ROW = "Someone else changed this line. Reload and try again." MSG_ROW = "Someone else changed this line. Reload and try again."
MSG_FIELD = "Someone else changed this since you opened it." MSG_FIELD = "Someone else changed this since you opened it."
MSG_DATABOOK = "The data book could not be built: {reason}. Nothing was filed."
def open_wos_text(wos: Sequence[str]) -> str:
"""The FILE confirm question: "VISUAL still shows 2 work orders open on this packet: L489052/1, L489052/2. File
anyway?" """
n = len(wos)
what = "1 work order" if n == 1 else f"{n} work orders"
return f"VISUAL still shows {what} open on this packet: {', '.join(wos)}. File anyway?"
# Codes that answer 400 (the UI asks for input and retries); every other refusal answers 403. # Codes that answer 400 (the UI asks for input and retries); every other refusal answers 403.
ASK_CODES = frozenset({"it_reason_required", "reason_required"}) ASK_CODES = frozenset({"it_reason_required", "reason_required"})
@@ -143,6 +157,17 @@ class Write:
def rule(self) -> policy.Rule: def rule(self) -> policy.Rule:
return policy.RULES[self.action] return policy.RULES[self.action]
@property
def settings(self) -> Any:
return _settings(self)
@property
def actor_ref(self) -> Any:
"""The signed-in person as the PDF builders' WhoRef: id, display name and employee number."""
from . import prints
return prints.who_of(self.principal)
def log(self, action_label: str, note: str | None = None) -> None: def log(self, action_label: str, note: str | None = None) -> None:
"""Override the packet_log label (and note) for this write.""" """Override the packet_log label (and note) for this write."""
self.label = action_label self.label = action_label
@@ -323,6 +348,11 @@ def packet_write(
bypass = bypass or dd.admin_bypass bypass = bypass or dd.admin_bypass
if if_match is not None and int(if_match) != int(row["version"]): if if_match is not None and int(if_match) != int(row["version"]):
raise ApiError(409, "version_conflict", MSG_VERSION, extra={"current": current_of(row)}) raise ApiError(409, "version_conflict", MSG_VERSION, extra={"current": current_of(row)})
if action is A.FILE and extra.get("confirm_open_wos") is not True:
# Before the signature: a cancelled confirm costs no attempt and writes nothing.
wos = packets.packet_open_wos(conn, row)
if wos:
raise ApiError(409, "open_work_orders", open_wos_text(wos), extra={"work_orders": wos})
w = Write( w = Write(
conn=conn, conn=conn,
principal=p, principal=p,
@@ -770,14 +800,7 @@ def apply_transition(w: Write) -> None:
_set_packet(w, "status = %s::packet_status, status_at = now(), dept_code = %s", (to_status, to_dept)) _set_packet(w, "status = %s::packet_status, status_at = now(), dept_code = %s", (to_status, to_dept))
if a is A.FILE: if a is A.FILE:
key = _seal_key(w) _file(w)
row = packets.load_row(w.conn, w.id)
assert row is not None
content = packets.seal_content(w.conn, row)
w.conn.execute(
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id) VALUES (%s, %s, %s, %s, %s)",
(w.id, seal_hash(key, content), seal_key_id(key), w.user_id, w.signature.id if w.signature else None),
)
elif a is A.ADMIN_REOPEN: elif a is A.ADMIN_REOPEN:
w.conn.execute( w.conn.execute(
"UPDATE packet_seals SET broken_at = now(), broken_by = %s, broken_reason = %s" "UPDATE packet_seals SET broken_at = now(), broken_by = %s, broken_reason = %s"
@@ -807,6 +830,84 @@ def apply_transition(w: Write) -> None:
w.note = reason or _note(w) w.note = reason or _note(w)
def _file(w: Write) -> None:
"""FILE: the data book date, the seal, and the data book built and stored on the seal, all in this transaction.
A data book that cannot be built answers 500 databook_failed and the whole filing rolls back."""
key = _seal_key(w)
_set_packet(w, "databook_at = now()")
row = packets.load_row(w.conn, w.id)
assert row is not None
wos = packets.packet_open_wos(w.conn, row) if w.extra.get("confirm_open_wos") is True else []
content = packets.seal_content(w.conn, row)
digest = seal_hash(key, content)
kid = seal_key_id(key)
seal_id, sealed_at = w.conn.execute(
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id, open_wos)"
" VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, sealed_at",
(w.id, digest, kid, w.user_id, w.signature.id if w.signature else None, wos or None),
).fetchone()
pages = _file_databook(w, row, {"hash": digest.hex(), "key_id": kid, "sealed_at": sealed_at}, int(seal_id))
w.log_also("Data book filed", f"{pages} pages")
if wos:
w.note = " · ".join(x for x in (_note(w), "Filed with work orders open in VISUAL: " + ", ".join(wos)) if x)
w.detail["open_wos"] = wos
w.detail["seal_id"] = int(seal_id)
w.detail["databook_pages"] = pages
def _file_databook(w: Write, row: Mapping[str, Any], seal: dict[str, Any], seal_id: int) -> int:
"""Build the filed data book, store it as an asset and put it on the seal row. Returns its page count."""
from ..pdf import databook
from ..pdf.style import PdfError
from . import assets
from . import prints as prints_dom
settings = _settings(w)
try:
built = databook.build(w.conn, settings, w.id, who=w.actor_ref, seal=seal, mode="filed")
except PdfError as e:
log.warning("data book for packet %s failed: %s", w.id, e)
raise ApiError(500, "databook_failed", MSG_DATABOOK.format(reason=_reason_text(e))) from None
read = packets.read_packet(w.conn, w.id, None, include_children=False)
name = prints_dom.databook_name(read)
tmp_dir = Path(settings.assets_dir) / "tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix="databook-", suffix=".pdf", dir=tmp_dir)
with os.fdopen(fd, "wb") as f:
f.write(built.bytes)
try:
stored = assets.store_file(
w.conn,
settings,
Path(tmp_name),
mime=assets.PDF,
original_name=name,
source="databook",
source_ref={"packet_id": w.id, "seal_id": seal_id},
uploaded_by=w.user_id,
packet_id=w.id,
)
except ApiError as e:
log.warning("data book for packet %s was not stored: %s", w.id, e.detail)
raise ApiError(500, "databook_failed", MSG_DATABOOK.format(reason=e.detail.rstrip("."))) from None
finally:
with contextlib.suppress(OSError):
Path(tmp_name).unlink()
pages = int(built.pages)
w.conn.execute(
"UPDATE packet_seals SET databook_asset_id = %s, databook_pages = %s WHERE id = %s",
(stored["id"], pages, seal_id),
)
w.detail["databook_asset_id"] = str(stored["id"])
return pages
def _reason_text(e: BaseException) -> str:
text = " ".join(str(e).split()).rstrip(".")
return text[:300] or "an unknown error"
def verify_seal(conn: Any, settings: Any, packet_id: int) -> dict[str, Any]: def verify_seal(conn: Any, settings: Any, packet_id: int) -> dict[str, Any]:
"""The seal history of a packet and whether the current unbroken seal still matches the packet.""" """The seal history of a packet and whether the current unbroken seal still matches the packet."""
seals = packets.rows(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC", (packet_id,)) seals = packets.rows(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC", (packet_id,))
@@ -834,6 +935,9 @@ def verify_seal(conn: Any, settings: Any, packet_id: int) -> dict[str, Any]:
"broken_at": s["broken_at"], "broken_at": s["broken_at"],
"broken_by": people.ref(s["broken_by"]), "broken_by": people.ref(s["broken_by"]),
"broken_reason": s["broken_reason"], "broken_reason": s["broken_reason"],
"open_wos": None if s.get("open_wos") is None else list(s["open_wos"]),
"databook_asset_id": None if s.get("databook_asset_id") is None else str(s["databook_asset_id"]),
"databook_pages": s.get("databook_pages"),
} }
return {"seal": shape(current) if current else None, "valid": valid, "seals": [shape(s) for s in seals]} return {"seal": shape(current) if current else None, "valid": valid, "seals": [shape(s) for s in seals]}
+182
View File
@@ -0,0 +1,182 @@
"""Printed copies and exports: who the PDF builders stamp, the file names, and the append-only print log.
Every print and every export writes one print_log row and one audit row, in its own transaction, after the bytes
were built. Prints and exports are not packet changes: they write no packet_log row and do not bump the version.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Literal, cast
from urllib.parse import quote
from .. import audit
from ..api import deps
from . import assets
from . import packets as packets_dom
if TYPE_CHECKING:
from ..pdf.style import WhoRef
PRINTS_LIMIT = 200
# The print_log kinds of the exports (prints use the print kind itself), and the audit kind of each.
EXPORT_KINDS = {"databook-export": "databook", "traveler": "traveler", "csv": "csv", "itp-pdf": "itp"}
def who_of(p: deps.Principal) -> WhoRef:
"""The signed-in person as the builders' WhoRef: {"id", "display_name", "emp_no"}."""
emp = p.user.get("emp_no")
return cast(
"WhoRef",
{
"id": str(p.user_id),
"display_name": str(p.user.get("display_name") or p.user.get("sam_account") or ""),
"emp_no": None if emp is None or str(emp).strip() == "" else str(emp),
},
)
def who_of_user(conn: Any, user_id: Any) -> WhoRef:
"""The same WhoRef for a user id (the ITP hook gets an actor, not a principal)."""
r = conn.execute("SELECT display_name, emp_no FROM users WHERE id = %s", (user_id,)).fetchone()
name = str(r[0] or "") if r else ""
emp = r[1] if r else None
return cast(
"WhoRef",
{"id": str(user_id), "display_name": name, "emp_no": None if emp is None or str(emp) == "" else str(emp)},
)
# ---------------------------------------------------------------- file names
def job_base(read: Mapping[str, Any]) -> str:
"""{project}-Lot{lot}: the top project number and the unit's lot, as the old build named its files."""
unit = read.get("unit") or {}
project = (unit.get("project") or {}).get("number") or ""
lot = unit.get("lot") or unit.get("number") or ""
return f"{project}-Lot{lot}"
def _named(text: str, fallback: str) -> str:
return assets.safe_name(text) or fallback
def databook_name(read: Mapping[str, Any]) -> str:
return _named(f"{job_base(read)}_{read.get('drawing') or ''}_Manufacturing_Data_Book.pdf", "Data_Book.pdf")
def traveler_name(read: Mapping[str, Any]) -> str:
return _named(f"{job_base(read)}_Job_Traveler.pdf", "Job_Traveler.pdf")
def print_name(read: Mapping[str, Any], what: str) -> str:
return _named(f"{job_base(read)} {what}.pdf", "Print.pdf")
def csv_name(read: Mapping[str, Any]) -> str:
return _named(f"{job_base(read)}_{read.get('drawing') or ''}_traceability.csv", "traceability.csv")
def itp_name(no: Any) -> str:
return _named(f"{no or 'ITP'}.pdf", "ITP.pdf")
def disposition(filename: str, download: bool = False) -> str:
"""Content-Disposition: inline unless a download is asked for. An ASCII filename, plus filename* (RFC 5987) when
the name has characters outside ASCII."""
kind = "attachment" if download else "inline"
plain = filename.encode("ascii", "replace").decode("ascii").replace("?", "_")
plain = plain.replace('"', "").replace(";", "_").replace("\\", "_").strip() or "download"
out = f'{kind}; filename="{plain}"'
if plain != filename:
out += f"; filename*=UTF-8''{quote(filename, safe='')}"
return out
# ---------------------------------------------------------------- the log
def record(
conn: Any,
p: deps.Principal,
*,
action: Literal["print", "export"],
kind: str,
what: str,
pages: int | None,
packet_id: int | None = None,
unit_id: int | None = None,
itp_id: int | None = None,
asset_id: Any = None,
stamp: str | None = None,
stamp_sig: str | None = None,
detail: Mapping[str, Any] | None = None,
) -> int:
"""One print_log row and one audit row (action `print` or `export`), in one transaction. The audit detail of an
export names its kind as databook, traveler, csv or itp."""
audit_detail: dict[str, Any] = {
"kind": EXPORT_KINDS.get(kind, kind) if action == "export" else kind,
"what": what,
"pages": pages,
"stamp_sig": stamp_sig,
}
audit_detail.update({k: v for k, v in (detail or {}).items() if v is not None})
if asset_id is not None:
audit_detail["asset_id"] = str(asset_id)
if itp_id is not None:
audit_detail["itp_id"] = itp_id
with conn.transaction():
new_id = conn.execute(
"INSERT INTO print_log (by, by_name, session_id, packet_id, unit_id, itp_id, kind, what, pages, asset_id,"
" stamp, stamp_sig, ip) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
(
p.user_id,
p.user.get("display_name"),
p.session_id,
packet_id,
unit_id,
itp_id,
kind,
what,
pages,
asset_id,
stamp,
stamp_sig,
p.ip,
),
).fetchone()[0]
audit.append(
conn,
action=action,
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="itp" if itp_id is not None and packet_id is None else "packet",
subject_id=itp_id if itp_id is not None and packet_id is None else packet_id,
packet_id=packet_id,
detail=audit_detail,
ip=p.ip,
)
return int(new_id)
def list_prints(conn: Any, packet_id: int, limit: int = PRINTS_LIMIT) -> list[dict[str, Any]]:
"""A packet's printed copies and exports, newest first."""
return [
{
"id": r["id"],
"at": r["at"],
"by": None if r["by"] is None else {"id": str(r["by"]), "display_name": r["by_name"]},
"kind": r["kind"],
"what": r["what"],
"pages": r["pages"],
}
for r in packets_dom.rows(
conn,
"SELECT id, at, by, by_name, kind, what, pages FROM print_log WHERE packet_id = %s"
" ORDER BY id DESC LIMIT %s",
(packet_id, limit),
)
]
+2
View File
@@ -105,6 +105,7 @@ def create_app(
jobs, jobs,
packet_children, packet_children,
packets, packets,
prints,
projects, projects,
qc, qc,
shop, shop,
@@ -124,6 +125,7 @@ def create_app(
units, units,
packets, packets,
packet_children, packet_children,
prints,
assets, assets,
jobs, jobs,
feed_settings, feed_settings,
@@ -0,0 +1,43 @@
-- Phase 7: close, file, PDFs and prints.
-- Every printed copy and every export, append-only. Who, when, what and how many pages.
CREATE TABLE print_log (
id bigserial PRIMARY KEY,
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id),
by_name text,
session_id uuid,
packet_id bigint REFERENCES packets (id),
unit_id bigint REFERENCES units (id),
itp_id bigint REFERENCES itps (id),
-- traveler, map-h, map-w, clean, void, databook, doc, itp, csv, databook-export, itp-pdf
kind text NOT NULL,
-- the human label: "Job traveler", "Drawing with heat map (sheet 2)", "MTR - item 3"
what text NOT NULL,
pages int,
-- the document printed, for kind doc; the filed data book for databook
asset_id uuid REFERENCES assets (id),
-- the stamp line as printed, and the 16 hex characters signed into it
stamp text,
stamp_sig text,
ip text
);
CREATE INDEX print_log_packet ON print_log (packet_id, id);
CREATE INDEX print_log_itp ON print_log (itp_id, id);
CREATE TRIGGER print_log_append_only
BEFORE UPDATE OR DELETE ON print_log
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER print_log_no_truncate
BEFORE TRUNCATE ON print_log
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
-- Explicit, after the default privileges: the app may only read and append the print log.
REVOKE UPDATE, DELETE, TRUNCATE ON print_log FROM ps_app;
GRANT SELECT, INSERT ON print_log TO ps_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
-- The data book filed with a seal, and the work orders the feed still showed open at filing.
ALTER TABLE packet_seals ADD COLUMN databook_asset_id uuid REFERENCES assets (id);
ALTER TABLE packet_seals ADD COLUMN databook_pages int;
ALTER TABLE packet_seals ADD COLUMN open_wos text[];
+5 -2
View File
@@ -1,2 +1,5 @@
"""PDF output: the data book, the traveler, the ITP, prints and stamps. Phase 7 builds them; phase 6 holds the ITP """PDF output: the data book, the job traveler, the ITP, the printed copies, their stamps and metadata.
filing hook only."""
Pure builders: they read through the connection they are given and the stored asset files, and return bytes. They
never write to the database and never log a print; the endpoints and the filing pipeline do that.
"""
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because it is too large Load Diff
+408
View File
@@ -0,0 +1,408 @@
"""The letter-page toolkit over a reportlab canvas: a port of the old databook.js text helpers and its autotable look.
Every y here is measured from the top of the page, as jsPDF did, and turned into reportlab's bottom-up y on drawing.
Text is always passed through `clean`. Cells are wrapped with reportlab's simpleSplit on plain strings, so no markup
is ever interpreted.
"""
from __future__ import annotations
import io
from collections.abc import Callable, Mapping, Sequence
from functools import lru_cache
from pathlib import Path
from typing import Any
from reportlab.lib.colors import Color
from reportlab.lib.utils import ImageReader, simpleSplit
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen.canvas import Canvas
from .style import AMBER, AMBER_BG, CW, GREY, LINE, NAVY, PAGE_H, PAGE_W, PESCO_NAME, TEAL, WHITE, L, R, clean
LOGO_PATH = Path(__file__).parent / "assets" / "logo_color.png"
LOGO_W = 132.0
LOGO_H = 132.0 / 3.5
TABLE_TOP = 118.0 # autotable margin top, where a table continues after a page break
TABLE_BOTTOM = 60.0 # autotable margin bottom
BODY_TOP = 124.0 # where ensure_space starts a new page
SPACE_LIMIT = 740.0
LINE_FACTOR = 1.15
PAD_V = 4.2
PAD_H = 4.0
def rgb(c: Sequence[int]) -> Color:
return Color(c[0] / 255, c[1] / 255, c[2] / 255)
@lru_cache(maxsize=1)
def _logo() -> ImageReader | None:
try:
return ImageReader(io.BytesIO(LOGO_PATH.read_bytes()))
except OSError:
return None
def font_of(bold: bool = False, mono: bool = False) -> str:
if mono:
return "Courier-Bold" if bold else "Courier"
return "Helvetica-Bold" if bold else "Helvetica"
def split_lines(text: Any, font: str, size: float, width: float) -> list[str]:
"""The text wrapped to `width`, keeping its own line breaks. An empty text is one empty line."""
out: list[str] = []
for part in clean(text).split("\n"):
out += simpleSplit(part, font, size, max(width, 1.0)) or [""]
return out or [""]
class Doc:
"""A letter document drawn page by page. The header (logo, title, sub, rule) and the foot line are drawn when a
page starts, so every page carries them (the old decorate()). Page numbers and stamps are added after assembly
as an overlay (stamps.py)."""
def __init__(self, header: tuple[str, str] | None = None, *, foot: bool = True) -> None:
self.buf = io.BytesIO()
self.c = Canvas(self.buf, pagesize=(PAGE_W, PAGE_H), pageCompression=1)
self.c.setAuthor("PESCO - Process Equipment & Service Company, Inc.")
self.header = header
self.foot = foot
self.pages = 1
self.on_page: Callable[[Doc], None] | None = None
self._decorate()
# ------------------------------------------------------------ pages
def _decorate(self) -> None:
if self.header is not None:
self.page_header(*self.header)
if self.foot:
self.foot_left()
if self.on_page is not None:
self.on_page(self)
def add_page(self) -> None:
self.c.showPage()
self.pages += 1
self._decorate()
def set_header(self, title: str, sub: str) -> None:
"""The header for the pages started from now on."""
self.header = (title, sub)
def finish(self) -> bytes:
self.c.showPage()
self.c.save()
return self.buf.getvalue()
# ------------------------------------------------------------ primitives
def Y(self, y: float) -> float:
"""A top-down y as reportlab's bottom-up y."""
return PAGE_H - y
def tx(
self,
s: Any,
x: float,
y: float,
*,
size: float = 9,
bold: bool = False,
color: Sequence[int] = NAVY,
align: str = "left",
cs: float = 0,
max_width: float | None = None,
mono: bool = False,
) -> int:
"""Text at baseline y. With max_width it wraps (line height 1.15). Returns the number of lines drawn."""
font = font_of(bold, mono)
c = self.c
c.setFont(font, size)
c.setFillColor(rgb(color))
lines = split_lines(s, font, size, max_width) if max_width else clean(s).split("\n")
for i, line in enumerate(lines):
yy = self.Y(y + i * size * LINE_FACTOR)
w = stringWidth(line, font, size) + cs * max(0, len(line) - 1)
if align == "right":
xx = x - w
elif align == "center":
xx = x - w / 2
else:
xx = x
if cs:
c.drawString(xx, yy, line, charSpace=cs)
else:
c.drawString(xx, yy, line)
return len(lines)
def lbl(
self,
s: Any,
x: float,
y: float,
*,
size: float = 6.5,
cs: float = 1.6,
color: Sequence[int] = NAVY,
align: str = "left",
) -> None:
"""A label: bold tracked capitals."""
self.tx(clean(s).upper(), x, y, size=size, bold=True, cs=cs, color=color, align=align)
def line(self, x1: float, y1: float, x2: float, y2: float, color: Sequence[int], width: float) -> None:
c = self.c
c.setStrokeColor(rgb(color))
c.setLineWidth(width)
c.line(x1, self.Y(y1), x2, self.Y(y2))
def rect(
self,
x: float,
y: float,
w: float,
h: float,
*,
fill: Sequence[int] | None = None,
stroke: Sequence[int] | None = None,
width: float = 0.8,
) -> None:
"""A rectangle whose top-left corner is (x, y)."""
c = self.c
if fill is not None:
c.setFillColor(rgb(fill))
if stroke is not None:
c.setStrokeColor(rgb(stroke))
c.setLineWidth(width)
c.rect(x, self.Y(y + h), w, h, stroke=1 if stroke is not None else 0, fill=1 if fill is not None else 0)
def rule(self, y: float) -> None:
self.line(L, y, R, y, NAVY, 1.2)
self.line(L, y + 3.2, L + CW * 0.26, y + 3.2, TEAL, 2.6)
def hair(self, y: float, x1: float = L, x2: float = R) -> None:
self.line(x1, y, x2, y, LINE, 0.6)
def logo(self) -> None:
img = _logo()
if img is not None:
self.c.drawImage(img, L, self.Y(34 + LOGO_H), LOGO_W, LOGO_H, mask="auto")
def page_header(self, title: str, sub: str) -> None:
self.logo()
self.tx(clean(title).upper(), R, 62, size=20, bold=True, cs=2.6, align="right")
self.tx(clean(sub).upper(), R, 76, size=6.5, cs=1.6, color=GREY, align="right")
self.rule(94)
def foot_left(self) -> None:
self.hair(758)
self.tx(PESCO_NAME, L, 772, size=7, color=GREY)
# ------------------------------------------------------------ flow
def ensure_space(self, y: float, need: float) -> float:
if y + need > SPACE_LIMIT:
self.add_page()
return BODY_TOP
return y
def section_label(self, s: str, y: float) -> float:
y = self.ensure_space(y, 60)
self.lbl(s, L, y)
return y + 8
def note_box(self, text: str, y: float, warn: bool = False) -> float:
size = 8.0
lines = split_lines(text, "Helvetica", size, CW - 24)
h = len(lines) * 10.5 + 16
y = self.ensure_space(y, h + 8)
if warn:
self.rect(L, y, CW, h, fill=AMBER_BG, stroke=AMBER, width=0.8)
else:
self.rect(L, y, CW, h, stroke=LINE, width=0.8)
self.c.setFont("Helvetica", size)
self.c.setFillColor(rgb(AMBER if warn else NAVY))
for i, ln in enumerate(lines):
self.c.drawString(L + 12, self.Y(y + 15 + i * size * LINE_FACTOR), ln)
return y + h + 10
# ------------------------------------------------------------ tables
def table(
self,
head: Sequence[str] | None,
body: Sequence[Sequence[Any]],
start_y: float,
cols: Mapping[int, Mapping[str, Any]] | None = None,
*,
size: float = 8.3,
head_size: float = 6.3,
) -> float:
"""The autotable look: navy 8.3 pt body, the head 6.3 pt bold grey over a 0.9 pt navy rule, hairlines
between rows, cell padding 4.2 and 4. Cells wrap; the head repeats after a page break. A cell may be a dict
{"content", "colspan", "bold", "fill", "size", "color"}. Returns the y under the table."""
cols = cols or {}
ncols = len(head) if head else 0
for row in body:
ncols = max(ncols, sum(int(c.get("colspan", 1)) if isinstance(c, Mapping) else 1 for c in row))
if ncols == 0:
return start_y
widths = self._widths(head, body, cols, ncols, size)
y = start_y
limit = PAGE_H - TABLE_BOTTOM
head_cells = [{"content": h, "bold": True, "size": head_size, "color": GREY} for h in head] if head else None
head_h = self._row_height(head_cells, widths, cols, head_size) if head_cells else 0.0
def draw_head(yy: float) -> float:
if not head_cells:
return yy
self._draw_row(head_cells, widths, cols, yy, head_h, head_size, head=True)
self.line(L, yy + head_h, L + sum(widths), yy + head_h, NAVY, 0.9)
return yy + head_h
first_h = self._row_height(body[0], widths, cols, size) if body else 0.0
if y + head_h + first_h > limit:
self.add_page()
y = TABLE_TOP
y = draw_head(y)
for row in body:
h = self._row_height(row, widths, cols, size)
if y + h > limit:
self.add_page()
y = draw_head(TABLE_TOP)
self._draw_row(row, widths, cols, y, h, size)
self.line(L, y + h, L + sum(widths), y + h, LINE, 0.5)
y += h
return y
def kv_table(self, rows: Sequence[tuple[str, Any]], start_y: float) -> float:
body = [[k, v if (v is not None and str(v).strip() != "") else "—"] for k, v in rows]
return self.table(None, body, start_y, {0: {"w": CW * 0.36, "color": GREY}, 1: {"bold": True}}, size=8.5)
# internals
@staticmethod
def _cells(row: Sequence[Any]) -> list[tuple[int, int, dict[str, Any]]]:
"""(first column, span, cell) per cell."""
out: list[tuple[int, int, dict[str, Any]]] = []
col = 0
for c in row:
cell = dict(c) if isinstance(c, Mapping) else {"content": c}
span = int(cell.get("colspan", 1))
out.append((col, span, cell))
col += span
return out
@staticmethod
def _font(cell: Mapping[str, Any], colspec: Mapping[str, Any]) -> str:
bold = cell.get("bold", colspec.get("bold", False))
return font_of(bool(bold), bool(cell.get("mono", colspec.get("mono", False))))
def _widths(
self,
head: Sequence[str] | None,
body: Sequence[Sequence[Any]],
cols: Mapping[int, Mapping[str, Any]],
ncols: int,
size: float,
) -> list[float]:
fixed = {i: float(cols[i]["w"]) for i in range(ncols) if i in cols and cols[i].get("w")}
free = [i for i in range(ncols) if i not in fixed]
widths = [fixed.get(i, 0.0) for i in range(ncols)]
if not free:
return widths
avail = max(CW - sum(fixed.values()), 20.0 * len(free))
natural = dict.fromkeys(free, 2 * PAD_H + 10)
rows: list[Sequence[Any]] = [list(head)] if head else []
rows += list(body)
for row in rows:
for col, span, cell in self._cells(row):
if span != 1 or col not in natural:
continue
spec = cols.get(col, {})
font = self._font(cell, spec)
sz = float(cell.get("size", spec.get("size", size)))
for part in clean(cell.get("content")).split("\n"):
natural[col] = max(natural[col], stringWidth(part, font, sz) + 2 * PAD_H)
total = sum(natural.values())
for i in free:
widths[i] = avail * natural[i] / total if total else avail / len(free)
# Keep every free column at least 28 pt by taking from the widest ones.
short = [i for i in free if widths[i] < 28]
if short and len(short) < len(free):
need = sum(28 - widths[i] for i in short)
wide = [i for i in free if i not in short]
wide_total = sum(widths[i] for i in wide)
for i in short:
widths[i] = 28
for i in wide:
widths[i] -= need * widths[i] / wide_total
return widths
def _cell_lines(
self, cell: Mapping[str, Any], spec: Mapping[str, Any], width: float, size: float
) -> tuple[list[str], str, float]:
font = self._font(cell, spec)
sz = float(cell.get("size", spec.get("size", size)))
return split_lines(cell.get("content"), font, sz, width - 2 * PAD_H), font, sz
def _row_height(
self, row: Sequence[Any], widths: Sequence[float], cols: Mapping[int, Mapping[str, Any]], size: float
) -> float:
h = 0.0
for col, span, cell in self._cells(row):
w = sum(widths[col : col + span])
lines, _font, sz = self._cell_lines(cell, cols.get(col, {}), w, size)
h = max(h, len(lines) * sz * LINE_FACTOR + 2 * PAD_V)
return h
def _draw_row(
self,
row: Sequence[Any],
widths: Sequence[float],
cols: Mapping[int, Mapping[str, Any]],
y: float,
h: float,
size: float,
head: bool = False,
) -> None:
c = self.c
for col, span, cell in self._cells(row):
spec = {} if head else cols.get(col, {})
if head and col in cols and cols[col].get("align"):
spec = {"align": cols[col]["align"]}
x = L + sum(widths[:col])
w = sum(widths[col : col + span])
if cell.get("fill") is not None:
self.rect(x, y, w, h, fill=cell["fill"])
lines, font, sz = self._cell_lines(cell, spec, w, size)
lh = sz * LINE_FACTOR
top = y + (h - len(lines) * lh) / 2
color = cell.get("color", spec.get("color", NAVY))
c.setFont(font, sz)
c.setFillColor(rgb(color))
align = cell.get("align", spec.get("align", "left"))
for i, ln in enumerate(lines):
base = top + i * lh + (lh - sz) / 2 + sz * 0.78
if align == "center":
c.drawCentredString(x + w / 2, self.Y(base), ln)
elif align == "right":
c.drawRightString(x + w - PAD_H, self.Y(base), ln)
else:
c.drawString(x + PAD_H, self.Y(base), ln)
def page_x_of_n(tag: str) -> Callable[[int, int], str]:
"""The page number text of our pages: 'Project P Lot U - Page i of N'."""
def text(i: int, n: int) -> str:
return f"{tag} - Page {i} of {n}"
return text
__all__ = ["WHITE", "Doc", "page_x_of_n", "rgb", "split_lines"]
+317 -6
View File
@@ -1,17 +1,328 @@
"""The signed ITP PDF. Phase 7 builds it; until then the hook files nothing. """The ITP PDF, after the old itpPdfBytes: one run of pages per ITP page group (WP, C, FA), each with the header
table, the code legend, a table per section and the block signature table after a section that closes a block.
`file_signed_itp_pdf` is called inside the sign-off transaction, after the ITP row is set complete. When it returns an `file_signed_itp_pdf` is called inside the sign-off transaction, after the ITP row is set complete. It builds the
asset id, the sign-off stores it as `signoff_asset_id` and files an automatic ITP document in the unit's Q.C. folder. PDF, stores it as an asset and returns the asset id. Without settings it files nothing and returns None.
""" """
from __future__ import annotations from __future__ import annotations
import contextlib
import io
import os
import tempfile
import uuid import uuid
from collections.abc import Mapping from collections.abc import Mapping
from datetime import datetime
from pathlib import Path
from typing import Any from typing import Any
from pypdf import PdfReader, PdfWriter
def file_signed_itp_pdf(conn: Any, itp_row: Mapping[str, Any], actor: Any) -> uuid.UUID | None: from .. import policy
"""Build and store the signed ITP PDF, and return its asset id. Phase 7 implements it; today it returns None.""" from ..domain import assets
del conn, itp_row, actor from ..domain import itp as dom
from . import parts, stamps
from .doc import Doc
from .style import (
CW,
GREEN,
GREY,
PANEL,
REMARK,
Built,
L,
PdfError,
R,
WhoRef,
ascii_only,
clean,
fmt_d,
fmt_dt,
site_tz,
utc_now,
utc_stamp,
who_text,
)
def read_itp(conn: Any, itp_row: Mapping[str, Any]) -> dict[str, Any]:
"""The full ITP read (domain/itp.py read) with a principal that may do nothing; `can` is not used here."""
pp = policy.principal_from_roles(None, [])
return dom.read(conn, itp_row, pp)
def _sig_text(users: Mapping[str, Mapping[str, Any]], ref: Any) -> str:
return parts.name_emp(users, ref)
def _ids(r: Mapping[str, Any]) -> list[Any]:
ids: list[Any] = []
for ln in (r.get("lines") or {}).values():
ids.append(ln.get("by"))
for b in r.get("blocks") or []:
ids += [b.get("signed_by"), b.get("cust_by")]
if r.get("signoff"):
ids.append(r["signoff"].get("by"))
return ids
def pages_of(conn: Any, settings: Any, itp_row: Mapping[str, Any]) -> tuple[bytes, int, dict[str, Any]]:
"""The ITP pages without page numbers or stamp, their count, and the read."""
try:
r = read_itp(conn, itp_row)
d = _draw(r, parts.users_of(conn, _ids(r)), site_tz(settings))
return d.finish(), d.pages, r
except PdfError:
raise
except Exception as e:
raise PdfError(f"The ITP PDF could not be built: {type(e).__name__}.") from e
def reject_lines(rows: list[Mapping[str, Any]], tz: str) -> list[str]:
"""'Rejected M/D/YYYY - NCR n' per reject. An NCR entered later (kind ncr) fills the reject before it."""
out: list[tuple[Any, str | None]] = []
for x in rows:
if x.get("kind") == "ncr":
if out and not out[-1][1]:
out[-1] = (out[-1][0], x.get("ncr"))
continue
out.append((x.get("at"), x.get("ncr")))
return [f"Rejected {fmt_d(at, tz)} - NCR {ncr or 'pending'}" for at, ncr in out]
def _draw(r: Mapping[str, Any], users: Mapping[str, Mapping[str, Any]], tz: str) -> Doc:
template = r.get("template") or {}
hdr = r.get("hdr") or {}
lines = r.get("lines") or {}
blocks = {str(b["key"]): b for b in r.get("blocks") or []}
no = str(r.get("no") or "")
d: Doc | None = None
for pi, code in enumerate(dom.PAGE_ORDER):
secs = [s for s in template.get("sections") or [] if s.get("page") == code]
if not secs:
continue
header = ("Inspection & Test Plan", f"{no} • {pi + 1} of 3: {code} - {dom.PAGE_NAMES[code]}")
if d is None:
d = Doc(header)
else:
d.set_header(*header)
d.add_page()
y = 126.0
y = d.table(
None,
[
["Inspection plan no.", no or "—", "Client", hdr.get("client") or "—"],
["Project name", hdr.get("pname") or "—", "Project no.", hdr.get("pno") or "—"],
["Purchase order", hdr.get("po") or "—", "Unit no.", hdr.get("unit") or "—"],
],
y,
{0: {"w": 86, "color": GREY}, 1: {"w": 172, "bold": True}, 2: {"w": 70, "color": GREY}, 3: {"bold": True}},
size=8,
)
y += 4
n = d.tx("Codes: " + str(template.get("legend") or ""), L, y + 8, size=6.4, color=GREY, max_width=CW)
y += 20 + (n - 1) * 7.4
for s in secs:
body: list[list[Any]] = [
[
{
"content": f"{s.get('n')} {s.get('t') or ''}",
"colspan": 7,
"bold": True,
"fill": PANEL,
"size": 7.6,
}
]
]
for it in s.get("items") or []:
ln = lines.get(str(it.get("n"))) or {}
result = ln.get("result")
rejects = "\n".join(reject_lines(ln.get("rejects") or [], tz))
if result == "na":
appr = "N/A"
elif result == "approve":
by = ln.get("by") or {}
ini = ln.get("initials") or dom.initials(parts.name_of(users, by))
emp = (users.get(str(by.get("id"))) or {}).get("emp_no") if isinstance(by, Mapping) else None
appr = f"Approved {ini} ({emp or ''})"
else:
appr = ""
if rejects:
appr = f"{appr}\n{rejects}" if appr else rejects
cust = dom.cust_code(it, ln)
if ln.get("cust_by"):
cust += f"\n{ln['cust_by']} {fmt_d(ln.get('cust_at'), tz, empty='')}".rstrip()
crit = str(it.get("crit") or "") + (f"\nNote: {ln['note']}" if ln.get("note") else "")
body.append(
[
str(it.get("n") or ""),
it.get("t") or "",
it.get("code") or "",
appr,
fmt_d(ln.get("at"), tz, empty="") if result == "approve" else "",
cust,
crit,
]
)
y = d.table(
["NO", "INSPECTION ITEM", "CODE", "QC APPROVAL", "DATE", "CUSTOMER", "REMARKS / ACCEPTANCE"],
body,
y,
{
0: {"w": 24},
1: {"w": 108, "bold": True},
2: {"w": 30, "align": "center"},
3: {"w": 74},
4: {"w": 44},
5: {"w": 48},
6: {"size": 6.3, "color": REMARK},
},
size=6.8,
)
b = blocks.get(str(s.get("n")))
if b is not None:
signed = (
f"{_sig_text(users, b.get('signed_by'))} {fmt_d(b.get('signed_at'), tz)} - signed with PIN"
if b.get("signed")
else ""
)
cust = (
f"{b.get('cust_name') or ''} {fmt_d(b.get('cust_at'), tz)} (recorded by"
f" {parts.name_of(users, b.get('cust_by'))})"
if b.get("cust_at")
else ""
)
y = d.table(
None,
[
["QN #", b.get("qn") or "", "PESCO QC signature", signed],
[
"Remarks / notes",
b.get("remarks") or "",
"COP complete" if b.get("final") else "Customer complete",
cust,
],
],
y + 2,
{0: {"w": 70, "color": GREY}, 1: {"w": 150}, 2: {"w": 96, "color": GREY}, 3: {"bold": True}},
size=7.4,
)
y += 10
if d is None:
d = Doc(("Inspection & Test Plan", no))
d.tx("The ITP template has no sections.", L, 130, size=9, color=GREY)
if r.get("status") == "complete" and r.get("signoff"):
so = r["signoff"]
d.tx(
f"ITP signed off {fmt_dt(so.get('at'), tz)} by {_sig_text(users, so.get('by'))} - PIN",
L,
748,
size=7.5,
bold=True,
color=GREEN,
)
return d
def finish(data: bytes, r: Mapping[str, Any], stamp: str, now: datetime, *, box: bool) -> bytes:
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(data)))
n = len(writer.pages)
def draw(c: Any, i: int, w: float, h: float) -> None:
del h
c.setFont("Helvetica", 7)
c.setFillColorRGB(GREY[0] / 255, GREY[1] / 255, GREY[2] / 255)
c.drawRightString(R, 20, f"Page {i + 1} of {n}")
if not stamp:
return
if box:
stamps.draw_box_stamp(c, w, stamp)
else:
stamps.draw_line_stamp(c, w, stamp, own=True)
stamps.overlay_writer(writer, draw)
hdr = r.get("hdr") or {}
meta = stamps.metadata(
f"ITP {r.get('no') or ''}",
f"Inspection and test plan {r.get('no') or ''}" + (" - signed off" if r.get("status") == "complete" else ""),
", ".join(str(x) for x in (r.get("no"), hdr.get("pno"), hdr.get("unit"), hdr.get("client"), "ITP") if x),
"Packet Station - controlled copy",
now=now,
)
return stamps.write(writer, meta)
def itp_stamp(who: WhoRef | None, now: datetime, status: Any) -> str:
"""Controlled copy - by Name (emp) UTC - signed off (or OPEN, not signed off)."""
state = "signed off" if status == "complete" else "OPEN, not signed off"
return ascii_only(f"Controlled copy - by {who_text(who)} {utc_stamp(now)} - {state}")
def build(conn: Any, settings: Any, itp_row: Mapping[str, Any], *, who: WhoRef, now: datetime | None = None) -> Built:
"""The ITP PDF as it is now (open or complete; the stamp says which)."""
now = utc_now(now)
data, pages, r = pages_of(conn, settings, itp_row)
warnings: list[str] = []
stamp, sig = stamps.signed_stamp(
settings,
itp_stamp(who, now, r.get("status")),
packet_id=f"itp-{itp_row['id']}",
kind="itp-pdf",
seal_hash_hex=None,
who=who,
now=now,
warnings=warnings,
)
data = finish(data, r, stamp, now, box=False)
return Built(
bytes=data, pages=pages, warnings=warnings, stamp=stamp, stamp_sig=sig, what=f"ITP {clean(r.get('no'))}"
)
def who_of_actor(conn: Any, actor: Any) -> WhoRef:
"""The WhoRef of a session actor (sessions.Actor, or a mapping with id or user_id and display_name)."""
if isinstance(actor, Mapping):
uid = actor.get("user_id") or actor.get("id")
name = actor.get("display_name")
else:
uid = getattr(actor, "user_id", None)
name = getattr(actor, "display_name", None)
info = parts.users_of(conn, [uid]).get(str(uid)) if uid else None
return {
"id": None if uid is None else str(uid),
"display_name": name or (info or {}).get("display_name"),
"emp_no": (info or {}).get("emp_no"),
}
def file_signed_itp_pdf(conn: Any, itp_row: Mapping[str, Any], actor: Any, settings: Any = None) -> uuid.UUID | None:
"""Build the signed ITP PDF, store it as an asset and return its id. Without settings it files nothing (None).
A build failure raises PdfError; the sign-off then answers 500 and nothing is signed off."""
if settings is None:
return None return None
who = who_of_actor(conn, actor)
built = build(conn, settings, itp_row, who=who)
tmp_dir = Path(settings.assets_dir) / "tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(prefix="itp-", suffix=".pdf", dir=tmp_dir)
with os.fdopen(fd, "wb") as f:
f.write(built.bytes)
try:
row = assets.store_file(
conn,
settings,
Path(name),
mime=assets.PDF,
original_name=f"{itp_row['no']}_signed.pdf",
source="itp_signoff",
source_ref={"itp_id": int(itp_row["id"]), "no": str(itp_row["no"])},
uploaded_by=who["id"],
)
except Exception as e:
raise PdfError(f"The signed ITP PDF could not be stored: {type(e).__name__}.") from e
finally:
with contextlib.suppress(OSError):
Path(name).unlink()
aid = row["id"]
return aid if isinstance(aid, uuid.UUID) else uuid.UUID(str(aid))
+99
View File
@@ -0,0 +1,99 @@
"""The heat map and the weld map of one drawing page, as a JPEG at the sheet's own size (the old renderMapCanvas)."""
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from ..domain import assets
from . import raster
from .style import fmt_d, site_tz
def asset_file(conn: Any, settings: Any, asset_id: Any) -> tuple[dict[str, Any], Path]:
"""The assets row and its stored file. Raises when the row or the file is missing."""
row = assets.get(conn, asset_id) if asset_id else None
if row is None:
raise FileNotFoundError("The file is not in the asset store.")
return row, assets.open_blob(settings, row)
def page_file(conn: Any, settings: Any, page: Mapping[str, Any]) -> tuple[Path, int]:
"""The stored PDF of a packet page and the page number inside it."""
_row, path = asset_file(conn, settings, page.get("asset_id"))
return path, int(page.get("pn") or 1)
def void_text(page: Mapping[str, Any], tz: str | None = None) -> str:
"""VOID - superseded by CO-2 (dwg Rev B) 10/16/2026. Empty for a live page (the old voidText)."""
v = page.get("void")
if not v:
return ""
co = f"CO-{v['co_no']}" if v.get("co_no") is not None else "a change order"
new = v.get("new")
sheet = f" ({new.get('dwg') or 'new sheet'} Rev {new.get('rev') or '?'})" if new else ""
day = fmt_d(v.get("at"), tz, empty="")
return f"VOID - superseded by {co}{sheet}" + (f" {day}" if day else "")
def heat_placement(packet: Mapping[str, Any], page_n: int) -> Mapping[str, Any] | None:
"""The heat table placed on a page, or None."""
for h in packet.get("heat_table") or []:
if h.get("page") == page_n and h.get("x") is not None:
return h
return None
def legacy_tags(packet: Mapping[str, Any], page_n: int) -> list[Mapping[str, Any]]:
"""The older heat tags of a page. They show only while no table is placed on it."""
if heat_placement(packet, page_n) is not None:
return []
return [t for t in packet.get("heat_tags") or [] if t.get("page") == page_n]
def page_welds(packet: Mapping[str, Any], page_n: int) -> list[Mapping[str, Any]]:
return [w for w in packet.get("welds") or [] if w.get("page") == page_n]
def _render(conn: Any, settings: Any, page: Mapping[str, Any]) -> tuple[Any, float, float]:
path, pn = page_file(conn, settings, page)
return raster.render_page(path, pn)
def heat_map_page(
conn: Any, settings: Any, packet: Mapping[str, Any], page: Mapping[str, Any]
) -> tuple[bytes, float, float]:
"""The sheet with its heat table (or its older heat tags when no table is placed), VOID when the page is void.
Returns (jpeg bytes, width pt, height pt)."""
img, w, h = _render(conn, settings, page)
n = int(page["n"])
bom = packet.get("bom") or []
placement = heat_placement(packet, n)
if placement is not None:
raster.draw_heat_table(img, placement, raster.heat_rows(bom))
else:
raster.draw_heat_tags(img, packet.get("heat_tags") or [], bom, n)
if page.get("void"):
img = raster.stamp_void(img, void_text(page, site_tz(settings)))
return raster.to_jpeg(img), w, h
def weld_map_page(
conn: Any, settings: Any, packet: Mapping[str, Any], page: Mapping[str, Any]
) -> tuple[bytes, float, float]:
"""The sheet with its weld bubbles, VOID when the page is void. Returns (jpeg bytes, width pt, height pt)."""
img, w, h = _render(conn, settings, page)
raster.draw_weld_marks(img, packet.get("welds") or [], int(page["n"]))
if page.get("void"):
img = raster.stamp_void(img, void_text(page, site_tz(settings)))
return raster.to_jpeg(img), w, h
def clean_page(conn: Any, settings: Any, page: Mapping[str, Any]) -> tuple[bytes, float, float]:
"""The sheet with no marks (VOID when void), as a raster. Prints copy the vector page instead; this is for a
page whose PDF cannot be copied."""
img, w, h = _render(conn, settings, page)
if page.get("void"):
img = raster.stamp_void(img, void_text(page, site_tz(settings)))
return raster.to_jpeg(img), w, h
+235
View File
@@ -0,0 +1,235 @@
"""What the builders share: the packet read model, people with their employee numbers, departments, the packet
log, and single pages made from a raster or an image.
Everything here only reads. The read model comes from domain/packets.py `build` (outside seal mode, no principal).
"""
from __future__ import annotations
import io
import uuid
from collections.abc import Iterable, Mapping
from pathlib import Path
from typing import Any
from pypdf import PdfReader
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen.canvas import Canvas
from ..domain import packets
from .style import PAGE_H, PAGE_W, PdfError
# The status texts of the frontend's lib/packets.ts STATUS_LABEL.
STATUS_LABEL = {
"draft": "Ready to release",
"warehouse": "With warehouse",
"ready": "Ready to assign",
"assigned": "Sent to station",
"in_progress": "Working",
"review": "Supervisor review",
"qc": "Q.C. inspection",
"complete": "Routed",
"filed": "Filed in data book",
}
# The old ROLE_N texts, extended to the role kinds of auth/groups.py.
ROLE_TEXT = {
"admin": "Admin",
"clerical": "Clerical",
"clerical_supervisor": "Clerical supervisor",
"warehouse": "Warehouse",
"warehouse_supervisor": "Warehouse supervisor",
"qc": "Q.C.",
"qc_supervisor": "Q.C. supervisor",
"pm": "Project Mgmt",
"shop_worker": "Worker",
"shop_lead": "Shop lead",
"shop_supervisor": "Supervisor",
"supervisor": "Supervisor",
"stall": "Weld Stall",
}
def load_packet(conn: Any, packet_id: int) -> dict[str, Any]:
"""The packet read model (outside seal mode, no principal). PdfError when there is no such packet."""
row = packets.load_row(conn, int(packet_id))
if row is None:
raise PdfError("No such packet.")
return packets.build(conn, row, None)
def _uuid(v: Any) -> uuid.UUID | None:
if v is None:
return None
if isinstance(v, Mapping):
v = v.get("id")
try:
return uuid.UUID(str(v))
except ValueError:
return None
def users_of(conn: Any, ids: Iterable[Any]) -> dict[str, dict[str, Any]]:
"""{user id text: {display_name, emp_no, stamp}} for the given ids (user refs or ids)."""
want = sorted({u for u in (_uuid(i) for i in ids) if u is not None}, key=str)
if not want:
return {}
out: dict[str, dict[str, Any]] = {}
for r in conn.execute("SELECT id, display_name, emp_no, welder_stamp FROM users WHERE id = ANY(%s)", (want,)):
out[str(r[0])] = {"display_name": r[1], "emp_no": r[2], "stamp": r[3]}
return out
def name_emp(users: Mapping[str, Mapping[str, Any]], ref: Any, fallback: str = "") -> str:
"""'Name (emp)' for a user ref or id; the fallback name when the user is unknown."""
u = _uuid(ref)
info = users.get(str(u)) if u else None
name = (info or {}).get("display_name") or (ref.get("display_name") if isinstance(ref, Mapping) else None)
name = name or fallback
if not name:
return ""
emp = (info or {}).get("emp_no") or "?"
return f"{name} ({emp})"
def name_of(users: Mapping[str, Mapping[str, Any]], ref: Any, fallback: str = "") -> str:
u = _uuid(ref)
info = users.get(str(u)) if u else None
name = (info or {}).get("display_name") or (ref.get("display_name") if isinstance(ref, Mapping) else None)
return str(name or fallback)
def departments(conn: Any) -> dict[str, dict[str, Any]]:
"""{code: {name, label, weld}}. The label is "03 Weld Parts" for a shop department, the name otherwise."""
out: dict[str, dict[str, Any]] = {}
for code, name, kind, weld in conn.execute("SELECT code, name, kind::text, weld FROM departments"):
out[code] = {"name": name, "label": packets.dept_label(code, name, kind), "weld": bool(weld)}
return out
def role_text(role: Any) -> str:
"""The role snapshot of a log row in words."""
parts = []
for r in str(role or "").split(","):
r = r.strip()
if not r:
continue
bits = r.split(":")
if bits[0] == "shop" and len(bits) == 3: # shop:03:supervisor
parts.append(f"{bits[1]} {ROLE_TEXT.get('shop_' + bits[2], bits[2].replace('_', ' '))}")
else:
parts.append(ROLE_TEXT.get(bits[0], bits[0].replace("_", " ")))
return ", ".join(dict.fromkeys(parts))
def log_rows(conn: Any, packet_id: int) -> list[dict[str, Any]]:
"""Every packet_log row oldest first, with the signer's name and employee number on signed rows (the log
endpoint's columns, no limit)."""
return packets.rows(
conn,
"SELECT l.id, l.at, l.by, l.by_name, l.role, l.action, l.note, l.it_reason, l.signature_id,"
" u.emp_no, s.display_name AS sig_name, su.emp_no AS sig_emp, su.welder_stamp AS sig_stamp"
" FROM packet_log l LEFT JOIN users u ON u.id = l.by"
" LEFT JOIN signatures s ON s.id = l.signature_id LEFT JOIN users su ON su.id = s.user_id"
" WHERE l.packet_id = %s ORDER BY l.id",
(int(packet_id),),
)
def shorts_of(conn: Any, packet_id: int) -> list[dict[str, Any]]:
"""Every short of the packet, open and closed, with its BOM item and its receipts."""
found = packets.rows(
conn,
"SELECT s.*, b.item FROM bom_shorts s JOIN bom_lines b ON b.id = s.bom_line_id"
" WHERE s.packet_id = %s ORDER BY b.item, s.id",
(int(packet_id),),
)
if not found:
return []
rec: dict[int, list[dict[str, Any]]] = {}
for r in packets.rows(
conn, "SELECT * FROM short_receipts WHERE short_id = ANY(%s) ORDER BY id", ([s["id"] for s in found],)
):
rec.setdefault(r["short_id"], []).append(r)
for s in found:
s["receipts"] = rec.get(s["id"], [])
return found
def project_of(p: Mapping[str, Any]) -> dict[str, Any]:
return dict(((p.get("unit") or {}).get("project")) or {})
def tag_of(p: Mapping[str, Any]) -> tuple[str, str]:
"""(project number, lot) as text."""
unit = p.get("unit") or {}
return str(project_of(p).get("number") or ""), str(unit.get("lot") or "")
# ---------------------------------------------------------------- pages
def drawing_pages(p: Mapping[str, Any]) -> list[dict[str, Any]]:
"""The live drawing pages in page order."""
return [pg for pg in p.get("pages") or [] if pg.get("type") == "drawing" and not pg.get("void")]
def void_pages(p: Mapping[str, Any]) -> list[dict[str, Any]]:
return [pg for pg in p.get("pages") or [] if pg.get("type") == "drawing" and pg.get("void")]
def page_co(p: Mapping[str, Any], n: int) -> int | None:
"""The change order a live page came with, from the change order items (new_page)."""
for co in p.get("change_orders") or []:
for it in co.get("items") or []:
if it.get("new_page") == n:
return co.get("co_no")
return None
def open_pdf(path: Path) -> PdfReader:
"""A PdfReader for a stored file. An encrypted file is opened with an empty password or refused."""
reader = PdfReader(str(path))
if reader.is_encrypted and not reader.decrypt(""):
raise PdfError("The PDF is encrypted.")
if len(reader.pages) < 1:
raise PdfError("The PDF has no pages.")
return reader
def raster_page_pdf(jpeg: bytes, w: float, h: float) -> bytes:
"""One page of the sheet's size with the JPEG filling it."""
buf = io.BytesIO()
c = Canvas(buf, pagesize=(w, h), pageCompression=1)
c.drawImage(ImageReader(io.BytesIO(jpeg)), 0, 0, w, h)
c.showPage()
c.save()
return buf.getvalue()
def image_page_pdf(path: Path) -> bytes:
"""A PNG or JPEG on a letter page, fit inside 540 x 700 and centred."""
img = ImageReader(str(path))
iw, ih = img.getSize()
if iw <= 0 or ih <= 0:
raise PdfError("The image is empty.")
sc = min(540 / iw, 700 / ih)
buf = io.BytesIO()
c = Canvas(buf, pagesize=(PAGE_W, PAGE_H), pageCompression=1)
c.drawImage(img, (PAGE_W - iw * sc) / 2, (PAGE_H - ih * sc) / 2, iw * sc, ih * sc, mask="auto")
c.showPage()
c.save()
return buf.getvalue()
def sniff(path: Path) -> str | None:
"""pdf, png or jpeg from the first bytes, or None."""
with path.open("rb") as f:
head = f.read(8)
if head.startswith(b"%PDF"):
return "pdf"
if head.startswith(b"\x89PNG"):
return "png"
if head.startswith(b"\xff\xd8"):
return "jpeg"
return None
+272
View File
@@ -0,0 +1,272 @@
"""PRINTED COPY PDFs: the packet print kinds and the ITP print, each page stamped with the old print stamp box.
Kinds: traveler, map-h (drawing with heat map), map-w (drawing with weld map), clean (the drawing pages as attached,
void pages stamped VOID), void (the weld-map raster of every void page), databook (the filed book when there is one,
else built now) and doc (one document of the packet: an MTR, a packet document, a Q.C. folder document or the filed
data book). The person prints the PDF from the viewer.
"""
from __future__ import annotations
import io
from collections.abc import Callable, Mapping
from datetime import datetime
from typing import Any
from pypdf import PdfReader, PdfWriter
from . import databook, maps, parts, stamps, traveler
from . import itp as itp_pdf
from .style import Built, NothingToPrint, PdfError, WhoRef, clean, utc_now
KINDS: dict[str, str] = {
"traveler": "Job traveler",
"map-h": "Drawing with heat map",
"map-w": "Drawing with weld map",
"clean": "Drawing (no markup)",
"void": "Superseded drawings (VOID)",
"databook": "Data book",
"doc": "Document",
}
SHEET_KINDS = ("map-h", "map-w", "clean")
def filed_book_asset(conn: Any, packet_id: int) -> Any:
"""The data book stored on the packet's unbroken seal, or None."""
r = conn.execute(
"SELECT databook_asset_id FROM packet_seals WHERE packet_id = %s AND broken_at IS NULL"
" AND databook_asset_id IS NOT NULL ORDER BY id DESC LIMIT 1",
(int(packet_id),),
).fetchone()
return None if r is None else r[0]
def doc_label(conn: Any, p: Mapping[str, Any], asset_id: Any) -> str:
"""The label of one of the packet's documents, or NothingToPrint when the asset is not one of them."""
want = str(asset_id or "")
if not want:
raise NothingToPrint("No document was named.")
for b in p.get("bom") or []:
if str(b.get("mtr_asset_id") or "") == want:
heat = str(b.get("heat") or "").strip()
return f"MTR - item {b.get('item')} {heat}".rstrip()
for d in p.get("docs") or []:
if str(d.get("asset_id") or "") == want:
return str(d.get("title") or d.get("kind_label") or d.get("file_name") or "Document")
for f in p.get("qc_folder_docs") or []:
if str(f.get("url") or "").rsplit("/", 1)[-1] == want:
return f"{f.get('type')} - {f.get('title') or ''}".rstrip(" -")
r = conn.execute(
"SELECT 1 FROM packet_seals WHERE packet_id = %s AND databook_asset_id::text = %s", (int(p["id"]), want)
).fetchone()
if r is not None:
return "Data book"
raise NothingToPrint("The document is not one of this packet's.")
def _sheets(p: Mapping[str, Any], kind: str, sheet: int | None) -> list[dict[str, Any]]:
drawing = [pg for pg in p.get("pages") or [] if pg.get("type") == "drawing"]
if sheet is not None:
found = [pg for pg in drawing if int(pg["n"]) == int(sheet)]
if not found:
raise NothingToPrint(f"The packet has no drawing sheet {sheet}.")
return found
if kind == "clean":
return drawing
return [pg for pg in drawing if not pg.get("void")]
def _add_pdf(writer: PdfWriter, data: bytes) -> None:
for pg in PdfReader(io.BytesIO(data)).pages:
writer.add_page(pg)
def _rasters(
conn: Any, settings: Any, p: Mapping[str, Any], pages: list[dict[str, Any]], fn: Callable[..., Any]
) -> PdfWriter:
writer = PdfWriter()
for pg in pages:
try:
jpeg, w, h = fn(conn, settings, p, pg)
except Exception as e:
raise PdfError(f"Drawing sheet {pg['n']} could not be read ({type(e).__name__}).") from e
_add_pdf(writer, parts.raster_page_pdf(jpeg, w, h))
return writer
def _clean(
conn: Any, settings: Any, p: Mapping[str, Any], pages: list[dict[str, Any]]
) -> tuple[PdfWriter, dict[int, str]]:
writer = PdfWriter()
voids: dict[int, str] = {}
readers: dict[str, PdfReader] = {}
tz = str(getattr(settings, "site_tz", "") or "America/Denver")
for pg in pages:
try:
key = str(pg.get("asset_id"))
if key not in readers:
_row, path = maps.asset_file(conn, settings, pg.get("asset_id"))
readers[key] = parts.open_pdf(path)
writer.add_page(readers[key].pages[int(pg.get("pn") or 1) - 1])
except Exception as e:
raise PdfError(f"Drawing sheet {pg['n']} could not be read ({type(e).__name__}).") from e
if pg.get("void"):
voids[len(writer.pages) - 1] = maps.void_text(pg, tz)
return writer, voids
def _doc(conn: Any, settings: Any, asset_id: Any) -> PdfWriter:
try:
_row, path = maps.asset_file(conn, settings, asset_id)
kind = parts.sniff(path)
writer = PdfWriter()
if kind == "pdf":
rd = parts.open_pdf(path)
for pg in rd.pages:
writer.add_page(pg)
elif kind in ("png", "jpeg"):
_add_pdf(writer, parts.image_page_pdf(path))
else:
raise PdfError("The file is not a PDF, PNG or JPEG.")
return writer
except PdfError:
raise
except Exception as e:
raise PdfError(f"The document could not be read ({type(e).__name__}).") from e
def _stamped(
writer: PdfWriter,
settings: Any,
*,
who: WhoRef,
now: datetime,
what: str,
kind: str,
packet: Mapping[str, Any] | None,
packet_id: Any,
voids: Mapping[int, str] | None = None,
title: str,
warnings: list[str] | None = None,
) -> Built:
if len(writer.pages) == 0:
raise NothingToPrint("Nothing to print.")
warnings = [] if warnings is None else warnings
text = stamps.print_stamp(who, now, what, packet)
stamp, sig = stamps.signed_stamp(
settings,
text,
packet_id=packet_id,
kind=f"print:{kind}",
seal_hash_hex=None,
who=who,
now=now,
warnings=warnings,
)
voids = voids or {}
def draw(c: Any, i: int, w: float, h: float) -> None:
if i in voids:
stamps.draw_void(c, w, h, voids[i])
stamps.draw_box_stamp(c, w, stamp)
stamps.overlay_writer(writer, draw)
keywords = ", ".join(x for x in ("printed copy", kind, f"stamp {sig}" if sig else "") if x)
meta = stamps.metadata(title, f"Printed copy - {what}", keywords, "Packet Station - printed copy", now=now)
data = stamps.write(writer, meta)
return Built(
bytes=data,
pages=len(PdfReader(io.BytesIO(data)).pages),
warnings=warnings,
stamp=stamp,
stamp_sig=sig,
what=what,
)
def build_print(
conn: Any,
settings: Any,
packet_id: int,
kind: str,
*,
who: WhoRef,
now: datetime | None = None,
sheet: int | None = None,
asset_id: Any = None,
) -> Built:
"""A PRINTED COPY of one packet print kind. NothingToPrint when the kind has nothing for this packet (no void
pages, an unknown sheet, a document that is not the packet's); PdfError when it cannot be built."""
if kind not in KINDS:
raise NothingToPrint(f"Unknown print kind {kind}.")
now = utc_now(now)
p = parts.load_packet(conn, packet_id)
project, lot = parts.tag_of(p)
what = KINDS[kind]
voids: dict[int, str] = {}
warnings: list[str] = []
if kind == "traveler":
data, _n = traveler.pages_of(conn, settings, p)
# Page numbers as on the exported traveler, then the print box.
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(traveler.finish(data, p, "", now, box=False))))
elif kind in ("map-h", "map-w"):
pages = _sheets(p, kind, sheet)
fn = maps.heat_map_page if kind == "map-h" else maps.weld_map_page
writer = _rasters(conn, settings, p, pages, fn)
elif kind == "clean":
writer, voids = _clean(conn, settings, p, _sheets(p, kind, sheet))
elif kind == "void":
pages = parts.void_pages(p)
if not pages:
raise NothingToPrint("The packet has no void pages.")
writer = _rasters(conn, settings, p, pages, maps.weld_map_page)
elif kind == "databook":
stored = asset_id or (filed_book_asset(conn, packet_id) if p.get("status") == "filed" else None)
if stored is not None:
writer = _doc(conn, settings, stored)
else:
if p.get("status") not in ("complete", "filed"):
raise NothingToPrint("The data book is built once the packet is back from Q.C.")
book = databook.build(conn, settings, packet_id, who=who, now=now, mode="print")
warnings += book.warnings
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(book.bytes)))
else:
what = doc_label(conn, p, asset_id)
writer = _doc(conn, settings, asset_id)
if sheet is not None and kind in SHEET_KINDS:
what = f"{what} (sheet {sheet})"
return _stamped(
writer,
settings,
who=who,
now=now,
what=clean(what),
kind=kind,
packet=p,
packet_id=p["id"],
voids=voids,
title=f"{project} Lot {lot} {clean(what)}",
warnings=warnings,
)
def print_itp(
conn: Any, settings: Any, itp_row: Mapping[str, Any], *, who: WhoRef, now: datetime | None = None
) -> Built:
"""The ITP PDF stamped PRINTED COPY, with its page numbers."""
now = utc_now(now)
data, _n, r = itp_pdf.pages_of(conn, settings, itp_row)
num = itp_pdf.finish(data, r, "", now, box=False)
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(num)))
what = f"ITP {clean(r.get('no'))}"
return _stamped(
writer,
settings,
who=who,
now=now,
what=what,
kind="itp",
packet=None,
packet_id=f"itp-{itp_row['id']}",
title=what,
)
+320
View File
@@ -0,0 +1,320 @@
"""Page rasters for the heat map, the weld map and the VOID stamp, drawn with Pillow.
Ports of the old renderMapCanvas, drawArrow, htDrawCanvas and stampVoidCanvas, and of the new frontend's
lib/heatTable.ts drawHeatTable, lib/voidStamp.ts and lib/geometry.ts bpos. Pillow's built-in sized font stands in
for Arial and Courier New (no font files are shipped); bold is drawn with a thin stroke of the same colour.
"""
from __future__ import annotations
import io
import math
from collections.abc import Iterable, Mapping, Sequence
from functools import lru_cache
from pathlib import Path
from typing import Any
from PIL import Image, ImageDraw, ImageFont
HEAT = (0xB0, 0x78, 0x00)
WELD = (0xC2, 0x18, 0x5B)
HEAT_MISSING = (0xB0, 0x00, 0x20)
HEAT_NA = (0x6B, 0x5A, 0x3A)
INK = (0x2B, 0x1D, 0x00)
HEAD_INK = (0x3D, 0x2A, 0x00)
HEAD_BG = (0xF3, 0xE2, 0xB8)
PILL_BG = (0xFF, 0xF8, 0xE6)
NO_HEAT_W = "MM" # the width of the red dash a missing heat shows (Pillow's font has no em dash glyph)
HT_ROWS = 12
HT_FONT = 0.0075
Font = Any # ImageFont.FreeTypeFont, or the bitmap default when FreeType is missing
@lru_cache(maxsize=256)
def font(size: float) -> Font:
px = max(6, round(size))
try:
return ImageFont.load_default(size=px)
except (TypeError, OSError, ImportError): # Pillow without FreeType: the fixed bitmap font
return ImageFont.load_default()
def text_w(f: Font, s: str) -> float:
try:
return float(f.getlength(s))
except AttributeError:
box = f.getbbox(s)
return float(box[2] - box[0])
def _text(
d: ImageDraw.ImageDraw,
xy: tuple[float, float],
s: str,
f: Font,
fill: Sequence[int],
anchor: str = "mm",
bold: bool = True,
) -> None:
size = getattr(f, "size", 12)
sw = max(1, round(size / 22)) if bold else 0
d.text(xy, s, font=f, fill=tuple(fill), anchor=anchor, stroke_width=sw, stroke_fill=tuple(fill))
# ---------------------------------------------------------------- the page
def page_points(page: Any) -> tuple[float, float]:
"""A pdfium page's MediaBox size in points, after /Rotate."""
try:
llx, lly, urx, ury = page.get_mediabox()
w, h = abs(urx - llx), abs(ury - lly)
except Exception: # an odd box: fall back to pdfium's own size
w, h = page.get_size()
if int(page.get_rotation() or 0) % 180 == 90:
w, h = h, w
return float(w), float(h)
def render_page(
path: Path | str, pn: int, max_px: int = 3400, max_scale: float = 200 / 72
) -> tuple[Image.Image, float, float]:
"""Page `pn` (1-based) of a PDF as an RGB image on white, rotation applied, at most `max_px` on the long side and
at most `max_scale` pixels per point. Returns the image and the page size in points."""
import pypdfium2 as pdfium
doc = pdfium.PdfDocument(str(path))
try:
if pn < 1 or pn > len(doc):
raise ValueError(f"The PDF has no page {pn}.")
page = doc[pn - 1]
try:
w_pt, h_pt = page_points(page)
scale = min(max_scale, max_px / max(w_pt, h_pt, 1.0))
bitmap = page.render(scale=scale, fill_color=(255, 255, 255, 255))
img = bitmap.to_pil().convert("RGB")
finally:
page.close()
finally:
doc.close()
return img, w_pt, h_pt
def to_jpeg(img: Image.Image, quality: int = 90) -> bytes:
buf = io.BytesIO()
img.convert("RGB").save(buf, "JPEG", quality=quality, optimize=True)
return buf.getvalue()
# ---------------------------------------------------------------- marks
def clamp01(v: float) -> float:
return max(0.01, min(0.99, v))
def bpos(m: Mapping[str, Any]) -> tuple[float, float, float, float]:
"""(fx, fy, bfx, bfy): the arrow point and the bubble. A stored bubble wins; else the old default spot."""
fx = float(m["fx"]) if m.get("fx") is not None else 0.5
fy = float(m["fy"]) if m.get("fy") is not None else 0.5
bfx = float(m["bfx"]) if m.get("bfx") is not None else clamp01(fx + (-0.05 if fx > 0.85 else 0.05))
bfy = float(m["bfy"]) if m.get("bfy") is not None else clamp01(fy + (0.07 if fy < 0.12 else -0.07))
return fx, fy, bfx, bfy
def mark_radius(width: float) -> float:
return max(18.0, width / 150)
def _arrow(
d: ImageDraw.ImageDraw, ax: float, ay: float, bx: float, by: float, size: float, color: Sequence[int]
) -> None:
"""The old drawArrow: a filled head at (ax, ay) pointing away from (bx, by)."""
ang = math.atan2(ay - by, ax - bx)
ca, sa = math.cos(ang), math.sin(ang)
pts = [(0.0, 0.0), (-size * 1.6, -size * 0.6), (-size * 1.6, size * 0.6)]
d.polygon([(ax + x * ca - y * sa, ay + x * sa + y * ca) for x, y in pts], fill=tuple(color))
def _leaders(d: ImageDraw.ImageDraw, marks: Iterable[Mapping[str, Any]], w: int, h: int, color: Sequence[int]) -> None:
r = mark_radius(w)
for m in marks:
fx, fy, bfx, bfy = bpos(m)
ax, ay, bx, by = fx * w, fy * h, bfx * w, bfy * h
d.line([(bx, by), (ax, ay)], fill=tuple(color), width=max(2, round(r / 7)))
_arrow(d, ax, ay, bx, by, r / 2.4, color)
def draw_weld_marks(img: Image.Image, welds: Iterable[Mapping[str, Any]], page_n: int) -> Image.Image:
"""The weld bubbles of one page: the leader to the arrow point, the arrowhead, a white disc with a pink ring of
r/6 and the id W{n} bold in the middle."""
on = [wd for wd in welds if wd.get("page") == page_n]
if not on:
return img
w, h = img.size
d = ImageDraw.Draw(img)
r = mark_radius(w)
_leaders(d, on, w, h, WELD)
f = font(round(r * 0.85))
for wd in on:
_fx, _fy, bfx, bfy = bpos(wd)
x, y = bfx * w, bfy * h
d.ellipse([x - r, y - r, x + r, y + r], fill=(255, 255, 255), outline=WELD, width=max(1, round(r / 6)))
label = f"W{wd.get('weld_no')}"
fl = f if text_w(f, label) <= r * 1.6 else font(r * 0.85 * r * 1.6 / max(text_w(f, label), 1.0))
_text(d, (x, y), label, fl, WELD)
return img
def heat_of(bom: Iterable[Mapping[str, Any]], item: Any) -> str:
for b in bom:
if str(b.get("item")) == str(item):
heat = str(b.get("heat") or "").strip()
return heat or ("N/A" if b.get("na") else "")
return ""
def draw_heat_tags(
img: Image.Image, tags: Iterable[Mapping[str, Any]], bom: Sequence[Mapping[str, Any]], page_n: int
) -> Image.Image:
"""The older heat tags of one page: an amber pill with the item in a filled circle and the heat (or NO HEAT)."""
on = [t for t in tags if t.get("page") == page_n]
if not on:
return img
w, h = img.size
d = ImageDraw.Draw(img)
r = mark_radius(w)
_leaders(d, on, w, h, HEAT)
f_txt = font(round(r * 0.8))
f_item = font(round(r * 0.75))
for t in on:
_fx, _fy, bfx, bfy = bpos(t)
x, y = bfx * w, bfy * h
txt = heat_of(bom, t.get("item")) or "NO HEAT"
tw = text_w(f_txt, txt)
ph = r * 1.7
cr = ph * 0.42
pw = tw + ph + r * 0.9
x0, y0 = x - pw / 2, y - ph / 2
d.rounded_rectangle(
[x0, y0, x0 + pw, y0 + ph], radius=ph / 2, fill=PILL_BG, outline=HEAT, width=max(1, round(r / 6))
)
cx = x0 + ph / 2
d.ellipse([cx - cr, y - cr, cx + cr, y + cr], fill=HEAT)
_text(d, (cx, y), str(t.get("item")), f_item, (255, 255, 255))
_text(d, (x0 + ph + r * 0.2, y), txt, f_txt, HEAD_INK, anchor="lm")
return img
# ---------------------------------------------------------------- the heat table
def heat_rows(bom: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
"""ITEM and HEAT NO. per BOM line: the heat, N/A on an N/A line, or empty when missing."""
out = []
for b in bom:
heat = str(b.get("heat") or "").strip()
out.append(
{"item": str(b.get("item")), "heat": heat or ("N/A" if b.get("na") else ""), "na": bool(b.get("na"))}
)
return out
def heat_groups(rows: Sequence[Any]) -> list[list[Any]]:
"""At most 12 rows per group, the groups as even as they can be. Always at least one group."""
n = len(rows)
per = math.ceil(n / math.ceil(n / HT_ROWS)) if n > HT_ROWS else HT_ROWS
out = [list(rows[i : i + per]) for i in range(0, n, per)]
return out or [[]]
def draw_heat_table(img: Image.Image, placement: Mapping[str, Any], rows: Sequence[Mapping[str, Any]]) -> Image.Image:
"""The heat table on a page raster: HEAT NUMBERS on an amber band, then ITEM | HEAT NO. groups side by side.
Font size W x 0.0075 x s; a missing heat is a red dash, N/A is brown."""
W, H = img.size
s = float(placement.get("s") or 1)
fs = W * HT_FONT * s
pad_x = fs * 0.5
pad_y = fs * 0.2
row_h = fs * 1.15 + pad_y * 2
bw = max(1, round(max(1.5, fs * 0.08)))
groups = heat_groups(rows)
th_f, td_f, cap_f = font(round(fs * 0.8)), font(round(fs)), font(round(fs * 0.85))
widths = []
for g in groups:
wi = max([text_w(th_f, "ITEM")] + [text_w(td_f, r["item"]) for r in g])
wh = max(
[text_w(th_f, "HEAT NO.")] + [text_w(td_f, r["heat"] or NO_HEAT_W) * (1 if r["heat"] else 0.5) for r in g]
)
widths.append((wi + pad_x * 2, wh + pad_x * 2))
total_w = sum(a + b for a, b in widths)
cap_h = row_h
max_rows = max(len(g) for g in groups)
total_h = cap_h + row_h * (max_rows + 1)
x0 = float(placement.get("x") or 0) * W
y0 = float(placement.get("y") or 0) * H
d = ImageDraw.Draw(img)
d.rectangle([x0, y0, x0 + total_w, y0 + total_h], fill=(255, 255, 255))
d.rectangle([x0, y0, x0 + total_w, y0 + cap_h], fill=HEAT)
_text(d, (x0 + total_w / 2, y0 + cap_h / 2), "HEAT NUMBERS", cap_f, (255, 255, 255))
gx = x0
for gi, g in enumerate(groups):
wi, wh = widths[gi]
y = y0 + cap_h
d.rectangle([gx, y, gx + wi + wh, y + row_h], fill=HEAD_BG)
_text(d, (gx + wi / 2, y + row_h / 2), "ITEM", th_f, HEAD_INK)
_text(d, (gx + wi + wh / 2, y + row_h / 2), "HEAT NO.", th_f, HEAD_INK)
d.rectangle([gx, y, gx + wi, y + row_h], outline=HEAT, width=bw)
d.rectangle([gx + wi, y, gx + wi + wh, y + row_h], outline=HEAT, width=bw)
y += row_h
for r in g:
_text(d, (gx + wi / 2, y + row_h / 2), r["item"], td_f, INK)
color = (HEAT_NA if r["na"] else INK) if r["heat"] else HEAT_MISSING
if r["heat"]:
_text(d, (gx + wi + pad_x, y + row_h / 2), r["heat"], td_f, color, anchor="lm")
else:
dash = text_w(td_f, NO_HEAT_W) * 0.5
ym = y + row_h / 2
d.line([(gx + wi + pad_x, ym), (gx + wi + pad_x + dash, ym)], fill=color, width=max(1, round(fs / 10)))
d.rectangle([gx, y, gx + wi, y + row_h], outline=HEAT, width=bw)
d.rectangle([gx + wi, y, gx + wi + wh, y + row_h], outline=HEAT, width=bw)
y += row_h
gx += wi + wh
d.rectangle([x0, y0, x0 + total_w, y0 + total_h], outline=HEAT, width=bw * 2)
return img
# ---------------------------------------------------------------- VOID
def stamp_void(img: Image.Image, text: str) -> Image.Image:
"""A big diagonal VOID at low opacity, the void text under it, and a red border (voidStamp.ts)."""
base = img.convert("RGBA")
w, h = base.size
layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
big = font(round(w / 5.5))
d.text(
(w / 2, h / 2),
"VOID",
font=big,
anchor="mm",
fill=(220, 0, 0, round(255 * 0.3)),
stroke_width=max(4, round(w / 260)),
stroke_fill=(200, 0, 0, round(255 * 0.55)),
)
if text:
small = font(round(w / 55))
d.text((w / 2, h / 2 + w / 9), text, font=small, anchor="mm", fill=(200, 0, 0, round(255 * 0.8)))
layer = layer.rotate(
math.degrees(math.atan2(h, w) * 0.85), center=(w / 2, h / 2), resample=Image.Resampling.BICUBIC
)
out = Image.alpha_composite(base, layer)
lw = max(6, round(w / 200))
border = Image.new("RGBA", (w, h), (0, 0, 0, 0))
ImageDraw.Draw(border).rectangle(
[lw / 2, lw / 2, w - lw / 2, h - lw / 2], outline=(200, 0, 0, round(255 * 0.7)), width=lw
)
out = Image.alpha_composite(out, border)
return out.convert("RGB")
+280
View File
@@ -0,0 +1,280 @@
"""Stamps and metadata: the controlled-copy line, the PRINTED COPY box, the stamp signature, the VOID overlay on a
copied drawing page, and the document information.
The stamp signature is 16 hex characters of HMAC-SHA256 with the seal key over
"stamp|{packet_id}|{kind}|{seal hash}|{user id}|{minute}". The minute is the one the stamp line names, as
YYYY-MM-DDTHH:MMZ (style.stamp_at), so a printed copy can be checked from its own stamp and the print log.
"""
from __future__ import annotations
import hashlib
import hmac
import io
import math
from collections.abc import Callable, Iterable, Mapping
from datetime import datetime
from typing import Any
from pypdf import PdfReader, PdfWriter, Transformation
from reportlab.lib.colors import Color
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen.canvas import Canvas
from .style import GREY, WhoRef, as_dt, ascii_only, pdf_date, stamp_at, utc_stamp, who_text
AUTHOR = "PESCO - Process Equipment & Service Company, Inc."
PRODUCER = "Packet Station"
SIG_LEN = 16
Draw = Callable[[Canvas, int, float, float], None]
# ---------------------------------------------------------------- texts
def controlled_stamp(who: WhoRef | None, now: datetime | None, *, mode: str, seal_hash_hex: str | None = None) -> str:
"""The controlled-copy line. mode "filed" names the seal (first 12 hex), "preview" says not yet filed, "print"
says printed by (and the seal or not yet filed), "traveler" carries no seal part."""
verb = "printed" if mode == "print" else "exported"
text = f"Controlled copy - {verb} by {who_text(who)} {utc_stamp(now)}"
if mode != "traveler":
text += f" - seal {seal_hash_hex[:12]}" if seal_hash_hex else " - not yet filed"
return ascii_only(text)
def project_lot(packet: Mapping[str, Any] | None) -> tuple[str, str]:
"""The project number and the lot of a packet read model, as text."""
unit = (packet or {}).get("unit") or {}
project = unit.get("project") or {}
return str(project.get("number") or ""), str(unit.get("lot") or "")
def print_stamp(who: WhoRef | None, now: datetime | None, what: str, packet: Mapping[str, Any] | None) -> str:
"""PRINTED COPY - {what} - printed by Name (emp) {UTC} - {project} Lot {unit} dwg {drawing} R{rev} - verify
revision before use. Anything outside printable ASCII becomes "-"."""
text = f"PRINTED COPY - {what} - printed by {who_text(who)} {utc_stamp(now)}"
if packet is not None:
project, lot = project_lot(packet)
text += f" - {project} Lot {lot} dwg {packet.get('drawing') or ''} R{packet.get('rev') or ''}"
text += " - verify revision before use"
return ascii_only(text, "-")
def minute_of(at_iso: Any) -> str:
"""The signed minute: any ISO time (or datetime) as YYYY-MM-DDTHH:MMZ in UTC. Text that does not parse is
signed as it is."""
v = as_dt(at_iso)
if isinstance(v, datetime):
return stamp_at(v)
return str(at_iso or "")
def stamp_sig(key: bytes, packet_id: Any, kind: str, seal_hash_hex: str | None, user_id: Any, at_iso: Any) -> str:
"""16 hex characters of HMAC-SHA256 over "stamp|" + the fields joined with "|". The time is signed to the
minute the stamp line shows (minute_of), so a stamp can be checked from its own text."""
parts = [
str(packet_id if packet_id is not None else ""),
str(kind),
seal_hash_hex or "",
str(user_id or ""),
minute_of(at_iso),
]
msg = ("stamp|" + "|".join(parts)).encode("utf-8")
return hmac.new(key, msg, hashlib.sha256).hexdigest()[:SIG_LEN]
def verify_stamp(
key: bytes, packet_id: Any, kind: str, seal_hash_hex: str | None, user_id: Any, at_iso: Any, sig: str
) -> bool:
"""True when `sig` is the stamp signature of these fields (the stamp_sig arguments, then the signature)."""
want = stamp_sig(key, packet_id, kind, seal_hash_hex, user_id, at_iso)
return hmac.compare_digest(want, str(sig or "").strip().lower())
def with_sig(text: str, sig: str) -> str:
return f"{text} - stamp {sig}" if sig else text
def signed_stamp(
settings: Any,
text: str,
*,
packet_id: Any,
kind: str,
seal_hash_hex: str | None,
who: WhoRef | None,
now: datetime | None,
warnings: list[str] | None = None,
) -> tuple[str, str]:
"""The stamp line with its signature, and the signature. Without a readable seal key the line goes out unsigned
and a warning is added."""
try:
key = settings.seal_key()
except Exception: # the key file is missing or unreadable: stamp without a signature
if warnings is not None:
warnings.append("The seal key is not available, so the stamp is not signed.")
return text, ""
sig = stamp_sig(key, packet_id, kind, seal_hash_hex, who.get("id") if who else None, stamp_at(now))
return with_sig(text, sig), sig
# ---------------------------------------------------------------- drawing on pages
def draw_line_stamp(c: Canvas, w: float, text: str, *, own: bool) -> None:
"""The controlled-copy line: on our pages at x=48, y=9 in 6.5 pt grey; on copied pages centred at y=3 in 6 pt."""
c.setFillColor(Color(GREY[0] / 255, GREY[1] / 255, GREY[2] / 255))
if own:
c.setFont("Helvetica", 6.5)
c.drawString(48, 9, text)
else:
c.setFont("Helvetica", 6)
c.drawString(max(2.0, (w - stringWidth(text, "Helvetica", 6)) / 2), 3, text)
def draw_box_stamp(c: Canvas, w: float, text: str) -> None:
"""The old print stamp: a white box at 92 % opacity at the bottom centre, the text in #333."""
fs = min(12.0, max(7.0, w / 150))
tw = stringWidth(text, "Helvetica", fs)
room = w - fs * 1.2 - 4
if tw > room:
fs = max(3.5, fs * room / tw)
tw = stringWidth(text, "Helvetica", fs)
pad = fs * 0.5
bh = fs * 1.6
x = max(pad, (w - tw) / 2)
y0 = pad * 0.4
c.saveState()
c.setFillColor(Color(1, 1, 1))
c.setFillAlpha(0.92)
c.rect(x - pad, y0, tw + pad * 2, bh, stroke=0, fill=1)
c.restoreState()
c.setFont("Helvetica", fs)
c.setFillColor(Color(0x33 / 255, 0x33 / 255, 0x33 / 255))
c.drawString(x, y0 + bh / 2 - fs * 0.35, text)
def draw_void(c: Canvas, w: float, h: float, sub: str) -> None:
"""VOID on a copied drawing page: HelveticaBold w/5.5 along the diagonal at 30 % red, the void text at 12 %
height in w/70, a red border inset 4 pt, width max(3, w/250), at 70 %."""
size = w / 5.5
tw = stringWidth("VOID", "Helvetica-Bold", size)
ang = math.atan2(h, w) * 0.85
x = w / 2 - math.cos(ang) * tw / 2 + math.sin(ang) * size * 0.35
y = h / 2 - math.sin(ang) * tw / 2 - math.cos(ang) * size * 0.35
red = Color(0.8, 0, 0)
c.saveState()
c.setFillColor(red)
c.setFillAlpha(0.3)
c.translate(x, y)
c.rotate(math.degrees(ang))
c.setFont("Helvetica-Bold", size)
c.drawString(0, 0, "VOID")
c.restoreState()
text = ascii_only(sub, "-")
ss = w / 70
c.setFillColor(red)
c.setFont("Helvetica-Bold", ss)
c.drawString(w / 2 - stringWidth(text, "Helvetica-Bold", ss) / 2, h * 0.12, text)
c.saveState()
c.setStrokeColor(red)
c.setStrokeAlpha(0.7)
c.setLineWidth(max(3.0, w / 250))
c.rect(4, 4, w - 8, h - 8, stroke=1, fill=0)
c.restoreState()
# ---------------------------------------------------------------- overlay
def page_size(page: Any) -> tuple[float, float, float, float]:
"""(llx, lly, width, height) of a pypdf page's MediaBox."""
box = page.mediabox
return float(box.left), float(box.bottom), float(box.width), float(box.height)
def overlay_writer(writer: PdfWriter, draw: Draw, pages: Iterable[int] | None = None) -> None:
"""Draw on every page (or the given page indices) of a writer: one reportlab page per page, merged on top.
A page with /Rotate has its rotation moved into its content first, so the overlay reads upright."""
wanted = list(range(len(writer.pages))) if pages is None else sorted(set(pages))
if not wanted:
return
buf = io.BytesIO()
c = Canvas(buf, pageCompression=1)
boxes: list[tuple[float, float]] = []
for i in wanted:
page = writer.pages[i]
if int(page.get("/Rotate", 0) or 0) % 360:
page.transfer_rotation_to_content()
llx, lly, w, h = page_size(page)
boxes.append((llx, lly))
c.setPageSize((w, h))
draw(c, i, w, h)
c.showPage()
c.save()
ov = PdfReader(io.BytesIO(buf.getvalue()))
for n, i in enumerate(wanted):
llx, lly = boxes[n]
if llx or lly:
writer.pages[i].merge_transformed_page(ov.pages[n], Transformation().translate(llx, lly))
else:
writer.pages[i].merge_page(ov.pages[n])
def overlay(pdf_bytes: bytes, draw: Draw, *, metadata: Mapping[str, str] | None = None) -> bytes:
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(pdf_bytes)))
overlay_writer(writer, draw)
if metadata:
writer.add_metadata(dict(metadata))
out = io.BytesIO()
writer.write(out)
return out.getvalue()
def apply_stamp(pdf_bytes: bytes, text: str, *, ours: set[int] | None = None, box: bool = False) -> bytes:
"""Draw the stamp line on every page. `ours` are the 0-based indices of our generated pages (None: all are
ours). box=True draws the PRINTED COPY box instead."""
def draw(c: Canvas, i: int, w: float, h: float) -> None:
del h
if box:
draw_box_stamp(c, w, text)
else:
draw_line_stamp(c, w, text, own=ours is None or i in ours)
return overlay(pdf_bytes, draw)
def metadata(title: str, subject: str, keywords: str, creator: str, *, now: datetime | None = None) -> dict[str, str]:
stamp = pdf_date(now)
return {
"/Title": ascii_only(title, "-"),
"/Author": AUTHOR,
"/Subject": ascii_only(subject, "-"),
"/Keywords": ascii_only(keywords, "-"),
"/Creator": ascii_only(creator, "-"),
"/Producer": PRODUCER,
"/CreationDate": stamp,
"/ModDate": stamp,
}
def set_metadata(
pdf_bytes: bytes, title: str, subject: str, keywords: str, creator: str, *, now: datetime | None = None
) -> bytes:
"""The document information: title, subject, keywords, creator, Author PESCO, Producer Packet Station and the
creation date."""
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(pdf_bytes)))
writer.add_metadata(metadata(title, subject, keywords, creator, now=now))
out = io.BytesIO()
writer.write(out)
return out.getvalue()
def write(writer: PdfWriter, meta: Mapping[str, str] | None = None) -> bytes:
if meta:
writer.add_metadata(dict(meta))
out = io.BytesIO()
writer.write(out)
return out.getvalue()
+227
View File
@@ -0,0 +1,227 @@
"""Shared look and helpers for the PDF builders: colours, page geometry, text cleaning, dates and the result types.
The colours, the page frame and the address lines are the old databook.js values (DBC, L, R, CW, PESCO_ADDR).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any, TypedDict
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
# ---------------------------------------------------------------- colours (RGB 0..255)
NAVY = (31, 43, 69)
TEAL = (13, 127, 146)
GREY = (107, 114, 128)
LINE = (213, 217, 223)
PANEL = (244, 246, 249)
AMBER = (138, 97, 0)
AMBER_BG = (253, 246, 230)
RED = (154, 43, 43)
GREEN = (30, 110, 70)
WHITE = (255, 255, 255)
GREEN_BG = (232, 245, 238)
RED_BG = (250, 234, 234)
REMARK = (55, 65, 81)
# ---------------------------------------------------------------- page geometry (points, letter portrait)
PAGE_W = 612.0
PAGE_H = 792.0
L = 48.0
R = 564.0
CW = R - L
PESCO_ADDR = ("5680 Highway 64", "Farmington, NM 87401", "505.327.2222 • pescoinc.biz")
PESCO_NAME = "PESCO - Process Equipment & Service Company, Inc."
# ---------------------------------------------------------------- results
class PdfError(Exception):
"""A PDF could not be assembled. Per-part problems never raise this; they become warnings."""
class NothingToPrint(PdfError):
"""The print kind has nothing to print for this packet, or the sheet or document is not one of its own."""
class WhoRef(TypedDict):
"""Who builds the PDF: the user id, the display name and the employee number (text, may be None)."""
id: str | None
display_name: str | None
emp_no: str | None
class IndexRow(TypedDict):
"""One row of the data book index. section is 1..7; page is the first page number or None."""
section: int
doc: str
ref: str | None
rev: str | None
page: int | None
warn: bool
count: int
@dataclass
class Built:
"""A built PDF. `stamp` is the stamp line as printed, `stamp_sig` the 16 hex signed into it, `what` the label."""
bytes: bytes
pages: int
index: list[IndexRow] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
stamp: str = ""
stamp_sig: str = ""
what: str = ""
# ---------------------------------------------------------------- text
_MAP = str.maketrans(
{
"‘": "'",
"’": "'",
"“": '"',
"”": '"',
"–": "-",
"—": "-",
"→": "->",
"\t": " ",
}
)
_BULLET = "•"
_DROP = re.compile(r"[\x00-\x09\x0b-\x1f\x7f-\x9f]")
def clean(text: Any) -> str:
"""The old clean(): curly quotes to straight, dashes to "-", arrows to "->". Anything outside Latin-1 is
dropped except the bullet. Control characters are dropped too; a newline stays (it breaks a table cell)."""
s = "" if text is None else str(text)
s = s.replace("\r\n", "\n").replace("\r", "\n").translate(_MAP)
s = "".join(ch for ch in s if ord(ch) <= 0xFF or ch == _BULLET)
return _DROP.sub("", s)
def ascii_only(text: Any, repl: str = "") -> str:
"""Printable ASCII only: everything else becomes `repl`."""
return re.sub(r"[^\x20-\x7e]", repl, clean(text).replace("\n", " "))
def who_text(who: WhoRef | None) -> str:
"""'Name (emp)'. A missing employee number reads 'Name (?)'."""
if not who:
return "unknown"
name = str(who.get("display_name") or "").strip() or "unknown"
emp = str(who.get("emp_no") or "").strip() or "?"
return f"{name} ({emp})"
def fmt_num(value: Any) -> str:
"""A quantity or hours without trailing zeros: 2, 2.5. Text stays as it is."""
if value is None or value == "":
return ""
if isinstance(value, bool):
return str(value)
if isinstance(value, int):
return str(value)
if isinstance(value, Decimal | float):
d = Decimal(str(value)).normalize()
out = format(d, "f")
return out.rstrip("0").rstrip(".") if "." in out else out
return str(value)
def fmt_clock(ms: Any) -> str:
"""Elapsed milliseconds as h:mm."""
try:
t = max(0, int(ms or 0) // 1000)
except (TypeError, ValueError):
t = 0
return f"{t // 3600}:{t % 3600 // 60:02d}"
# ---------------------------------------------------------------- dates
def zone(tz: str | None) -> ZoneInfo:
try:
return ZoneInfo(tz or "America/Denver")
except (ZoneInfoNotFoundError, ValueError):
return ZoneInfo("America/Denver")
def as_dt(value: Any) -> datetime | date | None:
"""A datetime or date from a column value or an ISO string (jsonb snapshots store ISO text)."""
if value is None or value == "":
return None
if isinstance(value, datetime | date):
return value
if isinstance(value, int | float):
try:
return datetime.fromtimestamp(float(value) / 1000, UTC)
except (OverflowError, OSError, ValueError):
return None
try:
return datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
except ValueError:
return None
def _local(value: Any, tz: str | None) -> datetime | date | None:
v = as_dt(value)
if isinstance(v, datetime) and v.tzinfo is not None:
return v.astimezone(zone(tz))
return v
def fmt_d(value: Any, tz: str | None = None, empty: str = "—") -> str:
"""M/D/YYYY in the site time zone."""
v = _local(value, tz)
if v is None:
return empty
return f"{v.month}/{v.day}/{v.year}"
def fmt_dt(value: Any, tz: str | None = None, empty: str = "—") -> str:
"""M/D/YYYY h:mm AM in the site time zone. A plain date gives the date only."""
v = _local(value, tz)
if v is None:
return empty
if not isinstance(v, datetime):
return f"{v.month}/{v.day}/{v.year}"
h = v.hour % 12 or 12
return f"{v.month}/{v.day}/{v.year} {h}:{v.minute:02d} {'AM' if v.hour < 12 else 'PM'}"
def utc_now(now: datetime | None = None) -> datetime:
n = now or datetime.now(UTC)
if n.tzinfo is None:
n = n.replace(tzinfo=UTC)
return n.astimezone(UTC)
def utc_stamp(now: datetime | None = None) -> str:
"""YYYY-MM-DD HH:MM UTC."""
return utc_now(now).strftime("%Y-%m-%d %H:%M UTC")
def stamp_at(now: datetime | None = None) -> str:
"""The minute a stamp names, as signed into the stamp signature: YYYY-MM-DDTHH:MMZ."""
return utc_now(now).strftime("%Y-%m-%dT%H:%MZ")
def pdf_date(now: datetime | None = None) -> str:
"""A PDF date string: D:YYYYMMDDHHmmSSZ."""
return utc_now(now).strftime("D:%Y%m%d%H%M%SZ")
def site_tz(settings: Any) -> str:
return str(getattr(settings, "site_tz", None) or "America/Denver")
+289
View File
@@ -0,0 +1,289 @@
"""The traceability CSV: the old exportCsv, row for row, from the packet read model.
Every cell goes through `csv_cell`, the formula guard: a value that starts with =, +, -, @, a tab or a CR gets a
leading apostrophe, so a spreadsheet shows it as text and never runs it. Lines end in CRLF. UTF-8, no BOM.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from ..domain import packets as packets_dom
from ..domain.pipeline import qty_text
FORMULA_START = ("=", "+", "-", "@", "\t", "\r")
TITLE = "PESCO weld & heat traceability record"
BOM_HEAD = (
"BOM item",
"Qty",
"Issued",
"Location",
"Part ID",
"Description",
"Length",
"Material",
"Heat no.",
"MTR on file",
"Open short qty",
"Short reason",
"Short received",
)
WELD_HEAD = (
"Weld",
"Work order",
"Piece",
"Joins items",
"Heats joined",
"Type",
"Size",
"Process",
"WPS",
"Stamp",
"Logged",
)
CHECK_HEAD = ("Checklist item", "Done", "Stamp", "When")
def csv_cell(value: Any) -> str:
"""One CSV cell with the formula guard and RFC 4180 quoting."""
text = "" if value is None else str(value)
if text.startswith(FORMULA_START):
text = "'" + text
if any(c in text for c in ('"', ",", "\n", "\r")):
text = '"' + text.replace('"', '""') + '"'
return text
def to_csv(rows: Iterable[Sequence[Any]]) -> str:
return "\r\n".join(",".join(csv_cell(v) for v in r) for r in rows)
def _zone(tz: str | None) -> ZoneInfo:
try:
return ZoneInfo(tz or "America/Denver")
except (ZoneInfoNotFoundError, ValueError):
return ZoneInfo("America/Denver")
def _when(value: Any, tz: str | None) -> datetime | date | None:
if value is None or value == "":
return None
if isinstance(value, datetime):
return value.astimezone(_zone(tz)) if value.tzinfo else value
if isinstance(value, date):
return value
try:
d = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
return d.astimezone(_zone(tz)) if d.tzinfo else d
def fmt_dt(value: Any, tz: str | None) -> str:
"""M/D/YYYY h:mm AM in the site time zone; empty for no value."""
d = _when(value, tz)
if d is None:
return ""
if not isinstance(d, datetime):
return f"{d.month}/{d.day}/{d.year}"
hour = d.hour % 12 or 12
return f"{d.month}/{d.day}/{d.year} {hour}:{d.minute:02d} {'AM' if d.hour < 12 else 'PM'}"
def fmt_d(value: Any, tz: str | None) -> str:
"""M/D/YYYY in the site time zone; empty for no value."""
d = _when(value, tz)
if d is None:
return ""
return f"{d.month}/{d.day}/{d.year}"
def _num(value: Any) -> str:
return "" if value is None else qty_text(value)
def _done(entry: Any) -> bool:
return entry is True or (isinstance(entry, Mapping) and entry.get("done") is True)
def _entry(entry: Any) -> Mapping[str, Any]:
return entry if isinstance(entry, Mapping) else {}
def _shorts(conn: Any, packet_id: int) -> dict[int, dict[str, Any]]:
"""The newest short per BOM line, open or closed, with its receipts (a closed short still has its record)."""
out: dict[int, dict[str, Any]] = {}
for s in packets_dom.rows(conn, "SELECT * FROM bom_shorts WHERE packet_id = %s ORDER BY id", (packet_id,)):
out[int(s["bom_line_id"])] = {**s, "receipts": []}
ids = {int(s["id"]): line for line, s in out.items()}
if ids:
for r in packets_dom.rows(
conn, "SELECT * FROM short_receipts WHERE short_id = ANY(%s) ORDER BY id", (list(ids),)
):
out[ids[int(r["short_id"])]]["receipts"].append(r)
return out
def _stage(read: Mapping[str, Any]) -> Mapping[str, Any]:
"""The open stage, else the last closed one, else nothing."""
if read.get("stage"):
return read["stage"]
stages = read.get("stages") or []
return stages[-1] if stages else {}
def _inspection(conn: Any, read: Mapping[str, Any]) -> Mapping[str, Any] | None:
"""The open inspection, else the newest closed one (its snapshot carries every field)."""
if read.get("qc"):
return read["qc"]
r = packets_dom.one(
conn,
"SELECT result, inspector, at, snapshot FROM qc_history WHERE packet_id = %s ORDER BY id DESC LIMIT 1",
(read["id"],),
)
if r is None:
return None
snap = r["snapshot"] if isinstance(r["snapshot"], Mapping) else {}
return {
**snap,
"result": r["result"] or snap.get("result"),
"inspector": r["inspector"] or snap.get("inspector"),
"result_at": snap.get("result_at") or r["at"],
}
def rows_for(conn: Any, read: Mapping[str, Any], tz: str | None) -> list[list[Any]]:
"""Every CSV row for one packet read model."""
unit = read.get("unit") or {}
project = (unit.get("project") or {}).get("number") or ""
lot = unit.get("lot") or unit.get("number") or ""
stage = _stage(read)
welder = stage.get("welder") or {}
station = stage.get("station") or {}
out: list[list[Any]] = [
[TITLE],
["Project", project, "Lot", lot, "Operation", read.get("op")],
[
"Drawing",
read.get("drawing"),
"Rev",
read.get("rev"),
"Title",
read.get("title"),
"Customer",
read.get("customer"),
],
[
"WPS",
read.get("wps"),
"Station",
station.get("name"),
"Welder",
welder.get("display_name"),
"Stamp",
welder.get("stamp"),
],
["Filler", stage.get("filler"), "Filler lot", stage.get("filler_lot"), "Preheat", stage.get("preheat")],
[],
list(BOM_HEAD),
]
bom = read.get("bom") or []
shorts = _shorts(conn, int(read["id"]))
for b in bom:
s = shorts.get(int(b["id"])) if b.get("id") is not None else None
open_short = b.get("short")
heat = b.get("heat") or ("N/A" if b.get("na") else "")
received = "; ".join(
f"{_num(r['qty'])} pc {r['heat'] or ''} {fmt_d(r['at'], tz)}" for r in (s or {}).get("receipts", [])
)
out.append(
[
b.get("item"),
_num(b.get("qty")),
_num(b.get("issued")),
b.get("loc") or "",
b.get("part_id"),
b.get("description"),
b.get("length"),
b.get("material"),
heat,
"Yes" if b.get("mtr_asset_id") else "No",
_num(open_short.get("qty_open")) if open_short else "",
(s.get("reason") or "") if s else "",
received,
]
)
out += [[], list(WELD_HEAD)]
heat_of = {str(b.get("item")): b.get("heat") for b in bom}
for w in read.get("welds") or []:
items = [str(i) for i in (w.get("items") or [])]
out.append(
[
f"W{w.get('weld_no')}",
w.get("wo_base"),
w.get("piece"),
" + ".join(items),
" + ".join((heat_of.get(i) or "-") for i in items),
w.get("type"),
w.get("size"),
w.get("process"),
read.get("wps"),
w.get("stamp"),
fmt_dt(w.get("at"), tz),
]
)
out += [[], list(CHECK_HEAD)]
checklist = stage.get("checklist") or {}
for item in read.get("checklist_items") or []:
c = checklist.get(item["key"])
e = _entry(c)
out.append([item["text"], "Yes" if _done(c) else "No", e.get("stamp") or "", fmt_dt(e.get("at"), tz)])
insp = _inspection(conn, read)
if insp is not None:
out += [
[],
[
"Q.C. inspection",
insp.get("result") or "open",
"Inspector",
insp.get("inspector"),
"Stamp",
insp.get("stamp"),
"Date",
fmt_dt(insp.get("result_at"), tz),
],
[
"NDE",
insp.get("nde_method"),
insp.get("nde_result"),
insp.get("nde_report"),
"Hardness",
insp.get("hardness"),
],
]
checks = _entry(insp.get("checks"))
for item in read.get("qc_items") or []:
c = checks.get(item["key"])
e = _entry(c)
out.append(
[
"Q.C.: " + str(item["text"]),
"Yes" if _done(c) else "No",
e.get("stamp") or "",
fmt_dt(e.get("at"), tz),
]
)
exam = _entry(insp.get("weld_exam"))
for w in read.get("welds") or []:
v = _entry(exam.get(str(w.get("weld_no"))))
out.append([f"VT W{w.get('weld_no')}", v.get("result") or "", v.get("note") or ""])
return out
def build(conn: Any, settings: Any, packet_id: int) -> tuple[str, dict[str, Any]]:
"""The CSV text and the read model it came from. 404 not_found for an unknown packet."""
read = packets_dom.read_packet(conn, packet_id, None)
return to_csv(rows_for(conn, read, getattr(settings, "site_tz", None))), read
+329
View File
@@ -0,0 +1,329 @@
"""The job traveler, letter portrait, after the old buildTraveler: job info, the work orders with QR codes for
clocking on at the labor kiosk, the BOM and heat numbers, the checklist with blanks to stamp, the welds, the weld
record, the sign-offs and a notes box.
The QR text is exactly `*%2{wo_base}${lot}${split or "0"}${sub or "0"}${seq}%*`, built from the text columns as they
are (never cast).
"""
from __future__ import annotations
import io
from collections.abc import Mapping
from datetime import datetime
from typing import Any
from pypdf import PdfReader, PdfWriter
from reportlab.graphics import renderPDF
from reportlab.graphics.barcode.qr import QrCodeWidget
from reportlab.graphics.shapes import Drawing
from . import parts, stamps
from .databook import heats_for
from .doc import Doc
from .style import CW, GREY, LINE, Built, L, PdfError, R, WhoRef, clean, fmt_d, fmt_dt, fmt_num, site_tz, utc_now
BLANK = "________"
BLANK_LONG = "________________"
QR_BOX_H = 96.0
QR_SIZE = 84.0
def qr_text(wo: Mapping[str, Any]) -> str:
"""The labor kiosk code of a work order (the old qrText)."""
split = str(wo.get("split") or "") or "0"
sub = str(wo.get("sub") or "") or "0"
seq = "" if wo.get("seq") is None else str(wo.get("seq"))
return f"*%2{wo.get('wo_base') or ''}${wo.get('lot') or ''}${split}${sub}${seq}%*"
def draw_qr(d: Doc, text: str, x: float, y_top: float, size: float) -> None:
"""A QR code (error correction M) `size` points square with its top-left corner at (x, y_top)."""
w = QrCodeWidget(text, barLevel="M", barBorder=0)
x0, y0, x1, y1 = w.getBounds()
bw, bh = x1 - x0, y1 - y0
drawing = Drawing(size, size, transform=[size / bw, 0, 0, size / bh, -x0 * size / bw, -y0 * size / bh])
drawing.add(w)
renderPDF.draw(drawing, d.c, x, d.Y(y_top + size))
def build(conn: Any, settings: Any, packet_id: int, *, who: WhoRef, now: datetime | None = None) -> Built:
"""The traveler with the controlled-copy stamp (mode traveler) and page numbers."""
now = utc_now(now)
p = parts.load_packet(conn, packet_id)
data, pages = pages_of(conn, settings, p)
warnings: list[str] = []
text = stamps.controlled_stamp(who, now, mode="traveler")
stamp, sig = stamps.signed_stamp(
settings, text, packet_id=p["id"], kind="traveler", seal_hash_hex=None, who=who, now=now, warnings=warnings
)
data = finish(data, p, stamp, now, box=False)
return Built(bytes=data, pages=pages, warnings=warnings, stamp=stamp, stamp_sig=sig, what="Job traveler")
def finish(data: bytes, p: Mapping[str, Any], stamp: str, now: datetime, *, box: bool) -> bytes:
"""Page numbers, the stamp (the line, or the PRINTED COPY box) and the metadata."""
project, lot = parts.tag_of(p)
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(data)))
n = len(writer.pages)
def draw(c: Any, i: int, w: float, h: float) -> None:
del h
c.setFont("Helvetica", 7)
c.setFillColorRGB(GREY[0] / 255, GREY[1] / 255, GREY[2] / 255)
c.drawRightString(R, 20, clean(f"{project} Lot {lot} - Page {i + 1} of {n}"))
if not stamp:
return
if box:
stamps.draw_box_stamp(c, w, stamp)
else:
stamps.draw_line_stamp(c, w, stamp, own=True)
stamps.overlay_writer(writer, draw)
meta = stamps.metadata(
f"{project} Lot {lot} Job Traveler",
f"Job traveler, drawing {p.get('drawing') or ''} R{p.get('rev') or ''}",
", ".join(x for x in (project, f"Lot {lot}", str(p.get("drawing") or ""), "traveler") if x),
"Packet Station - controlled copy",
now=now,
)
return stamps.write(writer, meta)
def pages_of(conn: Any, settings: Any, p: Mapping[str, Any]) -> tuple[bytes, int]:
"""The traveler pages without page numbers or stamp, and their count."""
try:
d = _draw(conn, settings, p)
return d.finish(), d.pages
except PdfError:
raise
except Exception as e:
raise PdfError(f"The traveler could not be built: {type(e).__name__}.") from e
def _draw(conn: Any, settings: Any, p: Mapping[str, Any]) -> Doc:
tz = site_tz(settings)
project, lot = parts.tag_of(p)
proj = parts.project_of(p)
unit = p.get("unit") or {}
depts = parts.departments(conn)
dept_code = (p.get("dept") or {}).get("code")
bom = p.get("bom") or []
stage = p.get("stage") or ((p.get("stages") or [None])[-1] if p.get("stages") else None) or {}
d = Doc(
(
"Job Traveler",
f"{project} Lot {lot} - {p.get('op') or ''} - drawing {p.get('drawing') or ''} R{p.get('rev') or ''}",
)
)
y = 124.0
welder = p.get("welder") or {}
station = (p.get("station") or {}).get("name") if p.get("station") else ""
worker = [
str(station or ""),
str(welder.get("display_name") or ""),
f"stamp {welder['stamp']}" if welder.get("stamp") else "",
]
rows: list[tuple[str, Any]] = [
("Job / lot", f"{project} · Lot {lot}"),
("Project", f"{proj.get('number') or ''}" + (f" - {proj['name']}" if proj.get("name") else "")),
("Unit", f"{unit.get('number') or ''}" + (f" - {unit['name']}" if unit.get("name") else "")),
]
if proj.get("po"):
rows.append(("Customer PO", proj["po"]))
if unit.get("due_effective"):
rows.append(("Due / ship date", fmt_d(unit["due_effective"], tz)))
rows += [
("Operation", p.get("op")),
("Title", p.get("title")),
("Customer", p.get("customer")),
("Drawing", f"{p.get('drawing') or ''} Rev {p.get('rev') or ''}"),
("WPS", p.get("wps")),
("Department", (depts.get(str(dept_code)) or {}).get("label") or dept_code),
("Station / worker", " · ".join(x for x in worker if x)),
("Status", parts.STATUS_LABEL.get(str(p.get("status")), str(p.get("status") or ""))),
("Target hours", fmt_num(p.get("target_hrs") or 0)),
]
y = d.kv_table(rows, y)
# work orders with QR codes
y += 16
y = d.section_label("Work orders - scan at the labor kiosk to clock on in Visual", y)
wos = p.get("work_orders") or []
bw = (CW - 12) / 2
for i, w in enumerate(wos):
col = i % 2
if col == 0:
y = d.ensure_space(y, QR_BOX_H + 8)
x = L + col * (bw + 12)
d.rect(x, y, bw, QR_BOX_H, stroke=LINE, width=0.8)
draw_qr(d, qr_text(w), x + 6, y + 6, QR_SIZE)
tx0 = x + QR_BOX_H + 2
d.tx(w.get("wo_base") or "", tx0, y + 20, size=12, bold=True)
d.tx(
f"Lot {w.get('lot') or ''} Seq {'' if w.get('seq') is None else w.get('seq')} {w.get('op') or ''}",
tx0,
y + 36,
size=8.5,
)
d.tx(f"Target {fmt_num(w.get('target_hrs'))} h Qty {fmt_num(w.get('qty'))}", tx0, y + 50, size=8.5)
d.tx(
f"Drawing {w.get('dwg') or p.get('drawing') or ''} Rev {w.get('rev') or p.get('rev') or ''}",
tx0,
y + 64,
size=7.5,
color=GREY,
)
if col == 1 or i == len(wos) - 1:
y += QR_BOX_H + 8
if not wos:
y = d.note_box("No work orders were read from this packet.", y, True)
# BOM, the job pull
y += 10
y = d.section_label("Bill of materials and heat numbers (job pull)", y)
body = []
for b in bom:
short = b.get("short") or {}
body.append(
[
str(b.get("item")),
fmt_num(b.get("qty")),
fmt_num(b.get("issued")),
b.get("loc") or "",
(f"{b['part_id']}\n" if b.get("part_id") else "") + str(b.get("description") or ""),
b.get("material") or "",
str(b.get("heat") or "").strip() or ("N/A" if b.get("na") else BLANK),
"Yes" if b.get("mtr_asset_id") else "",
fmt_num(short.get("qty_open")) if short else "",
]
)
y = d.table(
["ITEM", "QTY", "ISSUED", "LOC", "PART ID / DESCRIPTION", "MATERIAL", "HEAT NO.", "MTR", "SHORT"],
body,
y + 4,
{
0: {"w": 28, "align": "center"},
1: {"w": 28, "align": "center"},
2: {"w": 34, "align": "center"},
3: {"w": 34},
5: {"w": 66},
6: {"w": 78, "bold": True},
7: {"w": 28},
8: {"w": 32, "align": "center"},
},
)
# checklist of the current department
y += 16
y = d.section_label("Checklist - stamp each line when done", y)
ck = stage.get("checklist") or {}
body = []
for it in p.get("checklist_items") or []:
e = ck.get(it.get("key"))
e = e if isinstance(e, Mapping) else ({"done": True} if e is True else {})
done = bool(e.get("done"))
body.append(
[
it.get("text") or "",
"YES" if done else "[ ]",
(e.get("stamp") or "") if done else BLANK,
fmt_d(e.get("at"), tz, empty="") if done else BLANK,
]
)
y = d.table(
["CHECK", "DONE", "STAMP", "DATE"], body, y + 4, {1: {"w": 40, "align": "center"}, 2: {"w": 70}, 3: {"w": 70}}
)
# welds
welds = p.get("welds") or []
if welds:
y += 16
y = d.section_label("Weld log", y)
body = [
[
f"W{w.get('weld_no')}",
" + ".join(str(i) for i in w.get("items") or []),
heats_for(bom, w.get("items")),
f"{w.get('type') or ''} {w.get('size') or ''}".strip(),
w.get("process") or "",
w.get("stamp") or "",
]
for w in welds
]
y = d.table(
["WELD", "JOINS ITEMS", "HEATS", "TYPE / SIZE", "PROCESS", "STAMP"],
body,
y + 4,
{0: {"w": 38, "bold": True}},
)
# filler and preheat
y += 16
y = d.section_label("Weld record", y)
y = d.kv_table(
[
("Filler metal", stage.get("filler") or BLANK_LONG),
("Filler lot / heat", stage.get("filler_lot") or BLANK_LONG),
("Preheat", stage.get("preheat") or BLANK_LONG),
],
y + 4,
)
# sign-offs
y += 16
y = d.section_label("Sign-offs", y)
signed = [e for e in parts.log_rows(conn, int(p["id"])) if e.get("signature_id") is not None]
if signed:
body = [
[
fmt_dt(e.get("at"), tz),
e.get("action") or "",
f"{e.get('sig_name') or e.get('by_name') or ''} ({e.get('sig_emp') or e.get('emp_no') or '?'})",
e.get("sig_stamp") or "",
]
for e in signed
]
y = d.table(["WHEN", "STEP", "SIGNED BY", "STAMP"], body, y + 4, {0: {"w": 96}, 3: {"w": 50}})
else:
body = [
[s, "____________________", BLANK]
for s in ("Work complete (stall)", "Supervisor review", "Q.C. inspection")
]
y = d.table(["STEP", "SIGNED BY", "DATE"], body, y + 4)
q = _last_qc(p)
if q:
y += 10
text = f"Q.C. {str(q['result']).upper()} {fmt_dt(q.get('at'), tz)}"
if q.get("inspector"):
text += f" by {q['inspector']}"
if q.get("note"):
text += f" - {q['note']}"
y = d.note_box(text, y, q["result"] != "accepted")
# notes box for the shop floor
y += 12
y = d.ensure_space(y, 130)
d.lbl("Shop notes", L, y)
y += 8
d.rect(L, y, CW, 110, stroke=LINE, width=0.8)
for k in range(1, 5):
d.line(L + 8, y + k * 22, R - 8, y + k * 22, LINE, 0.8)
return d
def _last_qc(p: Mapping[str, Any]) -> dict[str, Any] | None:
"""The open inspection when it has a result, else the newest closed one: {result, at, inspector, note}."""
cur = p.get("qc")
if cur and cur.get("result"):
return {
"result": cur["result"],
"at": cur.get("result_at"),
"inspector": cur.get("inspector"),
"note": cur.get("result_note"),
}
hist = [h for h in p.get("qc_history") or [] if h.get("result")]
if hist:
h = hist[-1]
return {"result": h.get("result"), "at": h.get("at"), "inspector": h.get("inspector"), "note": h.get("note")}
return None
+3
View File
@@ -19,6 +19,8 @@ pypdf==6.21.0
python-multipart==0.0.32 python-multipart==0.0.32
pypdfium2==5.14.0 pypdfium2==5.14.0
openpyxl==3.1.5 openpyxl==3.1.5
reportlab==5.0.1
pillow==13.0.0
# needed by visualkit 0.3.0 and mfileskit 0.1.2 # needed by visualkit 0.3.0 and mfileskit 0.1.2
pyodbc==5.3.0 pyodbc==5.3.0
@@ -31,6 +33,7 @@ anyio==4.15.1
argon2-cffi-bindings==26.1.0 argon2-cffi-bindings==26.1.0
certifi==2026.7.22 certifi==2026.7.22
cffi==2.1.1 cffi==2.1.1
charset-normalizer==3.5.2
click==8.5.0 click==8.5.0
colorama==0.4.6; platform_system == "Windows" colorama==0.4.6; platform_system == "Windows"
cryptography==50.0.2; sys_platform == "linux" cryptography==50.0.2; sys_platform == "linux"
+382
View File
@@ -0,0 +1,382 @@
import { expect, test, type Page } from "@playwright/test";
import { PIN } from "./helpers";
import {
actor,
api,
closeAll,
packet,
packetTab,
setPin,
sign,
signSheet,
signWithPassword,
signWithPin,
statusOf,
stepApi,
userId,
} from "./shophelpers";
import type { Crew, QcPacket } from "./qchelpers";
import {
L,
atCompleteApi,
cardOf,
clickAndCatch,
column,
expectPdf,
fetchOk,
makeCloseProject,
openClericalHome,
openPacket,
panel,
printCount,
prints,
signedOffItpApi,
type CloseProject,
} from "./closehelpers";
import type { Json } from "./whhelpers";
// Phase 7 close and file scenarios on the CI stack, modelled on the old tests/ps tclose and tpr. clerk.clara makes
// a project with one unit; the crew (wh.wade, sup.wes, weld.andy) and qc.quinn take two packets, R1 and R2, to
// status complete routed to the data book through the API. Then the Clerical home lists both as ready to close,
// clara confirms R1 closed in VISUAL, R2 cannot be filed yet, the data book, the CSV and the prints answer as
// files, R1 is filed with her PIN and the seal holds, admin.ann reopens it with a reason and her password, and
// clara files it again on a second seal. Last, the unit's ITP is signed off through the API and its PDF is filed in
// the unit folder. Every UI label used here is in L (closehelpers.ts).
const RUN = Date.now().toString(36).toUpperCase().slice(-5);
const PROJECT = `C7${RUN}`;
const T = {
r1: `Close R1 spool ${RUN}`,
r2: `Close R2 spool ${RUN}`,
};
let proj: CloseProject;
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 homeShows(page: Page, heading: string, id: number): Promise<void> {
await openClericalHome(page, PROJECT);
await expect(cardOf(column(page, heading), id)).toBeVisible();
}
test.describe.serial("close", () => {
test.setTimeout(300_000);
test("setup: a project with one unit, two packets complete routed to the data book, clara'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 makeCloseProject(clara, PROJECT);
P.r1 = await atCompleteApi(crew, quinn, proj.unit, T.r1, "Stall 2");
P.r2 = await atCompleteApi(crew, quinn, proj.unit, T.r2, "Stall 3");
for (const id of [P.r1.id, P.r2.id]) {
const p = await packet(clara, id);
expect(p.vclosed).toBeFalsy();
expect(p.seal).toBeFalsy();
}
await setPin(clara, PIN);
await closeAll(clara, wade, wes, andy, quinn);
});
test("clerical home: both packets are ready to close", async ({ browser }) => {
const clara = await actor(browser, "clerk.clara");
const page = clara.page;
await openClericalHome(page, PROJECT);
const toClose = column(page, L.colToClose);
await expect(cardOf(toClose, pk("r1").id)).toBeVisible();
await expect(cardOf(toClose, pk("r2").id)).toBeVisible();
await expect(page.locator(`[aria-label="${L.kpiToClose(2)}"]`)).toBeVisible();
await closeAll(clara);
});
test("R1: confirmed closed in VISUAL, it moves to ready for the data book", async ({ browser }) => {
const id = pk("r1").id;
const clara = await actor(browser, "clerk.clara");
const page = clara.page;
await openPacket(page, id);
const box = panel(page);
await expect(box).toBeVisible();
await expect(box.getByText(L.readyToClose)).toBeVisible();
// a hand packet has no feed state, so no VISUAL line
expect((await packet(clara, id)).vclose_check ?? null).toBeNull();
await expect(box.getByTestId(L.visualLine)).toHaveCount(0);
const close = box.getByRole("button", { name: L.jobClosed, exact: true });
await expect(close).toBeEnabled();
await close.click();
await expect(box.getByText(L.readyForBook)).toBeVisible();
await expect.poll(async () => Boolean((await packet(clara, id)).vclosed)).toBe(true);
await expect(box.getByRole("button", { name: L.fileIt, exact: true })).toBeEnabled();
await expect(box.getByRole("button", { name: L.moveBack, exact: true })).toBeVisible();
await homeShows(page, L.colToBook, id);
await expect(cardOf(column(page, L.colToClose), id)).toHaveCount(0);
await expect(page.locator(`[aria-label="${L.kpiToClose(1)}"]`)).toBeVisible();
await closeAll(clara);
});
test("R2: filing is blocked until the job is closed in VISUAL; move back undoes the close", async ({
browser,
}) => {
const id = pk("r2").id;
const clara = await actor(browser, "clerk.clara");
const page = clara.page;
await openPacket(page, id);
const box = panel(page);
await expect(box.getByText(L.readyToClose)).toBeVisible();
const file = box.getByRole("button", { name: L.fileIt, exact: true });
await expect(file).toBeVisible();
await expect(file).toBeDisabled();
await expect(box.getByText(L.blocked)).toBeVisible();
// the API refuses it the same way (403 precondition)
await stepApi(clara, id, "file", { sign: sign("clerk.clara") }, 403);
expect(await statusOf(clara, id)).toBe("complete");
// close, then move back: it is ready to close again (the old vReopen)
await box.getByRole("button", { name: L.jobClosed, exact: true }).click();
await expect(box.getByText(L.readyForBook)).toBeVisible();
await box.getByRole("button", { name: L.moveBack, exact: true }).click();
await expect(box.getByText(L.readyToClose)).toBeVisible();
await expect.poll(async () => Boolean((await packet(clara, id)).vclosed)).toBe(false);
await homeShows(page, L.colToClose, id);
await closeAll(clara);
});
test("R1: the data book and the traceability CSV answer as files", async ({ browser }) => {
const id = pk("r1").id;
const clara = await actor(browser, "clerk.clara");
const page = clara.page;
await openPacket(page, id);
const box = panel(page);
await expect(box.getByText(L.readyForBook)).toBeVisible();
await expect(box.getByRole("button", { name: L.download, exact: true }).or(
box.getByRole("link", { name: L.download, exact: true }),
).first()).toBeVisible();
// Data book (PDF): the browser asks for the preview data book; the same URL answers a PDF
const bookBtn = box
.getByRole("button", { name: L.dataBookPdf, exact: true })
.or(box.getByRole("link", { name: L.dataBookPdf, exact: true }))
.first();
const bookReq = await clickAndCatch(clara.ctx, page, bookBtn, `/api/packets/${id}/pdf/databook`);
await expectPdf(clara, bookReq.url());
await expect(page).toHaveURL(new RegExp(`/packets/${id}`));
// Traceability (CSV): a link to the CSV; text/csv with the record's first line
const csvLink = box
.getByRole("link", { name: L.csv, exact: true })
.or(box.getByRole("button", { name: L.csv, exact: true }))
.first();
const csvReq = await clickAndCatch(clara.ctx, page, csvLink, `/api/packets/${id}/export/traceability.csv`);
const r = await fetchOk(clara, csvReq.url());
expect(r.headers()["content-type"]).toContain("text/csv");
const text = await r.text();
expect(text.split("\r\n")[0]).toBe("PESCO weld & heat traceability record");
expect(text).toContain("BOM item");
await closeAll(clara);
});
test("R1: the print menu lists the kinds; a traveler print is logged and shows on the History tab", async ({
browser,
}) => {
const id = pk("r1").id;
const clara = await actor(browser, "clerk.clara");
const page = clara.page;
await openPacket(page, id);
const box = panel(page);
await expect(box.getByText(L.readyForBook)).toBeVisible();
await box.getByRole("button", { name: L.printOpen, exact: true }).click();
let sheet = page.getByRole("dialog", { name: L.printSheet, exact: true });
await expect(sheet).toBeVisible();
for (const row of L.printRows) await expect(sheet.getByRole("button", { name: row, exact: true })).toBeVisible();
// a hand packet with no change orders has no void pages
await expect(sheet.getByRole("button", { name: L.voidRow, exact: true })).toHaveCount(0);
await expect(sheet.getByText(L.printFooter)).toBeVisible();
await sheet.getByRole("button", { name: L.cancel, exact: true }).click();
await expect(sheet).toBeHidden();
// a row opens the print in a new tab and closes the sheet; the server logs it
const before = await printCount(clara, id, "traveler");
await box.getByRole("button", { name: L.printOpen, exact: true }).click();
sheet = page.getByRole("dialog", { name: L.printSheet, exact: true });
await expect(sheet).toBeVisible();
await clickAndCatch(
clara.ctx,
page,
sheet.getByRole("button", { name: L.printRows[0], exact: true }),
`/api/packets/${id}/print/traveler`,
);
await expect(sheet).toBeHidden();
await expect.poll(() => printCount(clara, id, "traveler")).toBe(before + 1);
// the same print through the API: a PDF, and one more log row
await expectPdf(clara, `/api/packets/${id}/print/traveler`);
await expect.poll(() => printCount(clara, id, "traveler")).toBe(before + 2);
const rows = await prints(clara, id);
expect(rows[0].kind).toBe("traveler");
expect(rows[0].what).toBe("Job traveler");
expect(Number(rows[0].pages)).toBeGreaterThan(0);
// the Work orders tab has the traveler print and the traveler PDF
await packetTab(page, L.tabWorkOrders).click();
await expect(page.getByRole("button", { name: L.printTraveler, exact: true })).toBeVisible();
const trav = page.getByRole("link", { name: L.travelerPdf, exact: true });
await expect(trav).toBeVisible();
await expect(trav).toHaveAttribute("href", new RegExp(`/api/packets/${id}/pdf/traveler$`));
// the History tab lists the printed copies
await packetTab(page, L.tabHistory).click();
const hist = page.getByRole("region", { name: L.printsSection });
await expect(hist).toBeVisible();
await expect(hist.getByText(/Job traveler/).first()).toBeVisible();
await expect(hist.getByText(L.nothingPrinted)).toHaveCount(0);
await closeAll(clara);
});
test("R1: filed in the data book with the PIN; the seal holds and the filed data book downloads", async ({
browser,
}) => {
const id = pk("r1").id;
const clara = await actor(browser, "clerk.clara");
const page = clara.page;
await openPacket(page, id);
const box = panel(page);
const file = box.getByRole("button", { name: L.fileIt, exact: true });
await expect(file).toBeEnabled();
await file.click();
await signWithPin(page, PIN);
await expect(signSheet(page)).toBeHidden();
await expect.poll(() => statusOf(clara, id)).toBe("filed");
await expect(box.getByText(L.filed).first()).toBeVisible();
await expect(box.getByText(L.sealIntact).first()).toBeVisible();
await expect(box.getByText(L.sealBroken)).toHaveCount(0);
const p = await packet(clara, id);
expect(p.seals).toBe(1);
expect(p.databook_at).toBeTruthy();
expect(Number(p.seal.databook.pages)).toBeGreaterThan(0);
expect(p.seal.broken_at ?? null).toBeNull();
const seal = await api(clara, "get", `/api/packets/${id}/seal`);
expect(seal.valid).toBe(true);
const dl = await fetchOk(clara, String(p.seal.databook.download_url));
expect(dl.headers()["content-type"]).toContain("application/pdf");
expect(dl.headers()["content-disposition"] ?? "").toMatch(/^attachment/);
expect((await dl.body()).subarray(0, 4).toString("latin1")).toBe("%PDF");
// the export endpoint hands back the stored copy once filed
await expectPdf(clara, `/api/packets/${id}/pdf/databook`);
// the home lists it as filed
await openClericalHome(page, PROJECT);
const card = cardOf(column(page, L.colFiled), id);
await expect(card).toBeVisible();
await expect(card).toContainText(L.filedPill);
await closeAll(clara);
});
test("R1: admin.ann reopens with a reason and her password; clara files it again on a second seal", async ({
browser,
}) => {
const id = pk("r1").id;
const ann = await actor(browser, "admin.ann");
const page = ann.page;
await openPacket(page, id);
const box = panel(page);
await expect(box.getByText(L.filed).first()).toBeVisible();
await box.getByRole("button", { name: L.reopenIt, exact: true }).click();
const reason = page.getByRole("dialog", { name: L.reopenSheet, exact: true });
await expect(reason).toBeVisible();
const field = reason.getByRole("textbox").first();
await field.fill("Wrong heat number filed on item 2");
await field.press("Enter");
await signWithPassword(page, "admin.ann");
await expect(signSheet(page)).toBeHidden();
await expect.poll(() => statusOf(ann, id)).toBe("complete");
await expect(box.getByText(L.reopenedLine).first()).toBeVisible();
await expect(box.getByText(L.reopenedLine).first()).toContainText("Wrong heat number filed on item 2");
let seal = await api(ann, "get", `/api/packets/${id}/seal`);
expect((seal.seals as Json[]).length).toBe(1);
expect((seal.seals as Json[])[0].broken_reason).toBe("Wrong heat number filed on item 2");
const reopened = await packet(ann, id);
expect(reopened.seal.broken_at).toBeTruthy();
expect(reopened.seal.databook).toBeTruthy();
// clara files it again through the API (the UI path is covered above)
const clara = await actor(browser, "clerk.clara");
if (!(await packet(clara, id)).vclosed) await api(clara, "post", `/api/packets/${id}/vclosed`, { closed: true });
const out = await stepApi(clara, id, "file", { sign: sign("clerk.clara") });
expect(out.status).toBe("filed");
expect(out.seals).toBe(2);
seal = await api(clara, "get", `/api/packets/${id}/seal`);
expect((seal.seals as Json[]).length).toBe(2);
expect(seal.valid).toBe(true);
await openPacket(clara.page, id);
await expect(panel(clara.page).getByText(L.sealIntact).first()).toBeVisible();
await closeAll(ann, clara);
});
test("ITP: signed off through the API, its PDF is filed in the unit folder and linked on the ITP page", async ({
browser,
}) => {
const quinn = await actor(browser, "qc.quinn");
const unitId = Number(proj.unit.id);
const itp = await signedOffItpApi(quinn, unitId);
const no = String(itp.no);
expect(itp.signoff.asset_id).toBeTruthy();
// the ITP PDF, built now
await expectPdf(quinn, `/api/itps/${itp.id}/pdf`);
// the signed copy filed at sign-off
await expectPdf(quinn, `/_blob/${itp.signoff.asset_id}`);
// the unit folder lists the automatic ITP document
const folder = await api(quinn, "get", `/api/qc/folders/units/${unitId}`);
const titles = ((folder.docs ?? []) as Json[]).map((d) => String(d.title));
expect(titles).toContain(L.signedOffDoc(no));
await quinn.page.goto(`/qc/units/${unitId}`);
await expect(quinn.page.getByText(L.signedOffDoc(no)).first()).toBeVisible();
// the ITP page has the PDF button and the signed PDF link
await quinn.page.goto(`/qc/units/${unitId}/itp`);
await expect(quinn.page.getByRole("heading", { level: 1 })).toHaveText(no);
const pdfBtn = quinn.page
.getByRole("button", { name: L.itpPdf, exact: true })
.or(quinn.page.getByRole("link", { name: L.itpPdf, exact: true }))
.first();
const req = await clickAndCatch(quinn.ctx, quinn.page, pdfBtn, `/api/itps/${itp.id}/pdf`);
await expectPdf(quinn, req.url());
const signed = quinn.page.getByRole("link", { name: L.signedPdf, exact: true });
await expect(signed).toBeVisible();
await expect(signed).toHaveAttribute("href", new RegExp(`/_blob/${itp.signoff.asset_id}`));
await closeAll(quinn);
});
test("api: print guards and a signed-out read", async ({ browser }) => {
const clara = await actor(browser, "clerk.clara");
const id = pk("r2").id;
// R2 has no void pages
const v = await clara.page.request.get(`/api/packets/${id}/print/void`);
expect(v.status()).toBe(404);
expect(((await v.json()) as Json).code).toBe("nothing_to_print");
// a document that is not one of the packet's is refused
const d = await clara.page.request.get(`/api/packets/${id}/print/doc?asset=00000000-0000-0000-0000-000000000000`);
expect(d.status()).toBe(404);
// nobody signed in: 401
const anon = await browser.newContext();
const r = await anon.request.get(new URL(`/api/packets/${id}/pdf/traveler`, clara.page.url()).toString());
expect(r.status()).toBe(401);
await anon.close();
await closeAll(clara);
});
});
+255
View File
@@ -0,0 +1,255 @@
import { expect, type APIResponse, type BrowserContext, type Locator, type Page } from "@playwright/test";
import { api, packet, sign, stepApi, type Actor } from "./shophelpers";
import { atQcApi, type Crew, type QcPacket } from "./qchelpers";
import type { Json } from "./whhelpers";
// Helpers for the phase 7 close and file scenarios: a project with one unit, packets taken to complete routed to
// the data book through the API, the ITP signed off through the API, and the UI labels the close spec depends on
// (L). The labels are the "Labels" section of the phase 7 design contract and the components as agent B built
// them. The main session adjusts them here in one place when the components differ.
export const L = {
// the Clerical home (screens/clerical/ClericalHome.tsx, boards.ts)
clericalHead: "Clerical",
findJob: "Find a job",
colToClose: "Ready to close",
colToBook: "Ready for data book",
colFiled: "Filed",
kpiToClose: (n: number) => `Ready to close: ${n}`, // the kpi tile's aria-label
filedPill: "Filed in data book", // the status label of filed; the card's aria-label ends with it
// the close and file panel
panel: "Close and file", // section aria-label (role region)
readyToClose: "Back from the shop, ready to close.",
readyForBook: "Ready for the data book.",
filed: "Filed in the data book",
sealIntact: "Seal intact",
sealBroken: "Seal does not match the record",
sealUnknown: "Seal unknown",
reopenedLine: /^Reopened by .+: .+\. The earlier seal is kept as broken\./,
blocked: "Close the job in VISUAL first.",
visualLine: "visual-line", // testid of the VISUAL observed line
sealState: "seal-state", // testid of the seal line
reopened: "reopened", // testid of the reopen line
// panel buttons
jobClosed: "Job closed in VISUAL",
moveBack: "Not closed in VISUAL, move back",
fileIt: "File in the data book",
fileAnyway: "File anyway",
dataBookPdf: "Data book (PDF)",
download: "Download",
csv: "Traceability (CSV)",
printOpen: "Print…", // U+2026, as the contract writes it
reopenIt: "Reopen (IT)",
// sheets
printSheet: "Print",
openWosSheet: "File with open work orders?",
reopenSheet: "Reopen a filed packet",
// print rows (each a button whose name starts with the row title)
printRows: ["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)", "Data book"],
voidRow: "Superseded drawings (VOID)",
printFooter: /Every printed page is stamped with who printed it and when/,
// other print entry points
printTraveler: "Print traveler",
travelerPdf: "Traveler (PDF)",
printSheetBtn: "Print this sheet",
// the packet tabs
tabWorkOrders: /^Work orders/,
tabHistory: /^History/,
// the History tab
printsSection: "Printed copies and exports", // region
nothingPrinted: "Nothing printed yet.",
// the ITP page
itpPdf: "ITP (PDF)",
signedPdf: "Signed PDF",
signedOffDoc: (no: string) => `${no} — signed off`, // the automatic Q.C. folder document, em dash is the server's text
// shared
cancel: "Cancel",
statusPill: "status-pill", // testid (phase 5)
} as const;
/** A regex that matches a text literally from its start. */
export function starts(s: string): RegExp {
return new RegExp(`^${s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
}
export interface CloseProject {
id: number;
number: string;
unit: Json;
}
/** As clerk.clara: a project with a customer and a PO, and one unit. */
export async function makeCloseProject(clara: Actor, number: string, name = "Close and file skid"): Promise<CloseProject> {
const project = await api(
clara,
"post",
"/api/projects",
{ number, name, customer: "ACME", po: "7731" },
{ status: 201 },
);
const units = (await api(clara, "post", `/api/projects/${project.id}/units`, {}, { status: 201 })) as unknown as Json[];
expect(units.length).toBe(1);
return { id: Number(project.id), number: String(project.number), unit: units[0] as Json };
}
/** The ten Q.C. check keys (tests/server/qchelpers.py QC_KEYS). */
export const QC_KEYS = [
"db_heats",
"db_mtr",
"db_weldmap",
"db_wps",
"db_ck",
"ph_dim",
"ph_heat",
"ph_stamp",
"ph_clean",
"ph_qty",
] as const;
/** As qc.quinn through the API: inspector and stamp, every check, every live weld accepted. */
export async function finishInspectionApi(quinn: Actor, id: number): Promise<Json> {
let p = await api(quinn, "patch", `/api/packets/${id}/qc`, { set: { inspector: "Quinn QC", stamp: "Q1" }, base: {} });
for (const k of QC_KEYS) p = await api(quinn, "put", `/api/packets/${id}/qc/checks/${k}`, { done: true });
const welds = (p.welds as Json[]).filter((w) => !w.removed_at && !w.removed).map((w) => Number(w.weld_no));
for (const no of welds) p = await api(quinn, "put", `/api/packets/${id}/qc/welds/${no}`, { result: "accept" });
expect(p.qc.missing, JSON.stringify(p.qc.missing)).toEqual([]);
return p;
}
/** A packet taken through the shop and Q.C. to status complete, routed to the data book, all through the API. */
export async function atCompleteApi(crew: Crew, quinn: Actor, unit: Json, title: string, stall: string): Promise<QcPacket> {
const p = await atQcApi(crew, unit, title, stall, "Bay 7");
await finishInspectionApi(quinn, p.id);
const out = await stepApi(quinn, p.id, "qc_accept", { routed_to: "databook", sign: sign("qc.quinn") });
expect(out.status).toBe("complete");
expect(out.routed_to).toBe("databook");
return p;
}
/** A board column of the Clerical home by its heading (the heading ends with the card count). */
export function column(page: Page, heading: string): Locator {
return page.getByRole("region", { name: new RegExp(`^${heading}\\s*\\d*$`) }).first();
}
/** A packet card by its link. */
export function cardOf(scope: Locator, id: number): Locator {
return scope.locator(`a[href="/packets/${id}"]`).first();
}
/** The Clerical home, narrowed to one project through the find box so the numbers count only this run. */
export async function openClericalHome(page: Page, project: string): Promise<void> {
await page.goto("/clerical");
await expect(page.getByRole("heading", { level: 1, name: L.clericalHead })).toBeVisible();
await page.getByRole("searchbox", { name: L.findJob }).fill(project);
}
/** The close and file panel. */
export function panel(page: Page): Locator {
return page.getByRole("region", { name: L.panel, exact: true });
}
/** Open a packet page and wait for its heading. */
export async function openPacket(page: Page, id: number): Promise<void> {
await page.goto(`/packets/${id}`);
await expect(page.getByRole("heading").first()).toBeVisible();
}
/** A packet's current state on the server, for expect.poll. */
export async function field(a: Actor, id: number, pick: (p: Json) => unknown): Promise<unknown> {
return pick(await packet(a, id));
}
/** What a click asked the browser to open: the URL, plus the server's answer fetched through the page's session. */
export interface Opened {
url: () => string;
response: APIResponse;
}
/**
* Click `target` and catch the URL it opens, then fetch it through the page's own session so the server sees the
* request and logs it. A link is read from its href (a download link would start a download, not a navigation).
* A button that calls window.open is caught through a stub, because a noopener tab in headless Chromium does not
* report its request on the context. The URL must contain `part`.
*/
export async function clickAndCatch(ctx: BrowserContext, page: Page, target: Locator, part: string): Promise<Opened> {
await expect(target).toBeVisible();
let url: string | null = null;
const href = await target.getAttribute("href");
if (href) {
url = new URL(href, page.url()).toString();
} else {
await page.evaluate(() => {
const w = window as unknown as { __opened: string[]; open: (u?: string | URL) => Window | null };
w.__opened = [];
w.open = (u?: string | URL) => {
w.__opened.push(String(u ?? ""));
return null;
};
});
await target.click();
await expect
.poll(async () => (await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened)).length, {
timeout: 15_000,
})
.toBeGreaterThan(0);
const opened = await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened);
url = new URL(opened[0] ?? "", page.url()).toString();
}
expect(url, `opened ${url}`).toContain(part);
const response = await page.request.get(url);
for (const other of ctx.pages()) if (other !== page) await other.close();
const fixed = url;
return { url: () => fixed, response };
}
/** GET a URL through the actor's API context and check the status. */
export async function fetchOk(a: Actor, url: string): Promise<APIResponse> {
const r = await a.page.request.get(url);
expect(r.status(), `GET ${url}: ${r.status()}`).toBe(200);
return r;
}
/** GET a PDF through the actor's API context: 200, application/pdf, the body starts with %PDF. */
export async function expectPdf(a: Actor, url: string): Promise<Buffer> {
const r = await fetchOk(a, url);
expect(r.headers()["content-type"]).toContain("application/pdf");
const body = await r.body();
expect(body.subarray(0, 4).toString("latin1")).toBe("%PDF");
return body;
}
/** The packet's print log rows, newest first (GET /api/packets/{id}/prints). */
export async function prints(a: Actor, id: number): Promise<Json[]> {
const out = await api(a, "get", `/api/packets/${id}/prints`);
return (out.prints ?? []) as Json[];
}
/** How many print log rows of `kind` the packet has. */
export async function printCount(a: Actor, id: number, kind: string): Promise<number> {
return (await prints(a, id)).filter((r) => r.kind === kind).length;
}
/** The ITP of a unit, or null. */
export async function itpOf(a: Actor, unitId: number): Promise<Json | null> {
const out = await api(a, "get", `/api/units/${unitId}/itp`);
return out.itp === null ? null : out;
}
/**
* As qc.quinn through the API: start the unit's ITP, approve every open line, sign every block with the
* password, sign off. Returns the signed-off ITP.
*/
export async function signedOffItpApi(quinn: Actor, unitId: number): Promise<Json> {
let itp = (await itpOf(quinn, unitId)) ?? (await api(quinn, "post", `/api/units/${unitId}/itp`, {}));
for (const line of Object.values(itp.lines as Record<string, Json>)) {
if (line.result === null || line.result === undefined)
itp = await api(quinn, "post", `/api/itps/${itp.id}/lines/${line.key}/sign`, {});
}
for (const b of itp.blocks as Json[]) {
if (b.signed) continue;
itp = await api(quinn, "post", `/api/itps/${itp.id}/blocks/${b.key}/sign`, { sign: sign("qc.quinn") });
}
itp = await api(quinn, "post", `/api/itps/${itp.id}/signoff`, { sign: sign("qc.quinn") });
expect(itp.status).toBe("complete");
return itp;
}
+282
View File
@@ -0,0 +1,282 @@
"""Helpers for the phase 7 tests: a packet at complete on either route, the feed state written by SQL, a rich
packet for the data book (two drawing sheets, a void sheet, a heat table, two welds, MTRs, a short with a receipt,
a packet document and a Q.C. folder document), and PDF text helpers.
Built on shophelpers and qchelpers (imported, never edited). Nothing here reads VISUAL or M-Files.
"""
from __future__ import annotations
import io
import secrets
from collections.abc import Iterator
from typing import Any
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import app_conn
from psycopg.types.json import Jsonb
from qchelpers import accept, add_packet_doc, at_qc, finish_inspection, make_qc_doc, upload_doc
from shophelpers import (
SIGN,
Shop,
draft,
finish_stage,
im,
line_of,
make_unit,
ok,
open_shop,
release,
start,
submit,
to_station,
to_warehouse,
)
from packetstation.settings import Settings
PDF = "application/pdf"
HEATS = {1: "HT-10", 2: "HT-20", 3: "HT-30"}
RECEIPT_HEAT = "HT-31"
def open_close_shop(settings: Settings, fake_dir: FakeDirectory) -> Iterator[Shop]:
yield from open_shop(settings, fake_dir)
# ---------------------------------------------------------------- PDFs and files
def pdf_reader(data: bytes) -> Any:
from pypdf import PdfReader
return PdfReader(io.BytesIO(data))
def pdf_pages(data: bytes) -> int:
return len(pdf_reader(data).pages)
def page_texts(data: bytes) -> list[str]:
return [p.extract_text() or "" for p in pdf_reader(data).pages]
def pdf_text(data: bytes) -> str:
"""The text of every page, joined with newlines."""
return "\n".join(page_texts(data))
def flat(text: str) -> str:
"""Whitespace folded to single spaces (text extraction breaks lines where the layout wraps)."""
return " ".join(text.split())
def tiny_png(color: tuple[int, int, int] | None = None) -> bytes:
from PIL import Image
buf = io.BytesIO()
c = color or (secrets.randbelow(256), secrets.randbelow(256), secrets.randbelow(256))
Image.new("RGB", (4, 4), c).save(buf, "PNG")
return buf.getvalue()
def is_pdf(data: bytes) -> bool:
return data[:5] == b"%PDF-"
# ---------------------------------------------------------------- SQL
def q1(settings: Settings, sql: str, params: Any = ()) -> Any:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchone()
def qall(settings: Settings, sql: str, params: Any = ()) -> list[Any]:
with app_conn(settings) as conn:
return list(conn.execute(sql, params).fetchall())
def set_feed_legs(
settings: Settings,
packet_id: int,
legs: list[dict[str, Any]] | None,
*,
closed: bool = False,
observed: bool = True,
) -> None:
"""Simulate the feed: the packet becomes a VISUAL packet (source_kind visual, a visual_key), packet_visual_state
holds the legs as the feed writes them ({key: "base/lot/split/sub", seq, st}) and visual_closed_at is set when
`closed`. `legs` None writes no state row (the feed has not looked yet)."""
with app_conn(settings) as conn:
conn.execute(
"UPDATE packets SET source_kind = 'visual', visual_key = coalesce(visual_key, %s),"
" visual_closed_at = CASE WHEN %s THEN now() END WHERE id = %s",
(f"test-{packet_id}-{secrets.token_hex(3)}", closed, packet_id),
)
if legs is None:
conn.execute("DELETE FROM packet_visual_state WHERE packet_id = %s", (packet_id,))
return
conn.execute(
"INSERT INTO packet_visual_state (packet_id, legs, observed_at)"
" VALUES (%s, %s, CASE WHEN %s THEN now() END)"
" ON CONFLICT (packet_id) DO UPDATE SET legs = EXCLUDED.legs, observed_at = EXCLUDED.observed_at",
(packet_id, Jsonb(legs), observed),
)
def leg(base: str, lot: str = "001", st: str = "R", seq: int = 10, split: str = "0", sub: str = "0") -> dict[str, Any]:
"""One leg as the feed stores it, with `status` beside `st` (the contract names the code `status`)."""
return {"key": f"{base}/{lot}/{split}/{sub}", "seq": seq, "st": st, "status": st}
def void_page_by_sql(settings: Settings, packet_id: int, of_n: int = 1, co_no: int = 1) -> int:
"""A change order (approved, minor, no hold) and a void copy of sheet `of_n` as a new page row, the shape a
change order approval leaves (void_co_id, void_at, void_by, void_new_n). Returns the void page's n."""
with app_conn(settings) as conn:
by = conn.execute("SELECT id FROM users WHERE sam_account = 'clerk.clara'").fetchone()[0]
co = conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review, minor, hold, reason, issued_by,"
" approved_at, approved_by) VALUES (%s, %s, 'manual', 'approved', true, false, 'Nozzle moved', %s,"
" now(), %s) RETURNING id",
(packet_id, co_no, by, by),
).fetchone()[0]
src = conn.execute(
"SELECT asset_id, pn, dwg, rev FROM packet_pages WHERE packet_id = %s AND n = %s", (packet_id, of_n)
).fetchone()
n = conn.execute("SELECT max(n) + 1 FROM packet_pages WHERE packet_id = %s", (packet_id,)).fetchone()[0]
conn.execute(
"INSERT INTO packet_pages (packet_id, n, type, asset_id, pn, dwg, rev, void_co_id, void_at, void_by,"
" void_new_n) VALUES (%s, %s, 'drawing', %s, %s, %s, %s, %s, now(), %s, %s)",
(packet_id, n, src[0], src[1], src[2], "A", co, by, of_n),
)
return int(n)
def remove_by_sql(settings: Settings, packet_id: int) -> None:
with app_conn(settings) as conn:
conn.execute(
"UPDATE packets SET removed_at = now(), removed_reason = 'test' WHERE id = %s",
(packet_id,),
)
def delete_asset_file(settings: Settings, asset_id: str) -> None:
"""Remove a stored file from disk (the asset row stays): a part that cannot be read."""
from packetstation.domain import assets
row = q1(settings, "SELECT storage_key FROM assets WHERE id = %s", (asset_id,))
assets.path_of(settings, str(row[0])).unlink()
# ---------------------------------------------------------------- packets
def at_complete(shop: Shop, unit_id: int | None = None, route: str = "databook", welds: int = 2) -> dict[str, Any]:
"""A shop packet taken through Q.C. and accepted with `route` (databook or shipping): status complete."""
pkt = finish_inspection(shop, at_qc(shop, unit_id, welds=welds))
out = ok(accept(shop, pkt, route))
assert out["status"] == "complete" and out["routed_to"] == route, out["status"]
return out
def vclose(shop: Shop, pkt: dict[str, Any], by: str = "clerk.clara") -> dict[str, Any]:
return ok(shop(by).post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True}))
def file_packet(shop: Shop, pkt: dict[str, Any], by: str = "clerk.clara", **body: Any) -> Any:
return step(shop(by), pkt, "file", sign=SIGN, **body)
def filed(shop: Shop, unit_id: int | None = None) -> dict[str, Any]:
pkt = vclose(shop, at_complete(shop, unit_id))
out = ok(file_packet(shop, pkt))
assert out["status"] == "filed"
return out
def attach_mtr(shop: Shop, pkt: dict[str, Any], item: int, data: bytes, name: str, mime: str, by: str) -> Any:
"""Upload an MTR (purpose mtr) and set it on a BOM line. Returns the packet answer."""
a = upload_doc(shop(by), name, data, mime, purpose="mtr")
cur = line_of(pkt, item)
return ok(
shop(by).patch(
f"/api/packets/{pkt['id']}/bom/{item}", json={"mtr_asset_id": a["id"]}, headers=im(cur["row_version"])
)
)
def add_doc(shop: Shop, pkt: dict[str, Any], title: str = "RT report W1-W2", by: str = "qc.quinn", **kw: Any) -> Any:
return ok(add_packet_doc(shop(by), pkt["id"], title=title, **kw))
def rich_complete(shop: Shop, *, itp_doc: bool = False, mtrs: bool = True) -> dict[str, Any]:
"""The data book packet, complete on the data book route:
- drawing D-5001 rev B, two sheets, BOM items 1 to 3, two work orders;
- in the warehouse: items 1 and 2 pulled with heats HT-10 and HT-20; item 3 short 2 of 4 (On order) with heat
HT-30, then 1 pc received with heat HT-31 (one still open); an MTR PDF on item 1 and an MTR PNG on item 2
(when `mtrs`); the heat table on sheet 1;
- the shop: two welds, the checklist and the filler, submitted, sent to Q.C., inspected and accepted;
- a packet document (RT report) and a Q.C. folder document on the unit (WPS / PQR, or ITP when `itp_doc`);
- sheet 1 voided by change order 1 as a new sheet 3.
Returns the fresh packet read. `pkt["_unit_id"]` and `pkt["_project_id"]` carry the ids."""
project_id, unit_id = make_unit(shop.settings)
pkt = to_warehouse(shop, draft(shop, unit_id, pages=2))
wade = shop("wh.wade")
pid = pkt["id"]
for item in (1, 2):
cur = line_of(pkt, item)
pkt = ok(wade.patch(f"/api/packets/{pid}/bom/{item}", json={"pulled": True}, headers=im(cur["row_version"])))
cur = line_of(pkt, item)
pkt = ok(
wade.patch(f"/api/packets/{pid}/bom/{item}", json={"heat": HEATS[item]}, headers=im(cur["row_version"]))
)
pkt = ok(
wade.post(
f"/api/packets/{pid}/bom/3/short",
json={"qty": 2, "reason": "On order", "heat": HEATS[3], "expect_open": False},
)
)
pkt = ok(wade.post(f"/api/packets/{pid}/bom/3/short/receipts", json={"qty": 1, "heat": RECEIPT_HEAT}))
if mtrs:
pkt = attach_mtr(shop, pkt, 1, upload_bytes_pdf(), "mtr-1.pdf", PDF, "wh.wade")
pkt = attach_mtr(shop, pkt, 2, tiny_png(), "mtr-2.png", "image/png", "wh.wade")
pkt = ok(wade.put(f"/api/packets/{pid}/heat-table/1", json={"x": 0.6, "y": 0.6, "s": 1.0}))
pkt = release(shop, pkt)
pkt = start(shop, to_station(shop, pkt))
pkt = submit(shop, finish_stage(shop, pkt, welds=2))
pkt = ok(step(shop("sup.wes"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
pkt = finish_inspection(shop, pkt)
pkt = ok(accept(shop, pkt, "databook"))
add_doc(shop, pkt)
r = make_qc_doc(shop, unit_id=unit_id, type="ITP" if itp_doc else "WPS / PQR", title="Unit folder doc")
assert r.status_code in (200, 201), r.text
void_page_by_sql(shop.settings, pid, of_n=1)
out = ok(shop("clerk.clara").get(f"/api/packets/{pid}"))
out["_unit_id"] = unit_id
out["_project_id"] = project_id
return out
def upload_bytes_pdf() -> bytes:
"""A one-page PDF with its own bytes (the store dedupes equal bytes)."""
from qchelpers import unique_pdf
return unique_pdf()
# ---------------------------------------------------------------- the builders' inputs
def who(shop: Shop, sam: str = "clerk.clara") -> dict[str, Any]:
"""The WhoRef of a test user: {id, display_name, emp_no}."""
row = q1(shop.settings, "SELECT id, display_name, emp_no FROM users WHERE sam_account = %s", (sam,))
return {"id": str(row[0]), "display_name": row[1], "emp_no": row[2]}
def builder_conn(settings: Settings) -> Any:
"""A connection for calling a builder directly (autocommit, app role)."""
return app_conn(settings)
+302
View File
@@ -0,0 +1,302 @@
"""Phase 7 close and file through the API: the VISUAL close and the filing as every role, the feed's open work
orders (the note suffix, the 409 and the confirm), the data book filed with the seal, the reopen that keeps the
broken seal and its data book, the refile with a second seal, a failed data book build that files nothing, and a
hand packet with no feed state.
The feed state is written by SQL in the shape feed/apply.py writes it. Nothing here reads VISUAL or M-Files."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from closehelpers import (
at_complete,
file_packet,
filed,
flat,
leg,
pdf_text,
q1,
qall,
set_feed_legs,
vclose,
)
from fakedir import FakeDirectory
from pshelpers import needs_pg
from qchelpers import at_qc
from shophelpers import SIGN, Shop, find_log, fresh, log_rows, ok, open_shop, refused
from packetstation.policy import Action, check, principal_from_roles
from packetstation.settings import Settings
pytestmark = needs_pg
MSG_VCLOSED = "Close the job in VISUAL first."
CLERICAL = ("clerk.clara", "clerk.leo")
NOT_CLERICAL = ("admin.ann", "wh.wade", "qc.quinn", "pm.paula", "weld.andy", "lead.lou", "sup.wes")
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def oracle(sam: str, status: str, action: Action, routed_to: str = "databook", vclosed: bool = False) -> str:
"""The expected code for SET_VCLOSED and FILE, from the phase 2 rule table: Clerical only, status complete;
FILE on the data book route needs the VISUAL close; an admin outside the role needs an IT reason."""
if status != "complete":
return "wrong_status"
if action is Action.SET_VCLOSED and routed_to != "databook":
return "wrong_status"
if sam not in CLERICAL:
return "it_reason_required" if sam == "admin.ann" else "forbidden"
if action is Action.FILE and routed_to == "databook" and not vclosed:
return "precondition"
return ""
# ---------------------------------------------------------------- the pure rule, every role and status
ROLES = {
"clerk.clara": ["clerical"],
"clerk.leo": ["clerical_supervisor"],
"admin.ann": ["admin"],
"wh.wade": ["warehouse"],
"qc.quinn": ["qc"],
"pm.paula": ["pm"],
"weld.andy": ["shop:03:worker"],
"lead.lou": ["shop:03:lead"],
"sup.wes": ["shop:03:supervisor"],
}
def test_rule_table_matches_the_oracle_for_every_role_and_status() -> None:
import dataclasses
from test_policy_matrix import state
from packetstation.policy import STATUSES
for sam, roles in ROLES.items():
p = principal_from_roles("00000000-0000-0000-0000-0000000000b0", roles)
for status in STATUSES:
for action in (Action.SET_VCLOSED, Action.FILE):
for route in ("databook", "shipping"):
for vclosed in (False, True):
s = dataclasses.replace(state(status), routed_to=route, vclosed=vclosed)
d = check(p, s, action)
want = oracle(sam, status, action, route, vclosed)
if want == "precondition":
assert not d.ok and d.why == MSG_VCLOSED, (sam, status, action, route, d)
elif want == "wrong_status" and action is Action.SET_VCLOSED and status == "complete":
# SET_VCLOSED on the Shipping route: refused, whatever code the engine picks
assert not d.ok, (sam, status, action, route)
else:
assert d.ok is (want == ""), (sam, status, action, route, vclosed, d.code)
assert d.code == want, (sam, status, action, route, vclosed, d.code)
# ---------------------------------------------------------------- the API, every role
def test_vclosed_and_file_as_every_role_at_complete(shop: Shop) -> None:
pkt = at_complete(shop)
pid = pkt["id"]
for sam in NOT_CLERICAL:
r = shop(sam).post(f"/api/packets/{pid}/vclosed", json={"closed": True})
want = oracle(sam, "complete", Action.SET_VCLOSED)
assert r.status_code in (400, 403) and r.json()["code"] == want, (sam, r.text)
r = file_packet(shop, fresh(shop("clerk.clara"), pid), by=sam)
assert r.json()["code"] in (oracle(sam, "complete", Action.FILE), "signature_required"), (sam, r.text)
assert r.status_code in (400, 403), (sam, r.text)
# Clerical: the file waits for the VISUAL close on the data book route
refused(file_packet(shop, pkt), 403, "precondition", MSG_VCLOSED)
pkt = vclose(shop, pkt, by="clerk.leo")
assert pkt["vclosed"]["by"]["display_name"] == "Leo Lead"
out = ok(file_packet(shop, pkt, by="clerk.clara"))
assert out["status"] == "filed"
def test_vclosed_and_file_refused_outside_complete(shop: Shop) -> None:
pkt = at_qc(shop)
c = shop("clerk.clara")
refused(c.post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True}), 403, "wrong_status")
refused(file_packet(shop, pkt), 403, "wrong_status")
done = filed(shop)
refused(c.post(f"/api/packets/{done['id']}/vclosed", json={"closed": False}), 403, "filed")
r = file_packet(shop, done)
assert r.status_code == 403 and r.json()["code"] in ("filed", "wrong_status"), r.text
def test_shipping_route_files_without_the_visual_close(shop: Shop) -> None:
pkt = at_complete(shop, route="shipping")
r = shop("clerk.clara").post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True})
assert r.status_code in (403, 409) and r.json()["code"] in ("wrong_status", "precondition"), r.text
out = ok(file_packet(shop, pkt))
assert out["status"] == "filed" and out["vclosed"] is None
assert out["seal"]["databook"]["pages"] > 0
# ---------------------------------------------------------------- the feed's open work orders
def test_vclose_check_and_the_open_wo_note(shop: Shop) -> None:
pkt = at_complete(shop)
pid = pkt["id"]
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", st="R")])
view = fresh(shop("clerk.clara"), pid)
vc = view["vclose_check"]
assert vc["open_wos"] == ["L670002/001"] and vc["closed_at"] is None and vc["observed_at"] is not None
assert vc["agrees"] is True # neither closed
pkt = vclose(shop, view)
assert pkt["vclose_check"]["agrees"] is False
row = find_log(shop("clerk.clara"), pid, "Job closed in VISUAL")
assert row["note"] == "L670001, L670002 · VISUAL still shows open: L670002/001", row
def test_vclose_check_closed_by_the_feed(shop: Shop) -> None:
pkt = at_complete(shop)
pid = pkt["id"]
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", st="C")], closed=True)
vc = fresh(shop("clerk.clara"), pid)["vclose_check"]
assert vc["open_wos"] == [] and vc["closed_at"] is not None and vc["agrees"] is False
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
assert pkt["vclose_check"]["agrees"] is True
assert find_log(shop("clerk.clara"), pid, "Job closed in VISUAL")["note"] == "L670001, L670002"
out = ok(file_packet(shop, pkt))
assert out["seal"]["open_wos"] is None
def test_vclose_check_before_the_feed_looked(shop: Shop) -> None:
pkt = at_complete(shop)
set_feed_legs(shop.settings, pkt["id"], None)
vc = fresh(shop("clerk.clara"), pkt["id"])["vclose_check"]
assert vc is not None and vc["observed_at"] is None and vc["open_wos"] == []
def test_file_with_open_work_orders_needs_the_confirm(shop: Shop) -> None:
pkt = at_complete(shop)
pid = pkt["id"]
set_feed_legs(shop.settings, pid, [leg("L670001", st="R"), leg("L670002", lot="7A", st="R")])
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
sigs = q1(shop.settings, "SELECT count(*) FROM signatures")[0]
body = refused(file_packet(shop, pkt), 409, "open_work_orders")
assert body["work_orders"] == ["L670001/001", "L670002/7A"]
assert body["detail"] == (
"VISUAL still shows 2 work orders open on this packet: L670001/001, L670002/7A. File anyway?"
)
# nothing written, no signature taken
assert q1(shop.settings, "SELECT count(*) FROM signatures")[0] == sigs
assert q1(shop.settings, "SELECT count(*) FROM packet_seals WHERE packet_id = %s", (pid,))[0] == 0
assert fresh(shop("clerk.clara"), pid)["status"] == "complete"
refused(file_packet(shop, pkt, confirm_open_wos=False), 409, "open_work_orders")
out = ok(file_packet(shop, pkt, confirm_open_wos=True, note="Customer pickup today"))
assert out["status"] == "filed" and out["seal"]["open_wos"] == ["L670001/001", "L670002/7A"]
row = q1(shop.settings, "SELECT open_wos FROM packet_seals WHERE packet_id = %s", (pid,))
assert row[0] == ["L670001/001", "L670002/7A"]
note = find_log(shop("clerk.clara"), pid, "Filed in the data book")["note"]
assert note == "Customer pickup today · Filed with work orders open in VISUAL: L670001/001, L670002/7A", note
def test_cancelled_legs_do_not_count_as_open(shop: Shop) -> None:
pkt = at_complete(shop)
set_feed_legs(shop.settings, pkt["id"], [leg("L670001", st="C"), leg("L670002", st="X")])
assert fresh(shop("clerk.clara"), pkt["id"])["vclose_check"]["open_wos"] == []
def test_hand_packet_has_no_feed_check(shop: Shop) -> None:
pkt = at_complete(shop)
assert pkt["vclose_check"] is None
pkt = vclose(shop, pkt)
assert find_log(shop("clerk.clara"), pkt["id"], "Job closed in VISUAL")["note"] == "L670001, L670002"
out = ok(file_packet(shop, pkt))
assert out["status"] == "filed" and out["seal"]["open_wos"] is None
# ---------------------------------------------------------------- the filing, the seal, the data book
def test_filing_builds_and_stores_the_data_book(shop: Shop) -> None:
pkt = vclose(shop, at_complete(shop))
pid = pkt["id"]
out = ok(file_packet(shop, pkt))
assert out["status"] == "filed" and out["databook_at"] is not None
seal = out["seal"]
assert seal["broken_at"] is None and seal["signed"] is True and out["seals"] == 1
db = seal["databook"]
assert db["pages"] > 0 and db["url"] == f"/_blob/{db['asset_id']}"
assert db["download_url"] == f"/_blob/{db['asset_id']}?download=1"
assert db["file_name"].endswith("_Manufacturing_Data_Book.pdf") and db["size"] > 0
rows = log_rows(shop("clerk.clara"), pid)
actions = [r["action"] for r in rows]
i = actions.index("Filed in the data book")
j = actions.index("Data book filed")
assert j < i, actions # newest first: the data book row comes after the filing row
assert rows[j]["note"] == f"{db['pages']} pages"
# the seal checks out and the stored copy is the filed data book
view = ok(shop("clerk.clara").get(f"/api/packets/{pid}/seal"))
assert view["valid"] is True
row = q1(shop.settings, "SELECT databook_asset_id, databook_pages FROM packet_seals WHERE packet_id = %s", (pid,))
assert str(row[0]) == db["asset_id"] and row[1] == db["pages"]
r = shop("clerk.clara").get(db["url"])
assert r.status_code == 200 and r.headers["content-type"].startswith("application/pdf")
text = flat(pdf_text(r.content))
assert "MANUFACTURING" in text
project = out["unit"]["project"]["number"]
assert project in text
assert f"seal {view['seal']['hash'][:12]}" in text
src = q1(shop.settings, "SELECT source, source_ref, packet_id FROM assets WHERE id = %s", (db["asset_id"],))
assert src[0] == "databook" and src[1]["packet_id"] == pid and src[2] == pid
# the export answers with the stored copy, unchanged
r2 = shop("pm.paula").get(f"/api/packets/{pid}/pdf/databook")
assert r2.status_code == 200 and r2.content == r.content
def test_reopen_keeps_the_broken_seal_and_refile_adds_a_second(shop: Shop) -> None:
pkt = filed(shop)
pid = pkt["id"]
first = pkt["seal"]["databook"]
r = shop("admin.ann").post(f"/api/packets/{pid}/reopen", json={"reason": "Wrong heat number filed", "sign": SIGN})
assert r.status_code == 200, r.text
out = r.json()
assert out["status"] == "complete" and out["seal"]["broken_at"] is not None
assert out["seal"]["broken_reason"] == "Wrong heat number filed"
assert out["seal"]["databook"]["asset_id"] == first["asset_id"] and out["seals"] == 1
assert shop("clerk.clara").get(first["url"]).status_code == 200
# complete again: the export builds a preview, not the broken seal's copy
r = shop("clerk.clara").get(f"/api/packets/{pid}/pdf/databook")
assert r.status_code == 200 and "not yet filed" in flat(pdf_text(r.content))
# refile: a second seal, a second data book, the first kept
again = ok(file_packet(shop, fresh(shop("clerk.clara"), pid)))
assert again["status"] == "filed" and again["seals"] == 2
assert again["seal"]["broken_at"] is None and again["seal"]["databook"]["asset_id"] != first["asset_id"]
view = ok(shop("clerk.clara").get(f"/api/packets/{pid}/seal"))
assert view["valid"] is True and len(view["seals"]) == 2
assert view["seals"][1]["broken_reason"] == "Wrong heat number filed"
rows = qall(
shop.settings,
"SELECT databook_asset_id, broken_at IS NOT NULL FROM packet_seals WHERE packet_id = %s ORDER BY id",
(pid,),
)
assert [r[1] for r in rows] == [True, False] and all(r[0] is not None for r in rows)
def test_failed_data_book_files_nothing(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
from packetstation.pdf import databook
from packetstation.pdf.style import PdfError
def boom(*a: Any, **k: Any) -> Any:
raise PdfError("the drawing could not be read")
monkeypatch.setattr(databook, "build", boom)
pkt = vclose(shop, at_complete(shop))
pid = pkt["id"]
body = refused(file_packet(shop, pkt), 500, "databook_failed")
assert body["detail"].startswith("The data book could not be built:") and "Nothing was filed." in body["detail"]
view = fresh(shop("clerk.clara"), pid)
assert view["status"] == "complete" and view["seal"] is None and view["seals"] == 0
assert q1(shop.settings, "SELECT count(*) FROM packet_seals WHERE packet_id = %s", (pid,))[0] == 0
assert "Filed in the data book" not in [r["action"] for r in log_rows(shop("clerk.clara"), pid)]
+12 -6
View File
@@ -405,7 +405,8 @@ def finish_all(shop: Shop, itp: dict[str, Any]) -> dict[str, Any]:
return sign_all_blocks(shop, approve_all(shop, itp)) return sign_all_blocks(shop, approve_all(shop, itp))
def test_signoff_without_pdf(qc_shop: Shop) -> None: def test_signoff_files_pdf(qc_shop: Shop) -> None:
"""Phase 7: the sign-off builds the signed ITP PDF and files it in the unit's Q.C. folder."""
shop = qc_shop shop = qc_shop
unit_id, itp = new_itp(shop) unit_id, itp = new_itp(shop)
q = shop("qc.quinn") q = shop("qc.quinn")
@@ -425,13 +426,17 @@ def test_signoff_without_pdf(qc_shop: Shop) -> None:
itp = ok(sign_off(q, itp)) itp = ok(sign_off(q, itp))
assert itp["status"] == "complete" and itp["can"] == {"edit": False, "sign_off": False, "reopen": False} 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"]["by"]["display_name"] == "Quinn QC" and itp["signoff"]["at"]
assert itp["signoff"]["asset_id"] is None asset_id = itp["signoff"]["asset_id"]
assert itp_log(itp)[0] == ("ITP signed off", "PDF filing reserved for phase 7") and itp["log"][0]["signed"] assert asset_id is not None
assert itp_log(itp)[0] == ("ITP signed off", "PDF filed") and itp["log"][0]["signed"]
row = one( row = one(
shop.settings, "SELECT signoff_asset_id, signoff_signature_id IS NOT NULL FROM itps WHERE id = %s", (itp["id"],) 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 (str(row[0]), row[1]) == (asset_id, True)
assert all_rows(shop.settings, "SELECT count(*) FROM qc_docs WHERE unit_id = %s", (unit_id,))[0][0] == 0 asset = one(shop.settings, "SELECT mime, source, original_name FROM assets WHERE id = %s", (asset_id,))
assert tuple(asset) == ("application/pdf", "itp_signoff", f"{itp['no']}_signed.pdf")
docs = all_rows(shop.settings, "SELECT type, auto, asset_id FROM qc_docs WHERE unit_id = %s", (unit_id,))
assert [(d[0], d[1], str(d[2])) for d in docs] == [("ITP", True, asset_id)]
# the admin may reopen; qc.quinn and qc.sue may not # 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(shop("admin.ann"), unit_id)["can"]["reopen"] is True
assert get_itp(q, unit_id)["can"]["reopen"] is False assert get_itp(q, unit_id)["can"]["reopen"] is False
@@ -485,7 +490,8 @@ def test_signoff_with_pdf_hook(qc_shop: Shop, monkeypatch: pytest.MonkeyPatch) -
calls: list[Any] = [] calls: list[Any] = []
asset_ids = [upload_doc(q, "itp-1.pdf")["id"], upload_doc(q, "itp-2.pdf")["id"]] 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: def fake_hook(conn: Any, itp_row: Any, actor: Any, settings: Any = None) -> uuid.UUID:
assert settings is not None
calls.append(itp_row) calls.append(itp_row)
return uuid.UUID(asset_ids[len(calls) - 1]) return uuid.UUID(asset_ids[len(calls) - 1])
+2 -1
View File
@@ -264,7 +264,8 @@ def test_happy_path_file_and_reopen(pg_client2: TestClient, pg_settings: Setting
"qc_accept", "qc_accept",
"file", "file",
] ]
assert pg_client2.get(f"/api/packets/{pid}/log").json()[0]["action"] == "Filed in the data book" actions = [e["action"] for e in pg_client2.get(f"/api/packets/{pid}/log").json()[:2]]
assert actions == ["Data book filed", "Filed in the data book"]
# filed: every write is refused, Admin included # filed: every write is refused, Admin included
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"drawing": "D-2"}}) r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"drawing": "D-2"}})
assert r.status_code == 403 and r.json()["code"] == "filed" assert r.status_code == 403 and r.json()["code"] == "filed"
+201
View File
@@ -0,0 +1,201 @@
"""Phase 7 data book builder, called directly on a packet built through the API: two drawing sheets and a void
copy of sheet 1, a heat table, two welds, an MTR PDF and an MTR PNG, a short with a receipt, a packet document, a
Q.C. folder document, the closed stage and the Q.C. accept.
The index (seven sections in order, the expected rows), the page count, the cover, the index page, the void
sheet, the traceability page, the warning rows, the metadata, the stamp on every page and its signature, and the
stamp per mode. Nothing here reads VISUAL or M-Files."""
from __future__ import annotations
import re
from collections.abc import Iterator
from datetime import UTC, datetime
from typing import Any
import pytest
from closehelpers import (
HEATS,
RECEIPT_HEAT,
builder_conn,
delete_asset_file,
flat,
page_texts,
pdf_reader,
rich_complete,
who,
)
from fakedir import FakeDirectory
from pshelpers import TEST_SEAL_KEY, needs_pg
from qchelpers import g
from shophelpers import Shop, open_shop, run
from packetstation.pdf import databook, stamps
from packetstation.pdf.style import clean
from packetstation.settings import Settings
pytestmark = needs_pg
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
SEAL = {"hash": "ab12cd34ef56" + "0" * 52, "key_id": "k1234567", "sealed_at": NOW}
SECTIONS = [1, 2, 3, 4, 5, 6, 7]
SIG_RE = re.compile(r"stamp ([0-9a-f]{16})")
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def build(shop: Shop, pid: int, mode: str = "preview", **kw: Any) -> Any:
with builder_conn(shop.settings) as conn:
return databook.build(
conn,
shop.settings,
pid,
who=who(shop),
now=NOW,
seal=SEAL if mode == "filed" else None,
mode=mode,
**kw,
)
def index(built: Any) -> list[dict[str, Any]]:
keys = ("section", "doc", "ref", "rev", "page", "warn", "count")
return [{k: g(r, k) for k in keys} for r in built.index]
def docs_in(rows: list[dict[str, Any]], section: int) -> list[str]:
return [str(r["doc"]) for r in rows if r["section"] == section]
def test_data_book_preview(shop: Shop) -> None:
pkt = rich_complete(shop)
pid = pkt["id"]
built = build(shop, pid)
data: bytes = built.bytes
assert data[:5] == b"%PDF-"
reader = pdf_reader(data)
assert len(reader.pages) == built.pages
rows = index(built)
# the seven sections, in order, each present
seen = [r["section"] for r in rows]
assert seen == sorted(seen) and sorted(set(seen)) == SECTIONS, seen
s1 = docs_in(rows, 1)
assert sum("approved drawing" in d for d in s1) == 2, s1
assert any(d.startswith("SUPERSEDED - VOID by CO-1") for d in s1), s1
drawing_rows = [r for r in rows if r["section"] == 1 and "approved drawing" in str(r["doc"])]
assert all(r["ref"] == "D-5001" and r["rev"] == "RB" for r in drawing_rows), drawing_rows
s2 = docs_in(rows, 2)
assert s2[0].startswith("Material traceability record"), s2
assert any(d.startswith("Heat map") and "heat number table" in d for d in s2), s2
s3 = docs_in(rows, 3)
assert any(d.startswith("Weld map") and "W1, W2" in d for d in s3), s3
assert any(d.startswith("Weld log, welding record and checklist") for d in s3), s3
s4 = docs_in(rows, 4)
assert len(s4) == 1 and s4[0].startswith("Q.C. inspection") and "ACCEPTED" in s4[0], s4
s5 = docs_in(rows, 5)
assert any("WPS / PQR" in d and "Unit folder doc" in d for d in s5), s5
assert any("RT report W1-W2" in d for d in s5), s5
itp_warn = [r for r in rows if r["section"] == 5 and r["doc"] == "No signed ITP filed for this unit"]
assert len(itp_warn) == 1 and itp_warn[0]["warn"] is True
s6 = docs_in(rows, 6)
assert len(s6) == 2 and all(d.startswith("MTR") for d in s6), s6
assert "item 1" in s6[0] and "item 2" in s6[1]
mtr_rows = [r for r in rows if r["section"] == 6]
assert mtr_rows[0]["ref"] == HEATS[1]
assert docs_in(rows, 7) == ["Packet record history"]
# pages: the parts plus the front matter (cover and index); page numbers point into the book
parts = sum(int(r["count"] or 0) for r in rows)
front = built.pages - parts
assert front == 2, (built.pages, parts) # the cover and one index page
numbered = [r for r in rows if r["page"] is not None]
assert all(front < int(r["page"]) <= built.pages for r in numbered), numbered
assert all(r["page"] is None and not r["count"] for r in rows if r["warn"] and "could not" in str(r["doc"]))
texts = page_texts(data)
assert "MANUFACTURING" in texts[0] and "DATA BOOK" in flat(texts[0])
assert pkt["unit"]["project"]["number"] in flat(texts[0])
index_text = flat(" ".join(texts[1:front]))
assert "INDEX" in index_text and "TABLE OF CONTENTS" in index_text
for r in rows:
words = flat(clean(str(r["doc"])))[:30]
assert words in index_text, (words, index_text[:2000])
# the void sheet carries the void text, the traceability page the heats
void_page = next(r for r in rows if str(r["doc"]).startswith("SUPERSEDED"))["page"]
assert "VOID - superseded by CO-1" in flat(texts[int(void_page) - 1])
trace_page = next(r for r in rows if str(r["doc"]).startswith("Material traceability"))["page"]
trace = flat(" ".join(texts[int(trace_page) - 1 : int(trace_page) + 1]))
for heat in (HEATS[1], HEATS[2], RECEIPT_HEAT):
assert heat in trace, heat
assert "open items" in trace.lower() and "item 3" in trace
# metadata
meta = reader.metadata
assert meta.title == f"{pkt['unit']['project']['number']} Lot 001 Manufacturing Data Book - Spool 7 outlet"
assert meta.producer == "Packet Station"
assert meta.get("/Author") == "PESCO - Process Equipment & Service Company, Inc."
# the stamp on every page, preview mode, and its signature verifies
sigs = set()
for i, t in enumerate(texts):
ft = flat(t)
assert "Controlled copy - exported by Clara Clerk (4001)" in ft, (i, ft[-300:])
assert "not yet filed" in ft, i
m = SIG_RE.search(ft)
assert m, (i, ft[-300:])
sigs.add(m.group(1))
assert len(sigs) == 1
sig = sigs.pop()
user = who(shop)["id"]
key = TEST_SEAL_KEY.encode()
assert stamps.verify_stamp(key, pid, "databook", "", user, NOW.isoformat(), sig)
assert not stamps.verify_stamp(key, pid, "traveler", "", user, NOW.isoformat(), sig)
def test_data_book_filed_mode_and_warnings(shop: Shop) -> None:
pkt = rich_complete(shop)
pid = pkt["id"]
filed = build(shop, pid, mode="filed")
texts = page_texts(filed.bytes)
assert all("seal ab12cd34ef56" in flat(t) for t in texts)
assert not any("not yet filed" in flat(t) for t in texts)
m = SIG_RE.search(flat(texts[0]))
assert m and filed.stamp_sig == m.group(1)
assert stamps.verify_stamp(TEST_SEAL_KEY.encode(), pid, "databook", SEAL["hash"], who(shop)["id"], NOW, m.group(1))
meta = pdf_reader(filed.bytes).metadata
assert "seal" in (meta.creator or "") and "4001" in (meta.creator or "")
# no MTRs: the MTR warning row; a deleted packet document file: could not be read
run(shop.settings, "UPDATE bom_lines SET mtr_asset_id = NULL WHERE packet_id = %s", (pid,))
doc = next(d for d in pkt["docs"] if d.get("removed") is None)
asset_id = doc.get("asset_id") or doc["url"].rsplit("/", 1)[-1]
delete_asset_file(shop.settings, asset_id)
built = build(shop, pid)
rows = index(built)
mtr = [r for r in rows if r["section"] == 6]
assert [r["doc"] for r in mtr] == ["No MTRs attached in Packet Station — add from the warehouse files"]
assert mtr[0]["warn"] is True and mtr[0]["page"] is None
bad = [r for r in rows if r["section"] == 5 and str(r["doc"]).endswith("could not be read")]
assert len(bad) == 1 and "RT report W1-W2" in bad[0]["doc"] and bad[0]["warn"] is True
# the index still fits on one page: the cover, the index and the parts
assert built.pages == 2 + sum(int(r["count"] or 0) for r in rows)
def test_data_book_with_an_itp_document_has_no_itp_warning(shop: Shop) -> None:
pkt = rich_complete(shop, itp_doc=True, mtrs=False)
rows = index(build(shop, pkt["id"]))
s5 = [r for r in rows if r["section"] == 5]
assert s5[0]["doc"].startswith("ITP"), s5
assert not any(r["doc"] == "No signed ITP filed for this unit" for r in s5)
+111
View File
@@ -0,0 +1,111 @@
"""Phase 7 ITP PDF: the builder on an open and a complete ITP (the number, a section title, the approvals, the
stamp, three page groups), and the sign-off through the API that now files the signed PDF (the asset on the ITP,
the automatic Q.C. folder document, the log note), superseded by a second sign-off after a reopen."""
from __future__ import annotations
from collections.abc import Iterator
from datetime import UTC, datetime
from typing import Any
import pytest
from closehelpers import builder_conn, flat, page_texts, pdf_reader, q1, qall, who
from pshelpers import needs_pg
from psycopg.rows import dict_row
from qchelpers import (
SECTION_TITLE_5,
approve_all,
itp_log,
open_qc_shop,
qc_directory,
reopen_itp,
sign_all_blocks,
sign_line,
sign_off,
start_itp,
)
from shophelpers import Shop, make_unit, ok
from packetstation.pdf import itp as itp_pdf
from packetstation.settings import Settings
pytestmark = needs_pg
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
PAGE_HEADS = ("1 OF 3: WP", "2 OF 3: C", "3 OF 3: FA") # the header sub is drawn in capitals
@pytest.fixture
def qc_shop(pg_settings: Settings, dev_visual: None) -> Iterator[Shop]:
yield from open_qc_shop(pg_settings, qc_directory())
def build(shop: Shop, itp_id: int) -> Any:
with builder_conn(shop.settings) as conn:
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute("SELECT * FROM itps WHERE id = %s", (itp_id,)).fetchone()
return itp_pdf.build(conn, shop.settings, row, who=who(shop, "qc.quinn"), now=NOW)
def test_open_itp_pdf(qc_shop: Shop) -> None:
shop = qc_shop
_, unit_id = make_unit(shop.settings)
itp = start_itp(shop, unit_id)
itp = ok(sign_line(shop("qc.quinn"), itp, "1.1"))
built = build(shop, itp["id"])
assert built.bytes[:5] == b"%PDF-"
assert len(pdf_reader(built.bytes).pages) == built.pages >= 3
texts = [flat(t) for t in page_texts(built.bytes)]
text = " ".join(texts)
assert itp["no"] in text and SECTION_TITLE_5 in text
assert "Approved" in text and "INSPECTION & TEST PLAN" in text
# three page groups, in order WP, C, FA
firsts = [next(i for i, t in enumerate(texts) if head in t) for head in PAGE_HEADS]
assert firsts == sorted(firsts) and firsts[0] == 0, firsts
for t in texts:
assert "OPEN, not signed off" in t
assert "Controlled copy - by Quinn QC (5001) 2026-10-17 21:04 UTC - OPEN, not signed off - stamp " in t
assert pdf_reader(built.bytes).metadata.title == f"ITP {itp['no']}"
def test_signoff_files_the_signed_pdf(qc_shop: Shop) -> None:
shop = qc_shop
_, unit_id = make_unit(shop.settings)
q = shop("qc.quinn")
itp = sign_all_blocks(shop, approve_all(shop, start_itp(shop, unit_id)))
itp = ok(sign_off(q, itp))
assert itp["status"] == "complete"
asset_id = itp["signoff"]["asset_id"]
assert asset_id is not None
assert itp_log(itp)[0] == ("ITP signed off", "PDF filed")
row = q1(shop.settings, "SELECT signoff_asset_id FROM itps WHERE id = %s", (itp["id"],))
assert str(row[0]) == asset_id
a = q1(shop.settings, "SELECT original_name, mime, source FROM assets WHERE id = %s", (asset_id,))
assert a[0] == f"{itp['no']}_signed.pdf" and a[1] == "application/pdf" and a[2] == "itp_signoff"
docs = qall(
shop.settings,
"SELECT id, type, title, auto, superseded_by, asset_id FROM qc_docs WHERE unit_id = %s ORDER BY id",
(unit_id,),
)
assert len(docs) == 1
assert tuple(docs[0][1:5]) == ("ITP", f"{itp['no']} — signed off", True, None)
assert str(docs[0][5]) == asset_id
r = q.get(f"/_blob/{asset_id}")
assert r.status_code == 200 and r.content[:5] == b"%PDF-"
text = flat(" ".join(page_texts(r.content)))
assert itp["no"] in text and "signed off" in text and "OPEN, not signed off" not in text
assert "ITP signed off" in text and "Quinn QC (5001)" in text
# the builder on the complete ITP says signed off on every page
for t in page_texts(build(shop, itp["id"]).bytes):
assert "signed off" in flat(t) and "OPEN, not signed off" not in flat(t)
# reopen and sign off again: a second PDF supersedes the first automatic document
itp = ok(reopen_itp(shop("admin.ann"), itp, "Customer asked for 4.2 again"))
itp = ok(sign_off(q, itp))
second = itp["signoff"]["asset_id"]
assert second is not None and second != asset_id
docs = qall(
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 and str(docs[1][2]) == second
+78
View File
@@ -0,0 +1,78 @@
"""Phase 7 job traveler builder: the page count, the work order boxes (the QR text is a barcode, never text; the
work order, lot and seq are text), the blanks for a packet with no heats, the sign-off rows after signed steps, the
note for a packet with no work orders, the stamp and the metadata."""
from __future__ import annotations
from collections.abc import Iterator
from datetime import UTC, datetime
from typing import Any
import pytest
from closehelpers import at_complete, builder_conn, flat, page_texts, pdf_reader, who
from fakedir import FakeDirectory
from pkthelpers import make_draft
from pshelpers import needs_pg
from shophelpers import BOM3, WOS, Shop, draft, make_unit, open_shop
from packetstation.pdf import traveler
from packetstation.settings import Settings
pytestmark = needs_pg
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def build(shop: Shop, pid: int) -> Any:
with builder_conn(shop.settings) as conn:
return traveler.build(conn, shop.settings, pid, who=who(shop), now=NOW)
def test_traveler_of_a_draft(shop: Shop) -> None:
"""A draft with no signed step yet (no drawing attached, so no signed attach row): the blank sign-off rows."""
_, unit_id = make_unit(shop.settings)
pkt = make_draft(shop("clerk.clara"), unit_id, bom=BOM3, work_orders=WOS)
built = build(shop, pkt["id"])
assert built.bytes[:5] == b"%PDF-"
reader = pdf_reader(built.bytes)
assert len(reader.pages) == built.pages >= 1
texts = page_texts(built.bytes)
text = flat(" ".join(texts))
assert "JOB TRAVELER" in text.upper()
for wo in ("L670001", "L670002"):
assert wo in text
assert "Lot 001" in text and "Seq" in text
# the QR payload is drawn as a barcode, never as text
assert "*%2" not in text and "$001$0$0$" not in text
# no heats yet: blanks
assert "________" in text
assert "Work complete (stall)" in text and "Supervisor review" in text and "Q.C. inspection" in text
project = pkt["unit"]["project"]["number"]
for i, t in enumerate(texts):
ft = flat(t)
assert "Controlled copy - exported by Clara Clerk (4001)" in ft, i
assert f"Page {i + 1} of {len(texts)}" in ft, i
assert reader.metadata.title == f"{project} Lot 001 Job Traveler", reader.metadata.title
assert reader.metadata.producer == "Packet Station"
def test_traveler_after_signed_steps(shop: Shop) -> None:
pkt = at_complete(shop)
text = flat(" ".join(page_texts(build(shop, pkt["id"]).bytes)))
assert "Andy Welder (3003)" in text # signed the submit
assert "Wes Super (2003)" in text # signed the send to Q.C.
assert "Quinn QC (5001)" in text # signed the accept
assert "Work complete (stall)" not in text
assert "HT-10" in text # the heats pulled in the warehouse
assert "Q.C. ACCEPTED" in text
def test_traveler_without_work_orders(shop: Shop) -> None:
pkt = draft(shop, wos=[])
text = flat(" ".join(page_texts(build(shop, pkt["id"]).bytes)))
assert "No work orders were read from this packet." in text
+255
View File
@@ -0,0 +1,255 @@
"""Phase 7 printed copies and exports through the API: every print kind with its stamp on every page, the print log
and the audit, one sheet, a document print and a foreign asset, nothing to print, the data book before complete,
the prints list, the ITP print and PDF, the exports, the signed-out call, the PM role, a removed packet, and the
append-only print log."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import psycopg
import pytest
from closehelpers import (
at_complete,
flat,
is_pdf,
page_texts,
pdf_pages,
q1,
qall,
remove_by_sql,
rich_complete,
)
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg
from qchelpers import approve_all, at_qc, sign_all_blocks, sign_off, start_itp, upload_doc
from shophelpers import Shop, draft, ok, open_shop, refused
from packetstation.settings import Settings
pytestmark = needs_pg
WHAT = {
"traveler": "Job traveler",
"map-h": "Drawing with heat map",
"map-w": "Drawing with weld map",
"clean": "Drawing (no markup)",
"void": "Superseded drawings (VOID)",
"databook": "Data book",
}
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def get_pdf(c: TestClient, url: str) -> bytes:
r = c.get(url)
assert r.status_code == 200, (url, r.text[:500])
assert r.headers["content-type"].startswith("application/pdf"), url
assert r.headers.get("x-content-type-options") == "nosniff"
assert r.headers.get("cache-control") == "no-store"
assert r.headers["content-disposition"].startswith("inline;"), r.headers["content-disposition"]
assert is_pdf(r.content)
return r.content
def assert_print_stamp(data: bytes, what: str, by: str = "Clara Clerk (4001)") -> None:
for i, t in enumerate(page_texts(data)):
ft = flat(t)
assert f"PRINTED COPY - {what} - printed by {by}" in ft, (i, what, ft[-400:])
assert "verify revision before use" in ft, (i, what)
def last_print(settings: Settings, pid: int) -> Any:
return q1(
settings,
"SELECT kind, what, pages, by_name, stamp_sig, stamp, asset_id FROM print_log WHERE packet_id = %s"
" ORDER BY id DESC LIMIT 1",
(pid,),
)
def last_audit(settings: Settings, pid: int, action: str) -> Any:
row = q1(
settings,
"SELECT detail, user_id FROM audit_events WHERE action = %s AND packet_id = %s ORDER BY id DESC LIMIT 1",
(action, pid),
)
assert row is not None, (action, pid)
return row
def test_every_print_kind(shop: Shop) -> None:
pkt = rich_complete(shop)
pid = pkt["id"]
c = shop("clerk.clara")
for kind, what in WHAT.items():
data = get_pdf(c, f"/api/packets/{pid}/print/{kind}")
assert_print_stamp(data, what)
row = last_print(shop.settings, pid)
assert (row[0], row[1], row[2], row[3]) == (kind, what, pdf_pages(data), "Clara Clerk"), row
assert row[4] and len(row[4]) == 16 and row[4] in row[5]
au = last_audit(shop.settings, pid, "print")
assert au[0]["kind"] == kind and au[0]["what"] == what and au[0]["pages"] == pdf_pages(data)
assert au[0]["stamp_sig"] == row[4]
# page counts: two live sheets, one void sheet
assert pdf_pages(get_pdf(c, f"/api/packets/{pid}/print/clean")) == 3
assert pdf_pages(get_pdf(c, f"/api/packets/{pid}/print/void")) == 1
# one sheet
for kind in ("map-h", "map-w", "clean"):
data = get_pdf(c, f"/api/packets/{pid}/print/{kind}?sheet=2")
assert pdf_pages(data) == 1
assert_print_stamp(data, f"{WHAT[kind]} (sheet 2)")
assert last_print(shop.settings, pid)[1] == f"{WHAT[kind]} (sheet 2)"
# the download form
r = c.get(f"/api/packets/{pid}/print/traveler?download=1")
assert r.status_code == 200 and r.headers["content-disposition"].startswith("attachment;")
assert '-Lot001 Job traveler.pdf"' in r.headers["content-disposition"], r.headers["content-disposition"]
def test_document_prints(shop: Shop) -> None:
pkt = rich_complete(shop)
pid = pkt["id"]
c = shop("clerk.clara")
mtr = next(b for b in pkt["bom"] if b["item"] == 1)
mtr_id = (mtr.get("mtr") or {}).get("asset_id") or mtr.get("mtr_asset_id")
png = next(b for b in pkt["bom"] if b["item"] == 2)
png_id = (png.get("mtr") or {}).get("asset_id") or png.get("mtr_asset_id")
doc = next(d for d in pkt["docs"] if d.get("removed") is None)
doc_id = doc.get("asset_id") or doc["url"].rsplit("/", 1)[-1]
folder = pkt["qc_folder_docs"][0]
folder_id = folder.get("asset_id") or folder["url"].rsplit("/", 1)[-1]
for asset, what_starts in (
(mtr_id, "MTR - item 1"),
(png_id, "MTR - item 2"),
(doc_id, "RT report W1-W2"),
(folder_id, "WPS / PQR - Unit folder doc"),
):
data = get_pdf(c, f"/api/packets/{pid}/print/doc?asset={asset}")
row = last_print(shop.settings, pid)
assert row[0] == "doc" and row[1].startswith(what_starts) and str(row[6]) == str(asset), row
assert_print_stamp(data, row[1])
assert last_audit(shop.settings, pid, "print")[0].get("asset_id") in (str(asset), None)
# a PNG MTR prints on one letter page
assert pdf_pages(get_pdf(c, f"/api/packets/{pid}/print/doc?asset={png_id}")) == 1
# an asset that is not the packet's
foreign = upload_doc(c, "other.pdf")["id"]
refused(c.get(f"/api/packets/{pid}/print/doc?asset={foreign}"), 404, "not_found")
r = c.get(f"/api/packets/{pid}/print/doc")
assert r.status_code in (404, 422), r.text
r = c.get(f"/api/packets/{pid}/print/nonsense")
assert r.status_code in (404, 422), r.text
def test_nothing_to_print_and_wrong_status(shop: Shop) -> None:
pkt = at_qc(shop)
c = shop("clerk.clara")
refused(c.get(f"/api/packets/{pkt['id']}/print/void"), 404, "nothing_to_print", "Nothing to print.")
refused(c.get(f"/api/packets/{pkt['id']}/print/databook"), 409, "wrong_status")
refused(
c.get(f"/api/packets/{pkt['id']}/pdf/databook"),
409,
"wrong_status",
"The data book is built once the packet is back from Q.C.",
)
assert q1(shop.settings, "SELECT count(*) FROM print_log WHERE packet_id = %s", (pkt["id"],))[0] == 0
# the traveler prints in any status
get_pdf(c, f"/api/packets/{pkt['id']}/print/traveler")
def test_prints_list_newest_first_and_exports(shop: Shop) -> None:
pkt = at_complete(shop)
pid = pkt["id"]
c = shop("clerk.clara")
assert ok(c.get(f"/api/packets/{pid}/prints")) == {"prints": []}
get_pdf(c, f"/api/packets/{pid}/print/traveler")
get_pdf(shop("pm.paula"), f"/api/packets/{pid}/pdf/traveler")
data = get_pdf(shop("pm.paula"), f"/api/packets/{pid}/pdf/databook")
assert "not yet filed" in flat(page_texts(data)[0])
assert shop("pm.paula").get(f"/api/packets/{pid}/export/traceability.csv").status_code == 200
out = ok(shop("qc.quinn").get(f"/api/packets/{pid}/prints"))["prints"]
assert [p["kind"] for p in out] == ["csv", "databook-export", "traveler", "traveler"]
assert out[0]["by"]["display_name"] == "Paula PM" and out[-1]["by"]["display_name"] == "Clara Clerk"
assert out[1]["pages"] == pdf_pages(data) and out[1]["what"]
assert set(out[0]) >= {"id", "at", "by", "kind", "what", "pages"}
ids = [p["id"] for p in out]
assert ids == sorted(ids, reverse=True)
kinds = [
r[0]["kind"]
for r in qall(
shop.settings,
"SELECT detail FROM audit_events WHERE action = 'export' AND packet_id = %s ORDER BY id",
(pid,),
)
]
assert kinds == ["traveler", "databook", "csv"]
assert (
q1(shop.settings, "SELECT count(*) FROM audit_events WHERE action = 'print' AND packet_id = %s", (pid,))[0] == 1
)
# prints and exports write no packet log rows
actions = [r[0] for r in qall(shop.settings, "SELECT action FROM packet_log WHERE packet_id = %s", (pid,))]
assert not any("print" in a.lower() or "export" in a.lower() for a in actions)
def test_itp_print_and_pdf(shop: Shop) -> None:
pkt = at_complete(shop)
unit_id = pkt["unit"]["id"]
itp = start_itp(shop, unit_id)
q = shop("qc.quinn")
data = get_pdf(q, f"/api/itps/{itp['id']}/pdf")
assert "OPEN, not signed off" in flat(page_texts(data)[0])
row = q1(shop.settings, "SELECT kind, unit_id, itp_id, packet_id FROM print_log ORDER BY id DESC LIMIT 1")
assert tuple(row) == ("itp-pdf", unit_id, itp["id"], None)
r = q.get(f"/api/itps/{itp['id']}/pdf?download=1")
assert r.status_code == 200 and r.headers["content-disposition"].startswith("attachment;")
assert f"{itp['no']}.pdf" in r.headers["content-disposition"]
data = get_pdf(shop("pm.paula"), f"/api/itps/{itp['id']}/print")
for t in page_texts(data):
assert "PRINTED COPY" in flat(t) and "verify revision before use" in flat(t)
assert q1(shop.settings, "SELECT kind FROM print_log ORDER BY id DESC LIMIT 1")[0] == "itp"
itp = ok(sign_off(q, sign_all_blocks(shop, approve_all(shop, itp))))
data = get_pdf(q, f"/api/itps/{itp['id']}/pdf")
assert "signed off" in flat(page_texts(data)[0]) and "OPEN, not signed off" not in flat(page_texts(data)[0])
refused(q.get("/api/itps/999999/pdf"), 404, "not_found")
def test_signed_out_pm_removed_and_append_only(shop: Shop) -> None:
gone = draft(shop)
remove_by_sql(shop.settings, gone["id"])
pkt = at_complete(shop)
pid = pkt["id"]
anon = TestClient(shop.main.app, headers=ORIGIN_HEADERS)
for url in (
f"/api/packets/{pid}/pdf/databook",
f"/api/packets/{pid}/pdf/traveler",
f"/api/packets/{pid}/export/traceability.csv",
f"/api/packets/{pid}/print/traveler",
f"/api/packets/{pid}/prints",
):
assert anon.get(url).status_code == 401, url
# the PM role reads every one
pm = shop("pm.paula")
for kind in ("traveler", "map-h", "map-w", "clean", "databook"):
get_pdf(pm, f"/api/packets/{pid}/print/{kind}")
# a missing packet and a removed packet are 404
refused(pm.get("/api/packets/999999/print/traveler"), 404, "not_found")
# a fresh session: the PDFs above can take longer than the idle lock (15 minutes over the tunnel)
clara = shop.fresh("clerk.clara")
for url in (
f"/api/packets/{gone['id']}/print/traveler",
f"/api/packets/{gone['id']}/pdf/traveler",
f"/api/packets/{gone['id']}/export/traceability.csv",
):
refused(clara.get(url), 404, "not_found")
# the print log is append-only
with app_conn(shop.settings) as conn:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("UPDATE print_log SET what = 'x' WHERE packet_id = %s", (pid,))
with app_conn(shop.settings) as conn:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("DELETE FROM print_log WHERE packet_id = %s", (pid,))
assert q1(shop.settings, "SELECT count(*) FROM print_log WHERE packet_id = %s", (pid,))[0] == 5
+3
View File
@@ -67,6 +67,9 @@ APPEND_INSERTS = {
"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)"
), ),
"print_log": (
"INSERT INTO print_log (packet_id, kind, what, by) VALUES (%(packet)s, 'traveler', 'Job traveler', %(user)s)"
),
} }
APPEND_ONLY = list(APPEND_INSERTS) APPEND_ONLY = list(APPEND_INSERTS)
+119
View File
@@ -0,0 +1,119 @@
"""Phase 7 stamps: the controlled-copy and print stamp texts, the short stamp signature made with the seal key, and
its check. Pure functions; no database."""
from __future__ import annotations
import re
from datetime import UTC, datetime
from typing import Any
from pshelpers import TEST_SEAL_KEY
from packetstation.pdf import stamps
KEY = TEST_SEAL_KEY.encode()
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
WHO = {"id": "00000000-0000-0000-0000-000000000c1a", "display_name": "Clara Clerk", "emp_no": "4001"}
SEAL = "0123456789abcdef" * 4
USER = WHO["id"]
AT = NOW.isoformat()
def sig(packet_id: int = 7, kind: str = "databook", seal: str = SEAL, user: str = USER, at: str = AT) -> str:
return stamps.stamp_sig(KEY, packet_id, kind, seal, user, at)
def test_stamp_sig_is_16_hex_and_stable() -> None:
s = sig()
assert re.fullmatch(r"[0-9a-f]{16}", s), s
assert sig() == s
def test_stamp_sig_differs_per_kind_packet_user_time_seal_and_key() -> None:
base = sig()
assert sig(kind="traveler") != base
assert sig(packet_id=8) != base
assert sig(user="00000000-0000-0000-0000-000000000bbb") != base
assert sig(at="2026-10-17T21:05:00+00:00") != base
assert sig(seal="f" * 64) != base
assert stamps.stamp_sig(b"another-key-0123456789abcdef", 7, "databook", SEAL, USER, AT) != base
def test_verify_stamp_accepts_and_rejects() -> None:
good = sig()
assert stamps.verify_stamp(KEY, 7, "databook", SEAL, USER, AT, good) is True
assert stamps.verify_stamp(KEY, 7, "databook", SEAL, USER, AT, "0" * 16) is False
assert stamps.verify_stamp(KEY, 8, "databook", SEAL, USER, AT, good) is False
assert stamps.verify_stamp(KEY, 7, "traveler", SEAL, USER, AT, good) is False
# the time is signed to the minute, in any ISO form
assert stamps.verify_stamp(KEY, 7, "databook", SEAL, USER, "2026-10-17T21:04:59+00:00", good) is True
assert stamps.verify_stamp(KEY, 7, "databook", SEAL, USER, "2026-10-17T21:05:00+00:00", good) is False
def test_controlled_stamp_texts() -> None:
filed = stamps.controlled_stamp(WHO, NOW, mode="filed", seal_hash_hex=SEAL)
assert filed.startswith("Controlled copy - exported by Clara Clerk (4001) 2026-10-17 21:04 UTC")
assert "seal 0123456789ab" in filed and "seal 0123456789abc" not in filed
preview = stamps.controlled_stamp(WHO, NOW, mode="preview", seal_hash_hex=None)
assert preview.startswith("Controlled copy - exported by Clara Clerk (4001) 2026-10-17 21:04 UTC")
assert "not yet filed" in preview
printed = stamps.controlled_stamp(WHO, NOW, mode="print", seal_hash_hex=None)
assert "printed by Clara Clerk (4001)" in printed
trav = stamps.controlled_stamp(WHO, NOW, mode="traveler")
assert trav == "Controlled copy - exported by Clara Clerk (4001) 2026-10-17 21:04 UTC"
for text in (filed, preview, printed, trav):
assert text.isascii(), text
assert "—" not in text and "–" not in text
def test_missing_emp_number_reads_question_mark() -> None:
text = stamps.controlled_stamp({**WHO, "emp_no": None}, NOW, mode="preview", seal_hash_hex=None)
assert "Clara Clerk (?)" in text
def packet_like() -> Any:
"""The fields print_stamp reads, as a dict (the read model) and as an object, so either shape works."""
return {
"id": 7,
"drawing": "D-5001",
"rev": "B",
"unit": {
"id": 3,
"number": "L5A1B2C001",
"lot": "001",
"project": {"id": 2, "number": "L5A1B2C", "name": "Shop project"},
},
"project": {"number": "L5A1B2C"},
}
def test_print_stamp_text_is_ascii_and_carries_the_parts() -> None:
text = stamps.print_stamp(WHO, NOW, "Drawing with weld map — sheet 2", packet_like())
assert text.startswith("PRINTED COPY - Drawing with weld map")
assert "printed by Clara Clerk (4001) 2026-10-17 21:04 UTC" in text
assert "L5A1B2C Lot 001 dwg D-5001 RB" in text, text
assert text.endswith(" - verify revision before use"), text
assert text.isascii(), text
def test_print_stamp_replaces_non_ascii() -> None:
who = {**WHO, "display_name": "Zoë Łukasz"}
text = stamps.print_stamp(who, NOW, "MTR → item 3", packet_like())
assert text.isascii(), text
def test_apply_stamp_and_metadata() -> None:
from closehelpers import flat, page_texts, pdf_reader
from pkthelpers import tiny_pdf
line = "Controlled copy - exported by Clara Clerk (4001) 2026-10-17 21:04 UTC - stamp 0123456789abcdef"
for box in (False, True):
out = stamps.apply_stamp(tiny_pdf(2), line, ours={0}, box=box)
texts = page_texts(out)
assert len(texts) == 2 and all(line in flat(t) for t in texts), (box, texts)
out = stamps.set_metadata(tiny_pdf(1), "Title A", "Subject B", "k1, k2", "Packet Station - test")
meta = pdf_reader(out).metadata
assert meta.title == "Title A" and meta.subject == "Subject B" and meta.creator == "Packet Station - test"
assert meta.producer == "Packet Station"
assert meta.get("/Author") == "PESCO - Process Equipment & Service Company, Inc."
assert meta.get("/CreationDate")
+143
View File
@@ -0,0 +1,143 @@
"""Phase 7 traceability CSV: the formula guard of csv_cell, and the export endpoint (content type, file name, CRLF
line ends, no BOM, the header rows, a BOM row with a short and its receipts, the weld rows, the Q.C. rows, the
audit row)."""
from __future__ import annotations
import csv
import io
from collections.abc import Iterator
import pytest
from closehelpers import HEATS, RECEIPT_HEAT, at_complete, q1, qall, rich_complete
from fakedir import FakeDirectory
from pshelpers import app_conn, needs_pg
from shophelpers import Shop, open_shop
from packetstation.pdf.tracecsv import csv_cell
from packetstation.settings import Settings
@pytest.mark.parametrize(
("value", "want"),
[
("=1+1", "'=1+1"),
("+31", "'+31"),
("-2", "'-2"),
("@SUM(A1)", "'@SUM(A1)"),
("\tTAB", "'\tTAB"),
("\rCR", '"\'\rCR"'),
('say "hi"', '"say ""hi"""'),
("a,b", '"a,b"'),
("two\nlines", '"two\nlines"'),
('=HYPERLINK("x")', '"\'=HYPERLINK(""x"")"'),
("HT-10", "HT-10"),
("", ""),
(None, ""),
(3, "3"),
],
)
def test_csv_cell(value: object, want: str) -> None:
assert csv_cell(value) == want
# ---------------------------------------------------------------- the endpoint
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def rows_of(text: str) -> list[list[str]]:
return list(csv.reader(io.StringIO(text, newline="")))
@needs_pg
def test_traceability_csv(shop: Shop) -> None:
pkt = rich_complete(shop)
pid = pkt["id"]
project = pkt["unit"]["project"]["number"]
r = shop("pm.paula").get(f"/api/packets/{pid}/export/traceability.csv")
assert r.status_code == 200, r.text
assert r.headers["content-type"].startswith("text/csv")
cd = r.headers["content-disposition"]
assert "_D-5001_traceability.csv" in cd and project in cd, cd
assert r.headers.get("cache-control") == "no-store"
raw = r.content
assert not raw.startswith(b"\xef\xbb\xbf")
assert b"\r\n" in raw and b"\n" not in raw.replace(b"\r\n", b"")
rows = rows_of(raw.decode("utf-8"))
assert rows[0] == ["PESCO weld & heat traceability record"]
assert rows[1] == ["Project", project, "Lot", "001", "Operation", "WELD-PART"], rows[1]
flat_rows = [",".join(x) for x in rows]
assert any(x.startswith("Drawing,D-5001,Rev,B") for x in flat_rows), flat_rows[:6]
assert any(x.startswith("WPS,WPS-11,Station,Stall 1") for x in flat_rows), flat_rows[:6]
assert any(x.startswith("Filler,ER70S-6") for x in flat_rows), flat_rows[:6]
head = rows.index(
[
"BOM item",
"Qty",
"Issued",
"Location",
"Part ID",
"Description",
"Length",
"Material",
"Heat no.",
"MTR on file",
"Open short qty",
"Short reason",
"Short received",
]
)
bom = {r[0]: r for r in rows[head + 1 : head + 4]}
assert bom["1"][4] == "PIPE-4" and bom["1"][8] == HEATS[1] and bom["1"][9] == "Yes"
assert bom["2"][9] == "Yes"
short = bom["3"]
assert HEATS[3] in short[8] and RECEIPT_HEAT in short[8]
assert short[10] == "1" and short[11] == "On order"
assert short[12].startswith(f"1 pc {RECEIPT_HEAT} "), short
weld_head = next(i for i, x in enumerate(rows) if x[:2] == ["Weld", "Work order"])
assert rows[weld_head] == [
"Weld",
"Work order",
"Piece",
"Joins items",
"Heats joined",
"Type",
"Size",
"Process",
"WPS",
"Stamp",
"Logged",
]
welds = rows[weld_head + 1 : weld_head + 3]
assert [w[0] for w in welds] == ["W1", "W2"]
assert welds[0][3] == "1 + 2" and welds[0][4] == f"{HEATS[1]} + {HEATS[2]}" and welds[0][5] == "Fillet"
assert any(x[:2] == ["Checklist item", "Done"] for x in rows)
qc = next(x for x in rows if x and x[0] == "Q.C. inspection")
assert qc[:4] == ["Q.C. inspection", "accepted", "Inspector", "Quinn QC"], qc
assert "Quinn QC" in qc and "Q1" in qc
assert any(x and x[0] == "NDE" for x in rows)
assert any(x and x[0].startswith("Q.C.: ") for x in rows)
vt = [x for x in rows if x and x[0].startswith("VT W")]
assert [x[0] for x in vt] == ["VT W1", "VT W2"] and all(x[1] == "accept" for x in vt)
au = q1(
shop.settings,
"SELECT detail FROM audit_events WHERE action = 'export' AND packet_id = %s ORDER BY id DESC",
(pid,),
)
assert au is not None and au[0]["kind"] == "csv"
log = qall(shop.settings, "SELECT kind, by_name FROM print_log WHERE packet_id = %s", (pid,))
assert ("csv", "Paula PM") in [tuple(x) for x in log]
@needs_pg
def test_formula_values_are_guarded_in_the_export(shop: Shop) -> None:
pkt = at_complete(shop)
with app_conn(shop.settings) as conn:
conn.execute("UPDATE bom_lines SET description = '=cmd|calc' WHERE packet_id = %s AND item = 1", (pkt["id"],))
r = shop("clerk.clara").get(f"/api/packets/{pkt['id']}/export/traceability.csv")
assert r.status_code == 200
assert "'=cmd|calc" in r.text and ",=cmd" not in r.text