From 904437153dbb9b9e855279380fb3484191ac705d Mon Sep 17 00:00:00 2001 From: Spencer McGuire Date: Sat, 17 Oct 2026 23:49:14 -0600 Subject: [PATCH] Phase 7: close, file, PDFs and prints 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 --- CHANGELOG.md | 14 + README.md | 10 +- docs/PLAN.md | 16 +- docs/POLICY.md | 65 +- docs/SCHEMA.md | 43 +- docs/SECURITY.md | 36 +- frontend/src/api/packets.ts | 22 + frontend/src/api/qc.ts | 8 + frontend/src/api/types.ts | 70 +- frontend/src/packet/Act.tsx | 32 + frontend/src/packet/CloseFile.tsx | 231 ++++ frontend/src/packet/DrawingViewer.tsx | 12 +- frontend/src/packet/HeatMapTab.tsx | 6 + frontend/src/packet/PacketPage.tsx | 79 +- frontend/src/packet/PrintMenu.test.tsx | 192 +++ frontend/src/packet/PrintMenu.tsx | 44 + frontend/src/packet/WeldMapTab.tsx | 6 + frontend/src/packet/closeUtil.ts | 96 ++ frontend/src/packet/closefile.test.tsx | 269 +++++ frontend/src/packet/tabs.tsx | 120 +- frontend/src/screens/qc/ItpPage.tsx | 22 +- frontend/src/screens/qc/ItpPrint.test.tsx | 52 + frontend/src/theme/theme.css | 6 + pyproject.toml | 2 + server/packetstation/api/itp.py | 103 +- server/packetstation/api/packets.py | 7 +- server/packetstation/api/prints.py | 346 ++++++ server/packetstation/domain/packets.py | 98 +- server/packetstation/domain/pipeline.py | 120 +- server/packetstation/domain/prints.py | 182 +++ server/packetstation/main.py | 2 + .../packetstation/migrations/0008_close.sql | 43 + server/packetstation/pdf/__init__.py | 7 +- .../packetstation/pdf/assets/logo_color.png | Bin 0 -> 26423 bytes server/packetstation/pdf/databook.py | 1062 +++++++++++++++++ server/packetstation/pdf/doc.py | 408 +++++++ server/packetstation/pdf/itp.py | 325 ++++- server/packetstation/pdf/maps.py | 99 ++ server/packetstation/pdf/parts.py | 235 ++++ server/packetstation/pdf/prints.py | 272 +++++ server/packetstation/pdf/raster.py | 320 +++++ server/packetstation/pdf/stamps.py | 280 +++++ server/packetstation/pdf/style.py | 227 ++++ server/packetstation/pdf/tracecsv.py | 289 +++++ server/packetstation/pdf/traveler.py | 329 +++++ server/requirements.txt | 3 + tests/e2e/tests/close.spec.ts | 382 ++++++ tests/e2e/tests/closehelpers.ts | 255 ++++ tests/server/closehelpers.py | 282 +++++ tests/server/test_close_file.py | 302 +++++ tests/server/test_itp.py | 18 +- tests/server/test_packets_api.py | 3 +- tests/server/test_pdf_databook.py | 201 ++++ tests/server/test_pdf_itp.py | 111 ++ tests/server/test_pdf_traveler.py | 78 ++ tests/server/test_prints.py | 255 ++++ tests/server/test_schema.py | 3 + tests/server/test_stamps.py | 119 ++ tests/server/test_tracecsv.py | 143 +++ 59 files changed, 8226 insertions(+), 136 deletions(-) create mode 100644 frontend/src/packet/Act.tsx create mode 100644 frontend/src/packet/CloseFile.tsx create mode 100644 frontend/src/packet/PrintMenu.test.tsx create mode 100644 frontend/src/packet/PrintMenu.tsx create mode 100644 frontend/src/packet/closeUtil.ts create mode 100644 frontend/src/packet/closefile.test.tsx create mode 100644 frontend/src/screens/qc/ItpPrint.test.tsx create mode 100644 server/packetstation/api/prints.py create mode 100644 server/packetstation/domain/prints.py create mode 100644 server/packetstation/migrations/0008_close.sql create mode 100644 server/packetstation/pdf/assets/logo_color.png create mode 100644 server/packetstation/pdf/databook.py create mode 100644 server/packetstation/pdf/doc.py create mode 100644 server/packetstation/pdf/maps.py create mode 100644 server/packetstation/pdf/parts.py create mode 100644 server/packetstation/pdf/prints.py create mode 100644 server/packetstation/pdf/raster.py create mode 100644 server/packetstation/pdf/stamps.py create mode 100644 server/packetstation/pdf/style.py create mode 100644 server/packetstation/pdf/tracecsv.py create mode 100644 server/packetstation/pdf/traveler.py create mode 100644 tests/e2e/tests/close.spec.ts create mode 100644 tests/e2e/tests/closehelpers.ts create mode 100644 tests/server/closehelpers.py create mode 100644 tests/server/test_close_file.py create mode 100644 tests/server/test_pdf_databook.py create mode 100644 tests/server/test_pdf_itp.py create mode 100644 tests/server/test_pdf_traveler.py create mode 100644 tests/server/test_prints.py create mode 100644 tests/server/test_stamps.py create mode 100644 tests/server/test_tracecsv.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 202fa18..2265248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,20 @@ All notable changes to this project are recorded here. The format follows [Keep ### 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). - 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. diff --git a/README.md b/README.md index f7de906..70f97fc 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ This repo is the rebuild of Packet Station as a server application: Docker Compo ## 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? | |---|---| @@ -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) | | 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) | -| 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) | | 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 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: ```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/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/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/PAPER-FALLBACK.md](docs/PAPER-FALLBACK.md): what the shop does when the server is down (phase 10). - [CHANGELOG.md](CHANGELOG.md): what changed. diff --git a/docs/PLAN.md b/docs/PLAN.md index d2ca3c6..84a86b6 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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. 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. 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. @@ -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. 29. ITP status: the 0003 enum had `open`, `signed` and `reopened`. Default: `itp_status` gains `complete` for a signed-off ITP; a reopened ITP is `open` again with the reopen recorded. `signed` and `reopened` stay in the enum unused, since PostgreSQL cannot drop an enum value. Applied in phase 6. 30. Removing an ITP block signature: the old build let any Q.C. editor undo it. Default: the signer or an admin, while the ITP is open. The block's QN, remarks and customer fields stay. Applied in phase 6. -31. The signed ITP PDF on sign-off: default a hook (`pdf.itp.file_signed_itp_pdf`) that phase 7 builds. Today it files nothing, the sign-off log says `PDF filing reserved for phase 7`, and the automatic `qc_docs` ITP row is written only when the hook returns a file. Applied in phase 6. +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 - 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 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 10: the full 38-scenario bench green in Gitea Actions. diff --git a/docs/POLICY.md b/docs/POLICY.md index 252bea5..9ac30ec 100644 --- a/docs/POLICY.md +++ b/docs/POLICY.md @@ -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. - **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: `. 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 @@ -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. - 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). @@ -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 unsigned | `ITP block 3 signature removed` | | | Customer complete | `ITP block 3 customer complete recorded`, `ITP block 3 customer complete cleared` | `Rick Inspector` (the cleared name on a clear) | -| Sign-off | `ITP signed off` | `PDF filed` when the hook filed one, else `PDF filing reserved for phase 7` | +| Sign-off | `ITP signed off` | `PDF filed` | | Reopen | `ITP reopened` | the reason | A Q.C. folder document writes an audit row `qc_doc.add` or `qc_doc.revise` (subject `qc_doc`), with `doc_id`, `project_id`, `unit_id` and `replaces` in the detail, `detail.text` `Q.C. document added` or `Q.C. document revised` and `detail.note` `L670 · WPS / PQR · WPS-101 GTAW/SMAW Rev 2 · wps.pdf`. +## 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: . 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) 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.`, role `system`) and a change event. See [FEED.md](FEED.md) for when each write happens. diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index b6c7e0e..3adb858 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -1,6 +1,6 @@ # 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 @@ -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. - `qc_inspections`: `stage_id`, `sent_at`, `sent_by`, `book`, `spool`, `location`, `sent_note`, `checks` jsonb, `weld_exam` jsonb (weld_no to `{result, note}`), `nde_method`, `nde_result`, `nde_report`, `hardness`, `nace`, `inspector`, `stamp`, `notes`, `result`, `result_at`, `result_by`, `result_note`, `signature_id`, `row_version`, `closed_at`, `ncr` (0007). At most one open inspection per packet. See "The Q.C. inspection". - `qc_history`: append-only. `at`, `result`, `inspector`, `note`, `dept_code`, `snapshot` jsonb. -- `packet_seals`: the HMAC-SHA256 `hash` written at filing, `key_id`, `sealed_at`, `sealed_by`, `signature_id`, and `broken_at`, `broken_by`, `broken_reason` when IT reopens. At most one unbroken seal per packet. +- `packet_seals`: the HMAC-SHA256 `hash` written at filing, `key_id`, `sealed_at`, `sealed_by`, `signature_id`, and `broken_at`, `broken_by`, `broken_reason` when IT reopens. At most one unbroken seal per packet. Migration 0008 adds the filed data book (`databook_asset_id`, `databook_pages`) and `open_wos`. See "Close and file (phase 7, migration 0008)". - `packet_log`: append-only. The user-visible history: `at`, `by`, `by_name` and `role` (snapshots), `action`, `note`, `it_reason`, `signature_id`, `transition_id`. Indexed on (`packet_id`, `id`). - `packet_visual_state`: one row per packet, written by the worker. `legs` jsonb, `ticket_hours`, `order_ref`, `observed_at`. @@ -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`. - 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`: `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 -- `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_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. @@ -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: -`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`. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 01fca79..c021f95 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -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). -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: @@ -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 | | 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 | -| 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 | | 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 | | 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 | @@ -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 | | 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 | -| 17 | Controlled-copy stamp on data books | Applied to server-built PDFs and signed with the seal key. | 7 | -| 18 | CSV formula guard | Applied to every server export. | 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. 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) | | 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) -- 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). +### 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) `, the seal's first 12 hex or `not yet filed`, and ` - stamp `. The filed data book is the stored copy, served as it was built. +- Printed-copy stamp (S8): every printed page carries `PRINTED COPY - - printed by Name (emp) - Lot dwg R - 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:`, 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) - 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) | | 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) | -| 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 | | M4, M5 | Deferred (logs to stdout now; SIEM shipping is IT's) | | S1 | Closed, phase 0, tuned phase 10 | | S2 | Closed, phase 1 | | S3 | Closed, phase 9 (CI scanning) | | 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) | -| 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. | +| S5 | Closed, phase 2 (React escapes output; server messages are plain text); phase 7 adds escaping in the PDF builders | +| 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 | | O3, O4 | Closed, phase 10 | | O5 | Superseded (no migration; M: retired read-only) | diff --git a/frontend/src/api/packets.ts b/frontend/src/api/packets.ts index 5109100..942a0bf 100644 --- a/frontend/src/api/packets.ts +++ b/frontend/src/api/packets.ts @@ -16,8 +16,11 @@ import type { PacketHeader, PacketListQuery, PacketRead, + PrintKind, + PrintRow, QcFields, ReceiptBody, + SealView, ShortBody, StageFields, TransitionBody, @@ -127,6 +130,25 @@ export const packetsApi = { api.put(`${base(id)}/qc/welds/${no}`, { ...body, ...g }), /** The scalar inspection fields with a field-level base: 409 field_conflict when someone changed one first. */ qcPatch: (id: number, body: FieldCas) => api.patch(`${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(`${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 => { + const raw = await api.get | 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 => + asList(await api.get(`${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. */ diff --git a/frontend/src/api/qc.ts b/frontend/src/api/qc.ts index 9978e46..4a8b717 100644 --- a/frontend/src/api/qc.ts +++ b/frontend/src/api/qc.ts @@ -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(`/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 = { getQcHome, getFolders, @@ -433,4 +439,6 @@ export const qcApi = { unsignBlock, signOff, reopenItp, + itpPdfUrl, + itpPrintUrl, }; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 7215f53..cbfc81c 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -384,6 +384,8 @@ export interface QcFolderDoc { url: string; scope: "unit" | "project"; 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. */ @@ -480,7 +482,12 @@ export interface PacketRead { open_cos: number[]; open_shorts: number; 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[]; blocked: Record; holder: string; @@ -505,6 +512,65 @@ export interface PacketRead { 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/", inline. */ + url: string; + /** "/_blob/?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 ---------- */ export interface Weld { @@ -726,6 +792,8 @@ export interface TransitionBody { for_sup_id?: string; /** Phase 6: QC_REJECT. An NCR number like NCR-2026-0021. */ ncr?: string; + /** Phase 7: FILE. Files although the feed still shows work orders open in VISUAL. */ + confirm_open_wos?: boolean; sign?: SignBody; it_reason?: string; } diff --git a/frontend/src/packet/Act.tsx b/frontend/src/packet/Act.tsx new file mode 100644 index 0000000..6479d51 --- /dev/null +++ b/frontend/src/packet/Act.tsx @@ -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 ( +
+ + {!can && reason ?
{reason}
: null} +
+ ); +} diff --git a/frontend/src/packet/CloseFile.tsx b/frontend/src/packet/CloseFile.tsx new file mode 100644 index 0000000..9ec60e2 --- /dev/null +++ b/frontend/src/packet/CloseFile.tsx @@ -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
Checking the seal
; + if (load.state === "error") return
The seal could not be checked. {load.message}
; + const key = load.data.seal?.key_id ?? p.seal?.key_id ?? ""; + if (load.data.valid === true) { + return ( +
+ + Seal intact (key {key}) + +
+ ); + } + if (load.data.valid === false) { + return ( +
+ Seal does not match the record. The filed record changed after it was sealed. Tell IT. +
+ ); + } + return ( +
+ Seal unknown (key not available) +
+ ); +} + +/** + * 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; + sub: string; + onPrint: () => void; +}) { + const [confirm, setConfirm] = useState(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 = ( + <> +
+ +
+ + +
+ +
+ + ); + + const reopened = + !filed && seal?.broken_at ? ( +
+ Reopened by {seal.broken_by?.display_name ?? "IT"} {fmtTime(seal.broken_at)}: {seal.broken_reason ?? ""}. The earlier seal is kept as broken. +
+ ) : null; + + return ( +
+ {filed ? ( + <> +
+ Filed in the data book {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"}.` : ""} +
+ + {seal?.open_wos?.length ?
Filed with work orders open in VISUAL: {seal.open_wos.join(", ")}.
: null} +
+ {exports} + void reopen()}> + Reopen (IT) + +
+ + ) : readyToClose ? ( + <> +
+ Back from the shop, ready to close. Close the job in VISUAL, then click below. The packet moves on to the data book. +
+ {vline ? ( +
+ {vline} +
+ ) : null} + {reopened} +
+ void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}> + Job closed in VISUAL + + void fileIt(false)}> + File in the data book + + {exports} +
+ + ) : ( + <> +
+ Ready for the data book. + {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. +
+ {(check?.open_wos ?? []).length > 0 && vline ? ( +
+ {vline} +
+ ) : null} + {reopened} +
+ void fileIt(false)}> + File in the data book + + {exports} + {databookRoute ? ( + void mutate((g) => packetsApi.setVclosed(p.id, false, g))}> + Not closed in VISUAL, move back + + ) : null} +
+ + )} + {confirm !== null ? ( + setConfirm(null)}> +

{confirm}

+
+ + +
+
+ ) : null} +
+ ); +} diff --git a/frontend/src/packet/DrawingViewer.tsx b/frontend/src/packet/DrawingViewer.tsx index 7ab28d4..1c3fc0c 100644 --- a/frontend/src/packet/DrawingViewer.tsx +++ b/frontend/src/packet/DrawingViewer.tsx @@ -4,8 +4,11 @@ import { openPdf } from "../lib/pdf"; 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 [zi, setZi] = useState(2); const [width, setWidth] = useState(0); @@ -84,6 +87,11 @@ export function DrawingViewer({ pages }: { pages: PacketPage[] }) { {Math.round(zoom * 100)}% + {onPrintSheet ? ( + + ) : null} Open in a new tab diff --git a/frontend/src/packet/HeatMapTab.tsx b/frontend/src/packet/HeatMapTab.tsx index 18e4225..0708e39 100644 --- a/frontend/src/packet/HeatMapTab.tsx +++ b/frontend/src/packet/HeatMapTab.tsx @@ -21,6 +21,7 @@ import { errorText } from "../lib/format"; import { voidText } from "../lib/voidStamp"; import type { Write, WriteResult } from "../warehouse/PullTable"; import { MapViewer } from "./MapViewer"; +import { openPrint } from "./closeUtil"; import { HeatTableInner, HeatTableOnDrawing, @@ -327,6 +328,11 @@ export function HeatMapTab({ : "No heat table on this sheet"} ) : null} + {!p.removed ? ( + + ) : null}
@@ -630,35 +606,7 @@ export function PacketPage() { ) : null} - {!draft && (can("set_vclosed") || can("file") || p.blocked.file) ? ( -
-
- {p.vclosed ? ( - <> - Ready for the data book. Job closed in VISUAL by {p.vclosed.by?.display_name ?? "Clerical"}. Mark it filed when the data book is done. - - ) : ( - <> - Back from the shop, ready to close. Close the job in VISUAL, then click below. The packet moves on to the data book. - - )} -
-
- {!p.vclosed ? ( - void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}> - Job closed in VISUAL - - ) : ( - void mutate((g) => packetsApi.setVclosed(p.id, false, g))}> - Not closed in VISUAL, move back - - )} - void fileIt()}> - Mark filed - -
-
- ) : null} + setSheet("print")} /> setTab(t)} /> setTab("qc")} /> @@ -694,7 +642,7 @@ export function PacketPage() { ) : null} - + openPrint(p.id, "clean", { sheet: n }) })} /> ) : null} {shownTab === "heat" ? : null} @@ -732,6 +680,7 @@ export function PacketPage() { }} /> ) : null} + {sheet === "print" ? setSheet(null)} /> : null} {wh.sheet} {guardSheet} {toast ? ( diff --git a/frontend/src/packet/PrintMenu.test.tsx b/frontend/src/packet/PrintMenu.test.tsx new file mode 100644 index 0000000..27a43a2 --- /dev/null +++ b/frontend/src/packet/PrintMenu.test.tsx @@ -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 { + 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 = {}) { + 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(); + }); +}); diff --git a/frontend/src/packet/PrintMenu.tsx b/frontend/src/packet/PrintMenu.tsx new file mode 100644 index 0000000..e1fbdef --- /dev/null +++ b/frontend/src/packet/PrintMenu.tsx @@ -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 ( + +
+ {rows.map((r) => ( + + ))} +
+
+ Every printed page is stamped with who printed it and when, plus "verify revision before use". Printing is recorded. +
+
+ +
+
+ ); +} diff --git a/frontend/src/packet/WeldMapTab.tsx b/frontend/src/packet/WeldMapTab.tsx index 7cc46f1..cfc0fcb 100644 --- a/frontend/src/packet/WeldMapTab.tsx +++ b/frontend/src/packet/WeldMapTab.tsx @@ -8,6 +8,7 @@ import { voidText } from "../lib/voidStamp"; import { weldId, weldSummary } from "../lib/weldDefaults"; import type { Write, WriteResult } from "../warehouse/PullTable"; import { MapViewer } from "./MapViewer"; +import { openPrint } from "./closeUtil"; import { WeldMark, type MarkMove } from "./marks"; import { landingPage, pageLabel, usePeople, viewPages } from "./shopUtil"; import { useSerial } from "./useSerial"; @@ -167,6 +168,11 @@ export function WeldMapTab({ {p.welds.length} weld{p.welds.length === 1 ? "" : "s"} recorded ) : null} + {!p.removed ? ( + + ) : null}
" 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; + 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): 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"); +} diff --git a/frontend/src/packet/closefile.test.tsx b/frontend/src/packet/closefile.test.tsx new file mode 100644 index 0000000..1923b32 --- /dev/null +++ b/frontend/src/packet/closefile.test.tsx @@ -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 { + return { observed_at: "2026-10-17T07:00:00Z", closed_at: null, open_wos: [], agrees: true, ...over }; +} + +function seal(over: Partial = {}): 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 { + 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 { + return toClose({ vclosed: { at: "2026-10-17T09:00:00Z", by: CLARA }, can: ["file", "set_vclosed"], blocked: {}, ...over }); +} + +function filed(over: Partial = {}): 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 = {}, 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."); + }); +}); diff --git a/frontend/src/packet/tabs.tsx b/frontend/src/packet/tabs.tsx index ec0a41d..cb67ca2 100644 --- a/frontend/src/packet/tabs.tsx +++ b/frontend/src/packet/tabs.tsx @@ -2,7 +2,7 @@ import QRCode from "qrcode"; import { useEffect, useRef, useState, type FormEvent } from "react"; import { assetsApi } from "../api/assets"; 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 { Sheet } from "../components/Sheet"; import { errorText, fmtTime } from "../lib/format"; @@ -11,6 +11,7 @@ import { useLoad } from "../lib/useLoad"; import { MtrViewer } from "../warehouse/MtrViewer"; import { PullTable, ShortChip, type Write } from "../warehouse/PullTable"; import { ShortSheet } from "../warehouse/ShortSheet"; +import { assetOfUrl, kioskCode, openPrint, printLineText } from "./closeUtil"; export type Mutate = ( fn: (g: Guarded) => Promise, @@ -209,6 +210,19 @@ export function MaterialsTab({ + {!p.removed ? ( + <> + {" "} + + + ) : null} ) : null} @@ -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 }) { if (!p.work_orders.length) return
No work orders on this packet.
; return (
+
+
+ + 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. + + {!p.removed ? ( + <> + + + Traveler (PDF) + + + ) : null} +
+
@@ -316,13 +347,21 @@ export function WorkOrdersTab({ p }: { p: PacketRead }) {
{p.work_orders.map((w) => { - const k = woKey(w); + const code = kioskCode(w); return ( -
- +
+
{w.wo_base}
-
{k}
+
+ Lot {w.lot} · Seq {show(w.seq)} {w.op ?? ""} +
+
+ Target {show(w.target_hrs)} h · Qty {show(w.qty)} +
+
+ {code} +
); @@ -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 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 ( @@ -440,6 +493,11 @@ function DocRow({ d, old, canRemove, busy, onOut }: { d: PacketDoc; old: boolean + {!old && onPrint ? ( + + ) : null} {!old && canRemove ? (
{live.length ? ( - live.map((d) => void takeOut(d)} />) + live.map((d) => ( + void takeOut(d)} + onPrint={p.removed ? undefined : () => openPrint(p.id, "doc", { asset: d.asset_id })} + /> + )) ) : (
No documents added to this packet yet.{canAdd ? " Use Add document." : " Q.C. or Clerical add them."}
)} @@ -551,6 +619,11 @@ export function DocumentsTab({ + {!p.removed ? ( + + ) : null}
)) @@ -582,10 +655,41 @@ export function HistoryTab({ p }: { p: PacketRead }) { ) : (
Nothing recorded yet.
)} + ); } +/** 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 ( +
+
+

Printed copies and exports

+ {load.state === "ok" ? {load.data.length} : null} +
+ {load.state === "loading" ? ( +
Loading
+ ) : load.state === "error" ? ( +
{load.message}
+ ) : load.data.length ? ( +
    + {load.data.map((r) => ( + + ))} +
+ ) : ( +
Nothing printed yet.
+ )} +
+ ); +} + +function PrintLine({ r }: { r: PrintRow }) { + return
  • {printLineText(r)}
  • ; +} + /** One row per closed Q.C. inspection, oldest first. */ function QcInspections({ rows }: { rows: QcHistoryRow[] }) { if (!rows.length) return null; diff --git a/frontend/src/screens/qc/ItpPage.tsx b/frontend/src/screens/qc/ItpPage.tsx index 23bb450..a385815 100644 --- a/frontend/src/screens/qc/ItpPage.tsx +++ b/frontend/src/screens/qc/ItpPage.tsx @@ -5,6 +5,8 @@ import { enterNcr, getItp, getUnitFolder, + itpPdfUrl, + itpPrintUrl, patchBlock, patchItp, patchLine, @@ -445,6 +447,15 @@ export function ItpPage() {
    + + + Download + + {r.can.sign_off ? (