Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
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:
@@ -6,6 +6,20 @@ All notable changes to this project are recorded here. The format follows [Keep
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
|
||||
@@ -6,9 +6,9 @@ This repo is the rebuild of Packet Station as a server application: Docker Compo
|
||||
|
||||
## 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? |
|
||||
|---|---|
|
||||
@@ -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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| Regression bench and go-live | No (phase 10) |
|
||||
|
||||
|
||||
+13
-1
@@ -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/`.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
- 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`.
|
||||
|
||||
+7
-1
@@ -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.
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -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).
|
||||
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
|
||||
|
||||
- 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
@@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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). |
|
||||
@@ -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.
|
||||
|
||||
## 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)
|
||||
|
||||
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 |
|
||||
| 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 |
|
||||
| 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 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 |
|
||||
@@ -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 |
|
||||
| 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 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
@@ -1,6 +1,6 @@
|
||||
# Packet Station schema
|
||||
|
||||
Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 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
|
||||
|
||||
@@ -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.
|
||||
- `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_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.
|
||||
- `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.
|
||||
- `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_table_placements`: `page`, `x`, `y`, `s`, `by`, `at`. Unique on (`packet_id`, `page`), written as an upsert.
|
||||
- `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), `bfx` and `bfy` (0006), `wo_base`, `piece`, `items` int[], `type`, `size`, `process`, `stamp`, `welder_id`, `dept_code`, `stage_id`, `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `weld_no`) among live rows.
|
||||
- `heat_tags`: `tag_no` (from `next_heat_no`), `page`, `item`, `fx`, `fy`, `bfx` and `bfy` (0006), `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `tag_no`) among live rows.
|
||||
- `heat_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_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.
|
||||
@@ -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)`.
|
||||
|
||||
## 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`: `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_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:
|
||||
|
||||
|
||||
+12
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
This document is rewritten for the server build. The old browser build's security document is in the old repo (`C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md`). The design comes from [BRIEF.md](BRIEF.md) and [PLAN.md](PLAN.md).
|
||||
|
||||
Status: phase 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:
|
||||
|
||||
@@ -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.
|
||||
- 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)
|
||||
|
||||
- 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) |
|
||||
| S4 | Deferred to IT (pentest before go-live) |
|
||||
| S5 | Closed, phase 2 (React escapes output; server messages are plain text; PDF export escaping comes with phase 7) |
|
||||
| S6, S7, S8 | Closed, phase 7 |
|
||||
| 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 |
|
||||
| O3, O4 | Closed, phase 10 |
|
||||
| O5 | Superseded (no migration; M: retired read-only) |
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("sign-in", () => {
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret-pass" } });
|
||||
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(screen.getByText("Clara Clerk")).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");
|
||||
|
||||
fireEvent.click(within(nav).getByRole("button", { name: "Weld Stall" }));
|
||||
expect(await screen.findByRole("heading", { name: "Weld Stall" })).toBeInTheDocument();
|
||||
expect(within(nav).getByRole("button", { name: "Weld Stall" })).toHaveAttribute("aria-pressed", "true");
|
||||
await waitFor(() => expect(within(nav).getByRole("button", { name: "Weld Stall" })).toHaveAttribute("aria-pressed", "true"));
|
||||
});
|
||||
|
||||
it("is hidden for a user with one screen", async () => {
|
||||
|
||||
@@ -5,8 +5,11 @@ import type {
|
||||
AttachPagesBody,
|
||||
BomLine,
|
||||
BomPatch,
|
||||
DeptPerson,
|
||||
FieldCas,
|
||||
Guarded,
|
||||
HeatTableBody,
|
||||
HeatTagBody,
|
||||
LogRow,
|
||||
PacketCard,
|
||||
PacketCreate,
|
||||
@@ -15,7 +18,9 @@ import type {
|
||||
PacketRead,
|
||||
ReceiptBody,
|
||||
ShortBody,
|
||||
StageFields,
|
||||
TransitionBody,
|
||||
WeldBody,
|
||||
WhStageBody,
|
||||
WorkOrder,
|
||||
} from "./types";
|
||||
@@ -86,4 +91,33 @@ export const packetsApi = {
|
||||
receiveShort: (id: number, item: number, body: ReceiptBody, g: Guarded = {}) =>
|
||||
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 }),
|
||||
|
||||
/* 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"),
|
||||
};
|
||||
|
||||
@@ -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
@@ -227,10 +227,20 @@ export interface PacketPage {
|
||||
pn: number;
|
||||
dwg: string | null;
|
||||
rev: string | null;
|
||||
void: { co_id: number; at: string; new_n: number | null } | null;
|
||||
void: PageVoid | null;
|
||||
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 {
|
||||
id: number;
|
||||
qty: number | string;
|
||||
@@ -327,11 +337,11 @@ export interface PacketRead {
|
||||
work_orders: WorkOrder[];
|
||||
pages: PacketPage[];
|
||||
bom: BomLine[];
|
||||
welds: unknown[];
|
||||
heat_tags: unknown[];
|
||||
heat_table: unknown[];
|
||||
stage: Record<string, unknown> | null;
|
||||
stages: unknown[];
|
||||
welds: Weld[];
|
||||
heat_tags: HeatTag[];
|
||||
heat_table: HeatTablePlacement[];
|
||||
stage: Stage | null;
|
||||
stages: Stage[];
|
||||
wh: WhInfo | null;
|
||||
qc: Record<string, unknown> | null;
|
||||
qc_history: unknown[];
|
||||
@@ -346,6 +356,178 @@ export interface PacketRead {
|
||||
checklist_items: Array<{ group: string; key: string; text: string }>;
|
||||
qc_items: unknown[];
|
||||
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. */
|
||||
@@ -541,7 +723,13 @@ export interface ChangeOrder {
|
||||
rejected_reason?: string | null;
|
||||
items: ChangeOrderItem[];
|
||||
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;
|
||||
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;
|
||||
|
||||
@@ -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 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[];
|
||||
|
||||
@@ -36,4 +37,43 @@ describe("roles", () => {
|
||||
expect(roleChipClass("admin")).toBe("rchip admin");
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,17 +79,76 @@ export function orderScreens(list: readonly string[]): Screen[] {
|
||||
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 {
|
||||
const ordered = orderScreens(list);
|
||||
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 {
|
||||
const s = firstScreen(list);
|
||||
const s = landingScreen(list);
|
||||
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> = {
|
||||
admin: "Admin",
|
||||
clerical: "Clerical",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
interface EventHandlers {
|
||||
@@ -8,6 +9,11 @@ interface EventHandlers {
|
||||
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 {
|
||||
if (typeof data !== "string") return null;
|
||||
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.
|
||||
* 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[] {
|
||||
const [presence, setPresence] = useState<PresenceUser[]>([]);
|
||||
const packetId = useSyncExternalStore(subscribeFocus, getFocus, getFocus);
|
||||
const ref = useRef(handlers);
|
||||
useEffect(() => {
|
||||
ref.current = handlers;
|
||||
@@ -31,7 +65,7 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers): Pr
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
const p = parse((ev as MessageEvent).data);
|
||||
@@ -59,7 +93,36 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers): Pr
|
||||
es.close();
|
||||
setPresence([]);
|
||||
};
|
||||
}, [sessionId]);
|
||||
}, [sessionId, packetId]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 & 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"));
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,27 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import { SCREEN_LABELS, type Screen } from "../auth/roles";
|
||||
import { rememberScreen, SCREEN_LABELS, type Screen } from "../auth/roles";
|
||||
|
||||
interface RoleSwitcherProps {
|
||||
screens: readonly Screen[];
|
||||
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) {
|
||||
const navigate = useNavigate();
|
||||
if (screens.length < 2) return null;
|
||||
return (
|
||||
<nav className="roles" aria-label="Screens">
|
||||
{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]}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ describe("PacketPage", () => {
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
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));
|
||||
expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse");
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { ApiError } from "../api/client";
|
||||
import { ApiError, errorCode } from "../api/client";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { PacketHeader, PacketRead, TransitionBody } from "../api/types";
|
||||
import { homePath } from "../auth/roles";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { usePacketPresence } from "../auth/useEvents";
|
||||
import { useGuard } from "../components/useGuard";
|
||||
import { useChanged } from "../lib/changes";
|
||||
import { errorText } from "../lib/format";
|
||||
@@ -14,12 +15,23 @@ import { DrawingViewer } from "./DrawingViewer";
|
||||
import { MoveSheet, SendToDeptSheet } from "./sheets";
|
||||
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
|
||||
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 { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whStage } from "../warehouse/stages";
|
||||
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. */
|
||||
const SIGN_ATTACH = "Attach a drawing by hand";
|
||||
@@ -119,9 +131,13 @@ export function PacketPage() {
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
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 [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 fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -140,14 +156,20 @@ export function PacketPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setPacket(null);
|
||||
setTab(null);
|
||||
setTab(isTab(wantTab) ? wantTab : null);
|
||||
void reload();
|
||||
}, [reload]);
|
||||
}, [reload, wantTab]);
|
||||
|
||||
useEffect(() => {
|
||||
versionRef.current = packet?.version ?? null;
|
||||
}, [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.
|
||||
useChanged(() => void reload(), {
|
||||
delayMs: 500,
|
||||
@@ -195,7 +217,9 @@ export function PacketPage() {
|
||||
} catch (err) {
|
||||
// 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();
|
||||
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;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -293,7 +317,7 @@ export function PacketPage() {
|
||||
|
||||
const drawingPages = p.pages.filter((x) => x.type === "drawing");
|
||||
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.
|
||||
const passive = new Set(["wh_receive_short", "set_databook_at"]);
|
||||
const viewOnly =
|
||||
@@ -322,14 +346,20 @@ export function PacketPage() {
|
||||
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]);
|
||||
|
||||
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 }> = [
|
||||
{ id: "drawing", label: "Drawing", count: drawingPages.length },
|
||||
{ id: "heat", label: "Heat map" },
|
||||
{ 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: "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: "history", label: "History" },
|
||||
];
|
||||
const onTabError = (text: string) => setMsg({ ok: false, text });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -446,6 +476,12 @@ export function PacketPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div className={msg.ok ? "callout" : "callout warn"} role={msg.ok ? "status" : "alert"}>
|
||||
{msg.text}
|
||||
@@ -454,7 +490,7 @@ export function PacketPage() {
|
||||
|
||||
{review && (me?.roles ?? []).some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin") ? (
|
||||
<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}{" "}
|
||||
<button className="btn sm pri" type="button" onClick={() => setTab("changes")}>
|
||||
Review change
|
||||
@@ -615,6 +651,8 @@ export function PacketPage() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<ShopBar p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} goTab={(t) => setTab(t)} />
|
||||
|
||||
<div className="tabs" role="tablist" aria-label="Packet">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
@@ -648,7 +686,12 @@ export function PacketPage() {
|
||||
<DrawingViewer pages={drawingPages} />
|
||||
</div>
|
||||
) : 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 === "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 === "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}
|
||||
@@ -679,6 +722,11 @@ export function PacketPage() {
|
||||
) : null}
|
||||
{wh.sheet}
|
||||
{guardSheet}
|
||||
{toast ? (
|
||||
<div className="toast" role="status" data-testid="toast">
|
||||
{toast}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"] }) },
|
||||
});
|
||||
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" }));
|
||||
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("L900001 qty 2 -> 3")).toBeInTheDocument();
|
||||
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();
|
||||
await sign();
|
||||
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.findByRole("table", { name: "Change order 2 verification" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("Change order CO-2 approved. The shop packet is updated.")).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 () => {
|
||||
@@ -190,7 +190,7 @@ describe("Packet page, Changes tab", () => {
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
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).getByText("Waits on that rev in M-Files.")).toHaveClass("why");
|
||||
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 }]);
|
||||
|
||||
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.click(within(ask).getByRole("button", { name: "Reject" }));
|
||||
await sign();
|
||||
|
||||
@@ -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 { assetsApi, blobUrl } from "../api/assets";
|
||||
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 { Sheet } from "../components/Sheet";
|
||||
import { errorText, fmtTime } from "../lib/format";
|
||||
import { coSummary, feedOrigin } from "../lib/changeOrders";
|
||||
import { DRAWING_FLAGS } from "../lib/packets";
|
||||
import type { Mutate } from "./tabs";
|
||||
|
||||
@@ -307,15 +306,7 @@ export function VisualBox({
|
||||
|
||||
/* ---------- Change orders ---------- */
|
||||
|
||||
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 = 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({
|
||||
export function ReviewPanel({
|
||||
p,
|
||||
co,
|
||||
sub,
|
||||
@@ -339,11 +330,11 @@ function ReviewPanel({
|
||||
const old = p.pages.find((x) => x.type === "drawing" && !x.void);
|
||||
|
||||
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 reason = await askReason({
|
||||
title: `Reject change order ${co.co_no}`,
|
||||
title: `Reject change order CO-${co.co_no}`,
|
||||
sub,
|
||||
note: "The shop packet is not changed. If VISUAL changes again, a new change order comes in.",
|
||||
min: 3,
|
||||
@@ -352,7 +343,7 @@ function ReviewPanel({
|
||||
submitLabel: "Reject",
|
||||
});
|
||||
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 = () =>
|
||||
@@ -421,142 +412,3 @@ function ReviewPanel({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useAuth } from "../auth/useAuth";
|
||||
import { AdminPage } from "./admin/AdminPage";
|
||||
import { ClericalHome } from "./clerical/ClericalHome";
|
||||
import { PlaceholderScreen } from "./PlaceholderScreen";
|
||||
import { StallHome } from "./stall/StallHome";
|
||||
import { SupervisorHome } from "./supervisor/SupervisorHome";
|
||||
import { WarehouseHome } from "./warehouse/WarehouseHome";
|
||||
|
||||
/** 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 === "clerical") return <ClericalHome />;
|
||||
if (screen === "warehouse") return <WarehouseHome />;
|
||||
if (screen === "supervisor") return <SupervisorHome />;
|
||||
if (screen === "stall") return <StallHome />;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -404,3 +404,152 @@ td.late { color: var(--red); }
|
||||
.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 .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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,9 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..domain import shop
|
||||
from .deps import Db, Principal, current_principal, get_db
|
||||
from .errors import ApiError
|
||||
|
||||
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], [])}
|
||||
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]))
|
||||
|
||||
@@ -16,7 +16,13 @@ SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connecti
|
||||
|
||||
|
||||
@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
|
||||
p = await run_in_threadpool(resolve_standalone, request)
|
||||
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,
|
||||
display_name=p.user["display_name"],
|
||||
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}
|
||||
return StreamingResponse(
|
||||
|
||||
@@ -25,6 +25,7 @@ from ..domain.children import NEVER_VERIFY, active_depts, co_touched, current_wh
|
||||
from ..domain.pipeline import (
|
||||
SignBody,
|
||||
Write,
|
||||
apply_field_cas,
|
||||
check_row_version,
|
||||
jsonable,
|
||||
next_counter,
|
||||
@@ -43,10 +44,36 @@ router = APIRouter(prefix="/api/packets", tags=["packet-children"])
|
||||
|
||||
A = policy.Action
|
||||
|
||||
TEXT_SHORT = 40 # weld type, size, process, wo_base
|
||||
FILLER_MAX = 120
|
||||
TEXT_SHORT = LIMITS["weld_text"] # weld type, size, process, wo_base
|
||||
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.
|
||||
WH_HOLD_REASONS = (
|
||||
"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_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_VOID = "This sheet is VOID. Mark the current sheet."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- bodies
|
||||
@@ -145,6 +173,8 @@ class WeldBody(It):
|
||||
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)
|
||||
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)
|
||||
piece: int | None = Field(default=None, ge=1, le=100_000)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
piece: int | None = Field(default=None, ge=1, le=100_000)
|
||||
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)
|
||||
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)
|
||||
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):
|
||||
@@ -183,19 +217,26 @@ class TagPatchBody(It):
|
||||
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)
|
||||
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):
|
||||
x: 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):
|
||||
"""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_lot: 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)
|
||||
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):
|
||||
@@ -420,6 +461,51 @@ def _tag(conn: Any, packet_id: int, no: int) -> dict[str, Any]:
|
||||
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]:
|
||||
row = packets.one(
|
||||
conn,
|
||||
@@ -428,7 +514,7 @@ def _co(conn: Any, packet_id: int, no: int) -> dict[str, Any]:
|
||||
(packet_id, no),
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -437,7 +523,7 @@ def _co_state(row: dict[str, Any], want: str, what: str) -> None:
|
||||
raise ApiError(
|
||||
409,
|
||||
"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"]}},
|
||||
)
|
||||
|
||||
@@ -785,12 +871,22 @@ def receive_short(
|
||||
|
||||
def _weld_values(conn: Any, packet_id: int, body: WeldBody | WeldPatchBody, given: set[str]) -> 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:
|
||||
values[k] = getattr(body, k)
|
||||
for k in ("wo_base", "type", "size", "process"):
|
||||
if k in given:
|
||||
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:
|
||||
values["stamp"] = _stamp(body.stamp)
|
||||
if "items" in given:
|
||||
@@ -800,6 +896,33 @@ def _weld_values(conn: Any, packet_id: int, body: WeldBody | WeldPatchBody, give
|
||||
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)
|
||||
def add_weld(
|
||||
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
|
||||
) as w:
|
||||
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"])
|
||||
worker = pp.level(dept) == 1
|
||||
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)
|
||||
no = next_counter(conn, packet_id, "weld")
|
||||
conn.execute(
|
||||
"INSERT INTO welds (packet_id, weld_no, page, fx, fy, wo_base, piece, items, type, size, process, 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)",
|
||||
"INSERT INTO welds (packet_id, weld_no, page, fx, fy, bfx, bfy, wo_base, piece, items, type, size, process,"
|
||||
" 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)",
|
||||
(
|
||||
packet_id,
|
||||
no,
|
||||
values.get("page"),
|
||||
values.get("fx"),
|
||||
values.get("fy"),
|
||||
values.get("bfx"),
|
||||
values.get("bfy"),
|
||||
values.get("wo_base"),
|
||||
values.get("piece"),
|
||||
values["items"],
|
||||
@@ -848,8 +976,7 @@ def add_weld(
|
||||
)
|
||||
w.changed("welds")
|
||||
w.detail["weld_no"] = no
|
||||
desc = " ".join(x for x in (values.get("type"), values.get("size")) if x)
|
||||
w.log(f"Logged W{no}", " · ".join(x for x in (desc, "items " + " + ".join(map(str, values["items"]))) if x))
|
||||
w.log(f"Weld W{no} marked", _weld_note(values))
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -870,6 +997,7 @@ def patch_weld(
|
||||
) as w:
|
||||
row = _weld(conn, packet_id, no)
|
||||
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))
|
||||
changes = _diff(row, values)
|
||||
if "items" in changes and sorted(row["items"] or []) == changes["items"]:
|
||||
@@ -877,10 +1005,15 @@ def patch_weld(
|
||||
if not changes:
|
||||
w.skip()
|
||||
return _read(d, pp, packet_id)
|
||||
if "page" in changes:
|
||||
_live_page(conn, packet_id, changes["page"])
|
||||
update_row(conn, "welds", row["id"], changes)
|
||||
w.changed(*[f"weld.{k}" for k in changes])
|
||||
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)
|
||||
|
||||
|
||||
@@ -905,6 +1038,7 @@ def remove_weld(
|
||||
row = _weld(conn, packet_id, no)
|
||||
if if_match is not None:
|
||||
check_row_version(row, if_match)
|
||||
_not_void(conn, packet_id, row["page"])
|
||||
conn.execute(
|
||||
"UPDATE welds SET removed_at = now(), removed_by = %s, removed_reason = %s, row_version = row_version + 1"
|
||||
" WHERE id = %s",
|
||||
@@ -912,7 +1046,7 @@ def remove_weld(
|
||||
)
|
||||
w.changed("welds")
|
||||
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)
|
||||
|
||||
|
||||
@@ -920,7 +1054,7 @@ def remove_weld(
|
||||
|
||||
|
||||
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 body.item is None:
|
||||
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
|
||||
) as w:
|
||||
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")
|
||||
conn.execute(
|
||||
"INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy, by) VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
||||
(packet_id, no, values.get("page"), values["item"], values.get("fx"), values.get("fy"), w.user_id),
|
||||
"INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy, bfx, bfy, by)"
|
||||
" 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.detail["tag_no"] = no
|
||||
@@ -970,14 +1116,18 @@ def patch_tag(
|
||||
) as w:
|
||||
row = _tag(conn, packet_id, no)
|
||||
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)))
|
||||
if not changes:
|
||||
w.skip()
|
||||
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)
|
||||
w.changed(*[f"heat_tag.{k}" for k in changes])
|
||||
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)
|
||||
|
||||
|
||||
@@ -1001,6 +1151,7 @@ def remove_tag(
|
||||
row = _tag(conn, packet_id, no)
|
||||
if if_match is not None:
|
||||
check_row_version(row, if_match)
|
||||
_not_void(conn, packet_id, row["page"])
|
||||
conn.execute(
|
||||
"UPDATE heat_tags SET removed_at = now(), removed_by = %s, removed_reason = %s,"
|
||||
" row_version = row_version + 1 WHERE id = %s",
|
||||
@@ -1008,7 +1159,7 @@ def remove_tag(
|
||||
)
|
||||
w.changed("heat_tags")
|
||||
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)
|
||||
|
||||
|
||||
@@ -1021,29 +1172,50 @@ def put_heat_table(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> 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:
|
||||
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)
|
||||
conn = d.conn
|
||||
with packet_write(
|
||||
d, p, packet_id, A.EDIT_HEAT_TABLE, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||
) 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(
|
||||
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):
|
||||
w.skip()
|
||||
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(
|
||||
"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,"
|
||||
" by = EXCLUDED.by, at = now()",
|
||||
(packet_id, page, body.x, body.y, body.s, w.user_id),
|
||||
" carried_from = EXCLUDED.carried_from, by = EXCLUDED.by, at = now()",
|
||||
(packet_id, page, body.x, body.y, body.s, carried, w.user_id),
|
||||
)
|
||||
w.changed("heat_table")
|
||||
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)
|
||||
|
||||
|
||||
@@ -1062,14 +1234,15 @@ def remove_heat_table(
|
||||
with packet_write(
|
||||
d, p, packet_id, A.EDIT_HEAT_TABLE, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||
) as w:
|
||||
_not_void(conn, packet_id, page)
|
||||
gone = conn.execute(
|
||||
"DELETE FROM heat_table_placements WHERE packet_id = %s AND page = %s RETURNING id", (packet_id, page)
|
||||
).fetchone()
|
||||
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.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)
|
||||
|
||||
|
||||
@@ -1084,16 +1257,33 @@ def patch_stage(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> 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)
|
||||
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)
|
||||
conn = d.conn
|
||||
with packet_write(
|
||||
d, p, packet_id, A.EDIT_STAGE_FIELDS, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||
) as w:
|
||||
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)
|
||||
changes = _diff(stage, values)
|
||||
if not changes:
|
||||
@@ -1101,10 +1291,24 @@ def patch_stage(
|
||||
return _read(d, pp, packet_id)
|
||||
update_row(conn, "packet_stages", stage["id"], 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)
|
||||
|
||||
|
||||
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)
|
||||
def put_checklist(
|
||||
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
|
||||
) as w:
|
||||
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.")
|
||||
stage = _need_stage(conn, packet_id)
|
||||
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",
|
||||
(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.log(("Checked: " if body.done else "Unchecked: ") + text)
|
||||
return _read(d, pp, packet_id)
|
||||
@@ -1453,7 +1659,7 @@ def issue_co(
|
||||
_co_log_detail(w, no)
|
||||
w.detail["required"] = required
|
||||
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)
|
||||
|
||||
|
||||
@@ -1605,6 +1811,7 @@ def _apply_feed_co(w: Write, co: dict[str, Any]) -> str | None:
|
||||
first, count, old = pv.replace_drawing_pages(
|
||||
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)):
|
||||
o = old[i] if i < len(old) else None
|
||||
new = i < count
|
||||
@@ -1642,6 +1849,26 @@ def _apply_feed_co(w: Write, co: dict[str, Any]) -> str | 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)
|
||||
def approve_co(
|
||||
packet_id: int,
|
||||
@@ -1699,7 +1926,7 @@ def approve_co(
|
||||
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))
|
||||
_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)
|
||||
|
||||
|
||||
@@ -1743,7 +1970,7 @@ def reject_co(
|
||||
},
|
||||
)
|
||||
_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)
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
_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)
|
||||
|
||||
|
||||
@@ -1811,12 +2038,12 @@ def verify_co(
|
||||
_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"],))}
|
||||
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(
|
||||
"SELECT 1 FROM co_verifications WHERE co_id = %s AND dept_code = %s", (co["id"], dept)
|
||||
).fetchone()
|
||||
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:
|
||||
conn.execute(
|
||||
"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),
|
||||
)
|
||||
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)
|
||||
w.detail["dept"] = dept
|
||||
name = packets.dept_names(conn).get(dept, dept)
|
||||
w.log(f"Change order {no} verified for {name}", note)
|
||||
names = packets.dept_names(conn)
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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)
|
||||
return counts
|
||||
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}
|
||||
wanted = db_items(lines)
|
||||
kept: set[int] = set()
|
||||
@@ -544,13 +551,17 @@ def merge_visual_bom(conn: Any, packet_id: int, raw_lines: list[Any]) -> dict[st
|
||||
sort,
|
||||
)
|
||||
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:
|
||||
kept.add(int(match["id"]))
|
||||
conn.execute(
|
||||
"UPDATE bom_lines SET qty = %s, part_id = %s, description = %s, loc = %s, whs = %s, comm = %s,"
|
||||
" cons = %s, sub = %s, na = %s, grp = %s, issued = coalesce(%s, issued), sort = %s, cl_item = %s,"
|
||||
"UPDATE bom_lines SET qty = %s, part_id = %s, description = %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",
|
||||
(*values, b.item, match["id"]),
|
||||
(*values, b.item, b.vkey or None, match["id"]),
|
||||
)
|
||||
counts["updated"] += 1
|
||||
continue
|
||||
|
||||
@@ -17,7 +17,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from .. import audit, events, policy
|
||||
from ..domain import assets as asset_store
|
||||
from ..domain import packets
|
||||
from ..domain import packets, shop
|
||||
from ..domain.pipeline import (
|
||||
Expect,
|
||||
SignBody,
|
||||
@@ -28,7 +28,7 @@ from ..domain.pipeline import (
|
||||
require_if_match,
|
||||
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 .errors import ApiError
|
||||
|
||||
@@ -534,6 +534,15 @@ def transition(
|
||||
extra["dept"] = shop_dept(conn, extra["dept"])
|
||||
if "for_sup_id" in extra:
|
||||
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}
|
||||
with packet_write(
|
||||
d,
|
||||
@@ -556,6 +565,7 @@ def transition(
|
||||
if ok is None:
|
||||
raise ApiError(422, "invalid_station", "Choose a station in this department.")
|
||||
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)
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -1,14 +1,19 @@
|
||||
"""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.
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NO_REV = "-"
|
||||
NDE_NONE = ("", "none", "none required")
|
||||
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}
|
||||
|
||||
|
||||
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."""
|
||||
dwg = f"Drawing {drawing or ''} Rev {rev or NO_REV} matches the packet".replace(" ", " ")
|
||||
if not weld:
|
||||
g1, g2 = "Before starting", "When done"
|
||||
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"),
|
||||
]
|
||||
t = templates or DEFAULT_TEMPLATES
|
||||
items = t.get("weld" if weld else "plain") or DEFAULT_TEMPLATES["weld" if weld else "plain"]
|
||||
return [_item(i["group"], i["key"], _fill(i["text"], drawing, rev, wps)) for i in items]
|
||||
|
||||
|
||||
def qc_items_for(
|
||||
@@ -66,8 +135,8 @@ def qc_items_for(
|
||||
]
|
||||
|
||||
|
||||
def checklist_keys(weld: bool) -> list[str]:
|
||||
return [i["key"] for i in checklist_for(weld, None, None, None)]
|
||||
def checklist_keys(weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None) -> list[str]:
|
||||
return [i["key"] for i in checklist_for(weld, None, None, None, templates)]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
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:
|
||||
|
||||
@@ -39,6 +39,10 @@ SEAL_EXCLUDE = (
|
||||
"qc_items",
|
||||
"flag",
|
||||
"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)
|
||||
|
||||
|
||||
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] = []
|
||||
for co in open_cos:
|
||||
label = co_label(co["co_no"])
|
||||
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:
|
||||
names = ", ".join(dept_names.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}.")
|
||||
names = ", ".join(labels.get(c, c) for c in co.get("pending_depts") or [])
|
||||
parts.append(f"Change order {label} must be verified first by: {names}.")
|
||||
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")}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -215,6 +240,8 @@ class Children:
|
||||
docs: list[dict[str, Any]] = field(default_factory=list)
|
||||
seal: dict[str, Any] | None = None
|
||||
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
|
||||
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
|
||||
)
|
||||
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.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,))
|
||||
@@ -305,6 +335,13 @@ def needs_heat(line: Mapping[str, Any]) -> bool:
|
||||
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:
|
||||
weld = bool(ch.dept.get("weld"))
|
||||
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"]),
|
||||
weld_dept=weld,
|
||||
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),
|
||||
drawing_attached=any(pg["type"] == "drawing" and pg["void_at"] is None for pg in ch.pages),
|
||||
heat_map=row["heat_map"],
|
||||
bom_total=len(live_bom),
|
||||
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)),
|
||||
welds=len(ch.welds),
|
||||
checklist_complete=stage is not None and checklists.checklist_complete(stage["checklist"], weld),
|
||||
welds=dept_welds(row, ch),
|
||||
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()),
|
||||
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds)),
|
||||
has_qc_sent=ch.qc is not None,
|
||||
@@ -369,6 +407,19 @@ class People:
|
||||
return {"id": 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:
|
||||
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]
|
||||
|
||||
|
||||
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"])
|
||||
if welder is not None:
|
||||
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"],
|
||||
"station": _station_ref(conn, s["station_id"], cache),
|
||||
"welder": welder,
|
||||
"checklist": s["checklist"] or {},
|
||||
"checklist": _checklist(s["checklist"], people),
|
||||
"filler": s["filler"],
|
||||
"filler_lot": s["filler_lot"],
|
||||
"preheat": s["preheat"],
|
||||
"notes": s["notes"],
|
||||
"clock": {
|
||||
"elapsed_ms": int(s["clock_elapsed_ms"] or 0),
|
||||
"running_since": s["clock_running_since"],
|
||||
"started_at": s["clock_started_at"],
|
||||
},
|
||||
"clock": clock_of(s, now),
|
||||
"opened_at": s["opened_at"],
|
||||
"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]:
|
||||
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]
|
||||
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:
|
||||
ids.append(ch.wh["by"])
|
||||
if ch.qc:
|
||||
@@ -545,6 +630,7 @@ def build(
|
||||
state = state_from(row, ch)
|
||||
people = People(conn, _who_ids(row, ch), seal=seal)
|
||||
cache: dict[Any, Any] = {}
|
||||
now = None if seal else conn.execute("SELECT now()").fetchone()[0]
|
||||
welder = people.ref(row["welder_id"])
|
||||
if welder is not None:
|
||||
welder["stamp"] = row["welder_stamp"]
|
||||
@@ -585,6 +671,10 @@ def build(
|
||||
"open_cos": [c["co_no"] for c in ch.open_cos],
|
||||
"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:
|
||||
out["work_orders"] = [
|
||||
{
|
||||
@@ -608,30 +698,19 @@ def build(
|
||||
| {k: w.get(k) for k in ("dwg", "rev", "partspecs")}
|
||||
for w in ch.work_orders
|
||||
]
|
||||
out["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["pages"] = [_page(pg, ch, people) for pg in ch.pages]
|
||||
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
|
||||
out["stage"] = _stage(conn, st, people, cache, closed=False) if st else None
|
||||
out["stages"] = [_stage(conn, s, people, cache, closed=True) for s in ch.stages if s["closed_at"] is not None]
|
||||
out["welds"] = [_weld(w, people, st) for w in ch.welds]
|
||||
out["heat_tags"] = [_heat_tag(t, st) for t in ch.heat_tags]
|
||||
out["heat_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"] = (
|
||||
None
|
||||
if ch.wh is None
|
||||
@@ -666,11 +745,15 @@ def build(
|
||||
if ch.seal is None
|
||||
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["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"], row["visual"])
|
||||
if not seal:
|
||||
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:
|
||||
allowed, blocked = policy.can(pp, state)
|
||||
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.
|
||||
for b in out.get("bom") or []:
|
||||
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
|
||||
|
||||
|
||||
@@ -737,11 +936,33 @@ def _bom(b: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def _weld(w: Mapping[str, Any], people: People) -> dict[str, Any]:
|
||||
keys = ("weld_no", "page", "fx", "fy", "wo_base", "piece", "type", "size", "process", "stamp", "at", "row_version")
|
||||
out = {k: w[k] for k in keys}
|
||||
WELD_KEYS = (
|
||||
"weld_no",
|
||||
"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["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
|
||||
|
||||
|
||||
@@ -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")}
|
||||
for i in ch.co_items.get(c["id"], [])
|
||||
]
|
||||
out["required"] = ch.co_required.get(c["id"], [])
|
||||
out["verified"] = {
|
||||
v["dept_code"]: {"by": people.ref(v["by"]), "at": v["at"], "note": v["note"]}
|
||||
required = ch.co_required.get(c["id"], [])
|
||||
verified: dict[str, Any] = {
|
||||
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"], [])
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
@@ -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 += " ORDER BY p.updated_at DESC, p.id DESC LIMIT %s"
|
||||
params.append(int(filters.get("limit") or 500))
|
||||
names = dept_names(conn)
|
||||
names = dept_labels(conn)
|
||||
transitions = {str(a) for a in policy.TRANSITIONS}
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows(conn, sql, params):
|
||||
|
||||
@@ -63,7 +63,7 @@ LABELS: dict[policy.Action, str] = {
|
||||
A.UNASSIGN: "Taken off the station",
|
||||
A.PULL_BACK: "Pulled back from the station",
|
||||
A.START: "Work started",
|
||||
A.SUBMIT: "Submitted to the supervisor",
|
||||
A.SUBMIT: "Submitted to supervisor",
|
||||
A.RETURN_TO_STATION: "Returned to the station",
|
||||
A.SEND_TO_QC: "Sent to Q.C.",
|
||||
A.ROUTE_FROM_REVIEW: "Routed to another department",
|
||||
@@ -127,6 +127,8 @@ class Write:
|
||||
noop: bool = False
|
||||
from_status: 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
|
||||
def id(self) -> int:
|
||||
@@ -146,6 +148,10 @@ class Write:
|
||||
if note is not None:
|
||||
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:
|
||||
"""Record the fields this write changed (they go into the audit detail)."""
|
||||
for n in names:
|
||||
@@ -331,6 +337,7 @@ def packet_write(
|
||||
request=request,
|
||||
from_status=str(row["status"]),
|
||||
from_dept=str(row["dept_code"]),
|
||||
children=ch,
|
||||
)
|
||||
meaning = decision.signature
|
||||
needs = bool(meaning) if require_sign is None else require_sign
|
||||
@@ -398,6 +405,20 @@ def _finish(w: Write) -> None:
|
||||
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}
|
||||
if w.from_dept != dept:
|
||||
detail["from_dept"] = w.from_dept
|
||||
@@ -563,6 +584,64 @@ def _release_text(w: Write) -> None:
|
||||
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:
|
||||
"""Set status, status_at and dept, write the packet_transitions row and run the action's side effects."""
|
||||
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"):
|
||||
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):
|
||||
_set_packet(w, "wh_skipped = false, wh_sent_at = now(), return_note = NULL")
|
||||
_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,)):
|
||||
_open_stage(w, from_dept)
|
||||
_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):
|
||||
_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")
|
||||
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:
|
||||
_set_packet(w, "return_note = NULL")
|
||||
_stage_update(w, CLOCK_STOP)
|
||||
_shop_texts(w, a, to_dept)
|
||||
elif a is A.RETURN_TO_STATION:
|
||||
_set_packet(w, "return_note = %s", (reason,))
|
||||
elif a is A.SEND_TO_QC:
|
||||
|
||||
@@ -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}.")
|
||||
@@ -43,6 +43,7 @@ class Subscriber:
|
||||
user_id: str
|
||||
display_name: str
|
||||
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)
|
||||
closed: bool = False
|
||||
|
||||
@@ -65,7 +66,15 @@ class EventHub:
|
||||
|
||||
# ------------------------------------------------ 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."""
|
||||
sub = Subscriber(
|
||||
queue=asyncio.Queue(maxsize=QUEUE_MAX),
|
||||
@@ -74,6 +83,7 @@ class EventHub:
|
||||
user_id=str(user_id),
|
||||
display_name=display_name,
|
||||
screen=screen,
|
||||
packet_id=packet_id,
|
||||
)
|
||||
with self._lock:
|
||||
self._subs[sub.id] = sub
|
||||
@@ -90,12 +100,13 @@ class EventHub:
|
||||
def presence(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
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:
|
||||
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}
|
||||
|
||||
def _send(self, sub: Subscriber, item: Item) -> None:
|
||||
|
||||
@@ -112,7 +112,7 @@ def open_or_reaim(
|
||||
" WHERE id = %s",
|
||||
(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:
|
||||
new_vis = dict(vis)
|
||||
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 = (
|
||||
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"
|
||||
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.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),
|
||||
).fetchone()
|
||||
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"
|
||||
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.changed("change_orders")
|
||||
@@ -172,7 +172,7 @@ def withdraw(sw: SysWrite, co_id: int, reason: str) -> None:
|
||||
if r is None:
|
||||
sw.skip()
|
||||
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.detail.update({"co_no": int(r[0]), "co_id": co_id, "withdrawn": True})
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ def create_app(
|
||||
packet_children,
|
||||
packets,
|
||||
projects,
|
||||
shop,
|
||||
units,
|
||||
warehouse,
|
||||
)
|
||||
@@ -125,6 +126,7 @@ def create_app(
|
||||
jobs,
|
||||
feed_settings,
|
||||
warehouse,
|
||||
shop,
|
||||
):
|
||||
app.include_router(module.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;
|
||||
@@ -49,6 +49,9 @@ LIMITS: dict[str, int] = {
|
||||
"order_pin": 40,
|
||||
"file_name": 160,
|
||||
"job_note": 200,
|
||||
"filler": 40,
|
||||
"preheat": 40,
|
||||
"weld_text": 40,
|
||||
}
|
||||
REASON_MIN = 8
|
||||
MAX_BODY_BYTES = 200_000
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -31,8 +31,8 @@ def test_fan_out_presence_and_session_targeting() -> None:
|
||||
assert [n for n, _ in got_a] == ["presence", "presence"]
|
||||
assert got_a[-1][1] == {
|
||||
"users": [
|
||||
{"user_id": a.user_id, "display_name": "Ann", "screen": "admin"},
|
||||
{"user_id": b.user_id, "display_name": "Bo", "screen": "stall"},
|
||||
{"user_id": a.user_id, "display_name": "Ann", "screen": "admin", "packet_id": None},
|
||||
{"user_id": b.user_id, "display_name": "Bo", "screen": "stall", "packet_id": None},
|
||||
]
|
||||
}
|
||||
await _drain(b.queue)
|
||||
@@ -52,7 +52,8 @@ def test_fan_out_presence_and_session_targeting() -> None:
|
||||
|
||||
hub.unsubscribe(b)
|
||||
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())
|
||||
|
||||
|
||||
@@ -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"}
|
||||
r1 = c.post(f"/api/packets/{pid}/welds", json={**weld, "size": "1/4"})
|
||||
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
|
||||
welds = r2.json()["welds"]
|
||||
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"
|
||||
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})
|
||||
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})
|
||||
@@ -536,7 +539,7 @@ def test_change_orders(pg_client2: TestClient, pg_settings: Settings, unit: tupl
|
||||
as_user(c, "weld.andy")
|
||||
r = step(c, get(c, pid), "submit", sign=SIGN)
|
||||
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"
|
||||
assert c.post(vurl, json={"dept": "03", "sign": SIGN}).status_code == 403
|
||||
|
||||
|
||||
@@ -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"]
|
||||
r = step(pg_client2, pkt, "file", sign=SIGN)
|
||||
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")
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
@@ -497,3 +497,33 @@ def test_notification_events(pg_settings: Settings) -> None:
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user