Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
ci / frontend (push) Successful in 34s
ci / server (push) Successful in 7m15s
ci / image (push) Successful in 39s
ci / e2e (push) Successful in 2m4s

The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.

Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 22:46:56 -06:00
co-authored by Claude Fable 5.1
parent 39a4eb5213
commit 5a4174e0c2
81 changed files with 11532 additions and 352 deletions
+14
View File
@@ -6,6 +6,20 @@ All notable changes to this project are recorded here. The format follows [Keep
### Added ### Added
- Phase 5: the shop. The Supervisor screen (department chips for people who lead several departments, the change-order headline that cannot be closed, the numbers Ready to assign, At stations, Waiting on review and Station hours vs target, the Change orders on hold table, the station grid with a tile per station showing the job, the welder and stamp, the ticking clock against the target hours and a CHANGE ORDER tag, and the boards Ready to assign, Waiting on review, At Q.C., With warehouse and Routed on) and the Stall screen (station picker, the station hero, My packets and Submitted, waiting on supervisor).
- The shop steps on the packet page, driven by `can` and `blocked`: assign with the station select, a welder picker with search and the stamp filled from the welder's record; pull back to the queue (the clock freezes); Start work, Pause, Resume; Submit to supervisor with a PIN signature and the missing steps listed as links (checklist items, no welds on the drawing, filler metal); the supervisor's review with Data book package ready, Spool ready for inspection, the spool location and a note before Send to Q.C. (signature), Return to station with a return note, and Skip Q.C. with a route to another department (signature); a lead sees the review read-only. Send back to Warehouse and Send back to Clerical with reasons.
- The Weld map tab: the drawing rendered with pdf.js at scale min(2.2, 4200/width), pan, pinch and wheel zoom, FIT, full screen; Mark a weld then tap the joint; welds drawn as a leader line and arrowhead in pink with the id in a white circle; drag the bubble or the arrow; the weld sheet (work order, piece, type from Fillet, Groove, Fill, Seal, Tack, size default 5/16", process from GMAW, FCAW, SMAW, GTAW, SAW, stamp, the BOM items it joins) with Log weld, Save changes and Remove weld; the welds-on-this-sheet list; the weld record (filler, filler lot, preheat, notes) with field-level compare-and-set. Marks from an earlier department show as carried.
- The Heat map tab: the heat table (HEAT NUMBERS, ITEM and HEAT NO., at most 12 rows per group side by side, live heats from the Materials tab, a missing heat as a red dash) placed on a sheet, dragged to move, A- and A+ in 0.15 steps from 0.6 to 2.1, removed; older heat tags in amber shown only when the sheet has no table; a packet with no heat map and no raw material of its own shows the heat map of a sibling packet in the same unit with the same drawing, read-only, with a note.
- The Checklist tab: the department's checklist (weld departments: dwg, heat, wps, fit, pre before welding and vt, stamp, clean, qty after; other departments: dwg, prev, mat before and work, insp, qty when done) on the left and the weld record on the right. Each tick records done, when, who and the stamp. The templates live in the `checklist_templates` settings row (migration 0006).
- VOID stamps on voided drawing pages in the viewer (diagonal VOID, the void text, a red border); a VOID sheet refuses new marks.
- Change orders in the shop: the Changes tab shows CO-n with its items, the required departments and who verified; a supervisor of a pending department verifies with a note and a PIN signature (insert-only). Clerical's approval of a feed change order applies the staged change: new pages added, old pages voided, rev and drawing set, work orders remapped, the BOM merged keeping heat, N/A, issued, location, pulled and the short on matching lines (vkey, else item and part), and the heat table copied to the new sheet. Required departments are the ones the packet touched, without Clerical; a minor change order needs none. The hold texts read "CO-2: Clerical is reviewing a change from VISUAL. Held until Clerical approves or rejects it." and "Change order CO-2 must be verified first by: 03 Weld Parts."
- The station clock is kept on the server (start, pause, resume, folded on pull back and submit); the read model carries the live elapsed time and `server_now`, and no client value is ever stored. VISUAL labor ticket hours from the feed cache are summed per work order and shown beside the clock.
- Presence: the packet page shows who else has it open ("Also here: ..."), from the event stream.
- New endpoints: `GET /api/shop/board`, `GET /api/shop/stations`, `GET /api/shop/stall`, `GET /api/departments/{code}/people`. Assign checks that the welder belongs to the department and that a limited worker's station is allowed. Welds and heat tags carry a label position (`bfx`, `bfy`); the heat table placement carries `carried_from`.
- Packet history texts as the old build: "Sent to Stall 3", "Pulled back from Stall 3", "Work started", "Submitted to supervisor", "Sent to Q.C.", "Returned to Stall 3", "Routed to 09 Hydro", "Weld W4 marked", "Heat table placed on drawing", "Change order CO-2 verified by Weld Parts".
- Tests: every shop step as every role and status against a policy oracle, the preconditions, clock arithmetic, server-assigned weld and heat tag numbers under concurrent inserts, carried marks on routing, change-order approval applying the staged change and the BOM merge rules, verification per department and the hold; frontend tests for the canvas geometry, the heat table layout, the clock, the checklist, the weld sheet, the shop screens and the role switcher; Playwright scenarios for assign, start, submit, review and send to Q.C., the heat table, the change-order banner and verify, custody, two users at once, and the skip-Q.C. route.
- docs/SCHEMA.md, POLICY.md, SECURITY.md and FEED.md updated.
- Phase 4: the Warehouse. The Warehouse home with the numbers (Waiting to pull, Being pulled, On hold, Ready for production, Open shorts, Released last 24 h), the Open shorts table with Receive, the find bar, and the task board: Waiting to pull, Being pulled, On hold, Pulled, ready for production, and Released to shop (3 days) built from the release transitions. Cards are grouped by department, oldest first by the day they were sent then VISUAL's release date, and move by button (Start pulling, Hold, Mark pulled with a warning when lines remain, Resume pulling, Back to pulling, Release…) or by dragging; a drop on the released column is refused. Hold asks for one of five reasons and a note ("Other" needs the note). Every role sees the real stage on a warehouse packet: the pill reads WH · waiting, being pulled, on hold or pulled, with who, how long and the hold reason under it. - Phase 4: the Warehouse. The Warehouse home with the numbers (Waiting to pull, Being pulled, On hold, Ready for production, Open shorts, Released last 24 h), the Open shorts table with Receive, the find bar, and the task board: Waiting to pull, Being pulled, On hold, Pulled, ready for production, and Released to shop (3 days) built from the release transitions. Cards are grouped by department, oldest first by the day they were sent then VISUAL's release date, and move by button (Start pulling, Hold, Mark pulled with a warning when lines remain, Resume pulling, Back to pulling, Release…) or by dragging; a drop on the released column is refused. Hold asks for one of five reasons and a note ("Other" needs the note). Every role sees the real stage on a warehouse packet: the pill reads WH · waiting, being pulled, on hold or pulled, with who, how long and the hold reason under it.
- The pull table on the Materials tab for the warehouse: Pulled (the first tick starts the pulling stage), issued quantity, location, heat number typed or scanned with Enter moving to the next line, Short (record, edit, Not short after all) with the seven reasons, quantity, PO or requisition, ETA and note, receive with quantity, heat and note as an append-only receipt, N/A for parts with no traceability, and an MTR (PDF, PNG or JPG) per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays disabled with the server's reasons until every line not N/A is pulled or on an open short and every pulled line has a heat or is N/A or a sub-assembly. - The pull table on the Materials tab for the warehouse: Pulled (the first tick starts the pulling stage), issued quantity, location, heat number typed or scanned with Enter moving to the next line, Short (record, edit, Not short after all) with the seven reasons, quantity, PO or requisition, ETA and note, receive with quantity, heat and note as an append-only receipt, N/A for parts with no traceability, and an MTR (PDF, PNG or JPG) per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays disabled with the server's reasons until every line not N/A is pulled or on an open short and every pulled line has a heat or is N/A or a sub-assembly.
- `GET /api/warehouse/board` (numbers, the five columns, the open shorts). Board cards carry the warehouse stage with who and when, the sent date, VISUAL's release date and the pull counts. - `GET /api/warehouse/board` (numbers, the five columns, the open shorts). Board cards carry the warehouse stage with who and when, the sent date, VISUAL's release date and the pull counts.
+4 -3
View File
@@ -6,9 +6,9 @@ This repo is the rebuild of Packet Station as a server application: Docker Compo
## Status ## Status
**Rebuild in progress. Phase 4 done: the Warehouse screen, the pull table, shorts and MTRs, and the hardened upload pipeline.** **Rebuild in progress. Phase 5 done: the Supervisor and Stall screens, the weld map, the heat table, the station clock and change-order verification.**
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. Every packet write goes through the server policy. The shop and Q.C. screens are still placeholders; their API exists. The old build on `M:\Production\Packet Station` stays the live system until go-live. The new system starts empty; nothing is migrated from M:. People can sign in, set up projects and units, make hand drafts, attach a drawing, and send a packet to the warehouse, a department or Q.C. The worker reads released work orders from VISUAL on a schedule, makes and updates drafts, opens change orders on packets already in the shop, looks drawings up in M-Files and attaches them at the rev VISUAL calls. Clerical sees it all on the From VISUAL panel and in the Flagged and Changes to approve columns; Admin sets the feed up on the Feed tab. Warehouse works its task board (waiting, being pulled, on hold, pulled, released), pulls parts line by line with heat numbers, shorts, receipts and MTRs, and releases to the supervisor once every line is pulled or short and every pulled line has a heat. A supervisor assigns the packet to a station and a welder, the welder starts the clock, marks welds on the drawing, places the heat table, finishes the checklist and submits with a PIN signature, and the supervisor sends it to Q.C. or routes it to another department with the marks carried. Department supervisors verify change orders with a signature. Every packet write goes through the server policy. The Q.C. screen is still a placeholder; its API exists. The old build on `M:\Production\Packet Station` stays the live system until go-live. The new system starts empty; nothing is migrated from M:.
| Area | Exists yet? | | Area | Exists yet? |
|---|---| |---|---|
@@ -18,7 +18,8 @@ People can sign in, set up projects and units, make hand drafts, attach a drawin
| Schema, policy engine, packet API, projects and units, assets, Clerical screen, packet page | Yes (phase 2, verified against PostgreSQL and the Samba DC in the CI stack on docker-1) | | Schema, policy engine, packet API, projects and units, assets, Clerical screen, packet page | Yes (phase 2, verified against PostgreSQL and the Samba DC in the CI stack on docker-1) |
| VISUAL feed, M-Files drawings, change orders from the feed, From VISUAL panel, Admin Feed tab and resource map | Yes (phase 3, run once against VISUAL DEV and the vault from a developer machine; fixture-driven in CI) | | VISUAL feed, M-Files drawings, change orders from the feed, From VISUAL panel, Admin Feed tab and resource map | Yes (phase 3, run once against VISUAL DEV and the vault from a developer machine; fixture-driven in CI) |
| Warehouse screen, pull table, shorts and receipts, MTRs, release gate, upload kinds, optional ClamAV, storage alert | Yes (phase 4, verified in the CI stack on docker-1) | | Warehouse screen, pull table, shorts and receipts, MTRs, release gate, upload kinds, optional ClamAV, storage alert | Yes (phase 4, verified in the CI stack on docker-1) |
| Shop, Q.C., close and file, PDFs | API only (phase 2); screens in phases 5 to 7 | | Supervisor and Stall screens, weld map, heat table, station clock, change-order verification | Yes (phase 5, verified in the CI stack on docker-1) |
| Q.C., close and file, PDFs | API only (phase 2); screens in phases 6 and 7 |
| Project Management, Admin, notifications, backups | No (phases 8 and 9) | | Project Management, Admin, notifications, backups | No (phases 8 and 9) |
| Regression bench and go-live | No (phase 10) | | Regression bench and go-live | No (phase 10) |
+13 -1
View File
@@ -414,11 +414,23 @@ For CI and local runs the worker can read files instead of VISUAL and M-Files:
Real job data never enters git or CI. Recordings from VISUAL DEV stay local, in `tests/fixtures/local/` and `recordings/`. Real job data never enters git or CI. Recordings from VISUAL DEV stay local, in `tests/fixtures/local/` and `recordings/`.
## Ticket hours beside the station clock
The packet read model has a `labor` field from `visual_labor`. It joins the packet's work orders on (`wo_base`, `lot`, `split`, `sub`, `seq`) and sums per work order:
- `hours` and `tickets`: the totals over the packet's work orders.
- `per_wo`: one entry per work order leg, with `wo_base`, `lot`, `split`, `sub`, `seq`, `hours`, `tickets` and `last_ticket`.
- `observed_at`: when the worker last read the tickets.
`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.
## What the screens read ## What the screens read
Screens read PostgreSQL only, never VISUAL or M-Files per request: Screens read PostgreSQL only, never VISUAL or M-Files per request:
- Packets, flags, work order status, closed and cancelled state, ticket hours and the chosen order come from the packet tables and `packet_visual_state`. - Packets, flags, work order status, closed and cancelled state, ticket hours and the chosen order come from the packet tables and `packet_visual_state`. The ticket hours beside the station clock come from `visual_labor` (see "Ticket hours beside the station clock").
- The From VISUAL panel reads `/api/feed/overview`: pause state, next run, the heartbeat, the last ten feed and drawing runs, the ignored work orders, unmapped resources and projects without names. - The From VISUAL panel reads `/api/feed/overview`: pause state, next run, the heartbeat, the last ten feed and drawing runs, the ignored work orders, unmapped resources and projects without names.
- Admin reads the cache tables (`/api/feed/batches`, `/api/feed/changes`) for "what VISUAL changed". - Admin reads the cache tables (`/api/feed/batches`, `/api/feed/changes`) for "what VISUAL changed".
- The project page reads the cached orders from `visual_order_links` and `visual_orders`. - The project page reads the cached orders from `visual_order_links` and `visual_orders`.
+7 -1
View File
@@ -215,7 +215,7 @@ Conventions: identity bigints except users (uuid = AD objectGUID); Postgres enum
Need an answer before the named phase. Defaults apply if none arrives in time. Need an answer before the named phase. Defaults apply if none arrives in time.
1. **Station timer** (Seth, before phase 5): keep, cross-check against labor tickets, or drop. Default: keep the timer, show ticket hours beside it. 1. **Station timer** (Seth, before phase 5): keep, cross-check against labor tickets, or drop. Default: keep the timer, show ticket hours beside it. Phase 5 (2026-10-16) applied the default: the station timer is the clock of record and the VISUAL ticket hours are shown beside it.
2. **PDFs on the server** (confirm before phase 7): reportlab + pypdf + pypdfium2. Default: yes. 2. **PDFs on the server** (confirm before phase 7): reportlab + pypdf + pypdfium2. Default: yes.
3. **Frontend stack** (confirm before phase 1): TypeScript + React + Vite. Default: yes. Plain JavaScript would not be faster for parity: the old 7,000 lines are screen code that must be rewritten either way, and typed API models cut rework. 3. **Frontend stack** (confirm before phase 1): TypeScript + React + Vite. Default: yes. Plain JavaScript would not be faster for parity: the old 7,000 lines are screen code that must be rewritten either way, and typed API models cut rework.
4. **PIN defaults** (confirm): 6 to 12 digits, five attempts, 10-hour shop session, device cookie. Genuinely open (Seth and Q.C., before phase 6): whether Q.C. accept and filing require the domain password even with a PIN set. Default: PIN is enough. IT: device certificate later. 4. **PIN defaults** (confirm): 6 to 12 digits, five attempts, 10-hour shop session, device cookie. Genuinely open (Seth and Q.C., before phase 6): whether Q.C. accept and filing require the domain password even with a PIN set. Default: PIN is enough. IT: device certificate later.
@@ -245,6 +245,12 @@ From phase 4 (2026-10-16), for Seth to veto:
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). 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).
23. The quota alert: default a `notification_events` row (phase 9 builds the delivery on this table) at 85 percent of the assets volume. 23. The quota alert: default a `notification_events` row (phase 9 builds the delivery on this table) at 85 percent of the assets volume.
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.
25. The old build's manual "copy marks to the new sheet" button on a change order is not ported. On a page replacement only the heat table placement is copied to the new page. Welds and heat tags stay on the VOID page. Default: not ported in phase 5; a follow-up.
26. Checklist templates live in the `checklist_templates` settings row, with the old lists as defaults. Default: no screen edits them in phase 5; Admin editing comes in phase 9.
## Verification ## Verification
- Every phase: `pytest` server suite, `npm test` and `npm run build` for the frontend, `docker compose -f deploy/compose.ci.yml up` with the Samba DC and the Playwright scenarios for that phase, `packetstation doctor` inside the image. - Every phase: `pytest` server suite, `npm test` and `npm run build` for the frontend, `docker compose -f deploy/compose.ci.yml up` with the Samba DC and the Playwright scenarios for that phase, `packetstation doctor` inside the image.
+142 -2
View File
@@ -147,7 +147,7 @@ Each precondition is a named predicate over the packet state and the request inp
| short_open | at least one BOM line has an open short (the old whCanReceive) | No part is short on this packet. | | short_open | at least one BOM line has an open short (the old whCanReceive) | No part is short on this packet. |
| welder_set (input) | the request gives station_id and welder_id | Pick a station and a welder. | | welder_set (input) | the request gives station_id and welder_id | Pick a station and a welder. |
| checklist_complete | every key of the department checklist is done | Finish the checklist first. | | checklist_complete | every key of the department checklist is done | Finish the checklist first. |
| welds_recorded | at least one weld, in a weld department | Record at least one weld first. | | welds_recorded | at least one weld in the packet's current department, in a weld department | Record at least one weld first. |
| filler_set | filler metal entered, in a weld department | Enter the filler metal first. | | filler_set | filler metal entered, in a weld department | Enter the filler metal first. |
| qc_book_spool_location (input) | book and spool confirmed and a location given | Confirm the book and the spool, and give the location. | | qc_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). |
@@ -297,6 +297,142 @@ The `action` column of `packet_log` for each warehouse write. `{item}` is the BO
When a Pulled tick starts pulling by itself, there is no separate stage row in the log: the line's own row gets ` · started pulling` or ` · pulling resumed` on its note. Every write above also writes its audit row through the write pipeline, with `detail.item`, `wh_from` and `wh_to` as before, and `detail.qty` on short writes. When a Pulled tick starts pulling by itself, there is no separate stage row in the log: the line's own row gets ` · started pulling` or ` · pulling resumed` on its note. Every write above also writes its audit row through the write pipeline, with `detail.item`, `wh_from` and `wh_to` as before, and `detail.qty` on short writes.
## Shop (phase 5)
### Who takes each step
| Action | Who | From | Notes |
|---|---|---|---|
| assign | Lead or supervisor of the packet's department (DEPT_LEAD) | ready | Station and welder checked at assign time (below) |
| unassign | DEPT_LEAD | assigned | Clears station and welder, folds the clock |
| pull_back | DEPT_LEAD | in_progress | Clears station and welder, folds the clock |
| start | Lead or supervisor of the department, or the assigned welder within their station limits (WORKS_ON) | assigned | Starts the clock |
| submit | WORKS_ON | assigned, in_progress | Stops the clock. Signature "Submit work to the supervisor" |
| return_to_station | Supervisor of the department (DEPT_SUP) | review | Reason |
| send_to_qc | DEPT_SUP | review | Signature "Send to Q.C.". Opens the inspection |
| route_from_review | DEPT_SUP | review | Signature "Route to another department". Closes the stage and opens one in the target department |
| send_back_to_warehouse | DEPT_LEAD | ready | Reason |
| return_to_clerical | DEPT_LEAD | ready, only when wh_skipped | Reason |
A worker who is not the assigned welder cannot start or submit. Neither can the assigned welder when their station limits leave out the packet's station. Every step sends `expect: {status, dept}`, so a stale screen gets 409 `stale_step` "Someone else already took this step."
### Assign checks
ASSIGN checks the station and the welder on the server, at assign time:
| Check | Refusal |
|---|---|
| The station is an active station of the packet's department | 422 `invalid_station` |
| The welder is an active user with a shop role (worker, lead or supervisor) in the packet's department | 422 `invalid_welder` "Pick a welder in this department." |
| A worker with station limits (no lead or supervisor role in the department) is limited to the chosen station | 422 `station_limit` "{name} is limited to {station names}." |
| A `stamp` in the body matches the stamp pattern | 422 `invalid_stamp` |
The stamp in the body overrides the user's `welder_stamp` for this assignment. Without it, the user's stamp is used. The packet and the stage row both get the station, the welder and the stamp. `GET /api/departments/{code}/people` gives the assign sheet its welder list, with each person's level and station limits.
### The lead's review
A lead holds a packet in review (see "Custody"), but return_to_station, send_to_qc and route_from_review are the supervisor's. The server refuses them to a lead with 403 `forbidden`. The screen shows the lead the review read-only: "A supervisor for 03 Weld Parts reviews it and sends it on."
### Preconditions on the shop steps
| Step | Precondition | Passes when | Message |
|---|---|---|---|
| assign | welder_set (input) | the request gives station_id and welder_id | Pick a station and a welder. |
| submit | checklist_complete | every key of the department's checklist is done | Finish the checklist first. |
| submit | welds_recorded | at least one live weld whose `dept_code` is the packet's current department, in a weld department | Record at least one weld first. |
| submit | filler_set | filler metal entered on the open stage, in a weld department | Enter the filler metal first. |
| send_to_qc | qc_book_spool_location (input) | book and spool confirmed and a location given | Confirm the book and the spool, and give the location. |
| return_to_station, send_back_to_warehouse, return_to_clerical | reason_given (input) | a reason of at least 8 characters after cleaning | Give a reason of at least 8 characters. |
| route_from_review | target_dept_given (input) | a shop department different from the packet's | Choose a department. / Choose a different department. |
welds_recorded counts only the current department's welds. After a route, the new department needs a weld of its own before it can submit. The welds from earlier departments are carried marks (see [SCHEMA.md](SCHEMA.md), "Carried marks").
### Change-order hold
An open change order holds these steps: WH_RELEASE, SUBMIT, SEND_TO_QC, ROUTE_FROM_REVIEW, QC_ACCEPT, QC_ACCEPT_AND_ROUTE and FILE. It does not hold ASSIGN, START, the clock or the edits, so the shop keeps working while the departments verify. A change order is open when it is approved with departments still to verify, or pending with hold on (`v_open_cos`).
The hold text (the `co_hold` refusal and the `blocked` reason):
| Change order | Text |
|---|---|
| Pending, with hold | `CO-2: Clerical is reviewing a change from VISUAL. Held until Clerical approves or rejects it.` |
| Approved, departments pending | `Change order CO-2 must be verified first by: 03 Weld Parts, 09 Hydro.` |
With several open change orders, the texts are joined by a space, one sentence each. The id is `CO-<co_no>` everywhere.
### Change-order verification
CO_VERIFY is the supervisor of the department named in the request: a shop Supervisor for a shop code, the WH or Q.C. Supervisor for WH or QC. A lead cannot verify (403 `forbidden`). Clerical never verifies. Each verification needs a signature ("Verify a change order for the department") and is an insert into `co_verifications`. A second one for the same department is 409 `already_verified`. A department the change order does not need is 422 `invalid_dept`. A verification cannot be withdrawn.
The required departments on approval are the departments the change touches, without Clerical and PM. A minor change order needs none.
### The clock
CLOCK is the holder's in the shop statuses. The ops are `start`, `pause` and `resume`. An op in the wrong state is 409 `clock_state`. The server keeps the time with the database clock. No request sets the elapsed time. START starts the clock when it is not running. SUBMIT stops it. UNASSIGN and PULL_BACK fold the running time in, so the clock freezes. See [SCHEMA.md](SCHEMA.md), "The clock".
### Marks on the drawing
Welds (EDIT_WELDS), heat tags (EDIT_HEATS) and the heat table (EDIT_HEAT_TABLE) follow the rule table: the holder in the shop statuses, and for heat tags and the heat table also the warehouse while it holds the packet. The server hands out `W<n>` and `H<n>` from the packet counters under the packet row lock.
A VOID sheet refuses marks. A new weld or heat tag on a voided page, or a move of one already there, is 422 `void_page` "This sheet is VOID. Mark the current sheet." The marks on a void page stay and are shown, but cannot be edited.
### Heat map borrowing
A packet borrows a sibling's heat map when all of these hold (the old heatSrc):
- It has no heat map of its own: no heat table placement and no heat tag.
- It has no raw material of its own: every BOM line is N/A or a sub-assembly, or the BOM is empty.
- It has a drawing.
The sibling is a live packet in the same unit, with a different id and the same drawing (compared upper-case with white space removed), that has a heat map. The same rev is preferred, then the oldest. The read model gives it as `heat_src`. It is read-only: nothing on it can be written through the borrowing packet. The screen says "Heat map from <op> on this unit (same drawing). Record heats there."
### Shop screens
- `GET /api/shop/board` covers the shop departments where the person is lead or supervisor. Admin sees every active shop department. A department outside that scope is 403 `forbidden`. A person who leads no department gets 403 `forbidden` "You do not lead a department." The change order list also shows open change orders anywhere that wait on one of the person's departments.
- `GET /api/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/shop/stall?station_id=` is 403 for a station not in that list. Admin passes.
### Presence
The packet page shows who else has the packet open. Presence comes over SSE and carries the user id, display name, screen and packet id only.
### History texts
The `action` and `note` columns of `packet_log` for each shop write. A department is shown as code and name (`03 Weld Parts`), except in the verify text, which uses the name only. The station name is as stored (`Stall 3`).
| Write | action | note |
|---|---|---|
| assign | `Sent to Stall 3` | `Andy Welder [75]` (the stamp in brackets when set) |
| unassign | `Taken off Stall 3` | |
| pull_back | `Pulled back from Stall 3` | |
| start | `Work started` | `Stall 3` |
| submit | `Submitted to supervisor` | `4 welds · 1.5 h` |
| return_to_station | `Returned to Stall 3` | the reason |
| send_to_qc | `Sent to Q.C.` | `Spool at Bay 2 · note` |
| route_from_review | `Routed to 09 Hydro` | `Supervisor, Q.C. skipped · from 03 Weld Parts` |
| send_back_to_warehouse | `Sent back to the warehouse` | the reason |
| return_to_clerical | `Sent back to Clerical` | the reason |
| Weld added | `Weld W4 marked` | `Fillet 5/16" · items 1 + 2 · L662001 #1` |
| Weld edited | `Weld W4 changed` | the changed fields, `type Groove · items 1 + 3` |
| Weld moved (position only) | `Weld W4 moved` | |
| Weld removed | `Weld W4 removed` | the reason |
| Heat tag | `Heat tag H2 placed`, `Heat tag H2 changed`, `Heat tag H2 moved`, `Heat tag H2 removed` | `item 3` |
| Heat table placed | `Heat table placed on drawing` | the page's dwg, else `sheet 2` |
| Heat table moved | `Heat table moved` | the same |
| Heat table resized | `Heat table resized` | `A+` or `A-` and the new size, `A+ 1.15` |
| Heat table removed | `Heat table removed from drawing` | the same |
| Heat table copied on a change order | `Heat table copied to new sheet` | `from the VOID copy, check its position on the new revision` |
| Checklist | `Checked: <item text>`, `Unchecked: <item text>` | |
| Stage fields | `Weld record changed` | the changed fields, `filler ER70S-6 · filler lot 1234` |
| Clock | `Clock started`, `Paused`, `Resumed` | |
| Change order issued | `Change order CO-2 issued` | |
| Change order approved | `Change order CO-2 approved` | |
| Change order rejected | `Change order CO-2 rejected` | the reason |
| Change order hold | `Change order CO-2 holds the packet`, `Change order CO-2 no longer holds the packet` | |
| Change order verified | `Change order CO-2 verified by Weld Parts` | `note · still waiting on Hydro`, or `note · change order fully verified` |
The submit hours are the stage's `clock_elapsed_ms` after the stop, with one decimal.
## Worker writes (phase 3) ## Worker writes (phase 3)
The worker has no session. It writes through the same policy as a person, as the system principal `Principal(user_id=None, system="visual-feed")`. Each write locks the packet row (`FOR UPDATE SKIP LOCKED`; a row locked by someone else is left for the next run), builds the packet state, runs `policy.check()`, makes its change, bumps `version`, and writes a `packet_log` row (by nobody, by name "VISUAL feed", role `system`), an audit row (`packet.<action>`, role `system`) and a change event. See [FEED.md](FEED.md) for when each write happens. The worker has no session. It writes through the same policy as a person, as the system principal `Principal(user_id=None, system="visual-feed")`. Each write locks the packet row (`FOR UPDATE SKIP LOCKED`; a row locked by someone else is left for the next run), builds the packet state, runs `policy.check()`, makes its change, bumps `version`, and writes a `packet_log` row (by nobody, by name "VISUAL feed", role `system`), an audit row (`packet.<action>`, role `system`) and a change event. See [FEED.md](FEED.md) for when each write happens.
@@ -352,7 +488,7 @@ The old policy was looser than the old screens in places. The rebuild follows th
| 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. Only the Data Book route needs the VISUAL close before filing. | 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 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 |
| The old screen asked for a heat number on every pulled line before release, but the policy did not check it. | WH_RELEASE also needs a heat on every pulled line that issued pieces, unless the line is N/A or a sub-assembly (`heats_recorded`), checked on the server. | Traceability is the point of the pull. The screen alone could be skipped. | Yes | | The old screen asked for a heat number on every pulled line before release, but the policy did not check it. | WH_RELEASE also needs a heat on every pulled line that issued pieces, unless the line is N/A or a sub-assembly (`heats_recorded`), checked on the server. | Traceability is the point of the pull. The screen alone could be skipped. | Yes |
| Hold reasons and short reasons were any text the screen sent. | Both are closed sets checked on the server. "Other" on a hold needs a note. | The board and the shorts table group and report on the reason, so it must be one of the list. | Yes | | Hold reasons and short reasons were any text the screen sent. | Both are closed sets checked on the server. "Other" on a hold needs a note. | The board and the shorts table group and report on the reason, so it must be one of the list. | Yes |
@@ -364,3 +500,7 @@ The old policy was looser than the old screens in places. The rebuild follows th
| Approving a different M-Files rev was open to a Clerical lead. | rev_approve needs Clerical Supervisor, with a reason and a signature. | There is no Clerical lead AD group, and the approval overrides VISUAL. | Yes | | Approving a different M-Files rev was open to a Clerical lead. | rev_approve needs Clerical Supervisor, with a reason and a signature. | There is no Clerical lead AD group, and the approval overrides VISUAL. | Yes |
| The Sync on a Clerical PC skipped removed packets ("removed here, not re-made"), but only as the browser saw them. | A removed packet's key is never recreated, whatever VISUAL says. The match runs on the server across live and removed rows, in the feed's transaction. | A removal is a decision. The feed does not undo it. | No | | The Sync on a Clerical PC skipped removed packets ("removed here, not re-made"), but only as the browser saw them. | A removed packet's key is never recreated, whatever VISUAL says. The match runs on the server across live and removed rows, in the feed's transaction. | A removal is a decision. The feed does not undo it. | No |
| The Sync saved through the same packet document that held the shop's work, so nothing but the code kept it off the shop's fields. | The feed never writes shop-side tables. Packets past draft only get a change order, observed VISUAL state and drawing flags. | The shop's record is the shop's. | No | | The Sync saved through the same packet document that held the shop's work, so nothing but the code kept it off the shop's fields. | The feed never writes shop-side tables. Packets past draft only get a change order, observed VISUAL state and drawing flags. | The shop's record is the shop's. | No |
| The assign sheet only offered welders of the department and filtered the stations by the welder's limits. The policy checked neither. | ASSIGN checks on the server that the welder has a shop role in the packet's department, and that a limited worker's station is one of their stations. | The picker alone could be skipped. | Yes |
| The old screen asked for a weld of the current department before submit, but the policy did not check welds. | welds_recorded counts the welds of the packet's current department, on the server. After a route the new department marks its own. | Each department records its own welds. | Yes |
| A mark could be placed on a VOID sheet. | A VOID sheet refuses new marks and moves (422 `void_page`). The marks already there stay. | Marks belong on the current revision. | Yes |
| The station clock ran in the browser, and the elapsed time was saved from the browser. | The server keeps the clock with the database time. No request sets the elapsed time. | The clock is a record. A browser clock can be wrong or changed. | No |
+87 -6
View File
@@ -1,6 +1,6 @@
# Packet Station schema # Packet Station schema
Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 9 adds the notification rules and delivery. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema". Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 5 adds the shop columns, the checklist templates and the clock rules (migration 0006). Phase 9 adds the notification rules and delivery. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema".
## Conventions ## Conventions
@@ -95,10 +95,10 @@ 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`), `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.
- `welds`: `weld_no` (from `next_weld_no`), `page`, `fx` and `fy` (fractions of the page), `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`, `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`, `by`, `at`. Unique on (`packet_id`, `page`), written as an upsert. - `heat_table_placements`: `page`, `x`, `y`, `s`, `carried_from` (0006), `by`, `at`. Unique on (`packet_id`, `page`), written as an upsert.
- `packet_wh`: the warehouse stage history. `stage` (wh_stage), `by`, `at`, `reason`, `note`, `auto`, `closed_at`. The current row has `closed_at` null and is unique per packet. A stage change closes it and inserts the next. - `packet_wh`: the warehouse stage history. `stage` (wh_stage), `by`, `at`, `reason`, `note`, `auto`, `closed_at`. The current row has `closed_at` null and is unique per packet. A stage change closes it and inserts the next.
- `packet_transitions`: append-only. Every status change: `at`, `by`, `action`, `from_status`, `to_status`, `from_dept`, `to_dept`, `reason`, `signature_id`, `it_reason`. Replaces the old `hist` and `sentBack` lists. - `packet_transitions`: append-only. Every status change: `at`, `by`, `action`, `from_status`, `to_status`, `from_dept`, `to_dept`, `reason`, `signature_id`, `it_reason`. Replaces the old `hist` and `sentBack` lists.
- `qc_inspections`: `stage_id`, `sent_at`, `sent_by`, `book`, `spool`, `location`, `sent_note`, `checks` jsonb, `weld_exam` jsonb (weld_no to `{result, note}`), `nde_method`, `nde_result`, `nde_report`, `hardness`, `nace`, `inspector`, `stamp`, `notes`, `result`, `result_at`, `result_by`, `result_note`, `signature_id`, `row_version`, `closed_at`. At most one open inspection per packet. - `qc_inspections`: `stage_id`, `sent_at`, `sent_by`, `book`, `spool`, `location`, `sent_note`, `checks` jsonb, `weld_exam` jsonb (weld_no to `{result, note}`), `nde_method`, `nde_result`, `nde_report`, `hardness`, `nace`, `inspector`, `stamp`, `notes`, `result`, `result_at`, `result_by`, `result_note`, `signature_id`, `row_version`, `closed_at`. At most one open inspection per packet.
@@ -109,6 +109,87 @@ What the warehouse columns mean in practice (phase 4):
Every who column references `users (id)`. Every `signature_id` references `signatures (id)`. Every who column references `users (id)`. Every `signature_id` references `signatures (id)`.
## Shop (phase 5, migration 0006)
### Column additions
- `welds`: `bfx` and `bfy` (double precision, null). The label (bubble) position as fractions of the page. `fx` and `fy` stay where the arrow points.
- `heat_tags`: `bfx` and `bfy`, the same.
- `heat_table_placements`: `carried_from` (int, null). The packet page number the table was copied from on a change order page replacement.
A null `bfx` means the label has not been moved. The client then draws it at the old default offset: `bfx = clamp01(fx + (fx > 0.85 ? -0.05 : 0.05))` and `bfy = clamp01(fy + (fy < 0.12 ? 0.07 : -0.07))`, where clamp01 keeps a value between 0.01 and 0.99.
The API checks the values the columns have no CHECK for:
| Value | Rule | Refusal |
|---|---|---|
| `fx`, `fy`, `bfx`, `bfy` | 0 to 1, or null for `bfx` and `bfy` | 422 |
| `welds.type` | Fillet, Groove, Fill, Seal or Tack | 422 `invalid_weld` |
| `welds.process` | GMAW, FCAW, SMAW, GTAW or SAW | 422 `invalid_weld` |
| `welds.size` | `5/16"` on insert when missing | |
| `welds.piece` | 1 or more | 422 |
| `welds.wo_base` | one of the packet's work order bases | 422 `invalid_wo` |
| `heat_table_placements.x`, `y` | 0 to 0.97 | 422 `invalid_heat_table` |
| `heat_table_placements.s` | 0.6 to 2.1, in steps of 0.15 from the screen | 422 `invalid_heat_table` |
| `page` of a heat table placement | a live or voided drawing page of the packet | 422 `invalid_page` |
| `page` of a new or moved weld or heat tag | not a voided page | 422 `void_page` |
### Checklist templates
The `settings` row `checklist_templates` holds the shop checklists. Migration 0006 inserts the defaults with `ON CONFLICT DO NOTHING`, so a row already there is kept. The value:
```json
{
"weld": [{"group": "Before welding", "key": "dwg", "text": "Drawing {drawing} Rev {rev} matches the packet"}, ...],
"plain": [{"group": "Before starting", "key": "dwg", "text": "..."}, ...]
}
```
`weld` is the list for a weld department (`departments.weld` true), `plain` for every other shop department. Each entry has a `group`, a `key` and a `text`. The text may hold the placeholders `{drawing}`, `{rev}` and `{wps}`, filled from the packet.
| List | Group | Keys |
|---|---|---|
| weld | Before welding | `dwg`, `heat`, `wps`, `fit`, `pre` |
| weld | After welding | `vt`, `stamp`, `clean`, `qty` |
| plain | Before starting | `dwg`, `prev`, `mat` |
| 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.
`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.
### Carried marks
Welds, heat tags and heat table placements belong to the packet, not to the stage. Routing a packet to another department copies nothing. The read model computes a `carried` flag instead:
- A weld is carried when `stage_id` is set and is not the open stage's id. It was logged in an earlier department's stage.
- A heat tag is carried when its `at` is before the open stage's `opened_at`. The warehouse or an earlier department placed it.
The `carried_from` columns on `welds` and `heat_tags` (from 0003) are not written by routing.
There is one copy, on a change order page replacement. When a feed change order is approved and a drawing page is voided, each heat table placement on the voided page is copied to its new page with `carried_from` set to the old page number. The log says `Heat table copied to new sheet` with the note `from the VOID copy, check its position on the new revision`. Welds and heat tags stay on the void page.
### The clock
`packet_stages` has three clock columns (from 0003):
- `clock_elapsed_ms` (bigint, default 0): the stored total.
- `clock_running_since` (timestamptz): when the clock last started. Null while it is stopped.
- `clock_started_at` (timestamptz): the first start of the stage.
Only the server writes them, with the database `now()`. No request field sets them, and a value from the client is never used.
| Event | Write |
|---|---|
| START, or clock `start` | `clock_running_since = now()` when it is not running; `clock_started_at = coalesce(clock_started_at, now())` |
| clock `pause` | `clock_elapsed_ms += now() - clock_running_since`; `clock_running_since = null` |
| clock `resume` | `clock_running_since = now()` |
| PULL_BACK, UNASSIGN, SUBMIT | fold: `clock_elapsed_ms += now() - clock_running_since` when running; `clock_running_since = null` |
A clock op in the wrong state is 409 `clock_state`.
The read model gives `server_now` (the database `now()`) and `stage.clock`: `{elapsed_ms, stored_ms, running, running_since, started_at}`. `stored_ms` is the column. `elapsed_ms` is live: the stored total plus `now() - clock_running_since` while running. Each closed stage has the same object, with `running` false. The client adds the time since the fetch to `elapsed_ms` for the ticking display only.
## Change orders (phases 2 and 3) ## Change orders (phases 2 and 3)
- `change_orders`: `co_no` (from `next_co_no`; unique per packet), `origin` (co_origin), `review` (co_review), `minor`, `hold`, `held_by`, `held_at`, `ecn`, `reason`, `issued_*`, `approved_*` and `rejected_*` (each with a `*_signature_id`), `vis`, `waiting` and `staged` jsonb (feed-shaped), `target_fp`, `row_version`. At most one pending VISUAL change order per packet (unique on `packet_id` where origin is visual and review is pending), so the feed re-aims it instead of stacking new ones. - `change_orders`: `co_no` (from `next_co_no`; unique per packet), `origin` (co_origin), `review` (co_review), `minor`, `hold`, `held_by`, `held_at`, `ecn`, `reason`, `issued_*`, `approved_*` and `rejected_*` (each with a `*_signature_id`), `vis`, `waiting` and `staged` jsonb (feed-shaped), `target_fp`, `row_version`. At most one pending VISUAL change order per packet (unique on `packet_id` where origin is visual and review is pending), so the feed re-aims it instead of stacking new ones.
@@ -149,7 +230,7 @@ The app role is refused by privilege first. The migrate role owns the tables, so
## Audit, settings, jobs (phases 1 and 3) ## Audit, settings, jobs (phases 1 and 3)
- `audit_events` (migration 0001): append-only. `at`, `user_id`, `session_id`, `role_snapshot` (`system` for the worker), `action`, `subject_type`, `subject_id`, `packet_id`, `detail` jsonb, `it_reason`, `ip`. A hash chain: `row_hash` is sha256 over `prev_hash` and the canonical JSON of the row, and `prev_hash` is the row before it. BRIN index on `at`. - `audit_events` (migration 0001): append-only. `at`, `user_id`, `session_id`, `role_snapshot` (`system` for the worker), `action`, `subject_type`, `subject_id`, `packet_id`, `detail` jsonb, `it_reason`, `ip`. A hash chain: `row_hash` is sha256 over `prev_hash` and the canonical JSON of the row, and `prev_hash` is the row before it. BRIN index on `at`.
- `settings` (migration 0001): `key` (text PK), `value` jsonb, `version`, `updated_at`, `updated_by`. Phase 3 adds the row `feed`, the feed settings as one JSON object (see [FEED.md](FEED.md), "Settings"). A missing key takes its default. A save bumps `version` and sends `ps_notify('ps_settings', ...)` so the worker reschedules. - `settings` (migration 0001): `key` (text PK), `value` jsonb, `version`, `updated_at`, `updated_by`. Phase 3 adds the row `feed`, the feed settings as one JSON object (see [FEED.md](FEED.md), "Settings"). Phase 5 adds the row `checklist_templates` (see "Checklist templates"). A missing key takes its default. A save bumps `version` and sends `ps_notify('ps_settings', ...)` so the worker reschedules.
Phase 3, migration 0004: Phase 3, migration 0004:
+12 -2
View File
@@ -2,7 +2,7 @@
This document is rewritten for the server build. The old browser build's security document is in the old repo (`C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md`). The design comes from [BRIEF.md](BRIEF.md) and [PLAN.md](PLAN.md). This document is rewritten for the server build. The old browser build's security document is in the old repo (`C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md`). The design comes from [BRIEF.md](BRIEF.md) and [PLAN.md](PLAN.md).
Status: phase 4. 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. Every other control names the phase that builds it. Status: phase 5. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) and the policy controls (server policy on every write, workflow steps as compare-and-set, server-side validation and limits, IT edit reasons, the filed lock, the upload pipeline for drawings and documents) are built and tested. Phase 3 adds the worker controls (read-only VISUAL and M-Files reads, stack guard, advisory locks, redacted errors, audited system writes). No requirement ID closes in phase 3. Phase 4 completes the upload pipeline (a kind per upload with its own types and limit, XLSX and CSV by content, the optional ClamAV scan, the assets volume quota alert) and adds the warehouse rules (the heat gate on release, closed hold and short reasons, receive by custody). Phase 4 closes D5. Phase 5 adds the shop rules (the server as the clock of record, server-assigned mark numbers under the packet row lock, insert-only change-order verifications with a signature, VOID sheets never edited). Every other control names the phase that builds it.
Contents: Contents:
@@ -99,6 +99,16 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
- Short receipts go to the warehouse in any status but filed, or to the holder of the packet through the BOM edit rule. Nobody else. - Short receipts go to the warehouse in any status but filed, or to the holder of the packet through the BOM edit rule. Nobody else.
- An MTR on a line must be a PDF, PNG or JPEG asset. - An MTR on a line must be a PDF, PNG or JPEG asset.
### Shop rules (phase 5)
- The server is the clock of record. The station clock is kept in `packet_stages` with the database time. Only the server writes the clock columns. No request field sets the elapsed time.
- Weld and heat tag numbers (`W<n>`, `H<n>`) are assigned by the server from the packet counters, under the packet row lock. Two people marking at once never get the same number.
- ASSIGN checks on the server that the welder belongs to the department and that a limited worker's station is one of theirs.
- A change-order verification needs a signature and is insert-only. A second one for the same department is refused. It cannot be withdrawn.
- VOID sheets are never edited. A voided page is kept, and new marks or moves on it are refused (422 `void_page`).
- A borrowed heat map is read-only. Nothing on the sibling packet can be written through the borrowing packet.
- Presence over SSE carries display names, never other personal data. Each entry is the user id, display name, screen and packet id.
### Worker (phase 3) ### Worker (phase 3)
- The worker publishes no port and joins no proxy network. It reaches out to VISUAL and M-Files only; nothing reaches in. Admin and Clerical reach it only through rows in PostgreSQL (`job_requests`, the `feed` settings row) and NOTIFY. - The worker publishes no port and joins no proxy network. It reaches out to VISUAL and M-Files only; nothing reaches in. Admin and Clerical reach it only through rows in PostgreSQL (`job_requests`, the `feed` settings row) and NOTIFY.
@@ -171,7 +181,7 @@ The IDs come from Part 3 of the old SECURITY.md. The status follows the approved
| S3 | Closed, phase 9 (CI scanning) | | S3 | Closed, phase 9 (CI scanning) |
| S4 | Deferred to IT (pentest before go-live) | | S4 | Deferred to IT (pentest before go-live) |
| S5 | Closed, phase 2 (React escapes output; server messages are plain text; PDF export escaping comes with phase 7) | | S5 | Closed, phase 2 (React escapes output; server messages are plain text; PDF export escaping comes with phase 7) |
| S6, S7, S8 | Closed, phase 7 | | S6, S7, S8 | Closed, phase 7. PLAN.md lists S8 under phase 5, but the print stamps come with the PDFs in phase 7, so S8 closes there. |
| O1, O2 | Closed, phase 9 (pg_dump timer plus PBS; one rehearsed restore); quarterly restore IT's | | O1, O2 | Closed, phase 9 (pg_dump timer plus PBS; one rehearsed restore); quarterly restore IT's |
| O3, O4 | Closed, phase 10 | | O3, O4 | Closed, phase 10 |
| O5 | Superseded (no migration; M: retired read-only) | | O5 | Superseded (no migration; M: retired read-only) |
+2 -3
View File
@@ -56,7 +56,7 @@ describe("sign-in", () => {
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret-pass" } }); fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret-pass" } });
fireEvent.click(screen.getByRole("button", { name: "Sign in" })); fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByRole("heading", { name: "Supervisor" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "Dispatch, review, route" })).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/auth/signin/password")).toEqual([{ login: "sup.wes", password: "secret-pass" }]); expect(callsTo(fetch, "POST", "/api/auth/signin/password")).toEqual([{ login: "sup.wes", password: "secret-pass" }]);
expect(screen.getByText("Clara Clerk")).toBeInTheDocument(); expect(screen.getByText("Clara Clerk")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Account" })).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Account" })).toBeInTheDocument();
@@ -247,8 +247,7 @@ describe("role switcher", () => {
expect(within(nav).getByRole("button", { name: "Clerical" })).toHaveAttribute("aria-pressed", "true"); expect(within(nav).getByRole("button", { name: "Clerical" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(within(nav).getByRole("button", { name: "Weld Stall" })); fireEvent.click(within(nav).getByRole("button", { name: "Weld Stall" }));
expect(await screen.findByRole("heading", { name: "Weld Stall" })).toBeInTheDocument(); await waitFor(() => expect(within(nav).getByRole("button", { name: "Weld Stall" })).toHaveAttribute("aria-pressed", "true"));
expect(within(nav).getByRole("button", { name: "Weld Stall" })).toHaveAttribute("aria-pressed", "true");
}); });
it("is hidden for a user with one screen", async () => { it("is hidden for a user with one screen", async () => {
+34
View File
@@ -5,8 +5,11 @@ import type {
AttachPagesBody, AttachPagesBody,
BomLine, BomLine,
BomPatch, BomPatch,
DeptPerson,
FieldCas, FieldCas,
Guarded, Guarded,
HeatTableBody,
HeatTagBody,
LogRow, LogRow,
PacketCard, PacketCard,
PacketCreate, PacketCreate,
@@ -15,7 +18,9 @@ import type {
PacketRead, PacketRead,
ReceiptBody, ReceiptBody,
ShortBody, ShortBody,
StageFields,
TransitionBody, TransitionBody,
WeldBody,
WhStageBody, WhStageBody,
WorkOrder, WorkOrder,
} from "./types"; } from "./types";
@@ -86,4 +91,33 @@ export const packetsApi = {
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 }),
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. */
addWeld: (id: number, body: WeldBody, g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/welds`, { ...body, ...g }),
/** If-Match is the weld's row_version. */
patchWeld: (id: number, no: number, rowVersion: number, body: WeldBody, g: Guarded = {}) =>
request<PacketRead>(`${base(id)}/welds/${no}`, { method: "PATCH", body: { ...body, ...g }, headers: ifMatch(rowVersion) }),
removeWeld: (id: number, no: number, rowVersion: number, body: { reason?: string } & Guarded = {}) =>
request<PacketRead>(`${base(id)}/welds/${no}`, { method: "DELETE", body, headers: ifMatch(rowVersion) }),
addHeatTag: (id: number, body: HeatTagBody, g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/heat-tags`, { ...body, ...g }),
patchHeatTag: (id: number, no: number, rowVersion: number, body: HeatTagBody, g: Guarded = {}) =>
request<PacketRead>(`${base(id)}/heat-tags/${no}`, { method: "PATCH", body: { ...body, ...g }, headers: ifMatch(rowVersion) }),
removeHeatTag: (id: number, no: number, rowVersion: number, body: { reason?: string } & Guarded = {}) =>
request<PacketRead>(`${base(id)}/heat-tags/${no}`, { method: "DELETE", body, headers: ifMatch(rowVersion) }),
putHeatTable: (id: number, page: number, body: HeatTableBody, g: Guarded = {}) =>
api.put<PacketRead>(`${base(id)}/heat-table/${page}`, { ...body, ...g }),
removeHeatTable: (id: number, page: number, g: Guarded = {}) => api.delWithBody<PacketRead>(`${base(id)}/heat-table/${page}`, g),
/** The weld record fields with a field-level base: 409 field_conflict when someone changed one first. */
patchStage: (id: number, body: FieldCas<StageFields>) => api.patch<PacketRead>(`${base(id)}/stage`, body),
setChecklist: (id: number, key: string, done: boolean, g: Guarded = {}) =>
api.put<PacketRead>(`${base(id)}/stage/checklist/${encodeURIComponent(key)}`, { done, ...g }),
clock: (id: number, op: "start" | "pause" | "resume", g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/stage/clock`, { op, ...g }),
coVerify: (id: number, no: number, body: { dept: string; note?: string } & Guarded) =>
api.post<PacketRead>(`${base(id)}/change-orders/${no}/verify`, body),
};
/** GET /api/departments/{code}/people: the welder picker and the weld sheet's stamp. */
export const peopleApi = {
list: async (dept: string, signal?: AbortSignal): Promise<DeptPerson[]> =>
asList<DeptPerson>(await api.get<unknown>(`/api/departments/${encodeURIComponent(dept)}/people`, signal), "people"),
}; };
+155
View File
@@ -0,0 +1,155 @@
// The shop board, the stall and the department people (phase 5 contract, api/shop.py and api/departments.py).
// These types belong to the Supervisor and Stall screens. The packet page types stay in types.ts.
import { api } from "./client";
import { asList, qs } from "./query";
import type { PacketCard } from "./types";
export interface ShopClock {
/** Live on the server at the time of the answer: stored plus the running time. */
elapsed_ms: number;
running: boolean;
}
export interface ShopCo {
label: string;
/** "pending" while Clerical reviews a change from VISUAL, "approved" once it applies. */
review: string;
}
/** A card on the shop board: the phase 4 card plus the station, welder, clock and open change orders. */
export interface ShopCard extends Omit<PacketCard, "open_cos"> {
welder_stamp?: string | null;
clock?: ShopClock | null;
target_hrs?: number | null;
open_cos: ShopCo[];
return_note?: string | null;
}
export interface ShopStation {
id: number;
name: string;
packets: ShopCard[];
}
export type BoardLevel = "lead" | "supervisor" | "admin";
export interface ShopDept {
code: string;
name: string;
weld: boolean;
level: BoardLevel;
stations: ShopStation[];
}
export interface ShopKpis {
ready: number;
at_stations: number;
review: number;
elapsed_ms: number;
target_hrs: number;
}
/** One open change order on a packet in scope, or one waiting on a department of mine. */
export interface ShopCoRow {
packet_id: number;
unit_number: string;
op: string | null;
title: string | null;
dept_code: string;
co_no: number;
label: string;
review: string;
hold: boolean;
pending_depts: string[];
/** I can verify one of the pending departments. */
mine: boolean;
reason: string | null;
ecn: string | null;
issued_at: string | null;
}
export const SHOP_COLUMNS = ["ready", "review", "qc", "warehouse", "complete"] as const;
export type ShopColumn = (typeof SHOP_COLUMNS)[number];
export interface ShopBoard {
depts: ShopDept[];
kpis: ShopKpis;
cos: ShopCoRow[];
columns: Record<ShopColumn, ShopCard[]>;
}
export interface ShopBoardQuery {
/** A department code, or "all". */
dept?: string;
project_id?: number;
unit_id?: number;
}
export interface StationOption {
dept_code: string;
dept_name: string;
id: number;
name: string;
}
export interface StallAnswer {
station: { id: number; name: string; dept_code: string; dept_name: string };
mine: ShopCard[];
submitted: ShopCard[];
}
export interface DeptPerson {
id: string;
display_name: string;
stamp: string | null;
level: "worker" | "lead" | "supervisor";
station_limits: number[];
}
/** A card with every list present, so the screens never trip on a missing field. */
export function normaliseCard(raw: ShopCard): ShopCard {
return { ...raw, open_cos: Array.isArray(raw.open_cos) ? raw.open_cos : [] };
}
function cards(raw: unknown): ShopCard[] {
return Array.isArray(raw) ? (raw as ShopCard[]).map(normaliseCard) : [];
}
/** Every department, column and number present, even when the server leaves one out. */
export function normaliseBoard(raw: Partial<ShopBoard> | null): ShopBoard {
const cols = (raw?.columns ?? {}) as Partial<Record<ShopColumn, unknown>>;
const columns = Object.fromEntries(SHOP_COLUMNS.map((c) => [c, cards(cols[c])])) as ShopBoard["columns"];
const depts = (Array.isArray(raw?.depts) ? raw.depts : []).map((d) => ({
...d,
stations: (Array.isArray(d.stations) ? d.stations : []).map((s) => ({ ...s, packets: cards(s.packets) })),
}));
return {
depts,
kpis: { ready: 0, at_stations: 0, review: 0, elapsed_ms: 0, target_hrs: 0, ...(raw?.kpis ?? {}) },
cos: Array.isArray(raw?.cos) ? raw.cos.map((c) => ({ ...c, pending_depts: Array.isArray(c.pending_depts) ? c.pending_depts : [] })) : [],
columns,
};
}
export async function getBoard(q: ShopBoardQuery = {}, signal?: AbortSignal): Promise<ShopBoard> {
return normaliseBoard(await api.get<Partial<ShopBoard> | null>(`/api/shop/board${qs({ ...q })}`, signal));
}
export async function getStations(signal?: AbortSignal): Promise<StationOption[]> {
return asList<StationOption>(await api.get<unknown>("/api/shop/stations", signal), "stations");
}
export async function getStall(stationId: number, signal?: AbortSignal): Promise<StallAnswer> {
const raw = await api.get<Partial<StallAnswer> | null>(`/api/shop/stall${qs({ station_id: stationId })}`, signal);
return {
station: raw?.station ?? { id: stationId, name: "", dept_code: "", dept_name: "" },
mine: cards(raw?.mine),
submitted: cards(raw?.submitted),
};
}
export async function getPeople(dept: string, signal?: AbortSignal): Promise<DeptPerson[]> {
return asList<DeptPerson>(await api.get<unknown>(`/api/departments/${encodeURIComponent(dept)}/people`, signal), "people");
}
export const shopApi = { getBoard, getStations, getStall, getPeople };
+195 -7
View File
@@ -227,10 +227,20 @@ export interface PacketPage {
pn: number; pn: number;
dwg: string | null; dwg: string | null;
rev: string | null; rev: string | null;
void: { co_id: number; at: string; new_n: number | null } | null; void: PageVoid | null;
url: string; url: string;
} }
/** A voided drawing page. Phase 5 adds the CO number, who voided it and the page that replaced it. */
export interface PageVoid {
co_id: number;
at: string;
new_n: number | null;
co_no?: number | null;
by?: WhoRef | null;
new?: { n: number; dwg: string | null; rev: string | null } | null;
}
export interface BomShort { export interface BomShort {
id: number; id: number;
qty: number | string; qty: number | string;
@@ -327,11 +337,11 @@ export interface PacketRead {
work_orders: WorkOrder[]; work_orders: WorkOrder[];
pages: PacketPage[]; pages: PacketPage[];
bom: BomLine[]; bom: BomLine[];
welds: unknown[]; welds: Weld[];
heat_tags: unknown[]; heat_tags: HeatTag[];
heat_table: unknown[]; heat_table: HeatTablePlacement[];
stage: Record<string, unknown> | null; stage: Stage | null;
stages: unknown[]; stages: Stage[];
wh: WhInfo | null; wh: WhInfo | null;
qc: Record<string, unknown> | null; qc: Record<string, unknown> | null;
qc_history: unknown[]; qc_history: unknown[];
@@ -346,6 +356,178 @@ export interface PacketRead {
checklist_items: Array<{ group: string; key: string; text: string }>; checklist_items: Array<{ group: string; key: string; text: string }>;
qc_items: unknown[]; qc_items: unknown[];
flag: string | null; flag: string | null;
/** Phase 5: the database now(), to tick the clock from. */
server_now?: string | null;
/** Phase 5: the sum of the work orders' target hours. */
target_hrs?: number | null;
/** Phase 5: VISUAL labor tickets on the packet's work orders. Null for a hand packet or no match. */
labor?: Labor | null;
/** Phase 5: the borrowed heat map of a sibling packet, read-only. */
heat_src?: HeatSrc | null;
}
/* ---------- Phase 5: the shop on the packet ---------- */
export interface Weld {
weld_no: number;
page: number | null;
/** Where the arrow points, as a fraction of the page. */
fx: number | null;
fy: number | null;
/** The bubble. Null means the default position (geometry.bpos). */
bfx?: number | null;
bfy?: number | null;
wo_base: string | null;
piece: number | null;
items: number[];
type: string | null;
size: string | null;
process: string | null;
stamp: string | null;
at: string | null;
row_version: number;
welder: WhoRef | null;
by?: WhoRef | null;
dept_code?: string | null;
stage_id?: number | null;
/** Logged in an earlier department's stage. */
carried?: boolean;
}
export interface HeatTag {
tag_no: number;
page: number | null;
item: number;
fx: number | null;
fy: number | null;
bfx?: number | null;
bfy?: number | null;
row_version: number;
carried?: boolean;
carried_from?: number | null;
}
/** One heat table on a drawing page. x, y is the top-left corner as a fraction; s the size factor. */
export interface HeatTablePlacement {
page: number;
x: number;
y: number;
s: number;
carried_from?: number | null;
by?: WhoRef | null;
at?: string | null;
}
export interface StageClock {
/** Live on the server at read time: stored plus the running time. */
elapsed_ms: number;
stored_ms?: number;
running?: boolean;
running_since: string | null;
started_at: string | null;
}
export interface ChecklistEntry {
done: boolean;
at?: string | null;
by?: string | null;
by_name?: string | null;
stamp?: string | null;
}
export interface Stage {
id: number;
dept_code: string;
station: StationRef | null;
welder: WelderRef | null;
checklist: Record<string, ChecklistEntry>;
filler: string | null;
filler_lot: string | null;
preheat: string | null;
notes: string | null;
clock: StageClock;
opened_at: string | null;
closed_at?: string | null;
row_version: number;
}
/** The weld record fields on the open stage. */
export interface StageFields {
filler: string | null;
filler_lot: string | null;
preheat: string | null;
notes: string | null;
}
export interface LaborWo {
wo_base: string;
lot: string;
split: string;
sub: string;
seq: number | null;
hours: number;
tickets: number;
last_ticket: string | null;
}
export interface Labor {
hours: number;
tickets: number;
per_wo: LaborWo[];
observed_at: string | null;
}
export interface HeatSrc {
packet_id: number;
op: string | null;
status: PacketStatus;
drawing: string | null;
rev: string | null;
pages: PacketPage[];
heat_table: HeatTablePlacement[];
heat_tags: HeatTag[];
bom: Array<{ item: number; heat: string | null; na: boolean }>;
}
/** A weld write. Positions are fractions of the page. */
export interface WeldBody {
page?: number | null;
fx?: number | null;
fy?: number | null;
bfx?: number | null;
bfy?: number | null;
wo_base?: string | null;
piece?: number | null;
items?: number[];
type?: string | null;
size?: string | null;
process?: string | null;
stamp?: string | null;
}
export interface HeatTagBody {
page?: number | null;
item?: number;
fx?: number | null;
fy?: number | null;
bfx?: number | null;
bfy?: number | null;
}
export interface HeatTableBody {
x: number;
y: number;
s: number;
carried_from?: number | null;
}
/** One person on GET /api/departments/{code}/people. */
export interface DeptPerson {
id: string;
display_name: string;
stamp: string | null;
level: "worker" | "lead" | "supervisor";
station_limits: number[];
} }
/** One row of GET /api/packets/{id}/log, newest first. */ /** One row of GET /api/packets/{id}/log, newest first. */
@@ -541,7 +723,13 @@ export interface ChangeOrder {
rejected_reason?: string | null; rejected_reason?: string | null;
items: ChangeOrderItem[]; items: ChangeOrderItem[];
required: string[]; required: string[];
verified: Record<string, { by: WhoRef | null; at: string; note: string | null }>; verified: Record<string, { by: WhoRef | null; at: string; note: string | null; signed?: boolean }>;
/** Phase 5: "CO-<co_no>". */
label?: string;
/** Phase 5: required minus verified. */
pending_depts?: string[];
/** Phase 5: approved with pending departments, or pending with hold. */
open?: boolean;
vis?: CoVis | null; vis?: CoVis | null;
waiting?: { dwg: string; rev: string; since?: string | null; err?: string | null } | null; waiting?: { dwg: string; rev: string; since?: string | null; err?: string | null } | null;
staged?: { asset_id: string; name: string | null; rev: string; dwg: string; sha256?: string; pages?: number | null } | null; staged?: { asset_id: string; name: string | null; rev: string; dwg: string; sha256?: string; pages?: number | null } | null;
+42 -2
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { memoryStorage } from "../screens/supervisor/memoryStorage";
import { DEPARTMENTS } from "../test/auth"; import { DEPARTMENTS } from "../test/auth";
import type { Department } from "./types"; import type { Department } from "./types";
import { firstScreen, homePath, roleChipClass, roleFromKind, roleLabel, screensFor } from "./roles"; import { firstScreen, homePath, landingScreen, LAST_SCREEN_KEY, levelIn, myShopDepts, rememberScreen, roleChipClass, roleFromKind, roleLabel, screensFor } from "./roles";
const depts = DEPARTMENTS as Department[]; const depts = DEPARTMENTS as Department[];
@@ -36,4 +37,43 @@ describe("roles", () => {
expect(roleChipClass("admin")).toBe("rchip admin"); expect(roleChipClass("admin")).toBe("rchip admin");
expect(roleChipClass("clerical")).toBe("rchip"); expect(roleChipClass("clerical")).toBe("rchip");
}); });
describe("shop departments", () => {
it("levelIn gives the highest level in a department, admin is supervisor everywhere", () => {
expect(levelIn(["shop:03:worker"], "03")).toBe("worker");
expect(levelIn(["shop:03:worker", "shop:03:lead"], "03")).toBe("lead");
expect(levelIn(["clerical", "shop:06:supervisor"], "06")).toBe("supervisor");
expect(levelIn(["shop:06:supervisor"], "03")).toBeNull();
expect(levelIn(["clerical"], "03")).toBeNull();
expect(levelIn(["admin"], "09")).toBe("supervisor");
});
it("myShopDepts lists lead and supervisor departments for the Supervisor screen, every one for the Stall", () => {
const roles = ["clerical", "shop:09:worker", "shop:06:supervisor", "shop:03:lead"];
expect(myShopDepts(roles, "supervisor")).toEqual(["03", "06"]);
expect(myShopDepts(roles, "stall")).toEqual(["03", "06", "09"]);
expect(myShopDepts(["shop:03:worker"], "supervisor")).toEqual([]);
expect(myShopDepts(["shop:03:worker", "shop:03:lead"], "stall")).toEqual(["03"]);
expect(myShopDepts(["admin"], "supervisor", ["14", "03"])).toEqual(["03", "14"]);
expect(myShopDepts(["warehouse"], "stall", ["03"])).toEqual([]);
});
});
describe("landing screen", () => {
beforeEach(() => {
vi.stubGlobal("localStorage", memoryStorage());
});
it("lands on the last screen picked while it is still allowed", () => {
expect(landingScreen(["stall", "supervisor"], null)).toBe("supervisor");
expect(landingScreen(["stall", "supervisor"], "stall")).toBe("stall");
expect(landingScreen(["clerical"], "stall")).toBe("clerical");
rememberScreen("stall");
expect(window.localStorage.getItem(LAST_SCREEN_KEY)).toBe("stall");
expect(homePath(["supervisor", "stall"])).toBe("/stall");
expect(homePath(["clerical", "qc"])).toBe("/clerical");
window.localStorage.setItem(LAST_SCREEN_KEY, "nonsense");
expect(homePath(["supervisor", "stall"])).toBe("/supervisor");
});
});
}); });
+61 -2
View File
@@ -79,17 +79,76 @@ export function orderScreens(list: readonly string[]): Screen[] {
return SCREENS.filter((s) => list.includes(s)); return SCREENS.filter((s) => list.includes(s));
} }
/** The screen a user lands on: the first non-admin screen, else admin, else null. */ /** The first non-admin screen, else admin, else null. */
export function firstScreen(list: readonly string[]): Screen | null { export function firstScreen(list: readonly string[]): Screen | null {
const ordered = orderScreens(list); const ordered = orderScreens(list);
return ordered.find((s) => s !== "admin") ?? ordered[0] ?? null; return ordered.find((s) => s !== "admin") ?? ordered[0] ?? null;
} }
/** localStorage key for the last screen picked in the role switcher (the old build's ps.role). */
export const LAST_SCREEN_KEY = "ps.role";
/** The last screen picked, or null. Storage can be blocked; that reads as nothing remembered. */
export function lastScreen(): Screen | null {
try {
const v = window.localStorage.getItem(LAST_SCREEN_KEY) ?? undefined;
return isScreen(v) ? v : null;
} catch {
return null;
}
}
export function rememberScreen(s: Screen): void {
try {
window.localStorage.setItem(LAST_SCREEN_KEY, s);
} catch {
// Nothing to do: the next sign-in lands on the first screen.
}
}
/** The screen a user lands on: the last one picked while it is still allowed, else the first non-admin screen. */
export function landingScreen(list: readonly string[], last: Screen | null = lastScreen()): Screen | null {
const ordered = orderScreens(list);
if (last && ordered.includes(last)) return last;
return firstScreen(ordered);
}
export function homePath(list: readonly string[]): string { export function homePath(list: readonly string[]): string {
const s = firstScreen(list); const s = landingScreen(list);
return s ? `/${s}` : "/account"; return s ? `/${s}` : "/account";
} }
/**
* The user's level in one shop department (the old levelIn): the highest shop role there, or null.
* Admin counts as supervisor everywhere.
*/
export function levelIn(roles: readonly string[], dept: string): ShopLevel | null {
if (roles.includes("admin")) return "supervisor";
let best = -1;
for (const r of roles) {
const p = parseRole(r);
if (p.shop && p.dept === dept) best = Math.max(best, SHOP_LEVELS.indexOf(p.level));
}
return best < 0 ? null : (SHOP_LEVELS[best] ?? null);
}
/**
* The shop departments a user works in for a screen (the old myDepts), in code order.
* "supervisor": where the user is lead or supervisor. "stall": every shop department at any level.
* Admin gets every shop department in `allShop`.
*/
export function myShopDepts(roles: readonly string[], screen: "supervisor" | "stall", allShop: readonly string[] = []): string[] {
const out = new Set<string>();
if (roles.includes("admin")) allShop.forEach((d) => out.add(d));
for (const r of roles) {
const p = parseRole(r);
if (!p.shop) continue;
if (screen === "supervisor" && p.level === "worker") continue;
out.add(p.dept);
}
return [...out].sort((a, b) => a.localeCompare(b));
}
const OFFICE_LABELS: Record<string, string> = { const OFFICE_LABELS: Record<string, string> = {
admin: "Admin", admin: "Admin",
clerical: "Clerical", clerical: "Clerical",
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { eventsUrl, othersOn } from "./useEvents";
describe("packet presence", () => {
it("opens the stream with the packet in view", () => {
expect(eventsUrl(null)).toBe("/api/events");
expect(eventsUrl(42)).toBe("/api/events?packet_id=42");
});
it("lists the other people on the packet once each, sorted, without me", () => {
const users = [
{ user_id: "w", display_name: "Wes Super", screen: "supervisor", packet_id: 7 },
{ user_id: "a", display_name: "Andy Welder", screen: "stall", packet_id: "7" },
{ user_id: "a", display_name: "Andy Welder", screen: "stall", packet_id: 7 },
{ user_id: "me", display_name: "Me", screen: "stall", packet_id: 7 },
{ user_id: "q", display_name: "Quinn QC", screen: "qc", packet_id: 8 },
{ user_id: "c", display_name: "Clara Clerk", screen: "clerical", packet_id: null },
];
expect(othersOn(users, 7, "me").map((u) => u.display_name)).toEqual(["Andy Welder", "Wes Super"]);
});
});
+67 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { AuthCtx } from "./context";
import type { PresenceUser } from "./types"; import type { PresenceUser } from "./types";
interface EventHandlers { interface EventHandlers {
@@ -8,6 +9,11 @@ interface EventHandlers {
onChanged?: (payload: unknown) => void; onChanged?: (payload: unknown) => void;
} }
/** Phase 5: presence carries the packet each user has open. */
export interface PacketPresenceUser extends PresenceUser {
packet_id?: number | string | null;
}
function parse(data: unknown): unknown { function parse(data: unknown): unknown {
if (typeof data !== "string") return null; if (typeof data !== "string") return null;
try { try {
@@ -17,13 +23,41 @@ function parse(data: unknown): unknown {
} }
} }
/*
* The packet this browser is looking at. The packet page sets it (usePacketPresence) and the one EventSource
* reopens with ?packet_id= so the server's presence knows it. A module store keeps AuthProvider unchanged.
*/
let focusPacket: number | null = null;
const focusListeners = new Set<() => void>();
export function setPresencePacket(id: number | null): void {
if (focusPacket === id) return;
focusPacket = id;
for (const fn of focusListeners) fn();
}
function subscribeFocus(fn: () => void): () => void {
focusListeners.add(fn);
return () => {
focusListeners.delete(fn);
};
}
const getFocus = () => focusPacket;
/** The stream URL for the packet in view. */
export function eventsUrl(packetId: number | null): string {
return packetId ? `/api/events?packet_id=${encodeURIComponent(String(packetId))}` : "/api/events";
}
/** /**
* Opens /api/events with EventSource while `sessionId` is set. * Opens /api/events with EventSource while `sessionId` is set.
* A `session` event with this session id and action "revoked" calls onRevoked. * A `session` event with this session id and action "revoked" calls onRevoked.
* `presence` is kept in state for later phases. * `presence` is kept in state. The stream reopens when the packet in view changes.
*/ */
export function useEvents(sessionId: string | null, handlers: EventHandlers): PresenceUser[] { export function useEvents(sessionId: string | null, handlers: EventHandlers): PresenceUser[] {
const [presence, setPresence] = useState<PresenceUser[]>([]); const [presence, setPresence] = useState<PresenceUser[]>([]);
const packetId = useSyncExternalStore(subscribeFocus, getFocus, getFocus);
const ref = useRef(handlers); const ref = useRef(handlers);
useEffect(() => { useEffect(() => {
ref.current = handlers; ref.current = handlers;
@@ -31,7 +65,7 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers): Pr
useEffect(() => { useEffect(() => {
if (!sessionId || typeof EventSource === "undefined") return; if (!sessionId || typeof EventSource === "undefined") return;
const es = new EventSource("/api/events", { withCredentials: true }); const es = new EventSource(eventsUrl(packetId), { withCredentials: true });
es.addEventListener("session", (ev) => { es.addEventListener("session", (ev) => {
const p = parse((ev as MessageEvent).data); const p = parse((ev as MessageEvent).data);
@@ -59,7 +93,36 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers): Pr
es.close(); es.close();
setPresence([]); setPresence([]);
}; };
}, [sessionId]); }, [sessionId, packetId]);
return presence; return presence;
} }
/** The other people on `packetId`, from the presence list. One entry per user, sorted by name. */
export function othersOn(users: readonly PacketPresenceUser[], packetId: number, myId: string | null | undefined): PacketPresenceUser[] {
const seen = new Set<string>();
const out: PacketPresenceUser[] = [];
for (const u of users) {
if (u.packet_id === null || u.packet_id === undefined || Number(u.packet_id) !== packetId) continue;
if (myId && u.user_id === myId) continue;
if (seen.has(u.user_id)) continue;
seen.add(u.user_id);
out.push(u);
}
return out.sort((a, b) => a.display_name.localeCompare(b.display_name));
}
/**
* Marks `packetId` as the packet in view while mounted, and returns the other people looking at it.
* Works under AuthProvider; outside it (a bare test render) it returns an empty list.
*/
export function usePacketPresence(packetId: number | null): PacketPresenceUser[] {
const ctx = useContext(AuthCtx);
useEffect(() => {
if (!packetId) return;
setPresencePacket(packetId);
return () => setPresencePacket(null);
}, [packetId]);
if (!ctx || !packetId) return [];
return othersOn(ctx.presence as PacketPresenceUser[], packetId, ctx.me?.user.id);
}
+52
View File
@@ -0,0 +1,52 @@
import { useNavigate } from "react-router";
export interface CoBannerItem {
key: string;
/** "CO-2". */
label: string;
/** "L662001 · WELD-PART". */
job: string;
/** The small line under it: when, for which departments, why. */
sub?: string;
/** Where the button goes: the packet's Change orders tab. */
href: string;
}
/**
* The change order headline (the old coBannerHtml). It has no close button: it stays until the
* department verifies, and the next board answer drops the row.
*/
export function CoBanner({ items, it = false }: { items: readonly CoBannerItem[]; it?: boolean }) {
const navigate = useNavigate();
if (!items.length) return null;
return (
<section className="cobanner" role="alert" aria-label="Change orders to verify">
<div className="cob-h">
<svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3 2 21h20L12 3z" />
<path d="M12 10v5M12 18h.01" />
</svg>
<div>
<div className="cob-t">{it ? "Change order: verification pending (IT view)" : "Change order: your verification is required"}</div>
<div className="small">
A document in {items.length === 1 ? "this packet" : "these packets"} was replaced. Review the new revision against work already done, then
verify. This stays here until you do.
</div>
</div>
</div>
{items.map((x) => (
<div key={x.key} className="cob-row">
<div className="cob-txt">
<div>
Change order <b>{x.label}</b> on <b>{x.job}</b> needs your verification
</div>
{x.sub ? <div className="small">{x.sub}</div> : null}
</div>
<button className="btn pri" type="button" aria-label={`Review & verify ${x.label} on ${x.job}`} onClick={() => navigate(x.href)}>
Review &amp; verify
</button>
</div>
))}
</section>
);
}
@@ -0,0 +1,50 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LAST_SCREEN_KEY } from "../auth/roles";
import { memoryStorage } from "../screens/supervisor/memoryStorage";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { mockFetch } from "../test/fetchMock";
const LOU = makeMe({ roles: ["shop:03:lead"], screens: ["supervisor", "stall"], user: { sam_account: "lead.lou", display_name: "Lou Lead" } });
function routes() {
return {
"/api/auth/me": { body: LOU },
"/api/departments": { body: DEPARTMENTS },
"/api/shop/board": { body: {} },
"/api/shop/stations": { body: [] },
"/api/projects": { body: [] },
};
}
beforeEach(() => {
vi.stubGlobal("localStorage", memoryStorage());
});
describe("RoleSwitcher", () => {
it("lands on the first screen, and remembers the screen picked", async () => {
mockFetch(routes());
renderApp("/");
const nav = await screen.findByRole("navigation", { name: "Screens" });
await waitFor(() => expect(within(nav).getByRole("button", { name: "Supervisor" })).toHaveAttribute("aria-pressed", "true"));
fireEvent.click(within(nav).getByRole("button", { name: "Weld 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();
});
it("lands on the remembered screen when it is still allowed", async () => {
window.localStorage.setItem(LAST_SCREEN_KEY, "stall");
mockFetch(routes());
renderApp("/");
const nav = await screen.findByRole("navigation", { name: "Screens" });
await waitFor(() => expect(within(nav).getByRole("button", { name: "Weld Stall" })).toHaveAttribute("aria-pressed", "true"));
});
it("ignores a remembered screen the user no longer has", async () => {
window.localStorage.setItem(LAST_SCREEN_KEY, "warehouse");
mockFetch(routes());
renderApp("/");
const nav = await screen.findByRole("navigation", { name: "Screens" });
await waitFor(() => expect(within(nav).getByRole("button", { name: "Supervisor" })).toHaveAttribute("aria-pressed", "true"));
});
});
+11 -3
View File
@@ -1,19 +1,27 @@
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { SCREEN_LABELS, type Screen } from "../auth/roles"; import { rememberScreen, SCREEN_LABELS, type Screen } from "../auth/roles";
interface RoleSwitcherProps { interface RoleSwitcherProps {
screens: readonly Screen[]; screens: readonly Screen[];
active: Screen | null; active: Screen | null;
} }
/** One button per screen the user has. Drawn only when there is more than one. */ /** One button per screen the user has. Drawn only when there is more than one. The pick is remembered for the next landing. */
export function RoleSwitcher({ screens, active }: RoleSwitcherProps) { export function RoleSwitcher({ screens, active }: RoleSwitcherProps) {
const navigate = useNavigate(); const navigate = useNavigate();
if (screens.length < 2) return null; if (screens.length < 2) return null;
return ( return (
<nav className="roles" aria-label="Screens"> <nav className="roles" aria-label="Screens">
{screens.map((s) => ( {screens.map((s) => (
<button key={s} type="button" aria-pressed={active === s} onClick={() => navigate(`/${s}`)}> <button
key={s}
type="button"
aria-pressed={active === s}
onClick={() => {
rememberScreen(s);
navigate(`/${s}`);
}}
>
{SCREEN_LABELS[s]} {SCREEN_LABELS[s]}
</button> </button>
))} ))}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { fmtClock, fmtHours, liveElapsed } from "./clock";
describe("clock", () => {
it("fmtClock pads and counts hours", () => {
expect(fmtClock(0)).toBe("00:00:00");
expect(fmtClock(61_000)).toBe("00:01:01");
expect(fmtClock(5_400_999)).toBe("01:30:00");
expect(fmtClock(-5)).toBe("00:00:00");
expect(fmtClock(360_000_000)).toBe("100:00:00");
});
it("fmtHours has one decimal", () => {
expect(fmtHours(5_400_000)).toBe("1.5 h");
expect(fmtHours(0)).toBe("0.0 h");
});
it("liveElapsed adds the time since the fetch only while running", () => {
const fetched = 1_000_000;
expect(liveElapsed({ elapsed_ms: 60_000, running: false, running_since: null }, fetched, fetched + 5000)).toBe(60_000);
expect(liveElapsed({ elapsed_ms: 60_000, running: true, running_since: "2026-10-16T08:00:00Z" }, fetched, fetched + 5000)).toBe(65_000);
expect(liveElapsed({ elapsed_ms: 60_000, running_since: "2026-10-16T08:00:00Z" }, fetched, fetched + 2000)).toBe(62_000);
// A client clock behind the fetch time never takes time away.
expect(liveElapsed({ elapsed_ms: 60_000, running: true }, fetched, fetched - 9000)).toBe(60_000);
expect(liveElapsed(null, fetched)).toBe(0);
});
});
+51
View File
@@ -0,0 +1,51 @@
// The station clock on screen. The server is the clock of record: every stored value comes from the read model.
// The client only adds the time since the fetch while the clock runs, for the ticking display.
import { useEffect, useState } from "react";
export interface ClockLike {
elapsed_ms: number;
running?: boolean | undefined;
running_since?: string | null | undefined;
}
/** HH:MM:SS. Hours keep counting past 99. */
export function fmtClock(ms: number): string {
const t = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
/** Hours with one decimal: "1.5 h". */
export function fmtHours(ms: number): string {
return `${((Number(ms) || 0) / 3_600_000).toFixed(1)} h`;
}
export function isRunning(c: ClockLike | null | undefined): boolean {
if (!c) return false;
return c.running ?? !!c.running_since;
}
/**
* The elapsed time to show now. `fetchedAt` is the client time when the packet arrived; the server's elapsed_ms
* was live at that moment. While running, the time since the fetch is added. Never below the server's value.
*/
export function liveElapsed(c: ClockLike | null | undefined, fetchedAt: number, now: number = Date.now()): number {
if (!c) return 0;
const base = Math.max(0, Number(c.elapsed_ms) || 0);
if (!isRunning(c)) return base;
return base + Math.max(0, now - fetchedAt);
}
/** Re-renders every second while `running`. Returns the current time. */
export function useTicker(running: boolean, everyMs = 1000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!running) return;
setNow(Date.now());
const t = window.setInterval(() => setNow(Date.now()), everyMs);
return () => window.clearInterval(t);
}, [running, everyMs]);
return now;
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { arrowRotation, bpos, clamp01, fitView, markRadius, toFraction, toPixels, zoomAt, type View } from "./geometry";
describe("geometry", () => {
it("clamp01 keeps a margin", () => {
expect(clamp01(-1)).toBe(0.01);
expect(clamp01(2)).toBe(0.99);
expect(clamp01(0.5)).toBe(0.5);
});
it("bpos defaults right and up, left near the right edge, down near the top", () => {
const mid = bpos({ fx: 0.5, fy: 0.5 });
expect(mid.bfx).toBeCloseTo(0.55);
expect(mid.bfy).toBeCloseTo(0.43);
const right = bpos({ fx: 0.9, fy: 0.5 });
expect(right.bfx).toBeCloseTo(0.85);
const top = bpos({ fx: 0.5, fy: 0.05 });
expect(top.bfy).toBeCloseTo(0.12);
const corner = bpos({ fx: 0.99, fy: 0.0 });
expect(corner.bfx).toBeCloseTo(0.94);
expect(corner.bfy).toBeCloseTo(0.07);
expect(bpos({ fx: 0.995, fy: 0.5 }).bfx).toBeLessThan(0.99);
});
it("bpos keeps a stored label", () => {
expect(bpos({ fx: 0.5, fy: 0.5, bfx: 0.2, bfy: 0.3 })).toEqual({ bfx: 0.2, bfy: 0.3 });
});
it("fractions to pixels and back on a few transforms", () => {
const frame = { left: 100, top: 50 };
const views: View[] = [
{ x: 0, y: 0, z: 1, w: 1000, h: 700 },
{ x: 40, y: -20, z: 0.5, w: 4200, h: 2970 },
{ x: -300, y: 120, z: 2.75, w: 2000, h: 1400 },
];
for (const v of views) {
for (const [fx, fy] of [
[0, 0],
[0.25, 0.75],
[1, 1],
] as const) {
const { px, py } = toPixels(fx, fy, v);
const back = toFraction(px + frame.left, py + frame.top, frame, v);
expect(back.fx).toBeCloseTo(fx, 9);
expect(back.fy).toBeCloseTo(fy, 9);
}
}
expect(toPixels(0.5, 0.5, views[0]!)).toEqual({ px: 500, py: 350 });
});
it("arrowRotation points from the bubble to the anchor", () => {
expect(arrowRotation(0.5, 0.5, 0.4, 0.5, 1.5)).toBeCloseTo(0);
expect(arrowRotation(0.4, 0.5, 0.5, 0.5, 1.5)).toBeCloseTo(180);
expect(arrowRotation(0.5, 0.6, 0.5, 0.5, 1.5)).toBeCloseTo(90);
});
it("markRadius has a floor", () => {
expect(markRadius(1000)).toBe(18);
expect(markRadius(4200)).toBe(28);
});
it("fitView centres and zoomAt keeps the point under the cursor", () => {
const v = fitView(800, 600, 1600, 800);
expect(v.z).toBe(0.5);
expect(v.y).toBe(100);
const z = zoomAt(v, 2, 400, 300, 0.1);
const before = toFraction(400, 300, { left: 0, top: 0 }, v);
const after = toFraction(400, 300, { left: 0, top: 0 }, z);
expect(after.fx).toBeCloseTo(before.fx);
expect(after.fy).toBeCloseTo(before.fy);
expect(zoomAt(v, 100, 0, 0, 0.1).z).toBe(6);
});
});
+92
View File
@@ -0,0 +1,92 @@
// Mark positions on a drawing page. Every position is a fraction of the page (0..1), so a mark stays on its
// joint whatever the zoom or the bitmap size. A port of the old build's clamp01, bpos, arrowRot and toNorm.
/** The viewer transform: the stage is translated by (x, y) and scaled by z. w and h are the page size at z = 1. */
export interface View {
x: number;
y: number;
z: number;
w: number;
h: number;
}
/** A point on the page, as fractions. */
export interface Frac {
fx: number;
fy: number;
}
/** Marks keep a small margin from the page edge. */
export function clamp01(v: number): number {
return Math.max(0.01, Math.min(0.99, v));
}
/** Plain 0..1 clamp, for drops and table corners. */
export function clamp(v: number, lo = 0, hi = 1): number {
return Math.max(lo, Math.min(hi, v));
}
export interface Placed {
fx: number | null;
fy: number | null;
bfx?: number | null | undefined;
bfy?: number | null | undefined;
}
/**
* Where the label (bubble) sits. A stored bfx, bfy wins. Otherwise the old default: a little right of the arrow
* point (left near the right edge) and a little above it (below near the top edge).
*/
export function bpos(m: Placed): { bfx: number; bfy: number } {
const fx = m.fx ?? 0.5;
const fy = m.fy ?? 0.5;
return {
bfx: m.bfx ?? clamp01(fx + (fx > 0.85 ? -0.05 : 0.05)),
bfy: m.bfy ?? clamp01(fy + (fy < 0.12 ? 0.07 : -0.07)),
};
}
/** A client point to page fractions. `frame` is the viewer's bounding box. Not clamped. */
export function toFraction(clientX: number, clientY: number, frame: { left: number; top: number }, view: View): Frac {
if (!view.w || !view.h || !view.z) return { fx: NaN, fy: NaN };
return {
fx: (clientX - frame.left - view.x) / view.z / view.w,
fy: (clientY - frame.top - view.y) / view.z / view.h,
};
}
/** Page fractions to pixels inside the viewer frame. The inverse of toFraction. */
export function toPixels(fx: number, fy: number, view: View): { px: number; py: number } {
return { px: view.x + fx * view.w * view.z, py: view.y + fy * view.h * view.z };
}
/** True when the fraction lies on the page. */
export function onPage(f: Frac): boolean {
return f.fx >= 0 && f.fx <= 1 && f.fy >= 0 && f.fy <= 1;
}
/**
* The arrowhead's rotation in degrees: it points from the bubble (bx, by) to the anchor (ax, ay).
* The fractions are scaled by the page aspect (width / height) so the angle is right on a non-square page.
*/
export function arrowRotation(ax: number, ay: number, bx: number, by: number, aspect: number): number {
return (Math.atan2(ay - by, (ax - bx) * (aspect || 1.5)) * 180) / Math.PI;
}
/** The weld circle radius in page pixels for the printed raster. */
export function markRadius(width: number): number {
return Math.max(18, width / 150);
}
/** The fit transform: the whole page in the frame, centred. */
export function fitView(frameW: number, frameH: number, w: number, h: number): View {
const z = Math.min(frameW / w, frameH / h) || 1;
return { x: (frameW - w * z) / 2, y: (frameH - h * z) / 2, z, w, h };
}
/** Zoom by factor f around the frame point (cx, cy), clamped between minZ and maxZ. */
export function zoomAt(view: View, f: number, cx: number, cy: number, minZ: number, maxZ = 6): View {
const nz = Math.max(minZ, Math.min(maxZ, view.z * f));
const k = nz / view.z;
return { ...view, x: cx - (cx - view.x) * k, y: cy - (cy - view.y) * k, z: nz };
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { clampCorner, fontPx, heatGroups, heatRows, stepSize } from "./heatTable";
const rows = (n: number) => Array.from({ length: n }, (_, i) => i + 1);
describe("heatTable", () => {
it("heatRows shows N/A and leaves a missing heat empty", () => {
expect(heatRows([{ item: 1, heat: "H123" }, { item: 2, na: true }, { item: 3 }])).toEqual([
{ item: "1", heat: "H123", na: false },
{ item: "2", heat: "N/A", na: true },
{ item: "3", heat: "", na: false },
]);
});
it("groups at most 12 rows, evenly", () => {
expect(heatGroups(rows(12)).map((g) => g.length)).toEqual([12]);
expect(heatGroups(rows(13)).map((g) => g.length)).toEqual([7, 6]);
expect(heatGroups(rows(24)).map((g) => g.length)).toEqual([12, 12]);
expect(heatGroups(rows(25)).map((g) => g.length)).toEqual([9, 9, 7]);
expect(heatGroups([])).toEqual([[]]);
});
it("stepSize moves 0.15 and clamps", () => {
expect(stepSize(1, 1)).toBe(1.15);
expect(stepSize(1.15, 1)).toBe(1.3);
expect(stepSize(1, -1)).toBe(0.85);
expect(stepSize(0.6, -1)).toBe(0.6);
expect(stepSize(0.7, -1)).toBe(0.6);
expect(stepSize(2.1, 1)).toBe(2.1);
expect(stepSize(2.0, 1)).toBe(2.1);
});
it("fontPx follows the page width and size", () => {
expect(fontPx(4000, 1)).toBe(30);
expect(fontPx(4000, 2)).toBe(60);
});
it("clampCorner keeps the corner on the page", () => {
expect(clampCorner(-0.2, 1.5)).toEqual({ x: 0, y: 0.97 });
});
});
+136
View File
@@ -0,0 +1,136 @@
// The heat table: ITEM | HEAT NO., placed on a drawing sheet. A port of the old build's heattable.js.
// The table always shows the live BOM heats, so a heat fixed in Materials updates the drawing with no re-placing.
export const HT_ROWS = 12;
export const HT_FONT = 0.0075;
export const HT_MIN = 0.6;
export const HT_MAX = 2.1;
export const HT_STEP = 0.15;
export const HEAT_COLOR = "#b07800";
export const HEAT_MISSING = "#b00020";
export const HEAT_NA = "#6b5a3a";
/** The red dash a missing heat shows. */
export const NO_HEAT = "—";
export interface HeatRow {
item: string;
/** "N/A" on an N/A line, "" when the heat is missing. */
heat: string;
na: boolean;
}
export interface HeatBomLine {
item: number | string;
heat?: string | null | undefined;
na?: boolean | null | undefined;
}
export function heatRows(bom: readonly HeatBomLine[]): HeatRow[] {
return bom.map((b) => ({ item: String(b.item), heat: b.heat || (b.na ? "N/A" : ""), na: !!b.na }));
}
/** At most 12 rows per group, the groups as even as they can be. Always at least one (maybe empty) group. */
export function heatGroups<T>(rows: readonly T[]): T[][] {
const n = rows.length;
const per = n > HT_ROWS ? Math.ceil(n / Math.ceil(n / HT_ROWS)) : HT_ROWS;
const out: T[][] = [];
for (let i = 0; i < n; i += per) out.push(rows.slice(i, i + per));
return out.length ? out : [[]];
}
/** The table's font size in page pixels. */
export function fontPx(pageWidth: number, s: number): number {
return pageWidth * HT_FONT * s;
}
/** One A- (dir -1) or A+ (dir 1) step, clamped to 0.6..2.1 and rounded to 2 decimals. */
export function stepSize(s: number, dir: number): number {
const v = Math.max(HT_MIN, Math.min(HT_MAX, (s || 1) + HT_STEP * Math.sign(dir)));
return Math.round(v * 100) / 100;
}
/** The table's top-left corner stays on the page. */
export function clampCorner(x: number, y: number): { x: number; y: number } {
return { x: Math.max(0, Math.min(0.97, x)), y: Math.max(0, Math.min(0.97, y)) };
}
/** Draws the table into a page raster (print, full-screen copy). A port of htDrawCanvas. */
export function drawHeatTable(
c: CanvasRenderingContext2D,
placement: { x: number; y: number; s: number },
rows: readonly HeatRow[],
W: number,
H: number,
): void {
const fs = fontPx(W, placement.s || 1);
const padX = fs * 0.5;
const padY = fs * 0.2;
const rowH = fs * 1.15 + padY * 2;
const bw = Math.max(1.5, fs * 0.08);
const col = HEAT_COLOR;
const groups = heatGroups(rows);
c.save();
c.textBaseline = "middle";
const thF = `bold ${fs * 0.8}px Arial`;
const tdF = `bold ${fs}px Arial`;
const monoF = `bold ${fs}px "Courier New", monospace`;
const widths = groups.map((g) => {
c.font = thF;
let wi = c.measureText("ITEM").width;
let wh = c.measureText("HEAT NO.").width;
c.font = tdF;
for (const r of g) wi = Math.max(wi, c.measureText(r.item).width);
c.font = monoF;
for (const r of g) wh = Math.max(wh, c.measureText(r.heat || NO_HEAT).width);
return [wi + padX * 2, wh + padX * 2] as const;
});
const totalW = widths.reduce((a, [i, h]) => a + i + h, 0);
const capH = rowH;
const maxRows = Math.max(...groups.map((g) => g.length));
const totalH = capH + rowH * (maxRows + 1);
const x0 = placement.x * W;
const y0 = placement.y * H;
c.fillStyle = "#fff";
c.fillRect(x0, y0, totalW, totalH);
c.fillStyle = col;
c.fillRect(x0, y0, totalW, capH);
c.fillStyle = "#fff";
c.font = `bold ${fs * 0.85}px Arial`;
c.textAlign = "center";
c.fillText("HEAT NUMBERS", x0 + totalW / 2, y0 + capH / 2);
let gx = x0;
groups.forEach((g, gi) => {
const [wi, wh] = widths[gi] ?? [0, 0];
let y = y0 + capH;
c.fillStyle = "#f3e2b8";
c.fillRect(gx, y, wi + wh, rowH);
c.fillStyle = "#3d2a00";
c.font = thF;
c.textAlign = "center";
c.fillText("ITEM", gx + wi / 2, y + rowH / 2);
c.fillText("HEAT NO.", gx + wi + wh / 2, y + rowH / 2);
c.strokeStyle = col;
c.lineWidth = bw;
c.strokeRect(gx, y, wi, rowH);
c.strokeRect(gx + wi, y, wh, rowH);
y += rowH;
for (const r of g) {
c.font = tdF;
c.fillStyle = "#2b1d00";
c.textAlign = "center";
c.fillText(r.item, gx + wi / 2, y + rowH / 2);
c.font = monoF;
c.textAlign = "left";
c.fillStyle = r.heat ? (r.na ? HEAT_NA : "#2b1d00") : HEAT_MISSING;
c.fillText(r.heat || NO_HEAT, gx + wi + padX, y + rowH / 2);
c.strokeRect(gx, y, wi, rowH);
c.strokeRect(gx + wi, y, wh, rowH);
y += rowH;
}
gx += wi + wh;
});
c.lineWidth = bw * 2;
c.strokeStyle = col;
c.strokeRect(x0, y0, totalW, totalH);
c.restore();
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { voidText } from "./voidStamp";
describe("voidText", () => {
it("names the CO, the new sheet and the date", () => {
const at = new Date(2026, 9, 16, 12).toISOString();
expect(voidText({ void: { co_id: 9, co_no: 2, at, new_n: 1001, new: { n: 1001, dwg: "CI-DWG-1", rev: "B" } } })).toBe(
"VOID - superseded by CO-2 (CI-DWG-1 Rev B) 10/16/2026",
);
});
it("finds the new sheet in the pages when the read model gives only new_n", () => {
const at = new Date(2026, 0, 5, 12).toISOString();
expect(voidText({ void: { co_id: 9, co_no: 3, at, new_n: 4 } }, [{ n: 4, dwg: null, rev: null }])).toBe(
"VOID - superseded by CO-3 (new sheet Rev ?) 1/5/2026",
);
});
it("is empty for a live page", () => {
expect(voidText({ void: null })).toBe("");
});
});
+49
View File
@@ -0,0 +1,49 @@
// The VOID stamp on a superseded drawing sheet. A port of the old build's voidText and stampVoidCanvas.
import type { PacketPage } from "../api/types";
/** "10/16/2026", the shop's date format. */
function usDate(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
}
/** `VOID - superseded by CO-2 (dwg Rev B) 10/16/2026`. Empty for a live page. */
export function voidText(page: Pick<PacketPage, "void">, pages: ReadonlyArray<Pick<PacketPage, "n" | "dwg" | "rev">> = []): string {
const v = page.void;
if (!v) return "";
const co = v.co_no !== undefined && v.co_no !== null ? `CO-${v.co_no}` : "a change order";
const nw = v.new ?? (v.new_n !== null ? pages.find((x) => x.n === v.new_n) : undefined) ?? null;
const sheet = nw ? ` (${nw.dwg || "new sheet"} Rev ${nw.rev || "?"})` : "";
const day = usDate(v.at);
return `VOID - superseded by ${co}${sheet}${day ? ` ${day}` : ""}`;
}
/** Stamps a page raster: a big diagonal VOID at low opacity, the text under it, a red border. */
export function stampVoid(canvas: HTMLCanvasElement, text: string): HTMLCanvasElement {
const c = canvas.getContext("2d");
if (!c) return canvas;
const w = canvas.width;
const h = canvas.height;
c.save();
c.translate(w / 2, h / 2);
c.rotate(-Math.atan2(h, w) * 0.85);
c.textAlign = "center";
c.textBaseline = "middle";
c.font = `bold ${Math.round(w / 5.5)}px Arial`;
c.lineWidth = Math.max(4, w / 260);
c.strokeStyle = "rgba(200,0,0,.55)";
c.fillStyle = "rgba(220,0,0,.3)";
c.fillText("VOID", 0, 0);
c.strokeText("VOID", 0, 0);
c.font = `bold ${Math.round(w / 55)}px Arial`;
c.fillStyle = "rgba(200,0,0,.8)";
c.fillText(text, 0, w / 9);
c.restore();
const lw = Math.max(6, w / 200);
c.strokeStyle = "rgba(200,0,0,.7)";
c.lineWidth = lw;
c.strokeRect(lw / 2, lw / 2, w - lw, h - lw);
return canvas;
}
+40
View File
@@ -0,0 +1,40 @@
// The weld vocabulary from the old build (app.js WELD_TYPES, PROCESSES). The server checks the same lists.
import type { PacketRead, Weld } from "../api/types";
export const WELD_TYPES = ["Fillet", "Groove", "Fill", "Seal", "Tack"] as const;
export const PROCESSES = ["GMAW", "FCAW", "SMAW", "GTAW", "SAW"] as const;
export const DEFAULT_SIZE = '5/16"';
export const DEFAULT_TYPE = "Fillet";
export const DEFAULT_PROCESS = "GMAW";
export const WELD_COLOR = "#c2185b";
/** "W4". */
export function weldId(w: Pick<Weld, "weld_no">): string {
return `W${w.weld_no}`;
}
/** The process of the newest weld logged in the open stage, else GMAW. */
export function lastProcess(p: Pick<PacketRead, "welds" | "stage">): string {
const stageId = p.stage?.id;
const mine = p.welds.filter((w) => w.process && !w.carried && (stageId === undefined || w.stage_id == null || w.stage_id === stageId));
const last = [...mine].sort((a, b) => b.weld_no - a.weld_no)[0];
return last?.process ?? DEFAULT_PROCESS;
}
/** `Fillet 5/16" · items 1 + 2`. */
export function weldSummary(w: Pick<Weld, "type" | "size" | "items">): string {
const desc = [w.type, w.size].filter(Boolean).join(" ");
const items = w.items.length ? `items ${w.items.join(" + ")}` : "";
return [desc, items].filter(Boolean).join(" · ");
}
/** The packet's work order bases, once each, with the largest lot quantity for the piece list. */
export function woChoices(p: Pick<PacketRead, "work_orders">): Array<{ base: string; qty: number }> {
const out = new Map<string, number>();
for (const w of p.work_orders) {
if (w.gone_at) continue;
const q = Math.max(1, Math.floor(Number(w.qty) || 1));
out.set(w.wo_base, Math.max(out.get(w.wo_base) ?? 1, q));
}
return [...out.entries()].map(([base, qty]) => ({ base, qty }));
}
+335
View File
@@ -0,0 +1,335 @@
import { useState } from "react";
import { packetsApi } from "../api/packets";
import type { ChangeOrder, PacketRead } from "../api/types";
import type { ReasonOptions } from "../components/ReasonSheet";
import { useAuth } from "../auth/useAuth";
import { coSummary, feedOrigin } from "../lib/changeOrders";
import { fmtTime } from "../lib/format";
import { canVerifyDept } from "./shopUtil";
import { ReviewPanel } from "./visual";
import type { Mutate } from "./tabs";
export const SIGN_CO_VERIFY = "Verify a change order for the department";
const isCl = (roles: readonly string[]) =>
roles.some(
(r) => r === "clerical" || r === "clerical_supervisor" || r === "admin",
);
const coLabel = (co: ChangeOrder) => co.label ?? `CO-${co.co_no}`;
const pendingOf = (co: ChangeOrder) =>
co.pending_depts ?? co.required.filter((d) => !co.verified[d]);
function coPill(co: ChangeOrder) {
if (co.review === "rejected")
return <span className="pill st-filed">Rejected</span>;
if (co.review === "pending")
return <span className="pill st-review">Waiting on Clerical</span>;
const open = pendingOf(co);
if (open.length)
return (
<span className="pill st-review">Open - {open.length} to verify</span>
);
return <span className="pill st-in_progress">Verified</span>;
}
/** The supervisor's verify box for one department: the confirmation, a note, Verify and the signature. */
function VerifyBox({
p,
co,
dept,
deptName,
mutate,
busy,
}: {
p: PacketRead;
co: ChangeOrder;
dept: string;
deptName: string;
mutate: Mutate;
busy: boolean;
}) {
const [ok, setOk] = useState(false);
const [note, setNote] = useState("");
const label = coLabel(co);
const verify = () =>
mutate(
(g) =>
packetsApi.coVerify(p.id, co.co_no, {
dept,
...(note.trim() ? { note: note.trim() } : {}),
...g,
}),
{
sign: SIGN_CO_VERIFY,
ok: `${label} verified for ${deptName}.`,
},
);
return (
<div
className="panel stack verifybox"
role="group"
aria-label={`Verify ${label} for ${deptName}`}
>
<h3>
Verify {label} for {deptName}
</h3>
<div className="small">
Review the new sheet and the changes with your department. Sign-offs on
this packet stay on hold until every department verifies.
</div>
<label className="ckl">
<input
type="checkbox"
checked={ok}
onChange={(e) => setOk(e.target.checked)}
/>
I reviewed this change with my department
</label>
<label className="f">
Note (optional)
<input
aria-label="Note (optional)"
className="in"
value={note}
maxLength={500}
onChange={(e) => setNote(e.target.value)}
/>
</label>
<div className="acts">
<button
className="btn pri"
type="button"
disabled={!ok || busy}
onClick={() => void verify()}
>
Verify
</button>
{!ok ? (
<span className="small muted">
Tick the box to confirm you reviewed the change.
</span>
) : null}
</div>
</div>
);
}
/** The Changes tab: every change order, newest first. Clerical reviews the feed ones; supervisors verify here. */
export function ChangesTab({
p,
roles,
sub,
mutate,
busy,
askReason,
}: {
p: PacketRead;
roles: readonly string[];
sub: string;
mutate: Mutate;
busy: boolean;
askReason: (opts: ReasonOptions) => Promise<string | null>;
}) {
const { departments } = useAuth();
const list = [...(p.change_orders ?? [])].sort((a, b) => b.co_no - a.co_no);
if (!list.length)
return <div className="empty">No change orders on this packet.</div>;
const canReview = isCl(roles) || p.can.includes("co_approve");
const deptName = (code: string) => {
if (code === "WH") return "Warehouse";
if (code === "QC") return "Q.C.";
const d = departments.find((x) => x.code === code);
return d ? `${d.code} ${d.name}` : code;
};
return (
<div className="stack">
{list.map((co) => {
const feed = feedOrigin(co);
const label = coLabel(co);
const pending = pendingOf(co);
const mine =
co.review === "approved"
? pending.filter((d) => canVerifyDept(roles, d))
: [];
return (
<section
key={co.id}
className="sec"
aria-label={`Change order ${label}`}
>
<div className="sec-h flush">
<h2>
{label}
{co.ecn ? ` · ${co.ecn}` : ""}
</h2>
{coPill(co)}
<span className="ct">
{feed
? co.origin === "drawing"
? "from M-Files"
: "from VISUAL"
: `issued by ${co.issued_by?.display_name ?? "-"}`}{" "}
{fmtTime(co.issued_at)}
{co.hold ? " · shop packet on hold" : ""}
</span>
</div>
<div className="small">
<b>{coSummary(co)}</b>
</div>
{co.review === "pending" && feed ? (
canReview ? (
<ReviewPanel
p={p}
co={co}
sub={sub}
mutate={mutate}
busy={busy}
askReason={askReason}
canReview={canReview}
/>
) : (
<div className="callout warn flush">
<b>Clerical is reviewing a change from VISUAL.</b> Keep
working to the current drawing. If Clerical approves it, the
new drawing and changes come onto this packet and you will be
asked to verify.
</div>
)
) : null}
{co.review === "rejected" ? (
<div className="callout flush">
{co.rejected_by ? (
<>
<b>Rejected by Clerical</b> - {co.rejected_by.display_name}{" "}
{fmtTime(co.rejected_at)}: {co.rejected_reason ?? ""}. The
shop packet was not changed.
</>
) : (
<>
<b>Withdrawn</b> {fmtTime(co.rejected_at)}:{" "}
{co.rejected_reason ??
"VISUAL went back to what the packet has"}
. The shop packet was not changed.
</>
)}
</div>
) : null}
{co.review === "approved" && co.approved_by ? (
<div className="small">
<b>Approved by Clerical:</b> {co.approved_by.display_name}{" "}
{fmtTime(co.approved_at)}
</div>
) : null}
{co.review === "approved" && feed && co.vis?.changes?.length ? (
<div className="small">
<b>Also changed from VISUAL:</b> {co.vis.changes.join("; ")}
</div>
) : null}
<div className="small">
<b>Reason:</b> {co.reason || "-"}
</div>
{co.items.length ? (
<div className="tw">
<table aria-label={`${label} sheets`}>
<thead>
<tr>
<th>Old sheet</th>
<th>New sheet</th>
</tr>
</thead>
<tbody>
{co.items.map((it, i) => (
<tr key={i}>
<td className="small">
{it.added ? (
<span className="muted">Added</span>
) : (
<>
<span className="pill st-filed">VOID</span>{" "}
{it.old_dwg || "sheet"} Rev {it.old_rev || "?"}
{it.old_page ? (
<span className="muted">
{" "}
· page {it.old_page}
</span>
) : null}
</>
)}
</td>
<td className="small">
<span className="pill st-in_progress">
{it.added ? "ADDED" : "NEW"}
</span>{" "}
{it.new_dwg || "sheet"} Rev {it.new_rev || "?"}
{it.new_page ? (
<span className="muted"> · page {it.new_page}</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{co.review === "approved" && co.required.length ? (
<div className="tw">
<table aria-label={`${label} verification`}>
<thead>
<tr>
<th>Department</th>
<th>Verified</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{co.required.map((d) => {
const ver = co.verified[d];
return (
<tr key={d}>
<td>{deptName(d)}</td>
<td className="small">
{ver ? (
<>
✓ {ver.by?.display_name ?? "-"}{" "}
{fmtTime(ver.at)}
{ver.signed ? (
<span className="muted"> · signed</span>
) : null}
</>
) : (
<span className="due soon">
Pending{mine.includes(d) ? " · you" : ""}
</span>
)}
</td>
<td className="small">{ver?.note ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
{co.review === "approved" && !co.required.length ? (
<div className="small muted">
No department needs to verify this change.
</div>
) : null}
{mine.map((d) => (
<VerifyBox
key={d}
p={p}
co={co}
dept={d}
deptName={deptName(d)}
mutate={mutate}
busy={busy}
/>
))}
</section>
);
})}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useState } from "react";
import { ApiError } from "../api/client";
import { packetsApi } from "../api/packets";
import type { PacketRead } from "../api/types";
import { errorText, fmtTime } from "../lib/format";
import type { Write } from "../warehouse/PullTable";
import { WeldRecord } from "./WeldRecord";
import type { Mutate } from "./tabs";
/**
* The stage checklist, grouped as the department's template, with the weld record beside it. Each tick is one PUT
* for its key; ticks of different keys never clash.
*/
export function ChecklistTab({
p,
write,
mutate,
reload,
onError,
}: {
p: PacketRead;
write: Write;
mutate: Mutate;
reload: () => Promise<void>;
onError: (text: string) => void;
}) {
const editable = !!p.stage && p.can.includes("edit_checklist");
const [pending, setPending] = useState<Record<string, boolean>>({});
const state = p.stage?.checklist ?? {};
const groups: Array<{ group: string; items: PacketRead["checklist_items"] }> = [];
for (const it of p.checklist_items) {
const g = groups.find((x) => x.group === it.group);
if (g) g.items.push(it);
else groups.push({ group: it.group, items: [it] });
}
const tick = async (key: string, done: boolean) => {
setPending((o) => ({ ...o, [key]: done }));
const r = await write((g) => packetsApi.setChecklist(p.id, key, done, g));
setPending((o) => {
const n = { ...o };
delete n[key];
return n;
});
if (!r.ok && r.err !== undefined) {
if (r.err instanceof ApiError && (r.err.status === 409 || r.err.status === 412)) await reload();
onError(errorText(r.err));
}
};
const weld = p.dept.weld !== false;
return (
<div className="cklay">
<div className="ckmain">
{groups.length ? null : <div className="empty">No checklist for this department.</div>}
{groups.map((g) => (
<section className="sec" key={g.group} aria-label={g.group}>
<div className="sec-h">
<h2>{g.group}</h2>
</div>
<div className="ck">
{g.items.map((i) => {
const c = state[i.key];
const done = pending[i.key] ?? !!c?.done;
const by = c?.done ? [c.stamp || c.by_name, fmtTime(c.at)].filter(Boolean).join(" · ") : "";
return (
<label key={i.key} className={`ck-item${done ? " done" : ""}`} aria-disabled={!editable}>
<input
type="checkbox"
checked={done}
disabled={!editable || pending[i.key] !== undefined}
onChange={(e) => void tick(i.key, e.target.checked)}
/>
<span className="t">{i.text}</span>
<span className="by">{by}</span>
</label>
);
})}
</div>
</section>
))}
{!editable && groups.length ? <div className="small muted">Checklist is completed at the stall.</div> : null}
</div>
{weld ? (
<div className="ckside">
<WeldRecord p={p} mutate={mutate} />
</div>
) : null}
</div>
);
}
+515
View File
@@ -0,0 +1,515 @@
import {
useRef,
useState,
type MutableRefObject,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Link } from "react-router";
import { ApiError } from "../api/client";
import { packetsApi } from "../api/packets";
import type { HeatTag, PacketPage, PacketRead } from "../api/types";
import { Sheet } from "../components/Sheet";
import { onPage } from "../lib/geometry";
import {
clampCorner,
heatRows,
NO_HEAT,
stepSize,
type HeatRow,
} from "../lib/heatTable";
import { errorText } from "../lib/format";
import { voidText } from "../lib/voidStamp";
import type { Write, WriteResult } from "../warehouse/PullTable";
import { MapViewer } from "./MapViewer";
import {
HeatTableInner,
HeatTableOnDrawing,
HeatTagMark,
type MarkMove,
} from "./marks";
import { landingPage, pageLabel, viewPages } from "./shopUtil";
import { useSerial } from "./useSerial";
import type { ViewerApi } from "./viewerContext";
const sheetName = (pg: PacketPage | undefined, n: number) =>
pg?.dwg || `sheet ${n}`;
/** The heat table panel's grab: drag the table from here onto the drawing. */
function HeatGrab({
rows,
apiRef,
onDrop,
onMiss,
}: {
rows: readonly HeatRow[];
apiRef: MutableRefObject<ViewerApi | null>;
onDrop: (x: number, y: number) => void;
onMiss: () => void;
}) {
const [ghost, setGhost] = useState<{ x: number; y: number } | null>(null);
const down = (e: ReactPointerEvent) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.preventDefault();
const sx = e.clientX;
const sy = e.clientY;
const id = e.pointerId;
let started = false;
const mv = (ev: PointerEvent) => {
if (ev.pointerId !== id) return;
if (!started && Math.hypot(ev.clientX - sx, ev.clientY - sy) < 8) return;
started = true;
setGhost({ x: ev.clientX, y: ev.clientY });
};
const up = (ev: PointerEvent) => {
if (ev.pointerId !== id) return;
window.removeEventListener("pointermove", mv);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
setGhost(null);
if (!started || ev.type !== "pointerup") return;
const f = apiRef.current?.toFrac(ev.clientX, ev.clientY);
if (f && f.inside && onPage(f)) {
const c = clampCorner(f.fx, f.fy);
onDrop(c.x, c.y);
} else onMiss();
};
window.addEventListener("pointermove", mv);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
};
return (
<>
<div
className="htprev htgrab"
data-testid="heat-table-grab"
title="Drag onto the drawing"
onPointerDown={down}
>
<div className="small muted mb6">Drag me onto the drawing</div>
<div className="htbl static">
<HeatTableInner rows={rows} />
</div>
</div>
{ghost ? (
<div
className="htbl htghost"
style={{ left: ghost.x, top: ghost.y }}
aria-hidden="true"
>
<HeatTableInner rows={rows} />
</div>
) : null}
</>
);
}
function TagSheet({
p,
tag,
sub,
onSave,
onRemove,
onClose,
}: {
p: PacketRead;
tag: HeatTag;
sub: string;
onSave: (item: number) => void;
onRemove: () => void;
onClose: () => void;
}) {
const [item, setItem] = useState(tag.item);
return (
<Sheet title={`Heat tag H${tag.tag_no}`} sub={sub} onClose={onClose}>
<label className="f">
Points to BOM item
<select
aria-label="Points to BOM item"
className="in"
value={item}
onChange={(e) => setItem(Number(e.target.value))}
>
{p.bom.map((b) => (
<option key={b.item} value={b.item}>
{b.item} - {b.description ?? ""} ·{" "}
{b.heat || (b.na ? "N/A" : "no heat")}
</option>
))}
</select>
</label>
<div className="sheet-acts start">
<button
className="btn pri"
type="button"
onClick={() => (item === tag.item ? onClose() : onSave(item))}
>
Save
</button>
<button className="btn ghost" type="button" onClick={onClose}>
Cancel
</button>
<span className="grow" />
<button className="btn ghost pinktxt" type="button" onClick={onRemove}>
Remove tag
</button>
</div>
</Sheet>
);
}
/**
* The heat map: the heat table placed on each sheet (always the live BOM heats), and the older amber heat bubbles
* where a sheet has no table. A packet with no heat map and no raw material of its own shows its sibling's map,
* read-only.
*/
export function HeatMapTab({
p,
write,
reload,
sub,
onError,
}: {
p: PacketRead;
write: Write;
reload: () => Promise<void>;
sub: string;
onError: (text: string) => void;
}) {
const own = p.heat_table.length > 0 || p.heat_tags.length > 0;
const src = !own && p.heat_src ? p.heat_src : null;
const allPages = src ? src.pages : p.pages;
const pages = viewPages(allPages);
const [picked, setPicked] = useState<{
n: number;
onPurpose: boolean;
} | null>(null);
const page = landingPage(
pages,
picked?.n ?? null,
picked?.onPurpose ?? false,
);
const [tagOpen, setTagOpen] = useState<number | null>(null);
const apiRef = useRef<ViewerApi | null>(null);
const serial = useSerial(p, write);
if (!page) {
return <div className="empty">No drawing pages in this packet.</div>;
}
const bom = src ? src.bom : p.bom;
const rows = heatRows(bom);
const missing = rows.filter((r) => !r.heat).length;
const tables = src ? src.heat_table : p.heat_table;
const tags = src ? src.heat_tags : p.heat_tags;
const placement = tables.find((t) => t.page === page.n) ?? null;
const legacyHere = tags.filter((t) => t.page === page.n);
const shownTags = placement ? [] : legacyHere;
const isVoid = !!page.void;
const editTable = !src && !isVoid && p.can.includes("edit_heat_table");
const editTags = !src && !isVoid && p.can.includes("edit_heats");
const vText = isVoid ? voidText(page, allPages) : "";
const heatOf = (item: number) => {
const b = bom.find((x) => x.item === item);
return b ? b.heat || (b.na ? "N/A" : "") : "";
};
const name = sheetName(page, page.n);
const tag =
tagOpen === null
? null
: (p.heat_tags.find((t) => t.tag_no === tagOpen) ?? null);
const done = async (r: WriteResult): Promise<boolean> => {
if (r.ok) return true;
if (r.err !== undefined) {
if (
r.err instanceof ApiError &&
(r.err.status === 409 || r.err.status === 412)
)
await reload();
onError(errorText(r.err));
}
return false;
};
const cur = (c: PacketRead) =>
c.heat_table.find((t) => t.page === page.n) ?? null;
const put = (x: number, y: number, s?: number) =>
serial((c, g) =>
packetsApi.putHeatTable(
c.id,
page.n,
{ x, y, s: s ?? cur(c)?.s ?? 1 },
g,
),
).then(done);
const resize = (dir: number) =>
serial((c, g) => {
const t = cur(c);
if (!t) return Promise.resolve(c);
const s = stepSize(t.s, dir);
return s === t.s
? Promise.resolve(c)
: packetsApi.putHeatTable(c.id, page.n, { x: t.x, y: t.y, s }, g);
}).then(done);
const removeTable = () =>
serial((c, g) => packetsApi.removeHeatTable(c.id, page.n, g)).then(done);
const tagRv = (c: PacketRead, t: HeatTag) =>
c.heat_tags.find((x) => x.tag_no === t.tag_no)?.row_version ??
t.row_version;
const moveTag = (t: HeatTag, m: MarkMove) =>
serial((c, g) =>
packetsApi.patchHeatTag(
c.id,
t.tag_no,
tagRv(c, t),
m.bubble
? { bfx: m.bfx, bfy: m.bfy }
: { fx: m.fx, fy: m.fy, bfx: m.bfx, bfy: m.bfy },
g,
),
).then(done);
const tagCount = (item: number) => tags.filter((t) => t.item === item).length;
return (
<div className="stack">
{src ? (
<div className="callout flush" role="note">
Heat map from{" "}
<Link to={`/packets/${src.packet_id}`}>
{src.op ?? "another packet"}
</Link>{" "}
on this unit (same drawing). Record heats there.
</div>
) : null}
{isVoid ? (
<div className="callout warn flush">
<b>VOID copy, not for fabrication.</b> {vText}. Kept for the record
with its heat marks.{" "}
{page.void?.new_n ? (
<button
className="btn sm"
type="button"
onClick={() =>
setPicked({ n: page.void?.new_n ?? page.n, onPurpose: false })
}
>
Go to the current sheet
</button>
) : null}
</div>
) : null}
<div className="pagesel">
{pages.length > 1 ? (
<div className="chips" role="group" aria-label="Sheets">
{pages.map((x) => (
<button
key={x.n}
type="button"
className={`btn sm${x.n === page.n ? " pri" : ""}${x.void ? " voidbtn" : ""}`}
aria-pressed={x.n === page.n}
onClick={() => setPicked({ n: x.n, onPurpose: true })}
>
{pageLabel(x)}
</button>
))}
</div>
) : (
<span className="mono small muted">{pageLabel(page)}</span>
)}
{!editTable ? (
<span className="small muted">
{placement
? "Heat table on this sheet"
: "No heat table on this sheet"}
</span>
) : null}
</div>
<div className="dv">
<MapViewer
page={page}
voidLabel={vText}
apiRef={apiRef}
label="Heat map"
fsBar={<span className="fst">{pageLabel(page)}</span>}
>
{shownTags.map((t) => (
<HeatTagMark
key={t.tag_no}
tag={t}
heat={heatOf(t.item)}
editable={editTags}
onOpen={(x) => {
if (editTags) setTagOpen(x.tag_no);
}}
onMove={moveTag}
/>
))}
{placement ? (
<HeatTableOnDrawing
placement={placement}
rows={rows}
editable={editTable}
onMove={(x, y) => put(x, y)}
/>
) : null}
</MapViewer>
<div className="side">
<div className="panel" role="group" aria-label="Heat table panel">
<h3>Heat table</h3>
<div className="small mb10">
{editTable
? placement
? "Drag the table on the drawing to move it. It always shows the heat numbers from the Materials tab."
: "Drag the table onto the drawing, or tap Place on this sheet. Heat numbers fill in from the Materials tab."
: placement
? "The heat table is on this sheet."
: "No heat table on this sheet yet."}
</div>
{placement?.carried_from ? (
<div className="small amber mb10">
Copied from the VOID copy. Check its position on the new
revision.
</div>
) : null}
{editTable ? (
<HeatGrab
rows={rows}
apiRef={apiRef}
onDrop={(x, y) => void put(x, y)}
onMiss={() => onError("Drop the table on the drawing.")}
/>
) : (
<div className="htprev">
<div className="htbl static">
<HeatTableInner rows={rows} />
</div>
</div>
)}
{missing ? (
<div className="small pinktxt mt8">
{missing} item{missing > 1 ? "s" : ""} with no heat number yet.
</div>
) : null}
{editTable ? (
<div className="acts mt10">
{placement ? (
<>
<button
className="btn sm"
type="button"
aria-label="Smaller table"
title="Smaller table (A-)"
onClick={() => void resize(-1)}
>
A−
</button>
<button
className="btn sm"
type="button"
aria-label="Larger table"
title="Larger table (A+)"
onClick={() => void resize(1)}
>
A+
</button>
<button
className="btn sm ghost"
type="button"
onClick={() => void removeTable()}
>
Remove from this sheet
</button>
</>
) : (
<button
className="btn sm pri"
type="button"
onClick={() => void put(0.02, 0.02, 1)}
>
Place on this sheet
</button>
)}
</div>
) : null}
{legacyHere.length ? (
<div className="small muted mt8">
{legacyHere.length} older heat bubble
{legacyHere.length > 1 ? "s are" : " is"} on this sheet
{placement ? " (hidden now that the table is placed)" : ""}.
</div>
) : null}
{placement ? (
<div className="small muted mt8">
Size {placement.s.toFixed(2)} · {name}
</div>
) : null}
</div>
<div className="panel">
<h3>Items and heat numbers</h3>
<div className="hlist" aria-label="Items and heat numbers">
{bom.length ? (
bom.map((b) => {
const line = p.bom.find((x) => x.item === b.item);
const n = tagCount(b.item);
return (
<div className="row" key={b.item}>
<span className="balloon">{b.item}</span>
<span className="d">
{line?.description ?? ""}
<div className="small muted mono">
{line?.material ?? ""}
{n ? ` · ${n} tag${n > 1 ? "s" : ""} on drawing` : ""}
</div>
</span>
<span className={b.heat ? "heat" : "heat none"}>
{b.heat || (b.na ? "N/A" : NO_HEAT)}
</span>
</div>
);
})
) : (
<div className="small muted">No BOM lines.</div>
)}
</div>
</div>
</div>
</div>
{tag ? (
<TagSheet
p={p}
tag={tag}
sub={sub}
onClose={() => setTagOpen(null)}
onSave={(item) =>
void serial((c, g) =>
packetsApi.patchHeatTag(
c.id,
tag.tag_no,
tagRv(c, tag),
{ item },
g,
),
)
.then(done)
.then((ok) => {
if (ok) setTagOpen(null);
})
}
onRemove={() =>
void serial((c, g) =>
packetsApi.removeHeatTag(c.id, tag.tag_no, tagRv(c, tag), g),
)
.then(done)
.then((ok) => {
if (ok) setTagOpen(null);
})
}
/>
) : null}
</div>
);
}
+476
View File
@@ -0,0 +1,476 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type MutableRefObject,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import type { PacketPage } from "../api/types";
import {
fitView,
onPage,
toFraction,
zoomAt,
type Frac,
type View,
} from "../lib/geometry";
import { openPdf } from "../lib/pdf";
import { stampVoid } from "../lib/voidStamp";
import {
DRAG_PX,
TAP_PX,
ViewerCtx as Ctx,
renderScale,
type DragHandlers,
type ViewerApi,
type ViewerInfo,
} from "./viewerContext";
interface MapViewerProps {
page: PacketPage;
/** The text under VOID on a voided page. */
voidLabel?: string | undefined;
/** marking: crosshair with a pink outline; placing: crosshair with an amber outline. */
mode?: "" | "marking" | "placing";
/** A tap on the drawing (pointer moved no more than 6 px). */
onTap?: ((fx: number, fy: number) => void) | undefined;
/** The overlay: marks, leader lines, the heat table. Positioned in percent of the page. */
children?: ReactNode;
/** Shown at the top left in full screen, for the marking controls. */
fsBar?: ReactNode;
/** Filled with the viewer's api, for drags that start outside it (the heat table panel). */
apiRef?: MutableRefObject<ViewerApi | null>;
label?: string;
}
const ICON_FS = (
<svg
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
</svg>
);
const ICON_EXIT = (
<svg
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
</svg>
);
/**
* One drawing page in a pan and zoom viewer. pdf.js renders the page once to a canvas; panning and zooming are a CSS
* transform on the stage. Mouse, pen and touch pan on every drag; two fingers pinch; the wheel zooms at the pointer.
* Ported from the old wireViewer with its fixes: the text selection is cleared, the browser's own drag is blocked,
* stale pointers are cleared, and moves and ups are followed on the window so a drag that leaves the drawing ends.
*/
export function MapViewer({
page,
voidLabel,
mode = "",
onTap,
children,
fsBar,
apiRef,
label,
}: MapViewerProps) {
const viewerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const view = useRef<View>({ x: 0, y: 0, z: 1, w: 0, h: 0 });
const fitZ = useRef(1);
const atFit = useRef(true);
const [size, setSize] = useState({ w: 0, h: 0 });
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(true);
const [fs, setFs] = useState(false);
const tapRef = useRef(onTap);
const dragEnd = useRef(0);
useEffect(() => {
tapRef.current = onTap;
});
const apply = useCallback(() => {
const st = stageRef.current;
if (!st) return;
const v = view.current;
st.style.transform = `translate(${v.x}px, ${v.y}px) scale(${v.z})`;
st.style.setProperty("--inv", String(1 / v.z));
}, []);
const fit = useCallback(() => {
const el = viewerRef.current;
const v = view.current;
if (!el || !v.w || !v.h) return;
const f = fitView(el.clientWidth || v.w, el.clientHeight || v.h, v.w, v.h);
fitZ.current = f.z;
view.current = f;
atFit.current = true;
apply();
}, [apply]);
const zoom = useCallback(
(f: number, cx?: number, cy?: number) => {
const el = viewerRef.current;
if (!el || !view.current.w) return;
const x = cx ?? el.clientWidth / 2;
const y = cy ?? el.clientHeight / 2;
view.current = zoomAt(view.current, f, x, y, fitZ.current * 0.6);
atFit.current = false;
apply();
},
[apply],
);
// Render the page once per page.
const url = page.url;
const pn = page.pn || 1;
const voided = !!page.void;
useEffect(() => {
let cancelled = false;
let task: { cancel: () => void; promise: Promise<unknown> } | null = null;
setBusy(true);
setError(null);
openPdf(url)
.then((doc) => doc.getPage(Math.max(1, Math.min(pn, doc.numPages))))
.then(async (pg) => {
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
const vp = pg.getViewport({
scale: renderScale(pg.getViewport({ scale: 1 }).width),
});
canvas.width = Math.floor(vp.width);
canvas.height = Math.floor(vp.height);
task = pg.render({ canvas, viewport: vp });
await task.promise;
if (cancelled) return;
if (voided) stampVoid(canvas, voidLabel ?? "VOID");
view.current = { ...view.current, w: canvas.width, h: canvas.height };
setSize({ w: canvas.width, h: canvas.height });
setBusy(false);
fit();
})
.catch((err: unknown) => {
if (cancelled) return;
if (
err &&
typeof err === "object" &&
"name" in err &&
(err as { name: string }).name === "RenderingCancelledException"
)
return;
setBusy(false);
setError(
"The drawing could not be shown. Open it in a new tab instead.",
);
});
return () => {
cancelled = true;
task?.cancel();
};
}, [url, pn, voided, voidLabel, fit]);
// Refit when the frame changes size and the user has not zoomed.
useEffect(() => {
const el = viewerRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => {
if (atFit.current) fit();
});
ro.observe(el);
return () => ro.disconnect();
}, [fit]);
// Full screen: a body class; Esc leaves it.
useEffect(() => {
if (!fs) return;
document.body.classList.add("fs-view");
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !document.querySelector(".scrim")) setFs(false);
};
document.addEventListener("keydown", onKey);
const t = window.setTimeout(fit, 0);
return () => {
document.body.classList.remove("fs-view");
document.removeEventListener("keydown", onKey);
window.clearTimeout(t);
window.setTimeout(fit, 0);
};
}, [fs, fit]);
const toFrac = useCallback((clientX: number, clientY: number) => {
const el = viewerRef.current;
if (!el) return { fx: NaN, fy: NaN, inside: false };
const r = el.getBoundingClientRect();
const f = toFraction(clientX, clientY, r, view.current);
return {
...f,
inside:
clientX >= r.left &&
clientX <= r.right &&
clientY >= r.top &&
clientY <= r.bottom,
};
}, []);
// Pan, pinch and tap. Pointers are tracked on the window.
useEffect(() => {
const el = viewerRef.current;
if (!el) return;
const pts = new Map<number, { x: number; y: number }>();
let moved = false;
let start: { x: number; y: number; vx: number; vy: number } | null = null;
let pinch: { d: number; z: number } | null = null;
const down = (e: PointerEvent) => {
const t = e.target as Element | null;
if (t?.closest("[data-mark], .vtools, .fsbar")) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
e.preventDefault();
try {
const sel = window.getSelection();
if (sel && sel.rangeCount) sel.removeAllRanges();
} catch {
/* nothing selected */
}
if (e.isPrimary) pts.clear();
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 1) {
moved = false;
start = {
x: e.clientX,
y: e.clientY,
vx: view.current.x,
vy: view.current.y,
};
pinch = null;
el.classList.add("panning");
}
if (pts.size === 2) {
const [a, b] = [...pts.values()];
if (a && b)
pinch = {
d: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)),
z: view.current.z,
};
moved = true;
}
};
const move = (e: PointerEvent) => {
if (!pts.has(e.pointerId)) return;
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size >= 2 && pinch) {
const [a, b] = [...pts.values()];
if (!a || !b) return;
const r = el.getBoundingClientRect();
const d = Math.hypot(a.x - b.x, a.y - b.y);
zoom(
(pinch.z * d) / pinch.d / view.current.z,
(a.x + b.x) / 2 - r.left,
(a.y + b.y) / 2 - r.top,
);
return;
}
if (!start) return;
if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > TAP_PX)
moved = true;
if (moved && pts.size === 1) {
view.current = {
...view.current,
x: start.vx + (e.clientX - start.x),
y: start.vy + (e.clientY - start.y),
};
atFit.current = false;
apply();
}
};
const end = (e: PointerEvent) => {
if (!pts.has(e.pointerId)) return;
pts.delete(e.pointerId);
if (pts.size === 1) {
// One finger lifted from a pinch: keep panning with the other.
const [a] = [...pts.values()];
if (a)
start = { x: a.x, y: a.y, vx: view.current.x, vy: view.current.y };
pinch = null;
return;
}
if (pts.size === 0) {
el.classList.remove("panning");
if (!moved && view.current.w && e.type === "pointerup") {
const f = toFrac(e.clientX, e.clientY);
if (onPage(f)) tapRef.current?.(f.fx, f.fy);
}
start = null;
pinch = null;
}
};
const wheel = (e: WheelEvent) => {
e.preventDefault();
const r = el.getBoundingClientRect();
zoom(e.deltaY < 0 ? 1.15 : 0.87, e.clientX - r.left, e.clientY - r.top);
};
const block = (e: Event) => e.preventDefault();
el.addEventListener("pointerdown", down);
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", end);
window.addEventListener("pointercancel", end);
el.addEventListener("wheel", wheel, { passive: false });
el.addEventListener("dragstart", block);
el.addEventListener("selectstart", block);
return () => {
el.removeEventListener("pointerdown", down);
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", end);
el.removeEventListener("wheel", wheel);
el.removeEventListener("dragstart", block);
el.removeEventListener("selectstart", block);
};
}, [apply, zoom, toFrac]);
const startDrag = useCallback(
(e: ReactPointerEvent, h: DragHandlers) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const sx = e.clientX;
const sy = e.clientY;
const id = e.pointerId;
let moved = false;
let last: Frac | null = null;
const mv = (ev: PointerEvent) => {
if (ev.pointerId !== id) return;
if (!moved && Math.hypot(ev.clientX - sx, ev.clientY - sy) < DRAG_PX)
return;
moved = true;
const f = toFrac(ev.clientX, ev.clientY);
last = { fx: f.fx, fy: f.fy };
h.onMove(last);
};
const up = (ev: PointerEvent) => {
if (ev.pointerId !== id) return;
window.removeEventListener("pointermove", mv);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
if (moved) dragEnd.current = Date.now();
h.onEnd(moved && ev.type === "pointerup" ? last : null);
};
window.addEventListener("pointermove", mv);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
},
[toFrac],
);
const justDragged = useCallback(() => Date.now() - dragEnd.current < 300, []);
const api = useMemo<ViewerApi>(
() => ({ toFrac, startDrag, justDragged }),
[toFrac, startDrag, justDragged],
);
useEffect(() => {
if (!apiRef) return;
apiRef.current = api;
return () => {
apiRef.current = null;
};
}, [api, apiRef]);
const info = useMemo<ViewerInfo>(
() => ({
...api,
w: size.w,
h: size.h,
aspect: size.h ? size.w / size.h : 1.5,
}),
[api, size],
);
return (
<div
ref={viewerRef}
className={`viewer${mode ? ` ${mode}` : ""}`}
data-testid="map-viewer"
data-ready={busy || error ? "0" : "1"}
aria-label={label ?? `Drawing page ${page.n}`}
role="group"
>
<div
ref={stageRef}
className="stage"
style={
size.w
? ({
width: size.w,
height: size.h,
["--sw" as string]: `${size.w}px`,
} as CSSProperties)
: undefined
}
>
<canvas
ref={canvasRef}
className="stage-canvas"
role="img"
aria-label={`Drawing page ${page.n}${voided ? ", VOID" : ""}`}
/>
{size.w ? <Ctx.Provider value={info}>{children}</Ctx.Provider> : null}
</div>
{busy && !error ? (
<div className="vload small">Drawing the page</div>
) : null}
{error ? (
<div className="vload">
<div className="callout warn flush">{error}</div>
</div>
) : null}
<div className="vtools">
<button type="button" aria-label="Zoom in" onClick={() => zoom(1.25)}>
+
</button>
<button type="button" aria-label="Zoom out" onClick={() => zoom(0.8)}>
−
</button>
<button
type="button"
aria-label="Fit drawing"
className="fit"
onClick={fit}
>
FIT
</button>
<button
type="button"
aria-label={fs ? "Exit full screen" : "Full screen"}
title={fs ? "Exit full screen (Esc)" : "Full screen"}
onClick={() => setFs((v) => !v)}
>
{fs ? ICON_EXIT : ICON_FS}
</button>
</div>
{fsBar ? <div className="fsbar">{fsBar}</div> : null}
</div>
);
}
+1 -1
View File
@@ -115,7 +115,7 @@ describe("PacketPage", () => {
}); });
renderApp("/packets/1"); renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" })); fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Someone else already took this step."); expect(await screen.findByTestId("toast")).toHaveTextContent("Someone else already took this step.");
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2)); await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"); expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse");
}); });
+59 -11
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { Link, useNavigate, useParams } from "react-router"; import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { assetsApi } from "../api/assets"; import { assetsApi } from "../api/assets";
import { ApiError } from "../api/client"; import { ApiError, errorCode } from "../api/client";
import { packetsApi } from "../api/packets"; import { packetsApi } from "../api/packets";
import type { PacketHeader, PacketRead, TransitionBody } from "../api/types"; import type { PacketHeader, PacketRead, TransitionBody } from "../api/types";
import { homePath } from "../auth/roles"; import { homePath } from "../auth/roles";
import { useAuth } from "../auth/useAuth"; import { useAuth } from "../auth/useAuth";
import { usePacketPresence } from "../auth/useEvents";
import { useGuard } from "../components/useGuard"; import { useGuard } from "../components/useGuard";
import { useChanged } from "../lib/changes"; import { useChanged } from "../lib/changes";
import { errorText } from "../lib/format"; import { errorText } from "../lib/format";
@@ -14,12 +15,23 @@ import { DrawingViewer } from "./DrawingViewer";
import { MoveSheet, SendToDeptSheet } from "./sheets"; import { MoveSheet, SendToDeptSheet } from "./sheets";
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs"; import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
import { coSummary, coToReview } from "../lib/changeOrders"; import { coSummary, coToReview } from "../lib/changeOrders";
import { ChangesTab, VisualBox } from "./visual"; import { VisualBox } from "./visual";
import { ChangesTab } from "./ChangesTab";
import { ChecklistTab } from "./ChecklistTab";
import { HeatMapTab } from "./HeatMapTab";
import { ShopBar } from "./ShopActions";
import { WeldMapTab } from "./WeldMapTab";
import type { Write } from "../warehouse/PullTable"; import type { Write } from "../warehouse/PullTable";
import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whStage } from "../warehouse/stages"; import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whStage } from "../warehouse/stages";
import { useWhMove } from "../warehouse/useWhMove"; import { useWhMove } from "../warehouse/useWhMove";
type Tab = "drawing" | "materials" | "workorders" | "changes" | "docs" | "history"; type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "workorders" | "changes" | "docs" | "history";
const TABS: readonly Tab[] = ["drawing", "heat", "materials", "checklist", "welds", "workorders", "changes", "docs", "history"];
function isTab(v: string | null): v is Tab {
return v !== null && (TABS as readonly string[]).includes(v);
}
const TOAST_MS = 5000;
/** The rule's signature meaning for attaching a drawing by hand. */ /** The rule's signature meaning for attaching a drawing by hand. */
const SIGN_ATTACH = "Attach a drawing by hand"; const SIGN_ATTACH = "Attach a drawing by hand";
@@ -119,9 +131,13 @@ export function PacketPage() {
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [tab, setTab] = useState<Tab | null>(null); const [search] = useSearchParams();
const wantTab = search.get("tab");
const [tab, setTab] = useState<Tab | null>(isTab(wantTab) ? wantTab : null);
const [sheet, setSheet] = useState<"dept" | "move" | null>(null); const [sheet, setSheet] = useState<"dept" | "move" | null>(null);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const others = usePacketPresence(Number.isFinite(id) ? id : null);
const versionRef = useRef<number | null>(null); const versionRef = useRef<number | null>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@@ -140,14 +156,20 @@ export function PacketPage() {
useEffect(() => { useEffect(() => {
setPacket(null); setPacket(null);
setTab(null); setTab(isTab(wantTab) ? wantTab : null);
void reload(); void reload();
}, [reload]); }, [reload, wantTab]);
useEffect(() => { useEffect(() => {
versionRef.current = packet?.version ?? null; versionRef.current = packet?.version ?? null;
}, [packet]); }, [packet]);
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), TOAST_MS);
return () => window.clearTimeout(t);
}, [toast]);
// Live refresh: another screen changed this packet. // Live refresh: another screen changed this packet.
useChanged(() => void reload(), { useChanged(() => void reload(), {
delayMs: 500, delayMs: 500,
@@ -195,7 +217,9 @@ export function PacketPage() {
} catch (err) { } catch (err) {
// stale_step, field_conflict, row_conflict: someone else moved first. Show their state. // stale_step, field_conflict, row_conflict: someone else moved first. Show their state.
if (err instanceof ApiError && (err.status === 409 || err.status === 412)) await reload(); if (err instanceof ApiError && (err.status === 409 || err.status === 412)) await reload();
setMsg({ ok: false, text: errorText(err) }); // A step someone else already took: the page shows their state and says so once, as a toast.
if (errorCode(err) === "stale_step") setToast(errorText(err));
else setMsg({ ok: false, text: errorText(err) });
return false; return false;
} finally { } finally {
setBusy(false); setBusy(false);
@@ -293,7 +317,7 @@ export function PacketPage() {
const drawingPages = p.pages.filter((x) => x.type === "drawing"); const drawingPages = p.pages.filter((x) => x.type === "drawing");
const review = coToReview(p); const review = coToReview(p);
const shownTab: Tab = tab ?? (drawingPages.length ? "drawing" : "materials"); const shownTab: Tab = tab === "changes" && !(p.change_orders ?? []).length ? "materials" : (tab ?? (drawingPages.length ? "drawing" : "materials"));
// The warehouse short-parts exception and the data book date are not edits of the packet itself. // The warehouse short-parts exception and the data book date are not edits of the packet itself.
const passive = new Set(["wh_receive_short", "set_databook_at"]); const passive = new Set(["wh_receive_short", "set_databook_at"]);
const viewOnly = const viewOnly =
@@ -322,14 +346,20 @@ export function PacketPage() {
const hasDraftControls = const hasDraftControls =
draft && ["edit_header", "send_to_warehouse", "release_to_shop", "send_draft_to_qc", "edit_document_set", "move_unit", "remove"].some((a) => can(a) || p.blocked[a]); draft && ["edit_header", "send_to_warehouse", "release_to_shop", "send_draft_to_qc", "edit_document_set", "move_unit", "remove"].some((a) => can(a) || p.blocked[a]);
const cos = (p.change_orders ?? []).length;
const ckOpen = p.checklist_items.filter((i) => !p.stage?.checklist?.[i.key]?.done).length;
const tabs: Array<{ id: Tab; label: string; count?: number }> = [ const tabs: Array<{ id: Tab; label: string; count?: number }> = [
{ id: "drawing", label: "Drawing", count: drawingPages.length }, { id: "drawing", label: "Drawing", count: drawingPages.length },
{ id: "heat", label: "Heat map" },
{ id: "materials", label: "Materials", count: p.bom.length }, { id: "materials", label: "Materials", count: p.bom.length },
{ id: "checklist", label: "Checklist", ...(p.stage ? { count: ckOpen } : {}) },
{ id: "welds", label: "Weld map", count: p.welds.length },
{ id: "workorders", label: "Work orders", count: p.work_orders.length }, { id: "workorders", label: "Work orders", count: p.work_orders.length },
{ id: "changes", label: "Changes", count: (p.change_orders ?? []).length }, ...(cos ? [{ id: "changes" as const, label: "Changes", count: cos }] : []),
{ id: "docs", label: "Documents", count: p.docs.length }, { id: "docs", label: "Documents", count: p.docs.length },
{ id: "history", label: "History" }, { id: "history", label: "History" },
]; ];
const onTabError = (text: string) => setMsg({ ok: false, text });
return ( return (
<> <>
@@ -446,6 +476,12 @@ export function PacketPage() {
</div> </div>
</div> </div>
) : null} ) : null}
{others.length ? (
<div className="presence small" data-testid="presence" role="status">
<span className="pdot" aria-hidden="true" />
Also here: {others.map((u) => u.display_name).join(", ")}
</div>
) : null}
{msg ? ( {msg ? (
<div className={msg.ok ? "callout" : "callout warn"} role={msg.ok ? "status" : "alert"}> <div className={msg.ok ? "callout" : "callout warn"} role={msg.ok ? "status" : "alert"}>
{msg.text} {msg.text}
@@ -454,7 +490,7 @@ export function PacketPage() {
{review && (me?.roles ?? []).some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin") ? ( {review && (me?.roles ?? []).some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin") ? (
<div className="callout warn" role="status"> <div className="callout warn" role="status">
<b>Change order {review.co_no}: a change from VISUAL is waiting for your approval</b> - {coSummary(review)}. Nothing has gone to the shop packet yet. <b>Change order CO-{review.co_no}: a change from VISUAL is waiting for your approval</b> - {coSummary(review)}. Nothing has gone to the shop packet yet.
{review.hold ? <b> Shop packet on hold.</b> : null}{" "} {review.hold ? <b> Shop packet on hold.</b> : null}{" "}
<button className="btn sm pri" type="button" onClick={() => setTab("changes")}> <button className="btn sm pri" type="button" onClick={() => setTab("changes")}>
Review change Review change
@@ -615,6 +651,8 @@ export function PacketPage() {
</section> </section>
) : null} ) : null}
<ShopBar p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} goTab={(t) => setTab(t)} />
<div className="tabs" role="tablist" aria-label="Packet"> <div className="tabs" role="tablist" aria-label="Packet">
{tabs.map((t) => ( {tabs.map((t) => (
<button <button
@@ -648,7 +686,12 @@ export function PacketPage() {
<DrawingViewer pages={drawingPages} /> <DrawingViewer pages={drawingPages} />
</div> </div>
) : null} ) : null}
{shownTab === "heat" ? <HeatMapTab key={p.id} p={p} write={write} reload={reload} sub={job} onError={onTabError} /> : null}
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} write={write} reload={reload} /> : null} {shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} write={write} reload={reload} /> : null}
{shownTab === "checklist" ? <ChecklistTab p={p} write={write} mutate={mutate} reload={reload} onError={onTabError} /> : null}
{shownTab === "welds" ? (
<WeldMapTab key={p.id} p={p} write={write} mutate={mutate} reload={reload} askReason={askReason} sub={job} myId={me?.user.id ?? null} onError={onTabError} />
) : null}
{shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null} {shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null}
{shownTab === "changes" ? <ChangesTab p={p} roles={me?.roles ?? []} sub={job} mutate={mutate} busy={busy} askReason={askReason} /> : null} {shownTab === "changes" ? <ChangesTab p={p} roles={me?.roles ?? []} sub={job} mutate={mutate} busy={busy} askReason={askReason} /> : null}
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} /> : null} {shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} /> : null}
@@ -679,6 +722,11 @@ export function PacketPage() {
) : null} ) : null}
{wh.sheet} {wh.sheet}
{guardSheet} {guardSheet}
{toast ? (
<div className="toast" role="status" data-testid="toast">
{toast}
</div>
) : null}
</> </>
); );
} }
+742
View File
@@ -0,0 +1,742 @@
import { useState, type FormEvent, type ReactNode } from "react";
import type { PacketRead } from "../api/types";
import { packetsApi } from "../api/packets";
import { useAuth } from "../auth/useAuth";
import type { ReasonOptions } from "../components/ReasonSheet";
import { Sheet } from "../components/Sheet";
import { fmtClock, isRunning, liveElapsed, useTicker } from "../lib/clock";
import { targetHours } from "../lib/packets";
import { useShopDepartments } from "../lib/useDepartments";
import {
makeStep,
shopLevel,
stallMissing,
usePeople,
type ShopTab,
type StepFn,
} from "./shopUtil";
import type { Mutate } from "./tabs";
/** Signature meanings, as the rules state them. */
export const SIGN_SUBMIT = "Submit work to the supervisor";
export const SIGN_SEND_TO_QC = "Send to Q.C.";
export const SIGN_ROUTE = "Route to another department";
/** The server's precondition texts the stall shows as links instead. */
const STALL_PRECONDITIONS = new Set([
"Finish the checklist first.",
"Record at least one weld first.",
"Enter the filler metal first.",
]);
function stationsOf(
depts: ReturnType<typeof useAuth>["departments"],
code: string,
): Array<{ id: number; name: string }> {
const d = depts.find((x) => x.code === code);
const out: Array<{ id: number; name: string }> = [];
for (const s of d?.stations ?? []) {
if (typeof s === "object" && s.id !== undefined && s.active !== false)
out.push({ id: s.id, name: s.name });
}
return out;
}
/** The clock line: HH:MM:SS, running or paused, the target, and the VISUAL ticket hours beside it. */
export function ClockLine({ p }: { p: PacketRead }) {
const clock = p.stage?.clock ?? null;
const running = isRunning(clock);
const key = `${p.id}:${p.version}:${clock?.elapsed_ms ?? 0}:${clock?.running_since ?? ""}`;
const [fetched, setFetched] = useState(() => ({ key, at: Date.now() }));
if (fetched.key !== key) setFetched({ key, at: Date.now() });
const now = useTicker(running);
const ms = liveElapsed(clock, fetched.key === key ? fetched.at : now, now);
const target = p.target_hrs ?? targetHours(p);
const state = running
? "running"
: p.status === "in_progress"
? "paused"
: "";
return (
<div className="clockrow">
<div className="clock" data-testid="clock" aria-live="off">
<span
className={`cdot${running ? " run" : ""}`}
title={running ? "Running" : "Stopped"}
aria-hidden="true"
/>
{fmtClock(ms)}
<small>
{state ? ` ${state}` : ""} · target {Number(target || 0).toFixed(1)} h
</small>
</div>
{p.labor ? (
<div
className="labor small"
data-testid="labor"
title={
p.labor.observed_at
? `From VISUAL, read ${p.labor.observed_at}`
: "From VISUAL"
}
>
VISUAL tickets <b>{Number(p.labor.hours || 0).toFixed(1)} h</b>
</div>
) : null}
</div>
);
}
/** The supervisor's assign sheet: station, welder (with search) and stamp. */
export function AssignSheet({
p,
sub,
onCancel,
onSend,
}: {
p: PacketRead;
sub: string;
onCancel: () => void;
onSend: (stationId: number, welderId: string, stamp: string) => void;
}) {
const { departments } = useAuth();
const stations = stationsOf(departments, p.dept.code);
const { people, loaded, error: peopleError } = usePeople(p.dept.code);
const [station, setStation] = useState<number | null>(
p.station?.id ?? stations[0]?.id ?? null,
);
const [q, setQ] = useState("");
const [welder, setWelder] = useState<string>(p.welder?.id ?? "");
const [stamp, setStamp] = useState<string>(p.welder?.stamp ?? "");
const [error, setError] = useState<string | null>(null);
const st = stations.find((s) => s.id === station) ?? stations[0];
const allowed = people.filter(
(u) =>
u.level !== "worker" ||
!u.station_limits.length ||
(st ? u.station_limits.includes(st.id) : true),
);
const needle = q.trim().toLowerCase();
const shown = allowed.filter(
(u) =>
!needle ||
u.display_name.toLowerCase().includes(needle) ||
(u.stamp ?? "").toLowerCase().includes(needle) ||
u.id === welder,
);
const pick = (id: string) => {
setWelder(id);
const u = people.find((x) => x.id === id);
setStamp(u?.stamp ?? "");
setError(null);
};
const submit = (e: FormEvent) => {
e.preventDefault();
if (!st) {
setError("This department has no stations. Ask an admin to add one.");
return;
}
if (!welder || !allowed.some((u) => u.id === welder)) {
setError("Pick a welder in this department.");
return;
}
onSend(st.id, welder, stamp.trim());
};
return (
<Sheet title="Send to station" sub={sub} onClose={onCancel}>
<div className="small">
The packet appears on that station's screen right away.
</div>
<form className="stack" onSubmit={submit} noValidate aria-label="Assign">
<label className="f">
Station
<select
aria-label="Station"
className="in"
value={st?.id ?? ""}
onChange={(e) => setStation(Number(e.target.value))}
>
{stations.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</label>
<label className="f">
Find a welder
<input
aria-label="Find a welder"
className="in"
type="search"
value={q}
placeholder="Name or stamp"
onChange={(e) => setQ(e.target.value)}
/>
</label>
<label className="f">
Welder
<select
aria-label="Welder"
className="in"
value={welder}
onChange={(e) => pick(e.target.value)}
>
<option value="">{loaded ? "Choose" : "Loading"}</option>
{shown.map((u) => (
<option key={u.id} value={u.id}>
{u.display_name}
{u.stamp ? ` [${u.stamp}]` : ""}
</option>
))}
</select>
</label>
{peopleError ? (
<div className="small pinktxt">{peopleError}</div>
) : null}
{loaded && !allowed.length ? (
<div className="small muted">
No one in this department may work at this station.
</div>
) : null}
<label className="f">
Stamp
<input
aria-label="Stamp"
className="in w110"
value={stamp}
maxLength={20}
placeholder="ID"
onChange={(e) => setStamp(e.target.value)}
/>
</label>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit">
Send to station
</button>
</div>
</form>
</Sheet>
);
}
/** One step button: hidden when the user can neither do it nor is blocked from it. */
function Step({
p,
action,
busy,
onClick,
className = "btn",
disabled = false,
why,
children,
}: {
p: PacketRead;
action: string;
busy: boolean;
onClick: () => void;
className?: string;
disabled?: boolean;
why?: ReactNode;
children: ReactNode;
}) {
const can = p.can.includes(action);
const blocked = p.blocked[action];
if (!can && !blocked) return null;
const reason = blocked?.join(" ") ?? "";
return (
<div className="act">
<button
className={className}
type="button"
disabled={!can || busy || disabled}
title={!can ? reason : undefined}
onClick={onClick}
>
{children}
</button>
{why ??
(!can && reason ? <div className="why small">{reason}</div> : null)}
</div>
);
}
const has = (p: PacketRead, a: string) => p.can.includes(a) || !!p.blocked[a];
/** The supervisor's review: send to Q.C., return to the station, or skip Q.C. and route on. */
function ReviewBox({
p,
busy,
step,
askReason,
sub,
}: {
p: PacketRead;
busy: boolean;
step: StepFn;
askReason: AskReason;
sub: string;
}) {
const depts = useShopDepartments().filter((d) => d.code !== p.dept.code);
const [book, setBook] = useState(false);
const [spool, setSpool] = useState(false);
const [loc, setLoc] = useState(
String((p.qc as { location?: string } | null)?.location ?? ""),
);
const [note, setNote] = useState("");
const [ret, setRet] = useState("");
const [route, setRoute] = useState("");
const routeTo = depts.some((d) => d.code === route)
? route
: (depts[0]?.code ?? "");
const rejected = (p.qc as { result?: string } | null)?.result === "rejected";
const ready = book && spool && !!loc.trim();
const returnIt = async () => {
let reason = ret.trim();
if (reason.length < 8) {
const r = await askReason({
title: "Return to station",
sub,
label: "Return note",
note: "The station sees this note. Say what needs fixing.",
min: 8,
submitLabel: "Return to station",
});
if (r === null) return;
reason = r;
}
if (
await step(
"return_to_station",
{ reason },
{ ok: "Returned to the station." },
)
)
setRet("");
};
return (
<section className="panel actbox" aria-label="Supervisor review">
<div>
{rejected ? (
<>
<b>Rejected by Q.C.</b> Return it to the station for repair, or fix
it and send it back to Q.C.
</>
) : (
<>
<b>Submitted by {p.station?.name ?? "the station"}.</b> Check the
weld map, checklist and heats. When the data book package and the
spool are ready, send it to Q.C.
</>
)}
</div>
{has(p, "send_to_qc") ? (
<div className="revrow">
<label className="ckl">
<input
type="checkbox"
checked={book}
onChange={(e) => setBook(e.target.checked)}
/>
Data book package ready
</label>
<label className="ckl">
<input
type="checkbox"
checked={spool}
onChange={(e) => setSpool(e.target.checked)}
/>
Spool ready for inspection
</label>
<label className="f">
Spool location
<input
aria-label="Spool location"
className="in"
value={loc}
maxLength={80}
placeholder="e.g. Bay 2, rack B"
onChange={(e) => setLoc(e.target.value)}
/>
</label>
<label className="f grow2">
Note to Q.C.
<input
aria-label="Note to Q.C."
className="in"
value={note}
maxLength={500}
placeholder="Optional"
onChange={(e) => setNote(e.target.value)}
/>
</label>
<Step
p={p}
action="send_to_qc"
busy={busy}
className="btn pri"
disabled={!ready}
onClick={() =>
void step(
"send_to_qc",
{
book: true,
spool: true,
location: loc.trim(),
...(note.trim() ? { note: note.trim() } : {}),
},
{ sign: SIGN_SEND_TO_QC, ok: "Sent to Q.C." },
)
}
>
Send to Q.C.
</Step>
</div>
) : null}
{has(p, "return_to_station") ? (
<div className="revrow sep">
<label className="f grow2">
Return note
<input
aria-label="Return note"
className="in"
value={ret}
maxLength={500}
placeholder="What needs fixing"
onChange={(e) => setRet(e.target.value)}
/>
</label>
<Step
p={p}
action="return_to_station"
busy={busy}
className="btn pink"
onClick={() => void returnIt()}
>
Return to station
</Step>
</div>
) : null}
{has(p, "route_from_review") ? (
<div className="revrow sep">
<label className="f grow2">
Skip Q.C. - send to department
<select
aria-label="Skip Q.C. - send to department"
className="in"
value={routeTo}
onChange={(e) => setRoute(e.target.value)}
>
{depts.map((d) => (
<option key={d.code} value={d.code}>
{d.code} {d.name}
</option>
))}
</select>
</label>
<Step
p={p}
action="route_from_review"
busy={busy}
disabled={!routeTo}
onClick={() =>
void step(
"route_from_review",
{ dept: routeTo },
{ sign: SIGN_ROUTE, ok: "Sent to the department." },
)
}
>
Send to department
</Step>
</div>
) : null}
</section>
);
}
type AskReason = (opts: ReasonOptions) => Promise<string | null>;
/**
* The shop action bars on the packet page, per status and what `can` allows: assign, pull back, the review, and
* the stall's start, pause, resume and submit. The server decides; this only shapes the buttons.
*/
export function ShopBar({
p,
busy,
mutate,
askReason,
sub,
goTab,
}: {
p: PacketRead;
busy: boolean;
mutate: Mutate;
askReason: AskReason;
sub: string;
goTab: (t: ShopTab) => void;
}) {
const { me } = useAuth();
const roles = me?.roles ?? [];
const [assign, setAssign] = useState(false);
const step = makeStep(p, mutate);
const st = p.status;
const level = shopLevel(roles, p.dept.code);
const deptLabel = `${p.dept.code} ${p.dept.name}`;
const reasonStep = async (
action: string,
title: string,
note: string,
ok: string,
) => {
const reason = await askReason({
title,
sub,
note,
min: 8,
submitLabel: title,
});
if (reason === null) return;
await step(action, { reason }, { ok });
};
if (st === "ready") {
if (
!has(p, "assign") &&
!has(p, "send_back_to_warehouse") &&
!has(p, "return_to_clerical")
)
return null;
return (
<section className="panel actbox" aria-label="Assign">
<div>
{p.return_note ? (
<>
<b>Returned.</b> Assign it again when it is ready.
</>
) : (
<>
<b>Assign this packet</b> to a station and welder. It appears on
that station's screen right away.
</>
)}
</div>
<div className="acts">
<Step
p={p}
action="assign"
busy={busy}
className="btn pri"
onClick={() => setAssign(true)}
>
Send to station
</Step>
<Step
p={p}
action="send_back_to_warehouse"
busy={busy}
className="btn ghost"
onClick={() =>
void reasonStep(
"send_back_to_warehouse",
"Send back to Warehouse",
"The warehouse gets it back for the parts. Say what is missing.",
"Sent back to the warehouse.",
)
}
>
Send back to Warehouse
</Step>
<Step
p={p}
action="return_to_clerical"
busy={busy}
className="btn ghost"
onClick={() =>
void reasonStep(
"return_to_clerical",
"Send back to Clerical",
"The packet goes back to Clerical as a draft. Say what needs fixing.",
"Sent back to Clerical.",
)
}
>
Send back to Clerical
</Step>
</div>
{assign ? (
<AssignSheet
p={p}
sub={sub}
onCancel={() => setAssign(false)}
onSend={(station_id, welder_id, stamp) => {
setAssign(false);
void step(
"assign",
{ station_id, welder_id, ...(stamp ? { stamp } : {}) },
{ ok: "Sent to the station." },
);
}}
/>
) : null}
</section>
);
}
if (st === "assigned" || st === "in_progress") {
const sup = has(p, "unassign") || has(p, "pull_back");
const stall = has(p, "start") || has(p, "submit");
if (!sup && !stall) return null;
const running = isRunning(p.stage?.clock);
const miss = st === "in_progress" ? stallMissing(p) : [];
const other = (p.blocked.submit ?? []).filter(
(m) => !STALL_PRECONDITIONS.has(m),
);
return (
<section className="panel actbox" aria-label="Station">
<div>
{stall && st === "assigned" ? (
<>
<b>Ready to start.</b> Clock on to the work order in VISUAL (Work
orders tab) and start the timer.
</>
) : stall && st === "in_progress" ? (
miss.length ? (
<span data-testid="submit-missing">
<b>Before submitting:</b>{" "}
{miss.map((m, i) => (
<span key={m.t}>
{i ? " · " : ""}
<button
className="lnk"
type="button"
onClick={() => goTab(m.tab)}
>
{m.t}
</button>
</span>
))}
</span>
) : (
<>
<b>Everything is filled in.</b> Submit when the parts are done.
</>
)
) : (
<>
In <b>{p.station?.name ?? "a station"}</b> with{" "}
{p.welder?.display_name ?? "-"}. You can edit the heat map, weld
map, materials and checklist while it is out.
</>
)}
</div>
{other.length && stall && st === "in_progress" ? (
<div className="why small">{other.join(" ")}</div>
) : null}
<div className="acts center">
<ClockLine p={p} />
{stall && st === "assigned" ? (
<Step
p={p}
action="start"
busy={busy}
className="btn pri"
onClick={() => void step("start", {}, { ok: "Work started." })}
>
Start work
</Step>
) : null}
{stall && st === "in_progress" && p.can.includes("clock") ? (
<div className="act">
<button
className="btn"
type="button"
disabled={busy}
onClick={() =>
void mutate((g) =>
packetsApi.clock(p.id, running ? "pause" : "resume", g),
)
}
>
{running ? "Pause" : "Resume"}
</button>
</div>
) : null}
{stall && st === "in_progress" ? (
<Step
p={p}
action="submit"
busy={busy}
className="btn pri"
why={<span />}
onClick={() =>
void step(
"submit",
{},
{ sign: SIGN_SUBMIT, ok: "Submitted to the supervisor." },
)
}
>
Submit to supervisor
</Step>
) : null}
<Step
p={p}
action={st === "assigned" ? "unassign" : "pull_back"}
busy={busy}
onClick={() =>
void step(
st === "assigned" ? "unassign" : "pull_back",
{},
{ ok: "Pulled back to the queue." },
)
}
>
Pull back to queue
</Step>
</div>
</section>
);
}
if (st === "review") {
if (
has(p, "send_to_qc") ||
has(p, "return_to_station") ||
has(p, "route_from_review")
) {
return (
<ReviewBox
p={p}
busy={busy}
step={step}
askReason={askReason}
sub={sub}
/>
);
}
if (level && level !== "supervisor") {
return (
<div className="callout" role="note" data-testid="review-readonly">
<b>Submitted by {p.station?.name ?? "the station"}.</b> A supervisor
for {deptLabel} reviews it and sends it on.
</div>
);
}
}
return null;
}
+243
View File
@@ -0,0 +1,243 @@
import { useState } from "react";
import { ApiError } from "../api/client";
import { packetsApi } from "../api/packets";
import type { PacketRead, Weld, WeldBody } from "../api/types";
import type { ReasonOptions } from "../components/ReasonSheet";
import { errorText } from "../lib/format";
import { voidText } from "../lib/voidStamp";
import { weldId, weldSummary } from "../lib/weldDefaults";
import type { Write, WriteResult } from "../warehouse/PullTable";
import { MapViewer } from "./MapViewer";
import { WeldMark, type MarkMove } from "./marks";
import { landingPage, pageLabel, usePeople, viewPages } from "./shopUtil";
import { useSerial } from "./useSerial";
import { WeldRecord } from "./WeldRecord";
import { WeldSheet, type NewWeldAt } from "./WeldSheet";
import type { Mutate } from "./tabs";
const rv = (cur: PacketRead, no: number, fallback: number) => cur.welds.find((w) => w.weld_no === no)?.row_version ?? fallback;
function WeldRow({ w, onOpen }: { w: Weld; onOpen: (w: Weld) => void }) {
return (
<button type="button" className="row rowbtn" onClick={() => onOpen(w)} aria-label={`Open weld ${weldId(w)}`}>
<span className={`balloon pinkb${w.carried ? " carried" : ""}`}>{w.weld_no}</span>
<span className="d">
{weldSummary(w)}
<div className="small muted mono">
{w.wo_base ?? "-"} #{w.piece ?? 1} · {w.process ?? "-"} · {w.stamp ?? ""}
</div>
</span>
<span />
</button>
);
}
/**
* The weld map: the drawing with the welds of the open sheet as pink leader marks. "Mark a weld" turns on
* marking; a tap on the joint opens the weld sheet. Bubbles and arrows drag when the user may edit welds.
*/
export function WeldMapTab({
p,
write,
mutate,
reload,
askReason,
sub,
myId,
onError,
}: {
p: PacketRead;
write: Write;
mutate: Mutate;
reload: () => Promise<void>;
askReason: (o: ReasonOptions) => Promise<string | null>;
sub: string;
myId: string | null;
onError: (text: string) => void;
}) {
const pages = viewPages(p.pages);
const [picked, setPicked] = useState<{ n: number; onPurpose: boolean } | null>(null);
const page = landingPage(pages, picked?.n ?? null, picked?.onPurpose ?? false);
const [marking, setMarking] = useState(false);
const [sheet, setSheet] = useState<{ weldNo: number | null; at: NewWeldAt | null } | null>(null);
const serial = useSerial(p, write);
const { people } = usePeople(p.dept.code);
const myStamp = people.find((x) => x.id === myId)?.stamp ?? null;
if (!page) return <div className="empty">No drawing pages in this packet.</div>;
const isVoid = !!page.void;
const canEdit = p.can.includes("edit_welds");
const editable = canEdit && !isVoid;
const onSheet = p.welds.filter((w) => w.page === page.n);
const current = onSheet.filter((w) => !w.carried);
const carried = onSheet.filter((w) => w.carried);
const vText = isVoid ? voidText(page, p.pages) : "";
const open = sheet ? (sheet.weldNo !== null ? (p.welds.find((w) => w.weld_no === sheet.weldNo) ?? null) : null) : null;
const done = async (r: WriteResult): Promise<boolean> => {
if (r.ok) return true;
if (r.err !== undefined) {
if (r.err instanceof ApiError && (r.err.status === 409 || r.err.status === 412)) await reload();
onError(errorText(r.err));
}
return false;
};
const add = async (body: WeldBody) => {
if (await done(await serial((cur, g) => packetsApi.addWeld(cur.id, body, g)))) {
setSheet(null);
setMarking(false);
}
};
const save = async (w: Weld, body: WeldBody) => {
if (await done(await serial((cur, g) => packetsApi.patchWeld(cur.id, w.weld_no, rv(cur, w.weld_no, w.row_version), body, g)))) setSheet(null);
};
const remove = async (w: Weld) => {
const reason = await askReason({
title: `Remove weld ${weldId(w)}`,
sub,
note: "The weld leaves the weld map. It stays in the record with your reason.",
min: 3,
danger: true,
submitLabel: "Remove weld",
});
if (reason === null) return;
if (await done(await serial((cur, g) => packetsApi.removeWeld(cur.id, w.weld_no, rv(cur, w.weld_no, w.row_version), { reason, ...g })))) setSheet(null);
};
const move = (w: Weld, m: MarkMove) =>
serial((cur, g) =>
packetsApi.patchWeld(
cur.id,
w.weld_no,
rv(cur, w.weld_no, w.row_version),
m.bubble ? { bfx: m.bfx, bfy: m.bfy } : { fx: m.fx, fy: m.fy, bfx: m.bfx, bfy: m.bfy },
g,
),
).then(done);
const markBtn = editable ? (
<button
type="button"
className={marking ? "btn pink" : "btn pri"}
aria-pressed={marking}
onClick={() => setMarking((v) => !v)}
>
{marking ? "Cancel marking" : "Mark a weld"}
</button>
) : null;
return (
<div className="stack">
{isVoid ? (
<div className="callout warn flush">
<b>VOID copy, not for fabrication.</b> {vText}. Kept for the record with its weld marks.{" "}
{page.void?.new_n ? (
<button className="btn sm" type="button" onClick={() => setPicked({ n: page.void?.new_n ?? page.n, onPurpose: false })}>
Go to the current sheet
</button>
) : null}
</div>
) : null}
<div className="pagesel">
{pages.length > 1 ? (
<div className="chips" role="group" aria-label="Sheets">
{pages.map((x) => (
<button
key={x.n}
type="button"
className={`btn sm${x.n === page.n ? " pri" : ""}${x.void ? " voidbtn" : ""}`}
aria-pressed={x.n === page.n}
onClick={() => setPicked({ n: x.n, onPurpose: true })}
>
{pageLabel(x)}
</button>
))}
</div>
) : (
<span className="mono small muted">{pageLabel(page)}</span>
)}
{markBtn}
{marking ? <span className="small">Tap the weld location on the drawing.</span> : null}
{!canEdit ? (
<span className="small muted">
{p.welds.length} weld{p.welds.length === 1 ? "" : "s"} recorded
</span>
) : null}
</div>
<div className="dv">
<MapViewer
page={page}
voidLabel={vText}
mode={marking && editable ? "marking" : ""}
label="Weld map"
onTap={(fx, fy) => {
if (marking && editable) setSheet({ weldNo: null, at: { page: page.n, fx, fy } });
}}
fsBar={
<>
<span className="fst">{pageLabel(page)}</span>
{markBtn}
</>
}
>
{onSheet.map((w) => (
<WeldMark
key={w.weld_no}
weld={w}
editable={editable && !w.carried}
onOpen={(x) => setSheet({ weldNo: x.weld_no, at: null })}
onMove={move}
/>
))}
</MapViewer>
<div className="side">
<div className="panel">
<h3>
Welds on this sheet ({current.length})
{current.length !== p.welds.filter((w) => !w.carried).length ? <span className="small muted"> of {p.welds.filter((w) => !w.carried).length}</span> : null}
</h3>
{editable && current.length ? <div className="small muted mb8">Drag a bubble to move it off the linework. Drag the arrow to fix the weld location.</div> : null}
<div className="hlist">
{current.length ? (
current.map((w) => <WeldRow key={w.weld_no} w={w} onOpen={(x) => setSheet({ weldNo: x.weld_no, at: null })} />)
) : (
<div className="small muted">{editable ? "Tap Mark a weld, then tap the joint on the drawing." : "No welds recorded yet."}</div>
)}
</div>
</div>
{carried.length ? (
<div className="panel">
<h3>Carried from an earlier department</h3>
<div className="hlist">
{carried.map((w) => (
<WeldRow key={w.weld_no} w={w} onOpen={(x) => setSheet({ weldNo: x.weld_no, at: null })} />
))}
</div>
</div>
) : null}
<WeldRecord p={p} mutate={mutate} />
</div>
</div>
{sheet && (sheet.weldNo === null || open) ? (
<WeldSheet
key={sheet.weldNo ?? "new"}
p={p}
weld={open}
at={sheet.at}
editable={canEdit && !(open ? !!p.pages.find((x) => x.n === open.page)?.void : isVoid) && !open?.carried}
myStamp={myStamp}
sub={sub}
onSave={(body) => void (open ? save(open, body) : add(body))}
onRemove={() => {
if (open) void remove(open);
}}
onClose={() => setSheet(null)}
/>
) : null}
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { useRef, useState } from "react";
import { packetsApi } from "../api/packets";
import type { PacketRead, StageFields } from "../api/types";
import type { Mutate } from "./tabs";
const FIELDS: ReadonlyArray<{
k: keyof StageFields;
label: string;
ph: string;
max: number;
}> = [
{ k: "filler", label: "Filler metal", ph: "e.g. ER70S-6", max: 40 },
{ k: "filler_lot", label: "Filler lot / heat", ph: "Lot no.", max: 40 },
{ k: "preheat", label: "Preheat °F", ph: "e.g. 50 min", max: 40 },
];
/**
* The weld record of the open stage: filler, filler lot, preheat, notes. Each field saves on blur (or Enter) with
* a field-level base, so two people editing different fields never clash; the same field answers field_conflict.
*/
export function WeldRecord({ p, mutate }: { p: PacketRead; mutate: Mutate }) {
const st = p.stage;
const editable = !!st && p.can.includes("edit_stage_fields");
const [draft, setDraft] = useState<
Partial<Record<keyof StageFields, string>>
>({});
const server = (k: keyof StageFields) => (st ? (st[k] ?? "") : "");
const shown = (k: keyof StageFields) => draft[k] ?? server(k);
const saving = useRef(new Set<keyof StageFields>());
const save = async (k: keyof StageFields) => {
const now = draft[k];
if (now === undefined || saving.current.has(k)) return;
const was = st ? st[k] : null;
const value = now.trim() || null;
if (value === (was ?? null)) {
setDraft((d) => ({ ...d, [k]: undefined }));
return;
}
saving.current.add(k);
try {
await mutate((g) =>
packetsApi.patchStage(p.id, {
set: { [k]: value },
base: { [k]: was ?? null },
...g,
}),
);
} finally {
saving.current.delete(k);
}
// On a conflict the page reloads; the field then shows the value someone else saved.
setDraft((d) => ({ ...d, [k]: undefined }));
};
if (!st) {
return (
<div className="panel stack">
<h3>Weld record</h3>
<div className="small muted">
The weld record opens when the packet is at a station.
</div>
</div>
);
}
return (
<div className="panel stack" aria-label="Weld record" role="group">
<h3>Weld record</h3>
{FIELDS.map((f) =>
editable ? (
<label key={f.k} className="f">
{f.label}
<input
aria-label={f.label}
className="in"
value={shown(f.k)}
maxLength={f.max}
placeholder={f.ph}
onChange={(e) =>
setDraft((d) => ({ ...d, [f.k]: e.target.value }))
}
onBlur={() => void save(f.k)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void save(f.k);
}
}}
/>
</label>
) : (
<div key={f.k} className="small">
<span className="muted">{f.label}: </span>
<b className="near">{server(f.k) || "-"}</b>
</div>
),
)}
{editable ? (
<label className="f">
Notes
<textarea
aria-label="Notes"
className="in"
value={shown("notes")}
maxLength={2000}
onChange={(e) => setDraft((d) => ({ ...d, notes: e.target.value }))}
onBlur={() => void save("notes")}
/>
</label>
) : (
<div className="small">
<span className="muted">Notes: </span>
{server("notes") || "-"}
</div>
)}
</div>
);
}
+305
View File
@@ -0,0 +1,305 @@
import { useState, type FormEvent } from "react";
import type { PacketRead, Weld, WeldBody } from "../api/types";
import { Sheet } from "../components/Sheet";
import { bpos } from "../lib/geometry";
import { fmtTime } from "../lib/format";
import {
DEFAULT_SIZE,
DEFAULT_TYPE,
PROCESSES,
WELD_TYPES,
lastProcess,
weldId,
woChoices,
} from "../lib/weldDefaults";
/** The new weld's spot, from the tap on the drawing. */
export interface NewWeldAt {
page: number;
fx: number;
fy: number;
}
interface Draft {
wo_base: string;
piece: number;
type: string;
size: string;
process: string;
stamp: string;
items: number[];
}
/**
* The weld sheet: work order, piece, type, size, process, stamp and the BOM items the weld joins (at least one).
* A new weld sends its point and the default label position; an existing one sends only what changed.
*/
export function WeldSheet({
p,
weld,
at,
editable,
myStamp,
sub,
onSave,
onRemove,
onClose,
}: {
p: PacketRead;
/** The weld to edit, or null for a new one. */
weld: Weld | null;
at: NewWeldAt | null;
editable: boolean;
/** The signed-in user's welder stamp, when known. */
myStamp: string | null;
sub: string;
onSave: (body: WeldBody) => void;
onRemove: () => void;
onClose: () => void;
}) {
const wos = woChoices(p);
const [d, setD] = useState<Draft>(() =>
weld
? {
wo_base: weld.wo_base ?? wos[0]?.base ?? "",
piece: weld.piece ?? 1,
type: weld.type ?? DEFAULT_TYPE,
size: weld.size ?? DEFAULT_SIZE,
process: weld.process ?? lastProcess(p),
stamp: weld.stamp ?? "",
items: [...weld.items],
}
: {
wo_base: wos[0]?.base ?? "",
piece: 1,
type: DEFAULT_TYPE,
size: DEFAULT_SIZE,
process: lastProcess(p),
stamp: myStamp || p.welder?.stamp || "",
items: [],
},
);
const [error, setError] = useState<string | null>(null);
const qty = wos.find((w) => w.base === d.wo_base)?.qty ?? 1;
const title = weld ? `Weld ${weldId(weld)}` : "New weld";
const dis = !editable;
const toggle = (item: number) =>
setD((o) => ({
...o,
items: o.items.includes(item)
? o.items.filter((x) => x !== item)
: [...o.items, item].sort((a, b) => a - b),
}));
const heats = d.items
.map((i) => {
const b = p.bom.find((x) => x.item === i);
return b ? `${i}: ${b.heat || (b.na ? "N/A" : "-")}` : "";
})
.filter(Boolean)
.join(" · ");
const submit = (e: FormEvent) => {
e.preventDefault();
if (!editable) return;
if (!d.items.length) {
setError("Pick at least one BOM item this weld joins.");
return;
}
const size = d.size.trim() || DEFAULT_SIZE;
const stamp = d.stamp.trim() || null;
if (!weld) {
if (!at) return;
const b = bpos({ fx: at.fx, fy: at.fy });
onSave({
page: at.page,
fx: at.fx,
fy: at.fy,
bfx: b.bfx,
bfy: b.bfy,
...(d.wo_base ? { wo_base: d.wo_base } : {}),
piece: d.piece,
items: d.items,
type: d.type,
size,
process: d.process,
...(stamp ? { stamp } : {}),
});
return;
}
const body: WeldBody = {};
if ((weld.wo_base ?? "") !== d.wo_base && d.wo_base)
body.wo_base = d.wo_base;
if ((weld.piece ?? 1) !== d.piece) body.piece = d.piece;
if ((weld.type ?? "") !== d.type) body.type = d.type;
if ((weld.size ?? "") !== size) body.size = size;
if ((weld.process ?? "") !== d.process) body.process = d.process;
if ((weld.stamp ?? null) !== stamp) body.stamp = stamp;
if ([...weld.items].sort((a, b) => a - b).join(",") !== d.items.join(","))
body.items = d.items;
if (!Object.keys(body).length) {
onClose();
return;
}
onSave(body);
};
return (
<Sheet
title={title}
sub={weld ? `${sub} · recorded ${fmtTime(weld.at)}` : sub}
onClose={onClose}
wide
>
{weld?.carried ? (
<div className="callout flush">
Logged in an earlier department. Shown for the record.
</div>
) : null}
<form className="stack" onSubmit={submit} noValidate aria-label={title}>
<div className="frow">
<label className="f">
Work order
<select
aria-label="Work order"
className="in"
value={d.wo_base}
disabled={dis}
onChange={(e) =>
setD((o) => ({ ...o, wo_base: e.target.value, piece: 1 }))
}
>
{wos.map((w) => (
<option key={w.base} value={w.base}>
{w.base}
</option>
))}
</select>
</label>
<label className="f">
Piece
<select
aria-label="Piece"
className="in"
value={d.piece}
disabled={dis}
onChange={(e) =>
setD((o) => ({ ...o, piece: Number(e.target.value) }))
}
>
{Array.from({ length: Math.max(qty, d.piece) }, (_, i) => (
<option key={i + 1} value={i + 1}>
{i + 1} of {qty}
</option>
))}
</select>
</label>
<label className="f">
Type
<select
aria-label="Type"
className="in"
value={d.type}
disabled={dis}
onChange={(e) => setD((o) => ({ ...o, type: e.target.value }))}
>
{WELD_TYPES.map((t) => (
<option key={t}>{t}</option>
))}
</select>
</label>
<label className="f">
Size
<input
aria-label="Size"
className="in"
value={d.size}
maxLength={40}
disabled={dis}
onChange={(e) => setD((o) => ({ ...o, size: e.target.value }))}
/>
</label>
<label className="f">
Process
<select
aria-label="Process"
className="in"
value={d.process}
disabled={dis}
onChange={(e) => setD((o) => ({ ...o, process: e.target.value }))}
>
{PROCESSES.map((t) => (
<option key={t}>{t}</option>
))}
</select>
</label>
<label className="f">
Welder stamp
<input
aria-label="Welder stamp"
className="in"
value={d.stamp}
maxLength={40}
disabled={dis}
onChange={(e) => setD((o) => ({ ...o, stamp: e.target.value }))}
/>
</label>
</div>
<div role="group" aria-label="Mating BOM items">
<div className="flabel">Mating BOM items</div>
<div className="chips">
{p.bom.map((b) => (
<button
key={b.item}
type="button"
className="ichip"
aria-pressed={d.items.includes(b.item)}
aria-label={`Item ${b.item}`}
disabled={dis}
onClick={() => {
toggle(b.item);
setError(null);
}}
>
{b.item}
</button>
))}
</div>
<div className="small muted">
{d.items.length
? `Selected: item ${d.items.join(" + ")}${heats ? ` · heats ${heats}` : ""}`
: "Tap each BOM item number this weld joins."}
</div>
</div>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div className="sheet-acts start">
{editable ? (
<button className="btn pri" type="submit">
{weld ? "Save changes" : "Log weld"}
</button>
) : null}
<button className="btn ghost" type="button" onClick={onClose}>
{editable ? "Cancel" : "Close"}
</button>
{editable && weld ? (
<>
<span className="grow" />
<button
className="btn ghost pinktxt"
type="button"
onClick={onRemove}
>
Remove weld
</button>
</>
) : null}
</div>
</form>
</Sheet>
);
}
+258
View File
@@ -0,0 +1,258 @@
import { useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import type { HeatTablePlacement, HeatTag, Weld } from "../api/types";
import { arrowRotation, bpos, clamp01 } from "../lib/geometry";
import { clampCorner, heatGroups, NO_HEAT, type HeatRow } from "../lib/heatTable";
import { weldId } from "../lib/weldDefaults";
import { useViewer } from "./viewerContext";
/** A mark's position while it is dragged, before the server has it. */
interface Pos {
fx: number;
fy: number;
bfx: number;
bfy: number;
}
export interface MarkMove {
/** Only the bubble moved. */
bubble: boolean;
fx: number;
fy: number;
bfx: number;
bfy: number;
}
const pct = (v: number) => `${v * 100}%`;
/**
* One leader mark: the line, the arrowhead at the point and the bubble. Shared by welds (pink) and the older heat
* bubbles (amber). The bubble and the arrowhead drag when editable; a tap on the bubble opens it.
*/
function Leader({
kind,
at,
editable,
carried,
label,
bubble,
bubbleClass,
onOpen,
onMove,
}: {
kind: "w" | "h";
at: Pos;
editable: boolean;
carried: boolean;
label: string;
bubble: ReactNode;
bubbleClass: string;
onOpen: () => void;
onMove: (m: MarkMove) => Promise<boolean>;
}) {
const v = useViewer();
const key = `${at.fx},${at.fy},${at.bfx},${at.bfy}`;
// The dragged spot shows until the server's answer moves the mark (a new key) or the write fails.
const [drag, setDrag] = useState<{ key: string; pos: Pos } | null>(null);
const pos = drag && drag.key === key ? drag.pos : at;
const rot = arrowRotation(pos.fx, pos.fy, pos.bfx, pos.bfy, v.aspect);
const begin = (e: ReactPointerEvent, part: "anchor" | "bubble") => {
if (!editable) return;
let last: Pos | null = null;
v.startDrag(e, {
onMove: (f) => {
const x = clamp01(f.fx);
const y = clamp01(f.fy);
last = part === "anchor" ? { ...at, fx: x, fy: y } : { ...at, bfx: x, bfy: y };
setDrag({ key, pos: last });
},
onEnd: (f) => {
const done = f ? last : null;
if (!done) {
setDrag(null);
return;
}
void onMove({ bubble: part === "bubble", ...done }).then((ok) => {
if (!ok) setDrag(null);
});
},
});
};
return (
<>
<svg className="lead" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
<line className={`ld ${kind}${carried ? " carried" : ""}`} x1={pos.fx} y1={pos.fy} x2={pos.bfx} y2={pos.bfy} />
</svg>
<span
className={`arw ${kind}${editable ? " drag" : ""}`}
data-mark={editable ? "anchor" : undefined}
style={{ left: pct(pos.fx), top: pct(pos.fy), ["--rot" as string]: `${rot}deg` } as CSSProperties}
title={editable ? "Drag to move where the arrow points" : undefined}
aria-hidden="true"
onPointerDown={editable ? (e) => begin(e, "anchor") : undefined}
/>
<button
type="button"
className={`${bubbleClass}${editable ? " drag" : ""}${carried ? " carried" : ""}`}
data-mark="bubble"
style={{ left: pct(pos.bfx), top: pct(pos.bfy) }}
aria-label={label}
onPointerDown={editable ? (e) => begin(e, "bubble") : undefined}
onClick={() => {
if (!v.justDragged()) onOpen();
}}
>
{bubble}
</button>
</>
);
}
function posOf(m: { fx: number | null; fy: number | null; bfx?: number | null | undefined; bfy?: number | null | undefined }): Pos {
const b = bpos(m);
return { fx: m.fx ?? 0.5, fy: m.fy ?? 0.5, ...b };
}
export function WeldMark({
weld,
editable,
onOpen,
onMove,
}: {
weld: Weld;
editable: boolean;
onOpen: (w: Weld) => void;
onMove: (w: Weld, m: MarkMove) => Promise<boolean>;
}) {
const id = weldId(weld);
return (
<Leader
kind="w"
at={posOf(weld)}
editable={editable}
carried={!!weld.carried}
label={`Weld ${id}${weld.carried ? ", carried" : ""}`}
bubble={id}
bubbleClass="mk"
onOpen={() => onOpen(weld)}
onMove={(m) => onMove(weld, m)}
/>
);
}
export function HeatTagMark({
tag,
heat,
editable,
onOpen,
onMove,
}: {
tag: HeatTag;
heat: string;
editable: boolean;
onOpen: (t: HeatTag) => void;
onMove: (t: HeatTag, m: MarkMove) => Promise<boolean>;
}) {
return (
<Leader
kind="h"
at={posOf(tag)}
editable={editable}
carried={!!tag.carried}
label={`Heat tag H${tag.tag_no}, item ${tag.item}, ${heat || "no heat"}`}
bubble={
<>
<span className="hn">{tag.item}</span>
<span>{heat || "no heat"}</span>
</>
}
bubbleClass={heat ? "ht" : "ht none"}
onOpen={() => onOpen(tag)}
onMove={(m) => onMove(tag, m)}
/>
);
}
/** The table body: the caption and the groups side by side. */
export function HeatTableInner({ rows }: { rows: readonly HeatRow[] }) {
return (
<>
<div className="htcap">HEAT NUMBERS</div>
<div className="htcols">
{heatGroups(rows).map((g, i) => (
<table key={i}>
<thead>
<tr>
<th>ITEM</th>
<th>HEAT NO.</th>
</tr>
</thead>
<tbody>
{g.map((r) => (
<tr key={r.item}>
<td className="i">{r.item}</td>
<td className={`h${r.heat ? "" : " none"}${r.na ? " na" : ""}`}>{r.heat || NO_HEAT}</td>
</tr>
))}
</tbody>
</table>
))}
</div>
</>
);
}
/** The heat table on the drawing. Its font follows the page width (--sw) and the size (--hs). Drags when editable. */
export function HeatTableOnDrawing({
placement,
rows,
editable,
onMove,
}: {
placement: HeatTablePlacement;
rows: readonly HeatRow[];
editable: boolean;
onMove: (x: number, y: number) => Promise<boolean>;
}) {
const v = useViewer();
const key = `${placement.x},${placement.y}`;
const [drag, setDrag] = useState<{ key: string; x: number; y: number } | null>(null);
const at = drag && drag.key === key ? drag : placement;
const down = (e: ReactPointerEvent) => {
if (!editable) return;
const f0 = v.toFrac(e.clientX, e.clientY);
let last: { x: number; y: number } | null = null;
v.startDrag(e, {
onMove: (f) => {
last = clampCorner(placement.x + (f.fx - f0.fx), placement.y + (f.fy - f0.fy));
setDrag({ key, ...last });
},
onEnd: (f) => {
if (!f || !last) {
setDrag(null);
return;
}
void onMove(last.x, last.y).then((ok) => {
if (!ok) setDrag(null);
});
},
});
};
return (
<div
className={`htbl${editable ? " drag" : ""}${drag && drag.key === key ? " moving" : ""}`}
data-mark={editable ? "table" : undefined}
data-testid="heat-table"
role="group"
aria-label="Heat table on the drawing"
style={{ left: pct(at.x), top: pct(at.y), ["--hs" as string]: String(placement.s || 1) } as CSSProperties}
title={editable ? "Drag to move the heat table" : "Heat numbers from the Materials tab"}
onPointerDown={editable ? down : undefined}
>
<HeatTableInner rows={rows} />
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { PacketRead, Stage } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch } from "../test/fetchMock";
import { makePacket } from "../test/packets";
import { WeldSheet } from "./WeldSheet";
const DEPTS = DEPARTMENTS.map((d) => (d.code === "03" ? { ...d, weld: true, stations: [{ id: 1, name: "Stall 1" }, { id: 2, name: "Stall 2" }] } : d));
const CHECK = [
{ group: "Before welding", key: "dwg", text: "Drawing CI-DWG-1 Rev 0 matches the packet" },
{ group: "Before welding", key: "heat", text: "Heat numbers on material match the Materials tab" },
{ group: "After welding", key: "vt", text: "Visual inspection of all welds acceptable" },
];
function stage(over: Partial<Stage> = {}): Stage {
return {
id: 5,
dept_code: "03",
station: { id: 1, name: "Stall 1" },
welder: { id: "andy", display_name: "Andy Welder", stamp: "75" },
checklist: {},
filler: null,
filler_lot: null,
preheat: null,
notes: null,
clock: { elapsed_ms: 3_723_000, running: true, running_since: "2026-10-16T08:00:00Z", started_at: "2026-10-16T08:00:00Z" },
opened_at: "2026-10-16T07:00:00Z",
row_version: 1,
...over,
};
}
function shopPacket(over: Partial<PacketRead> = {}): PacketRead {
return makePacket({
status: "in_progress",
holder: "Stall 1",
station: { id: 1, name: "Stall 1" },
welder: { id: "andy", display_name: "Andy Welder", stamp: "75" },
heat_map: true,
bom: [
{ item: 1, qty: 1, part_id: "P-1", description: "Pipe", dwg_ref: null, length: null, material: "A106", heat: "H1", row_version: 1 },
{ item: 2, qty: 2, part_id: "P-2", description: "Flange", dwg_ref: null, length: null, material: "A105", heat: null, row_version: 1 },
],
work_orders: [{ id: 1, wo_base: "L999001", lot: "001", split: "0", sub: "0", qty: 3, target_hrs: 4.5, row_version: 1 }],
checklist_items: CHECK,
stage: stage(),
can: [],
blocked: {},
...over,
});
}
function base(packet: PacketRead, roles: string[], name = "Wes Super") {
return {
"/api/auth/me": { body: makeMe({ roles, screens: ["supervisor", "stall"], user: { id: name === "Andy Welder" ? "andy" : "wes", display_name: name } }) },
"/api/departments": { body: DEPTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
"/api/departments/03/people": { body: [{ id: "andy", display_name: "Andy Welder", stamp: "75", level: "worker", station_limits: [] }] },
};
}
async function sign(secret = "pw-1") {
const sheet = await screen.findByRole("dialog", { name: "Sign" });
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: secret } });
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
}
describe("Checklist tab", () => {
it("each tick PUTs its key and the answer replaces the packet", async () => {
const p = shopPacket({ can: ["edit_checklist", "edit_stage_fields"] });
const after = shopPacket({ version: 4, can: ["edit_checklist", "edit_stage_fields"], stage: stage({ checklist: { dwg: { done: true, at: "2026-10-16T09:00:00Z", stamp: "75" } } }) });
const fetch = mockFetch({ ...base(p, ["shop:03:worker"], "Andy Welder"), "PUT /api/packets/1/stage/checklist/dwg": { body: after } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
const box = screen.getByRole("checkbox", { name: /Drawing CI-DWG-1 Rev 0 matches the packet/ });
expect(box).toBeEnabled();
fireEvent.click(box);
await waitFor(() => expect(screen.getByRole("checkbox", { name: /Drawing CI-DWG-1/ })).toBeChecked());
expect(callsTo(fetch, "PUT", "/api/packets/1/stage/checklist/dwg")).toEqual([{ done: true }]);
expect(screen.getByText(/^75 · /)).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Weld record" })).toBeInTheDocument();
});
it("is read-only without edit_checklist", async () => {
mockFetch(base(shopPacket({ can: [] }), ["shop:03:lead"]));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
for (const box of screen.getAllByRole("checkbox")) expect(box).toBeDisabled();
expect(screen.getByText("Checklist is completed at the stall.")).toBeInTheDocument();
});
});
describe("Weld record", () => {
it("saves a field on blur with its base", async () => {
const p = shopPacket({ can: ["edit_checklist", "edit_stage_fields"] });
const fetch = mockFetch({ ...base(p, ["shop:03:worker"], "Andy Welder"), "PATCH /api/packets/1/stage": { body: shopPacket({ version: 4, stage: stage({ filler: "ER70S-6" }) }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
const filler = screen.getByLabelText("Filler metal");
fireEvent.change(filler, { target: { value: "ER70S-6" } });
fireEvent.blur(filler);
await waitFor(() => expect(callsTo(fetch, "PATCH", "/api/packets/1/stage")).toEqual([{ set: { filler: "ER70S-6" }, base: { filler: null } }]));
});
});
describe("Weld sheet", () => {
it("needs at least one item, then sends the point and the default label position", () => {
const onSave = vi.fn();
render(
<WeldSheet
p={shopPacket()}
weld={null}
at={{ page: 1, fx: 0.5, fy: 0.5 }}
editable
myStamp="75"
sub="L999001 · WELD-PART"
onSave={onSave}
onRemove={() => undefined}
onClose={() => undefined}
/>,
);
expect(screen.getByLabelText("Size")).toHaveValue('5/16"');
expect(screen.getByLabelText("Type")).toHaveValue("Fillet");
expect(screen.getByLabelText("Process")).toHaveValue("GMAW");
expect(screen.getByLabelText("Welder stamp")).toHaveValue("75");
expect(screen.getByLabelText("Piece")).toHaveTextContent("1 of 3");
fireEvent.click(screen.getByRole("button", { name: "Log weld" }));
expect(screen.getByRole("alert")).toHaveTextContent("Pick at least one BOM item this weld joins.");
expect(onSave).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Item 2" }));
fireEvent.click(screen.getByRole("button", { name: "Item 1" }));
fireEvent.click(screen.getByRole("button", { name: "Log weld" }));
expect(onSave).toHaveBeenCalledTimes(1);
const body = onSave.mock.calls[0]?.[0] as Record<string, unknown>;
expect(body).toMatchObject({ page: 1, fx: 0.5, fy: 0.5, wo_base: "L999001", piece: 1, items: [1, 2], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75" });
expect(body.bfx).toBeCloseTo(0.55);
expect(body.bfy).toBeCloseTo(0.43);
});
it("is read-only without edit rights", () => {
render(
<WeldSheet
p={shopPacket()}
weld={{ weld_no: 4, page: 1, fx: 0.2, fy: 0.3, wo_base: "L999001", piece: 1, items: [1], type: "Groove", size: "1/4\"", process: "GTAW", stamp: "75", at: null, row_version: 1, welder: null }}
at={null}
editable={false}
myStamp={null}
sub=""
onSave={() => undefined}
onRemove={() => undefined}
onClose={() => undefined}
/>,
);
expect(screen.getByRole("heading", { name: "Weld W4" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save changes" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
expect(screen.getByLabelText("Type")).toBeDisabled();
});
});
describe("Packet page action bars", () => {
it("sup.wes on a review packet: Send to Q.C. waits for both boxes and a location, then signs", async () => {
const p = shopPacket({ status: "review", holder: "Supervisor", stage: stage({ clock: { elapsed_ms: 1000, running: false, running_since: null, started_at: null } }), can: ["send_to_qc", "return_to_station", "route_from_review"] });
const fetch = mockFetch({
...base(p, ["shop:03:supervisor"]),
"POST /api/packets/1/transitions": { body: shopPacket({ status: "qc", version: 5, holder: "Q.C.", can: [], blocked: {} }) },
});
renderApp("/packets/1");
const box = await screen.findByRole("region", { name: "Supervisor review" });
const send = within(box).getByRole("button", { name: "Send to Q.C." });
expect(send).toBeDisabled();
fireEvent.click(within(box).getByLabelText("Data book package ready"));
expect(send).toBeDisabled();
fireEvent.click(within(box).getByLabelText("Spool ready for inspection"));
expect(send).toBeDisabled();
fireEvent.change(within(box).getByLabelText("Spool location"), { target: { value: "Bay 2" } });
expect(send).toBeEnabled();
expect(within(box).getByRole("button", { name: "Return to station" })).toBeEnabled();
expect(within(box).getByRole("button", { name: "Send to department" })).toBeEnabled();
fireEvent.click(send);
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Send to Q.C.")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "send_to_qc", expect: { status: "review", dept: "03" }, book: true, spool: true, location: "Bay 2", sign: { factor: "password", secret: "pw-1" } },
]);
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Q.C. inspection"));
});
it("a lead sees the review read-only", async () => {
mockFetch(base(shopPacket({ status: "review", holder: "Supervisor", can: [] }), ["shop:03:lead"], "Lou Lead"));
renderApp("/packets/1");
expect(await screen.findByTestId("review-readonly")).toHaveTextContent("A supervisor for 03 Weld Parts reviews it and sends it on.");
expect(screen.queryByRole("button", { name: "Send to Q.C." })).not.toBeInTheDocument();
});
it("weld.andy in progress: the clock, Pause, and Submit with the missing steps as links", async () => {
const p = shopPacket({
can: ["clock", "edit_checklist", "edit_welds", "edit_stage_fields"],
blocked: { submit: ["Finish the checklist first.", "Record at least one weld first.", "Enter the filler metal first."] },
labor: { hours: 3.24, tickets: 2, per_wo: [], observed_at: null },
});
mockFetch(base(p, ["shop:03:worker"], "Andy Welder"));
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Station" });
expect(within(bar).getByRole("button", { name: "Submit to supervisor" })).toBeDisabled();
expect(within(bar).getByTestId("submit-missing")).toHaveTextContent("Before submitting: 3 checklist items · no welds marked on the drawing · filler metal");
expect(within(bar).getByRole("button", { name: "Pause" })).toBeEnabled();
expect(within(bar).getByTestId("clock")).toHaveTextContent(/01:02:0\d running · target 4.5 h/);
expect(within(bar).getByTestId("labor")).toHaveTextContent("VISUAL tickets 3.2 h");
fireEvent.click(within(bar).getByRole("button", { name: "no welds marked on the drawing" }));
expect(screen.getByRole("tab", { name: /Weld map/ })).toHaveAttribute("aria-selected", "true");
});
it("Submit signs and sends the expect guard; a stale step reloads and toasts the server text", async () => {
let fresh = false;
const p = shopPacket({ can: ["clock", "submit"], stage: stage({ filler: "ER70S-6", checklist: { dwg: { done: true }, heat: { done: true }, vt: { done: true } } }), welds: [{ weld_no: 1, page: 1, fx: 0.4, fy: 0.4, wo_base: "L999001", piece: 1, items: [1], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75", at: null, row_version: 1, welder: null, dept_code: "03" }] });
const back = shopPacket({ status: "ready", version: 9, station: null, welder: null, stage: null, holder: "Supervisor", can: [], blocked: {} });
const fetch = mockFetch({
...base(p, ["shop:03:worker"], "Andy Welder"),
"/api/packets/1": () => ({ body: fresh ? back : p }),
"POST /api/packets/1/transitions": () => {
fresh = true;
return { status: 409, body: { detail: "Someone else already took this step.", code: "stale_step" } };
},
});
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Station" });
expect(within(bar).getByText(/Everything is filled in/)).toBeInTheDocument();
fireEvent.click(within(bar).getByRole("button", { name: "Submit to supervisor" }));
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "submit", expect: { status: "in_progress", dept: "03" }, sign: { factor: "password", secret: "pw-1" } }]);
expect(await screen.findByTestId("toast")).toHaveTextContent("Someone else already took this step.");
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to assign"));
});
it("the assign sheet sends the station, the welder and the stamp", async () => {
const p = shopPacket({ status: "ready", station: null, welder: null, stage: null, holder: "Supervisor", can: ["assign", "send_back_to_warehouse"] });
const fetch = mockFetch({ ...base(p, ["shop:03:supervisor"]), "POST /api/packets/1/transitions": { body: shopPacket({ status: "assigned", version: 5, can: [] }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Send to station" }));
const sheet = await screen.findByRole("dialog", { name: "Send to station" });
fireEvent.change(within(sheet).getByLabelText("Station"), { target: { value: "2" } });
await within(sheet).findByRole("option", { name: "Andy Welder [75]" });
fireEvent.change(within(sheet).getByLabelText("Find a welder"), { target: { value: "andy" } });
fireEvent.change(within(sheet).getByLabelText("Welder"), { target: { value: "andy" } });
expect(within(sheet).getByLabelText("Stamp")).toHaveValue("75");
fireEvent.click(within(sheet).getByRole("button", { name: "Send to station" }));
await waitFor(() =>
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "assign", expect: { status: "ready", dept: "03" }, station_id: 2, welder_id: "andy", stamp: "75" },
]),
);
});
});
describe("Changes tab, verify", () => {
it("a supervisor of a pending department ticks, verifies and signs", async () => {
const co = {
id: 9,
co_no: 2,
label: "CO-2",
origin: "manual",
review: "approved",
minor: false,
hold: false,
ecn: null,
reason: "New rev",
issued_at: "2026-10-16T08:00:00Z",
issued_by: { id: "clara", display_name: "Clara Clerk" },
items: [],
required: ["03", "09"],
verified: { "09": { by: { id: "x", display_name: "Hal Hydro" }, at: "2026-10-16T09:00:00Z", note: null, signed: true } },
pending_depts: ["03"],
open: true,
} as const;
const p = shopPacket({ change_orders: [{ ...co, items: [], verified: { ...co.verified }, required: [...co.required], pending_depts: [...co.pending_depts] }] });
const fetch = mockFetch({ ...base(p, ["shop:03:supervisor"]), "POST /api/packets/1/change-orders/2/verify": { body: shopPacket({ version: 6 }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
const box = await screen.findByRole("group", { name: "Verify CO-2 for 03 Weld Parts" });
const verify = within(box).getByRole("button", { name: "Verify" });
expect(verify).toBeDisabled();
fireEvent.click(within(box).getByLabelText("I reviewed this change with my department"));
fireEvent.change(within(box).getByLabelText("Note (optional)"), { target: { value: "Checked with the crew" } });
fireEvent.click(verify);
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Verify a change order for the department")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/verify")).toEqual([
{ dept: "03", note: "Checked with the crew", sign: { factor: "password", secret: "pw-1" } },
]);
});
it("a lead gets no verify box", async () => {
const p = shopPacket({
change_orders: [
{ id: 9, co_no: 2, origin: "manual", review: "approved", minor: false, hold: false, ecn: null, reason: null, issued_at: null, issued_by: null, items: [], required: ["03"], verified: {} },
],
});
mockFetch(base(p, ["shop:03:lead"], "Lou Lead"));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
await screen.findByRole("region", { name: "Change order CO-2" });
expect(screen.queryByRole("button", { name: "Verify" })).not.toBeInTheDocument();
});
});
+104
View File
@@ -0,0 +1,104 @@
// Small helpers the shop tabs share: the drawing page list, the user's level, the department people.
import { useEffect, useState } from "react";
import { packetsApi, peopleApi } from "../api/packets";
import type { DeptPerson, Guarded, PacketPage, PacketRead, PacketStatus, TransitionBody } from "../api/types";
import type { Mutate } from "./tabs";
import { parseRole } from "../auth/roles";
/** Live drawing pages first, then the VOID copies (the old viewPages). */
export function viewPages(pages: readonly PacketPage[]): PacketPage[] {
const dwg = pages.filter((x) => x.type === "drawing");
return [...dwg.filter((x) => !x.void), ...dwg.filter((x) => !!x.void)];
}
/**
* The page to show: the one picked when it exists, else the first live page. A VOID page that was not picked on
* purpose lands on the sheet that replaced it.
*/
export function landingPage(pages: readonly PacketPage[], picked: number | null, onPurpose: boolean): PacketPage | undefined {
let pg = pages.find((x) => x.n === picked) ?? pages[0];
for (let i = 0; i < 10 && pg?.void && !onPurpose; i++) {
const next = pages.find((x) => x.n === pg?.void?.new_n);
if (!next) break;
pg = next;
}
return pg;
}
/** "M00543 R2 · VOID" for the page buttons. */
export function pageLabel(pg: PacketPage): string {
return `${pg.dwg || `Page ${pg.n}`}${pg.rev ? ` R${pg.rev}` : ""}${pg.void ? " · VOID" : ""}`;
}
export type Level = "worker" | "lead" | "supervisor";
/** The highest shop level in a department; admin counts as supervisor. */
export function shopLevel(roles: readonly string[], dept: string): Level | null {
if (roles.includes("admin")) return "supervisor";
const order: Level[] = ["worker", "lead", "supervisor"];
let best = -1;
for (const r of roles) {
const p = parseRole(r);
if (p.shop && p.dept === dept) best = Math.max(best, order.indexOf(p.level));
}
return best < 0 ? null : (order[best] ?? null);
}
/** May this user verify a change order for the department? Mirrors the server's can_verify. */
export function canVerifyDept(roles: readonly string[], dept: string): boolean {
if (roles.includes("admin")) return true;
if (dept === "WH") return roles.includes("warehouse_supervisor");
if (dept === "QC") return roles.includes("qc_supervisor");
if (dept === "CL" || dept === "PM") return false;
return shopLevel(roles, dept) === "supervisor";
}
/** The active people of a shop department, for the welder picker and the weld sheet's stamp. */
export function usePeople(dept: string | null): { people: DeptPerson[]; loaded: boolean; error: string | null } {
const [state, setState] = useState<{ dept: string | null; people: DeptPerson[]; error: string | null } | null>(null);
useEffect(() => {
if (!dept) return;
const ctl = new AbortController();
peopleApi
.list(dept, ctl.signal)
.then((people) => {
if (!ctl.signal.aborted) setState({ dept, people, error: null });
})
.catch((err: unknown) => {
if (ctl.signal.aborted) return;
setState({ dept, people: [], error: err instanceof Error ? err.message : "The people list could not be read." });
});
return () => ctl.abort();
}, [dept]);
const mine = state && state.dept === dept ? state : null;
return { people: mine?.people ?? [], loaded: !!mine, error: mine?.error ?? null };
}
export type ShopTab = "checklist" | "welds";
/** The old missing(): what the stall still has to do before Submit, each with the tab that fixes it. */
export function stallMissing(p: PacketRead): Array<{ t: string; tab: ShopTab }> {
const out: Array<{ t: string; tab: ShopTab }> = [];
const done = p.stage?.checklist ?? {};
const open = p.checklist_items.filter((i) => !done[i.key]?.done).length;
if (open) out.push({ t: `${open} checklist item${open > 1 ? "s" : ""}`, tab: "checklist" });
if (p.dept.weld !== false) {
const here = p.welds.some((w) => !w.carried && (w.dept_code === undefined || w.dept_code === null || w.dept_code === p.dept.code));
if (!here) out.push({ t: "no welds marked on the drawing", tab: "welds" });
if (!p.stage?.filler) out.push({ t: "filler metal", tab: "checklist" });
}
return out;
}
export type StepFn = (
action: string,
extra?: Omit<TransitionBody, "action" | "expect">,
opts?: { sign?: string; ok?: string },
) => Promise<boolean>;
/** A transition with the expect guard, through the page's mutate. */
export function makeStep(p: PacketRead, mutate: Mutate): StepFn {
const expect: { status: PacketStatus; dept: string } = { status: p.status, dept: p.dept.code };
return (action, extra = {}, opts = {}) =>
mutate((g: Guarded) => packetsApi.transition(p.id, { action, expect, ...extra, ...g }), { sign: opts.sign, ok: opts.ok });
}
+40
View File
@@ -0,0 +1,40 @@
// Row writes on the packet page run one after another. Each takes the row version from the newest packet seen
// (the prop, or an earlier write's own answer before React re-rendered), and a row_conflict reads the packet
// again and retries once. The phase 4 pull table found these races at full speed.
import { useCallback, useRef } from "react";
import { errorCode } from "../api/client";
import { packetsApi } from "../api/packets";
import type { Guarded, PacketRead } from "../api/types";
import type { Write, WriteResult } from "../warehouse/PullTable";
export type RowCall = (cur: PacketRead, g: Guarded) => Promise<PacketRead>;
export function useSerial(p: PacketRead, write: Write): (call: RowCall) => Promise<WriteResult> {
const latest = useRef(p);
if (latest.current.id !== p.id || p.version >= latest.current.version) latest.current = p;
const chain = useRef<Promise<unknown>>(Promise.resolve());
const id = p.id;
return useCallback(
(call: RowCall) => {
const next = chain.current.then(async () => {
const send = () =>
write((g) =>
call(latest.current, g).then((r) => {
if (r && r.version >= latest.current.version) latest.current = r;
return r;
}),
);
let r = await send();
if (!r.ok && r.err !== undefined && errorCode(r.err) === "row_conflict") {
const fresh = await packetsApi.get(id);
if (fresh.version >= latest.current.version) latest.current = fresh;
r = await send();
}
return r;
});
chain.current = next.catch(() => undefined);
return next;
},
[write, id],
);
}
+46
View File
@@ -0,0 +1,46 @@
// The viewer context the drawing overlay reads: pointer to page fractions, mark drags, the page size.
import { createContext, useContext, type PointerEvent as ReactPointerEvent } from "react";
import type { Frac } from "../lib/geometry";
/** The bitmap: at most 2.2 times the PDF size and at most 4200 px wide. */
export function renderScale(pdfWidth: number): number {
return Math.min(2.2, 4200 / Math.max(1, pdfWidth));
}
/** A pointer that moved less than this is a tap. */
export const TAP_PX = 6;
/** A mark drag starts after this. */
export const DRAG_PX = 5;
export interface DragHandlers {
/** The pointer is at f (page fractions, not clamped). */
onMove: (f: Frac) => void;
/** The drag ended at f, or null when it never moved (a tap). */
onEnd: (f: Frac | null) => void;
}
export interface ViewerApi {
/** A client point as page fractions, and whether it lies inside the viewer frame. */
toFrac: (clientX: number, clientY: number) => Frac & { inside: boolean };
/** Starts a mark drag from a pointerdown. Moves and ends follow the window. */
startDrag: (e: ReactPointerEvent, h: DragHandlers) => void;
/** True for a short while after a drag ended, so the click that follows it is ignored. */
justDragged: () => boolean;
}
export interface ViewerInfo extends ViewerApi {
/** Page size in bitmap pixels; 0 until the page is drawn. */
w: number;
h: number;
aspect: number;
}
export const ViewerCtx = createContext<ViewerInfo | null>(null);
/** The viewer the overlay lives in. Marks use it to turn pointers into page fractions. */
export function useViewer(): ViewerInfo {
const v = useContext(ViewerCtx);
if (!v) throw new Error("useViewer must be used inside MapViewer");
return v;
}
+6 -6
View File
@@ -167,9 +167,9 @@ describe("Packet page, Changes tab", () => {
"POST /api/packets/1/change-orders/2/approve": { body: inShop({ ...co, review: "approved", approved_by: { id: "u", display_name: "Clara Clerk" }, required: ["03"] }) }, "POST /api/packets/1/change-orders/2/approve": { body: inShop({ ...co, review: "approved", approved_by: { id: "u", display_name: "Clara Clerk" }, required: ["03"] }) },
}); });
renderApp("/packets/1"); renderApp("/packets/1");
expect(await screen.findByText(/Change order 2: a change from VISUAL is waiting for your approval/)).toBeInTheDocument(); expect(await screen.findByText(/Change order CO-2: a change from VISUAL is waiting for your approval/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Review change" })); fireEvent.click(screen.getByRole("button", { name: "Review change" }));
const sec = await screen.findByRole("region", { name: "Change order 2" }); const sec = await screen.findByRole("region", { name: "Change order CO-2" });
expect(within(sec).getByText("M00543 Rev 1 -> Rev 2 + 1 other change")).toBeInTheDocument(); expect(within(sec).getByText("M00543 Rev 1 -> Rev 2 + 1 other change")).toBeInTheDocument();
expect(within(sec).getByText("L900001 qty 2 -> 3")).toBeInTheDocument(); expect(within(sec).getByText("L900001 qty 2 -> 3")).toBeInTheDocument();
expect(within(sec).getByRole("link", { name: "Open the new drawing" })).toHaveAttribute("href", "/_blob/as-2"); expect(within(sec).getByRole("link", { name: "Open the new drawing" })).toHaveAttribute("href", "/_blob/as-2");
@@ -177,8 +177,8 @@ describe("Packet page, Changes tab", () => {
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Approve a change order")).toBeInTheDocument(); expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Approve a change order")).toBeInTheDocument();
await sign(); await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/approve")).toEqual([{ sign: { factor: "password", secret: "pw-1" } }]); expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/approve")).toEqual([{ sign: { factor: "password", secret: "pw-1" } }]);
expect(await screen.findByText("Change order 2 approved. The shop packet is updated.")).toBeInTheDocument(); expect(await screen.findByText("Change order CO-2 approved. The shop packet is updated.")).toBeInTheDocument();
expect(await screen.findByRole("table", { name: "Change order 2 verification" })).toBeInTheDocument(); expect(await screen.findByRole("table", { name: "CO-2 verification" })).toBeInTheDocument();
}); });
it("Approve is disabled with the reason while the drawing waits; reject and hold post", async () => { it("Approve is disabled with the reason while the drawing waits; reject and hold post", async () => {
@@ -190,7 +190,7 @@ describe("Packet page, Changes tab", () => {
}); });
renderApp("/packets/1"); renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ })); fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
const sec = await screen.findByRole("region", { name: "Change order 2" }); const sec = await screen.findByRole("region", { name: "Change order CO-2" });
expect(within(sec).getByRole("button", { name: "Approve - update the shop packet" })).toBeDisabled(); expect(within(sec).getByRole("button", { name: "Approve - update the shop packet" })).toBeDisabled();
expect(within(sec).getByText("Waits on that rev in M-Files.")).toHaveClass("why"); expect(within(sec).getByText("Waits on that rev in M-Files.")).toHaveClass("why");
expect(within(sec).getByText(/That rev isn't released in M-Files yet/)).toBeInTheDocument(); expect(within(sec).getByText(/That rev isn't released in M-Files yet/)).toBeInTheDocument();
@@ -200,7 +200,7 @@ describe("Packet page, Changes tab", () => {
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/hold")).toEqual([{ hold: true }]); expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/hold")).toEqual([{ hold: true }]);
fireEvent.click(within(sec).getByRole("button", { name: "Reject" })); fireEvent.click(within(sec).getByRole("button", { name: "Reject" }));
const ask = await screen.findByRole("dialog", { name: "Reject change order 2" }); const ask = await screen.findByRole("dialog", { name: "Reject change order CO-2" });
fireEvent.change(within(ask).getByLabelText("Reason"), { target: { value: "Not for this unit" } }); fireEvent.change(within(ask).getByLabelText("Reason"), { target: { value: "Not for this unit" } });
fireEvent.click(within(ask).getByRole("button", { name: "Reject" })); fireEvent.click(within(ask).getByRole("button", { name: "Reject" }));
await sign(); await sign();
+5 -153
View File
@@ -1,4 +1,4 @@
// The VISUAL box and the Changes tab on the packet page (phase 3). // The VISUAL box and Clerical's change order review panel on the packet page (phase 3). The Changes tab is ChangesTab.tsx.
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { assetsApi, blobUrl } from "../api/assets"; import { assetsApi, blobUrl } from "../api/assets";
import { packetsApi } from "../api/packets"; import { packetsApi } from "../api/packets";
@@ -6,7 +6,6 @@ import type { ChangeOrder, FoldedOp, HandUpload, PacketRead, WhoRef } from "../a
import type { ReasonOptions } from "../components/ReasonSheet"; import type { ReasonOptions } from "../components/ReasonSheet";
import { Sheet } from "../components/Sheet"; import { Sheet } from "../components/Sheet";
import { errorText, fmtTime } from "../lib/format"; import { errorText, fmtTime } from "../lib/format";
import { coSummary, feedOrigin } from "../lib/changeOrders";
import { DRAWING_FLAGS } from "../lib/packets"; import { DRAWING_FLAGS } from "../lib/packets";
import type { Mutate } from "./tabs"; import type { Mutate } from "./tabs";
@@ -307,15 +306,7 @@ export function VisualBox({
/* ---------- Change orders ---------- */ /* ---------- Change orders ---------- */
function coPill(co: ChangeOrder) { export function ReviewPanel({
if (co.review === "rejected") return <span className="pill st-filed">Rejected</span>;
if (co.review === "pending") return <span className="pill st-review">Waiting on Clerical</span>;
const open = co.required.filter((d) => !co.verified[d]);
if (open.length) return <span className="pill st-review">Open - {open.length} to verify</span>;
return <span className="pill st-in_progress">Verified</span>;
}
function ReviewPanel({
p, p,
co, co,
sub, sub,
@@ -339,11 +330,11 @@ function ReviewPanel({
const old = p.pages.find((x) => x.type === "drawing" && !x.void); const old = p.pages.find((x) => x.type === "drawing" && !x.void);
const approve = () => const approve = () =>
mutate((g) => packetsApi.coApprove(p.id, co.co_no, g), { sign: SIGN_CO_APPROVE, ok: `Change order ${co.co_no} approved. The shop packet is updated.` }); mutate((g) => packetsApi.coApprove(p.id, co.co_no, g), { sign: SIGN_CO_APPROVE, ok: `Change order CO-${co.co_no} approved. The shop packet is updated.` });
const reject = async () => { const reject = async () => {
const reason = await askReason({ const reason = await askReason({
title: `Reject change order ${co.co_no}`, title: `Reject change order CO-${co.co_no}`,
sub, sub,
note: "The shop packet is not changed. If VISUAL changes again, a new change order comes in.", note: "The shop packet is not changed. If VISUAL changes again, a new change order comes in.",
min: 3, min: 3,
@@ -352,7 +343,7 @@ function ReviewPanel({
submitLabel: "Reject", submitLabel: "Reject",
}); });
if (reason === null) return; if (reason === null) return;
await mutate((g) => packetsApi.coReject(p.id, co.co_no, { reason, ...g }), { sign: SIGN_CO_REJECT, ok: `Change order ${co.co_no} rejected.` }); await mutate((g) => packetsApi.coReject(p.id, co.co_no, { reason, ...g }), { sign: SIGN_CO_REJECT, ok: `Change order CO-${co.co_no} rejected.` });
}; };
const hold = () => const hold = () =>
@@ -421,142 +412,3 @@ function ReviewPanel({
</div> </div>
); );
} }
/** The Changes tab: every change order, newest first. Clerical reviews the ones from VISUAL and M-Files here. */
export function ChangesTab({
p,
roles,
sub,
mutate,
busy,
askReason,
}: {
p: PacketRead;
roles: readonly string[];
sub: string;
mutate: Mutate;
busy: boolean;
askReason: AskReason;
}) {
const list = [...(p.change_orders ?? [])].sort((a, b) => b.co_no - a.co_no);
if (!list.length) return <div className="empty">No change orders on this packet.</div>;
const canReview = isCl(roles) || p.can.includes("co_approve");
return (
<div className="stack">
{list.map((co) => {
const feed = feedOrigin(co);
return (
<section key={co.id} className="sec" aria-label={`Change order ${co.co_no}`}>
<div className="sec-h flush">
<h2>
Change order {co.co_no}
{co.ecn ? ` · ${co.ecn}` : ""}
</h2>
{coPill(co)}
<span className="ct">
{feed ? (co.origin === "drawing" ? "from M-Files" : "from VISUAL") : `issued by ${co.issued_by?.display_name ?? "-"}`} {fmtTime(co.issued_at)}
{co.hold ? " · shop packet on hold" : ""}
</span>
</div>
<div className="small">
<b>{coSummary(co)}</b>
</div>
{co.review === "pending" && feed ? (
canReview ? (
<ReviewPanel p={p} co={co} sub={sub} mutate={mutate} busy={busy} askReason={askReason} canReview={canReview} />
) : (
<div className="callout warn flush">
<b>Clerical is reviewing a change from VISUAL.</b> Keep working to the current drawing. If Clerical approves it, the new drawing and changes come onto
this packet and you will be asked to verify.
</div>
)
) : null}
{co.review === "rejected" ? (
<div className="callout flush">
{co.rejected_by ? (
<>
<b>Rejected by Clerical</b> - {co.rejected_by.display_name} {fmtTime(co.rejected_at)}: {co.rejected_reason ?? ""}. The shop packet was not changed.
</>
) : (
<>
<b>Withdrawn</b> {fmtTime(co.rejected_at)}: {co.rejected_reason ?? "VISUAL went back to what the packet has"}. The shop packet was not changed.
</>
)}
</div>
) : null}
{co.review === "approved" && co.approved_by ? (
<div className="small">
<b>Approved by Clerical:</b> {co.approved_by.display_name} {fmtTime(co.approved_at)}
</div>
) : null}
{co.review === "approved" && feed && co.vis?.changes?.length ? (
<div className="small">
<b>Also changed from VISUAL:</b> {co.vis.changes.join("; ")}
</div>
) : null}
<div className="small">
<b>Reason:</b> {co.reason || "-"}
</div>
{co.items.length ? (
<div className="tw">
<table aria-label={`Change order ${co.co_no} sheets`}>
<thead>
<tr>
<th>Old sheet</th>
<th>New sheet</th>
</tr>
</thead>
<tbody>
{co.items.map((it, i) => (
<tr key={i}>
<td className="small">
{it.added ? (
<span className="muted">Added</span>
) : (
<>
<span className="pill st-filed">VOID</span> {it.old_dwg || "sheet"} Rev {it.old_rev || "?"}
{it.old_page ? <span className="muted"> · page {it.old_page}</span> : null}
</>
)}
</td>
<td className="small">
<span className="pill st-in_progress">{it.added ? "ADDED" : "NEW"}</span> {it.new_dwg || "sheet"} Rev {it.new_rev || "?"}
{it.new_page ? <span className="muted"> · page {it.new_page}</span> : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{co.review === "approved" && co.required.length ? (
<div className="tw">
<table aria-label={`Change order ${co.co_no} verification`}>
<thead>
<tr>
<th>Department</th>
<th>Verified</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{co.required.map((d) => {
const ver = co.verified[d];
return (
<tr key={d}>
<td className="mono">{d}</td>
<td className="small">{ver ? `${ver.by?.display_name ?? "-"} ${fmtTime(ver.at)}` : <span className="due soon">Pending</span>}</td>
<td className="small">{ver?.note ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</section>
);
})}
</div>
);
}
+4
View File
@@ -5,6 +5,8 @@ import { useAuth } from "../auth/useAuth";
import { AdminPage } from "./admin/AdminPage"; import { AdminPage } from "./admin/AdminPage";
import { ClericalHome } from "./clerical/ClericalHome"; import { ClericalHome } from "./clerical/ClericalHome";
import { PlaceholderScreen } from "./PlaceholderScreen"; import { PlaceholderScreen } from "./PlaceholderScreen";
import { StallHome } from "./stall/StallHome";
import { SupervisorHome } from "./supervisor/SupervisorHome";
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. */
@@ -25,5 +27,7 @@ export function ScreenRoute() {
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 />;
if (screen === "supervisor") return <SupervisorHome />;
if (screen === "stall") return <StallHome />;
return <PlaceholderScreen key={screen} screen={screen} />; return <PlaceholderScreen key={screen} screen={screen} />;
} }
@@ -0,0 +1,97 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ShopCard, StallAnswer, StationOption } from "../../api/shop";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { mockFetch } from "../../test/fetchMock";
import { makeCard } from "../../test/packets";
import { memoryStorage } from "../supervisor/memoryStorage";
import { NO_STATION, STALL_KEY } from "./StallHome";
const ANDY = makeMe({ roles: ["shop:03:worker"], screens: ["stall"], user: { sam_account: "weld.andy", display_name: "Andy Welder" } });
const STATIONS: StationOption[] = [
{ 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" });
return { ...base, open_cos: [], ...over };
}
function stall(id: number): StallAnswer {
const st = STATIONS.find((s) => s.id === id) as StationOption;
return {
station: { id, name: st.name, dept_code: "03", dept_name: "Weld Parts" },
mine:
id === 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) {
return {
"/api/auth/me": { body: ANDY },
"/api/departments": { body: DEPARTMENTS },
"/api/shop/stations": { body: stations },
"/api/shop/stall?station_id=1": { body: stall(1) },
"/api/shop/stall?station_id=2": { body: stall(2) },
};
}
const column = (title: string) => screen.getByRole("heading", { name: new RegExp(`^${title}`) }).closest("section") as HTMLElement;
beforeEach(() => {
vi.stubGlobal("localStorage", memoryStorage());
});
describe("StallHome", () => {
it("shows the hero, the picker and the two boards", 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());
renderApp("/stall");
const pick = await screen.findByRole("combobox", { name: "Station" });
fireEvent.change(pick, { target: { value: "2" } });
expect(await screen.findByText("STALL 2")).toBeInTheDocument();
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/shop/stall?station_id=2")).toBe(true));
expect(window.localStorage.getItem(STALL_KEY)).toBe("2");
expect(await screen.findByText("No work assigned to this stall right now.")).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();
});
it("says so when the user has no station", async () => {
mockFetch(base([]));
renderApp("/stall");
expect(await screen.findByText(NO_STATION)).toBeInTheDocument();
});
});
+189
View File
@@ -0,0 +1,189 @@
import { useMemo, useState } from "react";
import {
getStall,
getStations,
type ShopCard,
type StallAnswer,
type StationOption,
} from "../../api/shop";
import { Card } from "../../components/Card";
import { useChanged } from "../../lib/changes";
import { useLoad } from "../../lib/useLoad";
import {
asCard,
fmtClock,
fmtHrs,
liveMs,
lsGet,
lsSet,
useNow,
} from "../supervisor/shopView";
/** localStorage key for the picked station id (the old ps.stall). */
export const STALL_KEY = "ps.stall";
export const NO_STATION =
"You aren't assigned to a station. Ask an admin to add you to a department.";
type LoadedStall = StallAnswer & { fetchedAt: number };
function StallCard({
p,
fetchedAt,
now,
}: {
p: ShopCard;
fetchedAt: number;
now: number;
}) {
const running = !!p.clock?.running;
const state = running
? "running"
: p.status === "in_progress"
? "paused"
: "";
const extra = (
<>
<div className="small mono stclock">
<span className={running ? "sdot run" : "sdot"} aria-hidden="true" />
{fmtClock(liveMs(p.clock, fetchedAt, now))}
{state ? ` ${state}` : ""} · target {fmtHrs(Number(p.target_hrs) || 0)}{" "}
h
</div>
{p.return_note && p.status === "assigned" ? (
<div className="small due soon">Returned: {p.return_note}</div>
) : null}
</>
);
return <Card p={asCard(p)} extra={extra} />;
}
function StallColumn({
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, "-")}`;
return (
<section className="col" aria-labelledby={id}>
<h3 className="col-h" id={id}>
{title}
<span className="c">{cards.length}</span>
</h3>
{cards.length ? (
cards.map((p) => (
<StallCard key={p.id} p={p} fetchedAt={fetchedAt} now={now} />
))
) : (
<div className="empty">{empty}</div>
)}
</section>
);
}
function stationLabel(s: StationOption): string {
return `${s.dept_code} ${s.dept_name} · ${s.name}`;
}
/** /stall (the old homeStall): the station picker, the hero and the two boards. */
export function StallHome() {
const [stations] = useLoad<StationOption[]>(
(s) => getStations(s),
"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 [stall, reload] = useLoad<LoadedStall | null>(
async (s) =>
station
? { ...(await getStall(station.id, s)), fetchedAt: Date.now() }
: null,
String(station?.id ?? ""),
);
useChanged(reload, { delayMs: 500 });
const data = stall.state === "ok" ? stall.data : null;
const running = data ? data.mine.some((p) => p.clock?.running) : false;
const now = useNow(running);
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 (
<>
<div className="stallhero">
<div className="big">{station.name.toUpperCase()}</div>
<div className="grow">
<div className="eyebrow">{deptLabel}</div>
<div className="small muted">
Packets your supervisor sends to this station appear here.
</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>
{stall.state === "loading" ? (
<div className="empty slim">Loading</div>
) : null}
{stall.state === "error" ? (
<div className="callout warn">{stall.message}</div>
) : null}
{data ? (
<div className="board">
<StallColumn
title="My packets"
cards={data.mine}
empty="No work assigned to this stall right now."
fetchedAt={data.fetchedAt}
now={now}
/>
<StallColumn
title="Submitted, waiting on supervisor"
cards={data.submitted}
empty="Nothing waiting."
fetchedAt={data.fetchedAt}
now={now}
/>
</div>
) : null}
</>
);
}
@@ -0,0 +1,87 @@
import { useNavigate } from "react-router";
import type { ShopCoRow } from "../../api/shop";
import { coHref, deptShort } from "./shopView";
/**
* "Change orders on hold" (the old coDeptSection): the open change orders in my departments that the
* banner does not already ask me to verify, such as one waiting on Clerical or on another department.
*/
export function CoTable({
rows,
names,
canVerify,
}: {
rows: readonly ShopCoRow[];
names: ReadonlyMap<string, string>;
canVerify: (dept: string) => boolean;
}) {
const navigate = useNavigate();
if (!rows.length) return null;
return (
<section className="sec cosec" aria-labelledby="cosec-h">
<div className="sec-h">
<h2 id="cosec-h">Change orders on hold</h2>
<span className="ct">
{rows.length} in your department{rows.length === 1 ? "" : "s"} · sign-offs on these packets are on hold
</span>
</div>
<div className="tw">
<table>
<thead>
<tr>
<th>CO</th>
<th>Packet</th>
<th>What</th>
<th>Waiting on</th>
<th>
<span className="sr">Open</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const job = r.op ? `${r.unit_number} · ${r.op}` : r.unit_number;
const clerical = r.review !== "approved";
return (
<tr key={`${r.packet_id}-${r.co_no}`}>
<td>
<b>{r.label}</b>
{r.ecn ? <div className="small muted">{r.ecn}</div> : null}
</td>
<td>
<b>{job}</b>
<div className="small muted">
{deptShort(r.dept_code, names)}
{r.title ? ` · ${r.title}` : ""}
</div>
</td>
<td className="small">
{clerical ? "A change from VISUAL" : "Drawing changed"}
{r.reason ? <div className="muted">{r.reason}</div> : null}
</td>
<td className="small">
{clerical ? (
<div>○ Clerical review{r.hold ? " · packet held" : ""}</div>
) : (
r.pending_depts.map((d) => (
<div key={d}>
○ {deptShort(d, names)}
{canVerify(d) ? <b className="due soon"> you</b> : null}
</div>
))
)}
</td>
<td>
<button className="btn sm" type="button" aria-label={`Open ${r.label} on ${job}`} onClick={() => navigate(coHref(r.packet_id))}>
Open
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
);
}
@@ -0,0 +1,91 @@
import { useNavigate } from "react-router";
import type { BoardLevel, ShopCard, ShopDept } from "../../api/shop";
import { jobName, statusLabel } from "../../lib/packets";
import { coHref, coTagText, deptShort, fmtClock, fmtHrs, liveMs } from "./shopView";
const LEVEL_LABEL: Record<BoardLevel, string> = { lead: "Lead", supervisor: "Supervisor", admin: "Admin" };
function StationJob({ p, fetchedAt, now }: { p: ShopCard; fetchedAt: number; now: number }) {
const navigate = useNavigate();
const ms = liveMs(p.clock, fetchedAt, now);
const target = Number(p.target_hrs) || 0;
const hrs = ms / 3_600_000;
const stamp = p.welder_stamp ?? p.welder?.stamp ?? null;
return (
<div className="stjob">
<button type="button" className="stopen" onClick={() => navigate(`/packets/${p.id}`)}>
{jobName(p)}
</button>
{p.open_cos.length ? (
<button type="button" className="cotag" onClick={() => navigate(coHref(p.id))}>
{coTagText(p.open_cos)}
</button>
) : null}
{p.welder || stamp ? (
<div className="small">
{p.welder?.display_name ?? ""} {stamp ? <span className="mono muted">[{stamp}]</span> : null}
</div>
) : null}
<div className="small mono muted stclock">
{fmtClock(ms)} / {fmtHrs(target)} h · {statusLabel(p.status)}
</div>
<div className="bar" title={`${fmtHrs(hrs)} of ${fmtHrs(target)} h`}>
<i className={target && hrs > target ? "over" : undefined} style={{ width: `${target ? Math.min(100, (hrs / target) * 100) : 0}%` }} />
</div>
</div>
);
}
/** The station grid (the old homeSupervisor grid): one section per department, one tile per station. */
export function StationGrid({
depts,
names,
fetchedAt,
now,
}: {
depts: readonly ShopDept[];
names: ReadonlyMap<string, string>;
fetchedAt: number;
now: number;
}) {
// With several departments, a department with nothing at its stations is left out.
const shown = depts.filter((d) => depts.length === 1 || d.stations.some((s) => s.packets.length));
if (!shown.length) return <div className="empty slim stempty">No packets at stations right now.</div>;
return (
<>
{shown.map((d) => {
const id = `stations-${d.code}`;
return (
<section key={d.code} className="sec" aria-labelledby={id}>
<div className="sec-h">
<h2 id={id}>{deptShort(d.code, names)}</h2>
<span className="ct">{LEVEL_LABEL[d.level] ?? ""}</span>
</div>
{d.stations.length ? (
<div className="stalls">
{d.stations.map((st) => {
const n = st.packets.length;
const hot = st.packets.some((p) => p.open_cos.length);
const cls = ["stall", n ? "busy" : "idle", hot ? "cohot" : ""].filter(Boolean).join(" ");
return (
<div key={st.id} className={cls} role="group" aria-label={`${st.name}, ${n ? `${n} job` : "idle"}`} data-station={st.id}>
<div className="sn">
{st.name.toUpperCase()}
<span className="mono small muted">{n ? `${n} job` : "idle"}</span>
</div>
{st.packets.map((p) => (
<StationJob key={p.id} p={p} fetchedAt={fetchedAt} now={now} />
))}
</div>
);
})}
</div>
) : (
<div className="empty slim">No stations set up for this department.</div>
)}
</section>
);
})}
</>
);
}
@@ -0,0 +1,204 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ShopBoard, ShopCard } from "../../api/shop";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { mockFetch } from "../../test/fetchMock";
import { makeCard } from "../../test/packets";
import { memoryStorage } from "./memoryStorage";
import { SUP_DEPT_KEY } from "./SupervisorHome";
const WES = makeMe({ roles: ["shop:03:supervisor"], screens: ["supervisor", "stall"], user: { sam_account: "sup.wes", display_name: "Wes Super" } });
const WELD = { code: "03", name: "Weld Parts" };
function shopCard(id: number, over: Partial<ShopCard> = {}): ShopCard {
const { open_cos: _ignored, ...base } = makeCard({ id, unit: { id: 100 + id, number: `L66200${id}` }, dept: WELD, holder: "Weld Parts" });
return { ...base, open_cos: [], ...over };
}
const AT_STALL = shopCard(1, {
status: "in_progress",
station: { id: 1, name: "Stall 1" },
welder: { id: "u-andy", display_name: "Andy Welder", stamp: "75" },
welder_stamp: "75",
clock: { elapsed_ms: 5_400_000, running: true },
target_hrs: 2,
open_cos: [{ label: "CO-2", review: "approved" }],
});
function board(over: Partial<ShopBoard> = {}): ShopBoard {
return {
depts: [
{
code: "03",
name: "Weld Parts",
weld: true,
level: "supervisor",
stations: [
{ id: 1, name: "Stall 1", packets: [AT_STALL] },
{ id: 2, name: "Stall 2", packets: [] },
],
},
],
kpis: { ready: 2, at_stations: 1, review: 0, elapsed_ms: 16_200_000, target_hrs: 12 },
cos: [
{
packet_id: 1,
unit_number: "L662001",
op: "WELD-PART",
title: "CI spool",
dept_code: "03",
co_no: 2,
label: "CO-2",
review: "approved",
hold: false,
pending_depts: ["03"],
mine: true,
reason: "Nozzle moved",
ecn: null,
issued_at: "2026-10-16T08:00:00Z",
},
{
packet_id: 4,
unit_number: "L662004",
op: "WELD-PART",
title: "Other spool",
dept_code: "03",
co_no: 1,
label: "CO-1",
review: "pending",
hold: true,
pending_depts: [],
mine: false,
reason: null,
ecn: null,
issued_at: "2026-10-16T09:00:00Z",
},
],
columns: {
ready: [shopCard(2, { status: "ready" }), shopCard(3, { status: "ready", title: "Elbow" })],
review: [],
qc: [],
warehouse: [],
complete: [],
},
...over,
};
}
function base(b: ShopBoard = board(), me = WES) {
return {
"/api/auth/me": { body: me },
"/api/departments": { body: DEPARTMENTS },
"/api/shop/board": { body: b },
"/api/projects": { body: [] },
};
}
beforeEach(() => {
vi.stubGlobal("localStorage", memoryStorage());
});
describe("SupervisorHome", () => {
it("shows the page head, the KPIs, the station grid and the five boards", async () => {
mockFetch(base());
renderApp("/supervisor");
expect(await screen.findByRole("heading", { name: "Dispatch, review, route" })).toBeInTheDocument();
expect(await screen.findByText("Supervisor · 03 Weld Parts")).toBeInTheDocument();
await screen.findByRole("heading", { name: /^Ready to assign/ });
expect(screen.getByLabelText("Ready to assign: 2")).toHaveClass("hot");
expect(screen.getByLabelText("At stations: 1")).not.toHaveClass("hot");
expect(screen.getByLabelText("Waiting on review: 0")).toHaveClass("zero");
expect(screen.getByLabelText("Station hours vs target: 4.5 / 12.0 h")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "03 Weld Parts" })).toBeInTheDocument();
const busy = screen.getByRole("group", { name: "Stall 1, 1 job" });
expect(busy).toHaveClass("stall", "busy", "cohot");
expect(within(busy).getByRole("button", { name: "L662001 · WELD-PART" })).toBeInTheDocument();
expect(within(busy).getByRole("button", { name: "CHANGE ORDER CO-2 - drawing changed, verify" })).toHaveClass("cotag");
expect(within(busy).getByText("Andy Welder", { exact: false })).toBeInTheDocument();
expect(within(busy).getByText("[75]")).toBeInTheDocument();
expect(within(busy).getByText(/^01:30:0\d \/ 2\.0 h · Working$/)).toBeInTheDocument();
const idle = screen.getByRole("group", { name: "Stall 2, idle" });
expect(idle).toHaveClass("idle");
expect(idle).not.toHaveClass("cohot");
for (const t of ["Ready to assign", "Waiting on review", "At Q.C.", "With warehouse", "Routed on"]) {
expect(screen.getByRole("heading", { name: new RegExp(`^${t.replace(/\./g, "\\.")}`) })).toBeInTheDocument();
}
expect(screen.getByText("Stations submit finished packets here.")).toBeInTheDocument();
expect(screen.getByText("Approved packets show their next stop here.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /^L662002 · WELD-PART/ })).toHaveAttribute("href", "/packets/2");
});
it("shows the CO banner without a close button, and the held CO in the table", async () => {
const fetch = mockFetch(base());
renderApp("/supervisor");
const banner = await screen.findByRole("alert", { name: "Change orders to verify" });
expect(banner).toHaveTextContent("Change order: your verification is required");
expect(banner).toHaveTextContent("Change order CO-2 on L662001 · WELD-PART needs your verification");
expect(banner).toHaveTextContent("for 03 Weld Parts");
expect(within(banner).getAllByRole("button")).toHaveLength(1);
expect(within(banner).queryByRole("button", { name: /close|dismiss/i })).not.toBeInTheDocument();
const table = screen.getByRole("heading", { name: "Change orders on hold" }).closest("section") as HTMLElement;
expect(table).toHaveTextContent("CO-1");
expect(table).toHaveTextContent("L662004 · WELD-PART");
expect(table).toHaveTextContent("○ Clerical review · packet held");
expect(table).not.toHaveTextContent("CO-2");
fireEvent.click(within(banner).getByRole("button", { name: "Review & verify CO-2 on L662001 · WELD-PART" }));
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u).startsWith("/api/packets/1"))).toBe(true));
});
it("the CO tag on a held packet says Clerical is reviewing", async () => {
const held = { ...AT_STALL, open_cos: [{ label: "CO-3", review: "pending" }] };
const b = board({ cos: [] });
b.depts[0]!.stations[0]!.packets = [held];
mockFetch(base(b));
renderApp("/supervisor");
expect(await screen.findByRole("button", { name: "CHANGE ORDER CO-3 - Clerical reviewing a change, on hold" })).toBeInTheDocument();
expect(screen.queryByRole("alert", { name: "Change orders to verify" })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Change orders on hold" })).not.toBeInTheDocument();
});
it("shows department chips to a user who leads more than one, and remembers the pick", async () => {
const mia = makeMe({ roles: ["shop:03:supervisor", "shop:14:lead"], screens: ["supervisor", "stall"] });
const fetch = mockFetch(base(board(), mia));
renderApp("/supervisor");
const chips = await screen.findByRole("group", { name: "Department" });
expect(within(chips).getByRole("button", { name: "All" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByText("Supervisor · 2 departments")).toBeInTheDocument();
fireEvent.click(within(chips).getByRole("button", { name: "14 Electrical" }));
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/shop/board?dept=14")).toBe(true));
expect(window.localStorage.getItem(SUP_DEPT_KEY)).toBe("14");
expect(within(chips).getByRole("button", { name: "14 Electrical" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByText("Supervisor · 14 Electrical")).toBeInTheDocument();
});
it("starts on the remembered department", async () => {
window.localStorage.setItem(SUP_DEPT_KEY, "03");
const mia = makeMe({ roles: ["shop:03:supervisor", "shop:14:lead"], screens: ["supervisor", "stall"] });
const fetch = mockFetch(base(board(), mia));
renderApp("/supervisor");
await screen.findByRole("group", { name: "Department" });
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/shop/board?dept=03")).toBe(true));
expect(fetch.mock.calls.some(([u]) => String(u) === "/api/shop/board?dept=all")).toBe(false);
});
it("hides the chips with one department and filters the boards with the find box", async () => {
mockFetch(base());
renderApp("/supervisor");
await screen.findByRole("link", { name: /^L662003/ });
expect(screen.queryByRole("group", { name: "Department" })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Find a job"), { target: { value: "elbow" } });
expect(screen.queryByRole("link", { name: /^L662002/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: /^L662003/ })).toBeInTheDocument();
});
it("shows the server's refusal", async () => {
mockFetch({ ...base(), "/api/shop/board": { status: 403, body: { detail: "You do not lead a department.", code: "forbidden" } } });
renderApp("/supervisor");
expect(await screen.findByText("You do not lead a department.")).toBeInTheDocument();
});
});
@@ -0,0 +1,154 @@
import { useEffect, useMemo, useState } from "react";
import { getBoard, type ShopBoard, type ShopCard, type ShopColumn } from "../../api/shop";
import { levelIn, myShopDepts } from "../../auth/roles";
import { useAuth } from "../../auth/useAuth";
import { Board, type ColumnDef } from "../../components/Board";
import { CoBanner, type CoBannerItem } from "../../components/CoBanner";
import { FindBar } from "../../components/FindBar";
import { Kpis } from "../../components/Kpis";
import type { UnitPick } from "../../components/PickUnit";
import { useChanged } from "../../lib/changes";
import { fmtTime } from "../../lib/format";
import { useShopDepartments } from "../../lib/useDepartments";
import { useLoad } from "../../lib/useLoad";
import { CoTable } from "./CoTable";
import { asCard, coHref, deptShort, fmtHrs, lsGet, lsSet, matches, useNow } from "./shopView";
import { StationGrid } from "./StationGrid";
/** localStorage key for the department scope (the old ps.supdept). */
export const SUP_DEPT_KEY = "ps.supDept";
const COLUMNS: ReadonlyArray<{ key: ShopColumn; title: string; empty: string }> = [
{ key: "ready", title: "Ready to assign", empty: "Warehouse releases packets here, and other departments route them here." },
{ key: "review", title: "Waiting on review", empty: "Stations submit finished packets here." },
{ key: "qc", title: "At Q.C.", empty: "Packets you send for inspection wait here." },
{ key: "warehouse", title: "With warehouse", empty: "None." },
{ key: "complete", title: "Routed on", empty: "Approved packets show their next stop here." },
];
type Loaded = ShopBoard & { fetchedAt: number };
/** /supervisor (the old homeSupervisor): the change order headline, numbers, stations and the five boards. */
export function SupervisorHome() {
const { me } = useAuth();
const roles = useMemo(() => me?.roles ?? [], [me]);
const shop = useShopDepartments();
const isAdmin = roles.includes("admin");
const mine = useMemo(() => myShopDepts(roles, "supervisor", shop.map((d) => d.code)), [roles, shop]);
const [picked, setPicked] = useState<string>(() => lsGet(SUP_DEPT_KEY) ?? "all");
const dept = picked !== "all" && mine.includes(picked) ? picked : "all";
const choose = (v: string) => {
setPicked(v);
lsSet(SUP_DEPT_KEY, v);
};
const [q, setQ] = useState("");
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
const projectId = pick.project?.id;
const unitId = pick.unit?.id;
const [load, reload] = useLoad<Loaded>(
async (s) => {
const b = await getBoard({ dept, ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s);
return { ...b, fetchedAt: Date.now() };
},
`${dept}|${projectId ?? ""}|${unitId ?? ""}`,
);
useChanged(reload, { delayMs: 500 });
const data = load.state === "ok" ? load.data : null;
const atStations = useMemo(() => (data ? data.depts.flatMap((d) => d.stations.flatMap((s) => s.packets)) : []), [data]);
const running = atStations.filter((p) => p.clock?.running).length;
const now = useNow(running > 0);
const names = useMemo(() => {
const m = new Map<string, string>();
shop.forEach((d) => m.set(d.code, d.name));
data?.depts.forEach((d) => m.set(d.code, d.name));
return m;
}, [shop, data]);
// Keep the scope chip valid when the role list changes under it.
useEffect(() => {
if (picked !== "all" && mine.length && !mine.includes(picked)) setPicked("all");
}, [picked, mine]);
const scope = dept !== "all" ? [dept] : mine.length ? mine : (data?.depts.map((d) => d.code) ?? []);
const title = scope.length === 1 && scope[0] ? deptShort(scope[0], names) : `${scope.length} departments`;
const canVerify = (d: string) => isAdmin || levelIn(roles, d) === "supervisor";
const cos = data?.cos ?? [];
const toVerify = cos.filter((c) => c.review === "approved" && c.mine);
const onHold = cos.filter((c) => !(c.review === "approved" && c.mine));
const banner: CoBannerItem[] = toVerify.map((c) => {
const job = c.op ? `${c.unit_number} · ${c.op}` : c.unit_number;
const forDepts = c.pending_depts.filter(canVerify).map((d) => deptShort(d, names));
const sub = [c.issued_at ? `Issued ${fmtTime(c.issued_at)}` : "", forDepts.length ? `for ${forDepts.join(", ")}` : "", c.reason ?? ""]
.filter(Boolean)
.join(" · ");
return { key: `${c.packet_id}-${c.co_no}`, label: c.ecn ? `${c.label} (${c.ecn})` : c.label, job, sub, href: coHref(c.packet_id) };
});
const k = data?.kpis;
const elapsedMs = (k?.elapsed_ms ?? 0) + (data ? running * Math.max(0, now - data.fetchedAt) : 0);
const filtered = (list: ShopCard[]) => list.filter((c) => matches(c, q));
const columns: ColumnDef[] = COLUMNS.map((c) => {
const all = data?.columns[c.key] ?? [];
const shown = filtered(all);
return {
key: c.key,
title: c.title,
cards: shown.map(asCard),
empty: all.length && !shown.length ? "Nothing here matches the filters." : c.empty,
};
});
const count = data ? columns.reduce((n, c) => n + c.cards.length, 0) : null;
return (
<>
<CoBanner items={banner} it={isAdmin} />
<header className="phead">
<div style={{ minWidth: 0 }}>
<div className="eyebrow">Supervisor · {title}</div>
<h1>Dispatch, review, route</h1>
</div>
{mine.length > 1 ? (
<div className="pacts">
<div className="chips supchips" role="group" aria-label="Department">
<button type="button" className="chip sm" aria-pressed={dept === "all"} onClick={() => choose("all")}>
All
</button>
{mine.map((c) => (
<button key={c} type="button" className="chip sm" aria-pressed={dept === c} onClick={() => choose(c)}>
{deptShort(c, names)}
</button>
))}
</div>
</div>
) : null}
</header>
<Kpis
items={[
{ n: k?.ready ?? 0, label: "Ready to assign", alert: true },
{ n: k?.at_stations ?? 0, label: "At stations" },
{ n: k?.review ?? 0, label: "Waiting on review", alert: true },
{ n: `${fmtHrs(elapsedMs / 3_600_000)} / ${fmtHrs(Number(k?.target_hrs) || 0)} h`, label: "Station hours vs target" },
]}
/>
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
{data ? <CoTable rows={onHold} names={names} canVerify={canVerify} /> : null}
{data ? <StationGrid depts={data.depts} names={names} fetchedAt={data.fetchedAt} now={now} /> : null}
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} count={count} />
{data ? <Board columns={columns} /> : null}
</>
);
}
@@ -0,0 +1,18 @@
// An in-memory Storage for tests. Node's own localStorage global can hide jsdom's and throw.
export function memoryStorage(): Storage {
const m = new Map<string, string>();
return {
get length() {
return m.size;
},
clear: () => m.clear(),
getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
key: (i: number) => [...m.keys()][i] ?? null,
removeItem: (k: string) => {
m.delete(k);
},
setItem: (k: string, v: string) => {
m.set(k, String(v));
},
};
}
@@ -0,0 +1,99 @@
// Clock, hours and search helpers shared by the Supervisor and Stall screens.
// The server owns every stored clock value. The screens only add the time since the answer while a clock runs.
import { useEffect, useState } from "react";
import type { ShopCard, ShopClock } from "../../api/shop";
import type { PacketCard } from "../../api/types";
import { jobName } from "../../lib/packets";
/** "HH:MM:SS" (the old fmtClock). */
export function fmtClock(ms: number): string {
const t = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":");
}
/** Hours with one decimal: "4.5". */
export function fmtHrs(h: number): string {
return (Number.isFinite(h) ? h : 0).toFixed(1);
}
/** The display value of a clock: the server's elapsed plus the time since the fetch while it runs. */
export function liveMs(clock: ShopClock | null | undefined, fetchedAt: number, now: number): number {
if (!clock) return 0;
const base = Number(clock.elapsed_ms) || 0;
return clock.running ? base + Math.max(0, now - fetchedAt) : base;
}
/** The time now, refreshed every second while `active`. */
export function useNow(active: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
setNow(Date.now());
const t = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(t);
}, [active]);
return now;
}
/** "03 Weld Parts" from a code and the names known (the old deptShort). */
export function deptShort(code: string, names: ReadonlyMap<string, string>): string {
const n = names.get(code);
return n ? `${code} ${n}` : code;
}
/** The board card shape for the shared Card component. */
export function asCard(c: ShopCard): PacketCard {
return { ...c, open_cos: c.open_cos.length } as PacketCard;
}
/** Whether a card matches the find box: unit, op, title, drawing, customer, project, station or welder. */
export function matches(c: ShopCard, q: string): boolean {
const t = q.trim().toLowerCase();
if (!t) return true;
const hay = [
jobName(c),
c.title,
c.drawing,
c.customer,
c.project?.number,
c.project?.name,
c.station?.name,
c.welder?.display_name,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return t.split(/\s+/).every((w) => hay.includes(w));
}
/** "CHANGE ORDER CO-2 - drawing changed, verify" (the old station tile tag). */
export function coTagText(cos: ShopCard["open_cos"]): string {
const labels = cos.map((c) => c.label).join(", ");
const held = cos.some((c) => c.review === "pending");
return `CHANGE ORDER ${labels} - ${held ? "Clerical reviewing a change, on hold" : "drawing changed, verify"}`;
}
/** The packet page on its Change orders tab. */
export function coHref(packetId: number): string {
return `/packets/${packetId}?tab=changes`;
}
/** localStorage, never throwing. */
export function lsGet(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
export function lsSet(key: string, value: string): void {
try {
window.localStorage.setItem(key, value);
} catch {
// Storage blocked: the choice lasts for this page only.
}
}
+149
View File
@@ -404,3 +404,152 @@ td.late { color: var(--red); }
.mtrimg { max-width: 100%; height: auto; border-radius: 10px; background: var(--white); align-self: center; } .mtrimg { max-width: 100%; height: auto; border-radius: 10px; background: var(--white); align-self: center; }
.bar.usage { position: relative; height: 12px; border-radius: 6px; } .bar.usage { position: relative; height: 12px; border-radius: 6px; }
.bar.usage .mark { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--amber); } .bar.usage .mark { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--amber); }
/* Phase 5: shop screens. From .stalls, .stall, .sn, .cotag, .cohot, .stallhero, .cobanner, .cob-h, .cob-t, .cob-row, .cosec. */
.stalls { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; align-items: start; }
.stall { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 5px; min-height: 112px; }
.stall .sn { font-family: var(--f-disp); font-weight: 700; font-size: 18px; letter-spacing: .08em; color: var(--near); display: flex; justify-content: space-between; gap: 8px; }
.stall.idle { opacity: .55; min-height: 0; padding-block: 8px; }
.stall.idle .sn { font-size: 15px; }
.stall .stjob { display: flex; flex-direction: column; gap: 4px; }
.stall .stjob + .stjob { border-top: 1px solid var(--line); padding-top: 6px; }
.stall button.stopen { all: unset; cursor: pointer; color: var(--cyan); font-weight: 600; font-size: 14px; }
.stall button.stopen:focus-visible, .cotag:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
.stall.cohot { border: 2px solid var(--pink); box-shadow: 0 0 0 3px rgba(229, 88, 159, .18); }
.cotag, .stall button.cotag { all: unset; cursor: pointer; display: block; margin: 6px 0; background: var(--pink); color: var(--white); font-weight: 700; font-size: 11.5px; letter-spacing: .04em; padding: 5px 8px; border-radius: 6px; line-height: 1.25; text-transform: uppercase; }
.stempty { margin-bottom: 22px; }
.stclock { display: flex; align-items: center; flex-wrap: wrap; gap: 0 4px; }
.sdot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--muted); margin-right: 6px; }
.sdot.run { background: var(--green); animation: sdotpulse 1.6s infinite; }
@keyframes sdotpulse { 0% { box-shadow: 0 0 0 0 rgba(95, 201, 138, .55); } 70% { box-shadow: 0 0 0 8px rgba(95, 201, 138, 0); } 100% { box-shadow: 0 0 0 0 rgba(95, 201, 138, 0); } }
.stallhero { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-block: 6px 18px; }
.stallhero .big { font-family: var(--f-disp); font-weight: 700; font-size: 64px; line-height: .9; color: var(--white); letter-spacing: .04em; }
.stallhero .stpick { min-width: 220px; }
.stnone { margin-top: 30px; }
.cobanner { border: 3px solid var(--pink); background: linear-gradient(180deg, rgba(229, 88, 159, .22), rgba(229, 88, 159, .08)); border-radius: 14px; padding: 14px 16px; margin: 4px 0 18px; display: flex; flex-direction: column; gap: 10px; box-shadow: 0 0 0 4px rgba(229, 88, 159, .12); animation: copulse 2.2s ease-in-out infinite; }
@keyframes copulse { 0%, 100% { box-shadow: 0 0 0 4px rgba(229, 88, 159, .12), 0 0 0 0 rgba(229, 88, 159, 0); border-color: var(--pink); } 50% { box-shadow: 0 0 0 6px rgba(229, 88, 159, .30), 0 0 34px 6px rgba(229, 88, 159, .38); border-color: #ff8cc4; } }
.cob-h { display: flex; gap: 12px; align-items: center; color: var(--near); }
.cob-h svg { color: var(--pink); flex: none; animation: cobpulse 1.6s ease-in-out infinite; }
@keyframes cobpulse { 50% { opacity: .35; } }
.cob-t { font-family: var(--f-disp); font-size: 26px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--white); line-height: 1.1; }
.cob-row { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; background: rgba(13, 22, 27, .7); border-radius: 10px; padding: 10px 12px; }
.cob-txt { min-width: 0; }
.cosec { border-left: 4px solid var(--pink); padding-left: 12px; }
.supchips { justify-content: flex-end; }
@media (max-width: 640px) {
.stalls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stall.busy { grid-column: 1 / -1; }
.cob-t { font-size: 20px; }
.stallhero .big { font-size: 48px; }
}
/* Phase 5: packet page. From .dv, .viewer, .stage, .mk, .ld, .arw, .ht, .htbl, .htprev, .vtools, .fsbar, body.fs-view,
.pagesel, .side, .hlist, .ichip, .ck, .ck-item, .cklay, .clock, .cdot. The drawing stays on white.
Appended after the shop screens block because that block was written first; no selector overlaps it. */
.dv { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 14px; align-items: start; }
.dv.solo { grid-template-columns: minmax(0, 1fr); }
@media (max-width: 980px) { .dv { grid-template-columns: minmax(0, 1fr); } }
.viewer { position: relative; background: var(--white); border-radius: 10px; overflow: hidden; height: min(68vh, 720px); touch-action: none; border: 1px solid var(--line2); cursor: grab; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
.viewer.panning { cursor: grabbing; }
.viewer.marking { cursor: crosshair; outline: 3px solid var(--pink); outline-offset: -3px; }
.viewer.placing { cursor: crosshair; outline: 3px solid var(--amber); outline-offset: -3px; }
.stage { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
.stage-canvas { display: block; max-width: none; pointer-events: none; }
.vload { position: absolute; inset: 0; display: grid; place-items: center; color: #44535a; padding: 16px; pointer-events: none; }
.vload .callout { pointer-events: auto; }
.lead { position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.ld { stroke-width: 2.5; vector-effect: non-scaling-stroke; fill: none; }
.ld.h { stroke: #b07800; }
.ld.w { stroke: #c2185b; }
.ld.carried { stroke-dasharray: 6 4; }
.mk { position: absolute; z-index: 2; transform: translate(-50%, -50%) scale(var(--inv, 1)); transform-origin: center; width: 44px; height: 44px; border-radius: 50%; background: #fff; color: #c2185b; border: 3px solid #c2185b; font-family: var(--f-mono); font-size: 13px; font-weight: 700; display: grid; place-items: center; box-shadow: 0 2px 6px rgba(0, 0, 0, .45); cursor: pointer; padding: 0; }
.mk.drag { cursor: move; touch-action: none; }
.mk.carried { border-style: dashed; opacity: .85; }
.arw { position: absolute; z-index: 1; width: 20px; height: 14px; transform: translate(-100%, -50%) rotate(var(--rot, 0deg)) scale(var(--inv, 1)); transform-origin: 100% 50%; pointer-events: none; }
.arw::before { content: ""; position: absolute; inset: 0; clip-path: polygon(0 0, 100% 50%, 0 100%); }
.arw.h::before { background: #b07800; }
.arw.w::before { background: #c2185b; }
.arw.drag { pointer-events: auto; cursor: move; touch-action: none; width: 30px; height: 22px; }
.ht { position: absolute; z-index: 2; transform: translate(-50%, -50%) scale(var(--inv, 1)); display: inline-flex; align-items: center; gap: 7px; background: #fff8e6; border: 2px solid #b07800; color: #3d2a00; border-radius: 999px; padding: 3px 12px 3px 3px; font-family: var(--f-mono); font-weight: 700; font-size: 13px; white-space: nowrap; box-shadow: 0 2px 6px rgba(0, 0, 0, .35); cursor: pointer; line-height: 1.2; min-height: 34px; }
.ht .hn { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: #b07800; color: #fff; font-size: 12px; flex: none; }
.ht.none { border-style: dashed; color: #7a5a20; }
.ht.drag { cursor: move; touch-action: none; }
.ht.carried { opacity: .85; }
.htbl { position: absolute; z-index: 3; font-size: calc(var(--sw, 3000px) * 0.0075 * var(--hs, 1)); background: #fff; border: .16em solid #b07800; color: #2b1d00; font-family: Arial, Helvetica, sans-serif; line-height: 1.15; box-shadow: 0 .1em .4em rgba(0, 0, 0, .35); user-select: none; -webkit-user-select: none; }
.htbl.drag { cursor: move; touch-action: none; }
.htbl.drag:hover, .htbl.moving { outline: .12em dashed var(--cyan); outline-offset: .25em; }
.htbl .htcap { background: #b07800; color: #fff; font-weight: 700; font-size: .85em; text-align: center; padding: .2em .5em; letter-spacing: .04em; }
.htbl .htcols { display: flex; align-items: flex-start; }
.htbl table { border-collapse: collapse; margin: 0; width: auto; font-size: inherit; }
.htbl th, .htbl td { border: .08em solid #b07800; padding: .2em .5em; white-space: nowrap; background: #fff; color: #2b1d00; font-size: 1em; }
.htbl th { background: #f3e2b8; color: #3d2a00; font-size: .8em; font-weight: 700; text-align: center; letter-spacing: .03em; text-transform: none; }
.htbl td.i { text-align: center; font-weight: 700; }
.htbl td.h { font-family: "Courier New", monospace; font-weight: 700; }
.htbl td.h.none { color: #b00020; }
.htbl td.h.na { color: #6b5a3a; }
.htbl.static { position: static; font-size: 13px; box-shadow: none; display: inline-block; max-width: 100%; overflow: auto; }
.htbl.htghost { position: fixed; z-index: 70; pointer-events: none; font-size: 13px; opacity: .92; transform: translate(-10px, -10px); }
.htprev { border: 1px dashed var(--line2); border-radius: 10px; padding: 10px; }
.htprev.htgrab { cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; }
.htprev.htgrab:hover { border-color: var(--cyan); }
.vtools { position: absolute; right: 10px; top: 10px; display: flex; flex-direction: column; gap: 6px; z-index: 4; }
.vtools button { width: 44px; height: 44px; border-radius: 9px; border: 1px solid #c9d3d8; background: rgba(255, 255, 255, .94); color: #1a252b; font-size: 20px; font-weight: 700; cursor: pointer; display: grid; place-items: center; padding: 0; }
.vtools button.fit { font-size: 13px; }
.vtools button svg { display: block; margin: auto; }
.fsbar { display: none; }
body.fs-view { overflow: hidden; }
body.fs-view .viewer { position: fixed; inset: 0; width: 100vw; height: 100vh; height: 100dvh; z-index: 80; border-radius: 0; border: 0; }
body.fs-view .fsbar { display: flex; position: absolute; left: 10px; top: 10px; max-width: calc(100% - 80px); z-index: 4; gap: 6px; flex-wrap: wrap; align-items: center; background: rgba(13, 22, 27, .9); padding: 6px 8px; border-radius: 10px; color: var(--near); cursor: default; }
.fsbar .fst { font-size: 13px; margin-right: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 40vw; }
body.fs-view .scrim { z-index: 95; }
body.fs-view .toast { z-index: 99; }
.pagesel { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.side { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.hlist { display: flex; flex-direction: column; gap: 7px; font-size: 13.5px; }
.hlist .row { display: grid; grid-template-columns: 28px 1fr auto; gap: 8px; align-items: center; }
.hlist .row .d { min-width: 0; color: var(--near); overflow-wrap: anywhere; }
.hlist .rowbtn { all: unset; display: grid; grid-template-columns: 28px 1fr auto; gap: 8px; align-items: center; cursor: pointer; min-height: 44px; border-radius: 8px; padding: 0 4px; }
.hlist .rowbtn:hover { background: var(--panel2); }
.hlist .rowbtn:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
.balloon.pinkb { border-color: var(--pink); color: var(--pink); }
.balloon.carried { border-style: dashed; }
.flabel { font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
.ichip { width: 56px; height: 56px; border-radius: 50%; border: 2px solid var(--line2); background: var(--panel); color: var(--near); font-family: var(--f-mono); font-size: 20px; font-weight: 700; cursor: pointer; transition: background .12s, border-color .12s, transform .12s; }
.ichip:hover { border-color: var(--cyan); }
.ichip[aria-pressed="true"] { background: var(--cyan); border-color: var(--cyan); color: var(--on-cyan); box-shadow: 0 0 0 4px rgba(73, 192, 222, .28); transform: scale(1.06); }
.ichip:disabled { cursor: default; }
.ck { display: flex; flex-direction: column; gap: 8px; }
.ck-item { display: flex; gap: 14px; align-items: center; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; cursor: pointer; min-height: 58px; }
.ck-item input { width: 28px; height: 28px; accent-color: var(--green); flex: none; }
.ck-item .t { color: var(--near); font-weight: 500; flex: 1; min-width: 0; }
.ck-item .by { font-size: 12px; color: var(--muted); text-align: right; }
.ck-item.done { border-color: #2d5a41; }
.ck-item[aria-disabled="true"] { cursor: default; }
.cklay { display: grid; grid-template-columns: minmax(0, 1fr) 360px; grid-template-areas: "main side"; gap: 18px; align-items: start; }
.cklay .ckmain { grid-area: main; min-width: 0; }
.cklay .ckside { grid-area: side; position: sticky; top: calc(var(--toph, 64px) + 64px); }
@media (max-width: 980px) { .cklay { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "side"; } .cklay .ckside { position: static; } }
.clockrow { display: flex; gap: 16px; align-items: baseline; flex-wrap: wrap; margin-right: auto; }
.clock { font-family: var(--f-disp); font-weight: 700; font-size: 40px; color: var(--white); line-height: 1; font-variant-numeric: tabular-nums; }
.clock small { font-size: 18px; color: var(--muted); }
.cdot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; background: var(--muted); margin-right: 10px; vertical-align: middle; }
.cdot.run { background: var(--green); animation: cdotpulse 1.6s infinite; }
@keyframes cdotpulse { 0% { box-shadow: 0 0 0 0 rgba(95, 201, 138, .6); } 70% { box-shadow: 0 0 0 10px rgba(95, 201, 138, 0); } 100% { box-shadow: 0 0 0 0 rgba(95, 201, 138, 0); } }
.labor { color: var(--body); }
.labor b { color: var(--amber); font-family: var(--f-mono); }
.acts.center { align-items: center; }
.revrow { display: flex; gap: 10px 16px; flex-wrap: wrap; align-items: flex-end; }
.revrow.sep { border-top: 1px solid var(--line); padding-top: 10px; }
.revrow .f { flex: 0 1 220px; }
.revrow .f.grow2 { flex: 1 1 260px; }
.presence { display: flex; gap: 8px; align-items: center; color: var(--body); margin: 6px 0; }
.pdot { width: 9px; height: 9px; border-radius: 50%; background: var(--cyan); flex: none; }
.verifybox { border-color: var(--pink); }
.pinktxt { color: var(--pink); }
.amber { color: var(--amber); }
.near { color: var(--near); }
.mb6 { margin-bottom: 6px; }
.mb8 { margin-bottom: 8px; }
.mb10 { margin-bottom: 10px; }
.mt8 { margin-top: 8px; }
.mt10 { margin-top: 10px; }
+20 -1
View File
@@ -1,4 +1,4 @@
"""Departments and their stations (read-only in phase 1).""" """Departments and their stations, and the people of a shop department (read-only)."""
from __future__ import annotations from __future__ import annotations
@@ -6,7 +6,9 @@ from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from ..domain import shop
from .deps import Db, Principal, current_principal, get_db from .deps import Db, Principal, current_principal, get_db
from .errors import ApiError
router = APIRouter(prefix="/api", tags=["departments"]) router = APIRouter(prefix="/api", tags=["departments"])
@@ -27,3 +29,20 @@ def departments(_: Principal = Depends(current_principal), d: Db = Depends(get_d
{"code": r[0], "slug": r[1], "name": r[2], "kind": r[3], "weld": bool(r[4]), "stations": by_dept.get(r[0], [])} {"code": r[0], "slug": r[1], "name": r[2], "kind": r[3], "weld": bool(r[4]), "stations": by_dept.get(r[0], [])}
for r in rows for r in rows
] ]
@router.get("/departments/{code}/people")
def department_people(
code: str,
_: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> list[dict[str, Any]]:
"""The active users with a shop role in a shop department: the assign sheet's welder picker. 404 for an
unknown or support department."""
conn = d.conn
found = conn.execute(
"SELECT code FROM departments WHERE code = %s AND kind = 'shop' AND active", (code[:10],)
).fetchone()
if found is None:
raise ApiError(404, "not_found", "No such shop department.")
return shop.people(conn, str(found[0]))
+8 -1
View File
@@ -16,7 +16,13 @@ SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connecti
@router.get("/events") @router.get("/events")
async def events(request: Request, screen: str | None = Query(None, max_length=32)) -> StreamingResponse: async def events(
request: Request,
screen: str | None = Query(None, max_length=32),
packet_id: int | None = Query(None, ge=1, le=2**62),
) -> StreamingResponse:
"""The event stream. `packet_id` is the packet the page has open; presence carries it, so the packet page can
show who else is looking at the same packet. The client reopens the stream when the open packet changes."""
# The session check runs on its own pooled connection, returned before the stream starts # The session check runs on its own pooled connection, returned before the stream starts
p = await run_in_threadpool(resolve_standalone, request) p = await run_in_threadpool(resolve_standalone, request)
hub: EventHub = request.app.state.events hub: EventHub = request.app.state.events
@@ -25,6 +31,7 @@ async def events(request: Request, screen: str | None = Query(None, max_length=3
user_id=p.user_id, user_id=p.user_id,
display_name=p.user["display_name"], display_name=p.user["display_name"],
screen=screen if screen in SCREENS and screen in p.screens else None, screen=screen if screen in SCREENS and screen in p.screens else None,
packet_id=packet_id,
) )
hello = {"session_id": str(p.session_id), "build": settings_of(request).ps_build} hello = {"session_id": str(p.session_id), "build": settings_of(request).ps_build}
return StreamingResponse( return StreamingResponse(
+276 -41
View File
@@ -25,6 +25,7 @@ from ..domain.children import NEVER_VERIFY, active_depts, co_touched, current_wh
from ..domain.pipeline import ( from ..domain.pipeline import (
SignBody, SignBody,
Write, Write,
apply_field_cas,
check_row_version, check_row_version,
jsonable, jsonable,
next_counter, next_counter,
@@ -43,10 +44,36 @@ router = APIRouter(prefix="/api/packets", tags=["packet-children"])
A = policy.Action A = policy.Action
TEXT_SHORT = 40 # weld type, size, process, wo_base TEXT_SHORT = LIMITS["weld_text"] # weld type, size, process, wo_base
FILLER_MAX = 120
QC_FIELD_MAX = 120 QC_FIELD_MAX = 120
# The weld vocabulary (the old WELD_TYPES and PROCESSES).
WELD_TYPES = ("Fillet", "Groove", "Fill", "Seal", "Tack")
PROCESSES = ("GMAW", "FCAW", "SMAW", "GTAW", "SAW")
DEFAULT_SIZE = '5/16"'
POSITION = frozenset({"fx", "fy", "bfx", "bfy"})
# The weld fields in the order the change note lists them, with their note names.
WELD_NOTE_NAMES = {
"type": "type",
"size": "size",
"process": "process",
"items": "items",
"wo_base": "wo",
"piece": "piece",
"stamp": "stamp",
"page": "sheet",
}
# The heat table bounds (the old HT_MIN, HT_MAX and the drag clamp).
HT_XY_MAX = 0.97
HT_S_MIN, HT_S_MAX = 0.6, 2.1
STAGE_FIELDS = {
"filler": LIMITS["filler"],
"filler_lot": LIMITS["filler"],
"preheat": LIMITS["preheat"],
"notes": LIMITS["notes"],
}
STAGE_NOTE_NAMES = {"filler": "filler", "filler_lot": "filler lot", "preheat": "preheat", "notes": "notes"}
# The warehouse hold reasons, a closed set (the old build took any text). "Other" needs a note. # The warehouse hold reasons, a closed set (the old build took any text). "Other" needs a note.
WH_HOLD_REASONS = ( WH_HOLD_REASONS = (
"Waiting on material (not in stock)", "Waiting on material (not in stock)",
@@ -86,6 +113,7 @@ WH_PULLING_FROM = {"hold": "Pulling resumed", "pulled": "Back to pulling"}
MSG_NO_STAGE = "This packet has no open stage." MSG_NO_STAGE = "This packet has no open stage."
MSG_NO_INSPECTION = "This packet has no open Q.C. inspection." MSG_NO_INSPECTION = "This packet has no open Q.C. inspection."
MSG_SHORT_OPEN = "This line has an open short. Use the short to change heat, issued or location." MSG_SHORT_OPEN = "This line has an open short. Use the short to change heat, issued or location."
MSG_VOID = "This sheet is VOID. Mark the current sheet."
# ---------------------------------------------------------------- bodies # ---------------------------------------------------------------- bodies
@@ -145,6 +173,8 @@ 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)
fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False) fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
bfx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
bfy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
wo_base: str | None = Field(default=None, max_length=200) wo_base: str | None = Field(default=None, max_length=200)
piece: int | None = Field(default=None, ge=1, le=100_000) piece: int | None = Field(default=None, ge=1, le=100_000)
items: list[int] = Field(min_length=1, max_length=200) items: list[int] = Field(min_length=1, max_length=200)
@@ -158,6 +188,8 @@ class WeldPatchBody(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)
fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False) fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
bfx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
bfy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
wo_base: str | None = Field(default=None, max_length=200) wo_base: str | None = Field(default=None, max_length=200)
piece: int | None = Field(default=None, ge=1, le=100_000) piece: int | None = Field(default=None, ge=1, le=100_000)
items: list[int] | None = Field(default=None, min_length=1, max_length=200) items: list[int] | None = Field(default=None, min_length=1, max_length=200)
@@ -176,6 +208,8 @@ class TagBody(It):
item: int = Field(ge=1, le=100_000) item: int = Field(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)
fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False) fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
bfx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
bfy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
class TagPatchBody(It): class TagPatchBody(It):
@@ -183,19 +217,26 @@ class TagPatchBody(It):
item: int | None = Field(default=None, ge=1, le=100_000) item: 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)
fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False) fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
bfx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
bfy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
class HeatTableBody(It): class HeatTableBody(It):
x: float = Field(allow_inf_nan=False) x: float = Field(allow_inf_nan=False)
y: float = Field(allow_inf_nan=False) y: float = Field(allow_inf_nan=False)
s: float = Field(gt=0, le=100, allow_inf_nan=False) s: float = Field(allow_inf_nan=False)
carried_from: int | None = Field(default=None, ge=1, le=100_000)
class StageBody(It): class StageBody(It):
"""The flat body (with If-Match on the stage row), or `{set, base}` for field-level compare-and-set."""
filler: str | None = Field(default=None, max_length=500) filler: str | None = Field(default=None, max_length=500)
filler_lot: str | None = Field(default=None, max_length=500) filler_lot: str | None = Field(default=None, max_length=500)
preheat: str | None = Field(default=None, max_length=500) preheat: str | None = Field(default=None, max_length=500)
notes: str | None = Field(default=None, max_length=5000) notes: str | None = Field(default=None, max_length=5000)
set: dict[str, str | None] | None = Field(default=None, max_length=10)
base: dict[str, str | None] | None = Field(default=None, max_length=10)
class ChecklistBody(It): class ChecklistBody(It):
@@ -420,6 +461,51 @@ def _tag(conn: Any, packet_id: int, no: int) -> dict[str, Any]:
return row return row
def _page_row(conn: Any, packet_id: int, page: int) -> dict[str, Any] | None:
return packets.one(
conn, "SELECT n, type, dwg, rev, void_at FROM packet_pages WHERE packet_id = %s AND n = %s", (packet_id, page)
)
def _drawing_page(conn: Any, packet_id: int, page: int) -> dict[str, Any]:
"""A drawing page of the packet, live or VOID. 422 invalid_page otherwise."""
pg = _page_row(conn, packet_id, page)
if pg is None or pg["type"] != "drawing":
raise ApiError(422, "invalid_page", f"There is no drawing sheet {page} on this packet.")
return pg
def _live_page(conn: Any, packet_id: int, page: int | None) -> None:
"""A mark goes on a live drawing page. 422 void_page on a VOID sheet; nothing is checked without a page."""
if page is None:
return
if _drawing_page(conn, packet_id, page)["void_at"] is not None:
raise ApiError(422, "void_page", MSG_VOID)
def _not_void(conn: Any, packet_id: int, page: int | None) -> None:
"""An existing mark on a VOID sheet is never changed."""
if page is None:
return
pg = _page_row(conn, packet_id, page)
if pg is not None and pg["void_at"] is not None:
raise ApiError(422, "void_page", MSG_VOID)
def _page_text(pg: dict[str, Any] | None, page: int) -> str:
"""A sheet in a log note: its drawing number, else "sheet 2"."""
dwg = (pg or {}).get("dwg")
return str(dwg) if dwg else f"sheet {page}"
def _num_text(value: Any) -> str:
return f"{round(float(value), 2):g}"
def _co_no_text(no: int) -> str:
return packets.co_label(no)
def _co(conn: Any, packet_id: int, no: int) -> dict[str, Any]: def _co(conn: Any, packet_id: int, no: int) -> dict[str, Any]:
row = packets.one( row = packets.one(
conn, conn,
@@ -428,7 +514,7 @@ def _co(conn: Any, packet_id: int, no: int) -> dict[str, Any]:
(packet_id, no), (packet_id, no),
) )
if row is None: if row is None:
raise ApiError(404, "not_found", f"There is no change order {no} on this packet.") raise ApiError(404, "not_found", f"There is no change order {_co_no_text(no)} on this packet.")
return row return row
@@ -437,7 +523,7 @@ def _co_state(row: dict[str, Any], want: str, what: str) -> None:
raise ApiError( raise ApiError(
409, 409,
"co_state", "co_state",
f"Change order {row['co_no']} is {row['review']}. Only a {want} change order can be {what}.", f"Change order {_co_no_text(row['co_no'])} is {row['review']}. Only a {want} change order can be {what}.",
extra={"current": {"co_no": row["co_no"], "review": row["review"], "hold": row["hold"]}}, extra={"current": {"co_no": row["co_no"], "review": row["review"], "hold": row["hold"]}},
) )
@@ -785,12 +871,22 @@ def receive_short(
def _weld_values(conn: Any, packet_id: int, body: WeldBody | WeldPatchBody, given: set[str]) -> dict[str, Any]: def _weld_values(conn: Any, packet_id: int, body: WeldBody | WeldPatchBody, given: set[str]) -> dict[str, Any]:
values: dict[str, Any] = {} values: dict[str, Any] = {}
for k in ("page", "fx", "fy", "piece"): for k in ("page", "fx", "fy", "bfx", "bfy", "piece"):
if k in given: if k in given:
values[k] = getattr(body, k) values[k] = getattr(body, k)
for k in ("wo_base", "type", "size", "process"): for k in ("wo_base", "type", "size", "process"):
if k in given: if k in given:
values[k] = clean_opt(getattr(body, k), TEXT_SHORT) values[k] = clean_opt(getattr(body, k), TEXT_SHORT)
if values.get("type") is not None and values["type"] not in WELD_TYPES:
raise ApiError(422, "invalid_weld", "Pick a weld type from the list.")
if values.get("process") is not None and values["process"] not in PROCESSES:
raise ApiError(422, "invalid_weld", "Pick a process from the list.")
if values.get("wo_base") is not None:
bases = {
r[0] for r in conn.execute("SELECT wo_base FROM packet_work_orders WHERE packet_id = %s", (packet_id,))
}
if values["wo_base"] not in bases:
raise ApiError(422, "invalid_wo", "Pick one of this packet's work orders.")
if "stamp" in given: if "stamp" in given:
values["stamp"] = _stamp(body.stamp) values["stamp"] = _stamp(body.stamp)
if "items" in given: if "items" in given:
@@ -800,6 +896,33 @@ def _weld_values(conn: Any, packet_id: int, body: WeldBody | WeldPatchBody, give
return values return values
def _items_text(items: Any) -> str:
return "items " + " + ".join(map(str, items or []))
def _weld_note(values: dict[str, Any]) -> str:
"""The note of a new weld: Fillet 5/16" · items 1 + 2 · L662001 #1."""
desc = " ".join(str(x) for x in (values.get("type"), values.get("size")) if x)
wo = values.get("wo_base")
if wo and values.get("piece") is not None:
wo = f"{wo} #{values['piece']}"
return " · ".join(x for x in (desc, _items_text(values["items"]), wo) if x)
def _change_note(changes: dict[str, Any]) -> str | None:
"""The changed weld fields: type Groove · items 1 + 3."""
parts: list[str] = []
for k, name in WELD_NOTE_NAMES.items():
if k not in changes:
continue
v = changes[k]
if k == "items":
parts.append(_items_text(v))
else:
parts.append(f"{name} {v}" if v not in (None, "") else f"{name} cleared")
return " · ".join(parts) or None
@router.post("/{packet_id}/welds", dependencies=WRITE) @router.post("/{packet_id}/welds", dependencies=WRITE)
def add_weld( def add_weld(
packet_id: int, packet_id: int,
@@ -816,6 +939,9 @@ def add_weld(
d, p, packet_id, A.EDIT_WELDS, request=request, it_reason=body.it_reason, policy_principal=pp d, p, packet_id, A.EDIT_WELDS, request=request, it_reason=body.it_reason, policy_principal=pp
) as w: ) as w:
values = _weld_values(conn, packet_id, body, given) values = _weld_values(conn, packet_id, body, given)
_live_page(conn, packet_id, values.get("page"))
if values.get("size") is None:
values["size"] = DEFAULT_SIZE
dept = str(w.packet["dept_code"]) dept = str(w.packet["dept_code"])
worker = pp.level(dept) == 1 worker = pp.level(dept) == 1
welder_id = w.user_id if worker else w.packet["welder_id"] welder_id = w.user_id if worker else w.packet["welder_id"]
@@ -824,15 +950,17 @@ def add_weld(
stage = _open_stage(conn, packet_id) stage = _open_stage(conn, packet_id)
no = next_counter(conn, packet_id, "weld") no = next_counter(conn, packet_id, "weld")
conn.execute( conn.execute(
"INSERT INTO welds (packet_id, weld_no, page, fx, fy, wo_base, piece, items, type, size, process, stamp," "INSERT INTO welds (packet_id, weld_no, page, fx, fy, bfx, bfy, wo_base, piece, items, type, size, process,"
" welder_id, dept_code, stage_id, by) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s," " stamp, welder_id, dept_code, stage_id, by) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,"
" %s)", " %s, %s, %s, %s, %s)",
( (
packet_id, packet_id,
no, no,
values.get("page"), values.get("page"),
values.get("fx"), values.get("fx"),
values.get("fy"), values.get("fy"),
values.get("bfx"),
values.get("bfy"),
values.get("wo_base"), values.get("wo_base"),
values.get("piece"), values.get("piece"),
values["items"], values["items"],
@@ -848,8 +976,7 @@ def add_weld(
) )
w.changed("welds") w.changed("welds")
w.detail["weld_no"] = no w.detail["weld_no"] = no
desc = " ".join(x for x in (values.get("type"), values.get("size")) if x) w.log(f"Weld W{no} marked", _weld_note(values))
w.log(f"Logged W{no}", " · ".join(x for x in (desc, "items " + " + ".join(map(str, values["items"]))) if x))
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
@@ -870,6 +997,7 @@ def patch_weld(
) as w: ) as w:
row = _weld(conn, packet_id, no) row = _weld(conn, packet_id, no)
check_row_version(row, if_match) check_row_version(row, if_match)
_not_void(conn, packet_id, row["page"])
values = _weld_values(conn, packet_id, body, set(body.model_fields_set)) values = _weld_values(conn, packet_id, body, set(body.model_fields_set))
changes = _diff(row, values) changes = _diff(row, values)
if "items" in changes and sorted(row["items"] or []) == changes["items"]: if "items" in changes and sorted(row["items"] or []) == changes["items"]:
@@ -877,10 +1005,15 @@ def patch_weld(
if not changes: if not changes:
w.skip() w.skip()
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
if "page" in changes:
_live_page(conn, packet_id, changes["page"])
update_row(conn, "welds", row["id"], changes) update_row(conn, "welds", row["id"], changes)
w.changed(*[f"weld.{k}" for k in changes]) w.changed(*[f"weld.{k}" for k in changes])
w.detail["weld_no"] = no w.detail["weld_no"] = no
w.log(f"Edited W{no}") if set(changes) <= POSITION:
w.log(f"Weld W{no} moved")
else:
w.log(f"Weld W{no} changed", _change_note(changes))
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
@@ -905,6 +1038,7 @@ def remove_weld(
row = _weld(conn, packet_id, no) row = _weld(conn, packet_id, no)
if if_match is not None: if if_match is not None:
check_row_version(row, if_match) check_row_version(row, if_match)
_not_void(conn, packet_id, row["page"])
conn.execute( conn.execute(
"UPDATE welds SET removed_at = now(), removed_by = %s, removed_reason = %s, row_version = row_version + 1" "UPDATE welds SET removed_at = now(), removed_by = %s, removed_reason = %s, row_version = row_version + 1"
" WHERE id = %s", " WHERE id = %s",
@@ -912,7 +1046,7 @@ def remove_weld(
) )
w.changed("welds") w.changed("welds")
w.detail["weld_no"] = no w.detail["weld_no"] = no
w.log(f"Removed W{no}", reason) w.log(f"Weld W{no} removed", reason)
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
@@ -920,7 +1054,7 @@ def remove_weld(
def _tag_values(conn: Any, packet_id: int, body: TagBody | TagPatchBody, given: set[str]) -> dict[str, Any]: def _tag_values(conn: Any, packet_id: int, body: TagBody | TagPatchBody, given: set[str]) -> dict[str, Any]:
values: dict[str, Any] = {k: getattr(body, k) for k in ("page", "fx", "fy") if k in given} values: dict[str, Any] = {k: getattr(body, k) for k in ("page", "fx", "fy", "bfx", "bfy") if k in given}
if "item" in given: if "item" in given:
if body.item is None: if body.item is None:
raise ApiError(422, "invalid_items", "Pick the BOM item this tag marks.") raise ApiError(422, "invalid_items", "Pick the BOM item this tag marks.")
@@ -942,10 +1076,22 @@ def add_tag(
d, p, packet_id, A.EDIT_HEATS, request=request, it_reason=body.it_reason, policy_principal=pp d, p, packet_id, A.EDIT_HEATS, request=request, it_reason=body.it_reason, policy_principal=pp
) as w: ) as w:
values = _tag_values(conn, packet_id, body, set(body.model_fields_set) | {"item"}) values = _tag_values(conn, packet_id, body, set(body.model_fields_set) | {"item"})
_live_page(conn, packet_id, values.get("page"))
no = next_counter(conn, packet_id, "heat") no = next_counter(conn, packet_id, "heat")
conn.execute( conn.execute(
"INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy, by) VALUES (%s, %s, %s, %s, %s, %s, %s)", "INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy, bfx, bfy, by)"
(packet_id, no, values.get("page"), values["item"], values.get("fx"), values.get("fy"), w.user_id), " VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
(
packet_id,
no,
values.get("page"),
values["item"],
values.get("fx"),
values.get("fy"),
values.get("bfx"),
values.get("bfy"),
w.user_id,
),
) )
w.changed("heat_tags") w.changed("heat_tags")
w.detail["tag_no"] = no w.detail["tag_no"] = no
@@ -970,14 +1116,18 @@ def patch_tag(
) as w: ) as w:
row = _tag(conn, packet_id, no) row = _tag(conn, packet_id, no)
check_row_version(row, if_match) check_row_version(row, if_match)
_not_void(conn, packet_id, row["page"])
changes = _diff(row, _tag_values(conn, packet_id, body, set(body.model_fields_set))) changes = _diff(row, _tag_values(conn, packet_id, body, set(body.model_fields_set)))
if not changes: if not changes:
w.skip() w.skip()
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
if "page" in changes:
_live_page(conn, packet_id, changes["page"])
update_row(conn, "heat_tags", row["id"], changes) update_row(conn, "heat_tags", row["id"], changes)
w.changed(*[f"heat_tag.{k}" for k in changes]) w.changed(*[f"heat_tag.{k}" for k in changes])
w.detail["tag_no"] = no w.detail["tag_no"] = no
w.log(f"Heat tag H{no} changed", f"item {changes['item']}" if "item" in changes else None) item = changes.get("item", row["item"])
w.log(f"Heat tag H{no} {'moved' if set(changes) <= POSITION else 'changed'}", f"item {item}")
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
@@ -1001,6 +1151,7 @@ def remove_tag(
row = _tag(conn, packet_id, no) row = _tag(conn, packet_id, no)
if if_match is not None: if if_match is not None:
check_row_version(row, if_match) check_row_version(row, if_match)
_not_void(conn, packet_id, row["page"])
conn.execute( conn.execute(
"UPDATE heat_tags SET removed_at = now(), removed_by = %s, removed_reason = %s," "UPDATE heat_tags SET removed_at = now(), removed_by = %s, removed_reason = %s,"
" row_version = row_version + 1 WHERE id = %s", " row_version = row_version + 1 WHERE id = %s",
@@ -1008,7 +1159,7 @@ def remove_tag(
) )
w.changed("heat_tags") w.changed("heat_tags")
w.detail["tag_no"] = no w.detail["tag_no"] = no
w.log(f"Heat tag H{no} removed", reason) w.log(f"Heat tag H{no} removed", " · ".join(x for x in (f"item {row['item']}", reason) if x))
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
@@ -1021,29 +1172,50 @@ def put_heat_table(
p: Principal = Depends(current_principal), # noqa: B008 p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008 d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Place the heat table on a page. The same placement again changes nothing.""" """Place, move or resize the heat table on a drawing page. The same placement again changes nothing."""
if page < 1: if page < 1:
raise ApiError(422, "invalid_page", "Pages start at 1.") raise ApiError(422, "invalid_page", "Pages start at 1.")
if not (0 <= body.x <= HT_XY_MAX and 0 <= body.y <= HT_XY_MAX and HT_S_MIN <= body.s <= HT_S_MAX):
raise ApiError(
422,
"invalid_heat_table",
f"Keep the heat table on the sheet: x and y from 0 to {HT_XY_MAX}, size from {HT_S_MIN} to {HT_S_MAX}.",
)
pp = _pp(d, p) pp = _pp(d, p)
conn = d.conn conn = d.conn
with packet_write( with packet_write(
d, p, packet_id, A.EDIT_HEAT_TABLE, request=request, it_reason=body.it_reason, policy_principal=pp d, p, packet_id, A.EDIT_HEAT_TABLE, request=request, it_reason=body.it_reason, policy_principal=pp
) as w: ) as w:
pg = _drawing_page(conn, packet_id, page)
if pg["void_at"] is not None:
raise ApiError(422, "void_page", MSG_VOID)
if body.carried_from is not None and _page_row(conn, packet_id, body.carried_from) is None:
raise ApiError(422, "invalid_page", f"There is no sheet {body.carried_from} on this packet.")
cur = packets.one( cur = packets.one(
conn, "SELECT x, y, s FROM heat_table_placements WHERE packet_id = %s AND page = %s", (packet_id, page) conn,
"SELECT x, y, s, carried_from FROM heat_table_placements WHERE packet_id = %s AND page = %s",
(packet_id, page),
) )
if cur is not None and (cur["x"], cur["y"], cur["s"]) == (body.x, body.y, body.s): if cur is not None and (cur["x"], cur["y"], cur["s"]) == (body.x, body.y, body.s):
w.skip() w.skip()
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
carried = body.carried_from if "carried_from" in body.model_fields_set else (cur or {}).get("carried_from")
conn.execute( conn.execute(
"INSERT INTO heat_table_placements (packet_id, page, x, y, s, by) VALUES (%s, %s, %s, %s, %s, %s)" "INSERT INTO heat_table_placements (packet_id, page, x, y, s, carried_from, by)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)"
" ON CONFLICT (packet_id, page) DO UPDATE SET x = EXCLUDED.x, y = EXCLUDED.y, s = EXCLUDED.s," " ON CONFLICT (packet_id, page) DO UPDATE SET x = EXCLUDED.x, y = EXCLUDED.y, s = EXCLUDED.s,"
" by = EXCLUDED.by, at = now()", " carried_from = EXCLUDED.carried_from, by = EXCLUDED.by, at = now()",
(packet_id, page, body.x, body.y, body.s, w.user_id), (packet_id, page, body.x, body.y, body.s, carried, w.user_id),
) )
w.changed("heat_table") w.changed("heat_table")
w.detail["page"] = page w.detail["page"] = page
w.log(f"Heat table placed on page {page}") where = _page_text(pg, page)
if cur is None:
w.log("Heat table placed on drawing", where)
elif (cur["x"], cur["y"]) != (body.x, body.y):
w.log("Heat table moved", where)
else:
w.log("Heat table resized", f"{'A+' if body.s > cur['s'] else 'A-'} {_num_text(body.s)}")
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
@@ -1062,14 +1234,15 @@ def remove_heat_table(
with packet_write( with packet_write(
d, p, packet_id, A.EDIT_HEAT_TABLE, request=request, it_reason=body.it_reason, policy_principal=pp d, p, packet_id, A.EDIT_HEAT_TABLE, request=request, it_reason=body.it_reason, policy_principal=pp
) as w: ) as w:
_not_void(conn, packet_id, page)
gone = conn.execute( gone = conn.execute(
"DELETE FROM heat_table_placements WHERE packet_id = %s AND page = %s RETURNING id", (packet_id, page) "DELETE FROM heat_table_placements WHERE packet_id = %s AND page = %s RETURNING id", (packet_id, page)
).fetchone() ).fetchone()
if gone is None: if gone is None:
raise ApiError(404, "not_found", f"There is no heat table on page {page}.") raise ApiError(404, "not_found", f"There is no heat table on sheet {page}.")
w.changed("heat_table") w.changed("heat_table")
w.detail["page"] = page w.detail["page"] = page
w.log(f"Heat table removed from page {page}") w.log("Heat table removed from drawing", _page_text(_page_row(conn, packet_id, page), page))
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
@@ -1084,16 +1257,33 @@ def patch_stage(
p: Principal = Depends(current_principal), # noqa: B008 p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008 d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Filler, filler lot, preheat and notes of the open stage. If-Match is the stage's row_version.""" """Filler, filler lot, preheat and notes of the open stage (the weld record).
Either the flat body with If-Match (the stage's row_version), or `{set, base}`: field-level compare-and-set,
409 field_conflict with `current` for a field someone else changed since `base`.
"""
cas = body.set is not None
if cas:
assert body.set is not None
bad = sorted(k for k in body.set if k not in STAGE_FIELDS)
if bad:
raise ApiError(422, "invalid_field", f"Not a weld record field: {', '.join(bad)}.")
values = {k: clean_opt(v, STAGE_FIELDS[k]) for k, v in body.set.items()}
base = {k: clean_opt(v, STAGE_FIELDS[k]) for k, v in (body.base or {}).items() if k in STAGE_FIELDS}
if_match = None
else:
values = {k: clean_opt(getattr(body, k), n) for k, n in STAGE_FIELDS.items() if k in body.model_fields_set}
base = {}
if_match = parse_if_match(request) if_match = parse_if_match(request)
limits = {"filler": FILLER_MAX, "filler_lot": FILLER_MAX, "preheat": FILLER_MAX, "notes": LIMITS["notes"]}
values = {k: clean_opt(getattr(body, k), n) for k, n in limits.items() if k in body.model_fields_set}
pp = _pp(d, p) pp = _pp(d, p)
conn = d.conn conn = d.conn
with packet_write( with packet_write(
d, p, packet_id, A.EDIT_STAGE_FIELDS, request=request, it_reason=body.it_reason, policy_principal=pp d, p, packet_id, A.EDIT_STAGE_FIELDS, request=request, it_reason=body.it_reason, policy_principal=pp
) as w: ) as w:
stage = _need_stage(conn, packet_id) stage = _need_stage(conn, packet_id)
if cas:
changes = apply_field_cas({k: stage[k] for k in STAGE_FIELDS}, values, base)
else:
check_row_version(stage, if_match) check_row_version(stage, if_match)
changes = _diff(stage, values) changes = _diff(stage, values)
if not changes: if not changes:
@@ -1101,10 +1291,24 @@ def patch_stage(
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
update_row(conn, "packet_stages", stage["id"], changes) update_row(conn, "packet_stages", stage["id"], changes)
w.changed(*[f"stage.{k}" for k in changes]) w.changed(*[f"stage.{k}" for k in changes])
w.log("Stage details changed", f"filler {changes['filler']}" if changes.get("filler") else None) w.log("Weld record changed", _stage_note(changes))
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
def _stage_note(changes: dict[str, Any]) -> str | None:
"""The changed weld record fields: filler ER70S-6 · filler lot 1234."""
parts: list[str] = []
for k, name in STAGE_NOTE_NAMES.items():
if k not in changes:
continue
v = changes[k]
if k == "notes":
parts.append("notes changed" if v else "notes cleared")
else:
parts.append(f"{name} {v}" if v else f"{name} cleared")
return " · ".join(parts) or None
@router.put("/{packet_id}/stage/checklist/{key}", dependencies=WRITE) @router.put("/{packet_id}/stage/checklist/{key}", dependencies=WRITE)
def put_checklist( def put_checklist(
packet_id: int, packet_id: int,
@@ -1121,7 +1325,8 @@ def put_checklist(
d, p, packet_id, A.EDIT_CHECKLIST, request=request, it_reason=body.it_reason, policy_principal=pp d, p, packet_id, A.EDIT_CHECKLIST, request=request, it_reason=body.it_reason, policy_principal=pp
) as w: ) as w:
weld = bool(w.state.weld_dept) weld = bool(w.state.weld_dept)
if key not in checklists.checklist_keys(weld): templates = w.children.templates if w.children is not None else None
if key not in checklists.checklist_keys(weld, templates):
raise ApiError(422, "invalid_key", "That is not an item on this department's checklist.") raise ApiError(422, "invalid_key", "That is not an item on this department's checklist.")
stage = _need_stage(conn, packet_id) stage = _need_stage(conn, packet_id)
cur = (stage["checklist"] or {}).get(key) cur = (stage["checklist"] or {}).get(key)
@@ -1140,7 +1345,8 @@ def put_checklist(
"UPDATE packet_stages SET checklist = jsonb_set(checklist, ARRAY[%s]::text[], %s) WHERE id = %s", "UPDATE packet_stages SET checklist = jsonb_set(checklist, ARRAY[%s]::text[], %s) WHERE id = %s",
(key, Jsonb({"done": False}), stage["id"]), (key, Jsonb({"done": False}), stage["id"]),
) )
text = next((i["text"] for i in checklists.checklist_for(weld, None, None, None) if i["key"] == key), key) items = checklists.checklist_for(weld, w.packet["drawing"], w.packet["rev"], w.packet["wps"], templates)
text = next((i["text"] for i in items if i["key"] == key), key)
w.changed(f"checklist.{key}") w.changed(f"checklist.{key}")
w.log(("Checked: " if body.done else "Unchecked: ") + text) w.log(("Checked: " if body.done else "Unchecked: ") + text)
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
@@ -1453,7 +1659,7 @@ def issue_co(
_co_log_detail(w, no) _co_log_detail(w, no)
w.detail["required"] = required w.detail["required"] = required
verify = f"verify: {', '.join(required)}" if required else "no departments to verify" verify = f"verify: {', '.join(required)}" if required else "no departments to verify"
w.log(f"Change order {no} issued", " · ".join(x for x in (reason, ecn, verify) if x)) w.log(f"Change order {_co_no_text(no)} issued", " · ".join(x for x in (reason, ecn, verify) if x))
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
@@ -1605,6 +1811,7 @@ def _apply_feed_co(w: Write, co: dict[str, Any]) -> str | None:
first, count, old = pv.replace_drawing_pages( first, count, old = pv.replace_drawing_pages(
conn, pid, asset, dwg=dwg, rev=rev, co_id=int(co["id"]), void_by=w.user_id conn, pid, asset, dwg=dwg, rev=rev, co_id=int(co["id"]), void_by=w.user_id
) )
_carry_heat_tables(w, old, first, count)
for i in range(max(len(old), count)): for i in range(max(len(old), count)):
o = old[i] if i < len(old) else None o = old[i] if i < len(old) else None
new = i < count new = i < count
@@ -1642,6 +1849,26 @@ def _apply_feed_co(w: Write, co: dict[str, Any]) -> str | None:
return "; ".join(notes) or None return "; ".join(notes) or None
def _carry_heat_tables(w: Write, old: list[dict[str, Any]], first: int, count: int) -> None:
"""Copy each heat table placement from a voided sheet to the sheet that replaces it (the old htCarry), with
carried_from set to the old sheet. Welds and heat tags stay on the VOID sheet."""
for i, o in enumerate(old):
new_n = first + i if i < count else first
cur = packets.one(
w.conn, "SELECT x, y, s FROM heat_table_placements WHERE packet_id = %s AND page = %s", (w.id, o["n"])
)
if cur is None:
continue
done = w.conn.execute(
"INSERT INTO heat_table_placements (packet_id, page, x, y, s, carried_from, by)"
" VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (packet_id, page) DO NOTHING RETURNING id",
(w.id, new_n, cur["x"], cur["y"], cur["s"], o["n"], w.user_id),
).fetchone()
if done is not None:
w.changed("heat_table")
w.log_also("Heat table copied to new sheet", "from the VOID copy, check its position on the new revision")
@router.post("/{packet_id}/change-orders/{no}/approve", dependencies=WRITE) @router.post("/{packet_id}/change-orders/{no}/approve", dependencies=WRITE)
def approve_co( def approve_co(
packet_id: int, packet_id: int,
@@ -1699,7 +1926,7 @@ def approve_co(
for code in co_touched(conn, w.packet): for code in co_touched(conn, w.packet):
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, %s)", (co["id"], code)) conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, %s)", (co["id"], code))
_co_log_detail(w, no) _co_log_detail(w, no)
w.log(f"Change order {no} approved", note) w.log(f"Change order {_co_no_text(no)} approved", note)
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
@@ -1743,7 +1970,7 @@ def reject_co(
}, },
) )
_co_log_detail(w, no) _co_log_detail(w, no)
w.log(f"Change order {no} rejected", reason) w.log(f"Change order {_co_no_text(no)} rejected", reason)
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
@@ -1775,7 +2002,7 @@ def hold_co(
{"hold": body.hold, "held_by": w.user_id if body.hold else None, "held_at": now if body.hold else None}, {"hold": body.hold, "held_by": w.user_id if body.hold else None, "held_at": now if body.hold else None},
) )
_co_log_detail(w, no) _co_log_detail(w, no)
w.log(f"Change order {no} {'holds the packet' if body.hold else 'no longer holds the packet'}") w.log(f"Change order {_co_no_text(no)} {'holds the packet' if body.hold else 'no longer holds the packet'}")
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
@@ -1811,12 +2038,12 @@ def verify_co(
_co_state(co, "approved", "verified") _co_state(co, "approved", "verified")
required = {r[0] for r in conn.execute("SELECT dept_code FROM co_required_depts WHERE co_id = %s", (co["id"],))} required = {r[0] for r in conn.execute("SELECT dept_code FROM co_required_depts WHERE co_id = %s", (co["id"],))}
if dept not in required: if dept not in required:
raise ApiError(422, "invalid_dept", f"Change order {no} does not need verification by {dept}.") raise ApiError(422, "invalid_dept", f"Change order {_co_no_text(no)} does not need verification by {dept}.")
done = conn.execute( done = conn.execute(
"SELECT 1 FROM co_verifications WHERE co_id = %s AND dept_code = %s", (co["id"], dept) "SELECT 1 FROM co_verifications WHERE co_id = %s AND dept_code = %s", (co["id"], dept)
).fetchone() ).fetchone()
if done is not None: if done is not None:
raise ApiError(409, "already_verified", f"{dept} already verified change order {no}.") raise ApiError(409, "already_verified", f"{dept} already verified change order {_co_no_text(no)}.")
try: try:
conn.execute( conn.execute(
"INSERT INTO co_verifications (co_id, dept_code, by, note, signature_id, it_reason)" "INSERT INTO co_verifications (co_id, dept_code, by, note, signature_id, it_reason)"
@@ -1824,9 +2051,17 @@ def verify_co(
(co["id"], dept, w.user_id, note, w.signature.id if w.signature else None, w.it_reason), (co["id"], dept, w.user_id, note, w.signature.id if w.signature else None, w.it_reason),
) )
except psycopg.errors.UniqueViolation: except psycopg.errors.UniqueViolation:
raise ApiError(409, "already_verified", f"{dept} already verified change order {no}.") from None raise ApiError(
409, "already_verified", f"{dept} already verified change order {_co_no_text(no)}."
) from None
_co_log_detail(w, no) _co_log_detail(w, no)
w.detail["dept"] = dept w.detail["dept"] = dept
name = packets.dept_names(conn).get(dept, dept) names = packets.dept_names(conn)
w.log(f"Change order {no} verified for {name}", note) verified = {r[0] for r in conn.execute("SELECT dept_code FROM co_verifications WHERE co_id = %s", (co["id"],))}
left = [names.get(c, c) for c in sorted(required) if c not in verified]
tail = f"still waiting on {', '.join(left)}" if left else "change order fully verified"
w.log(
f"Change order {_co_no_text(no)} verified by {names.get(dept, dept)}",
" · ".join(x for x in (note, tail) if x),
)
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
+15 -4
View File
@@ -504,7 +504,10 @@ def merge_visual_bom(conn: Any, packet_id: int, raw_lines: list[Any]) -> dict[st
When nothing has been recorded on the lines (no pulled mark, heat, MTR or short) the BOM is replaced through When nothing has been recorded on the lines (no pulled mark, heat, MTR or short) the BOM is replaced through
feed.materials.insert_bom, exactly as the feed writes it. Otherwise lines are matched on vkey and updated in feed.materials.insert_bom, exactly as the feed writes it. Otherwise lines are matched on vkey and updated in
place, so pulled marks, heats and shorts stay. New lines are added with the same encoding (item numbers from place, so pulled marks, heats and shorts stay. A line with no vkey match falls back to the same VISUAL item label
(cl_item) and part id, as the old coKeepShop did. On a matched line the recorded work wins: heat, N/A, issued,
location, pulled, pulled_at, pulled_by, the MTR and the short are kept (heat, pulled and the MTR are never
written here). New lines are added with the same encoding (item numbers from
feed.materials.db_items, the label in cl_item, cons Y/N, sub Y or NULL). Lines VISUAL dropped are deleted, or feed.materials.db_items, the label in cl_item, cons Y/N, sub Y or NULL). Lines VISUAL dropped are deleted, or
marked n/a when they were pulled or have a short. marked n/a when they were pulled or have a short.
""" """
@@ -525,6 +528,10 @@ def merge_visual_bom(conn: Any, packet_id: int, raw_lines: list[Any]) -> dict[st
counts["removed"] = len(existing) counts["removed"] = len(existing)
return counts return counts
by_vkey = {e["vkey"]: e for e in existing if e["vkey"]} by_vkey = {e["vkey"]: e for e in existing if e["vkey"]}
by_label: dict[tuple[str, str], dict[str, Any]] = {}
for e in existing:
if e["cl_item"] is not None:
by_label.setdefault((str(e["cl_item"]), str(e["part_id"] or "")), e)
used = {int(e["item"]) for e in existing} used = {int(e["item"]) for e in existing}
wanted = db_items(lines) wanted = db_items(lines)
kept: set[int] = set() kept: set[int] = set()
@@ -544,13 +551,17 @@ def merge_visual_bom(conn: Any, packet_id: int, raw_lines: list[Any]) -> dict[st
sort, sort,
) )
match = by_vkey.get(b.vkey) if b.vkey else None match = by_vkey.get(b.vkey) if b.vkey else None
if match is None or int(match["id"]) in kept:
match = by_label.get((str(b.item), b.part_id[:60]))
if match is not None and int(match["id"]) not in kept: if match is not None and int(match["id"]) not in kept:
kept.add(int(match["id"])) kept.add(int(match["id"]))
conn.execute( conn.execute(
"UPDATE bom_lines SET qty = %s, part_id = %s, description = %s, loc = %s, whs = %s, comm = %s," "UPDATE bom_lines SET qty = %s, part_id = %s, description = %s,"
" cons = %s, sub = %s, na = %s, grp = %s, issued = coalesce(%s, issued), sort = %s, cl_item = %s," " loc = coalesce(nullif(loc, ''), %s), whs = %s, comm = %s, cons = coalesce(cons, %s), sub = %s,"
" na = CASE WHEN pulled OR heat IS NOT NULL OR na THEN na ELSE %s END, grp = %s,"
" issued = coalesce(issued, %s), sort = %s, cl_item = %s, vkey = coalesce(%s, vkey),"
" row_version = row_version + 1 WHERE id = %s", " row_version = row_version + 1 WHERE id = %s",
(*values, b.item, match["id"]), (*values, b.item, b.vkey or None, match["id"]),
) )
counts["updated"] += 1 counts["updated"] += 1
continue continue
+12 -2
View File
@@ -17,7 +17,7 @@ from pydantic import BaseModel, Field
from .. import audit, events, policy from .. import audit, events, policy
from ..domain import assets as asset_store from ..domain import assets as asset_store
from ..domain import packets from ..domain import packets, shop
from ..domain.pipeline import ( from ..domain.pipeline import (
Expect, Expect,
SignBody, SignBody,
@@ -28,7 +28,7 @@ from ..domain.pipeline import (
require_if_match, require_if_match,
verify_seal, verify_seal,
) )
from ..text import LIMITS, MAX_BODY_BYTES, clean_opt, clean_text, reason_ok from ..text import LIMITS, MAX_BODY_BYTES, RX_STAMP, clean_opt, clean_text, reason_ok
from .deps import Db, Principal, current_principal, get_db, settings_of from .deps import Db, Principal, current_principal, get_db, settings_of
from .errors import ApiError from .errors import ApiError
@@ -534,6 +534,15 @@ def transition(
extra["dept"] = shop_dept(conn, extra["dept"]) extra["dept"] = shop_dept(conn, extra["dept"])
if "for_sup_id" in extra: if "for_sup_id" in extra:
extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor") extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor")
if "stamp" in extra:
# The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs.
stamp = clean_text(extra["stamp"], 40)
if stamp and not RX_STAMP.match(stamp):
raise ApiError(422, "invalid_stamp", "A stamp is up to 10 letters, digits or dashes.")
if stamp:
extra["stamp"] = stamp
else:
del extra["stamp"]
content = {"action": str(action), "packet_id": packet_id, "expect": body.expect.model_dump(), **extra} content = {"action": str(action), "packet_id": packet_id, "expect": body.expect.model_dump(), **extra}
with packet_write( with packet_write(
d, d,
@@ -556,6 +565,7 @@ def transition(
if ok is None: if ok is None:
raise ApiError(422, "invalid_station", "Choose a station in this department.") raise ApiError(422, "invalid_station", "Choose a station in this department.")
active_user(conn, extra["welder_id"], "welder") active_user(conn, extra["welder_id"], "welder")
shop.welder_check(conn, w.state.dept, str(extra["welder_id"]), int(extra["station_id"]))
apply_transition(w) apply_transition(w)
return _read(d, pp, packet_id) return _read(d, pp, packet_id)
+49
View File
@@ -0,0 +1,49 @@
"""The shop screens: the supervisor board, the stations a person may work at, and the stall board.
Any signed-in session reads them; the board answers 403 for someone who leads no department. They read
PostgreSQL only.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from ..domain import packets, shop
from ..text import clean_text
from .deps import Db, Principal, current_principal, get_db
router = APIRouter(prefix="/api/shop", tags=["shop"])
@router.get("/board")
def board(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
dept: str = Query("", max_length=10),
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
) -> dict[str, Any]:
conn = d.conn
pp = packets.policy_principal_for(conn, p)
return shop.board(conn, pp, dept=clean_text(dept, 10) or None, project_id=project_id, unit_id=unit_id)
@router.get("/stations")
def stations(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> list[dict[str, Any]]:
conn = d.conn
return shop.stations_for(conn, packets.policy_principal_for(conn, p))
@router.get("/stall")
def stall(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
station_id: int = Query(ge=1),
) -> dict[str, Any]:
conn = d.conn
return shop.stall(conn, packets.policy_principal_for(conn, p), station_id)
+99 -28
View File
@@ -1,14 +1,19 @@
"""The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing(). """The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing().
Weld departments get the welding list, the others the plain list. The drawing, rev and WPS are filled into the text. Weld departments get the welding list, the others the plain list. The drawing, rev and WPS are filled into the text.
The lists live in the settings row `checklist_templates` (migration 0006); the defaults here are the fallback.
The 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.
""" """
from __future__ import annotations from __future__ import annotations
import logging
import re
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from typing import Any from typing import Any
log = logging.getLogger(__name__)
NO_REV = "-" NO_REV = "-"
NDE_NONE = ("", "none", "none required") NDE_NONE = ("", "none", "none required")
NDE_OK = ("acceptable", "accept", "accepted") NDE_OK = ("acceptable", "accept", "accepted")
@@ -19,31 +24,95 @@ def _item(group: str, key: str, text: str) -> dict[str, str]:
return {"group": group, "key": key, "text": text} return {"group": group, "key": key, "text": text}
def checklist_for(weld: bool, drawing: str | None, rev: str | None, wps: str | None) -> list[dict[str, str]]: DWG_TEXT = "Drawing {drawing} Rev {rev} matches the packet"
# The defaults. The settings row `checklist_templates` holds the same lists (migration 0006); these are the fallback
# when that row is missing or does not parse.
DEFAULT_TEMPLATES: dict[str, list[dict[str, str]]] = {
"weld": [
_item("Before welding", "dwg", DWG_TEXT),
_item("Before welding", "heat", "Heat numbers on material match the Materials tab"),
_item("Before welding", "wps", "WPS reviewed: {wps}"),
_item("Before welding", "fit", "Fit-up and dimensions checked against the drawing"),
_item("Before welding", "pre", "Preheat and interpass temperature per WPS"),
_item("After welding", "vt", "Visual inspection of all welds acceptable"),
_item("After welding", "stamp", "Welds stamped with welder ID"),
_item("After welding", "clean", "Slag and spatter removed"),
_item("After welding", "qty", "Completed quantity matches each work order"),
],
"plain": [
_item("Before starting", "dwg", DWG_TEXT),
_item("Before starting", "prev", "Previous department's work complete and released"),
_item("Before starting", "mat", "Material and parts on hand match the packet"),
_item("When done", "work", "Work completed per drawing and procedure"),
_item("When done", "insp", "Self-inspection done; no damage to finished surfaces"),
_item("When done", "qty", "Completed quantity matches each work order"),
],
}
SETTINGS_KEY = "checklist_templates"
RX_KEY = re.compile(r"^[a-z][a-z0-9_]{0,29}$")
RX_PLACEHOLDER = re.compile(r"\{(drawing|rev|wps)\}")
TEXT_MAX = 200
ITEMS_MAX = 40
def _valid_list(value: Any) -> list[dict[str, str]] | None:
"""A template list from settings, or None when it does not have the right shape."""
if not isinstance(value, list) or not value or len(value) > ITEMS_MAX:
return None
out: list[dict[str, str]] = []
seen: set[str] = set()
for it in value:
if not isinstance(it, Mapping):
return None
group, key, text = it.get("group"), it.get("key"), it.get("text")
if not (isinstance(group, str) and isinstance(key, str) and isinstance(text, str)):
return None
if not RX_KEY.match(key) or key in seen or not group.strip() or not text.strip():
return None
seen.add(key)
out.append(_item(group.strip()[:TEXT_MAX], key, text.strip()[:TEXT_MAX]))
return out
def parse_templates(value: Any) -> dict[str, list[dict[str, str]]] | None:
"""The `checklist_templates` settings value, checked. None when it is malformed."""
if not isinstance(value, Mapping):
return None
weld, plain = _valid_list(value.get("weld")), _valid_list(value.get("plain"))
if weld is None or plain is None:
return None
return {"weld": weld, "plain": plain}
def load_templates(conn: Any) -> dict[str, list[dict[str, str]]]:
"""The department checklists from settings, else the defaults in code (with a warning when the row is bad)."""
row = conn.execute("SELECT value FROM settings WHERE key = %s", (SETTINGS_KEY,)).fetchone()
if row is None:
return DEFAULT_TEMPLATES
parsed = parse_templates(row[0])
if parsed is None:
log.warning("settings %s does not parse; using the default checklists", SETTINGS_KEY)
return DEFAULT_TEMPLATES
return parsed
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"}
return " ".join(RX_PLACEHOLDER.sub(lambda m: values[m.group(1)], text).split())
def checklist_for(
weld: bool,
drawing: str | None,
rev: str | None,
wps: str | None,
templates: Mapping[str, list[dict[str, str]]] | None = None,
) -> list[dict[str, str]]:
"""The stage checklist for a department, as a flat list of {group, key, text} in display order.""" """The stage checklist for a department, as a flat list of {group, key, text} in display order."""
dwg = f"Drawing {drawing or ''} Rev {rev or NO_REV} matches the packet".replace(" ", " ") t = templates or DEFAULT_TEMPLATES
if not weld: items = t.get("weld" if weld else "plain") or DEFAULT_TEMPLATES["weld" if weld else "plain"]
g1, g2 = "Before starting", "When done" return [_item(i["group"], i["key"], _fill(i["text"], drawing, rev, wps)) for i in items]
return [
_item(g1, "dwg", dwg),
_item(g1, "prev", "Previous department's work complete and released"),
_item(g1, "mat", "Material and parts on hand match the packet"),
_item(g2, "work", "Work completed per drawing and procedure"),
_item(g2, "insp", "Self-inspection done; no damage to finished surfaces"),
_item(g2, "qty", "Completed quantity matches each work order"),
]
g1, g2 = "Before welding", "After welding"
return [
_item(g1, "dwg", dwg),
_item(g1, "heat", "Heat numbers on material match the Materials tab"),
_item(g1, "wps", f"WPS reviewed: {wps or 'per drawing'}"),
_item(g1, "fit", "Fit-up and dimensions checked against the drawing"),
_item(g1, "pre", "Preheat and interpass temperature per WPS"),
_item(g2, "vt", "Visual inspection of all welds acceptable"),
_item(g2, "stamp", "Welds stamped with welder ID"),
_item(g2, "clean", "Slag and spatter removed"),
_item(g2, "qty", "Completed quantity matches each work order"),
]
def qc_items_for( def qc_items_for(
@@ -66,8 +135,8 @@ def qc_items_for(
] ]
def checklist_keys(weld: bool) -> list[str]: def checklist_keys(weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None) -> list[str]:
return [i["key"] for i in checklist_for(weld, None, None, None)] return [i["key"] for i in checklist_for(weld, None, None, None, templates)]
def qc_keys() -> list[str]: def qc_keys() -> list[str]:
@@ -81,10 +150,12 @@ def is_done(value: Any) -> bool:
return isinstance(value, Mapping) and value.get("done") is True return isinstance(value, Mapping) and value.get("done") is True
def checklist_complete(checklist: Mapping[str, Any] | None, weld: bool) -> bool: def checklist_complete(
checklist: Mapping[str, Any] | None, weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None
) -> bool:
"""Every key of the department checklist is done. An empty list counts as complete.""" """Every key of the department checklist is done. An empty list counts as complete."""
ck = checklist or {} ck = checklist or {}
return all(is_done(ck.get(k)) for k in checklist_keys(weld)) return all(is_done(ck.get(k)) for k in checklist_keys(weld, templates))
def _plural(n: int, word: str) -> str: def _plural(n: int, word: str) -> str:
+279 -46
View File
@@ -39,6 +39,10 @@ SEAL_EXCLUDE = (
"qc_items", "qc_items",
"flag", "flag",
"visual_state", "visual_state",
"server_now",
"target_hrs",
"labor",
"heat_src",
) )
@@ -173,14 +177,24 @@ def policy_principal_for(conn: Any, p: deps.Principal) -> policy.Principal:
return policy.principal_from_roles(str(p.user_id), p.roles, station_limits=limits, session_kind=p.kind) return policy.principal_from_roles(str(p.user_id), p.roles, station_limits=limits, session_kind=p.kind)
def co_hold_why(open_cos: Iterable[Mapping[str, Any]], dept_names: Mapping[str, str]) -> str: def co_label(co_no: Any) -> str:
"""The change order id shown everywhere: CO-2."""
return f"CO-{co_no}"
def co_hold_why(open_cos: Iterable[Mapping[str, Any]], labels: Mapping[str, str]) -> str:
"""The CO hold text, one sentence per open change order. `labels` maps a department code to its display name
(dept_labels: "03 Weld Parts", "Warehouse")."""
parts: list[str] = [] parts: list[str] = []
for co in open_cos: for co in open_cos:
label = co_label(co["co_no"])
if str(co.get("review")) == "pending": if str(co.get("review")) == "pending":
parts.append(f"Change order {co['co_no']} is on hold.") parts.append(
f"{label}: Clerical is reviewing a change from VISUAL. Held until Clerical approves or rejects it."
)
else: else:
names = ", ".join(dept_names.get(c, c) for c in co.get("pending_depts") or []) names = ", ".join(labels.get(c, c) for c in co.get("pending_depts") or [])
parts.append(f"Change order {co['co_no']} is waiting for verification by {names}.") parts.append(f"Change order {label} must be verified first by: {names}.")
return " ".join(parts) return " ".join(parts)
@@ -188,6 +202,17 @@ def dept_names(conn: Any) -> dict[str, str]:
return {r[0]: r[1] for r in conn.execute("SELECT code, name FROM departments")} return {r[0]: r[1] for r in conn.execute("SELECT code, name FROM departments")}
def dept_label(code: str, name: str | None, kind: str | None) -> str:
"""A department in texts: a shop department as code and name ("03 Weld Parts"), a support one by name."""
if kind == "shop":
return f"{code} {name or ''}".strip()
return name or code
def dept_labels(conn: Any) -> dict[str, str]:
return {r[0]: dept_label(r[0], r[1], r[2]) for r in conn.execute("SELECT code, name, kind::text FROM departments")}
# ---------------------------------------------------------------- the child rows # ---------------------------------------------------------------- the child rows
@@ -215,6 +240,8 @@ class Children:
docs: list[dict[str, Any]] = field(default_factory=list) docs: list[dict[str, Any]] = field(default_factory=list)
seal: dict[str, Any] | None = None seal: dict[str, Any] | None = None
dept_names: dict[str, str] = field(default_factory=dict) dept_names: dict[str, str] = field(default_factory=dict)
dept_labels: dict[str, str] = field(default_factory=dict)
templates: dict[str, list[dict[str, str]]] = field(default_factory=dict)
@property @property
def open_stage(self) -> dict[str, Any] | None: def open_stage(self) -> dict[str, Any] | None:
@@ -233,7 +260,10 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
one(conn, "SELECT id, name FROM stations WHERE id = %s", (row["station_id"],)) if row["station_id"] else None one(conn, "SELECT id, name FROM stations WHERE id = %s", (row["station_id"],)) if row["station_id"] else None
) )
ch = Children(dept=dept, station=station) ch = Children(dept=dept, station=station)
ch.dept_names = dept_names(conn) for code, name, kind in conn.execute("SELECT code, name, kind::text FROM departments"):
ch.dept_names[code] = name
ch.dept_labels[code] = dept_label(code, name, kind)
ch.templates = checklists.load_templates(conn)
ch.pages = rows(conn, "SELECT * FROM packet_pages WHERE packet_id = %s ORDER BY n", (pid,)) ch.pages = rows(conn, "SELECT * FROM packet_pages WHERE packet_id = %s ORDER BY n", (pid,))
ch.bom = rows(conn, "SELECT * FROM bom_lines WHERE packet_id = %s ORDER BY item", (pid,)) ch.bom = rows(conn, "SELECT * FROM bom_lines WHERE packet_id = %s ORDER BY item", (pid,))
shorts = rows(conn, "SELECT * FROM bom_shorts WHERE packet_id = %s AND closed_at IS NULL", (pid,)) shorts = rows(conn, "SELECT * FROM bom_shorts WHERE packet_id = %s AND closed_at IS NULL", (pid,))
@@ -305,6 +335,13 @@ def needs_heat(line: Mapping[str, Any]) -> bool:
return False return False
def dept_welds(row: Mapping[str, Any], ch: Children) -> int:
"""The live welds of the packet's current department. A weld row without a department (written outside the API)
counts for the current one. Welds an earlier department logged are carried, not counted (the old missing())."""
dept = str(row["dept_code"])
return sum(1 for w in ch.welds if w.get("dept_code") in (None, dept))
def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState: def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
weld = bool(ch.dept.get("weld")) weld = bool(ch.dept.get("weld"))
live_bom = [b for b in ch.bom if not b["na"]] live_bom = [b for b in ch.bom if not b["na"]]
@@ -322,15 +359,16 @@ def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
source_kind=str(row["source_kind"]), source_kind=str(row["source_kind"]),
weld_dept=weld, weld_dept=weld,
co_hold=bool(ch.open_cos), co_hold=bool(ch.open_cos),
co_hold_why=co_hold_why(ch.open_cos, ch.dept_names), co_hold_why=co_hold_why(ch.open_cos, ch.dept_labels),
open_shorts=len(ch.shorts), open_shorts=len(ch.shorts),
drawing_attached=any(pg["type"] == "drawing" and pg["void_at"] is None for pg in ch.pages), drawing_attached=any(pg["type"] == "drawing" and pg["void_at"] is None for pg in ch.pages),
heat_map=row["heat_map"], heat_map=row["heat_map"],
bom_total=len(live_bom), bom_total=len(live_bom),
bom_ready=sum(1 for b in live_bom if b["pulled"] or b["id"] in ch.shorts), bom_ready=sum(1 for b in live_bom if b["pulled"] or b["id"] in ch.shorts),
bom_missing_heat=sum(1 for b in live_bom if needs_heat(b)), bom_missing_heat=sum(1 for b in live_bom if needs_heat(b)),
welds=len(ch.welds), welds=dept_welds(row, ch),
checklist_complete=stage is not None and checklists.checklist_complete(stage["checklist"], weld), checklist_complete=stage is not None
and checklists.checklist_complete(stage["checklist"], weld, ch.templates or None),
filler_set=stage is not None and bool((stage["filler"] or "").strip()), filler_set=stage is not None and bool((stage["filler"] or "").strip()),
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds)), qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds)),
has_qc_sent=ch.qc is not None, has_qc_sent=ch.qc is not None,
@@ -369,6 +407,19 @@ class People:
return {"id": str(uid)} return {"id": str(uid)}
return {"id": str(uid), "display_name": self.names.get(str(uid))} return {"id": str(uid), "display_name": self.names.get(str(uid))}
def name(self, uid: Any) -> str | None:
return None if uid is None else self.names.get(str(uid))
def as_uuid(value: Any) -> uuid.UUID | None:
"""A uuid from stored text (a checklist entry's `by`), or None when it is not one."""
if value is None:
return None
try:
return uuid.UUID(str(value))
except ValueError:
return None
def _station_ref(conn: Any, station_id: Any, cache: dict[Any, dict[str, Any] | None]) -> dict[str, Any] | None: def _station_ref(conn: Any, station_id: Any, cache: dict[Any, dict[str, Any] | None]) -> dict[str, Any] | None:
if station_id is None: if station_id is None:
@@ -378,7 +429,40 @@ def _station_ref(conn: Any, station_id: Any, cache: dict[Any, dict[str, Any] | N
return cache[station_id] return cache[station_id]
def _stage(conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any], *, closed: bool) -> dict[str, Any]: def clock_of(s: Mapping[str, Any], now: Any) -> dict[str, Any]:
"""A stage clock. elapsed_ms is live: the stored time plus the running span up to the database now. Only the
server writes the clock columns; the client adds the time since its fetch for the ticking display only."""
stored = int(s["clock_elapsed_ms"] or 0)
since = s["clock_running_since"]
running = since is not None and s.get("closed_at") is None
live = stored
if running and now is not None:
live += max(0, int((now - since).total_seconds() * 1000))
return {
"elapsed_ms": live,
"stored_ms": stored,
"running": running,
"running_since": since,
"started_at": s["clock_started_at"],
}
def _checklist(value: Any, people: People) -> dict[str, Any]:
out: dict[str, Any] = {}
for k, v in (value or {}).items():
if isinstance(v, Mapping):
entry = dict(v)
if entry.get("by") is not None:
entry["by_name"] = people.name(as_uuid(entry["by"]))
out[k] = entry
else:
out[k] = v
return out
def _stage(
conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any], *, closed: bool, now: Any = None
) -> dict[str, Any]:
welder = people.ref(s["welder_id"]) welder = people.ref(s["welder_id"])
if welder is not None: if welder is not None:
welder["stamp"] = s["welder_stamp"] welder["stamp"] = s["welder_stamp"]
@@ -387,16 +471,12 @@ def _stage(conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any
"dept_code": s["dept_code"], "dept_code": s["dept_code"],
"station": _station_ref(conn, s["station_id"], cache), "station": _station_ref(conn, s["station_id"], cache),
"welder": welder, "welder": welder,
"checklist": s["checklist"] or {}, "checklist": _checklist(s["checklist"], people),
"filler": s["filler"], "filler": s["filler"],
"filler_lot": s["filler_lot"], "filler_lot": s["filler_lot"],
"preheat": s["preheat"], "preheat": s["preheat"],
"notes": s["notes"], "notes": s["notes"],
"clock": { "clock": clock_of(s, now),
"elapsed_ms": int(s["clock_elapsed_ms"] or 0),
"running_since": s["clock_running_since"],
"started_at": s["clock_started_at"],
},
"opened_at": s["opened_at"], "opened_at": s["opened_at"],
"row_version": s["row_version"], "row_version": s["row_version"],
} }
@@ -436,8 +516,13 @@ def _qc(q: Mapping[str, Any], people: People) -> dict[str, Any]:
def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]: def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]:
ids: list[Any] = [row["welder_id"], row["for_sup_id"], row["vclosed_by"], row["removed_by"]] ids: list[Any] = [row["welder_id"], row["for_sup_id"], row["vclosed_by"], row["removed_by"]]
ids += [w["welder_id"] for w in ch.welds] ids += [w["welder_id"] for w in ch.welds] + [w["by"] for w in ch.welds]
ids += [s["welder_id"] for s in ch.stages] ids += [s["welder_id"] for s in ch.stages]
for s in ch.stages:
for v in (s["checklist"] or {}).values():
if isinstance(v, Mapping):
ids.append(as_uuid(v.get("by")))
ids += [h["by"] for h in ch.heat_table] + [pg["void_by"] for pg in ch.pages]
if ch.wh: if ch.wh:
ids.append(ch.wh["by"]) ids.append(ch.wh["by"])
if ch.qc: if ch.qc:
@@ -545,6 +630,7 @@ def build(
state = state_from(row, ch) state = state_from(row, ch)
people = People(conn, _who_ids(row, ch), seal=seal) people = People(conn, _who_ids(row, ch), seal=seal)
cache: dict[Any, Any] = {} cache: dict[Any, Any] = {}
now = None if seal else conn.execute("SELECT now()").fetchone()[0]
welder = people.ref(row["welder_id"]) welder = people.ref(row["welder_id"])
if welder is not None: if welder is not None:
welder["stamp"] = row["welder_stamp"] welder["stamp"] = row["welder_stamp"]
@@ -585,6 +671,10 @@ def build(
"open_cos": [c["co_no"] for c in ch.open_cos], "open_cos": [c["co_no"] for c in ch.open_cos],
"open_shorts": len(ch.shorts), "open_shorts": len(ch.shorts),
} }
if not seal:
out["server_now"] = now
out["target_hrs"] = target_hrs(ch.work_orders)
out["labor"] = labor_of(conn, row)
if include_children: if include_children:
out["work_orders"] = [ out["work_orders"] = [
{ {
@@ -608,30 +698,19 @@ def build(
| {k: w.get(k) for k in ("dwg", "rev", "partspecs")} | {k: w.get(k) for k in ("dwg", "rev", "partspecs")}
for w in ch.work_orders for w in ch.work_orders
] ]
out["pages"] = [ out["pages"] = [_page(pg, ch, people) for pg in ch.pages]
{
"n": pg["n"],
"type": pg["type"],
"asset_id": uid_text(pg["asset_id"]),
"pn": pg["pn"],
"dwg": pg["dwg"],
"rev": pg["rev"],
"void": None
if pg["void_at"] is None
else {"co_id": pg["void_co_id"], "at": pg["void_at"], "new_n": pg["void_new_n"]},
"url": f"/_blob/{pg['asset_id']}" if pg["asset_id"] else None,
}
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]
out["welds"] = [_weld(w, people) for w in ch.welds]
out["heat_tags"] = [
{k: t[k] for k in ("tag_no", "page", "item", "fx", "fy", "row_version")} for t in ch.heat_tags
]
out["heat_table"] = [{k: h[k] for k in ("page", "x", "y", "s")} for h in ch.heat_table]
st = ch.open_stage st = ch.open_stage
out["stage"] = _stage(conn, st, people, cache, closed=False) if st else None out["welds"] = [_weld(w, people, st) for w in ch.welds]
out["stages"] = [_stage(conn, s, people, cache, closed=True) for s in ch.stages if s["closed_at"] is not None] out["heat_tags"] = [_heat_tag(t, st) for t in ch.heat_tags]
out["heat_table"] = [
{k: h[k] for k in ("page", "x", "y", "s", "carried_from", "at")} | {"by": people.ref(h["by"])}
for h in ch.heat_table
]
out["stage"] = _stage(conn, st, people, cache, closed=False, now=now) if st else None
out["stages"] = [
_stage(conn, s, people, cache, closed=True, now=now) for s in ch.stages if s["closed_at"] is not None
]
out["wh"] = ( out["wh"] = (
None None
if ch.wh is None if ch.wh is None
@@ -666,11 +745,15 @@ def build(
if ch.seal is None if ch.seal is None
else {"sealed_at": ch.seal["sealed_at"], "key_id": ch.seal["key_id"], "broken_at": ch.seal["broken_at"]} else {"sealed_at": ch.seal["sealed_at"], "key_id": ch.seal["key_id"], "broken_at": ch.seal["broken_at"]}
) )
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"]) out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"], ch.templates)
out["qc_items"] = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"]) out["qc_items"] = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"])
out["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"], row["visual"]) out["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"], row["visual"])
if not seal: if not seal:
out["visual_state"] = visual_state(conn, row) out["visual_state"] = visual_state(conn, row)
if include_children:
from . import shop # shop builds on this module
out["heat_src"] = shop.heat_src(conn, row, ch)
if pp is not None: if pp is not None:
allowed, blocked = policy.can(pp, state) allowed, blocked = policy.can(pp, state)
out["can"] = [str(a) for a in allowed] out["can"] = [str(a) for a in allowed]
@@ -682,6 +765,122 @@ def build(
# `sub` joined the BOM lines in phase 4; seals made before that do not cover it. # `sub` joined the BOM lines in phase 4; seals made before that do not cover it.
for b in out.get("bom") or []: for b in out.get("bom") or []:
b.pop("sub", None) b.pop("sub", None)
_seal_strip(out)
return out
# Keys phase 5 added inside the child rows. Seals made before phase 5 do not cover them, and they are derived
# (carried, labels, pending lists) or volatile (the live clock), so the seal leaves them out.
SEAL_PHASE5 = {
"welds": ("bfx", "bfy", "dept_code", "stage_id", "carried", "by"),
"heat_tags": ("bfx", "bfy", "carried", "carried_from"),
"heat_table": ("carried_from", "by", "at"),
"change_orders": ("label", "pending_depts", "open"),
}
SEAL_CLOCK = ("stored_ms", "running")
SEAL_VOID = ("co_no", "by", "new")
def _seal_strip(out: dict[str, Any]) -> None:
for key, names in SEAL_PHASE5.items():
for r in out.get(key) or []:
for n in names:
r.pop(n, None)
for co in out.get("change_orders") or []:
for v in (co.get("verified") or {}).values():
v.pop("signed", None)
for pg in out.get("pages") or []:
if pg.get("void"):
for n in SEAL_VOID:
pg["void"].pop(n, None)
for st in [out.get("stage"), *(out.get("stages") or [])]:
if not st:
continue
for n in SEAL_CLOCK:
st["clock"].pop(n, None)
for v in (st.get("checklist") or {}).values():
if isinstance(v, dict):
v.pop("by_name", None)
def target_hrs(work_orders: Iterable[Mapping[str, Any]]) -> float:
"""The packet's target hours: the sum of its work orders' target hours (0 when none)."""
total = 0.0
for w in work_orders:
try:
total += float(w.get("target_hrs") or 0)
except (TypeError, ValueError):
continue
return round(total, 3)
LABOR_SQL = """
SELECT l.wo_base, l.lot, l.split, l.sub, l.seq, l.hours, l.tickets, l.last_ticket, l.observed_at
FROM packet_work_orders w
JOIN visual_labor l ON l.wo_base = w.wo_base AND l.lot = w.lot AND l.split = w.split AND l.sub = w.sub
AND l.seq = w.seq
WHERE w.packet_id = %s
ORDER BY w.sort, w.id
"""
def labor_of(conn: Any, row: Mapping[str, Any]) -> dict[str, Any] | None:
"""VISUAL's labor ticket hours on the packet's work orders, or None for a hand packet or when nothing matches.
The station timer stays the clock of record; these hours are shown beside it."""
if row["source_kind"] != "visual":
return None
found = rows(conn, LABOR_SQL, (row["id"],))
if not found:
return None
per = [
{
"wo_base": r["wo_base"],
"lot": r["lot"],
"split": r["split"],
"sub": r["sub"],
"seq": r["seq"],
"hours": float(r["hours"] or 0),
"tickets": int(r["tickets"] or 0),
"last_ticket": r["last_ticket"],
}
for r in found
]
return {
"hours": round(sum(x["hours"] for x in per), 3),
"tickets": sum(x["tickets"] for x in per),
"per_wo": per,
"observed_at": max(r["observed_at"] for r in found),
}
def _page(pg: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
void: dict[str, Any] | None = None
if pg["void_at"] is not None:
co_no = next((c["co_no"] for c in ch.change_orders if c["id"] == pg["void_co_id"]), None)
new = next((x for x in ch.pages if pg["void_new_n"] is not None and x["n"] == pg["void_new_n"]), None)
void = {
"co_id": pg["void_co_id"],
"at": pg["void_at"],
"new_n": pg["void_new_n"],
"co_no": co_no,
"by": people.ref(pg["void_by"]),
"new": None if new is None else {"n": new["n"], "dwg": new["dwg"], "rev": new["rev"]},
}
return {
"n": pg["n"],
"type": pg["type"],
"asset_id": uid_text(pg["asset_id"]),
"pn": pg["pn"],
"dwg": pg["dwg"],
"rev": pg["rev"],
"void": void,
"url": f"/_blob/{pg['asset_id']}" if pg["asset_id"] else None,
}
def _heat_tag(t: Mapping[str, Any], open_stage: Mapping[str, Any] | None) -> dict[str, Any]:
out = {k: t[k] for k in ("tag_no", "page", "item", "fx", "fy", "bfx", "bfy", "carried_from", "row_version")}
out["carried"] = open_stage is not None and t["at"] is not None and t["at"] < open_stage["opened_at"]
return out return out
@@ -737,11 +936,33 @@ def _bom(b: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
return out return out
def _weld(w: Mapping[str, Any], people: People) -> dict[str, Any]: WELD_KEYS = (
keys = ("weld_no", "page", "fx", "fy", "wo_base", "piece", "type", "size", "process", "stamp", "at", "row_version") "weld_no",
out = {k: w[k] for k in keys} "page",
"fx",
"fy",
"bfx",
"bfy",
"wo_base",
"piece",
"type",
"size",
"process",
"stamp",
"dept_code",
"stage_id",
"at",
"row_version",
)
def _weld(w: Mapping[str, Any], people: People, open_stage: Mapping[str, Any] | None = None) -> dict[str, Any]:
out = {k: w[k] for k in WELD_KEYS}
out["items"] = list(w["items"] or []) out["items"] = list(w["items"] or [])
out["welder"] = people.ref(w["welder_id"]) out["welder"] = people.ref(w["welder_id"])
out["by"] = people.ref(w["by"])
open_id = open_stage["id"] if open_stage else None
out["carried"] = w["stage_id"] is not None and w["stage_id"] != open_id
return out return out
@@ -773,11 +994,23 @@ def _co(c: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
{k: i[k] for k in ("old_page", "old_dwg", "old_rev", "new_page", "new_dwg", "new_rev", "added")} {k: i[k] for k in ("old_page", "old_dwg", "old_rev", "new_page", "new_dwg", "new_rev", "added")}
for i in ch.co_items.get(c["id"], []) for i in ch.co_items.get(c["id"], [])
] ]
out["required"] = ch.co_required.get(c["id"], []) required = ch.co_required.get(c["id"], [])
out["verified"] = { verified: dict[str, Any] = {
v["dept_code"]: {"by": people.ref(v["by"]), "at": v["at"], "note": v["note"]} v["dept_code"]: {
"by": people.ref(v["by"]),
"at": v["at"],
"note": v["note"],
"signed": v["signature_id"] is not None,
}
for v in ch.co_verified.get(c["id"], []) for v in ch.co_verified.get(c["id"], [])
} }
out["required"] = required
out["verified"] = verified
out["label"] = co_label(c["co_no"])
out["pending_depts"] = [d for d in required if d not in verified]
out["open"] = (c["review"] == "approved" and bool(out["pending_depts"])) or (
c["review"] == "pending" and bool(c["hold"])
)
return out return out
@@ -955,7 +1188,7 @@ def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[s
sql = CARD_SQL + (" WHERE " + " AND ".join(where) if where else "") sql = CARD_SQL + (" WHERE " + " AND ".join(where) if where else "")
sql += " ORDER BY p.updated_at DESC, p.id DESC LIMIT %s" sql += " ORDER BY p.updated_at DESC, p.id DESC LIMIT %s"
params.append(int(filters.get("limit") or 500)) params.append(int(filters.get("limit") or 500))
names = dept_names(conn) names = dept_labels(conn)
transitions = {str(a) for a in policy.TRANSITIONS} transitions = {str(a) for a in policy.TRANSITIONS}
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for r in rows(conn, sql, params): for r in rows(conn, sql, params):
+92 -2
View File
@@ -63,7 +63,7 @@ LABELS: dict[policy.Action, str] = {
A.UNASSIGN: "Taken off the station", A.UNASSIGN: "Taken off the station",
A.PULL_BACK: "Pulled back from the station", A.PULL_BACK: "Pulled back from the station",
A.START: "Work started", A.START: "Work started",
A.SUBMIT: "Submitted to the supervisor", A.SUBMIT: "Submitted to supervisor",
A.RETURN_TO_STATION: "Returned to the station", A.RETURN_TO_STATION: "Returned to the station",
A.SEND_TO_QC: "Sent to Q.C.", A.SEND_TO_QC: "Sent to Q.C.",
A.ROUTE_FROM_REVIEW: "Routed to another department", A.ROUTE_FROM_REVIEW: "Routed to another department",
@@ -127,6 +127,8 @@ class Write:
noop: bool = False noop: bool = False
from_status: str = "" from_status: str = ""
from_dept: str = "" from_dept: str = ""
children: Any = None # packets.Children loaded under the lock (full=False)
more_logs: list[tuple[str, str | None]] = field(default_factory=list)
@property @property
def id(self) -> int: def id(self) -> int:
@@ -146,6 +148,10 @@ class Write:
if note is not None: if note is not None:
self.note = note self.note = note
def log_also(self, action_label: str, note: str | None = None) -> None:
"""One more packet_log line for this write, written after the main one (same user, no signature)."""
self.more_logs.append((action_label, note))
def changed(self, *names: str) -> None: def changed(self, *names: str) -> None:
"""Record the fields this write changed (they go into the audit detail).""" """Record the fields this write changed (they go into the audit detail)."""
for n in names: for n in names:
@@ -331,6 +337,7 @@ def packet_write(
request=request, request=request,
from_status=str(row["status"]), from_status=str(row["status"]),
from_dept=str(row["dept_code"]), from_dept=str(row["dept_code"]),
children=ch,
) )
meaning = decision.signature meaning = decision.signature
needs = bool(meaning) if require_sign is None else require_sign needs = bool(meaning) if require_sign is None else require_sign
@@ -398,6 +405,20 @@ def _finish(w: Write) -> None:
w.transition_id, w.transition_id,
), ),
) )
for extra_label, extra_note in w.more_logs:
conn.execute(
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note, it_reason)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
(
w.id,
w.user_id,
w.principal.user["display_name"],
w.principal.role_snapshot,
extra_label,
clean_opt(extra_note, LIMITS["note"]) if extra_note else None,
w.it_reason,
),
)
detail: dict[str, Any] = {"from": w.from_status, "to": status, "dept": dept} detail: dict[str, Any] = {"from": w.from_status, "to": status, "dept": dept}
if w.from_dept != dept: if w.from_dept != dept:
detail["from_dept"] = w.from_dept detail["from_dept"] = w.from_dept
@@ -563,6 +584,64 @@ def _release_text(w: Write) -> None:
w.log(LABELS[A.WH_RELEASE], note) w.log(LABELS[A.WH_RELEASE], note)
def hours_text(ms: Any) -> str:
"""Stage clock hours with one decimal: 1.5 h."""
return f"{int(ms or 0) / 3600000:.1f} h"
def _station_name(w: Write, station_id: Any) -> str | None:
if station_id is None:
return None
ch = w.children
if ch is not None and ch.station and ch.station.get("id") == station_id:
return str(ch.station["name"])
r = w.conn.execute("SELECT name FROM stations WHERE id = %s", (station_id,)).fetchone()
return str(r[0]) if r else None
def _dept_text(w: Write, code: str) -> str:
labels = getattr(w.children, "dept_labels", None) or packets.dept_labels(w.conn)
return str(labels.get(code, code))
def _shop_texts(w: Write, a: policy.Action, to_dept: str, stamp: str | None = None) -> None:
"""The packet_log label and note of a shop step (the old click handlers' texts)."""
ex = w.extra
here = _station_name(w, w.packet["station_id"]) or "the station"
note = _note(w)
if a is A.ASSIGN:
station = _station_name(w, int(ex["station_id"])) or "a station"
r = w.conn.execute("SELECT display_name FROM users WHERE id = %s", (str(ex["welder_id"]),)).fetchone()
who = str(r[0]) if r else ""
w.log(f"Sent to {station}", f"{who} [{stamp}]" if stamp else who)
elif a is A.UNASSIGN:
w.log(f"Taken off {here}", note)
elif a is A.PULL_BACK:
w.log(f"Pulled back from {here}", note)
elif a is A.START:
w.log(LABELS[A.START], " · ".join(x for x in (here if w.packet["station_id"] else None, note) if x) or None)
elif a is A.SUBMIT:
r = w.conn.execute(
"SELECT clock_elapsed_ms FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)
).fetchone()
n = packets.dept_welds(w.packet, w.children) if w.children is not None else 0
text = f"{n} welds · {hours_text(r[0] if r else 0)}"
w.log(LABELS[A.SUBMIT], f"{text} · {note}" if note else text)
elif a is A.RETURN_TO_STATION:
w.log(f"Returned to {here}", _reason(w))
elif a is A.SEND_TO_QC:
loc = clean_opt(ex.get("location"), LIMITS["location"])
w.log(LABELS[A.SEND_TO_QC], " · ".join(x for x in (f"Spool at {loc}" if loc else None, note) if x) or None)
elif a is A.ROUTE_FROM_REVIEW:
text = f"Supervisor, Q.C. skipped · from {_dept_text(w, w.from_dept)}"
w.log(f"Routed to {_dept_text(w, to_dept)}", f"{text} · {note}" if note else text)
SHOP_TEXTS = frozenset(
{A.ASSIGN, A.UNASSIGN, A.PULL_BACK, A.START, A.SUBMIT, A.RETURN_TO_STATION, A.SEND_TO_QC, A.ROUTE_FROM_REVIEW}
)
def apply_transition(w: Write) -> None: def apply_transition(w: Write) -> None:
"""Set status, status_at and dept, write the packet_transitions row and run the action's side effects.""" """Set status, status_at and dept, write the packet_transitions row and run the action's side effects."""
if w.transitioned: if w.transitioned:
@@ -579,6 +658,10 @@ def apply_transition(w: Write) -> None:
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"):
to_dept = str(ex["dept"]) to_dept = str(ex["dept"])
stamp: str | None = None
if a in SHOP_TEXTS and a is not A.ASSIGN and a is not A.SUBMIT:
_shop_texts(w, a, to_dept) # before the side effects clear the station
if a in (A.SEND_TO_WAREHOUSE, A.VIS_AUTO_RELEASE): if a in (A.SEND_TO_WAREHOUSE, A.VIS_AUTO_RELEASE):
_set_packet(w, "wh_skipped = false, wh_sent_at = now(), return_note = NULL") _set_packet(w, "wh_skipped = false, wh_sent_at = now(), return_note = NULL")
_wh_incoming(w) _wh_incoming(w)
@@ -623,14 +706,21 @@ def apply_transition(w: Write) -> None:
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) _open_stage(w, from_dept)
_stage_update(w, "station_id = %s, welder_id = %s, welder_stamp = %s", (station_id, welder_id, stamp)) _stage_update(w, "station_id = %s, welder_id = %s, welder_stamp = %s", (station_id, welder_id, stamp))
_shop_texts(w, a, to_dept, stamp)
elif a in (A.UNASSIGN, A.PULL_BACK): elif a in (A.UNASSIGN, A.PULL_BACK):
_set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL") _set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL")
_stage_update(w, f"{CLOCK_STOP}, station_id = NULL, welder_id = NULL, welder_stamp = NULL") _stage_update(w, f"{CLOCK_STOP}, station_id = NULL, welder_id = NULL, welder_stamp = NULL")
elif a is A.START: elif a is A.START:
_stage_update(w, "clock_running_since = now(), clock_started_at = coalesce(clock_started_at, now())") # A clock that is already running keeps its start; only the server sets it.
_stage_update(
w,
"clock_running_since = coalesce(clock_running_since, now()),"
" clock_started_at = coalesce(clock_started_at, now())",
)
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)
_shop_texts(w, a, to_dept)
elif a is A.RETURN_TO_STATION: elif a is A.RETURN_TO_STATION:
_set_packet(w, "return_note = %s", (reason,)) _set_packet(w, "return_note = %s", (reason,))
elif a is A.SEND_TO_QC: elif a is A.SEND_TO_QC:
+414
View File
@@ -0,0 +1,414 @@
"""The shop: the supervisor board, the stations a person may work at, the stall board, and the borrowed heat map.
Everything here reads PostgreSQL only. The cards are the `list_cards` shape plus the shop fields (welder stamp, the
live stage clock, target hours, open change orders as labels, the return note), so a card reads the same on every
screen.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any
from .. import policy
from ..api.errors import ApiError
from . import packets
LEAD, SUPERVISOR = 2, 3
LEVEL_NAMES = {1: "worker", 2: "lead", 3: "supervisor"}
BOARD_LIMIT = 5000
# The columns of the supervisor board, by packet status. Packets at a station are on the station grid.
COLUMNS = {"ready": "ready", "review": "review", "qc": "qc", "warehouse": "warehouse", "complete": "complete"}
BOARD_STATUSES = ("ready", "assigned", "in_progress", "review", "qc", "warehouse", "complete")
MSG_NO_LEAD = "You do not lead a department."
MSG_NOT_YOURS = "You do not lead that department."
MSG_NO_STATION = "That station is not one of yours."
# ---------------------------------------------------------------- the borrowed heat map
def _has_heat_map(conn: Any, packet_id: int) -> bool:
return bool(
conn.execute(
"SELECT EXISTS (SELECT 1 FROM heat_table_placements WHERE packet_id = %s)"
" OR EXISTS (SELECT 1 FROM heat_tags WHERE packet_id = %s AND removed_at IS NULL)",
(packet_id, packet_id),
).fetchone()[0]
)
SIBLING_SQL = """
SELECT p.id
FROM packets p
WHERE p.unit_id = %(unit)s AND p.id <> %(id)s AND p.removed_at IS NULL
AND upper(regexp_replace(coalesce(p.drawing, ''), '\\s', '', 'g')) = %(dwg)s
AND (EXISTS (SELECT 1 FROM heat_table_placements h WHERE h.packet_id = p.id)
OR EXISTS (SELECT 1 FROM heat_tags t WHERE t.packet_id = p.id AND t.removed_at IS NULL))
ORDER BY (coalesce(p.rev, '') = %(rev)s) DESC, p.created_at, p.id
LIMIT 1
"""
def dwg_key(drawing: Any) -> str:
"""The drawing number compared between packets: white space removed, upper case (the old dwgKey)."""
return "".join(str(drawing or "").split()).upper()
def heat_src(conn: Any, row: Mapping[str, Any], ch: packets.Children) -> dict[str, Any] | None:
"""The heat map this packet borrows from a sibling, or None (the old heatSrc).
A packet borrows when it has no heat map of its own (no heat table, no heat tag), pulls no raw material of its
own (every BOM line is N/A or a sub-assembly, or the BOM is empty) and has a drawing. The sibling is a live
packet in the same unit with the same drawing and a heat map; the same rev first, then the oldest. Read-only.
"""
if ch.heat_table or ch.heat_tags:
return None
if any(not b["na"] and not packets.is_sub(b) for b in ch.bom):
return None
dwg = dwg_key(row["drawing"])
if not dwg:
return None
found = conn.execute(
SIBLING_SQL, {"unit": row["unit_id"], "id": row["id"], "dwg": dwg, "rev": str(row["rev"] or "")}
).fetchone()
if found is None:
return None
sib = packets.load_row(conn, int(found[0]))
if sib is None:
return None
sch = packets.load_children(conn, sib, full=True)
people = packets.People(conn, [h["by"] for h in sch.heat_table] + [pg["void_by"] for pg in sch.pages])
st = sch.open_stage
return {
"packet_id": sib["id"],
"op": sib["op"],
"status": sib["status"],
"drawing": sib["drawing"],
"rev": sib["rev"],
"pages": [packets._page(pg, sch, people) for pg in sch.pages if pg["type"] == "drawing"],
"heat_table": [
{k: h[k] for k in ("page", "x", "y", "s", "carried_from", "at")} | {"by": people.ref(h["by"])}
for h in sch.heat_table
],
"heat_tags": [packets._heat_tag(t, st) for t in sch.heat_tags],
"bom": [{"item": b["item"], "heat": b["heat"], "na": b["na"]} for b in sch.bom],
}
# ---------------------------------------------------------------- shop cards
EXTRAS_SQL = """
SELECT p.id, p.return_note, p.welder_stamp, s.clock_elapsed_ms, s.clock_running_since, s.clock_started_at,
(SELECT coalesce(sum(w.target_hrs), 0) FROM packet_work_orders w WHERE w.packet_id = p.id) AS target_hrs
FROM packets p
LEFT JOIN packet_stages s ON s.packet_id = p.id AND s.closed_at IS NULL
WHERE p.id = ANY(%s)
"""
def add_shop_fields(conn: Any, cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Add the shop fields to list_cards cards, in place: welder_stamp, clock {elapsed_ms, running}, target_hrs,
open_cos [{co_no, label, review}] and return_note."""
if not cards:
return cards
ids = [int(c["id"]) for c in cards]
now = conn.execute("SELECT now()").fetchone()[0]
extras = {int(r["id"]): r for r in packets.rows(conn, EXTRAS_SQL, (ids,))}
cos: dict[int, list[dict[str, Any]]] = {}
for r in packets.rows(
conn,
"SELECT packet_id, co_no, review::text AS review FROM v_open_cos WHERE packet_id = ANY(%s) ORDER BY co_no",
(ids,),
):
cos.setdefault(int(r["packet_id"]), []).append(
{"co_no": r["co_no"], "label": packets.co_label(r["co_no"]), "review": r["review"]}
)
for c in cards:
x = extras.get(int(c["id"])) or {}
if x.get("clock_elapsed_ms") is not None:
clock = packets.clock_of({**x, "closed_at": None}, now)
c["clock"] = {"elapsed_ms": clock["elapsed_ms"], "running": clock["running"]}
else:
c["clock"] = {"elapsed_ms": 0, "running": False}
c["welder_stamp"] = x.get("welder_stamp")
c["target_hrs"] = float(x.get("target_hrs") or 0)
c["return_note"] = x.get("return_note")
c["open_cos"] = cos.get(int(c["id"]), [])
return cards
def _cards(conn: Any, pp: policy.Principal, ids: Iterable[int], **filters: Any) -> list[dict[str, Any]]:
want = list(ids)
if not want:
return []
cards = packets.list_cards(conn, pp, {**filters, "ids": want, "limit": BOARD_LIMIT})
cards.sort(key=lambda c: (c["updated_at"], c["id"]))
return add_shop_fields(conn, cards)
# ---------------------------------------------------------------- scope
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 board_scope(conn: Any, pp: policy.Principal) -> list[dict[str, Any]]:
"""The shop departments the principal leads or supervises (admin: every active shop department), each with
its level name."""
out: list[dict[str, Any]] = []
for d in shop_departments(conn):
level = pp.level(d["code"])
if level >= LEAD:
out.append({**d, "level": LEVEL_NAMES[min(level, SUPERVISOR)]})
elif pp.admin:
out.append({**d, "level": "admin"})
return out
# ---------------------------------------------------------------- the supervisor board
SCOPE_SQL = """
SELECT p.id FROM packets p JOIN units u ON u.id = p.unit_id
WHERE p.removed_at IS NULL AND p.dept_code = ANY(%(depts)s) AND p.status::text = ANY(%(statuses)s) {extra}
ORDER BY p.updated_at, p.id
LIMIT %(limit)s
"""
COS_SQL = """
SELECT c.packet_id, c.co_no, c.review::text AS review, c.hold, c.pending_depts, co.reason, co.ecn, co.issued_at,
p.dept_code, p.op, p.title, pr.number || u.lot AS unit_number
FROM v_open_cos c
JOIN change_orders co ON co.id = c.co_id
JOIN packets p ON p.id = c.packet_id
JOIN units u ON u.id = p.unit_id
JOIN projects pr ON pr.id = u.project_id
WHERE p.removed_at IS NULL AND (p.dept_code = ANY(%(depts)s) OR c.pending_depts && %(depts)s::text[]) {extra}
ORDER BY co.issued_at NULLS LAST, c.packet_id, c.co_no
LIMIT %(limit)s
"""
def _filter_sql(project_id: int | None, unit_id: int | None) -> tuple[str, dict[str, Any]]:
terms: list[str] = []
params: dict[str, Any] = {}
if project_id is not None:
terms.append("u.project_id = %(project_id)s")
params["project_id"] = int(project_id)
if unit_id is not None:
terms.append("p.unit_id = %(unit_id)s")
params["unit_id"] = int(unit_id)
return "".join(f" AND {t}" for t in terms), params
def board(
conn: Any,
pp: policy.Principal,
*,
dept: str | None = None,
project_id: int | None = None,
unit_id: int | None = None,
) -> dict[str, Any]:
"""GET /api/shop/board. 403 forbidden when the principal leads no department, or names one outside the scope."""
scope = board_scope(conn, pp)
if not scope:
raise ApiError(403, "forbidden", MSG_NO_LEAD)
if dept and dept.lower() != "all":
scope = [d for d in scope if d["code"] == dept]
if not scope:
raise ApiError(403, "forbidden", MSG_NOT_YOURS)
codes = [d["code"] for d in scope]
extra, fparams = _filter_sql(project_id, unit_id)
ids = [
int(r[0])
for r in conn.execute(
SCOPE_SQL.format(extra=extra),
{"depts": codes, "statuses": list(BOARD_STATUSES), "limit": BOARD_LIMIT, **fparams},
)
]
cards = _cards(conn, pp, ids)
columns: dict[str, list[dict[str, Any]]] = {k: [] for k in COLUMNS.values()}
at_station: list[dict[str, Any]] = []
for c in cards:
if c["status"] in policy.ACTIVE:
at_station.append(c)
elif c["status"] in COLUMNS:
columns[COLUMNS[c["status"]]].append(c)
stations = packets.rows(
conn,
"SELECT id, name, dept_code FROM stations WHERE active AND dept_code = ANY(%s) ORDER BY dept_code, sort, name",
(codes,),
)
by_station: dict[Any, list[dict[str, Any]]] = {}
for c in at_station:
sid = (c.get("station") or {}).get("id")
by_station.setdefault(sid, []).append(c)
depts = []
for d in scope:
depts.append(
{
"code": d["code"],
"name": d["name"],
"weld": bool(d["weld"]),
"level": d["level"],
"stations": [
{"id": s["id"], "name": s["name"], "packets": by_station.get(s["id"], [])}
for s in stations
if s["dept_code"] == d["code"]
],
}
)
cos = []
for r in packets.rows(conn, COS_SQL.format(extra=extra), {"depts": codes, "limit": BOARD_LIMIT, **fparams}):
pending = list(r["pending_depts"] or [])
cos.append(
{
"packet_id": r["packet_id"],
"unit_number": r["unit_number"],
"op": r["op"],
"title": r["title"],
"dept_code": r["dept_code"],
"co_no": r["co_no"],
"label": packets.co_label(r["co_no"]),
"review": r["review"],
"hold": bool(r["hold"]),
"pending_depts": pending,
"mine": r["review"] == "approved" and any(can_verify(pp, d) for d in pending),
"reason": r["reason"],
"ecn": r["ecn"],
"issued_at": r["issued_at"],
}
)
kpis = {
"ready": len(columns["ready"]),
"at_stations": len(at_station),
"review": len(columns["review"]),
"elapsed_ms": sum(int(c["clock"]["elapsed_ms"]) for c in at_station),
"target_hrs": round(sum(float(c["target_hrs"]) for c in at_station), 3),
}
return {"depts": depts, "kpis": kpis, "cos": cos, "columns": columns}
def can_verify(pp: policy.Principal, dept: str) -> bool:
"""A supervisor of the department (admin too: the verify itself asks for the IT reason)."""
if pp.admin:
return True
if dept in ("WH", "QC"):
return dept in pp.support_sup
return pp.level(dept) >= SUPERVISOR
# ---------------------------------------------------------------- stations and the stall board
def stations_for(conn: Any, pp: policy.Principal) -> list[dict[str, Any]]:
"""The stations the principal may pick on the Stall screen: every station of each shop department they belong
to at any level, honouring a worker's station limits. Admin: every active station."""
out: list[dict[str, Any]] = []
rows = packets.rows(
conn,
"SELECT s.id, s.name, s.dept_code, d.name AS dept_name FROM stations s"
" JOIN departments d ON d.code = s.dept_code WHERE s.active AND d.active AND d.kind = 'shop'"
" ORDER BY d.sort, d.code, s.sort, s.name",
)
for r in rows:
level = pp.level(r["dept_code"])
if not pp.admin:
if level == 0:
continue
if level < LEAD and pp.station_limits and int(r["id"]) not in pp.station_limits:
continue
out.append({"dept_code": r["dept_code"], "dept_name": r["dept_name"], "id": r["id"], "name": r["name"]})
return out
def stall(conn: Any, pp: policy.Principal, station_id: int) -> dict[str, Any]:
"""GET /api/shop/stall: the packets at one station. 403 when the station is not in the principal's list."""
mine_list = {int(s["id"]): s for s in stations_for(conn, pp)}
st = mine_list.get(int(station_id))
if st is None:
raise ApiError(403, "forbidden", MSG_NO_STATION)
found = packets.rows(
conn,
"SELECT id, status::text AS status FROM packets WHERE station_id = %s AND removed_at IS NULL"
" AND status IN ('assigned', 'in_progress', 'review') ORDER BY updated_at, id LIMIT %s",
(int(station_id), BOARD_LIMIT),
)
cards = _cards(conn, pp, [int(r["id"]) for r in found])
return {
"station": {"id": st["id"], "name": st["name"], "dept_code": st["dept_code"], "dept_name": st["dept_name"]},
"mine": [c for c in cards if c["status"] in policy.ACTIVE],
"submitted": [c for c in cards if c["status"] == "review"],
}
# ---------------------------------------------------------------- the assign sheet's people
PEOPLE_SQL = """
SELECT u.id, u.display_name, u.welder_stamp, max(CASE r.role::text WHEN 'shop_supervisor' THEN 3
WHEN 'shop_lead' THEN 2 ELSE 1 END) AS level
FROM users u JOIN user_roles r ON r.user_id = u.id
WHERE u.active AND r.dept_code = %s AND r.role::text IN ('shop_worker', 'shop_lead', 'shop_supervisor')
GROUP BY u.id, u.display_name, u.welder_stamp
ORDER BY lower(u.display_name), u.id
"""
def people(conn: Any, dept: str) -> list[dict[str, Any]]:
"""The active users with a shop role in a department, for the welder picker."""
found = packets.rows(conn, PEOPLE_SQL, (dept,))
limits: dict[str, list[int]] = {}
if found:
for uid, sid in conn.execute(
"SELECT user_id, station_id FROM user_station_limits WHERE user_id = ANY(%s) ORDER BY station_id",
([r["id"] for r in found],),
):
limits.setdefault(str(uid), []).append(int(sid))
return [
{
"id": str(r["id"]),
"display_name": r["display_name"],
"stamp": r["welder_stamp"],
"level": LEVEL_NAMES[int(r["level"])],
"station_limits": limits.get(str(r["id"]), []),
}
for r in found
]
def welder_check(conn: Any, dept: str, welder_id: str, station_id: int) -> None:
"""ASSIGN: the welder has a shop role in the department, and a worker with station limits is limited to a set
that holds the station. 422 invalid_welder or station_limit."""
level_row = conn.execute(
"SELECT max(CASE r.role::text WHEN 'shop_supervisor' THEN 3 WHEN 'shop_lead' THEN 2 ELSE 1 END)"
" FROM users u JOIN user_roles r ON r.user_id = u.id WHERE u.id = %s AND u.active AND r.dept_code = %s"
" AND r.role::text IN ('shop_worker', 'shop_lead', 'shop_supervisor')",
(welder_id, dept),
).fetchone()
level = int(level_row[0]) if level_row and level_row[0] is not None else 0
if level == 0:
raise ApiError(422, "invalid_welder", "Pick a welder in this department.")
if level >= LEAD:
return
limits = packets.rows(
conn,
"SELECT s.id, s.name FROM user_station_limits l JOIN stations s ON s.id = l.station_id"
" WHERE l.user_id = %s ORDER BY s.dept_code, s.sort, s.name",
(welder_id,),
)
if limits and int(station_id) not in {int(x["id"]) for x in limits}:
name = conn.execute("SELECT display_name FROM users WHERE id = %s", (welder_id,)).fetchone()[0]
names = ", ".join(str(x["name"]) for x in limits)
raise ApiError(422, "station_limit", f"{name} is limited to {names}.")
+15 -4
View File
@@ -43,6 +43,7 @@ class Subscriber:
user_id: str user_id: str
display_name: str display_name: str
screen: str | None = None screen: str | None = None
packet_id: int | None = None # the packet the subscriber has open, for "Also here" on the packet page
id: str = field(default_factory=lambda: uuid.uuid4().hex) id: str = field(default_factory=lambda: uuid.uuid4().hex)
closed: bool = False closed: bool = False
@@ -65,7 +66,15 @@ class EventHub:
# ------------------------------------------------ subscribers # ------------------------------------------------ subscribers
def subscribe(self, *, session_id: Any, user_id: Any, display_name: str, screen: str | None = None) -> Subscriber: def subscribe(
self,
*,
session_id: Any,
user_id: Any,
display_name: str,
screen: str | None = None,
packet_id: int | None = None,
) -> Subscriber:
"""Register a subscriber on the running loop and broadcast presence.""" """Register a subscriber on the running loop and broadcast presence."""
sub = Subscriber( sub = Subscriber(
queue=asyncio.Queue(maxsize=QUEUE_MAX), queue=asyncio.Queue(maxsize=QUEUE_MAX),
@@ -74,6 +83,7 @@ class EventHub:
user_id=str(user_id), user_id=str(user_id),
display_name=display_name, display_name=display_name,
screen=screen, screen=screen,
packet_id=packet_id,
) )
with self._lock: with self._lock:
self._subs[sub.id] = sub self._subs[sub.id] = sub
@@ -90,12 +100,13 @@ class EventHub:
def presence(self) -> dict[str, Any]: def presence(self) -> dict[str, Any]:
with self._lock: with self._lock:
subs = list(self._subs.values()) subs = list(self._subs.values())
seen: dict[tuple[str, str | None], dict[str, Any]] = {} seen: dict[tuple[str, str | None, int | None], dict[str, Any]] = {}
for s in subs: for s in subs:
seen.setdefault( seen.setdefault(
(s.user_id, s.screen), {"user_id": s.user_id, "display_name": s.display_name, "screen": s.screen} (s.user_id, s.screen, s.packet_id),
{"user_id": s.user_id, "display_name": s.display_name, "screen": s.screen, "packet_id": s.packet_id},
) )
users = sorted(seen.values(), key=lambda u: (u["display_name"].lower(), u["screen"] or "")) users = sorted(seen.values(), key=lambda u: (u["display_name"].lower(), u["screen"] or "", u["packet_id"] or 0))
return {"users": users} return {"users": users}
def _send(self, sub: Subscriber, item: Item) -> None: def _send(self, sub: Subscriber, item: Item) -> None:
+6 -6
View File
@@ -112,7 +112,7 @@ def open_or_reaim(
" WHERE id = %s", " WHERE id = %s",
(new_reason, _j(new_vis), _j(staged), _j(staged), pend["id"]), (new_reason, _j(new_vis), _j(staged), _j(staged), pend["id"]),
) )
label = f"Change order {pend['co_no']}: drawing from M-Files staged" label = f"Change order CO-{pend['co_no']}: drawing from M-Files staged"
else: else:
new_vis = dict(vis) new_vis = dict(vis)
new_vis["moved"] = [*moved, {"at": _now(), "rev": vis.get("to_rev"), "dwg": vis.get("to_dwg")}] new_vis["moved"] = [*moved, {"at": _now(), "rev": vis.get("to_rev"), "dwg": vis.get("to_dwg")}]
@@ -135,9 +135,9 @@ def open_or_reaim(
), ),
) )
label = ( label = (
f"Change order {pend['co_no']} updated: VISUAL changed again before Clerical approved" f"Change order CO-{pend['co_no']} updated: VISUAL changed again before Clerical approved"
if origin == "visual" if origin == "visual"
else f"Change order {pend['co_no']} updated: new drawing from M-Files" else f"Change order CO-{pend['co_no']} updated: new drawing from M-Files"
) )
sw.log(label, reason) sw.log(label, reason)
sw.changed("change_orders") sw.changed("change_orders")
@@ -151,9 +151,9 @@ def open_or_reaim(
(sw.id, co_no, origin, minor, ECN[origin], reason, _j(vis), _j(waiting), _j(staged), target_fp), (sw.id, co_no, origin, minor, ECN[origin], reason, _j(vis), _j(waiting), _j(staged), target_fp),
).fetchone() ).fetchone()
label = ( label = (
f"Change order {co_no}: change from VISUAL waiting on Clerical approval" f"Change order CO-{co_no}: change from VISUAL waiting on Clerical approval"
if origin == "visual" if origin == "visual"
else f"Change order {co_no}: new drawing from M-Files waiting on Clerical approval" else f"Change order CO-{co_no}: new drawing from M-Files waiting on Clerical approval"
) )
sw.log(label, reason) sw.log(label, reason)
sw.changed("change_orders") sw.changed("change_orders")
@@ -172,7 +172,7 @@ def withdraw(sw: SysWrite, co_id: int, reason: str) -> None:
if r is None: if r is None:
sw.skip() sw.skip()
return return
sw.log(f"Change order {r[0]} withdrawn", reason) sw.log(f"Change order CO-{r[0]} withdrawn", reason)
sw.changed("change_orders") sw.changed("change_orders")
sw.detail.update({"co_no": int(r[0]), "co_id": co_id, "withdrawn": True}) sw.detail.update({"co_no": int(r[0]), "co_id": co_id, "withdrawn": True})
+2
View File
@@ -105,6 +105,7 @@ def create_app(
packet_children, packet_children,
packets, packets,
projects, projects,
shop,
units, units,
warehouse, warehouse,
) )
@@ -125,6 +126,7 @@ def create_app(
jobs, jobs,
feed_settings, feed_settings,
warehouse, warehouse,
shop,
): ):
app.include_router(module.router) app.include_router(module.router)
app.include_router(assets.blob_router) app.include_router(assets.blob_router)
@@ -0,0 +1,39 @@
-- 0006_shop: the shop phase. Label positions for weld and heat tag marks, the page a heat table was copied from
-- on a change order, and the default department checklists as a settings row. Runs as ps_migrate.
-- bfx, bfy: where the label (bubble) of a mark sits, as fractions of the page. fx, fy stay where the arrow points.
-- NULL means the client places the label at the old default offset from the arrow.
ALTER TABLE welds
ADD COLUMN bfx double precision CHECK (bfx >= 0 AND bfx <= 1),
ADD COLUMN bfy double precision CHECK (bfy >= 0 AND bfy <= 1);
ALTER TABLE heat_tags
ADD COLUMN bfx double precision CHECK (bfx >= 0 AND bfx <= 1),
ADD COLUMN bfy double precision CHECK (bfy >= 0 AND bfy <= 1);
-- The page the placement was copied from when a change order replaced the sheet (the old htCarry).
ALTER TABLE heat_table_placements ADD COLUMN carried_from int;
-- The department checklists. {drawing}, {rev} and {wps} are filled in from the packet. The code keeps the same
-- lists as its fallback when this row is missing or does not parse. Admin edits it in phase 9.
INSERT INTO settings (key, value) VALUES ('checklist_templates', '{
"weld": [
{"group": "Before welding", "key": "dwg", "text": "Drawing {drawing} Rev {rev} matches the packet"},
{"group": "Before welding", "key": "heat", "text": "Heat numbers on material match the Materials tab"},
{"group": "Before welding", "key": "wps", "text": "WPS reviewed: {wps}"},
{"group": "Before welding", "key": "fit", "text": "Fit-up and dimensions checked against the drawing"},
{"group": "Before welding", "key": "pre", "text": "Preheat and interpass temperature per WPS"},
{"group": "After welding", "key": "vt", "text": "Visual inspection of all welds acceptable"},
{"group": "After welding", "key": "stamp", "text": "Welds stamped with welder ID"},
{"group": "After welding", "key": "clean", "text": "Slag and spatter removed"},
{"group": "After welding", "key": "qty", "text": "Completed quantity matches each work order"}
],
"plain": [
{"group": "Before starting", "key": "dwg", "text": "Drawing {drawing} Rev {rev} matches the packet"},
{"group": "Before starting", "key": "prev", "text": "Previous department''s work complete and released"},
{"group": "Before starting", "key": "mat", "text": "Material and parts on hand match the packet"},
{"group": "When done", "key": "work", "text": "Work completed per drawing and procedure"},
{"group": "When done", "key": "insp", "text": "Self-inspection done; no damage to finished surfaces"},
{"group": "When done", "key": "qty", "text": "Completed quantity matches each work order"}
]
}'::jsonb) ON CONFLICT (key) DO NOTHING;
+3
View File
@@ -49,6 +49,9 @@ LIMITS: dict[str, int] = {
"order_pin": 40, "order_pin": 40,
"file_name": 160, "file_name": 160,
"job_note": 200, "job_note": 200,
"filler": 40,
"preheat": 40,
"weld_text": 40,
} }
REASON_MIN = 8 REASON_MIN = 8
MAX_BODY_BYTES = 200_000 MAX_BODY_BYTES = 200_000
+644
View File
@@ -0,0 +1,644 @@
import { expect, test, type Page } from "@playwright/test";
import { PIN } from "./helpers";
import {
L,
actor,
api,
assignApi,
closeAll,
dragBy,
dragTo,
drawing,
finishStageApi,
makeShopDraft,
packet,
packetTab,
pullAndRelease,
setPin,
sign,
signSheet,
signWithPassword,
signWithPin,
statusOf,
stepApi,
tapDrawing,
userId,
type Actor,
type ShopPacket,
drawingReady,
} from "./shophelpers";
import type { Json } from "./whhelpers";
// Phase 5 shop scenarios on the CI stack, modelled on the old tests/ps tsec2, tht, tcosup, tcust and tsync.
// clerk.clara makes the packets by API and wh.wade pulls and releases them, so each starts Ready to assign in 03.
// tsec2: sup.wes assigns through the assign sheet, weld.andy works it on the Stall screen (start, a weld on the
// Weld map, the weld record, the checklist, submit with a wrong PIN then the right one), sup.wes cancels and then
// signs Send to Q.C.; the History tab has every step. tht: the heat table placed, dragged, sized, removed and
// dragged back from the panel; Q.C. sees it without controls. tcosup and tcust: a change order requiring 03 shows
// on the Supervisor screen and is verified with a signature; custody shows View only and the lead's read-only
// review; multi.mia cannot assign a 03 packet. tsync: two contexts mark welds and tick keys at once; a pull back
// makes weld.andy's submit stale; two supervisors verify one change order at once. Skip Q.C.: routed to 09 Hydro.
// Every UI label used here is in L (shophelpers.ts).
const RUN = Date.now().toString(36).toUpperCase().slice(-5);
const PROJECT = `S5${RUN}`;
const T = {
sec: `Stall spool ${RUN}`,
ht: `Heat spool ${RUN}`,
co: `CO spool ${RUN}`,
sync: `Sync spool ${RUN}`,
route: `Route spool ${RUN}`,
wh: `WH spool ${RUN}`,
mia: `Mia spool ${RUN}`,
};
const P: Record<keyof typeof T, ShopPacket> = {} as Record<
keyof typeof T,
ShopPacket
>;
let andyId = "";
async function open(page: Page, id: number, tabName?: RegExp): Promise<void> {
await page.goto(`/packets/${id}`);
await expect(page.getByRole("heading").first()).toBeVisible();
if (tabName) await packetTab(page, tabName).click();
}
async function logActions(a: Actor, id: number): Promise<string[]> {
const rows = (await api(
a,
"get",
`/api/packets/${id}/log`,
)) as unknown as Json[];
return rows.map((r) => String(r.action));
}
test.describe.serial("shop", () => {
test.setTimeout(240_000);
test("clerk.clara and wh.wade make the shop packets", async ({ browser }) => {
const clara = await actor(browser, "clerk.clara");
const wade = await actor(browser, "wh.wade");
for (const k of Object.keys(T) as Array<keyof typeof T>) {
P[k] = await makeShopDraft(clara, PROJECT, T[k]);
if (k !== "wh") await pullAndRelease(wade, P[k].id);
}
const andy = await actor(browser, "weld.andy");
await setPin(andy, PIN);
andyId = await userId(andy, "weld.andy");
expect(andyId).not.toBe("");
await closeAll(clara, wade, andy);
});
test("tsec2: assign, work at the stall, submit, send to Q.C.", async ({
browser,
}) => {
const id = P.sec.id;
const wes = await actor(browser, "sup.wes");
// sup.wes assigns Stall 1 and weld.andy through the assign sheet
await open(wes.page, id);
await expect(wes.page.getByText(L.readyToAssign).first()).toBeVisible();
await wes.page
.getByRole("button", { name: L.assignOpen, exact: true })
.first()
.click();
const sheet = wes.page.getByRole("dialog", { name: L.assignSheet });
await expect(sheet).toBeVisible();
await sheet
.getByLabel(L.station, { exact: true })
.selectOption({ label: "Stall 1" });
await sheet.getByLabel(L.welderSearch).fill("Andy");
await sheet.getByLabel(L.welder, { exact: true }).selectOption(andyId);
await sheet.getByLabel(L.stamp, { exact: true }).fill("A7");
await sheet
.getByRole("button", { name: L.sendToStation, exact: true })
.click();
await expect(sheet).toBeHidden();
await expect.poll(() => statusOf(wes, id)).toBe("assigned");
const assigned = await packet(wes, id);
expect(assigned.station.name).toBe("Stall 1");
expect(assigned.welder.id).toBe(andyId);
expect(assigned.welder.stamp).toBe("A7");
// weld.andy on the Stall screen
const andy = await actor(browser, "weld.andy");
await andy.page.goto("/stall");
const picker = andy.page.getByRole("combobox", { name: L.stallPicker });
if (await picker.count())
await picker.first().selectOption({ label: L.stallOption("Stall 1") });
const myHead = andy.page.getByRole("heading", {
name: new RegExp(`^${L.myPackets}`),
});
const mine = andy.page
.getByRole("region", { name: new RegExp(`^${L.myPackets}`) })
.or(andy.page.locator("section").filter({ has: myHead }))
.first();
const myCard = mine
.getByText(T.sec)
.or(mine.getByText(P.sec.unitNumber))
.first();
await expect(myCard).toBeVisible();
await myCard.click();
await expect(andy.page).toHaveURL(new RegExp(`/packets/${id}`));
await andy.page
.getByRole("button", { name: L.startWork, exact: true })
.click();
await expect.poll(() => statusOf(andy, id)).toBe("in_progress");
await expect(andy.page.getByTestId(L.clock)).toHaveText(L.clockRunning);
// a weld on the Weld map: Mark a weld, tap the drawing, pick the items, Log weld
await packetTab(andy.page, L.tabWeldMap).click();
await andy.page
.getByRole("button", { name: L.markWeld, exact: true })
.click();
await tapDrawing(andy.page, L.weldMap, 0.45, 0.5);
const ws = andy.page.getByRole("dialog", { name: L.newWeldSheet });
await expect(ws).toBeVisible();
await ws.getByRole("button", { name: L.item(1), exact: true }).click();
await ws.getByRole("button", { name: L.item(2), exact: true }).click();
await ws.getByRole("button", { name: L.logWeld, exact: true }).click();
await expect(ws).toBeHidden();
await expect(
andy.page.getByRole("button", { name: L.weldMark(1) }).first(),
).toBeVisible();
await expect
.poll(async () =>
((await packet(andy, id)).welds as Json[]).map((w) => w.weld_no),
)
.toEqual([1]);
// the weld record: filler and filler lot (each saves on Enter)
const filler = andy.page.getByLabel(L.filler, { exact: true }).first();
await filler.fill("ER70S-6");
await filler.press("Enter");
const lot = andy.page.getByLabel(L.fillerLot, { exact: true }).first();
await lot.fill("1234");
await lot.press("Enter");
await expect
.poll(async () => (await packet(andy, id)).stage.filler_lot)
.toBe("1234");
expect((await packet(andy, id)).stage.filler).toBe("ER70S-6");
// the checklist, every box
await packetTab(andy.page, L.tabChecklist).click();
const boxes = andy.page.getByRole("tabpanel").getByRole("checkbox");
await expect(boxes.first()).toBeVisible();
const n = await boxes.count();
expect(n).toBe(9);
for (let i = 0; i < n; i++) {
await boxes.nth(i).check();
await expect(boxes.nth(i)).toBeChecked();
}
await expect
.poll(
async () =>
Object.values(
(await packet(andy, id)).stage.checklist as Json,
).filter((v) => v.done).length,
)
.toBe(9);
// submit: a wrong PIN first (the sheet stays open), then the right one
const submit = andy.page.getByRole("button", {
name: new RegExp(`^${L.submit}`),
});
await expect(submit).toBeEnabled();
await submit.click();
await signWithPin(andy.page, "135790");
await expect(signSheet(andy.page).getByRole("alert")).toBeVisible();
expect(await statusOf(andy, id)).toBe("in_progress");
await signWithPin(andy.page, PIN);
await expect(signSheet(andy.page)).toBeHidden();
await expect.poll(() => statusOf(andy, id)).toBe("review");
await expect(andy.page.getByText(L.supervisorReview).first()).toBeVisible();
// sup.wes: Send to Q.C. needs both boxes and the location; cancel the signature first, nothing changes
await open(wes.page, id);
const toQc = wes.page.getByRole("button", {
name: L.sendToQc,
exact: true,
});
await expect(toQc).toBeDisabled();
await wes.page.getByLabel(L.bookReady).check();
await wes.page.getByLabel(L.spoolReady).check();
await expect(toQc).toBeDisabled();
await wes.page.getByLabel(L.spoolLocation, { exact: true }).fill("Bay 2");
await expect(toQc).toBeEnabled();
await toQc.click();
await expect(signSheet(wes.page)).toBeVisible();
await signSheet(wes.page)
.getByRole("button", { name: L.cancel, exact: true })
.click();
await expect(signSheet(wes.page)).toBeHidden();
expect(await statusOf(wes, id)).toBe("review");
await toQc.click();
await signWithPassword(wes.page, "sup.wes");
await expect.poll(() => statusOf(wes, id)).toBe("qc");
await expect(wes.page.getByTestId(L.statusPill).first()).toHaveText(L.atQc);
const sent = await packet(wes, id);
expect(sent.qc.location).toBe("Bay 2");
// the History tab has every step
await packetTab(wes.page, L.tabHistory).click();
for (const text of L.history) {
await expect(
wes.page.getByRole("tabpanel").getByText(text).first(),
).toBeVisible();
}
const actions = await logActions(wes, id);
for (const text of L.history) expect(actions).toContain(text);
await closeAll(wes, andy);
});
test("tht: the heat table on the drawing", async ({ browser }) => {
const id = P.ht.id;
const wes = await actor(browser, "sup.wes");
await assignApi(wes, id, "Stall 2", andyId);
await open(wes.page, id, L.tabHeatMap);
await drawingReady(wes.page, L.heatMap);
const panel = wes.page.getByRole("group", { name: L.heatTablePanel });
await expect(panel).toBeVisible();
// place it: the table shows the caption, the BOM rows and their heats
await panel
.getByRole("button", { name: L.placeOnSheet, exact: true })
.click();
const onDwg = wes.page.getByTestId(L.heatTable);
await expect(onDwg).toBeVisible();
await expect(onDwg.getByText(L.heatCaption)).toBeVisible();
for (const heat of ["HT-E5-1", "HT-E5-2", "HT-E5-3"])
await expect(onDwg.getByText(heat)).toBeVisible();
await expect
.poll(async () => ((await packet(wes, id)).heat_table as Json[]).length)
.toBe(1);
const at0 = ((await packet(wes, id)).heat_table as Json[])[0] as Json;
// drag it, and drag it again
await dragBy(wes.page, onDwg, 140, 90);
await expect
.poll(
async () =>
(((await packet(wes, id)).heat_table as Json[])[0] as Json).x,
)
.not.toBe(at0.x);
const at1 = ((await packet(wes, id)).heat_table as Json[])[0] as Json;
await dragBy(wes.page, onDwg, 60, 120);
await expect
.poll(
async () =>
(((await packet(wes, id)).heat_table as Json[])[0] as Json).y,
)
.not.toBe(at1.y);
const at2 = ((await packet(wes, id)).heat_table as Json[])[0] as Json;
// pan the drawing on an empty spot: the view moves, the table placement does not
const viewer = wes.page.getByRole("group", {
name: L.heatMap,
exact: true,
});
const stage = viewer.locator(".stage").first();
const before = await stage.getAttribute("style");
await viewer.scrollIntoViewIfNeeded();
const canvas = drawing(wes.page, L.heatMap);
const box = await canvas.boundingBox();
if (!box) throw new Error("no drawing");
await wes.page.mouse.move(
box.x + box.width * 0.85,
box.y + box.height * 0.85,
);
await wes.page.mouse.down();
await wes.page.mouse.move(
box.x + box.width * 0.85 - 80,
box.y + box.height * 0.85 - 60,
{ steps: 10 },
);
await wes.page.mouse.up();
await expect.poll(() => stage.getAttribute("style")).not.toBe(before);
const at3 = ((await packet(wes, id)).heat_table as Json[])[0] as Json;
expect([at3.x, at3.y]).toEqual([at2.x, at2.y]);
// A+ twice: s 1.3
await panel.getByRole("button", { name: L.larger, exact: true }).click();
await expect
.poll(
async () =>
(((await packet(wes, id)).heat_table as Json[])[0] as Json).s,
)
.toBeCloseTo(1.15, 2);
await panel.getByRole("button", { name: L.larger, exact: true }).click();
await expect
.poll(
async () =>
(((await packet(wes, id)).heat_table as Json[])[0] as Json).s,
)
.toBeCloseTo(1.3, 2);
// remove, then drag it from the panel onto the drawing
await panel
.getByRole("button", { name: L.removeFromSheet, exact: true })
.click();
await expect(onDwg).toBeHidden();
await expect
.poll(async () => ((await packet(wes, id)).heat_table as Json[]).length)
.toBe(0);
await dragTo(wes.page, panel.getByTestId(L.heatGrab), canvas, 0.4, 0.4);
await expect(onDwg).toBeVisible();
await expect
.poll(async () => ((await packet(wes, id)).heat_table as Json[]).length)
.toBe(1);
// qc.quinn sees the table and no place, size, remove or drag controls
const quinn = await actor(browser, "qc.quinn");
await open(quinn.page, id, L.tabHeatMap);
await expect(
quinn.page.getByRole("group", { name: L.heatTableOnDrawing }),
).toBeVisible();
for (const name of [
L.placeOnSheet,
L.removeFromSheet,
L.larger,
L.smaller,
]) {
await expect(
quinn.page.getByRole("button", { name, exact: true }),
).toHaveCount(0);
}
await expect(quinn.page.getByTestId(L.heatGrab)).toHaveCount(0);
await closeAll(wes, quinn);
});
test("tcosup: a change order requiring 03 on the Supervisor screen, verified with a signature", async ({
browser,
}) => {
const id = P.co.id;
const clara = await actor(browser, "clerk.clara");
const wes = await actor(browser, "sup.wes");
await assignApi(wes, id, "Stall 3", andyId);
const co = await api(clara, "post", `/api/packets/${id}/change-orders`, {
reason: "Nozzle N2 moved 2 in on rev B",
required_depts: ["03"],
sign: sign("clerk.clara"),
});
const label = `CO-${(co.change_orders as Json[]).at(-1)?.co_no}`;
await wes.page.goto("/supervisor");
await expect(wes.page.getByText(L.supervisorHead)).toBeVisible();
const banner = wes.page.getByRole("alert", { name: L.coBanner });
await expect(banner).toBeVisible();
await expect(banner.getByText(L.coBannerRow(label)).first()).toBeVisible();
const tile = wes.page.getByRole("group", {
name: L.stationTile("Stall 3"),
});
await expect(
tile.getByRole("button", { name: L.coTag(label) }),
).toBeVisible();
await banner
.getByRole("button", { name: L.reviewAndVerify(label) })
.first()
.click();
await expect(wes.page).toHaveURL(new RegExp(`/packets/${id}`));
const box = wes.page
.getByRole("group", { name: L.verifyBox(label) })
.first();
await expect(box).toBeVisible();
const verify = box.getByRole("button", { name: L.verify, exact: true });
await expect(verify).toBeDisabled();
await box.getByLabel(L.reviewedBox).check();
await box.getByLabel(L.verifyNote).fill("Reviewed with the crew");
await verify.click();
await signWithPassword(wes.page, "sup.wes");
await expect
.poll(async () =>
Object.keys(
((await packet(wes, id)).change_orders as Json[]).at(-1)?.verified ??
{},
),
)
.toEqual(["03"]);
await expect(box).toHaveCount(0);
await wes.page.goto("/supervisor");
await expect(wes.page.getByText(L.supervisorHead)).toBeVisible();
await expect(wes.page.getByRole("alert", { name: L.coBanner })).toHaveCount(
0,
);
await closeAll(clara, wes);
});
test("tcust: custody, view only, the lead's read-only review, multi.mia cannot assign 03", async ({
browser,
}) => {
const wes = await actor(browser, "sup.wes");
// a packet with the warehouse: view only, no shop controls
await open(wes.page, P.wh.id);
await expect(wes.page.getByText(L.viewOnly).first()).toBeVisible();
for (const name of [L.startWork, L.sendToStation, L.pullBack]) {
await expect(
wes.page.getByRole("button", { name, exact: true }),
).toHaveCount(0);
}
await expect(
wes.page.getByRole("button", { name: L.assignOpen, exact: true }),
).toHaveCount(0);
await packetTab(wes.page, L.tabWeldMap).click();
await expect(
wes.page.getByRole("button", { name: L.markWeld, exact: true }),
).toHaveCount(0);
await packetTab(wes.page, L.tabHeatMap).click();
await expect(
wes.page.getByRole("button", { name: L.placeOnSheet, exact: true }),
).toHaveCount(0);
// a packet in review: lead.lou reads the review, sup.wes acts on it
const andy = await actor(browser, "weld.andy");
const id = P.route.id;
await assignApi(wes, id, "Stall 5", andyId);
await stepApi(andy, id, "start");
await finishStageApi(andy, id, 1);
await stepApi(andy, id, "submit", { sign: sign("weld.andy") });
expect(await statusOf(andy, id)).toBe("review");
const lou = await actor(browser, "lead.lou");
await open(lou.page, id);
await expect(lou.page.getByTestId(L.reviewReadonly)).toContainText(
L.leadReviewNote,
);
for (const name of [L.sendToQc, L.returnToStation, L.sendToDept]) {
await expect(
lou.page.getByRole("button", { name, exact: true }),
).toHaveCount(0);
}
// multi.mia (06) has no assign button on a 03 packet, and the API refuses her
const mia = await actor(browser, "multi.mia");
await open(mia.page, P.mia.id);
await expect(mia.page.getByText(L.readyToAssign).first()).toBeVisible();
await expect(
mia.page.getByRole("button", { name: L.assignOpen, exact: true }),
).toHaveCount(0);
const refused = await stepApi(
mia,
P.mia.id,
"assign",
{ station_id: 1, welder_id: andyId },
403,
);
expect(refused.code).toBe("forbidden");
await closeAll(wes, andy, lou, mia);
});
test("tsync: two sessions at once", async ({ browser }) => {
const id = P.sync.id;
const wes = await actor(browser, "sup.wes");
const andy = await actor(browser, "weld.andy");
await assignApi(wes, id, "Stall 4", andyId);
await stepApi(andy, id, "start");
// both mark a weld at nearly the same time: W1 and W2, never the same
await open(wes.page, id, L.tabWeldMap);
await open(andy.page, id, L.tabWeldMap);
const sheets = [];
for (const [a, fx] of [
[wes, 0.3],
[andy, 0.7],
] as const) {
await a.page
.getByRole("button", { name: L.markWeld, exact: true })
.click();
await tapDrawing(a.page, L.weldMap, fx, 0.5);
const ws = a.page.getByRole("dialog", { name: L.newWeldSheet });
await expect(ws).toBeVisible();
await ws.getByRole("button", { name: L.item(1), exact: true }).click();
sheets.push(ws);
}
await Promise.all(
sheets.map((ws) =>
ws.getByRole("button", { name: L.logWeld, exact: true }).click(),
),
);
await expect
.poll(async () =>
((await packet(wes, id)).welds as Json[]).map((w) => w.weld_no).sort(),
)
.toEqual([1, 2]);
for (const a of [wes, andy]) {
await a.page.reload();
await packetTab(a.page, L.tabWeldMap).click();
await expect(
a.page.getByRole("button", { name: L.weldMark(1) }).first(),
).toBeVisible();
await expect(
a.page.getByRole("button", { name: L.weldMark(2) }).first(),
).toBeVisible();
}
// both tick a different checklist key at once; both stay ticked after a reload
await packetTab(wes.page, L.tabChecklist).click();
await packetTab(andy.page, L.tabChecklist).click();
const boxOf = (a: Actor, i: number) =>
a.page.getByRole("tabpanel").getByRole("checkbox").nth(i);
await expect(boxOf(wes, 0)).toBeEnabled();
await expect(boxOf(andy, 1)).toBeEnabled();
await Promise.all([boxOf(andy, 0).check(), boxOf(wes, 1).check()]);
await expect
.poll(async () => {
const ck = (await packet(wes, id)).stage.checklist as Json;
return ["dwg", "heat"].every((k) => ck[k]?.done === true);
})
.toBe(true);
for (const a of [wes, andy]) {
await a.page.reload();
await packetTab(a.page, L.tabChecklist).click();
await expect(boxOf(a, 0)).toBeChecked();
await expect(boxOf(a, 1)).toBeChecked();
}
// weld.andy is ready to submit; sup.wes pulls the packet back while Andy's page still shows it in progress
await finishStageApi(andy, id, 0);
await andy.page.reload();
const submit = andy.page.getByRole("button", {
name: new RegExp(`^${L.submit}`),
});
await expect(submit).toBeEnabled();
await open(wes.page, id);
await wes.page
.getByRole("button", { name: L.pullBack, exact: true })
.click();
await expect.poll(() => statusOf(wes, id)).toBe("ready");
await submit.click();
await signWithPin(andy.page, PIN);
await expect(andy.page.getByTestId(L.toast)).toContainText(L.stale);
await expect(andy.page.getByText(L.readyToAssign).first()).toBeVisible();
expect(await statusOf(andy, id)).toBe("ready");
// two supervisors (03 and 06) verify one change order at once; both land
const clara = await actor(browser, "clerk.clara");
const co = await api(clara, "post", `/api/packets/${id}/change-orders`, {
reason: "Flange rating changed on rev B",
required_depts: ["03", "06"],
sign: sign("clerk.clara"),
});
const label = `CO-${(co.change_orders as Json[]).at(-1)?.co_no}`;
const mia = await actor(browser, "multi.mia");
const boxes = [];
for (const [a, name] of [
[wes, L.verifyBox(label)],
[mia, L.verifyBox06(label)],
] as const) {
await a.page.goto(`/packets/${id}?tab=changes`);
const box = a.page.getByRole("group", { name }).first();
await expect(box).toBeVisible();
await box.getByLabel(L.reviewedBox).check();
boxes.push(box);
}
await Promise.all(
boxes.map((b) =>
b.getByRole("button", { name: L.verify, exact: true }).click(),
),
);
await Promise.all([
signWithPassword(wes.page, "sup.wes"),
signWithPassword(mia.page, "multi.mia"),
]);
await expect
.poll(async () =>
Object.keys(
((await packet(wes, id)).change_orders as Json[]).at(-1)?.verified ??
{},
).sort(),
)
.toEqual(["03", "06"]);
expect((await packet(wes, id)).open_cos).toEqual([]);
await closeAll(wes, andy, clara, mia);
});
test("skip Q.C.: sup.wes routes a review packet to 09 Hydro", async ({
browser,
}) => {
const id = P.route.id;
const wes = await actor(browser, "sup.wes");
expect(await statusOf(wes, id)).toBe("review");
await open(wes.page, id);
const select = wes.page.getByLabel(L.skipQcSelect, { exact: true });
await expect(select).toBeVisible();
await select.selectOption({ label: L.hydroOption });
await wes.page
.getByRole("button", { name: L.sendToDept, exact: true })
.click();
await signWithPassword(wes.page, "sup.wes");
await expect.poll(async () => (await packet(wes, id)).dept.code).toBe("09");
const p = await packet(wes, id);
expect(p.status).toBe("ready");
await expect(wes.page.getByTestId(L.statusPill).first()).toHaveText(
L.readyToAssign,
);
await expect(wes.page.getByText(/Hydro/).first()).toBeVisible();
await packetTab(wes.page, L.tabWeldMap).click();
await expect(
wes.page.getByRole("button", { name: L.carriedWeld(1) }).first(),
).toBeVisible();
await expect(wes.page.getByText(L.carriedPanel)).toBeVisible();
await closeAll(wes);
});
});
+511
View File
@@ -0,0 +1,511 @@
import {
expect,
type Browser,
type BrowserContext,
type Locator,
type Page,
} from "@playwright/test";
import { dcUser, PIN, signInPassword } from "./helpers";
import { getJson, onePagePdf, writeHeaders, type Json } from "./whhelpers";
// Helpers for the phase 5 shop scenarios: packets made and moved through the API, one signed-in browser context
// per user, and the UI labels the shop spec depends on (L). The labels are the texts of the phase 5 design
// contract (Frontend B and C) and of the components as built; the main session adjusts them here in one place.
export const L = {
// packet page tabs
tabHeatMap: /^Heat map/,
tabChecklist: /^Checklist/,
tabWeldMap: /^Weld map/,
tabChanges: /^Changes/, // "Change orders" in the contract; B named the tab "Changes"
tabHistory: /^History/,
// status pills and texts
readyToAssign: "Ready to assign",
supervisorReview: "Supervisor review",
atQc: /^(At Q\.C\.|Q\.C\. inspection)$/, // the status pill shows the status label
statusPill: "status-pill",
viewOnly: "View only.",
leadReviewNote: "A supervisor for 03 Weld Parts reviews it and sends it on.",
stale: "Someone else already took this step.",
// assign sheet (supervisor and lead, packet in Ready)
assignOpen: "Send to station", // the bar button; the dialog's submit has the same name
assignSheet: "Send to station",
station: "Station",
welderSearch: "Find a welder",
welder: "Welder", // a select; option text "Andy Welder [75]", value the user id
stamp: "Stamp",
sendToStation: "Send to station",
sendBackToWarehouse: "Send back to Warehouse",
pullBack: "Pull back to queue",
// stall
stallPicker: "Station", // combobox, option text "03 Weld Parts · Stall 1"
stallOption: (stall: string) => `03 Weld Parts · ${stall}`,
myPackets: "My packets",
submittedCol: "Submitted, waiting on supervisor",
startWork: "Start work",
pause: "Pause",
resume: "Resume",
submit: "Submit to supervisor",
clock: "clock", // testid: "01:02:03 running · target 4.5 h"
clockRunning: /\d\d:\d\d:\d\d\s*running/,
toast: "toast", // testid of the stale-step toast (role status)
reviewReadonly: "review-readonly", // testid of the lead's read-only review
// weld map
markWeld: "Mark a weld",
weldMap: "Weld map",
drawingCanvas: /^Drawing page \d+/,
newWeldSheet: "New weld",
item: (n: number) => `Item ${n}`,
logWeld: "Log weld",
weldMark: (n: number) => new RegExp(`^Weld W${n}(,|$)`),
carriedWeld: (n: number) => `Weld W${n}, carried`,
carriedPanel: "Carried from an earlier department",
filler: "Filler metal",
fillerLot: "Filler lot / heat",
// heat map
heatMap: "Heat map",
heatTablePanel: "Heat table panel",
heatTableOnDrawing: "Heat table on the drawing", // role group
heatTable: "heat-table", // its testid
placeOnSheet: "Place on this sheet",
removeFromSheet: "Remove from this sheet",
larger: "Larger table",
smaller: "Smaller table",
heatGrab: "heat-table-grab", // data-testid of the drag source in the panel
heatCaption: "HEAT NUMBERS",
// review (supervisor)
bookReady: "Data book package ready",
spoolReady: "Spool ready for inspection",
spoolLocation: "Spool location",
noteToQc: "Note to Q.C.",
sendToQc: "Send to Q.C.",
returnToStation: "Return to station",
skipQcSelect: "Skip Q.C. - send to department", // a select, values are department codes
sendToDept: "Send to department",
hydroOption: "09 Hydro",
// change orders
coBanner: "Change orders to verify",
coBannerRow: (label: string) =>
new RegExp(`Change order ${label} on .* needs your verification`),
reviewAndVerify: (label: string) =>
new RegExp(`^Review & verify ${label} on`),
coTag: (label: string) =>
new RegExp(`^CHANGE ORDER ${label} . drawing changed, verify`),
verifyBox: (label: string) => `Verify ${label} for 03 Weld Parts`,
verifyBox06: (label: string) => `Verify ${label} for 06 Code Bldg 2`,
reviewedBox: "I reviewed this change with my department",
verifyNote: /^Note/,
verify: "Verify",
stationTile: (name: string) => new RegExp(`^${name}, \\d+ job`),
supervisorHead: "Dispatch, review, route",
// signature sheet (components/SignatureSheet.tsx)
signSheet: "Sign",
signButton: "Sign",
cancel: "Cancel",
usePassword: "Use your password instead",
// history texts
history: [
"Sent to Stall 1",
"Work started",
"Weld W1 marked",
"Submitted to supervisor",
"Sent to Q.C.",
],
} as const;
export const WELD_KEYS = [
"dwg",
"heat",
"wps",
"fit",
"pre",
"vt",
"stamp",
"clean",
"qty",
] as const;
export interface Actor {
sam: string;
ctx: BrowserContext;
page: Page;
}
/** A fresh browser context signed in as `sam` (password). */
export async function actor(browser: Browser, sam: string): Promise<Actor> {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await signInPassword(page, sam);
return { sam, ctx, page };
}
export async function api(
a: Actor | Page,
method: "get" | "post" | "patch" | "put" | "delete",
url: string,
data?: Json,
opts: { status?: number; headers?: Record<string, string> } = {},
): Promise<Json> {
const page = "page" in a ? a.page : a;
const r = await page.request.fetch(url, {
method: method.toUpperCase(),
headers: { ...writeHeaders(page), ...(opts.headers ?? {}) },
...(data !== undefined ? { data } : {}),
});
const want = opts.status ?? 200;
expect(r.status(), `${method} ${url}: ${await r.text()}`).toBe(want);
const text = await r.text();
return text ? (JSON.parse(text) as Json) : {};
}
export async function packet(a: Actor | Page, id: number): Promise<Json> {
const page = "page" in a ? a.page : a;
return getJson(page.request, `/api/packets/${id}`);
}
export function sign(sam: string): Json {
return { factor: "password", secret: dcUser(sam).password };
}
/** POST a transition with the expect taken from the packet's current state. */
export async function stepApi(
a: Actor,
id: number,
action: string,
body: Json = {},
status = 200,
): Promise<Json> {
const p = await packet(a, id);
return api(
a,
"post",
`/api/packets/${id}/transitions`,
{ action, expect: { status: p.status, dept: p.dept.code }, ...body },
{ status },
);
}
/** Set the user's signature PIN through the account API (idempotent: the same PIN again is fine). */
export async function setPin(a: Actor, pin: string = PIN): Promise<void> {
await api(a, "post", "/api/account/pin", {
password: dcUser(a.sam).password,
pin,
});
}
export interface ShopPacket {
id: number;
title: string;
unitNumber: string;
}
/**
* As clerk.clara: a project (made once), a new unit, a hand draft in 03 with two work orders and three BOM lines,
* a one-page PDF drawing attached with her password, the heat map answered, sent to the warehouse.
*/
export async function makeShopDraft(
clara: Actor,
projectNumber: string,
title: string,
): Promise<ShopPacket> {
const projects = (await getJson(
clara.page.request,
"/api/projects?status=all",
)) as unknown as Json[];
let project = projects.find((p) => p.number === projectNumber);
if (!project)
project = await api(
clara,
"post",
"/api/projects",
{ number: projectNumber, name: "CI shop" },
{ status: 201 },
);
const units = (await api(
clara,
"post",
`/api/projects/${project.id}/units`,
{},
{ status: 201 },
)) as unknown as Json[];
const unit = units[0] as Json;
const draft = await api(
clara,
"post",
"/api/packets",
{
unit_id: unit.id,
dept_code: "03",
op: "WELD-PART",
title,
customer: "CI Customer",
drawing: `D-${projectNumber}`,
rev: "A",
wps: "WPS-11",
work_orders: [
{
wo_base: `W${projectNumber}`.slice(0, 20),
lot: "001",
split: "0",
sub: "0",
qty: 2,
target_hrs: 2,
},
{
wo_base: `X${projectNumber}`.slice(0, 20),
lot: "001",
split: "0",
sub: "0",
qty: 1,
target_hrs: 2.5,
},
],
bom: [
{ item: 1, qty: 1, part_id: "PIPE-4", description: "4in pipe SCH40" },
{ item: 2, qty: 2, part_id: "FLG-4", description: "4in flange" },
{ item: 3, qty: 1, part_id: "STUD-58", description: "5/8 stud bolt" },
],
},
{ status: 201 },
);
const up = await clara.page.request.post("/api/assets", {
headers: writeHeaders(clara.page),
multipart: {
file: {
name: `${projectNumber}.pdf`,
mimeType: "application/pdf",
buffer: onePagePdf(),
},
purpose: "drawing",
},
});
expect(up.status(), await up.text()).toBe(201);
const asset = (await up.json()) as Json;
await api(clara, "post", `/api/packets/${draft.id}/pages`, {
asset_id: asset.id,
type: "drawing",
sign: sign("clerk.clara"),
});
await api(clara, "patch", `/api/packets/${draft.id}`, {
set: { heat_map: true },
base: {},
});
const sent = await stepApi(clara, Number(draft.id), "send_to_warehouse");
expect(sent.status).toBe("warehouse");
return {
id: Number(draft.id),
title,
unitNumber: String(unit.number ?? sent.unit?.number ?? ""),
};
}
/** As wh.wade: every line pulled with a heat, then released: the packet is Ready to assign in 03. */
export async function pullAndRelease(wade: Actor, id: number): Promise<Json> {
let p = await packet(wade, id);
for (const line of p.bom as Json[]) {
const item = Number(line.item);
let cur = (p.bom as Json[]).find((b) => b.item === item) as Json;
p = await api(
wade,
"patch",
`/api/packets/${id}/bom/${item}`,
{ pulled: true },
{ headers: { "If-Match": String(cur.row_version) } },
);
cur = (p.bom as Json[]).find((b) => b.item === item) as Json;
p = await api(
wade,
"patch",
`/api/packets/${id}/bom/${item}`,
{ heat: `HT-E5-${item}` },
{ headers: { "If-Match": String(cur.row_version) } },
);
}
const out = await stepApi(wade, id, "wh_release");
expect(out.status).toBe("ready");
return out;
}
export async function userId(a: Actor, sam: string): Promise<string> {
const people = (await getJson(
a.page.request,
"/api/departments/03/people",
)) as unknown as Json[];
const me = people.find((x) => x.display_name === dcUser(sam).display_name);
if (!me) throw new Error(`${sam} is not in 03 people`);
return String(me.id);
}
export async function stationId(
a: Actor,
dept: string,
name: string,
): Promise<number> {
const depts = (await getJson(
a.page.request,
"/api/departments",
)) as unknown as Json[];
const d = depts.find((x) => x.code === dept) as Json;
const st = (d.stations as Json[]).find((x) => x.name === name);
if (!st) throw new Error(`no station ${name} in ${dept}`);
return Number(st.id);
}
/** As sup.wes through the API: assign to a stall and weld.andy. */
export async function assignApi(
wes: Actor,
id: number,
stall: string,
welderId: string,
): Promise<Json> {
return stepApi(wes, id, "assign", {
station_id: await stationId(wes, "03", stall),
welder_id: welderId,
});
}
/** As the holder through the API: every checklist key, `welds` welds on page 1, the filler. */
export async function finishStageApi(
a: Actor,
id: number,
welds = 1,
): Promise<Json> {
let p: Json = {};
for (const k of WELD_KEYS)
p = await api(a, "put", `/api/packets/${id}/stage/checklist/${k}`, {
done: true,
});
for (let i = 0; i < welds; i++) {
p = await api(a, "post", `/api/packets/${id}/welds`, {
page: 1,
fx: 0.3 + 0.1 * i,
fy: 0.4,
items: [1, 2],
type: "Fillet",
process: "GMAW",
});
}
p = await api(a, "patch", `/api/packets/${id}/stage`, {
set: { filler: "ER70S-6", filler_lot: "1234" },
base: {},
});
return p;
}
/** The status the packet shows on the server, for expect.poll. */
export async function statusOf(a: Actor, id: number): Promise<string> {
return String((await packet(a, id)).status);
}
/** A tab of the packet page by name (role=tab, or a button). */
export function packetTab(page: Page, name: RegExp): Locator {
return page
.getByRole("tab", { name })
.or(page.getByRole("button", { name }))
.first();
}
/** The signature sheet. */
export function signSheet(page: Page): Locator {
return page.getByRole("dialog", { name: L.signSheet, exact: true });
}
/** Sign with the password in the open signature sheet. */
export async function signWithPassword(page: Page, sam: string): Promise<void> {
const sheet = signSheet(page);
await expect(sheet).toBeVisible();
const toPw = sheet.getByRole("button", { name: L.usePassword });
if (await toPw.count()) await toPw.click();
await sheet
.getByLabel("Password", { exact: true })
.fill(dcUser(sam).password);
await sheet.getByRole("button", { name: L.signButton, exact: true }).click();
}
/** Sign with a PIN in the open signature sheet (the PIN field, typed). */
export async function signWithPin(page: Page, pin: string): Promise<void> {
const sheet = signSheet(page);
await expect(sheet).toBeVisible();
const field = sheet.getByLabel("PIN", { exact: true });
await field.fill(pin);
await sheet.getByRole("button", { name: L.signButton, exact: true }).click();
}
/** The drawing canvas of the open viewer. */
export function drawing(page: Page, viewer: string): Locator {
return page
.getByRole("group", { name: viewer, exact: true })
.getByRole("img", { name: L.drawingCanvas })
.first();
}
/** Waits until the viewer has rendered its page (data-ready="1"); taps before that are ignored. */
export async function drawingReady(page: Page, viewer: string): Promise<void> {
await expect(
page
.getByRole("group", { name: viewer, exact: true })
.and(page.locator('[data-ready="1"]')),
).toBeVisible({ timeout: 20000 });
}
/** Click a point on the drawing given as fractions of its box. */
export async function tapDrawing(
page: Page,
viewer: string,
fx: number,
fy: number,
): Promise<void> {
await drawingReady(page, viewer);
// page.mouse works in viewport coordinates, so the viewer must be on screen first.
await page
.getByRole("group", { name: viewer, exact: true })
.scrollIntoViewIfNeeded();
const c = drawing(page, viewer);
await expect(c).toBeVisible();
const b = await c.boundingBox();
if (!b) throw new Error("drawing not visible");
await page.mouse.click(b.x + b.width * fx, b.y + b.height * fy);
}
/** Drag with the pointer, in steps, from the middle of `from` by (dx, dy), or to the middle of `to`. */
export async function dragBy(
page: Page,
from: Locator,
dx: number,
dy: number,
): Promise<void> {
await from.scrollIntoViewIfNeeded();
const a = await from.boundingBox();
if (!a) throw new Error("drag: element not visible");
const x = a.x + a.width / 2;
const y = a.y + Math.min(a.height / 2, 14);
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + 8, y + 8, { steps: 3 });
await page.mouse.move(x + dx, y + dy, { steps: 14 });
await page.mouse.up();
}
export async function dragTo(
page: Page,
from: Locator,
to: Locator,
fx = 0.5,
fy = 0.5,
): Promise<void> {
await from.scrollIntoViewIfNeeded();
const a = await from.boundingBox();
const b = await to.boundingBox();
if (!a || !b) throw new Error("drag: element not visible");
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(a.x + a.width / 2 + 10, a.y + a.height / 2 + 10, {
steps: 3,
});
await page.mouse.move(b.x + b.width * fx, b.y + b.height * fy, { steps: 16 });
await page.mouse.up();
}
export async function closeAll(...actors: Actor[]): Promise<void> {
for (const a of actors) await a.ctx.close();
}
+392
View File
@@ -0,0 +1,392 @@
"""Helpers for the phase 5 shop tests: one signed-in client per user, a packet brought to `ready` in 03 through
the warehouse, and the shop steps (assign, start, finish the stage, submit, review).
The packet: a hand draft in 03 on a fresh unit, two work orders (target 2 h and 2.5 h), three BOM lines, a
two-page PDF drawing, heat map answered, sent to the warehouse, every line pulled with a heat by wh.wade, released.
"""
from __future__ import annotations
import secrets
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Any
from fakedir import FAKE_PASSWORD, FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import phase2_client, seed_users, step, tiny_pdf, upload
from pshelpers import ORIGIN_HEADERS, app_conn, sign_in
from psycopg.types.json import Jsonb
from packetstation.settings import Settings
SIGN = {"factor": "password", "secret": FAKE_PASSWORD}
IT = "Ticket 5501: shop lead is out today"
STALE = "Someone else already took this step."
USERS = (
"admin.ann",
"clerk.clara",
"clerk.leo",
"wh.wade",
"qc.quinn",
"pm.paula",
"weld.andy",
"lead.lou",
"sup.wes",
"multi.mia",
"both.bo",
"dup.dave",
)
WOS = [
{"wo_base": "L670001", "lot": "001", "split": "0", "sub": "0", "qty": 2, "target_hrs": 2},
{"wo_base": "L670002", "lot": "001", "split": "0", "sub": "0", "qty": 1, "target_hrs": 2.5},
]
BOM3 = [
{"item": 1, "qty": 1, "part_id": "PIPE-4", "description": "4in pipe SCH40", "material": "A106-B"},
{"item": 2, "qty": 2, "part_id": "GSKT-4", "description": "4in gasket"},
{"item": 3, "qty": 4, "part_id": "STUD-58", "description": "5/8 stud bolt"},
]
WELD_KEYS = ("dwg", "heat", "wps", "fit", "pre", "vt", "stamp", "clean", "qty")
PLAIN_KEYS = ("dwg", "prev", "mat", "work", "insp", "qty")
MSG_CHECKLIST = "Finish the checklist first."
MSG_WELDS = "Record at least one weld first."
MSG_FILLER = "Enter the filler metal first."
MSG_QC = "Confirm the book and the spool, and give the location."
MSG_OTHER_DEPT = "Choose a different department."
# ---------------------------------------------------------------- clients
@dataclass
class Shop:
"""One app, one signed-in TestClient per user (separate cookie jars)."""
main: TestClient
settings: Settings
clients: dict[str, TestClient] = field(default_factory=dict)
def __call__(self, sam: str) -> TestClient:
c = self.clients.get(sam)
if c is None:
c = self.fresh(sam)
self.clients[sam] = c
return c
def fresh(self, sam: str) -> TestClient:
"""A new client with its own session (two sessions of one user, or a thread of its own)."""
c = TestClient(self.main.app, headers=ORIGIN_HEADERS)
r = sign_in(c, sam)
assert r.status_code == 200, r.text
return c
def uid(self, sam: str) -> str:
return user_id(self.settings, sam)
def stall(self, dept: str = "03", name: str = "Stall 1") -> int:
return station_id(self.settings, dept, name)
def open_shop(settings: Settings, fake_dir: FakeDirectory) -> Iterator[Shop]:
"""The body of a `shop` fixture: the app, every test user signed in once (their users rows exist)."""
with phase2_client(settings, fake_dir) as client:
seed_users(client, USERS)
yield Shop(client, settings)
# ---------------------------------------------------------------- SQL
def one(settings: Settings, sql: str, params: Any = ()) -> Any:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchone()
def all_rows(settings: Settings, sql: str, params: Any = ()) -> list[Any]:
with app_conn(settings) as conn:
return list(conn.execute(sql, params).fetchall())
def run(settings: Settings, sql: str, params: Any = ()) -> None:
with app_conn(settings) as conn:
conn.execute(sql, params)
def user_id(settings: Settings, sam: str) -> str:
row = one(settings, "SELECT id FROM users WHERE sam_account = %s", (sam,))
assert row is not None, sam
return str(row[0])
def station_id(settings: Settings, dept: str = "03", name: str = "Stall 1") -> int:
row = one(settings, "SELECT id FROM stations WHERE dept_code = %s AND name = %s", (dept, name))
assert row is not None, (dept, name)
return int(row[0])
def set_stamp(settings: Settings, sam: str, stamp: str | None) -> None:
run(settings, "UPDATE users SET welder_stamp = %s WHERE sam_account = %s", (stamp, sam))
def set_limits(settings: Settings, sam: str, station_ids: list[int]) -> None:
"""Replace a user's station limits (an empty list removes them)."""
with app_conn(settings) as conn:
uid = conn.execute("SELECT id FROM users WHERE sam_account = %s", (sam,)).fetchone()[0]
conn.execute("DELETE FROM user_station_limits WHERE user_id = %s", (uid,))
for sid in station_ids:
conn.execute("INSERT INTO user_station_limits (user_id, station_id) VALUES (%s, %s)", (uid, sid))
def make_unit(settings: Settings, number: str | None = None, lot: str = "001") -> tuple[int, int]:
"""A project with one unit by SQL. Returns (project_id, unit_id)."""
number = number or "L5" + secrets.token_hex(3).upper()
with app_conn(settings) as conn:
pid = conn.execute(
"INSERT INTO projects (number, name, customer, po) VALUES (%s, 'Shop project', 'Acme Refining', 'PO-55')"
" RETURNING id",
(number,),
).fetchone()[0]
uid = conn.execute(
"INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, 'Unit one', 'open') RETURNING id",
(pid, lot),
).fetchone()[0]
return int(pid), int(uid)
def set_running_since(settings: Settings, packet_id: int, seconds_ago: float) -> None:
"""Move the open stage's running clock back in time, as if it had run that long."""
run(
settings,
"UPDATE packet_stages SET clock_running_since = now() - make_interval(secs => %s)"
" WHERE packet_id = %s AND closed_at IS NULL AND clock_running_since IS NOT NULL",
(seconds_ago, packet_id),
)
def set_templates(settings: Settings, value: Any) -> None:
run(
settings,
"INSERT INTO settings (key, value, version, updated_at) VALUES ('checklist_templates', %s, 1, now())"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1",
(Jsonb(value),),
)
# ---------------------------------------------------------------- reads
def fresh(c: TestClient, pid: int) -> dict[str, Any]:
r = c.get(f"/api/packets/{pid}")
assert r.status_code == 200, r.text
out: dict[str, Any] = r.json()
return out
def log_rows(c: TestClient, pid: int) -> list[dict[str, Any]]:
r = c.get(f"/api/packets/{pid}/log")
assert r.status_code == 200, r.text
out: list[dict[str, Any]] = r.json()
return out
def last_log(c: TestClient, pid: int) -> dict[str, Any]:
return log_rows(c, pid)[0]
def find_log(c: TestClient, pid: int, action: str) -> dict[str, Any]:
"""The newest log row with this action text."""
rows = [r for r in log_rows(c, pid) if r["action"] == action]
assert rows, (action, [r["action"] for r in log_rows(c, pid)])
return rows[0]
def ok(r: Any) -> dict[str, Any]:
assert r.status_code == 200, r.text
out: dict[str, Any] = r.json()
return out
def refused(r: Any, status: int, code: str, detail: str | None = None) -> dict[str, Any]:
assert r.status_code == status, r.text
body: dict[str, Any] = r.json()
assert body["code"] == code, body
if detail is not None:
assert body["detail"] == detail, body
return body
def weld_of(pkt: dict[str, Any], no: int) -> dict[str, Any]:
return next(w for w in pkt["welds"] if w["weld_no"] == no)
def tag_of(pkt: dict[str, Any], no: int) -> dict[str, Any]:
return next(t for t in pkt["heat_tags"] if t["tag_no"] == no)
def line_of(pkt: dict[str, Any], item: int) -> dict[str, Any]:
return next(b for b in pkt["bom"] if b["item"] == item)
def im(v: int) -> dict[str, str]:
return {"If-Match": str(v)}
# ---------------------------------------------------------------- the packet
def draft(
shop: Shop,
unit_id: int | None = None,
*,
title: str = "Spool 7 outlet",
drawing: str = "D-5001",
rev: str = "B",
bom: list[dict[str, Any]] | None = None,
wos: list[dict[str, Any]] | None = None,
dept: str = "03",
pages: int = 2,
) -> dict[str, Any]:
"""A hand draft by clerk.clara with the heat map answered and a PDF drawing of `pages` pages."""
if unit_id is None:
_, unit_id = make_unit(shop.settings)
c = shop("clerk.clara")
body = {
"unit_id": unit_id,
"dept_code": dept,
"op": "WELD-PART",
"title": title,
"customer": "Acme Refining",
"drawing": drawing,
"rev": rev,
"wps": "WPS-11",
"work_orders": WOS if wos is None else wos,
"bom": BOM3 if bom is None else bom,
}
r = c.post("/api/packets", json=body)
assert r.status_code == 201, r.text
pid = r.json()["id"]
assert c.patch(f"/api/packets/{pid}", json={"set": {"heat_map": True}, "base": {}}).status_code == 200
a = upload(c, tiny_pdf(pages))
assert a.status_code == 201, a.text
r = c.post(f"/api/packets/{pid}/pages", json={"asset_id": a.json()["id"], "type": "drawing", "sign": SIGN})
return ok(r)
def to_warehouse(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
out = ok(step(shop("clerk.clara"), pkt, "send_to_warehouse"))
assert out["status"] == "warehouse"
return out
def pull_all(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
"""wh.wade ticks every line pulled and gives it a heat."""
c = shop("wh.wade")
for b in list(pkt["bom"]):
item = b["item"]
cur = line_of(pkt, item)
pkt = ok(c.patch(f"/api/packets/{pkt['id']}/bom/{item}", json={"pulled": True}, headers=im(cur["row_version"])))
cur = line_of(pkt, item)
pkt = ok(
c.patch(
f"/api/packets/{pkt['id']}/bom/{item}", json={"heat": f"HT-{item}0"}, headers=im(cur["row_version"])
)
)
return pkt
def release(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
out = ok(step(shop("wh.wade"), pkt, "wh_release"))
assert out["status"] == "ready" and out["dept"]["code"] == "03", out["status"]
return out
def ready_packet(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
"""Draft, warehouse, every line pulled with a heat, released: `ready` in 03 with an open stage."""
pkt = to_warehouse(shop, draft(shop, unit_id, **kw))
return release(shop, pull_all(shop, pkt))
def ready_skipped(shop: Shop, unit_id: int | None = None, dept: str = "03", **kw: Any) -> dict[str, Any]:
"""A draft sent straight to a department (wh_skipped true)."""
pkt = draft(shop, unit_id, **kw)
out = ok(step(shop("clerk.clara"), pkt, "release_to_shop", dept=dept))
assert out["status"] == "ready" and out["wh_skipped"] is True
return out
def to_station(
shop: Shop,
pkt: dict[str, Any],
*,
by: str = "sup.wes",
welder: str = "weld.andy",
dept: str = "03",
station: str = "Stall 1",
**kw: Any,
) -> dict[str, Any]:
"""ASSIGN the packet to a station and a welder."""
out = ok(step(shop(by), pkt, "assign", station_id=shop.stall(dept, station), welder_id=shop.uid(welder), **kw))
assert out["status"] == "assigned"
return out
def start(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy") -> dict[str, Any]:
out = ok(step(shop(by), pkt, "start"))
assert out["status"] == "in_progress"
return out
def add_weld(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", **over: Any) -> dict[str, Any]:
body: dict[str, Any] = {"page": 1, "fx": 0.5, "fy": 0.5, "items": [1, 2], "type": "Fillet", "process": "GMAW"}
body.update(over)
return ok(shop(by).post(f"/api/packets/{pkt['id']}/welds", json=body))
def tick_all(
shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", keys: tuple[str, ...] = WELD_KEYS
) -> dict[str, Any]:
for k in keys:
pkt = ok(shop(by).put(f"/api/packets/{pkt['id']}/stage/checklist/{k}", json={"done": True}))
return pkt
def set_filler(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", filler: str = "ER70S-6") -> dict[str, Any]:
return ok(shop(by).patch(f"/api/packets/{pkt['id']}/stage", json={"set": {"filler": filler}, "base": {}}))
def finish_stage(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", welds: int = 1) -> dict[str, Any]:
"""Every checklist key ticked, `welds` welds marked, the filler set: SUBMIT can pass."""
keys = WELD_KEYS if pkt["dept"]["weld"] else PLAIN_KEYS
pkt = tick_all(shop, pkt, by, keys)
for _ in range(welds):
pkt = add_weld(shop, pkt, by)
return set_filler(shop, pkt, by)
def submit(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy") -> dict[str, Any]:
out = ok(step(shop(by), pkt, "submit", sign=SIGN))
assert out["status"] == "review"
return out
def in_progress(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
return start(shop, to_station(shop, ready_packet(shop, unit_id, **kw)))
def in_review(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
pkt = in_progress(shop, unit_id, **kw)
return submit(shop, finish_stage(shop, pkt))
def issue_co(shop: Shop, pkt: dict[str, Any], required: list[str], **over: Any) -> dict[str, Any]:
"""A manual change order by clerk.clara (approved at once, holds until every required department verifies)."""
body = {"reason": "Nozzle moved 2 in on rev C", "required_depts": required, "sign": SIGN, **over}
return ok(shop("clerk.clara").post(f"/api/packets/{pkt['id']}/change-orders", json=body))
def verify(shop: Shop, pkt: dict[str, Any], no: int, dept: str, by: str, **over: Any) -> Any:
body = {"dept": dept, "note": "Reviewed with the crew", "sign": SIGN, **over}
return shop(by).post(f"/api/packets/{pkt['id']}/change-orders/{no}/verify", json=body)
+431
View File
@@ -0,0 +1,431 @@
"""Phase 5 change-order approval applies the staged VISUAL change: new pages added with the change order, old pages
voided (void_co_id, void_at, void_by, void_new_n), rev and drawing set, work orders remapped, the BOM merged
keeping the shop's values (heat, na, issued, loc, pulled, pulled_at, pulled_by, the MTR and the open short) on
lines matched by vkey or by item label plus part; the required departments, the hold text, the verifications
with their texts (insert-only, signed, a supervisor of the department only), and the heat table copied to the
new sheet with carried_from. A minor change order needs no verification.
The change order row is inserted by SQL in the shape the feed writes (feed/changes.py vis_for and open_or_reaim,
drawing_lookup staged). Nothing here reads VISUAL or M-Files."""
from __future__ import annotations
import io
from collections.abc import Iterator
from typing import Any
import psycopg
import pytest
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import app_conn, needs_pg
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from shophelpers import (
IT,
SIGN,
Shop,
add_weld,
all_rows,
find_log,
fresh,
in_progress,
line_of,
ok,
one,
open_shop,
refused,
set_filler,
tick_all,
)
from packetstation.settings import Settings
pytestmark = needs_pg
FP = "fp-co-test-0001"
NEW_DWG = "D-5001A"
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def landscape_pdf(pages: int = 2) -> bytes:
"""A PDF that differs from the packet's drawing (so the upload is a new asset, not a dedupe)."""
from pypdf import PdfWriter
w = PdfWriter()
for _ in range(pages):
w.add_blank_page(width=792, height=612)
buf = io.BytesIO()
w.write(buf)
return buf.getvalue()
def stage_asset(shop: Shop) -> dict[str, Any]:
r = shop("clerk.clara").post(
"/api/assets",
files={"file": ("D-5001A_C.pdf", landscape_pdf(), "application/pdf")},
data={"purpose": "drawing"},
)
assert r.status_code == 201, r.text
out: dict[str, Any] = r.json()
assert out["pages"] == 2
return out
def vis_bom_line(item: str, qty: str, part: str, desc: str, vkey: str, **over: Any) -> dict[str, Any]:
line = {
"item": item,
"qty": qty,
"part_id": part,
"desc": desc,
"loc": "VIS-LOC",
"whs": "MAIN",
"comm": "",
"cons": False,
"sub": False,
"na": False,
"grp": None,
"vkey": vkey,
"issued": "0",
"istat": None,
}
line.update(over)
return line
def leg(base: str, qty: str, tgt: str) -> dict[str, Any]:
return {
"wo_base": base,
"lot": "001",
"split": "0",
"sub": "0",
"seq": None,
"op": "WELD-PART",
"qty": qty,
"target_hrs": tgt,
"dwg": None,
"rev": None,
"partspecs": None,
"st": "R",
}
def insert_feed_co(
shop: Shop,
pid: int,
*,
vis: dict[str, Any],
staged: dict[str, Any] | None,
minor: bool = False,
reason: str = "VISUAL changed the drawing: D-5001 -> D-5001A Rev B -> Rev C",
) -> tuple[int, int]:
"""A pending VISUAL change order, as the feed and the drawing job leave it. Returns (co id, co_no)."""
with app_conn(shop.settings) as conn:
no = conn.execute(
"UPDATE packets SET next_co_no = next_co_no + 1 WHERE id = %s RETURNING next_co_no - 1", (pid,)
).fetchone()[0]
co_id = conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review, minor, ecn, reason, issued_by, vis, waiting,"
" staged, target_fp) VALUES (%s, %s, 'visual', 'pending', %s, 'VISUAL', %s, NULL, %s, NULL, %s, %s)"
" RETURNING id",
(pid, no, minor, reason, Jsonb(vis), Jsonb(staged) if staged else None, FP),
).fetchone()[0]
return int(co_id), int(no)
def bom_rows(shop: Shop, pid: int) -> dict[int, dict[str, Any]]:
with app_conn(shop.settings) as conn, conn.cursor(row_factory=dict_row) as cur:
return {int(r["item"]): dict(r) for r in cur.execute("SELECT * FROM bom_lines WHERE packet_id = %s", (pid,))}
def held(no: int, names: str) -> str:
return f"Change order CO-{no} must be verified first by: {names}."
def prepared_packet(shop: Shop) -> tuple[dict[str, Any], dict[int, dict[str, Any]], str]:
"""An in_progress packet in 03 with VISUAL-shaped BOM lines, a short on line 2, an MTR on line 1, a weld and a
heat table on page 1. Returns (packet, bom rows before, the MTR asset id)."""
pkt = in_progress(shop, drawing="D-5001", rev="B")
pid = pkt["id"]
andy = shop("weld.andy")
pkt = add_weld(shop, pkt)
pkt = ok(andy.put(f"/api/packets/{pid}/heat-table/1", json={"x": 0.2, "y": 0.3, "s": 1.15}))
mtr = pkt["pages"][0]["asset_id"]
with app_conn(shop.settings) as conn:
conn.execute(
"UPDATE bom_lines SET src = 'visual', vkey = 'VK-1', cl_item = '1', loc = 'Rack 4', issued = 1,"
" mtr_asset_id = %s WHERE packet_id = %s AND item = 1",
(mtr, pid),
)
conn.execute(
"UPDATE bom_lines SET src = 'visual', vkey = NULL, cl_item = '2', issued = 1 WHERE packet_id = %s"
" AND item = 2",
(pid,),
)
conn.execute(
"UPDATE bom_lines SET src = 'visual', vkey = 'VK-3', cl_item = '3' WHERE packet_id = %s AND item = 3",
(pid,),
)
line2 = conn.execute("SELECT id FROM bom_lines WHERE packet_id = %s AND item = 2", (pid,)).fetchone()[0]
conn.execute(
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open, reason, ref) VALUES (%s, %s, 1, 1,"
" 'Not in stock', 'PO-4471')",
(pid, line2),
)
before = bom_rows(shop, pid)
assert all(b["pulled"] and b["pulled_at"] and b["pulled_by"] for b in before.values()), before
return fresh(andy, pid), before, mtr
def drawing_vis() -> dict[str, Any]:
return {
"from_dwg": "D-5001",
"from_rev": "B",
"to_dwg": NEW_DWG,
"to_rev": "C",
"dwg_change": True,
"changes": [],
"sig": FP,
"legs": [leg("L670001", "3", "2"), leg("L670003", "1", "1.25")],
"bom": [
vis_bom_line("1", "2", "PIPE-4", "4in pipe SCH80", "VK-1", loc="VIS-A", na=False, issued="0"),
vis_bom_line("2", "3", "GSKT-4", "4in gasket spiral", "VK-2-NEW"),
vis_bom_line("4", "6", "NUT-58", "5/8 heavy hex nut", "VK-4"),
],
"want": None,
"rel": None,
"folded": [],
"partspecs": [],
"fp_parts": {},
}
def test_feed_co_approval_applies_the_change(shop: Shop) -> None:
s = shop.settings
pkt, before, mtr = prepared_packet(shop)
pid = pkt["id"]
wo_before = {w["wo_base"]: w["id"] for w in pkt["work_orders"]}
asset = stage_asset(shop)
staged = {"asset_id": asset["id"], "dwg": NEW_DWG, "rev": "C", "name": "D-5001A_C.pdf", "sha256": asset["sha256"]}
co_id, no = insert_feed_co(shop, pid, vis=drawing_vis(), staged=staged)
assert no == 1
# pending without hold: nothing held yet
assert fresh(shop("weld.andy"), pid)["open_cos"] == []
clara = shop("clerk.clara")
refused(clara.post(f"/api/packets/{pid}/change-orders/1/approve", json={}), 400, "signature_required")
refused(shop("sup.wes").post(f"/api/packets/{pid}/change-orders/1/approve", json={"sign": SIGN}), 403, "forbidden")
out = ok(clara.post(f"/api/packets/{pid}/change-orders/1/approve", json={"sign": SIGN}))
clara_id = shop.uid("clerk.clara")
# header
assert (out["drawing"], out["rev"], out["drawing_state"]) == (NEW_DWG, "C", "attached")
assert one(s, "SELECT visual_fp_applied FROM packets WHERE id = %s", (pid,))[0] == FP
# pages: 1 and 2 voided pointing at 3; 3 and 4 new, carrying the change order
pages = {p["n"]: p for p in out["pages"]}
assert sorted(pages) == [1, 2, 3, 4]
for n in (1, 2):
v = pages[n]["void"]
assert v is not None and v["co_id"] == co_id and v["co_no"] == 1 and v["at"]
assert v["by"] == {"id": clara_id, "display_name": "Clara Clerk"}
assert v["new"] == {"n": 3, "dwg": NEW_DWG, "rev": "C"}
for n in (3, 4):
assert pages[n]["void"] is None and pages[n]["asset_id"] == asset["id"]
assert (pages[n]["dwg"], pages[n]["rev"]) == (NEW_DWG, "C")
rows = all_rows(
s,
"SELECT n, void_co_id, void_at IS NOT NULL, void_by::text, void_new_n, co_id FROM packet_pages"
" WHERE packet_id = %s ORDER BY n",
(pid,),
)
assert [tuple(r) for r in rows] == [
(1, co_id, True, clara_id, 3, None),
(2, co_id, True, clara_id, 3, None),
(3, None, False, None, None, co_id),
(4, None, False, None, None, co_id),
]
items = all_rows(s, "SELECT old_page, new_page, added FROM co_items WHERE co_id = %s ORDER BY id", (co_id,))
assert [tuple(r) for r in items] == [(1, 3, False), (2, 4, False)]
# work orders remapped: L670001 kept in place, L670003 added, L670002 removed
wos = {w["wo_base"]: w for w in out["work_orders"]}
assert set(wos) == {"L670001", "L670003"}
assert wos["L670001"]["id"] == wo_before["L670001"] and float(wos["L670001"]["qty"]) == 3
assert float(wos["L670003"]["target_hrs"]) == pytest.approx(1.25)
assert float(out["target_hrs"]) == pytest.approx(3.25)
# the BOM merge keeps the shop's values on matched lines
after = bom_rows(shop, pid)
one_b, one_a = before[1], after[1]
assert one_a["id"] == one_b["id"], "line 1 matched on vkey"
assert (float(one_a["qty"]), one_a["description"]) == (2.0, "4in pipe SCH80")
# the old coKeepShop: a value the shop recorded wins; an empty one takes VISUAL's
for k in ("heat", "na", "issued", "loc", "pulled", "pulled_at", "pulled_by", "mtr_asset_id"):
assert one_a[k] == one_b[k], (k, one_b[k], one_a[k])
assert str(one_a["mtr_asset_id"]) == mtr
two_b, two_a = before[2], after[2]
assert two_a["id"] == two_b["id"], "line 2 matched on item label plus part"
assert (float(two_a["qty"]), two_a["description"]) == (3.0, "4in gasket spiral")
for k in ("heat", "na", "issued", "pulled", "pulled_at", "pulled_by"):
assert two_a[k] == two_b[k], (k, two_b[k], two_a[k])
assert two_b["loc"] is None and two_a["loc"] == "VIS-LOC" # empty in the shop: VISUAL's value
short = one(s, "SELECT bom_line_id, qty_open, closed_at FROM bom_shorts WHERE packet_id = %s", (pid,))
assert (short[0], float(short[1]), short[2]) == (two_b["id"], 1.0, None)
assert line_of(out, 2)["short"] is not None and line_of(out, 2)["short"]["reason"] == "Not in stock"
# dropped by VISUAL after it was pulled: kept as N/A
assert after[3]["id"] == before[3]["id"] and after[3]["na"] is True and after[3]["heat"] == before[3]["heat"]
# new from VISUAL
four = after[4]
assert (four["part_id"], four["src"], four["pulled"], four["vkey"]) == ("NUT-58", "visual", False, "VK-4")
# the heat table follows to the new sheet; welds and tags stay on the VOID sheet
tables = {h["page"]: h for h in out["heat_table"]}
assert set(tables) == {1, 3}
assert (tables[3]["x"], tables[3]["y"], tables[3]["s"], tables[3]["carried_from"]) == (0.2, 0.3, 1.15, 1)
assert tables[1]["carried_from"] is None
log = find_log(clara, pid, "Heat table copied to new sheet")
assert log["note"] == "from the VOID copy, check its position on the new revision"
assert [w["page"] for w in out["welds"]] == [1]
appr = find_log(clara, pid, "Change order CO-1 approved")
assert appr["signed"] is True
# required: the touched departments without Clerical (WH and 03)
co = out["change_orders"][0]
assert co["review"] == "approved" and co["label"] == "CO-1" and co["open"] is True
assert sorted(co["required"]) == ["03", "WH"] and sorted(co["pending_depts"]) == ["03", "WH"]
req = all_rows(s, "SELECT dept_code FROM co_required_depts WHERE co_id = %s ORDER BY dept_code", (co_id,))
assert [r[0] for r in req] == ["03", "WH"]
assert out["open_cos"] == [1]
# the hold: SUBMIT waits for every verification; ASSIGN and START are never held
andy = shop("weld.andy")
pkt = set_filler(shop, tick_all(shop, fresh(andy, pid)))
both = (held(1, "Warehouse, 03 Weld Parts"), held(1, "03 Weld Parts, Warehouse"))
mine = fresh(andy, pid)
assert "submit" not in mine["can"] and mine["blocked"]["submit"][0] in both
r = refused(step(andy, pkt, "submit", sign=SIGN), 403, "co_hold")
assert r["detail"] in both
# a lead cannot verify; a supervisor of another department cannot verify 03
refused(verify_call(shop, pid, "lead.lou", "03"), 403, "forbidden")
refused(verify_call(shop, pid, "multi.mia", "03"), 403, "forbidden")
refused(verify_call(shop, pid, "sup.wes", "WH"), 403, "forbidden")
refused(verify_call(shop, pid, "sup.wes", "03", sign=None), 400, "signature_required")
refused(verify_call(shop, pid, "admin.ann", "09", it_reason=IT), 422, "invalid_dept")
out = ok(verify_call(shop, pid, "sup.wes", "03", note="Checked rev C"))
log = find_log(shop("sup.wes"), pid, "Change order CO-1 verified by Weld Parts")
assert (log["note"], log["signed"]) == ("Checked rev C · still waiting on Warehouse", True)
v = out["change_orders"][0]["verified"]["03"]
assert v["by"]["display_name"] == "Wes Super" and v["signed"] is True and v["note"] == "Checked rev C"
assert out["change_orders"][0]["pending_depts"] == ["WH"]
refused(verify_call(shop, pid, "sup.wes", "03"), 409, "already_verified")
mine = fresh(andy, pid)
assert mine["blocked"]["submit"] == [held(1, "Warehouse")]
# verifications are insert-only for the app role
with app_conn(s) as conn, pytest.raises(psycopg.Error):
conn.execute("UPDATE co_verifications SET note = 'changed' WHERE co_id = %s", (co_id,))
with app_conn(s) as conn, pytest.raises(psycopg.Error):
conn.execute("DELETE FROM co_verifications WHERE co_id = %s", (co_id,))
# the warehouse department, by the admin with an IT reason: fully verified, SUBMIT passes
refused(verify_call(shop, pid, "admin.ann", "WH", note="WH ok"), 400, "it_reason_required")
out = ok(verify_call(shop, pid, "admin.ann", "WH", note="WH ok", it_reason=IT))
log = find_log(shop("sup.wes"), pid, "Change order CO-1 verified by Warehouse")
assert log["note"] == "WH ok · change order fully verified"
assert out["open_cos"] == [] and out["change_orders"][0]["open"] is False
mine = fresh(andy, pid)
assert "submit" in mine["can"] and "submit" not in mine["blocked"]
out = ok(step(andy, mine, "submit", sign=SIGN))
assert out["status"] == "review"
def verify_call(
shop: Shop,
pid: int,
sam: str,
dept: str,
*,
note: str | None = None,
sign: Any = SIGN,
it_reason: str | None = None,
) -> Any:
body: dict[str, Any] = {"dept": dept}
if note is not None:
body["note"] = note
if sign is not None:
body["sign"] = sign
if it_reason is not None:
body["it_reason"] = it_reason
return shop(sam).post(f"/api/packets/{pid}/change-orders/1/verify", json=body)
def test_feed_co_waits_for_the_staged_drawing(shop: Shop) -> None:
pkt, _, _ = prepared_packet(shop)
insert_feed_co(shop, pkt["id"], vis=drawing_vis(), staged=None)
r = refused(
shop("clerk.clara").post(f"/api/packets/{pkt['id']}/change-orders/1/approve", json={"sign": SIGN}),
409,
"co_not_staged",
)
assert r["detail"] == "The new drawing has not been fetched from M-Files yet."
after = fresh(shop("clerk.clara"), pkt["id"])
assert after["rev"] == "B" and all(p["void"] is None for p in after["pages"])
def test_minor_co_needs_no_verification(shop: Shop) -> None:
pkt = in_progress(shop)
pid = pkt["id"]
vis = {
"from_dwg": "D-5001",
"from_rev": "B",
"to_dwg": "D-5001",
"to_rev": "B",
"dwg_change": False,
"changes": ["target hours 2 -> 3"],
"sig": FP,
"legs": [leg("L670001", "2", "3"), leg("L670002", "1", "2.5")],
"bom": None,
"want": None,
"rel": None,
"folded": [],
"partspecs": [],
"fp_parts": {},
}
co_id, no = insert_feed_co(shop, pid, vis=vis, staged=None, minor=True, reason="VISUAL changed the packet: hours")
out = ok(shop("clerk.clara").post(f"/api/packets/{pid}/change-orders/{no}/approve", json={"sign": SIGN}))
co = out["change_orders"][0]
assert co["review"] == "approved" and co["required"] == [] and co["pending_depts"] == [] and co["open"] is False
assert out["open_cos"] == [] and all(p["void"] is None for p in out["pages"])
assert float(next(w for w in out["work_orders"] if w["wo_base"] == "L670001")["target_hrs"]) == 3
assert one(shop.settings, "SELECT count(*) FROM co_required_depts WHERE co_id = %s", (co_id,))[0] == 0
# nothing to verify, so a verify is refused and SUBMIT is not held
refused(verify_call(shop, pid, "sup.wes", "03"), 422, "invalid_dept")
pkt = set_filler(shop, add_weld(shop, tick_all(shop, fresh(shop("weld.andy"), pid))))
assert "submit" in fresh(shop("weld.andy"), pid)["can"]
def test_co_hold_text_while_clerical_reviews(shop: Shop) -> None:
pkt = in_progress(shop)
pid = pkt["id"]
_, no = insert_feed_co(shop, pid, vis=drawing_vis(), staged=None)
ok(shop("clerk.clara").post(f"/api/packets/{pid}/change-orders/{no}/hold", json={"hold": True}))
log = find_log(shop("clerk.clara"), pid, f"Change order CO-{no} holds the packet")
assert log["note"] is None
pkt = set_filler(shop, add_weld(shop, tick_all(shop, fresh(shop("weld.andy"), pid))))
text = f"CO-{no}: Clerical is reviewing a change from VISUAL. Held until Clerical approves or rejects it."
refused(step(shop("weld.andy"), pkt, "submit", sign=SIGN), 403, "co_hold", text)
out = ok(shop("clerk.clara").post(f"/api/packets/{pid}/change-orders/{no}/hold", json={"hold": False}))
assert find_log(shop("clerk.clara"), pid, f"Change order CO-{no} no longer holds the packet")
assert out["open_cos"] == []
# rejected with a reason: the text names it
reason = "Rev C is not released yet"
ok(shop("clerk.clara").post(f"/api/packets/{pid}/change-orders/{no}/reject", json={"reason": reason, "sign": SIGN}))
log = find_log(shop("clerk.clara"), pid, f"Change order CO-{no} rejected")
assert log["note"] == reason
+4 -3
View File
@@ -31,8 +31,8 @@ def test_fan_out_presence_and_session_targeting() -> None:
assert [n for n, _ in got_a] == ["presence", "presence"] assert [n for n, _ in got_a] == ["presence", "presence"]
assert got_a[-1][1] == { assert got_a[-1][1] == {
"users": [ "users": [
{"user_id": a.user_id, "display_name": "Ann", "screen": "admin"}, {"user_id": a.user_id, "display_name": "Ann", "screen": "admin", "packet_id": None},
{"user_id": b.user_id, "display_name": "Bo", "screen": "stall"}, {"user_id": b.user_id, "display_name": "Bo", "screen": "stall", "packet_id": None},
] ]
} }
await _drain(b.queue) await _drain(b.queue)
@@ -52,7 +52,8 @@ def test_fan_out_presence_and_session_targeting() -> None:
hub.unsubscribe(b) hub.unsubscribe(b)
got = await _drain(a.queue) got = await _drain(a.queue)
assert got == [("presence", {"users": [{"user_id": a.user_id, "display_name": "Ann", "screen": "admin"}]})] users = [{"user_id": a.user_id, "display_name": "Ann", "screen": "admin", "packet_id": None}]
assert got == [("presence", {"users": users})]
asyncio.run(run()) asyncio.run(run())
+6 -3
View File
@@ -296,7 +296,7 @@ def test_welds(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, i
weld = {"page": 1, "fx": 0.25, "fy": 0.5, "wo_base": "L670001", "piece": 1, "items": [2, 1], "type": "Fillet"} weld = {"page": 1, "fx": 0.25, "fy": 0.5, "wo_base": "L670001", "piece": 1, "items": [2, 1], "type": "Fillet"}
r1 = c.post(f"/api/packets/{pid}/welds", json={**weld, "size": "1/4"}) r1 = c.post(f"/api/packets/{pid}/welds", json={**weld, "size": "1/4"})
assert r1.status_code == 200, r1.text assert r1.status_code == 200, r1.text
r2 = c.post(f"/api/packets/{pid}/welds", json={**weld, "type": "Butt"}) r2 = c.post(f"/api/packets/{pid}/welds", json={**weld, "type": "Groove"})
assert r2.status_code == 200, r2.text assert r2.status_code == 200, r2.text
welds = r2.json()["welds"] welds = r2.json()["welds"]
assert [w["weld_no"] for w in welds] == [1, 2] assert [w["weld_no"] for w in welds] == [1, 2]
@@ -355,7 +355,10 @@ def test_heat_tags_and_heat_table(pg_client2: TestClient, pg_settings: Settings,
url = f"/api/packets/{pid}/heat-table/1" url = f"/api/packets/{pid}/heat-table/1"
r1 = c.put(url, json={"x": 0.7, "y": 0.1, "s": 1.0}) r1 = c.put(url, json={"x": 0.7, "y": 0.1, "s": 1.0})
assert r1.status_code == 200 and r1.json()["heat_table"] == [{"page": 1, "x": 0.7, "y": 0.1, "s": 1.0}] assert r1.status_code == 200
ht = r1.json()["heat_table"]
assert [{k: h[k] for k in ("page", "x", "y", "s")} for h in ht] == [{"page": 1, "x": 0.7, "y": 0.1, "s": 1.0}]
assert ht[0]["carried_from"] is None and ht[0]["by"]["display_name"] == "Wade Warehouse"
r2 = c.put(url, json={"x": 0.7, "y": 0.1, "s": 1.0}) r2 = c.put(url, json={"x": 0.7, "y": 0.1, "s": 1.0})
assert r2.status_code == 200 and r2.json()["version"] == r1.json()["version"] assert r2.status_code == 200 and r2.json()["version"] == r1.json()["version"]
r3 = c.put(url, json={"x": 0.6, "y": 0.1, "s": 1.2}) r3 = c.put(url, json={"x": 0.6, "y": 0.1, "s": 1.2})
@@ -536,7 +539,7 @@ def test_change_orders(pg_client2: TestClient, pg_settings: Settings, unit: tupl
as_user(c, "weld.andy") as_user(c, "weld.andy")
r = step(c, get(c, pid), "submit", sign=SIGN) r = step(c, get(c, pid), "submit", sign=SIGN)
assert r.status_code == 403 and r.json()["code"] == "co_hold", r.text assert r.status_code == 403 and r.json()["code"] == "co_hold", r.text
assert "Change order 1" in r.json()["detail"] assert "Change order CO-1" in r.json()["detail"]
vurl = f"/api/packets/{pid}/change-orders/1/verify" vurl = f"/api/packets/{pid}/change-orders/1/verify"
assert c.post(vurl, json={"dept": "03", "sign": SIGN}).status_code == 403 assert c.post(vurl, json={"dept": "03", "sign": SIGN}).status_code == 403
+1 -1
View File
@@ -313,7 +313,7 @@ def test_co_hold_blocks_filing(pg_client2: TestClient, pg_settings: Settings, un
assert pkt["open_cos"] == [1] and "file" in pkt["blocked"] assert pkt["open_cos"] == [1] and "file" in pkt["blocked"]
r = step(pg_client2, pkt, "file", sign=SIGN) r = step(pg_client2, pkt, "file", sign=SIGN)
assert r.status_code == 403 and r.json()["code"] == "co_hold" assert r.status_code == 403 and r.json()["code"] == "co_hold"
assert r.json()["detail"] == "Change order 1 is waiting for verification by Weld Parts." assert r.json()["detail"] == "Change order CO-1 must be verified first by: 03 Weld Parts."
as_user(pg_client2, "admin.ann") as_user(pg_client2, "admin.ann")
r = step(pg_client2, pkt, "file", sign=SIGN, it_reason="Close out the old job now") r = step(pg_client2, pkt, "file", sign=SIGN, it_reason="Close out the old job now")
assert r.status_code == 403 and r.json()["code"] == "co_hold" assert r.status_code == 403 and r.json()["code"] == "co_hold"
+574
View File
@@ -0,0 +1,574 @@
"""Phase 5 shop policy: every shop step as every test user from every status the helpers reach, with the expected
code (200, 403 forbidden, 403 wrong_status, 403 precondition, 400 it_reason_required, 400 reason_required, 400
signature_required); the assigned-welder and station-limit rules; lead versus supervisor; RETURN_TO_CLERICAL only
when wh_skipped; the change-order hold set; and the read model's `can` and `blocked` agreeing with the writes.
The expectations come from an oracle written from the contract (who, from-status, preconditions), not from the
engine, so the engine and the oracle check each other. Part one runs the pure policy; part two runs the API."""
from __future__ import annotations
import dataclasses
import itertools
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import app_conn, needs_pg
from psycopg.types.json import Jsonb
from shophelpers import (
IT,
MSG_CHECKLIST,
SIGN,
USERS,
Shop,
draft,
finish_stage,
fresh,
in_progress,
in_review,
issue_co,
ok,
open_shop,
ready_packet,
ready_skipped,
refused,
run,
set_limits,
start,
submit,
to_station,
to_warehouse,
verify,
)
from packetstation.policy import Action, PacketState, Principal, can, check, principal_from_roles
from packetstation.settings import Settings
A = Action
ANDY = "00000000-0000-0000-0000-00000000a001"
STALL1, STALL2 = 101, 102
SHOP_ACTIONS = (
"assign",
"unassign",
"pull_back",
"start",
"submit",
"return_to_station",
"send_to_qc",
"route_from_review",
"send_back_to_warehouse",
"return_to_clerical",
)
FROM: dict[str, frozenset[str]] = {
"assign": frozenset({"ready"}),
"unassign": frozenset({"assigned"}),
"pull_back": frozenset({"in_progress"}),
"start": frozenset({"assigned"}),
"submit": frozenset({"assigned", "in_progress"}),
"return_to_station": frozenset({"review"}),
"send_to_qc": frozenset({"review"}),
"route_from_review": frozenset({"review"}),
"send_back_to_warehouse": frozenset({"ready"}),
"return_to_clerical": frozenset({"ready"}),
}
WHO = {
"assign": "lead",
"unassign": "lead",
"pull_back": "lead",
"start": "works",
"submit": "works",
"return_to_station": "sup",
"send_to_qc": "sup",
"route_from_review": "sup",
"send_back_to_warehouse": "lead",
"return_to_clerical": "lead",
}
CO_HELD = frozenset({"submit", "send_to_qc", "route_from_review"})
REASON = "Weld 2 needs a repair pass"
# Shop levels in 03 per test user (multi.mia leads 06 only; both.bo is worker and supervisor, so supervisor).
LEVEL_03 = {"weld.andy": 1, "dup.dave": 1, "lead.lou": 2, "sup.wes": 3, "both.bo": 3}
ROLES = {
"admin.ann": ["admin"],
"clerk.clara": ["clerical"],
"clerk.leo": ["clerical_supervisor"],
"wh.wade": ["warehouse"],
"qc.quinn": ["qc"],
"pm.paula": ["pm"],
"weld.andy": ["shop:03:worker"],
"lead.lou": ["shop:03:lead"],
"sup.wes": ["shop:03:supervisor"],
"multi.mia": ["clerical", "shop:06:supervisor"],
"both.bo": ["shop:03:worker", "shop:03:supervisor"],
"dup.dave": ["shop:03:worker"],
}
@dataclasses.dataclass(frozen=True)
class Case:
"""A status the helpers reach, and what matters about it for the shop steps."""
name: str
status: str
skipped: bool = False # wh_skipped
finished: bool = False # checklist, a weld and the filler done
CASES = {
"warehouse": Case("warehouse", "warehouse"),
"ready": Case("ready", "ready"),
"ready_skipped": Case("ready_skipped", "ready", skipped=True),
"assigned": Case("assigned", "assigned"),
"in_progress": Case("in_progress", "in_progress"),
"in_progress_done": Case("in_progress_done", "in_progress", finished=True),
"review": Case("review", "review", finished=True),
"qc": Case("qc", "qc", finished=True),
}
def who_ok(user: str, action: str, *, welder: str | None = "weld.andy", limited_out: bool = False) -> bool:
level = LEVEL_03.get(user, 0)
kind = WHO[action]
if kind == "lead":
return level >= 2
if kind == "sup":
return level >= 3
return level >= 2 or (level == 1 and user == welder and not limited_out)
def expected(
user: str,
case: Case,
action: str,
*,
it_reason: bool = False,
co_hold: bool = False,
welder: str | None = "weld.andy",
limited_out: bool = False,
) -> tuple[int, str]:
"""The answer to the step with every request input given (station, welder, reason, book, spool, location,
dept, signature). The order is the engine's: status, CO hold, admin bypass, who, preconditions."""
if case.status not in FROM[action]:
return 403, "wrong_status"
if action == "return_to_clerical" and not case.skipped:
return 403, "wrong_status"
if co_hold and action in CO_HELD:
return 403, "co_hold"
allowed = who_ok(user, action, welder=welder, limited_out=limited_out)
if not allowed and user == "admin.ann":
if not it_reason:
return 400, "it_reason_required"
allowed = True
if not allowed:
return 403, "forbidden"
if action == "submit" and not case.finished:
return 403, "precondition"
return 200, ""
def in_can(user: str, case: Case, action: str, **kw: Any) -> bool:
"""`can` assumes the admin's IT reason and skips request inputs."""
return expected(user, case, action, it_reason=True, **kw)[0] == 200
def in_blocked(user: str, case: Case, action: str, **kw: Any) -> bool:
"""`blocked`: who and custody pass (or the admin bypass) but a state precondition or the CO hold fails."""
code = expected(user, case, action, it_reason=True, **kw)[1]
if code == "co_hold":
# The engine defers the CO hold in read mode and lists it only once who passes.
return who_ok(user, action, welder=kw.get("welder", "weld.andy")) or user == "admin.ann"
return code == "precondition"
# ================================================================ part one: the pure policy
def P(user: str, limits: tuple[int, ...] = ()) -> Principal:
uid = ANDY if user == "weld.andy" else f"00000000-0000-0000-0000-{abs(hash(user)) % 10**12:012d}"
return principal_from_roles(uid, ROLES[user], station_limits=limits)
def state(case: Case, **kw: Any) -> PacketState:
active = case.status in ("assigned", "in_progress", "review")
base = PacketState(
id=1,
status=case.status,
dept="03",
station_id=STALL1 if active else None,
welder_id=ANDY if active else None,
wh_skipped=case.skipped,
routed_to=None,
vclosed=False,
removed=False,
source_kind="hand",
weld_dept=True,
co_hold=False,
drawing_attached=True,
heat_map=True,
bom_total=3,
bom_ready=3,
welds=1 if case.finished else 0,
checklist_complete=case.finished,
filler_set=case.finished,
)
return dataclasses.replace(base, **kw)
EXTRA = {
"assign": {"station_id": STALL1, "welder_id": ANDY},
"return_to_station": {"reason": REASON},
"send_back_to_warehouse": {"reason": REASON},
"return_to_clerical": {"reason": REASON},
"send_to_qc": {"book": True, "spool": True, "location": "Bay 2"},
"route_from_review": {"dept": "09"},
}
@pytest.mark.parametrize(("user", "case", "action"), list(itertools.product(ROLES, CASES, SHOP_ACTIONS)))
def test_policy_matrix(user: str, case: str, action: str) -> None:
c = CASES[case]
s = state(c)
d = check(P(user), s, A(action), extra=EXTRA.get(action, {}))
want = expected(user, c, action)
code = "" if d.ok else d.code
assert code == want[1], (user, case, action, d)
allowed, blocked = can(P(user), s)
assert (A(action) in allowed) == in_can(user, c, action), (user, case, action, allowed)
assert (A(action) in blocked) == in_blocked(user, c, action), (user, case, action, blocked)
@pytest.mark.parametrize("user", list(ROLES))
@pytest.mark.parametrize("case", ["ready", "assigned", "in_progress_done", "review"])
def test_policy_co_hold_set(user: str, case: str) -> None:
"""The CO hold blocks SUBMIT, SEND_TO_QC and ROUTE_FROM_REVIEW; never ASSIGN, START or the others."""
c = CASES[case]
why = "Change order CO-2 must be verified first by: 03 Weld Parts."
s = state(c, co_hold=True, co_hold_why=why)
allowed, blocked = can(P(user), s)
for action in SHOP_ACTIONS:
d = check(P(user), s, A(action), extra=EXTRA.get(action, {}))
want = expected(user, c, action, co_hold=True)
assert ("" if d.ok else d.code) == want[1], (user, case, action, d)
if want[1] == "co_hold":
assert d.why == why
if in_blocked(user, c, action, co_hold=True):
assert blocked[A(action)][0] == why
assert (A(action) in allowed) == in_can(user, c, action, co_hold=True), (user, case, action)
@pytest.mark.parametrize("action", ["start", "submit"])
def test_policy_assigned_welder_rules(action: str) -> None:
c = CASES["in_progress_done"] if action == "submit" else CASES["assigned"]
s = state(c)
assert check(P("weld.andy"), s, A(action)).ok
# another worker in the department is not the assigned welder
assert check(P("dup.dave"), s, A(action)).code == "forbidden"
# the assigned welder limited to another station
assert check(P("weld.andy", (STALL2,)), s, A(action)).code == "forbidden"
assert check(P("weld.andy", (STALL1, STALL2)), s, A(action)).ok
# no welder recorded: a worker cannot, a lead can
nobody = dataclasses.replace(s, welder_id=None)
assert check(P("weld.andy"), nobody, A(action)).code == "forbidden"
assert check(P("lead.lou"), nobody, A(action)).ok
# lead and above pass without being the welder
for user in ("lead.lou", "sup.wes", "both.bo"):
assert check(P(user), s, A(action)).ok, user
def test_policy_lead_versus_supervisor() -> None:
s = state(CASES["review"])
for action in ("send_to_qc", "route_from_review", "return_to_station"):
d = check(P("lead.lou"), s, A(action), extra=EXTRA[action])
assert d.code == "forbidden", action
assert check(P("sup.wes"), s, A(action), extra=EXTRA[action]).ok, action
allowed, blocked = can(P("lead.lou"), s)
assert not {A.SEND_TO_QC, A.ROUTE_FROM_REVIEW, A.RETURN_TO_STATION} & set(allowed)
assert not {A.SEND_TO_QC, A.ROUTE_FROM_REVIEW, A.RETURN_TO_STATION} & set(blocked)
def test_policy_return_to_clerical_only_when_skipped() -> None:
body = EXTRA["return_to_clerical"]
d = check(P("lead.lou"), state(CASES["ready"]), A.RETURN_TO_CLERICAL, extra=body)
assert d.code == "wrong_status"
assert d.why == "This packet came through the warehouse. Send it back to the warehouse instead."
assert check(P("lead.lou"), state(CASES["ready_skipped"]), A.RETURN_TO_CLERICAL, extra=body).ok
d = check(P("lead.lou"), state(CASES["ready_skipped"]), A.RETURN_TO_CLERICAL, extra={})
assert d.code == "reason_required"
# ================================================================ part two: the API
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def body_for(shop: Shop, action: str) -> dict[str, Any]:
"""Every request input the step can need."""
return {
"assign": {"station_id": shop.stall(), "welder_id": shop.uid("weld.andy")},
"submit": {"sign": SIGN},
"return_to_station": {"reason": REASON},
"send_to_qc": {"book": True, "spool": True, "location": "Bay 2", "sign": SIGN},
"route_from_review": {"dept": "09", "sign": SIGN},
"send_back_to_warehouse": {"reason": REASON},
"return_to_clerical": {"reason": REASON},
}.get(action, {})
def build(shop: Shop, case: str) -> dict[str, Any]:
"""A packet in the case's status, made through the API."""
if case == "warehouse":
return to_warehouse(shop, draft(shop))
if case == "ready":
return ready_packet(shop)
if case == "ready_skipped":
return ready_skipped(shop)
if case == "assigned":
return to_station(shop, ready_packet(shop))
if case == "in_progress":
return in_progress(shop)
if case == "in_progress_done":
return finish_stage(shop, in_progress(shop))
if case == "review":
return in_review(shop)
if case == "qc":
pkt = in_review(shop)
return ok(step(shop("sup.wes"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
raise AssertionError(case)
@needs_pg
@pytest.mark.parametrize("case", list(CASES))
def test_api_matrix(shop: Shop, case: str) -> None:
"""Every refused step answers the oracle's code and changes nothing; `can` and `blocked` agree for every user.
Steps the oracle allows are not sent here (they would move the packet); test_api_success sends each one."""
c = CASES[case]
pkt = build(shop, case)
assert pkt["status"] == c.status and pkt["wh_skipped"] == c.skipped
for user in USERS:
mine = fresh(shop(user), pkt["id"])
for action in SHOP_ACTIONS:
want = expected(user, c, action)
assert (action in mine["can"]) == in_can(user, c, action), (user, case, action, mine["can"])
assert (action in mine["blocked"]) == in_blocked(user, c, action), (user, case, action, mine["blocked"])
if want[0] == 200:
continue
r = step(shop(user), pkt, action, **body_for(shop, action))
assert (r.status_code, r.json().get("code")) == want, (user, case, action, r.text)
if want[1] == "precondition" and action == "submit":
assert r.json()["detail"] == MSG_CHECKLIST
assert mine["blocked"]["submit"][0] == MSG_CHECKLIST
after = fresh(shop("admin.ann"), pkt["id"])
assert (after["status"], after["version"]) == (pkt["status"], pkt["version"])
SUCCESS = [
(case, action, user)
for case, action in (
("ready", "assign"),
("ready", "send_back_to_warehouse"),
("ready_skipped", "return_to_clerical"),
("assigned", "unassign"),
("assigned", "start"),
("assigned", "submit"),
("in_progress", "pull_back"),
("in_progress_done", "submit"),
("review", "return_to_station"),
("review", "send_to_qc"),
("review", "route_from_review"),
)
for user in USERS
if expected(user, CASES[case], action, it_reason=True)[0] == 200 and not (case == "assigned" and action == "submit")
]
TO_STATUS = {
"assign": "assigned",
"send_back_to_warehouse": "warehouse",
"return_to_clerical": "draft",
"unassign": "ready",
"start": "in_progress",
"submit": "review",
"pull_back": "ready",
"return_to_station": "assigned",
"send_to_qc": "qc",
"route_from_review": "ready",
}
@needs_pg
@pytest.mark.parametrize(("case", "action", "user"), SUCCESS)
def test_api_success(shop: Shop, case: str, action: str, user: str) -> None:
"""Each allowed user does each step; the admin only with an IT reason."""
pkt = build(shop, case)
assert action in fresh(shop(user), pkt["id"])["can"]
body = body_for(shop, action)
if user == "admin.ann":
refused(step(shop(user), pkt, action, **body), 400, "it_reason_required")
body["it_reason"] = IT
out = ok(step(shop(user), pkt, action, **body))
assert out["status"] == TO_STATUS[action]
if action == "route_from_review":
assert out["dept"]["code"] == "09"
@needs_pg
@pytest.mark.parametrize(
("case", "action"),
[
("ready", "send_back_to_warehouse"),
("ready_skipped", "return_to_clerical"),
("review", "return_to_station"),
],
)
def test_api_reason_required(shop: Shop, case: str, action: str) -> None:
pkt = build(shop, case)
for reason in (None, "", "short"):
body = {} if reason is None else {"reason": reason}
refused(step(shop("sup.wes"), pkt, action, **body), 400, "reason_required")
assert fresh(shop("sup.wes"), pkt["id"])["status"] == pkt["status"]
@needs_pg
@pytest.mark.parametrize(
("case", "action", "user"),
[
("in_progress_done", "submit", "weld.andy"),
("review", "send_to_qc", "sup.wes"),
("review", "route_from_review", "sup.wes"),
],
)
def test_api_signature_required(shop: Shop, case: str, action: str, user: str) -> None:
pkt = build(shop, case)
body = body_for(shop, action)
body.pop("sign")
r = refused(step(shop(user), pkt, action, **body), 400, "signature_required")
assert r.get("meaning")
assert fresh(shop(user), pkt["id"])["status"] == pkt["status"]
@needs_pg
def test_api_assigned_welder_and_station_limits(shop: Shop) -> None:
pkt = to_station(shop, ready_packet(shop))
# dup.dave works in 03 but is not the welder
refused(step(shop("dup.dave"), pkt, "start"), 403, "forbidden")
assert "start" not in fresh(shop("dup.dave"), pkt["id"])["can"]
# weld.andy limited to Stall 2 while the packet is at Stall 1
set_limits(shop.settings, "weld.andy", [shop.stall("03", "Stall 2")])
refused(step(shop("weld.andy"), pkt, "start"), 403, "forbidden")
mine = fresh(shop("weld.andy"), pkt["id"])
assert "start" not in mine["can"] and "start" not in mine["blocked"]
# the lead starts it (lead and above pass)
pkt = start(shop, pkt, by="lead.lou")
refused(step(shop("weld.andy"), pkt, "submit", sign=SIGN), 403, "forbidden")
# limits that include Stall 1: weld.andy works on it again
set_limits(shop.settings, "weld.andy", [shop.stall("03", "Stall 1"), shop.stall("03", "Stall 2")])
pkt = finish_stage(shop, pkt)
pkt = submit(shop, pkt, by="weld.andy")
# the lead may submit too: a second packet
other = finish_stage(shop, start(shop, to_station(shop, ready_packet(shop)), by="weld.andy"), by="lead.lou")
submit(shop, other, by="lead.lou")
@needs_pg
def test_api_lead_review_read_only(shop: Shop) -> None:
pkt = in_review(shop)
lou = fresh(shop("lead.lou"), pkt["id"])
for action in ("send_to_qc", "route_from_review", "return_to_station"):
assert action not in lou["can"] and action not in lou["blocked"], action
refused(step(shop("lead.lou"), pkt, action, **body_for(shop, action)), 403, "forbidden")
wes = fresh(shop("sup.wes"), pkt["id"])
assert {"send_to_qc", "route_from_review", "return_to_station"} <= set(wes["can"])
@needs_pg
def test_api_return_to_clerical_only_when_skipped(shop: Shop) -> None:
pkt = ready_packet(shop)
r = refused(step(shop("lead.lou"), pkt, "return_to_clerical", reason=REASON), 403, "wrong_status")
assert r["detail"] == "This packet came through the warehouse. Send it back to the warehouse instead."
skipped = ready_skipped(shop)
out = ok(step(shop("lead.lou"), skipped, "return_to_clerical", reason=REASON))
assert out["status"] == "draft" and out["return_note"] == REASON
def held_text(no: int, depts: str = "03 Weld Parts") -> str:
return f"Change order CO-{no} must be verified first by: {depts}."
@needs_pg
def test_api_co_hold_set(shop: Shop) -> None:
"""A CO requiring 03 holds SUBMIT, SEND_TO_QC and ROUTE_FROM_REVIEW, never ASSIGN or START."""
pkt = ready_packet(shop)
pkt = issue_co(shop, pkt, ["03"])
assert pkt["open_cos"] == [1]
wes = fresh(shop("sup.wes"), pkt["id"])
assert "assign" in wes["can"] and "assign" not in wes["blocked"]
pkt = to_station(shop, pkt)
andy = fresh(shop("weld.andy"), pkt["id"])
assert "start" in andy["can"]
pkt = finish_stage(shop, start(shop, pkt))
andy = fresh(shop("weld.andy"), pkt["id"])
assert "submit" not in andy["can"] and andy["blocked"]["submit"] == [held_text(1)]
refused(step(shop("weld.andy"), pkt, "submit", sign=SIGN), 403, "co_hold", held_text(1))
refused(step(shop("admin.ann"), pkt, "submit", sign=SIGN, it_reason=IT), 403, "co_hold", held_text(1))
ok(verify(shop, pkt, 1, "03", "sup.wes"))
pkt = submit(shop, fresh(shop("weld.andy"), pkt["id"]))
pkt = issue_co(shop, pkt, ["03"])
wes = fresh(shop("sup.wes"), pkt["id"])
for action in ("send_to_qc", "route_from_review"):
assert action not in wes["can"] and wes["blocked"][action] == [held_text(2)], action
refused(step(shop("sup.wes"), pkt, action, **body_for(shop, action)), 403, "co_hold", held_text(2))
assert "return_to_station" in wes["can"]
# a pending VISUAL change order with hold: both sentences, joined by a space
with_pending = _pending_feed_co(shop, pkt["id"], hold=True)
pending = (
f"CO-{with_pending}: Clerical is reviewing a change from VISUAL. Held until Clerical approves or rejects it."
)
wes = fresh(shop("sup.wes"), pkt["id"])
assert wes["blocked"]["send_to_qc"] == [f"{held_text(2)} {pending}"] or wes["blocked"]["send_to_qc"] == [
f"{pending} {held_text(2)}"
]
ok(verify(shop, pkt, 2, "03", "sup.wes"))
wes = fresh(shop("sup.wes"), pkt["id"])
assert wes["blocked"]["send_to_qc"] == [pending]
run(
shop.settings,
"UPDATE change_orders SET hold = false WHERE packet_id = %s AND co_no = %s",
(pkt["id"], with_pending),
)
wes = fresh(shop("sup.wes"), pkt["id"])
assert "send_to_qc" in wes["can"] and "send_to_qc" not in wes["blocked"]
ok(step(shop("sup.wes"), wes, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
def _pending_feed_co(shop: Shop, pid: int, *, hold: bool) -> int:
"""A pending VISUAL change order by SQL, as the feed opens one. Returns its number."""
with app_conn(shop.settings) as conn:
no = conn.execute(
"UPDATE packets SET next_co_no = next_co_no + 1 WHERE id = %s RETURNING next_co_no - 1", (pid,)
).fetchone()[0]
conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review, minor, hold, held_at, ecn, reason, vis)"
" VALUES (%s, %s, 'visual', 'pending', false, %s, CASE WHEN %s THEN now() END, 'VISUAL',"
" 'VISUAL changed the packet: qty 2 -> 3', %s)",
(pid, no, hold, hold, Jsonb({"dwg_change": False, "changes": ["qty 2 -> 3"]})),
)
return int(no)
@needs_pg
def test_api_stale_expect_after_pull_back(shop: Shop) -> None:
pkt = finish_stage(shop, in_progress(shop))
ok(step(shop("sup.wes"), pkt, "pull_back"))
r = refused(
step(shop("weld.andy"), pkt, "submit", sign=SIGN), 409, "stale_step", "Someone else already took this step."
)
assert r["current"]["status"] == "ready"
+30
View File
@@ -497,3 +497,33 @@ def test_notification_events(pg_settings: Settings) -> None:
) )
conn.execute("INSERT INTO notification_events (kind, subject) VALUES ('t_kind', 'assets')") conn.execute("INSERT INTO notification_events (kind, subject) VALUES ('t_kind', 'assets')")
assert one(conn, "SELECT count(*) FROM notification_events WHERE kind = 't_kind'") == 2 assert one(conn, "SELECT count(*) FROM notification_events WHERE kind = 't_kind'") == 2
def test_shop_columns_and_checklist_templates(pg_settings: Settings) -> None:
"""0006: label positions on welds and heat tags, carried_from on heat table placements, the checklist row."""
with db.connect_as("migrate", pg_settings, autocommit=True) as conn:
names = [r[0] for r in conn.execute("SELECT name FROM schema_migrations ORDER BY name").fetchall()]
assert "0006_shop.sql" in names
with app_conn(pg_settings) as conn:
for t, c, kind in (
("welds", "bfx", "double precision"),
("welds", "bfy", "double precision"),
("heat_tags", "bfx", "double precision"),
("heat_tags", "bfy", "double precision"),
("heat_table_placements", "carried_from", "integer"),
):
got = one(
conn,
"SELECT data_type FROM information_schema.columns WHERE table_name = %s AND column_name = %s",
(t, c),
)
assert got == kind, (t, c)
ids = seed(conn)
with pytest.raises(psycopg.errors.CheckViolation), conn.transaction():
conn.execute(
"INSERT INTO welds (packet_id, weld_no, items, bfx) VALUES (%s, 1, '{1}', 1.5)", (ids["packet"],)
)
value = one(conn, "SELECT value FROM settings WHERE key = 'checklist_templates'")
assert [i["key"] for i in value["weld"]] == ["dwg", "heat", "wps", "fit", "pre", "vt", "stamp", "clean", "qty"]
assert [i["key"] for i in value["plain"]] == ["dwg", "prev", "mat", "work", "insp", "qty"]
assert value["weld"][0]["text"] == "Drawing {drawing} Rev {rev} matches the packet"
+948
View File
@@ -0,0 +1,948 @@
"""Phase 5 shop API: the assign flow and its validations, the shop transitions with their exact log texts, the
SUBMIT preconditions one by one, routing with carried marks, the checklist and its templates, stage field CAS,
welds, heat tags and the heat table with their texts and validation, heat map borrowing, VISUAL labor hours, and
the shop board, stations, stall and people endpoints."""
from __future__ import annotations
import time
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import step
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg
from shophelpers import (
IT,
MSG_CHECKLIST,
MSG_FILLER,
MSG_OTHER_DEPT,
MSG_QC,
MSG_WELDS,
PLAIN_KEYS,
SIGN,
WELD_KEYS,
Shop,
add_weld,
all_rows,
draft,
find_log,
finish_stage,
fresh,
im,
in_progress,
in_review,
issue_co,
last_log,
log_rows,
make_unit,
ok,
one,
open_shop,
pull_all,
ready_packet,
ready_skipped,
refused,
release,
run,
set_filler,
set_limits,
set_running_since,
set_stamp,
set_templates,
start,
submit,
tag_of,
tick_all,
to_station,
to_warehouse,
weld_of,
)
from packetstation.settings import Settings
pytestmark = needs_pg
MSG_WELDER = "Pick a welder in this department."
MSG_VOID = "This sheet is VOID. Mark the current sheet."
MSG_TYPE = "Pick a weld type from the list."
MSG_PROCESS = "Pick a process from the list."
MSG_NOT_LEAD = "You do not lead a department."
SHOP_DEPTS = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14"]
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def page_label(pkt: dict[str, Any], n: int) -> str:
page = next(p for p in pkt["pages"] if p["n"] == n)
return page["dwg"] or f"sheet {n}"
# ================================================================ assign
def test_assign_validations(shop: Shop) -> None:
s = shop.settings
set_stamp(s, "weld.andy", "75")
pkt = ready_packet(shop)
wes = shop("sup.wes")
andy = shop.uid("weld.andy")
# a station of another department
r = step(wes, pkt, "assign", station_id=shop.stall("06", "Stall 1"), welder_id=andy)
refused(r, 422, "invalid_station")
# welders outside the department: Q.C., a supervisor of 06, Clerical
for sam in ("qc.quinn", "multi.mia", "clerk.clara"):
r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=shop.uid(sam))
refused(r, 422, "invalid_welder", MSG_WELDER)
# no welder or no station
refused(step(wes, pkt, "assign", station_id=shop.stall()), 403, "precondition", "Pick a station and a welder.")
# a worker limited to Stall 2
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2")])
r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=andy)
refused(r, 422, "station_limit", "Andy Welder is limited to Stall 2.")
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2"), shop.stall("03", "Stall 3")])
r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=andy)
refused(r, 422, "station_limit", "Andy Welder is limited to Stall 2, Stall 3.")
# a bad stamp override
set_limits(s, "weld.andy", [])
r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=andy, stamp="no good!")
refused(r, 422, "invalid_stamp")
assert fresh(wes, pkt["id"])["status"] == "ready"
# the stamp defaults from users.welder_stamp; the stage gets the same three values
out = to_station(shop, pkt)
assert out["station"]["name"] == "Stall 1" and out["welder"]["id"] == andy and out["welder"]["stamp"] == "75"
st = out["stage"]
assert st["station"]["id"] == shop.stall() and st["welder"]["id"] == andy and st["welder"]["stamp"] == "75"
log = last_log(wes, pkt["id"])
assert (log["action"], log["note"]) == ("Sent to Stall 1", "Andy Welder [75]")
assert log["by"]["display_name"] == "Wes Super"
# unassign clears them; a stamp override wins over the user's stamp
out = ok(step(wes, out, "unassign"))
assert out["status"] == "ready" and out["station"] is None and out["welder"] is None
assert out["stage"]["station"] is None and out["stage"]["welder"] is None
assert last_log(wes, pkt["id"])["action"] == "Taken off Stall 1"
out = to_station(shop, out, station="Stall 4", stamp="A7")
assert out["welder"]["stamp"] == "A7" and out["stage"]["welder"]["stamp"] == "A7"
assert (last_log(wes, pkt["id"])["action"], last_log(wes, pkt["id"])["note"]) == (
"Sent to Stall 4",
"Andy Welder [A7]",
)
assert one(s, "SELECT welder_stamp FROM users WHERE sam_account = 'weld.andy'")[0] == "75"
# station limits bind workers only: lead.lou limited to Stall 2 may still be put on Stall 1
out = ok(step(wes, out, "unassign"))
set_limits(s, "lead.lou", [shop.stall("03", "Stall 2")])
out = to_station(shop, out, welder="lead.lou")
assert out["welder"]["display_name"] == "Lou Lead"
log = last_log(wes, pkt["id"])
assert (log["action"], log["note"]) == ("Sent to Stall 1", "Lou Lead")
# the lead assigns too
out = ok(step(shop("lead.lou"), out, "unassign"))
to_station(shop, out, by="lead.lou")
def test_people(shop: Shop) -> None:
s = shop.settings
set_stamp(s, "weld.andy", "75")
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2")])
r = shop("weld.andy").get("/api/departments/03/people")
people = ok(r)
names = [p["display_name"] for p in people]
assert names == sorted(names)
by = {p["display_name"]: p for p in people}
assert {"Andy Welder", "Lou Lead", "Wes Super", "Bo Both", "Dave Dup"} <= set(by)
assert not {"Mia Multi", "Quinn QC", "Clara Clerk", "Ann Admin"} & set(by)
assert by["Andy Welder"] == {
"id": shop.uid("weld.andy"),
"display_name": "Andy Welder",
"stamp": "75",
"level": "worker",
"station_limits": [shop.stall("03", "Stall 2")],
}
assert by["Lou Lead"]["level"] == "lead" and by["Wes Super"]["level"] == "supervisor"
assert by["Bo Both"]["level"] == "supervisor" and by["Lou Lead"]["station_limits"] == []
six = ok(shop("sup.wes").get("/api/departments/06/people"))
assert [p["display_name"] for p in six] == ["Mia Multi"]
for code in ("ZZ", "WH", "CL", "QC"):
assert shop("sup.wes").get(f"/api/departments/{code}/people").status_code == 404, code
# a session is needed
anon = TestClient(shop.main.app, headers=ORIGIN_HEADERS)
assert anon.get("/api/departments/03/people").status_code == 401
# ================================================================ the shop transitions and their texts
def test_unassign_and_pull_back_freeze_the_clock(shop: Shop) -> None:
wes = shop("sup.wes")
pkt = to_station(shop, ready_packet(shop))
pkt = start(shop, pkt)
log = last_log(wes, pkt["id"])
assert (log["action"], log["note"]) == ("Work started", "Stall 1")
assert pkt["stage"]["clock"]["running"] is True and pkt["stage"]["clock"]["running_since"]
assert pkt["stage"]["clock"]["started_at"]
set_running_since(shop.settings, pkt["id"], 90)
out = ok(step(wes, pkt, "pull_back"))
assert out["status"] == "ready" and out["station"] is None and out["welder"] is None
assert last_log(wes, pkt["id"])["action"] == "Pulled back from Stall 1"
clock = out["stage"]["clock"]
assert clock["running"] is False and clock["running_since"] is None
assert 90_000 <= clock["stored_ms"] < 120_000 and clock["elapsed_ms"] == clock["stored_ms"]
frozen = clock["stored_ms"]
time.sleep(1.1)
again = fresh(wes, pkt["id"])["stage"]["clock"]
assert again["elapsed_ms"] == frozen and again["running"] is False
# assigned again and unassigned before a start: the clock stays frozen at the same value
out = to_station(shop, out)
out = ok(step(wes, out, "unassign"))
assert out["stage"]["clock"]["elapsed_ms"] == frozen
assert last_log(wes, pkt["id"])["action"] == "Taken off Stall 1"
def test_submit_preconditions_one_by_one(shop: Shop) -> None:
andy = shop("weld.andy")
pkt = in_progress(shop)
mine = fresh(andy, pkt["id"])
assert mine["blocked"]["submit"] == [MSG_CHECKLIST, MSG_WELDS, MSG_FILLER]
refused(step(andy, pkt, "submit", sign=SIGN), 403, "precondition", MSG_CHECKLIST)
pkt = tick_all(shop, pkt)
assert fresh(andy, pkt["id"])["blocked"]["submit"] == [MSG_WELDS, MSG_FILLER]
refused(step(andy, pkt, "submit", sign=SIGN), 403, "precondition", MSG_WELDS)
pkt = add_weld(shop, pkt)
pkt = add_weld(shop, pkt, fx=0.6, fy=0.4)
refused(step(andy, pkt, "submit", sign=SIGN), 403, "precondition", MSG_FILLER)
pkt = set_filler(shop, pkt)
mine = fresh(andy, pkt["id"])
assert "submit" in mine["can"] and "submit" not in mine["blocked"]
r = refused(step(andy, pkt, "submit"), 400, "signature_required")
assert r["meaning"] == "Submit work to the supervisor"
set_running_since(shop.settings, pkt["id"], 54 * 60)
out = ok(step(andy, pkt, "submit", sign=SIGN))
assert out["status"] == "review" and out["return_note"] is None
assert out["stage"]["clock"]["running"] is False and out["station"]["name"] == "Stall 1"
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"], log["signed"]) == ("Submitted to supervisor", "2 welds · 0.9 h", True)
sig = one(
shop.settings,
"SELECT signature_id FROM packet_transitions WHERE packet_id = %s AND action = 'submit'",
(pkt["id"],),
)
assert sig[0] is not None
def test_return_to_station(shop: Shop) -> None:
wes = shop("sup.wes")
pkt = in_review(shop)
refused(step(wes, pkt, "return_to_station"), 400, "reason_required")
reason = "Weld 2 undercut, grind and reweld"
out = ok(step(wes, pkt, "return_to_station", reason=reason))
assert out["status"] == "assigned" and out["return_note"] == reason
assert out["station"]["name"] == "Stall 1" and out["welder"]["id"] == shop.uid("weld.andy")
log = last_log(wes, pkt["id"])
assert (log["action"], log["note"]) == ("Returned to Stall 1", reason)
# weld.andy starts again and submits: the return note clears
out = start(shop, out)
out = submit(shop, out)
assert out["return_note"] is None
def test_send_to_qc(shop: Shop) -> None:
wes = shop("sup.wes")
pkt = in_review(shop)
for body in (
{"book": True, "spool": True},
{"book": True, "spool": False, "location": "Bay 2"},
{"book": False, "spool": True, "location": "Bay 2"},
{"book": True, "spool": True, "location": " "},
):
refused(step(wes, pkt, "send_to_qc", sign=SIGN, **body), 403, "precondition", MSG_QC)
refused(
step(shop("lead.lou"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN), 403, "forbidden"
)
out = ok(step(wes, pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
assert out["status"] == "qc" and out["qc"]["location"] == "Bay 2"
assert out["qc"]["book"] is True and out["qc"]["spool"] is True and out["qc"]["sent_at"]
log = last_log(wes, pkt["id"])
assert (log["action"], log["note"], log["signed"]) == ("Sent to Q.C.", "Spool at Bay 2", True)
def test_send_to_qc_note(shop: Shop) -> None:
wes = shop("sup.wes")
pkt = in_review(shop)
ok(step(wes, pkt, "send_to_qc", book=True, spool=True, location="Bay 2", note="Top rack", sign=SIGN))
assert last_log(wes, pkt["id"])["note"] == "Spool at Bay 2 · Top rack"
def test_route_from_review_to_hydro(shop: Shop) -> None:
wes = shop("sup.wes")
pkt = in_progress(shop)
pkt = ok(
shop("weld.andy").post(f"/api/packets/{pkt['id']}/heat-tags", json={"page": 1, "item": 3, "fx": 0.3, "fy": 0.3})
)
pkt = submit(shop, finish_stage(shop, pkt, welds=2))
first_stage = pkt["stage"]["id"]
assert [w["carried"] for w in pkt["welds"]] == [False, False]
assert [t["carried"] for t in pkt["heat_tags"]] == [False]
refused(step(wes, pkt, "route_from_review", dept="03", sign=SIGN), 403, "precondition", MSG_OTHER_DEPT)
refused(step(wes, pkt, "route_from_review", dept="09"), 400, "signature_required")
out = ok(step(wes, pkt, "route_from_review", dept="09", sign=SIGN))
assert out["status"] == "ready" and out["dept"]["code"] == "09"
assert out["station"] is None and out["welder"] is None and out["return_note"] is None
assert out["stage"]["dept_code"] == "09" and out["stage"]["id"] != first_stage
assert out["stage"]["station"] is None and out["stage"]["welder"] is None
closed = [x for x in out["stages"] if x["id"] == first_stage]
assert len(closed) == 1 and closed[0]["closed_at"] and closed[0]["dept_code"] == "03"
assert closed[0]["clock"]["running"] is False
log = last_log(wes, pkt["id"])
assert (log["action"], log["note"], log["signed"]) == (
"Routed to 09 Hydro",
"Supervisor, Q.C. skipped · from 03 Weld Parts",
True,
)
# the marks stay on the packet and read as carried
assert len(out["welds"]) == 2 and all(w["carried"] is True for w in out["welds"])
assert all(w["dept_code"] == "03" and w["stage_id"] == first_stage for w in out["welds"])
assert len(out["heat_tags"]) == 1 and out["heat_tags"][0]["carried"] is True
# 09 is not a weld department: the plain checklist
assert [i["key"] for i in out["checklist_items"]] == list(PLAIN_KEYS)
assert out["stage"]["checklist"] == {}
def test_route_to_a_weld_department_needs_its_own_weld(shop: Shop) -> None:
"""welds_recorded counts the current department's welds only."""
pkt = in_review(shop)
out = ok(step(shop("sup.wes"), pkt, "route_from_review", dept="06", sign=SIGN))
assert out["dept"]["code"] == "06" and all(w["carried"] for w in out["welds"])
mia = shop("multi.mia")
out = to_station(shop, out, by="multi.mia", welder="multi.mia", dept="06", station="Stall 2")
out = start(shop, out, by="multi.mia")
out = tick_all(shop, out, by="multi.mia")
out = set_filler(shop, out, by="multi.mia")
mine = fresh(mia, out["id"])
assert mine["blocked"]["submit"] == [MSG_WELDS]
refused(step(mia, out, "submit", sign=SIGN), 403, "precondition", MSG_WELDS)
out = add_weld(shop, out, by="multi.mia")
w = max(out["welds"], key=lambda x: x["weld_no"])
assert w["dept_code"] == "06" and w["carried"] is False and w["weld_no"] == 2
out = submit(shop, out, by="multi.mia")
assert last_log(mia, out["id"])["note"].startswith("1 weld")
# ================================================================ checklist and templates
def test_checklist_per_key(shop: Shop) -> None:
s = shop.settings
set_stamp(s, "weld.andy", "75")
andy = shop("weld.andy")
pkt = in_progress(shop, drawing="D-5001", rev="B")
items = {i["key"]: i for i in pkt["checklist_items"]}
assert list(items) == list(WELD_KEYS)
assert items["dwg"]["text"] == "Drawing D-5001 Rev B matches the packet"
assert items["dwg"]["group"] == "Before welding" and items["vt"]["group"] == "After welding"
out = ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/dwg", json={"done": True}))
entry = out["stage"]["checklist"]["dwg"]
assert entry["done"] is True and entry["at"] and entry["by"] == shop.uid("weld.andy")
assert entry["stamp"] == "75" and entry["by_name"] == "Andy Welder"
assert last_log(andy, pkt["id"])["action"] == "Checked: Drawing D-5001 Rev B matches the packet"
stored = one(s, "SELECT checklist FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (pkt["id"],))[0]
assert stored["dwg"]["done"] is True and stored["dwg"]["stamp"] == "75"
# the same tick again writes nothing
before = len(log_rows(andy, pkt["id"]))
ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/dwg", json={"done": True}))
assert len(log_rows(andy, pkt["id"])) == before
out = ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/dwg", json={"done": False}))
assert out["stage"]["checklist"]["dwg"]["done"] is False
assert last_log(andy, pkt["id"])["action"] == "Unchecked: Drawing D-5001 Rev B matches the packet"
refused(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/zzz", json={"done": True}), 422, "invalid_key")
refused(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/prev", json={"done": True}), 422, "invalid_key")
# the stamp falls back to the packet's stamp when the user has none
set_stamp(s, "lead.lou", None)
out = ok(shop("lead.lou").put(f"/api/packets/{pkt['id']}/stage/checklist/fit", json={"done": True}))
assert out["stage"]["checklist"]["fit"]["stamp"] == "75"
assert out["stage"]["checklist"]["fit"]["by_name"] == "Lou Lead"
# someone who does not hold it
refused(shop("qc.quinn").put(f"/api/packets/{pkt['id']}/stage/checklist/vt", json={"done": True}), 403, "forbidden")
def test_checklist_plain_department(shop: Shop) -> None:
pkt = ready_skipped(shop, dept="09")
assert [i["key"] for i in pkt["checklist_items"]] == list(PLAIN_KEYS)
groups = {i["key"]: i["group"] for i in pkt["checklist_items"]}
assert groups["prev"] == "Before starting" and groups["work"] == "When done"
ann = shop("admin.ann")
url = f"/api/packets/{pkt['id']}/stage/checklist"
refused(ann.put(f"{url}/prev", json={"done": True}), 400, "it_reason_required")
out = ok(ann.put(f"{url}/prev", json={"done": True, "it_reason": IT}))
assert out["stage"]["checklist"]["prev"]["done"] is True
refused(ann.put(f"{url}/heat", json={"done": True, "it_reason": IT}), 422, "invalid_key")
def test_checklist_templates_from_settings(shop: Shop) -> None:
s = shop.settings
row = one(s, "SELECT value FROM settings WHERE key = 'checklist_templates'")
assert row is not None, "migration 0006 seeds checklist_templates"
defaults = row[0]
assert [i["key"] for i in defaults["weld"]] == list(WELD_KEYS)
assert [i["key"] for i in defaults["plain"]] == list(PLAIN_KEYS)
custom = {
"weld": [
{"group": "Before welding", "key": "dwg", "text": "Sheet {drawing} at Rev {rev} checked"},
{"group": "Before welding", "key": "wps", "text": "Procedure {wps} read"},
{"group": "After welding", "key": "vt", "text": "Visual done"},
],
"plain": defaults["plain"],
}
set_templates(s, custom)
andy = shop("weld.andy")
pkt = in_progress(shop, drawing="D-6006", rev="C")
pkt = fresh(andy, pkt["id"])
assert [(i["key"], i["text"]) for i in pkt["checklist_items"]] == [
("dwg", "Sheet D-6006 at Rev C checked"),
("wps", "Procedure WPS-11 read"),
("vt", "Visual done"),
]
refused(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/fit", json={"done": True}), 422, "invalid_key")
out = ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/wps", json={"done": True}))
assert last_log(andy, pkt["id"])["action"] == "Checked: Procedure WPS-11 read"
# three keys complete the custom checklist
out = tick_all(shop, out, keys=("dwg", "vt"))
out = set_filler(shop, add_weld(shop, out))
assert "submit" in fresh(andy, out["id"])["can"]
# a malformed row falls back to the defaults in code
set_templates(s, {"weld": "nonsense"})
pkt = fresh(andy, pkt["id"])
assert [i["key"] for i in pkt["checklist_items"]] == list(WELD_KEYS)
run(s, "DELETE FROM settings WHERE key = 'checklist_templates'")
assert [i["key"] for i in fresh(andy, pkt["id"])["checklist_items"]] == list(WELD_KEYS)
# ================================================================ stage fields
def test_stage_fields_field_cas(shop: Shop) -> None:
andy = shop("weld.andy")
pkt = in_progress(shop)
url = f"/api/packets/{pkt['id']}/stage"
out = ok(andy.patch(url, json={"set": {"filler": "ER70S-6", "filler_lot": "1234"}, "base": {"filler": None}}))
assert out["stage"]["filler"] == "ER70S-6" and out["stage"]["filler_lot"] == "1234"
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Weld record changed", "filler ER70S-6 · filler lot 1234")
# another session saw filler empty and writes over it: a conflict on that field only
r = refused(
shop("sup.wes").patch(url, json={"set": {"filler": "E7018"}, "base": {"filler": None}}), 409, "field_conflict"
)
assert r["current"] == {"filler": "ER70S-6"}
# a base that matches, and a key without a base, are written
out = ok(
shop("sup.wes").patch(url, json={"set": {"filler": "E7018", "preheat": "150F"}, "base": {"filler": "ER70S-6"}})
)
assert out["stage"]["filler"] == "E7018" and out["stage"]["preheat"] == "150F"
assert last_log(andy, pkt["id"])["note"] == "filler E7018 · preheat 150F"
# the flat body with If-Match still works
rv = out["stage"]["row_version"]
out = ok(andy.patch(url, json={"notes": "Root pass by Andy"}, headers=im(rv)))
assert out["stage"]["notes"] == "Root pass by Andy"
refused(andy.patch(url, json={"notes": "stale"}, headers=im(rv)), 409, "row_conflict")
# limits: 40 for the short fields, 2000 for notes
r = andy.patch(url, json={"set": {"filler_lot": "X" * 60, "notes": "n" * 2500}, "base": {}})
if r.status_code == 200:
assert len(r.json()["stage"]["filler_lot"]) == 40 and len(r.json()["stage"]["notes"]) == 2000
else:
assert r.status_code == 422, r.text
refused(shop("qc.quinn").patch(url, json={"set": {"filler": "x"}, "base": {}}), 403, "forbidden")
# ================================================================ welds
def test_welds(shop: Shop) -> None:
s = shop.settings
set_stamp(s, "weld.andy", "75")
andy = shop("weld.andy")
pkt = in_progress(shop)
url = f"/api/packets/{pkt['id']}/welds"
body = {
"page": 1,
"fx": 0.5,
"fy": 0.5,
"bfx": 0.55,
"bfy": 0.43,
"items": [2, 1],
"wo_base": "L670001",
"piece": 1,
"type": "Fillet",
"process": "GMAW",
}
out = ok(andy.post(url, json=body))
w1 = weld_of(out, 1)
assert (w1["size"], w1["type"], w1["process"], w1["items"]) == ('5/16"', "Fillet", "GMAW", [1, 2])
assert (w1["bfx"], w1["bfy"], w1["fx"], w1["fy"]) == (0.55, 0.43, 0.5, 0.5)
assert w1["dept_code"] == "03" and w1["stage_id"] == out["stage"]["id"] and w1["carried"] is False
assert w1["by"]["display_name"] == "Andy Welder" and w1["welder"]["id"] == shop.uid("weld.andy")
assert w1["stamp"] == "75"
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Weld W1 marked", 'Fillet 5/16" · items 1 + 2 · L670001 #1')
# the client never picks the number
out = ok(andy.post(url, json={**body, "weld_no": 9, "size": '1/4"', "bfx": None, "bfy": None}))
w2 = weld_of(out, 2)
assert w2["size"] == '1/4"' and w2["bfx"] is None and w2["bfy"] is None
assert sorted(w["weld_no"] for w in out["welds"]) == [1, 2]
# validation
refused(andy.post(url, json={**body, "type": "Butt"}), 422, "invalid_weld", MSG_TYPE)
refused(andy.post(url, json={**body, "process": "TIG"}), 422, "invalid_weld", MSG_PROCESS)
refused(andy.post(url, json={**body, "wo_base": "L999999"}), 422, "invalid_wo")
for bad in ({"fx": 1.5}, {"fy": -0.1}, {"bfx": 1.2}, {"bfy": -0.5}, {"piece": 0}):
r = andy.post(url, json={**body, **bad})
assert r.status_code == 422, (bad, r.text)
refused(andy.post(url, json={**body, "items": [7]}), 422, "invalid_items")
for t in ("Fillet", "Groove", "Fill", "Seal", "Tack"):
assert andy.post(url, json={**body, "type": t, "page": 2}).status_code == 200, t
for p in ("GMAW", "FCAW", "SMAW", "GTAW", "SAW"):
assert andy.post(url, json={**body, "process": p, "page": 2}).status_code == 200, p
out = fresh(andy, pkt["id"])
n = max(w["weld_no"] for w in out["welds"])
assert n == 12 and len(out["welds"]) == 12
# a position-only patch logs "moved"; a field change logs the changed fields
rv = weld_of(out, 1)["row_version"]
out = ok(andy.patch(f"{url}/1", json={"fx": 0.6, "fy": 0.62, "bfx": 0.7, "bfy": 0.5}, headers=im(rv)))
assert (weld_of(out, 1)["fx"], weld_of(out, 1)["bfy"]) == (0.6, 0.5)
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Weld W1 moved", None)
rv = weld_of(out, 1)["row_version"]
out = ok(andy.patch(f"{url}/1", json={"type": "Groove", "items": [1, 3]}, headers=im(rv)))
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Weld W1 changed", "type Groove · items 1 + 3")
refused(
andy.patch(f"{url}/1", json={"type": "Butt"}, headers=im(weld_of(out, 1)["row_version"])), 422, "invalid_weld"
)
refused(andy.patch(f"{url}/1", json={"fx": 0.1}, headers=im(rv)), 409, "row_conflict")
# removed with a reason
out = ok(andy.request("DELETE", f"{url}/2", json={"reason": "Logged twice"}))
assert 2 not in [w["weld_no"] for w in out["welds"]]
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Weld W2 removed", "Logged twice")
# numbers are never reused
out = ok(andy.post(url, json=body))
assert max(w["weld_no"] for w in out["welds"]) == 13
# a voided sheet refuses new marks and moving old ones
run(s, "UPDATE packet_pages SET void_at = now() WHERE packet_id = %s AND n = 2", (pkt["id"],))
refused(andy.post(url, json={**body, "page": 2}), 422, "void_page", MSG_VOID)
on_two = next(w for w in fresh(andy, pkt["id"])["welds"] if w["page"] == 2)
r = andy.patch(f"{url}/{on_two['weld_no']}", json={"fx": 0.2}, headers=im(on_two["row_version"]))
refused(r, 422, "void_page", MSG_VOID)
# moving a page-1 weld onto the void sheet is refused too
w1 = weld_of(fresh(andy, pkt["id"]), 1)
refused(andy.patch(f"{url}/1", json={"page": 2}, headers=im(w1["row_version"])), 422, "void_page", MSG_VOID)
# who: Q.C. and the warehouse may not
for sam in ("qc.quinn", "wh.wade", "clerk.clara"):
refused(shop(sam).post(url, json=body), 403, "forbidden")
# ================================================================ heat tags and the heat table
def test_heat_tags(shop: Shop) -> None:
andy = shop("weld.andy")
pkt = in_progress(shop)
url = f"/api/packets/{pkt['id']}/heat-tags"
out = ok(andy.post(url, json={"page": 1, "item": 3, "fx": 0.3, "fy": 0.3, "bfx": 0.35, "bfy": 0.24}))
t = tag_of(out, 1)
assert (t["item"], t["bfx"], t["bfy"], t["carried"]) == (3, 0.35, 0.24, False)
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Heat tag H1 placed", "item 3")
out = ok(andy.patch(f"{url}/1", json={"fx": 0.4, "bfx": 0.45}, headers=im(t["row_version"])))
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat tag H1 moved", "item 3")
out = ok(andy.patch(f"{url}/1", json={"item": 2}, headers=im(tag_of(out, 1)["row_version"])))
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Heat tag H1 changed", "item 2")
for bad in ({"bfx": 1.01}, {"bfy": -0.01}, {"fx": 2}):
assert andy.post(url, json={"page": 1, "item": 1, "fx": 0.2, "fy": 0.2, **bad}).status_code == 422, bad
out = ok(andy.post(url, json={"page": 1, "item": 1, "fx": 0.2, "fy": 0.2}))
assert tag_of(out, 2)["bfx"] is None
out = ok(andy.request("DELETE", f"{url}/1", json={"reason": "Wrong item"}))
log = last_log(andy, pkt["id"])
assert log["action"] == "Heat tag H1 removed" and log["note"] in ("Wrong item", "item 2 · Wrong item")
run(shop.settings, "UPDATE packet_pages SET void_at = now() WHERE packet_id = %s AND n = 2", (pkt["id"],))
refused(andy.post(url, json={"page": 2, "item": 1, "fx": 0.2, "fy": 0.2}), 422, "void_page", MSG_VOID)
def test_heat_tag_by_the_warehouse_reads_carried(shop: Shop) -> None:
pkt = draft(shop)
pkt = to_warehouse(shop, pkt)
pkt = ok(
shop("wh.wade").post(f"/api/packets/{pkt['id']}/heat-tags", json={"page": 1, "item": 1, "fx": 0.2, "fy": 0.2})
)
pkt = release(shop, pull_all(shop, pkt))
assert tag_of(pkt, 1)["carried"] is True
def test_heat_table(shop: Shop) -> None:
andy = shop("weld.andy")
pkt = in_progress(shop)
url = f"/api/packets/{pkt['id']}/heat-table"
sheet1 = page_label(pkt, 1)
out = ok(andy.put(f"{url}/1", json={"x": 0.1, "y": 0.1, "s": 1.3}))
ht = out["heat_table"][0]
assert (ht["page"], ht["x"], ht["y"], ht["s"], ht["carried_from"]) == (1, 0.1, 0.1, 1.3, None)
assert ht["by"]["display_name"] == "Andy Welder" and ht["at"]
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == (
"Heat table placed on drawing",
sheet1,
)
ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.3}))
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat table moved", sheet1)
ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.45}))
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat table resized", "A+ 1.45")
ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.3}))
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat table resized", "A- 1.3")
# the same placement again writes nothing
before = len(log_rows(andy, pkt["id"]))
ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.3}))
assert len(log_rows(andy, pkt["id"])) == before
# validation
for bad in (
{"x": 0.98, "y": 0.1, "s": 1},
{"x": -0.01, "y": 0.1, "s": 1},
{"x": 0.1, "y": 0.971, "s": 1},
{"x": 0.1, "y": 0.1, "s": 0.59},
{"x": 0.1, "y": 0.1, "s": 2.11},
):
refused(andy.put(f"{url}/1", json=bad), 422, "invalid_heat_table")
ok(andy.put(f"{url}/1", json={"x": 0.97, "y": 0.97, "s": 2.1}))
ok(andy.put(f"{url}/1", json={"x": 0, "y": 0, "s": 0.6}))
refused(andy.put(f"{url}/99", json={"x": 0.1, "y": 0.1, "s": 1}), 422, "invalid_page")
# carried_from is accepted and read back
out = ok(andy.put(f"{url}/2", json={"x": 0.1, "y": 0.1, "s": 1, "carried_from": 1}))
assert next(h for h in out["heat_table"] if h["page"] == 2)["carried_from"] == 1
# removed
ok(andy.request("DELETE", f"{url}/2"))
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == (
"Heat table removed from drawing",
page_label(pkt, 2),
)
refused(andy.request("DELETE", f"{url}/2"), 404, "not_found")
# Q.C. reads it but may not place it
q = fresh(shop("qc.quinn"), pkt["id"])
assert [h["page"] for h in q["heat_table"]] == [1]
assert "edit_heat_table" not in q["can"]
refused(shop("qc.quinn").put(f"{url}/1", json={"x": 0.1, "y": 0.1, "s": 1}), 403, "forbidden")
assert "edit_heat_table" in fresh(andy, pkt["id"])["can"]
# ================================================================ heat map borrowing
def test_heat_src_borrowing(shop: Shop) -> None:
s = shop.settings
_, unit = make_unit(s)
_, other_unit = make_unit(s)
sub_only = [{"item": 1, "qty": 1, "part_id": "SUB-1", "description": "Nozzle sub-assembly"}]
# the sibling with a heat map, rev B, made first
old_b = draft(shop, unit, title="Sibling rev B", drawing="D-77 01", rev="B")
run(s, "INSERT INTO heat_table_placements (packet_id, page, x, y, s) VALUES (%s, 1, 0.1, 0.1, 1)", (old_b["id"],))
# a newer sibling with the same drawing, rev C, with a heat tag only
new_c = draft(shop, unit, title="Sibling rev C", drawing="d-7701", rev="C")
run(
s,
"INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy) VALUES (%s, 1, 1, 1, 0.2, 0.2)",
(new_c["id"],),
)
# a sibling with no heat map is never a source
draft(shop, unit, title="Sibling no map", drawing="D-7701", rev="C")
# only sub-assembly lines, rev B: borrows rev B (same rev)
b = draft(shop, unit, title="Borrower B", drawing="D-7701", rev="B", bom=sub_only)
run(s, "UPDATE bom_lines SET sub = 'Y' WHERE packet_id = %s", (b["id"],))
src = fresh(shop("sup.wes"), b["id"])["heat_src"]
assert src is not None and src["packet_id"] == old_b["id"]
assert (src["drawing"], src["rev"], src["status"]) == ("D-77 01", "B", "draft")
assert [h["page"] for h in src["heat_table"]] == [1]
assert {"n", "type", "asset_id", "pn", "dwg", "rev", "void", "url"} <= set(src["pages"][0])
assert src["bom"] and {"item", "heat", "na"} <= set(src["bom"][0])
# rev C: the same rev wins over the older sibling
c = draft(shop, unit, title="Borrower C", drawing="D-7701", rev="C", bom=sub_only)
run(s, "UPDATE bom_lines SET sub = 'Y' WHERE packet_id = %s", (c["id"],))
assert fresh(shop("sup.wes"), c["id"])["heat_src"]["packet_id"] == new_c["id"]
# rev D: no same rev, the oldest sibling
d = draft(shop, unit, title="Borrower D", drawing="D-7701", rev="D", bom=[])
assert fresh(shop("sup.wes"), d["id"])["heat_src"]["packet_id"] == old_b["id"]
# every line N/A borrows too
na = draft(shop, unit, title="Borrower NA", drawing="D-7701", rev="B")
run(s, "UPDATE bom_lines SET na = true WHERE packet_id = %s", (na["id"],))
assert fresh(shop("sup.wes"), na["id"])["heat_src"]["packet_id"] == old_b["id"]
# its own raw material: no borrowing
own = draft(shop, unit, title="Own material", drawing="D-7701", rev="B")
assert fresh(shop("sup.wes"), own["id"])["heat_src"] is None
# its own heat map: no borrowing
mapped = draft(shop, unit, title="Own map", drawing="D-7701", rev="B", bom=[])
run(s, "INSERT INTO heat_table_placements (packet_id, page, x, y, s) VALUES (%s, 1, 0.5, 0.5, 1)", (mapped["id"],))
assert fresh(shop("sup.wes"), mapped["id"])["heat_src"] is None
# another unit or another drawing: no borrowing
far = draft(shop, other_unit, title="Other unit", drawing="D-7701", rev="B", bom=[])
assert fresh(shop("sup.wes"), far["id"])["heat_src"] is None
diff = draft(shop, unit, title="Other drawing", drawing="D-7702", rev="B", bom=[])
assert fresh(shop("sup.wes"), diff["id"])["heat_src"] is None
# a removed sibling is not a source
run(s, "UPDATE packets SET removed_at = now(), removed_reason = 'test' WHERE id = %s", (old_b["id"],))
assert fresh(shop("sup.wes"), d["id"])["heat_src"]["packet_id"] == new_c["id"]
# read-only: nothing on the source is written through the borrower
pk = ready_skipped(shop, unit, drawing="D-7701", rev="C", bom=[])
src = fresh(shop("sup.wes"), pk["id"])["heat_src"]
assert src["packet_id"] == new_c["id"]
before = all_rows(s, "SELECT tag_no, fx FROM heat_tags WHERE packet_id = %s", (new_c["id"],))
shop("sup.wes").post(f"/api/packets/{pk['id']}/heat-tags", json={"page": 1, "item": 1, "fx": 0.9, "fy": 0.9})
assert all_rows(s, "SELECT tag_no, fx FROM heat_tags WHERE packet_id = %s", (new_c["id"],)) == before
# ================================================================ labor from VISUAL
def test_labor_from_visual_rows(shop: Shop) -> None:
s = shop.settings
pkt = ready_packet(shop)
assert pkt["labor"] is None # a hand packet
assert float(pkt["target_hrs"]) == pytest.approx(4.5)
with app_conn(s) as conn:
conn.execute("UPDATE packets SET source_kind = 'visual' WHERE id = %s", (pkt["id"],))
conn.execute(
"UPDATE packet_work_orders SET seq = 10 WHERE packet_id = %s AND wo_base = 'L670001'", (pkt["id"],)
)
conn.execute(
"UPDATE packet_work_orders SET seq = 20 WHERE packet_id = %s AND wo_base = 'L670002'", (pkt["id"],)
)
assert fresh(shop("sup.wes"), pkt["id"])["labor"] is None # no rows match yet
with app_conn(s) as conn:
for base, seq, hours, tickets, last in (
("L670001", 10, "1.5", 2, "2026-10-10"),
("L670002", 20, "1.7", 1, "2026-10-12"),
("L670002", 30, "9.0", 4, "2026-10-12"), # another operation: not this packet's
("L670009", 10, "5.0", 1, "2026-10-12"),
):
conn.execute(
"INSERT INTO visual_labor (wo_base, lot, split, sub, seq, hours, tickets, last_ticket)"
" VALUES (%s, '001', '0', '0', %s, %s, %s, %s)",
(base, seq, hours, tickets, last),
)
labor = fresh(shop("sup.wes"), pkt["id"])["labor"]
assert labor is not None
assert float(labor["hours"]) == pytest.approx(3.2) and int(labor["tickets"]) == 3 and labor["observed_at"]
per = {w["wo_base"]: w for w in labor["per_wo"]}
assert set(per) == {"L670001", "L670002"}
assert float(per["L670001"]["hours"]) == pytest.approx(1.5) and per["L670001"]["tickets"] == 2
assert per["L670002"]["seq"] == 20 and str(per["L670002"]["last_ticket"]).startswith("2026-10-12")
assert (per["L670001"]["lot"], per["L670001"]["split"], per["L670001"]["sub"]) == ("001", "0", "0")
# ================================================================ the board, stations, stall
def _board(shop: Shop, sam: str, **params: Any) -> Any:
return shop(sam).get("/api/shop/board", params=params)
def _ids(cards: list[dict[str, Any]]) -> set[int]:
return {c["id"] for c in cards}
def test_shop_board(shop: Shop) -> None:
s = shop.settings
set_stamp(s, "weld.andy", "75")
at_stall = in_progress(shop, title="At the stall")
set_running_since(s, at_stall["id"], 600)
waiting = ready_packet(shop, title="Waiting to assign")
review = in_review(shop, title="Waiting on review")
six = ready_skipped(shop, dept="06", title="In code bldg 2")
at_stall = issue_co(shop, at_stall, ["03"])
b = ok(_board(shop, "sup.wes"))
assert [d["code"] for d in b["depts"]] == ["03"]
d03 = b["depts"][0]
assert (d03["name"], d03["weld"], d03["level"]) == ("Weld Parts", True, "supervisor")
assert [st["name"] for st in d03["stations"]] == [f"Stall {n}" for n in range(1, 7)]
stall1 = d03["stations"][0]
assert [p["id"] for p in stall1["packets"]] == [at_stall["id"]]
tile = stall1["packets"][0]
assert tile["welder"]["display_name"] == "Andy Welder" and tile["welder_stamp"] == "75"
assert tile["station"]["name"] == "Stall 1" and tile["status"] == "in_progress"
assert tile["clock"]["running"] is True and tile["clock"]["elapsed_ms"] >= 600_000
assert float(tile["target_hrs"]) == pytest.approx(4.5)
assert [{k: c[k] for k in ("label", "review")} for c in tile["open_cos"]] == [
{"label": "CO-1", "review": "approved"}
]
assert all(st["packets"] == [] for st in d03["stations"][1:])
k = b["kpis"]
assert (k["ready"], k["at_stations"], k["review"]) == (1, 1, 1)
assert k["elapsed_ms"] >= 600_000 and float(k["target_hrs"]) == pytest.approx(4.5)
cols = b["columns"]
assert set(cols) == {"ready", "review", "qc", "warehouse", "complete"}
assert _ids(cols["ready"]) == {waiting["id"]} and _ids(cols["review"]) == {review["id"]}
assert cols["qc"] == [] and cols["complete"] == [] and cols["warehouse"] == []
every = set().union(*(_ids(c) for c in cols.values()))
assert six["id"] not in every
assert len(b["cos"]) == 1
co = b["cos"][0]
assert (co["packet_id"], co["co_no"], co["label"], co["review"], co["mine"]) == (
at_stall["id"],
1,
"CO-1",
"approved",
True,
)
assert co["pending_depts"] == ["03"] and co["dept_code"] == "03" and co["op"] == "WELD-PART"
assert co["title"] == "At the stall" and co["reason"] and co["issued_at"]
# dept narrows within the scope; outside it is refused
assert [d["code"] for d in ok(_board(shop, "sup.wes", dept="03"))["depts"]] == ["03"]
assert [d["code"] for d in ok(_board(shop, "sup.wes", dept="all"))["depts"]] == ["03"]
refused(_board(shop, "sup.wes", dept="06"), 403, "forbidden")
# project and unit narrow everything
other = ok(_board(shop, "sup.wes", unit_id=waiting["unit"]["id"]))
assert _ids(other["columns"]["ready"]) == {waiting["id"]} and other["columns"]["review"] == []
assert other["depts"][0]["stations"][0]["packets"] == [] and other["cos"] == []
assert other["kpis"]["at_stations"] == 0
proj = ok(_board(shop, "sup.wes", project_id=review["unit"]["project"]["id"]))
assert _ids(proj["columns"]["review"]) == {review["id"]} and proj["columns"]["ready"] == []
# the lead sees the same scope at lead level
lou = ok(_board(shop, "lead.lou"))
assert [(d["code"], d["level"]) for d in lou["depts"]] == [("03", "lead")]
assert lou["cos"][0]["mine"] is False
# multi.mia leads 06 only
mia = ok(_board(shop, "multi.mia"))
assert [(d["code"], d["level"]) for d in mia["depts"]] == [("06", "supervisor")]
assert _ids(mia["columns"]["ready"]) == {six["id"]} and mia["cos"] == []
every = set().union(*(_ids(c) for c in mia["columns"].values()))
assert not {at_stall["id"], waiting["id"], review["id"]} & every
refused(_board(shop, "multi.mia", dept="03"), 403, "forbidden")
# a worker leads nothing; nor do support roles
for sam in ("weld.andy", "dup.dave", "qc.quinn", "wh.wade", "clerk.clara", "pm.paula"):
refused(_board(shop, sam), 403, "forbidden", MSG_NOT_LEAD)
# admin: every active shop department, at admin level
ann = ok(_board(shop, "admin.ann"))
assert [d["code"] for d in ann["depts"]] == SHOP_DEPTS
assert {d["level"] for d in ann["depts"]} == {"admin"}
assert _ids(ann["columns"]["ready"]) == {waiting["id"], six["id"]}
assert ok(_board(shop, "admin.ann", dept="09"))["depts"][0]["code"] == "09"
def test_shop_board_cos_follow_my_departments(shop: Shop) -> None:
"""A CO pending 03 shows on the 03 board when the packet has moved on to Q.C."""
pkt = in_review(shop)
pkt = ok(step(shop("sup.wes"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
pkt = issue_co(shop, pkt, ["03", "QC"])
b = ok(_board(shop, "sup.wes"))
assert _ids(b["columns"]["qc"]) == {pkt["id"]}
assert [(c["packet_id"], c["mine"], c["pending_depts"]) for c in b["cos"]] == [(pkt["id"], True, ["03", "QC"])]
mia = ok(_board(shop, "multi.mia"))
assert mia["cos"] == []
# a pending feed CO with hold shows as "review" pending, hold true
run(shop.settings, "UPDATE packets SET next_co_no = next_co_no + 1 WHERE id = %s", (pkt["id"],))
run(
shop.settings,
"INSERT INTO change_orders (packet_id, co_no, origin, review, hold, ecn, reason, vis)"
" VALUES (%s, 2, 'visual', 'pending', true, 'VISUAL', 'VISUAL changed the packet: qty', '{}')",
(pkt["id"],),
)
b = ok(_board(shop, "sup.wes"))
held = [c for c in b["cos"] if c["co_no"] == 2]
assert held and held[0]["review"] == "pending" and held[0]["hold"] is True
def test_shop_stations(shop: Shop) -> None:
s = shop.settings
def names(sam: str) -> list[tuple[str, str]]:
return [(x["dept_code"], x["name"]) for x in ok(shop(sam).get("/api/shop/stations"))]
stalls = [("03", f"Stall {n}") for n in range(1, 7)]
assert names("weld.andy") == stalls
first = ok(shop("weld.andy").get("/api/shop/stations"))[0]
assert first["dept_name"] == "Weld Parts" and first["id"] == shop.stall()
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2"), shop.stall("03", "Stall 5")])
assert names("weld.andy") == [("03", "Stall 2"), ("03", "Stall 5")]
# limits bind workers only
set_limits(s, "sup.wes", [shop.stall("03", "Stall 2")])
assert names("sup.wes") == stalls
assert names("lead.lou") == stalls
assert names("multi.mia") == [("06", f"Stall {n}") for n in range(1, 7)]
for sam in ("qc.quinn", "wh.wade", "clerk.clara", "pm.paula"):
assert names(sam) == [], sam
total = one(s, "SELECT count(*) FROM stations WHERE active")[0]
assert len(names("admin.ann")) == total
def test_shop_stall(shop: Shop) -> None:
s = shop.settings
mine = in_progress(shop, title="On the stall")
waiting = to_station(shop, ready_packet(shop, title="Queued at the stall"))
sent = in_review(shop, title="Sent to review")
elsewhere = to_station(shop, ready_packet(shop, title="Stall 3 job"), station="Stall 3")
stall1 = shop.stall()
out = ok(shop("weld.andy").get("/api/shop/stall", params={"station_id": stall1}))
assert out["station"] == {"id": stall1, "name": "Stall 1", "dept_code": "03", "dept_name": "Weld Parts"}
assert _ids(out["mine"]) == {mine["id"], waiting["id"]}
assert _ids(out["submitted"]) == {sent["id"]}
assert elsewhere["id"] not in _ids(out["mine"]) | _ids(out["submitted"])
card = next(c for c in out["mine"] if c["id"] == mine["id"])
assert card["status"] == "in_progress" and card["clock"]["running"] is True
# a station outside the user's list
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2")])
refused(shop("weld.andy").get("/api/shop/stall", params={"station_id": stall1}), 403, "forbidden")
refused(shop("multi.mia").get("/api/shop/stall", params={"station_id": stall1}), 403, "forbidden")
refused(shop("qc.quinn").get("/api/shop/stall", params={"station_id": stall1}), 403, "forbidden")
ok(shop("admin.ann").get("/api/shop/stall", params={"station_id": stall1}))
ok(shop("sup.wes").get("/api/shop/stall", params={"station_id": stall1}))
# after routing on, the packet leaves the stall
ok(step(shop("sup.wes"), sent, "route_from_review", dept="09", sign=SIGN))
out = ok(shop("sup.wes").get("/api/shop/stall", params={"station_id": stall1}))
assert sent["id"] not in _ids(out["submitted"])
def test_server_now_and_target_hours(shop: Shop) -> None:
pkt = ready_packet(shop)
assert isinstance(pkt["server_now"], str) and "T" in pkt["server_now"]
assert float(pkt["target_hrs"]) == pytest.approx(4.5)
empty = ready_skipped(shop, wos=[])
assert float(empty["target_hrs"]) == 0
def test_change_order_shape(shop: Shop) -> None:
pkt = issue_co(shop, ready_packet(shop), ["03"])
co = pkt["change_orders"][0]
assert co["label"] == "CO-1" and co["pending_depts"] == ["03"] and co["open"] is True
assert last_log(shop("sup.wes"), pkt["id"])["action"] == "Change order CO-1 issued"
out = ok(
shop("sup.wes").post(
f"/api/packets/{pkt['id']}/change-orders/1/verify", json={"dept": "03", "note": "OK", "sign": SIGN}
)
)
co = out["change_orders"][0]
assert co["pending_depts"] == [] and co["open"] is False
v = co["verified"]["03"]
assert v["signed"] is True and v["by"]["display_name"] == "Wes Super" and v["note"] == "OK"
log = find_log(shop("sup.wes"), pkt["id"], "Change order CO-1 verified by Weld Parts")
assert log["note"] == "OK · change order fully verified"
+188
View File
@@ -0,0 +1,188 @@
"""Phase 5 station clock: every value is the server's. START starts it, the clock endpoint pauses and resumes it,
PULL_BACK and SUBMIT fold the running time in, the live elapsed grows without a write, and no request field sets
it. Time is moved by updating clock_running_since by SQL, so the tests do not wait."""
from __future__ import annotations
import time
from collections.abc import Iterator
from datetime import datetime
from typing import Any
import pytest
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import needs_pg
from shophelpers import (
SIGN,
Shop,
finish_stage,
fresh,
im,
in_progress,
in_review,
last_log,
ok,
one,
open_shop,
ready_packet,
refused,
set_running_since,
start,
to_station,
)
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def stored(shop: Shop, pid: int) -> tuple[int, bool]:
row = one(
shop.settings,
"SELECT clock_elapsed_ms, clock_running_since IS NOT NULL FROM packet_stages"
" WHERE packet_id = %s AND closed_at IS NULL",
(pid,),
)
return int(row[0]), bool(row[1])
def clock_op(shop: Shop, pkt: dict[str, Any], op: str, by: str = "weld.andy", **extra: Any) -> Any:
return shop(by).post(f"/api/packets/{pkt['id']}/stage/clock", json={"op": op, **extra})
def ts(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def test_start_pause_resume_pull_back(shop: Shop) -> None:
pkt = to_station(shop, ready_packet(shop))
c = pkt["stage"]["clock"]
assert (c["running"], c["stored_ms"], c["elapsed_ms"], c["running_since"], c["started_at"]) == (
False,
0,
0,
None,
None,
)
pkt = start(shop, pkt)
c = pkt["stage"]["clock"]
assert c["running"] is True and c["stored_ms"] == 0 and c["running_since"] and c["started_at"]
started_at = c["started_at"]
# pause after 90 s of running
set_running_since(shop.settings, pkt["id"], 90)
out = ok(clock_op(shop, pkt, "pause"))
c = out["stage"]["clock"]
assert c["running"] is False and c["running_since"] is None
assert 90_000 <= c["stored_ms"] < 95_000 and c["elapsed_ms"] == c["stored_ms"]
assert stored(shop, pkt["id"]) == (c["stored_ms"], False)
assert last_log(shop("weld.andy"), pkt["id"])["action"] == "Paused"
paused_at = c["stored_ms"]
# resume, run 30 s, then the supervisor pulls it back: the running time is folded in
out = ok(clock_op(shop, out, "resume"))
assert out["stage"]["clock"]["running"] is True and out["stage"]["clock"]["started_at"] == started_at
assert last_log(shop("weld.andy"), pkt["id"])["action"] == "Resumed"
set_running_since(shop.settings, pkt["id"], 30)
out = ok(step(shop("sup.wes"), out, "pull_back"))
c = out["stage"]["clock"]
assert c["running"] is False and paused_at + 30_000 <= c["stored_ms"] < paused_at + 35_000
assert stored(shop, pkt["id"])[1] is False
# assigned and started again: it carries on from the stored value
out = start(shop, to_station(shop, out))
assert out["stage"]["clock"]["stored_ms"] == c["stored_ms"] and out["stage"]["clock"]["running"] is True
assert out["stage"]["clock"]["started_at"] == started_at
def test_live_elapsed_grows_without_a_write(shop: Shop) -> None:
pkt = in_progress(shop)
set_running_since(shop.settings, pkt["id"], 10)
a = fresh(shop("weld.andy"), pkt["id"])
time.sleep(1.1)
b = fresh(shop("weld.andy"), pkt["id"])
assert a["version"] == b["version"]
assert a["stage"]["clock"]["stored_ms"] == b["stage"]["clock"]["stored_ms"] == 0
assert 10_000 <= a["stage"]["clock"]["elapsed_ms"] < b["stage"]["clock"]["elapsed_ms"]
assert b["stage"]["clock"]["elapsed_ms"] - a["stage"]["clock"]["elapsed_ms"] >= 1_000
# elapsed = stored + (server_now - running_since), from the server's own clock
since = ts(b["stage"]["clock"]["running_since"])
now = ts(b["server_now"])
want = (now - since).total_seconds() * 1000
assert abs(b["stage"]["clock"]["elapsed_ms"] - want) < 1_000
def test_submit_stops_the_clock(shop: Shop) -> None:
pkt = finish_stage(shop, in_progress(shop))
set_running_since(shop.settings, pkt["id"], 3 * 3600)
out = ok(step(shop("weld.andy"), pkt, "submit", sign=SIGN))
c = out["stage"]["clock"]
assert c["running"] is False and 3 * 3_600_000 <= c["stored_ms"] < 3 * 3_600_000 + 10_000
assert last_log(shop("weld.andy"), pkt["id"])["note"] in ("1 weld · 3.0 h", "1 welds · 3.0 h")
def test_clock_state_conflicts(shop: Shop) -> None:
pkt = to_station(shop, ready_packet(shop))
# never started: pause and resume are refused
r = refused(clock_op(shop, pkt, "pause"), 409, "clock_state")
assert r["current"] == {"running": False, "started": False}
refused(clock_op(shop, pkt, "resume"), 409, "clock_state")
pkt = start(shop, pkt)
r = refused(clock_op(shop, pkt, "start"), 409, "clock_state")
assert r["current"]["running"] is True
refused(clock_op(shop, pkt, "resume"), 409, "clock_state")
ok(clock_op(shop, pkt, "pause"))
refused(clock_op(shop, pkt, "pause"), 409, "clock_state")
assert clock_op(shop, pkt, "stop").status_code == 422
# who: Q.C. and the warehouse may not touch it
for sam in ("qc.quinn", "wh.wade", "clerk.clara", "multi.mia"):
refused(clock_op(shop, pkt, "resume", by=sam), 403, "forbidden")
def test_no_field_sets_elapsed(shop: Shop) -> None:
pkt = in_progress(shop)
set_running_since(shop.settings, pkt["id"], 60)
big = 999_999_999
# the clock endpoint takes only op
r = clock_op(shop, pkt, "pause", elapsed_ms=big, clock_elapsed_ms=big, stored_ms=big)
assert r.status_code in (200, 422), r.text
if r.status_code == 422:
ok(clock_op(shop, pkt, "pause"))
ms, running = stored(shop, pkt["id"])
assert 60_000 <= ms < 70_000 and running is False
# the stage fields refuse or ignore clock columns, in both body shapes
url = f"/api/packets/{pkt['id']}/stage"
for body in (
{"set": {"clock_elapsed_ms": big, "clock_running_since": None}, "base": {}},
{"set": {"filler": "ER70S-6", "clock_elapsed_ms": big}, "base": {}},
):
r = shop("weld.andy").patch(url, json=body)
assert r.status_code in (200, 422), r.text
rv = fresh(shop("weld.andy"), pkt["id"])["stage"]["row_version"]
r = shop("weld.andy").patch(url, json={"clock_elapsed_ms": big, "notes": "x"}, headers=im(rv))
assert r.status_code in (200, 422), r.text
assert stored(shop, pkt["id"])[0] == ms
# the transitions carry no clock either
r = step(shop("sup.wes"), fresh(shop("sup.wes"), pkt["id"]), "pull_back", elapsed_ms=big, clock_elapsed_ms=big)
assert r.status_code in (200, 422), r.text
assert stored(shop, pkt["id"])[0] == ms
# and the read model's value is the stored one
assert fresh(shop("sup.wes"), pkt["id"])["stage"]["clock"]["elapsed_ms"] == ms
def test_closed_stage_clock(shop: Shop) -> None:
pkt = in_review(shop)
out = ok(step(shop("sup.wes"), pkt, "route_from_review", dept="09", sign=SIGN))
old = out["stages"][-1]
assert old["clock"]["running"] is False and old["clock"]["elapsed_ms"] == old["clock"]["stored_ms"]
new = out["stage"]["clock"]
assert (new["running"], new["stored_ms"], new["elapsed_ms"]) == (False, 0, 0)
+221
View File
@@ -0,0 +1,221 @@
"""Phase 5 shop writes under concurrency: weld and heat tag numbers come from the packet counter under the row
lock, so two sessions marking at once never collide; two supervisors verify two departments of one change order
at once and both land; two users tick different checklist keys and both stay; a stale expect is refused with the
message; a write to a packet filed in the meantime is refused."""
from __future__ import annotations
import threading
from collections.abc import Callable, Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import step
from pshelpers import needs_pg
from shophelpers import (
SIGN,
STALE,
Shop,
all_rows,
finish_stage,
fresh,
in_progress,
issue_co,
ok,
open_shop,
refused,
run,
to_station,
verify,
)
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def together(calls: list[Callable[[], Any]]) -> list[Any]:
"""Run the calls in threads released by one barrier. Returns their answers in order."""
results: list[Any] = [None] * len(calls)
errors: list[BaseException] = []
barrier = threading.Barrier(len(calls))
def run_one(i: int) -> None:
try:
barrier.wait(10)
results[i] = calls[i]()
except BaseException as e: # reported below
errors.append(e)
threads = [threading.Thread(target=run_one, args=(i,)) for i in range(len(calls))]
for t in threads:
t.start()
for t in threads:
t.join(60)
assert not errors, errors
return results
def weld_call(c: TestClient, pid: int, fx: float) -> Callable[[], Any]:
body = {"page": 1, "fx": fx, "fy": 0.5, "items": [1], "type": "Fillet", "process": "GMAW"}
return lambda: c.post(f"/api/packets/{pid}/welds", json=body)
def tag_call(c: TestClient, pid: int, fx: float) -> Callable[[], Any]:
return lambda: c.post(f"/api/packets/{pid}/heat-tags", json={"page": 1, "item": 2, "fx": fx, "fy": 0.4})
def test_concurrent_welds_never_collide(shop: Shop) -> None:
pkt = in_progress(shop)
pid = pkt["id"]
# weld.andy on two sessions (two tablets) and sup.wes, three welds each, all at once
clients = [shop.fresh("weld.andy"), shop.fresh("weld.andy"), shop.fresh("sup.wes")]
calls = [weld_call(c, pid, 0.1 + 0.1 * i + 0.01 * k) for i, c in enumerate(clients) for k in range(3)]
answers = together(calls)
assert [r.status_code for r in answers] == [200] * len(calls), [r.text for r in answers if r.status_code != 200]
nos = sorted(w["weld_no"] for w in fresh(shop("sup.wes"), pid)["welds"])
assert nos == list(range(1, len(calls) + 1))
rows = all_rows(shop.settings, "SELECT weld_no FROM welds WHERE packet_id = %s ORDER BY weld_no", (pid,))
assert [r[0] for r in rows] == nos
marked = sorted(
r[0]
for r in all_rows(shop.settings, "SELECT action FROM packet_log WHERE packet_id = %s", (pid,))
if r[0].endswith(" marked") and r[0].startswith("Weld W")
)
assert marked == sorted(f"Weld W{n} marked" for n in nos)
assert (
int(all_rows(shop.settings, "SELECT next_weld_no FROM packets WHERE id = %s", (pid,))[0][0]) == len(calls) + 1
)
def test_two_welds_at_once_are_w1_and_w2(shop: Shop) -> None:
pkt = in_progress(shop)
a, b = together(
[weld_call(shop.fresh("weld.andy"), pkt["id"], 0.2), weld_call(shop.fresh("sup.wes"), pkt["id"], 0.7)]
)
assert a.status_code == 200 and b.status_code == 200, (a.text, b.text)
got = {w["fx"]: w["weld_no"] for w in fresh(shop("sup.wes"), pkt["id"])["welds"]}
assert sorted(got.values()) == [1, 2] and got[0.2] != got[0.7]
def test_concurrent_heat_tags_never_collide(shop: Shop) -> None:
pkt = in_progress(shop)
pid = pkt["id"]
clients = [shop.fresh("weld.andy"), shop.fresh("sup.wes"), shop.fresh("lead.lou")]
calls = [tag_call(c, pid, 0.1 + 0.1 * i + 0.01 * k) for i, c in enumerate(clients) for k in range(3)]
answers = together(calls)
assert [r.status_code for r in answers] == [200] * len(calls), [r.text for r in answers if r.status_code != 200]
nos = sorted(t["tag_no"] for t in fresh(shop("sup.wes"), pid)["heat_tags"])
assert nos == list(range(1, len(calls) + 1))
def test_two_supervisors_verify_at_once(shop: Shop) -> None:
pkt = in_progress(shop)
pkt = issue_co(shop, pkt, ["03", "06"])
assert pkt["change_orders"][0]["required"] == ["03", "06"]
wes, mia = shop.fresh("sup.wes"), shop.fresh("multi.mia")
def call(c: TestClient, dept: str) -> Callable[[], Any]:
body = {"dept": dept, "note": f"Checked in {dept}", "sign": SIGN}
return lambda: c.post(f"/api/packets/{pkt['id']}/change-orders/1/verify", json=body)
a, b = together([call(wes, "03"), call(mia, "06")])
assert a.status_code == 200 and b.status_code == 200, (a.text, b.text)
out = fresh(shop("sup.wes"), pkt["id"])
co = out["change_orders"][0]
assert set(co["verified"]) == {"03", "06"} and co["pending_depts"] == [] and co["open"] is False
assert out["open_cos"] == []
rows = all_rows(shop.settings, "SELECT dept_code FROM co_verifications ORDER BY dept_code")
assert [r[0] for r in rows] == ["03", "06"]
notes = sorted(
r[0]
for r in all_rows(
shop.settings,
"SELECT note FROM packet_log WHERE packet_id = %s AND action LIKE 'Change order CO-1 verified by %%'",
(pkt["id"],),
)
)
# one of them landed first and still waited on the other; the second fully verified it
assert len(notes) == 2
assert any(n.endswith("change order fully verified") for n in notes)
assert any("still waiting on" in n for n in notes)
# the same department twice at once: one lands, one is told it is done
pkt2 = issue_co(shop, out, ["03"])
no = pkt2["change_orders"][-1]["co_no"]
def again(c: TestClient) -> Callable[[], Any]:
return lambda: c.post(
f"/api/packets/{pkt['id']}/change-orders/{no}/verify", json={"dept": "03", "note": "x", "sign": SIGN}
)
a, b = together([again(shop.fresh("sup.wes")), again(shop.fresh("sup.wes"))])
assert sorted([a.status_code, b.status_code]) == [200, 409], (a.text, b.text)
loser = a if a.status_code == 409 else b
assert loser.json()["code"] == "already_verified"
def test_checklist_keys_merge(shop: Shop) -> None:
pkt = in_progress(shop)
andy, wes = shop.fresh("weld.andy"), shop.fresh("sup.wes")
url = f"/api/packets/{pkt['id']}/stage/checklist"
pairs = [(andy, "dwg"), (wes, "heat"), (andy, "wps"), (wes, "fit")]
answers = together([(lambda c=c, k=k: c.put(f"{url}/{k}", json={"done": True})) for c, k in pairs])
assert [r.status_code for r in answers] == [200] * 4, [r.text for r in answers]
ck = fresh(shop("sup.wes"), pkt["id"])["stage"]["checklist"]
assert {k for k, v in ck.items() if v.get("done")} == {"dwg", "heat", "wps", "fit"}
assert ck["dwg"]["by"] == shop.uid("weld.andy") and ck["heat"]["by"] == shop.uid("sup.wes")
def test_stale_expect_is_refused(shop: Shop) -> None:
pkt = finish_stage(shop, in_progress(shop))
andy_view = fresh(shop("weld.andy"), pkt["id"])
ok(step(shop("sup.wes"), pkt, "pull_back"))
r = refused(step(shop("weld.andy"), andy_view, "submit", sign=SIGN), 409, "stale_step", STALE)
assert r["current"]["status"] == "ready" and r["current"]["dept"] == "03"
assert r["current"]["version"] == fresh(shop("sup.wes"), pkt["id"])["version"]
# pull back and start at once: one of them is stale
pkt = to_station(shop, fresh(shop("sup.wes"), pkt["id"]))
last = all_rows(
shop.settings, "SELECT coalesce(max(id), 0) FROM packet_transitions WHERE packet_id = %s", (pkt["id"],)
)
wes, andy = shop.fresh("sup.wes"), shop.fresh("weld.andy")
a, b = together([lambda: step(wes, pkt, "unassign"), lambda: step(andy, pkt, "start")])
assert sorted([a.status_code, b.status_code]) == [200, 409], (a.text, b.text)
loser = a if a.status_code == 409 else b
assert loser.json()["code"] == "stale_step" and loser.json()["detail"] == STALE
n = all_rows(
shop.settings,
"SELECT count(*) FROM packet_transitions WHERE packet_id = %s AND id > %s",
(pkt["id"], last[0][0]),
)[0][0]
assert n == 1
def test_filed_in_the_meantime_is_refused(shop: Shop) -> None:
pkt = in_progress(shop)
pkt = issue_co(shop, pkt, ["03"])
seen = fresh(shop("sup.wes"), pkt["id"])
run(shop.settings, "UPDATE packets SET status = 'filed', version = version + 1 WHERE id = %s", (pkt["id"],))
msg = "This packet is filed and locked. IT can reopen it if a correction is needed."
refused(verify(shop, seen, 1, "03", "sup.wes"), 403, "filed", msg)
refused(
shop("clerk.clara").patch(f"/api/packets/{pkt['id']}", json={"set": {"drawing": "D-9"}, "base": {}}),
403,
"filed",
msg,
)
refused(verify(shop, seen, 1, "03", "admin.ann", it_reason="Ticket 5502: verify after filing"), 403, "filed", msg)
# a shop edit is not open on a filed packet either
r = shop("weld.andy").post(
f"/api/packets/{pkt['id']}/welds", json={"page": 1, "fx": 0.5, "fy": 0.5, "items": [1], "type": "Fillet"}
)
assert r.status_code == 403 and r.json()["code"] in ("filed", "wrong_status"), r.text
r = step(shop("weld.andy"), seen, "submit", sign=SIGN)
assert r.status_code == 409 and r.json()["code"] == "stale_step", r.text