Compare commits
1
Commits
main
...
1cd6688247
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cd6688247 |
+17
-2
@@ -6,11 +6,14 @@ All notable changes to this project are recorded here. The format follows [Keep
|
|||||||
|
|
||||||
### Decided
|
### Decided
|
||||||
|
|
||||||
|
- Gitea issue 9: SethD kept the drawing-origin change order for a same-rev re-release on an in-shop packet (2026-10-29), so the shop confirms the new pages. A byte-identical file is still ignored. Nothing changes in the build.
|
||||||
|
- Gitea issue 4: SethD wants the order a packet shows to follow the shop schedule, which is Presley's Production Planner (2026-10-29). Asked on the issue on 2026-10-30: what it is, where it lives, what a row holds and how often it changes. Packet Station would only read it. The order rule stays as built until then.
|
||||||
|
- Gitea issue 31: two follow-up questions asked on 2026-10-30. A work order deleted from VISUAL while open blocks filing for good: count a leg gone for 2 runs as closed, or let an Admin close it with a reason? And should Shipping packets also wait for the VISUAL close? Both stay as built until answered.
|
||||||
- Gitea issues 10, 14, 15, 16, 22, 26, 28 and 29: SethD kept the built rules (cancelled work orders flagged only, data book date for Clerical and Q.C., signature undo rules, the server release gate, no "copy marks" button, block signature removal, the data book built at filing, every role may read the PDFs). Nothing changes in the build.
|
- Gitea issues 10, 14, 15, 16, 22, 26, 28 and 29: SethD kept the built rules (cancelled work orders flagged only, data book date for Clerical and Q.C., signature undo rules, the server release gate, no "copy marks" button, block signature removal, the data book built at filing, every role may read the PDFs). Nothing changes in the build.
|
||||||
- Gitea issue 6: every policy tightening stays, filing for packets routed to Shipping included.
|
- Gitea issue 6: every policy tightening stays, filing for packets routed to Shipping included.
|
||||||
- Gitea issues 8, 18, 23, 32, 33 and 36: SethD changed six more defaults, to build in phase 10 (PLAN.md changes C6 to C11): the data book leaves out the history pages; a supervisor approves each received short and a packet cannot complete until every short is approved; checklists are per department, edited by its supervisor, and changes reach new packets only; Q.C. and Clerical may run the stamp check; the PM screen is for PS-ProjectMgmt and Admin; the Stall screen follows the supervisor's assignment.
|
- Gitea issues 8, 18, 23, 32, 33 and 36: SethD changed six more defaults (PLAN.md changes C6 to C11, built 2026-10-30; see Added and Changed): the data book leaves out the history pages; a supervisor approves each received short and a packet cannot complete until every short is approved; checklists are per department, edited by its supervisor, and changes reach new packets only; Q.C. and Clerical may run the stamp check; the PM screen is for PS-ProjectMgmt and Admin; the Stall screen follows the supervisor's assignment.
|
||||||
- Gitea issues 7, 11, 13, 17, 30, 34 and 35: kept as built, or settled as planned (the archive gets the data book, the documents and a manifest at filing; nothing is purged).
|
- Gitea issues 7, 11, 13, 17, 30, 34 and 35: kept as built, or settled as planned (the archive gets the data book, the documents and a manifest at filing; nothing is purged).
|
||||||
- Gitea issues 1, 24, 25, 27 and 31: SethD changed five defaults, to build in phase 10 (PLAN.md changes C1 to C5): over target follows VISUAL ticket hours; a Q.C. reject always needs a reason and acceptance needs an NCR number; an incomplete ITP blocks filing; prints go to the print dialog from the page; filing waits until VISUAL shows the work order closed.
|
- Gitea issues 1, 24, 25, 27 and 31: SethD changed five defaults (PLAN.md changes C1 to C5, built 2026-10-30; see Added and Changed): over target follows VISUAL ticket hours; a Q.C. reject always needs a reason and acceptance needs an NCR number; an incomplete ITP blocks filing; prints go to the print dialog from the page; filing waits until VISUAL shows the work order closed.
|
||||||
- Gitea issue 21: carried marks stay computed; the next department adds its welds to the same weld map.
|
- Gitea issue 21: carried marks stay computed; the next department adds its welds to the same weld map.
|
||||||
- Gitea issue 37: production and test each run natively on their own Linux VM, not in Docker. docker-1 keeps CI only. Built in phase 10; the docker-1 test stack runs until the test VM is ready.
|
- Gitea issue 37: production and test each run natively on their own Linux VM, not in Docker. docker-1 keeps CI only. Built in phase 10; the docker-1 test stack runs until the test VM is ready.
|
||||||
- Gitea issue 7: project files are also archived to an NFS share on the QNAP. Phase 10 adds an Admin setting for the archive folder that checks read and write. Nothing is deleted from Packet Station.
|
- Gitea issue 7: project files are also archived to an NFS share on the QNAP. Phase 10 adds an Admin setting for the archive folder that checks read and write. Nothing is deleted from Packet Station.
|
||||||
@@ -28,6 +31,15 @@ All notable changes to this project are recorded here. The format follows [Keep
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Gitea issue 1 (change C1): over target follows the VISUAL ticket hours, not the station timer. A packet is over when its ticket hours are above its work-order target; a packet with no ticket hours is not over. The PM hours tables, the "Packets over target" table, the KPI, the summary line, the CSV and the report PDF follow. The columns read "Timer h", "Ticket h", "Target h", "Ticket variance h", "Ticket h / target" and "Over target (tickets)", and the over table ends with "Over by (tickets)". The timer is still shown.
|
||||||
|
- Gitea issue 24 (change C2): a Q.C. reject always needs a reason of at least 8 characters; an NCR number may go with it but no longer stands in for it. A packet rejected without an NCR number cannot be accepted, to complete or to another department, until Q.C. enters the NCR number (422 `ncr_required` "Enter the NCR number of the reject first."). The number is stored on the accepting inspection and in the log. The Q.C. bar shows the NCR field as required (`ncr_required` on the packet read). An NCR document added to the packet goes into section 5 of the data book.
|
||||||
|
- Gitea issue 25 (change C3): a packet cannot be filed while its unit's ITP is not complete (precondition `itp_complete`, "Complete the unit's ITP first. Q.C. signs it off on the ITP page."). A unit with no ITP does not block. The close panel shows the reason under File.
|
||||||
|
- Gitea issue 27 (change C4): prints go straight to the browser's print dialog from the page, with no new tab. Every print in the app, the ITP print included, goes through `frontend/src/lib/printPdf.ts`. A print that fails says "The print could not be made. Try again." Viewing a PDF still opens a tab. The stamped pages and the print log are unchanged.
|
||||||
|
- Gitea issue 31 (change C5): filing is blocked, not warned, while VISUAL shows a work order open, on both routes. The feed now marks a VISUAL packet closed in VISUAL when it sees every work order closed or cancelled, and clears it when one shows open again ("Open again in VISUAL (observed)"); the packet moves from "Ready to close" to "Ready for data book" by itself. Clerical confirms only hand packets; "Job closed in VISUAL" is refused on a VISUAL packet. Precondition `closed_in_visual` replaces `vclosed_if_databook`. The second confirmation (`confirm_open_wos`, 409 `open_work_orders`) is gone.
|
||||||
|
- Gitea issue 8 (change C6): the data book leaves out the packet history. Section 7 "Record history" is gone, so the book has six sections. The change-order table moved to a "Change order record" page at the end of section 1, only when the packet has change orders. The history stays on the packet page.
|
||||||
|
- Gitea issue 33 (change C10): the Project Management screen and every `/api/pm/*` endpoint, the CSV and PDF exports included, are for PS-ProjectMgmt and Admin only. Anyone else gets 403 "Project Management only." and sees no PM entry; typing `/pm` says the screen is not for their role.
|
||||||
|
- Gitea issue 36 (change C11): the Stall screen lists the packets assigned to the signed-in person, each at the station the supervisor picked, across stations (`GET /api/stall/mine`). Nobody picks a station there any more. Moving a welder is a re-assign by the supervisor. The stall's Mine no longer needs a station. With nothing assigned it says "Your supervisor assigns packets to you."
|
||||||
|
- Deploying C1 to C11: short receipts recorded before the deploy have no approval, so a supervisor approves them before the next Q.C. accept to complete, or before filing a packet already complete. A Q.C. reject since the last accept that has no NCR number needs one at the next accept. Migration 0011 freezes the open stage of each packet in progress, in review or at Q.C. to the default list for its kind, so a department edit does not reach it; if the `checklist_templates` row is missing, such a stage reads its department's live list until it closes. Nothing else is backfilled.
|
||||||
- Security fixes in the pinned dependencies, found by the new CI scan: `pypdf` 6.21.0 to 6.21.2 (GHSA-5hx5-vpf9-5wcv, a crafted PDF could reach the image parsers; 6.21.2 for GHSA-6c7f-9265-g8fw, GHSA-jr96-gvgh-j3r4 and GHSA-gmh7-3g9r-mjh5) and `certifi` 2026.7.22 to 2026.10.19 (PYSEC-2026-4320, a distrusted root removed). The image must be rebuilt for both to reach the stacks.
|
- Security fixes in the pinned dependencies, found by the new CI scan: `pypdf` 6.21.0 to 6.21.2 (GHSA-5hx5-vpf9-5wcv, a crafted PDF could reach the image parsers; 6.21.2 for GHSA-6c7f-9265-g8fw, GHSA-jr96-gvgh-j3r4 and GHSA-gmh7-3g9r-mjh5) and `certifi` 2026.7.22 to 2026.10.19 (PYSEC-2026-4320, a distrusted root removed). The image must be rebuilt for both to reach the stacks.
|
||||||
- `POST /api/admin/ad/refresh` no longer runs the AD refresh in the request. It asks the worker for an `ad-reconcile` run and answers 202 `{requested, pending, job}`; `GET /api/admin/ad/reconcile` gives the latest report.
|
- `POST /api/admin/ad/refresh` no longer runs the AD refresh in the request. It asks the worker for an `ad-reconcile` run and answers 202 `{requested, pending, job}`; `GET /api/admin/ad/reconcile` gives the latest report.
|
||||||
- The storage quota alert is now mailed through the Notifications rules (to PS-Admins by default), with a "Cleared" message when usage drops back.
|
- The storage quota alert is now mailed through the Notifications rules (to PS-Admins by default), with a "Cleared" message when usage drops back.
|
||||||
@@ -39,6 +51,9 @@ All notable changes to this project are recorded here. The format follows [Keep
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Gitea issue 18 (change C7): the department supervisor approves every received short, one receipt at a time, with a signature ("Approve a received short"), after checking the part and its heat number against what the warehouse issued. A lead is not enough. The Materials tab lists each receipt with who received it and who approved it, and an Approve button for the supervisor. A packet cannot be accepted as complete, or filed, until every short is received and approved ("Every short must be received and approved by the supervisor first."). Accept and route to another department does not wait. `POST /api/packets/{id}/shorts/receipts/{receipt_id}/approve`; the packet read gains `shorts` and `shorts_unapproved`. Migration 0011 adds the append-only `short_approvals` table.
|
||||||
|
- Gitea issue 23 (change C8): checklists per department. The Checklists screen (`/checklists`) lets a department supervisor edit their own department's checklist, and Admin any department's; a lead and Q.C. cannot. A department without its own list uses the default for its kind (weld or plain). A save needs a password session and the version it started from, and is audited (`checklists.dept_updated`). A stage keeps the list it had when work started (`packet_stages.checklist_template`, migration 0011), so a change reaches only stages not yet started. The Admin Checklists tab now edits department lists too. `GET /api/checklists`, `GET` and `PUT /api/checklists/{dept}`. The Q.C. check items are unchanged.
|
||||||
|
- Gitea issue 32 (change C9): the Stamp check screen (`/stamp-check`) for Admin, Q.C. and Clerical, in a password session. `POST /api/stamp-verify` and `POST /api/stamp-verify/pdf`; the `/api/admin` paths stay as aliases with the same rule.
|
||||||
- Phase 9: Admin completion, AD reconcile, integrity, notifications, backups. The Admin page gains eight tabs: Departments, Profiles, Checklists, Security, Access log, Who is in, Stamp check and Notifications. The tab list wraps into rows on a narrow screen.
|
- Phase 9: Admin completion, AD reconcile, integrity, notifications, backups. The Admin page gains eight tabs: Departments, Profiles, Checklists, Security, Access log, Who is in, Stamp check and Notifications. The tab list wraps into rows on a narrow screen.
|
||||||
- Departments tab: every department with its AD groups (and whether each is in the OU) and its stations. Edit a department's name, turn it on or off, rename, reorder, add or hide stations. Stations are never deleted, since station limits and packets point at them. The code and the short name cannot change. "New department" adds a shop department with its stations and shows the three AD groups IT must create (`PS-<code>-<short name>-Worker`, `-Lead`, `-Supervisor`) before it is saved. Only shop departments can be added; the support departments are fixed.
|
- Departments tab: every department with its AD groups (and whether each is in the OU) and its stations. Edit a department's name, turn it on or off, rename, reorder, add or hide stations. Stations are never deleted, since station limits and packets point at them. The code and the short name cannot change. "New department" adds a shop department with its stations and shows the three AD groups IT must create (`PS-<code>-<short name>-Worker`, `-Lead`, `-Supervisor`) before it is saved. Only shop departments can be added; the support departments are fixed.
|
||||||
- Profiles tab: a person's welder stamp (1 to 12 letters, numbers or dashes, unique), whether their profile is active, and their station limits per department. An inactive profile drops out of the welder picker and the worker filter; it changes nothing the person may do. A CSV import by employee ID (columns `employee_id`, `welder_stamp`, `active`, `stations`; an empty cell leaves a value, `-` clears it; the `stations` cell is the person's whole list) shows a preview with every change and every error, and applies only when the preview has no errors, in one go. The Directory flags panel lists active users with no employee ID and employee IDs shared by more than one account.
|
- Profiles tab: a person's welder stamp (1 to 12 letters, numbers or dashes, unique), whether their profile is active, and their station limits per department. An inactive profile drops out of the welder picker and the worker filter; it changes nothing the person may do. A CSV import by employee ID (columns `employee_id`, `welder_stamp`, `active`, `stations`; an empty cell leaves a value, `-` clears it; the `stations` cell is the person's whole list) shows a preview with every change and every error, and applies only when the preview has no errors, in one go. The Directory flags panel lists active users with no employee ID and employee IDs shared by more than one account.
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ This repo is the rebuild of Packet Station as a server application: Docker Compo
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Rebuild in progress. Phase 9 done: the Admin page completed (departments, profiles with the CSV import, checklists, security settings and alerts, the access log, who is in, the stamp check), the nightly AD reconcile and the 15-minute role refresh, the integrity job, notifications by email with rules per event, nightly database backups with a rehearsed restore, and dependency scanning in CI.**
|
**Rebuild in progress. Phase 9 done: the Admin page completed (departments, profiles with the CSV import, checklists, security settings and alerts, the access log, who is in, the stamp check), the nightly AD reconcile and the 15-minute role refresh, the integrity job, notifications by email with rules per event, nightly database backups with a rehearsed restore, and dependency scanning in CI. SethD's changes C1 to C11 (Gitea, 2026-10-26 and 2026-10-28) were built on 2026-10-30.**
|
||||||
|
|
||||||
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. An Admin manages departments and stations, welder stamps and station limits (one at a time or from a CSV), the checklists, the session and lockout settings, and who gets which email. The worker reconciles users with AD every night, re-reads each signed-in person's groups every 15 minutes, checks seals, files and the audit chain every night, and checks that the nightly backup ran. Problems and events reach people by email through app-03. 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 (the department supervisor then approves each received short with a signature), 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's Stall screen lists the packets assigned to them, each at the station the supervisor picked. 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 and, when there is one, an NCR number. A packet rejected without an NCR number needs one at the accept, and a packet with a short not yet approved cannot be accepted as complete. 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. The feed marks a VISUAL packet closed in VISUAL when it sees every work order closed, and the packet moves to Clerical's "Ready for data book" column by itself; Clerical confirms a hand packet by hand. Clerical files the packet with a signature once VISUAL is closed, the unit's ITP is complete and every short is approved. Filing seals the packet with an HMAC and builds the manufacturing data book on the server, stored on the seal. The data book goes to the customer and has no history pages; the history stays in Packet Station. 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. A print goes straight to the browser's print dialog. Every copy carries a stamp signed with the seal key, and every print and export is logged. Admin, Q.C. and Clerical check a copy's stamp on the Stamp check screen. Project Management and Admin read the PM reports. The ITP sign-off files the signed ITP PDF. Each department supervisor edits their department's checklist on the Checklists screen; a stage keeps the list it started with. An Admin manages departments and stations, welder stamps and station limits (one at a time or from a CSV), any department's checklist, the session and lockout settings, and who gets which email. The worker reconciles users with AD every night, re-reads each signed-in person's groups every 15 minutes, checks seals, files and the audit chain every night, and checks that the nightly backup ran. Problems and events reach people by email through app-03. Every packet write goes through the server policy. The old build on `M:\Production\Packet Station` stays the live system until go-live. The new system starts empty; nothing is migrated from M:.
|
||||||
|
|
||||||
| Area | Exists yet? |
|
| Area | Exists yet? |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -23,6 +23,7 @@ People can sign in, set up projects and units, make hand drafts, attach a drawin
|
|||||||
| 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) |
|
| 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 screen (six tabs, periods, health, CSV and PDF reports), the find bar completed (search, filters, quick flags, Mine, Board and List, saved per browser), presence, motion, iPad layout | Yes (phase 8, verified in the CI stack on docker-1) |
|
| Project Management screen (six tabs, periods, health, CSV and PDF reports), the find bar completed (search, filters, quick flags, Mine, Board and List, saved per browser), presence, motion, iPad layout | Yes (phase 8, verified in the CI stack on docker-1) |
|
||||||
| Admin completion (departments, profiles, checklists, Security, access log, who is in, stamp check, Notifications), AD reconcile and role refresh, integrity job, email notifications, backups and restore, CI dependency scan | Yes (phase 9) |
|
| Admin completion (departments, profiles, checklists, Security, access log, who is in, stamp check, Notifications), AD reconcile and role refresh, integrity job, email notifications, backups and restore, CI dependency scan | Yes (phase 9) |
|
||||||
|
| SethD's changes C1 to C11: over target on VISUAL ticket hours, the Q.C. reject reason and the NCR on the accept, the ITP complete before filing, prints to the print dialog, filing blocked until VISUAL closes, no history pages in the data book, supervisor approval of received shorts, checklists per department (`/checklists`), the stamp check for Q.C. and Clerical (`/stamp-check`), PM reports for PM and Admin only, the Stall screen by assignment | Yes (2026-10-30) |
|
||||||
| Regression bench and go-live | No (phase 10) |
|
| Regression bench and go-live | No (phase 10) |
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
@@ -88,12 +89,14 @@ Phase 3 adds the worker (`packetstation worker`: APScheduler, the `visual-feed`,
|
|||||||
|
|
||||||
Phase 6 adds the Q.C. screen (`/qc`: numbers, boards, project folders), `GET /api/qc/home`, `GET /api/qc/folders` with the project and unit folder reads, `POST /api/qc/docs` (a new revision supersedes the old row), the per-key Q.C. endpoints under `/api/packets/{id}/qc`, document kinds as a closed set with take-out reasons, and the digital ITP under `/api/units/{id}/itp` and `/api/itps/{id}` (start, line sign and unsign, N/A, reject with NCR, block sign, sign-off, reopen, log). The ITP PDF filed on sign-off is a hook for phase 7. See [docs/POLICY.md](docs/POLICY.md) "Q.C. (phase 6)".
|
Phase 6 adds the Q.C. screen (`/qc`: numbers, boards, project folders), `GET /api/qc/home`, `GET /api/qc/folders` with the project and unit folder reads, `POST /api/qc/docs` (a new revision supersedes the old row), the per-key Q.C. endpoints under `/api/packets/{id}/qc`, document kinds as a closed set with take-out reasons, and the digital ITP under `/api/units/{id}/itp` and `/api/itps/{id}` (start, line sign and unsign, N/A, reject with NCR, block sign, sign-off, reopen, log). The ITP PDF filed on sign-off is a hook for phase 7. See [docs/POLICY.md](docs/POLICY.md) "Q.C. (phase 6)".
|
||||||
|
|
||||||
Phase 7 adds the close and file panel, `confirm_open_wos` on the file transition (409 `open_work_orders` without it), the data book built and stored on the seal at filing, `GET /api/packets/{id}/pdf/databook`, `GET /api/packets/{id}/pdf/traveler`, `GET /api/packets/{id}/export/traceability.csv`, `GET /api/packets/{id}/print/{kind}` (`traveler`, `map-h`, `map-w`, `clean`, `void`, `databook`, `doc`), `GET /api/packets/{id}/prints`, `GET /api/itps/{id}/pdf` and `GET /api/itps/{id}/print`, and the `print_log` table (migration 0008). The PDFs are built on the server with reportlab 5.0.1, pypdf and pypdfium2; the heat and weld map rasters use Pillow 13.0.0. Both are pinned in `pyproject.toml` and `server/requirements.txt`, and need no system package or font in the image. See [docs/POLICY.md](docs/POLICY.md) "Phase 7: close, file, PDFs and prints".
|
Phase 7 adds the close and file panel, `confirm_open_wos` on the file transition (409 `open_work_orders` without it; removed on 2026-10-30 by change C5, since filing is now blocked while VISUAL shows a work order open), 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".
|
||||||
|
|
||||||
Phase 8 adds the Project Management screen (`/pm`) and its API under `/api/pm` (`overview`, `projects` with `expand=`, `aging`, `hours`, `quality`, `find`, `export.csv?table=<key>|jobs|all`, `report.pdf`), read by any signed-in role and writing nothing; every export is logged as `report exported` in the audit and the print log. The find bar params (`q`, `status`, `dept`, `station_id`, `welder_id`, `flags`, `flag`, `sort`, `mine_screen`, `mine_station_id`, `facets`) are read by `GET /api/packets` and by the shop, stall, warehouse and Q.C. boards, so the server applies every filter; the KPI numbers follow the project pick only. Presence over `/api/events` now carries the role and drops locked sessions (session events `locked` and `unlocked`). No new table or migration. See [docs/POLICY.md](docs/POLICY.md) "Phase 8: Project Management, find bar, presence".
|
Phase 8 adds the Project Management screen (`/pm`) and its API under `/api/pm` (`overview`, `projects` with `expand=`, `aging`, `hours`, `quality`, `find`, `export.csv?table=<key>|jobs|all`, `report.pdf`), read by PS-ProjectMgmt and Admin only since change C10 (any signed-in role in phase 8) and writing nothing; every export is logged as `report exported` in the audit and the print log. The find bar params (`q`, `status`, `dept`, `station_id`, `welder_id`, `flags`, `flag`, `sort`, `mine_screen`, `mine_station_id` (ignored since change C11), `facets`) are read by `GET /api/packets` and by the shop, stall, warehouse and Q.C. boards, so the server applies every filter; the KPI numbers follow the project pick only. Presence over `/api/events` now carries the role and drops locked sessions (session events `locked` and `unlocked`). No new table or migration. See [docs/POLICY.md](docs/POLICY.md) "Phase 8: Project Management, find bar, presence".
|
||||||
|
|
||||||
Phase 9 adds, under `/api/admin`: `departments` (list, `PATCH /departments/{code}`, `POST /departments`), `PATCH /users/{id}/profile`, `POST /users/import` (CSV preview and apply), `checklists`, `security/settings`, `security/alerts`, `security/integrity`, `access-log` and `access-log.csv`, `presence`, `stamp-verify` and `stamp-verify/pdf`, `directory-flags`, `ad/reconcile`, and `notifications` (rules, group search, test send, sent log). `POST /api/admin/ad/refresh` now asks the worker for an `ad-reconcile` run and answers 202. Any signed-in person reads `GET /api/account/notifications`, and opts out of an event type with `PUT /api/account/notifications/{event_type}` (password session; security events cannot be turned off). The Admin page gains the tabs Departments, Profiles, Checklists, Security, Access log, Who is in, Stamp check and Notifications, and Account settings gains a Notifications section. The worker gains six jobs: `ad-reconcile` (01:30), `session-refresh` (every 15 minutes), `integrity` (04:00), `backup-check` (07:15), `notify-send` (every minute) and `notify-digest` (every 5 minutes). Migration 0010 adds the notification tables, the integrity findings and the profile switch. See [docs/POLICY.md](docs/POLICY.md) "Phase 9: Admin, notifications, reconcile" and [docs/FEED.md](docs/FEED.md) "Phase 9 jobs".
|
Phase 9 adds, under `/api/admin`: `departments` (list, `PATCH /departments/{code}`, `POST /departments`), `PATCH /users/{id}/profile`, `POST /users/import` (CSV preview and apply), `checklists`, `security/settings`, `security/alerts`, `security/integrity`, `access-log` and `access-log.csv`, `presence`, `stamp-verify` and `stamp-verify/pdf`, `directory-flags`, `ad/reconcile`, and `notifications` (rules, group search, test send, sent log). `POST /api/admin/ad/refresh` now asks the worker for an `ad-reconcile` run and answers 202. Any signed-in person reads `GET /api/account/notifications`, and opts out of an event type with `PUT /api/account/notifications/{event_type}` (password session; security events cannot be turned off). The Admin page gains the tabs Departments, Profiles, Checklists, Security, Access log, Who is in, Stamp check and Notifications, and Account settings gains a Notifications section. The worker gains six jobs: `ad-reconcile` (01:30), `session-refresh` (every 15 minutes), `integrity` (04:00), `backup-check` (07:15), `notify-send` (every minute) and `notify-digest` (every 5 minutes). Migration 0010 adds the notification tables, the integrity findings and the profile switch. See [docs/POLICY.md](docs/POLICY.md) "Phase 9: Admin, notifications, reconcile" and [docs/FEED.md](docs/FEED.md) "Phase 9 jobs".
|
||||||
|
|
||||||
|
SethD's changes (built 2026-10-30, [docs/PLAN.md](docs/PLAN.md) changes C1 to C11) add two screens, `/checklists` (department supervisors and Admin) and `/stamp-check` (Admin, Q.C. and Clerical), both linked in the top bar. The API gains `GET /api/checklists`, `GET` and `PUT /api/checklists/{dept}`, `POST /api/stamp-verify` and `POST /api/stamp-verify/pdf` (the `/api/admin` paths stay as aliases), `GET /api/stall/mine` (the Stall screen; the station picker is gone) and `POST /api/packets/{id}/shorts/receipts/{receipt_id}/approve`. The Q.C. accepts take `ncr`, and the packet read gains `ncr_required`, `shorts` and `shorts_unapproved`. Every `/api/pm/*` endpoint is for PS-ProjectMgmt and Admin only. Every print goes through `frontend/src/lib/printPdf.ts`. The data book has six sections. Migration 0011 adds `short_approvals` and `packet_stages.checklist_template`. See [docs/POLICY.md](docs/POLICY.md) for the rules and [docs/SCHEMA.md](docs/SCHEMA.md), "Seth's changes", for the schema.
|
||||||
|
|
||||||
Backups run on the host, outside the app: `scripts/backup/ps-backup.sh` (nightly `pg_dump` of each stack from a systemd timer at 02:00 Denver time, 7 kept, with markers the worker checks) and `scripts/backup/ps-restore.sh` (restore into a fresh database, copy assets back, check hashes, compare row counts). [scripts/backup/README.md](scripts/backup/README.md) and [docs/DEPLOY.md](docs/DEPLOY.md) "Backup" have the install steps and the restore rehearsal of 2026-10-21.
|
Backups run on the host, outside the app: `scripts/backup/ps-backup.sh` (nightly `pg_dump` of each stack from a systemd timer at 02:00 Denver time, 7 kept, with markers the worker checks) and `scripts/backup/ps-restore.sh` (restore into a fresh database, copy assets back, check hashes, compare row counts). [scripts/backup/README.md](scripts/backup/README.md) and [docs/DEPLOY.md](docs/DEPLOY.md) "Backup" have the install steps and the restore rehearsal of 2026-10-21.
|
||||||
|
|
||||||
CI stack with the Samba domain controller and the Playwright scenario, on a Docker host:
|
CI stack with the Samba domain controller and the Playwright scenario, on a Docker host:
|
||||||
@@ -117,7 +120,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, notifications, backups and the restore rehearsal.
|
- [docs/DEPLOY.md](docs/DEPLOY.md): host, stacks, secrets, NPM proxy host, doctor, notifications, backups and the restore rehearsal.
|
||||||
- [docs/FEED.md](docs/FEED.md): the worker and its jobs, the VISUAL feed, the packet rules, the fingerprint, the drawing decision table, the flags and the feed settings.
|
- [docs/FEED.md](docs/FEED.md): the worker and its jobs, the VISUAL feed, the packet rules, the fingerprint, the drawing decision table, the flags and the feed settings.
|
||||||
- [docs/POLICY.md](docs/POLICY.md): who may do what, the preconditions, the order of checks, concurrency, and the tightenings against the old build.
|
- [docs/POLICY.md](docs/POLICY.md): who may do what, the preconditions, the order of checks, concurrency, and the tightenings against the old build.
|
||||||
- [docs/SCHEMA.md](docs/SCHEMA.md): the database schema (migrations 0001 to 0010).
|
- [docs/SCHEMA.md](docs/SCHEMA.md): the database schema (migrations 0001 to 0011).
|
||||||
- [docs/GO-LIVE.md](docs/GO-LIVE.md): go-live procedure (phase 10).
|
- [docs/GO-LIVE.md](docs/GO-LIVE.md): go-live procedure (phase 10).
|
||||||
- [docs/PAPER-FALLBACK.md](docs/PAPER-FALLBACK.md): what the shop does when the server is down (phase 10).
|
- [docs/PAPER-FALLBACK.md](docs/PAPER-FALLBACK.md): what the shop does when the server is down (phase 10).
|
||||||
- [CHANGELOG.md](CHANGELOG.md): what changed.
|
- [CHANGELOG.md](CHANGELOG.md): what changed.
|
||||||
|
|||||||
+9
-3
@@ -272,7 +272,13 @@ The apply runs in one transaction with a 5-second lock timeout. A packet row loc
|
|||||||
5. **Past draft: change orders** (action `vis_open_co`). See "Change orders from the feed". Filed packets get nothing.
|
5. **Past draft: change orders** (action `vis_open_co`). See "Change orders from the feed". Filed packets get nothing.
|
||||||
6. **Retire moved drafts** (action `vis_retire_draft`). A live draft made by the feed, with no drawing pages and no hand uploads, that is not among this run's candidates, and whose every leg now sits on another live candidate, is soft-removed with reason `moved`. `visual.moved` records where the legs went. Log "Retired: work orders moved to another packet". It is hidden from the boards and, being removed, is never recreated.
|
6. **Retire moved drafts** (action `vis_retire_draft`). A live draft made by the feed, with no drawing pages and no hand uploads, that is not among this run's candidates, and whose every leg now sits on another live candidate, is soft-removed with reason `moved`. `visual.moved` records where the legs went. Log "Retired: work orders moved to another packet". It is hidden from the boards and, being removed, is never recreated.
|
||||||
7. **Work order gone.** Skipped on a suspect batch. Each tracked leg VISUAL did not return gets `missing_runs + 1`. At 2 the leg gets `gone_at`, the packet gets flag `wo_gone` and `wo_gone_at`, and the log says "Work order <key> no longer in VISUAL". A leg that comes back resets to 0. When no leg is gone any more the flag goes and the log says "Work order back in VISUAL". The feed never removes a packet for this.
|
7. **Work order gone.** Skipped on a suspect batch. Each tracked leg VISUAL did not return gets `missing_runs + 1`. At 2 the leg gets `gone_at`, the packet gets flag `wo_gone` and `wo_gone_at`, and the log says "Work order <key> no longer in VISUAL". A leg that comes back resets to 0. When no leg is gone any more the flag goes and the log says "Work order back in VISUAL". The feed never removes a packet for this.
|
||||||
8. **Closed and cancelled.** Each leg's status code is stored, with ticket hours and the time observed. When every leg is C, `visual_closed_at` is set and the log says "Closed in VISUAL (observed)". Any leg X sets flag `cancelled` and `visual_cancelled_at`. The flag goes when no leg is X. A cancelled draft is not removed.
|
8. **Closed and cancelled.** Each leg's status code is stored, with ticket hours and the time observed. When every leg is C, `visual_closed_at` is set. Any leg X sets flag `cancelled` and `visual_cancelled_at`. The flag goes when no leg is X. A cancelled draft is not removed.
|
||||||
|
Since change C5 (Gitea issue 31, built 2026-10-30) the feed also sets and clears the packet's close in VISUAL, `vclosed_at`, on a VISUAL packet that is not filed or removed. It is the one writer there; Clerical sets it only on hand packets.
|
||||||
|
- When every leg has a status and each is C or X, the feed sets `vclosed_at` to now, with `vclosed_by` null (an existing value is kept). The log says "Closed in VISUAL (observed)" and the run counts `closed_observed`. The Clerical board then moves the packet from "Ready to close" to "Ready for data book" by itself.
|
||||||
|
- When a leg shows open again (a status that is not C or X) on a packet with `vclosed_at` set, the feed clears `vclosed_at`, `vclosed_by` and `visual_closed_at`. The log says "Open again in VISUAL (observed)" with the open work orders as the note. No count.
|
||||||
|
- The feed does this in any status before filed, so a packet can come back from Q.C. already "Ready for data book". A filed packet is never touched.
|
||||||
|
- FILE on a VISUAL packet needs `vclosed_at` set and no leg open, on both routes (precondition `closed_in_visual`, see [POLICY.md](POLICY.md), "Filing").
|
||||||
|
- A leg flagged `wo_gone` keeps its last status. If that status was open, the packet cannot file. Asked on Gitea issue 31 on 2026-10-30: count a leg gone for 2 runs as closed, or let an Admin close it with a reason.
|
||||||
9. **Auto-release** when `auto_release` is on and the feed is not paused: up to `auto_release_max` (default 25) live drafts made by the feed with the drawing attached, no flags at all, and the rev confirmed by the drawing job. A draft whose heat map is unanswered gets "yes" when it has a real material line (not a sub-assembly, not n/a, not consumable) and "no" otherwise. Then action `vis_auto_release` moves it to the warehouse with an automatic warehouse stage row and a transition row. The policy's preconditions (heat map answered, drawing attached) still apply.
|
9. **Auto-release** when `auto_release` is on and the feed is not paused: up to `auto_release_max` (default 25) live drafts made by the feed with the drawing attached, no flags at all, and the rev confirmed by the drawing job. A draft whose heat map is unanswered gets "yes" when it has a real material line (not a sub-assembly, not n/a, not consumable) and "no" otherwise. Then action `vis_auto_release` moves it to the warehouse with an automatic warehouse stage row and a transition row. The policy's preconditions (heat map answered, drawing attached) still apply.
|
||||||
10. **Summary.** One audit row `feed.run` with the counts, and one `feed` event to the screens after commit.
|
10. **Summary.** One audit row `feed.run` with the counts, and one `feed` event to the screens after commit.
|
||||||
|
|
||||||
@@ -417,7 +423,7 @@ Flags live in `packets.flags`. A draft with any flag shows in Clerical's Flagged
|
|||||||
|
|
||||||
With both hand flags the text is "Drawing and BOM uploaded by hand - check it and send it on". Hand drafts keep their phase 2 text.
|
With both hand flags the text is "Drawing and BOM uploaded by hand - check it and send it on". Hand drafts keep their phase 2 text.
|
||||||
|
|
||||||
"Closed in VISUAL" is not a flag. It is an observed state (`visual_closed_at`), shown on the packet. Filing a Data Book packet needs it, as the `vclosed_if_databook` precondition says.
|
"Closed in VISUAL" is not a flag. It is an observed state, shown on the packet: `vclosed_at` when every leg is closed or cancelled, `visual_closed_at` when every leg is C. Filing a VISUAL packet needs `vclosed_at` and no open leg, on both routes, as the `closed_in_visual` precondition says (change C5).
|
||||||
|
|
||||||
### Hand uploads
|
### Hand uploads
|
||||||
|
|
||||||
@@ -506,7 +512,7 @@ The packet read model has a `labor` field from `visual_labor`. It joins the pack
|
|||||||
|
|
||||||
`labor` is null for a hand packet, or when no `visual_labor` row matches. The packet page shows the sum beside the station clock, as `VISUAL tickets 3.2 h`.
|
`labor` is null for a hand packet, or when no `visual_labor` row matches. The packet page shows the sum beside the station clock, as `VISUAL tickets 3.2 h`.
|
||||||
|
|
||||||
The station timer stays the clock of record (open question 1, default kept). The ticket hours are shown for comparison only. Nothing is computed from them, and they never change the clock. The screen reads them from PostgreSQL, never from VISUAL.
|
The station timer stays and is shown as before. Since change C1 (Gitea issue 1, built 2026-10-30) the ticket hours decide "over target" on the Project Management reports: ticket hours above the work-order target. A packet with no ticket hours is not over target. The ticket hours never change the clock. The screen reads them from PostgreSQL, never from VISUAL.
|
||||||
|
|
||||||
## What the screens read
|
## What the screens read
|
||||||
|
|
||||||
|
|||||||
+38
-30
@@ -145,7 +145,8 @@ Each phase ends with passing tests, docs updated (README, CHANGELOG, SECURITY.md
|
|||||||
| D1, D2, D4 | Deferred (envelope encryption, Key Vault) |
|
| D1, D2, D4 | Deferred (envelope encryption, Key Vault) |
|
||||||
| D3 | IT's (host disk encryption on docker-1) |
|
| D3 | IT's (host disk encryption on docker-1) |
|
||||||
| D5 | Closed, phase 4 (optional ClamAV) |
|
| D5 | Closed, phase 4 (optional ClamAV) |
|
||||||
| D6, D7 | Phase 10 (decided 2026-10-28, Gitea issues 7 and 8: nothing is purged and filed packets are archived to the QNAP share at filing; the one data book leaves out the history pages) |
|
| D6 | Phase 10 (decided 2026-10-28, Gitea issue 7: nothing is purged and filed packets are archived to the QNAP share at filing) |
|
||||||
|
| D7 | Closed 2026-10-30 (Gitea issue 8, change C6: the one data book goes to the customer and leaves out the history pages) |
|
||||||
| M1 | Closed, phase 2 |
|
| M1 | Closed, phase 2 |
|
||||||
| M2 | Closed, phase 7 |
|
| M2 | Closed, phase 7 |
|
||||||
| M3 | Closed, phase 9 |
|
| M3 | Closed, phase 9 |
|
||||||
@@ -181,7 +182,7 @@ Conventions: identity bigints except users (uuid = AD objectGUID); Postgres enum
|
|||||||
- **Inputs:** `Principal` (user, admin, support set, clerical_supervisor, shop dept to level map, station limits, session kind, or `system="visual-feed"`) and `PacketState` (status, dept, station, wh_skipped, routed_to, vclosed, removed, open COs, open shorts). Pure functions in `server/packetstation/policy/`, no I/O.
|
- **Inputs:** `Principal` (user, admin, support set, clerical_supervisor, shop dept to level map, station limits, session kind, or `system="visual-feed"`) and `PacketState` (status, dept, station, wh_skipped, routed_to, vclosed, removed, open COs, open shorts). Pure functions in `server/packetstation/policy/`, no I/O.
|
||||||
- **Custody** `holds()` is a direct port: draft CL; warehouse WH; ready and review dept lead or above; assigned and in_progress dept lead or above or works_on (worker in dept whose station limits include the station, and, tightened per the README, the assigned welder for start and submit); qc QC; complete CL; filed nobody.
|
- **Custody** `holds()` is a direct port: draft CL; warehouse WH; ready and review dept lead or above; assigned and in_progress dept lead or above or works_on (worker in dept whose station limits include the station, and, tightened per the README, the assigned welder for start and submit); qc QC; complete CL; filed nobody.
|
||||||
- **Action enum** with a rule per action: who, from and to status, dept change, CO hold, preconditions, signature meaning, reason required. Transitions: SEND_TO_WAREHOUSE, RELEASE_TO_SHOP, SEND_DRAFT_TO_QC, RETURN_TO_CLERICAL, WH_RELEASE, WH_SEND_BACK, SEND_BACK_TO_WAREHOUSE, ASSIGN, UNASSIGN, PULL_BACK, START, SUBMIT, RETURN_TO_STATION, SEND_TO_QC, ROUTE_FROM_REVIEW, QC_REJECT, QC_ACCEPT, QC_ACCEPT_AND_ROUTE, FILE, ADMIN_REOPEN. Edits: EDIT_BOM, WH_RECEIVE_SHORT (a narrow endpoint that replaces onlyShortLines), EDIT_HEATS, EDIT_WELDS, EDIT_CHECKLIST, EDIT_STAGE_FIELDS, CLOCK, EDIT_QC, SET_WH_STAGE, EDIT_DEPT, MOVE_UNIT, EDIT_WOS, EDIT_DOCUMENT_SET, EDIT_HEADER, REV_APPROVE (clerical supervisor), SET_VCLOSED, SET_DATABOOK_AT, DOC_ADD, DOC_REMOVE, CO_ISSUE, CO_APPROVE, CO_REJECT, CO_HOLD_TOGGLE, CO_VERIFY, ITP_*, VIS_* (worker only).
|
- **Action enum** with a rule per action: who, from and to status, dept change, CO hold, preconditions, signature meaning, reason required. Transitions: SEND_TO_WAREHOUSE, RELEASE_TO_SHOP, SEND_DRAFT_TO_QC, RETURN_TO_CLERICAL, WH_RELEASE, WH_SEND_BACK, SEND_BACK_TO_WAREHOUSE, ASSIGN, UNASSIGN, PULL_BACK, START, SUBMIT, RETURN_TO_STATION, SEND_TO_QC, ROUTE_FROM_REVIEW, QC_REJECT, QC_ACCEPT, QC_ACCEPT_AND_ROUTE, FILE, ADMIN_REOPEN. Edits: EDIT_BOM, WH_RECEIVE_SHORT (a narrow endpoint that replaces onlyShortLines), EDIT_HEATS, EDIT_WELDS, EDIT_CHECKLIST, EDIT_STAGE_FIELDS, CLOCK, EDIT_QC, SET_WH_STAGE, EDIT_DEPT, MOVE_UNIT, EDIT_WOS, EDIT_DOCUMENT_SET, EDIT_HEADER, REV_APPROVE (clerical supervisor), SET_VCLOSED, SET_DATABOOK_AT, DOC_ADD, DOC_REMOVE, CO_ISSUE, CO_APPROVE, CO_REJECT, CO_HOLD_TOGGLE, CO_VERIFY, ITP_*, VIS_* (worker only).
|
||||||
- **Preconditions** as named predicates, now server-side: heat_map_answered, drawing_attached, bom_all_pulled_or_short, welder_set, checklist_complete, welds_recorded, filler_set, qc_book_spool_location, qc_missing_empty, reason_given, vclosed_if_databook (warning only when work orders are still open in VISUAL; block only when vclosed is unset and the route is Data Book), co_staged_if_dwg_change.
|
- **Preconditions** as named predicates, now server-side: heat_map_answered, drawing_attached, bom_all_pulled_or_short, welder_set, checklist_complete, welds_recorded, filler_set, qc_book_spool_location, qc_missing_empty, reason_given, closed_in_visual (replaced vclosed_if_databook on 2026-10-30, change C5: a VISUAL packet files only when the feed has seen every work order closed, on both routes; a hand packet on the Data Book route needs the Clerical confirm), itp_complete (C3), shorts_approved (C7), ncr_given (C2), co_staged_if_dwg_change.
|
||||||
- **Order of checks** keeps the old semantics: removed, wrong status, CO hold (applies to admins too), admin bypass of custody with a required IT reason, filed lock (only SET_DATABOOK_AT and ADMIN_REOPEN), who and custody, then preconditions (never bypassed).
|
- **Order of checks** keeps the old semantics: removed, wrong status, CO hold (applies to admins too), admin bypass of custody with a required IT reason, filed lock (only SET_DATABOOK_AT and ADMIN_REOPEN), who and custody, then preconditions (never bypassed).
|
||||||
- **`can` on reads:** every packet read returns `can: [...]` (passes role, custody, status and preconditions), `blocked: {action: [reasons]}` (passes role and custody but fails a precondition or the CO hold, so the UI can show a disabled control with the reason), `holder`. Boards evaluate transitions only from header columns plus an open-CO count.
|
- **`can` on reads:** every packet read returns `can: [...]` (passes role, custody, status and preconditions), `blocked: {action: [reasons]}` (passes role and custody but fails a precondition or the CO hold, so the UI can show a disabled control with the reason), `holder`. Boards evaluate transitions only from header columns plus an open-CO count.
|
||||||
- **Signatures:** required for SUBMIT, SEND_TO_QC, QC_ACCEPT, QC_ACCEPT_AND_ROUTE, QC_REJECT, FILE, ROUTE_FROM_REVIEW, ADMIN_REOPEN, CO_ISSUE, CO_VERIFY, CO_APPROVE, CO_REJECT, REV_APPROVE, hand uploads, ITP_BLOCK_SIGN, ITP_SIGN_OFF. The body carries `sign: {factor, secret}`; the server verifies the factor against the session's own user inside the transaction (PIN allowed in any session on any device, as the brief says; password everywhere), logs the attempt, applies the five-strike PIN disable and the three-strike session end, and writes the signatures row with a content hash over what was signed.
|
- **Signatures:** required for SUBMIT, SEND_TO_QC, QC_ACCEPT, QC_ACCEPT_AND_ROUTE, QC_REJECT, FILE, ROUTE_FROM_REVIEW, ADMIN_REOPEN, CO_ISSUE, CO_VERIFY, CO_APPROVE, CO_REJECT, REV_APPROVE, hand uploads, ITP_BLOCK_SIGN, ITP_SIGN_OFF. The body carries `sign: {factor, secret}`; the server verifies the factor against the session's own user inside the transaction (PIN allowed in any session on any device, as the brief says; password everywhere), logs the attempt, applies the five-strike PIN disable and the three-strike session end, and writes the signatures row with a content hash over what was signed.
|
||||||
@@ -217,19 +218,19 @@ Conventions: identity bigints except users (uuid = AD objectGUID); Postgres enum
|
|||||||
|
|
||||||
Need an answer before the named phase. Defaults apply if none arrives in time.
|
Need an answer before the named phase. Defaults apply if none arrives in time.
|
||||||
|
|
||||||
1. **Station timer** (Seth, before phase 5): keep, cross-check against labor tickets, or drop. Default: keep the timer, show ticket hours beside it. Phase 5 (2026-10-16) applied the default: the station timer is the clock of record and the VISUAL ticket hours are shown beside it. Decided by SethD on 2026-10-26, Gitea issue 1: keep the timer as built, but flag over target on the VISUAL ticket hours (change C1 below).
|
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. Decided by SethD on 2026-10-26, Gitea issue 1: keep the timer as built, but flag over target on the VISUAL ticket hours (change C1 below). Built on 2026-10-30.
|
||||||
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.
|
2. **PDFs on the server** (confirm before phase 7): reportlab + pypdf + pypdfium2. Default: yes. Phase 7 (2026-10-17) applied the default: reportlab 5.0.1, pypdf and pypdfium2, with Pillow 13.0.0 for the rasters.
|
||||||
3. **Frontend stack** (confirm before phase 1): TypeScript + React + Vite. Default: yes. Plain JavaScript would not be faster for parity: the old 7,000 lines are screen code that must be rewritten either way, and typed API models cut rework.
|
3. **Frontend stack** (confirm before phase 1): TypeScript + React + Vite. Default: yes. Plain JavaScript would not be faster for parity: the old 7,000 lines are screen code that must be rewritten either way, and typed API models cut rework.
|
||||||
4. **PIN defaults** (confirm): 6 to 12 digits, five attempts, 10-hour shop session, device cookie. Was 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. Confirmed by SethD on 2026-10-26, Gitea issue 2: the PIN is enough for every sign-off, Q.C. accept and filing included. IT: device certificate later. The device cookie stays and no device certificate is planned: confirmed by Spencer (IT) on 2026-10-18, Gitea issue 3.
|
4. **PIN defaults** (confirm): 6 to 12 digits, five attempts, 10-hour shop session, device cookie. Was 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. Confirmed by SethD on 2026-10-26, Gitea issue 2: the PIN is enough for every sign-off, Q.C. accept and filing included. IT: device certificate later. The device cookie stays and no device certificate is planned: confirmed by Spencer (IT) on 2026-10-18, Gitea issue 3.
|
||||||
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. SethD on 2026-10-28, Gitea issue 4: the order should follow the shop schedule, linked live so a job that is moved, pushed or pulled up changes what its packets show. That needs a discussion first; the rule stays as built until then.
|
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. SethD on 2026-10-28, Gitea issue 4: the order should follow the shop schedule, linked live so a job that is moved, pushed or pulled up changes what its packets show. That needs a discussion first; the rule stays as built until then. SethD on 2026-10-29: the schedule is Presley's Production Planner, which the whole shop works from today. Asked on issue 4 (2026-10-30): what it is, where it lives, what a row holds and how often it changes. Packet Station would only read it.
|
||||||
6. **VISUAL_ENCRYPT** (IT, before production): yes once vsql has a trusted certificate; `no` until then.
|
6. **VISUAL_ENCRYPT** (IT, before production): yes once vsql has a trusted certificate; `no` until then.
|
||||||
7. **Policy tightenings** listed under "Challenges" 1 to 3 (Seth, before phase 2). Confirmed by SethD on 2026-10-26, Gitea issue 6: "good as shown". Every row is kept, filing for packets routed to Shipping included (Spencer read the answer that way on 2026-10-27).
|
7. **Policy tightenings** listed under "Challenges" 1 to 3 (Seth, before phase 2). Confirmed by SethD on 2026-10-26, Gitea issue 6: "good as shown". Every row is kept, filing for packets routed to Shipping included (Spencer read the answer that way on 2026-10-27).
|
||||||
8. **Repo location**: PacketStation2 as proposed, or another name.
|
8. **Repo location**: PacketStation2 as proposed, or another name.
|
||||||
9. **Retention period** (D6) and customer-facing data book copy (D7): deferred, but a decision from Seth and Q.C. lets a later phase close them. Gitea issue 7: SethD expects all data to be kept (2026-10-26). Spencer decided on 2026-10-27 that project files are also archived to an NFS share he sets up on the QNAP, with an Admin setting for the folder path that checks read and write (phase 10, "Archive location"). Nothing is deleted from Packet Station; the archive is a copy. Still open on issue 7: a minimum retention period, if any, and what goes into the archive and when. Decided on 2026-10-28: all data is kept and nothing is purged; at filing the data book and each of the packet's documents go to the archive with a manifest (SethD, Gitea issue 7). There is one data book, the customer copy, without the history pages (SethD, Gitea issue 8, change C6).
|
9. **Retention period** (D6) and customer-facing data book copy (D7): deferred, but a decision from Seth and Q.C. lets a later phase close them. Gitea issue 7: SethD expects all data to be kept (2026-10-26). Spencer decided on 2026-10-27 that project files are also archived to an NFS share he sets up on the QNAP, with an Admin setting for the folder path that checks read and write (phase 10, "Archive location"). Nothing is deleted from Packet Station; the archive is a copy. Still open on issue 7: a minimum retention period, if any, and what goes into the archive and when. Decided on 2026-10-28: all data is kept and nothing is purged; at filing the data book and each of the packet's documents go to the archive with a manifest (SethD, Gitea issue 7). There is one data book, the customer copy, without the history pages (SethD, Gitea issue 8, change C6). C6 was built on 2026-10-30, which closes D7. D6 (the archive) is phase 10.
|
||||||
|
|
||||||
Smaller questions from the design, with the default the build takes unless told otherwise:
|
Smaller questions from the design, with the default the build takes unless told otherwise:
|
||||||
|
|
||||||
10. Same-rev drawing re-release (new file, same rev) on an in-shop packet: default a drawing-origin change order, not a silent swap.
|
10. Same-rev drawing re-release (new file, same rev) on an in-shop packet: default a drawing-origin change order, not a silent swap. Confirmed by SethD on 2026-10-29, Gitea issue 9: keep the change order as built, so the shop confirms the new pages. A byte-identical file is still ignored.
|
||||||
11. Cancelled (X) work orders: default flag only, like gone; drafts are not removed. SethD on 2026-10-26, Gitea issue 10: the default is fine, left to Spencer. Kept as built.
|
11. Cancelled (X) work orders: default flag only, like gone; drafts are not removed. SethD on 2026-10-26, Gitea issue 10: the default is fine, left to Spencer. Kept as built.
|
||||||
12. Partspecs, title or folded-operation target changes on an in-shop packet: default no change order, as the old build. Confirmed by SethD on 2026-10-28, Gitea issue 11: only a new drawing rev, a quantity change or a removed operation opens a change order.
|
12. Partspecs, title or folded-operation target changes on an in-shop packet: default no change order, as the old build. Confirmed by SethD on 2026-10-28, Gitea issue 11: only a new drawing rev, a quantity change or a removed operation opens a change order.
|
||||||
13. Work-order-gone confirmation after 2 consecutive runs and the 10% suspect-batch guard: defaults as stated. SethD left both numbers to Spencer on 2026-10-26, Gitea issue 12. The defaults stay unless Spencer changes them.
|
13. Work-order-gone confirmation after 2 consecutive runs and the 10% suspect-batch guard: defaults as stated. SethD left both numbers to Spencer on 2026-10-26, Gitea issue 12. The defaults stay unless Spencer changes them.
|
||||||
@@ -243,7 +244,7 @@ From phase 4 (2026-10-16), for Seth to veto:
|
|||||||
|
|
||||||
19. The release gate has two server rules: every line not N/A is pulled or on an open short, and every pulled line that issued pieces has a heat unless it is N/A or a sub-assembly. The old build enforced both on the screen only. Default: both on the server. Confirmed by SethD on 2026-10-26, Gitea issue 16: both stay, so the warehouse fills the heat map for the parts it sends.
|
19. The release gate has two server rules: every line not N/A is pulled or on an open short, and every pulled line that issued pieces has a heat unless it is N/A or a sub-assembly. The old build enforced both on the screen only. Default: both on the server. Confirmed by SethD on 2026-10-26, Gitea issue 16: both stay, so the warehouse fills the heat map for the parts it sends.
|
||||||
20. Hold and short reasons: the old build accepted any text. Default: closed sets on the server; "Other" on a hold needs a note. Confirmed by SethD on 2026-10-28, Gitea issue 17: keep the lists.
|
20. Hold and short reasons: the old build accepted any text. Default: closed sets on the server; "Other" on a hold needs a note. Confirmed by SethD on 2026-10-28, Gitea issue 17: keep the lists.
|
||||||
21. Receiving a short on a packet the warehouse no longer holds: default it stays a warehouse right (WH_RECEIVE_SHORT, any status but filed), and the holder of the packet (department lead or supervisor, Q.C., Clerical) can receive too, through edit_bom. Changed by SethD on 2026-10-28, Gitea issue 18 (change C7).
|
21. Receiving a short on a packet the warehouse no longer holds: default it stays a warehouse right (WH_RECEIVE_SHORT, any status but filed), and the holder of the packet (department lead or supervisor, Q.C., Clerical) can receive too, through edit_bom. Changed by SethD on 2026-10-28, Gitea issue 18 (change C7). Built on 2026-10-30: receiving works as before, and the department supervisor then approves each receipt.
|
||||||
22. Upload kinds and limits: default an MTR is PDF, PNG or JPG up to 25 MB, a hand BOM is XLSX or CSV up to 5 MB. ClamAV is optional and off unless CLAMAV_HOST is set; when set, an unreachable scanner refuses uploads (fail closed). Confirmed by Spencer (IT) on 2026-10-18, Gitea issue 19: the limits stay, and ClamAV stays off in both stacks because every workstation that uses Packet Station runs Microsoft Defender. The `clamav` profile stays in the build, unused.
|
22. Upload kinds and limits: default an MTR is PDF, PNG or JPG up to 25 MB, a hand BOM is XLSX or CSV up to 5 MB. ClamAV is optional and off unless CLAMAV_HOST is set; when set, an unreachable scanner refuses uploads (fail closed). Confirmed by Spencer (IT) on 2026-10-18, Gitea issue 19: the limits stay, and ClamAV stays off in both stacks because every workstation that uses Packet Station runs Microsoft Defender. The `clamav` profile stays in the build, unused.
|
||||||
23. The quota alert: default a `notification_events` row (phase 9 builds the delivery on this table) at 85 percent of the assets volume. Confirmed by Spencer (IT) on 2026-10-18, Gitea issue 20: 85 percent stays, and the alert goes to PS-Admins. Phase 9 seeds the `assets_quota` notification rule with PS-Admins as its recipient.
|
23. The quota alert: default a `notification_events` row (phase 9 builds the delivery on this table) at 85 percent of the assets volume. Confirmed by Spencer (IT) on 2026-10-18, Gitea issue 20: 85 percent stays, and the alert goes to PS-Admins. Phase 9 seeds the `assets_quota` notification rule with PS-Admins as its recipient.
|
||||||
|
|
||||||
@@ -251,32 +252,32 @@ From phase 5 (2026-10-16), for Seth to veto:
|
|||||||
|
|
||||||
24. Carried marks: when a packet is routed to another department, the welds, heat tags and heat table stay on the packet. The read model computes a `carried` flag. Nothing is copied. Default: computed, not copied. Confirmed by SethD on 2026-10-26, Gitea issue 21: the marks stay with the packet as it moves on, and a department that receives the packet (for example weld assembly after parts weld) adds its welds to the same weld map, so the data book carries one map.
|
24. Carried marks: when a packet is routed to another department, the welds, heat tags and heat table stay on the packet. The read model computes a `carried` flag. Nothing is copied. Default: computed, not copied. Confirmed by SethD on 2026-10-26, Gitea issue 21: the marks stay with the packet as it moves on, and a department that receives the packet (for example weld assembly after parts weld) adds its welds to the same weld map, so the data book carries one map.
|
||||||
25. The old build's manual "copy marks to the new sheet" button on a change order is not ported. On a page replacement only the heat table placement is copied to the new page. Welds and heat tags stay on the VOID page. Default: not ported in phase 5; a follow-up. Confirmed by SethD on 2026-10-26, Gitea issue 22: not ported. After a change order the department re-verifies the work, since a change can alter what was marked.
|
25. The old build's manual "copy marks to the new sheet" button on a change order is not ported. On a page replacement only the heat table placement is copied to the new page. Welds and heat tags stay on the VOID page. Default: not ported in phase 5; a follow-up. Confirmed by SethD on 2026-10-26, Gitea issue 22: not ported. After a change order the department re-verifies the work, since a change can alter what was marked.
|
||||||
26. Checklist templates live in the `checklist_templates` settings row, with the old lists as defaults. Default: no screen edits them in phase 5; Admin editing comes in phase 9. Changed by SethD on 2026-10-26 and 2026-10-28, Gitea issue 23 (change C8).
|
26. Checklist templates live in the `checklist_templates` settings row, with the old lists as defaults. Default: no screen edits them in phase 5; Admin editing comes in phase 9. Changed by SethD on 2026-10-26 and 2026-10-28, Gitea issue 23 (change C8). Built on 2026-10-30: the `checklist_templates` row now holds the default lists by kind, used by a department that has no list of its own.
|
||||||
|
|
||||||
From phase 6 (2026-10-17), for Seth and Q.C. to veto:
|
From phase 6 (2026-10-17), for Seth and Q.C. to veto:
|
||||||
|
|
||||||
27. Q.C. reject: the old build took any non-empty reason. Default: a reason of at least 8 characters, or a well-formed NCR number (`NCR-YYYY-NNNN`) in its place, or both. The NCR is stored on the inspection and linked to pesco-ncr. Applied in phase 6. Changed by SethD on 2026-10-26, Gitea issue 24 (change C2 below).
|
27. Q.C. reject: the old build took any non-empty reason. Default: a reason of at least 8 characters, or a well-formed NCR number (`NCR-YYYY-NNNN`) in its place, or both. The NCR is stored on the inspection and linked to pesco-ncr. Applied in phase 6. Changed by SethD on 2026-10-26, Gitea issue 24 (change C2 below). Built on 2026-10-30.
|
||||||
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. Changed by SethD on 2026-10-26, Gitea issue 25 (change C3 below).
|
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. Changed by SethD on 2026-10-26, Gitea issue 25 (change C3 below). Built on 2026-10-30: ITP writes still never look at a packet, but FILE waits until the unit's ITP is complete.
|
||||||
29. ITP status: the 0003 enum had `open`, `signed` and `reopened`. Default: `itp_status` gains `complete` for a signed-off ITP; a reopened ITP is `open` again with the reopen recorded. `signed` and `reopened` stay in the enum unused, since PostgreSQL cannot drop an enum value. Applied in phase 6.
|
29. ITP status: the 0003 enum had `open`, `signed` and `reopened`. Default: `itp_status` gains `complete` for a signed-off ITP; a reopened ITP is `open` again with the reopen recorded. `signed` and `reopened` stay in the enum unused, since PostgreSQL cannot drop an enum value. Applied in phase 6.
|
||||||
30. Removing an ITP block signature: the old build let any Q.C. editor undo it. Default: the signer or an admin, while the ITP is open. The block's QN, remarks and customer fields stay. Applied in phase 6. Confirmed by SethD on 2026-10-26, Gitea issue 26: a fix to customer fields or an earlier step's remarks goes back to that step or to Clerical.
|
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. Confirmed by SethD on 2026-10-26, Gitea issue 26: a fix to customer fields or an earlier step's remarks goes back to that step or to Clerical.
|
||||||
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.
|
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:
|
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. Changed by SethD on 2026-10-26, Gitea issue 27 (change C4 below).
|
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. Changed by SethD on 2026-10-26, Gitea issue 27 (change C4 below). Built on 2026-10-30.
|
||||||
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. Confirmed by SethD on 2026-10-26, Gitea issue 28.
|
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. Confirmed by SethD on 2026-10-26, Gitea issue 28.
|
||||||
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. Confirmed by SethD on 2026-10-26, Gitea issue 29: everyone may view the whole packet; only the holder edits, and missing work goes back to the department that owes it.
|
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. Confirmed by SethD on 2026-10-26, Gitea issue 29: everyone may view the whole packet; only the holder edits, and missing work goes back to the department that owes it.
|
||||||
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. Confirmed by SethD on 2026-10-28, Gitea issue 30: nothing is missed.
|
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. Confirmed by SethD on 2026-10-28, Gitea issue 30: nothing is missed.
|
||||||
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. Changed by SethD on 2026-10-26, Gitea issue 31 (change C5 below).
|
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. Changed by SethD on 2026-10-26, Gitea issue 31 (change C5 below). Built on 2026-10-30: `confirm_open_wos` is gone and filing is blocked. Two follow-up questions were asked on issue 31 on 2026-10-30 (see C5).
|
||||||
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. Changed by SethD on 2026-10-28, Gitea issue 32 (change C9).
|
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. Changed by SethD on 2026-10-28, Gitea issue 32 (change C9). Built on 2026-10-30.
|
||||||
|
|
||||||
From phase 8 (2026-10-18), for Seth to veto. The phase 8 design numbered these 35 to 39; 35 to 37 were already taken by phase 7, so they follow on from 38.
|
From phase 8 (2026-10-18), for Seth to veto. The phase 8 design numbered these 35 to 39; 35 to 37 were already taken by phase 7, so they follow on from 38.
|
||||||
|
|
||||||
38. Hours vs target: the station timer stays the clock of record. "Over target" means timer hours over the work-order target. VISUAL ticket hours are shown beside the timer in every hours table and never decide the flag. Phase 8 (2026-10-18) applied the default. Changed by SethD on 2026-10-26, Gitea issue 1 (change C1 below).
|
38. Hours vs target: the station timer stays the clock of record. "Over target" means timer hours over the work-order target. VISUAL ticket hours are shown beside the timer in every hours table and never decide the flag. Phase 8 (2026-10-18) applied the default. Changed by SethD on 2026-10-26, Gitea issue 1 (change C1 below). Built on 2026-10-30.
|
||||||
39. PM exports and who reads the reports: every PM export is logged as `report exported` in the audit and in `print_log`, with the scope and the period. Default: any signed-in role may read the PM reports (as every role may open every packet), not only PS-ProjectMgmt. Phase 8 (2026-10-18) applied the default. Changed by SethD on 2026-10-28, Gitea issue 33 (change C10).
|
39. PM exports and who reads the reports: every PM export is logged as `report exported` in the audit and in `print_log`, with the scope and the period. Default: any signed-in role may read the PM reports (as every role may open every packet), not only PS-ProjectMgmt. Phase 8 (2026-10-18) applied the default. Changed by SethD on 2026-10-28, Gitea issue 33 (change C10). Built on 2026-10-30.
|
||||||
40. Presence shows the display name and the role (the screen the person has open). Default: locked sessions drop out of presence at once; closing the tab drops out within the stream's ping cycle (25 seconds). Phase 8 (2026-10-18) applied the default. Confirmed by SethD on 2026-10-28, Gitea issue 34.
|
40. Presence shows the display name and the role (the screen the person has open). Default: locked sessions drop out of presence at once; closing the tab drops out within the stream's ping cycle (25 seconds). Phase 8 (2026-10-18) applied the default. Confirmed by SethD on 2026-10-28, Gitea issue 34.
|
||||||
41. The find bar filters are applied by the server on every board and list, so a board never fetches more than it shows. Default: the KPI numbers follow the project pick only, not the other filters. Phase 8 (2026-10-18) applied the default. Confirmed by SethD on 2026-10-28, Gitea issue 35.
|
41. The find bar filters are applied by the server on every board and list, so a board never fetches more than it shows. Default: the KPI numbers follow the project pick only, not the other filters. Phase 8 (2026-10-18) applied the default. Confirmed by SethD on 2026-10-28, Gitea issue 35.
|
||||||
42. Mine is decided from the person's AD roles and the screen they are on. Default: the stall's Mine needs the picked station and shows nothing without one. Phase 8 (2026-10-18) applied the default. Changed by SethD on 2026-10-28, Gitea issue 36 (change C11).
|
42. Mine is decided from the person's AD roles and the screen they are on. Default: the stall's Mine needs the picked station and shows nothing without one. Phase 8 (2026-10-18) applied the default. Changed by SethD on 2026-10-28, Gitea issue 36 (change C11). Built on 2026-10-30.
|
||||||
|
|
||||||
From phase 9 (2026-10-21), for Seth or Spencer to veto:
|
From phase 9 (2026-10-21), for Seth or Spencer to veto:
|
||||||
|
|
||||||
@@ -289,24 +290,31 @@ From phase 9 (2026-10-21), for Seth or Spencer to veto:
|
|||||||
49. New departments: default only shop departments can be added in the app (422 `support_fixed` for a support department). The support roles are fixed in the `role_kind` enum and the group map, so a new support department would map to no role. Phase 9 (2026-10-21) applied the default.
|
49. New departments: default only shop departments can be added in the app (422 `support_fixed` for a support department). The support roles are fixed in the `role_kind` enum and the group map, so a new support department would map to no role. Phase 9 (2026-10-21) applied the default.
|
||||||
50. The profile CSV `stations` cell: default the cell is the person's whole list of station limits across departments and replaces every limit they had; `-` clears them all; an empty cell leaves them. Phase 9 (2026-10-21) applied the default.
|
50. The profile CSV `stations` cell: default the cell is the person's whole list of station limits across departments and replaces every limit they had; `-` clears them all; an empty cell leaves them. Phase 9 (2026-10-21) applied the default.
|
||||||
51. A direct welder assign: default it does not check `profile_active`, so an Admin's inactive switch hides a person from the picker but a typed assign still works. Phase 9 (2026-10-21) applied the default; say if an inactive profile should be refused there too.
|
51. A direct welder assign: default it does not check `profile_active`, so an Admin's inactive switch hides a person from the picker but a typed assign still works. Phase 9 (2026-10-21) applied the default; say if an inactive profile should be refused there too.
|
||||||
52. A checklist item added while a stage is open: default it applies to that open stage too, because completeness reads the live template, so the item must be ticked before submit. Phase 9 (2026-10-21) applied the default. Replaced by change C8: a template change applies to new packets only.
|
52. A checklist item added while a stage is open: default it applies to that open stage too, because completeness reads the live template, so the item must be ticked before submit. Phase 9 (2026-10-21) applied the default. Replaced by change C8: a template change applies to new packets only. Built on 2026-10-30: a stage freezes its list at START, so a change reaches only stages not yet started.
|
||||||
|
|
||||||
Changes decided by SethD on 2026-10-26, to build (phase 10 unless a later plan moves them). Each replaces a default above:
|
Changes decided by SethD on 2026-10-26. Each replaces a default above. All five were built on 2026-10-30 (branch `seth-changes`):
|
||||||
|
|
||||||
- C1 (issue 1, Q1 and Q38): the station timer stays and is shown as today, but "over target" is decided by the VISUAL ticket hours against the work-order target, not by the timer.
|
- C1 (issue 1, Q1 and Q38): the station timer stays and is shown as today, but "over target" is decided by the VISUAL ticket hours against the work-order target, not by the timer. Built 2026-10-30: `domain/pm.py` `is_over()` flags a packet when its ticket hours are above a target above zero, so a packet with no ticket hours is never over. The hours tables, the "Packets over target" table, the KPI, the summary line, the CSV and the report PDF follow, with columns named "Ticket h / target" and "Over target (tickets)". The timer column stays.
|
||||||
- C2 (issue 24, Q27): a Q.C. reject always needs a detailed reason; an NCR number no longer stands in for it. A rejected item cannot be accepted until an NCR number is entered. Q.C. can upload the NCR document to the packet, and it goes into the data book.
|
- C2 (issue 24, Q27): a Q.C. reject always needs a detailed reason; an NCR number no longer stands in for it. A rejected item cannot be accepted until an NCR number is entered. Q.C. can upload the NCR document to the packet, and it goes into the data book. Built 2026-10-30: QC_REJECT needs a reason of 8 characters and may also carry an NCR number. When a reject since the last accept has no NCR number, QC_ACCEPT and QC_ACCEPT_AND_ROUTE need one in the request (precondition `ncr_given`, 422 `ncr_required`); the number is stored on the accepting inspection and in the log. The packet read has `ncr_required`, and the Q.C. bar shows the NCR field as required. An `ncr` packet document is in section 5 of the data book.
|
||||||
- C3 (issue 25, Q28): a packet whose unit has an ITP that is not complete cannot be filed, so no data book is built. The packet goes back to have the ITP completed.
|
- C3 (issue 25, Q28): a packet whose unit has an ITP that is not complete cannot be filed, so no data book is built. The packet goes back to have the ITP completed. Built 2026-10-30: precondition `itp_complete` on FILE. An ITP that is not complete (open, signed or reopened) blocks; a unit with no ITP does not. The reason shows under File in the close panel.
|
||||||
- C4 (issue 27, Q32): prints go straight to the browser's print dialog from the page, as the old build did, with no new tab. The stamped pages stay the same.
|
- C4 (issue 27, Q32): prints go straight to the browser's print dialog from the page, as the old build did, with no new tab. The stamped pages stay the same. Built 2026-10-30: every print in the app goes through `frontend/src/lib/printPdf.ts`, which renders the stamped PDF with pdf.js into a hidden print area and opens the print dialog. Viewing a PDF (data book, traveler, ITP PDF, PM report) still opens a tab. The server did not change.
|
||||||
- C5 (issue 31, Q36): filing is blocked, not warned, while VISUAL still shows a work order open. The packet waits in a Clerical "Ready to close" column until the feed sees VISUAL closed, then moves to "Ready for data book". This applies to both routes unless Seth says Shipping is different.
|
- C5 (issue 31, Q36): filing is blocked, not warned, while VISUAL still shows a work order open. The packet waits in a Clerical "Ready to close" column until the feed sees VISUAL closed, then moves to "Ready for data book". This applies to both routes unless Seth says Shipping is different. Built 2026-10-30: precondition `closed_in_visual` replaces `vclosed_if_databook`. On a VISUAL packet the feed is the one writer of `vclosed_at`: it sets it when every leg is closed or cancelled and clears it when a leg shows open again, so the Clerical board moves the packet by itself. FILE needs it and no open leg, on both routes. A hand packet has no feed state, so Clerical still confirms the close by hand (SET_VCLOSED, Data Book route only); SET_VCLOSED is refused on a VISUAL packet. `confirm_open_wos` and the 409 `open_work_orders` are gone.
|
||||||
|
Asked on issue 31 on 2026-10-30, open until answered: (a) a work order deleted from VISUAL while open keeps its last status, so its packet can never file. Should a leg gone for 2 runs count as closed, or should an Admin close it with a reason? (b) Shipping packets now wait for the VISUAL close too. Is that right for Shipping, or should Shipping file without it? Until Seth answers, both stay as built.
|
||||||
|
|
||||||
Changes decided by SethD on 2026-10-28, to build in phase 10:
|
Changes decided by SethD on 2026-10-28. All six were built on 2026-10-30 (branch `seth-changes`):
|
||||||
|
|
||||||
- C6 (issue 8, Q9, D7): the data book leaves out the packet history pages. It is the one copy, and it goes to the customer. History stays readable in Packet Station.
|
- C6 (issue 8, Q9, D7): the data book leaves out the packet history pages. It is the one copy, and it goes to the customer. History stays readable in Packet Station. Built 2026-10-30: section 7 "Record history" is gone and the data book has six sections. The change-order table it held moved to a "Change order record" page at the end of section 1, only when the packet has change orders. Say if that page should go too.
|
||||||
- C7 (issue 18, Q21): every received short also needs the department supervisor's approval, one short at a time. The supervisor checks the part and its heat number against what the warehouse issued. A packet cannot be marked complete until every short is received and approved.
|
- C7 (issue 18, Q21): every received short also needs the department supervisor's approval, one short at a time. The supervisor checks the part and its heat number against what the warehouse issued. A packet cannot be marked complete until every short is received and approved. Built 2026-10-30: migration 0011 adds `short_approvals` (append-only, one row per receipt). New action SHORT_APPROVE: the supervisor of the packet's current department (a lead is not enough), Admin through the bypass, any status but filed, signature "Approve a received short", at `POST /api/packets/{id}/shorts/receipts/{receipt_id}/approve`. Precondition `shorts_approved` on QC_ACCEPT and FILE, not on QC_ACCEPT_AND_ROUTE. The Materials tab shows each receipt with who received it and who approved it. There is no list of receipts to approve on the supervisor board yet.
|
||||||
- C8 (issue 23, Q26 and Q52): checklists become per department. Each department supervisor edits their own department's checklist, Admin edits any, and Q.C. does not edit department checklists. A change applies to packets not yet started, never to packets already started.
|
- C8 (issue 23, Q26 and Q52): checklists become per department. Each department supervisor edits their own department's checklist, Admin edits any, and Q.C. does not edit department checklists. A change applies to packets not yet started, never to packets already started. Built 2026-10-30: a department's own list is the settings row `checklist:<code>`; without one it uses the default for its kind (the weld or plain list in `checklist_templates`). `GET /api/checklists`, `GET` and `PUT /api/checklists/{dept}` with version compare-and-set. The department's Supervisor (not a lead) and Admin may save, in a password session. `packet_stages.checklist_template` (migration 0011) freezes the list at START, and every reader uses the frozen list when set. New screen `/checklists`.
|
||||||
- C9 (issue 32, Q37): the stamp check is open to Admin, Q.C. and Clerical.
|
- C9 (issue 32, Q37): the stamp check is open to Admin, Q.C. and Clerical. Built 2026-10-30: `POST /api/stamp-verify` and `POST /api/stamp-verify/pdf` for Admin and every Q.C. and Clerical level, in a password session. The `/api/admin` paths stay as aliases. New screen `/stamp-check`.
|
||||||
- C10 (issue 33, Q39): the PM screen and its reports are for PS-ProjectMgmt and Admin only.
|
- C10 (issue 33, Q39): the PM screen and its reports are for PS-ProjectMgmt and Admin only. Built 2026-10-30: every `/api/pm/*` endpoint is 403 for anyone else. No other screen calls `/api/pm`. The PM screen entry shows only for those roles.
|
||||||
- C11 (issue 36, Q42): the Stall screen shows a welder the packets assigned to him, each at the stall the supervisor picked when assigning. The welder does not pick a stall. Moving a welder to another stall is a re-assign by the supervisor.
|
- C11 (issue 36, Q42): the Stall screen shows a welder the packets assigned to him, each at the stall the supervisor picked when assigning. The welder does not pick a stall. Moving a welder to another stall is a re-assign by the supervisor. Built 2026-10-30: `GET /api/stall/mine` lists the packets assigned to the person (assigned, in progress, in review), each with the station the supervisor picked, across stations. The Stall screen has no station picker, and the stall's Mine no longer needs a station. Someone with nothing assigned sees "Your supervisor assigns packets to you."
|
||||||
|
|
||||||
|
Known edges of C1 to C11 at deploy:
|
||||||
|
|
||||||
|
- Packets in flight. Every short receipt recorded before the deploy has no approval, so the supervisor approves it before the next Q.C. accept, or before filing a packet that is already complete. A Q.C. reject since the last accept that has no NCR number needs one at the next accept. Nothing is backfilled.
|
||||||
|
- Stages started before migration 0011. The migration freezes the open stage of every packet in progress, in review or at Q.C. to the default list for its kind, from the `checklist_templates` row. A department edit does not reach those stages. When that row is missing, such a stage has no frozen list and reads its department's live list until it closes. A packet whose stages all closed before 0011 also reads the live list, so a later edit changes what its checklist shows.
|
||||||
|
- The feed sets `vclosed_at` in any status before filed, so a packet can come back from Q.C. straight into "Ready for data book".
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
+98
-43
@@ -90,9 +90,9 @@ Statuses in workflow order: draft, warehouse, ready, assigned, in_progress, revi
|
|||||||
| send_to_qc | DEPT_SUP | review | qc | no | yes | qc_book_spool_location | Send to Q.C. | |
|
| send_to_qc | DEPT_SUP | review | qc | no | yes | qc_book_spool_location | Send to Q.C. | |
|
||||||
| route_from_review | DEPT_SUP | review | ready | required | yes | target_dept_given | Route to another department | |
|
| route_from_review | DEPT_SUP | review | ready | required | yes | target_dept_given | Route to another department | |
|
||||||
| qc_reject | QC | qc | review | no | | reason_given | Q.C. reject | yes |
|
| qc_reject | QC | qc | review | no | | reason_given | Q.C. reject | yes |
|
||||||
| qc_accept | QC | qc | complete | no | yes | qc_missing_empty, route_given | Q.C. accept | |
|
| qc_accept | QC | qc | complete | no | yes | qc_missing_empty, shorts_approved, route_given, ncr_given | Q.C. accept | |
|
||||||
| qc_accept_and_route | QC | qc | ready | required | yes | qc_missing_empty, target_dept_given | Q.C. accept and route | |
|
| qc_accept_and_route | QC | qc | ready | required | yes | qc_missing_empty, target_dept_given, ncr_given | Q.C. accept and route | |
|
||||||
| file | CL | complete | filed | no | yes | vclosed_if_databook | File in the data book | |
|
| file | CL | complete | filed | no | yes | closed_in_visual, itp_complete, shorts_approved | File in the data book | |
|
||||||
| admin_reopen | ADMIN | filed (filed_ok) | complete | no | | reason_given | Reopen a filed packet | yes |
|
| admin_reopen | ADMIN | filed (filed_ok) | complete | no | | reason_given | Reopen a filed packet | yes |
|
||||||
| edit_header | CL | draft | | | | | | |
|
| edit_header | CL | draft | | | | | | |
|
||||||
| edit_drawing_ref | CL | any | | | | | | |
|
| edit_drawing_ref | CL | any | | | | | | |
|
||||||
@@ -101,6 +101,7 @@ Statuses in workflow order: draft, warehouse, ready, assigned, in_progress, revi
|
|||||||
| edit_wos | CL | draft | | | | | | |
|
| edit_wos | CL | draft | | | | | | |
|
||||||
| edit_bom | HOLDER | draft, warehouse, SHOP | | | | | | |
|
| edit_bom | HOLDER | draft, warehouse, SHOP | | | | | | |
|
||||||
| wh_receive_short | WH | any | | | | short_open | | |
|
| wh_receive_short | WH | any | | | | short_open | | |
|
||||||
|
| short_approve | DEPT_SUP | any | | | | | Approve a received short | |
|
||||||
| edit_heats | HOLDER | warehouse, SHOP | | | | | | |
|
| edit_heats | HOLDER | warehouse, SHOP | | | | | | |
|
||||||
| edit_heat_table | HOLDER | warehouse, SHOP | | | | | | |
|
| edit_heat_table | HOLDER | warehouse, SHOP | | | | | | |
|
||||||
| edit_welds | HOLDER | SHOP | | | | | | |
|
| edit_welds | HOLDER | SHOP | | | | | | |
|
||||||
@@ -113,7 +114,7 @@ Statuses in workflow order: draft, warehouse, ready, assigned, in_progress, revi
|
|||||||
| doc_add | CL_OR_QC | any | | | | | | |
|
| doc_add | CL_OR_QC | any | | | | | | |
|
||||||
| doc_remove | CL_OR_QC | any | | | | | | |
|
| doc_remove | CL_OR_QC | any | | | | | | |
|
||||||
| rev_approve | CL_SUP | draft | | | | reason_given | Approve the M-Files revision | yes |
|
| rev_approve | CL_SUP | draft | | | | reason_given | Approve the M-Files revision | yes |
|
||||||
| set_vclosed | CL | complete, only when routed_to is databook | | | | | | |
|
| set_vclosed | CL | complete, only for a hand packet routed to the data book | | | | | | |
|
||||||
| set_databook_at | CL_OR_QC | any (filed_ok) | | | | | | |
|
| set_databook_at | CL_OR_QC | any (filed_ok) | | | | | | |
|
||||||
| remove | CL | draft | | | | reason_given | | yes |
|
| remove | CL | draft | | | | reason_given | | yes |
|
||||||
| co_issue | CL | any | | | | | Issue a change order | |
|
| co_issue | CL | any | | | | | Issue a change order | |
|
||||||
@@ -141,7 +142,11 @@ The `vis_*` actions are system only. People can never do them, and the worker ca
|
|||||||
|
|
||||||
The Q.C. folder and ITP actions ("no packet (scoped)") belong to a project or a unit, not to a packet. They are checked with `check_scoped()` (see "Scoped checks"), never with the packet status, custody or the filed lock.
|
The Q.C. folder and ITP actions ("no packet (scoped)") belong to a project or a unit, not to a packet. They are checked with `check_scoped()` (see "Scoped checks"), never with the packet status, custody or the filed lock.
|
||||||
|
|
||||||
qc_reject takes an NCR number in place of the reason text (see "Q.C. (phase 6)", "Reject").
|
qc_reject always needs a reason of 8 characters and may also carry an NCR number. After a reject with no NCR number, qc_accept and qc_accept_and_route carry one (Gitea issue 24, change C2; see "Q.C. (phase 6)", "Reject").
|
||||||
|
|
||||||
|
short_approve is the department supervisor's approval of one received short (Gitea issue 18, change C7; see "Supervisor approval of received shorts"). A lead is not enough. The filed lock applies, so it is open in any status but filed.
|
||||||
|
|
||||||
|
set_vclosed is for hand packets only. On a VISUAL packet the feed sets the close (Gitea issue 31, change C5; see "Close in VISUAL").
|
||||||
|
|
||||||
## Preconditions
|
## Preconditions
|
||||||
|
|
||||||
@@ -161,29 +166,34 @@ Each precondition is a named predicate over the packet state and the request inp
|
|||||||
| qc_book_spool_location (input) | book and spool confirmed and a location given | Confirm the book and the spool, and give the location. |
|
| qc_book_spool_location (input) | book and spool confirmed and a location given | Confirm the book and the spool, and give the location. |
|
||||||
| qc_missing_empty | the Q.C. record has nothing missing | Q.C. still needs: (the list). |
|
| qc_missing_empty | the Q.C. record has nothing missing | Q.C. still needs: (the list). |
|
||||||
| reason_given (input) | a reason of at least 8 characters after cleaning | Give a reason of at least 8 characters. |
|
| reason_given (input) | a reason of at least 8 characters after cleaning | Give a reason of at least 8 characters. |
|
||||||
| vclosed_if_databook | routed to Shipping, or closed in VISUAL | Close the job in VISUAL first. |
|
| closed_in_visual | VISUAL packet, either route: the feed shows no work order open and has set `vclosed`. Hand packet: routed to Shipping, or Clerical confirmed the close | Hand packet: Close the job in VISUAL first. VISUAL packet: VISUAL still shows work orders open on this packet: L1/001, L2/7A. Filing waits until VISUAL closes them. / Filing waits until the VISUAL feed sees the job closed. |
|
||||||
|
| itp_complete | the packet's unit has no ITP, or its ITP is complete | Complete the unit's ITP first. Q.C. signs it off on the ITP page. |
|
||||||
|
| shorts_approved | no open short, and every short receipt has the supervisor's approval | Every short must be received and approved by the supervisor first. |
|
||||||
|
| ncr_given (input) | no Q.C. reject since the last accept lacks an NCR number, or the request gives a well-formed `ncr` | Enter the NCR number of the reject first. |
|
||||||
| co_staged_if_dwg_change (input) | no drawing change, or the new drawing is staged (phase 3) | Stage the new drawing for the change order first. |
|
| co_staged_if_dwg_change (input) | no drawing change, or the new drawing is staged (phase 3) | Stage the new drawing for the change order first. |
|
||||||
| route_given (input) | routed_to is databook or shipping | Choose Data Book or Shipping. |
|
| route_given (input) | routed_to is databook or shipping | Choose Data Book or Shipping. |
|
||||||
| target_dept_given (input) | a shop department different from the packet's; from a draft any shop department | Choose a department. / Choose a different department. |
|
| target_dept_given (input) | a shop department different from the packet's; from a draft any shop department | Choose a department. / Choose a different department. |
|
||||||
|
|
||||||
"(input)" marks a precondition that reads request input, not packet state. `can()` skips those.
|
"(input)" marks a precondition that reads request input, not packet state. `can()` skips those. `ncr_given` reads the state too (`ncr_required`), but the user gives the number with the step, so `can()` skips it and the packet read carries `ncr_required` for the screen.
|
||||||
|
|
||||||
|
closed_in_visual replaced vclosed_if_databook (Gitea issue 31, change C5). itp_complete is Gitea issue 25 (change C3). shorts_approved is Gitea issue 18 (change C7). ncr_given is Gitea issue 24 (change C2). All built 2026-10-30. A FILE that fails several shows every reason in `blocked.file`.
|
||||||
|
|
||||||
## Order of checks
|
## Order of checks
|
||||||
|
|
||||||
`check(principal, state, action, *, extra={}, it_reason=None) -> Decision` runs these in this order and stops at the first failure:
|
`check(principal, state, action, *, extra={}, it_reason=None) -> Decision` runs these in this order and stops at the first failure:
|
||||||
|
|
||||||
1. **Removed.** A removed packet refuses everything, Admin included. Code `removed`.
|
1. **Removed.** A removed packet refuses everything, Admin included. Code `removed`.
|
||||||
2. **Status.** The status must be in the rule's from set. RETURN_TO_CLERICAL also needs wh_skipped. SET_VCLOSED also needs routed_to databook. An unknown status fails here too. Code `wrong_status`.
|
2. **Status.** The status must be in the rule's from set. RETURN_TO_CLERICAL also needs wh_skipped. SET_VCLOSED also needs a hand packet routed to the data book; on a VISUAL packet it fails with "The VISUAL feed marks this job closed when it sees every work order closed." An unknown status fails here too. Code `wrong_status`.
|
||||||
3. **Change-order hold.** A held action refuses while an approved change order waits for verification, or a pending one is on hold. Admin included. Code `co_hold`, with the hold text.
|
3. **Change-order hold.** A held action refuses while an approved change order waits for verification, or a pending one is on hold. Admin included. Code `co_hold`, with the hold text.
|
||||||
4. **System.** A system-only action refuses every person (`system_only`). The system principal passes only system-only actions (`forbidden`).
|
4. **System.** A system-only action refuses every person (`system_only`). The system principal passes only system-only actions (`forbidden`).
|
||||||
5. **Admin bypass.** An admin who is not allowed by the rule's Who passes who and custody when an IT reason of at least 8 characters is given. Without one the code is `it_reason_required`. SET_DATABOOK_AT needs no reason. The decision is marked `admin_bypass`, and the write marks the log row as an IT edit.
|
5. **Admin bypass.** An admin who is not allowed by the rule's Who passes who and custody when an IT reason of at least 8 characters is given. Without one the code is `it_reason_required`. SET_DATABOOK_AT needs no reason. The decision is marked `admin_bypass`, and the write marks the log row as an IT edit.
|
||||||
6. **Filed lock.** A filed packet refuses everything except SET_DATABOOK_AT and ADMIN_REOPEN, Admin included. Code `filed`: "This packet is filed and locked. IT can reopen it if a correction is needed."
|
6. **Filed lock.** A filed packet refuses everything except SET_DATABOOK_AT and ADMIN_REOPEN, Admin included. Code `filed`: "This packet is filed and locked. IT can reopen it if a correction is needed."
|
||||||
7. **Who and custody.** Code `forbidden`. For a step: "Your access doesn't allow this step." For an edit: "Your access doesn't allow changing (what) on this packet. It's with (holder) right now." PM fails here for everything.
|
7. **Who and custody.** Code `forbidden`. For a step: "Your access doesn't allow this step." For an edit: "Your access doesn't allow changing (what) on this packet. It's with (holder) right now." PM fails here for everything.
|
||||||
8. **Preconditions,** in rule order, never bypassed. Code `precondition` with the message, or `reason_required` when the missing piece is the rule's reason, so the screen can ask for one.
|
8. **Preconditions,** in rule order, never bypassed. Code `precondition` with the message, `reason_required` when the missing piece is the rule's reason, so the screen can ask for one, or `ncr_required` when `ncr_given` fails.
|
||||||
|
|
||||||
`Decision` carries `ok`, `code`, `why`, `admin_bypass`, `signature` (the meaning text when the rule needs one) and, in read mode only, `reasons`.
|
`Decision` carries `ok`, `code`, `why`, `admin_bypass`, `signature` (the meaning text when the rule needs one) and, in read mode only, `reasons`.
|
||||||
|
|
||||||
The packet API answers 403 with the code for most failures, and 400 for `it_reason_required` and `reason_required`.
|
The packet API answers 403 with the code for most failures, 400 for `it_reason_required` and `reason_required`, and 422 for `ncr_required`.
|
||||||
|
|
||||||
### Scoped checks
|
### Scoped checks
|
||||||
|
|
||||||
@@ -198,7 +208,7 @@ There is no removed, status, change-order hold, filed lock or custody step: ther
|
|||||||
|
|
||||||
## Signatures
|
## Signatures
|
||||||
|
|
||||||
These actions need a signature: submit, send_to_qc, route_from_review, qc_reject, qc_accept, qc_accept_and_route, file, admin_reopen, edit_document_set (attach only), rev_approve, co_issue, co_approve, co_reject, co_verify, itp_block_sign and itp_sign_off. The rule's signature text is the meaning that is signed.
|
These actions need a signature: submit, send_to_qc, route_from_review, qc_reject, qc_accept, qc_accept_and_route, file, admin_reopen, short_approve, edit_document_set (attach only), rev_approve, co_issue, co_approve, co_reject, co_verify, itp_block_sign and itp_sign_off. The rule's signature text is the meaning that is signed.
|
||||||
|
|
||||||
The request carries `sign: {factor, secret}`. The write pipeline calls the phase 1 helper `packetstation.auth.signatures.sign()` inside the same transaction, after the policy passes. It checks the PIN or domain password against the session's own user. A PIN works in any session. It logs the attempt and applies the five-strike PIN disable and the three-strike session end. It writes an append-only `signatures` row with the user, session, account, display name, session kind, factor, action, meaning, packet, subject and a sha256 over the canonical JSON of what was signed (the action, the packet id, the expected state and the request fields). The `packet_transitions` and `packet_log` rows point at it.
|
The request carries `sign: {factor, secret}`. The write pipeline calls the phase 1 helper `packetstation.auth.signatures.sign()` inside the same transaction, after the policy passes. It checks the PIN or domain password against the session's own user. A PIN works in any session. It logs the attempt and applies the five-strike PIN disable and the three-strike session end. It writes an append-only `signatures` row with the user, session, account, display name, session kind, factor, action, meaning, packet, subject and a sha256 over the canonical JSON of what was signed (the action, the packet id, the expected state and the request fields). The `packet_transitions` and `packet_log` rows point at it.
|
||||||
|
|
||||||
@@ -211,7 +221,7 @@ The request carries `sign: {factor, secret}`. The write pipeline calls the phase
|
|||||||
- **Work orders** are a draft-only full replace with If-Match on the packet version.
|
- **Work orders** are a draft-only full replace with If-Match on the packet version.
|
||||||
- **Shorts and change-order actions** are state compare-and-set. Checklist keys are written one key at a time. The heat table position is an idempotent upsert. A change-order verification is an insert, so a second one is "already verified".
|
- **Shorts and change-order actions** are state compare-and-set. Checklist keys are written one key at a time. The heat table position is an idempotent upsert. A change-order verification is an insert, so a second one is "already verified".
|
||||||
- **ITP lines** are approved under the `itps` row lock, with a state check and an optional `If-Match: <row_version>`. ITP header fields use the same `set` and `base` field compare-and-set as packet headers.
|
- **ITP lines** are approved under the `itps` row lock, with a state check and an optional `If-Match: <row_version>`. ITP header fields use the same `set` and `base` field compare-and-set as packet headers.
|
||||||
- **Append-only tables** never conflict: packet_transitions, packet_log, qc_history, short_receipts, project_renumbers, itp_rejects, itp_log, print_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, short_approvals, project_renumbers, itp_rejects, itp_log, print_log and co_verifications. Triggers refuse UPDATE, DELETE and TRUNCATE, and the app role has no grant for them.
|
||||||
|
|
||||||
## Reads: can, blocked and holder
|
## Reads: can, blocked and holder
|
||||||
|
|
||||||
@@ -283,6 +293,16 @@ Receiving short parts (`POST .../bom/{item}/short/receipts`) picks its action fr
|
|||||||
|
|
||||||
The warehouse still cannot change any other field of a packet it no longer holds. EDIT_BOM stays the holder's. A receipt needs a heat unless the line is N/A, and its quantity is 1 to what is still open.
|
The warehouse still cannot change any other field of a packet it no longer holds. EDIT_BOM stays the holder's. A receipt needs a heat unless the line is N/A, and its quantity is 1 to what is still open.
|
||||||
|
|
||||||
|
### Supervisor approval of received shorts
|
||||||
|
|
||||||
|
Built 2026-10-30 (Gitea issue 18, change C7). Every receipt also needs the approval of the department supervisor, who checks the part and its heat number against what the warehouse issued. One approval per receipt.
|
||||||
|
|
||||||
|
- SHORT_APPROVE: the supervisor of the packet's current department (DEPT_SUP; a lead is not enough), or Admin through the bypass with an IT reason. Any status but filed. Signature "Approve a received short".
|
||||||
|
- `POST /api/packets/{id}/shorts/receipts/{receipt_id}/approve` `{sign, note?, it_reason?}` answers the packet read. A receipt not on the packet is 404 `not_found`. A second approval is 409 `already_approved` "This receipt is already approved.", checked before the signature and again under the packet lock; the unique key on `short_approvals.receipt_id` backs it.
|
||||||
|
- The approval row keeps the department the packet was in. After a QC_ACCEPT_AND_ROUTE the new department's supervisor approves the earlier receipts.
|
||||||
|
- `shorts_unapproved` (packet read, board cards and the policy state) counts open shorts plus receipts without an approval. The precondition `shorts_approved` needs it at 0 on QC_ACCEPT and FILE. QC_ACCEPT_AND_ROUTE does not need it, because the packet goes on to another department and is not complete.
|
||||||
|
- The packet read has `shorts`: every short of the packet, open or closed, each with its `receipts` and each receipt's `approval` (`null`, or `{at, by, note, dept_code}`). A short that is fully received closes and leaves the BOM line, but its receipts still need approval, so the screen reads `shorts`. `can` holds `short_approve` for the supervisor in every status but filed; the screen shows Approve only on a receipt without an approval.
|
||||||
|
|
||||||
### MTR files
|
### MTR files
|
||||||
|
|
||||||
The MTR on a line must be an asset whose type is PDF, PNG or JPEG. Anything else, or an asset that is not there, is 422 `invalid_asset`. Clearing the MTR is allowed.
|
The MTR on a line must be an asset whose type is PDF, PNG or JPEG. Anything else, or an asset that is not there, is 422 `invalid_asset`. Clearing the MTR is allowed.
|
||||||
@@ -307,6 +327,7 @@ The `action` column of `packet_log` for each warehouse write. `{item}` is the BO
|
|||||||
| Short opened | `Short recorded · item {item}` | `{qty} of {need} short · {reason} · {ref} · ETA {eta}`, parts present only |
|
| Short opened | `Short recorded · item {item}` | `{qty} of {need} short · {reason} · {ref} · ETA {eta}`, parts present only |
|
||||||
| Short edited | `Short updated · item {item}` | the same as recorded |
|
| Short edited | `Short updated · item {item}` | the same as recorded |
|
||||||
| Not short after all | `Short cleared · item {item}` | |
|
| Not short after all | `Short cleared · item {item}` | |
|
||||||
|
| Short approved | `Short approved · item {item}` | `{qty} pc · heat {heat} · {note}`, parts present only |
|
||||||
| Short received | `Short received · item {item}` | `{qty} pc · heat {heat} · {still} still short` or `... · short closed`, then ` · packet {status}` (lower case) and ` in {station}` when at a station |
|
| Short received | `Short received · item {item}` | `{qty} pc · heat {heat} · {still} still short` or `... · short closed`, then ` · packet {status}` (lower case) and ` in {station}` when at a station |
|
||||||
| Stage incoming to pulling | `Warehouse: Started pulling` | |
|
| Stage incoming to pulling | `Warehouse: Started pulling` | |
|
||||||
| Stage to hold | `Warehouse: Put on hold` | `{reason} · {note}` |
|
| Stage to hold | `Warehouse: Put on hold` | `{reason} · {note}` |
|
||||||
@@ -359,7 +380,7 @@ A lead holds a packet in review (see "Custody"), but return_to_station, send_to_
|
|||||||
| Step | Precondition | Passes when | Message |
|
| Step | Precondition | Passes when | Message |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| assign | welder_set (input) | the request gives station_id and welder_id | Pick a station and a welder. |
|
| assign | welder_set (input) | the request gives station_id and welder_id | Pick a station and a welder. |
|
||||||
| submit | checklist_complete | every key of the department's checklist is done | Finish the checklist first. |
|
| submit | checklist_complete | every key of the stage's checklist is done: the list frozen at START, else the department's live list (see "Checklists") | Finish the checklist first. |
|
||||||
| submit | welds_recorded | at least one live weld whose `dept_code` is the packet's current department, in a weld department | Record at least one weld first. |
|
| submit | welds_recorded | at least one live weld whose `dept_code` is the packet's current department, in a weld department | Record at least one weld first. |
|
||||||
| submit | filler_set | filler metal entered on the open stage, in a weld department | Enter the filler metal first. |
|
| submit | filler_set | filler metal entered on the open stage, in a weld department | Enter the filler metal first. |
|
||||||
| send_to_qc | qc_book_spool_location (input) | book and spool confirmed and a location given | Confirm the book and the spool, and give the location. |
|
| send_to_qc | qc_book_spool_location (input) | book and spool confirmed and a location given | Confirm the book and the spool, and give the location. |
|
||||||
@@ -410,8 +431,9 @@ The sibling is a live packet in the same unit, with a different id and the same
|
|||||||
### Shop screens
|
### Shop screens
|
||||||
|
|
||||||
- `GET /api/shop/board` covers the shop departments where the person is lead or supervisor. Admin sees every active shop department. A department outside that scope is 403 `forbidden`. A person who leads no department gets 403 `forbidden` "You do not lead a department." The change order list also shows open change orders anywhere that wait on one of the person's departments.
|
- `GET /api/shop/board` covers the shop departments where the person is lead or supervisor. Admin sees every active shop department. A department outside that scope is 403 `forbidden`. A person who leads no department gets 403 `forbidden` "You do not lead a department." The change order list also shows open change orders anywhere that wait on one of the person's departments.
|
||||||
- `GET /api/shop/stations` lists the stations the person may pick for the Stall screen: every station of their shop departments at any level, cut to their station limits for a worker. Admin gets every station.
|
- `GET /api/stall/mine` is the Stall screen (Gitea issue 36, change C11, built 2026-10-30). It lists the packets assigned to the signed-in person (`welder_id` is them; status assigned, in progress or review), each at the station the supervisor picked, across stations. Nobody picks a station on the Stall screen, and the request names none: the session decides. The answer is `{station: null, mine, submitted, facets, empty}`; `mine` holds the assigned and in-progress cards, `submitted` the cards in review, and every card carries `station_id` and `station_name`. With nothing assigned, `empty` says "Nothing is assigned to you now. Your supervisor assigns packets to you." The find bar narrows the cards; a `station_id` in the query narrows, never widens. Moving a welder to another station is a re-assign by the supervisor.
|
||||||
- `GET /api/shop/stall?station_id=` is 403 for a station not in that list. Admin passes.
|
- `GET /api/shop/stations` lists the stations of the person's shop departments, cut to their station limits for a worker. Admin gets every station. The Stall screen no longer uses it.
|
||||||
|
- `GET /api/shop/stall?station_id=` is the board of one station, 403 for a station not in that list. Admin passes. It stays for now; the Stall screen no longer uses it.
|
||||||
|
|
||||||
### Presence
|
### Presence
|
||||||
|
|
||||||
@@ -470,7 +492,7 @@ Q.C. holds a packet in status qc. EDIT_QC (QC, status qc) covers every write to
|
|||||||
|
|
||||||
### Accept
|
### Accept
|
||||||
|
|
||||||
Two actions accept. Both are Q.C. from status qc, held by an open change order, gated by `qc_missing_empty`, and signed.
|
Two actions accept. Both are Q.C. from status qc, held by an open change order, gated by `qc_missing_empty` and `ncr_given`, and signed. qc_accept, which marks the packet complete, also needs `shorts_approved` (Gitea issue 18, change C7): no open short and every receipt approved by the supervisor. qc_accept_and_route does not, because the packet goes on to another department.
|
||||||
|
|
||||||
| Route | Action | Body | Result |
|
| Route | Action | Body | Result |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -480,6 +502,8 @@ Two actions accept. Both are Q.C. from status qc, held by an open change order,
|
|||||||
|
|
||||||
The signature meanings are "Q.C. accept" and "Q.C. accept and route". The accept closes the inspection into `qc_history` and closes the stage with the inspection as its `qc_snapshot`. The note in the log counts every live weld of the packet.
|
The signature meanings are "Q.C. accept" and "Q.C. accept and route". The accept closes the inspection into `qc_history` and closes the stage with the inspection as its `qc_snapshot`. The note in the log counts every live weld of the packet.
|
||||||
|
|
||||||
|
NCR on the accept (Gitea issue 24, change C2, built 2026-10-30). The packet read's `ncr_required` is true when a Q.C. reject after the packet's last Q.C. accept has no NCR number (a `qc_history` row with result `rejected` and no `snapshot.ncr`). Then both accepts need `ncr` in the request: without it, or with a blank one, the answer is 422 `ncr_required` "Enter the NCR number of the reject first." A malformed one is 422 `invalid_ncr`. The number goes through `validate_ncr`, is stored on the accepting inspection (`qc_inspections.ncr`, so `qc_last` and the `qc_history` snapshot show it) and is added to the log note. An NCR given on an accept that does not need one is stored the same way. `can()` skips `ncr_given`, since the number comes with the step; the screen shows the NCR field as required from `ncr_required`.
|
||||||
|
|
||||||
### What Q.C. still needs
|
### What Q.C. still needs
|
||||||
|
|
||||||
`qc_missing_empty` reads the list built by `checklists.qc_missing()`. Its message is `Q.C. still needs: ` followed by the parts joined by `, ` and a full stop. `blocked.qc_accept` and `blocked.qc_accept_and_route` carry it, so the screen shows Accept disabled with the reason.
|
`qc_missing_empty` reads the list built by `checklists.qc_missing()`. Its message is `Q.C. still needs: ` followed by the parts joined by `, ` and a full stop. `blocked.qc_accept` and `blocked.qc_accept_and_route` carry it, so the screen shows Accept disabled with the reason.
|
||||||
@@ -494,11 +518,14 @@ The signature meanings are "Q.C. accept" and "Q.C. accept and route". The accept
|
|||||||
|
|
||||||
### Reject
|
### Reject
|
||||||
|
|
||||||
qc_reject is Q.C. from status qc back to review, signed ("Q.C. reject"). It takes a reason, an NCR number, or both:
|
qc_reject is Q.C. from status qc back to review, signed ("Q.C. reject"). It always needs a detailed reason and may also carry an NCR number (Gitea issue 24, change C2, built 2026-10-30):
|
||||||
|
|
||||||
- An NCR number goes through `validate_ncr`: trimmed, upper case, then the pattern `NCR-` four digits `-` four digits. A bad one is 422 `invalid_ncr` "Enter an NCR number like NCR-2026-0021."
|
- The reason must pass `reason_given`: at least 8 characters after cleaning. An NCR number no longer stands in for it. Without one the answer is 400 `reason_required` with the rule's message.
|
||||||
- The reason checked by `reason_given` is the NCR alone (`NCR-2026-0021`) when no text is given, else `NCR-2026-0021 · <reason>`. So an NCR number stands in for the reason, and the text has no minimum when an NCR is given.
|
- An NCR number, when given, goes through `validate_ncr`: trimmed, upper case, then the pattern `NCR-` four digits `-` four digits. A bad one is 422 `invalid_ncr` "Enter an NCR number like NCR-2026-0021."
|
||||||
- With neither, the answer is 400 `reason_required` with the rule's message. A reason without an NCR needs 8 characters.
|
- With both, the return note and the result note read `NCR-2026-0021 · <reason>`.
|
||||||
|
- A reject without an NCR number means the next accept must carry one (see "Accept").
|
||||||
|
|
||||||
|
Q.C. can add the NCR document to the packet as kind `ncr` (see "Packet documents"). It goes into section 5 of the data book.
|
||||||
|
|
||||||
The reject stores the NCR on the inspection, sets the packet's `return_note` to the reason, and closes the inspection into `qc_history` at once. The packet in review has no open inspection; the read model gives the reject as `qc_last`, and the screens show the "Q.C. rejected" callout from it. The supervisor's next send to Q.C. opens a fresh inspection.
|
The reject stores the NCR on the inspection, sets the packet's `return_note` to the reason, and closes the inspection into `qc_history` at once. The packet in review has no open inspection; the read model gives the reject as `qc_last`, and the screens show the "Q.C. rejected" callout from it. The supervisor's next send to Q.C. opens a fresh inspection.
|
||||||
|
|
||||||
@@ -596,6 +623,7 @@ The `action` and `note` of `packet_log` for each Q.C. packet write.
|
|||||||
| qc_accept to the data book | `Q.C. accepted → Clerical – Data Book` | `Quinn QC [Q1] · 3 welds VT accepted`, plus ` · RT Acceptable` when an NDE method other than None required is set, plus ` · <note>` when the step carries a note |
|
| qc_accept to the data book | `Q.C. accepted → Clerical – Data Book` | `Quinn QC [Q1] · 3 welds VT accepted`, plus ` · RT Acceptable` when an NDE method other than None required is set, plus ` · <note>` when the step carries a note |
|
||||||
| qc_accept to Shipping | `Q.C. accepted → Shipping` | the same |
|
| qc_accept to Shipping | `Q.C. accepted → Shipping` | the same |
|
||||||
| qc_accept_and_route | `Q.C. accepted → 09 Hydro` | `Quinn QC [Q1] · from 03 Weld Parts` |
|
| qc_accept_and_route | `Q.C. accepted → 09 Hydro` | `Quinn QC [Q1] · from 03 Weld Parts` |
|
||||||
|
| either accept, with an NCR number | as above | the note above plus ` · NCR-2026-0021` |
|
||||||
| qc_reject | `Q.C. rejected → supervisor` | `NCR-2026-0021 · <reason> · welds W1, W3` (parts present only; the rejected welds by number) |
|
| qc_reject | `Q.C. rejected → supervisor` | `NCR-2026-0021 · <reason> · welds W1, W3` (parts present only; the rejected welds by number) |
|
||||||
| Document added | `Document added` | `<label> · <title> · <file name>` |
|
| Document added | `Document added` | `<label> · <title> · <file name>` |
|
||||||
| Document taken out | `Document taken out of the data book` | `<title> · <reason>` |
|
| Document taken out | `Document taken out of the data book` | `<title> · <reason>` |
|
||||||
@@ -625,19 +653,26 @@ A Q.C. folder document writes an audit row `qc_doc.add` or `qc_doc.revise` (subj
|
|||||||
|
|
||||||
## Phase 7: close, file, PDFs and prints
|
## 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.
|
Nothing in the rule table changes in phase 7. FILE, ADMIN_REOPEN, SET_VCLOSED and SET_DATABOOK_AT keep their rows. Changes C2, C3, C5 and C7 (built 2026-10-30) changed FILE and SET_VCLOSED, as below.
|
||||||
|
|
||||||
### Close in VISUAL
|
### 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.
|
`packets.vclosed_at` is the one record of "closed in VISUAL" (Gitea issue 31, change C5, built 2026-10-30). Who writes it depends on the packet:
|
||||||
|
|
||||||
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.
|
- A VISUAL packet: the feed alone. It sets `vclosed_at` (with `vclosed_by` null) when every leg is closed or cancelled, and clears it when a leg shows open again (see [FEED.md](FEED.md), "The apply"). The Clerical board reads it, so the packet moves from "Ready to close" to "Ready for data book" by itself. SET_VCLOSED on a VISUAL packet is refused as `wrong_status`: "The VISUAL feed marks this job closed when it sees every work order closed."
|
||||||
|
- A hand packet has no feed state. SET_VCLOSED is Clerical, in status complete, only when the packet is routed to the data book (`POST /api/packets/{id}/vclosed` `{closed}`). Confirming sets `vclosed_at` and `vclosed_by`. Taking it back clears both ("Not closed in VISUAL, move back"). A hand packet routed to Shipping files without it.
|
||||||
|
|
||||||
|
The packet read carries `vclosed` (`{at, by}`; `by` is null when the feed set it) and `vclose_check`, outside the seal. `vclose_check` is null for a hand packet. For a feed packet: `observed_at` and `closed_at` from the feed's VISUAL state, `open_wos` (the legs whose status is not `C` and not cancelled, as `wo_base/lot` text, never cast), and `agrees`. The precondition `closed_in_visual` reads `vclosed_at` and, on a VISUAL packet, the open legs.
|
||||||
|
|
||||||
### Filing
|
### 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.").
|
FILE is Clerical, from complete, held by an open change order, signed ("File in the data book"), with three preconditions. `blocked.file` lists every one that fails, so the close panel says why.
|
||||||
|
|
||||||
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.
|
- `closed_in_visual` (Gitea issue 31, change C5). A VISUAL packet, on both routes: no work order open in the feed's state and `vclosed_at` set by the feed. While a work order is open: "VISUAL still shows work orders open on this packet: L489052/1, L489052/2. Filing waits until VISUAL closes them." With none open but the close not yet seen: "Filing waits until the VISUAL feed sees the job closed." A hand packet: "Close the job in VISUAL first." on the Data Book route until Clerical confirms; none on Shipping.
|
||||||
|
- `itp_complete` (Gitea issue 25, change C3): the unit's ITP is complete. An ITP that is open, signed or reopened blocks with "Complete the unit's ITP first. Q.C. signs it off on the ITP page." A unit with no ITP does not block.
|
||||||
|
- `shorts_approved` (Gitea issue 18, change C7): no open short and every receipt approved by the supervisor.
|
||||||
|
|
||||||
|
Filing is blocked, not warned. The phase 7 second confirmation (`confirm_open_wos` and the 409 `open_work_orders`) is gone; the server ignores `confirm_open_wos` if a client still sends it. New seals never record `open_wos`; older seals keep theirs.
|
||||||
|
|
||||||
In one transaction, filing sets status filed and `databook_at`, writes the seal, builds the data book, stores it as an asset on the seal, and writes the log and audit rows. A data book that cannot be built answers 500 `databook_failed` "The data book could not be built: <reason>. Nothing was filed." and nothing is written. A part that cannot be read (a missing file, an unreadable MTR) does not fail the filing: it becomes a warning row in the data book index.
|
In one transaction, filing sets status filed and `databook_at`, writes the seal, builds the data book, stores it as an asset on the seal, and writes the log and audit rows. A data book that cannot be built answers 500 `databook_failed` "The data book could not be built: <reason>. Nothing was filed." and nothing is written. A part that cannot be read (a missing file, an unreadable MTR) does not fail the filing: it becomes a warning row in the data book index.
|
||||||
|
|
||||||
@@ -667,15 +702,17 @@ Every read below needs a signed-in session and a packet that exists and is not r
|
|||||||
|
|
||||||
`?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`.
|
`?download=1` serves the file as an attachment. `?sheet=n` limits `map-h`, `map-w` and `clean` to packet page n. Prints and exports write no `packet_log` row, because they change nothing on the packet. The History tab lists them from `GET .../prints`.
|
||||||
|
|
||||||
|
A print in the app goes straight to the browser's print dialog from the page: the screen fetches the stamped print PDF (`print/{kind}`, or `/api/itps/{id}/print`), renders it into a hidden print area and prints it (Gitea issue 27, change C4, built 2026-10-30). Viewing a PDF still opens it in a tab. The endpoints, the stamps and the print log did not change.
|
||||||
|
|
||||||
### Log texts
|
### Log texts
|
||||||
|
|
||||||
The `action` and `note` of `packet_log` for close, file and reopen.
|
The `action` and `note` of `packet_log` for close, file and reopen.
|
||||||
|
|
||||||
| Write | action | note |
|
| 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, confirm (hand packet) | `Job closed in VISUAL` | the packet's work order bases, `L489052, L489053` |
|
||||||
| set_vclosed, taken back | `Moved back to ready to close` | |
|
| 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 | `Filed in the data book` | the note given with the step |
|
||||||
| file, the data book | `Data book filed` | `N pages` |
|
| file, the data book | `Data book filed` | `N pages` |
|
||||||
| admin_reopen | `Reopened by IT` | the reason |
|
| admin_reopen | `Reopened by IT` | the reason |
|
||||||
| set_databook_at | `Data book date set` | |
|
| set_databook_at | `Data book date set` | |
|
||||||
@@ -688,7 +725,7 @@ Nothing in the rule table changes in phase 8. Project Management, the find bar f
|
|||||||
|
|
||||||
### Who reads the Project Management endpoints
|
### Who reads the Project Management endpoints
|
||||||
|
|
||||||
Every endpoint under `/api/pm` needs a signed-in session. Any role may read them, not only PS-ProjectMgmt, as every role may open every packet (open question 39). Changing: SethD decided on 2026-10-28 (Gitea issue 33) that only PS-ProjectMgmt and Admin may (PLAN.md change C10). An unsigned request is 401. PM writes nothing: there is no POST, PUT, PATCH or DELETE under `/api/pm`, and the PM role passes no write action anywhere else. A packet opened from the PM screen shows "View only." The reports read PostgreSQL only. They never read VISUAL or M-Files.
|
Every endpoint under `/api/pm` is for PS-ProjectMgmt (role `pm`) and Admin only (Gitea issue 33, change C10, built 2026-10-30; it replaced the phase 8 default of any signed-in role, open question 39). Any other role is 403 `forbidden` "Project Management only." An unsigned request is 401. No other screen calls `/api/pm`: the find bar and the boards read `/api/packets` and the board endpoints. The PM entry in the screen switcher shows only for PM and Admin, and anyone else who types `/pm` sees "The Project Management screen is not for your role." PM writes nothing: there is no POST, PUT, PATCH or DELETE under `/api/pm`, and the PM role passes no write action anywhere else. A packet opened from the PM screen shows "View only." The reports read PostgreSQL only. They never read VISUAL or M-Files.
|
||||||
|
|
||||||
| Endpoint | What |
|
| Endpoint | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -708,7 +745,7 @@ Every PM endpoint takes the scope params: `project_id`, `unit_id`, `dept`, `stat
|
|||||||
- Projects in scope: every project not removed. A closed project only when a packet in scope belongs to it.
|
- Projects in scope: every project not removed. A closed project only when a packet in scope belongs to it.
|
||||||
- Units in scope: the project's units. When any filter other than the project is set, only the units with a packet in scope.
|
- Units in scope: the project's units. When any filter other than the project is set, only the units with a packet in scope.
|
||||||
- Health follows the old healthOf. `empty` with no packets. `done` when every packet is filed. `late` past the due date. `risk` when due within 7 days and under 75% done, or when any packet has an open change order or a warehouse hold. Else `ok`. A unit uses its own due date, else the project's. A project is as healthy as its worst unit.
|
- Health follows the old healthOf. `empty` with no packets. `done` when every packet is filed. `late` past the due date. `risk` when due within 7 days and under 75% done, or when any packet has an open change order or a warehouse hold. Else `ok`. A unit uses its own due date, else the project's. A project is as healthy as its worst unit.
|
||||||
- Over target means station timer hours over the work-order target hours. VISUAL ticket hours are shown beside the timer and never decide the flag (open question 38).
|
- Over target means VISUAL ticket hours over the work-order target hours (Gitea issue 1, change C1, built 2026-10-30; it replaced open question 38). A packet with no ticket hours, or no target, is not over target. The station timer is shown beside the ticket hours and never decides the flag. The group tables' variance and ratio are ticket based ("Ticket variance h", "Ticket h / target", "Over target (tickets)"), the over-target table ends with "Over by (tickets)", and the KPI reads "Packets over target hours (tickets)". The CSV and the report PDF follow.
|
||||||
|
|
||||||
### Search
|
### Search
|
||||||
|
|
||||||
@@ -727,7 +764,7 @@ A child match never repeats a packet. IDs are matched as text, never cast.
|
|||||||
|
|
||||||
### Filters
|
### Filters
|
||||||
|
|
||||||
The server applies the filters on every list and board (open question 41). A board never fetches more than it shows. A filter only narrows what the person could already read. It never widens access, and each board keeps its own scope checks (a supervisor's departments, the stall's station).
|
The server applies the filters on every list and board (open question 41). A board never fetches more than it shows. A filter only narrows what the person could already read. It never widens access, and each board keeps its own scope checks (a supervisor's departments, the stall's own assignments).
|
||||||
|
|
||||||
`status` takes one of the words below, or a comma list of raw statuses as before (`status=ready,review`).
|
`status` takes one of the words below, or a comma list of raw statuses as before (`status=ready,review`).
|
||||||
|
|
||||||
@@ -767,7 +804,7 @@ Mine is decided on the server from the person's AD roles and the screen they nam
|
|||||||
| clerical | status draft, or status complete routed to the data book |
|
| clerical | status draft, or status complete routed to the data book |
|
||||||
| warehouse | status warehouse, or an open short |
|
| warehouse | status warehouse, or an open short |
|
||||||
| supervisor | the departments where the person is lead or supervisor |
|
| supervisor | the departments where the person is lead or supervisor |
|
||||||
| stall | Changing to the packets assigned to the person, at their assigned stall (Gitea issue 36, PLAN.md change C11). Today: the person's shop departments and the picked station (`mine_station_id`). No station picked: nothing. |
|
| stall | the packets assigned to the person (`welder_id` is them), in status assigned, in progress or review, across stations. No station is needed; `mine_station_id` is accepted and ignored (Gitea issue 36, change C11, built 2026-10-30). |
|
||||||
| qc | status qc |
|
| qc | status qc |
|
||||||
| pm, or admin with no other role | everything. The chip is hidden on the PM and Stall screens. |
|
| pm, or admin with no other role | everything. The chip is hidden on the PM and Stall screens. |
|
||||||
|
|
||||||
@@ -830,11 +867,11 @@ The packet page shows who else has the packet open: `Also in this packet now: <N
|
|||||||
| What the old build allowed | What the rebuild does | Why | Can be vetoed |
|
| What the old build allowed | What the rebuild does | Why | Can be vetoed |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| The find bar filtered in the browser, over every packet the browser had loaded. | The server applies the search and every filter on each list and board. A filter only narrows what the person may read. | A board never fetches more than it shows. | No |
|
| The find bar filtered in the browser, over every packet the browser had loaded. | The server applies the search and every filter on each list and board. A filter only narrows what the person may read. | A board never fetches more than it shows. | No |
|
||||||
| "Mine" was worked out in the browser from the local session's role. | Mine is decided on the server from the person's AD roles and the screen they name. The stall's Mine needs the picked station. | The roles come from AD, not from what the browser keeps. | Yes |
|
| "Mine" was worked out in the browser from the local session's role. | Mine is decided on the server from the person's AD roles and the screen they name. The stall's Mine is the packets assigned to the person, across stations (Gitea issue 36, change C11). | The roles come from AD, not from what the browser keeps. | Yes |
|
||||||
| PM exports were built and saved in the browser, and the browser wrote the `report exported` log line. | Every export is built on the server and logged in `print_log` and the audit as `report exported`, with the scope, the period and, for the PDF, the page count. | The browser alone could skip the log. | Yes |
|
| PM exports were built and saved in the browser, and the browser wrote the `report exported` log line. | Every export is built on the server and logged in `print_log` and the audit as `report exported`, with the scope, the period and, for the PDF, the page count. | The browser alone could skip the log. | Yes |
|
||||||
| The PM reports were on the PM home screen only. | Any signed-in role may read them, as any role may open any packet. PM still writes nothing. | The figures come from the same packets every role can already open. | Yes |
|
| The PM reports were on the PM home screen only. | Only PS-ProjectMgmt and Admin may read them, on the server (Gitea issue 33, change C10). PM still writes nothing. | SethD decided the reports are for Project Management. The phase 8 default let every role read them. | No (decided by SethD) |
|
||||||
| Each browser published its own presence, with the name, employee ID, role and stall, refreshed every 20 seconds. | The server builds presence from the session: display name and role, no employee ID. A locked or revoked session drops out at once. | Presence shows only what it needs, and a locked screen is not someone in the packet. | Yes |
|
| Each browser published its own presence, with the name, employee ID, role and stall, refreshed every 20 seconds. | The server builds presence from the session: display name and role, no employee ID. A locked or revoked session drops out at once. | Presence shows only what it needs, and a locked screen is not someone in the packet. | Yes |
|
||||||
| Over target was worked out in the browser from the hours it had. | Over target is timer hours over the work-order target, on the server. Ticket hours are shown beside it and never decide it. | The station timer is the clock of record (open question 1). | Yes |
|
| Over target was worked out in the browser from the hours it had. | Over target is VISUAL ticket hours over the work-order target, on the server. The station timer is shown beside them and never decides it (Gitea issue 1, change C1). | SethD decided the ticket hours are the measure. The timer stays as a clock. | No (decided by SethD) |
|
||||||
|
|
||||||
## Phase 9: Admin, notifications, reconcile
|
## Phase 9: Admin, notifications, reconcile
|
||||||
|
|
||||||
@@ -842,7 +879,7 @@ Nothing in the packet rule table changes in phase 9. The new endpoints are Admin
|
|||||||
|
|
||||||
### Who may use the Admin endpoints
|
### Who may use the Admin endpoints
|
||||||
|
|
||||||
Every endpoint under `/api/admin` needs an Admin (PS-Admins) in a password session. An Admin in a PIN session is 403 `password_session_required`. Every other role is 403. An unsigned request is 401. Every write also passes the Origin check and the body limit. Tests try every endpoint as every role.
|
Every endpoint under `/api/admin` needs an Admin (PS-Admins) in a password session, except the stamp check (see "The stamp check"). An Admin in a PIN session is 403 `password_session_required`. Every other role is 403. An unsigned request is 401. Every write also passes the Origin check and the body limit. Tests try every endpoint as every role.
|
||||||
|
|
||||||
| Endpoint | What | Audit |
|
| Endpoint | What | Audit |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -852,14 +889,14 @@ Every endpoint under `/api/admin` needs an Admin (PS-Admins) in a password sessi
|
|||||||
| `GET /users`, `GET /users/{id}` | Gain the welder stamp, `profile_active`, station limits and the directory flags | none |
|
| `GET /users`, `GET /users/{id}` | Gain the welder stamp, `profile_active`, station limits and the directory flags | none |
|
||||||
| `PATCH /users/{id}/profile` | Welder stamp, `profile_active`, station limits per department | `admin.profile_updated` |
|
| `PATCH /users/{id}/profile` | Welder stamp, `profile_active`, station limits per department | `admin.profile_updated` |
|
||||||
| `POST /users/import` | The profile CSV, as a preview (`apply: false`) or applied | `admin.profile_updated` per changed person, `admin.profile_import` |
|
| `POST /users/import` | The profile CSV, as a preview (`apply: false`) or applied | `admin.profile_updated` per changed person, `admin.profile_import` |
|
||||||
| `GET /checklists`, `PUT /checklists` | The weld and plain station checklists | `admin.checklists_updated` |
|
| `GET /checklists`, `PUT /checklists` | The default weld and plain lists, used by a department without its own list (see "Checklists"). The Admin screen no longer calls these; its Checklists tab edits department lists. | `admin.checklists_updated` |
|
||||||
| `GET /security/settings`, `PUT /security/settings` | The session, lock and sign-in limit settings | `admin.security_settings_updated` |
|
| `GET /security/settings`, `PUT /security/settings` | The session, lock and sign-in limit settings | `admin.security_settings_updated` |
|
||||||
| `GET /security/alerts?hours=` | Security alerts for 1 to 168 hours | none |
|
| `GET /security/alerts?hours=` | Security alerts for 1 to 168 hours | none |
|
||||||
| `GET /security/integrity` | The newest finished integrity run and its findings | none |
|
| `GET /security/integrity` | The newest finished integrity run and its findings | none |
|
||||||
| `GET /access-log` | Sign-in attempts and identity audit rows for everyone | none |
|
| `GET /access-log` | Sign-in attempts and identity audit rows for everyone | none |
|
||||||
| `GET /access-log.csv` | The same, as CSV, at most 50,000 rows | `admin.access_log_exported`, a `print_log` row of kind `access-log-csv` |
|
| `GET /access-log.csv` | The same, as CSV, at most 50,000 rows | `admin.access_log_exported`, a `print_log` row of kind `access-log-csv` |
|
||||||
| `GET /presence` | Live sessions, locked or not, with the screen and packet | none |
|
| `GET /presence` | Live sessions, locked or not, with the screen and packet | none |
|
||||||
| `POST /stamp-verify`, `POST /stamp-verify/pdf` | Checks a stamp line, a 16-character code or a PDF (25 MB) against the print log | `admin.stamp_verified` |
|
| `POST /stamp-verify`, `POST /stamp-verify/pdf` | Checks a stamp line, a 16-character code or a PDF (25 MB) against the print log. Kept as aliases of `/api/stamp-verify`, open to Admin, Q.C. and Clerical (see "The stamp check") | `admin.stamp_verified` |
|
||||||
| `GET /directory-flags` | Active users with no employee ID, and employee IDs on more than one account | none |
|
| `GET /directory-flags` | Active users with no employee ID, and employee IDs on more than one account | none |
|
||||||
| `POST /ad/refresh` | Asks the worker for an `ad-reconcile` run; 202 (open question 48) | `ad.refresh_requested` |
|
| `POST /ad/refresh` | Asks the worker for an `ad-reconcile` run; 202 (open question 48) | `ad.refresh_requested` |
|
||||||
| `GET /ad/reconcile` | The newest finished reconcile run and its report | none |
|
| `GET /ad/reconcile` | The newest finished reconcile run and its report | none |
|
||||||
@@ -891,9 +928,21 @@ The checklists, the security settings and each notification rule carry a version
|
|||||||
|
|
||||||
### Checklists
|
### Checklists
|
||||||
|
|
||||||
Changing: SethD decided (Gitea issue 23, PLAN.md change C8) that checklists become per department, edited by that department's supervisor or Admin, and a change applies to packets not yet started only.
|
Each shop department has its own checklist (Gitea issue 23, change C8, built 2026-10-30). It replaced the two Admin lists of phase 9 and open questions 26 and 52.
|
||||||
|
|
||||||
Both lists need at least one item and at most 40. A key is lower case letters, digits and underscores, starting with a letter, at most 30 characters, unique in its list. Group and text are not empty, at most 200 characters. The only placeholders are `{drawing}`, `{rev}` and `{wps}`. Answers on open stages are kept by key. A removed item drops out of new checklists only. An item added while a stage is open must also be ticked on that stage before submit, since the completeness check reads the live list.
|
| Endpoint | Who | What |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/checklists` | any signed-in session | every active shop department in sort order: `{dept_code, dept_name, weld, custom, version, editable}` |
|
||||||
|
| `GET /api/checklists/{dept}` | any signed-in session | the same plus `items` (placeholders not filled) and `defaults` (the default list for its kind). An unknown or support department is 404 `not_found` "No such shop department." |
|
||||||
|
| `PUT /api/checklists/{dept}` `{items, base}` | that department's Supervisor, or Admin, in a password session | the department's list; answers the GET shape. Audit `checklists.dept_updated` (subject `settings` / `checklist:<code>`, detail `{dept, before, after}`) |
|
||||||
|
|
||||||
|
- Who edits: `editable` is true for Admin and for a Supervisor of that department. A lead, a worker, Q.C., Clerical, Warehouse and PM do not edit department checklists. A save by anyone else is 403 `forbidden` "Only this department's supervisor or an Admin can change its checklist." An editor in a PIN session is 403 `password_session_required`.
|
||||||
|
- Compare and set: the save sends the version it started from as `base`, or as `If-Match`. With neither the answer is 428 `if_match_required` "Send the version you started from." A stale version is 409 `version_conflict` "Someone else changed this checklist. Reload and try again." with `current`, the GET shape.
|
||||||
|
- `items: null` sets the department back to the default for its kind.
|
||||||
|
- A list needs at least one item and at most 40. A key is lower case letters, digits and underscores, starting with a letter, at most 30 characters, unique in its list. Group and text are not empty, at most 200 characters. The only placeholders are `{drawing}`, `{rev}` and `{wps}`. Anything else is 422 `bad_checklist` with `errors`, one line per problem.
|
||||||
|
- Default lists: a department without its own list uses the default for its kind, the weld or plain list of the `checklist_templates` row, else the code's defaults. `/api/admin/checklists` still edits that row.
|
||||||
|
- Frozen at START: START copies the department's live list onto the stage (`packet_stages.checklist_template`), once. A change reaches only stages not yet started. Every reader uses the stage's frozen list when it is set, else the department's live list: the checklist items in the packet read, `checklist_complete`, the key check on a tick (422 for a key not in the list) and the data book's weld record. Answers are kept by key.
|
||||||
|
- Ticking items (EDIT_CHECKLIST) stays the holder's. The Q.C. check items are not per department and stay Admin only.
|
||||||
|
|
||||||
### Security settings
|
### Security settings
|
||||||
|
|
||||||
@@ -901,12 +950,14 @@ Stored as the `security` settings row over the environment defaults. A value out
|
|||||||
|
|
||||||
### Access log, presence and the stamp check
|
### Access log, presence and the stamp check
|
||||||
|
|
||||||
Changing: the stamp check opens to Q.C. and Clerical as well as Admin (Gitea issue 32, PLAN.md change C9).
|
|
||||||
|
|
||||||
- The access log shows every person's sign-in attempts (successful and failed) and their `auth.*`, `pin.*`, `device.*` and `sig.*` audit rows, as the Account access log shows a person their own. The address filter takes an address or a range. Request bodies and secrets are never in it.
|
- The access log shows every person's sign-in attempts (successful and failed) and their `auth.*`, `pin.*`, `device.*` and `sig.*` audit rows, as the Account access log shows a person their own. The address filter takes an address or a range. Request bodies and secrets are never in it.
|
||||||
- Who is in shows live sessions (not revoked, not expired) seen within the idle minutes that apply to each. The screen and packet come from the event stream, matched by person.
|
- Who is in shows live sessions (not revoked, not expired) seen within the idle minutes that apply to each. The screen and packet come from the event stream, matched by person.
|
||||||
- The stamp check never trusts the text. It finds the 16-character signature, looks it up in `print_log`, recomputes it with the seal key from the logged row and compares the text with the logged stamp line. It answers genuine, does not verify (the signature or the text differs from the log), or not found.
|
- The stamp check never trusts the text. It finds the 16-character signature, looks it up in `print_log`, recomputes it with the seal key from the logged row and compares the text with the logged stamp line. It answers genuine, does not verify (the signature or the text differs from the log), or not found.
|
||||||
|
|
||||||
|
### The stamp check
|
||||||
|
|
||||||
|
Built 2026-10-30 (Gitea issue 32, change C9). `POST /api/stamp-verify` and `POST /api/stamp-verify/pdf` take the same bodies and give the same answers as the Admin paths, which stay as aliases with the same rule. Who: Admin, every Q.C. role (`qc`, `qc_supervisor`) and every Clerical role (`clerical`, `clerical_supervisor`). Anyone else is 403 `forbidden` "The stamp check is for Admin, Q.C. and Clerical." A PIN session is 403 `password_session_required`. The audit row stays `admin.stamp_verified`. The screen is `/stamp-check`, linked in the top bar for those roles; Admin's Stamp check tab uses the same endpoints.
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
- An Admin edits every rule. Recipients are any AD group under the base DN, a user, a mailbox, or (for the department events, `co_waiting` and `qc_reject`) the Supervisor group of the event's department. At most 50 recipients and 200 exclusions per rule.
|
- An Admin edits every rule. Recipients are any AD group under the base DN, a user, a mailbox, or (for the department events, `co_waiting` and `qc_reject`) the Supervisor group of the event's department. At most 50 recipients and 200 exclusions per rule.
|
||||||
@@ -1007,7 +1058,7 @@ SethD answered on 2026-10-26: every row is kept as shown (Gitea issue 6), and he
|
|||||||
| Any worker in the department within their station limits could start or submit a packet (`worksOn` ignored who was assigned). | START and SUBMIT need the assigned welder for a worker. Leads and supervisors still can. | The README says only the assigned worker. | Yes |
|
| Any worker in the department within their station limits could start or submit a packet (`worksOn` ignored who was assigned). | START and SUBMIT need the assigned welder for a worker. Leads and supervisors still can. | The README says only the assigned worker. | Yes |
|
||||||
| A status change could carry any other field in the same save. | A transition carries only its own fields (the step's inputs). Other changes go through their own edit actions and rules. | One rule per write keeps the check honest. | No |
|
| A status change could carry any other field in the same save. | A transition carries only its own fields (the step's inputs). Other changes go through their own edit actions and rules. | One rule per write keeps the check honest. | No |
|
||||||
| Clerical "lead" actions (approve an M-Files rev mismatch, move back from VISUAL review) were open to any Clerical member with a lead flag. | REV_APPROVE and moving back from VISUAL review need Clerical Supervisor (PS-Clerical-Supervisor). | There is no Clerical lead AD group. | Yes |
|
| Clerical "lead" actions (approve an M-Files rev mismatch, move back from VISUAL review) were open to any Clerical member with a lead flag. | REV_APPROVE and moving back from VISUAL review need Clerical Supervisor (PS-Clerical-Supervisor). | There is no Clerical lead AD group. | Yes |
|
||||||
| A packet routed to Shipping had no path to filed. | Complete packets routed to Shipping get the same Clerical close and file path. Only the Data Book route needs the VISUAL close before filing. | Shipping packets were stuck at complete. | Yes |
|
| A packet routed to Shipping had no path to filed. | Complete packets routed to Shipping get the same Clerical close and file path. A VISUAL packet files on either route only once the feed sees the job closed in VISUAL (Gitea issue 31, change C5). A hand packet needs the Clerical close confirm on the Data Book route only. | Shipping packets were stuck at complete. | Yes |
|
||||||
| Anyone could set the data book date, even on a filed packet. | SET_DATABOOK_AT is Clerical or Q.C. only (open question 17). It stays allowed on a filed packet. | The data book date is a Clerical and Q.C. record. | Yes |
|
| Anyone could set the data book date, even on a filed packet. | SET_DATABOOK_AT is Clerical or Q.C. only (open question 17). It stays allowed on a filed packet. | The data book date is a Clerical and Q.C. record. | Yes |
|
||||||
| Change-order verifications were stored in the packet document, with no rule against changing them later. | Verifications need a signature, are insert-only and cannot be withdrawn (open question 18). | A signed verification is a record. | Yes |
|
| Change-order verifications were stored in the packet document, with no rule against changing them later. | Verifications need a signature, are insert-only and cannot be withdrawn (open question 18). | A signed verification is a record. | Yes |
|
||||||
| The old policy did not look at the BOM when the warehouse released a packet. | WH_RELEASE needs every BOM line not marked n/a to be pulled or to have an open short. | A packet should reach the shop with its material, or with the shorts on record. | Yes |
|
| The old policy did not look at the BOM when the warehouse released a packet. | WH_RELEASE needs every BOM line not marked n/a to be pulled or to have an open short. | A packet should reach the shop with its material, or with the shorts on record. | Yes |
|
||||||
@@ -1027,9 +1078,13 @@ SethD answered on 2026-10-26: every row is kept as shown (Gitea issue 6), and he
|
|||||||
| The station clock ran in the browser, and the elapsed time was saved from the browser. | The server keeps the clock with the database time. No request sets the elapsed time. | The clock is a record. A browser clock can be wrong or changed. | No |
|
| The station clock ran in the browser, and the elapsed time was saved from the browser. | The server keeps the clock with the database time. No request sets the elapsed time. | The clock is a record. A browser clock can be wrong or changed. | No |
|
||||||
| Packet document kinds and Q.C. folder document types were whatever the screen sent. | Both are closed sets checked on the server (and by a CHECK in the database). | The data book sorts and labels by kind and type. | Yes |
|
| Packet document kinds and Q.C. folder document types were whatever the screen sent. | Both are closed sets checked on the server (and by a CHECK in the database). | The data book sorts and labels by kind and type. | Yes |
|
||||||
| A packet document could be taken out of the data book with no reason. | A take-out needs a reason of at least 3 characters. The row is kept with who, when and why. | A document leaving the record is a decision on record. | Yes |
|
| A packet document could be taken out of the data book with no reason. | A take-out needs a reason of at least 3 characters. The row is kept with who, when and why. | A document leaving the record is a decision on record. | Yes |
|
||||||
| A Q.C. reject took any non-empty text. | A reject needs a reason of at least 8 characters, or a well-formed NCR number, or both (open question 27). Changing: SethD decided on 2026-10-26 (Gitea issue 24) that a reason is always needed and acceptance needs an NCR number (PLAN.md change C2). | A short note like "no" says nothing to the supervisor. An NCR number points to the full record. | Yes |
|
| A Q.C. reject took any non-empty text. | A reject always needs a reason of at least 8 characters. An NCR number may go with it but no longer stands in for it. A packet rejected without an NCR number cannot be accepted until one is given (Gitea issue 24, change C2, built 2026-10-30; it replaced open question 27). | A short note like "no" says nothing to the supervisor. An NCR number points to the full record. | No (decided by SethD) |
|
||||||
| An ITP line rejected without an NCR could not be approved only because the screen disabled the button. | The server refuses the approval until the NCR number is entered (409 `ncr_required`). | The screen alone could be skipped. | Yes |
|
| An ITP line rejected without an NCR could not be approved only because the screen disabled the button. | The server refuses the approval until the NCR number is entered (409 `ncr_required`). | The screen alone could be skipped. | Yes |
|
||||||
| ITP block signatures and the sign-off were PIN checks in the browser. | Both are signatures verified on the server and stored with a content hash. | A signature is a record. | No |
|
| ITP block signatures and the sign-off were PIN checks in the browser. | Both are signatures verified on the server and stored with a content hash. | A signature is a record. | No |
|
||||||
| The ITP sign-off was offered when the screen saw every block signed. | The sign-off is a server rule: every block signed (409 `blocks_unsigned`), and a complete ITP refuses every write but the reopen. | The screen alone could be skipped. | Yes |
|
| The ITP sign-off was offered when the screen saw every block signed. | The sign-off is a server rule: every block signed (409 `blocks_unsigned`), and a complete ITP refuses every write but the reopen. | The screen alone could be skipped. | Yes |
|
||||||
| The screen limited removing an ITP line's initials to the same inspector or an admin, and refused it while the block was signed. Nothing else checked. | The server enforces both (open question 18). | The initials are that inspector's record. The screen alone could be skipped. | Yes |
|
| The screen limited removing an ITP line's initials to the same inspector or an admin, and refused it while the block was signed. Nothing else checked. | The server enforces both (open question 18). | The initials are that inspector's record. The screen alone could be skipped. | Yes |
|
||||||
| Any Q.C. user could remove a block signature. | Only the signer or an admin, while the ITP is open (open question 30). | A signature is that person's record. | Yes |
|
| Any Q.C. user could remove a block signature. | Only the signer or an admin, while the ITP is open (open question 30). | A signature is that person's record. | Yes |
|
||||||
|
| Clerical marked a job closed in VISUAL by hand, and nothing checked VISUAL. | On a VISUAL packet the feed sets the close when it sees every work order closed, and FILE is blocked while VISUAL shows one open, on both routes. Clerical confirms only hand packets (Gitea issue 31, change C5). | The data book should not be filed for a job still open in VISUAL. | No (decided by SethD) |
|
||||||
|
| A packet could be filed while its unit's ITP was still open. | FILE needs the unit's ITP complete when the unit has one (Gitea issue 25, change C3). | The data book should not be built from an incomplete ITP. | No (decided by SethD) |
|
||||||
|
| A received short needed no further check. | The department supervisor approves each receipt with a signature. Q.C. accept to complete and filing wait for every approval (Gitea issue 18, change C7). | The supervisor checks the part and its heat number against what the warehouse issued. | No (decided by SethD) |
|
||||||
|
| One checklist for weld departments and one for the rest, the same in every department of a kind. | Each department has its own checklist, edited by its supervisor or Admin. A stage keeps the list it started with (Gitea issue 23, change C8). | Each department knows its own work. A list changed during a stage would ask for items the work did not plan for. | No (decided by SethD) |
|
||||||
|
|||||||
+18
-7
@@ -1,6 +1,6 @@
|
|||||||
# Packet Station schema
|
# Packet Station schema
|
||||||
|
|
||||||
Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 5 adds the shop columns, the checklist templates and the clock rules (migration 0006). Phase 6 adds the Q.C. columns, the Q.C. check items, the Q.C. folder metadata, the ITP header, line and block details and the ITP log (migration 0007). Phase 7 adds the print log and the data book on the seal (migration 0008). Phase 8 adds no table and no migration: the PM report exports use new `print_log` kinds and presence uses two new session events. Phase 9 adds the notification rules and delivery, the integrity findings and the Admin profile switch (migration 0010). 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 8 adds no table and no migration: the PM report exports use new `print_log` kinds and presence uses two new session events. Phase 9 adds the notification rules and delivery, the integrity findings and the Admin profile switch (migration 0010). SethD's changes (built 2026-10-30) add the short approvals and the checklist frozen on each stage (migration 0011), and the per-department checklist settings rows. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema".
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ Smaller closed sets are text with a CHECK: `projects.source` and every `*_src` (
|
|||||||
`packets` is the header. The columns that matter:
|
`packets` is the header. The columns that matter:
|
||||||
|
|
||||||
- Placement: `unit_id`, `dept_code`, `op`, `title`, `customer`, `drawing`, `rev`, `wps`.
|
- Placement: `unit_id`, `dept_code`, `op`, `title`, `customer`, `drawing`, `rev`, `wps`.
|
||||||
- Workflow: `status` (packet_status), `status_at`, `station_id`, `welder_id` and `welder_stamp` (a snapshot taken at assign), `for_sup_id`, `wh_skipped` (true when Clerical sent the draft straight to a department or to Q.C.), `wh_sent_at`, `routed_to` (route_kind, set at Q.C. accept), `return_note` (the reason shown after a send-back), `vclosed_at` and `vclosed_by`, `databook_at`.
|
- Workflow: `status` (packet_status), `status_at`, `station_id`, `welder_id` and `welder_stamp` (a snapshot taken at assign), `for_sup_id`, `wh_skipped` (true when Clerical sent the draft straight to a department or to Q.C.), `wh_sent_at`, `routed_to` (route_kind, set at Q.C. accept), `return_note` (the reason shown after a send-back), `vclosed_at` and `vclosed_by` (closed in VISUAL: on a VISUAL packet the feed writes it with `vclosed_by` null, on a hand packet Clerical; see "Seth's changes"), `databook_at`.
|
||||||
- Preconditions: `heat_map` is nullable on purpose. Null means not answered yet, and the warehouse step needs an answer.
|
- Preconditions: `heat_map` is nullable on purpose. Null means not answered yet, and the warehouse step needs an answer.
|
||||||
- Source: `source_kind` (visual or hand), `source` jsonb, `visual_key` (the feed's match key), `visual_rel`, `visual_want`, `drawing_state`, `flags` text[], `visual_fp_applied` (the fingerprint the packet matches), `visual_closed_at`, `visual` jsonb (system-written sub-keys).
|
- Source: `source_kind` (visual or hand), `source` jsonb, `visual_key` (the feed's match key), `visual_rel`, `visual_want`, `drawing_state`, `flags` text[], `visual_fp_applied` (the fingerprint the packet matches), `visual_closed_at`, `visual` jsonb (system-written sub-keys).
|
||||||
- Counters: `next_weld_no`, `next_heat_no`, `next_page_no` and `next_co_no`, all starting at 1. See "Versioning".
|
- Counters: `next_weld_no`, `next_heat_no`, `next_page_no` and `next_co_no`, all starting at 1. See "Versioning".
|
||||||
@@ -82,6 +82,7 @@ The trigger `ps_packet_touch` sets `updated_at` on every UPDATE. It does not tou
|
|||||||
- `bom_lines`: `item`, `qty`, `part_id` (text), `description`, `dwg_ref`, `cl_item`, `length`, `material`, `heat`, `na`, `issued`, `loc`, `pulled`, `pulled_at`, `pulled_by`, `mtr_asset_id`, `src`, `vkey`, `whs`, `comm`, `cons`, `sub`, `grp`, `sort`, `row_version`. Unique on (`packet_id`, `item`).
|
- `bom_lines`: `item`, `qty`, `part_id` (text), `description`, `dwg_ref`, `cl_item`, `length`, `material`, `heat`, `na`, `issued`, `loc`, `pulled`, `pulled_at`, `pulled_by`, `mtr_asset_id`, `src`, `vkey`, `whs`, `comm`, `cons`, `sub`, `grp`, `sort`, `row_version`. Unique on (`packet_id`, `item`).
|
||||||
- `bom_shorts`: `bom_line_id` (cascades with the line), `qty`, `qty_open`, `reason`, `ref`, `eta`, `note`, `opened_at`, `opened_by`, `closed_at`, `row_version`. At most one open short per line (unique on `bom_line_id` where `closed_at` is null).
|
- `bom_shorts`: `bom_line_id` (cascades with the line), `qty`, `qty_open`, `reason`, `ref`, `eta`, `note`, `opened_at`, `opened_by`, `closed_at`, `row_version`. At most one open short per line (unique on `bom_line_id` where `closed_at` is null).
|
||||||
- `short_receipts`: append-only. `short_id`, `packet_id`, `qty`, `heat`, `note`, `at`, `by`.
|
- `short_receipts`: append-only. `short_id`, `packet_id`, `qty`, `heat`, `note`, `at`, `by`.
|
||||||
|
- `short_approvals` (migration 0011): append-only. The supervisor's approval of one receipt. See "Seth's changes".
|
||||||
|
|
||||||
What the warehouse columns mean in practice (phase 4):
|
What the warehouse columns mean in practice (phase 4):
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ What the warehouse columns mean in practice (phase 4):
|
|||||||
- `bom_shorts`: one open short per line at most. Opening a short sets the line's `issued` to `qty` minus the short quantity and ticks it pulled. `qty` is what was short in total, `qty_open` what is still missing. `reason` is one of the closed short reasons, `ref` the PO or requisition, `eta` the expected date. Editing an open short keeps what was already received in `qty`. "Not short after all" closes it (`closed_at`) and sets `issued` to the line's `qty`. While a short is open, the line's heat, issued and location change only through the short.
|
- `bom_shorts`: one open short per line at most. Opening a short sets the line's `issued` to `qty` minus the short quantity and ticks it pulled. `qty` is what was short in total, `qty_open` what is still missing. `reason` is one of the closed short reasons, `ref` the PO or requisition, `eta` the expected date. Editing an open short keeps what was already received in `qty`. "Not short after all" closes it (`closed_at`) and sets `issued` to the line's `qty`. While a short is open, the line's heat, issued and location change only through the short.
|
||||||
- `short_receipts`: one row per delivery, never changed. Each receipt lowers `qty_open`, adds its pieces to the line's `issued` and its heat to the line's `heat`. The receipt that brings `qty_open` to 0 closes the short.
|
- `short_receipts`: one row per delivery, never changed. Each receipt lowers `qty_open`, adds its pieces to the line's `issued` and its heat to the line's `heat`. The receipt that brings `qty_open` to 0 closes the short.
|
||||||
- `packet_wh`: `reason` is one of the closed hold reasons when the stage is `hold`, and `note` is up to 200 characters.
|
- `packet_wh`: `reason` is one of the closed hold reasons when the stage is `hold`, and `note` is up to 200 characters.
|
||||||
- `packet_stages`: one row per visit to a shop department. `dept_code`, `station_id`, `welder_id`, `welder_stamp`, `checklist` jsonb (key to `{done, at, by, stamp}`, written per key with `jsonb_set`), `filler`, `filler_lot`, `preheat`, `notes`, the clock (`clock_elapsed_ms`, `clock_running_since`, `clock_started_at`; only the server writes them, see "The clock"), `qc_snapshot`, `opened_at`, `closed_at`, `row_version`. At most one open stage per packet.
|
- `packet_stages`: one row per visit to a shop department. `dept_code`, `station_id`, `welder_id`, `welder_stamp`, `checklist` jsonb (key to `{done, at, by, stamp}`, written per key with `jsonb_set`), `filler`, `filler_lot`, `preheat`, `notes`, the clock (`clock_elapsed_ms`, `clock_running_since`, `clock_started_at`; only the server writes them, see "The clock"), `qc_snapshot`, `opened_at`, `closed_at`, `row_version`. At most one open stage per packet. Migration 0011 adds `checklist_template`, the list frozen at START (see "Seth's changes").
|
||||||
- `welds`: `weld_no` (from `next_weld_no`), `page`, `fx` and `fy` (fractions of the page), `bfx` and `bfy` (0006), `wo_base`, `piece`, `items` int[], `type`, `size`, `process`, `stamp`, `welder_id`, `dept_code`, `stage_id`, `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `weld_no`) among live rows.
|
- `welds`: `weld_no` (from `next_weld_no`), `page`, `fx` and `fy` (fractions of the page), `bfx` and `bfy` (0006), `wo_base`, `piece`, `items` int[], `type`, `size`, `process`, `stamp`, `welder_id`, `dept_code`, `stage_id`, `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `weld_no`) among live rows.
|
||||||
- `heat_tags`: `tag_no` (from `next_heat_no`), `page`, `item`, `fx`, `fy`, `bfx` and `bfy` (0006), `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `tag_no`) among live rows.
|
- `heat_tags`: `tag_no` (from `next_heat_no`), `page`, `item`, `fx`, `fy`, `bfx` and `bfy` (0006), `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `tag_no`) among live rows.
|
||||||
- `heat_table_placements`: `page`, `x`, `y`, `s`, `carried_from` (0006), `by`, `at`. Unique on (`packet_id`, `page`), written as an upsert.
|
- `heat_table_placements`: `page`, `x`, `y`, `s`, `carried_from` (0006), `by`, `at`. Unique on (`packet_id`, `page`), written as an upsert.
|
||||||
@@ -154,9 +155,9 @@ The `settings` row `checklist_templates` holds the shop checklists. Migration 00
|
|||||||
| plain | Before starting | `dwg`, `prev`, `mat` |
|
| plain | Before starting | `dwg`, `prev`, `mat` |
|
||||||
| plain | When done | `work`, `insp`, `qty` |
|
| plain | When done | `work`, `insp`, `qty` |
|
||||||
|
|
||||||
When the row is missing or malformed, the defaults in `domain/checklists.py` apply and a warning is logged. The read model and the checklist write both use the loaded templates. No screen edits the row in phase 5. Phase 9 (Admin) adds that.
|
When the row is missing or malformed, the defaults in `domain/checklists.py` apply and a warning is logged. The read model and the checklist write both use the loaded templates. No screen edits the row in phase 5. Phase 9 (Admin) adds that. Since change C8 (built 2026-10-30) this row holds the default lists by kind, and each department may have its own list (see "Seth's changes").
|
||||||
|
|
||||||
`packet_stages.checklist` maps each key to `{done, at, by, stamp}`. `by` is the user id. `stamp` is the user's `welder_stamp`, else the packet's. A key not in the department's list is 422.
|
`packet_stages.checklist` maps each key to `{done, at, by, stamp}`. `by` is the user id. `stamp` is the user's `welder_stamp`, else the packet's. A key not in the stage's list (the frozen `checklist_template`, else the department's live list) is 422.
|
||||||
|
|
||||||
### Carried marks
|
### Carried marks
|
||||||
|
|
||||||
@@ -238,7 +239,7 @@ The texts may hold `{wps}` (else `the WPS`), `{stamp}` (`[75] ` with a trailing
|
|||||||
|
|
||||||
### Column additions
|
### 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.
|
- `packet_seals`: `databook_asset_id` (uuid, references `assets`), the data book PDF built and stored when the packet was filed. `databook_pages` (int), its page count. `open_wos` (text[]), the work orders the feed showed open in VISUAL when Clerical filed anyway, as `wo_base/lot` text (`L489052/1`), or null. Since change C5 (built 2026-10-30) filing is blocked while VISUAL shows a work order open, so new seals leave it null; older seals keep theirs.
|
||||||
|
|
||||||
### The print log
|
### The print log
|
||||||
|
|
||||||
@@ -339,7 +340,7 @@ A unit folder shows its own current rows and inherits the project's current rows
|
|||||||
|
|
||||||
These tables have the `ps_append_only` row and TRUNCATE triggers, and `ps_app` holds only SELECT and INSERT on them:
|
These tables have the `ps_append_only` row and TRUNCATE triggers, and `ps_app` holds only SELECT and INSERT on them:
|
||||||
|
|
||||||
`audit_events`, `signin_attempts`, `signatures`, `project_renumbers`, `short_receipts`, `packet_transitions`, `qc_history`, `packet_log`, `itp_rejects`, `itp_log` (0007), `print_log` (0008), `notifications_sent` and `integrity_findings` (0010), `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), `notifications_sent` and `integrity_findings` (0010), `short_approvals` (0011), `co_verifications`.
|
||||||
|
|
||||||
The app role is refused by privilege first. The migrate role owns the tables, so the trigger refuses it. The views `v_units` and `v_open_cos` are SELECT only for `ps_app`.
|
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`.
|
||||||
|
|
||||||
@@ -378,6 +379,16 @@ Phase 9, migration 0010, builds the delivery on this table:
|
|||||||
- `integrity_findings`: append-only. One row per thing the integrity job found wrong: `run_id` (no foreign key, since housekeeping prunes `job_runs`), `at`, `kind` (seal, asset_hash, asset_missing, blob_orphan, audit_chain), `subject` (the packet, asset, file path or audit id), `detail` jsonb.
|
- `integrity_findings`: append-only. One row per thing the integrity job found wrong: `run_id` (no foreign key, since housekeeping prunes `job_runs`), `at`, `kind` (seal, asset_hash, asset_missing, blob_orphan, audit_chain), `subject` (the packet, asset, file path or audit id), `detail` jsonb.
|
||||||
- Settings rows used by phase 9: `security` (the Admin's overrides of the session, lockout and rate-limit settings; missing keys fall back to the environment), `checklist_templates` (now edited on the Admin Checklists tab), `notify.last_build` (the build the worker last announced).
|
- Settings rows used by phase 9: `security` (the Admin's overrides of the session, lockout and rate-limit settings; missing keys fall back to the environment), `checklist_templates` (now edited on the Admin Checklists tab), `notify.last_build` (the build the worker last announced).
|
||||||
|
|
||||||
|
## Seth's changes (built 2026-10-30, migration 0011)
|
||||||
|
|
||||||
|
Migration 0011 serves changes C7 and C8 in [PLAN.md](PLAN.md). The others need no schema.
|
||||||
|
|
||||||
|
- `short_approvals` (new, append-only; Gitea issue 18, change C7): the department supervisor's approval of one short receipt. `receipt_id` (unique, references `short_receipts`), `short_id` (references `bom_shorts`), `packet_id`, `dept_code` (the department the packet was in at the approval), `note`, `signature_id`, `at`, `by`. Indexed on `packet_id`. A second approval of a receipt breaks the unique key; the API answers 409 `already_approved`. The packet read and the board cards count `shorts_unapproved`: open shorts plus receipts with no row here.
|
||||||
|
- `packet_stages.checklist_template` (jsonb, new; Gitea issue 23, change C8): the checklist the stage froze at START, as a list of `{group, key, text}` with the placeholders not filled. NULL means the stage has not started and reads its department's live list. START sets it once, only when NULL. Every reader uses it when set. 0011 backfills it on the open stage of every packet in progress, in review or at Q.C. with the default list for the stage's kind from the `checklist_templates` row, so a department edit does not reach work already started. When that row is missing or its list is not an array, the stage stays NULL and reads the department's live list until it closes.
|
||||||
|
- Settings rows `checklist:<dept code>` (no migration; for example `checklist:03`): one department's own checklist, `{"items": [{group, key, text}, ...]}`. `{"items": null}` sets the department back to the default for its kind and keeps the row's `version` for compare and set. A row that does not parse is logged and the default applies. A department with no row uses the default for its kind: the `weld` or `plain` list of `checklist_templates`, else the code's defaults. The department's Supervisor or an Admin saves it through `PUT /api/checklists/{dept}` (see [POLICY.md](POLICY.md), "Checklists").
|
||||||
|
- `packets.vclosed_at` and `vclosed_by` (no migration; Gitea issue 31, change C5): on a VISUAL packet the feed is now the one writer. It sets `vclosed_at` (with `vclosed_by` null) when every leg is closed or cancelled and clears both when a leg shows open again. On a hand packet Clerical still sets them. See [FEED.md](FEED.md), "The apply".
|
||||||
|
- `qc_inspections.ncr` (no migration; Gitea issue 24, change C2) is now also written by the accept that carries the NCR number of an earlier reject.
|
||||||
|
|
||||||
## VISUAL and drawing caches (phase 3)
|
## VISUAL and drawing caches (phase 3)
|
||||||
|
|
||||||
Migration 0004. Every ID part is text (`wo_base`, `lot`, `split`, `sub`, part IDs, order IDs), with `''` in place of null in a key. `seq` and `piece` are int. Closed sets are text with a CHECK. The worker fills these tables. Screens read them, never VISUAL. [FEED.md](FEED.md) says how each is written.
|
Migration 0004. Every ID part is text (`wo_base`, `lot`, `split`, `sub`, part IDs, order IDs), with `''` in place of null in a key. `seq` and `piece` are int. Closed sets are text with a CHECK. The worker fills these tables. Screens read them, never VISUAL. [FEED.md](FEED.md) says how each is written.
|
||||||
|
|||||||
+21
-9
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This document is rewritten for the server build. The old browser build's security document is in the old repo (`C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md`). The design comes from [BRIEF.md](BRIEF.md) and [PLAN.md](PLAN.md).
|
This document is rewritten for the server build. The old browser build's security document is in the old repo (`C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md`). The design comes from [BRIEF.md](BRIEF.md) and [PLAN.md](PLAN.md).
|
||||||
|
|
||||||
Status: phase 9. 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. Phase 8 adds the Project Management reads and exports, the server-side find bar filters and the presence rules (display name and role only, locked and revoked sessions dropped). No requirement ID closes in phase 8. Phase 9 adds the nightly AD reconcile and the 15-minute role refresh, the integrity job with its alert, email notifications over verified STARTTLS with security events nobody can mute, the security alerts and the Security tab settings, the Admin tabs (every change audited), nightly backups with a rehearsed restore, and dependency scanning in CI. Phase 9 closes the app part of I6, M3, O1, O2 and S3. Every other control names the phase that builds it.
|
Status: phase 9. 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. Phase 8 adds the Project Management reads and exports, the server-side find bar filters and the presence rules (display name and role only, locked and revoked sessions dropped). No requirement ID closes in phase 8. Phase 9 adds the nightly AD reconcile and the 15-minute role refresh, the integrity job with its alert, email notifications over verified STARTTLS with security events nobody can mute, the security alerts and the Security tab settings, the Admin tabs (every change audited), nightly backups with a rehearsed restore, and dependency scanning in CI. Phase 9 closes the app part of I6, M3, O1, O2 and S3. On 2026-10-30 SethD's changes C1 to C11 were built (see "SethD's changes (built 2026-10-30)"); they close D7. Every other control names the phase that builds it.
|
||||||
|
|
||||||
Contents:
|
Contents:
|
||||||
|
|
||||||
@@ -74,9 +74,9 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
|
|||||||
- One policy in Python on the server (`server/packetstation/policy/`), pure functions with no I/O. Every write goes: session, policy, transition, validation, version check, packet log row, audit row, NOTIFY, in one transaction with the packet row locked. Nothing in the browser decides.
|
- One policy in Python on the server (`server/packetstation/policy/`), pure functions with no I/O. Every write goes: session, policy, transition, validation, version check, packet log row, audit row, NOTIFY, in one transaction with the packet row locked. Nothing in the browser decides.
|
||||||
- Every packet read returns `can: [...]`, `blocked: {...}` and `holder`, so the browser enables controls from the server's answer. The server re-checks on the write.
|
- Every packet read returns `can: [...]`, `blocked: {...}` and `holder`, so the browser enables controls from the server's answer. The server re-checks on the write.
|
||||||
- A workflow step taken twice is refused with 409 and the current state (compare-and-set on the expected status and department). Child rows carry a row version checked with If-Match. Weld, heat tag, page and change order numbers are assigned by the server under the lock.
|
- A workflow step taken twice is refused with 409 and the current state (compare-and-set on the expected status and department). Child rows carry a row version checked with If-Match. Weld, heat tag, page and change order numbers are assigned by the server under the lock.
|
||||||
- Preconditions (heat map answered, drawing attached, BOM pulled or short, heats recorded on pulled lines, checklist complete, welds recorded, Q.C. items complete, closed in VISUAL before filing) run on the server and are never bypassed, Admin included.
|
- Preconditions (heat map answered, drawing attached, BOM pulled or short, heats recorded on pulled lines, checklist complete, welds recorded, Q.C. items complete, an NCR number on the accept after a reject without one, every short received and approved before the Q.C. accept to complete and before filing, the unit's ITP complete before filing, closed in VISUAL before filing) run on the server and are never bypassed, Admin included.
|
||||||
- Admin edits outside custody need an IT reason of at least 8 characters, recorded in the packet log and the audit row (`it_reason`). An Admin cannot write a filed packet except through the reopen action, which needs a reason and a signature and breaks the seal.
|
- Admin edits outside custody need an IT reason of at least 8 characters, recorded in the packet log and the audit row (`it_reason`). An Admin cannot write a filed packet except through the reopen action, which needs a reason and a signature and breaks the seal.
|
||||||
- Signatures (PIN or domain password, verified in the same transaction; the PIN is enough everywhere, Q.C. accept and filing included, confirmed by SethD on 2026-10-26, Gitea issue 2) on submit, send to Q.C., Q.C. accept and reject, route, file, reopen, change order issue, approve, reject and verify, the revision approval and hand drawing uploads.
|
- Signatures (PIN or domain password, verified in the same transaction; the PIN is enough everywhere, Q.C. accept and filing included, confirmed by SethD on 2026-10-26, Gitea issue 2) on submit, send to Q.C., Q.C. accept and reject, route, file, reopen, change order issue, approve, reject and verify, the revision approval, hand drawing uploads and the supervisor's approval of a received short.
|
||||||
- Input: every string is cleaned (control and invisible characters removed, NFC, trimmed) and cut to its field limit; heat numbers, stamps, project numbers and lots are checked against the old patterns; JSON bodies over 200 KB are refused.
|
- Input: every string is cleaned (control and invisible characters removed, NFC, trimmed) and cut to its field limit; heat numbers, stamps, project numbers and lots are checked against the old patterns; JSON bodies over 200 KB are refused.
|
||||||
- Uploads: typed by content (PDF, PNG, JPEG), 25 MB limit read in chunks, sha256, stored by id under the assets volume, served only with a session and with `Content-Disposition` and `X-Content-Type-Options: nosniff`. Phase 4 completes it (see "Uploads and storage").
|
- Uploads: typed by content (PDF, PNG, JPEG), 25 MB limit read in chunks, sha256, stored by id under the assets volume, served only with a session and with `Content-Disposition` and `X-Content-Type-Options: nosniff`. Phase 4 completes it (see "Uploads and storage").
|
||||||
- The filed lock: a filed packet accepts only the data book date and the Admin reopen. Filing writes an HMAC-SHA256 seal with the server seal key; a reopen keeps the old seal with who broke it and why.
|
- The filed lock: a filed packet accepts only the data book date and the Admin reopen. Filing writes an HMAC-SHA256 seal with the server seal key; a reopen keeps the old seal with who broke it and why.
|
||||||
@@ -157,7 +157,7 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
|
|||||||
- 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.
|
- Every PDF and the CSV are built on the server. The browser only opens the URL. Reads need a signed-in session; every role that can open the packet may read them, PM included.
|
||||||
- Controlled-copy stamp (S7): every exported page carries `Controlled copy - exported by Name (emp) <UTC time>`, the seal's first 12 hex or `not yet filed`, and ` - stamp <sig>`. The filed data book is the stored copy, served as it was built.
|
- Controlled-copy stamp (S7): every exported page carries `Controlled copy - exported by Name (emp) <UTC time>`, the seal's first 12 hex or `not yet filed`, and ` - stamp <sig>`. The filed data book is the stored copy, served as it was built.
|
||||||
- Printed-copy stamp (S8): every printed page carries `PRINTED COPY - <what> - printed by Name (emp) <UTC time> - <project> Lot <unit> dwg <drawing> R<rev> - verify revision before use` and the stamp signature, in a white box at the bottom of the page.
|
- Printed-copy stamp (S8): every printed page carries `PRINTED COPY - <what> - printed by Name (emp) <UTC time> - <project> Lot <unit> dwg <drawing> R<rev> - verify revision before use` and the stamp signature, in a white box at the bottom of the page.
|
||||||
- Stamp signature: 16 hex of HMAC-SHA256 with the seal key over the packet, the kind (a printed copy signs `print:<kind>`, so a print and an export of the same thing in the same minute never share a signature), the seal hash, the user and the time. A copy can be checked against the server with `verify_stamp`; from phase 9 the Admin Stamp check tab does it from a pasted line or a PDF.
|
- Stamp signature: 16 hex of HMAC-SHA256 with the seal key over the packet, the kind (a printed copy signs `print:<kind>`, so a print and an export of the same thing in the same minute never share a signature), the seal hash, the user and the time. A copy can be checked against the server with `verify_stamp`; from phase 9 the Admin Stamp check tab does it from a pasted line or a PDF, and from 2026-10-30 Q.C. and Clerical can too, on the Stamp check screen (Gitea issue 32, change C9).
|
||||||
- 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.
|
- 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.
|
- 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.
|
- PDF metadata: title, subject and keywords name the packet and the stamp signature; Author is PESCO and Producer is Packet Station.
|
||||||
@@ -167,10 +167,10 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
|
|||||||
|
|
||||||
### Project Management, find bar and presence (phase 8)
|
### Project Management, find bar and presence (phase 8)
|
||||||
|
|
||||||
- Project Management reads only. Every endpoint under `/api/pm` is a GET and needs a signed-in session. Any role may read them, as any role may open any packet. The PM role passes no write action anywhere. The reports read PostgreSQL only, never VISUAL or M-Files.
|
- Project Management reads only. Every endpoint under `/api/pm` is a GET and needs a PS-ProjectMgmt or Admin session; every other role is 403 (Gitea issue 33, change C10, built 2026-10-30; phase 8 let any signed-in role read them). The PM role passes no write action anywhere. The reports read PostgreSQL only, never VISUAL or M-Files.
|
||||||
- Every PM export is built on the server and logged: one `print_log` row (kind `report-csv` or `report-pdf`, no packet, the scope label in `what`, the page count for the PDF) and one audit row with the action `report exported` and the detail `{name, format, scope, period}`. `print_log` stays append-only.
|
- Every PM export is built on the server and logged: one `print_log` row (kind `report-csv` or `report-pdf`, no packet, the scope label in `what`, the page count for the PDF) and one audit row with the action `report exported` and the detail `{name, format, scope, period}`. `print_log` stays append-only.
|
||||||
- The PM CSV has the formula guard on every cell (S6), a BOM and CRLF line ends. The PDF report carries the controlled-copy stamp on every page and the PDF metadata. Both answer with `Content-Disposition`, `nosniff` and `no-store`, as the phase 7 exports do.
|
- The PM CSV has the formula guard on every cell (S6), a BOM and CRLF line ends. The PDF report carries the controlled-copy stamp on every page and the PDF metadata. Both answer with `Content-Disposition`, `nosniff` and `no-store`, as the phase 7 exports do.
|
||||||
- Filters never widen access. The search, the status, flag, station, worker and Mine filters are applied by the server inside each list's and board's own scope (a supervisor's departments, the stall's station). A filter can only narrow what the person may already read.
|
- Filters never widen access. The search, the status, flag, station, worker and Mine filters are applied by the server inside each list's and board's own scope (a supervisor's departments, the stall's own assignments). A filter can only narrow what the person may already read.
|
||||||
- Search words are bound parameters, matched with ILIKE and the LIKE escape. A query keeps at most 8 words of at most 60 characters each. IDs are matched as text, never cast.
|
- Search words are bound parameters, matched with ILIKE and the LIKE escape. A query keeps at most 8 words of at most 60 characters each. IDs are matched as text, never cast.
|
||||||
- Mine is decided on the server from the person's AD roles and the screen they name. Nothing the browser keeps can widen it.
|
- Mine is decided on the server from the person's AD roles and the screen they name. Nothing the browser keeps can widen it.
|
||||||
- Presence shows the display name and the role only (the user id, screen, packet id and since travel with it). No employee ID, no email, no other personal data. The server builds it from the session, not from what the browser says about itself.
|
- Presence shows the display name and the role only (the user id, screen, packet id and since travel with it). No employee ID, no email, no other personal data. The server builds it from the session, not from what the browser says about itself.
|
||||||
@@ -244,10 +244,21 @@ The account limit is capped at 15 so the app always stops before AD's own lockou
|
|||||||
### Admin (phase 9)
|
### Admin (phase 9)
|
||||||
|
|
||||||
- Every Admin endpoint needs PS-Admins in a password session. Every Admin change is audited with before and after. Tests try every endpoint as every role.
|
- Every Admin endpoint needs PS-Admins in a password session. Every Admin change is audited with before and after. Tests try every endpoint as every role.
|
||||||
- Roles still come only from AD. The Admin edits departments (name, stations), profiles (stamp, profile active, station limits), checklists, the security settings and the notification rules. None of these grants a role. Turning a profile off hides the person from pickers and changes no right.
|
- Roles still come only from AD. The Admin edits departments (name, stations), profiles (stamp, profile active, station limits), the checklists (any department's, and the default lists by kind), the security settings and the notification rules. None of these grants a role. Turning a profile off hides the person from pickers and changes no right.
|
||||||
- Department codes and short names never change (a trigger, and 422 on the API). Stations are never deleted.
|
- Department codes and short names never change (a trigger, and 422 on the API). Stations are never deleted.
|
||||||
- The access log CSV has the formula guard, at most 50,000 rows, and is logged in `print_log` and the audit.
|
- The access log CSV has the formula guard, at most 50,000 rows, and is logged in `print_log` and the audit.
|
||||||
- The stamp check recomputes the stamp signature with the seal key and compares the text with the print log (S7, S8). It reads a pasted line or a PDF of up to 25 MB.
|
- The stamp check recomputes the stamp signature with the seal key and compares the text with the print log (S7, S8). It reads a pasted line or a PDF of up to 25 MB. From 2026-10-30 it is open to Q.C. and Clerical as well (see below).
|
||||||
|
|
||||||
|
### SethD's changes (built 2026-10-30)
|
||||||
|
|
||||||
|
- Stamp check (Gitea issue 32, change C9): `POST /api/stamp-verify` and `/api/stamp-verify/pdf` (and the `/api/admin` aliases) are for Admin, every Q.C. role and every Clerical role, in a password session. A PIN session is 403 `password_session_required`; every other role is 403. Each check is audited (`admin.stamp_verified`). The seal key never leaves the server, and the check never trusts the pasted text.
|
||||||
|
- Project Management (Gitea issue 33, change C10): every `/api/pm/*` endpoint, the CSV and PDF exports included, is for PS-ProjectMgmt and Admin only. Tests try every endpoint as Clerical, worker, Q.C., Warehouse and shop supervisor and expect 403.
|
||||||
|
- Department checklists (Gitea issue 23, change C8): any session reads them. A save needs that department's Supervisor or an Admin, in a password session (a PIN session is 403 `password_session_required`), with the version it started from. A lead, Q.C. and the office roles cannot save. Each save writes an audit row `checklists.dept_updated` with the list before and after. A stage keeps the list frozen at its START, so an edit cannot change what an open stage must tick.
|
||||||
|
- Short approvals (Gitea issue 18, change C7): the department supervisor approves each receipt with a signature. `short_approvals` is append-only (triggers, and `ps_app` has SELECT and INSERT only), one row per receipt by a unique key.
|
||||||
|
- Filing (Gitea issues 25 and 31, changes C3 and C5): FILE needs the unit's ITP complete and, on a VISUAL packet, the feed's own observation that VISUAL closed every work order. Clerical cannot mark a VISUAL packet closed by hand. Neither can be bypassed, Admin included.
|
||||||
|
- Q.C. (Gitea issue 24, change C2): a reject always needs a reason of 8 characters; the accept after a reject without an NCR number needs one, checked by pattern on the server.
|
||||||
|
- The data book without the history pages (D7; Gitea issue 8, change C6): the one data book goes to the customer and leaves out the packet log. The history stays in Packet Station, readable by anyone who can open the packet, and in the audit.
|
||||||
|
- The Stall screen (Gitea issue 36, change C11): `/api/stall/mine` lists only the packets assigned to the signed-in person. The request names no station; the session decides.
|
||||||
|
|
||||||
### Backups (phase 9)
|
### Backups (phase 9)
|
||||||
|
|
||||||
@@ -298,7 +309,8 @@ The IDs come from Part 3 of the old SECURITY.md. The status follows the approved
|
|||||||
| D1, D2, D4 | Deferred (envelope encryption, Key Vault) |
|
| D1, D2, D4 | Deferred (envelope encryption, Key Vault) |
|
||||||
| D3 | IT's (host disk encryption on docker-1) |
|
| D3 | IT's (host disk encryption on docker-1) |
|
||||||
| 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; left off in both stacks by IT on 2026-10-18, Gitea issue 19, with Microsoft Defender on the workstations) |
|
| 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; left off in both stacks by IT on 2026-10-18, Gitea issue 19, with Microsoft Defender on the workstations) |
|
||||||
| D6, D7 | Phase 10 (decided 2026-10-28, Gitea issues 7 and 8: nothing is purged, filed packets are archived to the QNAP share at filing, and the one data book leaves out the history pages) |
|
| D6 | Phase 10 (decided 2026-10-28, Gitea issue 7: nothing is purged, and filed packets are archived to the QNAP share at filing) |
|
||||||
|
| D7 | Closed 2026-10-30 (decided 2026-10-28, Gitea issue 8, change C6: the one data book, the customer copy, is built without the history pages; the history stays in Packet Station and the audit) |
|
||||||
| M1 | Closed, phase 2 (append-only audit and packet log written only by the server; every packet write carries both) |
|
| M1 | Closed, phase 2 (append-only audit and packet log written only by the server; every packet write carries both) |
|
||||||
| M2 | Closed, phase 7 (filing seal is HMAC-SHA256 with the server seal key from a Docker secret; a reopen keeps the broken seal and its data book) |
|
| 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 (the nightly `integrity` job re-verifies seals, rehashes asset files, finds missing and orphan files and walks the audit hash chain; findings are kept in the append-only `integrity_findings` and raise `integrity_failure`, which nobody can opt out of) |
|
| M3 | Closed, phase 9 (the nightly `integrity` job re-verifies seals, rehashes asset files, finds missing and orphan files and walks the audit hash chain; findings are kept in the append-only `integrity_findings` and raise `integrity_failure`, which nobody can opt out of) |
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { PacketPage } from "./packet/PacketPage";
|
|||||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||||
import { SignInPage } from "./pages/SignInPage";
|
import { SignInPage } from "./pages/SignInPage";
|
||||||
import { AccountPage } from "./screens/account/AccountPage";
|
import { AccountPage } from "./screens/account/AccountPage";
|
||||||
|
import { ChecklistsPage } from "./screens/checklists/ChecklistsPage";
|
||||||
import { ProjectPage } from "./screens/clerical/ProjectPage";
|
import { ProjectPage } from "./screens/clerical/ProjectPage";
|
||||||
import { UnitPage } from "./screens/clerical/UnitPage";
|
import { UnitPage } from "./screens/clerical/UnitPage";
|
||||||
import { ItpPage } from "./screens/qc/ItpPage";
|
import { ItpPage } from "./screens/qc/ItpPage";
|
||||||
import { QcProjectFolder } from "./screens/qc/QcProjectFolder";
|
import { QcProjectFolder } from "./screens/qc/QcProjectFolder";
|
||||||
import { QcUnitFolder } from "./screens/qc/QcUnitFolder";
|
import { QcUnitFolder } from "./screens/qc/QcUnitFolder";
|
||||||
import { RequireScreen, ScreenRoute } from "./screens/ScreenRoute";
|
import { RequireScreen, ScreenRoute } from "./screens/ScreenRoute";
|
||||||
|
import { StampCheckPage } from "./screens/stamp/StampCheckPage";
|
||||||
|
|
||||||
/** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */
|
/** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
@@ -25,6 +27,8 @@ export function AppRoutes() {
|
|||||||
<Route path="about" element={<AboutPage />} />
|
<Route path="about" element={<AboutPage />} />
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route path="account" element={<AccountPage />} />
|
<Route path="account" element={<AccountPage />} />
|
||||||
|
<Route path="checklists" element={<ChecklistsPage />} />
|
||||||
|
<Route path="stamp-check" element={<StampCheckPage />} />
|
||||||
<Route path=":screen" element={<ScreenRoute />} />
|
<Route path=":screen" element={<ScreenRoute />} />
|
||||||
<Route
|
<Route
|
||||||
path="clerical/projects/:projectId"
|
path="clerical/projects/:projectId"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Phase 9 Admin: departments, profiles, checklists, security, access log, presence, stamp check, directory flags,
|
// Phase 9 Admin: departments, profiles, security, access log, presence, directory flags, the AD reconcile, and
|
||||||
// the AD reconcile, and notifications. Field names follow the phase 9 design contract exactly.
|
// notifications. The stamp check result type stays here; the calls are in stamp.ts (C9), the checklists in
|
||||||
|
// checklists.ts (C8). Field names follow the phase 9 design contract exactly.
|
||||||
import type { AdminUser } from "../auth/types";
|
import type { AdminUser } from "../auth/types";
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
import { qs } from "./query";
|
import { qs } from "./query";
|
||||||
@@ -96,25 +97,6 @@ export interface ImportAnswer {
|
|||||||
applied: boolean;
|
applied: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Checklists ---------- */
|
|
||||||
|
|
||||||
export interface ChecklistItem {
|
|
||||||
group: string;
|
|
||||||
key: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChecklistLists {
|
|
||||||
weld: ChecklistItem[];
|
|
||||||
plain: ChecklistItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChecklistsAnswer {
|
|
||||||
value: ChecklistLists;
|
|
||||||
defaults: ChecklistLists;
|
|
||||||
version: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Security ---------- */
|
/* ---------- Security ---------- */
|
||||||
|
|
||||||
export interface SecuritySettingsAnswer {
|
export interface SecuritySettingsAnswer {
|
||||||
@@ -367,9 +349,6 @@ export const admin9Api = {
|
|||||||
patchProfile: (id: string, patch: ProfilePatch) => api.patch<ProfileUser>(`${A}/users/${enc(id)}/profile`, patch),
|
patchProfile: (id: string, patch: ProfilePatch) => api.patch<ProfileUser>(`${A}/users/${enc(id)}/profile`, patch),
|
||||||
importProfiles: (csv: string, apply: boolean) => api.post<ImportAnswer>(`${A}/users/import`, { csv, apply }),
|
importProfiles: (csv: string, apply: boolean) => api.post<ImportAnswer>(`${A}/users/import`, { csv, apply }),
|
||||||
|
|
||||||
checklists: (signal?: AbortSignal) => api.get<ChecklistsAnswer>(`${A}/checklists`, signal),
|
|
||||||
saveChecklists: (value: ChecklistLists, version: number) => api.put<ChecklistsAnswer>(`${A}/checklists`, { value, version }),
|
|
||||||
|
|
||||||
securitySettings: (signal?: AbortSignal) => api.get<SecuritySettingsAnswer>(`${A}/security/settings`, signal),
|
securitySettings: (signal?: AbortSignal) => api.get<SecuritySettingsAnswer>(`${A}/security/settings`, signal),
|
||||||
saveSecuritySettings: (values: Record<string, number>, version: number) =>
|
saveSecuritySettings: (values: Record<string, number>, version: number) =>
|
||||||
api.put<SecuritySettingsAnswer>(`${A}/security/settings`, { values, version }),
|
api.put<SecuritySettingsAnswer>(`${A}/security/settings`, { values, version }),
|
||||||
@@ -382,13 +361,6 @@ export const admin9Api = {
|
|||||||
|
|
||||||
presence: (signal?: AbortSignal) => api.get<PresenceAnswer>(`${A}/presence`, signal),
|
presence: (signal?: AbortSignal) => api.get<PresenceAnswer>(`${A}/presence`, signal),
|
||||||
|
|
||||||
stampVerify: (text: string) => api.post<StampResult>(`${A}/stamp-verify`, { text }),
|
|
||||||
stampVerifyPdf: (file: File) => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("file", file);
|
|
||||||
return api.post<{ stamps: StampResult[] }>(`${A}/stamp-verify/pdf`, fd);
|
|
||||||
},
|
|
||||||
|
|
||||||
directoryFlags: (signal?: AbortSignal) => api.get<DirectoryFlagsAnswer>(`${A}/directory-flags`, signal),
|
directoryFlags: (signal?: AbortSignal) => api.get<DirectoryFlagsAnswer>(`${A}/directory-flags`, signal),
|
||||||
refreshAd: () => api.post<RefreshRequested>(`${A}/ad/refresh`, {}),
|
refreshAd: () => api.post<RefreshRequested>(`${A}/ad/refresh`, {}),
|
||||||
reconcile: (signal?: AbortSignal) => api.get<ReconcileAnswer>(`${A}/ad/reconcile`, signal),
|
reconcile: (signal?: AbortSignal) => api.get<ReconcileAnswer>(`${A}/ad/reconcile`, signal),
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// The department checklists (change C8). A department supervisor edits their own department's list, Admin any.
|
||||||
|
// A department without its own list uses the default for its kind (weld or plain). A stage freezes the list at START,
|
||||||
|
// so a change applies to stages not started yet.
|
||||||
|
import { api } from "./client";
|
||||||
|
import { asList } from "./query";
|
||||||
|
|
||||||
|
export interface ChecklistItem {
|
||||||
|
group: string;
|
||||||
|
key: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row of GET /api/checklists, for every shop department. */
|
||||||
|
export interface DeptChecklistRow {
|
||||||
|
dept_code: string;
|
||||||
|
dept_name: string;
|
||||||
|
weld: boolean;
|
||||||
|
/** The department has its own list. False: it uses the default for its kind. */
|
||||||
|
custom: boolean;
|
||||||
|
version: number;
|
||||||
|
/** The caller may save this department's list. */
|
||||||
|
editable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/checklists/{dept}, and the answer of a save. */
|
||||||
|
export interface DeptChecklist extends DeptChecklistRow {
|
||||||
|
items: ChecklistItem[];
|
||||||
|
/** The default list for the department's kind. */
|
||||||
|
defaults: ChecklistItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const enc = encodeURIComponent;
|
||||||
|
|
||||||
|
function list<T>(raw: unknown): T[] {
|
||||||
|
return Array.isArray(raw) ? (raw as T[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every list present, so the screen never trips on a missing field. */
|
||||||
|
export function normaliseChecklist(raw: Partial<DeptChecklist> | null | undefined, dept = ""): DeptChecklist {
|
||||||
|
return {
|
||||||
|
dept_code: raw?.dept_code ?? dept,
|
||||||
|
dept_name: raw?.dept_name ?? "",
|
||||||
|
weld: !!raw?.weld,
|
||||||
|
custom: !!raw?.custom,
|
||||||
|
version: typeof raw?.version === "number" ? raw.version : 0,
|
||||||
|
editable: !!raw?.editable,
|
||||||
|
items: list<ChecklistItem>(raw?.items),
|
||||||
|
defaults: list<ChecklistItem>(raw?.defaults),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const checklistsApi = {
|
||||||
|
list: async (signal?: AbortSignal) => asList<DeptChecklistRow>(await api.get<unknown>("/api/checklists", signal), "checklists"),
|
||||||
|
get: async (dept: string, signal?: AbortSignal) =>
|
||||||
|
normaliseChecklist(await api.get<Partial<DeptChecklist> | null>(`/api/checklists/${enc(dept)}`, signal), dept),
|
||||||
|
/** 409 with the current list when `base` is stale; 403 when the caller may not edit this department. */
|
||||||
|
save: async (dept: string, items: ChecklistItem[], base: number) =>
|
||||||
|
normaliseChecklist(await api.put<Partial<DeptChecklist> | null>(`/api/checklists/${enc(dept)}`, { items, base }), dept),
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
QcFields,
|
QcFields,
|
||||||
ReceiptBody,
|
ReceiptBody,
|
||||||
SealView,
|
SealView,
|
||||||
|
ShortApproveBody,
|
||||||
ShortBody,
|
ShortBody,
|
||||||
StageFields,
|
StageFields,
|
||||||
TransitionBody,
|
TransitionBody,
|
||||||
@@ -104,6 +105,9 @@ export const packetsApi = {
|
|||||||
api.delWithBody<PacketRead>(`${base(id)}/bom/${item}/short`, { ...body, ...g }),
|
api.delWithBody<PacketRead>(`${base(id)}/bom/${item}/short`, { ...body, ...g }),
|
||||||
receiveShort: (id: number, item: number, body: ReceiptBody, g: Guarded = {}) =>
|
receiveShort: (id: number, item: number, body: ReceiptBody, g: Guarded = {}) =>
|
||||||
api.post<PacketRead>(`${base(id)}/bom/${item}/short/receipts`, { ...body, ...g }),
|
api.post<PacketRead>(`${base(id)}/bom/${item}/short/receipts`, { ...body, ...g }),
|
||||||
|
/** Change C7: the department supervisor approves one received short. Signed. */
|
||||||
|
approveShortReceipt: (id: number, receiptId: number, body: ShortApproveBody = {}, g: Guarded = {}) =>
|
||||||
|
api.post<PacketRead>(`${base(id)}/shorts/receipts/${encodeURIComponent(String(receiptId))}/approve`, { ...body, ...g }),
|
||||||
setWhStage: (id: number, body: WhStageBody, g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/wh`, { ...body, ...g }),
|
setWhStage: (id: number, body: WhStageBody, g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/wh`, { ...body, ...g }),
|
||||||
|
|
||||||
/* Phase 5: the shop. Every answer is the packet. */
|
/* Phase 5: the shop. Every answer is the packet. */
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// The Stall screen's own call (change C11): the packets assigned to me, across stations. Nobody picks a station on
|
||||||
|
// the Stall screen; each card carries the station the supervisor picked when assigning.
|
||||||
|
import { api } from "./client";
|
||||||
|
import { qs } from "./query";
|
||||||
|
import { normaliseCard, type ShopCard } from "./shop";
|
||||||
|
import { normaliseFacets, type BoardQuery, type Facets } from "./types";
|
||||||
|
|
||||||
|
/** A stall card: the shop card plus the station it is assigned to. */
|
||||||
|
export interface StallCard extends ShopCard {
|
||||||
|
station_id: number | null;
|
||||||
|
station_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/stall/mine: the stall board shape, without a station. */
|
||||||
|
export interface StallMine {
|
||||||
|
mine: StallCard[];
|
||||||
|
submitted: StallCard[];
|
||||||
|
facets?: Facets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cards(raw: unknown): StallCard[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return (raw as Array<Partial<StallCard> & ShopCard>).map((c) => {
|
||||||
|
const card = normaliseCard(c);
|
||||||
|
const id = c.station_id ?? c.station?.id ?? null;
|
||||||
|
const name = c.station_name ?? c.station?.name ?? null;
|
||||||
|
// The card's own station line reads `station`; fill it from the flat fields when the server sends only those.
|
||||||
|
const station = card.station ?? (id !== null ? { id, name: name ?? "" } : null);
|
||||||
|
return { ...card, station, station_id: id, station_name: name };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The find bar's query applies; `me` and the session decide whose packets. */
|
||||||
|
export async function getStallMine(q: BoardQuery = {}, signal?: AbortSignal): Promise<StallMine> {
|
||||||
|
const raw = await api.get<Partial<StallMine> | null>(`/api/stall/mine${qs({ ...q })}`, signal);
|
||||||
|
const facets = normaliseFacets(raw?.facets);
|
||||||
|
return { mine: cards(raw?.mine), submitted: cards(raw?.submitted), ...(facets ? { facets } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stallApi = { getStallMine };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// The stamp check (change C9): Admin, Q.C. and Clerical check a printed or saved copy against the print log.
|
||||||
|
// /api/stamp-verify and /api/stamp-verify/pdf take the same bodies and answer the same as the /api/admin aliases.
|
||||||
|
import type { StampResult } from "./admin";
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export type { StampResult };
|
||||||
|
|
||||||
|
export const stampApi = {
|
||||||
|
verify: (text: string) => api.post<StampResult>("/api/stamp-verify", { text }),
|
||||||
|
verifyPdf: (file: File) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
return api.post<{ stamps: StampResult[] }>("/api/stamp-verify/pdf", fd);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -161,6 +161,8 @@ export interface PacketCard {
|
|||||||
/** CO numbers, or a count. Both are handled. */
|
/** CO numbers, or a count. Both are handled. */
|
||||||
open_cos: number[] | number;
|
open_cos: number[] | number;
|
||||||
open_shorts: number;
|
open_shorts: number;
|
||||||
|
/** Change C7: open shorts plus received shorts the supervisor has not approved. */
|
||||||
|
shorts_unapproved?: number;
|
||||||
wh_stage: string | null;
|
wh_stage: string | null;
|
||||||
due_effective: string | null;
|
due_effective: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
@@ -296,11 +298,43 @@ export interface BomShort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortReceipt {
|
export interface ShortReceipt {
|
||||||
|
/** Change C7: the short_receipts row id, for the supervisor's approval. */
|
||||||
|
id?: number;
|
||||||
qty: number | string;
|
qty: number | string;
|
||||||
heat: string | null;
|
heat: string | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
at: string | null;
|
at: string | null;
|
||||||
by?: WhoRef | null;
|
by?: WhoRef | null;
|
||||||
|
/** Change C7: the department supervisor's approval of this receipt, or null while it waits. */
|
||||||
|
approval?: ShortApproval | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change C7: PacketRead.shorts, every short of the packet, open or closed, with its BOM item and its receipts. */
|
||||||
|
export interface PacketShort {
|
||||||
|
id: number;
|
||||||
|
item: number;
|
||||||
|
qty: number | string;
|
||||||
|
qty_open: number | string;
|
||||||
|
reason: string | null;
|
||||||
|
ref: string | null;
|
||||||
|
eta: string | null;
|
||||||
|
note: string | null;
|
||||||
|
opened_at: string | null;
|
||||||
|
closed_at: string | null;
|
||||||
|
opened_by?: WhoRef | null;
|
||||||
|
receipts: ShortReceipt[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change C7: one supervisor approval of a received short. */
|
||||||
|
export interface ShortApproval {
|
||||||
|
at: string;
|
||||||
|
by: WhoRef | null;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change C7: POST /api/packets/{id}/shorts/receipts/{receipt_id}/approve. The signature rides in Guarded. */
|
||||||
|
export interface ShortApproveBody {
|
||||||
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BomLine {
|
export interface BomLine {
|
||||||
@@ -551,6 +585,12 @@ export interface PacketRead {
|
|||||||
itp?: ItpSummary | null;
|
itp?: ItpSummary | null;
|
||||||
/** Phase 6: open ITP hold points for this packet's department. */
|
/** Phase 6: open ITP hold points for this packet's department. */
|
||||||
itp_hold?: ItpHold | null;
|
itp_hold?: ItpHold | null;
|
||||||
|
/** Change C2: a Q.C. reject since the last accept has no NCR number, so the accept must carry one. */
|
||||||
|
ncr_required?: boolean;
|
||||||
|
/** Change C7: open shorts plus received shorts the supervisor has not approved. */
|
||||||
|
shorts_unapproved?: number;
|
||||||
|
/** Change C7: every short, open or closed, with who received and who approved each receipt. Not on a sealed read. */
|
||||||
|
shorts?: PacketShort[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Phase 7: close, file, PDFs and prints ---------- */
|
/* ---------- Phase 7: close, file, PDFs and prints ---------- */
|
||||||
@@ -831,10 +871,8 @@ export interface TransitionBody {
|
|||||||
spool?: boolean;
|
spool?: boolean;
|
||||||
location?: string;
|
location?: string;
|
||||||
for_sup_id?: string;
|
for_sup_id?: string;
|
||||||
/** Phase 6: QC_REJECT. An NCR number like NCR-2026-0021. */
|
/** QC_REJECT, and QC_ACCEPT / QC_ACCEPT_AND_ROUTE when `ncr_required` (change C2). An NCR number like NCR-2026-0021. */
|
||||||
ncr?: string;
|
ncr?: string;
|
||||||
/** Phase 7: FILE. Files although the feed still shows work orders open in VISUAL. */
|
|
||||||
confirm_open_wos?: boolean;
|
|
||||||
sign?: SignBody;
|
sign?: SignBody;
|
||||||
it_reason?: string;
|
it_reason?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
import { Link, Outlet, useLocation } from "react-router";
|
import { Link, Outlet, useLocation } from "react-router";
|
||||||
import { SCREEN_LABELS, firstScreen, isScreen, orderScreens, type Screen } from "../auth/roles";
|
import { SCREEN_LABELS, firstScreen, isScreen, type Screen } from "../auth/roles";
|
||||||
import { useAuth } from "../auth/useAuth";
|
import { useAuth } from "../auth/useAuth";
|
||||||
import { replayClass } from "../lib/motion";
|
import { replayClass } from "../lib/motion";
|
||||||
import { useScrollRestore } from "../lib/scrollRestore";
|
import { useScrollRestore } from "../lib/scrollRestore";
|
||||||
|
import { canChecklists, canStampCheck, shellScreens } from "../lib/toolAccess";
|
||||||
import { LockScreen } from "./LockScreen";
|
import { LockScreen } from "./LockScreen";
|
||||||
import { RoleSwitcher } from "./RoleSwitcher";
|
import { RoleSwitcher } from "./RoleSwitcher";
|
||||||
import { TopBar } from "./TopBar";
|
import { TopBar } from "./TopBar";
|
||||||
@@ -22,7 +23,7 @@ export function Layout() {
|
|||||||
const locked = auth.status === "locked";
|
const locked = auth.status === "locked";
|
||||||
const me = auth.status === "ready" || locked ? auth.me : null;
|
const me = auth.status === "ready" || locked ? auth.me : null;
|
||||||
|
|
||||||
const screens = me ? orderScreens(me.screens) : [];
|
const screens = me ? shellScreens(me.screens, me.roles) : [];
|
||||||
const seg = pathname.split("/")[1];
|
const seg = pathname.split("/")[1];
|
||||||
const active = isScreen(seg) && screens.includes(seg) ? seg : null;
|
const active = isScreen(seg) && screens.includes(seg) ? seg : null;
|
||||||
const shown = active ?? firstScreen(screens);
|
const shown = active ?? firstScreen(screens);
|
||||||
@@ -51,6 +52,16 @@ export function Layout() {
|
|||||||
<>
|
<>
|
||||||
<b className="uname">{me.user.display_name}</b>
|
<b className="uname">{me.user.display_name}</b>
|
||||||
{shown ? <span className={chipClass(shown)}>{SCREEN_LABELS[shown]}</span> : null}
|
{shown ? <span className={chipClass(shown)}>{SCREEN_LABELS[shown]}</span> : null}
|
||||||
|
{canChecklists(me.roles) ? (
|
||||||
|
<Link className="btn sm ghost" to="/checklists">
|
||||||
|
Checklists
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{canStampCheck(me.roles) ? (
|
||||||
|
<Link className="btn sm ghost" to="/stamp-check">
|
||||||
|
Stamp check
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
<Link className="btn sm ghost" to="/account">
|
<Link className="btn sm ghost" to="/account">
|
||||||
Account
|
Account
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function routes() {
|
|||||||
"/api/auth/me": { body: LOU },
|
"/api/auth/me": { body: LOU },
|
||||||
"/api/departments": { body: DEPARTMENTS },
|
"/api/departments": { body: DEPARTMENTS },
|
||||||
"/api/shop/board": { body: {} },
|
"/api/shop/board": { body: {} },
|
||||||
"/api/shop/stations": { body: [] },
|
"/api/stall/mine": { body: { mine: [], submitted: [] } },
|
||||||
"/api/projects": { body: [] },
|
"/api/projects": { body: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ describe("RoleSwitcher", () => {
|
|||||||
await waitFor(() => expect(within(nav).getByRole("button", { name: "Supervisor" })).toHaveAttribute("aria-pressed", "true"));
|
await waitFor(() => expect(within(nav).getByRole("button", { name: "Supervisor" })).toHaveAttribute("aria-pressed", "true"));
|
||||||
fireEvent.click(within(nav).getByRole("button", { name: "Weld Stall" }));
|
fireEvent.click(within(nav).getByRole("button", { name: "Weld Stall" }));
|
||||||
expect(window.localStorage.getItem(LAST_SCREEN_KEY)).toBe("stall");
|
expect(window.localStorage.getItem(LAST_SCREEN_KEY)).toBe("stall");
|
||||||
expect(await screen.findByText("You aren't assigned to a station. Ask an admin to add you to a department.")).toBeInTheDocument();
|
expect(await screen.findByText("Your supervisor assigns packets to you.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lands on the remembered screen when it is still allowed", async () => {
|
it("lands on the remembered screen when it is still allowed", async () => {
|
||||||
|
|||||||
@@ -113,12 +113,12 @@ describe("toQuery", () => {
|
|||||||
expect(toQuery(f(), { project: PROJECT, unit: null }, { screen: "qc" })).toEqual({ project_id: 7 });
|
expect(toQuery(f(), { project: PROJECT, unit: null }, { screen: "qc" })).toEqual({ project_id: 7 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the stall sends no department, station, worker, flag kind or project, and its Mine names the station", () => {
|
it("the stall sends no department, station, worker or flag kind, and Mine never needs a station", () => {
|
||||||
const q = toQuery(f({ dept: "03", stall: "4", who: "u-1", flag: "rev", flags: ["mine", "short"], status: "station" }), { project: PROJECT, unit: null }, {
|
const q = toQuery(f({ dept: "03", stall: "4", who: "u-1", flag: "rev", flags: ["mine", "short"], status: "station" }), { project: PROJECT, unit: null }, {
|
||||||
screen: "stall",
|
screen: "stall",
|
||||||
stationId: 9,
|
|
||||||
});
|
});
|
||||||
expect(q).toEqual({ status: "station", flags: "short", project_id: 7 });
|
expect(q).toEqual({ status: "station", flags: "short", project_id: 7 });
|
||||||
|
expect(toQuery(f({ flags: ["mine"] }), { project: null, unit: null }, { screen: "supervisor" })).toEqual({ flags: "mine", mine_screen: "supervisor" });
|
||||||
expect(toQuery(f({ flags: ["mine"] }), { project: null, unit: null }, { screen: "warehouse" })).toEqual({ flags: "mine", mine_screen: "warehouse" });
|
expect(toQuery(f({ flags: ["mine"] }), { project: null, unit: null }, { screen: "warehouse" })).toEqual({ flags: "mine", mine_screen: "warehouse" });
|
||||||
expect(toQuery(f({ flag: "rev" }), { project: null, unit: null }, { screen: "supervisor" })).toEqual({});
|
expect(toQuery(f({ flag: "rev" }), { project: null, unit: null }, { screen: "supervisor" })).toEqual({});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -245,8 +245,11 @@ export function pickQuery(pick: UnitPick): PacketListQuery {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The server query for the filters and the pick. Defaults are left out. */
|
/**
|
||||||
export function toQuery(f: Filters, pick: UnitPick, opts: { screen: Screen; stationId?: number | null }): PacketListQuery {
|
* The server query for the filters and the pick. Defaults are left out. Mine goes with the screen only: the stall's
|
||||||
|
* Mine no longer needs a picked station (change C11), the session decides whose packets.
|
||||||
|
*/
|
||||||
|
export function toQuery(f: Filters, pick: UnitPick, opts: { screen: Screen }): PacketListQuery {
|
||||||
const e = effective(f, opts.screen);
|
const e = effective(f, opts.screen);
|
||||||
const out: PacketListQuery = {};
|
const out: PacketListQuery = {};
|
||||||
const q = e.q.trim();
|
const q = e.q.trim();
|
||||||
@@ -258,10 +261,7 @@ export function toQuery(f: Filters, pick: UnitPick, opts: { screen: Screen; stat
|
|||||||
if (e.flag !== ALL) out.flag = e.flag;
|
if (e.flag !== ALL) out.flag = e.flag;
|
||||||
if (e.flags.length) {
|
if (e.flags.length) {
|
||||||
out.flags = [...e.flags].sort((a, b) => FLT_FLAGS.findIndex(([k]) => k === a) - FLT_FLAGS.findIndex(([k]) => k === b)).join(",");
|
out.flags = [...e.flags].sort((a, b) => FLT_FLAGS.findIndex(([k]) => k === a) - FLT_FLAGS.findIndex(([k]) => k === b)).join(",");
|
||||||
if (e.flags.includes("mine")) {
|
if (e.flags.includes("mine")) out.mine_screen = opts.screen;
|
||||||
out.mine_screen = opts.screen;
|
|
||||||
if (opts.stationId) out.mine_station_id = opts.stationId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (e.sort !== "updated") out.sort = e.sort;
|
if (e.sort !== "updated") out.sort = e.sort;
|
||||||
return { ...out, ...pickQuery(pick) };
|
return { ...out, ...pickQuery(pick) };
|
||||||
@@ -323,7 +323,7 @@ export interface FiltersState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The find bar state for one screen. Saves on every change except q; keeps the pick in ps.proj. */
|
/** The find bar state for one screen. Saves on every change except q; keeps the pick in ps.proj. */
|
||||||
export function useFilters(screen: Screen, opts: { stationId?: number | null } = {}): FiltersState {
|
export function useFilters(screen: Screen): FiltersState {
|
||||||
const [f, setF] = useState<Filters>(loadFilters);
|
const [f, setF] = useState<Filters>(loadFilters);
|
||||||
const [pick, setPickState] = useState<UnitPick>(loadPick);
|
const [pick, setPickState] = useState<UnitPick>(loadPick);
|
||||||
const [dq, setDq] = useState("");
|
const [dq, setDq] = useState("");
|
||||||
@@ -359,8 +359,7 @@ export function useFilters(screen: Screen, opts: { stationId?: number | null } =
|
|||||||
const view: ViewKind = partsFor(screen).view && f.views[screen] === "list" ? "list" : "board";
|
const view: ViewKind = partsFor(screen).view && f.views[screen] === "list" ? "list" : "board";
|
||||||
const setView = useCallback((v: ViewKind) => setF((prev) => ({ ...prev, views: { ...prev.views, [screen]: v } })), [screen]);
|
const setView = useCallback((v: ViewKind) => setF((prev) => ({ ...prev, views: { ...prev.views, [screen]: v } })), [screen]);
|
||||||
|
|
||||||
const stationId = opts.stationId ?? null;
|
const query = useMemo(() => toQuery({ ...f, q: dq }, pick, { screen }), [f, dq, pick, screen]);
|
||||||
const query = useMemo(() => toQuery({ ...f, q: dq }, pick, { screen, stationId }), [f, dq, pick, screen, stationId]);
|
|
||||||
const queryKey = JSON.stringify(query);
|
const queryKey = JSON.stringify(query);
|
||||||
const scope = useMemo(() => pickQuery(pick), [pick]);
|
const scope = useMemo(() => pickQuery(pick), [pick]);
|
||||||
const scopeKey = JSON.stringify(scope);
|
const scopeKey = JSON.stringify(scope);
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Prints a server PDF from the page, as the old build did (change C4, Gitea issue 27): pdf.js renders each page to an
|
||||||
|
// image in a hidden print area, and the browser's print dialog opens on the same tab. No new tab, no frame, so the
|
||||||
|
// CSP stays as it is. The server stamps the copy and records the print when the PDF is fetched.
|
||||||
|
import { loadPdfJs } from "./pdf";
|
||||||
|
|
||||||
|
const ROOT_ID = "ps-print-root";
|
||||||
|
const STYLE_ID = "ps-print-style";
|
||||||
|
/** Render scale: 2 is about 150 dpi on a letter page, sharp enough for drawings and stamps. */
|
||||||
|
const SCALE = 2;
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
#${ROOT_ID} { display: none; }
|
||||||
|
@media print {
|
||||||
|
@page { margin: 0; }
|
||||||
|
body > *:not(#${ROOT_ID}) { display: none !important; }
|
||||||
|
#${ROOT_ID} { display: block !important; }
|
||||||
|
#${ROOT_ID} img { display: block; width: 100%; height: auto; max-height: 100vh; object-fit: contain; break-after: page; }
|
||||||
|
#${ROOT_ID} img:last-child { break-after: auto; }
|
||||||
|
}`;
|
||||||
|
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
function ensureStyle(): void {
|
||||||
|
if (document.getElementById(STYLE_ID)) return;
|
||||||
|
const s = document.createElement("style");
|
||||||
|
s.id = STYLE_ID;
|
||||||
|
s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasUrl(canvas: HTMLCanvasElement): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((b) => (b ? resolve(URL.createObjectURL(b)) : reject(new Error("The page could not be drawn."))), "image/png");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the PDF at a same-origin URL, draws every page and opens the print dialog. Resolves when the dialog closes.
|
||||||
|
* A second call while one is running is ignored. Throws when the PDF cannot be loaded, so the caller can say so.
|
||||||
|
*/
|
||||||
|
export async function printPdf(url: string): Promise<void> {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
const urls: string[] = [];
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.id = ROOT_ID;
|
||||||
|
try {
|
||||||
|
const pdfjs = await loadPdfJs();
|
||||||
|
const task = pdfjs.getDocument({ url, withCredentials: true, useWasm: false });
|
||||||
|
const doc = await task.promise;
|
||||||
|
try {
|
||||||
|
for (let n = 1; n <= doc.numPages; n++) {
|
||||||
|
const page = await doc.getPage(n);
|
||||||
|
const vp = page.getViewport({ scale: SCALE });
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.ceil(vp.width);
|
||||||
|
canvas.height = Math.ceil(vp.height);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("The page could not be drawn.");
|
||||||
|
await page.render({ canvas, canvasContext: ctx, viewport: vp }).promise;
|
||||||
|
const u = await canvasUrl(canvas);
|
||||||
|
urls.push(u);
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.alt = `Page ${n}`;
|
||||||
|
img.src = u;
|
||||||
|
root.appendChild(img);
|
||||||
|
page.cleanup();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
void task.destroy().catch(() => undefined);
|
||||||
|
}
|
||||||
|
ensureStyle();
|
||||||
|
document.getElementById(ROOT_ID)?.remove();
|
||||||
|
document.body.appendChild(root);
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(root.querySelectorAll("img")).map((img) => (img.complete ? Promise.resolve() : img.decode().catch(() => undefined))),
|
||||||
|
);
|
||||||
|
window.print();
|
||||||
|
} finally {
|
||||||
|
root.remove();
|
||||||
|
for (const u of urls) URL.revokeObjectURL(u);
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Who sees the screens outside the role switcher, and the PM screen. The server decides on every request; this only
|
||||||
|
// hides what cannot work.
|
||||||
|
import { orderScreens, parseRole, type Screen } from "../auth/roles";
|
||||||
|
|
||||||
|
/** The Project Management screen: PS-ProjectMgmt and Admin only (change C10). */
|
||||||
|
export function canPm(roles: readonly string[]): boolean {
|
||||||
|
return roles.some((r) => r === "pm" || r === "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stamp check: Admin, Q.C. and every Clerical level (change C9). */
|
||||||
|
export function canStampCheck(roles: readonly string[]): boolean {
|
||||||
|
return roles.some((r) => r === "admin" || r === "qc" || r.startsWith("qc_") || r === "clerical" || r.startsWith("clerical_"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The department checklists: a department supervisor (a lead is not enough) or Admin (change C8). */
|
||||||
|
export function canChecklists(roles: readonly string[]): boolean {
|
||||||
|
return roles.some((r) => {
|
||||||
|
if (r === "admin") return true;
|
||||||
|
const p = parseRole(r);
|
||||||
|
return p.shop && p.level === "supervisor";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PM_WHO = "It is for Project Management and Admin.";
|
||||||
|
|
||||||
|
/** The screens the shell offers: the session's list, with PM only for PS-ProjectMgmt and Admin (change C10). */
|
||||||
|
export function shellScreens(screens: readonly string[], roles: readonly string[]): Screen[] {
|
||||||
|
return orderScreens(screens).filter((s) => s !== "pm" || canPm(roles));
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { errorCode } from "../api/client";
|
|
||||||
import { packetsApi } from "../api/packets";
|
import { packetsApi } from "../api/packets";
|
||||||
import type { PacketRead } from "../api/types";
|
import type { PacketRead } from "../api/types";
|
||||||
import type { ReasonOptions } from "../components/ReasonSheet";
|
import type { ReasonOptions } from "../components/ReasonSheet";
|
||||||
import { Sheet } from "../components/Sheet";
|
import { fmtTime } from "../lib/format";
|
||||||
import { errorText, fmtTime } from "../lib/format";
|
|
||||||
import { useLoad } from "../lib/useLoad";
|
import { useLoad } from "../lib/useLoad";
|
||||||
import { Act } from "./Act";
|
import { Act } from "./Act";
|
||||||
import { openTab, showsCloseFile, SIGN_REOPEN, visualLine } from "./closeUtil";
|
import { openTab, showsCloseFile, SIGN_REOPEN, visualLine } from "./closeUtil";
|
||||||
@@ -39,6 +36,14 @@ function SealLine({ p }: { p: PacketRead }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** " Job closed in VISUAL <when> by <who>." Clerical's mark on a hand packet, the feed's on a VISUAL one. */
|
||||||
|
function closedText(p: PacketRead): string {
|
||||||
|
if (p.vclosed?.by) return ` Job closed in VISUAL ${fmtTime(p.vclosed.at)} by ${p.vclosed.by.display_name}.`;
|
||||||
|
const at = p.vclosed?.at ?? p.vclose_check?.closed_at ?? null;
|
||||||
|
if (!at) return "";
|
||||||
|
return p.source_kind === "hand" ? ` Job closed in VISUAL ${fmtTime(at)} by Clerical.` : ` VISUAL shows the job closed since ${fmtTime(at)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Clerical close and file panel, from status complete on. Ready to close (databook route, not confirmed closed),
|
* 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).
|
* ready for the data book, and filed (read-only for everyone but the IT reopen).
|
||||||
@@ -58,43 +63,26 @@ export function CloseFile({
|
|||||||
sub: string;
|
sub: string;
|
||||||
onPrint: () => void;
|
onPrint: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [confirm, setConfirm] = useState<string | null>(null);
|
|
||||||
if (!showsCloseFile(p)) return null;
|
if (!showsCloseFile(p)) return null;
|
||||||
|
|
||||||
const filed = p.status === "filed";
|
const filed = p.status === "filed";
|
||||||
const databookRoute = p.routed_to === "databook";
|
const databookRoute = p.routed_to === "databook";
|
||||||
const readyToClose = !filed && databookRoute && !p.vclosed;
|
const hand = p.source_kind === "hand";
|
||||||
const check = p.vclose_check ?? null;
|
const check = p.vclose_check ?? null;
|
||||||
|
// Change C5: a VISUAL packet is closed when the feed sees every leg closed; a hand packet when Clerical says so.
|
||||||
|
const feedClosed = !hand && !!check?.closed_at;
|
||||||
|
const readyToClose = !filed && databookRoute && !p.vclosed && !feedClosed;
|
||||||
const vline = visualLine(check);
|
const vline = visualLine(check);
|
||||||
const seal = p.seal;
|
const seal = p.seal;
|
||||||
const book = filed && seal?.databook ? seal.databook : null;
|
const book = filed && seal?.databook ? seal.databook : null;
|
||||||
const viewUrl = book?.url ?? packetsApi.pdfUrl(p.id, "databook");
|
const viewUrl = book?.url ?? packetsApi.pdfUrl(p.id, "databook");
|
||||||
const downloadUrl = book?.download_url ?? packetsApi.pdfUrl(p.id, "databook", true);
|
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. */
|
/** FILE. The server refuses it while VISUAL shows a work order open (change C5); `blocked.file` says why. */
|
||||||
const fileIt = async (confirmed: boolean) => {
|
const fileIt = () =>
|
||||||
const hit: { message?: string } = {};
|
mutate((g) => packetsApi.transition(p.id, { action: "file", expect: { status: p.status, dept: p.dept.code }, ...g }), {
|
||||||
await mutate(
|
ok: "Filed in the data book.",
|
||||||
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 reopen = async () => {
|
||||||
const reason = await askReason({
|
const reason = await askReason({
|
||||||
@@ -162,7 +150,10 @@ export function CloseFile({
|
|||||||
) : readyToClose ? (
|
) : readyToClose ? (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<b>Back from the shop, ready to close.</b> Close the job in VISUAL, then click below. The packet moves on to the data book.
|
<b>Back from the shop, ready to close.</b>{" "}
|
||||||
|
{hand
|
||||||
|
? "Close the job in VISUAL, then click below. The packet moves on to the data book."
|
||||||
|
: "Close the job in VISUAL. The packet moves on to the data book when the feed sees it closed."}
|
||||||
</div>
|
</div>
|
||||||
{vline ? (
|
{vline ? (
|
||||||
<div className="small" data-testid="visual-line">
|
<div className="small" data-testid="visual-line">
|
||||||
@@ -171,10 +162,12 @@ export function CloseFile({
|
|||||||
) : null}
|
) : null}
|
||||||
{reopened}
|
{reopened}
|
||||||
<div className="acts">
|
<div className="acts">
|
||||||
<Act p={p} action="set_vclosed" busy={busy} className="btn pri" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}>
|
{hand ? (
|
||||||
Job closed in VISUAL
|
<Act p={p} action="set_vclosed" busy={busy} className="btn pri" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}>
|
||||||
</Act>
|
Job closed in VISUAL
|
||||||
<Act p={p} action="file" busy={busy} onClick={() => void fileIt(false)}>
|
</Act>
|
||||||
|
) : null}
|
||||||
|
<Act p={p} action="file" busy={busy} onClick={() => void fileIt()}>
|
||||||
File in the data book
|
File in the data book
|
||||||
</Act>
|
</Act>
|
||||||
{exports}
|
{exports}
|
||||||
@@ -184,7 +177,7 @@ export function CloseFile({
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<b>Ready for the data book.</b>
|
<b>Ready for the data book.</b>
|
||||||
{databookRoute && p.vclosed ? ` Job closed in VISUAL ${fmtTime(p.vclosed.at)} by ${p.vclosed.by?.display_name ?? "Clerical"}.` : ""} Check the data book, then file it.
|
{databookRoute ? closedText(p) : ""} Check the data book, then file it.
|
||||||
</div>
|
</div>
|
||||||
{(check?.open_wos ?? []).length > 0 && vline ? (
|
{(check?.open_wos ?? []).length > 0 && vline ? (
|
||||||
<div className="callout warn flush" data-testid="visual-line">
|
<div className="callout warn flush" data-testid="visual-line">
|
||||||
@@ -193,11 +186,11 @@ export function CloseFile({
|
|||||||
) : null}
|
) : null}
|
||||||
{reopened}
|
{reopened}
|
||||||
<div className="acts">
|
<div className="acts">
|
||||||
<Act p={p} action="file" busy={busy} className="btn pri" onClick={() => void fileIt(false)}>
|
<Act p={p} action="file" busy={busy} className="btn pri" onClick={() => void fileIt()}>
|
||||||
File in the data book
|
File in the data book
|
||||||
</Act>
|
</Act>
|
||||||
{exports}
|
{exports}
|
||||||
{databookRoute ? (
|
{databookRoute && hand ? (
|
||||||
<Act p={p} action="set_vclosed" busy={busy} className="btn ghost" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, false, g))}>
|
<Act p={p} action="set_vclosed" busy={busy} className="btn ghost" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, false, g))}>
|
||||||
Not closed in VISUAL, move back
|
Not closed in VISUAL, move back
|
||||||
</Act>
|
</Act>
|
||||||
@@ -205,27 +198,6 @@ export function CloseFile({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{confirm !== null ? (
|
|
||||||
<Sheet title="File with open work orders?" sub={sub} onClose={() => setConfirm(null)}>
|
|
||||||
<p>{confirm}</p>
|
|
||||||
<div className="sheet-acts">
|
|
||||||
<button className="btn ghost" type="button" onClick={() => setConfirm(null)}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn pri"
|
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => {
|
|
||||||
setConfirm(null);
|
|
||||||
void fileIt(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
File anyway
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Sheet>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { dueInfo, flagClass, jobName, pillClass, statusLabel, targetHours } from
|
|||||||
import { Act } from "./Act";
|
import { Act } from "./Act";
|
||||||
import { CloseFile } from "./CloseFile";
|
import { CloseFile } from "./CloseFile";
|
||||||
import { DrawingViewer } from "./DrawingViewer";
|
import { DrawingViewer } from "./DrawingViewer";
|
||||||
import { openPrint } from "./closeUtil";
|
import { onPrintError, openPrint } from "./closeUtil";
|
||||||
import { PrintMenu } from "./PrintMenu";
|
import { PrintMenu } from "./PrintMenu";
|
||||||
import { MoveSheet, SendToDeptSheet } from "./sheets";
|
import { MoveSheet, SendToDeptSheet } from "./sheets";
|
||||||
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
|
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
|
||||||
@@ -146,6 +146,9 @@ export function PacketPage() {
|
|||||||
versionRef.current = packet?.version ?? null;
|
versionRef.current = packet?.version ?? null;
|
||||||
}, [packet]);
|
}, [packet]);
|
||||||
|
|
||||||
|
// A print that could not be made (change C4) says so where the page's errors show.
|
||||||
|
useEffect(() => onPrintError((text) => setMsg({ ok: false, text })), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!toast) return;
|
if (!toast) return;
|
||||||
const t = window.setTimeout(() => setToast(null), TOAST_MS);
|
const t = window.setTimeout(() => setToast(null), TOAST_MS);
|
||||||
@@ -300,8 +303,9 @@ export function PacketPage() {
|
|||||||
const hasQc = !!p.qc || p.qc_history.length > 0;
|
const hasQc = !!p.qc || p.qc_history.length > 0;
|
||||||
const hidden = (tab === "changes" && !(p.change_orders ?? []).length) || (tab === "qc" && !hasQc);
|
const hidden = (tab === "changes" && !(p.change_orders ?? []).length) || (tab === "qc" && !hasQc);
|
||||||
const shownTab: Tab = hidden ? "materials" : (tab ?? (drawingPages.length ? "drawing" : "materials"));
|
const shownTab: Tab = hidden ? "materials" : (tab ?? (drawingPages.length ? "drawing" : "materials"));
|
||||||
// The warehouse short-parts exception and the data book date are not edits of the packet itself.
|
// The warehouse short-parts exception, the supervisor's short approval and the data book date are not edits of
|
||||||
const passive = new Set(["wh_receive_short", "set_databook_at"]);
|
// the packet itself.
|
||||||
|
const passive = new Set(["wh_receive_short", "set_databook_at", "short_approve"]);
|
||||||
const viewOnly =
|
const viewOnly =
|
||||||
p.can.every((a) => passive.has(a)) && Object.keys(p.blocked).every((a) => passive.has(a));
|
p.can.every((a) => passive.has(a)) && Object.keys(p.blocked).every((a) => passive.has(a));
|
||||||
const project = p.unit.project;
|
const project = p.unit.project;
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import type { PacketPage, PacketRead } from "../api/types";
|
|||||||
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
||||||
import { mockFetch, type Route } from "../test/fetchMock";
|
import { mockFetch, type Route } from "../test/fetchMock";
|
||||||
import { makePacket } from "../test/packets";
|
import { makePacket } from "../test/packets";
|
||||||
import { kioskCode, printRows } from "./closeUtil";
|
import { printPdf } from "../lib/printPdf";
|
||||||
|
import { kioskCode, PRINT_FAILED, printRows } from "./closeUtil";
|
||||||
|
|
||||||
|
// Change C4: every print goes to the browser's print dialog through printPdf; jsdom has no pdf.js or print.
|
||||||
|
vi.mock("../lib/printPdf", () => ({ printPdf: vi.fn(() => Promise.resolve()) }));
|
||||||
|
const printed = vi.mocked(printPdf);
|
||||||
|
|
||||||
function page(n: number, over: Partial<PacketPage> = {}): PacketPage {
|
function page(n: number, over: Partial<PacketPage> = {}): PacketPage {
|
||||||
return { n, type: "drawing", asset_id: `as-${n}`, pn: n, dwg: "CI-DWG-1", rev: "0", void: null, url: `/_blob/as-${n}`, ...over };
|
return { n, type: "drawing", asset_id: `as-${n}`, pn: n, dwg: "CI-DWG-1", rev: "0", void: null, url: `/_blob/as-${n}`, ...over };
|
||||||
@@ -29,6 +34,8 @@ const rowNames = () =>
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
printed.mockReset();
|
||||||
|
printed.mockImplementation(() => Promise.resolve());
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("printRows", () => {
|
describe("printRows", () => {
|
||||||
@@ -52,7 +59,7 @@ describe("printRows", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Print menu", () => {
|
describe("Print menu", () => {
|
||||||
it("opens from the header, lists the rows, and a row opens its print in a new tab", async () => {
|
it("opens from the header, lists the rows, and a row prints straight from the page", async () => {
|
||||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
mockFetch(base(makePacket({ pages: [page(1)] })));
|
mockFetch(base(makePacket({ pages: [page(1)] })));
|
||||||
renderApp("/packets/1");
|
renderApp("/packets/1");
|
||||||
@@ -65,7 +72,8 @@ describe("Print menu", () => {
|
|||||||
);
|
);
|
||||||
expect(sheet).toHaveTextContent('Every printed page is stamped with who printed it and when, plus "verify revision before use". Printing is recorded.');
|
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" }));
|
fireEvent.click(within(sheet).getByRole("button", { name: "Drawing with weld map" }));
|
||||||
expect(open).toHaveBeenCalledWith("/api/packets/1/print/map-w", "_blank", "noopener");
|
expect(printed).toHaveBeenCalledWith("/api/packets/1/print/map-w");
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Print" })).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Print" })).not.toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,7 +85,8 @@ describe("Print menu", () => {
|
|||||||
await screen.findByRole("dialog", { 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"]);
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "Data book" }));
|
||||||
expect(open).toHaveBeenCalledWith("/api/packets/1/print/databook", "_blank", "noopener");
|
expect(printed).toHaveBeenCalledWith("/api/packets/1/print/databook");
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Cancel closes it without opening anything", async () => {
|
it("Cancel closes it without opening anything", async () => {
|
||||||
@@ -88,6 +97,16 @@ describe("Print menu", () => {
|
|||||||
fireEvent.click(within(await screen.findByRole("dialog", { name: "Print" })).getByRole("button", { name: "Cancel" }));
|
fireEvent.click(within(await screen.findByRole("dialog", { name: "Print" })).getByRole("button", { name: "Cancel" }));
|
||||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Print" })).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Print" })).not.toBeInTheDocument());
|
||||||
expect(open).not.toHaveBeenCalled();
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
expect(printed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when the print cannot be made", async () => {
|
||||||
|
printed.mockImplementation(() => Promise.reject(new Error("Unexpected server response (500)")));
|
||||||
|
mockFetch(base(makePacket({ pages: [page(1)] })));
|
||||||
|
renderApp("/packets/1");
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Print…" }));
|
||||||
|
fireEvent.click(within(await screen.findByRole("dialog", { name: "Print" })).getByRole("button", { name: "Job traveler" }));
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(PRINT_FAILED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is not offered on a removed packet", async () => {
|
it("is not offered on a removed packet", async () => {
|
||||||
@@ -113,7 +132,8 @@ describe("Print entry points on the tabs", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByTestId("kiosk-code")).toHaveTextContent("*%2L999001$7A$0$0$10%*");
|
expect(screen.getByTestId("kiosk-code")).toHaveTextContent("*%2L999001$7A$0$0$10%*");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Print traveler" }));
|
fireEvent.click(screen.getByRole("button", { name: "Print traveler" }));
|
||||||
expect(open).toHaveBeenCalledWith("/api/packets/1/print/traveler", "_blank", "noopener");
|
expect(printed).toHaveBeenCalledWith("/api/packets/1/print/traveler");
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
const pdf = screen.getByRole("link", { name: "Traveler (PDF)" });
|
const pdf = screen.getByRole("link", { name: "Traveler (PDF)" });
|
||||||
expect(pdf).toHaveAttribute("href", "/api/packets/1/pdf/traveler");
|
expect(pdf).toHaveAttribute("href", "/api/packets/1/pdf/traveler");
|
||||||
expect(pdf).toHaveAttribute("target", "_blank");
|
expect(pdf).toHaveAttribute("target", "_blank");
|
||||||
@@ -126,7 +146,8 @@ describe("Print entry points on the tabs", () => {
|
|||||||
renderApp("/packets/1");
|
renderApp("/packets/1");
|
||||||
fireEvent.click(await screen.findByRole("tab", { name: /^Materials/ }));
|
fireEvent.click(await screen.findByRole("tab", { name: /^Materials/ }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Print" }));
|
fireEvent.click(screen.getByRole("button", { name: "Print" }));
|
||||||
expect(open).toHaveBeenCalledWith("/api/packets/1/print/doc?asset=mtr-3", "_blank", "noopener");
|
expect(printed).toHaveBeenCalledWith("/api/packets/1/print/doc?asset=mtr-3");
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the Documents tab prints a packet document and a folder document", async () => {
|
it("the Documents tab prints a packet document and a folder document", async () => {
|
||||||
@@ -139,9 +160,11 @@ describe("Print entry points on the tabs", () => {
|
|||||||
renderApp("/packets/1");
|
renderApp("/packets/1");
|
||||||
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
|
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
|
||||||
fireEvent.click(within(screen.getByRole("region", { name: "Documents for the data book" })).getByRole("button", { name: "Print" }));
|
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");
|
expect(printed).toHaveBeenLastCalledWith("/api/packets/1/print/doc?asset=a4");
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
fireEvent.click(within(screen.getByRole("region", { name: "Also in the data book from the Q.C. folder" })).getByRole("button", { name: "Print" }));
|
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");
|
expect(printed).toHaveBeenLastCalledWith("/api/packets/1/print/doc?asset=f1");
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the Drawing tab prints the shown sheet", async () => {
|
it("the Drawing tab prints the shown sheet", async () => {
|
||||||
@@ -151,7 +174,8 @@ describe("Print entry points on the tabs", () => {
|
|||||||
await screen.findByRole("tab", { name: /^Drawing/ });
|
await screen.findByRole("tab", { name: /^Drawing/ });
|
||||||
fireEvent.click(screen.getByRole("button", { name: /^p\.2/ }));
|
fireEvent.click(screen.getByRole("button", { name: /^p\.2/ }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Print this sheet" }));
|
fireEvent.click(screen.getByRole("button", { name: "Print this sheet" }));
|
||||||
expect(open).toHaveBeenCalledWith("/api/packets/1/print/clean?sheet=2", "_blank", "noopener");
|
expect(printed).toHaveBeenCalledWith("/api/packets/1/print/clean?sheet=2");
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -164,7 +188,8 @@ describe("Print entry points on the tabs", () => {
|
|||||||
fireEvent.click(await screen.findByRole("tab", { name: new RegExp(`^${tab}`) }));
|
fireEvent.click(await screen.findByRole("tab", { name: new RegExp(`^${tab}`) }));
|
||||||
fireEvent.click(within(screen.getByRole("group", { name: "Sheets" })).getAllByRole("button")[1]!);
|
fireEvent.click(within(screen.getByRole("group", { name: "Sheets" })).getAllByRole("button")[1]!);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Print this sheet" }));
|
fireEvent.click(screen.getByRole("button", { name: "Print this sheet" }));
|
||||||
expect(open).toHaveBeenCalledWith(`/api/packets/1/print/${kind}?sheet=2`, "_blank", "noopener");
|
expect(printed).toHaveBeenCalledWith(`/api/packets/1/print/${kind}?sheet=2`);
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PacketRead } from "../api/types";
|
|||||||
import { Sheet } from "../components/Sheet";
|
import { Sheet } from "../components/Sheet";
|
||||||
import { openPrint, printRows } from "./closeUtil";
|
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. */
|
/** The print menu: one row per print kind. A row closes the sheet and opens the print dialog on the stamped copy. */
|
||||||
export function PrintMenu({ p, sub, onClose }: { p: PacketRead; sub: string; onClose: () => void }) {
|
export function PrintMenu({ p, sub, onClose }: { p: PacketRead; sub: string; onClose: () => void }) {
|
||||||
const base = useId();
|
const base = useId();
|
||||||
const rows = printRows(p);
|
const rows = printRows(p);
|
||||||
@@ -18,8 +18,8 @@ export function PrintMenu({ p, sub, onClose }: { p: PacketRead; sub: string; onC
|
|||||||
aria-label={r.label}
|
aria-label={r.label}
|
||||||
aria-describedby={`${base}-${r.kind}`}
|
aria-describedby={`${base}-${r.kind}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
openPrint(p.id, r.kind);
|
|
||||||
onClose();
|
onClose();
|
||||||
|
openPrint(p.id, r.kind);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="t">
|
<span className="t">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const MISSING_PREFIX = "Q.C. still needs";
|
|||||||
|
|
||||||
const REJECT_MIN = 8;
|
const REJECT_MIN = 8;
|
||||||
|
|
||||||
/** The reject sheet: a reason, an NCR number, or both. */
|
/** The reject sheet: a reason always (change C2), and the NCR number when there is one. */
|
||||||
export function RejectSheet({
|
export function RejectSheet({
|
||||||
sub,
|
sub,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -24,31 +24,35 @@ export function RejectSheet({
|
|||||||
}: {
|
}: {
|
||||||
sub: string;
|
sub: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onReject: (body: { reason?: string; ncr?: string }) => void;
|
onReject: (body: { reason: string; ncr?: string }) => void;
|
||||||
}) {
|
}) {
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
const [ncr, setNcr] = useState("");
|
const [ncr, setNcr] = useState("");
|
||||||
const r = reason.trim();
|
const r = reason.trim();
|
||||||
const n = ncr.trim();
|
const n = ncr.trim();
|
||||||
const ncrOk = isNcr(n);
|
const ncrOk = isNcr(n);
|
||||||
const ok = r.length >= REJECT_MIN || ncrOk;
|
const ok = r.length >= REJECT_MIN && (!n || ncrOk);
|
||||||
|
|
||||||
const submit = (e: FormEvent) => {
|
const submit = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
onReject({ ...(r ? { reason: r } : {}), ...(ncrOk ? { ncr: normNcr(n) } : {}) });
|
onReject({ reason: r, ...(ncrOk ? { ncr: normNcr(n) } : {}) });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet title="Reject" sub={sub} onClose={onCancel}>
|
<Sheet title="Reject" sub={sub} onClose={onCancel}>
|
||||||
<div className="small">Give a reason, an NCR number, or both. The supervisor sees it at the top of the packet.</div>
|
<div className="small">
|
||||||
|
Say what failed, in detail. Add the NCR number if there is one. Without it, Q.C. enters the NCR number before the packet can be accepted. The
|
||||||
|
supervisor sees the reason at the top of the packet.
|
||||||
|
</div>
|
||||||
<form className="stack" onSubmit={submit} noValidate>
|
<form className="stack" onSubmit={submit} noValidate>
|
||||||
<label className="f">
|
<label className="f">
|
||||||
Reason / what failed
|
Reason / what failed
|
||||||
<textarea aria-label="Reason / what failed" className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
|
<textarea aria-label="Reason / what failed" className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
{r && r.length < REJECT_MIN ? <div className="small muted">At least {REJECT_MIN} characters.</div> : null}
|
||||||
<label className="f">
|
<label className="f">
|
||||||
NCR number
|
NCR number (optional)
|
||||||
<input aria-label="NCR number" className="in w220 mono" value={ncr} maxLength={20} placeholder="NCR-2026-0021" onChange={(e) => setNcr(e.target.value)} />
|
<input aria-label="NCR number" className="in w220 mono" value={ncr} maxLength={20} placeholder="NCR-2026-0021" onChange={(e) => setNcr(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
{n && !ncrOk ? <div className="small pinktxt">Enter an NCR number like NCR-2026-0021.</div> : null}
|
{n && !ncrOk ? <div className="small pinktxt">Enter an NCR number like NCR-2026-0021.</div> : null}
|
||||||
@@ -75,12 +79,18 @@ export function QcBar({ p, busy, mutate, sub, goQc }: { p: PacketRead; busy: boo
|
|||||||
const depts = useShopDepartments().filter((d) => d.code !== p.dept.code);
|
const depts = useShopDepartments().filter((d) => d.code !== p.dept.code);
|
||||||
const [route, setRoute] = useState<string>("databook");
|
const [route, setRoute] = useState<string>("databook");
|
||||||
const [rejecting, setRejecting] = useState(false);
|
const [rejecting, setRejecting] = useState(false);
|
||||||
|
const [ncr, setNcr] = useState("");
|
||||||
if (p.status !== "qc") return null;
|
if (p.status !== "qc") return null;
|
||||||
if (!has(p, "qc_accept") && !has(p, "qc_accept_and_route") && !has(p, "qc_reject")) return null;
|
if (!has(p, "qc_accept") && !has(p, "qc_accept_and_route") && !has(p, "qc_reject")) return null;
|
||||||
|
|
||||||
const step = makeStep(p, mutate);
|
const step = makeStep(p, mutate);
|
||||||
const toDept = route.startsWith("DEPT:") ? route.slice(5) : null;
|
const toDept = route.startsWith("DEPT:") ? route.slice(5) : null;
|
||||||
const action = toDept ? "qc_accept_and_route" : "qc_accept";
|
const action = toDept ? "qc_accept_and_route" : "qc_accept";
|
||||||
|
// Change C2: a reject without an NCR number since the last accept; the accept carries the number.
|
||||||
|
const needNcr = !!p.ncr_required;
|
||||||
|
const ncrText = ncr.trim();
|
||||||
|
const ncrOk = isNcr(ncrText);
|
||||||
|
const ncrBody = needNcr && ncrOk ? { ncr: normNcr(ncrText) } : {};
|
||||||
const canAccept = p.can.includes(action);
|
const canAccept = p.can.includes(action);
|
||||||
const blocked = canAccept ? [] : (p.blocked[action] ?? p.blocked.qc_accept ?? []);
|
const blocked = canAccept ? [] : (p.blocked[action] ?? p.blocked.qc_accept ?? []);
|
||||||
const reason = blocked.join(" ");
|
const reason = blocked.join(" ");
|
||||||
@@ -89,8 +99,9 @@ export function QcBar({ p, busy, mutate, sub, goQc }: { p: PacketRead; busy: boo
|
|||||||
const other = listed ? blocked.filter((m) => !m.startsWith(MISSING_PREFIX)) : blocked;
|
const other = listed ? blocked.filter((m) => !m.startsWith(MISSING_PREFIX)) : blocked;
|
||||||
|
|
||||||
const accept = () => {
|
const accept = () => {
|
||||||
if (toDept) void step("qc_accept_and_route", { dept: toDept }, { sign: SIGN_QC_ACCEPT_ROUTE, ok: "Q.C. accepted. Sent to the department." });
|
if (needNcr && !ncrOk) return;
|
||||||
else void step("qc_accept", { routed_to: route as RouteKind }, { sign: SIGN_QC_ACCEPT, ok: "Q.C. accepted." });
|
if (toDept) void step("qc_accept_and_route", { dept: toDept, ...ncrBody }, { sign: SIGN_QC_ACCEPT_ROUTE, ok: "Q.C. accepted. Sent to the department." });
|
||||||
|
else void step("qc_accept", { routed_to: route as RouteKind, ...ncrBody }, { sign: SIGN_QC_ACCEPT, ok: "Q.C. accepted." });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -119,6 +130,11 @@ export function QcBar({ p, busy, mutate, sub, goQc }: { p: PacketRead; busy: boo
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{other.length ? <div className="why small">{other.join(" ")}</div> : null}
|
{other.length ? <div className="why small">{other.join(" ")}</div> : null}
|
||||||
|
{needNcr && (has(p, "qc_accept") || has(p, "qc_accept_and_route")) ? (
|
||||||
|
<div className="small" data-testid="ncr-required">
|
||||||
|
This packet was rejected without an NCR number. Enter the NCR number of the reject to accept it.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="acts center">
|
<div className="acts center">
|
||||||
{has(p, "qc_accept") || has(p, "qc_accept_and_route") ? (
|
{has(p, "qc_accept") || has(p, "qc_accept_and_route") ? (
|
||||||
<>
|
<>
|
||||||
@@ -134,8 +150,30 @@ export function QcBar({ p, busy, mutate, sub, goQc }: { p: PacketRead; busy: boo
|
|||||||
<option value="shipping">{ROUTE_SHIPPING}</option>
|
<option value="shipping">{ROUTE_SHIPPING}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
{needNcr ? (
|
||||||
|
<label className="f">
|
||||||
|
NCR number (required)
|
||||||
|
<input
|
||||||
|
aria-label="NCR number of the reject"
|
||||||
|
className="in w220 mono"
|
||||||
|
value={ncr}
|
||||||
|
maxLength={20}
|
||||||
|
placeholder="NCR-2026-0021"
|
||||||
|
required
|
||||||
|
aria-invalid={ncrText !== "" && !ncrOk}
|
||||||
|
onChange={(e) => setNcr(e.target.value)}
|
||||||
|
/>
|
||||||
|
{ncrText && !ncrOk ? <span className="small pinktxt">Enter an NCR number like NCR-2026-0021.</span> : null}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
<div className="act">
|
<div className="act">
|
||||||
<button className="btn pri" type="button" disabled={!canAccept || busy} title={!canAccept ? reason : undefined} onClick={accept}>
|
<button
|
||||||
|
className="btn pri"
|
||||||
|
type="button"
|
||||||
|
disabled={!canAccept || busy || (needNcr && !ncrOk)}
|
||||||
|
title={!canAccept ? reason : needNcr && !ncrOk ? "Enter the NCR number of the reject first." : undefined}
|
||||||
|
onClick={accept}
|
||||||
|
>
|
||||||
Accept
|
Accept
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Change C7: every received short needs the department supervisor's approval, one receipt at a time.
|
||||||
|
import { packetsApi } from "../api/packets";
|
||||||
|
import type { PacketRead, ShortReceipt } from "../api/types";
|
||||||
|
import { fmtTime } from "../lib/format";
|
||||||
|
import type { Mutate } from "./tabs";
|
||||||
|
|
||||||
|
/** The rule's signature meaning for SHORT_APPROVE. */
|
||||||
|
export const SIGN_SHORT_APPROVE = "Approve a received short";
|
||||||
|
|
||||||
|
/** "Received 2 · heat H123 · by Wes Warehouse <time>". */
|
||||||
|
function receiptText(r: ShortReceipt): string {
|
||||||
|
const parts = [`Received ${String(r.qty)}`, r.heat ? `heat ${r.heat}` : null, r.by?.display_name ? `by ${r.by.display_name}` : null, r.at ? fmtTime(r.at) : null];
|
||||||
|
return parts.filter((x): x is string => !!x).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Approved by Sam Super <time>", with the note when there is one. */
|
||||||
|
function approvalText(a: NonNullable<ShortReceipt["approval"]>): string {
|
||||||
|
const head = ["Approved", a.by?.display_name ? `by ${a.by.display_name}` : null, a.at ? fmtTime(a.at) : null].filter((x): x is string => !!x).join(" ");
|
||||||
|
return a.note ? `${head}: ${a.note}` : head;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The receipts of one BOM line: who received each, who approved it, and Approve for a supervisor who may. */
|
||||||
|
export function ShortReceipts({ p, receipts, item, busy, mutate }: { p: PacketRead; receipts: ShortReceipt[]; item: number; busy: boolean; mutate: Mutate }) {
|
||||||
|
if (!receipts.length) return null;
|
||||||
|
const canApprove = p.can.includes("short_approve") && !p.removed;
|
||||||
|
const approve = (r: ShortReceipt) => {
|
||||||
|
if (r.id === undefined) return;
|
||||||
|
const id = r.id;
|
||||||
|
void mutate((g) => packetsApi.approveShortReceipt(p.id, id, {}, g), { sign: SIGN_SHORT_APPROVE, ok: "Short approved." });
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="stack tight small" role="group" aria-label={`Receipts for item ${item}`}>
|
||||||
|
{receipts.map((r, i) => (
|
||||||
|
<div key={r.id ?? i} data-testid="short-receipt">
|
||||||
|
<div>{receiptText(r)}</div>
|
||||||
|
{r.approval ? (
|
||||||
|
<div>
|
||||||
|
<span className="pill st-ok">Approved</span> {approvalText(r.approval).replace(/^Approved ?/, "")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<span className="due soon">Waiting for the supervisor's approval.</span>
|
||||||
|
{canApprove && r.id !== undefined ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<button
|
||||||
|
className="btn sm pri"
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
title="Check the part and its heat number against what the warehouse issued, then approve."
|
||||||
|
onClick={() => approve(r)}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,19 +3,39 @@
|
|||||||
import { packetsApi } from "../api/packets";
|
import { packetsApi } from "../api/packets";
|
||||||
import type { PacketRead, PrintKind, PrintRow, VcloseCheck, WorkOrder } from "../api/types";
|
import type { PacketRead, PrintKind, PrintRow, VcloseCheck, WorkOrder } from "../api/types";
|
||||||
import { fmtTime } from "../lib/format";
|
import { fmtTime } from "../lib/format";
|
||||||
|
import { printPdf } from "../lib/printPdf";
|
||||||
|
|
||||||
/** The rule's signature meanings for FILE and ADMIN_REOPEN. */
|
/** The rule's signature meanings for FILE and ADMIN_REOPEN. */
|
||||||
export const SIGN_FILE = "File in the data book";
|
export const SIGN_FILE = "File in the data book";
|
||||||
export const SIGN_REOPEN = "Reopen a filed packet";
|
export const SIGN_REOPEN = "Reopen a filed packet";
|
||||||
|
|
||||||
/** Opens a PDF (or any server URL) in a new tab. Every PDF view and print in the packet goes through here. */
|
/** Opens a PDF (or any server URL) in a new tab, to view it. Prints do not come here (change C4). */
|
||||||
export function openTab(url: string): void {
|
export function openTab(url: string): void {
|
||||||
window.open(url, "_blank", "noopener");
|
window.open(url, "_blank", "noopener");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens a printed copy of one kind. The server stamps it and records the print. */
|
/** The short error when a print cannot be made. */
|
||||||
|
export const PRINT_FAILED = "The print could not be made. Try again.";
|
||||||
|
|
||||||
|
type PrintErrorListener = (text: string) => void;
|
||||||
|
const printErrorListeners = new Set<PrintErrorListener>();
|
||||||
|
|
||||||
|
/** The packet page listens here to show a failed print. Returns the unsubscribe. */
|
||||||
|
export function onPrintError(fn: PrintErrorListener): () => void {
|
||||||
|
printErrorListeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
printErrorListeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints a stamped copy of one kind straight to the browser's print dialog (change C4). The server stamps it and
|
||||||
|
* records the print when the PDF is fetched. A failure goes to the onPrintError listeners.
|
||||||
|
*/
|
||||||
export function openPrint(id: number, kind: PrintKind, opts: { sheet?: number | undefined; asset?: string | undefined } = {}): void {
|
export function openPrint(id: number, kind: PrintKind, opts: { sheet?: number | undefined; asset?: string | undefined } = {}): void {
|
||||||
openTab(packetsApi.printUrl(id, kind, opts));
|
void printPdf(packetsApi.printUrl(id, kind, opts)).catch(() => {
|
||||||
|
for (const fn of printErrorListeners) fn(PRINT_FAILED);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The asset id of a "/_blob/<id>" URL, for documents whose answer has no asset_id. */
|
/** The asset id of a "/_blob/<id>" URL, for documents whose answer has no asset_id. */
|
||||||
|
|||||||
@@ -79,12 +79,13 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Close and file: ready to close", () => {
|
describe("Close and file: ready to close", () => {
|
||||||
it("shows the state, the exports, and confirms the close", async () => {
|
it("a hand packet: shows the state, the exports, and Clerical confirms the close", async () => {
|
||||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
const fetch = mockFetch(base(toClose(), { "POST /api/packets/1/vclosed": { body: toFile({ version: 4 }) } }));
|
const hand = { source_kind: "hand" as const, vclose_check: null };
|
||||||
|
const fetch = mockFetch(base(toClose(hand), { "POST /api/packets/1/vclosed": { body: toFile({ version: 4, ...hand }) } }));
|
||||||
renderApp("/packets/1");
|
renderApp("/packets/1");
|
||||||
const box = await panel();
|
const box = await panel();
|
||||||
expect(box).toHaveTextContent("Back from the shop, ready to close.");
|
expect(box).toHaveTextContent("Back from the shop, ready to close. Close the job in VISUAL, then click below.");
|
||||||
const file = within(box).getByRole("button", { name: "File in the data book" });
|
const file = within(box).getByRole("button", { name: "File in the data book" });
|
||||||
expect(file).toBeDisabled();
|
expect(file).toBeDisabled();
|
||||||
expect(within(box).getByText("Close the job in VISUAL first.")).toHaveClass("why");
|
expect(within(box).getByText("Close the job in VISUAL first.")).toHaveClass("why");
|
||||||
@@ -102,10 +103,30 @@ describe("Close and file: ready to close", () => {
|
|||||||
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book."));
|
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(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\./);
|
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent(/Job closed in VISUAL .* by Clara Clerk\./);
|
||||||
|
expect(screen.getByRole("button", { name: "Not closed in VISUAL, move back" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a VISUAL packet waits for the feed: no manual close, File says why it is blocked", async () => {
|
||||||
|
const why = "VISUAL still shows a work order open on this packet. Close it in VISUAL first.";
|
||||||
|
mockFetch(base(toClose({ blocked: { file: [why] }, vclose_check: check({ open_wos: ["L999001/001"] }) })));
|
||||||
|
renderApp("/packets/1");
|
||||||
|
const box = await panel();
|
||||||
|
expect(box).toHaveTextContent("Back from the shop, ready to close. Close the job in VISUAL. The packet moves on to the data book when the feed sees it closed.");
|
||||||
|
expect(within(box).queryByRole("button", { name: "Job closed in VISUAL" })).not.toBeInTheDocument();
|
||||||
|
expect(within(box).getByRole("button", { name: "File in the data book" })).toBeDisabled();
|
||||||
|
expect(within(box).getByText(why)).toHaveClass("why");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a VISUAL packet the feed saw closed is ready for the data book", async () => {
|
||||||
|
mockFetch(base(toClose({ can: ["file"], blocked: {}, vclose_check: check({ closed_at: "2026-10-17T06:00:00Z", agrees: false }) })));
|
||||||
|
renderApp("/packets/1");
|
||||||
|
const box = await panel();
|
||||||
|
expect(box).toHaveTextContent(/^Ready for the data book\. VISUAL shows the job closed since .+\. Check the data book, then file it\./);
|
||||||
|
expect(within(box).getByRole("button", { name: "File in the data book" })).toBeEnabled();
|
||||||
|
expect(within(box).queryByRole("button", { name: "Not closed in VISUAL, move back" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
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\.$/],
|
["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\.$/],
|
["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 feed has not looked", check({ observed_at: null }), /^The feed has not checked VISUAL yet\.$/],
|
||||||
@@ -145,8 +166,9 @@ describe("Close and file: ready for the data book", () => {
|
|||||||
);
|
);
|
||||||
renderApp("/packets/1");
|
renderApp("/packets/1");
|
||||||
const box = await panel();
|
const box = await panel();
|
||||||
expect(box).toHaveTextContent("Ready for the data book.");
|
expect(box).toHaveTextContent(/^Ready for the data book\. Job closed in VISUAL .+ by Clara Clerk\./);
|
||||||
expect(within(box).getByRole("button", { name: "Not closed in VISUAL, move back" })).toBeEnabled();
|
// A VISUAL packet: the feed decides, so there is no manual move back.
|
||||||
|
expect(within(box).queryByRole("button", { name: "Not closed in VISUAL, move back" })).not.toBeInTheDocument();
|
||||||
fireEvent.click(within(box).getByRole("button", { name: "File in the data book" }));
|
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();
|
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("File in the data book")).toBeInTheDocument();
|
||||||
await sign();
|
await sign();
|
||||||
@@ -157,57 +179,32 @@ describe("Close and file: ready for the data book", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("warns in a callout when VISUAL still shows work orders open", async () => {
|
it("File is blocked while VISUAL shows work orders open, with no second confirmation", async () => {
|
||||||
mockFetch(base(toFile({ vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) })));
|
const why = "VISUAL still shows a work order open on this packet. Close it in VISUAL first.";
|
||||||
|
const fetch = mockFetch(base(toFile({ can: [], blocked: { file: [why] }, vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) })));
|
||||||
renderApp("/packets/1");
|
renderApp("/packets/1");
|
||||||
const box = await panel();
|
const box = await panel();
|
||||||
const line = within(box).getByTestId("visual-line");
|
const line = within(box).getByTestId("visual-line");
|
||||||
expect(line).toHaveClass("callout", "warn");
|
expect(line).toHaveClass("callout", "warn");
|
||||||
expect(line).toHaveTextContent("VISUAL still shows 1 work order open: L999001/001.");
|
expect(line).toHaveTextContent("VISUAL still shows 1 work order open: L999001/001.");
|
||||||
|
const file = within(box).getByRole("button", { name: "File in the data book" });
|
||||||
|
expect(file).toBeDisabled();
|
||||||
|
expect(within(box).getByText(why)).toHaveClass("why");
|
||||||
|
fireEvent.click(file);
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asks on 409 open_work_orders and sends it again with confirm_open_wos", async () => {
|
it("shows the server's refusal when filing is refused after all", async () => {
|
||||||
const msg = "VISUAL still shows 1 work order open on this packet: L999001/001. File anyway?";
|
const detail = "VISUAL still shows a work order open on this packet. Close it in VISUAL first.";
|
||||||
const fetch = mockFetch(
|
const fetch = mockFetch(
|
||||||
base(toFile({ vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) }), {
|
base(toFile(), { "POST /api/packets/1/transitions": { status: 409, body: { code: "precondition_failed", detail } } }),
|
||||||
"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");
|
renderApp("/packets/1");
|
||||||
fireEvent.click(within(await panel()).getByRole("button", { name: "File in the data book" }));
|
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(await screen.findByRole("alert")).toHaveTextContent(detail);
|
||||||
expect(within(sheet).getByText(msg)).toBeInTheDocument();
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "file", expect: { status: "complete", dept: "03" } }]);
|
||||||
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.");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -178,29 +178,100 @@ describe("Q.C. bar", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the reject sheet enables on an NCR alone and sends it upper-cased", async () => {
|
it("the reject sheet needs a reason of 8 characters; the NCR number is optional", async () => {
|
||||||
const fetch = mockFetch({ ...base(qcPacket()), "POST /api/packets/1/transitions": { body: makePacket({ status: "review", version: 6, can: [], blocked: {} }) } });
|
const fetch = mockFetch({ ...base(qcPacket()), "POST /api/packets/1/transitions": { body: makePacket({ status: "review", version: 6, can: [], blocked: {} }) } });
|
||||||
renderApp("/packets/1");
|
renderApp("/packets/1");
|
||||||
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
|
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
|
||||||
fireEvent.click(within(bar).getByRole("button", { name: "Reject" }));
|
fireEvent.click(within(bar).getByRole("button", { name: "Reject" }));
|
||||||
const sheet = await screen.findByRole("dialog", { name: "Reject" });
|
const sheet = await screen.findByRole("dialog", { name: "Reject" });
|
||||||
expect(within(sheet).getByText("Give a reason, an NCR number, or both. The supervisor sees it at the top of the packet.")).toBeInTheDocument();
|
expect(sheet).toHaveTextContent("Say what failed, in detail. Add the NCR number if there is one.");
|
||||||
const submit = within(sheet).getByRole("button", { name: "Reject to supervisor" });
|
const submit = within(sheet).getByRole("button", { name: "Reject to supervisor" });
|
||||||
expect(submit).toBeDisabled();
|
expect(submit).toBeDisabled();
|
||||||
fireEvent.change(within(sheet).getByLabelText("Reason / what failed"), { target: { value: "short" } });
|
// An NCR number alone no longer stands in for the reason.
|
||||||
expect(submit).toBeDisabled();
|
|
||||||
const ncr = within(sheet).getByLabelText("NCR number");
|
const ncr = within(sheet).getByLabelText("NCR number");
|
||||||
expect(ncr).toHaveAttribute("placeholder", "NCR-2026-0021");
|
expect(ncr).toHaveAttribute("placeholder", "NCR-2026-0021");
|
||||||
fireEvent.change(within(sheet).getByLabelText("Reason / what failed"), { target: { value: "" } });
|
|
||||||
fireEvent.change(ncr, { target: { value: "ncr-2026-0021" } });
|
fireEvent.change(ncr, { target: { value: "ncr-2026-0021" } });
|
||||||
|
expect(submit).toBeDisabled();
|
||||||
|
const reason = within(sheet).getByLabelText("Reason / what failed");
|
||||||
|
fireEvent.change(reason, { target: { value: "short" } });
|
||||||
|
expect(submit).toBeDisabled();
|
||||||
|
expect(within(sheet).getByText("At least 8 characters.")).toBeInTheDocument();
|
||||||
|
fireEvent.change(reason, { target: { value: "undercut at W1" } });
|
||||||
expect(submit).toBeEnabled();
|
expect(submit).toBeEnabled();
|
||||||
|
// A malformed NCR number holds it back.
|
||||||
|
fireEvent.change(ncr, { target: { value: "NCR-26" } });
|
||||||
|
expect(submit).toBeDisabled();
|
||||||
|
fireEvent.change(ncr, { target: { value: "ncr-2026-0021" } });
|
||||||
fireEvent.click(submit);
|
fireEvent.click(submit);
|
||||||
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. reject")).toBeInTheDocument();
|
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. reject")).toBeInTheDocument();
|
||||||
await sign();
|
await sign();
|
||||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||||
{ action: "qc_reject", expect: { status: "qc", dept: "03" }, ncr: "NCR-2026-0021", sign: { factor: "password", secret: "pw-1" } },
|
{ action: "qc_reject", expect: { status: "qc", dept: "03" }, reason: "undercut at W1", ncr: "NCR-2026-0021", sign: { factor: "password", secret: "pw-1" } },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects with a reason and no NCR number", async () => {
|
||||||
|
const fetch = mockFetch({ ...base(qcPacket()), "POST /api/packets/1/transitions": { body: makePacket({ status: "review", version: 6, can: [], blocked: {} }) } });
|
||||||
|
renderApp("/packets/1");
|
||||||
|
fireEvent.click(within(await screen.findByRole("region", { name: "Q.C. inspection" })).getByRole("button", { name: "Reject" }));
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "Reject" });
|
||||||
|
fireEvent.change(within(sheet).getByLabelText("Reason / what failed"), { target: { value: " porosity in W2 " } });
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Reject to supervisor" }));
|
||||||
|
await sign();
|
||||||
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||||
|
{ action: "qc_reject", expect: { status: "qc", dept: "03" }, reason: "porosity in W2", sign: { factor: "password", secret: "pw-1" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after a reject without an NCR number, Accept needs the NCR number and sends it", async () => {
|
||||||
|
const p = qcPacket({ qc: inspection({ missing: [] }), ncr_required: true, can: ["qc_accept", "qc_accept_and_route", "qc_reject"], blocked: {} });
|
||||||
|
const fetch = mockFetch({
|
||||||
|
...base(p),
|
||||||
|
"POST /api/packets/1/transitions": { body: makePacket({ status: "complete", version: 6, routed_to: "databook", can: [], blocked: {}, holder: "Clerical" }) },
|
||||||
|
});
|
||||||
|
renderApp("/packets/1");
|
||||||
|
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
|
||||||
|
expect(within(bar).getByTestId("ncr-required")).toHaveTextContent("This packet was rejected without an NCR number. Enter the NCR number of the reject to accept it.");
|
||||||
|
const accept = within(bar).getByRole("button", { name: "Accept" });
|
||||||
|
expect(accept).toBeDisabled();
|
||||||
|
expect(accept).toHaveAttribute("title", "Enter the NCR number of the reject first.");
|
||||||
|
const ncr = within(bar).getByLabelText("NCR number of the reject");
|
||||||
|
expect(ncr).toBeRequired();
|
||||||
|
fireEvent.change(ncr, { target: { value: "NCR-2026" } });
|
||||||
|
expect(accept).toBeDisabled();
|
||||||
|
expect(within(bar).getByText("Enter an NCR number like NCR-2026-0021.")).toBeInTheDocument();
|
||||||
|
fireEvent.change(ncr, { target: { value: " ncr-2026-0042 " } });
|
||||||
|
expect(accept).toBeEnabled();
|
||||||
|
fireEvent.click(accept);
|
||||||
|
await sign();
|
||||||
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||||
|
{ action: "qc_accept", expect: { status: "qc", dept: "03" }, routed_to: "databook", ncr: "NCR-2026-0042", sign: { factor: "password", secret: "pw-1" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accept and route also carries the NCR number when it is required", async () => {
|
||||||
|
const p = qcPacket({ qc: inspection({ missing: [] }), ncr_required: true, can: ["qc_accept", "qc_accept_and_route", "qc_reject"], blocked: {} });
|
||||||
|
const fetch = mockFetch({ ...base(p), "POST /api/packets/1/transitions": { body: makePacket({ status: "ready", version: 6, can: [], blocked: {} }) } });
|
||||||
|
renderApp("/packets/1");
|
||||||
|
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
|
||||||
|
fireEvent.change(within(bar).getByLabelText("Route"), { target: { value: "DEPT:09" } });
|
||||||
|
fireEvent.change(within(bar).getByLabelText("NCR number of the reject"), { target: { value: "NCR-2026-0042" } });
|
||||||
|
fireEvent.click(within(bar).getByRole("button", { name: "Accept" }));
|
||||||
|
await sign();
|
||||||
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||||
|
{ action: "qc_accept_and_route", expect: { status: "qc", dept: "03" }, dept: "09", ncr: "NCR-2026-0042", sign: { factor: "password", secret: "pw-1" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no NCR field when none is required", async () => {
|
||||||
|
const p = qcPacket({ qc: inspection({ missing: [] }), ncr_required: false, can: ["qc_accept", "qc_accept_and_route", "qc_reject"], blocked: {} });
|
||||||
|
mockFetch(base(p));
|
||||||
|
renderApp("/packets/1");
|
||||||
|
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
|
||||||
|
expect(within(bar).queryByLabelText("NCR number of the reject")).not.toBeInTheDocument();
|
||||||
|
expect(within(bar).queryByTestId("ncr-required")).not.toBeInTheDocument();
|
||||||
|
expect(within(bar).getByRole("button", { name: "Accept" })).toBeEnabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Callouts", () => {
|
describe("Callouts", () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Small helpers the shop tabs share: the drawing page list, the user's level, the department people.
|
// Small helpers the shop tabs share: the drawing page list, the user's level, the department people.
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { packetsApi, peopleApi } from "../api/packets";
|
import { packetsApi, peopleApi } from "../api/packets";
|
||||||
import type { DeptPerson, Guarded, PacketPage, PacketRead, PacketStatus, TransitionBody } from "../api/types";
|
import type { BomLine, DeptPerson, Guarded, PacketPage, PacketRead, PacketStatus, ShortReceipt, TransitionBody } from "../api/types";
|
||||||
import type { Mutate } from "./tabs";
|
import type { Mutate } from "./tabs";
|
||||||
import { parseRole } from "../auth/roles";
|
import { parseRole } from "../auth/roles";
|
||||||
|
|
||||||
@@ -102,3 +102,12 @@ export function makeStep(p: PacketRead, mutate: Mutate): StepFn {
|
|||||||
return (action, extra = {}, opts = {}) =>
|
return (action, extra = {}, opts = {}) =>
|
||||||
mutate((g: Guarded) => packetsApi.transition(p.id, { action, expect, ...extra, ...g }), { sign: opts.sign, ok: opts.ok });
|
mutate((g: Guarded) => packetsApi.transition(p.id, { action, expect, ...extra, ...g }), { sign: opts.sign, ok: opts.ok });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The receipts of a BOM line's shorts, open or closed: `shorts` on the read when the server sends it, else the
|
||||||
|
* open short on the line.
|
||||||
|
*/
|
||||||
|
export function lineReceipts(p: PacketRead, b: BomLine): ShortReceipt[] {
|
||||||
|
if (p.shorts) return p.shorts.filter((s) => s.item === b.item).flatMap((s) => s.receipts ?? []);
|
||||||
|
return b.short?.receipts ?? [];
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { BomLine, BomShort, PacketRead, PacketShort } from "../api/types";
|
||||||
|
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
||||||
|
import { callsTo, mockFetch } from "../test/fetchMock";
|
||||||
|
import { makePacket } from "../test/packets";
|
||||||
|
|
||||||
|
const WES = { id: "33333333-3333-3333-3333-333333333333", display_name: "Wes Warehouse" };
|
||||||
|
const SAM = { id: "44444444-4444-4444-4444-444444444444", display_name: "Sam Super" };
|
||||||
|
|
||||||
|
function short(over: Partial<BomShort> = {}): BomShort {
|
||||||
|
return {
|
||||||
|
id: 5,
|
||||||
|
qty: 2,
|
||||||
|
qty_open: 0,
|
||||||
|
reason: "Not in stock",
|
||||||
|
ref: "PO-1",
|
||||||
|
eta: null,
|
||||||
|
note: null,
|
||||||
|
opened_at: "2026-10-16T08:00:00Z",
|
||||||
|
receipts: [
|
||||||
|
{ id: 11, qty: 1, heat: "H100", note: null, at: "2026-10-17T08:00:00Z", by: WES, approval: { at: "2026-10-17T09:00:00Z", by: SAM, note: null } },
|
||||||
|
{ id: 12, qty: 1, heat: "H200", note: null, at: "2026-10-18T08:00:00Z", by: WES, approval: null },
|
||||||
|
],
|
||||||
|
row_version: 2,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The read's `shorts` row of a short. */
|
||||||
|
function row(s: BomShort, closed = true): PacketShort {
|
||||||
|
return { ...s, item: 3, closed_at: closed ? "2026-10-18T08:00:00Z" : null, receipts: s.receipts ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(s: BomShort | null): BomLine {
|
||||||
|
return { item: 3, qty: 2, part_id: "P-1", description: "Pipe", dwg_ref: null, length: null, material: "A106", heat: "H100", pulled: false, short: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
function packet(over: Partial<PacketRead> = {}): PacketRead {
|
||||||
|
// A fully received short is closed: it leaves the BOM line and stays in `shorts`.
|
||||||
|
return makePacket({
|
||||||
|
status: "in_progress",
|
||||||
|
holder: "03 Weld Parts",
|
||||||
|
bom: [line(null)],
|
||||||
|
shorts: [row(short())],
|
||||||
|
shorts_unapproved: 1,
|
||||||
|
can: ["short_approve"],
|
||||||
|
blocked: {},
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function base(p: PacketRead, roles = ["shop:03:supervisor"]) {
|
||||||
|
return {
|
||||||
|
"/api/auth/me": { body: makeMe({ roles, screens: ["supervisor"] }) },
|
||||||
|
"/api/departments": { body: DEPARTMENTS },
|
||||||
|
"/api/packets/1": { body: p },
|
||||||
|
"/api/packets/1/log": { body: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMaterials() {
|
||||||
|
renderApp("/packets/1");
|
||||||
|
fireEvent.click(await screen.findByRole("tab", { name: /^Materials/ }));
|
||||||
|
return screen.getByRole("group", { name: "Receipts for item 3" });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Short receipts (change C7)", () => {
|
||||||
|
it("shows who received each receipt and who approved it", async () => {
|
||||||
|
mockFetch(base(packet({ can: [] })));
|
||||||
|
const box = await openMaterials();
|
||||||
|
const rows = within(box).getAllByTestId("short-receipt");
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0]).toHaveTextContent(/^Received 1 · heat H100 · by Wes Warehouse · .+Approved by Sam Super /);
|
||||||
|
expect(rows[1]).toHaveTextContent(/^Received 1 · heat H200 · by Wes Warehouse · .+Waiting for the supervisor's approval\.$/);
|
||||||
|
expect(screen.getByText("Short received")).toHaveClass("pill", "st-ok");
|
||||||
|
expect(screen.getByTestId("shorts-unapproved")).toHaveTextContent("1 short is still to receive or approve.");
|
||||||
|
// Without short_approve there is no button.
|
||||||
|
expect(within(box).queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a supervisor approves one receipt with the signature", async () => {
|
||||||
|
const done = packet({
|
||||||
|
version: 4,
|
||||||
|
shorts_unapproved: 0,
|
||||||
|
shorts: [row(short({ receipts: (short().receipts ?? []).map((r) => ({ ...r, approval: r.approval ?? { at: "2026-10-18T09:00:00Z", by: SAM, note: null } })) }))],
|
||||||
|
});
|
||||||
|
const fetch = mockFetch({
|
||||||
|
...base(packet()),
|
||||||
|
"POST /api/packets/1/shorts/receipts/12/approve": (req) =>
|
||||||
|
(req.body as { sign?: unknown }).sign
|
||||||
|
? { body: done }
|
||||||
|
: { status: 400, body: { code: "signature_required", detail: "This step needs your signature.", meaning: "Approve a received short" } },
|
||||||
|
});
|
||||||
|
const box = await openMaterials();
|
||||||
|
const approve = within(box).getAllByRole("button", { name: "Approve" });
|
||||||
|
expect(approve).toHaveLength(1);
|
||||||
|
fireEvent.click(approve[0]!);
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "Sign" });
|
||||||
|
expect(within(sheet).getByText("Approve a received short")).toBeInTheDocument();
|
||||||
|
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "pw-1" } });
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("shorts-unapproved")).not.toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
|
||||||
|
expect(callsTo(fetch, "POST", "/api/packets/1/shorts/receipts/12/approve")).toEqual([{ sign: { factor: "password", secret: "pw-1" } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an open short keeps its chip and lists the receipts so far", async () => {
|
||||||
|
const open = short({ qty_open: 1, receipts: [short().receipts![1]!] });
|
||||||
|
mockFetch(base(packet({ can: [], shorts_unapproved: 2, bom: [line(open)], shorts: [row(open, false)] })));
|
||||||
|
const box = await openMaterials();
|
||||||
|
expect(screen.getByText("Short 1")).toBeInTheDocument();
|
||||||
|
expect(within(box).getAllByTestId("short-receipt")).toHaveLength(1);
|
||||||
|
expect(screen.getByTestId("shorts-unapproved")).toHaveTextContent("2 shorts are still to receive or approve.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without `shorts` on the read, the open short's own receipts show", async () => {
|
||||||
|
const open = short({ qty_open: 1, receipts: [short().receipts![1]!] });
|
||||||
|
const p = packet({ bom: [line(open)] });
|
||||||
|
delete p.shorts;
|
||||||
|
mockFetch(base(p));
|
||||||
|
const box = await openMaterials();
|
||||||
|
expect(within(box).getAllByTestId("short-receipt")).toHaveLength(1);
|
||||||
|
expect(within(box).getByRole("button", { name: "Approve" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,8 @@ import { MtrViewer } from "../warehouse/MtrViewer";
|
|||||||
import { PullTable, ShortChip, type Write } from "../warehouse/PullTable";
|
import { PullTable, ShortChip, type Write } from "../warehouse/PullTable";
|
||||||
import { ShortSheet } from "../warehouse/ShortSheet";
|
import { ShortSheet } from "../warehouse/ShortSheet";
|
||||||
import { assetOfUrl, kioskCode, openPrint, printLineText } from "./closeUtil";
|
import { assetOfUrl, kioskCode, openPrint, printLineText } from "./closeUtil";
|
||||||
|
import { ShortReceipts } from "./ShortReceipts";
|
||||||
|
import { lineReceipts } from "./shopUtil";
|
||||||
|
|
||||||
export type Mutate = (
|
export type Mutate = (
|
||||||
fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
|
fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
|
||||||
@@ -173,6 +175,12 @@ export function MaterialsTab({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{(p.shorts_unapproved ?? 0) > 0 ? (
|
||||||
|
<div className="small due soon" data-testid="shorts-unapproved">
|
||||||
|
{p.shorts_unapproved === 1 ? "1 short is" : `${p.shorts_unapproved} shorts are`} still to receive or approve. The department supervisor approves every
|
||||||
|
received short before the packet can be complete.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{p.bom.length ? (
|
{p.bom.length ? (
|
||||||
<div className="tw">
|
<div className="tw">
|
||||||
<table>
|
<table>
|
||||||
@@ -227,9 +235,7 @@ export function MaterialsTab({
|
|||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{b.na && !b.short ? (
|
{b.short ? (
|
||||||
<span className="pill st-filed">N/A</span>
|
|
||||||
) : b.short ? (
|
|
||||||
<div className="stack tight">
|
<div className="stack tight">
|
||||||
<ShortChip s={b.short} />
|
<ShortChip s={b.short} />
|
||||||
{canReceive && write ? (
|
{canReceive && write ? (
|
||||||
@@ -237,7 +243,15 @@ export function MaterialsTab({
|
|||||||
Receive
|
Receive
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<ShortReceipts p={p} receipts={lineReceipts(p, b)} item={b.item} busy={busy} mutate={mutate} />
|
||||||
</div>
|
</div>
|
||||||
|
) : lineReceipts(p, b).length ? (
|
||||||
|
<div className="stack tight">
|
||||||
|
<span className="pill st-ok">Short received</span>
|
||||||
|
<ShortReceipts p={p} receipts={lineReceipts(p, b)} item={b.item} busy={busy} mutate={mutate} />
|
||||||
|
</div>
|
||||||
|
) : b.na ? (
|
||||||
|
<span className="pill st-filed">N/A</span>
|
||||||
) : b.pulled ? (
|
) : b.pulled ? (
|
||||||
<span className="pill st-ok">Pulled</span>
|
<span className="pill st-ok">Pulled</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
|||||||
import { Navigate, useParams } from "react-router";
|
import { Navigate, useParams } from "react-router";
|
||||||
import { homePath, isScreen, orderScreens, type Screen } from "../auth/roles";
|
import { homePath, isScreen, orderScreens, type Screen } from "../auth/roles";
|
||||||
import { useAuth } from "../auth/useAuth";
|
import { useAuth } from "../auth/useAuth";
|
||||||
|
import { PM_WHO, shellScreens } from "../lib/toolAccess";
|
||||||
import { AdminPage } from "./admin/AdminPage";
|
import { AdminPage } from "./admin/AdminPage";
|
||||||
import { ClericalHome } from "./clerical/ClericalHome";
|
import { ClericalHome } from "./clerical/ClericalHome";
|
||||||
import { PlaceholderScreen } from "./PlaceholderScreen";
|
import { PlaceholderScreen } from "./PlaceholderScreen";
|
||||||
@@ -9,6 +10,7 @@ import { PmHome } from "./pm/PmHome";
|
|||||||
import { QcHome } from "./qc/QcHome";
|
import { QcHome } from "./qc/QcHome";
|
||||||
import { StallHome } from "./stall/StallHome";
|
import { StallHome } from "./stall/StallHome";
|
||||||
import { SupervisorHome } from "./supervisor/SupervisorHome";
|
import { SupervisorHome } from "./supervisor/SupervisorHome";
|
||||||
|
import { NotForRole } from "./tools/RequireRole";
|
||||||
import { WarehouseHome } from "./warehouse/WarehouseHome";
|
import { WarehouseHome } from "./warehouse/WarehouseHome";
|
||||||
|
|
||||||
/** A page under a screen, like /clerical/projects/7. Users without the screen go to their first screen. */
|
/** A page under a screen, like /clerical/projects/7. Users without the screen go to their first screen. */
|
||||||
@@ -24,8 +26,10 @@ export function ScreenRoute() {
|
|||||||
const { screen } = useParams();
|
const { screen } = useParams();
|
||||||
const { me } = useAuth();
|
const { me } = useAuth();
|
||||||
if (!me) return null;
|
if (!me) return null;
|
||||||
const allowed = orderScreens(me.screens);
|
const allowed = shellScreens(me.screens, me.roles);
|
||||||
if (!isScreen(screen) || !allowed.includes(screen)) return <Navigate to={homePath(me.screens)} replace />;
|
// The PM screen says why instead of moving on (change C10).
|
||||||
|
if (screen === "pm" && !allowed.includes("pm")) return <NotForRole what="The Project Management screen" who={PM_WHO} />;
|
||||||
|
if (!isScreen(screen) || !allowed.includes(screen)) return <Navigate to={homePath(allowed)} replace />;
|
||||||
if (screen === "admin") return <AdminPage />;
|
if (screen === "admin") return <AdminPage />;
|
||||||
if (screen === "clerical") return <ClericalHome />;
|
if (screen === "clerical") return <ClericalHome />;
|
||||||
if (screen === "warehouse") return <WarehouseHome />;
|
if (screen === "warehouse") return <WarehouseHome />;
|
||||||
|
|||||||
@@ -1,94 +1,35 @@
|
|||||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { adminBase } from "../../test/admin";
|
import { adminBase } from "../../test/admin";
|
||||||
import { renderApp } from "../../test/auth";
|
import { renderApp } from "../../test/auth";
|
||||||
import { callsTo, mockFetch } from "../../test/fetchMock";
|
import { callsTo, mockFetch } from "../../test/fetchMock";
|
||||||
|
import { memoryStorage } from "../supervisor/memoryStorage";
|
||||||
|
|
||||||
const VALUE = {
|
const ITEMS = [{ group: "Before welding", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" }];
|
||||||
weld: [
|
|
||||||
{ group: "Before welding", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" },
|
|
||||||
{ group: "Before welding", key: "wps", text: "WPS reviewed: {wps}" },
|
|
||||||
{ group: "After welding", key: "vt", text: "Visual inspection of all welds acceptable" },
|
|
||||||
],
|
|
||||||
plain: [{ group: "Before starting", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" }],
|
|
||||||
};
|
|
||||||
const DEFAULTS = { weld: VALUE.weld.slice(0, 2), plain: VALUE.plain };
|
|
||||||
|
|
||||||
async function openTab() {
|
beforeEach(() => {
|
||||||
renderApp("/admin");
|
vi.stubGlobal("localStorage", memoryStorage());
|
||||||
fireEvent.click(await screen.findByRole("tab", { name: "Checklists" }));
|
});
|
||||||
return screen.findByLabelText("Weld item 1 text");
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Admin Checklists tab", () => {
|
describe("Admin Checklists tab", () => {
|
||||||
it("edits, adds, reorders and removes items, checks them, and saves with the version", async () => {
|
it("shows the department editor on the new endpoints and links the Checklists screen", async () => {
|
||||||
const fetch = mockFetch({
|
const fetch = mockFetch({
|
||||||
...adminBase(),
|
...adminBase(),
|
||||||
"GET /api/admin/checklists": { body: { value: VALUE, defaults: DEFAULTS, version: 4 } },
|
"GET /api/checklists": {
|
||||||
"PUT /api/admin/checklists": { body: { value: VALUE, defaults: DEFAULTS, version: 5 } },
|
body: [
|
||||||
});
|
{ dept_code: "03", dept_name: "Weld Parts", weld: true, custom: false, version: 0, editable: true },
|
||||||
await openTab();
|
{ dept_code: "14", dept_name: "Electrical", weld: false, custom: true, version: 2, editable: true },
|
||||||
expect(screen.getByLabelText("Weld item 1 key")).toHaveAttribute("readonly");
|
],
|
||||||
expect(screen.getByLabelText("Plain item 1 group")).toHaveValue("Before starting");
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Weld item 3 text"), { target: { value: "Visual check of every weld" } });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Move weld item 3 up" }));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Remove weld item 1" }));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Add weld item" }));
|
|
||||||
const key = screen.getByLabelText("Weld item 3 key");
|
|
||||||
expect(key).not.toHaveAttribute("readonly");
|
|
||||||
fireEvent.change(key, { target: { value: "Clean" } });
|
|
||||||
fireEvent.change(screen.getByLabelText("Weld item 3 text"), { target: { value: "Slag removed near {part}" } });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent("Weld list, item 3: the key is lower case");
|
|
||||||
|
|
||||||
fireEvent.change(key, { target: { value: "clean" } });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent("only {drawing}, {rev} and {wps}");
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Weld item 3 text"), { target: { value: "Slag removed" } });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
|
|
||||||
expect(await screen.findByText(/Checklists saved/)).toBeInTheDocument();
|
|
||||||
expect(callsTo(fetch, "PUT", "/api/admin/checklists")).toEqual([
|
|
||||||
{
|
|
||||||
version: 4,
|
|
||||||
value: {
|
|
||||||
weld: [
|
|
||||||
{ group: "After welding", key: "vt", text: "Visual check of every weld" },
|
|
||||||
{ group: "Before welding", key: "wps", text: "WPS reviewed: {wps}" },
|
|
||||||
{ group: "Before welding", key: "clean", text: "Slag removed" },
|
|
||||||
],
|
|
||||||
plain: VALUE.plain,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]);
|
"GET /api/checklists/03": {
|
||||||
});
|
body: { dept_code: "03", dept_name: "Weld Parts", weld: true, custom: false, version: 0, editable: true, items: ITEMS, defaults: ITEMS },
|
||||||
|
|
||||||
it("loads the other version after a 409", async () => {
|
|
||||||
let gets = 0;
|
|
||||||
const fetch = mockFetch({
|
|
||||||
...adminBase(),
|
|
||||||
"GET /api/admin/checklists": () => {
|
|
||||||
gets += 1;
|
|
||||||
return { body: { value: gets > 1 ? { ...VALUE, plain: [{ ...VALUE.plain[0], text: "Changed by Bob" }] } : VALUE, defaults: DEFAULTS, version: 3 + gets } };
|
|
||||||
},
|
},
|
||||||
"PUT /api/admin/checklists": { status: 409, body: { detail: "Changed by someone else", code: "version_conflict" } },
|
|
||||||
});
|
});
|
||||||
await openTab();
|
renderApp("/admin");
|
||||||
fireEvent.change(screen.getByLabelText("Plain item 1 text"), { target: { value: "Mine" } });
|
fireEvent.click(await screen.findByRole("tab", { name: "Checklists" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
|
expect(await screen.findByLabelText("Item 1 text")).toHaveValue(ITEMS[0]?.text);
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent("Someone else saved the checklists first.");
|
expect(within(screen.getByRole("main")).getByRole("link", { name: "Checklists" })).toHaveAttribute("href", "/checklists");
|
||||||
await waitFor(() => expect(screen.getByLabelText("Plain item 1 text")).toHaveValue("Changed by Bob"));
|
expect(screen.getByRole("combobox", { name: "Department" })).toHaveValue("03");
|
||||||
expect(callsTo(fetch, "GET", "/api/admin/checklists")).toHaveLength(2);
|
await waitFor(() => expect(callsTo(fetch, "GET", "/api/admin/checklists")).toHaveLength(0));
|
||||||
});
|
|
||||||
|
|
||||||
it("copies the default list in", async () => {
|
|
||||||
mockFetch({ ...adminBase(), "GET /api/admin/checklists": { body: { value: VALUE, defaults: DEFAULTS, version: 1 } } });
|
|
||||||
await openTab();
|
|
||||||
expect(screen.getByLabelText("Weld item 3 text")).toBeInTheDocument();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Use the default weld list" }));
|
|
||||||
expect(screen.queryByLabelText("Weld item 3 text")).not.toBeInTheDocument();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Discard changes" }));
|
|
||||||
expect(screen.getByLabelText("Weld item 3 text")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,231 +1,19 @@
|
|||||||
// Admin: the department checklists, one list for weld departments and one for the rest (phase 9).
|
// Admin: the department checklists. Since change C8 every department has its own list, edited on the Checklists
|
||||||
import { useEffect, useState } from "react";
|
// screen; Admin may edit any department, so this tab shows the same editor on the same endpoints (/api/checklists).
|
||||||
import { admin9Api, type ChecklistItem, type ChecklistLists, type ChecklistsAnswer } from "../../api/admin";
|
import { Link } from "react-router";
|
||||||
import { ApiError } from "../../api/client";
|
import { DeptChecklists } from "../checklists/DeptChecklists";
|
||||||
import { errorText } from "../../lib/format";
|
|
||||||
import { useLoad } from "../../lib/useLoad";
|
|
||||||
import { Notice, Problem, type Msg } from "./common";
|
|
||||||
|
|
||||||
type ListId = "weld" | "plain";
|
|
||||||
const LISTS: ReadonlyArray<{ id: ListId; title: string; one: string }> = [
|
|
||||||
{ id: "weld", title: "Weld departments", one: "weld" },
|
|
||||||
{ id: "plain", title: "Other departments", one: "plain" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const RX_KEY = /^[a-z][a-z0-9_]{0,29}$/;
|
|
||||||
const MAX_ITEMS = 40;
|
|
||||||
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
const PLACEHOLDERS = new Set(["drawing", "rev", "wps"]);
|
|
||||||
|
|
||||||
interface Row extends ChecklistItem {
|
|
||||||
/** True for a key the saved list already has. Its key cannot change, because answers on open stages use it. */
|
|
||||||
saved: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Draft = Record<ListId, Row[]>;
|
|
||||||
|
|
||||||
function toDraft(v: ChecklistLists): Draft {
|
|
||||||
return {
|
|
||||||
weld: v.weld.map((it) => ({ ...it, saved: true })),
|
|
||||||
plain: v.plain.map((it) => ({ ...it, saved: true })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The first problem with one list, or null. Mirrors the server's rules. */
|
|
||||||
function listProblem(rows: readonly ChecklistItem[], title: string): string | null {
|
|
||||||
if (rows.length === 0) return `${title}: the list needs at least one item.`;
|
|
||||||
if (rows.length > MAX_ITEMS) return `${title}: at most ${MAX_ITEMS} items.`;
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const [i, r] of rows.entries()) {
|
|
||||||
const n = i + 1;
|
|
||||||
if (!r.group.trim()) return `${title}, item ${n}: give it a group.`;
|
|
||||||
if (!r.text.trim()) return `${title}, item ${n}: give it a text.`;
|
|
||||||
if (!RX_KEY.test(r.key)) return `${title}, item ${n}: the key is lower case letters, digits and _, starting with a letter.`;
|
|
||||||
if (seen.has(r.key)) return `${title}, item ${n}: the key ${r.key} is used twice.`;
|
|
||||||
seen.add(r.key);
|
|
||||||
for (const m of r.text.matchAll(/\{([^}]*)\}/g)) {
|
|
||||||
if (!PLACEHOLDERS.has(m[1] ?? "")) return `${title}, item ${n}: only {drawing}, {rev} and {wps} can be filled in.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ListEditor({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
one,
|
|
||||||
rows,
|
|
||||||
onChange,
|
|
||||||
onDefaults,
|
|
||||||
}: {
|
|
||||||
id: ListId;
|
|
||||||
title: string;
|
|
||||||
one: string;
|
|
||||||
rows: Row[];
|
|
||||||
onChange: (rows: Row[]) => void;
|
|
||||||
onDefaults: () => void;
|
|
||||||
}) {
|
|
||||||
const set = (i: number, patch: Partial<Row>) => onChange(rows.map((r, j) => (j === i ? { ...r, ...patch } : r)));
|
|
||||||
const move = (i: number, d: number) => {
|
|
||||||
const j = i + d;
|
|
||||||
if (j < 0 || j >= rows.length) return;
|
|
||||||
const out = [...rows];
|
|
||||||
const a = out[i];
|
|
||||||
const b = out[j];
|
|
||||||
if (!a || !b) return;
|
|
||||||
out[i] = b;
|
|
||||||
out[j] = a;
|
|
||||||
onChange(out);
|
|
||||||
};
|
|
||||||
const add = () => {
|
|
||||||
const last = rows[rows.length - 1];
|
|
||||||
onChange([...rows, { group: last?.group ?? "", key: "", text: "", saved: false }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="panel stack" aria-labelledby={`ckl-${id}-h`}>
|
|
||||||
<div className="sec-h flush">
|
|
||||||
<h3 id={`ckl-${id}-h`}>{title}</h3>
|
|
||||||
<span className="ct">{rows.length} items</span>
|
|
||||||
<span className="grow" />
|
|
||||||
<button className="btn sm ghost" type="button" onClick={onDefaults}>
|
|
||||||
{`Use the default ${one} list`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{rows.map((r, i) => {
|
|
||||||
const n = i + 1;
|
|
||||||
return (
|
|
||||||
<div className="frow" key={`${i}-${r.saved ? r.key : "new"}`}>
|
|
||||||
<label className="f">
|
|
||||||
{`${cap(one)} item ${n} group`}
|
|
||||||
<input className="in" value={r.group} maxLength={200} onChange={(e) => set(i, { group: e.target.value })} />
|
|
||||||
</label>
|
|
||||||
<label className="f">
|
|
||||||
{`${cap(one)} item ${n} key`}
|
|
||||||
<input
|
|
||||||
className="in mono"
|
|
||||||
value={r.key}
|
|
||||||
maxLength={30}
|
|
||||||
readOnly={r.saved}
|
|
||||||
title={r.saved ? "A saved key cannot change. Answers on open stages use it." : undefined}
|
|
||||||
onChange={(e) => set(i, { key: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="f" style={{ gridColumn: "span 2" }}>
|
|
||||||
{`${cap(one)} item ${n} text`}
|
|
||||||
<input className="in" value={r.text} maxLength={200} onChange={(e) => set(i, { text: e.target.value })} />
|
|
||||||
</label>
|
|
||||||
<div className="end nowrap">
|
|
||||||
<button className="btn sm ghost" type="button" aria-label={`Move ${one} item ${n} up`} disabled={i === 0} onClick={() => move(i, -1)}>
|
|
||||||
Up
|
|
||||||
</button>{" "}
|
|
||||||
<button
|
|
||||||
className="btn sm ghost"
|
|
||||||
type="button"
|
|
||||||
aria-label={`Move ${one} item ${n} down`}
|
|
||||||
disabled={i === rows.length - 1}
|
|
||||||
onClick={() => move(i, 1)}
|
|
||||||
>
|
|
||||||
Down
|
|
||||||
</button>{" "}
|
|
||||||
<button className="btn sm rejb" type="button" aria-label={`Remove ${one} item ${n}`} onClick={() => onChange(rows.filter((_, j) => j !== i))}>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div>
|
|
||||||
<button className="btn sm" type="button" disabled={rows.length >= MAX_ITEMS} onClick={add}>
|
|
||||||
{`Add ${one} item`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChecklistsTab() {
|
export function ChecklistsTab() {
|
||||||
const [load, reload] = useLoad((s) => admin9Api.checklists(s));
|
|
||||||
const [base, setBase] = useState<ChecklistsAnswer | null>(null);
|
|
||||||
const [draft, setDraft] = useState<Draft | null>(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [msg, setMsg] = useState<Msg>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (load.state === "ok") {
|
|
||||||
setBase(load.data);
|
|
||||||
setDraft(toDraft(load.data.value));
|
|
||||||
}
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
if (!base || !draft) return <Problem load={load} />;
|
|
||||||
|
|
||||||
const strip = (rows: Row[]): ChecklistItem[] => rows.map((r) => ({ group: r.group.trim(), key: r.key.trim(), text: r.text.trim() }));
|
|
||||||
const savedKeys = new Set([...base.value.weld, ...base.value.plain].map((r) => r.key));
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
const value: ChecklistLists = { weld: strip(draft.weld), plain: strip(draft.plain) };
|
|
||||||
const problem = listProblem(value.weld, "Weld list") ?? listProblem(value.plain, "Other list");
|
|
||||||
if (problem) {
|
|
||||||
setMsg({ ok: false, text: problem });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy(true);
|
|
||||||
setMsg(null);
|
|
||||||
try {
|
|
||||||
const r = await admin9Api.saveChecklists(value, base.version);
|
|
||||||
setBase(r);
|
|
||||||
setDraft(toDraft(r.value));
|
|
||||||
setMsg({ ok: true, text: "Checklists saved. New checklists use them. Open stages keep their answers." });
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError && err.status === 409) {
|
|
||||||
setMsg({ ok: false, text: "Someone else saved the checklists first. Their version is loaded now. Make your change again." });
|
|
||||||
reload();
|
|
||||||
} else {
|
|
||||||
setMsg({ ok: false, text: errorText(err) });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaults = (id: ListId) =>
|
|
||||||
setDraft((cur) => (cur ? { ...cur, [id]: base.defaults[id].map((it) => ({ ...it, saved: savedKeys.has(it.key) })) } : cur));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<div className="small muted">
|
<div className="small muted">
|
||||||
Weld departments get the first list, the others the second. Text can use {"{drawing}"}, {"{rev}"} and {"{wps}"}. A removed item drops out of new
|
Department supervisors edit their own department's list on the{" "}
|
||||||
checklists only.
|
<Link className="lnk" to="/checklists">
|
||||||
</div>
|
Checklists
|
||||||
{LISTS.map((l) => (
|
</Link>{" "}
|
||||||
<ListEditor
|
screen.
|
||||||
key={l.id}
|
|
||||||
id={l.id}
|
|
||||||
title={l.title}
|
|
||||||
one={l.one}
|
|
||||||
rows={draft[l.id]}
|
|
||||||
onChange={(rows) => setDraft((cur) => (cur ? { ...cur, [l.id]: rows } : cur))}
|
|
||||||
onDefaults={() => defaults(l.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Notice msg={msg} />
|
|
||||||
<div className="chips">
|
|
||||||
<button className="btn pri" type="button" disabled={busy} onClick={() => void save()}>
|
|
||||||
Save checklists
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn ghost"
|
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => {
|
|
||||||
setDraft(toDraft(base.value));
|
|
||||||
setMsg(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Discard changes
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<DeptChecklists />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("Admin Stamp check tab", () => {
|
|||||||
it("checks a typed stamp line", async () => {
|
it("checks a typed stamp line", async () => {
|
||||||
const fetch = mockFetch({
|
const fetch = mockFetch({
|
||||||
...adminBase(),
|
...adminBase(),
|
||||||
"POST /api/admin/stamp-verify": { body: { found: true, valid: true, reason: null, print: PRINT } },
|
"POST /api/stamp-verify": { body: { found: true, valid: true, reason: null, print: PRINT } },
|
||||||
});
|
});
|
||||||
const box = await openTab();
|
const box = await openTab();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Check stamp" }));
|
fireEvent.click(screen.getByRole("button", { name: "Check stamp" }));
|
||||||
@@ -37,14 +37,14 @@ describe("Admin Stamp check tab", () => {
|
|||||||
expect(within(result).getByText("Clara Clerk")).toBeInTheDocument();
|
expect(within(result).getByText("Clara Clerk")).toBeInTheDocument();
|
||||||
expect(within(result).getByText("L662001/1")).toBeInTheDocument();
|
expect(within(result).getByText("L662001/1")).toBeInTheDocument();
|
||||||
expect(within(result).getByText("Data book, 12 pages")).toBeInTheDocument();
|
expect(within(result).getByText("Data book, 12 pages")).toBeInTheDocument();
|
||||||
expect(callsTo(fetch, "POST", "/api/admin/stamp-verify")).toEqual([{ text: "PS 0123456789abcdef" }]);
|
expect(callsTo(fetch, "POST", "/api/stamp-verify")).toEqual([{ text: "PS 0123456789abcdef" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says when a stamp is not found or does not verify", async () => {
|
it("says when a stamp is not found or does not verify", async () => {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
mockFetch({
|
mockFetch({
|
||||||
...adminBase(),
|
...adminBase(),
|
||||||
"POST /api/admin/stamp-verify": () => {
|
"POST /api/stamp-verify": () => {
|
||||||
n += 1;
|
n += 1;
|
||||||
return n === 1
|
return n === 1
|
||||||
? { body: { found: false, valid: false, reason: null, print: null } }
|
? { body: { found: false, valid: false, reason: null, print: null } }
|
||||||
@@ -63,7 +63,7 @@ describe("Admin Stamp check tab", () => {
|
|||||||
it("uploads a PDF and lists each stamp found", async () => {
|
it("uploads a PDF and lists each stamp found", async () => {
|
||||||
const fetch = mockFetch({
|
const fetch = mockFetch({
|
||||||
...adminBase(),
|
...adminBase(),
|
||||||
"POST /api/admin/stamp-verify/pdf": {
|
"POST /api/stamp-verify/pdf": {
|
||||||
body: { stamps: [{ found: true, valid: true, reason: null, print: PRINT }, { found: false, valid: false, reason: null, print: null }] },
|
body: { stamps: [{ found: true, valid: true, reason: null, print: PRINT }, { found: false, valid: false, reason: null, print: null }] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -78,8 +78,8 @@ describe("Admin Stamp check tab", () => {
|
|||||||
const results = await screen.findByRole("status", { name: "PDF stamp results" });
|
const results = await screen.findByRole("status", { name: "PDF stamp results" });
|
||||||
expect(results).toHaveTextContent("Stamp 1: Genuine.");
|
expect(results).toHaveTextContent("Stamp 1: Genuine.");
|
||||||
expect(results).toHaveTextContent("Stamp 2: Not found.");
|
expect(results).toHaveTextContent("Stamp 2: Not found.");
|
||||||
await waitFor(() => expect(callsTo(fetch, "POST", "/api/admin/stamp-verify/pdf")).toHaveLength(1));
|
await waitFor(() => expect(callsTo(fetch, "POST", "/api/stamp-verify/pdf")).toHaveLength(1));
|
||||||
const init = fetch.mock.calls.find(([u]) => String(u) === "/api/admin/stamp-verify/pdf")?.[1];
|
const init = fetch.mock.calls.find(([u]) => String(u) === "/api/stamp-verify/pdf")?.[1];
|
||||||
expect(init?.body).toBeInstanceOf(FormData);
|
expect(init?.body).toBeInstanceOf(FormData);
|
||||||
expect((init?.body as FormData).get("file")).toBeInstanceOf(File);
|
expect((init?.body as FormData).get("file")).toBeInstanceOf(File);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Admin: check the stamp on a printed or saved copy against the print log (phase 9).
|
// Check the stamp on a printed or saved copy against the print log (phase 9). Admin's tab and, since change C9, the
|
||||||
|
// Stamp check screen for Admin, Q.C. and Clerical. Both call /api/stamp-verify.
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { admin9Api, type StampResult } from "../../api/admin";
|
import { stampApi, type StampResult } from "../../api/stamp";
|
||||||
import { errorText, fmtTime } from "../../lib/format";
|
import { errorText, fmtTime } from "../../lib/format";
|
||||||
import { Notice, type Msg } from "./common";
|
import { Notice, type Msg } from "./common";
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ export function StampVerifyTab() {
|
|||||||
setMsg(null);
|
setMsg(null);
|
||||||
setOne(null);
|
setOne(null);
|
||||||
try {
|
try {
|
||||||
setOne(await admin9Api.stampVerify(text.trim()));
|
setOne(await stampApi.verify(text.trim()));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMsg({ ok: false, text: errorText(err) });
|
setMsg({ ok: false, text: errorText(err) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -91,7 +92,7 @@ export function StampVerifyTab() {
|
|||||||
setMsg(null);
|
setMsg(null);
|
||||||
setMany(null);
|
setMany(null);
|
||||||
try {
|
try {
|
||||||
setMany((await admin9Api.stampVerifyPdf(file)).stamps);
|
setMany((await stampApi.verifyPdf(file)).stamps);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMsg({ ok: false, text: errorText(err) });
|
setMsg({ ok: false, text: errorText(err) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||||
|
import { callsTo, mockFetch, type Route } from "../../test/fetchMock";
|
||||||
|
import { memoryStorage } from "../supervisor/memoryStorage";
|
||||||
|
import { CONFLICT_TEXT, NONE_TEXT, SAVED_TEXT } from "./DeptChecklists";
|
||||||
|
|
||||||
|
const SUP = makeMe({
|
||||||
|
roles: ["shop:03:supervisor"],
|
||||||
|
screens: ["supervisor", "stall"],
|
||||||
|
user: { display_name: "Wes Super", sam_account: "sup.wes" },
|
||||||
|
});
|
||||||
|
const LEAD = makeMe({ roles: ["shop:03:lead"], screens: ["supervisor", "stall"] });
|
||||||
|
const QC = makeMe({ roles: ["qc"], screens: ["qc"] });
|
||||||
|
|
||||||
|
const DEFAULTS = [
|
||||||
|
{ group: "Before welding", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" },
|
||||||
|
{ group: "Before welding", key: "wps", text: "WPS reviewed: {wps}" },
|
||||||
|
];
|
||||||
|
const OWN = [
|
||||||
|
{ group: "Before welding", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" },
|
||||||
|
{ group: "After welding", key: "vt", text: "Visual inspection of all welds acceptable" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROWS = [
|
||||||
|
{ dept_code: "03", dept_name: "Weld Parts", weld: true, custom: true, version: 3, editable: true },
|
||||||
|
{ dept_code: "14", dept_name: "Electrical", weld: false, custom: false, version: 0, editable: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function dept03(over: Record<string, unknown> = {}) {
|
||||||
|
return { dept_code: "03", dept_name: "Weld Parts", weld: true, custom: true, version: 3, editable: true, items: OWN, defaults: DEFAULTS, ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
function base(me = SUP, extra: Record<string, Route> = {}): Record<string, Route> {
|
||||||
|
return {
|
||||||
|
"/api/auth/me": { body: me },
|
||||||
|
"/api/departments": { body: DEPARTMENTS },
|
||||||
|
"GET /api/checklists": { body: ROWS },
|
||||||
|
"GET /api/checklists/03": { body: dept03() },
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("localStorage", memoryStorage());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Checklists screen", () => {
|
||||||
|
it("is in the top bar for a department supervisor, and lists only their own departments", async () => {
|
||||||
|
mockFetch(base());
|
||||||
|
renderApp("/stall");
|
||||||
|
const link = await screen.findByRole("link", { name: "Checklists" });
|
||||||
|
expect(link).toHaveAttribute("href", "/checklists");
|
||||||
|
fireEvent.click(link);
|
||||||
|
expect(await screen.findByRole("heading", { name: "Checklists" })).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole("heading", { name: "03 Weld Parts" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Own list · 2 items/)).toBeInTheDocument();
|
||||||
|
// One editable department: no picker, and Electrical is not offered.
|
||||||
|
expect(screen.queryByRole("combobox", { name: "Department" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Electrical/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits, checks and saves with the base version", async () => {
|
||||||
|
const fetch = mockFetch(base(SUP, { "PUT /api/checklists/03": { body: dept03({ version: 4 }) } }));
|
||||||
|
renderApp("/checklists");
|
||||||
|
expect(await screen.findByLabelText("Item 1 key")).toHaveAttribute("readonly");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add item" }));
|
||||||
|
fireEvent.change(screen.getByLabelText("Item 3 text"), { target: { value: "Weld size checked" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save checklist" }));
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent("Item 3: the key is lower case letters");
|
||||||
|
fireEvent.change(screen.getByLabelText("Item 3 key"), { target: { value: "size" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Move item 3 up" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save checklist" }));
|
||||||
|
expect(await screen.findByRole("status")).toHaveTextContent(SAVED_TEXT);
|
||||||
|
expect(callsTo(fetch, "PUT", "/api/checklists/03")).toEqual([
|
||||||
|
{
|
||||||
|
items: [OWN[0], { group: "After welding", key: "size", text: "Weld size checked" }, OWN[1]],
|
||||||
|
base: 3,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts from the default list for the kind", async () => {
|
||||||
|
const fetch = mockFetch(base(SUP, { "PUT /api/checklists/03": { body: dept03({ items: DEFAULTS, version: 4 }) } }));
|
||||||
|
renderApp("/checklists");
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Start from the default weld list" }));
|
||||||
|
expect(screen.getByLabelText("Item 2 text")).toHaveValue("WPS reviewed: {wps}");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save checklist" }));
|
||||||
|
await waitFor(() => expect(callsTo(fetch, "PUT", "/api/checklists/03")).toEqual([{ items: DEFAULTS, base: 3 }]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["under current", (c: object) => ({ detail: "Someone saved first.", code: "version_conflict", current: c })],
|
||||||
|
["as the body", (c: object) => c],
|
||||||
|
])("loads the newer list a 409 carries %s", async (_how, wrap) => {
|
||||||
|
const newer = dept03({ version: 5, items: [OWN[0]] });
|
||||||
|
mockFetch(base(SUP, { "PUT /api/checklists/03": { status: 409, body: wrap(newer) } }));
|
||||||
|
renderApp("/checklists");
|
||||||
|
fireEvent.change(await screen.findByLabelText("Item 2 text"), { target: { value: "Changed" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save checklist" }));
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(CONFLICT_TEXT);
|
||||||
|
expect(screen.queryByLabelText("Item 2 text")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Own list · 1 items/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the server's message on a 403", async () => {
|
||||||
|
mockFetch(base(SUP, { "PUT /api/checklists/03": { status: 403, body: { detail: "Only the department supervisor or Admin edits this checklist." } } }));
|
||||||
|
renderApp("/checklists");
|
||||||
|
await screen.findByLabelText("Item 1 text");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save checklist" }));
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent("Only the department supervisor or Admin edits this checklist.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a picker when more than one department is editable", async () => {
|
||||||
|
const fetch = mockFetch(
|
||||||
|
base(makeMe({ roles: ["shop:03:supervisor", "shop:14:supervisor"], screens: ["supervisor", "stall"] }), {
|
||||||
|
"GET /api/checklists": { body: ROWS.map((r) => ({ ...r, editable: true })) },
|
||||||
|
"GET /api/checklists/14": {
|
||||||
|
body: { dept_code: "14", dept_name: "Electrical", weld: false, custom: false, version: 0, editable: true, items: [], defaults: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp("/checklists");
|
||||||
|
const pick = await screen.findByRole("combobox", { name: "Department" });
|
||||||
|
expect(pick).toHaveTextContent("14 Electrical · Uses the default list");
|
||||||
|
fireEvent.change(pick, { target: { value: "14" } });
|
||||||
|
expect(await screen.findByRole("heading", { name: "14 Electrical" })).toBeInTheDocument();
|
||||||
|
expect(callsTo(fetch, "GET", "/api/checklists/14")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when nothing is editable", async () => {
|
||||||
|
mockFetch(base(SUP, { "GET /api/checklists": { body: ROWS.map((r) => ({ ...r, editable: false })) } }));
|
||||||
|
renderApp("/checklists");
|
||||||
|
expect(await screen.findByText(NONE_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not offered to a lead or Q.C., and the URL says it is not for their role", async () => {
|
||||||
|
const fetch = mockFetch(base(LEAD));
|
||||||
|
renderApp("/checklists");
|
||||||
|
expect(await screen.findByTestId("not-for-role")).toHaveTextContent("The Checklists screen is not for your role.");
|
||||||
|
expect(screen.queryByRole("link", { name: "Checklists" })).not.toBeInTheDocument();
|
||||||
|
expect(callsTo(fetch, "GET", "/api/checklists")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not for Q.C.", async () => {
|
||||||
|
mockFetch(base(QC, { "/api/qc/folders": { body: { projects: [] } } }));
|
||||||
|
renderApp("/checklists");
|
||||||
|
expect(await screen.findByTestId("not-for-role")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("link", { name: "Checklists" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { canChecklists } from "../../lib/toolAccess";
|
||||||
|
import { RequireRole } from "../tools/RequireRole";
|
||||||
|
import { DeptChecklists } from "./DeptChecklists";
|
||||||
|
|
||||||
|
/** /checklists: department supervisors edit their own department's checklist, Admin any (change C8). */
|
||||||
|
export function ChecklistsPage() {
|
||||||
|
return (
|
||||||
|
<RequireRole allow={canChecklists} what="The Checklists screen" who="It is for department supervisors and Admin.">
|
||||||
|
<header className="phead">
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="eyebrow">Department checklists</div>
|
||||||
|
<h1>Checklists</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<DeptChecklists />
|
||||||
|
</RequireRole>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
// The department checklists (change C8). A department supervisor edits their own department's list, Admin any.
|
||||||
|
// Q.C. does not edit them. A stage freezes its list at START, so a saved change applies to stages not started yet.
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { checklistsApi, normaliseChecklist, type ChecklistItem, type DeptChecklist, type DeptChecklistRow } from "../../api/checklists";
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import { errorText } from "../../lib/format";
|
||||||
|
import { useLoad } from "../../lib/useLoad";
|
||||||
|
import { Notice, Problem, type Msg } from "../admin/common";
|
||||||
|
|
||||||
|
const RX_KEY = /^[a-z][a-z0-9_]{0,29}$/;
|
||||||
|
const MAX_ITEMS = 40;
|
||||||
|
const PLACEHOLDERS = new Set(["drawing", "rev", "wps"]);
|
||||||
|
const DEPT_KEY = "ps.ckdept";
|
||||||
|
|
||||||
|
export const SAVED_TEXT = "Checklist saved. Stages not started yet use it. Started stages keep the list they started with.";
|
||||||
|
export const CONFLICT_TEXT = "Someone else saved this checklist first. Their version is loaded now. Make your change again.";
|
||||||
|
export const NONE_TEXT = "No department checklist is yours to edit. A department supervisor edits their own department's list.";
|
||||||
|
|
||||||
|
interface Row extends ChecklistItem {
|
||||||
|
/** A key the saved list already has. It cannot change, because ticks on open stages use it. */
|
||||||
|
saved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lsGet(): string | null {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(DEPT_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lsSet(v: string): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(DEPT_KEY, v);
|
||||||
|
} catch {
|
||||||
|
// Storage may be blocked. The pick still holds for this page.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toRows = (items: readonly ChecklistItem[], savedKeys: ReadonlySet<string>): Row[] => items.map((it) => ({ ...it, saved: savedKeys.has(it.key) }));
|
||||||
|
const keysOf = (items: readonly ChecklistItem[]) => new Set(items.map((i) => i.key));
|
||||||
|
|
||||||
|
/** The first problem with the list, or null. Mirrors the server's rules. */
|
||||||
|
function listProblem(rows: readonly ChecklistItem[]): string | null {
|
||||||
|
if (rows.length === 0) return "The list needs at least one item.";
|
||||||
|
if (rows.length > MAX_ITEMS) return `At most ${MAX_ITEMS} items.`;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const [i, r] of rows.entries()) {
|
||||||
|
const n = i + 1;
|
||||||
|
if (!r.group.trim()) return `Item ${n}: give it a group.`;
|
||||||
|
if (!r.text.trim()) return `Item ${n}: give it a text.`;
|
||||||
|
if (!RX_KEY.test(r.key)) return `Item ${n}: the key is lower case letters, digits and _, starting with a letter.`;
|
||||||
|
if (seen.has(r.key)) return `Item ${n}: the key ${r.key} is used twice.`;
|
||||||
|
seen.add(r.key);
|
||||||
|
for (const m of r.text.matchAll(/\{([^}]*)\}/g)) {
|
||||||
|
if (!PLACEHOLDERS.has(m[1] ?? "")) return `Item ${n}: only {drawing}, {rev} and {wps} can be filled in.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current list a 409 carries, under `current` or at the top. */
|
||||||
|
function currentOf(body: unknown): Partial<DeptChecklist> | null {
|
||||||
|
if (!body || typeof body !== "object") return null;
|
||||||
|
const b = body as { current?: unknown; items?: unknown };
|
||||||
|
if (b.current && typeof b.current === "object" && "items" in b.current) return b.current as Partial<DeptChecklist>;
|
||||||
|
return "items" in b ? (b as Partial<DeptChecklist>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindText(d: Pick<DeptChecklistRow, "custom" | "weld">): string {
|
||||||
|
if (d.custom) return "Own list";
|
||||||
|
return d.weld ? "Uses the default weld list" : "Uses the default list";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListEditor({ rows, onChange }: { rows: Row[]; onChange: (rows: Row[]) => void }) {
|
||||||
|
const set = (i: number, patch: Partial<Row>) => onChange(rows.map((r, j) => (j === i ? { ...r, ...patch } : r)));
|
||||||
|
const move = (i: number, d: number) => {
|
||||||
|
const j = i + d;
|
||||||
|
const a = rows[i];
|
||||||
|
const b = rows[j];
|
||||||
|
if (!a || !b) return;
|
||||||
|
const out = [...rows];
|
||||||
|
out[i] = b;
|
||||||
|
out[j] = a;
|
||||||
|
onChange(out);
|
||||||
|
};
|
||||||
|
const add = () => {
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
onChange([...rows, { group: last?.group ?? "", key: "", text: "", saved: false }]);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rows.map((r, i) => {
|
||||||
|
const n = i + 1;
|
||||||
|
return (
|
||||||
|
<div className="frow" key={`${i}-${r.saved ? r.key : "new"}`}>
|
||||||
|
<label className="f">
|
||||||
|
{`Item ${n} group`}
|
||||||
|
<input className="in" value={r.group} maxLength={200} onChange={(e) => set(i, { group: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
{`Item ${n} key`}
|
||||||
|
<input
|
||||||
|
className="in mono"
|
||||||
|
value={r.key}
|
||||||
|
maxLength={30}
|
||||||
|
readOnly={r.saved}
|
||||||
|
title={r.saved ? "A saved key cannot change. Ticks on open stages use it." : undefined}
|
||||||
|
onChange={(e) => set(i, { key: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="f" style={{ gridColumn: "span 2" }}>
|
||||||
|
{`Item ${n} text`}
|
||||||
|
<input className="in" value={r.text} maxLength={200} onChange={(e) => set(i, { text: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<div className="end nowrap">
|
||||||
|
<button className="btn sm ghost" type="button" aria-label={`Move item ${n} up`} disabled={i === 0} onClick={() => move(i, -1)}>
|
||||||
|
Up
|
||||||
|
</button>{" "}
|
||||||
|
<button className="btn sm ghost" type="button" aria-label={`Move item ${n} down`} disabled={i === rows.length - 1} onClick={() => move(i, 1)}>
|
||||||
|
Down
|
||||||
|
</button>{" "}
|
||||||
|
<button className="btn sm rejb" type="button" aria-label={`Remove item ${n}`} onClick={() => onChange(rows.filter((_, j) => j !== i))}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div>
|
||||||
|
<button className="btn sm" type="button" disabled={rows.length >= MAX_ITEMS} onClick={add}>
|
||||||
|
Add item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A read-only list, for a department the caller may not edit. */
|
||||||
|
function ListView({ items }: { items: readonly ChecklistItem[] }) {
|
||||||
|
return (
|
||||||
|
<ul className="plain stack tight" aria-label="Checklist items">
|
||||||
|
{items.map((it) => (
|
||||||
|
<li key={it.key}>
|
||||||
|
<span className="muted small">{it.group}</span> · {it.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeptEditor({ dept, onSaved }: { dept: string; onSaved: () => void }) {
|
||||||
|
const [load, reload] = useLoad((s) => checklistsApi.get(dept, s), dept);
|
||||||
|
const [base, setBase] = useState<DeptChecklist | null>(null);
|
||||||
|
const [draft, setDraft] = useState<Row[] | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<Msg>(null);
|
||||||
|
|
||||||
|
const take = (d: DeptChecklist) => {
|
||||||
|
setBase(d);
|
||||||
|
setDraft(toRows(d.items, keysOf(d.items)));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (load.state === "ok") {
|
||||||
|
setBase(load.data);
|
||||||
|
setDraft(toRows(load.data.items, keysOf(load.data.items)));
|
||||||
|
}
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!base || !draft || base.dept_code !== dept) return <Problem load={load} />;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const items = draft.map((r) => ({ group: r.group.trim(), key: r.key.trim(), text: r.text.trim() }));
|
||||||
|
const problem = listProblem(items);
|
||||||
|
if (problem) {
|
||||||
|
setMsg({ ok: false, text: problem });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
take(await checklistsApi.save(dept, items, base.version));
|
||||||
|
onSaved();
|
||||||
|
setMsg({ ok: true, text: SAVED_TEXT });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
// The 409 carries the current list ({detail, code, current}, or the list itself). Without it, load it again.
|
||||||
|
const cur = currentOf(err.body);
|
||||||
|
if (cur) take(normaliseChecklist(cur, dept));
|
||||||
|
else reload();
|
||||||
|
setMsg({ ok: false, text: CONFLICT_TEXT });
|
||||||
|
} else {
|
||||||
|
setMsg({ ok: false, text: errorText(err) });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = `${base.dept_code} ${base.dept_name}`.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel stack" aria-labelledby="ckl-dept-h">
|
||||||
|
<div className="sec-h flush">
|
||||||
|
<h3 id="ckl-dept-h">{title}</h3>
|
||||||
|
<span className="ct">
|
||||||
|
{kindText(base)} · {base.items.length} items
|
||||||
|
</span>
|
||||||
|
<span className="grow" />
|
||||||
|
{base.editable ? (
|
||||||
|
<button
|
||||||
|
className="btn sm ghost"
|
||||||
|
type="button"
|
||||||
|
disabled={busy || base.defaults.length === 0}
|
||||||
|
onClick={() => setDraft(toRows(base.defaults, keysOf(base.items)))}
|
||||||
|
>
|
||||||
|
{base.weld ? "Start from the default weld list" : "Start from the default list"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{base.editable ? (
|
||||||
|
<>
|
||||||
|
<ListEditor rows={draft} onChange={setDraft} />
|
||||||
|
<Notice msg={msg} />
|
||||||
|
<div className="chips">
|
||||||
|
<button className="btn pri" type="button" disabled={busy} onClick={() => void save()}>
|
||||||
|
Save checklist
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn ghost"
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
take(base);
|
||||||
|
setMsg(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Discard changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="small muted">You can read this list. Its department supervisor or Admin edits it.</div>
|
||||||
|
<ListView items={base.items} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The department picker and the editor. Lists only the departments the caller may edit. */
|
||||||
|
export function DeptChecklists() {
|
||||||
|
const [load, reload] = useLoad((s) => checklistsApi.list(s));
|
||||||
|
const [picked, setPicked] = useState<string | null>(lsGet);
|
||||||
|
|
||||||
|
if (load.state !== "ok") return <Problem load={load} />;
|
||||||
|
const rows = load.data.filter((d) => d.editable);
|
||||||
|
const dept = rows.find((d) => d.dept_code === picked) ?? rows[0];
|
||||||
|
if (!dept) return <div className="empty">{NONE_TEXT}</div>;
|
||||||
|
|
||||||
|
const choose = (v: string) => {
|
||||||
|
setPicked(v);
|
||||||
|
lsSet(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<div className="small muted">
|
||||||
|
Each department has its own checklist. Until it has one, it uses the default for its kind. Text can use {"{drawing}"}, {"{rev}"} and {"{wps}"}. A
|
||||||
|
stage keeps the list it started with, so a change applies to stages not started yet.
|
||||||
|
</div>
|
||||||
|
{rows.length > 1 ? (
|
||||||
|
<label className="f" style={{ maxWidth: 420 }}>
|
||||||
|
Department
|
||||||
|
<select className="in" value={dept.dept_code} onChange={(e) => choose(e.target.value)}>
|
||||||
|
{rows.map((d) => (
|
||||||
|
<option key={d.dept_code} value={d.dept_code}>
|
||||||
|
{`${d.dept_code} ${d.dept_name} · ${kindText(d)}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<DeptEditor key={dept.dept_code} dept={dept.dept_code} onSaved={reload} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -96,7 +96,7 @@ export function ClericalHome() {
|
|||||||
) : null}
|
) : null}
|
||||||
{k.toClose ? (
|
{k.toClose ? (
|
||||||
<div className="callout flush">
|
<div className="callout flush">
|
||||||
<b>{k.toClose === 1 ? "1 packet is" : `${k.toClose} packets are`} back from the shop.</b> Close the job in VISUAL, then mark it closed on the packet.
|
<b>{k.toClose === 1 ? "1 packet is" : `${k.toClose} packets are`} back from the shop.</b> Close the job in VISUAL. A VISUAL packet moves on when the feed sees it closed. Mark a hand packet closed on the packet.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function clericalBoards(all: PacketCard[]): {
|
|||||||
key: "close",
|
key: "close",
|
||||||
title: "Ready to close",
|
title: "Ready to close",
|
||||||
cards: toClose,
|
cards: toClose,
|
||||||
empty: "Finished packets come back here. Close the job in VISUAL, then mark it closed on the packet.",
|
empty: "Finished packets come back here. Close the job in VISUAL. A VISUAL packet moves on when the feed sees it closed; mark a hand packet closed on the packet.",
|
||||||
},
|
},
|
||||||
{ key: "book", title: "Ready for data book", cards: toBook, empty: "Jobs closed in VISUAL wait here for the data book." },
|
{ key: "book", title: "Ready for data book", cards: toBook, empty: "Jobs closed in VISUAL wait here for the data book." },
|
||||||
{ key: "filed", title: "Filed", cards: filed, empty: "Nothing filed yet." },
|
{ key: "filed", title: "Filed", cards: filed, empty: "Nothing filed yet." },
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const OVERVIEW: PmOverview = {
|
|||||||
{ key: "open", n: 6, label: "Open packets", tab: "aging", alert: false },
|
{ key: "open", n: 6, label: "Open packets", tab: "aging", alert: false },
|
||||||
{ key: "late_units", n: 1, label: "Late units", tab: "projects", alert: true },
|
{ key: "late_units", n: 1, label: "Late units", tab: "projects", alert: true },
|
||||||
{ key: "risk_units", n: 0, label: "Units at risk", tab: "projects", alert: true },
|
{ key: "risk_units", n: 0, label: "Units at risk", tab: "projects", alert: true },
|
||||||
{ key: "over", n: 2, label: "Packets over target hours", tab: "hours", alert: true },
|
{ key: "over", n: 2, label: "Packets over target hours (tickets)", tab: "hours", alert: true },
|
||||||
{ key: "lead", n: "4.2 d", label: "Average lead time, created → filed", tab: "aging", alert: false },
|
{ key: "lead", n: "4.2 d", label: "Average lead time, created → filed", tab: "aging", alert: false },
|
||||||
],
|
],
|
||||||
summary: ["6 open packets in scope (All projects).", "Late: L670001."],
|
summary: ["6 open packets in scope (All projects).", "Late: L670001."],
|
||||||
@@ -70,9 +70,9 @@ const HOURS: PmTables = {
|
|||||||
{
|
{
|
||||||
key: "hours_over",
|
key: "hours_over",
|
||||||
title: "Packets over target",
|
title: "Packets over target",
|
||||||
note: "Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the clock of record.",
|
note: "VISUAL ticket hours against the work-order target hours decide over target. The station timer is shown beside them. A packet with no ticket hours is not over target.",
|
||||||
head: ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
|
head: ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by (tickets)"],
|
||||||
rows: [row(["L670001 · A", "Stall 1", "Working", "9.0", "8.5", "6.0", "+3.0 h"], { pid: 41, project_id: 7, unit_id: 12, flag: true })] as PmTable["rows"],
|
rows: [row(["L670001 · A", "Stall 1", "Working", "5.0", "8.5", "6.0", "+2.5 h"], { pid: 41, project_id: 7, unit_id: 12, flag: true })] as PmTable["rows"],
|
||||||
},
|
},
|
||||||
{ key: "hours_dept", title: "By department", note: null, head: ["Department", "Packets"], rows: [] },
|
{ key: "hours_dept", title: "By department", note: null, head: ["Department", "Packets"], rows: [] },
|
||||||
],
|
],
|
||||||
@@ -151,7 +151,7 @@ describe("PmHome", () => {
|
|||||||
expect(urls(fetch)).toContain("/api/pm/hours?period=90");
|
expect(urls(fetch)).toContain("/api/pm/hours?period=90");
|
||||||
|
|
||||||
const over = screen.getByRole("table", { name: "Packets over target" });
|
const over = screen.getByRole("table", { name: "Packets over target" });
|
||||||
const r = within(over).getByText("+3.0 h").closest("tr");
|
const r = within(over).getByText("+2.5 h").closest("tr");
|
||||||
expect(r).toHaveClass("flag", "rowlink");
|
expect(r).toHaveClass("flag", "rowlink");
|
||||||
expect(screen.getByText("1 row")).toBeInTheDocument();
|
expect(screen.getByText("1 row")).toBeInTheDocument();
|
||||||
expect(within(screen.getByRole("table", { name: "By department" })).getByText("Nothing in scope.")).toBeInTheDocument();
|
expect(within(screen.getByRole("table", { name: "By department" })).getByText("Nothing in scope.")).toBeInTheDocument();
|
||||||
@@ -250,4 +250,41 @@ describe("PmHome", () => {
|
|||||||
renderApp("/pm");
|
renderApp("/pm");
|
||||||
expect(await screen.findByText("Every unit with packets is on track.")).toBeInTheDocument();
|
expect(await screen.findByText("Every unit with packets is on track.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels over target as decided by the ticket hours and keeps the timer column (C1)", async () => {
|
||||||
|
mockFetch(routes());
|
||||||
|
renderApp("/pm");
|
||||||
|
fireEvent.click(await screen.findByTestId("pm-kpi-over"));
|
||||||
|
const over = await screen.findByRole("table", { name: "Packets over target" });
|
||||||
|
const heads = within(over).getAllByRole("columnheader").map((h) => h.textContent);
|
||||||
|
expect(heads).toEqual(["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by (tickets)"]);
|
||||||
|
expect(screen.getByText(/A packet with no ticket hours is not over target\./)).toBeInTheDocument();
|
||||||
|
// The server flags the row on the tickets: 8.5 ticket h over 6.0 target while the timer shows 5.0.
|
||||||
|
expect(within(over).getByText("5.0").closest("tr")).toHaveClass("flag");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PM access (C10)", () => {
|
||||||
|
it("shows the PM nav entry to PS-ProjectMgmt and Admin only", async () => {
|
||||||
|
mockFetch(routes({ "/api/auth/me": { body: makeMe({ roles: ["pm", "qc"], screens: ["qc", "pm"] }) }, "/api/qc/folders": { body: { projects: [] } } }));
|
||||||
|
renderApp("/pm");
|
||||||
|
const nav = await screen.findByRole("navigation", { name: "Screens" });
|
||||||
|
expect(within(nav).getByRole("button", { name: "Project Mgmt" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a role without PM that types the URL sees a plain message and no PM call is made", async () => {
|
||||||
|
const fetch = mockFetch(routes({ "/api/auth/me": { body: makeMe({ roles: ["clerical"], screens: ["clerical"] }) } }));
|
||||||
|
renderApp("/pm");
|
||||||
|
expect(await screen.findByTestId("not-for-role")).toHaveTextContent(
|
||||||
|
"The Project Management screen is not for your role. It is for Project Management and Admin.",
|
||||||
|
);
|
||||||
|
expect(urls(fetch).some((u) => u.startsWith("/api/pm/"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops PM from the switcher when the session lists it without the role", async () => {
|
||||||
|
mockFetch(routes({ "/api/auth/me": { body: makeMe({ roles: ["qc"], screens: ["qc", "pm"] }) }, "/api/qc/folders": { body: { projects: [] } } }));
|
||||||
|
renderApp("/pm");
|
||||||
|
expect(await screen.findByTestId("not-for-role")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Project Mgmt" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { Sheet } from "../../components/Sheet";
|
|||||||
import { useGuard, type RunOptions } from "../../components/useGuard";
|
import { useGuard, type RunOptions } from "../../components/useGuard";
|
||||||
import { useChanged } from "../../lib/changes";
|
import { useChanged } from "../../lib/changes";
|
||||||
import { errorText, fmtTime } from "../../lib/format";
|
import { errorText, fmtTime } from "../../lib/format";
|
||||||
|
import { printPdf } from "../../lib/printPdf";
|
||||||
import { useLoad } from "../../lib/useLoad";
|
import { useLoad } from "../../lib/useLoad";
|
||||||
import { fmtQcDate } from "./folders";
|
import { fmtQcDate } from "./folders";
|
||||||
import {
|
import {
|
||||||
@@ -324,6 +325,7 @@ export function ItpPage() {
|
|||||||
const [sheet, setSheet] = useState<LineSheetState>(null);
|
const [sheet, setSheet] = useState<LineSheetState>(null);
|
||||||
const [showLog, setShowLog] = useState(false);
|
const [showLog, setShowLog] = useState(false);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [printing, setPrinting] = useState(false);
|
||||||
useChanged(reload, { delayMs: 500, filter: (ev) => ev.kind === "itp" && Number(ev.unit_id) === uid });
|
useChanged(reload, { delayMs: 500, filter: (ev) => ev.kind === "itp" && Number(ev.unit_id) === uid });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -428,6 +430,19 @@ export function ItpPage() {
|
|||||||
|
|
||||||
const sheetItem = sheet ? sections.flatMap((s) => s.items).find((i) => i.n === sheet.key) : undefined;
|
const sheetItem = sheet ? sections.flatMap((s) => s.items).find((i) => i.n === sheet.key) : undefined;
|
||||||
|
|
||||||
|
// Change C4: the stamped copy goes straight to the browser's print dialog, on this page.
|
||||||
|
const print = async () => {
|
||||||
|
setMsg(null);
|
||||||
|
setPrinting(true);
|
||||||
|
try {
|
||||||
|
await printPdf(itpPrintUrl(id));
|
||||||
|
} catch {
|
||||||
|
setMsg("The print could not be prepared. Try again, or open the ITP (PDF) and print it from there.");
|
||||||
|
} finally {
|
||||||
|
setPrinting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const reopen = async () => {
|
const reopen = async () => {
|
||||||
const reason = await guard.askReason({ title: "Reopen ITP", sub: r.no, label: "Reason", min: 8, max: 300, submitLabel: "Reopen" });
|
const reason = await guard.askReason({ title: "Reopen ITP", sub: r.no, label: "Reason", min: 8, max: 300, submitLabel: "Reopen" });
|
||||||
if (reason === null) return;
|
if (reason === null) return;
|
||||||
@@ -453,8 +468,8 @@ export function ItpPage() {
|
|||||||
<a className="btn sm ghost" href={itpPdfUrl(id, true)} download>
|
<a className="btn sm ghost" href={itpPdfUrl(id, true)} download>
|
||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
<button className="btn sm" type="button" onClick={() => window.open(itpPrintUrl(id), "_blank", "noopener")}>
|
<button className="btn sm" type="button" disabled={printing} onClick={() => void print()}>
|
||||||
Print
|
{printing ? "Preparing print" : "Print"}
|
||||||
</button>
|
</button>
|
||||||
{r.can.sign_off ? (
|
{r.can.sign_off ? (
|
||||||
<button className="btn pri" type="button" onClick={() => void write((g) => signOff(id, g.sign, g), { sign: "Sign off the ITP", sub: r.no })}>
|
<button className="btn pri" type="button" onClick={() => void write((g) => signOff(id, g.sign, g), { sign: "Sign off the ITP", sub: r.no })}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fireEvent, screen } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { printPdf } from "../../lib/printPdf";
|
||||||
import type { Itp } from "../../api/qc";
|
import type { Itp } from "../../api/qc";
|
||||||
import { DEPARTMENTS, renderApp } from "../../test/auth";
|
import { DEPARTMENTS, renderApp } from "../../test/auth";
|
||||||
import { mockFetch } from "../../test/fetchMock";
|
import { mockFetch } from "../../test/fetchMock";
|
||||||
@@ -14,12 +15,16 @@ function base(itp: Itp) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vi.mock("../../lib/printPdf", () => ({ printPdf: vi.fn(() => Promise.resolve()) }));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
vi.mocked(printPdf).mockReset();
|
||||||
|
vi.mocked(printPdf).mockImplementation(() => Promise.resolve());
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ItpPage PDF and print", () => {
|
describe("ItpPage PDF and print", () => {
|
||||||
it("opens the ITP PDF, offers the download, and opens the printed copy", async () => {
|
it("opens the ITP PDF, offers the download, and prints on the page", async () => {
|
||||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
mockFetch(base(makeItp()));
|
mockFetch(base(makeItp()));
|
||||||
renderApp("/qc/units/12/itp");
|
renderApp("/qc/units/12/itp");
|
||||||
@@ -28,11 +33,22 @@ describe("ItpPage PDF and print", () => {
|
|||||||
const dl = screen.getByRole("link", { name: "Download" });
|
const dl = screen.getByRole("link", { name: "Download" });
|
||||||
expect(dl).toHaveAttribute("href", "/api/itps/5/pdf?download=1");
|
expect(dl).toHaveAttribute("href", "/api/itps/5/pdf?download=1");
|
||||||
expect(dl).toHaveAttribute("download");
|
expect(dl).toHaveAttribute("download");
|
||||||
|
open.mockClear();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Print" }));
|
fireEvent.click(screen.getByRole("button", { name: "Print" }));
|
||||||
expect(open).toHaveBeenLastCalledWith("/api/itps/5/print", "_blank", "noopener");
|
await waitFor(() => expect(printPdf).toHaveBeenCalledWith("/api/itps/5/print"));
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
expect(await screen.findByRole("button", { name: "Print" })).toBeEnabled();
|
||||||
expect(screen.queryByRole("link", { name: "Signed PDF" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("link", { name: "Signed PDF" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says so when the print cannot be prepared", async () => {
|
||||||
|
vi.mocked(printPdf).mockRejectedValueOnce(new Error("no pdf"));
|
||||||
|
mockFetch(base(makeItp()));
|
||||||
|
renderApp("/qc/units/12/itp");
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Print" }));
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent("The print could not be prepared.");
|
||||||
|
});
|
||||||
|
|
||||||
it("links the signed PDF on a signed-off ITP", async () => {
|
it("links the signed PDF on a signed-off ITP", async () => {
|
||||||
mockFetch(
|
mockFetch(
|
||||||
base(
|
base(
|
||||||
|
|||||||
@@ -1,43 +1,32 @@
|
|||||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { ShopCard, StallAnswer, StationOption } from "../../api/shop";
|
import type { StallCard, StallMine } from "../../api/stall";
|
||||||
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||||
import { mockFetch } from "../../test/fetchMock";
|
import { mockFetch } from "../../test/fetchMock";
|
||||||
import { makeCard } from "../../test/packets";
|
import { makeCard } from "../../test/packets";
|
||||||
import { memoryStorage } from "../supervisor/memoryStorage";
|
import { memoryStorage } from "../supervisor/memoryStorage";
|
||||||
import { NO_STATION, STALL_KEY } from "./StallHome";
|
import { NONE_ASSIGNED } from "./StallHome";
|
||||||
|
|
||||||
const ANDY = makeMe({ roles: ["shop:03:worker"], screens: ["stall"], user: { sam_account: "weld.andy", display_name: "Andy Welder" } });
|
const ANDY = makeMe({ roles: ["shop:03:worker"], screens: ["stall"], user: { sam_account: "weld.andy", display_name: "Andy Welder" } });
|
||||||
|
|
||||||
const STATIONS: StationOption[] = [
|
function stallCard(id: number, over: Partial<StallCard> = {}): StallCard {
|
||||||
{ dept_code: "03", dept_name: "Weld Parts", id: 1, name: "Stall 1" },
|
|
||||||
{ dept_code: "03", dept_name: "Weld Parts", id: 2, name: "Stall 2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function shopCard(id: number, over: Partial<ShopCard> = {}): ShopCard {
|
|
||||||
const { open_cos: _ignored, ...base } = makeCard({ id, unit: { id: 100 + id, number: `L66200${id}` }, holder: "Stall 1" });
|
const { open_cos: _ignored, ...base } = makeCard({ id, unit: { id: 100 + id, number: `L66200${id}` }, holder: "Stall 1" });
|
||||||
return { ...base, open_cos: [], ...over };
|
return { ...base, open_cos: [], station_id: null, station_name: null, ...over };
|
||||||
}
|
}
|
||||||
|
|
||||||
function stall(id: number): StallAnswer {
|
const MINE: StallMine = {
|
||||||
const st = STATIONS.find((s) => s.id === id) as StationOption;
|
mine: [
|
||||||
return {
|
stallCard(1, { status: "in_progress", station: null, station_id: 1, station_name: "Stall 1", clock: { elapsed_ms: 3_600_000, running: false }, target_hrs: 4.5 }),
|
||||||
station: { id, name: st.name, dept_code: "03", dept_name: "Weld Parts" },
|
stallCard(3, { status: "assigned", station: null, station_id: 7, station_name: "Bay 2", dept: { code: "14", name: "Electrical" } }),
|
||||||
mine:
|
],
|
||||||
id === 1
|
submitted: [stallCard(2, { status: "review", station: { id: 1, name: "Stall 1" }, station_id: 1, station_name: "Stall 1" })],
|
||||||
? [shopCard(1, { status: "in_progress", station: { id: 1, name: "Stall 1" }, clock: { elapsed_ms: 3_600_000, running: false }, target_hrs: 4.5 })]
|
};
|
||||||
: [],
|
|
||||||
submitted: id === 1 ? [shopCard(2, { status: "review", station: { id: 1, name: "Stall 1" } })] : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function base(stations: StationOption[] = STATIONS) {
|
function base(mine: StallMine = MINE) {
|
||||||
return {
|
return {
|
||||||
"/api/auth/me": { body: ANDY },
|
"/api/auth/me": { body: ANDY },
|
||||||
"/api/departments": { body: DEPARTMENTS },
|
"/api/departments": { body: DEPARTMENTS },
|
||||||
"/api/shop/stations": { body: stations },
|
"/api/stall/mine": { body: mine },
|
||||||
"/api/shop/stall?station_id=1": { body: stall(1) },
|
|
||||||
"/api/shop/stall?station_id=2": { body: stall(2) },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,50 +37,42 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("StallHome", () => {
|
describe("StallHome", () => {
|
||||||
it("shows the hero, the picker and the two boards", async () => {
|
it("lists my packets across stations, each with its station, and has no station picker", async () => {
|
||||||
mockFetch(base());
|
|
||||||
renderApp("/stall");
|
|
||||||
expect(await screen.findByText("STALL 1")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("03 Weld Parts")).toHaveClass("eyebrow");
|
|
||||||
expect(screen.getByText("Packets your supervisor sends to this station appear here.")).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("combobox", { name: "Station" })).toHaveValue("1");
|
|
||||||
|
|
||||||
const mine = await waitFor(() => column("My packets"));
|
|
||||||
const card = within(mine).getByRole("link", { name: /^L662001/ });
|
|
||||||
expect(card).toHaveTextContent("01:00:00 paused · target 4.5 h");
|
|
||||||
expect(card).toHaveTextContent("Working");
|
|
||||||
expect(within(column("Submitted, waiting on supervisor")).getByRole("link", { name: /^L662002/ })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches station and remembers it", async () => {
|
|
||||||
const fetch = mockFetch(base());
|
const fetch = mockFetch(base());
|
||||||
renderApp("/stall");
|
renderApp("/stall");
|
||||||
const pick = await screen.findByRole("combobox", { name: "Station" });
|
const mine = await waitFor(() => column("My packets"));
|
||||||
fireEvent.change(pick, { target: { value: "2" } });
|
const one = within(mine).getByRole("link", { name: /^L662001/ });
|
||||||
expect(await screen.findByText("STALL 2")).toBeInTheDocument();
|
expect(one).toHaveTextContent("01:00:00 paused · target 4.5 h");
|
||||||
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/shop/stall?station_id=2")).toBe(true));
|
expect(one).toHaveTextContent("Stall 1");
|
||||||
expect(window.localStorage.getItem(STALL_KEY)).toBe("2");
|
expect(within(mine).getByRole("link", { name: /^L662003/ })).toHaveTextContent("Bay 2");
|
||||||
expect(await screen.findByText("No work assigned to this stall right now.")).toBeInTheDocument();
|
expect(within(column("Submitted, waiting on supervisor")).getByRole("link", { name: /^L662002/ })).toBeInTheDocument();
|
||||||
expect(screen.getByText("Nothing waiting.")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts on the remembered station", async () => {
|
|
||||||
window.localStorage.setItem(STALL_KEY, "2");
|
|
||||||
mockFetch(base());
|
|
||||||
renderApp("/stall");
|
|
||||||
expect(await screen.findByText("STALL 2")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the picker with one station", async () => {
|
|
||||||
mockFetch(base([STATIONS[0] as StationOption]));
|
|
||||||
renderApp("/stall");
|
|
||||||
expect(await screen.findByText("STALL 1")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("combobox", { name: "Station" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("combobox", { name: "Station" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Weld stall · Andy Welder")).toHaveClass("eyebrow");
|
||||||
|
const urls = fetch.mock.calls.map(([u]) => String(u));
|
||||||
|
expect(urls.some((u) => u.startsWith("/api/stall/mine"))).toBe(true);
|
||||||
|
expect(urls.some((u) => u.includes("station_id") || u.startsWith("/api/shop/stations") || u.startsWith("/api/shop/stall"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says so when the user has no station", async () => {
|
it("says the supervisor assigns packets when none are assigned", async () => {
|
||||||
mockFetch(base([]));
|
mockFetch(base({ mine: [], submitted: [] }));
|
||||||
renderApp("/stall");
|
renderApp("/stall");
|
||||||
expect(await screen.findByText(NO_STATION)).toBeInTheDocument();
|
expect(await screen.findByText(NONE_ASSIGNED)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("heading", { name: /^My packets/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the board when only submitted packets are left", async () => {
|
||||||
|
mockFetch(base({ mine: [], submitted: MINE.submitted }));
|
||||||
|
renderApp("/stall");
|
||||||
|
const mine = await waitFor(() => column("My packets"));
|
||||||
|
expect(mine).toHaveTextContent(`Nothing to work on right now. ${NONE_ASSIGNED}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the find box to the server without a station", async () => {
|
||||||
|
const fetch = mockFetch(base());
|
||||||
|
renderApp("/stall");
|
||||||
|
await waitFor(() => column("My packets"));
|
||||||
|
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "L662003" } });
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/stall/mine?q=L662003")).toBe(true));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import {
|
import { getStallMine, type StallCard, type StallMine } from "../../api/stall";
|
||||||
getStall,
|
|
||||||
getStations,
|
|
||||||
type ShopCard,
|
|
||||||
type StallAnswer,
|
|
||||||
type StationOption,
|
|
||||||
} from "../../api/shop";
|
|
||||||
import { CardSlot } from "../../components/Board";
|
import { CardSlot } from "../../components/Board";
|
||||||
import { Card } from "../../components/Card";
|
import { Card } from "../../components/Card";
|
||||||
import { FindBar } from "../../components/FindBar";
|
import { FindBar } from "../../components/FindBar";
|
||||||
@@ -15,68 +9,30 @@ import { useChanged } from "../../lib/changes";
|
|||||||
import { uniqueCards, useFilters } from "../../lib/filters";
|
import { uniqueCards, useFilters } from "../../lib/filters";
|
||||||
import { sigMap, useFlip } from "../../lib/useFlip";
|
import { sigMap, useFlip } from "../../lib/useFlip";
|
||||||
import { useLoad } from "../../lib/useLoad";
|
import { useLoad } from "../../lib/useLoad";
|
||||||
import {
|
import { asCard, fmtClock, fmtHrs, liveMs, useNow } from "../supervisor/shopView";
|
||||||
asCard,
|
|
||||||
fmtClock,
|
|
||||||
fmtHrs,
|
|
||||||
liveMs,
|
|
||||||
lsGet,
|
|
||||||
lsSet,
|
|
||||||
useNow,
|
|
||||||
} from "../supervisor/shopView";
|
|
||||||
|
|
||||||
/** localStorage key for the picked station id (the old ps.stall). */
|
/** The empty state: nobody picks a station here (change C11). */
|
||||||
export const STALL_KEY = "ps.stall";
|
export const NONE_ASSIGNED = "Your supervisor assigns packets to you.";
|
||||||
|
|
||||||
export const NO_STATION =
|
type LoadedStall = StallMine & { fetchedAt: number };
|
||||||
"You aren't assigned to a station. Ask an admin to add you to a department.";
|
|
||||||
|
|
||||||
type LoadedStall = StallAnswer & { fetchedAt: number };
|
function StallCardView({ p, fetchedAt, now }: { p: StallCard; fetchedAt: number; now: number }) {
|
||||||
|
|
||||||
function StallCard({
|
|
||||||
p,
|
|
||||||
fetchedAt,
|
|
||||||
now,
|
|
||||||
}: {
|
|
||||||
p: ShopCard;
|
|
||||||
fetchedAt: number;
|
|
||||||
now: number;
|
|
||||||
}) {
|
|
||||||
const running = !!p.clock?.running;
|
const running = !!p.clock?.running;
|
||||||
const state = running
|
const state = running ? "running" : p.status === "in_progress" ? "paused" : "";
|
||||||
? "running"
|
|
||||||
: p.status === "in_progress"
|
|
||||||
? "paused"
|
|
||||||
: "";
|
|
||||||
const extra = (
|
const extra = (
|
||||||
<>
|
<>
|
||||||
<div className="small mono stclock">
|
<div className="small mono stclock">
|
||||||
<span className={running ? "sdot run" : "sdot"} aria-hidden="true" />
|
<span className={running ? "sdot run" : "sdot"} aria-hidden="true" />
|
||||||
{fmtClock(liveMs(p.clock, fetchedAt, now))}
|
{fmtClock(liveMs(p.clock, fetchedAt, now))}
|
||||||
{state ? ` ${state}` : ""} · target {fmtHrs(Number(p.target_hrs) || 0)}{" "}
|
{state ? ` ${state}` : ""} · target {fmtHrs(Number(p.target_hrs) || 0)} h
|
||||||
h
|
|
||||||
</div>
|
</div>
|
||||||
{p.return_note && p.status === "assigned" ? (
|
{p.return_note && p.status === "assigned" ? <div className="small due soon">Returned: {p.return_note}</div> : null}
|
||||||
<div className="small due soon">Returned: {p.return_note}</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
return <Card p={asCard(p)} extra={extra} />;
|
return <Card p={asCard(p)} extra={extra} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StallColumn({
|
function StallColumn({ title, cards, empty, fetchedAt, now }: { title: string; cards: StallCard[]; empty: string; fetchedAt: number; now: number }) {
|
||||||
title,
|
|
||||||
cards,
|
|
||||||
empty,
|
|
||||||
fetchedAt,
|
|
||||||
now,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
cards: ShopCard[];
|
|
||||||
empty: string;
|
|
||||||
fetchedAt: number;
|
|
||||||
now: number;
|
|
||||||
}) {
|
|
||||||
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||||
return (
|
return (
|
||||||
<section className="col" aria-labelledby={id}>
|
<section className="col" aria-labelledby={id}>
|
||||||
@@ -87,7 +43,7 @@ function StallColumn({
|
|||||||
{cards.length ? (
|
{cards.length ? (
|
||||||
cards.map((p) => (
|
cards.map((p) => (
|
||||||
<CardSlot key={p.id} id={p.id}>
|
<CardSlot key={p.id} id={p.id}>
|
||||||
<StallCard p={p} fetchedAt={fetchedAt} now={now} />
|
<StallCardView p={p} fetchedAt={fetchedAt} now={now} />
|
||||||
</CardSlot>
|
</CardSlot>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -97,37 +53,15 @@ function StallColumn({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stationLabel(s: StationOption): string {
|
/**
|
||||||
return `${s.dept_code} ${s.dept_name} · ${s.name}`;
|
* /stall: the packets assigned to me, each at the station the supervisor picked when assigning, across stations.
|
||||||
}
|
* Moving to another station is a re-assign by the supervisor.
|
||||||
|
*/
|
||||||
/** /stall (the old homeStall): the station picker, the hero and the two boards. */
|
|
||||||
export function StallHome() {
|
export function StallHome() {
|
||||||
const [stations] = useLoad<StationOption[]>(
|
const { me, departments } = useAuth();
|
||||||
(s) => getStations(s),
|
const flt = useFilters("stall");
|
||||||
"stations",
|
|
||||||
);
|
|
||||||
const list = useMemo(
|
|
||||||
() => (stations.state === "ok" ? stations.data : []),
|
|
||||||
[stations],
|
|
||||||
);
|
|
||||||
const [picked, setPicked] = useState<string | null>(() => lsGet(STALL_KEY));
|
|
||||||
const station = list.find((s) => String(s.id) === picked) ?? list[0] ?? null;
|
|
||||||
const choose = (v: string) => {
|
|
||||||
setPicked(v);
|
|
||||||
lsSet(STALL_KEY, v);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { departments } = useAuth();
|
|
||||||
const flt = useFilters("stall", { stationId: station?.id ?? null });
|
|
||||||
const { f, query, queryKey } = flt;
|
const { f, query, queryKey } = flt;
|
||||||
const [stall, reload] = useLoad<LoadedStall | null>(
|
const [stall, reload] = useLoad<LoadedStall>(async (s) => ({ ...(await getStallMine(query, s)), fetchedAt: Date.now() }), queryKey);
|
||||||
async (s) =>
|
|
||||||
station
|
|
||||||
? { ...(await getStall(station.id, s, query)), fetchedAt: Date.now() }
|
|
||||||
: null,
|
|
||||||
`${station?.id ?? ""}|${queryKey}`,
|
|
||||||
);
|
|
||||||
useChanged(reload, { delayMs: 500 });
|
useChanged(reload, { delayMs: 500 });
|
||||||
|
|
||||||
const data = stall.state === "ok" ? stall.data : null;
|
const data = stall.state === "ok" ? stall.data : null;
|
||||||
@@ -138,49 +72,20 @@ export function StallHome() {
|
|||||||
const sigs = useMemo(() => sigMap(cols.flat()), [cols]);
|
const sigs = useMemo(() => sigMap(cols.flat()), [cols]);
|
||||||
useFlip(boardRef, "[data-card-id]", [cols], sigs);
|
useFlip(boardRef, "[data-card-id]", [cols], sigs);
|
||||||
const rows = useMemo(() => uniqueCards(cols).map(asCard), [cols]);
|
const rows = useMemo(() => uniqueCards(cols).map(asCard), [cols]);
|
||||||
|
const total = data ? data.mine.length + data.submitted.length : 0;
|
||||||
|
const nothing = !!data && total === 0 && flt.active === 0;
|
||||||
|
|
||||||
if (stations.state === "loading")
|
|
||||||
return <div className="empty slim">Loading</div>;
|
|
||||||
if (stations.state === "error")
|
|
||||||
return <div className="callout warn">{stations.message}</div>;
|
|
||||||
if (!station) return <div className="empty stnone">{NO_STATION}</div>;
|
|
||||||
|
|
||||||
const deptLabel = `${station.dept_code} ${station.dept_name}`;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="stallhero">
|
<div className="stallhero">
|
||||||
<div className="big">{station.name.toUpperCase()}</div>
|
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<div className="eyebrow">{deptLabel}</div>
|
<div className="eyebrow">Weld stall{me ? ` · ${me.user.display_name}` : ""}</div>
|
||||||
<div className="small muted">
|
<div className="small muted">The packets assigned to you, each at the station your supervisor picked.</div>
|
||||||
Packets your supervisor sends to this station appear here.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{list.length > 1 ? (
|
|
||||||
<label className="f stpick">
|
|
||||||
<span>Station</span>
|
|
||||||
<select
|
|
||||||
aria-label="Station"
|
|
||||||
className="in"
|
|
||||||
value={String(station.id)}
|
|
||||||
onChange={(e) => choose(e.target.value)}
|
|
||||||
>
|
|
||||||
{list.map((s) => (
|
|
||||||
<option key={s.id} value={String(s.id)}>
|
|
||||||
{stationLabel(s)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stall.state === "loading" ? (
|
{stall.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||||
<div className="empty slim">Loading</div>
|
{stall.state === "error" ? <div className="callout warn">{stall.message}</div> : null}
|
||||||
) : null}
|
|
||||||
{stall.state === "error" ? (
|
|
||||||
<div className="callout warn">{stall.message}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<FindBar
|
<FindBar
|
||||||
f={f}
|
f={f}
|
||||||
@@ -190,29 +95,24 @@ export function StallHome() {
|
|||||||
onPick={flt.setPick}
|
onPick={flt.setPick}
|
||||||
view={flt.view}
|
view={flt.view}
|
||||||
onView={flt.setView}
|
onView={flt.setView}
|
||||||
count={data ? data.mine.length + data.submitted.length : null}
|
count={data ? total : null}
|
||||||
facets={null}
|
facets={null}
|
||||||
depts={departments}
|
depts={departments}
|
||||||
screen="stall"
|
screen="stall"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{data && flt.view === "list" ? <JobList rows={rows} sort={f.sort} onSort={(sort) => flt.set({ sort })} /> : null}
|
{nothing ? <div className="empty stnone">{NONE_ASSIGNED}</div> : null}
|
||||||
{data && flt.view === "board" ? (
|
{data && !nothing && flt.view === "list" ? <JobList rows={rows} sort={f.sort} onSort={(sort) => flt.set({ sort })} /> : null}
|
||||||
|
{data && !nothing && flt.view === "board" ? (
|
||||||
<div className="board" ref={boardRef}>
|
<div className="board" ref={boardRef}>
|
||||||
<StallColumn
|
<StallColumn
|
||||||
title="My packets"
|
title="My packets"
|
||||||
cards={data.mine}
|
cards={data.mine}
|
||||||
empty="No work assigned to this stall right now."
|
empty={flt.active ? "Nothing assigned to you matches the filters." : `Nothing to work on right now. ${NONE_ASSIGNED}`}
|
||||||
fetchedAt={data.fetchedAt}
|
|
||||||
now={now}
|
|
||||||
/>
|
|
||||||
<StallColumn
|
|
||||||
title="Submitted, waiting on supervisor"
|
|
||||||
cards={data.submitted}
|
|
||||||
empty="Nothing waiting."
|
|
||||||
fetchedAt={data.fetchedAt}
|
fetchedAt={data.fetchedAt}
|
||||||
now={now}
|
now={now}
|
||||||
/>
|
/>
|
||||||
|
<StallColumn title="Submitted, waiting on supervisor" cards={data.submitted} empty="Nothing waiting." fetchedAt={data.fetchedAt} now={now} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { fireEvent, screen, within } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||||
|
import { callsTo, mockFetch } from "../../test/fetchMock";
|
||||||
|
|
||||||
|
const VALID = {
|
||||||
|
found: true,
|
||||||
|
valid: true,
|
||||||
|
reason: null,
|
||||||
|
print: { id: 5, at: "2026-10-20T10:00:00Z", by_name: "Clara Clerk", kind: "databook", what: "Data book", pages: 12, packet_id: 42, packet_label: "L662001/1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function base(roles: string[], screens: string[]) {
|
||||||
|
return {
|
||||||
|
"/api/auth/me": { body: makeMe({ roles, screens }) },
|
||||||
|
"/api/departments": { body: DEPARTMENTS },
|
||||||
|
"POST /api/stamp-verify": { body: VALID },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Stamp check screen", () => {
|
||||||
|
it.each([
|
||||||
|
["Clerical", ["clerical"], ["clerical"]],
|
||||||
|
["Clerical supervisor", ["clerical_supervisor"], ["clerical"]],
|
||||||
|
["Q.C.", ["qc"], ["qc"]],
|
||||||
|
["Q.C. supervisor", ["qc_supervisor"], ["qc"]],
|
||||||
|
["Admin", ["admin"], ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"]],
|
||||||
|
])("is open to %s from the top bar and checks on /api/stamp-verify", async (_name, roles, screens) => {
|
||||||
|
const fetch = mockFetch(base(roles, screens));
|
||||||
|
renderApp("/account");
|
||||||
|
const link = await screen.findByRole("link", { name: "Stamp check" });
|
||||||
|
expect(link).toHaveAttribute("href", "/stamp-check");
|
||||||
|
fireEvent.click(link);
|
||||||
|
expect(await screen.findByRole("heading", { name: "Stamp check" })).toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByLabelText("Stamp line or code"), { target: { value: "PS 0123456789abcdef" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Check stamp" }));
|
||||||
|
const result = await screen.findByRole("status", { name: "Stamp result" });
|
||||||
|
expect(within(result).getByText("L662001/1")).toBeInTheDocument();
|
||||||
|
expect(callsTo(fetch, "POST", "/api/stamp-verify")).toEqual([{ text: "PS 0123456789abcdef" }]);
|
||||||
|
expect(callsTo(fetch, "POST", "/api/admin/stamp-verify")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Warehouse", ["warehouse"], ["warehouse"]],
|
||||||
|
["a shop supervisor", ["shop:03:supervisor"], ["supervisor", "stall"]],
|
||||||
|
["Project Management", ["pm"], ["pm"]],
|
||||||
|
])("is not for %s", async (_name, roles, screens) => {
|
||||||
|
mockFetch(base(roles, screens));
|
||||||
|
renderApp("/stamp-check");
|
||||||
|
expect(await screen.findByTestId("not-for-role")).toHaveTextContent("The Stamp check is not for your role. It is for Admin, Q.C. and Clerical.");
|
||||||
|
expect(screen.queryByRole("link", { name: "Stamp check" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Stamp line or code")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { canStampCheck } from "../../lib/toolAccess";
|
||||||
|
import { StampVerifyTab } from "../admin/StampVerifyTab";
|
||||||
|
import { RequireRole } from "../tools/RequireRole";
|
||||||
|
|
||||||
|
/** /stamp-check: Admin, Q.C. and Clerical check a printed or saved copy against the print log (change C9). */
|
||||||
|
export function StampCheckPage() {
|
||||||
|
return (
|
||||||
|
<RequireRole allow={canStampCheck} what="The Stamp check" who="It is for Admin, Q.C. and Clerical.">
|
||||||
|
<header className="phead">
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="eyebrow">Controlled copies</div>
|
||||||
|
<h1>Stamp check</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<StampVerifyTab />
|
||||||
|
</RequireRole>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useAuth } from "../../auth/useAuth";
|
||||||
|
|
||||||
|
/** A page some roles may open. Anyone else who types the URL sees a plain message, not the page. */
|
||||||
|
export function RequireRole({ allow, what, who, children }: { allow: (roles: readonly string[]) => boolean; what: string; who: string; children: ReactNode }) {
|
||||||
|
const { me } = useAuth();
|
||||||
|
if (!me) return null;
|
||||||
|
if (!allow(me.roles)) return <NotForRole what={what} who={who} />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "The Stamp check is not for your role. It is for Admin, Q.C. and Clerical." */
|
||||||
|
export function NotForRole({ what, who }: { what: string; who: string }) {
|
||||||
|
return (
|
||||||
|
<div className="callout warn" role="alert" data-testid="not-for-role">
|
||||||
|
{what} is not for your role. {who}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Admin: departments and stations, people's profiles (one at a time and the CSV import), checklist templates.
|
"""Admin: departments and stations, people's profiles (one at a time and the CSV import), the default checklists by
|
||||||
|
kind (weld and plain; each department's own list is at /api/checklists, change C8).
|
||||||
|
|
||||||
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
|
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -171,9 +171,6 @@ def profile_import(
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- checklists
|
# ---------------------------------------------------------------- checklists
|
||||||
|
|
||||||
RX_BRACES = re.compile(r"\{[^{}]*\}")
|
|
||||||
ALLOWED_PLACEHOLDERS = ("{drawing}", "{rev}", "{wps}")
|
|
||||||
|
|
||||||
|
|
||||||
class ChecklistsPut(BaseModel):
|
class ChecklistsPut(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
@@ -183,7 +180,8 @@ class ChecklistsPut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def check_templates(value: Any) -> tuple[dict[str, list[dict[str, str]]], list[str]]:
|
def check_templates(value: Any) -> tuple[dict[str, list[dict[str, str]]], list[str]]:
|
||||||
"""The checklist templates cleaned, and one error line per problem."""
|
"""The default checklists by kind (weld and plain) cleaned, and one error line per problem. A department with
|
||||||
|
its own list (change C8, /api/checklists) does not read these."""
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
out: dict[str, list[dict[str, str]]] = {}
|
out: dict[str, list[dict[str, str]]] = {}
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
@@ -192,41 +190,10 @@ def check_templates(value: Any) -> tuple[dict[str, list[dict[str, str]]], list[s
|
|||||||
if extra:
|
if extra:
|
||||||
errors.append(f"unknown list: {', '.join(extra)}")
|
errors.append(f"unknown list: {', '.join(extra)}")
|
||||||
for name in ("weld", "plain"):
|
for name in ("weld", "plain"):
|
||||||
items = value.get(name)
|
clean, errs = checklists.check_list(name, value.get(name))
|
||||||
if not isinstance(items, list) or not items:
|
errors += errs
|
||||||
errors.append(f"{name}: at least one item")
|
if clean is not None:
|
||||||
continue
|
out[name] = clean
|
||||||
if len(items) > checklists.ITEMS_MAX:
|
|
||||||
errors.append(f"{name}: at most {checklists.ITEMS_MAX} items")
|
|
||||||
seen: set[str] = set()
|
|
||||||
clean: list[dict[str, str]] = []
|
|
||||||
for i, it in enumerate(items, start=1):
|
|
||||||
where = f"{name} item {i}"
|
|
||||||
if not isinstance(it, dict):
|
|
||||||
errors.append(f"{where}: must be an object with group, key and text")
|
|
||||||
continue
|
|
||||||
group = " ".join(str(it.get("group") or "").split())
|
|
||||||
key = str(it.get("key") or "").strip()
|
|
||||||
text = " ".join(str(it.get("text") or "").split())
|
|
||||||
if not checklists.RX_KEY.match(key):
|
|
||||||
errors.append(f"{where}: the key is a lower-case letter, then letters, digits or _ (at most 30)")
|
|
||||||
elif key in seen:
|
|
||||||
errors.append(f"{where}: key {key} is used twice")
|
|
||||||
seen.add(key)
|
|
||||||
if not group:
|
|
||||||
errors.append(f"{where}: the group is empty")
|
|
||||||
if not text:
|
|
||||||
errors.append(f"{where}: the text is empty")
|
|
||||||
if len(group) > checklists.TEXT_MAX or len(text) > checklists.TEXT_MAX:
|
|
||||||
errors.append(f"{where}: at most {checklists.TEXT_MAX} characters")
|
|
||||||
bad = [m for m in RX_BRACES.findall(text) if m not in ALLOWED_PLACEHOLDERS]
|
|
||||||
stray = RX_BRACES.sub("", text)
|
|
||||||
if bad:
|
|
||||||
errors.append(f"{where}: unknown placeholder {', '.join(bad)} (use {{drawing}}, {{rev}} or {{wps}})")
|
|
||||||
elif "{" in stray or "}" in stray:
|
|
||||||
errors.append(f"{where}: a brace without its pair")
|
|
||||||
clean.append({"group": group, "key": key, "text": text})
|
|
||||||
out[name] = clean
|
|
||||||
return out, errors
|
return out, errors
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Admin: the Security tab (settings, alerts, the integrity result), the access log and its CSV, who is in now, the
|
"""Admin: the Security tab (settings, alerts, the integrity result), the access log and its CSV, who is in now, the
|
||||||
stamp check, and the directory flags.
|
stamp check, and the directory flags.
|
||||||
|
|
||||||
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
|
Every route needs the admin role and a password session, except the stamp check: Admin, Q.C. and Clerical on a password
|
||||||
|
session, at /api/stamp-verify (change C9) and at the old /api/admin paths. Every change is audited (`admin.*`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -26,12 +27,23 @@ from ..auth import secsettings
|
|||||||
from ..domain import security_alerts
|
from ..domain import security_alerts
|
||||||
from ..pdf import stamps, tracecsv
|
from ..pdf import stamps, tracecsv
|
||||||
from .account import LOG_PREFIXES
|
from .account import LOG_PREFIXES
|
||||||
from .deps import Db, Principal, admin_principal, get_db, idle_minutes, settings_of
|
from .deps import (
|
||||||
|
Db,
|
||||||
|
Principal,
|
||||||
|
admin_principal,
|
||||||
|
current_principal,
|
||||||
|
get_db,
|
||||||
|
idle_minutes,
|
||||||
|
require_password_session,
|
||||||
|
settings_of,
|
||||||
|
)
|
||||||
from .errors import ApiError
|
from .errors import ApiError
|
||||||
from .jobs import run_out
|
from .jobs import run_out
|
||||||
from .packets import WRITE
|
from .packets import WRITE
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
# The stamp check outside /admin (change C9): Admin, Q.C. and Clerical. The /api/admin paths stay as aliases.
|
||||||
|
stamp_router = APIRouter(prefix="/api", tags=["stamp"])
|
||||||
|
|
||||||
CSV = "text/csv; charset=utf-8"
|
CSV = "text/csv; charset=utf-8"
|
||||||
PDF_MAX_BYTES = 25 * 1024 * 1024
|
PDF_MAX_BYTES = 25 * 1024 * 1024
|
||||||
@@ -499,6 +511,14 @@ def check_stamp(conn: Any, key: bytes | None, sig: str | None, shown: str | None
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def stamp_principal(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008
|
||||||
|
"""Admin, every Q.C. role and every Clerical role (change C9, Gitea issue 32), on a password session as the Admin
|
||||||
|
path always needed."""
|
||||||
|
if not (p.is_admin or any(r.startswith(("qc", "clerical")) for r in p.roles)):
|
||||||
|
raise ApiError(403, "forbidden", "The stamp check is for Admin, Q.C. and Clerical.")
|
||||||
|
return require_password_session(p)
|
||||||
|
|
||||||
|
|
||||||
def _seal_key(request: Request) -> bytes | None:
|
def _seal_key(request: Request) -> bytes | None:
|
||||||
try:
|
try:
|
||||||
return settings_of(request).seal_key()
|
return settings_of(request).seal_key()
|
||||||
@@ -526,10 +546,11 @@ def _audit_verify(conn: Any, p: Principal, results: list[dict[str, Any]], source
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/stamp-verify", dependencies=WRITE)
|
@router.post("/stamp-verify", dependencies=WRITE)
|
||||||
|
@stamp_router.post("/stamp-verify", dependencies=WRITE)
|
||||||
def stamp_verify(
|
def stamp_verify(
|
||||||
body: StampText,
|
body: StampText,
|
||||||
request: Request,
|
request: Request,
|
||||||
p: Principal = Depends(admin_principal), # noqa: B008
|
p: Principal = Depends(stamp_principal), # noqa: B008
|
||||||
d: Db = Depends(get_db), # noqa: B008
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
text = body.text
|
text = body.text
|
||||||
@@ -569,9 +590,10 @@ def pdf_stamps(data: bytes) -> list[tuple[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/stamp-verify/pdf")
|
@router.post("/stamp-verify/pdf")
|
||||||
|
@stamp_router.post("/stamp-verify/pdf")
|
||||||
async def stamp_verify_pdf(
|
async def stamp_verify_pdf(
|
||||||
request: Request,
|
request: Request,
|
||||||
p: Principal = Depends(admin_principal), # noqa: B008
|
p: Principal = Depends(stamp_principal), # noqa: B008
|
||||||
d: Db = Depends(get_db), # noqa: B008
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
length = request.headers.get("content-length")
|
length = request.headers.get("content-length")
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Per-department checklists (change C8, Gitea issue 23).
|
||||||
|
|
||||||
|
Every signed-in session reads them. A department's supervisor edits that department's list and Admin edits any;
|
||||||
|
a lead, a worker, Q.C. and the office roles do not. A save needs a password session, carries the version it started
|
||||||
|
from (`base`, or If-Match) and writes one audit row (`checklists.dept_updated`). A change reaches only stages that
|
||||||
|
have not started: a stage freezes its list at START. `items: null` sets the department back to the default for its
|
||||||
|
kind (Admin's weld or plain list at /api/admin/checklists). The Q.C. check items are not here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from .. import audit, policy
|
||||||
|
from ..domain import checklists, packets
|
||||||
|
from ..policy.custody import SUPERVISOR
|
||||||
|
from .deps import Db, Principal, current_principal, get_db, require_password_session
|
||||||
|
from .errors import ApiError
|
||||||
|
from .packets import WRITE
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/checklists", tags=["checklists"])
|
||||||
|
|
||||||
|
MSG_NOT_YOURS = "Only this department's supervisor or an Admin can change its checklist."
|
||||||
|
MSG_STALE = "Someone else changed this checklist. Reload and try again."
|
||||||
|
MSG_NO_DEPT = "No such shop department."
|
||||||
|
|
||||||
|
|
||||||
|
class DeptChecklistPut(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
items: list[Any] | None = Field(max_length=checklists.ITEMS_MAX * 2)
|
||||||
|
base: int | None = Field(None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
def can_edit(pp: policy.Principal, dept_code: str) -> bool:
|
||||||
|
"""Admin, or the department's Supervisor (a lead is not enough). Q.C. never edits a department list."""
|
||||||
|
return pp.admin or pp.level(dept_code) >= SUPERVISOR
|
||||||
|
|
||||||
|
|
||||||
|
def _shop_departments(conn: Any) -> list[dict[str, Any]]:
|
||||||
|
return packets.rows(
|
||||||
|
conn,
|
||||||
|
"SELECT code, name, weld FROM departments WHERE kind = 'shop' AND active ORDER BY sort, code",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dept(conn: Any, code: str) -> dict[str, Any]:
|
||||||
|
d = packets.one(conn, "SELECT code, name, weld FROM departments WHERE code = %s AND kind = 'shop'", (code,))
|
||||||
|
if d is None:
|
||||||
|
raise ApiError(404, "not_found", MSG_NO_DEPT)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(conn: Any, pp: policy.Principal, d: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
own, version = checklists.load_dept(conn, d["code"])
|
||||||
|
return {
|
||||||
|
"dept_code": d["code"],
|
||||||
|
"dept_name": d["name"],
|
||||||
|
"weld": bool(d["weld"]),
|
||||||
|
"custom": own is not None,
|
||||||
|
"version": version,
|
||||||
|
"editable": can_edit(pp, d["code"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _one(conn: Any, pp: policy.Principal, d: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
out = _summary(conn, pp, d)
|
||||||
|
out["items"] = checklists.live_list(conn, d["code"], bool(d["weld"]))
|
||||||
|
out["defaults"] = checklists.default_for(conn, bool(d["weld"]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_checklists(
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Every active shop department: whether it has its own list, the version and whether the caller may edit."""
|
||||||
|
conn = d.conn
|
||||||
|
pp = packets.policy_principal_for(conn, p)
|
||||||
|
return [_summary(conn, pp, x) for x in _shop_departments(conn)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{dept}")
|
||||||
|
def get_checklist(
|
||||||
|
dept: str,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""One department's live list (its own, else the default for its kind), unfilled: {drawing}, {rev} and {wps}
|
||||||
|
stay as placeholders. `defaults` is the default for its kind."""
|
||||||
|
conn = d.conn
|
||||||
|
pp = packets.policy_principal_for(conn, p)
|
||||||
|
return _one(conn, pp, _dept(conn, dept[:10]))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{dept}", dependencies=WRITE)
|
||||||
|
def put_checklist(
|
||||||
|
dept: str,
|
||||||
|
body: DeptChecklistPut,
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
conn = d.conn
|
||||||
|
pp = packets.policy_principal_for(conn, p)
|
||||||
|
row = _dept(conn, dept[:10])
|
||||||
|
code = str(row["code"])
|
||||||
|
if not can_edit(pp, code):
|
||||||
|
raise ApiError(403, "forbidden", MSG_NOT_YOURS)
|
||||||
|
require_password_session(p)
|
||||||
|
base = body.base
|
||||||
|
if base is None:
|
||||||
|
tag = (request.headers.get("if-match") or "").strip().strip('"').removeprefix("W/").strip('"')
|
||||||
|
if not tag.isdigit():
|
||||||
|
raise ApiError(428, "if_match_required", "Send the version you started from.")
|
||||||
|
base = int(tag)
|
||||||
|
value: dict[str, Any]
|
||||||
|
if body.items is None:
|
||||||
|
value = {"items": None}
|
||||||
|
else:
|
||||||
|
clean, errors = checklists.check_list("checklist", body.items)
|
||||||
|
if errors or clean is None:
|
||||||
|
raise ApiError(422, "bad_checklist", errors[0], extra={"errors": errors})
|
||||||
|
value = {"items": clean}
|
||||||
|
key = checklists.dept_key(code)
|
||||||
|
with conn.transaction():
|
||||||
|
cur = conn.execute("SELECT version FROM settings WHERE key = %s FOR UPDATE", (key,)).fetchone()
|
||||||
|
current = int(cur[0]) if cur else 0
|
||||||
|
if base != current:
|
||||||
|
raise ApiError(409, "version_conflict", MSG_STALE, extra={"current": jsonable_encoder(_one(conn, pp, row))})
|
||||||
|
before = checklists.load_dept(conn, code)[0]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO settings (key, value, version, updated_at, updated_by) VALUES (%s, %s, 1, now(), %s)"
|
||||||
|
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1,"
|
||||||
|
" updated_at = now(), updated_by = EXCLUDED.updated_by",
|
||||||
|
(key, Jsonb(value), p.user_id),
|
||||||
|
)
|
||||||
|
audit.append(
|
||||||
|
conn,
|
||||||
|
action="checklists.dept_updated",
|
||||||
|
user_id=p.user_id,
|
||||||
|
session_id=p.session_id,
|
||||||
|
role_snapshot=p.role_snapshot,
|
||||||
|
subject_type="settings",
|
||||||
|
subject_id=key,
|
||||||
|
detail={"dept": code, "before": before, "after": value["items"]},
|
||||||
|
ip=p.ip,
|
||||||
|
)
|
||||||
|
return _one(conn, pp, row)
|
||||||
@@ -170,6 +170,11 @@ class ReceiptBody(It):
|
|||||||
note: str | None = Field(default=None, max_length=2000)
|
note: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class ShortApproveBody(It):
|
||||||
|
sign: SignBody | None = None
|
||||||
|
note: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
class WeldBody(It):
|
class WeldBody(It):
|
||||||
page: int | None = Field(default=None, ge=1, le=100_000)
|
page: int | None = Field(default=None, ge=1, le=100_000)
|
||||||
fx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
fx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||||
@@ -880,6 +885,75 @@ def receive_short(
|
|||||||
return _read(d, pp, packet_id)
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _not_approved(conn: Any, receipt_id: int) -> None:
|
||||||
|
if conn.execute("SELECT 1 FROM short_approvals WHERE receipt_id = %s", (receipt_id,)).fetchone():
|
||||||
|
raise ApiError(409, "already_approved", "This receipt is already approved.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{packet_id}/shorts/receipts/{receipt_id}/approve", dependencies=WRITE)
|
||||||
|
def approve_short_receipt(
|
||||||
|
packet_id: int,
|
||||||
|
receipt_id: int,
|
||||||
|
body: ShortApproveBody,
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The department supervisor approves one received short (C7): the part and its heat number match what the
|
||||||
|
warehouse issued. One approval per receipt; a second answers 409 already_approved."""
|
||||||
|
note = clean_opt(body.note, LIMITS["note"])
|
||||||
|
pp = _pp(d, p)
|
||||||
|
conn = d.conn
|
||||||
|
receipt = packets.one(
|
||||||
|
conn,
|
||||||
|
"SELECT r.*, b.item FROM short_receipts r JOIN bom_shorts s ON s.id = r.short_id"
|
||||||
|
" JOIN bom_lines b ON b.id = s.bom_line_id WHERE r.id = %s AND r.packet_id = %s",
|
||||||
|
(receipt_id, packet_id),
|
||||||
|
)
|
||||||
|
if receipt is None:
|
||||||
|
raise ApiError(404, "not_found", "No such short receipt on this packet.")
|
||||||
|
_not_approved(conn, receipt_id) # before the signature: a second approval costs no attempt
|
||||||
|
content = {
|
||||||
|
"action": str(A.SHORT_APPROVE),
|
||||||
|
"packet_id": packet_id,
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
"item": receipt["item"],
|
||||||
|
"qty": qty_text(receipt["qty"]),
|
||||||
|
"heat": receipt["heat"],
|
||||||
|
"note": note,
|
||||||
|
}
|
||||||
|
with packet_write(
|
||||||
|
d,
|
||||||
|
p,
|
||||||
|
packet_id,
|
||||||
|
A.SHORT_APPROVE,
|
||||||
|
request=request,
|
||||||
|
it_reason=body.it_reason,
|
||||||
|
sign=body.sign,
|
||||||
|
sign_content=content,
|
||||||
|
policy_principal=pp,
|
||||||
|
) as w:
|
||||||
|
_not_approved(conn, receipt_id) # again under the packet lock: a racing approval waits, then sees the first
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO short_approvals (receipt_id, short_id, packet_id, dept_code, note, signature_id, by)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
||||||
|
(
|
||||||
|
receipt_id,
|
||||||
|
receipt["short_id"],
|
||||||
|
packet_id,
|
||||||
|
w.state.dept,
|
||||||
|
note,
|
||||||
|
w.signature.id if w.signature else None,
|
||||||
|
w.user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
w.changed("bom.short")
|
||||||
|
w.detail.update({"item": receipt["item"], "receipt_id": receipt_id})
|
||||||
|
parts = [f"{qty_text(receipt['qty'])} pc", receipt["heat"] and f"heat {receipt['heat']}", note]
|
||||||
|
w.log(f"Short approved · item {receipt['item']}", " · ".join(x for x in parts if x))
|
||||||
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- welds
|
# ---------------------------------------------------------------- welds
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ class TransitionBody(BaseModel):
|
|||||||
location: str | None = Field(default=None, max_length=500)
|
location: str | None = Field(default=None, max_length=500)
|
||||||
for_sup_id: uuid.UUID | None = None
|
for_sup_id: uuid.UUID | None = None
|
||||||
ncr: str | None = Field(default=None, max_length=100)
|
ncr: str | None = Field(default=None, max_length=100)
|
||||||
confirm_open_wos: bool | None = None
|
|
||||||
sign: SignBody | None = None
|
sign: SignBody | None = None
|
||||||
it_reason: str | None = Field(default=None, max_length=1000)
|
it_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
@@ -539,9 +538,9 @@ TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
|
|||||||
A.SEND_TO_QC: ("book", "spool", "location", "note"),
|
A.SEND_TO_QC: ("book", "spool", "location", "note"),
|
||||||
A.ROUTE_FROM_REVIEW: ("dept", "note"),
|
A.ROUTE_FROM_REVIEW: ("dept", "note"),
|
||||||
A.QC_REJECT: ("reason", "ncr"),
|
A.QC_REJECT: ("reason", "ncr"),
|
||||||
A.QC_ACCEPT: ("routed_to", "note"),
|
A.QC_ACCEPT: ("routed_to", "note", "ncr"),
|
||||||
A.QC_ACCEPT_AND_ROUTE: ("dept", "note"),
|
A.QC_ACCEPT_AND_ROUTE: ("dept", "note", "ncr"),
|
||||||
A.FILE: ("note", "confirm_open_wos"),
|
A.FILE: ("note",),
|
||||||
A.ADMIN_REOPEN: ("reason",),
|
A.ADMIN_REOPEN: ("reason",),
|
||||||
}
|
}
|
||||||
DEPT_ACTIONS = (A.RELEASE_TO_SHOP, A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE)
|
DEPT_ACTIONS = (A.RELEASE_TO_SHOP, A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE)
|
||||||
@@ -580,6 +579,8 @@ def transition(
|
|||||||
extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor")
|
extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor")
|
||||||
if action is A.QC_REJECT:
|
if action is A.QC_REJECT:
|
||||||
_reject_reason(extra)
|
_reject_reason(extra)
|
||||||
|
elif "ncr" in extra:
|
||||||
|
_accept_ncr(extra)
|
||||||
if "stamp" in extra:
|
if "stamp" in extra:
|
||||||
# The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs.
|
# The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs.
|
||||||
stamp = clean_text(extra["stamp"], 40)
|
stamp = clean_text(extra["stamp"], 40)
|
||||||
@@ -617,20 +618,25 @@ def transition(
|
|||||||
|
|
||||||
|
|
||||||
def _reject_reason(extra: dict[str, Any]) -> None:
|
def _reject_reason(extra: dict[str, Any]) -> None:
|
||||||
"""A Q.C. reject takes a reason, an NCR number, or both. The NCR stands in for the reason text: the reason the
|
"""A Q.C. reject always needs a detailed reason (8 characters or more, C2); an NCR number does not stand in for
|
||||||
policy checks is "NCR-2026-0021" alone, or "NCR-2026-0021 · <reason>". Without an NCR the reason needs its usual
|
it. The reject may carry an NCR number too: the return note then reads "NCR-2026-0021 · <reason>"."""
|
||||||
8 characters."""
|
|
||||||
text = clean_text(extra.pop("reason", None), LIMITS["reason"])
|
text = clean_text(extra.pop("reason", None), LIMITS["reason"])
|
||||||
raw = clean_text(extra.pop("ncr", None), 100)
|
raw = clean_text(extra.pop("ncr", None), 100)
|
||||||
ncr = ncrs.validate_ncr(raw) if raw else None
|
ncr = ncrs.validate_ncr(raw) if raw else None
|
||||||
if ncr:
|
if ncr:
|
||||||
extra["ncr"] = ncr
|
extra["ncr"] = ncr
|
||||||
extra["reason"] = f"{ncr} · {text}" if text else ncr
|
if text:
|
||||||
elif text:
|
|
||||||
extra["reason"] = text
|
extra["reason"] = text
|
||||||
extra["reason_text"] = text
|
extra["reason_text"] = text
|
||||||
|
|
||||||
|
|
||||||
|
def _accept_ncr(extra: dict[str, Any]) -> None:
|
||||||
|
"""The NCR number an accept carries after a reject without one (C2). An empty one counts as not given."""
|
||||||
|
raw = clean_text(extra.pop("ncr", None), 100)
|
||||||
|
if raw:
|
||||||
|
extra["ncr"] = ncrs.validate_ncr(raw)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{packet_id}/reopen", dependencies=WRITE)
|
@router.post("/{packet_id}/reopen", dependencies=WRITE)
|
||||||
def reopen(
|
def reopen(
|
||||||
packet_id: int,
|
packet_id: int,
|
||||||
@@ -958,9 +964,8 @@ def set_vclosed(
|
|||||||
"SELECT DISTINCT wo_base FROM packet_work_orders WHERE packet_id = %s ORDER BY 1", (packet_id,)
|
"SELECT DISTINCT wo_base FROM packet_work_orders WHERE packet_id = %s ORDER BY 1", (packet_id,)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
still_open = packets.packet_open_wos(d.conn, w.packet)
|
# Only a hand packet gets here (C5): the feed closes a VISUAL packet, so there is no feed state to compare.
|
||||||
suffix = "VISUAL still shows open: " + ", ".join(still_open) if still_open else None
|
w.note = ", ".join(wos) or None
|
||||||
w.note = " · ".join(x for x in (", ".join(wos), suffix) if x) or None
|
|
||||||
else:
|
else:
|
||||||
d.conn.execute("UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL WHERE id = %s", (packet_id,))
|
d.conn.execute("UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL WHERE id = %s", (packet_id,))
|
||||||
w.changed("vclosed")
|
w.changed("vclosed")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Project Management: the read-only reports, the CSV exports and the production status PDF.
|
"""Project Management: the read-only reports, the CSV exports and the production status PDF.
|
||||||
|
|
||||||
Any signed-in session reads (every role may open every packet, PM included). Nothing here writes a packet; an export
|
PS-ProjectMgmt and Admin only (change C10, Gitea issue 33); every other role gets 403 on every endpoint here. No other
|
||||||
|
screen calls /api/pm: the find bar and the boards read /api/packets. Nothing here writes a packet; an export
|
||||||
writes one print_log row and one audit row (`report exported`). Every endpoint takes the find bar's scope: the
|
writes one print_log row and one audit row (`report exported`). Every endpoint takes the find bar's scope: the
|
||||||
project and unit pick, dept, status, station_id, welder_id, flags, flag, q, and the history period (30, 90, 365 or
|
project and unit pick, dept, status, station_id, welder_id, flags, flag, q, and the history period (30, 90, 365 or
|
||||||
all days; default 90). Mine is ignored here. The reports read PostgreSQL only.
|
all days; default 90). Mine is ignored here. The reports read PostgreSQL only.
|
||||||
@@ -21,7 +22,15 @@ from .deps import Db, Principal, current_principal, get_db, settings_of
|
|||||||
from .errors import ApiError
|
from .errors import ApiError
|
||||||
from .packets import FIND
|
from .packets import FIND
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/pm", tags=["pm"])
|
|
||||||
|
def pm_only(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008
|
||||||
|
"""PS-ProjectMgmt (role `pm`) or Admin. Others get 403."""
|
||||||
|
if not (p.is_admin or "pm" in p.roles):
|
||||||
|
raise ApiError(403, "forbidden", "Project Management only.")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/pm", tags=["pm"], dependencies=[Depends(pm_only)])
|
||||||
|
|
||||||
PERIOD = Query(pm.DEFAULT_PERIOD, pattern="^(30|90|365|all)$")
|
PERIOD = Query(pm.DEFAULT_PERIOD, pattern="^(30|90|365|all)$")
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""The shop screens: the supervisor board, the stations a person may work at, and the stall board.
|
"""The shop screens: the supervisor board, the stations a person may work at, the stall board of one station, and
|
||||||
|
the Stall screen's own list (/api/stall/mine: the packets assigned to me, across stations).
|
||||||
|
|
||||||
Any signed-in session reads them; the board answers 403 for someone who leads no department. They read
|
Any signed-in session reads them; the board answers 403 for someone who leads no department. They read
|
||||||
PostgreSQL only.
|
PostgreSQL only.
|
||||||
@@ -16,6 +17,7 @@ from .deps import Db, Principal, current_principal, get_db
|
|||||||
from .packets import FIND, site_today
|
from .packets import FIND, site_today
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/shop", tags=["shop"])
|
router = APIRouter(prefix="/api/shop", tags=["shop"])
|
||||||
|
stall_router = APIRouter(prefix="/api/stall", tags=["shop"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/board")
|
@router.get("/board")
|
||||||
@@ -65,3 +67,19 @@ def stall(
|
|||||||
raw = {**find, "station_id": None, "mine_station_id": find.get("mine_station_id") or station_id}
|
raw = {**find, "station_id": None, "mine_station_id": find.get("mine_station_id") or station_id}
|
||||||
filters = search.parse_filters(raw, pp, screen="stall", today=site_today(request))
|
filters = search.parse_filters(raw, pp, screen="stall", today=site_today(request))
|
||||||
return shop.stall(conn, pp, station_id, filters)
|
return shop.stall(conn, pp, station_id, filters)
|
||||||
|
|
||||||
|
|
||||||
|
@stall_router.get("/mine")
|
||||||
|
def stall_mine(
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
find: dict[str, Any] = FIND,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The Stall screen (change C11): the packets assigned to the signed-in person, each at the station the
|
||||||
|
supervisor picked, across stations. No station in the request; the session decides. The find bar values narrow
|
||||||
|
the cards (Mine is these cards already)."""
|
||||||
|
conn = d.conn
|
||||||
|
pp = packets.policy_principal_for(conn, p)
|
||||||
|
filters = search.parse_filters(find, pp, screen="stall", today=site_today(request))
|
||||||
|
return shop.stall_mine(conn, pp, filters)
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"""The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing().
|
"""The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing().
|
||||||
|
|
||||||
Weld departments get the welding list, the others the plain list. The drawing, rev and WPS are filled into the text.
|
Each shop department has its own checklist (change C8, Gitea issue 23): the settings row `checklist:<dept code>` holds
|
||||||
The lists live in the settings rows `checklist_templates` (migration 0006) and `qc_check_items` (migration 0007); the
|
{"items": [{group, key, text}]}, edited by that department's supervisor or by Admin. A department without its own
|
||||||
defaults here are the fallback.
|
list uses the default for its kind: the welding list for a weld department, else the plain list, from the settings row
|
||||||
|
`checklist_templates` (migration 0006, Admin edits it) and, when that row is missing or bad, from the defaults here.
|
||||||
|
A stage freezes its department's list when work starts on it (`packet_stages.checklist_template`, migration 0011), so
|
||||||
|
a change reaches only stages not yet started. The drawing, rev and WPS are filled into the text at read time.
|
||||||
|
The Q.C. check items live in the settings row `qc_check_items` (migration 0007); they are not per department.
|
||||||
The keys are the old keys, so a checklist stored as {key: {done, at, by, stamp}} reads the same.
|
The keys are the old keys, so a checklist stored as {key: {done, at, by, stamp}} reads the same.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -13,6 +17,8 @@ import re
|
|||||||
from collections.abc import Iterable, Mapping
|
from collections.abc import Iterable, Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
NO_REV = "-"
|
NO_REV = "-"
|
||||||
@@ -98,6 +104,146 @@ def load_templates(conn: Any) -> dict[str, list[dict[str, str]]]:
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
RX_BRACES = re.compile(r"\{[^{}]*\}")
|
||||||
|
ALLOWED_PLACEHOLDERS = ("{drawing}", "{rev}", "{wps}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_list(name: str, items: Any) -> tuple[list[dict[str, str]] | None, list[str]]:
|
||||||
|
"""One checklist from an editor, cleaned, and one error line per problem (`name` starts each line). None when
|
||||||
|
it is not a list."""
|
||||||
|
errors: list[str] = []
|
||||||
|
if not isinstance(items, list) or not items:
|
||||||
|
return None, [f"{name}: at least one item"]
|
||||||
|
if len(items) > ITEMS_MAX:
|
||||||
|
errors.append(f"{name}: at most {ITEMS_MAX} items")
|
||||||
|
seen: set[str] = set()
|
||||||
|
clean: list[dict[str, str]] = []
|
||||||
|
for i, it in enumerate(items, start=1):
|
||||||
|
where = f"{name} item {i}"
|
||||||
|
if not isinstance(it, Mapping):
|
||||||
|
errors.append(f"{where}: must be an object with group, key and text")
|
||||||
|
continue
|
||||||
|
group = " ".join(str(it.get("group") or "").split())
|
||||||
|
key = str(it.get("key") or "").strip()
|
||||||
|
text = " ".join(str(it.get("text") or "").split())
|
||||||
|
if not RX_KEY.match(key):
|
||||||
|
errors.append(f"{where}: the key is a lower-case letter, then letters, digits or _ (at most 30)")
|
||||||
|
elif key in seen:
|
||||||
|
errors.append(f"{where}: key {key} is used twice")
|
||||||
|
seen.add(key)
|
||||||
|
if not group:
|
||||||
|
errors.append(f"{where}: the group is empty")
|
||||||
|
if not text:
|
||||||
|
errors.append(f"{where}: the text is empty")
|
||||||
|
if len(group) > TEXT_MAX or len(text) > TEXT_MAX:
|
||||||
|
errors.append(f"{where}: at most {TEXT_MAX} characters")
|
||||||
|
bad = [m for m in RX_BRACES.findall(text) if m not in ALLOWED_PLACEHOLDERS]
|
||||||
|
stray = RX_BRACES.sub("", text)
|
||||||
|
if bad:
|
||||||
|
errors.append(f"{where}: unknown placeholder {', '.join(bad)} (use {{drawing}}, {{rev}} or {{wps}})")
|
||||||
|
elif "{" in stray or "}" in stray:
|
||||||
|
errors.append(f"{where}: a brace without its pair")
|
||||||
|
clean.append(_item(group, key, text))
|
||||||
|
return clean, errors
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- per department (change C8)
|
||||||
|
|
||||||
|
DEPT_PREFIX = "checklist:"
|
||||||
|
|
||||||
|
|
||||||
|
def dept_key(code: str) -> str:
|
||||||
|
"""The settings key of one department's own checklist."""
|
||||||
|
return f"{DEPT_PREFIX}{code}"
|
||||||
|
|
||||||
|
|
||||||
|
def load_dept(conn: Any, code: str) -> tuple[list[dict[str, str]] | None, int]:
|
||||||
|
"""A department's own checklist and the row's version: (None, 0) when it never had one, (None, version) when it
|
||||||
|
was set back to the default (`{"items": null}`) or the row does not parse (with a warning). The default for its
|
||||||
|
kind applies then."""
|
||||||
|
row = conn.execute("SELECT value, version FROM settings WHERE key = %s", (dept_key(code),)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None, 0
|
||||||
|
value = row[0]
|
||||||
|
if isinstance(value, Mapping) and value.get("items") is None:
|
||||||
|
return None, int(row[1]) # set back to the default for its kind
|
||||||
|
items = _valid_list(value.get("items")) if isinstance(value, Mapping) else None
|
||||||
|
if items is None:
|
||||||
|
log.warning("settings %s does not parse; using the default checklist", dept_key(code))
|
||||||
|
return items, int(row[1])
|
||||||
|
|
||||||
|
|
||||||
|
def dept_weld(conn: Any, code: str) -> bool:
|
||||||
|
row = conn.execute("SELECT weld FROM departments WHERE code = %s", (code,)).fetchone()
|
||||||
|
return bool(row and row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def default_for(conn: Any, weld: bool) -> list[dict[str, str]]:
|
||||||
|
"""The default list for a department kind (weld or plain): Admin's `checklist_templates`, else the code's."""
|
||||||
|
return load_templates(conn)["weld" if weld else "plain"]
|
||||||
|
|
||||||
|
|
||||||
|
def live_list(conn: Any, code: str, weld: bool | None = None) -> list[dict[str, str]]:
|
||||||
|
"""The department's live checklist: its own list, else the default for its kind."""
|
||||||
|
own, _ = load_dept(conn, code)
|
||||||
|
if own is not None:
|
||||||
|
return own
|
||||||
|
return default_for(conn, dept_weld(conn, code) if weld is None else weld)
|
||||||
|
|
||||||
|
|
||||||
|
def as_templates(items: list[dict[str, str]]) -> dict[str, list[dict[str, str]]]:
|
||||||
|
"""One list in the {weld, plain} shape checklist_for() takes: both kinds read the same list, so the department's
|
||||||
|
weld flag no longer picks it."""
|
||||||
|
return {"weld": items, "plain": items}
|
||||||
|
|
||||||
|
|
||||||
|
def stage_list(conn: Any, stage: Mapping[str, Any]) -> list[dict[str, str]]:
|
||||||
|
"""The checklist of one stage: the list frozen at START when set, else its department's live list."""
|
||||||
|
frozen = _valid_list(stage.get("checklist_template"))
|
||||||
|
if frozen is not None:
|
||||||
|
return frozen
|
||||||
|
return live_list(conn, str(stage["dept_code"]))
|
||||||
|
|
||||||
|
|
||||||
|
def stage_templates(conn: Any, stage_id: Any, dept_code: str) -> dict[str, list[dict[str, str]]]:
|
||||||
|
"""The checklist of a stage by id (the data book's stage records): frozen when set, else the department's live
|
||||||
|
list. A record with no stage id reads the live list."""
|
||||||
|
frozen = None
|
||||||
|
if stage_id is not None:
|
||||||
|
row = conn.execute("SELECT checklist_template FROM packet_stages WHERE id = %s", (stage_id,)).fetchone()
|
||||||
|
frozen = row[0] if row else None
|
||||||
|
return as_templates(stage_list(conn, {"dept_code": dept_code, "checklist_template": frozen}))
|
||||||
|
|
||||||
|
|
||||||
|
def templates_for_packet(
|
||||||
|
conn: Any, dept_code: str, stages: Iterable[Mapping[str, Any]]
|
||||||
|
) -> dict[str, list[dict[str, str]]]:
|
||||||
|
"""The checklist the packet read, its completeness and the tick check use: the open stage's (frozen or live),
|
||||||
|
else the last stage's when it is in the packet's department, else the department's live list."""
|
||||||
|
stages = list(stages)
|
||||||
|
stage = next((s for s in stages if s.get("closed_at") is None), None)
|
||||||
|
if stage is None and stages and stages[-1].get("dept_code") == dept_code:
|
||||||
|
stage = stages[-1]
|
||||||
|
if stage is not None:
|
||||||
|
return as_templates(stage_list(conn, stage))
|
||||||
|
return as_templates(live_list(conn, dept_code))
|
||||||
|
|
||||||
|
|
||||||
|
def freeze_on_start(conn: Any, packet_id: int) -> None:
|
||||||
|
"""START: freeze the open stage's checklist, once. Later edits to the department's list do not reach it."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, dept_code FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL"
|
||||||
|
" AND checklist_template IS NULL",
|
||||||
|
(packet_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE packet_stages SET checklist_template = %s WHERE id = %s AND checklist_template IS NULL",
|
||||||
|
(Jsonb(live_list(conn, str(row[1]))), row[0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fill(text: str, drawing: str | None, rev: str | None, wps: str | None) -> str:
|
def _fill(text: str, drawing: str | None, rev: str | None, wps: str | None) -> str:
|
||||||
values = {"drawing": drawing or "", "rev": rev or NO_REV, "wps": wps or "per drawing"}
|
values = {"drawing": drawing or "", "rev": rev or NO_REV, "wps": wps or "per drawing"}
|
||||||
return " ".join(RX_PLACEHOLDER.sub(lambda m: values[m.group(1)], text).split())
|
return " ".join(RX_PLACEHOLDER.sub(lambda m: values[m.group(1)], text).split())
|
||||||
|
|||||||
@@ -232,6 +232,12 @@ class Children:
|
|||||||
bom: list[dict[str, Any]] = field(default_factory=list)
|
bom: list[dict[str, Any]] = field(default_factory=list)
|
||||||
shorts: dict[int, dict[str, Any]] = field(default_factory=dict) # bom_line_id -> open short
|
shorts: dict[int, dict[str, Any]] = field(default_factory=dict) # bom_line_id -> open short
|
||||||
receipts: dict[int, list[dict[str, Any]]] = field(default_factory=dict) # short id -> receipts
|
receipts: dict[int, list[dict[str, Any]]] = field(default_factory=dict) # short id -> receipts
|
||||||
|
all_shorts: list[dict[str, Any]] = field(default_factory=list) # every short, open or closed, with its item
|
||||||
|
approvals: dict[int, dict[str, Any]] = field(default_factory=dict) # receipt id -> supervisor approval (C7)
|
||||||
|
receipts_unapproved: int = 0 # receipts with no supervisor approval (C7)
|
||||||
|
ncr_required: bool = False # a Q.C. reject since the last accept has no NCR number (C2)
|
||||||
|
itp_open: bool = False # the unit has an ITP that is not complete (C3)
|
||||||
|
visual_open_wos: list[str] = field(default_factory=list) # what the feed last saw open in VISUAL (C5)
|
||||||
welds: list[dict[str, Any]] = field(default_factory=list)
|
welds: list[dict[str, Any]] = field(default_factory=list)
|
||||||
heat_tags: list[dict[str, Any]] = field(default_factory=list)
|
heat_tags: list[dict[str, Any]] = field(default_factory=list)
|
||||||
heat_table: list[dict[str, Any]] = field(default_factory=list)
|
heat_table: list[dict[str, Any]] = field(default_factory=list)
|
||||||
@@ -271,7 +277,6 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
|
|||||||
for code, name, kind in conn.execute("SELECT code, name, kind::text FROM departments"):
|
for code, name, kind in conn.execute("SELECT code, name, kind::text FROM departments"):
|
||||||
ch.dept_names[code] = name
|
ch.dept_names[code] = name
|
||||||
ch.dept_labels[code] = dept_label(code, name, kind)
|
ch.dept_labels[code] = dept_label(code, name, kind)
|
||||||
ch.templates = checklists.load_templates(conn)
|
|
||||||
ch.qc_items = checklists.load_qc_items(conn)
|
ch.qc_items = checklists.load_qc_items(conn)
|
||||||
ch.pages = rows(conn, "SELECT * FROM packet_pages WHERE packet_id = %s ORDER BY n", (pid,))
|
ch.pages = rows(conn, "SELECT * FROM packet_pages WHERE packet_id = %s ORDER BY n", (pid,))
|
||||||
ch.bom = rows(conn, "SELECT * FROM bom_lines WHERE packet_id = %s ORDER BY item", (pid,))
|
ch.bom = rows(conn, "SELECT * FROM bom_lines WHERE packet_id = %s ORDER BY item", (pid,))
|
||||||
@@ -279,6 +284,8 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
|
|||||||
ch.shorts = {s["bom_line_id"]: s for s in shorts}
|
ch.shorts = {s["bom_line_id"]: s for s in shorts}
|
||||||
ch.welds = rows(conn, "SELECT * FROM welds WHERE packet_id = %s AND removed_at IS NULL ORDER BY weld_no", (pid,))
|
ch.welds = rows(conn, "SELECT * FROM welds WHERE packet_id = %s AND removed_at IS NULL ORDER BY weld_no", (pid,))
|
||||||
ch.stages = rows(conn, "SELECT * FROM packet_stages WHERE packet_id = %s ORDER BY id", (pid,))
|
ch.stages = rows(conn, "SELECT * FROM packet_stages WHERE packet_id = %s ORDER BY id", (pid,))
|
||||||
|
# Change C8: the stage's checklist frozen at START, else the department's live list
|
||||||
|
ch.templates = checklists.templates_for_packet(conn, row["dept_code"], ch.stages)
|
||||||
ch.qc = one(conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (pid,))
|
ch.qc = one(conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (pid,))
|
||||||
ch.open_cos = rows(
|
ch.open_cos = rows(
|
||||||
conn,
|
conn,
|
||||||
@@ -286,16 +293,22 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
|
|||||||
" ORDER BY co_no",
|
" ORDER BY co_no",
|
||||||
(pid,),
|
(pid,),
|
||||||
)
|
)
|
||||||
|
_load_close_facts(conn, row, ch)
|
||||||
if not full:
|
if not full:
|
||||||
return ch
|
return ch
|
||||||
ch.work_orders = rows(conn, "SELECT * FROM packet_work_orders WHERE packet_id = %s ORDER BY sort, id", (pid,))
|
ch.work_orders = rows(conn, "SELECT * FROM packet_work_orders WHERE packet_id = %s ORDER BY sort, id", (pid,))
|
||||||
if shorts:
|
ch.all_shorts = rows(
|
||||||
for r in rows(
|
conn,
|
||||||
conn,
|
"SELECT s.*, b.item FROM bom_shorts s JOIN bom_lines b ON b.id = s.bom_line_id WHERE s.packet_id = %s"
|
||||||
"SELECT * FROM short_receipts WHERE short_id = ANY(%s) ORDER BY id",
|
" ORDER BY s.id",
|
||||||
([s["id"] for s in shorts],),
|
(pid,),
|
||||||
):
|
)
|
||||||
|
if ch.all_shorts:
|
||||||
|
for r in rows(conn, "SELECT * FROM short_receipts WHERE packet_id = %s ORDER BY id", (pid,)):
|
||||||
ch.receipts.setdefault(r["short_id"], []).append(r)
|
ch.receipts.setdefault(r["short_id"], []).append(r)
|
||||||
|
ch.approvals = {
|
||||||
|
int(r["receipt_id"]): r for r in rows(conn, "SELECT * FROM short_approvals WHERE packet_id = %s", (pid,))
|
||||||
|
}
|
||||||
ch.heat_tags = rows(
|
ch.heat_tags = rows(
|
||||||
conn, "SELECT * FROM heat_tags WHERE packet_id = %s AND removed_at IS NULL ORDER BY tag_no", (pid,)
|
conn, "SELECT * FROM heat_tags WHERE packet_id = %s AND removed_at IS NULL ORDER BY tag_no", (pid,)
|
||||||
)
|
)
|
||||||
@@ -331,6 +344,31 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
|
|||||||
return ch
|
return ch
|
||||||
|
|
||||||
|
|
||||||
|
# A Q.C. reject after the packet's last accept that has no NCR number (C2).
|
||||||
|
NCR_REQUIRED_SQL = """
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM qc_history h
|
||||||
|
WHERE h.packet_id = %(p)s AND h.result = 'rejected' AND btrim(coalesce(h.snapshot->>'ncr', '')) = ''
|
||||||
|
AND h.id > coalesce((SELECT max(a.id) FROM qc_history a WHERE a.packet_id = %(p)s AND a.result = 'accepted'), 0)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
RECEIPTS_UNAPPROVED_SQL = (
|
||||||
|
"SELECT count(*) FROM short_receipts r WHERE r.packet_id = %s"
|
||||||
|
" AND NOT EXISTS (SELECT 1 FROM short_approvals a WHERE a.receipt_id = r.id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_close_facts(conn: Any, row: Mapping[str, Any], ch: Children) -> None:
|
||||||
|
"""The policy facts of C2, C3, C5 and C7: unapproved short receipts, the NCR an accept needs, the unit's open ITP
|
||||||
|
and the work orders the feed last saw open."""
|
||||||
|
pid = row["id"]
|
||||||
|
ch.receipts_unapproved = int(conn.execute(RECEIPTS_UNAPPROVED_SQL, (pid,)).fetchone()[0])
|
||||||
|
ch.ncr_required = bool(conn.execute(NCR_REQUIRED_SQL, {"p": pid}).fetchone()[0])
|
||||||
|
itp_sql = "SELECT 1 FROM itps WHERE unit_id = %s AND status <> 'complete'"
|
||||||
|
ch.itp_open = conn.execute(itp_sql, (row["unit_id"],)).fetchone() is not None
|
||||||
|
ch.visual_open_wos = open_wos(visual_state(conn, row))
|
||||||
|
|
||||||
|
|
||||||
def is_sub(line: Mapping[str, Any]) -> bool:
|
def is_sub(line: Mapping[str, Any]) -> bool:
|
||||||
"""A sub-assembly line: made on its own packet, so its heats are recorded there."""
|
"""A sub-assembly line: made on its own packet, so its heats are recorded there."""
|
||||||
return str(line.get("sub") or "").strip().upper() == "Y"
|
return str(line.get("sub") or "").strip().upper() == "Y"
|
||||||
@@ -385,6 +423,10 @@ def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
|
|||||||
filler_set=stage is not None and bool((stage["filler"] or "").strip()),
|
filler_set=stage is not None and bool((stage["filler"] or "").strip()),
|
||||||
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds, ch.qc_items or None)),
|
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds, ch.qc_items or None)),
|
||||||
has_qc_sent=ch.qc is not None,
|
has_qc_sent=ch.qc is not None,
|
||||||
|
shorts_unapproved=len(ch.shorts) + ch.receipts_unapproved,
|
||||||
|
itp_open=ch.itp_open,
|
||||||
|
ncr_required=ch.ncr_required,
|
||||||
|
visual_open_wos=tuple(ch.visual_open_wos),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -596,6 +638,7 @@ def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]:
|
|||||||
ids += [v["by"] for v in vs]
|
ids += [v["by"] for v in vs]
|
||||||
for rs in ch.receipts.values():
|
for rs in ch.receipts.values():
|
||||||
ids += [r["by"] for r in rs]
|
ids += [r["by"] for r in rs]
|
||||||
|
ids += [s["opened_by"] for s in ch.all_shorts] + [a["by"] for a in ch.approvals.values()]
|
||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
|
||||||
@@ -823,6 +866,8 @@ def build(
|
|||||||
out["server_now"] = now
|
out["server_now"] = now
|
||||||
out["target_hrs"] = target_hrs(ch.work_orders)
|
out["target_hrs"] = target_hrs(ch.work_orders)
|
||||||
out["labor"] = labor_of(conn, row)
|
out["labor"] = labor_of(conn, row)
|
||||||
|
out["shorts_unapproved"] = state.shorts_unapproved
|
||||||
|
out["ncr_required"] = state.ncr_required
|
||||||
if include_children:
|
if include_children:
|
||||||
out["work_orders"] = [
|
out["work_orders"] = [
|
||||||
{
|
{
|
||||||
@@ -848,6 +893,12 @@ def build(
|
|||||||
]
|
]
|
||||||
out["pages"] = [_page(pg, ch, people) for pg in ch.pages]
|
out["pages"] = [_page(pg, ch, people) for pg in ch.pages]
|
||||||
out["bom"] = [_bom(b, ch, people) for b in ch.bom]
|
out["bom"] = [_bom(b, ch, people) for b in ch.bom]
|
||||||
|
if not seal:
|
||||||
|
# C7: who approved each receipt. Not in the seal: seals made before C7 do not cover it.
|
||||||
|
for b in out["bom"]:
|
||||||
|
for r in (b["short"] or {}).get("receipts") or []:
|
||||||
|
r["approval"] = _approval(ch.approvals.get(int(r["id"])), people)
|
||||||
|
out["shorts"] = [_short(sh, ch, people) for sh in ch.all_shorts]
|
||||||
st = ch.open_stage
|
st = ch.open_stage
|
||||||
out["welds"] = [_weld(w, people, st) for w in ch.welds]
|
out["welds"] = [_weld(w, people, st) for w in ch.welds]
|
||||||
out["heat_tags"] = [_heat_tag(t, st) for t in ch.heat_tags]
|
out["heat_tags"] = [_heat_tag(t, st) for t in ch.heat_tags]
|
||||||
@@ -1143,6 +1194,32 @@ def _bom(b: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _approval(a: Mapping[str, Any] | None, people: People) -> dict[str, Any] | None:
|
||||||
|
if a is None:
|
||||||
|
return None
|
||||||
|
return {"at": a["at"], "by": people.ref(a["by"]), "note": a["note"], "dept_code": a["dept_code"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _short(s: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
|
||||||
|
"""One short, open or closed, with each receipt: who received it and who approved it (C7)."""
|
||||||
|
keys = ("id", "item", "qty", "qty_open", "reason", "ref", "eta", "note", "opened_at", "closed_at")
|
||||||
|
out = {k: s[k] for k in keys}
|
||||||
|
out["opened_by"] = people.ref(s["opened_by"])
|
||||||
|
out["receipts"] = [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"qty": r["qty"],
|
||||||
|
"heat": r["heat"],
|
||||||
|
"note": r["note"],
|
||||||
|
"at": r["at"],
|
||||||
|
"by": people.ref(r["by"]),
|
||||||
|
"approval": _approval(ch.approvals.get(int(r["id"])), people),
|
||||||
|
}
|
||||||
|
for r in ch.receipts.get(s["id"], [])
|
||||||
|
]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
WELD_KEYS = (
|
WELD_KEYS = (
|
||||||
"weld_no",
|
"weld_no",
|
||||||
"page",
|
"page",
|
||||||
@@ -1254,6 +1331,7 @@ SELECT p.id, p.version, p.unit_id, u.lot, pr.number || u.lot AS unit_number, pr.
|
|||||||
pw.reason AS wh_reason, pw.note AS wh_note, pw.auto AS wh_auto, p.wh_sent_at, p.visual_rel,
|
pw.reason AS wh_reason, pw.note AS wh_note, pw.auto AS wh_auto, p.wh_sent_at, p.visual_rel,
|
||||||
bc.bom_total, bc.bom_pulled, bc.bom_missing_heat,
|
bc.bom_total, bc.bom_pulled, bc.bom_missing_heat,
|
||||||
(SELECT count(*) FROM bom_shorts s WHERE s.packet_id = p.id AND s.closed_at IS NULL) AS open_shorts,
|
(SELECT count(*) FROM bom_shorts s WHERE s.packet_id = p.id AND s.closed_at IS NULL) AS open_shorts,
|
||||||
|
coalesce(ur.n, 0) AS receipts_unapproved, coalesce(itp.status::text <> 'complete', false) AS itp_open,
|
||||||
(SELECT count(*) FROM packet_work_orders wo WHERE wo.packet_id = p.id) AS wo_count,
|
(SELECT count(*) FROM packet_work_orders wo WHERE wo.packet_id = p.id) AS wo_count,
|
||||||
(SELECT coalesce(json_agg(json_build_object('co_no', c.co_no, 'review', c.review,
|
(SELECT coalesce(json_agg(json_build_object('co_no', c.co_no, 'review', c.review,
|
||||||
'pending_depts', c.pending_depts) ORDER BY c.co_no), '[]')
|
'pending_depts', c.pending_depts) ORDER BY c.co_no), '[]')
|
||||||
@@ -1267,6 +1345,10 @@ LEFT JOIN users wu ON wu.id = p.welder_id
|
|||||||
LEFT JOIN users vu ON vu.id = p.vclosed_by
|
LEFT JOIN users vu ON vu.id = p.vclosed_by
|
||||||
LEFT JOIN packet_wh pw ON pw.packet_id = p.id AND pw.closed_at IS NULL
|
LEFT JOIN packet_wh pw ON pw.packet_id = p.id AND pw.closed_at IS NULL
|
||||||
LEFT JOIN users whu ON whu.id = pw.by
|
LEFT JOIN users whu ON whu.id = pw.by
|
||||||
|
LEFT JOIN itps itp ON itp.unit_id = p.unit_id
|
||||||
|
LEFT JOIN (SELECT r.packet_id, count(*) AS n FROM short_receipts r
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM short_approvals a WHERE a.receipt_id = r.id)
|
||||||
|
GROUP BY r.packet_id) ur ON ur.packet_id = p.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT count(*) FILTER (WHERE NOT b.na) AS bom_total,
|
SELECT count(*) FILTER (WHERE NOT b.na) AS bom_total,
|
||||||
count(*) FILTER (WHERE NOT b.na AND (b.pulled OR EXISTS (SELECT 1 FROM bom_shorts s
|
count(*) FILTER (WHERE NOT b.na AND (b.pulled OR EXISTS (SELECT 1 FROM bom_shorts s
|
||||||
@@ -1306,6 +1388,8 @@ def card_state(r: Mapping[str, Any], names: Mapping[str, str]) -> policy.PacketS
|
|||||||
filler_set=True,
|
filler_set=True,
|
||||||
qc_missing=(),
|
qc_missing=(),
|
||||||
has_qc_sent=r["status"] == "qc",
|
has_qc_sent=r["status"] == "qc",
|
||||||
|
shorts_unapproved=int(r["open_shorts"] or 0) + int(r["receipts_unapproved"] or 0),
|
||||||
|
itp_open=bool(r["itp_open"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1407,6 +1491,7 @@ def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[s
|
|||||||
"drawing_state": r["drawing_state"],
|
"drawing_state": r["drawing_state"],
|
||||||
"open_cos": [c["co_no"] for c in r["open_co_rows"] or []],
|
"open_cos": [c["co_no"] for c in r["open_co_rows"] or []],
|
||||||
"open_shorts": int(r["open_shorts"] or 0),
|
"open_shorts": int(r["open_shorts"] or 0),
|
||||||
|
"shorts_unapproved": int(r["open_shorts"] or 0) + int(r["receipts_unapproved"] or 0),
|
||||||
"wh_stage": r["wh_stage"],
|
"wh_stage": r["wh_stage"],
|
||||||
"wh": wh_card(r),
|
"wh": wh_card(r),
|
||||||
"wh_sent_at": r["wh_sent_at"],
|
"wh_sent_at": r["wh_sent_at"],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Inside one transaction: lock the packet row (SELECT ... FOR UPDATE), build the policy's PacketState, check the
|
Inside one transaction: lock the packet row (SELECT ... FOR UPDATE), build the policy's PacketState, check the
|
||||||
caller's expected step (409 stale_step), check the packet version when If-Match is given (409 version_conflict),
|
caller's expected step (409 stale_step), check the packet version when If-Match is given (409 version_conflict),
|
||||||
ask the policy (403 or 400 with the decision's code), take the signature when the rule needs one, hand the caller a
|
ask the policy (403, 400 or 422 with the decision's code), take the signature when the rule needs one, hand the caller a
|
||||||
`Write`, then bump the version, write the packet_log row, the transition row and its side effects, the audit row and
|
`Write`, then bump the version, write the packet_log row, the transition row and its side effects, the audit row and
|
||||||
the NOTIFY. The policy decides; a handler never does.
|
the NOTIFY. The policy decides; a handler never does.
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ from ..auth.signatures import Signature
|
|||||||
from ..policy.engine import MSG_FILED
|
from ..policy.engine import MSG_FILED
|
||||||
from ..settings import SecretFileError
|
from ..settings import SecretFileError
|
||||||
from ..text import LIMITS, clean_opt, reason_ok
|
from ..text import LIMITS, clean_opt, reason_ok
|
||||||
from . import packets
|
from . import checklists, packets
|
||||||
from . import qc as qc_dom
|
from . import qc as qc_dom
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -52,14 +52,6 @@ MSG_FIELD = "Someone else changed this since you opened it."
|
|||||||
MSG_DATABOOK = "The data book could not be built: {reason}. Nothing was filed."
|
MSG_DATABOOK = "The data book could not be built: {reason}. Nothing was filed."
|
||||||
|
|
||||||
|
|
||||||
def open_wos_text(wos: Sequence[str]) -> str:
|
|
||||||
"""The FILE confirm question: "VISUAL still shows 2 work orders open on this packet: L489052/1, L489052/2. File
|
|
||||||
anyway?" """
|
|
||||||
n = len(wos)
|
|
||||||
what = "1 work order" if n == 1 else f"{n} work orders"
|
|
||||||
return f"VISUAL still shows {what} open on this packet: {', '.join(wos)}. File anyway?"
|
|
||||||
|
|
||||||
|
|
||||||
# Codes that answer 400 (the UI asks for input and retries); every other refusal answers 403.
|
# Codes that answer 400 (the UI asks for input and retries); every other refusal answers 403.
|
||||||
ASK_CODES = frozenset({"it_reason_required", "reason_required"})
|
ASK_CODES = frozenset({"it_reason_required", "reason_required"})
|
||||||
|
|
||||||
@@ -202,7 +194,7 @@ def refuse(decision: policy.Decision) -> None:
|
|||||||
"""Raise the API error for a failed policy decision."""
|
"""Raise the API error for a failed policy decision."""
|
||||||
if decision.ok:
|
if decision.ok:
|
||||||
return
|
return
|
||||||
status = 400 if decision.code in ASK_CODES else 403
|
status = 400 if decision.code in ASK_CODES else 422 if decision.code == "ncr_required" else 403
|
||||||
raise ApiError(status, decision.code, decision.why)
|
raise ApiError(status, decision.code, decision.why)
|
||||||
|
|
||||||
|
|
||||||
@@ -348,11 +340,6 @@ def packet_write(
|
|||||||
bypass = bypass or dd.admin_bypass
|
bypass = bypass or dd.admin_bypass
|
||||||
if if_match is not None and int(if_match) != int(row["version"]):
|
if if_match is not None and int(if_match) != int(row["version"]):
|
||||||
raise ApiError(409, "version_conflict", MSG_VERSION, extra={"current": current_of(row)})
|
raise ApiError(409, "version_conflict", MSG_VERSION, extra={"current": current_of(row)})
|
||||||
if action is A.FILE and extra.get("confirm_open_wos") is not True:
|
|
||||||
# Before the signature: a cancelled confirm costs no attempt and writes nothing.
|
|
||||||
wos = packets.packet_open_wos(conn, row)
|
|
||||||
if wos:
|
|
||||||
raise ApiError(409, "open_work_orders", open_wos_text(wos), extra={"work_orders": wos})
|
|
||||||
w = Write(
|
w = Write(
|
||||||
conn=conn,
|
conn=conn,
|
||||||
principal=p,
|
principal=p,
|
||||||
@@ -555,6 +542,13 @@ def _set_result(w: Write, insp: Mapping[str, Any] | None, result: str, note: str
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_ncr(w: Write, insp: Mapping[str, Any] | None) -> None:
|
||||||
|
"""The NCR number the step carries (a reject, or the accept after a reject without one), on the inspection."""
|
||||||
|
ncr = w.extra.get("ncr") or None
|
||||||
|
if insp is not None and (ncr or w.action is A.QC_REJECT):
|
||||||
|
w.conn.execute("UPDATE qc_inspections SET ncr = %s WHERE id = %s", (ncr, insp["id"]))
|
||||||
|
|
||||||
|
|
||||||
def _set_packet(w: Write, assignments: str, params: Sequence[Any] = ()) -> None:
|
def _set_packet(w: Write, assignments: str, params: Sequence[Any] = ()) -> None:
|
||||||
w.conn.execute(f"UPDATE packets SET {assignments} WHERE id = %s", (*params, w.id)) # noqa: S608 (fixed fragments)
|
w.conn.execute(f"UPDATE packets SET {assignments} WHERE id = %s", (*params, w.id)) # noqa: S608 (fixed fragments)
|
||||||
|
|
||||||
@@ -685,6 +679,8 @@ def apply_transition(w: Write) -> None:
|
|||||||
assert to_status is not None
|
assert to_status is not None
|
||||||
ex = w.extra
|
ex = w.extra
|
||||||
reason = _reason(w)
|
reason = _reason(w)
|
||||||
|
if a is A.QC_REJECT and reason and ex.get("ncr"):
|
||||||
|
reason = f"{ex['ncr']} · {reason}" # the supervisor's return note leads with the NCR number
|
||||||
from_dept = w.from_dept
|
from_dept = w.from_dept
|
||||||
to_dept = from_dept
|
to_dept = from_dept
|
||||||
if rule.dept_change != "no" and isinstance(ex.get("dept"), str) and ex.get("dept"):
|
if rule.dept_change != "no" and isinstance(ex.get("dept"), str) and ex.get("dept"):
|
||||||
@@ -751,6 +747,8 @@ def apply_transition(w: Write) -> None:
|
|||||||
"clock_running_since = coalesce(clock_running_since, now()),"
|
"clock_running_since = coalesce(clock_running_since, now()),"
|
||||||
" clock_started_at = coalesce(clock_started_at, now())",
|
" clock_started_at = coalesce(clock_started_at, now())",
|
||||||
)
|
)
|
||||||
|
# change C8: the stage keeps the checklist it started with
|
||||||
|
checklists.freeze_on_start(w.conn, w.id)
|
||||||
elif a is A.SUBMIT:
|
elif a is A.SUBMIT:
|
||||||
_set_packet(w, "return_note = NULL")
|
_set_packet(w, "return_note = NULL")
|
||||||
_stage_update(w, CLOCK_STOP)
|
_stage_update(w, CLOCK_STOP)
|
||||||
@@ -776,6 +774,7 @@ def apply_transition(w: Write) -> None:
|
|||||||
elif a in (A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE):
|
elif a in (A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE):
|
||||||
insp = _open_inspection(w)
|
insp = _open_inspection(w)
|
||||||
if a is A.QC_ACCEPT_AND_ROUTE:
|
if a is A.QC_ACCEPT_AND_ROUTE:
|
||||||
|
_set_ncr(w, insp)
|
||||||
insp = _set_result(w, insp, "accepted", _note(w))
|
insp = _set_result(w, insp, "accepted", _note(w))
|
||||||
_close_stage(w, qc_snapshot=insp)
|
_close_stage(w, qc_snapshot=insp)
|
||||||
_close_inspection(w, insp, from_dept)
|
_close_inspection(w, insp, from_dept)
|
||||||
@@ -784,15 +783,16 @@ def apply_transition(w: Write) -> None:
|
|||||||
elif a is A.QC_REJECT:
|
elif a is A.QC_REJECT:
|
||||||
# The reject closes the inspection into qc_history at once; the next SEND_TO_QC opens a fresh one.
|
# The reject closes the inspection into qc_history at once; the next SEND_TO_QC opens a fresh one.
|
||||||
insp = _open_inspection(w)
|
insp = _open_inspection(w)
|
||||||
if insp is not None:
|
_set_ncr(w, insp)
|
||||||
w.conn.execute("UPDATE qc_inspections SET ncr = %s WHERE id = %s", (ex.get("ncr") or None, insp["id"]))
|
|
||||||
insp = _set_result(w, insp, "rejected", reason)
|
insp = _set_result(w, insp, "rejected", reason)
|
||||||
_close_inspection(w, insp, from_dept)
|
_close_inspection(w, insp, from_dept)
|
||||||
_set_packet(w, "return_note = %s", (reason,))
|
_set_packet(w, "return_note = %s", (reason,))
|
||||||
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
|
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
|
||||||
_open_stage(w, from_dept) # a draft sent straight to Q.C. has no stage yet
|
_open_stage(w, from_dept) # a draft sent straight to Q.C. has no stage yet
|
||||||
elif a is A.QC_ACCEPT:
|
elif a is A.QC_ACCEPT:
|
||||||
insp = _set_result(w, _open_inspection(w), "accepted", _note(w))
|
insp = _open_inspection(w)
|
||||||
|
_set_ncr(w, insp)
|
||||||
|
insp = _set_result(w, insp, "accepted", _note(w))
|
||||||
_close_inspection(w, insp, from_dept)
|
_close_inspection(w, insp, from_dept)
|
||||||
_close_stage(w, qc_snapshot=insp)
|
_close_stage(w, qc_snapshot=insp)
|
||||||
_set_packet(w, "routed_to = %s::route_kind", (str(ex["routed_to"]),))
|
_set_packet(w, "routed_to = %s::route_kind", (str(ex["routed_to"]),))
|
||||||
@@ -838,20 +838,16 @@ def _file(w: Write) -> None:
|
|||||||
_set_packet(w, "databook_at = now()")
|
_set_packet(w, "databook_at = now()")
|
||||||
row = packets.load_row(w.conn, w.id)
|
row = packets.load_row(w.conn, w.id)
|
||||||
assert row is not None
|
assert row is not None
|
||||||
wos = packets.packet_open_wos(w.conn, row) if w.extra.get("confirm_open_wos") is True else []
|
|
||||||
content = packets.seal_content(w.conn, row)
|
content = packets.seal_content(w.conn, row)
|
||||||
digest = seal_hash(key, content)
|
digest = seal_hash(key, content)
|
||||||
kid = seal_key_id(key)
|
kid = seal_key_id(key)
|
||||||
seal_id, sealed_at = w.conn.execute(
|
seal_id, sealed_at = w.conn.execute(
|
||||||
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id, open_wos)"
|
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id)"
|
||||||
" VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, sealed_at",
|
" VALUES (%s, %s, %s, %s, %s) RETURNING id, sealed_at",
|
||||||
(w.id, digest, kid, w.user_id, w.signature.id if w.signature else None, wos or None),
|
(w.id, digest, kid, w.user_id, w.signature.id if w.signature else None),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
pages = _file_databook(w, row, {"hash": digest.hex(), "key_id": kid, "sealed_at": sealed_at}, int(seal_id))
|
pages = _file_databook(w, row, {"hash": digest.hex(), "key_id": kid, "sealed_at": sealed_at}, int(seal_id))
|
||||||
w.log_also("Data book filed", f"{pages} pages")
|
w.log_also("Data book filed", f"{pages} pages")
|
||||||
if wos:
|
|
||||||
w.note = " · ".join(x for x in (_note(w), "Filed with work orders open in VISUAL: " + ", ".join(wos)) if x)
|
|
||||||
w.detail["open_wos"] = wos
|
|
||||||
w.detail["seal_id"] = int(seal_id)
|
w.detail["seal_id"] = int(seal_id)
|
||||||
w.detail["databook_pages"] = pages
|
w.detail["databook_pages"] = pages
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ NOTE_AGING_STEPS = "How long packets have been sitting where they are right now.
|
|||||||
NOTE_AGING_OPEN = "Over 3 days in one step is flagged."
|
NOTE_AGING_OPEN = "Over 3 days in one step is flagged."
|
||||||
NOTE_AGING_HISTORY = "From the step history recorded on every move."
|
NOTE_AGING_HISTORY = "From the step history recorded on every move."
|
||||||
NOTE_HOURS_OVER = (
|
NOTE_HOURS_OVER = (
|
||||||
"Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the clock of record."
|
"VISUAL ticket hours against the work-order target hours decide over target. The station timer is shown beside"
|
||||||
|
" them. A packet with no ticket hours is not over target."
|
||||||
)
|
)
|
||||||
|
|
||||||
PROJECTS_HEAD = [
|
PROJECTS_HEAD = [
|
||||||
@@ -108,7 +109,16 @@ PROJECTS_HEAD = [
|
|||||||
"Health",
|
"Health",
|
||||||
]
|
]
|
||||||
ATTENTION_HEAD = ["Unit", "Description", "Due", "Left", "Packets", "% done", "Health"]
|
ATTENTION_HEAD = ["Unit", "Description", "Due", "Left", "Packets", "% done", "Health"]
|
||||||
HOURS_HEAD = ["Packets", "Timer h", "Ticket h", "Target h", "Variance h", "Timer / target", "Over target"]
|
HOURS_HEAD = [
|
||||||
|
"Packets",
|
||||||
|
"Timer h",
|
||||||
|
"Ticket h",
|
||||||
|
"Target h",
|
||||||
|
"Ticket variance h",
|
||||||
|
"Ticket h / target",
|
||||||
|
"Over target (tickets)",
|
||||||
|
]
|
||||||
|
OVER_HEAD = ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by (tickets)"]
|
||||||
JOBS_HEAD = [
|
JOBS_HEAD = [
|
||||||
"Job",
|
"Job",
|
||||||
"Project",
|
"Project",
|
||||||
@@ -849,6 +859,12 @@ def _counts_in_hours(c: Mapping[str, Any], h: Mapping[str, float]) -> bool:
|
|||||||
return h["target_h"] > 0 and (h["timer_h"] > 0 or h["ticket_h"] > 0 or c["status"] in HOURS_STATUSES)
|
return h["target_h"] > 0 and (h["timer_h"] > 0 or h["ticket_h"] > 0 or c["status"] in HOURS_STATUSES)
|
||||||
|
|
||||||
|
|
||||||
|
def is_over(h: Mapping[str, float]) -> bool:
|
||||||
|
"""Over target: the VISUAL ticket hours are over the work-order target (change C1). The timer never decides, and
|
||||||
|
a packet with no ticket hours is not over."""
|
||||||
|
return h["target_h"] > 0 and h["ticket_h"] > 0 and h["ticket_h"] > h["target_h"]
|
||||||
|
|
||||||
|
|
||||||
def station_worker(c: Mapping[str, Any]) -> str:
|
def station_worker(c: Mapping[str, Any]) -> str:
|
||||||
parts = [(c.get("station") or {}).get("name"), (c.get("welder") or {}).get("display_name")]
|
parts = [(c.get("station") or {}).get("name"), (c.get("welder") or {}).get("display_name")]
|
||||||
return " · ".join(str(x) for x in parts if x) or DASH
|
return " · ".join(str(x) for x in parts if x) or DASH
|
||||||
@@ -856,7 +872,7 @@ def station_worker(c: Mapping[str, Any]) -> str:
|
|||||||
|
|
||||||
def hours_tables(rep: Report) -> list[dict[str, Any]]:
|
def hours_tables(rep: Report) -> list[dict[str, Any]]:
|
||||||
"""The old rptHours, with the station timer and the VISUAL ticket hours side by side against the target. The
|
"""The old rptHours, with the station timer and the VISUAL ticket hours side by side against the target. The
|
||||||
timer decides "over target"."""
|
ticket hours decide "over target" (change C1); the timer is shown as today."""
|
||||||
hrs = rep.hours()
|
hrs = rep.hours()
|
||||||
zero = {"timer_h": 0.0, "ticket_h": 0.0, "target_h": 0.0}
|
zero = {"timer_h": 0.0, "ticket_h": 0.0, "target_h": 0.0}
|
||||||
hp = [(c, hrs.get(int(c["id"]), zero)) for c in rep.cards]
|
hp = [(c, hrs.get(int(c["id"]), zero)) for c in rep.cards]
|
||||||
@@ -871,9 +887,9 @@ def hours_tables(rep: Report) -> list[dict[str, Any]]:
|
|||||||
g["ticket"] += h["ticket_h"]
|
g["ticket"] += h["ticket_h"]
|
||||||
g["target"] += h["target_h"]
|
g["target"] += h["target_h"]
|
||||||
g["n"] += 1
|
g["n"] += 1
|
||||||
if h["timer_h"] > h["target_h"]:
|
if is_over(h):
|
||||||
g["over"] += 1
|
g["over"] += 1
|
||||||
return sorted(m.items(), key=lambda kv: -(kv[1]["timer"] - kv[1]["target"]))
|
return sorted(m.items(), key=lambda kv: -(kv[1]["ticket"] - kv[1]["target"]))
|
||||||
|
|
||||||
def rows_of(groups: list[tuple[str, dict[str, float]]]) -> list[dict[str, Any]]:
|
def rows_of(groups: list[tuple[str, dict[str, float]]]) -> list[dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
@@ -884,11 +900,11 @@ def hours_tables(rep: Report) -> list[dict[str, Any]]:
|
|||||||
fmt_h(v["timer"]),
|
fmt_h(v["timer"]),
|
||||||
fmt_h(v["ticket"]),
|
fmt_h(v["ticket"]),
|
||||||
fmt_h(v["target"]),
|
fmt_h(v["target"]),
|
||||||
signed_h(v["timer"] - v["target"]),
|
signed_h(v["ticket"] - v["target"]),
|
||||||
f"{half_up(v['timer'] / v['target'] * 100)}%" if v["target"] else DASH,
|
f"{half_up(v['ticket'] / v['target'] * 100)}%" if v["target"] else DASH,
|
||||||
int(v["over"]),
|
int(v["over"]),
|
||||||
],
|
],
|
||||||
flag=v["timer"] > v["target"],
|
flag=v["ticket"] > v["target"],
|
||||||
)
|
)
|
||||||
for k, v in groups
|
for k, v in groups
|
||||||
]
|
]
|
||||||
@@ -911,13 +927,13 @@ def hours_tables(rep: Report) -> list[dict[str, Any]]:
|
|||||||
)
|
)
|
||||||
t3 = table("hours_project", "By project", ["Project", *HOURS_HEAD], rows_of(group(project_key)))
|
t3 = table("hours_project", "By project", ["Project", *HOURS_HEAD], rows_of(group(project_key)))
|
||||||
over = sorted(
|
over = sorted(
|
||||||
((c, h) for c, h in hp if h["timer_h"] > h["target_h"]),
|
((c, h) for c, h in hp if is_over(h)),
|
||||||
key=lambda ch: -(ch[1]["timer_h"] - ch[1]["target_h"]),
|
key=lambda ch: -(ch[1]["ticket_h"] - ch[1]["target_h"]),
|
||||||
)
|
)
|
||||||
t4 = table(
|
t4 = table(
|
||||||
"hours_over",
|
"hours_over",
|
||||||
"Packets over target",
|
"Packets over target",
|
||||||
["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
|
OVER_HEAD,
|
||||||
[
|
[
|
||||||
row(
|
row(
|
||||||
[
|
[
|
||||||
@@ -927,7 +943,7 @@ def hours_tables(rep: Report) -> list[dict[str, Any]]:
|
|||||||
fmt_h(h["timer_h"]),
|
fmt_h(h["timer_h"]),
|
||||||
fmt_h(h["ticket_h"]),
|
fmt_h(h["ticket_h"]),
|
||||||
fmt_h(h["target_h"]),
|
fmt_h(h["target_h"]),
|
||||||
"+" + fmt_h(h["timer_h"] - h["target_h"]) + " h",
|
"+" + fmt_h(h["ticket_h"] - h["target_h"]) + " h",
|
||||||
],
|
],
|
||||||
pid=c["id"],
|
pid=c["id"],
|
||||||
flag=True,
|
flag=True,
|
||||||
@@ -944,7 +960,7 @@ def over_target_count(rep: Report) -> int:
|
|||||||
n = 0
|
n = 0
|
||||||
for c in rep.cards:
|
for c in rep.cards:
|
||||||
h = hrs.get(int(c["id"]))
|
h = hrs.get(int(c["id"]))
|
||||||
if h and h["target_h"] > 0 and h["timer_h"] > h["target_h"]:
|
if h and is_over(h):
|
||||||
n += 1
|
n += 1
|
||||||
return n
|
return n
|
||||||
|
|
||||||
@@ -1177,7 +1193,7 @@ def kpis(rep: Report) -> list[dict[str, Any]]:
|
|||||||
{
|
{
|
||||||
"key": "over",
|
"key": "over",
|
||||||
"n": over_target_count(rep),
|
"n": over_target_count(rep),
|
||||||
"label": "Packets over target hours",
|
"label": "Packets over target hours (tickets)",
|
||||||
"tab": "hours",
|
"tab": "hours",
|
||||||
"alert": True,
|
"alert": True,
|
||||||
},
|
},
|
||||||
@@ -1230,7 +1246,7 @@ def summary(rep: Report, k: list[dict[str, Any]] | None = None, pipe: list[dict[
|
|||||||
)
|
)
|
||||||
over = int(n["over"])
|
over = int(n["over"])
|
||||||
if over:
|
if over:
|
||||||
out.append(f"{over} packet{' is' if over == 1 else 's are'} over target hours.")
|
out.append(f"{over} packet{' is' if over == 1 else 's are'} over target hours on the VISUAL tickets.")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -86,10 +86,12 @@ def _notify_reject(w: Any, why: str) -> None:
|
|||||||
def qc_texts(w: Any, a: policy.Action, to_dept: str) -> None:
|
def qc_texts(w: Any, a: policy.Action, to_dept: str) -> None:
|
||||||
"""The packet_log label and note of a Q.C. accept or reject. Called before the side effects close the inspection.
|
"""The packet_log label and note of a Q.C. accept or reject. Called before the side effects close the inspection.
|
||||||
|
|
||||||
`w` is the pipeline Write; its extra holds `reason_text` and `ncr` for a reject.
|
`w` is the pipeline Write; its extra holds `reason_text` and `ncr` for a reject, and `ncr` for an accept that
|
||||||
|
follows a reject without one.
|
||||||
"""
|
"""
|
||||||
insp = packets.one(w.conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
insp = packets.one(w.conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
||||||
note = str(w.extra.get("note") or "").strip() or None
|
note = str(w.extra.get("note") or "").strip() or None
|
||||||
|
ncr = str(w.extra.get("ncr") or "").strip() or None
|
||||||
if a is A.QC_REJECT:
|
if a is A.QC_REJECT:
|
||||||
exam = (insp or {}).get("weld_exam") or {}
|
exam = (insp or {}).get("weld_exam") or {}
|
||||||
parts = [str(w.extra.get("ncr") or ""), str(w.extra.get("reason_text") or "")]
|
parts = [str(w.extra.get("ncr") or ""), str(w.extra.get("reason_text") or "")]
|
||||||
@@ -101,7 +103,8 @@ def qc_texts(w: Any, a: policy.Action, to_dept: str) -> None:
|
|||||||
return
|
return
|
||||||
if a is A.QC_ACCEPT_AND_ROUTE:
|
if a is A.QC_ACCEPT_AND_ROUTE:
|
||||||
labels = getattr(w.children, "dept_labels", None) or packets.dept_labels(w.conn)
|
labels = getattr(w.children, "dept_labels", None) or packets.dept_labels(w.conn)
|
||||||
text = " · ".join(x for x in (inspector_text(insp), f"from {labels.get(w.from_dept, w.from_dept)}", note) if x)
|
bits = (inspector_text(insp), f"from {labels.get(w.from_dept, w.from_dept)}", ncr, note)
|
||||||
|
text = " · ".join(x for x in bits if x)
|
||||||
w.log(f"Q.C. accepted → {labels.get(to_dept, to_dept)}", text)
|
w.log(f"Q.C. accepted → {labels.get(to_dept, to_dept)}", text)
|
||||||
return
|
return
|
||||||
if a is A.QC_ACCEPT:
|
if a is A.QC_ACCEPT:
|
||||||
@@ -110,6 +113,8 @@ def qc_texts(w: Any, a: policy.Action, to_dept: str) -> None:
|
|||||||
method = str((insp or {}).get("nde_method") or "").strip()
|
method = str((insp or {}).get("nde_method") or "").strip()
|
||||||
if method and method != NDE_NONE:
|
if method and method != NDE_NONE:
|
||||||
parts.append(f"{method} {(insp or {}).get('nde_result') or ''}".strip())
|
parts.append(f"{method} {(insp or {}).get('nde_result') or ''}".strip())
|
||||||
|
if ncr:
|
||||||
|
parts.append(ncr)
|
||||||
if note:
|
if note:
|
||||||
parts.append(note)
|
parts.append(note)
|
||||||
route = ROUTE_TEXTS.get(str(w.extra.get("routed_to")), str(w.extra.get("routed_to")))
|
route = ROUTE_TEXTS.get(str(w.extra.get("routed_to")), str(w.extra.get("routed_to")))
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ def mine_scope(pp: policy.Principal, screen: str | None, station_id: int | None)
|
|||||||
return MineScope(screen=s, principal=pp, station_id=station_id)
|
return MineScope(screen=s, principal=pp, station_id=station_id)
|
||||||
|
|
||||||
|
|
||||||
|
# The statuses a packet shows on the Stall screen: assigned, in progress and submitted for review.
|
||||||
|
STALL_STATUSES = ("assigned", "in_progress", "review")
|
||||||
|
|
||||||
|
|
||||||
def mine_term(m: MineScope) -> tuple[str | None, list[Any]]:
|
def mine_term(m: MineScope) -> tuple[str | None, list[Any]]:
|
||||||
"""The Mine rule (the old mineFilter) for the screen, or None when it is everything (PM, admin)."""
|
"""The Mine rule (the old mineFilter) for the screen, or None when it is everything (PM, admin)."""
|
||||||
pp = m.principal
|
pp = m.principal
|
||||||
@@ -269,12 +273,11 @@ def mine_term(m: MineScope) -> tuple[str | None, list[Any]]:
|
|||||||
return (None, []) if pp.admin else ("false", [])
|
return (None, []) if pp.admin else ("false", [])
|
||||||
return "p.dept_code = ANY(%s)", [depts]
|
return "p.dept_code = ANY(%s)", [depts]
|
||||||
if m.screen == "stall":
|
if m.screen == "stall":
|
||||||
depts = sorted(pp.shop)
|
# Change C11 (Gitea issue 36): the packets assigned to me, at whatever station the supervisor picked. No
|
||||||
if m.station_id is None:
|
# picked station is needed; `station_id` is ignored here.
|
||||||
|
if not pp.user_id:
|
||||||
return "false", []
|
return "false", []
|
||||||
if not depts and pp.admin:
|
return "(p.welder_id = %s::uuid AND p.status::text = ANY(%s))", [str(pp.user_id), list(STALL_STATUSES)]
|
||||||
return "p.station_id = %s", [int(m.station_id)]
|
|
||||||
return "(p.dept_code = ANY(%s) AND p.station_id = %s)", [depts, int(m.station_id)]
|
|
||||||
return None, []
|
return None, []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -398,6 +398,37 @@ def stall(conn: Any, pp: policy.Principal, station_id: int, filters: Mapping[str
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
MSG_NONE_ASSIGNED = "Nothing is assigned to you now. Your supervisor assigns packets to you."
|
||||||
|
|
||||||
|
|
||||||
|
def stall_mine(conn: Any, pp: policy.Principal, filters: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
"""GET /api/stall/mine: the packets assigned to the signed-in person (welder_id = me), each at the station the
|
||||||
|
supervisor picked when assigning, across stations (change C11, Gitea issue 36). Nobody picks a station; moving a
|
||||||
|
welder is a re-assign by the supervisor. The stall board's shape, with `station` None and `station_id` and
|
||||||
|
`station_name` on every card. `filters` (the find bar's) narrow the cards."""
|
||||||
|
ids: list[int] = []
|
||||||
|
if pp.user_id:
|
||||||
|
found = packets.rows(
|
||||||
|
conn,
|
||||||
|
"SELECT id FROM packets WHERE welder_id = %s::uuid AND removed_at IS NULL"
|
||||||
|
" AND status::text = ANY(%s) ORDER BY updated_at, id LIMIT %s",
|
||||||
|
(str(pp.user_id), list(search.STALL_STATUSES), BOARD_LIMIT),
|
||||||
|
)
|
||||||
|
ids = [int(r["id"]) for r in found]
|
||||||
|
cards = _cards(conn, pp, ids, **search.board_filters(filters or {}))
|
||||||
|
for c in cards:
|
||||||
|
st = c.get("station") or {}
|
||||||
|
c["station_id"] = st.get("id")
|
||||||
|
c["station_name"] = st.get("name")
|
||||||
|
return {
|
||||||
|
"station": None,
|
||||||
|
"mine": [c for c in cards if c["status"] in policy.ACTIVE],
|
||||||
|
"submitted": [c for c in cards if c["status"] == "review"],
|
||||||
|
"facets": search.facets(conn, ids=ids),
|
||||||
|
"empty": None if ids else MSG_NONE_ASSIGNED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- the assign sheet's people
|
# ---------------------------------------------------------------- the assign sheet's people
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from psycopg.rows import dict_row
|
|||||||
from psycopg.types.json import Jsonb
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
from .. import audit, events, policy
|
from .. import audit, events, policy
|
||||||
|
from ..domain import packets as packets_mod
|
||||||
from ..domain import projects as projects_domain
|
from ..domain import projects as projects_domain
|
||||||
from ..text import LIMITS, RX_PROJECT, clean_opt
|
from ..text import LIMITS, RX_PROJECT, clean_opt
|
||||||
from . import changes, materials
|
from . import changes, materials
|
||||||
@@ -1038,7 +1039,7 @@ def _observe(run: _Run, seen: Mapping[WoKey, str], labor: LaborMap | None) -> No
|
|||||||
with conn.cursor(row_factory=dict_row) as cur:
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
legs = cur.execute(
|
legs = cur.execute(
|
||||||
"SELECT w.id, w.packet_id, w.wo_base, w.lot, w.split, w.sub, w.seq, w.visual_status," # noqa: S608
|
"SELECT w.id, w.packet_id, w.wo_base, w.lot, w.split, w.sub, w.seq, w.visual_status," # noqa: S608
|
||||||
" p.status::text AS status, p.flags, p.visual_closed_at FROM packet_work_orders w"
|
" p.status::text AS status, p.flags, p.visual_closed_at, p.vclosed_at FROM packet_work_orders w"
|
||||||
f" JOIN packets p ON p.id = w.packet_id WHERE {_LIVE} AND w.packet_id IN (SELECT w2.packet_id"
|
f" JOIN packets p ON p.id = w.packet_id WHERE {_LIVE} AND w.packet_id IN (SELECT w2.packet_id"
|
||||||
f" FROM packet_work_orders w2, {_UNNEST.replace('k(', 'k2(')} WHERE w2.wo_base = k2.b AND w2.lot = k2.l"
|
f" FROM packet_work_orders w2, {_UNNEST.replace('k(', 'k2(')} WHERE w2.wo_base = k2.b AND w2.lot = k2.l"
|
||||||
" AND w2.split = k2.sp AND w2.sub = k2.su) ORDER BY w.packet_id, w.sort, w.id",
|
" AND w2.split = k2.sp AND w2.sub = k2.su) ORDER BY w.packet_id, w.sort, w.id",
|
||||||
@@ -1069,11 +1070,20 @@ def _observe(run: _Run, seen: Mapping[WoKey, str], labor: LaborMap | None) -> No
|
|||||||
head = pl[0]
|
head = pl[0]
|
||||||
status = head["status"]
|
status = head["status"]
|
||||||
flags = list(head["flags"] or [])
|
flags = list(head["flags"] or [])
|
||||||
if sts and all(s == "C" for s in sts) and head["visual_closed_at"] is None:
|
# C5: the feed is the one writer of vclosed on a VISUAL packet. Every leg closed (or cancelled) sets it, and
|
||||||
|
# the Clerical board moves the packet to "Ready for data book"; a leg seen open again clears it.
|
||||||
|
done = bool(sts) and all(s in packets_mod.LEG_DONE for s in sts)
|
||||||
|
all_closed = bool(sts) and all(s == "C" for s in sts)
|
||||||
|
if (done and head["vclosed_at"] is None) or (all_closed and head["visual_closed_at"] is None):
|
||||||
|
|
||||||
def closed(sw: SysWrite) -> bool:
|
def closed(sw: SysWrite, all_closed: bool = all_closed) -> bool:
|
||||||
sw.conn.execute("UPDATE packets SET visual_closed_at = now() WHERE id = %s", (sw.id,))
|
sw.conn.execute(
|
||||||
sw.changed("visual_closed_at")
|
"UPDATE packets SET vclosed_at = coalesce(vclosed_at, now()), vclosed_by = CASE WHEN vclosed_at"
|
||||||
|
" IS NULL THEN NULL ELSE vclosed_by END, visual_closed_at = CASE WHEN %s THEN"
|
||||||
|
" coalesce(visual_closed_at, now()) ELSE visual_closed_at END WHERE id = %s",
|
||||||
|
(all_closed, sw.id),
|
||||||
|
)
|
||||||
|
sw.changed("vclosed", "visual_closed_at")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if run.guarded(
|
if run.guarded(
|
||||||
@@ -1087,6 +1097,26 @@ def _observe(run: _Run, seen: Mapping[WoKey, str], labor: LaborMap | None) -> No
|
|||||||
closed,
|
closed,
|
||||||
):
|
):
|
||||||
run.counts["closed_observed"] += 1
|
run.counts["closed_observed"] += 1
|
||||||
|
elif not done and any(s and s not in packets_mod.LEG_DONE for s in sts) and head["vclosed_at"] is not None:
|
||||||
|
|
||||||
|
def reopened(sw: SysWrite) -> bool:
|
||||||
|
sw.conn.execute(
|
||||||
|
"UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL, visual_closed_at = NULL WHERE id = %s",
|
||||||
|
(sw.id,),
|
||||||
|
)
|
||||||
|
sw.changed("vclosed", "visual_closed_at")
|
||||||
|
return True
|
||||||
|
|
||||||
|
run.guarded(
|
||||||
|
f"reopened {pid}",
|
||||||
|
_flag_write,
|
||||||
|
run,
|
||||||
|
pid,
|
||||||
|
status,
|
||||||
|
"Open again in VISUAL (observed)",
|
||||||
|
", ".join(packets_mod.open_wos({"legs": state_legs})) or None,
|
||||||
|
reopened,
|
||||||
|
)
|
||||||
cancelled = any(s == "X" for s in sts)
|
cancelled = any(s == "X" for s in sts)
|
||||||
if cancelled and "cancelled" not in flags:
|
if cancelled and "cancelled" not in flags:
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ def create_app(
|
|||||||
admin_security,
|
admin_security,
|
||||||
assets,
|
assets,
|
||||||
auth,
|
auth,
|
||||||
|
checklists,
|
||||||
departments,
|
departments,
|
||||||
feed_settings,
|
feed_settings,
|
||||||
itp,
|
itp,
|
||||||
@@ -139,9 +140,12 @@ def create_app(
|
|||||||
qc,
|
qc,
|
||||||
itp,
|
itp,
|
||||||
pm,
|
pm,
|
||||||
|
checklists,
|
||||||
):
|
):
|
||||||
app.include_router(module.router)
|
app.include_router(module.router)
|
||||||
app.include_router(assets.blob_router)
|
app.include_router(assets.blob_router)
|
||||||
|
app.include_router(admin_security.stamp_router)
|
||||||
|
app.include_router(shop.stall_router)
|
||||||
for name in ("feed", "packet_visual", "notifications"):
|
for name in ("feed", "packet_visual", "notifications"):
|
||||||
modname = f"{__package__}.api.{name}"
|
modname = f"{__package__}.api.{name}"
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- 0011: schema for Seth's changes of 2026-10-26 and 2026-10-28 (docs/PLAN.md, changes C7 and C8). Runs as ps_migrate.
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- C7: supervisor approval of received shorts
|
||||||
|
|
||||||
|
-- Gitea issue 18. The warehouse records a receipt (short_receipts); the department supervisor then approves that
|
||||||
|
-- receipt, checking the part and its heat number against what the warehouse issued. One approval per receipt.
|
||||||
|
-- A packet cannot be accepted as complete while a short is open or a receipt has no approval.
|
||||||
|
CREATE TABLE short_approvals (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
receipt_id bigint NOT NULL UNIQUE REFERENCES short_receipts (id),
|
||||||
|
short_id bigint NOT NULL REFERENCES bom_shorts (id),
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
-- the department the packet was in when the supervisor approved
|
||||||
|
dept_code text NOT NULL REFERENCES departments (code),
|
||||||
|
note text,
|
||||||
|
signature_id bigint REFERENCES signatures (id),
|
||||||
|
at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
by uuid NOT NULL REFERENCES users (id)
|
||||||
|
);
|
||||||
|
CREATE INDEX short_approvals_packet ON short_approvals (packet_id);
|
||||||
|
|
||||||
|
CREATE TRIGGER short_approvals_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON short_approvals
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
|
||||||
|
CREATE TRIGGER short_approvals_no_truncate
|
||||||
|
BEFORE TRUNCATE ON short_approvals
|
||||||
|
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- C8: per-department checklists
|
||||||
|
|
||||||
|
-- Gitea issue 23. The checklist a stage uses is frozen when work starts on it. NULL means the stage has not started
|
||||||
|
-- and reads its department's live template, so a template change reaches packets not yet started only.
|
||||||
|
ALTER TABLE packet_stages ADD COLUMN checklist_template jsonb;
|
||||||
|
|
||||||
|
-- Stages already started before this migration freeze the list they use today (the default for their kind), so a
|
||||||
|
-- later department edit does not reach them. No department has its own list yet.
|
||||||
|
UPDATE packet_stages s
|
||||||
|
SET checklist_template = t.value -> (CASE WHEN d.weld THEN 'weld' ELSE 'plain' END)
|
||||||
|
FROM packets p, departments d, settings t
|
||||||
|
WHERE p.id = s.packet_id AND d.code = s.dept_code AND t.key = 'checklist_templates'
|
||||||
|
AND s.closed_at IS NULL AND p.status IN ('in_progress', 'review', 'qc')
|
||||||
|
AND jsonb_typeof(t.value -> (CASE WHEN d.weld THEN 'weld' ELSE 'plain' END)) = 'array';
|
||||||
|
|
||||||
|
-- Explicit, after the default privileges: the app may only read and append the approvals.
|
||||||
|
REVOKE UPDATE, DELETE, TRUNCATE ON short_approvals FROM ps_app;
|
||||||
|
GRANT SELECT, INSERT ON short_approvals TO ps_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
"""The manufacturing data book, page for page after the old databook.js.
|
"""The manufacturing data book, page for page after the old databook.js.
|
||||||
|
|
||||||
Sections, in order: 1 Drawings, 2 Material traceability, 3 Welding record, 4 Q.C. inspection, 5 Q.C. and test
|
Sections, in order: 1 Drawings (with the change order record when the packet has change orders), 2 Material
|
||||||
documents, 6 Material test reports, 7 Record history. Front matter (cover and index) is ours; drawings, documents
|
traceability, 3 Welding record, 4 Q.C. inspection, 5 Q.C. and test documents (the packet's documents, NCRs included),
|
||||||
and MTRs are copied at their own size; the heat and weld maps are rasters at the sheet's size.
|
6 Material test reports. Front matter (cover and index) is ours; drawings, documents and MTRs are copied at their own
|
||||||
|
size; the heat and weld maps are rasters at the sheet's size. The packet history pages are left out (change C6, Gitea
|
||||||
|
issue 8): the book is the one copy and goes to the customer. History stays readable in Packet Station.
|
||||||
|
|
||||||
A part that cannot be read (a missing file, a file that is not a PDF or an image, a pypdf error) becomes a warning row
|
A part that cannot be read (a missing file, a file that is not a PDF or an image, a pypdf error) becomes a warning row
|
||||||
in the index and in `Built.warnings`. Only a failure to assemble the whole book raises PdfError.
|
in the index and in `Built.warnings`. Only a failure to assemble the whole book raises PdfError.
|
||||||
@@ -65,7 +67,6 @@ SECTION_NAMES = (
|
|||||||
"Q.C. inspection",
|
"Q.C. inspection",
|
||||||
"Q.C. and test documents",
|
"Q.C. and test documents",
|
||||||
"Material test reports",
|
"Material test reports",
|
||||||
"Record history",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -240,7 +241,6 @@ class Book:
|
|||||||
self.s4_qc()
|
self.s4_qc()
|
||||||
self.s5_documents()
|
self.s5_documents()
|
||||||
self.s6_mtrs()
|
self.s6_mtrs()
|
||||||
self.s7_history()
|
|
||||||
size = self.drawing_size(live or void)
|
size = self.drawing_size(live or void)
|
||||||
|
|
||||||
front_n = 2
|
front_n = 2
|
||||||
@@ -314,6 +314,8 @@ class Book:
|
|||||||
pg,
|
pg,
|
||||||
void=maps.void_text(pg, self.tz),
|
void=maps.void_text(pg, self.tz),
|
||||||
)
|
)
|
||||||
|
if p.get("change_orders"):
|
||||||
|
self.gen(1, "Change order record", self.change_orders)
|
||||||
|
|
||||||
# ------------------------------------------------------------ section 2
|
# ------------------------------------------------------------ section 2
|
||||||
|
|
||||||
@@ -545,9 +547,9 @@ class Book:
|
|||||||
)
|
)
|
||||||
y += 18
|
y += 18
|
||||||
y = d.section_label("Station checklist", y)
|
y = d.section_label("Station checklist", y)
|
||||||
items = checklists.checklist_for(
|
# change C8: the stage's checklist frozen at START, else its department's live list
|
||||||
weld, p.get("drawing"), p.get("rev"), p.get("wps"), checklists.load_templates(self.conn)
|
templates = checklists.stage_templates(self.conn, rec.get("id"), str(code))
|
||||||
)
|
items = checklists.checklist_for(weld, p.get("drawing"), p.get("rev"), p.get("wps"), templates)
|
||||||
ck = rec.get("checklist") or {}
|
ck = rec.get("checklist") or {}
|
||||||
body = []
|
body = []
|
||||||
for it in items:
|
for it in items:
|
||||||
@@ -750,75 +752,50 @@ class Book:
|
|||||||
if not lines:
|
if not lines:
|
||||||
self.warn(6, "No MTRs attached in Packet Station — add from the warehouse files")
|
self.warn(6, "No MTRs attached in Packet Station — add from the warehouse files")
|
||||||
|
|
||||||
# ------------------------------------------------------------ section 7
|
# ------------------------------------------------------------ change orders (section 1)
|
||||||
|
|
||||||
def s7_history(self) -> None:
|
def change_orders(self) -> Doc:
|
||||||
self.gen(7, "Packet record history", self.history)
|
"""The change orders on this packet: who issued each, what it changed and who verified it. The packet's
|
||||||
|
history pages are not in the book (change C6); they stay readable in Packet Station."""
|
||||||
def history(self) -> Doc:
|
|
||||||
p = self.p
|
p = self.p
|
||||||
d = Doc(("Record History", "Section 6 • Manufacturing data book"))
|
d = Doc(("Change Order Record", "Section 1 • Manufacturing data book"))
|
||||||
y = 128.0
|
y = 128.0
|
||||||
d.tx(
|
d.tx(
|
||||||
"Every action taken on this packet in Packet Station, oldest first, with the person who took it.",
|
"The change orders issued on this packet, with the departments that verified each one.",
|
||||||
L,
|
L,
|
||||||
y,
|
y,
|
||||||
size=8.5,
|
size=8.5,
|
||||||
color=GREY,
|
color=GREY,
|
||||||
)
|
)
|
||||||
y += 18
|
y += 18
|
||||||
cos = p.get("change_orders") or []
|
|
||||||
if cos:
|
|
||||||
d.lbl("Change orders", L, y)
|
|
||||||
y += 8
|
|
||||||
body = []
|
|
||||||
for co in cos:
|
|
||||||
verified = co.get("verified") or {}
|
|
||||||
req = co.get("required") or []
|
|
||||||
ver = "\n".join(
|
|
||||||
f"{self.dept_name(dp)}: "
|
|
||||||
+ (
|
|
||||||
f"{parts.name_of(self.users, verified[dp].get('by'))} {self.d(verified[dp].get('at'))}"
|
|
||||||
if dp in verified
|
|
||||||
else "PENDING"
|
|
||||||
)
|
|
||||||
for dp in req
|
|
||||||
)
|
|
||||||
body.append(
|
|
||||||
[
|
|
||||||
f"CO-{co.get('co_no')}" + (f"\n{co['ecn']}" if co.get("ecn") else ""),
|
|
||||||
f"{self.dt(co.get('issued_at'))}\n{parts.name_emp(self.users, co.get('issued_by'))}",
|
|
||||||
co_summary(co),
|
|
||||||
co.get("reason") or "",
|
|
||||||
ver or "No departments to verify",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
y = d.table(
|
|
||||||
["CHANGE", "ISSUED", "DOCUMENTS", "REASON", "VERIFIED BY"],
|
|
||||||
body,
|
|
||||||
y,
|
|
||||||
{0: {"w": 56, "bold": True}, 1: {"w": 96}, 4: {"w": 130}},
|
|
||||||
size=7.4,
|
|
||||||
)
|
|
||||||
y += 16
|
|
||||||
y = d.ensure_space(y, 40)
|
|
||||||
d.lbl("Record history", L, y)
|
|
||||||
y += 8
|
|
||||||
body = []
|
body = []
|
||||||
for e in parts.log_rows(self.conn, int(p["id"])):
|
for co in p.get("change_orders") or []:
|
||||||
who = str(e.get("by_name") or "")
|
verified = co.get("verified") or {}
|
||||||
if who and e.get("by") is not None:
|
req = co.get("required") or []
|
||||||
who += f" ({e.get('emp_no') or '?'})"
|
ver = "\n".join(
|
||||||
role = parts.role_text(e.get("role"))
|
f"{self.dept_name(dp)}: "
|
||||||
|
+ (
|
||||||
|
f"{parts.name_of(self.users, verified[dp].get('by'))} {self.d(verified[dp].get('at'))}"
|
||||||
|
if dp in verified
|
||||||
|
else "PENDING"
|
||||||
|
)
|
||||||
|
for dp in req
|
||||||
|
)
|
||||||
body.append(
|
body.append(
|
||||||
[self.dt(e.get("at")), e.get("action") or "", who + (f"\n{role}" if role else ""), e.get("note") or ""]
|
[
|
||||||
|
f"CO-{co.get('co_no')}" + (f"\n{co['ecn']}" if co.get("ecn") else ""),
|
||||||
|
f"{self.dt(co.get('issued_at'))}\n{parts.name_emp(self.users, co.get('issued_by'))}",
|
||||||
|
co_summary(co),
|
||||||
|
co.get("reason") or "",
|
||||||
|
ver or "No departments to verify",
|
||||||
|
]
|
||||||
)
|
)
|
||||||
d.table(
|
d.table(
|
||||||
["DATE / TIME", "EVENT", "BY", "DETAIL"],
|
["CHANGE", "ISSUED", "DOCUMENTS", "REASON", "VERIFIED BY"],
|
||||||
body,
|
body,
|
||||||
y,
|
y,
|
||||||
{0: {"w": 92}, 1: {"w": 150, "bold": True}, 2: {"w": 96}},
|
{0: {"w": 56, "bold": True}, 1: {"w": 96}, 4: {"w": 130}},
|
||||||
size=7.6,
|
size=7.4,
|
||||||
)
|
)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ def build(
|
|||||||
r.section(
|
r.section(
|
||||||
"PART 04 · LABOR",
|
"PART 04 · LABOR",
|
||||||
"Hours vs target",
|
"Hours vs target",
|
||||||
"Station clock hours and VISUAL ticket hours against work-order target hours.",
|
"VISUAL ticket hours against work-order target hours decide over target. The station clock is shown too.",
|
||||||
pm.hours_tables(rep),
|
pm.hours_tables(rep),
|
||||||
)
|
)
|
||||||
r.section(
|
r.section(
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class Action(StrEnum):
|
|||||||
EDIT_WOS = "edit_wos"
|
EDIT_WOS = "edit_wos"
|
||||||
EDIT_BOM = "edit_bom"
|
EDIT_BOM = "edit_bom"
|
||||||
WH_RECEIVE_SHORT = "wh_receive_short"
|
WH_RECEIVE_SHORT = "wh_receive_short"
|
||||||
|
SHORT_APPROVE = "short_approve"
|
||||||
EDIT_HEATS = "edit_heats"
|
EDIT_HEATS = "edit_heats"
|
||||||
EDIT_HEAT_TABLE = "edit_heat_table"
|
EDIT_HEAT_TABLE = "edit_heat_table"
|
||||||
EDIT_WELDS = "edit_welds"
|
EDIT_WELDS = "edit_welds"
|
||||||
@@ -209,7 +210,7 @@ RULES: dict[Action, Rule] = {
|
|||||||
_s("qc"),
|
_s("qc"),
|
||||||
"complete",
|
"complete",
|
||||||
co_hold=True,
|
co_hold=True,
|
||||||
preconditions=("qc_missing_empty", "route_given"),
|
preconditions=("qc_missing_empty", "shorts_approved", "route_given", "ncr_given"),
|
||||||
signature="Q.C. accept",
|
signature="Q.C. accept",
|
||||||
),
|
),
|
||||||
A.QC_ACCEPT_AND_ROUTE: Rule(
|
A.QC_ACCEPT_AND_ROUTE: Rule(
|
||||||
@@ -218,7 +219,7 @@ RULES: dict[Action, Rule] = {
|
|||||||
"ready",
|
"ready",
|
||||||
dept_change="required",
|
dept_change="required",
|
||||||
co_hold=True,
|
co_hold=True,
|
||||||
preconditions=("qc_missing_empty", "target_dept_given"),
|
preconditions=("qc_missing_empty", "target_dept_given", "ncr_given"),
|
||||||
signature="Q.C. accept and route",
|
signature="Q.C. accept and route",
|
||||||
),
|
),
|
||||||
A.FILE: Rule(
|
A.FILE: Rule(
|
||||||
@@ -226,7 +227,7 @@ RULES: dict[Action, Rule] = {
|
|||||||
_s("complete"),
|
_s("complete"),
|
||||||
"filed",
|
"filed",
|
||||||
co_hold=True,
|
co_hold=True,
|
||||||
preconditions=("vclosed_if_databook",),
|
preconditions=("closed_in_visual", "itp_complete", "shorts_approved"),
|
||||||
signature="File in the data book",
|
signature="File in the data book",
|
||||||
),
|
),
|
||||||
A.ADMIN_REOPEN: Rule(
|
A.ADMIN_REOPEN: Rule(
|
||||||
@@ -246,6 +247,8 @@ RULES: dict[Action, Rule] = {
|
|||||||
A.EDIT_WOS: Rule(Who.CL, _s("draft"), None),
|
A.EDIT_WOS: Rule(Who.CL, _s("draft"), None),
|
||||||
A.EDIT_BOM: Rule(Who.HOLDER, _s("draft", "warehouse", SHOP), None),
|
A.EDIT_BOM: Rule(Who.HOLDER, _s("draft", "warehouse", SHOP), None),
|
||||||
A.WH_RECEIVE_SHORT: Rule(Who.WH, ANY, None, preconditions=("short_open",)),
|
A.WH_RECEIVE_SHORT: Rule(Who.WH, ANY, None, preconditions=("short_open",)),
|
||||||
|
# The department supervisor (a lead is not enough) approves each received short; the filed lock still applies.
|
||||||
|
A.SHORT_APPROVE: Rule(Who.DEPT_SUP, ANY, None, signature="Approve a received short"),
|
||||||
A.EDIT_HEATS: Rule(Who.HOLDER, _s("warehouse", SHOP), None),
|
A.EDIT_HEATS: Rule(Who.HOLDER, _s("warehouse", SHOP), None),
|
||||||
A.EDIT_HEAT_TABLE: Rule(Who.HOLDER, _s("warehouse", SHOP), None),
|
A.EDIT_HEAT_TABLE: Rule(Who.HOLDER, _s("warehouse", SHOP), None),
|
||||||
A.EDIT_WELDS: Rule(Who.HOLDER, SHOP, None),
|
A.EDIT_WELDS: Rule(Who.HOLDER, SHOP, None),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Order of checks, exactly:
|
Order of checks, exactly:
|
||||||
1. removed packet: nothing passes, Admin included.
|
1. removed packet: nothing passes, Admin included.
|
||||||
2. status: the rule's from statuses and the status conditions (RETURN_TO_CLERICAL needs wh_skipped,
|
2. status: the rule's from statuses and the status conditions (RETURN_TO_CLERICAL needs wh_skipped,
|
||||||
SET_VCLOSED needs routed_to databook). An unknown status fails here.
|
SET_VCLOSED needs a hand packet routed to the data book). An unknown status fails here.
|
||||||
3. change-order hold, Admin included.
|
3. change-order hold, Admin included.
|
||||||
4. system-only rules refuse people; the system principal passes only system-only rules.
|
4. system-only rules refuse people; the system principal passes only system-only rules.
|
||||||
5. admin bypass of who and custody, with an IT reason (SET_DATABOOK_AT needs none).
|
5. admin bypass of who and custody, with an IT reason (SET_DATABOOK_AT needs none).
|
||||||
@@ -40,6 +40,7 @@ MSG_FILED = "This packet is filed and locked. IT can reopen it if a correction i
|
|||||||
MSG_FORBIDDEN_STEP = "Your access doesn't allow this step."
|
MSG_FORBIDDEN_STEP = "Your access doesn't allow this step."
|
||||||
MSG_NOT_WH_SKIPPED = "This packet came through the warehouse. Send it back to the warehouse instead."
|
MSG_NOT_WH_SKIPPED = "This packet came through the warehouse. Send it back to the warehouse instead."
|
||||||
MSG_NOT_DATABOOK = "Only packets routed to the data book are closed in VISUAL here."
|
MSG_NOT_DATABOOK = "Only packets routed to the data book are closed in VISUAL here."
|
||||||
|
MSG_FEED_CLOSES = "The VISUAL feed marks this job closed when it sees every work order closed."
|
||||||
MSG_FORBIDDEN_SCOPED = "Your access doesn't allow this."
|
MSG_FORBIDDEN_SCOPED = "Your access doesn't allow this."
|
||||||
|
|
||||||
# The packet view check_scoped hands the who predicates. Only predicates that ignore the packet (CL, QC, QC_SUP,
|
# The packet view check_scoped hands the who predicates. Only predicates that ignore the packet (CL, QC, QC_SUP,
|
||||||
@@ -60,11 +61,15 @@ _NO_PACKET = PacketState(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Preconditions with their own refusal code (the API maps reason_required to 400 and ncr_required to 422).
|
||||||
|
PRECONDITION_CODES: dict[str, str] = {"reason_given": "reason_required", "ncr_given": "ncr_required"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Decision:
|
class Decision:
|
||||||
ok: bool
|
ok: bool
|
||||||
code: str = "" # removed | wrong_status | co_hold | filed | it_reason_required | forbidden | precondition
|
code: str = "" # removed | wrong_status | co_hold | filed | it_reason_required | forbidden | precondition
|
||||||
# | reason_required | system_only
|
# | reason_required | ncr_required | system_only
|
||||||
why: str = "" # the message for the user
|
why: str = "" # the message for the user
|
||||||
admin_bypass: bool = False # an admin passed who and custody only through the bypass (an IT edit)
|
admin_bypass: bool = False # an admin passed who and custody only through the bypass (an IT edit)
|
||||||
signature: str | None = None # the rule's signature meaning, when one is required
|
signature: str | None = None # the rule's signature meaning, when one is required
|
||||||
@@ -78,15 +83,22 @@ STATUS_CONDITIONS: dict[Action, tuple[Callable[[PacketState], bool], str]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _status_condition(state: PacketState, action: Action) -> str | None:
|
||||||
|
# C5: the feed alone closes a VISUAL packet in VISUAL; Clerical confirms a hand packet only.
|
||||||
|
if action is Action.SET_VCLOSED and state.source_kind == "visual":
|
||||||
|
return MSG_FEED_CLOSES
|
||||||
|
cond = STATUS_CONDITIONS.get(action)
|
||||||
|
if cond is not None and not cond[0](state):
|
||||||
|
return cond[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _wrong_status(state: PacketState, action: Action, rule: Rule) -> str | None:
|
def _wrong_status(state: PacketState, action: Action, rule: Rule) -> str | None:
|
||||||
if state.status not in STATUSES:
|
if state.status not in STATUSES:
|
||||||
return MSG_UNKNOWN_STATUS
|
return MSG_UNKNOWN_STATUS
|
||||||
if rule.from_status and state.status not in rule.from_status:
|
if rule.from_status and state.status not in rule.from_status:
|
||||||
return f"This step isn't open while the packet is {STATUS_LABELS[state.status]}."
|
return f"This step isn't open while the packet is {STATUS_LABELS[state.status]}."
|
||||||
cond = STATUS_CONDITIONS.get(action)
|
return _status_condition(state, action)
|
||||||
if cond is not None and not cond[0](state):
|
|
||||||
return cond[1]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _forbidden_why(action: Action, rule: Rule, state: PacketState, extra: Mapping[str, Any]) -> str:
|
def _forbidden_why(action: Action, rule: Rule, state: PacketState, extra: Mapping[str, Any]) -> str:
|
||||||
@@ -155,7 +167,9 @@ def _evaluate(
|
|||||||
if read:
|
if read:
|
||||||
blockers.append(failed)
|
blockers.append(failed)
|
||||||
continue
|
continue
|
||||||
code = "reason_required" if rule.reason_required and name == "reason_given" else "precondition"
|
code = PRECONDITION_CODES.get(name, "precondition")
|
||||||
|
if name == "reason_given" and not rule.reason_required:
|
||||||
|
code = "precondition"
|
||||||
return Decision(False, code, failed, admin_bypass=bypass)
|
return Decision(False, code, failed, admin_bypass=bypass)
|
||||||
if blockers:
|
if blockers:
|
||||||
code = "co_hold" if rule.co_hold and state.co_hold else "precondition"
|
code = "co_hold" if rule.co_hold and state.co_hold else "precondition"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ than packet state; `can()` skips those, because they are inputs the user gives w
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -30,7 +31,10 @@ MESSAGES: dict[str, str] = {
|
|||||||
"qc_book_spool_location": "Confirm the book and the spool, and give the location.",
|
"qc_book_spool_location": "Confirm the book and the spool, and give the location.",
|
||||||
"qc_missing_empty": "Q.C. is not finished.",
|
"qc_missing_empty": "Q.C. is not finished.",
|
||||||
"reason_given": "Give a reason of at least 8 characters.",
|
"reason_given": "Give a reason of at least 8 characters.",
|
||||||
"vclosed_if_databook": "Close the job in VISUAL first.",
|
"closed_in_visual": "Close the job in VISUAL first.",
|
||||||
|
"itp_complete": "Complete the unit's ITP first. Q.C. signs it off on the ITP page.",
|
||||||
|
"shorts_approved": "Every short must be received and approved by the supervisor first.",
|
||||||
|
"ncr_given": "Enter the NCR number of the reject first.",
|
||||||
"co_staged_if_dwg_change": "Stage the new drawing for the change order first.",
|
"co_staged_if_dwg_change": "Stage the new drawing for the change order first.",
|
||||||
"route_given": "Choose Data Book or Shipping.",
|
"route_given": "Choose Data Book or Shipping.",
|
||||||
"target_dept_given": "Choose a different department.",
|
"target_dept_given": "Choose a different department.",
|
||||||
@@ -45,10 +49,12 @@ INPUT_ONLY: frozenset[str] = frozenset(
|
|||||||
"reason_given",
|
"reason_given",
|
||||||
"qc_book_spool_location",
|
"qc_book_spool_location",
|
||||||
"co_staged_if_dwg_change",
|
"co_staged_if_dwg_change",
|
||||||
|
"ncr_given",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
ROUTES = ("databook", "shipping")
|
ROUTES = ("databook", "shipping")
|
||||||
|
RX_NCR = re.compile(r"^NCR-\d{4}-\d{4}$") # domain/ncr.py validates and normalises; this only checks the form
|
||||||
|
|
||||||
|
|
||||||
def _fail(name: str, ok: bool) -> str | None:
|
def _fail(name: str, ok: bool) -> str | None:
|
||||||
@@ -108,8 +114,36 @@ def reason_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|||||||
return _fail("reason_given", reason_ok(extra.get("reason")) is not None)
|
return _fail("reason_given", reason_ok(extra.get("reason")) is not None)
|
||||||
|
|
||||||
|
|
||||||
def vclosed_if_databook(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
def closed_in_visual(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
return _fail("vclosed_if_databook", state.routed_to != "databook" or state.vclosed)
|
"""FILE waits for the job to be closed in VISUAL (C5).
|
||||||
|
|
||||||
|
A VISUAL packet, on either route: the feed sets vclosed when it sees every work order closed, and no work order
|
||||||
|
may show open. A hand packet has no feed state: on the data book route Clerical confirms the close by hand."""
|
||||||
|
if state.source_kind != "visual":
|
||||||
|
return _fail("closed_in_visual", state.routed_to != "databook" or state.vclosed)
|
||||||
|
if state.visual_open_wos:
|
||||||
|
wos = ", ".join(state.visual_open_wos)
|
||||||
|
return f"VISUAL still shows work orders open on this packet: {wos}. Filing waits until VISUAL closes them."
|
||||||
|
if not state.vclosed:
|
||||||
|
return "Filing waits until the VISUAL feed sees the job closed."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def itp_complete(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
"""A unit with no ITP does not block (C3)."""
|
||||||
|
return _fail("itp_complete", not state.itp_open)
|
||||||
|
|
||||||
|
|
||||||
|
def shorts_approved(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
"""No open short and no received short without the supervisor's approval (C7)."""
|
||||||
|
return _fail("shorts_approved", state.shorts_unapproved <= 0)
|
||||||
|
|
||||||
|
|
||||||
|
def ncr_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
"""After a Q.C. reject with no NCR number, the accept carries one (C2). State plus input, so can() skips it."""
|
||||||
|
if not state.ncr_required:
|
||||||
|
return None
|
||||||
|
return _fail("ncr_given", bool(RX_NCR.match(clean_text(extra.get("ncr"), 100).upper())))
|
||||||
|
|
||||||
|
|
||||||
def co_staged_if_dwg_change(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
def co_staged_if_dwg_change(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
@@ -148,7 +182,10 @@ PRECONDITIONS: dict[str, Precondition] = {
|
|||||||
"qc_book_spool_location": qc_book_spool_location,
|
"qc_book_spool_location": qc_book_spool_location,
|
||||||
"qc_missing_empty": qc_missing_empty,
|
"qc_missing_empty": qc_missing_empty,
|
||||||
"reason_given": reason_given,
|
"reason_given": reason_given,
|
||||||
"vclosed_if_databook": vclosed_if_databook,
|
"closed_in_visual": closed_in_visual,
|
||||||
|
"itp_complete": itp_complete,
|
||||||
|
"shorts_approved": shorts_approved,
|
||||||
|
"ncr_given": ncr_given,
|
||||||
"co_staged_if_dwg_change": co_staged_if_dwg_change,
|
"co_staged_if_dwg_change": co_staged_if_dwg_change,
|
||||||
"route_given": route_given,
|
"route_given": route_given,
|
||||||
"target_dept_given": target_dept_given,
|
"target_dept_given": target_dept_given,
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ class PacketState:
|
|||||||
filler_set: bool = False
|
filler_set: bool = False
|
||||||
qc_missing: tuple[str, ...] = () # the old qcMissing() list, computed by the read model
|
qc_missing: tuple[str, ...] = () # the old qcMissing() list, computed by the read model
|
||||||
has_qc_sent: bool = False # qc_inspections row exists for the current stage
|
has_qc_sent: bool = False # qc_inspections row exists for the current stage
|
||||||
|
shorts_unapproved: int = 0 # open shorts plus received shorts the supervisor has not approved (C7)
|
||||||
|
itp_open: bool = False # the packet's unit has an ITP that is not complete (C3)
|
||||||
|
ncr_required: bool = False # a Q.C. reject since the last accept has no NCR number (C2)
|
||||||
|
visual_open_wos: tuple[str, ...] = () # the work orders the feed last saw open in VISUAL (C5)
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_FEED = Principal(user_id=None, system="visual-feed")
|
SYSTEM_FEED = Principal(user_id=None, system="visual-feed")
|
||||||
|
|||||||
@@ -99,4 +99,36 @@ test.describe.serial("admin stamp check", () => {
|
|||||||
await expect(results).toContainText("Job traveler");
|
await expect(results).toContainText("Job traveler");
|
||||||
await closeAll(ann);
|
await closeAll(ann);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("C9: Q.C. checks a stamp on the Stamp check screen; Warehouse is refused", async ({ browser }) => {
|
||||||
|
const quinn = await actor(browser, "qc.quinn");
|
||||||
|
const page = quinn.page;
|
||||||
|
// the API outside /admin answers Q.C.
|
||||||
|
const ok = await api(quinn, "post", "/api/stamp-verify", { text: LINE });
|
||||||
|
expect(ok.valid).toBe(true);
|
||||||
|
expect(ok.print.packet_id).toBe(P.id);
|
||||||
|
|
||||||
|
// the top-bar link opens the screen
|
||||||
|
await page.goto("/qc");
|
||||||
|
await page.getByRole("banner").getByRole("link", { name: "Stamp check", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/stamp-check$/);
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "Stamp check" })).toBeVisible();
|
||||||
|
const form = page.locator("form").filter({ has: page.getByRole("heading", { name: "Check a stamp line" }) });
|
||||||
|
await form.getByLabel("Stamp line or code", { exact: true }).fill(LINE);
|
||||||
|
await form.getByRole("button", { name: "Check stamp", exact: true }).click();
|
||||||
|
const result = form.getByRole("status", { name: "Stamp result" });
|
||||||
|
await expect(result).toContainText("Genuine.");
|
||||||
|
await expect(result).toContainText("Job traveler");
|
||||||
|
|
||||||
|
// Clerical may check too; Warehouse may not, and its screen says so
|
||||||
|
const clara = await actor(browser, "clerk.clara");
|
||||||
|
expect((await api(clara, "post", "/api/stamp-verify", { text: LINE })).valid).toBe(true);
|
||||||
|
const wade = await actor(browser, "wh.wade");
|
||||||
|
const refused = await call(wade, "post", "/api/stamp-verify", { text: LINE });
|
||||||
|
expect(refused.status).toBe(403);
|
||||||
|
await expect(wade.page.getByRole("banner").getByRole("link", { name: "Stamp check", exact: true })).toHaveCount(0);
|
||||||
|
await wade.page.goto("/stamp-check");
|
||||||
|
await expect(wade.page.getByTestId("not-for-role")).toContainText("The Stamp check is not for your role.");
|
||||||
|
await closeAll(quinn, clara, wade);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { actor, api, closeAll } from "./shophelpers";
|
||||||
|
import { call } from "./adminhelpers";
|
||||||
|
import type { Json } from "./whhelpers";
|
||||||
|
|
||||||
|
// Change C8 on the CI stack: checklists per department. multi.mia (Clerical and 06 Code Bldg 2 supervisor) opens
|
||||||
|
// Checklists from the top bar, sees only her own department, adds an item and saves. sup.wes (03 supervisor) can
|
||||||
|
// read 06 but not change it, and Q.C. and a lead cannot change any. At the end mia sets 06 back to the default list
|
||||||
|
// so the other scenarios keep the lists they expect.
|
||||||
|
|
||||||
|
const RUN = Date.now().toString(36).toLowerCase().slice(-5);
|
||||||
|
const KEY = `e2e_${RUN}`;
|
||||||
|
const TEXT = `Nameplate checked ${RUN}`;
|
||||||
|
|
||||||
|
async function resetTo06Default(a: Parameters<typeof api>[0]): Promise<void> {
|
||||||
|
const cur = await api(a, "get", "/api/checklists/06");
|
||||||
|
if (cur.custom) await api(a, "put", "/api/checklists/06", { items: null, base: cur.version });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.serial("department checklists", () => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
|
test("C8: a supervisor edits their own department's checklist; others cannot", async ({ browser }) => {
|
||||||
|
const mia = await actor(browser, "multi.mia");
|
||||||
|
await resetTo06Default(mia);
|
||||||
|
const list = (await api(mia, "get", "/api/checklists")) as unknown as Json[];
|
||||||
|
const editable = list.filter((d) => d.editable).map((d) => String(d.dept_code));
|
||||||
|
expect(editable).toEqual(["06"]);
|
||||||
|
|
||||||
|
// the top-bar link; one department, so no picker
|
||||||
|
const page = mia.page;
|
||||||
|
await page.goto("/clerical");
|
||||||
|
await page.getByRole("banner").getByRole("link", { name: "Checklists", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/checklists$/);
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "Checklists" })).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Department", { exact: true })).toHaveCount(0);
|
||||||
|
const editor = page.locator("section").filter({ has: page.getByRole("heading", { name: /^06 / }) });
|
||||||
|
await expect(editor).toBeVisible();
|
||||||
|
await expect(editor).toContainText(/Uses the default/);
|
||||||
|
const keys = editor.getByLabel(/^Item \d+ key$/);
|
||||||
|
const n = await keys.count();
|
||||||
|
expect(n).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// add an item at the end and save
|
||||||
|
await editor.getByRole("button", { name: "Add item", exact: true }).click();
|
||||||
|
await expect(keys).toHaveCount(n + 1);
|
||||||
|
await editor.getByLabel(`Item ${n + 1} group`, { exact: true }).fill("Final");
|
||||||
|
await editor.getByLabel(`Item ${n + 1} key`, { exact: true }).fill(KEY);
|
||||||
|
await editor.getByLabel(`Item ${n + 1} text`, { exact: true }).fill(TEXT);
|
||||||
|
await editor.getByRole("button", { name: "Save checklist", exact: true }).click();
|
||||||
|
await expect(editor.getByText(/^Checklist saved\./)).toBeVisible();
|
||||||
|
await expect(editor).toContainText("Own list");
|
||||||
|
const saved = await api(mia, "get", "/api/checklists/06");
|
||||||
|
expect(saved.custom).toBe(true);
|
||||||
|
expect(saved.editable).toBe(true);
|
||||||
|
expect((saved.items as Json[]).map((i) => i.key)).toContain(KEY);
|
||||||
|
expect((saved.items as Json[]).length).toBe(n + 1);
|
||||||
|
|
||||||
|
// sup.wes reads 06 but may not change it; his own list is 03
|
||||||
|
const wes = await actor(browser, "sup.wes");
|
||||||
|
const as03 = await api(wes, "get", "/api/checklists/06");
|
||||||
|
expect(as03.editable).toBe(false);
|
||||||
|
expect((as03.items as Json[]).map((i) => i.key)).toContain(KEY);
|
||||||
|
const refused = await call(wes, "put", "/api/checklists/06", { items: [], base: saved.version });
|
||||||
|
expect(refused.status).toBe(403);
|
||||||
|
await wes.page.goto("/checklists");
|
||||||
|
await expect(wes.page.locator("section").filter({ has: wes.page.getByRole("heading", { name: /^03 / }) })).toBeVisible();
|
||||||
|
await expect(wes.page.getByRole("heading", { name: /^06 / })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Q.C. and a lead: no edit, no screen
|
||||||
|
const quinn = await actor(browser, "qc.quinn");
|
||||||
|
expect((await call(quinn, "put", "/api/checklists/03", { items: [], base: 0 })).status).toBe(403);
|
||||||
|
const lou = await actor(browser, "lead.lou");
|
||||||
|
expect((await api(lou, "get", "/api/checklists/03")).editable).toBe(false);
|
||||||
|
await lou.page.goto("/checklists");
|
||||||
|
await expect(lou.page.getByTestId("not-for-role")).toContainText("The Checklists screen is not for your role.");
|
||||||
|
|
||||||
|
// a stale save is refused with the current list
|
||||||
|
const stale = await call(mia, "put", "/api/checklists/06", { items: saved.items, base: Number(saved.version) - 1 });
|
||||||
|
expect(stale.status).toBe(409);
|
||||||
|
|
||||||
|
// back to the default so the other scenarios keep their lists
|
||||||
|
await resetTo06Default(mia);
|
||||||
|
expect((await api(mia, "get", "/api/checklists/06")).custom).toBe(false);
|
||||||
|
await closeAll(mia, wes, quinn, lou);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,9 +21,11 @@ import {
|
|||||||
atCompleteApi,
|
atCompleteApi,
|
||||||
cardOf,
|
cardOf,
|
||||||
clickAndCatch,
|
clickAndCatch,
|
||||||
|
clickAndPrint,
|
||||||
column,
|
column,
|
||||||
expectPdf,
|
expectPdf,
|
||||||
fetchOk,
|
fetchOk,
|
||||||
|
itpOf,
|
||||||
makeCloseProject,
|
makeCloseProject,
|
||||||
openClericalHome,
|
openClericalHome,
|
||||||
openPacket,
|
openPacket,
|
||||||
@@ -206,18 +208,20 @@ test.describe.serial("close", () => {
|
|||||||
await sheet.getByRole("button", { name: L.cancel, exact: true }).click();
|
await sheet.getByRole("button", { name: L.cancel, exact: true }).click();
|
||||||
await expect(sheet).toBeHidden();
|
await expect(sheet).toBeHidden();
|
||||||
|
|
||||||
// a row opens the print in a new tab and closes the sheet; the server logs it
|
// a row closes the sheet and prints on the same page (C4): the stamped PDF is fetched and the print dialog
|
||||||
|
// opens, no new tab; the server logs it
|
||||||
const before = await printCount(clara, id, "traveler");
|
const before = await printCount(clara, id, "traveler");
|
||||||
await box.getByRole("button", { name: L.printOpen, exact: true }).click();
|
await box.getByRole("button", { name: L.printOpen, exact: true }).click();
|
||||||
sheet = page.getByRole("dialog", { name: L.printSheet, exact: true });
|
sheet = page.getByRole("dialog", { name: L.printSheet, exact: true });
|
||||||
await expect(sheet).toBeVisible();
|
await expect(sheet).toBeVisible();
|
||||||
await clickAndCatch(
|
await clickAndPrint(
|
||||||
clara.ctx,
|
|
||||||
page,
|
page,
|
||||||
sheet.getByRole("button", { name: L.printRows[0], exact: true }),
|
sheet.getByRole("button", { name: L.printRows[0], exact: true }),
|
||||||
`/api/packets/${id}/print/traveler`,
|
`/api/packets/${id}/print/traveler`,
|
||||||
);
|
);
|
||||||
await expect(sheet).toBeHidden();
|
await expect(sheet).toBeHidden();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/packets/${id}$`));
|
||||||
|
expect(clara.ctx.pages().length).toBe(1);
|
||||||
await expect.poll(() => printCount(clara, id, "traveler")).toBe(before + 1);
|
await expect.poll(() => printCount(clara, id, "traveler")).toBe(before + 1);
|
||||||
|
|
||||||
// the same print through the API: a PDF, and one more log row
|
// the same print through the API: a PDF, and one more log row
|
||||||
@@ -244,6 +248,33 @@ test.describe.serial("close", () => {
|
|||||||
await closeAll(clara);
|
await closeAll(clara);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("C3: an open ITP on the unit blocks filing until Q.C. signs it off", async ({ browser }) => {
|
||||||
|
const id = pk("r1").id;
|
||||||
|
const quinn = await actor(browser, "qc.quinn");
|
||||||
|
const unitId = Number(proj.unit.id);
|
||||||
|
const started = await api(quinn, "post", `/api/units/${unitId}/itp`, {});
|
||||||
|
expect(started.status).not.toBe("complete");
|
||||||
|
|
||||||
|
const clara = await actor(browser, "clerk.clara");
|
||||||
|
const page = clara.page;
|
||||||
|
await openPacket(page, id);
|
||||||
|
const box = panel(page);
|
||||||
|
await expect(box.getByText(L.readyForBook)).toBeVisible();
|
||||||
|
const file = box.getByRole("button", { name: L.fileIt, exact: true });
|
||||||
|
await expect(file).toBeDisabled();
|
||||||
|
await expect(box.getByText(L.itpOpen)).toBeVisible();
|
||||||
|
expect((await packet(clara, id)).blocked.file).toContain(L.itpOpen);
|
||||||
|
await stepApi(clara, id, "file", { sign: sign("clerk.clara") }, 403);
|
||||||
|
expect(await statusOf(clara, id)).toBe("complete");
|
||||||
|
|
||||||
|
// signed off: File is enabled again
|
||||||
|
await signedOffItpApi(quinn, unitId);
|
||||||
|
await page.reload();
|
||||||
|
await expect(box.getByRole("button", { name: L.fileIt, exact: true })).toBeEnabled();
|
||||||
|
await expect(box.getByText(L.itpOpen)).toHaveCount(0);
|
||||||
|
await closeAll(quinn, clara);
|
||||||
|
});
|
||||||
|
|
||||||
test("R1: filed in the data book with the PIN; the seal holds and the filed data book downloads", async ({
|
test("R1: filed in the data book with the PIN; the seal holds and the filed data book downloads", async ({
|
||||||
browser,
|
browser,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -326,12 +357,14 @@ test.describe.serial("close", () => {
|
|||||||
await closeAll(ann, clara);
|
await closeAll(ann, clara);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ITP: signed off through the API, its PDF is filed in the unit folder and linked on the ITP page", async ({
|
test("ITP: signed off, its PDF is filed in the unit folder and linked on the ITP page", async ({
|
||||||
browser,
|
browser,
|
||||||
}) => {
|
}) => {
|
||||||
const quinn = await actor(browser, "qc.quinn");
|
const quinn = await actor(browser, "qc.quinn");
|
||||||
const unitId = Number(proj.unit.id);
|
const unitId = Number(proj.unit.id);
|
||||||
const itp = await signedOffItpApi(quinn, unitId);
|
// signed off in the C3 scenario above
|
||||||
|
const itp = (await itpOf(quinn, unitId)) as Json;
|
||||||
|
expect(itp.status).toBe("complete");
|
||||||
const no = String(itp.no);
|
const no = String(itp.no);
|
||||||
expect(itp.signoff.asset_id).toBeTruthy();
|
expect(itp.signoff.asset_id).toBeTruthy();
|
||||||
|
|
||||||
@@ -356,6 +389,13 @@ test.describe.serial("close", () => {
|
|||||||
.first();
|
.first();
|
||||||
const req = await clickAndCatch(quinn.ctx, quinn.page, pdfBtn, `/api/itps/${itp.id}/pdf`);
|
const req = await clickAndCatch(quinn.ctx, quinn.page, pdfBtn, `/api/itps/${itp.id}/pdf`);
|
||||||
await expectPdf(quinn, req.url());
|
await expectPdf(quinn, req.url());
|
||||||
|
// Print goes to the print dialog on the same page (C4)
|
||||||
|
await clickAndPrint(
|
||||||
|
quinn.page,
|
||||||
|
quinn.page.getByRole("button", { name: "Print", exact: true }),
|
||||||
|
`/api/itps/${itp.id}/print`,
|
||||||
|
);
|
||||||
|
await expect(quinn.page).toHaveURL(new RegExp(`/qc/units/${unitId}/itp$`));
|
||||||
const signed = quinn.page.getByRole("link", { name: L.signedPdf, exact: true });
|
const signed = quinn.page.getByRole("link", { name: L.signedPdf, exact: true });
|
||||||
await expect(signed).toBeVisible();
|
await expect(signed).toBeVisible();
|
||||||
await expect(signed).toHaveAttribute("href", new RegExp(`/_blob/${itp.signoff.asset_id}`));
|
await expect(signed).toHaveAttribute("href", new RegExp(`/_blob/${itp.signoff.asset_id}`));
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export const L = {
|
|||||||
sealUnknown: "Seal unknown",
|
sealUnknown: "Seal unknown",
|
||||||
reopenedLine: /^Reopened by .+: .+\. The earlier seal is kept as broken\./,
|
reopenedLine: /^Reopened by .+: .+\. The earlier seal is kept as broken\./,
|
||||||
blocked: "Close the job in VISUAL first.",
|
blocked: "Close the job in VISUAL first.",
|
||||||
|
itpOpen: "Complete the unit's ITP first. Q.C. signs it off on the ITP page.", // C3
|
||||||
|
shortsOpen: "Every short must be received and approved by the supervisor first.", // C7
|
||||||
visualLine: "visual-line", // testid of the VISUAL observed line
|
visualLine: "visual-line", // testid of the VISUAL observed line
|
||||||
sealState: "seal-state", // testid of the seal line
|
sealState: "seal-state", // testid of the seal line
|
||||||
reopened: "reopened", // testid of the reopen line
|
reopened: "reopened", // testid of the reopen line
|
||||||
@@ -34,7 +36,6 @@ export const L = {
|
|||||||
jobClosed: "Job closed in VISUAL",
|
jobClosed: "Job closed in VISUAL",
|
||||||
moveBack: "Not closed in VISUAL, move back",
|
moveBack: "Not closed in VISUAL, move back",
|
||||||
fileIt: "File in the data book",
|
fileIt: "File in the data book",
|
||||||
fileAnyway: "File anyway",
|
|
||||||
dataBookPdf: "Data book (PDF)",
|
dataBookPdf: "Data book (PDF)",
|
||||||
download: "Download",
|
download: "Download",
|
||||||
csv: "Traceability (CSV)",
|
csv: "Traceability (CSV)",
|
||||||
@@ -42,7 +43,6 @@ export const L = {
|
|||||||
reopenIt: "Reopen (IT)",
|
reopenIt: "Reopen (IT)",
|
||||||
// sheets
|
// sheets
|
||||||
printSheet: "Print",
|
printSheet: "Print",
|
||||||
openWosSheet: "File with open work orders?",
|
|
||||||
reopenSheet: "Reopen a filed packet",
|
reopenSheet: "Reopen a filed packet",
|
||||||
// print rows (each a button whose name starts with the row title)
|
// print rows (each a button whose name starts with the row title)
|
||||||
printRows: ["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)", "Data book"],
|
printRows: ["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)", "Data book"],
|
||||||
@@ -202,6 +202,40 @@ export async function clickAndCatch(ctx: BrowserContext, page: Page, target: Loc
|
|||||||
return { url: () => fixed, response };
|
return { url: () => fixed, response };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click a print control and check it prints on the same page (change C4): the page fetches the stamped PDF (the URL
|
||||||
|
* contains `part`), draws it and calls window.print, which is stubbed here. No tab is opened. Returns the PDF URL.
|
||||||
|
*/
|
||||||
|
export async function clickAndPrint(page: Page, target: Locator, part: string): Promise<string> {
|
||||||
|
await expect(target).toBeVisible();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const w = window as unknown as {
|
||||||
|
__printed: number;
|
||||||
|
__opened: string[];
|
||||||
|
print: () => void;
|
||||||
|
open: (u?: string | URL) => Window | null;
|
||||||
|
};
|
||||||
|
w.__printed = 0;
|
||||||
|
w.__opened = [];
|
||||||
|
w.print = () => {
|
||||||
|
w.__printed += 1;
|
||||||
|
};
|
||||||
|
w.open = (u?: string | URL) => {
|
||||||
|
w.__opened.push(String(u ?? ""));
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const fetched = page.waitForResponse((r) => r.url().includes(part), { timeout: 30_000 });
|
||||||
|
await target.click();
|
||||||
|
const res = await fetched;
|
||||||
|
expect(res.status(), `print fetch ${res.url()}`).toBe(200);
|
||||||
|
await expect
|
||||||
|
.poll(async () => page.evaluate(() => (window as unknown as { __printed: number }).__printed), { timeout: 30_000 })
|
||||||
|
.toBe(1);
|
||||||
|
expect(await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened)).toEqual([]);
|
||||||
|
return res.url();
|
||||||
|
}
|
||||||
|
|
||||||
/** GET a URL through the actor's API context and check the status. */
|
/** GET a URL through the actor's API context and check the status. */
|
||||||
export async function fetchOk(a: Actor, url: string): Promise<APIResponse> {
|
export async function fetchOk(a: Actor, url: string): Promise<APIResponse> {
|
||||||
const r = await a.page.request.get(url);
|
const r = await a.page.request.get(url);
|
||||||
|
|||||||
@@ -180,4 +180,24 @@ test.describe.serial("project management", () => {
|
|||||||
await expect(pmTab(page, L.pm.tabs.hours)).toHaveAttribute("aria-selected", "true"); // ps.pmtab
|
await expect(pmTab(page, L.pm.tabs.hours)).toHaveAttribute("aria-selected", "true"); // ps.pmtab
|
||||||
await closeAll(paula);
|
await closeAll(paula);
|
||||||
});
|
});
|
||||||
|
test("C10: the PM screen and /api/pm are for Project Management and Admin only", async ({ browser }) => {
|
||||||
|
const clara = await actor(browser, "clerk.clara");
|
||||||
|
const wes = await actor(browser, "sup.wes");
|
||||||
|
for (const a of [clara, wes]) {
|
||||||
|
for (const url of ["/api/pm/overview", "/api/pm/hours", "/api/pm/export.csv", "/api/pm/report.pdf"]) {
|
||||||
|
const r = await a.page.request.get(url);
|
||||||
|
expect(r.status(), `${a.sam} ${url}`).toBe(403);
|
||||||
|
}
|
||||||
|
await a.page.goto(L.homes.pm);
|
||||||
|
await expect(a.page.getByTestId("not-for-role")).toContainText(
|
||||||
|
"The Project Management screen is not for your role.",
|
||||||
|
);
|
||||||
|
await expect(a.page.getByRole("heading", { level: 1, name: L.pm.head })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
// Admin still reads it
|
||||||
|
const ann = await actor(browser, "admin.ann");
|
||||||
|
const r = await ann.page.request.get("/api/pm/overview");
|
||||||
|
expect(r.status()).toBe(200);
|
||||||
|
await closeAll(clara, wes, ann);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+122
-3
@@ -7,6 +7,8 @@ import {
|
|||||||
closeAll,
|
closeAll,
|
||||||
packet,
|
packet,
|
||||||
packetTab,
|
packetTab,
|
||||||
|
assignApi,
|
||||||
|
finishStageApi,
|
||||||
pullAndRelease,
|
pullAndRelease,
|
||||||
setPin,
|
setPin,
|
||||||
sign,
|
sign,
|
||||||
@@ -43,6 +45,8 @@ import {
|
|||||||
type QcProject,
|
type QcProject,
|
||||||
} from "./qchelpers";
|
} from "./qchelpers";
|
||||||
import type { Json } from "./whhelpers";
|
import type { Json } from "./whhelpers";
|
||||||
|
import { L as CL, finishInspectionApi } from "./closehelpers";
|
||||||
|
import { pullWithHeat } from "./pmhelpers";
|
||||||
|
|
||||||
// Phase 6 Q.C. scenarios on the CI stack, modelled on the old tests/ps titp, tqcf and tpdocs, plus the accept and
|
// Phase 6 Q.C. scenarios on the CI stack, modelled on the old tests/ps titp, tqcf and tpdocs, plus the accept and
|
||||||
// reject flows of the old qcTab. clerk.clara makes a project with two units; the crew (wh.wade, sup.wes,
|
// reject flows of the old qcTab. clerk.clara makes a project with two units; the crew (wh.wade, sup.wes,
|
||||||
@@ -60,6 +64,8 @@ const T = {
|
|||||||
reject: `QC reject spool ${RUN}`,
|
reject: `QC reject spool ${RUN}`,
|
||||||
wp: `ITP weld spool ${RUN}`,
|
wp: `ITP weld spool ${RUN}`,
|
||||||
fa: `ITP final spool ${RUN}`,
|
fa: `ITP final spool ${RUN}`,
|
||||||
|
ncr: `QC ncr spool ${RUN}`,
|
||||||
|
short: `QC short spool ${RUN}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
let proj: QcProject;
|
let proj: QcProject;
|
||||||
@@ -104,6 +110,20 @@ test.describe.serial("qc", () => {
|
|||||||
const rel = await stepApi(clara, fa.id, "release_to_shop", { dept: "11" });
|
const rel = await stepApi(clara, fa.id, "release_to_shop", { dept: "11" });
|
||||||
expect(rel.status).toBe("ready");
|
expect(rel.status).toBe("ready");
|
||||||
P.fa = fa;
|
P.fa = fa;
|
||||||
|
// C2: a packet to reject without an NCR number
|
||||||
|
P.ncr = await atQcApi(crew, proj.units[0], T.ncr, "Stall 4", "Bay 2");
|
||||||
|
// C7: a packet with item 2 short; wh.wade receives it before the shop works it
|
||||||
|
const sh = await makeQcDraft(clara, proj.units[0], T.short);
|
||||||
|
await pullWithHeat(wade, sh.id, `HS${RUN}-`, [2]);
|
||||||
|
const rec = await api(wade, "post", `/api/packets/${sh.id}/bom/2/short/receipts`, { qty: 2, heat: `HS${RUN}-R2` });
|
||||||
|
expect(rec.shorts_unapproved).toBe(1);
|
||||||
|
await assignApi(wes, sh.id, "Stall 4", andyId);
|
||||||
|
await stepApi(andy, sh.id, "start");
|
||||||
|
await finishStageApi(andy, sh.id, 2);
|
||||||
|
await stepApi(andy, sh.id, "submit", { sign: sign("weld.andy") });
|
||||||
|
const shq = await stepApi(wes, sh.id, "send_to_qc", { book: true, spool: true, location: "Bay 2", sign: sign("sup.wes") });
|
||||||
|
expect(shq.status).toBe("qc");
|
||||||
|
P.short = sh;
|
||||||
await setPin(quinn, PIN);
|
await setPin(quinn, PIN);
|
||||||
await closeAll(clara, wade, wes, andy, quinn);
|
await closeAll(clara, wade, wes, andy, quinn);
|
||||||
});
|
});
|
||||||
@@ -184,7 +204,7 @@ test.describe.serial("qc", () => {
|
|||||||
await closeAll(quinn);
|
await closeAll(quinn);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reject: one weld rejected, an NCR number only, the password; the supervisor sends it again", async ({
|
test("reject: one weld rejected, a reason and an NCR number, the password; the supervisor sends it again", async ({
|
||||||
browser,
|
browser,
|
||||||
}) => {
|
}) => {
|
||||||
const id = pk("reject").id;
|
const id = pk("reject").id;
|
||||||
@@ -206,7 +226,7 @@ test.describe.serial("qc", () => {
|
|||||||
})
|
})
|
||||||
.toContain("reject:undercut");
|
.toContain("reject:undercut");
|
||||||
|
|
||||||
// the reject sheet: enabled on an NCR number alone
|
// the reject sheet: an NCR number alone is not enough, the reason needs 8 characters (C2)
|
||||||
const bar = qcBar(page);
|
const bar = qcBar(page);
|
||||||
await bar.getByRole("button", { name: L.reject, exact: true }).click();
|
await bar.getByRole("button", { name: L.reject, exact: true }).click();
|
||||||
const sheet = page.getByRole("dialog", { name: L.rejectSheet, exact: true });
|
const sheet = page.getByRole("dialog", { name: L.rejectSheet, exact: true });
|
||||||
@@ -214,6 +234,12 @@ test.describe.serial("qc", () => {
|
|||||||
const submitReject = sheet.getByRole("button", { name: L.rejectSubmit });
|
const submitReject = sheet.getByRole("button", { name: L.rejectSubmit });
|
||||||
await expect(submitReject).toBeDisabled();
|
await expect(submitReject).toBeDisabled();
|
||||||
await sheet.getByLabel(L.ncrNumber, { exact: true }).fill(NCR);
|
await sheet.getByLabel(L.ncrNumber, { exact: true }).fill(NCR);
|
||||||
|
await expect(submitReject).toBeDisabled();
|
||||||
|
const reason = sheet.getByLabel(L.rejectReason, { exact: true });
|
||||||
|
await reason.fill("Short");
|
||||||
|
await expect(sheet.getByText(L.reasonShort)).toBeVisible();
|
||||||
|
await expect(submitReject).toBeDisabled();
|
||||||
|
await reason.fill("Undercut on W1, grind and reweld");
|
||||||
await expect(submitReject).toBeEnabled();
|
await expect(submitReject).toBeEnabled();
|
||||||
await submitReject.click();
|
await submitReject.click();
|
||||||
await signWithPassword(page, "qc.quinn");
|
await signWithPassword(page, "qc.quinn");
|
||||||
@@ -530,12 +556,105 @@ test.describe.serial("qc", () => {
|
|||||||
await closeAll(quinn, wes, ann);
|
await closeAll(quinn, wes, ann);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("C2: a reject without an NCR number needs the number at the accept", async ({ browser }) => {
|
||||||
|
const id = pk("ncr").id;
|
||||||
|
const NCR2 = "NCR-2026-0042";
|
||||||
|
const quinn = await actor(browser, "qc.quinn");
|
||||||
|
const wes = await actor(browser, "sup.wes");
|
||||||
|
// rejected with a detailed reason and no NCR number; sup.wes sends it again; a fresh inspection is complete
|
||||||
|
const rej = await stepApi(quinn, id, "qc_reject", { reason: "Porosity on W2, grind out and reweld", sign: sign("qc.quinn") });
|
||||||
|
expect(rej.status).toBe("review");
|
||||||
|
expect(rej.ncr_required).toBe(true);
|
||||||
|
await stepApi(wes, id, "send_to_qc", { book: true, spool: true, location: "Bay 3", sign: sign("sup.wes") });
|
||||||
|
await finishInspectionApi(quinn, id);
|
||||||
|
expect((await packet(quinn, id)).ncr_required).toBe(true);
|
||||||
|
// the API refuses the accept without the number (422)
|
||||||
|
const r = await quinn.page.request.post(`/api/packets/${id}/transitions`, {
|
||||||
|
headers: { Origin: new URL(quinn.page.url()).origin, "X-Requested-With": "PacketStation" },
|
||||||
|
data: { action: "qc_accept", expect: { status: "qc", dept: "03" }, routed_to: "databook", sign: sign("qc.quinn") },
|
||||||
|
});
|
||||||
|
expect(r.status()).toBe(422);
|
||||||
|
expect(((await r.json()) as Json).detail).toBe(L.ncrFirst);
|
||||||
|
|
||||||
|
// the Q.C. bar asks for it; Accept waits for a well-formed number
|
||||||
|
const page = quinn.page;
|
||||||
|
await open(page, id);
|
||||||
|
const bar = qcBar(page);
|
||||||
|
await expect(bar.getByTestId("ncr-required")).toBeVisible();
|
||||||
|
const acceptBtn = bar.getByRole("button", { name: L.accept, exact: true });
|
||||||
|
const field = bar.getByLabel(L.ncrOfReject, { exact: true });
|
||||||
|
await expect(acceptBtn).toBeDisabled();
|
||||||
|
await field.fill("NCR-12");
|
||||||
|
await expect(acceptBtn).toBeDisabled();
|
||||||
|
await field.fill(NCR2);
|
||||||
|
await expect(acceptBtn).toBeEnabled();
|
||||||
|
await bar.getByLabel(L.route, { exact: true }).selectOption({ label: L.routeDataBook });
|
||||||
|
await acceptBtn.click();
|
||||||
|
await signWithPin(page, PIN);
|
||||||
|
await expect(signSheet(page)).toBeHidden();
|
||||||
|
await expect.poll(() => statusOf(quinn, id)).toBe("complete");
|
||||||
|
const after = await packet(quinn, id);
|
||||||
|
expect(after.qc_last.ncr).toBe(NCR2);
|
||||||
|
expect(after.ncr_required).toBe(false);
|
||||||
|
await closeAll(quinn, wes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C7: a received short needs the supervisor's approval before Q.C. accepts", async ({ browser }) => {
|
||||||
|
const id = pk("short").id;
|
||||||
|
const quinn = await actor(browser, "qc.quinn");
|
||||||
|
await finishInspectionApi(quinn, id);
|
||||||
|
let p = await packet(quinn, id);
|
||||||
|
expect(p.shorts_unapproved).toBe(1);
|
||||||
|
expect(p.blocked.qc_accept).toContain(CL.shortsOpen);
|
||||||
|
await stepApi(quinn, id, "qc_accept", { routed_to: "databook", sign: sign("qc.quinn") }, 403);
|
||||||
|
const receipt = Number(((p.shorts as Json[])[0].receipts as Json[])[0].id);
|
||||||
|
const approveUrl = `/api/packets/${id}/shorts/receipts/${receipt}/approve`;
|
||||||
|
|
||||||
|
// the Q.C. bar says why Accept is off
|
||||||
|
await open(quinn.page, id);
|
||||||
|
const bar = qcBar(quinn.page);
|
||||||
|
await expect(bar.getByText(CL.shortsOpen)).toBeVisible();
|
||||||
|
await expect(bar.getByRole("button", { name: L.accept, exact: true })).toBeDisabled();
|
||||||
|
|
||||||
|
// Q.C. and a lead may not approve; the department supervisor does
|
||||||
|
await api(quinn, "post", approveUrl, { sign: sign("qc.quinn") }, { status: 403 });
|
||||||
|
const lou = await actor(browser, "lead.lou");
|
||||||
|
await api(lou, "post", approveUrl, { sign: sign("lead.lou") }, { status: 403 });
|
||||||
|
const wes = await actor(browser, "sup.wes");
|
||||||
|
await open(wes.page, id, L.tabMaterials);
|
||||||
|
const receipts = wes.page.getByRole("group", { name: "Receipts for item 2" });
|
||||||
|
await expect(receipts).toContainText(`heat HS${RUN}-R2`);
|
||||||
|
await expect(receipts).toContainText(L.waitingApproval);
|
||||||
|
await receipts.getByRole("button", { name: "Approve", exact: true }).click();
|
||||||
|
await signWithPassword(wes.page, "sup.wes");
|
||||||
|
await expect(signSheet(wes.page)).toBeHidden();
|
||||||
|
await expect(receipts).toContainText("Approved");
|
||||||
|
await expect(receipts).not.toContainText(L.waitingApproval);
|
||||||
|
p = await packet(wes, id);
|
||||||
|
expect(p.shorts_unapproved).toBe(0);
|
||||||
|
// a second approval of the same receipt is refused
|
||||||
|
await api(wes, "post", approveUrl, { sign: sign("sup.wes") }, { status: 409 });
|
||||||
|
|
||||||
|
// now Q.C. accepts
|
||||||
|
await quinn.page.reload();
|
||||||
|
const acceptBtn = qcBar(quinn.page).getByRole("button", { name: L.accept, exact: true });
|
||||||
|
await expect(acceptBtn).toBeEnabled();
|
||||||
|
await acceptBtn.click();
|
||||||
|
await signWithPin(quinn.page, PIN);
|
||||||
|
await expect(signSheet(quinn.page)).toBeHidden();
|
||||||
|
await expect.poll(() => statusOf(quinn, id)).toBe("complete");
|
||||||
|
await closeAll(quinn, lou, wes);
|
||||||
|
});
|
||||||
|
|
||||||
test("api: the signature and the step body used here still answer as the contract says", async ({ browser }) => {
|
test("api: the signature and the step body used here still answer as the contract says", async ({ browser }) => {
|
||||||
// A guard for the helpers: a reject without a reason or an NCR is refused before any signature is asked.
|
// A guard for the helpers: a reject without a reason is refused before any signature is asked, and an NCR
|
||||||
|
// number does not stand in for the reason (C2).
|
||||||
const quinn: Actor = await actor(browser, "qc.quinn");
|
const quinn: Actor = await actor(browser, "qc.quinn");
|
||||||
const p = await packet(quinn, pk("reject").id);
|
const p = await packet(quinn, pk("reject").id);
|
||||||
expect(p.status).toBe("qc");
|
expect(p.status).toBe("qc");
|
||||||
await stepApi(quinn, p.id, "qc_reject", { sign: sign("qc.quinn") }, 400);
|
await stepApi(quinn, p.id, "qc_reject", { sign: sign("qc.quinn") }, 400);
|
||||||
|
await stepApi(quinn, p.id, "qc_reject", { ncr: NCR, sign: sign("qc.quinn") }, 400);
|
||||||
|
await stepApi(quinn, p.id, "qc_reject", { reason: "Bad", ncr: NCR, sign: sign("qc.quinn") }, 400);
|
||||||
await closeAll(quinn);
|
await closeAll(quinn);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ export const L = {
|
|||||||
rejectSheet: "Reject",
|
rejectSheet: "Reject",
|
||||||
rejectReason: "Reason / what failed",
|
rejectReason: "Reason / what failed",
|
||||||
ncrNumber: "NCR number",
|
ncrNumber: "NCR number",
|
||||||
|
reasonShort: "At least 8 characters.", // C2: under the reason while it is short
|
||||||
|
ncrOfReject: "NCR number of the reject", // C2: the accept's field when ncr_required
|
||||||
|
ncrFirst: "Enter the NCR number of the reject first.", // C2: the 422 text
|
||||||
|
tabMaterials: /^Materials/,
|
||||||
|
waitingApproval: "Waiting for the supervisor's approval.", // C7
|
||||||
rejectSubmit: "Reject to supervisor",
|
rejectSubmit: "Reject to supervisor",
|
||||||
// the Q.C. tab (packet/QcTab.tsx)
|
// the Q.C. tab (packet/QcTab.tsx)
|
||||||
inspector: "Inspector",
|
inspector: "Inspector",
|
||||||
|
|||||||
@@ -121,12 +121,16 @@ test.describe.serial("shop", () => {
|
|||||||
expect(assigned.welder.id).toBe(andyId);
|
expect(assigned.welder.id).toBe(andyId);
|
||||||
expect(assigned.welder.stamp).toBe("A7");
|
expect(assigned.welder.stamp).toBe("A7");
|
||||||
|
|
||||||
// weld.andy on the Stall screen
|
// weld.andy on the Stall screen: no station picker (C11); his packets come from GET /api/stall/mine, each
|
||||||
|
// with the station sup.wes picked
|
||||||
const andy = await actor(browser, "weld.andy");
|
const andy = await actor(browser, "weld.andy");
|
||||||
|
const mineApi = await api(andy, "get", "/api/stall/mine");
|
||||||
|
const mineCard = ((mineApi.mine ?? []) as Json[]).find((c) => Number(c.id) === id);
|
||||||
|
expect(mineCard?.station_name).toBe("Stall 1");
|
||||||
|
const stallMine = andy.page.waitForResponse((r) => r.url().includes("/api/stall/mine"));
|
||||||
await andy.page.goto("/stall");
|
await andy.page.goto("/stall");
|
||||||
const picker = andy.page.getByRole("combobox", { name: L.stallPicker });
|
expect((await stallMine).status()).toBe(200);
|
||||||
if (await picker.count())
|
await expect(andy.page.getByRole("combobox", { name: L.stallPicker })).toHaveCount(0);
|
||||||
await picker.first().selectOption({ label: L.stallOption("Stall 1") });
|
|
||||||
const myHead = andy.page.getByRole("heading", {
|
const myHead = andy.page.getByRole("heading", {
|
||||||
name: new RegExp(`^${L.myPackets}`),
|
name: new RegExp(`^${L.myPackets}`),
|
||||||
});
|
});
|
||||||
@@ -642,3 +646,16 @@ test.describe.serial("shop", () => {
|
|||||||
await closeAll(wes);
|
await closeAll(wes);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("C11: a person with nothing assigned sees that the supervisor assigns packets", async ({ browser }) => {
|
||||||
|
// both.bo (03 worker and supervisor) has no packet assigned to him in these scenarios
|
||||||
|
const bo = await actor(browser, "both.bo");
|
||||||
|
const mine = await api(bo, "get", "/api/stall/mine");
|
||||||
|
expect(mine.mine).toEqual([]);
|
||||||
|
expect(mine.submitted).toEqual([]);
|
||||||
|
expect(String(mine.empty)).toContain("Your supervisor assigns packets to you.");
|
||||||
|
await bo.page.goto("/stall");
|
||||||
|
await expect(bo.page.getByText("Your supervisor assigns packets to you.").first()).toBeVisible();
|
||||||
|
await expect(bo.page.getByRole("combobox", { name: L.stallPicker })).toHaveCount(0);
|
||||||
|
await closeAll(bo);
|
||||||
|
});
|
||||||
|
|||||||
@@ -107,13 +107,15 @@ def set_feed_legs(
|
|||||||
observed: bool = True,
|
observed: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Simulate the feed: the packet becomes a VISUAL packet (source_kind visual, a visual_key), packet_visual_state
|
"""Simulate the feed: the packet becomes a VISUAL packet (source_kind visual, a visual_key), packet_visual_state
|
||||||
holds the legs as the feed writes them ({key: "base/lot/split/sub", seq, st}) and visual_closed_at is set when
|
holds the legs as the feed writes them ({key: "base/lot/split/sub", seq, st}), and when `closed` the feed's
|
||||||
`closed`. `legs` None writes no state row (the feed has not looked yet)."""
|
close is set: visual_closed_at, and vclosed_at with no vclosed_by (C5: the feed is the writer of vclosed on a
|
||||||
|
VISUAL packet). `legs` None writes no state row (the feed has not looked yet)."""
|
||||||
with app_conn(settings) as conn:
|
with app_conn(settings) as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE packets SET source_kind = 'visual', visual_key = coalesce(visual_key, %s),"
|
"UPDATE packets SET source_kind = 'visual', visual_key = coalesce(visual_key, %s),"
|
||||||
" visual_closed_at = CASE WHEN %s THEN now() END WHERE id = %s",
|
" visual_closed_at = CASE WHEN %s THEN now() END, vclosed_at = CASE WHEN %s THEN now() END,"
|
||||||
(f"test-{packet_id}-{secrets.token_hex(3)}", closed, packet_id),
|
" vclosed_by = NULL WHERE id = %s",
|
||||||
|
(f"test-{packet_id}-{secrets.token_hex(3)}", closed, closed, packet_id),
|
||||||
)
|
)
|
||||||
if legs is None:
|
if legs is None:
|
||||||
conn.execute("DELETE FROM packet_visual_state WHERE packet_id = %s", (packet_id,))
|
conn.execute("DELETE FROM packet_visual_state WHERE packet_id = %s", (packet_id,))
|
||||||
@@ -250,7 +252,7 @@ def rich_complete(shop: Shop, *, itp_doc: bool = False, mtrs: bool = True) -> di
|
|||||||
pkt = submit(shop, finish_stage(shop, pkt, welds=2))
|
pkt = submit(shop, finish_stage(shop, pkt, welds=2))
|
||||||
pkt = ok(step(shop("sup.wes"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
|
pkt = ok(step(shop("sup.wes"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
|
||||||
pkt = finish_inspection(shop, pkt)
|
pkt = finish_inspection(shop, pkt)
|
||||||
pkt = ok(accept(shop, pkt, "databook"))
|
pkt = accept_with_open_short(shop, pkt, 3)
|
||||||
add_doc(shop, pkt)
|
add_doc(shop, pkt)
|
||||||
r = make_qc_doc(shop, unit_id=unit_id, type="ITP" if itp_doc else "WPS / PQR", title="Unit folder doc")
|
r = make_qc_doc(shop, unit_id=unit_id, type="ITP" if itp_doc else "WPS / PQR", title="Unit folder doc")
|
||||||
assert r.status_code in (200, 201), r.text
|
assert r.status_code in (200, 201), r.text
|
||||||
@@ -261,6 +263,34 @@ def rich_complete(shop: Shop, *, itp_doc: bool = False, mtrs: bool = True) -> di
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def approve_receipts(shop: Shop, pkt: dict[str, Any], by: str = "sup.wes") -> dict[str, Any]:
|
||||||
|
"""The department supervisor approves every received short not yet approved (C7). Returns the packet read."""
|
||||||
|
for sh in pkt.get("shorts") or []:
|
||||||
|
for r in sh["receipts"]:
|
||||||
|
if r["approval"] is None:
|
||||||
|
url = f"/api/packets/{pkt['id']}/shorts/receipts/{r['id']}/approve"
|
||||||
|
pkt = ok(shop(by).post(url, json={"sign": SIGN}))
|
||||||
|
return pkt
|
||||||
|
|
||||||
|
|
||||||
|
def accept_with_open_short(shop: Shop, pkt: dict[str, Any], item: int) -> dict[str, Any]:
|
||||||
|
"""Accept to the data book while item `item` keeps its open short: a packet completed before change C7, which
|
||||||
|
the data book and the trace CSV still show. The receipts are approved through the API; the open short is closed
|
||||||
|
by SQL for the accept only (C7 refuses an accept with an open short) and opened again after it."""
|
||||||
|
pkt = approve_receipts(shop, pkt)
|
||||||
|
sid = line_of(pkt, item)["short"]["id"]
|
||||||
|
with app_conn(shop.settings) as conn:
|
||||||
|
conn.execute("UPDATE bom_shorts SET closed_at = now() WHERE id = %s", (sid,))
|
||||||
|
pkt = ok(accept(shop, fresh_read(shop, pkt["id"]), "databook"))
|
||||||
|
with app_conn(shop.settings) as conn:
|
||||||
|
conn.execute("UPDATE bom_shorts SET closed_at = NULL WHERE id = %s", (sid,))
|
||||||
|
return fresh_read(shop, pkt["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_read(shop: Shop, pid: int) -> dict[str, Any]:
|
||||||
|
return ok(shop("clerk.clara").get(f"/api/packets/{pid}"))
|
||||||
|
|
||||||
|
|
||||||
def upload_bytes_pdf() -> bytes:
|
def upload_bytes_pdf() -> bytes:
|
||||||
"""A one-page PDF with its own bytes (the store dedupes equal bytes)."""
|
"""A one-page PDF with its own bytes (the store dedupes equal bytes)."""
|
||||||
from qchelpers import unique_pdf
|
from qchelpers import unique_pdf
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ The labor hours are rows written straight into `visual_labor`, as the feed tests
|
|||||||
The PM world (dates are calendar days in the site time zone, America/Denver):
|
The PM world (dates are calendar days in the site time zone, America/Denver):
|
||||||
|
|
||||||
project L670 "Pump skids" (Acme Gas, PO-9, due today + 20), alias OLD670
|
project L670 "Pump skids" (Acme Gas, PO-9, due today + 20), alias OLD670
|
||||||
unit 001 "Skid A" due yesterday -> late k_run (in progress, timer 6 h, ticket 3.2 h, target 4.5 h),
|
unit 001 "Skid A" due yesterday -> late k_run (in progress, timer 6 h, ticket 5.2 h, target 4.5 h),
|
||||||
k_short (warehouse pulling, an open short with a past ETA)
|
k_short (warehouse pulling, an open short with a past ETA)
|
||||||
unit 002 "Skid B" due today + 4 -> risk k_rev (review after a Q.C. reject), k_qc (at Q.C.)
|
unit 002 "Skid B" due today + 4 -> risk k_rev (review after a Q.C. reject), k_qc (at Q.C.)
|
||||||
unit 003 "Skid C" due today + 30 -> risk k_hold (warehouse hold for 5 days), k_inc (warehouse waiting)
|
unit 003 "Skid C" due today + 30 -> risk k_hold (warehouse hold for 5 days), k_inc (warehouse waiting)
|
||||||
@@ -101,13 +101,21 @@ def kpi_labels(period: str = "90") -> tuple[str, ...]:
|
|||||||
"Open change orders",
|
"Open change orders",
|
||||||
"Open shorts",
|
"Open shorts",
|
||||||
f"Q.C. rejects · {lower}",
|
f"Q.C. rejects · {lower}",
|
||||||
"Packets over target hours",
|
"Packets over target hours (tickets)",
|
||||||
f"Filed · {lower}",
|
f"Filed · {lower}",
|
||||||
"Average lead time, created → filed",
|
"Average lead time, created → filed",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
HOURS_TAIL = ["Packets", "Timer h", "Ticket h", "Target h", "Variance h", "Timer / target", "Over target"]
|
HOURS_TAIL = [
|
||||||
|
"Packets",
|
||||||
|
"Timer h",
|
||||||
|
"Ticket h",
|
||||||
|
"Target h",
|
||||||
|
"Ticket variance h",
|
||||||
|
"Ticket h / target",
|
||||||
|
"Over target (tickets)",
|
||||||
|
]
|
||||||
HEADS: dict[str, list[str]] = {
|
HEADS: dict[str, list[str]] = {
|
||||||
"projects": [
|
"projects": [
|
||||||
"Project / unit",
|
"Project / unit",
|
||||||
@@ -131,7 +139,7 @@ HEADS: dict[str, list[str]] = {
|
|||||||
"hours_dept": ["Department", *HOURS_TAIL],
|
"hours_dept": ["Department", *HOURS_TAIL],
|
||||||
"hours_worker": ["Worker", *HOURS_TAIL],
|
"hours_worker": ["Worker", *HOURS_TAIL],
|
||||||
"hours_project": ["Project", *HOURS_TAIL],
|
"hours_project": ["Project", *HOURS_TAIL],
|
||||||
"hours_over": ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
|
"hours_over": ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by (tickets)"],
|
||||||
"quality_rejects": ["Job", "Date", "Inspector", "Reason / NCR", "Station / worker"],
|
"quality_rejects": ["Job", "Date", "Inspector", "Reason / NCR", "Station / worker"],
|
||||||
"quality_shorts": ["Job", "Item", "Short qty", "Reason", "PO / req", "ETA", "Age", "Packet is"],
|
"quality_shorts": ["Job", "Item", "Short qty", "Reason", "PO / req", "ETA", "Age", "Packet is"],
|
||||||
"quality_cos": ["Job", "Change order", "Change", "Waiting on", "Open for"],
|
"quality_cos": ["Job", "Change order", "Change", "Waiting on", "Open for"],
|
||||||
@@ -190,8 +198,8 @@ NOTES = {
|
|||||||
"aging_steps": "How long packets have been sitting where they are right now.",
|
"aging_steps": "How long packets have been sitting where they are right now.",
|
||||||
"aging_open": "Over 3 days in one step is flagged.",
|
"aging_open": "Over 3 days in one step is flagged.",
|
||||||
"aging_history": "From the step history recorded on every move.",
|
"aging_history": "From the step history recorded on every move.",
|
||||||
"hours_over": "Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the"
|
"hours_over": "VISUAL ticket hours against the work-order target hours decide over target. The station timer is"
|
||||||
" clock of record.",
|
" shown beside them. A packet with no ticket hours is not over target.",
|
||||||
}
|
}
|
||||||
|
|
||||||
TAB_TABLES = {
|
TAB_TABLES = {
|
||||||
@@ -509,7 +517,7 @@ def pm_world(shop: Shop) -> PmWorld:
|
|||||||
u = w.units
|
u = w.units
|
||||||
wade = shop("wh.wade")
|
wade = shop("wh.wade")
|
||||||
|
|
||||||
# unit 001, late: a running packet over target with ticket hours, a short with a past ETA
|
# unit 001, late: a running packet over target on its ticket hours, a short with a past ETA
|
||||||
run_pkt = start(shop, to_station(shop, ready_skipped(shop, u[1], title="Running spool")))
|
run_pkt = start(shop, to_station(shop, ready_skipped(shop, u[1], title="Running spool")))
|
||||||
w.k["k_run"] = run_pkt["id"]
|
w.k["k_run"] = run_pkt["id"]
|
||||||
run(
|
run(
|
||||||
@@ -518,7 +526,7 @@ def pm_world(shop: Shop) -> PmWorld:
|
|||||||
(4 * 3600 * 1000, run_pkt["id"]),
|
(4 * 3600 * 1000, run_pkt["id"]),
|
||||||
)
|
)
|
||||||
set_running_since(s, run_pkt["id"], 2 * 3600)
|
set_running_since(s, run_pkt["id"], 2 * 3600)
|
||||||
labor_rows(s, run_pkt["id"], [("L670001", 10, "1.5"), ("L670002", 20, "1.7")])
|
labor_rows(s, run_pkt["id"], [("L670001", 10, "3.0"), ("L670002", 20, "2.2")])
|
||||||
|
|
||||||
short = to_warehouse(shop, draft(shop, u[1], title="Short spool"))
|
short = to_warehouse(shop, draft(shop, u[1], title="Short spool"))
|
||||||
yesterday = (today - timedelta(days=1)).isoformat()
|
yesterday = (today - timedelta(days=1)).isoformat()
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ def endpoints(andy: str, ck_version: int) -> list[tuple[str, str, dict[str, Any]
|
|||||||
("GET", "/api/admin/access-log", {}, 200),
|
("GET", "/api/admin/access-log", {}, 200),
|
||||||
("GET", "/api/admin/access-log.csv", {}, 200),
|
("GET", "/api/admin/access-log.csv", {}, 200),
|
||||||
("GET", "/api/admin/presence", {}, 200),
|
("GET", "/api/admin/presence", {}, 200),
|
||||||
("POST", "/api/admin/stamp-verify", {"json": {"text": "0123456789abcdef"}}, 200),
|
|
||||||
("POST", "/api/admin/stamp-verify/pdf", {"files": {"file": ("a.pdf", tiny_pdf(), "application/pdf")}}, 200),
|
|
||||||
("GET", "/api/admin/directory-flags", {}, 200),
|
("GET", "/api/admin/directory-flags", {}, 200),
|
||||||
("POST", "/api/admin/ad/refresh", {}, 202),
|
("POST", "/api/admin/ad/refresh", {}, 202),
|
||||||
("GET", "/api/admin/ad/reconcile", {}, 200),
|
("GET", "/api/admin/ad/reconcile", {}, 200),
|
||||||
@@ -123,3 +121,38 @@ def test_every_new_admin_endpoint_as_every_role(
|
|||||||
assert n == len(OTHER_ROLES) * len(eps)
|
assert n == len(OTHER_ROLES) * len(eps)
|
||||||
details = conn.execute("SELECT detail FROM audit_events WHERE action = 'policy.denied' LIMIT 1").fetchone()[0]
|
details = conn.execute("SELECT detail FROM audit_events WHERE action = 'policy.denied' LIMIT 1").fetchone()[0]
|
||||||
assert set(details) == {"method", "path", "code"}
|
assert set(details) == {"method", "path", "code"}
|
||||||
|
|
||||||
|
|
||||||
|
def stamp_endpoints() -> list[tuple[str, str, dict[str, Any]]]:
|
||||||
|
out = []
|
||||||
|
for base in ("/api/stamp-verify", "/api/admin/stamp-verify"):
|
||||||
|
out.append(("POST", base, {"json": {"text": "0123456789abcdef"}}))
|
||||||
|
out.append(("POST", f"{base}/pdf", {"files": {"file": ("a.pdf", tiny_pdf(), "application/pdf")}}))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_stamp_check_roles(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
|
||||||
|
"""Change C9: the stamp check is open to Admin, Q.C. and every Clerical level, at both paths, on a password
|
||||||
|
session. Every other role gets 403."""
|
||||||
|
eps = stamp_endpoints()
|
||||||
|
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as anon:
|
||||||
|
for method, path, kw in eps:
|
||||||
|
assert call(anon, method, path, kw).status_code == 401, path
|
||||||
|
for sam in ("admin.ann", "qc.quinn", "clerk.clara", "clerk.leo"):
|
||||||
|
assert sign_in(pg_client, sam).status_code == 200, sam
|
||||||
|
for method, path, kw in eps:
|
||||||
|
r = call(pg_client, method, path, kw)
|
||||||
|
assert r.status_code == 200, (sam, path, r.status_code, r.text)
|
||||||
|
pg_client.post("/api/auth/signout")
|
||||||
|
for sam in ("wh.wade", "pm.paula", "weld.andy", "lead.lou", "sup.wes"):
|
||||||
|
assert sign_in(pg_client, sam).status_code == 200, sam
|
||||||
|
for method, path, kw in eps:
|
||||||
|
r = call(pg_client, method, path, kw)
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden", (sam, path, r.status_code)
|
||||||
|
pg_client.post("/api/auth/signout")
|
||||||
|
# A PIN session: the password session rule stays
|
||||||
|
register_device(pg_client)
|
||||||
|
set_pin(pg_client, "admin.ann")
|
||||||
|
assert pin_signin(pg_client, "1001").status_code == 200
|
||||||
|
r = call(pg_client, eps[0][0], eps[0][1], eps[0][2])
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "password_session_required", r.text
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Change C1 (Gitea issue 1): "over target" is decided by the VISUAL ticket hours against the work-order target, not
|
||||||
|
by the station timer. A packet with no ticket hours is not over target. No database: a stub report feeds the PM
|
||||||
|
hours tables."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from packetstation.domain import pm
|
||||||
|
|
||||||
|
|
||||||
|
class StubReport:
|
||||||
|
def __init__(self, hours: dict[int, dict[str, float]]) -> None:
|
||||||
|
self._h = hours
|
||||||
|
self.cards = [
|
||||||
|
{"id": pid, "status": "station", "dept": {"code": "WP"}, "unit": {"number": f"L1-00{pid}"}, "op": "10"}
|
||||||
|
for pid in hours
|
||||||
|
]
|
||||||
|
|
||||||
|
def hours(self) -> dict[int, dict[str, float]]:
|
||||||
|
return self._h
|
||||||
|
|
||||||
|
def dept_short(self, code: str) -> str:
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def h(timer: float, ticket: float, target: float) -> dict[str, float]:
|
||||||
|
return {"timer_h": timer, "ticket_h": ticket, "target_h": target}
|
||||||
|
|
||||||
|
|
||||||
|
WORLD = {
|
||||||
|
1: h(9.0, 0.0, 4.0), # timer over, no tickets: not over
|
||||||
|
2: h(1.0, 5.5, 4.0), # tickets over, timer under: over
|
||||||
|
3: h(9.0, 3.0, 4.0), # timer over, tickets under: not over
|
||||||
|
4: h(0.0, 2.0, 0.0), # no target: never over
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tables() -> dict[str, dict[str, Any]]:
|
||||||
|
rep: Any = StubReport(WORLD)
|
||||||
|
return {t["key"]: t for t in pm.hours_tables(rep)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_over_reads_the_tickets() -> None:
|
||||||
|
assert [pid for pid, x in WORLD.items() if pm.is_over(x)] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_over_table_lists_ticket_overs_only() -> None:
|
||||||
|
over = tables()["hours_over"]
|
||||||
|
assert over["head"][-1] == "Over by (tickets)"
|
||||||
|
assert [r["pid"] for r in over["rows"]] == [2]
|
||||||
|
assert over["rows"][0]["cells"][-1] == "+1.5 h"
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_counts_and_heads() -> None:
|
||||||
|
dept = tables()["hours_dept"]
|
||||||
|
assert dept["head"][-3:] == ["Ticket variance h", "Ticket h / target", "Over target (tickets)"]
|
||||||
|
cells = dept["rows"][0]["cells"]
|
||||||
|
assert cells[-1] == "1" # one packet over on its tickets
|
||||||
|
# ticket 8.5 against target 12: under, so the group is not flagged and the ratio is the tickets'
|
||||||
|
assert cells[-2] == "71%" and not dept["rows"][0]["flag"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_over_target_count() -> None:
|
||||||
|
rep: Any = StubReport(WORLD)
|
||||||
|
assert pm.over_target_count(rep) == 1
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Change C2 (Gitea issue 24): a Q.C. reject always needs a detailed reason, and a packet rejected without an NCR
|
||||||
|
number cannot be accepted until the NCR number is given with the accept (QC_ACCEPT and QC_ACCEPT_AND_ROUTE).
|
||||||
|
|
||||||
|
The packet read says `ncr_required`; can() does not block on it (the NCR comes with the step); the server refuses
|
||||||
|
the step without it, 422 ncr_required. The accept stores the NCR on its inspection and in the log."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fakedir import FakeDirectory
|
||||||
|
from pkthelpers import step
|
||||||
|
from pshelpers import needs_pg
|
||||||
|
from qchelpers import NCR, accept, at_qc, finish_inspection, reject, to_qc
|
||||||
|
from shophelpers import SIGN, Shop, fresh, last_log, ok, one, open_shop, refused
|
||||||
|
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
REASON = "Weld 2 undercut too deep"
|
||||||
|
MSG = "Enter the NCR number of the reject first."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||||
|
yield from open_shop(pg_settings, fake_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_after_a_reject_without_ncr_needs_one(shop: Shop) -> None:
|
||||||
|
pkt = at_qc(shop)
|
||||||
|
assert pkt["ncr_required"] is False
|
||||||
|
out = ok(reject(shop, pkt, REASON))
|
||||||
|
assert out["ncr_required"] is True and out["qc_last"]["ncr"] is None
|
||||||
|
pkt = finish_inspection(shop, to_qc(shop, fresh(shop("sup.wes"), pkt["id"])))
|
||||||
|
assert pkt["ncr_required"] is True
|
||||||
|
# can() does not block on it: the NCR is input with the step
|
||||||
|
assert {"qc_accept", "qc_accept_and_route"} <= set(pkt["can"]) and "qc_accept" not in pkt["blocked"]
|
||||||
|
refused(accept(shop, pkt), 422, "ncr_required", MSG)
|
||||||
|
refused(step(shop("qc.quinn"), pkt, "qc_accept_and_route", dept="09", sign=SIGN), 422, "ncr_required", MSG)
|
||||||
|
refused(step(shop("qc.quinn"), pkt, "qc_accept", routed_to="databook", ncr=" ", sign=SIGN), 422, "ncr_required")
|
||||||
|
refused(
|
||||||
|
step(shop("qc.quinn"), pkt, "qc_accept", routed_to="databook", ncr="NCR-26-1", sign=SIGN), 422, "invalid_ncr"
|
||||||
|
)
|
||||||
|
assert fresh(shop("qc.quinn"), pkt["id"])["status"] == "qc"
|
||||||
|
out = ok(step(shop("qc.quinn"), pkt, "qc_accept", routed_to="databook", ncr=" ncr-2026-0021 ", sign=SIGN))
|
||||||
|
assert out["status"] == "complete" and out["ncr_required"] is False
|
||||||
|
assert out["qc_last"]["result"] == "accepted" and out["qc_last"]["ncr"] == NCR
|
||||||
|
row = one(
|
||||||
|
shop.settings,
|
||||||
|
"SELECT ncr, result FROM qc_inspections WHERE packet_id = %s ORDER BY id DESC LIMIT 1",
|
||||||
|
(pkt["id"],),
|
||||||
|
)
|
||||||
|
assert tuple(row) == (NCR, "accepted")
|
||||||
|
log = last_log(shop("qc.quinn"), pkt["id"])
|
||||||
|
assert log["action"] == "Q.C. accepted → Clerical – Data Book"
|
||||||
|
assert log["note"] == f"Quinn QC [Q1] · 2 welds VT accepted · {NCR}", log
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_and_route_carries_the_ncr(shop: Shop) -> None:
|
||||||
|
pkt = at_qc(shop)
|
||||||
|
ok(reject(shop, pkt, REASON))
|
||||||
|
pkt = finish_inspection(shop, to_qc(shop, fresh(shop("sup.wes"), pkt["id"])))
|
||||||
|
out = ok(step(shop("qc.quinn"), pkt, "qc_accept_and_route", dept="09", ncr=NCR, sign=SIGN))
|
||||||
|
assert out["status"] == "ready" and out["ncr_required"] is False and out["qc_last"]["ncr"] == NCR
|
||||||
|
log = last_log(shop("qc.quinn"), pkt["id"])
|
||||||
|
assert log["note"] == f"Quinn QC [Q1] · from 03 Weld Parts · {NCR}", log
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reject_with_its_ncr_needs_none_at_accept(shop: Shop) -> None:
|
||||||
|
pkt = at_qc(shop)
|
||||||
|
out = ok(reject(shop, pkt, REASON, ncr=NCR))
|
||||||
|
assert out["ncr_required"] is False and out["return_note"] == f"{NCR} · {REASON}"
|
||||||
|
pkt = finish_inspection(shop, to_qc(shop, fresh(shop("sup.wes"), pkt["id"])))
|
||||||
|
out = ok(accept(shop, pkt))
|
||||||
|
assert out["status"] == "complete" and out["qc_last"]["ncr"] is None
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Change C3 (Gitea issue 25): FILE is refused while the packet's unit has an ITP that is not complete. A unit with
|
||||||
|
no ITP does not block. The reason shows in `blocked.file` so the close panel says why."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from closehelpers import at_complete, file_packet, vclose
|
||||||
|
from fakedir import FakeDirectory
|
||||||
|
from pshelpers import needs_pg
|
||||||
|
from qchelpers import start_itp
|
||||||
|
from shophelpers import Shop, fresh, make_unit, ok, open_shop, refused, run
|
||||||
|
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
MSG = "Complete the unit's ITP first. Q.C. signs it off on the ITP page."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||||
|
yield from open_shop(pg_settings, fake_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_itp_blocks_the_file_until_signed_off(shop: Shop) -> None:
|
||||||
|
_, unit_id = make_unit(shop.settings)
|
||||||
|
pkt = vclose(shop, at_complete(shop, unit_id))
|
||||||
|
pid = pkt["id"]
|
||||||
|
assert "file" in pkt["can"] # no ITP on the unit: nothing blocks
|
||||||
|
itp = start_itp(shop, unit_id)
|
||||||
|
view = fresh(shop("clerk.clara"), pid)
|
||||||
|
assert "file" not in view["can"] and view["blocked"]["file"] == [MSG]
|
||||||
|
[card] = ok(shop("clerk.clara").get(f"/api/packets?unit_id={unit_id}"))["packets"]
|
||||||
|
assert card["blocked"]["file"] == [MSG]
|
||||||
|
refused(file_packet(shop, view), 403, "precondition", MSG)
|
||||||
|
# the ITP sign-off itself is tested in test_itp.py; here its end state is enough
|
||||||
|
run(shop.settings, "UPDATE itps SET status = 'complete', signoff_at = now() WHERE id = %s", (itp["id"],))
|
||||||
|
view = fresh(shop("clerk.clara"), pid)
|
||||||
|
assert "file" in view["can"] and "file" not in view["blocked"]
|
||||||
|
assert ok(file_packet(shop, view))["status"] == "filed"
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Change C5 (Gitea issue 31): filing is blocked, not warned, while VISUAL still shows a work order open, on both
|
||||||
|
routes. On a VISUAL packet the feed is the one writer of `vclosed`: it sets it when it sees every work order closed
|
||||||
|
(the Clerical board then shows the packet under "Ready for data book") and clears it when one shows open again.
|
||||||
|
|
||||||
|
The feed's observe step runs here on legs written by SQL. Nothing here reads VISUAL or M-Files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator, Mapping
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from closehelpers import at_complete, file_packet, leg, q1, set_feed_legs
|
||||||
|
from fakedir import FakeDirectory
|
||||||
|
from pshelpers import app_conn, needs_pg
|
||||||
|
from shophelpers import Shop, find_log, fresh, ok, open_shop, refused
|
||||||
|
|
||||||
|
from packetstation.feed import apply
|
||||||
|
from packetstation.feed.types import WoKey
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
W1 = WoKey("L670001", "001", "0", "0")
|
||||||
|
W2 = WoKey("L670002", "001", "0", "0")
|
||||||
|
OPEN_W2 = "VISUAL still shows work orders open on this packet: L670002/001. Filing waits until VISUAL closes them."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||||
|
yield from open_shop(pg_settings, fake_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def observe(settings: Settings, seen: Mapping[WoKey, str]) -> dict[str, Any]:
|
||||||
|
"""The feed's observe step for the given leg statuses. Returns the run's counts."""
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
run = apply._Run(conn, None, None, None, date.today())
|
||||||
|
apply._observe(run, seen, None)
|
||||||
|
return dict(run.counts)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("route", ["databook", "shipping"])
|
||||||
|
def test_feed_closes_and_opens_a_visual_packet(shop: Shop, route: str) -> None:
|
||||||
|
pkt = at_complete(shop, route=route)
|
||||||
|
pid = pkt["id"]
|
||||||
|
set_feed_legs(shop.settings, pid, [leg("L670001", st="R"), leg("L670002", st="R")])
|
||||||
|
c = shop("clerk.clara")
|
||||||
|
|
||||||
|
# one leg closed: still open in VISUAL, nothing set
|
||||||
|
counts = observe(shop.settings, {W1: "C", W2: "R"})
|
||||||
|
assert counts["closed_observed"] == 0 and counts["errors"] == 0, counts
|
||||||
|
view = fresh(c, pid)
|
||||||
|
assert view["vclosed"] is None and view["blocked"]["file"] == [OPEN_W2]
|
||||||
|
|
||||||
|
# every leg closed: the feed sets vclosed (no person) and the file opens
|
||||||
|
counts = observe(shop.settings, {W1: "C", W2: "C"})
|
||||||
|
assert counts["closed_observed"] == 1 and counts["errors"] == 0, counts
|
||||||
|
view = fresh(c, pid)
|
||||||
|
assert view["vclosed"]["at"] is not None and view["vclosed"]["by"] is None
|
||||||
|
assert view["vclose_check"]["closed_at"] is not None and view["vclose_check"]["agrees"] is True
|
||||||
|
assert "file" in view["can"]
|
||||||
|
assert find_log(c, pid, "Closed in VISUAL (observed)")
|
||||||
|
assert observe(shop.settings, {W1: "C", W2: "C"})["closed_observed"] == 0 # nothing changed: no write
|
||||||
|
|
||||||
|
# a leg open again: the feed clears it and the file waits
|
||||||
|
observe(shop.settings, {W1: "C", W2: "R"})
|
||||||
|
view = fresh(c, pid)
|
||||||
|
assert view["vclosed"] is None and view["vclose_check"]["closed_at"] is None
|
||||||
|
assert view["blocked"]["file"] == [OPEN_W2]
|
||||||
|
assert find_log(c, pid, "Open again in VISUAL (observed)")["note"] == "L670002/001"
|
||||||
|
refused(file_packet(shop, view), 403, "precondition", OPEN_W2)
|
||||||
|
|
||||||
|
# closed and cancelled count as done
|
||||||
|
assert observe(shop.settings, {W1: "C", W2: "X"})["closed_observed"] == 1
|
||||||
|
out = ok(file_packet(shop, fresh(c, pid)))
|
||||||
|
assert out["status"] == "filed" and out["vclosed"]["by"] is None
|
||||||
|
# the feed leaves a filed packet alone
|
||||||
|
observe(shop.settings, {W1: "R", W2: "R"})
|
||||||
|
assert q1(shop.settings, "SELECT vclosed_at IS NOT NULL FROM packets WHERE id = %s", (pid,))[0] is True
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Change C7 (Gitea issue 18): every received short needs the department supervisor's approval, one receipt at a time.
|
||||||
|
|
||||||
|
SHORT_APPROVE through POST /api/packets/{id}/shorts/receipts/{receipt_id}/approve: who (the supervisor of the
|
||||||
|
packet's department, Admin with an IT reason), the signature, one approval per receipt (409), the read model (who
|
||||||
|
received and who approved each receipt, shorts_unapproved, can), and the shorts_approved precondition on QC_ACCEPT
|
||||||
|
and FILE (not on QC_ACCEPT_AND_ROUTE). Nothing here reads VISUAL or M-Files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from closehelpers import at_complete, file_packet, q1, vclose
|
||||||
|
from fakedir import FakeDirectory
|
||||||
|
from pshelpers import app_conn, needs_pg
|
||||||
|
from qchelpers import accept, accept_route, finish_inspection, to_qc
|
||||||
|
from shophelpers import (
|
||||||
|
IT,
|
||||||
|
SIGN,
|
||||||
|
Shop,
|
||||||
|
draft,
|
||||||
|
find_log,
|
||||||
|
finish_stage,
|
||||||
|
fresh,
|
||||||
|
im,
|
||||||
|
line_of,
|
||||||
|
ok,
|
||||||
|
open_shop,
|
||||||
|
refused,
|
||||||
|
release,
|
||||||
|
start,
|
||||||
|
submit,
|
||||||
|
to_station,
|
||||||
|
to_warehouse,
|
||||||
|
)
|
||||||
|
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
MSG = "Every short must be received and approved by the supervisor first."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||||
|
yield from open_shop(pg_settings, fake_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def approve(shop: Shop, pkt: dict[str, Any], receipt_id: int, by: str = "sup.wes", **body: Any) -> Any:
|
||||||
|
body.setdefault("sign", SIGN)
|
||||||
|
return shop(by).post(f"/api/packets/{pkt['id']}/shorts/receipts/{receipt_id}/approve", json=body)
|
||||||
|
|
||||||
|
|
||||||
|
def receive(shop: Shop, pkt: dict[str, Any], item: int, qty: int, heat: str, by: str = "wh.wade") -> dict[str, Any]:
|
||||||
|
return ok(shop(by).post(f"/api/packets/{pkt['id']}/bom/{item}/short/receipts", json={"qty": qty, "heat": heat}))
|
||||||
|
|
||||||
|
|
||||||
|
def short_at_qc(shop: Shop) -> dict[str, Any]:
|
||||||
|
"""Items 1 and 2 pulled; item 3 short 2 of 4, one piece received (HT-31), one still open; through the shop
|
||||||
|
and at Q.C. with the inspection finished."""
|
||||||
|
pkt = to_warehouse(shop, draft(shop))
|
||||||
|
wade = shop("wh.wade")
|
||||||
|
pid = pkt["id"]
|
||||||
|
for item in (1, 2):
|
||||||
|
cur = line_of(pkt, item)
|
||||||
|
pkt = ok(wade.patch(f"/api/packets/{pid}/bom/{item}", json={"pulled": True}, headers=im(cur["row_version"])))
|
||||||
|
cur = line_of(pkt, item)
|
||||||
|
pkt = ok(wade.patch(f"/api/packets/{pid}/bom/{item}", json={"heat": "HT-1"}, headers=im(cur["row_version"])))
|
||||||
|
pkt = ok(wade.post(f"/api/packets/{pid}/bom/3/short", json={"qty": 2, "reason": "On order", "expect_open": False}))
|
||||||
|
pkt = receive(shop, pkt, 3, 1, "HT-31")
|
||||||
|
pkt = start(shop, to_station(shop, release(shop, pkt)))
|
||||||
|
pkt = submit(shop, finish_stage(shop, pkt, welds=1))
|
||||||
|
return finish_inspection(shop, to_qc(shop, pkt))
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_receipts_then_accept(shop: Shop) -> None:
|
||||||
|
pkt = short_at_qc(shop)
|
||||||
|
pid = pkt["id"]
|
||||||
|
assert pkt["shorts_unapproved"] == 2 # one open short and one receipt without approval
|
||||||
|
assert pkt["blocked"]["qc_accept"] == [MSG]
|
||||||
|
assert "qc_accept_and_route" in pkt["can"] and "qc_accept_and_route" not in pkt["blocked"]
|
||||||
|
refused(accept(shop, pkt), 403, "precondition", MSG)
|
||||||
|
|
||||||
|
[short] = pkt["shorts"]
|
||||||
|
assert (short["item"], float(short["qty"]), float(short["qty_open"]), short["closed_at"]) == (3, 2, 1, None)
|
||||||
|
[r1] = short["receipts"]
|
||||||
|
assert float(r1["qty"]) == 1 and r1["heat"] == "HT-31" and r1["by"]["display_name"] and r1["approval"] is None
|
||||||
|
assert line_of(pkt, 3)["short"]["receipts"][0]["approval"] is None
|
||||||
|
|
||||||
|
# who: the supervisor of the packet's department; a lead, Q.C., the warehouse and Clerical are refused
|
||||||
|
wes = fresh(shop("sup.wes"), pid)
|
||||||
|
assert "short_approve" in wes["can"]
|
||||||
|
for sam in ("lead.lou", "weld.andy", "qc.quinn", "wh.wade", "clerk.leo"):
|
||||||
|
assert "short_approve" not in fresh(shop(sam), pid)["can"], sam
|
||||||
|
refused(approve(shop, pkt, r1["id"], by=sam), 403, "forbidden", "Your access doesn't allow this step.")
|
||||||
|
refused(approve(shop, pkt, r1["id"], by="admin.ann"), 400, "it_reason_required")
|
||||||
|
# a signature is required; an unknown receipt is 404
|
||||||
|
refused(approve(shop, pkt, r1["id"], sign=None), 400, "signature_required")
|
||||||
|
refused(approve(shop, pkt, r1["id"] + 100000), 404, "not_found")
|
||||||
|
|
||||||
|
out = ok(approve(shop, pkt, r1["id"], note="Heat matches the issue ticket"))
|
||||||
|
a = out["shorts"][0]["receipts"][0]["approval"]
|
||||||
|
assert a["by"]["id"] == str(shop.uid("sup.wes")) and a["by"]["display_name"], a
|
||||||
|
assert a["note"] == "Heat matches the issue ticket" and a["at"] and a["dept_code"] == "03"
|
||||||
|
assert line_of(out, 3)["short"]["receipts"][0]["approval"]["note"] == "Heat matches the issue ticket"
|
||||||
|
assert out["shorts_unapproved"] == 1 # the short is still open
|
||||||
|
log = find_log(shop("sup.wes"), pid, "Short approved · item 3")
|
||||||
|
assert log["note"] == "1 pc · heat HT-31 · Heat matches the issue ticket" and log["signed"] is True
|
||||||
|
row = q1(shop.settings, "SELECT dept_code, signature_id FROM short_approvals WHERE receipt_id = %s", (r1["id"],))
|
||||||
|
assert row[0] == "03" and row[1] is not None
|
||||||
|
# one approval per receipt
|
||||||
|
refused(approve(shop, pkt, r1["id"]), 409, "already_approved", "This receipt is already approved.")
|
||||||
|
|
||||||
|
# the warehouse receives the last piece: the short closes, the new receipt waits for approval
|
||||||
|
out = receive(shop, out, 3, 1, "HT-32")
|
||||||
|
assert out["shorts"][0]["closed_at"] is not None and line_of(out, 3)["short"] is None
|
||||||
|
assert out["shorts_unapproved"] == 1 and fresh(shop("qc.quinn"), pid)["blocked"]["qc_accept"] == [MSG]
|
||||||
|
r2 = out["shorts"][0]["receipts"][1]
|
||||||
|
assert r2["heat"] == "HT-32" and r2["approval"] is None
|
||||||
|
# Admin approves through the bypass with an IT reason
|
||||||
|
out = ok(approve(shop, out, r2["id"], by="admin.ann", it_reason=IT))
|
||||||
|
assert out["shorts_unapproved"] == 0 and "qc_accept" not in fresh(shop("qc.quinn"), pid)["blocked"]
|
||||||
|
done = ok(accept(shop, fresh(shop("qc.quinn"), pid)))
|
||||||
|
assert done["status"] == "complete"
|
||||||
|
# an approval needs no open step: still open to the supervisor at complete, but nothing is left to approve
|
||||||
|
refused(approve(shop, done, r2["id"]), 409, "already_approved")
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_and_route_does_not_wait_for_the_shorts(shop: Shop) -> None:
|
||||||
|
pkt = short_at_qc(shop)
|
||||||
|
out = ok(accept_route(shop, pkt, "09"))
|
||||||
|
assert out["status"] == "ready" and out["dept"]["code"] == "09" and out["shorts_unapproved"] == 2
|
||||||
|
# the packet's department is now 09: the 03 supervisor no longer approves, a 09 supervisor would
|
||||||
|
r1 = out["shorts"][0]["receipts"][0]
|
||||||
|
refused(approve(shop, out, r1["id"]), 403, "forbidden")
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_waits_for_the_approval_and_the_filed_lock(shop: Shop) -> None:
|
||||||
|
"""A packet completed before C7 with a receipt nobody approved: FILE shows and refuses it until approved."""
|
||||||
|
pkt = at_complete(shop)
|
||||||
|
pid = pkt["id"]
|
||||||
|
with app_conn(shop.settings) as conn:
|
||||||
|
line = conn.execute("SELECT id FROM bom_lines WHERE packet_id = %s AND item = 3", (pid,)).fetchone()[0]
|
||||||
|
sid = conn.execute(
|
||||||
|
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open, reason, closed_at)"
|
||||||
|
" VALUES (%s, %s, 1, 0, 'On order', now()) RETURNING id",
|
||||||
|
(pid, line),
|
||||||
|
).fetchone()[0]
|
||||||
|
rid = conn.execute(
|
||||||
|
"INSERT INTO short_receipts (short_id, packet_id, qty, heat) VALUES (%s, %s, 1, 'HT-77') RETURNING id",
|
||||||
|
(sid, pid),
|
||||||
|
).fetchone()[0]
|
||||||
|
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
|
||||||
|
assert pkt["shorts_unapproved"] == 1 and pkt["blocked"]["file"] == [MSG]
|
||||||
|
refused(file_packet(shop, pkt), 403, "precondition", MSG)
|
||||||
|
[card] = ok(shop("clerk.clara").get(f"/api/packets?unit_id={pkt['unit']['id']}"))["packets"]
|
||||||
|
assert card["shorts_unapproved"] == 1 and card["blocked"]["file"] == [MSG]
|
||||||
|
ok(approve(shop, pkt, rid))
|
||||||
|
out = ok(file_packet(shop, fresh(shop("clerk.clara"), pid)))
|
||||||
|
assert out["status"] == "filed"
|
||||||
|
# filed: nobody approves any more
|
||||||
|
refused(approve(shop, out, rid, by="admin.ann", it_reason=IT), 409, "already_approved")
|
||||||
|
with app_conn(shop.settings) as conn:
|
||||||
|
rid2 = conn.execute(
|
||||||
|
"INSERT INTO short_receipts (short_id, packet_id, qty, heat) VALUES (%s, %s, 1, 'HT-78') RETURNING id",
|
||||||
|
(sid, pid),
|
||||||
|
).fetchone()[0]
|
||||||
|
refused(approve(shop, out, rid2), 403, "filed")
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Change C8 (Gitea issue 23): checklists per department.
|
||||||
|
|
||||||
|
The department supervisor edits their own department's list, Admin edits any, a lead, a worker and Q.C. do not. A
|
||||||
|
save carries the version (`base` or If-Match) and is audited. A stage freezes its list at START, so a change reaches
|
||||||
|
only stages not yet started. A department without its own list uses the default for its kind. Nothing here reads
|
||||||
|
VISUAL or M-Files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fakedir import FakeDirectory
|
||||||
|
from shophelpers import (
|
||||||
|
PLAIN_KEYS,
|
||||||
|
WELD_KEYS,
|
||||||
|
Shop,
|
||||||
|
add_weld,
|
||||||
|
fresh,
|
||||||
|
ok,
|
||||||
|
one,
|
||||||
|
open_shop,
|
||||||
|
ready_packet,
|
||||||
|
refused,
|
||||||
|
set_filler,
|
||||||
|
start,
|
||||||
|
tick_all,
|
||||||
|
to_station,
|
||||||
|
)
|
||||||
|
|
||||||
|
from packetstation.domain import checklists
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
URL = "/api/checklists"
|
||||||
|
NEW_LIST = [
|
||||||
|
{"group": "Before welding", "key": "dwg", "text": "Drawing {drawing} Rev {rev} on the bench"},
|
||||||
|
{"group": "Before welding", "key": "purge", "text": "Back purge set"},
|
||||||
|
{"group": "After welding", "key": "vt", "text": "Visual done"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||||
|
yield from open_shop(pg_settings, fake_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def keys(pkt: dict[str, Any]) -> list[str]:
|
||||||
|
return [i["key"] for i in pkt["checklist_items"]]
|
||||||
|
|
||||||
|
|
||||||
|
def put(shop: Shop, sam: str, dept: str, items: Any, base: int | None) -> Any:
|
||||||
|
body: dict[str, Any] = {"items": items}
|
||||||
|
if base is not None:
|
||||||
|
body["base"] = base
|
||||||
|
return shop(sam).put(f"{URL}/{dept}", json=body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_and_get(shop: Shop) -> None:
|
||||||
|
rows = {r["dept_code"]: r for r in ok(shop("sup.wes").get(URL))}
|
||||||
|
assert "03" in rows and "CL" not in rows and "QC" not in rows
|
||||||
|
assert rows["03"]["editable"] is True and rows["03"]["weld"] is True and rows["03"]["custom"] is False
|
||||||
|
assert rows["03"]["version"] == 0 and rows["03"]["dept_name"] == "Weld Parts"
|
||||||
|
assert not any(r["editable"] for code, r in rows.items() if code != "03")
|
||||||
|
assert all(r["editable"] for r in ok(shop("admin.ann").get(URL)))
|
||||||
|
for sam in ("lead.lou", "weld.andy", "qc.quinn", "clerk.clara"):
|
||||||
|
assert not any(r["editable"] for r in ok(shop(sam).get(URL))), sam
|
||||||
|
got = ok(shop("qc.quinn").get(f"{URL}/03"))
|
||||||
|
assert [i["key"] for i in got["items"]] == list(WELD_KEYS) and got["defaults"] == got["items"]
|
||||||
|
assert got["editable"] is False and got["custom"] is False
|
||||||
|
assert "{drawing}" in got["items"][0]["text"] # unfilled
|
||||||
|
plain = ok(shop("sup.wes").get(f"{URL}/09"))
|
||||||
|
assert [i["key"] for i in plain["items"]] == list(PLAIN_KEYS) and plain["editable"] is False
|
||||||
|
refused(shop("sup.wes").get(f"{URL}/QC"), 404, "not_found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_who_may_save(shop: Shop) -> None:
|
||||||
|
for sam in ("lead.lou", "weld.andy", "qc.quinn", "clerk.clara", "pm.paula"):
|
||||||
|
refused(put(shop, sam, "03", NEW_LIST, 0), 403, "forbidden")
|
||||||
|
refused(put(shop, "sup.wes", "09", NEW_LIST, 0), 403, "forbidden") # not his department
|
||||||
|
out = ok(put(shop, "sup.wes", "03", NEW_LIST, 0))
|
||||||
|
assert out["custom"] is True and out["version"] == 1 and [i["key"] for i in out["items"]] == ["dwg", "purge", "vt"]
|
||||||
|
# Admin edits any department
|
||||||
|
out = ok(put(shop, "admin.ann", "09", NEW_LIST, 0))
|
||||||
|
assert out["custom"] is True and out["dept_code"] == "09"
|
||||||
|
# the other departments keep the default for their kind
|
||||||
|
assert ok(shop("sup.wes").get(f"{URL}/04"))["custom"] is False
|
||||||
|
row = one(
|
||||||
|
shop.settings,
|
||||||
|
"SELECT count(*) FROM audit_events WHERE action = 'checklists.dept_updated' AND subject_id = 'checklist:03'",
|
||||||
|
)
|
||||||
|
assert row is not None and row[0] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_cas_and_validation(shop: Shop) -> None:
|
||||||
|
ok(put(shop, "sup.wes", "03", NEW_LIST, 0))
|
||||||
|
stale = refused(put(shop, "sup.wes", "03", NEW_LIST, 0), 409, "version_conflict")
|
||||||
|
assert stale["current"]["version"] == 1 and stale["current"]["custom"] is True
|
||||||
|
refused(put(shop, "sup.wes", "03", NEW_LIST, None), 428, "if_match_required")
|
||||||
|
r = shop("sup.wes").put(f"{URL}/03", json={"items": NEW_LIST}, headers={"If-Match": '"1"'})
|
||||||
|
assert ok(r)["version"] == 2
|
||||||
|
bad = refused(put(shop, "sup.wes", "03", [{"group": "G", "key": "Bad-Key", "text": "t"}], 2), 422, "bad_checklist")
|
||||||
|
assert "key" in bad["detail"]
|
||||||
|
refused(put(shop, "sup.wes", "03", [], 2), 422, "bad_checklist")
|
||||||
|
refused(put(shop, "sup.wes", "03", [{"group": "G", "key": "x", "text": "{stamp}"}], 2), 422, "bad_checklist")
|
||||||
|
# null sets the department back to the default for its kind
|
||||||
|
back = ok(put(shop, "sup.wes", "03", None, 2))
|
||||||
|
assert back["custom"] is False and back["version"] == 3 and [i["key"] for i in back["items"]] == list(WELD_KEYS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_change_reaches_only_stages_not_started(shop: Shop) -> None:
|
||||||
|
andy = shop("weld.andy")
|
||||||
|
started = start(shop, to_station(shop, ready_packet(shop, drawing="D-7007", rev="A")))
|
||||||
|
assigned = to_station(shop, ready_packet(shop), station="Stall 2")
|
||||||
|
waiting = ready_packet(shop)
|
||||||
|
ok(put(shop, "sup.wes", "03", NEW_LIST, 0))
|
||||||
|
|
||||||
|
# the started stage keeps the default it started with
|
||||||
|
assert keys(fresh(andy, started["id"])) == list(WELD_KEYS)
|
||||||
|
refused(andy.put(f"/api/packets/{started['id']}/stage/checklist/purge", json={"done": True}), 422, "invalid_key")
|
||||||
|
# the stages not yet started read the new list
|
||||||
|
assert keys(fresh(andy, assigned["id"])) == ["dwg", "purge", "vt"]
|
||||||
|
assert keys(fresh(shop("sup.wes"), waiting["id"])) == ["dwg", "purge", "vt"]
|
||||||
|
|
||||||
|
# START freezes: a later change does not reach it
|
||||||
|
later = start(shop, assigned)
|
||||||
|
ok(put(shop, "sup.wes", "03", [*NEW_LIST, {"group": "After welding", "key": "tag", "text": "Tagged"}], 1))
|
||||||
|
assert keys(fresh(andy, later["id"])) == ["dwg", "purge", "vt"]
|
||||||
|
assert keys(fresh(shop("sup.wes"), waiting["id"])) == ["dwg", "purge", "vt", "tag"]
|
||||||
|
stage = one(
|
||||||
|
shop.settings,
|
||||||
|
"SELECT checklist_template FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL",
|
||||||
|
(later["id"],),
|
||||||
|
)
|
||||||
|
assert stage is not None and [i["key"] for i in stage[0]] == ["dwg", "purge", "vt"]
|
||||||
|
|
||||||
|
# completeness reads the frozen list: three ticks, a weld and the filler let it submit
|
||||||
|
out = tick_all(shop, later, keys=("dwg", "purge", "vt"))
|
||||||
|
out = set_filler(shop, add_weld(shop, out))
|
||||||
|
assert "submit" in fresh(andy, out["id"])["can"]
|
||||||
|
# the started packet still needs the default keys
|
||||||
|
assert "submit" not in fresh(andy, started["id"])["can"]
|
||||||
|
|
||||||
|
# the data book's checklist section reads the frozen list too
|
||||||
|
with shop_conn(shop) as conn:
|
||||||
|
t = checklists.stage_templates(conn, stage_id(shop, later["id"]), "03")
|
||||||
|
assert [i["key"] for i in t["weld"]] == ["dwg", "purge", "vt"]
|
||||||
|
live = checklists.stage_templates(conn, None, "03")
|
||||||
|
assert [i["key"] for i in live["weld"]] == ["dwg", "purge", "vt", "tag"]
|
||||||
|
|
||||||
|
|
||||||
|
def stage_id(shop: Shop, packet_id: int) -> int:
|
||||||
|
row = one(shop.settings, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
|
||||||
|
assert row is not None
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def shop_conn(shop: Shop) -> Any:
|
||||||
|
from pshelpers import app_conn
|
||||||
|
|
||||||
|
return app_conn(shop.settings)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Phase 7 close and file through the API: the VISUAL close and the filing as every role, the feed's open work
|
"""Phase 7 close and file through the API: the VISUAL close and the filing as every role, the feed's open work
|
||||||
orders (the note suffix, the 409 and the confirm), the data book filed with the seal, the reopen that keeps the
|
orders (a block on both routes since C5; the feed closes a VISUAL packet), the data book filed with the seal, the
|
||||||
broken seal and its data book, the refile with a second seal, a failed data book build that files nothing, and a
|
reopen that keeps the broken seal and its data book, the refile with a second seal, a failed data book build that
|
||||||
hand packet with no feed state.
|
files nothing, and a hand packet with no feed state.
|
||||||
|
|
||||||
The feed state is written by SQL in the shape feed/apply.py writes it. Nothing here reads VISUAL or M-Files."""
|
The feed state is written by SQL in the shape feed/apply.py writes it. Nothing here reads VISUAL or M-Files."""
|
||||||
|
|
||||||
@@ -132,6 +132,7 @@ def test_vclosed_and_file_refused_outside_complete(shop: Shop) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_shipping_route_files_without_the_visual_close(shop: Shop) -> None:
|
def test_shipping_route_files_without_the_visual_close(shop: Shop) -> None:
|
||||||
|
"""A hand packet on the Shipping route: no feed state, no Clerical close."""
|
||||||
pkt = at_complete(shop, route="shipping")
|
pkt = at_complete(shop, route="shipping")
|
||||||
r = shop("clerk.clara").post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True})
|
r = shop("clerk.clara").post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True})
|
||||||
assert r.status_code in (403, 409) and r.json()["code"] in ("wrong_status", "precondition"), r.text
|
assert r.status_code in (403, 409) and r.json()["code"] in ("wrong_status", "precondition"), r.text
|
||||||
@@ -140,10 +141,18 @@ def test_shipping_route_files_without_the_visual_close(shop: Shop) -> None:
|
|||||||
assert out["seal"]["databook"]["pages"] > 0
|
assert out["seal"]["databook"]["pages"] > 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- the feed's open work orders
|
# ---------------------------------------------------------------- the feed's open work orders (C5)
|
||||||
|
|
||||||
|
MSG_FEED_CLOSES = "The VISUAL feed marks this job closed when it sees every work order closed."
|
||||||
|
MSG_FEED_WAIT = "Filing waits until the VISUAL feed sees the job closed."
|
||||||
|
|
||||||
|
|
||||||
def test_vclose_check_and_the_open_wo_note(shop: Shop) -> None:
|
def open_msg(wos: str) -> str:
|
||||||
|
return f"VISUAL still shows work orders open on this packet: {wos}. Filing waits until VISUAL closes them."
|
||||||
|
|
||||||
|
|
||||||
|
def test_visual_packet_open_in_visual_cannot_be_closed_or_filed(shop: Shop) -> None:
|
||||||
|
"""C5: Clerical cannot mark a VISUAL packet closed; the open work orders block the file, and say why."""
|
||||||
pkt = at_complete(shop)
|
pkt = at_complete(shop)
|
||||||
pid = pkt["id"]
|
pid = pkt["id"]
|
||||||
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", st="R")])
|
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", st="R")])
|
||||||
@@ -151,54 +160,58 @@ def test_vclose_check_and_the_open_wo_note(shop: Shop) -> None:
|
|||||||
vc = view["vclose_check"]
|
vc = view["vclose_check"]
|
||||||
assert vc["open_wos"] == ["L670002/001"] and vc["closed_at"] is None and vc["observed_at"] is not None
|
assert vc["open_wos"] == ["L670002/001"] and vc["closed_at"] is None and vc["observed_at"] is not None
|
||||||
assert vc["agrees"] is True # neither closed
|
assert vc["agrees"] is True # neither closed
|
||||||
pkt = vclose(shop, view)
|
assert "set_vclosed" not in view["can"] and "file" not in view["can"]
|
||||||
assert pkt["vclose_check"]["agrees"] is False
|
assert view["blocked"]["file"] == [open_msg("L670002/001")]
|
||||||
row = find_log(shop("clerk.clara"), pid, "Job closed in VISUAL")
|
refused(shop("clerk.clara").post(f"/api/packets/{pid}/vclosed", json={"closed": True}), 403, "wrong_status")
|
||||||
assert row["note"] == "L670001, L670002 · VISUAL still shows open: L670002/001", row
|
refused(file_packet(shop, view), 403, "precondition", open_msg("L670002/001"))
|
||||||
|
|
||||||
|
|
||||||
def test_vclose_check_closed_by_the_feed(shop: Shop) -> None:
|
def test_vclose_check_closed_by_the_feed(shop: Shop) -> None:
|
||||||
pkt = at_complete(shop)
|
pkt = at_complete(shop)
|
||||||
pid = pkt["id"]
|
pid = pkt["id"]
|
||||||
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", st="C")], closed=True)
|
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", st="C")], closed=True)
|
||||||
vc = fresh(shop("clerk.clara"), pid)["vclose_check"]
|
view = fresh(shop("clerk.clara"), pid)
|
||||||
assert vc["open_wos"] == [] and vc["closed_at"] is not None and vc["agrees"] is False
|
vc = view["vclose_check"]
|
||||||
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
|
assert vc["open_wos"] == [] and vc["closed_at"] is not None and vc["agrees"] is True
|
||||||
assert pkt["vclose_check"]["agrees"] is True
|
assert view["vclosed"]["at"] is not None and view["vclosed"]["by"] is None # the feed, not a person
|
||||||
assert find_log(shop("clerk.clara"), pid, "Job closed in VISUAL")["note"] == "L670001, L670002"
|
assert "file" in view["can"] and "file" not in view["blocked"]
|
||||||
out = ok(file_packet(shop, pkt))
|
out = ok(file_packet(shop, view))
|
||||||
assert out["seal"]["open_wos"] is None
|
assert out["status"] == "filed" and out["seal"]["open_wos"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_vclose_check_before_the_feed_looked(shop: Shop) -> None:
|
def test_vclose_check_before_the_feed_looked(shop: Shop) -> None:
|
||||||
pkt = at_complete(shop)
|
pkt = at_complete(shop)
|
||||||
set_feed_legs(shop.settings, pkt["id"], None)
|
set_feed_legs(shop.settings, pkt["id"], None)
|
||||||
vc = fresh(shop("clerk.clara"), pkt["id"])["vclose_check"]
|
view = fresh(shop("clerk.clara"), pkt["id"])
|
||||||
|
vc = view["vclose_check"]
|
||||||
assert vc is not None and vc["observed_at"] is None and vc["open_wos"] == []
|
assert vc is not None and vc["observed_at"] is None and vc["open_wos"] == []
|
||||||
|
assert view["blocked"]["file"] == [MSG_FEED_WAIT]
|
||||||
|
|
||||||
|
|
||||||
def test_file_with_open_work_orders_needs_the_confirm(shop: Shop) -> None:
|
@pytest.mark.parametrize("route", ["databook", "shipping"])
|
||||||
pkt = at_complete(shop)
|
def test_file_blocked_while_visual_shows_work_orders_open(shop: Shop, route: str) -> None:
|
||||||
|
"""C5: a block on both routes, not a warning. The old confirm_open_wos is ignored. A close set before C5
|
||||||
|
(vclosed by hand on a VISUAL packet) does not pass while the feed shows work orders open."""
|
||||||
|
pkt = at_complete(shop, route=route)
|
||||||
pid = pkt["id"]
|
pid = pkt["id"]
|
||||||
set_feed_legs(shop.settings, pid, [leg("L670001", st="R"), leg("L670002", lot="7A", st="R")])
|
set_feed_legs(shop.settings, pid, [leg("L670001", st="R"), leg("L670002", lot="7A", st="R")])
|
||||||
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
|
with_hand_close = route == "databook"
|
||||||
|
if with_hand_close:
|
||||||
|
q1(shop.settings, "UPDATE packets SET vclosed_at = now() WHERE id = %s RETURNING id", (pid,))
|
||||||
|
pkt = fresh(shop("clerk.clara"), pid)
|
||||||
sigs = q1(shop.settings, "SELECT count(*) FROM signatures")[0]
|
sigs = q1(shop.settings, "SELECT count(*) FROM signatures")[0]
|
||||||
body = refused(file_packet(shop, pkt), 409, "open_work_orders")
|
msg = open_msg("L670001/001, L670002/7A")
|
||||||
assert body["work_orders"] == ["L670001/001", "L670002/7A"]
|
refused(file_packet(shop, pkt), 403, "precondition", msg)
|
||||||
assert body["detail"] == (
|
refused(file_packet(shop, pkt, confirm_open_wos=True), 403, "precondition", msg)
|
||||||
"VISUAL still shows 2 work orders open on this packet: L670001/001, L670002/7A. File anyway?"
|
|
||||||
)
|
|
||||||
# nothing written, no signature taken
|
# nothing written, no signature taken
|
||||||
assert q1(shop.settings, "SELECT count(*) FROM signatures")[0] == sigs
|
assert q1(shop.settings, "SELECT count(*) FROM signatures")[0] == sigs
|
||||||
assert q1(shop.settings, "SELECT count(*) FROM packet_seals WHERE packet_id = %s", (pid,))[0] == 0
|
assert q1(shop.settings, "SELECT count(*) FROM packet_seals WHERE packet_id = %s", (pid,))[0] == 0
|
||||||
assert fresh(shop("clerk.clara"), pid)["status"] == "complete"
|
assert fresh(shop("clerk.clara"), pid)["status"] == "complete"
|
||||||
refused(file_packet(shop, pkt, confirm_open_wos=False), 409, "open_work_orders")
|
# the feed sees VISUAL closed: the packet files, on either route
|
||||||
out = ok(file_packet(shop, pkt, confirm_open_wos=True, note="Customer pickup today"))
|
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", lot="7A", st="X")], closed=True)
|
||||||
assert out["status"] == "filed" and out["seal"]["open_wos"] == ["L670001/001", "L670002/7A"]
|
out = ok(file_packet(shop, fresh(shop("clerk.clara"), pid), confirm_open_wos=True, note="Customer pickup today"))
|
||||||
row = q1(shop.settings, "SELECT open_wos FROM packet_seals WHERE packet_id = %s", (pid,))
|
assert out["status"] == "filed" and out["seal"]["open_wos"] is None
|
||||||
assert row[0] == ["L670001/001", "L670002/7A"]
|
assert find_log(shop("clerk.clara"), pid, "Filed in the data book")["note"] == "Customer pickup today"
|
||||||
note = find_log(shop("clerk.clara"), pid, "Filed in the data book")["note"]
|
|
||||||
assert note == "Customer pickup today · Filed with work orders open in VISUAL: L670001/001, L670002/7A", note
|
|
||||||
|
|
||||||
|
|
||||||
def test_cancelled_legs_do_not_count_as_open(shop: Shop) -> None:
|
def test_cancelled_legs_do_not_count_as_open(shop: Shop) -> None:
|
||||||
|
|||||||
@@ -204,7 +204,12 @@ def test_mine_per_screen(world: FilterWorld) -> None:
|
|||||||
assert ids(world, "multi.mia", flags="mine") == clerical # Clerical comes first in the screen order
|
assert ids(world, "multi.mia", flags="mine") == clerical # Clerical comes first in the screen order
|
||||||
stall1 = shop.stall("03", "Stall 1")
|
stall1 = shop.stall("03", "Stall 1")
|
||||||
assert ids(world, "weld.andy", flags="mine", mine_screen="stall", mine_station_id=stall1) == world.ids("a1")
|
assert ids(world, "weld.andy", flags="mine", mine_screen="stall", mine_station_id=stall1) == world.ids("a1")
|
||||||
assert ids(world, "weld.andy", flags="mine", mine_screen="stall") == set() # no station picked
|
# change C11: the stall's Mine is the packets assigned to me, at any station; a picked station is not needed
|
||||||
|
assert ids(world, "weld.andy", flags="mine", mine_screen="stall") == world.ids("a1")
|
||||||
|
stall2 = shop.stall("03", "Stall 2")
|
||||||
|
assert ids(world, "weld.andy", flags="mine", mine_screen="stall", mine_station_id=stall2) == world.ids("a1")
|
||||||
|
assert ids(world, "dup.dave", flags="mine", mine_screen="stall") == world.ids("a2")
|
||||||
|
assert ids(world, "admin.ann", flags="mine", mine_screen="stall") == set() # nothing assigned to her
|
||||||
assert ids(world, "qc.quinn", flags="mine", mine_screen="qc") == world.ids("q1")
|
assert ids(world, "qc.quinn", flags="mine", mine_screen="qc") == world.ids("q1")
|
||||||
assert ids(world, "pm.paula", flags="mine") == world.every
|
assert ids(world, "pm.paula", flags="mine") == world.every
|
||||||
assert ids(world, "admin.ann", flags="mine", mine_screen="admin") == world.every
|
assert ids(world, "admin.ann", flags="mine", mine_screen="admin") == world.every
|
||||||
@@ -367,6 +372,29 @@ def test_stall_board_filters(world: FilterWorld) -> None:
|
|||||||
assert get_ok(andy, "/api/shop/stall", station_id=stall1, status="review")["mine"] == []
|
assert get_ok(andy, "/api/shop/stall", station_id=stall1, status="review")["mine"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_stall_mine_across_stations(world: FilterWorld) -> None:
|
||||||
|
"""Change C11: GET /api/stall/mine, the packets assigned to the signed-in person, each with its station."""
|
||||||
|
shop = world.shop
|
||||||
|
andy = get_ok(shop("weld.andy"), "/api/stall/mine")
|
||||||
|
assert andy["station"] is None and andy["empty"] is None and "facets" in andy
|
||||||
|
assert [c["id"] for c in andy["mine"]] == [world.k["a1"]] and andy["submitted"] == []
|
||||||
|
card = andy["mine"][0]
|
||||||
|
assert card["station_id"] == shop.stall("03", "Stall 1") and card["station_name"] == "Stall 1"
|
||||||
|
dave = get_ok(shop("dup.dave"), "/api/stall/mine")
|
||||||
|
got = [(c["id"], c["station_name"], c["status"]) for c in dave["mine"]]
|
||||||
|
assert got == [(world.k["a2"], "Stall 2", "in_progress")]
|
||||||
|
# the find bar narrows
|
||||||
|
assert get_ok(shop("weld.andy"), "/api/stall/mine", q="nothing-matches-this")["mine"] == []
|
||||||
|
# nobody assigned: the empty state names the supervisor
|
||||||
|
for sam in ("lead.lou", "qc.quinn"):
|
||||||
|
none = get_ok(shop(sam), "/api/stall/mine")
|
||||||
|
assert none["mine"] == [] and none["submitted"] == []
|
||||||
|
assert none["empty"] == "Nothing is assigned to you now. Your supervisor assigns packets to you."
|
||||||
|
# a station in the request is a find bar narrowing, never a pick that widens
|
||||||
|
other = get_ok(shop("weld.andy"), "/api/stall/mine", station_id=shop.stall("03", "Stall 2"))
|
||||||
|
assert other["mine"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_warehouse_board_filters_keep_the_kpis(world: FilterWorld) -> None:
|
def test_warehouse_board_filters_keep_the_kpis(world: FilterWorld) -> None:
|
||||||
wade = world.shop("wh.wade")
|
wade = world.shop("wh.wade")
|
||||||
base = get_ok(wade, "/api/warehouse/board")
|
base = get_ok(wade, "/api/warehouse/board")
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
copy of sheet 1, a heat table, two welds, an MTR PDF and an MTR PNG, a short with a receipt, a packet document, a
|
copy of sheet 1, a heat table, two welds, an MTR PDF and an MTR PNG, a short with a receipt, a packet document, a
|
||||||
Q.C. folder document, the closed stage and the Q.C. accept.
|
Q.C. folder document, the closed stage and the Q.C. accept.
|
||||||
|
|
||||||
The index (seven sections in order, the expected rows), the page count, the cover, the index page, the void
|
The index (six sections in order, the expected rows; no history pages since change C6), the page count, the cover,
|
||||||
sheet, the traceability page, the warning rows, the metadata, the stamp on every page and its signature, and the
|
the index page, the void sheet, the traceability page, the warning rows, the metadata, the stamp on every page and
|
||||||
stamp per mode. Nothing here reads VISUAL or M-Files."""
|
its signature, and the stamp per mode. Nothing here reads VISUAL or M-Files."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ import pytest
|
|||||||
from closehelpers import (
|
from closehelpers import (
|
||||||
HEATS,
|
HEATS,
|
||||||
RECEIPT_HEAT,
|
RECEIPT_HEAT,
|
||||||
|
add_doc,
|
||||||
builder_conn,
|
builder_conn,
|
||||||
delete_asset_file,
|
delete_asset_file,
|
||||||
flat,
|
flat,
|
||||||
@@ -43,7 +44,7 @@ pytestmark = needs_pg
|
|||||||
|
|
||||||
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
|
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
|
||||||
SEAL = {"hash": "ab12cd34ef56" + "0" * 52, "key_id": "k1234567", "sealed_at": NOW}
|
SEAL = {"hash": "ab12cd34ef56" + "0" * 52, "key_id": "k1234567", "sealed_at": NOW}
|
||||||
SECTIONS = [1, 2, 3, 4, 5, 6, 7]
|
SECTIONS = [1, 2, 3, 4, 5, 6]
|
||||||
SIG_RE = re.compile(r"stamp ([0-9a-f]{16})")
|
SIG_RE = re.compile(r"stamp ([0-9a-f]{16})")
|
||||||
DUMP_DIR = os.environ.get("DATABOOK_DUMP_DIR") # off unless set
|
DUMP_DIR = os.environ.get("DATABOOK_DUMP_DIR") # off unless set
|
||||||
|
|
||||||
@@ -98,13 +99,14 @@ def test_data_book_preview(shop: Shop) -> None:
|
|||||||
assert len(reader.pages) == built.pages
|
assert len(reader.pages) == built.pages
|
||||||
rows = index(built)
|
rows = index(built)
|
||||||
|
|
||||||
# the seven sections, in order, each present
|
# the six sections, in order, each present
|
||||||
seen = [r["section"] for r in rows]
|
seen = [r["section"] for r in rows]
|
||||||
assert seen == sorted(seen) and sorted(set(seen)) == SECTIONS, seen
|
assert seen == sorted(seen) and sorted(set(seen)) == SECTIONS, seen
|
||||||
|
|
||||||
s1 = docs_in(rows, 1)
|
s1 = docs_in(rows, 1)
|
||||||
assert sum("approved drawing" in d for d in s1) == 2, s1
|
assert sum("approved drawing" in d for d in s1) == 2, s1
|
||||||
assert any(d.startswith("SUPERSEDED - VOID by CO-1") for d in s1), s1
|
assert any(d.startswith("SUPERSEDED - VOID by CO-1") for d in s1), s1
|
||||||
|
assert s1[-1] == "Change order record", s1
|
||||||
drawing_rows = [r for r in rows if r["section"] == 1 and "approved drawing" in str(r["doc"])]
|
drawing_rows = [r for r in rows if r["section"] == 1 and "approved drawing" in str(r["doc"])]
|
||||||
assert all(r["ref"] == "D-5001" and r["rev"] == "RB" for r in drawing_rows), drawing_rows
|
assert all(r["ref"] == "D-5001" and r["rev"] == "RB" for r in drawing_rows), drawing_rows
|
||||||
|
|
||||||
@@ -131,7 +133,8 @@ def test_data_book_preview(shop: Shop) -> None:
|
|||||||
mtr_rows = [r for r in rows if r["section"] == 6]
|
mtr_rows = [r for r in rows if r["section"] == 6]
|
||||||
assert mtr_rows[0]["ref"] == HEATS[1]
|
assert mtr_rows[0]["ref"] == HEATS[1]
|
||||||
|
|
||||||
assert docs_in(rows, 7) == ["Packet record history"]
|
# change C6: no history pages; the book is the one copy and goes to the customer
|
||||||
|
assert not any("history" in str(r["doc"]).lower() for r in rows), rows
|
||||||
|
|
||||||
# pages: the parts plus the front matter (cover and index); page numbers point into the book
|
# pages: the parts plus the front matter (cover and index); page numbers point into the book
|
||||||
parts = sum(int(r["count"] or 0) for r in rows)
|
parts = sum(int(r["count"] or 0) for r in rows)
|
||||||
@@ -167,8 +170,10 @@ def test_data_book_preview(shop: Shop) -> None:
|
|||||||
qc_page = next(r for r in rows if r["section"] == 4)["page"]
|
qc_page = next(r for r in rows if r["section"] == 4)["page"]
|
||||||
qc_lines = texts[int(qc_page) - 1].splitlines()
|
qc_lines = texts[int(qc_page) - 1].splitlines()
|
||||||
assert "AM" not in qc_lines and "PM" not in qc_lines, qc_lines # the date and time stay on one line
|
assert "AM" not in qc_lines and "PM" not in qc_lines, qc_lines # the date and time stay on one line
|
||||||
hist_page = next(r for r in rows if r["section"] == 7)["page"]
|
co_page = next(r for r in rows if r["doc"] == "Change order record")["page"]
|
||||||
assert "CO-1 " in flat(texts[int(hist_page) - 1]) and "documents changed" in flat(texts[int(hist_page) - 1])
|
assert "CO-1 " in flat(texts[int(co_page) - 1]) and "documents changed" in flat(texts[int(co_page) - 1])
|
||||||
|
whole = flat(" ".join(texts))
|
||||||
|
assert "RECORD HISTORY" not in whole.upper() and "Every action taken on this packet" not in whole
|
||||||
|
|
||||||
# metadata
|
# metadata
|
||||||
meta = reader.metadata
|
meta = reader.metadata
|
||||||
@@ -230,6 +235,15 @@ def test_data_book_with_an_itp_document_has_no_itp_warning(shop: Shop) -> None:
|
|||||||
assert not any(r["doc"] == "No signed ITP filed for this unit" for r in s5)
|
assert not any(r["doc"] == "No signed ITP filed for this unit" for r in s5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_book_carries_an_ncr_document(shop: Shop) -> None:
|
||||||
|
"""Change C2 asks the NCR document Q.C. uploads to be in the data book: every packet document kind is in
|
||||||
|
section 5, the NCR kind included."""
|
||||||
|
pkt = rich_complete(shop, mtrs=False)
|
||||||
|
add_doc(shop, pkt, title="NCR 2026-0017", kind="ncr")
|
||||||
|
s5 = docs_in(index(build(shop, pkt["id"])), 5)
|
||||||
|
assert any(d.startswith("NCR") and "NCR 2026-0017" in d for d in s5), s5
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- phase 8: against the old build's book
|
# ---------------------------------------------------------------- phase 8: against the old build's book
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ def test_find_bar_boards_and_pm_reports(shop: Shop) -> None:
|
|||||||
assert qc["kpis"]["waiting"] == 0 and "facets" in qc
|
assert qc["kpis"]["waiting"] == 0 and "facets" in qc
|
||||||
stall = ok(shop("weld.andy").get("/api/shop/stall", params={"station_id": shop.stall(), "flags": "mine"}))
|
stall = ok(shop("weld.andy").get("/api/shop/stall", params={"station_id": shop.stall(), "flags": "mine"}))
|
||||||
assert [c["id"] for c in stall["mine"]] == [p_st["id"]]
|
assert [c["id"] for c in stall["mine"]] == [p_st["id"]]
|
||||||
|
mine = ok(shop("weld.andy").get("/api/stall/mine")) # change C11: no station picked
|
||||||
|
assert [c["id"] for c in mine["mine"]] == [p_st["id"]] and mine["mine"][0]["station_id"] == shop.stall()
|
||||||
|
|
||||||
# Project Management
|
# Project Management
|
||||||
pmc = shop("pm.paula")
|
pmc = shop("pm.paula")
|
||||||
@@ -111,7 +113,8 @@ def test_find_bar_boards_and_pm_reports(shop: Shop) -> None:
|
|||||||
assert [t["key"] for t in ag["tables"]] == ["aging_steps", "aging_open", "aging_history"]
|
assert [t["key"] for t in ag["tables"]] == ["aging_steps", "aging_open", "aging_history"]
|
||||||
hr = ok(pmc.get("/api/pm/hours"))
|
hr = ok(pmc.get("/api/pm/hours"))
|
||||||
over_rows = hr["tables"][3]["rows"]
|
over_rows = hr["tables"][3]["rows"]
|
||||||
assert [r["pid"] for r in over_rows] == [p_st["id"]], hr
|
# change C1: the timer is over its target but there are no ticket hours, so it is not over target
|
||||||
|
assert over_rows == [], hr
|
||||||
ql = ok(pmc.get("/api/pm/quality"))
|
ql = ok(pmc.get("/api/pm/quality"))
|
||||||
assert [len(t["rows"]) for t in ql["tables"]][:4] == [1, 1, 1, 1], ql
|
assert [len(t["rows"]) for t in ql["tables"]][:4] == [1, 1, 1, 1], ql
|
||||||
fd = ok(pmc.get("/api/pm/find", params={"sort": "project", "project_id": project_id}))
|
fd = ok(pmc.get("/api/pm/find", params={"sort": "project", "project_id": project_id}))
|
||||||
@@ -134,4 +137,4 @@ def test_find_bar_boards_and_pm_reports(shop: Shop) -> None:
|
|||||||
kinds = [x[0] for x in conn.execute("SELECT kind FROM print_log ORDER BY id")]
|
kinds = [x[0] for x in conn.execute("SELECT kind FROM print_log ORDER BY id")]
|
||||||
acts = [x[0] for x in conn.execute("SELECT action FROM audit_events WHERE action = 'report exported'")]
|
acts = [x[0] for x in conn.execute("SELECT action FROM audit_events WHERE action = 'report exported'")]
|
||||||
assert kinds == ["report-csv", "report-pdf"] and len(acts) == 2
|
assert kinds == ["report-csv", "report-pdf"] and len(acts) == 2
|
||||||
assert shop("clerk.clara").get("/api/pm/overview").status_code == 200
|
assert shop("clerk.clara").get("/api/pm/overview").status_code == 403 # change C10: PM and Admin only
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ def test_overview_summary(world: PmWorld) -> None:
|
|||||||
slow = [x for x in lines if x.startswith("Slowest step right now: ")]
|
slow = [x for x in lines if x.startswith("Slowest step right now: ")]
|
||||||
assert slow and slow[0].startswith(f'Slowest step right now: "{STEP_LABEL["wh_hold"]}", averaging 5')
|
assert slow and slow[0].startswith(f'Slowest step right now: "{STEP_LABEL["wh_hold"]}", averaging 5')
|
||||||
assert "Needs attention: 1 warehouse hold, 1 open change order, 1 open short, 1 Q.C. reject." in lines
|
assert "Needs attention: 1 warehouse hold, 1 open change order, 1 open short, 1 Q.C. reject." in lines
|
||||||
assert "1 packet is over target hours." in lines
|
assert "1 packet is over target hours on the VISUAL tickets." in lines
|
||||||
|
|
||||||
|
|
||||||
def test_overview_attention(world: PmWorld) -> None:
|
def test_overview_attention(world: PmWorld) -> None:
|
||||||
@@ -217,7 +217,7 @@ def test_hours(world: PmWorld) -> None:
|
|||||||
assert len(dept) == 1 and "Weld Parts" in dept[0]["cells"][0]
|
assert len(dept) == 1 and "Weld Parts" in dept[0]["cells"][0]
|
||||||
# counted: the running packet, and every packet past review with target hours
|
# counted: the running packet, and every packet past review with target hours
|
||||||
assert dept[0]["cells"][1] == "6"
|
assert dept[0]["cells"][1] == "6"
|
||||||
assert dept[0]["cells"][3] == "3.2" # ticket hours: the running packet's VISUAL rows only
|
assert dept[0]["cells"][3] == "5.2" # ticket hours: the running packet's VISUAL rows only
|
||||||
assert dept[0]["cells"][4] == "27.0" # 6 x 4.5
|
assert dept[0]["cells"][4] == "27.0" # 6 x 4.5
|
||||||
assert dept[0]["cells"][7] == "1"
|
assert dept[0]["cells"][7] == "1"
|
||||||
over = table(out, "hours_over")["rows"]
|
over = table(out, "hours_over")["rows"]
|
||||||
@@ -226,7 +226,8 @@ def test_hours(world: PmWorld) -> None:
|
|||||||
# 4 h stored plus the running span (2 h when the world was built, more by now)
|
# 4 h stored plus the running span (2 h when the world was built, more by now)
|
||||||
timer = float(cells[3])
|
timer = float(cells[3])
|
||||||
assert 6.0 <= timer < 8.0, cells
|
assert 6.0 <= timer < 8.0, cells
|
||||||
assert cells[4] == "3.2" and cells[5] == "4.5" and cells[6] == f"+{timer - 4.5:.1f} h"
|
# over by the ticket hours (C1), not the timer
|
||||||
|
assert cells[4] == "5.2" and cells[5] == "4.5" and cells[6] == "+0.7 h"
|
||||||
assert "Andy Welder" in cells[1] or "Stall 1" in cells[1]
|
assert "Andy Welder" in cells[1] or "Stall 1" in cells[1]
|
||||||
workers = {r["cells"][0]: r for r in table(out, "hours_worker")["rows"]}
|
workers = {r["cells"][0]: r for r in table(out, "hours_worker")["rows"]}
|
||||||
assert set(workers) == {"Andy Welder", "—"}
|
assert set(workers) == {"Andy Welder", "—"}
|
||||||
@@ -343,13 +344,23 @@ def test_scope_follows_the_project(world: PmWorld) -> None:
|
|||||||
assert all(table(q, k)["rows"] == [] for k in TAB_TABLES["quality"])
|
assert all(table(q, k)["rows"] == [] for k in TAB_TABLES["quality"])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sam", ["pm.paula", "clerk.clara", "weld.andy"])
|
@pytest.mark.parametrize("sam", ["pm.paula", "admin.ann"])
|
||||||
def test_every_signed_in_role_reads(world: PmWorld, sam: str) -> None:
|
def test_pm_and_admin_read(world: PmWorld, sam: str) -> None:
|
||||||
for what in ENDPOINTS:
|
for what in ENDPOINTS:
|
||||||
r = world.shop(sam).get(f"/api/pm/{what}")
|
r = world.shop(sam).get(f"/api/pm/{what}")
|
||||||
assert r.status_code == 200, (sam, what, r.text)
|
assert r.status_code == 200, (sam, what, r.text)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sam", ["clerk.clara", "weld.andy", "qc.quinn", "wh.wade", "sup.wes"])
|
||||||
|
def test_other_roles_are_refused(world: PmWorld, sam: str) -> None:
|
||||||
|
# change C10: PS-ProjectMgmt and Admin only
|
||||||
|
for what in [*ENDPOINTS, "export.csv", "report.pdf"]:
|
||||||
|
r = world.shop(sam).get(f"/api/pm/{what}")
|
||||||
|
assert r.status_code == 403, (sam, what, r.text)
|
||||||
|
me = get_ok(world.shop(sam), "/api/auth/me")
|
||||||
|
assert "pm" not in me.get("screens", []), me
|
||||||
|
|
||||||
|
|
||||||
def test_unsigned_is_refused_and_nothing_writes(world: PmWorld) -> None:
|
def test_unsigned_is_refused_and_nothing_writes(world: PmWorld) -> None:
|
||||||
c = anon(world.shop)
|
c = anon(world.shop)
|
||||||
for what in ENDPOINTS:
|
for what in ENDPOINTS:
|
||||||
|
|||||||
@@ -178,7 +178,8 @@ def test_export_needs_a_session(world: PmWorld) -> None:
|
|||||||
c = anon(world.shop)
|
c = anon(world.shop)
|
||||||
assert c.get("/api/pm/export.csv", params={"table": "all"}).status_code == 401
|
assert c.get("/api/pm/export.csv", params={"table": "all"}).status_code == 401
|
||||||
assert c.get("/api/pm/report.pdf").status_code == 401
|
assert c.get("/api/pm/report.pdf").status_code == 401
|
||||||
assert world.shop("clerk.clara").get("/api/pm/export.csv", params={"table": "jobs"}).status_code == 200
|
assert world.shop("clerk.clara").get("/api/pm/export.csv", params={"table": "jobs"}).status_code == 403
|
||||||
|
assert world.shop("clerk.clara").get("/api/pm/report.pdf").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
# ================================================================ PDF
|
# ================================================================ PDF
|
||||||
|
|||||||
@@ -376,7 +376,12 @@ PRECONDITION_CASES: list[tuple[Action, str, str, dict[str, Any], dict[str, Any],
|
|||||||
(A.QC_ACCEPT, "qc", "qc", {"qc_missing": ("weld exam",)}, {}, "qc_missing_empty"),
|
(A.QC_ACCEPT, "qc", "qc", {"qc_missing": ("weld exam",)}, {}, "qc_missing_empty"),
|
||||||
(A.QC_ACCEPT, "qc", "qc", {}, {"routed_to": "scrap"}, "route_given"),
|
(A.QC_ACCEPT, "qc", "qc", {}, {"routed_to": "scrap"}, "route_given"),
|
||||||
(A.QC_ACCEPT_AND_ROUTE, "qc", "qc", {}, {"dept": "WH"}, "target_dept_given"),
|
(A.QC_ACCEPT_AND_ROUTE, "qc", "qc", {}, {"dept": "WH"}, "target_dept_given"),
|
||||||
(A.FILE, "complete", "clerical", {"vclosed": False}, {}, "vclosed_if_databook"),
|
(A.FILE, "complete", "clerical", {"vclosed": False}, {}, "closed_in_visual"),
|
||||||
|
(A.FILE, "complete", "clerical", {"itp_open": True}, {}, "itp_complete"),
|
||||||
|
(A.FILE, "complete", "clerical", {"shorts_unapproved": 1}, {}, "shorts_approved"),
|
||||||
|
(A.QC_ACCEPT, "qc", "qc", {"shorts_unapproved": 2}, {}, "shorts_approved"),
|
||||||
|
(A.QC_ACCEPT, "qc", "qc", {"ncr_required": True}, {}, "ncr_given"),
|
||||||
|
(A.QC_ACCEPT_AND_ROUTE, "qc", "qc", {"ncr_required": True}, {"ncr": "NCR-26-1"}, "ncr_given"),
|
||||||
(A.WH_SEND_BACK, "warehouse", "warehouse", {}, {"reason": "short"}, "reason_given"),
|
(A.WH_SEND_BACK, "warehouse", "warehouse", {}, {"reason": "short"}, "reason_given"),
|
||||||
(A.QC_REJECT, "qc", "qc", {}, {"reason": None}, "reason_given"),
|
(A.QC_REJECT, "qc", "qc", {}, {"reason": None}, "reason_given"),
|
||||||
(A.REMOVE, "draft", "clerical", {}, {"reason": " x "}, "reason_given"),
|
(A.REMOVE, "draft", "clerical", {}, {"reason": " x "}, "reason_given"),
|
||||||
@@ -396,7 +401,7 @@ def test_each_precondition_fails_alone(
|
|||||||
x = {**FULL_EXTRA, **extra}
|
x = {**FULL_EXTRA, **extra}
|
||||||
assert check(ROLES[role], state(status), action, extra=FULL_EXTRA).ok
|
assert check(ROLES[role], state(status), action, extra=FULL_EXTRA).ok
|
||||||
d = check(ROLES[role], s, action, extra=x)
|
d = check(ROLES[role], s, action, extra=x)
|
||||||
want = "reason_required" if name == "reason_given" else "precondition"
|
want = {"reason_given": "reason_required", "ncr_given": "ncr_required"}.get(name, "precondition")
|
||||||
assert (d.ok, d.code) == (False, want)
|
assert (d.ok, d.code) == (False, want)
|
||||||
if name == "qc_missing_empty":
|
if name == "qc_missing_empty":
|
||||||
assert d.why == "Q.C. still needs: weld exam."
|
assert d.why == "Q.C. still needs: weld exam."
|
||||||
@@ -423,6 +428,76 @@ def test_vclosed_only_needed_for_the_data_book_route() -> None:
|
|||||||
assert check(ROLES["clerical"], state("complete", routed_to="shipping", vclosed=False), A.FILE).ok
|
assert check(ROLES["clerical"], state("complete", routed_to="shipping", vclosed=False), A.FILE).ok
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("route", ["databook", "shipping"])
|
||||||
|
def test_visual_packet_files_only_once_the_feed_saw_it_closed(route: str) -> None:
|
||||||
|
"""C5: a VISUAL packet waits for the feed on both routes; open work orders are named."""
|
||||||
|
clerk = ROLES["clerical"]
|
||||||
|
vis = state("complete", source_kind="visual", routed_to=route)
|
||||||
|
assert check(clerk, vis, A.FILE).ok
|
||||||
|
d = check(clerk, dataclasses.replace(vis, vclosed=False), A.FILE)
|
||||||
|
assert (d.code, d.why) == ("precondition", "Filing waits until the VISUAL feed sees the job closed.")
|
||||||
|
opened = dataclasses.replace(vis, visual_open_wos=("L670001/001", "L670002/7A"))
|
||||||
|
d = check(clerk, opened, A.FILE)
|
||||||
|
assert d.code == "precondition"
|
||||||
|
assert d.why == (
|
||||||
|
"VISUAL still shows work orders open on this packet: L670001/001, L670002/7A."
|
||||||
|
" Filing waits until VISUAL closes them."
|
||||||
|
)
|
||||||
|
assert can(clerk, opened)[1][A.FILE] == [d.why]
|
||||||
|
# never bypassed
|
||||||
|
assert check(ROLES["admin"], opened, A.FILE, it_reason=IT).code == "precondition"
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_blocked_shows_every_close_reason() -> None:
|
||||||
|
s = state("complete", vclosed=False, itp_open=True, shorts_unapproved=1)
|
||||||
|
allowed, blocked = can(ROLES["clerical"], s)
|
||||||
|
assert A.FILE not in allowed
|
||||||
|
assert blocked[A.FILE] == [MESSAGES["closed_in_visual"], MESSAGES["itp_complete"], MESSAGES["shorts_approved"]]
|
||||||
|
assert MESSAGES["itp_complete"] == "Complete the unit's ITP first. Q.C. signs it off on the ITP page."
|
||||||
|
assert MESSAGES["shorts_approved"] == "Every short must be received and approved by the supervisor first."
|
||||||
|
|
||||||
|
|
||||||
|
def test_ncr_given_is_input_and_skipped_by_can() -> None:
|
||||||
|
"""C2: after a reject with no NCR, the accept carries a well-formed NCR. can() does not block on it."""
|
||||||
|
s = state("qc", ncr_required=True)
|
||||||
|
allowed, blocked = can(ROLES["qc"], s)
|
||||||
|
assert {A.QC_ACCEPT, A.QC_ACCEPT_AND_ROUTE} <= set(allowed) and A.QC_ACCEPT not in blocked
|
||||||
|
d = check(ROLES["qc"], s, A.QC_ACCEPT, extra=FULL_EXTRA)
|
||||||
|
assert (d.ok, d.code, d.why) == (False, "ncr_required", "Enter the NCR number of the reject first.")
|
||||||
|
assert check(ROLES["qc"], s, A.QC_ACCEPT, extra={**FULL_EXTRA, "ncr": "NCR-2026-0021"}).ok
|
||||||
|
assert check(ROLES["qc"], s, A.QC_ACCEPT_AND_ROUTE, extra={**FULL_EXTRA, "ncr": "ncr-2026-0021"}).ok
|
||||||
|
# not needed when no reject is waiting for one
|
||||||
|
assert check(ROLES["qc"], state("qc"), A.QC_ACCEPT, extra=FULL_EXTRA).ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_shorts_block_the_accept_but_not_the_accept_and_route() -> None:
|
||||||
|
s = state("qc", shorts_unapproved=1)
|
||||||
|
_, blocked = can(ROLES["qc"], s)
|
||||||
|
assert blocked[A.QC_ACCEPT] == [MESSAGES["shorts_approved"]]
|
||||||
|
assert A.QC_ACCEPT_AND_ROUTE not in blocked
|
||||||
|
assert check(ROLES["qc"], s, A.QC_ACCEPT_AND_ROUTE, extra=FULL_EXTRA).ok
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", STATUSES)
|
||||||
|
def test_short_approve_is_the_department_supervisor(status: str) -> None:
|
||||||
|
"""C7: the supervisor of the packet's department (a lead is not enough), any status but filed; Admin through
|
||||||
|
the bypass with an IT reason. A signature is required."""
|
||||||
|
s = state(status)
|
||||||
|
d = check(ROLES["sup03"], s, A.SHORT_APPROVE)
|
||||||
|
if status == "filed":
|
||||||
|
assert d.code == "filed"
|
||||||
|
return
|
||||||
|
assert d.ok and d.signature == "Approve a received short"
|
||||||
|
assert A.SHORT_APPROVE in can(ROLES["sup03"], s)[0]
|
||||||
|
for role in ("lead03", "worker03", "welder", "warehouse_sup", "qc_sup", "clerical_sup", "pm", "worker06"):
|
||||||
|
d = check(ROLES[role], s, A.SHORT_APPROVE)
|
||||||
|
assert (d.code, d.why) == ("forbidden", "Your access doesn't allow this step."), role
|
||||||
|
assert check(P(["shop:06:supervisor"]), s, A.SHORT_APPROVE).code == "forbidden"
|
||||||
|
assert check(ROLES["admin"], s, A.SHORT_APPROVE).code == "it_reason_required"
|
||||||
|
a = check(ROLES["admin"], s, A.SHORT_APPROVE, it_reason=IT)
|
||||||
|
assert a.ok and a.admin_bypass
|
||||||
|
|
||||||
|
|
||||||
def test_co_staged_if_dwg_change() -> None:
|
def test_co_staged_if_dwg_change() -> None:
|
||||||
from packetstation.policy.preconditions import co_staged_if_dwg_change
|
from packetstation.policy.preconditions import co_staged_if_dwg_change
|
||||||
|
|
||||||
@@ -468,6 +543,16 @@ def test_set_vclosed_needs_the_data_book_route() -> None:
|
|||||||
assert check(ROLES["qc"], state("complete"), A.SET_VCLOSED).code == "forbidden"
|
assert check(ROLES["qc"], state("complete"), A.SET_VCLOSED).code == "forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_vclosed_is_for_a_hand_packet_only() -> None:
|
||||||
|
"""C5: the feed closes a VISUAL packet; Clerical confirms a hand packet only."""
|
||||||
|
for route in ("databook", "shipping"):
|
||||||
|
d = check(ROLES["clerical"], state("complete", source_kind="visual", routed_to=route), A.SET_VCLOSED)
|
||||||
|
assert (d.code, d.why) == (
|
||||||
|
"wrong_status",
|
||||||
|
"The VISUAL feed marks this job closed when it sees every work order closed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", STATUSES)
|
@pytest.mark.parametrize("status", STATUSES)
|
||||||
def test_set_databook_at_is_clerical_or_qc(status: str) -> None:
|
def test_set_databook_at_is_clerical_or_qc(status: str) -> None:
|
||||||
s = state(status)
|
s = state(status)
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ def test_api_co_hold_blocks_accept_not_reject(shop: Shop) -> None:
|
|||||||
assert "qc_reject" in mine["can"]
|
assert "qc_reject" in mine["can"]
|
||||||
refused(step(shop("qc.quinn"), mine, "qc_accept", routed_to="databook", sign=SIGN), 403, "co_hold", held)
|
refused(step(shop("qc.quinn"), mine, "qc_accept", routed_to="databook", sign=SIGN), 403, "co_hold", held)
|
||||||
refused(step(shop("qc.quinn"), mine, "qc_accept_and_route", dept="09", sign=SIGN), 403, "co_hold", held)
|
refused(step(shop("qc.quinn"), mine, "qc_accept_and_route", dept="09", sign=SIGN), 403, "co_hold", held)
|
||||||
out = ok(step(shop("qc.quinn"), mine, "qc_reject", ncr=NCR, sign=SIGN))
|
out = ok(step(shop("qc.quinn"), mine, "qc_reject", reason="Weld 2 undercut too deep", ncr=NCR, sign=SIGN))
|
||||||
assert out["status"] == "review"
|
assert out["status"] == "review"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -289,16 +289,20 @@ def test_reject_with_reason_only(shop: Shop) -> None:
|
|||||||
assert out["qc_last"]["ncr"] is None and out["qc_last"]["rejected_welds"] == [f"W{w2}"]
|
assert out["qc_last"]["ncr"] is None and out["qc_last"]["rejected_welds"] == [f"W{w2}"]
|
||||||
|
|
||||||
|
|
||||||
def test_reject_with_ncr_only(shop: Shop) -> None:
|
def test_reject_needs_a_reason_even_with_an_ncr(shop: Shop) -> None:
|
||||||
|
"""C2: an NCR number no longer stands in for the reason."""
|
||||||
pkt = at_qc(shop)
|
pkt = at_qc(shop)
|
||||||
q = shop("qc.quinn")
|
q = shop("qc.quinn")
|
||||||
refused(reject(shop, pkt, ncr="NCR-26-1"), 422, "invalid_ncr", "Enter an NCR number like NCR-2026-0021.")
|
refused(reject(shop, pkt, REASON, ncr="NCR-26-1"), 422, "invalid_ncr", "Enter an NCR number like NCR-2026-0021.")
|
||||||
refused(reject(shop, pkt), 400, "reason_required")
|
refused(reject(shop, pkt), 400, "reason_required")
|
||||||
refused(reject(shop, pkt, "bad"), 400, "reason_required")
|
refused(reject(shop, pkt, "bad"), 400, "reason_required")
|
||||||
|
refused(reject(shop, pkt, ncr=NCR), 400, "reason_required", "Give a reason of at least 8 characters.")
|
||||||
|
refused(reject(shop, pkt, "bad", ncr=NCR), 400, "reason_required")
|
||||||
assert fresh(q, pkt["id"])["status"] == "qc"
|
assert fresh(q, pkt["id"])["status"] == "qc"
|
||||||
out = ok(reject(shop, pkt, ncr=" ncr-2026-0021 "))
|
out = ok(reject(shop, pkt, REASON, ncr=" ncr-2026-0021 "))
|
||||||
_rejected(shop, pkt, out, NCR, NCR)
|
_rejected(shop, pkt, out, f"{NCR} · {REASON}", f"{NCR} · {REASON}")
|
||||||
assert out["qc_last"]["ncr"] == NCR and out["qc_last"]["rejected_welds"] == []
|
assert out["qc_last"]["ncr"] == NCR and out["qc_last"]["rejected_welds"] == []
|
||||||
|
assert out["ncr_required"] is False
|
||||||
row = one(
|
row = one(
|
||||||
shop.settings, "SELECT ncr FROM qc_inspections WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pkt["id"],)
|
shop.settings, "SELECT ncr FROM qc_inspections WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pkt["id"],)
|
||||||
)
|
)
|
||||||
@@ -361,7 +365,7 @@ def test_qc_home(shop: Shop) -> None:
|
|||||||
assert "itp_hold_count" in card
|
assert "itp_hold_count" in card
|
||||||
assert home["columns"]["accepted"] == [] and home["columns"]["rejected"] == []
|
assert home["columns"]["accepted"] == [] and home["columns"]["rejected"] == []
|
||||||
|
|
||||||
ok(reject(shop, to_reject, ncr=NCR))
|
ok(reject(shop, to_reject, REASON, ncr=NCR))
|
||||||
accepted = ok(accept(shop, finish_inspection(shop, waiting), "databook"))
|
accepted = ok(accept(shop, finish_inspection(shop, waiting), "databook"))
|
||||||
home = ok(q.get("/api/qc/home"))
|
home = ok(q.get("/api/qc/home"))
|
||||||
assert home["kpis"] == {"waiting": 0, "accepted_7d": 1, "rejected_7d": 1}
|
assert home["kpis"] == {"waiting": 0, "accepted_7d": 1, "rejected_7d": 1}
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ APPEND_INSERTS = {
|
|||||||
"short_receipts": (
|
"short_receipts": (
|
||||||
"INSERT INTO short_receipts (short_id, packet_id, qty, by) VALUES (%(short)s, %(packet)s, 1, %(user)s)"
|
"INSERT INTO short_receipts (short_id, packet_id, qty, by) VALUES (%(short)s, %(packet)s, 1, %(user)s)"
|
||||||
),
|
),
|
||||||
|
# C7: after short_receipts, whose row it approves
|
||||||
|
"short_approvals": (
|
||||||
|
"INSERT INTO short_approvals (receipt_id, short_id, packet_id, dept_code, by)"
|
||||||
|
" SELECT id, short_id, packet_id, '03', %(user)s FROM short_receipts WHERE short_id = %(short)s LIMIT 1"
|
||||||
|
),
|
||||||
"packet_transitions": (
|
"packet_transitions": (
|
||||||
"INSERT INTO packet_transitions (packet_id, action, by) VALUES (%(packet)s, 'send_to_warehouse', %(user)s)"
|
"INSERT INTO packet_transitions (packet_id, action, by) VALUES (%(packet)s, 'send_to_warehouse', %(user)s)"
|
||||||
),
|
),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user