Phase 2: core domain, policy engine, packet API, Clerical screen
Migration 0003: projects, aliases, renumbers, units, packets and every child table, change orders, assets, documents, ITPs, station limits, department weld flag. Policy package: Principal, PacketState, Action rules, custody, preconditions, CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept, If-Match row versions, server-assigned numbers, field CAS, version bump, packet log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal. API: /api/packets (header, transitions, work orders, pages, docs, move, remove, BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets, /_blob. Frontend: Clerical home, projects and units, packet page with pdf.js and QR codes, hand draft, heat map, send to warehouse, department or Q.C. Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical draft scenario. Verified on docker-1 with the CI stack and the Samba DC. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -6,6 +6,16 @@ All notable changes to this project are recorded here. The format follows [Keep
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Phase 2: core domain. Migration 0003 with projects, project aliases and renumber history, units, packets and every child table (work orders, pages, BOM lines, shorts and receipts, welds, heat tags, heat table placements, stages, warehouse stage rows, transitions, Q.C. inspections and history, seals, packet log, VISUAL state), change orders with items, required departments and verifications, assets, packet documents, Q.C. documents, ITPs, user station limits and the department weld flag. Append-only triggers, soft-delete columns with partial unique indexes, server-assigned counters on packets.
|
||||||
|
- The policy engine in Python (`server/packetstation/policy/`): Principal, PacketState, the Action enum, one rule per action, custody, named preconditions, the change-order hold, and the order of checks from the plan. Every packet read returns `can`, `blocked` with reasons, and `holder`. docs/POLICY.md lists the rules and the tightenings against the old build for Seth.
|
||||||
|
- The write pipeline: one transaction per write with the packet row locked, the policy decision, compare-and-set on the expected status and department (409 "someone else already took this step" with the current state), If-Match row versions on child rows, server-assigned weld, heat tag, page and change order numbers, field-level compare-and-set on header scalars, packet version bump, packet log and audit rows, NOTIFY. Signatures through the phase 1 helper on every action the plan marks. Admin edits need an IT reason and are marked in the packet log and the audit. Filed packets are locked; filing writes an HMAC seal and Admin reopen breaks it with a reason.
|
||||||
|
- The packet API under `/api/packets`: list for the boards with the basic search, read, hand-made draft, header edit, transitions, work orders, pages, documents, move, remove, closed in VISUAL, data book date, reopen, seal, log, BOM, shorts and receipts, welds, heat tags, heat table, stage fields, checklist, clock, warehouse stage, Q.C. inspection, change orders (issue, approve, reject, hold, verify).
|
||||||
|
- `/api/projects` and `/api/units`: create, edit with field compare-and-set, units one or several at a time, renumber with aliases and history, soft delete with a reason, number lookup through aliases.
|
||||||
|
- `/api/assets` upload (type by content, 25 MB, sha256, stored by id under ASSETS_DIR, PDF page count) and `/_blob/{id}` served with a session, Content-Disposition and nosniff.
|
||||||
|
- Input cleaning and limits on the server (`text.py`): the old cleanText rules and per-field lengths; bodies over 200 KB refused.
|
||||||
|
- Frontend: the home layout (headline, KPIs, attention, find bar with the basic search and the two-step project and unit picker, boards and list view), the Clerical screen (Changes to approve, Flagged, Going to the warehouse, Ready to close, Ready for data book, Filed; projects and units with new project and unit sheets, renumbering, move packet), the packet page (header, Drawing tab with pdf.js, Materials, Work orders with QR codes, Documents, History) with every control driven by `can` and `blocked`, hand-made draft creation, the heat map question, send to warehouse, send straight to a department or Q.C., signature and IT reason sheets, live refresh from server events.
|
||||||
|
- Tests: the full policy matrix (every transition as every role in every status), custody, short-parts and change-order exceptions, preconditions, compare-and-set conflicts and row versions per table, server-assigned numbers under concurrent inserts, the audit and packet log rows, projects and units, assets. A Playwright scenario for the Clerical draft flow against the CI stack.
|
||||||
|
- `/api/departments` now returns station ids and the weld flag.
|
||||||
- Phase 1: identity. Sign-in with the domain account and password against PESCO's Active Directory over LDAPS (search with the service account, then bind as the user; nested groups resolved; disabled accounts refused). Roles are the union of a person's `PS-*` groups, mapped by a table derived from the department list.
|
- Phase 1: identity. Sign-in with the domain account and password against PESCO's Active Directory over LDAPS (search with the service account, then bind as the user; nested groups resolved; disabled accounts refused). Roles are the union of a person's `PS-*` groups, mapped by a table derived from the department list.
|
||||||
- Server-side sessions: HttpOnly, SameSite=Strict cookie, idle sign-out after 15 minutes, absolute 12 hours for password sessions and 10 hours for PIN sessions, server-recorded lock after 3 idle minutes or a tab hidden for over a minute, step-up unlock with PIN or password. Origin and `X-Requested-With` check on every state-changing request.
|
- Server-side sessions: HttpOnly, SameSite=Strict cookie, idle sign-out after 15 minutes, absolute 12 hours for password sessions and 10 hours for PIN sessions, server-recorded lock after 3 idle minutes or a tab hidden for over a minute, step-up unlock with PIN or password. Origin and `X-Requested-With` check on every state-changing request.
|
||||||
- Optional Packet Station PIN, 6 to 12 digits, set by the user with their domain password, hashed with Argon2id. PIN sign-in by employee ID on registered devices only. Five wrong attempts disable the PIN; three wrong attempts at unlock or signature end the session.
|
- Optional Packet Station PIN, 6 to 12 digits, set by the user with their domain password, hashed with Argon2id. PIN sign-in by employee ID on registered devices only. Five wrong attempts disable the PIN; three wrong attempts at unlock or signature end the session.
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ This repo is the rebuild of Packet Station as a server application: Docker Compo
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Rebuild in progress. Phase 1 done: identity, sessions, audit, frontend shell.**
|
**Rebuild in progress. Phase 2 done: core domain, policy engine, packet API, Clerical screen.**
|
||||||
|
|
||||||
People can sign in; no packet feature exists yet. 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. Every packet write goes through the server policy. The warehouse, 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:.
|
||||||
|
|
||||||
| Area | Exists yet? |
|
| Area | Exists yet? |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Repo, docs, CLAUDE.md | Yes (phase 0) |
|
| Repo, docs, CLAUDE.md | Yes (phase 0) |
|
||||||
| Image, compose, CI, `doctor`, migration runner, security headers, build version endpoint | Yes (phase 0, image and stack verified on docker-1) |
|
| Image, compose, CI, `doctor`, migration runner, security headers, build version endpoint | Yes (phase 0, image and stack verified on docker-1) |
|
||||||
| Sign-in (AD password, PIN on registered devices), sessions, lock screen, Account settings, Admin shell, audit, SSE | Yes (phase 1, verified against the Samba DC in the CI stack on docker-1) |
|
| Sign-in (AD password, PIN on registered devices), sessions, lock screen, Account settings, Admin shell, audit, SSE | Yes (phase 1, verified against the Samba DC in the CI stack on docker-1) |
|
||||||
| Packets, policy, Clerical screens | No (phase 2) |
|
| 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 and drawings | No (phase 3) |
|
| VISUAL feed and drawings | No (phase 3) |
|
||||||
| Warehouse, shop, Q.C., close and file, PDFs | No (phases 4 to 7) |
|
| Warehouse, shop, Q.C., close and file, PDFs | API only (phase 2); screens in phases 4 to 7 |
|
||||||
| Project Management, Admin, notifications, backups | No (phases 8 and 9) |
|
| Project Management, Admin, notifications, backups | No (phases 8 and 9) |
|
||||||
| Regression bench and go-live | No (phase 10) |
|
| Regression bench and go-live | No (phase 10) |
|
||||||
|
|
||||||
@@ -76,6 +76,8 @@ docker compose -p packetstation-test --env-file /etc/packetstation/test/stack.en
|
|||||||
-f deploy/compose.yaml -f deploy/compose.proxy.yaml up -d
|
-f deploy/compose.yaml -f deploy/compose.proxy.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Phase 2 adds `/api/projects`, `/api/units`, `/api/packets` (header, transitions, work orders, pages, documents, BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse stage, Q.C., change orders, log, seal), `/api/assets` and `/_blob/{id}`. Every packet read carries `can`, `blocked` and `holder` from the policy. See [docs/POLICY.md](docs/POLICY.md) and [docs/SCHEMA.md](docs/SCHEMA.md).
|
||||||
|
|
||||||
The `packetstation` command has `doctor`, `migrate`, `app`, `worker`, `ad-groups --list` (the 50 AD group names the app expects) and `ad-refresh` (create or refresh the users from the groups' members).
|
The `packetstation` command has `doctor`, `migrate`, `app`, `worker`, `ad-groups --list` (the 50 AD group names the app expects) and `ad-refresh` (create or refresh the users from the groups' members).
|
||||||
|
|
||||||
CI stack with the Samba domain controller and the Playwright scenario, on a Docker host:
|
CI stack with the Samba domain controller and the Playwright scenario, on a Docker host:
|
||||||
@@ -96,8 +98,8 @@ Server tests that need PostgreSQL run when `TEST_PG_SUPERUSER_URL` is set; the o
|
|||||||
- [docs/SECURITY.md](docs/SECURITY.md): old layers and where they live now, the new controls, the requirement ID table.
|
- [docs/SECURITY.md](docs/SECURITY.md): old layers and where they live now, the new controls, the requirement ID table.
|
||||||
- [docs/DEPLOY.md](docs/DEPLOY.md): host, stacks, secrets, NPM proxy host, doctor, backups.
|
- [docs/DEPLOY.md](docs/DEPLOY.md): host, stacks, secrets, NPM proxy host, doctor, backups.
|
||||||
- [docs/FEED.md](docs/FEED.md): the VISUAL feed and drawing jobs (phase 3).
|
- [docs/FEED.md](docs/FEED.md): the VISUAL feed and drawing jobs (phase 3).
|
||||||
- [docs/POLICY.md](docs/POLICY.md): who may do what (phase 2).
|
- [docs/POLICY.md](docs/POLICY.md): who may do what, the preconditions, the order of checks, concurrency, and the tightenings against the old build.
|
||||||
- [docs/SCHEMA.md](docs/SCHEMA.md): the database schema (phases 1 to 3).
|
- [docs/SCHEMA.md](docs/SCHEMA.md): the database schema (phases 1 and 2; phase 3 adds the caches).
|
||||||
- [docs/GO-LIVE.md](docs/GO-LIVE.md): go-live procedure (phase 10).
|
- [docs/GO-LIVE.md](docs/GO-LIVE.md): go-live procedure (phase 10).
|
||||||
- [docs/PAPER-FALLBACK.md](docs/PAPER-FALLBACK.md): what the shop does when the server is down (phase 10).
|
- [docs/PAPER-FALLBACK.md](docs/PAPER-FALLBACK.md): what the shop does when the server is down (phase 10).
|
||||||
- [CHANGELOG.md](CHANGELOG.md): what changed.
|
- [CHANGELOG.md](CHANGELOG.md): what changed.
|
||||||
|
|||||||
+191
-11
@@ -1,39 +1,219 @@
|
|||||||
# Packet Station policy
|
# Packet Station policy
|
||||||
|
|
||||||
Status: planned, phase 2. This is a stub. It lists what the approved plan says this document will contain. See [PLAN.md](PLAN.md), "Policy port and concurrency rules".
|
Who may do what to a packet, and when. This file is written from the code in `server/packetstation/policy/`. When they disagree, the code is the rule and this file is out of date.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Who may do what to a packet, and when. A Python port of the old `guard.js`, run only on the server. Pure functions, no I/O.
|
The policy is a Python port of the old `guard.js`. It runs only on the server. Every packet write calls `policy.check()` inside the write transaction, under the packet row lock. Every packet read returns `can`, `blocked` and `holder` from the same rules, so the screens only show what the server will accept.
|
||||||
|
|
||||||
|
The package is pure functions with no I/O:
|
||||||
|
|
||||||
|
- `actions.py`: the `Action` enum, the `Who` predicates, the `Rule` record and the `RULES` table.
|
||||||
|
- `principal.py`: `Principal`, `PacketState`, `principal_from_roles()`.
|
||||||
|
- `custody.py`: `holds()`, `works_on()`, `holder_name()` and the Who predicates.
|
||||||
|
- `preconditions.py`: the named preconditions and their messages.
|
||||||
|
- `engine.py`: `check()`, `evaluate()`, `can()` and `Decision`.
|
||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
|
|
||||||
Every role comes from an AD group: Admin; Clerical, Warehouse and Q.C. with a member and a Supervisor level; Project Management (view only); and 14 shop departments with Worker, Lead and Supervisor. A person's roles are the union of their groups.
|
Every role comes from an AD group (see docs/SECURITY.md). The role strings are `admin`, `clerical`, `clerical_supervisor`, `warehouse`, `warehouse_supervisor`, `qc`, `qc_supervisor`, `pm` and `shop:<code>:<worker|lead|supervisor>`. A person's roles are the union of their groups.
|
||||||
|
|
||||||
|
`principal_from_roles(user_id, roles, station_limits=(), session_kind="password")` builds the policy's `Principal`:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `admin` | In PS-Admins. Admin is not membership. It is the bypass rule below. |
|
||||||
|
| `support` | The codes among CL, WH and Q.C. the person belongs to, at any level. A supervisor role implies membership. |
|
||||||
|
| `support_sup` | The codes among CL, WH and Q.C. where the person is Supervisor. |
|
||||||
|
| `shop` | Shop department code to level: 1 worker, 2 lead, 3 supervisor. The highest level wins. |
|
||||||
|
| `station_limits` | Station ids a worker is limited to. Empty means any station. |
|
||||||
|
| `pm` | Project Management. PM can read every packet and change nothing. |
|
||||||
|
| `session_kind` | `password` or `pin`. |
|
||||||
|
| `system` | `visual-feed` for the worker. Never set from a session. |
|
||||||
|
|
||||||
|
A role string that does not parse is ignored.
|
||||||
|
|
||||||
## Custody
|
## Custody
|
||||||
|
|
||||||
Only whoever holds the packet may change it. Draft: Clerical. Warehouse: Warehouse. Ready and review: the department lead or above. Assigned and in progress: the lead or above, or the assigned worker. Q.C.: Q.C. Complete: Clerical. Filed: nobody.
|
Only whoever holds the packet may change it. Everyone else can open it and follow it.
|
||||||
|
|
||||||
## Actions and transitions
|
| Status | Holder |
|
||||||
|
|---|---|
|
||||||
|
| draft | Clerical |
|
||||||
|
| warehouse | Warehouse |
|
||||||
|
| ready, review | Lead or supervisor of the packet's department |
|
||||||
|
| assigned, in_progress | Lead or supervisor of the department, or a worker who works on it |
|
||||||
|
| qc | Q.C. |
|
||||||
|
| complete | Clerical (data book) |
|
||||||
|
| filed | Nobody |
|
||||||
|
|
||||||
An Action enum with one rule per action: who, from and to status, department change, change-order hold, preconditions, signature meaning, and whether a reason is required.
|
`works_on(principal, state)`: a member of the packet's department. Leads and supervisors always pass. A worker passes when they have no station limits, or the limits hold the packet's station. START and SUBMIT also need the assigned welder for a worker (see the tightenings).
|
||||||
|
|
||||||
|
`holder_name(state, dept_name, station_name)` gives the text for messages: "Clerical", "Warehouse", "Q.C.", "Clerical (data book)", "the data book (filed)", else the department name, plus " · <station>" while the packet is at a station.
|
||||||
|
|
||||||
|
Statuses in workflow order: draft, warehouse, ready, assigned, in_progress, review, qc, complete, filed. SHOP is ready, assigned, in_progress and review. ACTIVE is assigned and in_progress.
|
||||||
|
|
||||||
|
## Who
|
||||||
|
|
||||||
|
| Who | Passes when |
|
||||||
|
|---|---|
|
||||||
|
| CL, WH, QC | A member of that support department, any level |
|
||||||
|
| CL_SUP, WH_SUP, QC_SUP | Supervisor of that support department |
|
||||||
|
| CL_OR_QC | A member of Clerical or Q.C. |
|
||||||
|
| HOLDER | Holds the packet (custody above) |
|
||||||
|
| DEPT_LEAD | Lead or supervisor in the packet's department |
|
||||||
|
| DEPT_SUP | Supervisor in the packet's department |
|
||||||
|
| WORKS_ON | Lead or above in the department, or a worker in the department who is the assigned welder and whose station limits allow the station |
|
||||||
|
| CO_VERIFIER | Supervisor of the department named in the request: a shop Supervisor for a shop code, WH or Q.C. Supervisor for WH or QC. Clerical never verifies. |
|
||||||
|
| ADMIN | In PS-Admins |
|
||||||
|
| SYSTEM | The worker (`system` is set) |
|
||||||
|
|
||||||
|
## Rule table
|
||||||
|
|
||||||
|
`from` is the set of statuses the action starts from. "any" means any status, with the filed lock applied separately. `co hold` means an open change order holds the step.
|
||||||
|
|
||||||
|
| Action | Who | From | To | Dept | CO hold | Preconditions | Signature | Reason |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| send_to_warehouse | CL | draft | warehouse | no | | heat_map_answered, drawing_attached | | |
|
||||||
|
| release_to_shop | CL | draft | ready | allowed | | heat_map_answered, drawing_attached, target_dept_given | | |
|
||||||
|
| send_draft_to_qc | CL | draft | qc | no | | heat_map_answered, drawing_attached | | |
|
||||||
|
| wh_send_back | WH | warehouse | draft | no | | reason_given | | yes |
|
||||||
|
| return_to_clerical | DEPT_LEAD | ready, only when wh_skipped | draft | no | | reason_given | | yes |
|
||||||
|
| wh_release | WH | warehouse | ready | no | yes | bom_all_pulled_or_short | | |
|
||||||
|
| send_back_to_warehouse | DEPT_LEAD | ready | warehouse | no | | reason_given | | yes |
|
||||||
|
| assign | DEPT_LEAD | ready | assigned | no | | welder_set | | |
|
||||||
|
| unassign | DEPT_LEAD | assigned | ready | no | | | | |
|
||||||
|
| pull_back | DEPT_LEAD | in_progress | ready | no | | | | |
|
||||||
|
| start | WORKS_ON | assigned | in_progress | no | | | | |
|
||||||
|
| submit | WORKS_ON | assigned, in_progress | review | no | yes | checklist_complete, welds_recorded, filler_set | Submit work to the supervisor | |
|
||||||
|
| return_to_station | DEPT_SUP | review | assigned | no | | reason_given | | yes |
|
||||||
|
| send_to_qc | DEPT_SUP | review | qc | no | yes | qc_book_spool_location | Send to Q.C. | |
|
||||||
|
| route_from_review | DEPT_SUP | review | ready | required | yes | target_dept_given | Route to another department | |
|
||||||
|
| qc_reject | QC | qc | review | no | | reason_given | Q.C. reject | yes |
|
||||||
|
| qc_accept | QC | qc | complete | no | yes | qc_missing_empty, route_given | Q.C. accept | |
|
||||||
|
| qc_accept_and_route | QC | qc | ready | required | yes | qc_missing_empty, target_dept_given | Q.C. accept and route | |
|
||||||
|
| file | CL | complete | filed | no | yes | vclosed_if_databook | File in the data book | |
|
||||||
|
| admin_reopen | ADMIN | filed (filed_ok) | complete | no | | reason_given | Reopen a filed packet | yes |
|
||||||
|
| edit_header | CL | draft | | | | | | |
|
||||||
|
| edit_drawing_ref | CL | any | | | | | | |
|
||||||
|
| edit_dept | CL | draft | | | | | | |
|
||||||
|
| move_unit | CL | any | | | | | | |
|
||||||
|
| edit_wos | CL | draft | | | | | | |
|
||||||
|
| edit_bom | HOLDER | draft, warehouse, SHOP | | | | | | |
|
||||||
|
| wh_receive_short | WH | any | | | | short_open | | |
|
||||||
|
| edit_heats | HOLDER | warehouse, SHOP | | | | | | |
|
||||||
|
| edit_heat_table | HOLDER | warehouse, SHOP | | | | | | |
|
||||||
|
| edit_welds | HOLDER | SHOP | | | | | | |
|
||||||
|
| edit_checklist | HOLDER | SHOP | | | | | | |
|
||||||
|
| edit_stage_fields | HOLDER | SHOP | | | | | | |
|
||||||
|
| clock | HOLDER | SHOP | | | | | | |
|
||||||
|
| edit_qc | QC | qc | | | | | | |
|
||||||
|
| set_wh_stage | WH | warehouse | | | | | | |
|
||||||
|
| edit_document_set | CL | any | | | | | Attach a drawing by hand (attach only) | |
|
||||||
|
| doc_add | CL_OR_QC | any | | | | | | |
|
||||||
|
| doc_remove | CL_OR_QC | any | | | | | | |
|
||||||
|
| rev_approve | CL_SUP | draft | | | | reason_given | Approve the M-Files revision | yes |
|
||||||
|
| set_vclosed | CL | complete, only when routed_to is databook | | | | | | |
|
||||||
|
| set_databook_at | CL_OR_QC | any (filed_ok) | | | | | | |
|
||||||
|
| remove | CL | draft | | | | reason_given | | yes |
|
||||||
|
| co_issue | CL | any | | | | | Issue a change order | |
|
||||||
|
| co_approve | CL | any | | | | | Approve a change order | |
|
||||||
|
| co_reject | CL | any | | | | reason_given | Reject a change order | yes |
|
||||||
|
| co_hold_toggle | CL | any | | | | | | |
|
||||||
|
| co_verify | CO_VERIFIER | any | | | | | Verify a change order for the department | |
|
||||||
|
| itp_edit_line | QC | any | | | | | | |
|
||||||
|
| itp_block_sign | QC | any | | | | | Sign an ITP block | |
|
||||||
|
| itp_sign_off | QC | any | | | | | Sign off the ITP | |
|
||||||
|
| itp_reopen | QC_SUP (Admin through the bypass) | any | | | | reason_given | | yes |
|
||||||
|
| vis_update_draft | SYSTEM | draft | | | | | | |
|
||||||
|
| vis_retire_draft | SYSTEM | draft | | | | | | |
|
||||||
|
| vis_open_co | SYSTEM | any but draft and filed | | | | | | |
|
||||||
|
| vis_auto_release | SYSTEM | draft | warehouse | | | heat_map_answered, drawing_attached | | |
|
||||||
|
|
||||||
|
The `vis_*` actions are system only. People can never do them, and the worker can do nothing else.
|
||||||
|
|
||||||
## Preconditions
|
## Preconditions
|
||||||
|
|
||||||
Named predicates, checked on the server and never bypassed, including by Admin.
|
Each precondition is a named predicate over the packet state and the request input. They are never bypassed, Admin included. The first one that fails gives the message.
|
||||||
|
|
||||||
|
| Name | Passes when | Message |
|
||||||
|
|---|---|---|
|
||||||
|
| heat_map_answered | heat_map is yes or no | Say whether this packet needs a heat map. |
|
||||||
|
| drawing_attached | a drawing is attached | Attach the drawing first. |
|
||||||
|
| bom_all_pulled_or_short | every line not marked n/a is pulled or has an open short | Pull every material line or mark it short first. |
|
||||||
|
| 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. |
|
||||||
|
| 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). |
|
||||||
|
| reason_given (input) | a reason of at least 8 characters after cleaning | Give a reason of at least 8 characters. |
|
||||||
|
| vclosed_if_databook | routed to Shipping, or closed in VISUAL | Close the job in VISUAL first. |
|
||||||
|
| co_staged_if_dwg_change (input) | no drawing change, or the new drawing is staged (phase 3) | Stage the new drawing for the change order first. |
|
||||||
|
| route_given (input) | routed_to is databook or shipping | Choose Data Book or Shipping. |
|
||||||
|
| target_dept_given (input) | a shop department different from the packet's; from a draft any shop department | Choose a department. / Choose a different department. |
|
||||||
|
|
||||||
|
"(input)" marks a precondition that reads request input, not packet state. `can()` skips those.
|
||||||
|
|
||||||
## Order of checks
|
## Order of checks
|
||||||
|
|
||||||
Removed, wrong status, change-order hold, admin bypass of custody with an IT reason, filed lock, who and custody, then preconditions.
|
`check(principal, state, action, *, extra={}, it_reason=None) -> Decision` runs these in this order and stops at the first failure:
|
||||||
|
|
||||||
|
1. **Removed.** A removed packet refuses everything, Admin included. Code `removed`.
|
||||||
|
2. **Status.** The status must be in the rule's from set. RETURN_TO_CLERICAL also needs wh_skipped. SET_VCLOSED also needs routed_to databook. An unknown status fails here too. Code `wrong_status`.
|
||||||
|
3. **Change-order hold.** A held action refuses while an approved change order waits for verification, or a pending one is on hold. Admin included. Code `co_hold`, with the hold text.
|
||||||
|
4. **System.** A system-only action refuses every person (`system_only`). The system principal passes only system-only actions (`forbidden`).
|
||||||
|
5. **Admin bypass.** An admin who is not allowed by the rule's Who passes who and custody when an IT reason of at least 8 characters is given. Without one the code is `it_reason_required`. SET_DATABOOK_AT needs no reason. The decision is marked `admin_bypass`, and the write marks the log row as an IT edit.
|
||||||
|
6. **Filed lock.** A filed packet refuses everything except SET_DATABOOK_AT and ADMIN_REOPEN, Admin included. Code `filed`: "This packet is filed and locked. IT can reopen it if a correction is needed."
|
||||||
|
7. **Who and custody.** Code `forbidden`. For a step: "Your access doesn't allow this step." For an edit: "Your access doesn't allow changing (what) on this packet. It's with (holder) right now." PM fails here for everything.
|
||||||
|
8. **Preconditions,** in rule order, never bypassed. Code `precondition` with the message, or `reason_required` when the missing piece is the rule's reason, so the screen can ask for one.
|
||||||
|
|
||||||
|
`Decision` carries `ok`, `code`, `why`, `admin_bypass`, `signature` (the meaning text when the rule needs one) and, in read mode only, `reasons`.
|
||||||
|
|
||||||
|
The packet API answers 403 with the code for most failures, and 400 for `it_reason_required` and `reason_required`.
|
||||||
|
|
||||||
## Signatures
|
## Signatures
|
||||||
|
|
||||||
Which actions need a PIN or domain password signature, and what the signature records.
|
These actions need a signature: submit, send_to_qc, route_from_review, qc_reject, qc_accept, qc_accept_and_route, file, admin_reopen, edit_document_set (attach only), rev_approve, co_issue, co_approve, co_reject, co_verify, itp_block_sign and itp_sign_off. The rule's signature text is the meaning that is signed.
|
||||||
|
|
||||||
|
The request carries `sign: {factor, secret}`. The write pipeline calls the phase 1 helper `packetstation.auth.signatures.sign()` inside the same transaction, after the policy passes. It checks the PIN or domain password against the session's own user. A PIN works in any session. It logs the attempt and applies the five-strike PIN disable and the three-strike session end. It writes an append-only `signatures` row with the user, session, account, display name, session kind, factor, action, meaning, packet, subject and a sha256 over the canonical JSON of what was signed (the action, the packet id, the expected state and the request fields). The `packet_transitions` and `packet_log` rows point at it.
|
||||||
|
|
||||||
## Concurrency
|
## Concurrency
|
||||||
|
|
||||||
Compare-and-set on workflow steps (409 "someone else already took this step"), row versions on child rows, server-assigned numbers, append-only tables.
|
- **Workflow steps** are compare-and-set on `expect: {status, dept}` under the packet row lock (`SELECT ... FOR UPDATE`). When the packet moved on, the answer is 409 `stale_step` "Someone else already took this step." with the current status, department and version. There is no packet version check on a step, so a weld edit never blocks a send. Side effects run in the same transaction.
|
||||||
|
- **Keyed child rows** (BOM lines, Q.C. inspections, stage fields, welds, heat tags) take `If-Match: <row_version>`. A mismatch is 409 `row_conflict` with the current row.
|
||||||
|
- **Numbers** for welds, heat tags, pages and change orders come from counters on the packet row under the row lock, so concurrent adds never collide.
|
||||||
|
- **Header scalars** use field-level compare-and-set: the body sends `set` and `base`. A key whose base differs from the current value is 409 `field_conflict` with the current value. A key with no base is written as is.
|
||||||
|
- **Work orders** are a draft-only full replace with If-Match on the packet version.
|
||||||
|
- **Shorts and change-order actions** are state compare-and-set. Checklist keys are written one key at a time. The heat table position is an idempotent upsert. A change-order verification is an insert, so a second one is "already verified".
|
||||||
|
- **Append-only tables** never conflict: packet_transitions, packet_log, qc_history, short_receipts, project_renumbers, itp_rejects and co_verifications. Triggers refuse UPDATE, DELETE and TRUNCATE, and the app role has no grant for them.
|
||||||
|
|
||||||
|
## Reads: can, blocked and holder
|
||||||
|
|
||||||
|
Every packet read returns:
|
||||||
|
|
||||||
|
- `can`: the actions that pass now. Input-only preconditions are skipped, because they are what the user gives with the step.
|
||||||
|
- `blocked`: the actions that pass who and custody but fail a state precondition or the change-order hold, with every reason. The screen shows the control disabled with the reason.
|
||||||
|
- `holder`: `holder_name()` for the packet.
|
||||||
|
|
||||||
|
`can(principal, state, assume_it_reason=True) -> (list[Action], dict[Action, list[str]])`. For an admin the IT reason is assumed, so the list shows what the admin may do once they give one. `evaluate(principal, state, action)` returns the read-mode decision with `admin_bypass` set, so the screen knows to ask for the reason. System-only actions are never listed. CO_VERIFY needs a department in the request, so it is not listed; the change order list says which departments the person can verify.
|
||||||
|
|
||||||
## Tightenings against the old build
|
## Tightenings against the old build
|
||||||
|
|
||||||
The old policy is looser than the old UI in places. Each tightening is listed here so Seth can veto it. Known so far: Clerical lead actions move to Clerical Supervisor; packets routed to Shipping get the Clerical close and file path; SET_DATABOOK_AT is restricted to Clerical and Q.C. by default.
|
The old policy was looser than the old screens in places. The rebuild follows the screens where they were stricter, and the README where it states the intent. Each row is a change Seth can veto.
|
||||||
|
|
||||||
|
| What the old build allowed | What the rebuild does | Why | Can be vetoed |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A department lead could write welds, BOM, heat tags, the checklist, stage fields and the clock through the policy whenever the packet was in a shop status. | These edits need the holder of the packet (the holder rule, applied as the old screens did). A lead of another department cannot. | The screens only offered these to the holder. The policy now matches. | Yes |
|
||||||
|
| Any worker in the department within their station limits could start or submit a packet (`worksOn` ignored who was assigned). | START and SUBMIT need the assigned welder for a worker. Leads and supervisors still can. | The README says only the assigned worker. | Yes |
|
||||||
|
| A status change could carry any other field in the same save. | A transition carries only its own fields (the step's inputs). Other changes go through their own edit actions and rules. | One rule per write keeps the check honest. | No |
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
| A hand draft could leave Clerical with no drawing (the packet PDF upload stood in for it). | A hand draft needs a drawing attached before any send from Clerical. | There is no packet PDF upload in the rebuild. | Yes |
|
||||||
|
| Attaching drawing pages by hand needed no signature. | Attaching a drawing by hand needs a signature ("Attach a drawing by hand"). | It replaces what M-Files would supply, so a person stands behind it. | Yes |
|
||||||
|
| Admin could write anything on a filed packet with an IT reason. | Admin cannot write a filed packet except through ADMIN_REOPEN (with a reason and a signature), and SET_DATABOOK_AT. | A filed packet is sealed. A change breaks the seal on purpose, on record. | No |
|
||||||
|
| Admin passed every check after the change-order hold, so an admin could make a step from any status. | Admin goes through the same status check. The bypass covers who and custody only. Preconditions still apply. | Admin fixes data, not the workflow order. | Yes |
|
||||||
|
|||||||
+86
-8
@@ -1,6 +1,6 @@
|
|||||||
# Packet Station schema
|
# Packet Station schema
|
||||||
|
|
||||||
Status: phase 1 built (identity and organisation seed). Phases 2 and 3 add the rest. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema".
|
Status: phases 1 and 2 built (identity, organisation, projects, packets, change orders, documents). Phase 3 adds the job and VISUAL tables, phase 9 the notification tables. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema".
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -26,27 +26,101 @@ Append-only tables use the `ps_append_only` trigger from 0001 and revoke UPDATE,
|
|||||||
|
|
||||||
## Organisation (phase 1 seed, phase 2 completes)
|
## Organisation (phase 1 seed, phase 2 completes)
|
||||||
|
|
||||||
- `departments`: `code` (text PK, `CL`, `WH`, `QC`, `PM`, `01` to `14`), `slug` (the AD group short name, unique), `name`, `kind`, `sort`, `active`, `supervisor_group` (false only for PM). A trigger refuses changes to `code` and `slug`. Seeded with the 18 departments from the brief and the old `depts.js`.
|
- `departments`: `code` (text PK, `CL`, `WH`, `QC`, `PM`, `01` to `14`), `slug` (the AD group short name, unique), `name`, `kind`, `sort`, `active`, `supervisor_group` (false only for PM). A trigger refuses changes to `code` and `slug`. Seeded with the 18 departments from the brief and the old `depts.js`. Migration 0003 adds `weld` (bool, default false), true for 03, 04, 05, 06, 07 and 10. A weld department gets the welding checklist, and submit there needs at least one weld and a filler.
|
||||||
- `stations`: (`dept_code`, `name`, `sort`, `active`), seeded with the old station lists (Stall 1 to 6, Station 1 to 4, Hydro Bay 1 and 2, Blast Booth, Paint Booth 1 and 2).
|
- `stations`: (`dept_code`, `name`, `sort`, `active`), seeded with the old station lists (Stall 1 to 6, Station 1 to 4, Hydro Bay 1 and 2, Blast Booth, Paint Booth 1 and 2).
|
||||||
- `user_station_limits`: phase 2.
|
- `user_station_limits`: (`user_id`, `station_id`) primary key. A worker with rows here may only work on packets at those stations. No rows means any station in the department. The rows go when the user row goes.
|
||||||
|
|
||||||
The 50 AD group names are derived from `departments` plus `PS-Admins`: `PS-<slug>` and `PS-<slug>-Supervisor` for support departments, `PS-<code>-<slug>-<Worker|Lead|Supervisor>` for shop departments. `packetstation ad-groups --list` prints them.
|
The 50 AD group names are derived from `departments` plus `PS-Admins`: `PS-<slug>` and `PS-<slug>-Supervisor` for support departments, `PS-<code>-<slug>-<Worker|Lead|Supervisor>` for shop departments. `packetstation ad-groups --list` prints them.
|
||||||
|
|
||||||
|
## Enums (phase 2, migration 0003)
|
||||||
|
|
||||||
|
Declaration order matters for `packet_status`: it is the workflow order, so `status >= 'qc'` works in SQL.
|
||||||
|
|
||||||
|
| Enum | Values in order |
|
||||||
|
|---|---|
|
||||||
|
| `packet_status` | draft, warehouse, ready, assigned, in_progress, review, qc, complete, filed |
|
||||||
|
| `wh_stage` | incoming, pulling, hold, pulled |
|
||||||
|
| `co_review` | pending, approved, rejected |
|
||||||
|
| `co_origin` | visual, drawing, manual |
|
||||||
|
| `source_kind` | visual, hand |
|
||||||
|
| `project_status` | open, hold, closed |
|
||||||
|
| `route_kind` | databook, shipping |
|
||||||
|
| `drawing_state` | none, needed, attached, rev_mismatch, not_released, not_found, lookup_failed, hand |
|
||||||
|
| `itp_status` | open, signed, reopened |
|
||||||
|
|
||||||
|
Smaller closed sets are text with a CHECK: `projects.source` and every `*_src` (visual, hand), `project_aliases.kind` (renumber, unit_renumber, import), `packet_pages.type` (drawing, info, overlay), `bom_lines.src` (visual, hand), `qc_inspections.result` (accepted, rejected), `itp_lines.result` (approve, reject, na). Null means unset in each.
|
||||||
|
|
||||||
## Projects and units (phase 2)
|
## Projects and units (phase 2)
|
||||||
|
|
||||||
projects, project_aliases, project_renumbers, units.
|
- `projects`: `number` (text, unique among live rows on `upper(number)`), `name`, `customer`, `po`, `due`, `status` (project_status), `notes`, `source` (hand or visual), `needs_details` (the feed made it and Clerical has not filled it in), `version` for field CAS. Order-derived values carry provenance: `customer_src`, `po_src`, `order_date_src` and `ship_date_src` say visual or hand, and `customer_visual` and `po_visual` keep what VISUAL said. The feed never overwrites a hand value. Soft delete.
|
||||||
|
- `units`: `project_id`, `lot` (text), `name`, `due`, `status`, `notes`, the same `*_src` companions, `order_date`, `ship_date`, `version`. A CHECK keeps `lot` to 1 to 3 upper-case letters or digits, and a lot of digits only is exactly 3 long (`001`, `7A`). Unique among live rows on (`project_id`, `lot`). Soft delete.
|
||||||
|
- `project_aliases`: `alias` (text PK, upper-cased by CHECK), `project_id`, `unit_id` (null for a project number), `kind` (renumber, unit_renumber, import), `created_at`. Every current and old number of every project and unit is here, so a lookup by any number resolves, and a renumber cannot reuse a number that was ever taken.
|
||||||
|
- `project_renumbers`: append-only. `at`, `by`, `project_id`, `unit_id`, `old_number`, `new_number`, `reason`.
|
||||||
|
- View `v_units`: every `units` column plus `number` (`project.number || lot`, so `L999` and `001` make `L999001`), `project_number`, `project_name`, `project_status` and `due_effective` (the unit's due, else the project's).
|
||||||
|
|
||||||
## Packets (phase 2)
|
## Packets (phase 2)
|
||||||
|
|
||||||
The packet header and its child tables: work orders, pages, BOM lines, shorts, welds, heat tags, stages, warehouse, transitions, Q.C., seals, log.
|
### Header
|
||||||
|
|
||||||
|
`packets` is the header. The columns that matter:
|
||||||
|
|
||||||
|
- Placement: `unit_id`, `dept_code`, `op`, `title`, `customer`, `drawing`, `rev`, `wps`.
|
||||||
|
- Workflow: `status` (packet_status), `status_at`, `station_id`, `welder_id` and `welder_stamp` (a snapshot taken at assign), `for_sup_id`, `wh_skipped` (true when Clerical sent the draft straight to a department or to Q.C.), `wh_sent_at`, `routed_to` (route_kind, set at Q.C. accept), `return_note` (the reason shown after a send-back), `vclosed_at` and `vclosed_by`, `databook_at`.
|
||||||
|
- Preconditions: `heat_map` is nullable on purpose. Null means not answered yet, and the warehouse step needs an answer.
|
||||||
|
- Source: `source_kind` (visual or hand), `source` jsonb, `visual_key` (the feed's match key), `visual_rel`, `visual_want`, `drawing_state`, `flags` text[], `visual_fp_applied` (the fingerprint the packet matches), `visual_closed_at`, `visual` jsonb (system-written sub-keys).
|
||||||
|
- Counters: `next_weld_no`, `next_heat_no`, `next_page_no` and `next_co_no`, all starting at 1. See "Versioning".
|
||||||
|
- `version`, `created_at`, `created_by`, `updated_at`, soft delete.
|
||||||
|
|
||||||
|
Indexes: `packets_visual_key` is unique on `visual_key` where the key is set and the row is live. A removed packet keeps its key, so two removed rows may share one, but only one live row may hold it. Also `(status)` for live rows, `(unit_id)` and `(dept_code, status)` for the boards.
|
||||||
|
|
||||||
|
The trigger `ps_packet_touch` sets `updated_at` on every UPDATE. It does not touch `version`.
|
||||||
|
|
||||||
|
### Children
|
||||||
|
|
||||||
|
- `packet_work_orders`: `wo_base`, `lot`, `split`, `sub` (all text; the last three default to `''` so the key has no nulls), `seq`, `qty`, `target_hrs`, `op`, `description`, `visual_status`, `missing_runs` and `gone_at` (the feed's "work order gone" check), `sort`, `row_version`. Unique on (`packet_id`, `wo_base`, `lot`, `split`, `sub`).
|
||||||
|
- `packet_pages`: `n` (the packet page number, from `next_page_no`), `type`, `asset_id`, `pn` (page within the asset), `dwg`, `rev`, `co_id` (the change order that added it), `void_co_id`, `void_at`, `void_by`, `void_new_n` (a replaced page is voided, never deleted), `row_version`. Unique on (`packet_id`, `n`).
|
||||||
|
- `bom_lines`: `item`, `qty`, `part_id` (text), `description`, `dwg_ref`, `cl_item`, `length`, `material`, `heat`, `na`, `issued`, `loc`, `pulled`, `pulled_at`, `pulled_by`, `mtr_asset_id`, `src`, `vkey`, `whs`, `comm`, `cons`, `sub`, `grp`, `sort`, `row_version`. Unique on (`packet_id`, `item`).
|
||||||
|
- `bom_shorts`: `bom_line_id` (cascades with the line), `qty`, `qty_open`, `reason`, `ref`, `eta`, `note`, `opened_at`, `opened_by`, `closed_at`, `row_version`. At most one open short per line (unique on `bom_line_id` where `closed_at` is null).
|
||||||
|
- `short_receipts`: append-only. `short_id`, `packet_id`, `qty`, `heat`, `note`, `at`, `by`.
|
||||||
|
- `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_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.
|
||||||
|
- `qc_history`: append-only. `at`, `result`, `inspector`, `note`, `dept_code`, `snapshot` jsonb.
|
||||||
|
- `packet_seals`: the HMAC-SHA256 `hash` written at filing, `key_id`, `sealed_at`, `sealed_by`, `signature_id`, and `broken_at`, `broken_by`, `broken_reason` when IT reopens. At most one unbroken seal per packet.
|
||||||
|
- `packet_log`: append-only. The user-visible history: `at`, `by`, `by_name` and `role` (snapshots), `action`, `note`, `it_reason`, `signature_id`, `transition_id`. Indexed on (`packet_id`, `id`).
|
||||||
|
- `packet_visual_state`: one row per packet, written by the worker. `legs` jsonb, `ticket_hours`, `order_ref`, `observed_at`.
|
||||||
|
|
||||||
|
Every who column references `users (id)`. Every `signature_id` references `signatures (id)`.
|
||||||
|
|
||||||
## Change orders (phases 2 and 3)
|
## Change orders (phases 2 and 3)
|
||||||
|
|
||||||
change_orders, co_items, co_required_depts, co_verifications.
|
- `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.
|
||||||
|
- `co_items`: the page changes. `old_page`, `old_dwg`, `old_rev`, `new_page`, `new_dwg`, `new_rev`, `added`.
|
||||||
|
- `co_required_depts`: (`co_id`, `dept_code`) primary key. The departments that must verify.
|
||||||
|
- `co_verifications`: append-only, keyed on (`co_id`, `dept_code`). `by` and `signature_id` are required, plus `at`, `note`, `it_reason`. A primary key violation means the department already verified. A verification cannot be withdrawn.
|
||||||
|
- View `v_open_cos` ports the old `openCOs()`: `packet_id`, `co_id`, `co_no`, `review`, `hold`, `pending_depts` (text[], the required departments without a verification, sorted by code). A row appears when review is approved and `pending_depts` is not empty, or when review is pending and `hold` is true. A pending change order without hold holds nothing. A rejected one never appears. The policy's CO hold and the boards' open-CO count read this view.
|
||||||
|
|
||||||
## Documents (phases 2, 4 and 6)
|
## Documents (phases 2, 4 and 6)
|
||||||
|
|
||||||
assets, packet_docs, qc_docs, ITPs.
|
- `assets`: `id` uuid, `sha256` (indexed, for dedupe), `size`, `mime`, `original_name`, `storage_key` (the path under ASSETS_DIR, unique), `source` (`upload` by default), `source_ref` jsonb, `pages`, `uploaded_by`, `uploaded_at`, `packet_id` (the packet it was uploaded for, without a foreign key; `packet_pages` and `packet_docs` are the real links).
|
||||||
|
- `packet_docs`: `asset_id`, `kind`, `title`, `added_at`, `added_by`, soft delete.
|
||||||
|
- `qc_docs`: `project_id`, `unit_id`, `type`, `rev`, `asset_id`, `auto`, `superseded_by` (the newer row), `added_at`, `added_by`.
|
||||||
|
- `itps`: one per unit (`unit_id` unique). `template` jsonb, frozen when the ITP is made. `status` (itp_status), `signoff_*` (with the signature and the signed PDF asset), `reopen_*`, `version`.
|
||||||
|
- `itp_lines`: `key` (unique per ITP), `result`, `by`, `at`, `ncr`, `note`, `signature_id`, `row_version`.
|
||||||
|
- `itp_rejects`: append-only. `line_key`, `ncr` (required), `note`, `at`, `by`. Every reject keeps its NCR id, even after the line is approved later.
|
||||||
|
- `itp_blocks`: `block_key` (unique per ITP), `signed_at`, `signed_by`, `signature_id`.
|
||||||
|
|
||||||
|
## Append-only set
|
||||||
|
|
||||||
|
These tables have the `ps_append_only` row and TRUNCATE triggers, and `ps_app` holds only SELECT and INSERT on them:
|
||||||
|
|
||||||
|
`audit_events`, `signin_attempts`, `signatures`, `project_renumbers`, `short_receipts`, `packet_transitions`, `qc_history`, `packet_log`, `itp_rejects`, `co_verifications`.
|
||||||
|
|
||||||
|
The app role is refused by privilege first. The migrate role owns the tables, so the trigger refuses it. The views `v_units` and `v_open_cos` are SELECT only for `ps_app`.
|
||||||
|
|
||||||
## Audit, settings, jobs (phases 1 and 3)
|
## Audit, settings, jobs (phases 1 and 3)
|
||||||
|
|
||||||
@@ -62,4 +136,8 @@ visual_batches, visual_rows, visual_materials, order links, labor, change rows,
|
|||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
packets.version goes up in every transaction that touches the packet or a child. It is the ETag and the NOTIFY payload.
|
- `packets.version` goes up by one in every write transaction that touches the packet or any of its children. The write pipeline does it explicitly (`UPDATE packets SET version = version + 1 ... RETURNING version`) after the caller's SQL. No trigger bumps it, so a read never does. It is the packet ETag and the NOTIFY payload.
|
||||||
|
- `projects.version`, `units.version` and `itps.version` play the same part for those rows.
|
||||||
|
- Child rows that are edited in place carry `row_version`. A PATCH sends `If-Match: <row_version>`. A mismatch is 409 `row_conflict` with the current row. The write bumps `row_version`.
|
||||||
|
- Every write takes `SELECT ... FOR UPDATE` on the packet row first. That lock replaces the old 8-second lease and serialises the counters: `next_weld_no`, `next_heat_no`, `next_page_no` and `next_co_no` hand out the next number with `UPDATE packets SET next_weld_no = next_weld_no + 1 RETURNING next_weld_no - 1`, so two concurrent adds never get the same number.
|
||||||
|
- Transitions do not check `version`. They compare and set on `expect: {status, dept}`, so a weld edit never blocks a send.
|
||||||
|
|||||||
+14
-8
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This document is rewritten for the server build. The old browser build's security document is in the old repo (`C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md`). The design comes from [BRIEF.md](BRIEF.md) and [PLAN.md](PLAN.md).
|
This document is rewritten for the server build. The old browser build's security document is in the old repo (`C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md`). The design comes from [BRIEF.md](BRIEF.md) and [PLAN.md](PLAN.md).
|
||||||
|
|
||||||
Status: phase 1. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) are built and tested against a Samba domain controller in CI. Every other control names the phase that builds it.
|
Status: phase 2. 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. Every other control names the phase that builds it.
|
||||||
|
|
||||||
Contents:
|
Contents:
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
|
|||||||
| 7 | Record integrity check | A worker job (seals, asset hashes, audit hash chain) with an alert. | 9 |
|
| 7 | Record integrity check | A worker job (seals, asset hashes, audit hash chain) with an alert. | 9 |
|
||||||
| 8 | Self-hosted libraries | Kept: no CDN. The CSP allows nothing external. | 0 |
|
| 8 | Self-hosted libraries | Kept: no CDN. The CSP allows nothing external. | 0 |
|
||||||
| 9 | Output escaping | React escapes output by default. The PDF export escapes on the server. | 1, 7 |
|
| 9 | Output escaping | React escapes output by default. The PDF export escapes on the server. | 1, 7 |
|
||||||
| 10 | Central permission policy | Server policy, checked on every write, including writes that do not come from the screens. | 2 |
|
| 10 | Central permission policy | Server policy, checked on every write, including writes that do not come from the screens. Built. | 2 |
|
||||||
| 11 | PIN sign-off (electronic signature) | Server-verified signature with the PIN or the domain password, stored with who, when, what, factor and a content hash. | 1 |
|
| 11 | PIN sign-off (electronic signature) | Server-verified signature with the PIN or the domain password, stored with who, when, what, factor and a content hash. | 1 |
|
||||||
| 12 | IT edit reasons | Kept. Admin writes need a reason and are marked as IT edits in the packet history and the audit. | 2 |
|
| 12 | IT edit reasons | Kept. Admin writes need a reason and are marked as IT edits in the packet history and the audit. | 2 |
|
||||||
| 13 | Screen lock | Server-enforced sessions. The browser reports idle and background state; the server records it and refuses an idle or locked session. Unlock is a step-up check on the server. | 1 |
|
| 13 | Screen lock | Server-enforced sessions. The browser reports idle and background state; the server records it and refuses an idle or locked session. Unlock is a step-up check on the server. | 1 |
|
||||||
@@ -71,9 +71,15 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
|
|||||||
|
|
||||||
### Policy (phase 2)
|
### Policy (phase 2)
|
||||||
|
|
||||||
- One policy in Python on the server. Every write goes: session, policy, transition, validation, version check, audit row, in one transaction.
|
- One policy in Python on the server (`server/packetstation/policy/`), pure functions with no I/O. Every write goes: session, policy, transition, validation, version check, packet log row, audit row, NOTIFY, in one transaction with the packet row locked. Nothing in the browser decides.
|
||||||
- Every packet read returns `can: [...]` and `blocked: {...}` so the browser enables controls from the server's answer.
|
- Every packet read returns `can: [...]`, `blocked: {...}` and `holder`, so the browser enables controls from the server's answer. The server re-checks on the write.
|
||||||
- A workflow step taken twice is refused with 409 and the current state.
|
- A workflow step taken twice is refused with 409 and the current state (compare-and-set on the expected status and department). Child rows carry a row version checked with If-Match. Weld, heat tag, page and change order numbers are assigned by the server under the lock.
|
||||||
|
- Preconditions (heat map answered, drawing attached, BOM pulled or short, checklist complete, welds recorded, Q.C. items complete, closed in VISUAL before filing) run on the server and are never bypassed, Admin included.
|
||||||
|
- Admin edits outside custody need an IT reason of at least 8 characters, recorded in the packet log and the audit row (`it_reason`). An Admin cannot write a filed packet except through the reopen action, which needs a reason and a signature and breaks the seal.
|
||||||
|
- Signatures (PIN or domain password, verified in the same transaction) on submit, send to Q.C., Q.C. accept and reject, route, file, reopen, change order issue, approve, reject and verify, the revision approval and hand drawing uploads.
|
||||||
|
- Input: every string is cleaned (control and invisible characters removed, NFC, trimmed) and cut to its field limit; heat numbers, stamps, project numbers and lots are checked against the old patterns; JSON bodies over 200 KB are refused.
|
||||||
|
- Uploads: typed by content (PDF, PNG, JPEG), 25 MB limit read in chunks, sha256, stored by id under the assets volume, served only with a session and with `Content-Disposition` and `X-Content-Type-Options: nosniff`.
|
||||||
|
- The filed lock: a filed packet accepts only the data book date and the Admin reopen. Filing writes an HMAC-SHA256 seal with the server seal key; a reopen keeps the old seal with who broke it and why.
|
||||||
- Details in [POLICY.md](POLICY.md).
|
- Details in [POLICY.md](POLICY.md).
|
||||||
|
|
||||||
### Audit (phase 1)
|
### Audit (phase 1)
|
||||||
@@ -122,13 +128,13 @@ The IDs come from Part 3 of the old SECURITY.md. The status follows the approved
|
|||||||
| I6 | Largely closed, phase 9 (groups plus reconcile); quarterly review IT's |
|
| I6 | Largely closed, phase 9 (groups plus reconcile); quarterly review IT's |
|
||||||
| I7 | Closed, phase 1 |
|
| I7 | Closed, phase 1 |
|
||||||
| I8 | App part closed, phase 1; kiosk lock policy IT's (Intune or GPO) |
|
| I8 | App part closed, phase 1; kiosk lock policy IT's (Intune or GPO) |
|
||||||
| A1, A2, A3 | Closed, phase 2 |
|
| A1, A2, A3 | Closed, phase 2 (server policy on every call, workflow steps as compare-and-set, validation and limits on the server) |
|
||||||
| A4 | Closed, phase 0 |
|
| A4 | Closed, phase 0 |
|
||||||
| D1, D2, D4 | Deferred (envelope encryption, Key Vault) |
|
| D1, D2, D4 | Deferred (envelope encryption, Key Vault) |
|
||||||
| D3 | IT's (host disk encryption on docker-1) |
|
| D3 | IT's (host disk encryption on docker-1) |
|
||||||
| D5 | Closed, phase 4 (optional ClamAV) |
|
| D5 | Closed, phase 4 (optional ClamAV) |
|
||||||
| D6, D7 | Deferred (retention period and customer copy need a decision from Seth and Q.C.) |
|
| D6, D7 | Deferred (retention period and customer copy need a decision from Seth and Q.C.) |
|
||||||
| M1 | Closed, phase 2 |
|
| M1 | Closed, phase 2 (append-only audit and packet log written only by the server; every packet write carries both) |
|
||||||
| M2 | Closed, phase 7 |
|
| M2 | Closed, phase 7 |
|
||||||
| M3 | Closed, phase 9 |
|
| M3 | Closed, phase 9 |
|
||||||
| M4, M5 | Deferred (logs to stdout now; SIEM shipping is IT's) |
|
| M4, M5 | Deferred (logs to stdout now; SIEM shipping is IT's) |
|
||||||
@@ -136,7 +142,7 @@ The IDs come from Part 3 of the old SECURITY.md. The status follows the approved
|
|||||||
| S2 | Closed, phase 1 |
|
| S2 | Closed, phase 1 |
|
||||||
| S3 | Closed, phase 9 (CI scanning) |
|
| S3 | Closed, phase 9 (CI scanning) |
|
||||||
| S4 | Deferred to IT (pentest before go-live) |
|
| S4 | Deferred to IT (pentest before go-live) |
|
||||||
| S5 | Closed, phase 2 |
|
| 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 |
|
||||||
| O1, O2 | Closed, phase 9 (pg_dump timer plus PBS; one rehearsed restore); quarterly restore IT's |
|
| O1, O2 | Closed, phase 9 (pg_dump timer plus PBS; one rehearsed restore); quarterly restore IT's |
|
||||||
| O3, O4 | Closed, phase 10 |
|
| O3, O4 | Closed, phase 10 |
|
||||||
|
|||||||
Generated
+544
-5
@@ -11,6 +11,8 @@
|
|||||||
"@fontsource/barlow-condensed": "5.3.0",
|
"@fontsource/barlow-condensed": "5.3.0",
|
||||||
"@fontsource/ibm-plex-mono": "5.3.0",
|
"@fontsource/ibm-plex-mono": "5.3.0",
|
||||||
"@fontsource/ibm-plex-sans": "5.3.0",
|
"@fontsource/ibm-plex-sans": "5.3.0",
|
||||||
|
"pdfjs-dist": "6.3.289",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router": "7.18.4"
|
"react-router": "7.18.4"
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
"@testing-library/jest-dom": "6.10.0",
|
"@testing-library/jest-dom": "6.10.0",
|
||||||
"@testing-library/react": "16.3.3",
|
"@testing-library/react": "16.3.3",
|
||||||
"@types/node": "22.20.5",
|
"@types/node": "22.20.5",
|
||||||
|
"@types/qrcode": "1.5.6",
|
||||||
"@types/react": "18.3.31",
|
"@types/react": "18.3.31",
|
||||||
"@types/react-dom": "18.3.7",
|
"@types/react-dom": "18.3.7",
|
||||||
"@vitejs/plugin-react": "4.7.0",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
@@ -1212,6 +1215,271 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "1.0.11",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "1.0.11",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "1.0.11",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.11",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "1.0.11",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "1.0.11",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.11",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "1.0.11",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "1.0.11",
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": "1.0.11",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "1.0.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-dtJS4FQEfH9OwSYLtMrEnj7T8DzBbJc/97LI/OkiWOYpW/iW5t6R/gcxVrUXS8l45gCVpwMufgucRe/pe0ISqA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-J/JylWoJTG8OQqZaiqly4IODyAxNofUlZo0wtSBJtHk6AdymbdIkPynAM1JvQaZpPaG5h+msk9QuK8y4i1ooqw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nlecCk3xy06eAGC7krZtzNDm9UNTHKuHQYK8ZYegdjzgSL8anGt/eLJZm86OWaNgZkwQwvDqfSmG8Nqe6MPZNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-DKImmG+sTPolE0kGPsYPBxTtOjb3INALp32O2lW2CfSr0bAzSEKKuu+GrQbtilClyoPOahUErS3OrznFDraoWA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-55sykACGENj5j1rhj+81AO8JbR9HYgGjRZ7/H+pNr4VE1DvINKYNMTcutk8ZmWxk276jAQvUKJ3+Kdj9GXU3lQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-6Jz6jys17yXydO+WdJgu7JhoB4K7/w9sWHByzzTmLjQghUx3TDMWvLv+UcCdnWhg4oprET67EMymnrETdeGNAg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-lf2XeEebWiUYVOVEjLXxF7dfXrBBSEOBn9AqBh39VYXrPt5cDqI+1t58IFSjn8npqkg5jIOs8TQbjKABbFW1gQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-iDapw7nFSE5tsmQxcH2+KotJhyfVPmHd6vUOQN/Em+8WNbkZJwJ/rDowlJMFnMl0SYSDSfgKCNUGi6Sn5++Smg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-26q26AyITo3CGZX4HWFIMgBMA1lu3YN07gxzBm4tW6Hglq/OVyTP50kDrmWuKybCX6dh3m8Ly+c7YhI+sqCgCQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-cUO96IdsfvIj2S+2PNcDSkTcLhjPj8pIOEWRV1Kj70MOP4wnE9zSk7N4C1EKHlFGRcgdR5AkbnBZj0dfHK9nbQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-DFYp/ZQRdKunI/LPc0UkRXG1vlNjJKOJf8O8Ctb8+wm2Nt2ELg9Eamc31D2VoakkfGo8L+z0Iejz6WtFbQwyGw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||||
@@ -1815,6 +2083,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.31",
|
"version": "18.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||||
@@ -2319,7 +2597,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -2329,7 +2606,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -2443,6 +2719,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001816",
|
"version": "1.0.30001816",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz",
|
||||||
@@ -2491,11 +2776,21 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -2508,7 +2803,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
@@ -2613,6 +2907,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decimal.js": {
|
"node_modules/decimal.js": {
|
||||||
"version": "10.6.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
@@ -2637,6 +2940,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
"node_modules/dom-accessibility-api": {
|
||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
@@ -2651,6 +2960,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
@@ -3049,6 +3364,15 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -3196,6 +3520,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-glob": {
|
"node_modules/is-glob": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
@@ -3565,6 +3898,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -3595,7 +3937,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -3618,6 +3959,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "6.3.289",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz",
|
||||||
|
"integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3638,6 +3991,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.30",
|
"version": "8.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
|
||||||
@@ -3715,6 +4077,23 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -3793,6 +4172,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
@@ -3895,6 +4289,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
@@ -3955,6 +4355,32 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-indent": {
|
"node_modules/strip-indent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||||
@@ -4445,6 +4871,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/why-is-node-running": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
@@ -4472,6 +4904,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.22.0",
|
"version": "8.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||||
@@ -4511,6 +4957,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
@@ -4518,6 +4970,93 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"@fontsource/barlow-condensed": "5.3.0",
|
"@fontsource/barlow-condensed": "5.3.0",
|
||||||
"@fontsource/ibm-plex-mono": "5.3.0",
|
"@fontsource/ibm-plex-mono": "5.3.0",
|
||||||
"@fontsource/ibm-plex-sans": "5.3.0",
|
"@fontsource/ibm-plex-sans": "5.3.0",
|
||||||
|
"pdfjs-dist": "6.3.289",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router": "7.18.4"
|
"react-router": "7.18.4"
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
"@testing-library/jest-dom": "6.10.0",
|
"@testing-library/jest-dom": "6.10.0",
|
||||||
"@testing-library/react": "16.3.3",
|
"@testing-library/react": "16.3.3",
|
||||||
"@types/node": "22.20.5",
|
"@types/node": "22.20.5",
|
||||||
|
"@types/qrcode": "1.5.6",
|
||||||
"@types/react": "18.3.31",
|
"@types/react": "18.3.31",
|
||||||
"@types/react-dom": "18.3.7",
|
"@types/react-dom": "18.3.7",
|
||||||
"@vitejs/plugin-react": "4.7.0",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe("routes", () => {
|
|||||||
mockFetch({ "/api/auth/me": { body: makeMe() } });
|
mockFetch({ "/api/auth/me": { body: makeMe() } });
|
||||||
renderApp("/admin");
|
renderApp("/admin");
|
||||||
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("This screen arrives in phase 2.")).toBeInTheDocument();
|
expect(screen.getByText("Projects and job packets")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -4,10 +4,13 @@ import { Layout } from "./components/Layout";
|
|||||||
import { RequireAuth } from "./components/RequireAuth";
|
import { RequireAuth } from "./components/RequireAuth";
|
||||||
import { AboutPage } from "./pages/AboutPage";
|
import { AboutPage } from "./pages/AboutPage";
|
||||||
import { HomePage } from "./pages/HomePage";
|
import { HomePage } from "./pages/HomePage";
|
||||||
|
import { PacketPage } from "./packet/PacketPage";
|
||||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||||
import { SignInPage } from "./pages/SignInPage";
|
import { SignInPage } from "./pages/SignInPage";
|
||||||
import { AccountPage } from "./screens/account/AccountPage";
|
import { AccountPage } from "./screens/account/AccountPage";
|
||||||
import { ScreenRoute } from "./screens/ScreenRoute";
|
import { ProjectPage } from "./screens/clerical/ProjectPage";
|
||||||
|
import { UnitPage } from "./screens/clerical/UnitPage";
|
||||||
|
import { RequireScreen, ScreenRoute } from "./screens/ScreenRoute";
|
||||||
|
|
||||||
/** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */
|
/** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
@@ -20,6 +23,23 @@ export function AppRoutes() {
|
|||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route path="account" element={<AccountPage />} />
|
<Route path="account" element={<AccountPage />} />
|
||||||
<Route path=":screen" element={<ScreenRoute />} />
|
<Route path=":screen" element={<ScreenRoute />} />
|
||||||
|
<Route
|
||||||
|
path="clerical/projects/:projectId"
|
||||||
|
element={
|
||||||
|
<RequireScreen screen="clerical">
|
||||||
|
<ProjectPage />
|
||||||
|
</RequireScreen>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="clerical/units/:unitId"
|
||||||
|
element={
|
||||||
|
<RequireScreen screen="clerical">
|
||||||
|
<UnitPage />
|
||||||
|
</RequireScreen>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="packets/:id" element={<PacketPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Asset upload and the blob URL. The upload is multipart through the shared request.
|
||||||
|
import { api, request } from "./client";
|
||||||
|
import type { Asset } from "./types";
|
||||||
|
|
||||||
|
/** Same-origin URL of a stored file. */
|
||||||
|
export function blobUrl(assetId: string, download = false): string {
|
||||||
|
return `/_blob/${encodeURIComponent(assetId)}${download ? "?download=1" : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const assetsApi = {
|
||||||
|
upload: (file: File, opts: { packet_id?: number; purpose?: string } = {}) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file, file.name);
|
||||||
|
if (opts.packet_id !== undefined) fd.append("packet_id", String(opts.packet_id));
|
||||||
|
if (opts.purpose) fd.append("purpose", opts.purpose);
|
||||||
|
return request<Asset>("/api/assets", { method: "POST", body: fd });
|
||||||
|
},
|
||||||
|
get: (id: string, signal?: AbortSignal) => api.get<Asset>(`/api/assets/${encodeURIComponent(id)}`, signal),
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Packet endpoints from the phase 2 contract.
|
||||||
|
import { api, request } from "./client";
|
||||||
|
import { asList, ifMatch, qs } from "./query";
|
||||||
|
import type {
|
||||||
|
AttachPagesBody,
|
||||||
|
BomLine,
|
||||||
|
FieldCas,
|
||||||
|
Guarded,
|
||||||
|
LogRow,
|
||||||
|
PacketCard,
|
||||||
|
PacketCreate,
|
||||||
|
PacketHeader,
|
||||||
|
PacketListQuery,
|
||||||
|
PacketRead,
|
||||||
|
TransitionBody,
|
||||||
|
WorkOrder,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const base = (id: number) => `/api/packets/${encodeURIComponent(String(id))}`;
|
||||||
|
|
||||||
|
export const packetsApi = {
|
||||||
|
list: async (q: PacketListQuery = {}, signal?: AbortSignal): Promise<PacketCard[]> =>
|
||||||
|
asList<PacketCard>(await api.get<unknown>(`/api/packets${qs({ limit: 500, ...q })}`, signal), "packets"),
|
||||||
|
get: (id: number, signal?: AbortSignal) => api.get<PacketRead>(base(id), signal),
|
||||||
|
log: async (id: number, opts: { limit?: number; before?: number } = {}, signal?: AbortSignal): Promise<LogRow[]> =>
|
||||||
|
asList<LogRow>(await api.get<unknown>(`${base(id)}/log${qs({ limit: opts.limit ?? 300, before: opts.before })}`, signal), "log"),
|
||||||
|
create: (body: PacketCreate) => api.post<PacketRead>("/api/packets", body),
|
||||||
|
patch: (id: number, body: FieldCas<PacketHeader>) => api.patch<PacketRead>(base(id), body),
|
||||||
|
transition: (id: number, body: TransitionBody) => api.post<PacketRead>(`${base(id)}/transitions`, body),
|
||||||
|
putWorkOrders: (id: number, version: number, work_orders: WorkOrder[], extra: Guarded = {}) =>
|
||||||
|
request<PacketRead>(`${base(id)}/work-orders`, { method: "PUT", body: { work_orders, ...extra }, headers: ifMatch(version) }),
|
||||||
|
putBom: (id: number, version: number, bom: BomLine[], extra: Guarded = {}) =>
|
||||||
|
request<PacketRead>(`${base(id)}/bom`, { method: "PUT", body: { bom, ...extra }, headers: ifMatch(version) }),
|
||||||
|
attachPages: (id: number, body: AttachPagesBody) => api.post<PacketRead>(`${base(id)}/pages`, body),
|
||||||
|
removePages: (id: number, n: number, body: { reason?: string } & Guarded = {}) =>
|
||||||
|
api.delWithBody<PacketRead>(`${base(id)}/pages/${n}`, body),
|
||||||
|
addDoc: (id: number, body: { asset_id: string; kind: string; title: string } & Guarded) =>
|
||||||
|
api.post<PacketRead>(`${base(id)}/docs`, body),
|
||||||
|
removeDoc: (id: number, docId: number, extra: Guarded = {}) =>
|
||||||
|
api.delWithBody<PacketRead>(`${base(id)}/docs/${docId}`, extra),
|
||||||
|
move: (id: number, body: { unit_id: number; reason?: string } & Guarded) => api.post<PacketRead>(`${base(id)}/move`, body),
|
||||||
|
remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody<PacketRead | null>(base(id), body),
|
||||||
|
setVclosed: (id: number, closed: boolean, extra: Guarded = {}) =>
|
||||||
|
api.post<PacketRead>(`${base(id)}/vclosed`, { closed, ...extra }),
|
||||||
|
databook: (id: number) => api.post<PacketRead>(`${base(id)}/databook`, {}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Project and unit endpoints from the phase 2 contract.
|
||||||
|
import { api } from "./client";
|
||||||
|
import { asList, qs } from "./query";
|
||||||
|
import type { FieldCas, Guarded, NumberLookup, Project, ProjectCreate, Unit, UnitsCreate } from "./types";
|
||||||
|
|
||||||
|
export type ProjectShow = "active" | "open" | "hold" | "closed" | "all";
|
||||||
|
|
||||||
|
export interface ProjectFields {
|
||||||
|
name: string | null;
|
||||||
|
customer: string | null;
|
||||||
|
po: string | null;
|
||||||
|
due: string | null;
|
||||||
|
status: Project["status"];
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnitFields {
|
||||||
|
name: string | null;
|
||||||
|
due: string | null;
|
||||||
|
status: Unit["status"];
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pid = (id: number) => `/api/projects/${encodeURIComponent(String(id))}`;
|
||||||
|
const uid = (id: number) => `/api/units/${encodeURIComponent(String(id))}`;
|
||||||
|
|
||||||
|
export const projectsApi = {
|
||||||
|
list: async (q: string, status: ProjectShow = "active", signal?: AbortSignal): Promise<Project[]> =>
|
||||||
|
asList<Project>(await api.get<unknown>(`/api/projects${qs({ q, status })}`, signal), "projects"),
|
||||||
|
get: (id: number, signal?: AbortSignal) => api.get<Project>(pid(id), signal),
|
||||||
|
create: (body: ProjectCreate & Guarded) => api.post<Project>("/api/projects", body),
|
||||||
|
patch: (id: number, body: FieldCas<ProjectFields>) => api.patch<Project>(pid(id), body),
|
||||||
|
renumber: (id: number, body: { number: string; reason: string } & Guarded) => api.post<Project>(`${pid(id)}/renumber`, body),
|
||||||
|
remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody<null>(pid(id), body),
|
||||||
|
addUnits: async (id: number, body: UnitsCreate & Guarded): Promise<Unit[]> =>
|
||||||
|
asList<Unit>(await api.post<unknown>(`${pid(id)}/units`, body), "units"),
|
||||||
|
units: async (projectId: number, q = "", signal?: AbortSignal): Promise<Unit[]> =>
|
||||||
|
asList<Unit>(await api.get<unknown>(`/api/units${qs({ project_id: projectId, q })}`, signal), "units"),
|
||||||
|
unit: (id: number, signal?: AbortSignal) => api.get<Unit>(uid(id), signal),
|
||||||
|
patchUnit: (id: number, body: FieldCas<UnitFields>) => api.patch<Unit>(uid(id), body),
|
||||||
|
renumberUnit: (id: number, body: { lot: string; reason: string } & Guarded) => api.post<Unit>(`${uid(id)}/renumber`, body),
|
||||||
|
removeUnit: (id: number, body: { reason: string } & Guarded) => api.delWithBody<null>(uid(id), body),
|
||||||
|
lookup: (n: string, signal?: AbortSignal) => api.get<NumberLookup>(`/api/lookup/number${qs({ n })}`, signal),
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Query strings and list answers shared by the API modules.
|
||||||
|
|
||||||
|
/** "?a=1&b=x" from the defined, non-empty values. Empty when nothing is set. */
|
||||||
|
export function qs(params: Record<string, string | number | boolean | null | undefined>): string {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v === undefined || v === null || v === "") continue;
|
||||||
|
sp.set(k, String(v));
|
||||||
|
}
|
||||||
|
const s = sp.toString();
|
||||||
|
return s ? `?${s}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A list answer may come bare or wrapped as {key: [...]}. Both give the array. */
|
||||||
|
export function asList<T>(raw: unknown, key: string): T[] {
|
||||||
|
if (Array.isArray(raw)) return raw as T[];
|
||||||
|
if (raw && typeof raw === "object" && Array.isArray((raw as Record<string, unknown>)[key])) {
|
||||||
|
return (raw as Record<string, T[]>)[key] as T[];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The If-Match header for a version check. */
|
||||||
|
export function ifMatch(version: number): Record<string, string> {
|
||||||
|
return { "If-Match": String(version) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
// API shapes from the phase 2 design contract. IDs stay text where the contract says so.
|
||||||
|
// Work order, lot, split, sub and part IDs are always strings. Timestamps are ISO strings.
|
||||||
|
import type { Factor } from "../auth/types";
|
||||||
|
|
||||||
|
export const STATUSES = ["draft", "warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete", "filed"] as const;
|
||||||
|
export type PacketStatus = (typeof STATUSES)[number];
|
||||||
|
|
||||||
|
export type ProjectStatus = "open" | "hold" | "closed";
|
||||||
|
export type RouteKind = "databook" | "shipping";
|
||||||
|
|
||||||
|
/** Who references in packet answers. The frontend never gets emp_no or email here. */
|
||||||
|
export interface WhoRef {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WelderRef extends WhoRef {
|
||||||
|
stamp: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StationRef {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeptRef {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
weld?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A signature in a request body. The server checks it against the session user. */
|
||||||
|
export interface SignBody {
|
||||||
|
factor: Factor;
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Packet counts per stage on project and unit rows. */
|
||||||
|
export interface StageCounts {
|
||||||
|
draft: number;
|
||||||
|
warehouse: number;
|
||||||
|
shop: number;
|
||||||
|
qc: number;
|
||||||
|
complete: number;
|
||||||
|
filed: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Projects and units ---------- */
|
||||||
|
|
||||||
|
export interface ProjectRef {
|
||||||
|
id: number;
|
||||||
|
number: string;
|
||||||
|
name: string | null;
|
||||||
|
customer?: string | null;
|
||||||
|
po?: string | null;
|
||||||
|
status?: ProjectStatus;
|
||||||
|
due?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row of GET /api/units?project_id= and of a project's `units`. */
|
||||||
|
export interface Unit {
|
||||||
|
id: number;
|
||||||
|
project_id?: number;
|
||||||
|
number: string;
|
||||||
|
lot: string;
|
||||||
|
name: string | null;
|
||||||
|
due: string | null;
|
||||||
|
due_effective: string | null;
|
||||||
|
status: ProjectStatus;
|
||||||
|
notes?: string | null;
|
||||||
|
packets?: StageCounts;
|
||||||
|
version: number;
|
||||||
|
project?: ProjectRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row of GET /api/projects. GET /api/projects/{id} adds units and aliases. */
|
||||||
|
export interface Project {
|
||||||
|
id: number;
|
||||||
|
number: string;
|
||||||
|
name: string | null;
|
||||||
|
customer: string | null;
|
||||||
|
po: string | null;
|
||||||
|
due: string | null;
|
||||||
|
status: ProjectStatus;
|
||||||
|
notes: string | null;
|
||||||
|
source?: string;
|
||||||
|
needs_details?: boolean;
|
||||||
|
/** A count on list rows, the unit rows on GET /api/projects/{id}. */
|
||||||
|
units: number | Unit[];
|
||||||
|
packets?: StageCounts;
|
||||||
|
aliases?: Array<string | { alias: string; kind?: string }>;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCreate {
|
||||||
|
number: string;
|
||||||
|
name: string;
|
||||||
|
customer: string;
|
||||||
|
po: string;
|
||||||
|
due: string | null;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnitsCreate {
|
||||||
|
lot?: string;
|
||||||
|
lots?: string[];
|
||||||
|
name: string;
|
||||||
|
due: string | null;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldCas<T> {
|
||||||
|
set: Partial<T>;
|
||||||
|
base: Partial<T>;
|
||||||
|
it_reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberLookup {
|
||||||
|
kind: "project" | "unit";
|
||||||
|
id: number;
|
||||||
|
number: string;
|
||||||
|
alias: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Packets ---------- */
|
||||||
|
|
||||||
|
export interface VclosedRef {
|
||||||
|
at: string;
|
||||||
|
by: WhoRef | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One packet on a board. GET /api/packets -> {packets: [card...]}. */
|
||||||
|
export interface PacketCard {
|
||||||
|
id: number;
|
||||||
|
version: number;
|
||||||
|
unit: { id: number; number: string };
|
||||||
|
project: { id: number; number: string; name: string | null };
|
||||||
|
dept: DeptRef;
|
||||||
|
op: string | null;
|
||||||
|
title: string | null;
|
||||||
|
customer: string | null;
|
||||||
|
drawing: string | null;
|
||||||
|
rev: string | null;
|
||||||
|
status: PacketStatus;
|
||||||
|
status_at: string | null;
|
||||||
|
station: StationRef | null;
|
||||||
|
welder: WelderRef | null;
|
||||||
|
routed_to: RouteKind | null;
|
||||||
|
vclosed: VclosedRef | null;
|
||||||
|
source_kind: "visual" | "hand";
|
||||||
|
flag: string | null;
|
||||||
|
/** CO numbers, or a count. Both are handled. */
|
||||||
|
open_cos: number[] | number;
|
||||||
|
open_shorts: number;
|
||||||
|
wh_stage: string | null;
|
||||||
|
due_effective: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
holder: string;
|
||||||
|
can: string[];
|
||||||
|
blocked: Record<string, string[]>;
|
||||||
|
/** Not in the contract's card shape. Shown when the server sends it. */
|
||||||
|
wo_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PacketList {
|
||||||
|
packets: PacketCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PacketListQuery {
|
||||||
|
status?: string;
|
||||||
|
dept?: string;
|
||||||
|
unit_id?: number;
|
||||||
|
project_id?: number;
|
||||||
|
q?: string;
|
||||||
|
flag?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkOrder {
|
||||||
|
id?: number;
|
||||||
|
wo_base: string;
|
||||||
|
lot: string;
|
||||||
|
split: string;
|
||||||
|
sub: string;
|
||||||
|
seq?: number | null;
|
||||||
|
qty: number | string | null;
|
||||||
|
target_hrs: number | string | null;
|
||||||
|
op?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
visual_status?: string | null;
|
||||||
|
gone_at?: string | null;
|
||||||
|
row_version?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PacketPage {
|
||||||
|
n: number;
|
||||||
|
type: "drawing" | "info" | "overlay" | string;
|
||||||
|
asset_id: string | null;
|
||||||
|
pn: number;
|
||||||
|
dwg: string | null;
|
||||||
|
rev: string | null;
|
||||||
|
void: { co_id: number; at: string; new_n: number | null } | null;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BomShort {
|
||||||
|
id: number;
|
||||||
|
qty: number | string;
|
||||||
|
qty_open: number | string;
|
||||||
|
reason: string | null;
|
||||||
|
ref: string | null;
|
||||||
|
eta: string | null;
|
||||||
|
note: string | null;
|
||||||
|
opened_at: string | null;
|
||||||
|
receipts?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BomLine {
|
||||||
|
id?: number;
|
||||||
|
item: number;
|
||||||
|
qty: number | string | null;
|
||||||
|
part_id: string | null;
|
||||||
|
description: string | null;
|
||||||
|
dwg_ref: string | null;
|
||||||
|
cl_item?: string | null;
|
||||||
|
length: string | null;
|
||||||
|
material: string | null;
|
||||||
|
heat?: string | null;
|
||||||
|
na?: boolean;
|
||||||
|
issued?: number | string | null;
|
||||||
|
loc?: string | null;
|
||||||
|
pulled?: boolean;
|
||||||
|
mtr_asset_id?: string | null;
|
||||||
|
src?: string | null;
|
||||||
|
short?: BomShort | null;
|
||||||
|
row_version?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PacketDoc {
|
||||||
|
id: number;
|
||||||
|
asset_id: string;
|
||||||
|
kind: string;
|
||||||
|
title: string | null;
|
||||||
|
added_at: string | null;
|
||||||
|
added_by: WhoRef | null;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/packets/{id} and every packet mutation answer. Children the phase 2 screens do not use stay loose. */
|
||||||
|
export interface PacketRead {
|
||||||
|
id: number;
|
||||||
|
version: number;
|
||||||
|
unit: {
|
||||||
|
id: number;
|
||||||
|
number: string;
|
||||||
|
lot: string;
|
||||||
|
name: string | null;
|
||||||
|
project: { id: number; number: string; name: string | null; customer: string | null; po: string | null; status: ProjectStatus };
|
||||||
|
due_effective: string | null;
|
||||||
|
};
|
||||||
|
dept: DeptRef;
|
||||||
|
op: string | null;
|
||||||
|
title: string | null;
|
||||||
|
customer: string | null;
|
||||||
|
drawing: string | null;
|
||||||
|
rev: string | null;
|
||||||
|
wps: string | null;
|
||||||
|
status: PacketStatus;
|
||||||
|
status_at: string | null;
|
||||||
|
station: StationRef | null;
|
||||||
|
welder: WelderRef | null;
|
||||||
|
for_sup: WhoRef | null;
|
||||||
|
heat_map: boolean | null;
|
||||||
|
wh_skipped: boolean;
|
||||||
|
wh_sent_at: string | null;
|
||||||
|
routed_to: RouteKind | null;
|
||||||
|
return_note: string | null;
|
||||||
|
vclosed: VclosedRef | null;
|
||||||
|
databook_at: string | null;
|
||||||
|
source_kind: "visual" | "hand";
|
||||||
|
source: Record<string, unknown>;
|
||||||
|
drawing_state: string;
|
||||||
|
flags: string[];
|
||||||
|
visual: Record<string, unknown>;
|
||||||
|
removed: { at: string; by: WhoRef | null; reason: string | null } | null;
|
||||||
|
work_orders: WorkOrder[];
|
||||||
|
pages: PacketPage[];
|
||||||
|
bom: BomLine[];
|
||||||
|
welds: unknown[];
|
||||||
|
heat_tags: unknown[];
|
||||||
|
heat_table: unknown[];
|
||||||
|
stage: Record<string, unknown> | null;
|
||||||
|
stages: unknown[];
|
||||||
|
wh: Record<string, unknown> | null;
|
||||||
|
qc: Record<string, unknown> | null;
|
||||||
|
qc_history: unknown[];
|
||||||
|
change_orders: unknown[];
|
||||||
|
open_cos: number[];
|
||||||
|
open_shorts: number;
|
||||||
|
docs: PacketDoc[];
|
||||||
|
seal: { sealed_at: string; key_id: string; broken_at: string | null } | null;
|
||||||
|
can: string[];
|
||||||
|
blocked: Record<string, string[]>;
|
||||||
|
holder: string;
|
||||||
|
checklist_items: Array<{ group: string; key: string; text: string }>;
|
||||||
|
qc_items: unknown[];
|
||||||
|
flag: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row of GET /api/packets/{id}/log, newest first. */
|
||||||
|
export interface LogRow {
|
||||||
|
id: number;
|
||||||
|
at: string;
|
||||||
|
by: WhoRef | null;
|
||||||
|
role: string | null;
|
||||||
|
action: string;
|
||||||
|
note: string | null;
|
||||||
|
it_reason: string | null;
|
||||||
|
signed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/packets: a hand draft. */
|
||||||
|
export interface PacketCreate {
|
||||||
|
unit_id: number;
|
||||||
|
dept_code: string;
|
||||||
|
op: string;
|
||||||
|
title: string;
|
||||||
|
customer: string;
|
||||||
|
drawing: string;
|
||||||
|
rev: string;
|
||||||
|
wps: string;
|
||||||
|
heat_map?: boolean | null;
|
||||||
|
work_orders: Array<Pick<WorkOrder, "wo_base" | "lot" | "split" | "sub" | "qty" | "target_hrs"> & { op?: string; description?: string }>;
|
||||||
|
bom: Array<Pick<BomLine, "item" | "qty" | "part_id" | "description" | "material" | "length" | "dwg_ref">>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The header fields PATCH /api/packets/{id} takes. */
|
||||||
|
export interface PacketHeader {
|
||||||
|
title: string | null;
|
||||||
|
customer: string | null;
|
||||||
|
wps: string | null;
|
||||||
|
op: string | null;
|
||||||
|
heat_map: boolean | null;
|
||||||
|
for_sup_id: string | null;
|
||||||
|
drawing: string | null;
|
||||||
|
rev: string | null;
|
||||||
|
dept_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/packets/{id}/transitions. A transition carries only its own fields. */
|
||||||
|
export interface TransitionBody {
|
||||||
|
action: string;
|
||||||
|
expect: { status: PacketStatus; dept: string };
|
||||||
|
reason?: string;
|
||||||
|
note?: string;
|
||||||
|
dept?: string;
|
||||||
|
station_id?: number;
|
||||||
|
welder_id?: string;
|
||||||
|
stamp?: string;
|
||||||
|
routed_to?: RouteKind;
|
||||||
|
book?: boolean;
|
||||||
|
spool?: boolean;
|
||||||
|
location?: string;
|
||||||
|
for_sup_id?: string;
|
||||||
|
sign?: SignBody;
|
||||||
|
it_reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachPagesBody {
|
||||||
|
asset_id: string;
|
||||||
|
type: "drawing";
|
||||||
|
dwg?: string;
|
||||||
|
rev?: string;
|
||||||
|
sign?: SignBody;
|
||||||
|
it_reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Assets ---------- */
|
||||||
|
|
||||||
|
/** POST /api/assets and GET /api/assets/{id}. */
|
||||||
|
export interface Asset {
|
||||||
|
id: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
sha256: string;
|
||||||
|
pages: number | null;
|
||||||
|
original_name: string | null;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extra fields the guard adds on a retry: the IT reason and the signature. */
|
||||||
|
export interface Guarded {
|
||||||
|
it_reason?: string;
|
||||||
|
sign?: SignBody;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { ApiError, errorCode, onApiError } from "../api/client";
|
import { ApiError, errorCode, onApiError } from "../api/client";
|
||||||
|
import { emitChanged } from "../lib/changes";
|
||||||
import { authApi, departmentsApi } from "./api";
|
import { authApi, departmentsApi } from "./api";
|
||||||
import { AuthCtx, type AuthState, type AuthStatus } from "./context";
|
import { AuthCtx, type AuthState, type AuthStatus } from "./context";
|
||||||
import { roleLabel as labelFor } from "./roles";
|
import { roleLabel as labelFor } from "./roles";
|
||||||
@@ -203,6 +204,7 @@ export function AuthProvider({ children, idleLockMs = IDLE_LOCK_MS, idleCheckMs
|
|||||||
const sessionId = status === "ready" || status === "locked" ? (me?.session.id ?? null) : null;
|
const sessionId = status === "ready" || status === "locked" ? (me?.session.id ?? null) : null;
|
||||||
const presence = useEvents(sessionId, {
|
const presence = useEvents(sessionId, {
|
||||||
onRevoked: () => endSession("This session was signed out from another place."),
|
onRevoked: () => endSession("This session was signed out from another place."),
|
||||||
|
onChanged: emitChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
const roleLabel = useCallback((role: string) => labelFor(role, departments), [departments]);
|
const roleLabel = useCallback((role: string) => labelFor(role, departments), [departments]);
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ export interface SigninAttempt {
|
|||||||
reason: string | null;
|
reason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stations may come as names or as rows. */
|
/** Stations come as rows with their ids (ASSIGN needs the id). Older answers were plain names. */
|
||||||
export type StationEntry = string | { name: string; active?: boolean };
|
export type StationEntry = string | { id?: number; name: string; active?: boolean };
|
||||||
|
|
||||||
/** One row of GET /api/departments. */
|
/** One row of GET /api/departments. */
|
||||||
export interface Department {
|
export interface Department {
|
||||||
@@ -182,6 +182,7 @@ export interface Department {
|
|||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
kind: "support" | "shop";
|
kind: "support" | "shop";
|
||||||
|
weld?: boolean;
|
||||||
stations: StationEntry[];
|
stations: StationEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { PacketCard } from "../api/types";
|
||||||
|
import { Card } from "./Card";
|
||||||
|
|
||||||
|
export interface ColumnDef {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
cards: PacketCard[];
|
||||||
|
/** Shown when the column is empty. */
|
||||||
|
empty: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The old column(): a titled column of cards with a count, or the empty note. */
|
||||||
|
export function Column({ title, cards, empty }: Omit<ColumnDef, "key">) {
|
||||||
|
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) => <Card key={p.id} p={p} />) : <div className="empty">{empty}</div>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of columns. */
|
||||||
|
export function Board({ columns }: { columns: ColumnDef[] }) {
|
||||||
|
return (
|
||||||
|
<div className="board">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<Column key={c.key} title={c.title} cards={c.cards} empty={c.empty} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import type { PacketCard } from "../api/types";
|
||||||
|
import { flagClass, jobName, openCoCount, pillClass, statusLabel } from "../lib/packets";
|
||||||
|
|
||||||
|
/** One packet on a board. The old card(): job name and pill, title, department, meta, flag line. */
|
||||||
|
export function Card({ p, extra }: { p: PacketCard; extra?: ReactNode }) {
|
||||||
|
const shorts = p.open_shorts || 0;
|
||||||
|
const cos = openCoCount(p);
|
||||||
|
return (
|
||||||
|
<Link className="card" to={`/packets/${p.id}`} aria-label={`${jobName(p)}, ${statusLabel(p.status)}`}>
|
||||||
|
<div className="r1">
|
||||||
|
<span className="job">{jobName(p)}</span>
|
||||||
|
<span className={pillClass(p.status)}>{statusLabel(p.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="tt">{p.title || "Untitled"}</div>
|
||||||
|
<div className="small dept">{p.dept.name}</div>
|
||||||
|
<div className="meta">
|
||||||
|
<span>
|
||||||
|
Dwg <b className="mono">{p.drawing || "-"}</b>
|
||||||
|
{p.rev ? <> R{p.rev}</> : null}
|
||||||
|
</span>
|
||||||
|
{p.wo_count !== undefined ? (
|
||||||
|
<span>
|
||||||
|
<b>{p.wo_count}</b> WO
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{p.station ? (
|
||||||
|
<span>
|
||||||
|
<b>{p.station.name}</b>
|
||||||
|
{p.welder ? ` · ${p.welder.display_name}` : ""}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{p.status === "complete" && p.routed_to ? (
|
||||||
|
<span>
|
||||||
|
To <b>{p.routed_to === "databook" ? "Clerical (data book)" : "Shipping"}</b>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{shorts ? <span className="pill st-review">{shorts} short</span> : null}
|
||||||
|
{cos ? <span className="pill st-review">CO {cos}</span> : null}
|
||||||
|
</div>
|
||||||
|
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
|
||||||
|
{extra}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { PickUnit, type UnitPick } from "./PickUnit";
|
||||||
|
|
||||||
|
export type BoardView = "board" | "list";
|
||||||
|
|
||||||
|
interface FindBarProps {
|
||||||
|
q: string;
|
||||||
|
onQ: (q: string) => void;
|
||||||
|
pick: UnitPick;
|
||||||
|
onPick: (v: UnitPick) => void;
|
||||||
|
view: BoardView;
|
||||||
|
onView: (v: BoardView) => void;
|
||||||
|
/** "Showing n packets". */
|
||||||
|
count: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a job: the search box, the project then unit picker, and the Board | List toggle. */
|
||||||
|
export function FindBar({ q, onQ, pick, onPick, view, onView, count }: FindBarProps) {
|
||||||
|
return (
|
||||||
|
<section className="fltbar" aria-label="Find jobs">
|
||||||
|
<div className="fltrow">
|
||||||
|
<label className="f fltq">
|
||||||
|
<span>Find a job</span>
|
||||||
|
<input
|
||||||
|
className="in"
|
||||||
|
type="search"
|
||||||
|
placeholder="Project, unit, lot, drawing, WO, customer, PO, title"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => onQ(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape" && q) {
|
||||||
|
e.stopPropagation();
|
||||||
|
onQ("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="f fltp">
|
||||||
|
<PickUnit value={pick} onChange={onPick} />
|
||||||
|
</div>
|
||||||
|
<div className="fltbtns">
|
||||||
|
<div className="seg" role="group" aria-label="View">
|
||||||
|
<button type="button" aria-pressed={view === "board"} onClick={() => onView("board")}>
|
||||||
|
Board
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-pressed={view === "list"} onClick={() => onView("list")}>
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{count !== null ? (
|
||||||
|
<div className="fltrow2">
|
||||||
|
<span className="grow" />
|
||||||
|
<span className="small muted">
|
||||||
|
Showing <b>{count}</b> packet{count === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface Kpi {
|
||||||
|
n: ReactNode;
|
||||||
|
label: string;
|
||||||
|
/** Turns pink when above zero. */
|
||||||
|
alert?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The old kpis(): number tiles. Zero is grey, an alert above zero is pink. */
|
||||||
|
export function Kpis({ items, label = "Numbers" }: { items: Kpi[]; label?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="kpis" role="group" aria-label={label}>
|
||||||
|
{items.map((k) => {
|
||||||
|
const zero = String(k.n) === "0";
|
||||||
|
const cls = ["kpi", k.alert && !zero ? "hot" : "", zero ? "zero" : ""].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<div key={k.label} className={cls} aria-label={`${k.label}: ${String(k.n)}`}>
|
||||||
|
<div className="n">{k.n}</div>
|
||||||
|
<div className="l">{k.label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import { projectsApi } from "../api/projects";
|
||||||
|
import type { Project, ProjectRef, Unit } from "../api/types";
|
||||||
|
import { errorText } from "../lib/format";
|
||||||
|
import { projectPillClass, PROJECT_STATUS_LABEL } from "../lib/packets";
|
||||||
|
|
||||||
|
export interface UnitPick {
|
||||||
|
project: ProjectRef | null;
|
||||||
|
unit: Unit | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PickUnitProps {
|
||||||
|
value: UnitPick;
|
||||||
|
onChange: (v: UnitPick) => void;
|
||||||
|
/** A packet must go in a unit: no "All units" choice, and closed units are left out. */
|
||||||
|
requireUnit?: boolean;
|
||||||
|
/** Label of the first step. */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two steps: find the project, then pick one of its units. */
|
||||||
|
export function PickUnit({ value, onChange, requireUnit = false, label = "Project" }: PickUnitProps) {
|
||||||
|
const id = useId();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [hits, setHits] = useState<Project[] | null>(null);
|
||||||
|
const [units, setUnits] = useState<Unit[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const projectId = value.project?.id ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setTimeout(() => setQuery(q.trim()), 250);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
// Step one: projects matching the search, while no project is chosen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectId !== null) return;
|
||||||
|
const ctl = new AbortController();
|
||||||
|
projectsApi
|
||||||
|
.list(query, "active", ctl.signal)
|
||||||
|
.then((list) => {
|
||||||
|
if (!ctl.signal.aborted) setHits(list);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!ctl.signal.aborted) setError(errorText(err));
|
||||||
|
});
|
||||||
|
return () => ctl.abort();
|
||||||
|
}, [query, projectId]);
|
||||||
|
|
||||||
|
// Step two: the chosen project's units.
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectId === null) {
|
||||||
|
setUnits(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctl = new AbortController();
|
||||||
|
projectsApi
|
||||||
|
.units(projectId, "", ctl.signal)
|
||||||
|
.then((list) => {
|
||||||
|
if (!ctl.signal.aborted) setUnits(list);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!ctl.signal.aborted) setError(errorText(err));
|
||||||
|
});
|
||||||
|
return () => ctl.abort();
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const choose = (p: Project) => {
|
||||||
|
setError(null);
|
||||||
|
onChange({ project: { id: p.id, number: p.number, name: p.name, customer: p.customer, po: p.po, status: p.status, due: p.due }, unit: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!value.project) {
|
||||||
|
const shown = (hits ?? []).slice(0, 8);
|
||||||
|
return (
|
||||||
|
<div className="pick">
|
||||||
|
<label className="f">
|
||||||
|
{label}
|
||||||
|
<input
|
||||||
|
className="in"
|
||||||
|
type="search"
|
||||||
|
placeholder="Find a project: number, name, customer, PO"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error ? <div className="small due over">{error}</div> : null}
|
||||||
|
{hits === null ? null : shown.length ? (
|
||||||
|
<div className="projhits" role="group" aria-label="Projects found">
|
||||||
|
{shown.map((p) => (
|
||||||
|
<button key={p.id} type="button" className="projhit" onClick={() => choose(p)}>
|
||||||
|
<b className="mono">{p.number}</b> {p.name ?? ""}
|
||||||
|
<span className="muted small">{p.customer ?? ""}</span>
|
||||||
|
<span className={projectPillClass(p.status)}>{PROJECT_STATUS_LABEL[p.status]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="small muted">{query ? `Nothing matches "${query}".` : "No open projects yet."}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = (units ?? []).filter((u) => !requireUnit || u.status !== "closed" || u.id === value.unit?.id);
|
||||||
|
return (
|
||||||
|
<div className="pick">
|
||||||
|
<div className="pickp">
|
||||||
|
<span className="small muted">{label}</span>
|
||||||
|
<b className="mono">{value.project.number}</b> <span>{value.project.name ?? ""}</span>
|
||||||
|
<button className="lnk small" type="button" onClick={() => onChange({ project: null, unit: null })}>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label className="f" htmlFor={`${id}-unit`}>
|
||||||
|
Unit
|
||||||
|
</label>
|
||||||
|
{units === null ? (
|
||||||
|
<div className="small muted">Loading units</div>
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
<div className="small muted">This project has no open units. Add one from the project page first.</div>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
id={`${id}-unit`}
|
||||||
|
className="in"
|
||||||
|
value={value.unit ? String(value.unit.id) : ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const u = list.find((x) => String(x.id) === e.target.value) ?? null;
|
||||||
|
onChange({ project: value.project, unit: u });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{requireUnit ? "Choose a unit" : "All units"}</option>
|
||||||
|
{list.map((u) => (
|
||||||
|
<option key={u.id} value={String(u.id)}>
|
||||||
|
{u.number}
|
||||||
|
{u.name ? ` · ${u.name}` : ""}
|
||||||
|
{u.status === "hold" ? " (on hold)" : u.status === "closed" ? " (closed)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{error ? <div className="small due over">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { Sheet } from "./Sheet";
|
||||||
|
|
||||||
|
export interface ReasonOptions {
|
||||||
|
title: string;
|
||||||
|
sub?: string | undefined;
|
||||||
|
/** The field label. */
|
||||||
|
label?: string;
|
||||||
|
/** A line under the title. */
|
||||||
|
note?: string | undefined;
|
||||||
|
placeholder?: string | undefined;
|
||||||
|
/** Shortest reason accepted. 0 makes the field optional. */
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
submitLabel?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReasonSheetProps extends ReasonOptions {
|
||||||
|
onSubmit: (reason: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks for a reason, or an optional note, before a step. */
|
||||||
|
export function ReasonSheet({
|
||||||
|
title,
|
||||||
|
sub,
|
||||||
|
label = "Reason",
|
||||||
|
note,
|
||||||
|
placeholder,
|
||||||
|
min = 1,
|
||||||
|
max = 500,
|
||||||
|
submitLabel = "Continue",
|
||||||
|
danger = false,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: ReasonSheetProps) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const t = text.trim();
|
||||||
|
if (t.length < min) {
|
||||||
|
setError(min <= 1 ? "Give a reason." : `Give a reason of at least ${min} characters.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet title={title} sub={sub} onClose={onCancel}>
|
||||||
|
{note ? <div className="small">{note}</div> : null}
|
||||||
|
<form className="stack" onSubmit={submit} noValidate>
|
||||||
|
<label className="f">
|
||||||
|
{label}
|
||||||
|
<input className="in" value={text} maxLength={max} placeholder={placeholder} onChange={(e) => setText(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={danger ? "btn pink" : "btn pri"} type="submit">
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface SheetProps {
|
||||||
|
title: string;
|
||||||
|
/** Small text next to the title, like the job name. */
|
||||||
|
sub?: string | undefined;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
/** The old sheets were 560 px for short forms and 720 px or more for long ones. */
|
||||||
|
wide?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open sheets, innermost last. Only the innermost one answers Escape. */
|
||||||
|
const open: string[] = [];
|
||||||
|
|
||||||
|
/** A modal sheet over a scrim. Escape and a click on the scrim close it. The first field gets the focus. */
|
||||||
|
export function Sheet({ title, sub, onClose, children, wide = false }: SheetProps) {
|
||||||
|
const id = useId();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const closeRef = useRef(onClose);
|
||||||
|
useEffect(() => {
|
||||||
|
closeRef.current = onClose;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
const first = el?.querySelector<HTMLElement>("input:not([type=hidden]):not([type=file]):not([disabled]), select, textarea");
|
||||||
|
(first ?? el)?.focus();
|
||||||
|
open.push(id);
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && open[open.length - 1] === id) {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeRef.current();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
const i = open.lastIndexOf(id);
|
||||||
|
if (i >= 0) open.splice(i, 1);
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="scrim"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={ref} className={wide ? "sheet" : "sheet narrow"} role="dialog" aria-modal="true" aria-labelledby={id} tabIndex={-1}>
|
||||||
|
<div className="sec-h flush">
|
||||||
|
<h2 id={id}>{title}</h2>
|
||||||
|
{sub ? <span className="ct">{sub}</span> : null}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||||
|
import { ApiError, errorCode } from "../api/client";
|
||||||
|
import type { SignBody } from "../api/types";
|
||||||
|
import type { Factor } from "../auth/types";
|
||||||
|
import { useAuth } from "../auth/useAuth";
|
||||||
|
import { errorText } from "../lib/format";
|
||||||
|
import { applyKey, type KeypadKey } from "../lib/keypad";
|
||||||
|
import { Keypad } from "./Keypad";
|
||||||
|
import { Sheet } from "./Sheet";
|
||||||
|
|
||||||
|
const PIN_MAX = 12;
|
||||||
|
|
||||||
|
interface SignatureSheetProps {
|
||||||
|
/** What the signature means, from the rule. */
|
||||||
|
meaning: string;
|
||||||
|
sub?: string | undefined;
|
||||||
|
/** Sends the step with the signature. A wrong secret keeps the sheet open. */
|
||||||
|
onSign: (sign: SignBody) => Promise<void>;
|
||||||
|
/** Any other failure. The caller closes the sheet. */
|
||||||
|
onFail: (err: unknown) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptsLeft(err: unknown): number | null {
|
||||||
|
if (err instanceof ApiError && err.body && typeof err.body === "object" && "attempts_left" in err.body) {
|
||||||
|
const n = Number((err.body as { attempts_left: unknown }).attempts_left);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs a step with the user's PIN or password. */
|
||||||
|
export function SignatureSheet({ meaning, sub, onSign, onFail, onCancel }: SignatureSheetProps) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const me = auth.me;
|
||||||
|
const hasPin = me ? me.pin.set && !me.pin.disabled : false;
|
||||||
|
const [factor, setFactor] = useState<Factor>(hasPin ? "pin" : "password");
|
||||||
|
const [secret, setSecret] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [left, setLeft] = useState<number | null>(null);
|
||||||
|
const [missed, setMissed] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [factor]);
|
||||||
|
|
||||||
|
const switchTo = (f: Factor) => {
|
||||||
|
setFactor(f);
|
||||||
|
setSecret("");
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!secret || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onSign({ factor, secret });
|
||||||
|
} catch (err) {
|
||||||
|
const code = errorCode(err);
|
||||||
|
if (code === "session_ended") {
|
||||||
|
// The auth layer signs out on this 401 too. Make sure, with the server's words.
|
||||||
|
auth.endSession(errorText(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code === "wrong_secret" || code === "bad_secret") {
|
||||||
|
setError(errorText(err));
|
||||||
|
setLeft(attemptsLeft(err));
|
||||||
|
setMissed(true);
|
||||||
|
setSecret("");
|
||||||
|
setBusy(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFail(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKey = (k: KeypadKey) => setSecret((v) => applyKey(v, k, PIN_MAX));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet title="Sign" sub={sub} onClose={onCancel}>
|
||||||
|
<div className="sigmean">
|
||||||
|
<div className="eyebrow">This signature means</div>
|
||||||
|
<div className="tt">{meaning}</div>
|
||||||
|
</div>
|
||||||
|
<div className="small">
|
||||||
|
You are signing as <b>{me?.user.display_name ?? "yourself"}</b>.
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<div className="callout warn flush" role="alert">
|
||||||
|
{error}
|
||||||
|
{left !== null ? <div className="small">{left === 1 ? "1 attempt left." : `${left} attempts left.`}</div> : null}
|
||||||
|
{missed ? <div className="small">Three wrong attempts end the session.</div> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<form className="auth-form" onSubmit={submit} noValidate autoComplete="off">
|
||||||
|
{factor === "pin" ? (
|
||||||
|
<>
|
||||||
|
<label className="f">
|
||||||
|
PIN
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="in big"
|
||||||
|
name="sign-pin"
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="off"
|
||||||
|
maxLength={PIN_MAX}
|
||||||
|
value={secret}
|
||||||
|
onChange={(e) => setSecret(e.target.value.replace(/\D/g, "").slice(0, PIN_MAX))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Keypad onKey={onKey} disabled={busy} label="Keypad, signature PIN" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<label className="f">
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="in"
|
||||||
|
name="sign-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={secret}
|
||||||
|
onChange={(e) => setSecret(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="sheet-acts">
|
||||||
|
{factor === "pin" ? (
|
||||||
|
<button className="lnk small" type="button" onClick={() => switchTo("password")}>
|
||||||
|
Use your password instead
|
||||||
|
</button>
|
||||||
|
) : hasPin ? (
|
||||||
|
<button className="lnk small" type="button" onClick={() => switchTo("pin")}>
|
||||||
|
Use your PIN instead
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<span className="grow" />
|
||||||
|
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn pri" type="submit" disabled={busy || !secret}>
|
||||||
|
{busy ? "Signing" : "Sign"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { ApiError, errorCode } from "../api/client";
|
||||||
|
import type { Guarded, SignBody } from "../api/types";
|
||||||
|
import { ReasonSheet, type ReasonOptions } from "./ReasonSheet";
|
||||||
|
import { SignatureSheet } from "./SignatureSheet";
|
||||||
|
|
||||||
|
type Ask =
|
||||||
|
| { kind: "reason"; opts: ReasonOptions; resolve: (r: string | null) => void }
|
||||||
|
| {
|
||||||
|
kind: "sign";
|
||||||
|
meaning: string;
|
||||||
|
sub: string | undefined;
|
||||||
|
attempt: (sign: SignBody) => Promise<unknown>;
|
||||||
|
resolve: (r: { ok: true; value: unknown } | { ok: false }) => void;
|
||||||
|
reject: (err: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RunOptions {
|
||||||
|
/** The signature meaning when the step is known to need one. Otherwise the sheet opens on 400 signature_required. */
|
||||||
|
sign?: string | undefined;
|
||||||
|
/** Shown next to the sheet titles, like the job name. */
|
||||||
|
sub?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Guard {
|
||||||
|
/**
|
||||||
|
* Runs a call. On 400 it_reason_required it asks for the IT reason and retries with `it_reason`.
|
||||||
|
* On 400 signature_required it opens the signature sheet and retries with `sign`.
|
||||||
|
* Resolves undefined when the user cancels. Other errors are thrown.
|
||||||
|
*/
|
||||||
|
run: <T>(fn: (g: Guarded) => Promise<T>, opts?: RunOptions) => Promise<T | undefined>;
|
||||||
|
/** Asks for a reason. Null when cancelled. */
|
||||||
|
askReason: (opts: ReasonOptions) => Promise<string | null>;
|
||||||
|
/** The open sheet, if any. Render it once in the screen. */
|
||||||
|
sheet: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IT_REASON: ReasonOptions = {
|
||||||
|
title: "IT reason",
|
||||||
|
label: "Why IT is making this change",
|
||||||
|
note: "You are changing this as IT. The reason is recorded with the change.",
|
||||||
|
min: 8,
|
||||||
|
submitLabel: "Continue",
|
||||||
|
};
|
||||||
|
|
||||||
|
function meaningOf(err: unknown): string {
|
||||||
|
if (err instanceof ApiError && err.body && typeof err.body === "object" && "meaning" in err.body) {
|
||||||
|
const m = (err.body as { meaning: unknown }).meaning;
|
||||||
|
if (typeof m === "string" && m) return m;
|
||||||
|
}
|
||||||
|
return "Sign this step";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The IT reason and signature retries, as sheets. */
|
||||||
|
export function useGuard(): Guard {
|
||||||
|
const [ask, setAsk] = useState<Ask | null>(null);
|
||||||
|
|
||||||
|
const askReason = useCallback(
|
||||||
|
(opts: ReasonOptions) =>
|
||||||
|
new Promise<string | null>((resolve) => {
|
||||||
|
setAsk({ kind: "reason", opts, resolve });
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const askSign = useCallback(
|
||||||
|
<T,>(meaning: string, sub: string | undefined, attempt: (sign: SignBody) => Promise<T>) =>
|
||||||
|
new Promise<{ ok: true; value: T } | { ok: false }>((resolve, reject) => {
|
||||||
|
setAsk({
|
||||||
|
kind: "sign",
|
||||||
|
meaning,
|
||||||
|
sub,
|
||||||
|
attempt,
|
||||||
|
resolve: resolve as (r: { ok: true; value: unknown } | { ok: false }) => void,
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = useCallback(
|
||||||
|
async <T,>(fn: (g: Guarded) => Promise<T>, opts: RunOptions = {}): Promise<T | undefined> => {
|
||||||
|
let itReason: string | undefined;
|
||||||
|
let meaning = opts.sign;
|
||||||
|
for (let round = 0; round < 4; round++) {
|
||||||
|
const call = (sign?: SignBody) => {
|
||||||
|
const g: Guarded = {};
|
||||||
|
if (itReason) g.it_reason = itReason;
|
||||||
|
if (sign) g.sign = sign;
|
||||||
|
return fn(g);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (meaning) {
|
||||||
|
const r = await askSign(meaning, opts.sub, call);
|
||||||
|
return r.ok ? r.value : undefined;
|
||||||
|
}
|
||||||
|
return await call();
|
||||||
|
} catch (err) {
|
||||||
|
const code = errorCode(err);
|
||||||
|
if (code === "it_reason_required" && !itReason) {
|
||||||
|
const r = await askReason({ ...IT_REASON, sub: opts.sub });
|
||||||
|
if (r === null) return undefined;
|
||||||
|
itReason = r;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code === "signature_required" && !meaning) {
|
||||||
|
meaning = meaningOf(err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
[askReason, askSign],
|
||||||
|
);
|
||||||
|
|
||||||
|
let sheet: ReactNode = null;
|
||||||
|
if (ask?.kind === "reason") {
|
||||||
|
sheet = (
|
||||||
|
<ReasonSheet
|
||||||
|
{...ask.opts}
|
||||||
|
onSubmit={(r) => {
|
||||||
|
setAsk(null);
|
||||||
|
ask.resolve(r);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setAsk(null);
|
||||||
|
ask.resolve(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (ask?.kind === "sign") {
|
||||||
|
sheet = (
|
||||||
|
<SignatureSheet
|
||||||
|
meaning={ask.meaning}
|
||||||
|
sub={ask.sub}
|
||||||
|
onSign={async (sign) => {
|
||||||
|
const value = await ask.attempt(sign);
|
||||||
|
setAsk(null);
|
||||||
|
ask.resolve({ ok: true, value });
|
||||||
|
}}
|
||||||
|
onFail={(err) => {
|
||||||
|
setAsk(null);
|
||||||
|
ask.reject(err);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setAsk(null);
|
||||||
|
ask.resolve({ ok: false });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return useMemo(() => ({ run, askReason, sheet }), [run, askReason, sheet]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// What the screens offer. The server decides on every request; this only hides what cannot work.
|
||||||
|
|
||||||
|
/** Clerical (or a Clerical supervisor) sets up projects and units. Admin can, with an IT reason. */
|
||||||
|
export function canEditProjects(roles: readonly string[]): boolean {
|
||||||
|
return roles.some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renumbering a project or unit needs a Clerical supervisor, or admin. */
|
||||||
|
export function canRenumber(roles: readonly string[]): boolean {
|
||||||
|
return roles.some((r) => r === "clerical_supervisor" || r === "admin");
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// The `changed` events from /api/events, fanned out to the open screens.
|
||||||
|
// AuthProvider owns the one EventSource and feeds every payload in here.
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export interface ChangedEvent {
|
||||||
|
event?: string;
|
||||||
|
packet_id?: number;
|
||||||
|
version?: number;
|
||||||
|
status?: string;
|
||||||
|
dept?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (ev: ChangedEvent) => void;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
export function emitChanged(payload: unknown): void {
|
||||||
|
const ev: ChangedEvent = payload && typeof payload === "object" ? (payload as ChangedEvent) : {};
|
||||||
|
for (const fn of listeners) fn(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onChanged(fn: Listener): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls `fn` after `changed` events. With `delayMs`, a burst of events gives one call
|
||||||
|
* `delayMs` after the last one. `filter` drops events the screen does not care about.
|
||||||
|
*/
|
||||||
|
export function useChanged(fn: () => void, opts: { delayMs?: number; filter?: (ev: ChangedEvent) => boolean } = {}): void {
|
||||||
|
const fnRef = useRef(fn);
|
||||||
|
const filterRef = useRef(opts.filter);
|
||||||
|
useEffect(() => {
|
||||||
|
fnRef.current = fn;
|
||||||
|
filterRef.current = opts.filter;
|
||||||
|
});
|
||||||
|
const delay = opts.delayMs ?? 500;
|
||||||
|
useEffect(() => {
|
||||||
|
let t: number | undefined;
|
||||||
|
const off = onChanged((ev) => {
|
||||||
|
if (filterRef.current && !filterRef.current(ev)) return;
|
||||||
|
window.clearTimeout(t);
|
||||||
|
t = window.setTimeout(() => fnRef.current(), delay);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
off();
|
||||||
|
window.clearTimeout(t);
|
||||||
|
};
|
||||||
|
}, [delay]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Status labels, pills, job names and due dates. Copy from the old build where it had one.
|
||||||
|
import type { PacketCard, PacketRead, PacketStatus, ProjectStatus, StageCounts } from "../api/types";
|
||||||
|
|
||||||
|
export const STATUS_LABEL: Record<PacketStatus, string> = {
|
||||||
|
draft: "Ready to release",
|
||||||
|
warehouse: "With warehouse",
|
||||||
|
ready: "Ready to assign",
|
||||||
|
assigned: "Sent to station",
|
||||||
|
in_progress: "Working",
|
||||||
|
review: "Supervisor review",
|
||||||
|
qc: "Q.C. inspection",
|
||||||
|
complete: "Routed",
|
||||||
|
filed: "Filed in data book",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function statusLabel(s: string): string {
|
||||||
|
return (STATUS_LABEL as Record<string, string>)[s] ?? s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pill class for a status: "pill st-<status>". */
|
||||||
|
export function pillClass(s: string): string {
|
||||||
|
return `pill st-${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHOP: readonly PacketStatus[] = ["ready", "assigned", "in_progress", "review"];
|
||||||
|
export const IN_SHOP_OR_QC: readonly PacketStatus[] = [...SHOP, "qc"];
|
||||||
|
|
||||||
|
/** "L999001 · WELD-PART". */
|
||||||
|
export function jobName(p: Pick<PacketCard, "unit" | "op"> | Pick<PacketRead, "unit" | "op">): string {
|
||||||
|
return p.op ? `${p.unit.number} · ${p.op}` : p.unit.number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROJECT_STATUS_LABEL: Record<ProjectStatus, string> = { open: "Open", hold: "On hold", closed: "Closed" };
|
||||||
|
|
||||||
|
/** The old projPill colours: open green, on hold pink, closed grey. */
|
||||||
|
export function projectPillClass(s: ProjectStatus): string {
|
||||||
|
return `pill ${s === "open" ? "st-in_progress" : s === "hold" ? "st-review" : "st-filed"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A flag text that names a hard problem shows in pink, the rest in amber. */
|
||||||
|
export function flagClass(flag: string): string {
|
||||||
|
return /couldn't|not in|mismatch|No approved|Not released/i.test(flag) ? "due over" : "due soon";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
|
||||||
|
/** The old dueInfo(): "Due Oct 20 '26 · 4 d left", with a class for colour. */
|
||||||
|
export function dueInfo(due: string | null | undefined, closed = false, today: Date = new Date()): { txt: string; cls: string } {
|
||||||
|
if (!due) return { txt: "No due date", cls: "due muted" };
|
||||||
|
const d = new Date(`${due.slice(0, 10)}T12:00:00`);
|
||||||
|
if (Number.isNaN(d.getTime())) return { txt: `Due ${due}`, cls: "due" };
|
||||||
|
const noon = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 12);
|
||||||
|
const days = Math.round((d.getTime() - noon.getTime()) / 86_400_000);
|
||||||
|
const lab = `${MONTHS[d.getMonth()] ?? ""} ${d.getDate()} '${String(d.getFullYear()).slice(2)}`;
|
||||||
|
if (closed) return { txt: `Due ${lab}`, cls: "due muted" };
|
||||||
|
if (days < 0) return { txt: `Due ${lab} · ${-days} d overdue`, cls: "due over" };
|
||||||
|
if (days <= 7) return { txt: `Due ${lab} · ${days === 0 ? "today" : `${days} d left`}`, cls: "due soon" };
|
||||||
|
return { txt: `Due ${lab} · ${days} d left`, cls: "due" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The open change orders on a card, as a count. */
|
||||||
|
export function openCoCount(p: Pick<PacketCard, "open_cos">): number {
|
||||||
|
return Array.isArray(p.open_cos) ? p.open_cos.length : Number(p.open_cos) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum of the work orders' target hours. */
|
||||||
|
export function targetHours(p: Pick<PacketRead, "work_orders">): number {
|
||||||
|
return p.work_orders.reduce((a, w) => a + (Number(w.target_hrs) || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "001" from "1", "7A" stays "7A". The server does the same. */
|
||||||
|
export function normLot(v: string): string {
|
||||||
|
const t = v.trim().toUpperCase();
|
||||||
|
return /^\d{1,3}$/.test(t) ? t.padStart(3, "0") : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RX_LOT = /^[A-Z0-9]{1,3}$/;
|
||||||
|
export const RX_PROJECT = /^[A-Z0-9][A-Z0-9-]{0,19}$/;
|
||||||
|
|
||||||
|
/** Lots counting up from a numeric start: ("008", 3) -> 008, 009, 010. Null when the start is not digits. */
|
||||||
|
export function lotSequence(start: string, count: number): string[] | null {
|
||||||
|
const s = start.trim();
|
||||||
|
if (!/^\d{1,3}$/.test(s) || !Number.isInteger(count) || count < 1) return null;
|
||||||
|
const b = Number(s);
|
||||||
|
if (b + count - 1 > 999) return null;
|
||||||
|
return Array.from({ length: count }, (_, i) => String(b + i).padStart(3, "0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next free numeric lot after the ones in use. */
|
||||||
|
export function nextLot(lots: readonly string[]): string {
|
||||||
|
const nums = lots.filter((l) => /^\d+$/.test(l)).map(Number);
|
||||||
|
const n = nums.length ? Math.max(...nums) + 1 : 1;
|
||||||
|
return String(Math.min(n, 999)).padStart(3, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Packet counts with every stage present. */
|
||||||
|
export function stageCounts(c: Partial<StageCounts> | undefined | null): StageCounts {
|
||||||
|
return { draft: 0, warehouse: 0, shop: 0, qc: 0, complete: 0, filed: 0, total: 0, ...(c ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts of a list of cards, the same way. */
|
||||||
|
export function countCards(list: readonly Pick<PacketCard, "status">[]): StageCounts {
|
||||||
|
const c = stageCounts(null);
|
||||||
|
for (const p of list) {
|
||||||
|
c.total++;
|
||||||
|
if (p.status === "draft") c.draft++;
|
||||||
|
else if (p.status === "warehouse") c.warehouse++;
|
||||||
|
else if ((SHOP as readonly string[]).includes(p.status)) c.shop++;
|
||||||
|
else if (p.status === "qc") c.qc++;
|
||||||
|
else if (p.status === "complete") c.complete++;
|
||||||
|
else if (p.status === "filed") c.filed++;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// pdf.js, loaded on first use. The worker is a same-origin file from the build (CSP worker-src 'self').
|
||||||
|
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||||
|
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||||
|
|
||||||
|
type PdfJs = typeof import("pdfjs-dist");
|
||||||
|
|
||||||
|
let lib: Promise<PdfJs> | null = null;
|
||||||
|
|
||||||
|
export function loadPdfJs(): Promise<PdfJs> {
|
||||||
|
if (!lib) {
|
||||||
|
lib = import("pdfjs-dist").then((m) => {
|
||||||
|
m.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
lib.catch(() => {
|
||||||
|
lib = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return lib;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
promise: Promise<PDFDocumentProxy>;
|
||||||
|
destroy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docs = new Map<string, Entry>();
|
||||||
|
const MAX_DOCS = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a PDF from a same-origin URL, once per URL. The session cookie rides along.
|
||||||
|
* WebAssembly decoders stay off: the CSP does not allow wasm, and drawings rarely need them.
|
||||||
|
*/
|
||||||
|
export function openPdf(url: string): Promise<PDFDocumentProxy> {
|
||||||
|
const hit = docs.get(url);
|
||||||
|
if (hit) return hit.promise;
|
||||||
|
let destroy = () => undefined as void;
|
||||||
|
const promise = loadPdfJs().then((m) => {
|
||||||
|
const task = m.getDocument({ url, withCredentials: true, useWasm: false });
|
||||||
|
destroy = () => void task.destroy().catch(() => undefined);
|
||||||
|
return task.promise;
|
||||||
|
});
|
||||||
|
const entry: Entry = { promise, destroy: () => destroy() };
|
||||||
|
docs.set(url, entry);
|
||||||
|
promise.catch(() => docs.delete(url));
|
||||||
|
// Keep a few documents open; drop the oldest.
|
||||||
|
while (docs.size > MAX_DOCS) {
|
||||||
|
const first = docs.keys().next().value;
|
||||||
|
if (first === undefined) break;
|
||||||
|
docs.get(first)?.destroy();
|
||||||
|
docs.delete(first);
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { departmentsApi } from "../auth/api";
|
||||||
|
import type { Department } from "../auth/types";
|
||||||
|
import { useAuth } from "../auth/useAuth";
|
||||||
|
|
||||||
|
/** The shop departments from /api/departments, in code order. */
|
||||||
|
export function useShopDepartments(): Department[] {
|
||||||
|
const { departments } = useAuth();
|
||||||
|
const [own, setOwn] = useState<Department[] | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (departments.length) return;
|
||||||
|
const ctl = new AbortController();
|
||||||
|
departmentsApi
|
||||||
|
.list(ctl.signal)
|
||||||
|
.then((d) => {
|
||||||
|
if (!ctl.signal.aborted) setOwn(Array.isArray(d) ? d : []);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => ctl.abort();
|
||||||
|
}, [departments.length]);
|
||||||
|
const list = departments.length ? departments : (own ?? []);
|
||||||
|
return list.filter((d) => d.kind === "shop").sort((a, b) => a.code.localeCompare(b.code));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { PacketPage } from "../api/types";
|
||||||
|
import { openPdf } from "../lib/pdf";
|
||||||
|
|
||||||
|
const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
||||||
|
|
||||||
|
/** The drawing pages, one at a time, fitted to the width. Zoom buttons and a page list. No marking yet. */
|
||||||
|
export function DrawingViewer({ pages }: { pages: PacketPage[] }) {
|
||||||
|
const [sel, setSel] = useState(0);
|
||||||
|
const [zi, setZi] = useState(2);
|
||||||
|
const [width, setWidth] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const page = pages[Math.min(sel, pages.length - 1)];
|
||||||
|
const zoom = ZOOMS[zi] ?? 1;
|
||||||
|
const url = page?.url ?? "";
|
||||||
|
const pn = page?.pn ?? 1;
|
||||||
|
|
||||||
|
// Fit to width: follow the frame's width.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = wrapRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => setWidth(el.clientWidth);
|
||||||
|
measure();
|
||||||
|
if (typeof ResizeObserver === "undefined") return;
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url || !width) return;
|
||||||
|
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 || 1, doc.numPages))))
|
||||||
|
.then(async (pg) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const base = pg.getViewport({ scale: 1 });
|
||||||
|
const scale = (Math.max(200, width - 2) / base.width) * zoom;
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
const vp = pg.getViewport({ scale: scale * ratio });
|
||||||
|
canvas.width = Math.floor(vp.width);
|
||||||
|
canvas.height = Math.floor(vp.height);
|
||||||
|
canvas.style.width = `${Math.floor(vp.width / ratio)}px`;
|
||||||
|
canvas.style.height = `${Math.floor(vp.height / ratio)}px`;
|
||||||
|
task = pg.render({ canvas, viewport: vp });
|
||||||
|
await task.promise;
|
||||||
|
if (!cancelled) setBusy(false);
|
||||||
|
})
|
||||||
|
.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, width, zoom]);
|
||||||
|
|
||||||
|
if (!page) return <div className="empty">No drawing attached yet.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pdfv">
|
||||||
|
<div className="pdfv-bar">
|
||||||
|
<div className="seg" role="group" aria-label="Zoom">
|
||||||
|
<button type="button" aria-label="Zoom out" disabled={zi === 0} onClick={() => setZi((z) => Math.max(0, z - 1))}>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-label="Fit to width" aria-pressed={zoom === 1} onClick={() => setZi(2)}>
|
||||||
|
Fit
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-label="Zoom in" disabled={zi === ZOOMS.length - 1} onClick={() => setZi((z) => Math.min(ZOOMS.length - 1, z + 1))}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="small muted mono">{Math.round(zoom * 100)}%</span>
|
||||||
|
<span className="grow" />
|
||||||
|
<a className="btn sm ghost" href={page.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
Open in a new tab
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="pdfv-body">
|
||||||
|
<ol className="pdfv-pages" aria-label="Pages">
|
||||||
|
{pages.map((p, i) => (
|
||||||
|
<li key={p.n}>
|
||||||
|
<button type="button" aria-current={i === sel ? "page" : undefined} onClick={() => setSel(i)}>
|
||||||
|
<span className="mono">p.{p.n}</span>
|
||||||
|
{p.dwg ? <span className="small muted"> {p.dwg}{p.rev ? ` R${p.rev}` : ""}</span> : null}
|
||||||
|
{p.void ? <span className="pill st-bad">Void</span> : null}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<div className="pdfv-frame" ref={wrapRef}>
|
||||||
|
{error ? <div className="callout warn flush">{error}</div> : null}
|
||||||
|
{busy && !error ? <div className="small muted">Drawing the page</div> : null}
|
||||||
|
<canvas ref={canvasRef} className="pdfv-canvas" role="img" aria-label={`Drawing page ${page.n}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
||||||
|
import { callsTo, mockFetch } from "../test/fetchMock";
|
||||||
|
import { makePacket } from "../test/packets";
|
||||||
|
|
||||||
|
function base(packet = makePacket()) {
|
||||||
|
return {
|
||||||
|
"/api/auth/me": { body: makeMe() },
|
||||||
|
"/api/departments": { body: DEPARTMENTS },
|
||||||
|
"/api/packets/1": { body: packet },
|
||||||
|
"/api/packets/1/log": { body: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PacketPage", () => {
|
||||||
|
it("shows Send to warehouse disabled with the blocked reason", async () => {
|
||||||
|
mockFetch(base());
|
||||||
|
renderApp("/packets/1");
|
||||||
|
const send = await screen.findByRole("button", { name: "Send to warehouse" });
|
||||||
|
expect(send).toBeDisabled();
|
||||||
|
expect(screen.getByText("Say whether this packet needs a heat map.")).toHaveClass("why");
|
||||||
|
expect(screen.getByRole("heading", { name: "L999001 · WELD-PART" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to release");
|
||||||
|
expect(screen.queryByText("View only.")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables Send to warehouse when can includes it, and posts the transition with expect", async () => {
|
||||||
|
const ready = makePacket({ heat_map: true, can: ["edit_header", "send_to_warehouse"], blocked: {} });
|
||||||
|
const fetch = mockFetch({
|
||||||
|
...base(ready),
|
||||||
|
"POST /api/packets/1/transitions": { body: makePacket({ status: "warehouse", version: 4, can: [], blocked: {}, holder: "Warehouse" }) },
|
||||||
|
});
|
||||||
|
renderApp("/packets/1");
|
||||||
|
const send = await screen.findByRole("button", { name: "Send to warehouse" });
|
||||||
|
expect(send).toBeEnabled();
|
||||||
|
expect(screen.queryByText("Say whether this packet needs a heat map.")).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(send);
|
||||||
|
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"));
|
||||||
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" } }]);
|
||||||
|
expect(screen.getByText("View only.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Send to warehouse" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("heat map chips PATCH with the base value", async () => {
|
||||||
|
const fetch = mockFetch({
|
||||||
|
...base(),
|
||||||
|
"PATCH /api/packets/1": { body: makePacket({ heat_map: true, version: 4 }) },
|
||||||
|
});
|
||||||
|
renderApp("/packets/1");
|
||||||
|
const group = await screen.findByRole("group", { name: "Heat map required?" });
|
||||||
|
fireEvent.click(within(group).getByRole("button", { name: "Yes" }));
|
||||||
|
await waitFor(() => expect(within(group).getByRole("button", { name: "Yes" })).toHaveAttribute("aria-pressed", "true"));
|
||||||
|
expect(callsTo(fetch, "PATCH", "/api/packets/1")).toEqual([{ set: { heat_map: true }, base: { heat_map: null } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaching a drawing uploads it, then signs and sends sign", async () => {
|
||||||
|
const fetch = mockFetch({
|
||||||
|
...base(),
|
||||||
|
"POST /api/assets": {
|
||||||
|
status: 201,
|
||||||
|
body: { id: "a1b2", mime: "application/pdf", size: 900, sha256: "00", pages: 1, original_name: "d.pdf", url: "/_blob/a1b2" },
|
||||||
|
},
|
||||||
|
"POST /api/packets/1/pages": { body: makePacket({ version: 4, drawing_state: "hand" }) },
|
||||||
|
});
|
||||||
|
renderApp("/packets/1");
|
||||||
|
const input = await screen.findByLabelText("Attach drawing (PDF)");
|
||||||
|
const file = new File(["%PDF-1.4\n"], "d.pdf", { type: "application/pdf" });
|
||||||
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "Sign" });
|
||||||
|
expect(within(sheet).getByText("Attach a drawing by hand")).toBeInTheDocument();
|
||||||
|
expect(within(sheet).getByText(/You are signing as/)).toHaveTextContent("You are signing as Clara Clerk.");
|
||||||
|
expect(callsTo(fetch, "POST", "/api/assets")).toHaveLength(1);
|
||||||
|
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "pw-123" } });
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
|
||||||
|
expect(callsTo(fetch, "POST", "/api/packets/1/pages")).toEqual([
|
||||||
|
{ asset_id: "a1b2", type: "drawing", dwg: "CI-DWG-1", rev: "0", sign: { factor: "password", secret: "pw-123" } },
|
||||||
|
]);
|
||||||
|
expect(await screen.findByText("Drawing attached (1 page).")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a wrong signature keeps the sheet open with the attempts left", async () => {
|
||||||
|
mockFetch({
|
||||||
|
...base(),
|
||||||
|
"POST /api/assets": { status: 201, body: { id: "a1", mime: "application/pdf", size: 9, sha256: "00", pages: 2, original_name: "d.pdf", url: "/_blob/a1" } },
|
||||||
|
"POST /api/packets/1/pages": { status: 403, body: { detail: "Wrong password.", code: "wrong_secret", attempts_left: 2 } },
|
||||||
|
});
|
||||||
|
renderApp("/packets/1");
|
||||||
|
fireEvent.change(await screen.findByLabelText("Attach drawing (PDF)"), { target: { files: [new File(["%PDF"], "d.pdf")] } });
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "Sign" });
|
||||||
|
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "nope" } });
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
|
||||||
|
expect(await within(sheet).findByText("Wrong password.")).toBeInTheDocument();
|
||||||
|
expect(within(sheet).getByText("2 attempts left.")).toBeInTheDocument();
|
||||||
|
expect(within(sheet).getByText("Three wrong attempts end the session.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stale step shows the server message and reloads the packet", async () => {
|
||||||
|
let gets = 0;
|
||||||
|
const fetch = mockFetch({
|
||||||
|
...base(),
|
||||||
|
"/api/packets/1": () => {
|
||||||
|
gets++;
|
||||||
|
return gets === 1
|
||||||
|
? { body: makePacket({ heat_map: true, can: ["send_to_warehouse", "edit_header"], blocked: {} }) }
|
||||||
|
: { body: makePacket({ status: "warehouse", heat_map: true, can: [], blocked: {}, holder: "Warehouse" }) };
|
||||||
|
},
|
||||||
|
"POST /api/packets/1/transitions": {
|
||||||
|
status: 409,
|
||||||
|
body: { detail: "Someone else already took this step.", code: "stale_step", current: { status: "warehouse", dept: "03", version: 5 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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.");
|
||||||
|
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
|
||||||
|
expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks an admin for the IT reason and retries with it", async () => {
|
||||||
|
const admin = makeMe({ roles: ["admin"], screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"] });
|
||||||
|
let tries = 0;
|
||||||
|
const fetch = mockFetch({
|
||||||
|
...base(makePacket({ heat_map: true, can: ["send_to_warehouse"], blocked: {} })),
|
||||||
|
"/api/auth/me": { body: admin },
|
||||||
|
"POST /api/packets/1/transitions": () =>
|
||||||
|
++tries === 1
|
||||||
|
? { status: 400, body: { detail: "Give an IT reason.", code: "it_reason_required" } }
|
||||||
|
: { body: makePacket({ status: "warehouse", can: [], blocked: {} }) },
|
||||||
|
});
|
||||||
|
renderApp("/packets/1");
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" }));
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "IT reason" });
|
||||||
|
fireEvent.change(within(sheet).getByLabelText("Why IT is making this change"), { target: { value: "Clerk is out sick today" } });
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Continue" }));
|
||||||
|
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"));
|
||||||
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||||
|
{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" } },
|
||||||
|
{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" }, it_reason: "Clerk is out sick today" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows View only with the holder when the user has no actions", async () => {
|
||||||
|
mockFetch({
|
||||||
|
...base(makePacket({ can: [], blocked: {}, holder: "Clerical" })),
|
||||||
|
"/api/auth/me": { body: makeMe({ roles: ["warehouse"], screens: ["warehouse"] }) },
|
||||||
|
});
|
||||||
|
renderApp("/packets/1");
|
||||||
|
const note = (await screen.findByText("View only.")).closest(".viewonly") as HTMLElement;
|
||||||
|
expect(note).toHaveTextContent("This packet is with Clerical.");
|
||||||
|
expect(screen.queryByRole("button", { name: /Send/ })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("group", { name: "Heat map required?" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads when a changed event names this packet", async () => {
|
||||||
|
const sources: Array<{ emit: (t: string, d: unknown) => void }> = [];
|
||||||
|
class FakeEventSource {
|
||||||
|
listeners = new Map<string, (ev: MessageEvent) => void>();
|
||||||
|
constructor() {
|
||||||
|
sources.push(this);
|
||||||
|
}
|
||||||
|
addEventListener(type: string, fn: (ev: MessageEvent) => void) {
|
||||||
|
this.listeners.set(type, fn);
|
||||||
|
}
|
||||||
|
close() {}
|
||||||
|
emit(type: string, data: unknown) {
|
||||||
|
this.listeners.get(type)?.(new MessageEvent(type, { data: JSON.stringify(data) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.stubGlobal("EventSource", FakeEventSource);
|
||||||
|
const fetch = mockFetch(base());
|
||||||
|
renderApp("/packets/1");
|
||||||
|
await screen.findByRole("heading", { name: "L999001 · WELD-PART" });
|
||||||
|
expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(1);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 2, version: 9 }));
|
||||||
|
act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 1, version: 9 }));
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
|
import { assetsApi } from "../api/assets";
|
||||||
|
import { ApiError } 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 { useGuard } from "../components/useGuard";
|
||||||
|
import { useChanged } from "../lib/changes";
|
||||||
|
import { errorText } from "../lib/format";
|
||||||
|
import { dueInfo, flagClass, jobName, pillClass, statusLabel, targetHours } from "../lib/packets";
|
||||||
|
import { DrawingViewer } from "./DrawingViewer";
|
||||||
|
import { MoveSheet, SendToDeptSheet } from "./sheets";
|
||||||
|
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
|
||||||
|
|
||||||
|
type Tab = "drawing" | "materials" | "workorders" | "docs" | "history";
|
||||||
|
|
||||||
|
/** The rule's signature meaning for attaching a drawing by hand. */
|
||||||
|
const SIGN_ATTACH = "Attach a drawing by hand";
|
||||||
|
const SIGN_FILE = "File in the data book";
|
||||||
|
|
||||||
|
/** One step button. Hidden when the user can neither do it nor is blocked from it. Blocked shows the reason under it. */
|
||||||
|
function Act({
|
||||||
|
p,
|
||||||
|
action,
|
||||||
|
busy,
|
||||||
|
onClick,
|
||||||
|
className = "btn",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
p: PacketRead;
|
||||||
|
action: string;
|
||||||
|
busy: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const can = p.can.includes(action);
|
||||||
|
const why = p.blocked[action];
|
||||||
|
if (!can && !why) return null;
|
||||||
|
const reason = why?.join(" ") ?? "";
|
||||||
|
return (
|
||||||
|
<div className="act">
|
||||||
|
<button className={className} type="button" disabled={!can || busy} title={!can ? reason : undefined} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
{!can && reason ? <div className="why small">{reason}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Title, customer, WPS, drawing and rev, edited in place with a field-level base. */
|
||||||
|
function HeaderEdit({ p, mutate, busy, onDone }: { p: PacketRead; mutate: Mutate; busy: boolean; onDone: () => void }) {
|
||||||
|
const head = p.can.includes("edit_header");
|
||||||
|
const dwg = p.can.includes("edit_drawing_ref");
|
||||||
|
const [v, setV] = useState({ title: p.title ?? "", customer: p.customer ?? "", wps: p.wps ?? "", drawing: p.drawing ?? "", rev: p.rev ?? "" });
|
||||||
|
const field = (k: keyof typeof v, label: string, max: number, on: boolean) =>
|
||||||
|
on ? (
|
||||||
|
<label className="f">
|
||||||
|
{label}
|
||||||
|
<input className="in" value={v[k]} maxLength={max} onChange={(e) => setV((o) => ({ ...o, [k]: e.target.value }))} />
|
||||||
|
</label>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const save = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const set: Partial<PacketHeader> = {};
|
||||||
|
const base: Partial<PacketHeader> = {};
|
||||||
|
for (const k of Object.keys(v) as Array<keyof typeof v>) {
|
||||||
|
const now = v[k].trim() || null;
|
||||||
|
const was = p[k] ?? null;
|
||||||
|
if (now !== was) {
|
||||||
|
set[k] = now;
|
||||||
|
base[k] = was;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(set).length === 0) {
|
||||||
|
onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await mutate((g) => packetsApi.patch(p.id, { set, base, ...g }), { ok: "Saved" })) onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="panel stack" onSubmit={save} noValidate aria-label="Edit details">
|
||||||
|
<div className="frow">
|
||||||
|
{field("title", "Title", 200, head)}
|
||||||
|
{field("customer", "Customer", 120, head)}
|
||||||
|
{field("wps", "WPS", 40, head)}
|
||||||
|
{field("drawing", "Drawing no.", 60, dwg)}
|
||||||
|
{field("rev", "Rev", 10, dwg)}
|
||||||
|
</div>
|
||||||
|
<div className="sheet-acts start">
|
||||||
|
<button className="btn pri" type="submit" disabled={busy}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button className="btn ghost" type="button" onClick={onDone}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /packets/:id: the packet for every signed-in role. Controls come from `can` and `blocked`. */
|
||||||
|
export function PacketPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = Number(params.id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { me } = useAuth();
|
||||||
|
const { run, askReason, sheet: guardSheet } = useGuard();
|
||||||
|
const [packet, setPacket] = useState<PacketRead | null>(null);
|
||||||
|
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 [sheet, setSheet] = useState<"dept" | "move" | null>(null);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const versionRef = useRef<number | null>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const p = await packetsApi.get(id);
|
||||||
|
setPacket(p);
|
||||||
|
setLoadError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setLoadError(errorText(err));
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPacket(null);
|
||||||
|
setTab(null);
|
||||||
|
void reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
versionRef.current = packet?.version ?? null;
|
||||||
|
}, [packet]);
|
||||||
|
|
||||||
|
// Live refresh: another screen changed this packet.
|
||||||
|
useChanged(() => void reload(), {
|
||||||
|
delayMs: 500,
|
||||||
|
filter: (ev) => Number(ev.packet_id) === id && (ev.version === undefined || ev.version !== versionRef.current),
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = packet ? jobName(packet) : "";
|
||||||
|
|
||||||
|
const mutate: Mutate = useCallback(
|
||||||
|
async (fn, opts = {}) => {
|
||||||
|
setBusy(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const r = await run(fn, { sign: opts.sign, sub: job });
|
||||||
|
if (r === undefined) return false;
|
||||||
|
if (r && typeof r === "object" && "id" in r) setPacket(r);
|
||||||
|
else await reload();
|
||||||
|
if (opts.ok) setMsg({ ok: true, text: opts.ok });
|
||||||
|
return true;
|
||||||
|
} 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) });
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[run, reload, job],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!packet) {
|
||||||
|
return loadError ? <div className="callout warn">{loadError}</div> : <div className="empty slim">Loading</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = packet;
|
||||||
|
const can = (a: string) => p.can.includes(a);
|
||||||
|
const draft = p.status === "draft";
|
||||||
|
const transition = (action: string, extra: Omit<TransitionBody, "action" | "expect"> = {}, ok?: string) =>
|
||||||
|
mutate((g) => packetsApi.transition(p.id, { action, expect: { status: p.status, dept: p.dept.code }, ...extra, ...g }), { ok });
|
||||||
|
|
||||||
|
const setHeatMap = (v: boolean) =>
|
||||||
|
mutate((g) => packetsApi.patch(p.id, { set: { heat_map: v }, base: { heat_map: p.heat_map }, ...g }));
|
||||||
|
|
||||||
|
const attach = async (file: File) => {
|
||||||
|
setBusy(true);
|
||||||
|
setMsg(null);
|
||||||
|
let asset;
|
||||||
|
try {
|
||||||
|
asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "drawing" });
|
||||||
|
} catch (err) {
|
||||||
|
setMsg({ ok: false, text: errorText(err) });
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
if (asset.mime !== "application/pdf") {
|
||||||
|
setMsg({ ok: false, text: "The drawing must be a PDF." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pages = asset.pages ?? 0;
|
||||||
|
await mutate(
|
||||||
|
(g) =>
|
||||||
|
packetsApi.attachPages(p.id, {
|
||||||
|
asset_id: asset.id,
|
||||||
|
type: "drawing",
|
||||||
|
...(p.drawing ? { dwg: p.drawing } : {}),
|
||||||
|
...(p.rev ? { rev: p.rev } : {}),
|
||||||
|
...g,
|
||||||
|
}),
|
||||||
|
{ sign: SIGN_ATTACH, ok: pages ? `Drawing attached (${pages} page${pages === 1 ? "" : "s"}).` : "Drawing attached." },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendToQc = async () => {
|
||||||
|
const note = await askReason({
|
||||||
|
title: "Send straight to Q.C.",
|
||||||
|
sub: job,
|
||||||
|
label: "Note (optional, Q.C. will see it)",
|
||||||
|
note: "Skips the warehouse and the shop. Q.C. gets it for inspection.",
|
||||||
|
min: 0,
|
||||||
|
submitLabel: "Send to Q.C.",
|
||||||
|
});
|
||||||
|
if (note === null) return;
|
||||||
|
await transition("send_draft_to_qc", note ? { note } : {}, "Sent to Q.C.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
const reason = await askReason({
|
||||||
|
title: "Remove packet",
|
||||||
|
sub: job,
|
||||||
|
note: "The packet leaves every board. It stays in the record with your reason.",
|
||||||
|
min: 8,
|
||||||
|
danger: true,
|
||||||
|
submitLabel: "Remove packet",
|
||||||
|
});
|
||||||
|
if (reason === null) return;
|
||||||
|
setBusy(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const r = await run((g) => packetsApi.remove(p.id, { reason, ...g }), { sub: job });
|
||||||
|
if (r !== undefined) navigate(`/clerical/units/${p.unit.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409) await reload();
|
||||||
|
setMsg({ ok: false, text: errorText(err) });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDrawing = async (n: number) => {
|
||||||
|
await mutate((g) => packetsApi.removePages(p.id, n, g), { ok: "Drawing removed." });
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileIt = () => mutate((g) => packetsApi.transition(p.id, { action: "file", expect: { status: p.status, dept: p.dept.code }, ...g }), { sign: SIGN_FILE, ok: "Filed in the data book." });
|
||||||
|
|
||||||
|
const drawingPages = p.pages.filter((x) => x.type === "drawing");
|
||||||
|
const shownTab: Tab = 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 =
|
||||||
|
p.can.every((a) => passive.has(a)) && Object.keys(p.blocked).every((a) => passive.has(a));
|
||||||
|
const project = p.unit.project;
|
||||||
|
const di = dueInfo(p.unit.due_effective, project.status === "closed");
|
||||||
|
const target = targetHours(p);
|
||||||
|
const lotQty = Math.max(0, ...p.work_orders.map((w) => Number(w.qty) || 0));
|
||||||
|
const backTo = me?.screens.includes("clerical") ? `/clerical/units/${p.unit.id}` : homePath(me?.screens ?? []);
|
||||||
|
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 tabs: Array<{ id: Tab; label: string; count?: number }> = [
|
||||||
|
{ id: "drawing", label: "Drawing", count: drawingPages.length },
|
||||||
|
{ id: "materials", label: "Materials", count: p.bom.length },
|
||||||
|
{ id: "workorders", label: "Work orders", count: p.work_orders.length },
|
||||||
|
{ id: "docs", label: "Documents", count: p.docs.length },
|
||||||
|
{ id: "history", label: "History" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link className="back" to={backTo}>
|
||||||
|
← Back
|
||||||
|
</Link>
|
||||||
|
<div className="dhead">
|
||||||
|
<div className="grow0">
|
||||||
|
<div className="eyebrow">
|
||||||
|
{p.op ?? ""} · {statusLabel(p.status)}
|
||||||
|
</div>
|
||||||
|
<h1>{job}</h1>
|
||||||
|
{p.title ? <div className="dtitle">{p.title}</div> : null}
|
||||||
|
<div className="small crumbs2">
|
||||||
|
Project <b>{project.number}</b> {project.name ?? ""} <span className="muted">›</span> Unit <b>{p.unit.number}</b> {p.unit.name ?? ""}
|
||||||
|
{project.po ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
· PO <span className="mono">{project.po}</span>
|
||||||
|
</>
|
||||||
|
) : null}{" "}
|
||||||
|
· <span className={di.cls}>{di.txt}</span>
|
||||||
|
</div>
|
||||||
|
<div className="facts">
|
||||||
|
{lotQty ? (
|
||||||
|
<div>
|
||||||
|
<span>Lot qty </span>
|
||||||
|
<b>{lotQty}</b>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<span>Customer </span>
|
||||||
|
<b>{p.customer || "-"}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Drawing </span>
|
||||||
|
<b>{p.drawing ? `${p.drawing} R${p.rev || "-"}` : "-"}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>WPS </span>
|
||||||
|
<b>{p.wps || "-"}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Department </span>
|
||||||
|
<b>
|
||||||
|
{p.dept.code} {p.dept.name}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Target </span>
|
||||||
|
<b>{target.toFixed(1)} h</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Heat map </span>
|
||||||
|
<b>{p.heat_map === true ? "Yes" : p.heat_map === false ? "Not required" : "Not answered"}</b>
|
||||||
|
</div>
|
||||||
|
{p.for_sup ? (
|
||||||
|
<div>
|
||||||
|
<span>For </span>
|
||||||
|
<b>{p.for_sup.display_name}</b>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{p.station ? (
|
||||||
|
<div>
|
||||||
|
<span>Station </span>
|
||||||
|
<b>{p.station.name}</b>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{p.welder ? (
|
||||||
|
<div>
|
||||||
|
<span>Welder </span>
|
||||||
|
<b>
|
||||||
|
{p.welder.display_name}
|
||||||
|
{p.welder.stamp ? ` [${p.welder.stamp}]` : ""}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pacts">
|
||||||
|
<span className={pillClass(p.status)} data-testid="status-pill">
|
||||||
|
{statusLabel(p.status)}
|
||||||
|
</span>
|
||||||
|
{can("edit_header") || can("edit_drawing_ref") ? (
|
||||||
|
<button className="btn sm" type="button" onClick={() => setEditing((e) => !e)} aria-expanded={editing}>
|
||||||
|
Edit details
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{can("move_unit") ? (
|
||||||
|
<button className="btn sm" type="button" disabled={busy} onClick={() => setSheet("move")}>
|
||||||
|
Move
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.flag ? <div className={`small flagline ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
|
||||||
|
{p.removed ? (
|
||||||
|
<div className="callout warn">
|
||||||
|
<b>Removed</b>
|
||||||
|
{p.removed.by ? ` by ${p.removed.by.display_name}` : ""}. {p.removed.reason ?? ""}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{p.return_note ? (
|
||||||
|
<div className="callout warn">
|
||||||
|
<b>Returned:</b> {p.return_note}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{viewOnly && !p.removed ? (
|
||||||
|
<div className="viewonly">
|
||||||
|
<span aria-hidden="true">●</span>
|
||||||
|
<div>
|
||||||
|
<b>View only.</b> This packet is with <b>{p.holder}</b>. You can follow its progress. Changes open up only when it is sent to you or sent back.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{msg ? (
|
||||||
|
<div className={msg.ok ? "callout" : "callout warn"} role={msg.ok ? "status" : "alert"}>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editing ? <HeaderEdit key={p.version} p={p} mutate={mutate} busy={busy} onDone={() => setEditing(false)} /> : null}
|
||||||
|
|
||||||
|
{hasDraftControls ? (
|
||||||
|
<section className="panel actbox" aria-label="Draft steps">
|
||||||
|
<div>
|
||||||
|
<b>Ready to release.</b> Check the BOM and work orders, attach the drawing, and say whether it needs a heat map. Then send it to the warehouse for the
|
||||||
|
parts pull, or straight to a department or Q.C.
|
||||||
|
</div>
|
||||||
|
{can("edit_header") ? (
|
||||||
|
<div className="hmq" role="group" aria-label="Heat map required?">
|
||||||
|
<b>Heat map required?</b>
|
||||||
|
<button type="button" className="chip sm" aria-pressed={p.heat_map === true} disabled={busy} onClick={() => void setHeatMap(true)}>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button type="button" className="chip sm" aria-pressed={p.heat_map === false} disabled={busy} onClick={() => void setHeatMap(false)}>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
{p.heat_map === null ? <span className="due soon small">choose before sending</span> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="acts">
|
||||||
|
{can("edit_document_set") ? (
|
||||||
|
<div className="act">
|
||||||
|
<label className={busy ? "btn dim" : "btn"}>
|
||||||
|
{drawingPages.length ? "Attach another drawing" : "Attach drawing"}
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
className="sr"
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
aria-label="Attach drawing (PDF)"
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void attach(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Act p={p} action="send_to_warehouse" busy={busy} className="btn pri" onClick={() => void transition("send_to_warehouse", {}, "Sent to the warehouse.")}>
|
||||||
|
Send to warehouse
|
||||||
|
</Act>
|
||||||
|
<Act p={p} action="release_to_shop" busy={busy} onClick={() => setSheet("dept")}>
|
||||||
|
Send to a department
|
||||||
|
</Act>
|
||||||
|
<Act p={p} action="send_draft_to_qc" busy={busy} onClick={() => void sendToQc()}>
|
||||||
|
Send to Q.C.
|
||||||
|
</Act>
|
||||||
|
<Act p={p} action="remove" busy={busy} className="btn rejb" onClick={() => void remove()}>
|
||||||
|
Remove
|
||||||
|
</Act>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!draft && (can("set_vclosed") || can("file") || p.blocked.file) ? (
|
||||||
|
<section className="panel actbox" aria-label="Close and file">
|
||||||
|
<div>
|
||||||
|
{p.vclosed ? (
|
||||||
|
<>
|
||||||
|
<b>Ready for the data book.</b> Job closed in VISUAL by {p.vclosed.by?.display_name ?? "Clerical"}. Mark it filed when the data book is done.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<b>Back from the shop, ready to close.</b> Close the job in VISUAL, then click below. The packet moves on to the data book.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="acts">
|
||||||
|
{!p.vclosed ? (
|
||||||
|
<Act p={p} action="set_vclosed" busy={busy} className="btn pri" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}>
|
||||||
|
Job closed in VISUAL
|
||||||
|
</Act>
|
||||||
|
) : (
|
||||||
|
<Act p={p} action="set_vclosed" busy={busy} className="btn ghost" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, false, g))}>
|
||||||
|
Not closed in VISUAL, move back
|
||||||
|
</Act>
|
||||||
|
)}
|
||||||
|
<Act p={p} action="file" busy={busy} onClick={() => void fileIt()}>
|
||||||
|
Mark filed
|
||||||
|
</Act>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="tabs" role="tablist" aria-label="Packet">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`ptab-${t.id}`}
|
||||||
|
aria-selected={shownTab === t.id}
|
||||||
|
aria-controls={`ppanel-${t.id}`}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{t.count !== undefined ? <span className="cnt">{t.count}</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div role="tabpanel" id={`ppanel-${shownTab}`} aria-labelledby={`ptab-${shownTab}`}>
|
||||||
|
{shownTab === "drawing" ? (
|
||||||
|
<div className="stack">
|
||||||
|
{draft && can("edit_document_set") && drawingPages.length ? (
|
||||||
|
<div className="sec-h flush">
|
||||||
|
<span className="small muted">
|
||||||
|
{drawingPages.length} drawing page{drawingPages.length === 1 ? "" : "s"} attached by hand.
|
||||||
|
</span>
|
||||||
|
<span className="grow" />
|
||||||
|
<button className="btn sm ghost" type="button" disabled={busy} onClick={() => void removeDrawing(drawingPages[0]?.n ?? 1)}>
|
||||||
|
Remove drawing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DrawingViewer pages={drawingPages} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} /> : null}
|
||||||
|
{shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null}
|
||||||
|
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} /> : null}
|
||||||
|
{shownTab === "history" ? <HistoryTab p={p} /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sheet === "dept" ? (
|
||||||
|
<SendToDeptSheet
|
||||||
|
p={p}
|
||||||
|
sub={job}
|
||||||
|
onCancel={() => setSheet(null)}
|
||||||
|
onSend={(dept, note) => {
|
||||||
|
setSheet(null);
|
||||||
|
void transition("release_to_shop", { dept, ...(note ? { note } : {}) }, "Sent to the department.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{sheet === "move" ? (
|
||||||
|
<MoveSheet
|
||||||
|
p={p}
|
||||||
|
sub={job}
|
||||||
|
onCancel={() => setSheet(null)}
|
||||||
|
onMove={(unitId, reason) => {
|
||||||
|
setSheet(null);
|
||||||
|
void mutate((g) => packetsApi.move(p.id, { unit_id: unitId, ...(reason ? { reason } : {}), ...g }), { ok: "Packet moved." });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{guardSheet}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import type { PacketRead } from "../api/types";
|
||||||
|
import { PickUnit, type UnitPick } from "../components/PickUnit";
|
||||||
|
import { Sheet } from "../components/Sheet";
|
||||||
|
import { useShopDepartments } from "../lib/useDepartments";
|
||||||
|
|
||||||
|
/** Clerical: send a draft straight to a shop department. Skips the warehouse parts pull. */
|
||||||
|
export function SendToDeptSheet({
|
||||||
|
p,
|
||||||
|
sub,
|
||||||
|
onSend,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
p: PacketRead;
|
||||||
|
sub: string;
|
||||||
|
onSend: (dept: string, note: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const depts = useShopDepartments();
|
||||||
|
const [dept, setDept] = useState(p.dept.code);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const value = depts.some((d) => d.code === dept) ? dept : (depts[0]?.code ?? "");
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (value) onSend(value, note.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet title="Send straight to a department" sub={sub} onClose={onCancel}>
|
||||||
|
<div className="small">Skips the warehouse parts pull. The department's lead or supervisor gets it ready to assign.</div>
|
||||||
|
<form className="stack" onSubmit={submit} noValidate>
|
||||||
|
<label className="f">
|
||||||
|
Department
|
||||||
|
<select className="in" value={value} onChange={(e) => setDept(e.target.value)}>
|
||||||
|
{depts.map((d) => (
|
||||||
|
<option key={d.code} value={d.code}>
|
||||||
|
{d.code} {d.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Note (optional, they will see it)
|
||||||
|
<input className="in" value={note} maxLength={500} placeholder="e.g. parts already staged at the station" onChange={(e) => setNote(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="sheet-acts">
|
||||||
|
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn pri" type="submit" disabled={!value}>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clerical: move a packet to another unit, through the two-step picker. */
|
||||||
|
export function MoveSheet({
|
||||||
|
p,
|
||||||
|
sub,
|
||||||
|
onMove,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
p: PacketRead;
|
||||||
|
sub: string;
|
||||||
|
onMove: (unitId: number, reason: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [pick, setPick] = useState<UnitPick>({
|
||||||
|
project: { id: p.unit.project.id, number: p.unit.project.number, name: p.unit.project.name, customer: p.unit.project.customer, po: p.unit.project.po, status: p.unit.project.status },
|
||||||
|
unit: null,
|
||||||
|
});
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!pick.unit) {
|
||||||
|
setError("Pick the unit to move it to.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pick.unit.id === p.unit.id) {
|
||||||
|
setError("The packet is in that unit already.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onMove(pick.unit.id, reason.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet title="Move to another unit" sub={sub} onClose={onCancel}>
|
||||||
|
<form className="stack" onSubmit={submit} noValidate>
|
||||||
|
<PickUnit value={pick} onChange={setPick} requireUnit />
|
||||||
|
<label className="f">
|
||||||
|
Reason (optional)
|
||||||
|
<input className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="small muted">The move is recorded in the packet history and the access log.</div>
|
||||||
|
{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">
|
||||||
|
Move packet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
import QRCode from "qrcode";
|
||||||
|
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||||
|
import { assetsApi } from "../api/assets";
|
||||||
|
import { packetsApi } from "../api/packets";
|
||||||
|
import type { BomLine, Guarded, PacketRead, WorkOrder } from "../api/types";
|
||||||
|
import { errorText, fmtTime } from "../lib/format";
|
||||||
|
import { useLoad } from "../lib/useLoad";
|
||||||
|
|
||||||
|
export type Mutate = (
|
||||||
|
fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
|
||||||
|
opts?: { sign?: string | undefined; ok?: string | undefined },
|
||||||
|
) => Promise<boolean>;
|
||||||
|
|
||||||
|
const show = (v: unknown) => (v === null || v === undefined || v === "" ? "-" : String(v));
|
||||||
|
|
||||||
|
/* ---------- Materials ---------- */
|
||||||
|
|
||||||
|
interface LineDraft {
|
||||||
|
qty: string;
|
||||||
|
part_id: string;
|
||||||
|
description: string;
|
||||||
|
material: string;
|
||||||
|
length: string;
|
||||||
|
dwg_ref: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDraft = (b: BomLine): LineDraft => ({
|
||||||
|
qty: b.qty === null || b.qty === undefined ? "" : String(b.qty),
|
||||||
|
part_id: b.part_id ?? "",
|
||||||
|
description: b.description ?? "",
|
||||||
|
material: b.material ?? "",
|
||||||
|
length: b.length ?? "",
|
||||||
|
dwg_ref: b.dwg_ref ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const EMPTY_LINE: LineDraft = { qty: "", part_id: "", description: "", material: "", length: "", dwg_ref: "" };
|
||||||
|
|
||||||
|
/** The BOM. Clerical may edit the lines of a hand draft (PUT /bom, full replace). */
|
||||||
|
export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) {
|
||||||
|
const editable = p.status === "draft" && p.source_kind === "hand" && p.can.includes("edit_bom");
|
||||||
|
const [lines, setLines] = useState<LineDraft[] | null>(null);
|
||||||
|
const set = (i: number, k: keyof LineDraft, v: string) => setLines((rows) => (rows ?? []).map((r, j) => (j === i ? { ...r, [k]: v } : r)));
|
||||||
|
|
||||||
|
const save = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!lines) return;
|
||||||
|
const bom: BomLine[] = lines
|
||||||
|
.filter((l) => l.part_id.trim() || l.description.trim())
|
||||||
|
.map((l, i) => ({
|
||||||
|
item: i + 1,
|
||||||
|
qty: l.qty.trim() === "" ? null : Number(l.qty),
|
||||||
|
part_id: l.part_id.trim() || null,
|
||||||
|
description: l.description.trim() || null,
|
||||||
|
material: l.material.trim() || null,
|
||||||
|
length: l.length.trim() || null,
|
||||||
|
dwg_ref: l.dwg_ref.trim() || null,
|
||||||
|
}));
|
||||||
|
const ok = await mutate((g) => packetsApi.putBom(p.id, p.version, bom, g), { ok: "BOM saved" });
|
||||||
|
if (ok) setLines(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lines) {
|
||||||
|
return (
|
||||||
|
<form className="stack" onSubmit={save} noValidate>
|
||||||
|
<div className="tw">
|
||||||
|
<table className="edit">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th className="num">Qty</th>
|
||||||
|
<th>Part ID</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Material</th>
|
||||||
|
<th>Length</th>
|
||||||
|
<th>Dwg ref</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lines.map((l, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="mono">{i + 1}</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm num" aria-label={`Line ${i + 1} quantity`} inputMode="decimal" value={l.qty} onChange={(e) => set(i, "qty", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm mono" aria-label={`Line ${i + 1} part ID`} maxLength={60} value={l.part_id} onChange={(e) => set(i, "part_id", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm" aria-label={`Line ${i + 1} description`} maxLength={240} value={l.description} onChange={(e) => set(i, "description", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm" aria-label={`Line ${i + 1} material`} maxLength={120} value={l.material} onChange={(e) => set(i, "material", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm" aria-label={`Line ${i + 1} length`} maxLength={40} value={l.length} onChange={(e) => set(i, "length", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm" aria-label={`Line ${i + 1} drawing reference`} maxLength={60} value={l.dwg_ref} onChange={(e) => set(i, "dwg_ref", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn sm ghost" type="button" aria-label={`Remove line ${i + 1}`} onClick={() => setLines((rows) => (rows ?? []).filter((_, j) => j !== i))}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="sheet-acts start">
|
||||||
|
<button className="btn sm" type="button" onClick={() => setLines((rows) => [...(rows ?? []), { ...EMPTY_LINE }])}>
|
||||||
|
Add line
|
||||||
|
</button>
|
||||||
|
<span className="grow" />
|
||||||
|
<button className="btn ghost" type="button" onClick={() => setLines(null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn pri" type="submit" disabled={busy}>
|
||||||
|
Save BOM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
{editable ? (
|
||||||
|
<div className="sec-h flush">
|
||||||
|
<span className="small muted">This packet was made by hand. Clerical can change its lines while it is a draft.</span>
|
||||||
|
<span className="grow" />
|
||||||
|
<button className="btn sm" type="button" onClick={() => setLines(p.bom.length ? p.bom.map(toDraft) : [{ ...EMPTY_LINE }])}>
|
||||||
|
Edit lines
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{p.bom.length ? (
|
||||||
|
<div className="tw">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th className="num">Qty</th>
|
||||||
|
<th>Part ID</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Material</th>
|
||||||
|
<th>Length</th>
|
||||||
|
<th>Heat</th>
|
||||||
|
<th>Pulled</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{p.bom.map((b) => (
|
||||||
|
<tr key={b.id ?? b.item} className={b.na ? "muted" : undefined}>
|
||||||
|
<td>
|
||||||
|
<span className="balloon">{b.item}</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">{show(b.qty)}</td>
|
||||||
|
<td className="mono small">{show(b.part_id)}</td>
|
||||||
|
<td>
|
||||||
|
{show(b.description)}
|
||||||
|
{b.dwg_ref ? <div className="sub">Dwg ref {b.dwg_ref}</div> : null}
|
||||||
|
</td>
|
||||||
|
<td className="small">{show(b.material)}</td>
|
||||||
|
<td className="small">{show(b.length)}</td>
|
||||||
|
<td className="mono small">{show(b.heat)}</td>
|
||||||
|
<td>
|
||||||
|
{b.na ? (
|
||||||
|
<span className="pill st-filed">N/A</span>
|
||||||
|
) : b.short ? (
|
||||||
|
<span className="pill st-review">{String(b.short.qty_open)} short</span>
|
||||||
|
) : b.pulled ? (
|
||||||
|
<span className="pill st-ok">Pulled</span>
|
||||||
|
) : (
|
||||||
|
<span className="muted small">Not yet</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty">No BOM lines on this packet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Work orders ---------- */
|
||||||
|
|
||||||
|
function woKey(w: Pick<WorkOrder, "wo_base" | "lot" | "split" | "sub">): string {
|
||||||
|
return `${w.wo_base}/${w.lot}/${w.split}/${w.sub}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Qr({ text }: { text: string }) {
|
||||||
|
const ref = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const c = ref.current;
|
||||||
|
if (!c) return;
|
||||||
|
let ok = true;
|
||||||
|
try {
|
||||||
|
QRCode.toCanvas(c, text, { width: 128, margin: 1, errorCorrectionLevel: "M" }).catch(() => {
|
||||||
|
if (ok) setFailed(true);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
ok = false;
|
||||||
|
};
|
||||||
|
}, [text]);
|
||||||
|
return (
|
||||||
|
<div className="code">
|
||||||
|
{failed ? <span className="small muted">No QR</span> : <canvas ref={ref} role="img" aria-label={`QR code ${text}`} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The work orders, and one QR code per work order key. */
|
||||||
|
export function WorkOrdersTab({ p }: { p: PacketRead }) {
|
||||||
|
if (!p.work_orders.length) return <div className="empty">No work orders on this packet.</div>;
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<div className="tw">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>WO</th>
|
||||||
|
<th>Lot</th>
|
||||||
|
<th>Split</th>
|
||||||
|
<th>Sub</th>
|
||||||
|
<th>Seq</th>
|
||||||
|
<th>Operation</th>
|
||||||
|
<th className="num">Qty</th>
|
||||||
|
<th className="num">Target hrs</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{p.work_orders.map((w) => (
|
||||||
|
<tr key={w.id ?? woKey(w)} className={w.gone_at ? "muted" : undefined}>
|
||||||
|
<td className="mono">{w.wo_base}</td>
|
||||||
|
<td className="mono">{w.lot}</td>
|
||||||
|
<td className="mono">{w.split}</td>
|
||||||
|
<td className="mono">{w.sub}</td>
|
||||||
|
<td className="mono">{show(w.seq)}</td>
|
||||||
|
<td>{show(w.op)}</td>
|
||||||
|
<td className="num">{show(w.qty)}</td>
|
||||||
|
<td className="num">{show(w.target_hrs)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="qrs">
|
||||||
|
{p.work_orders.map((w) => {
|
||||||
|
const k = woKey(w);
|
||||||
|
return (
|
||||||
|
<div key={w.id ?? k} className="qr">
|
||||||
|
<Qr text={k} />
|
||||||
|
<div>
|
||||||
|
<div className="wo">{w.wo_base}</div>
|
||||||
|
<div className="mono small">{k}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Documents ---------- */
|
||||||
|
|
||||||
|
const DOC_KINDS: ReadonlyArray<[string, string]> = [
|
||||||
|
["mtr", "Material test report"],
|
||||||
|
["nde", "NDE report"],
|
||||||
|
["procedure", "Procedure (WPS / PQR)"],
|
||||||
|
["photo", "Photo"],
|
||||||
|
["other", "Other"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const kindLabel = (k: string) => DOC_KINDS.find(([v]) => v === k)?.[1] ?? k;
|
||||||
|
|
||||||
|
/** Documents attached to the packet. Clerical and Q.C. add and remove them. */
|
||||||
|
export function DocumentsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) {
|
||||||
|
const canAdd = p.can.includes("doc_add");
|
||||||
|
const canRemove = p.can.includes("doc_remove");
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [kind, setKind] = useState("mtr");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [confirm, setConfirm] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const add = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!file) {
|
||||||
|
setError("Choose a file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "doc" });
|
||||||
|
const ok = await mutate((g) => packetsApi.addDoc(p.id, { asset_id: asset.id, kind, title: title.trim() || file.name, ...g }), { ok: "Document added" });
|
||||||
|
if (ok) {
|
||||||
|
setTitle("");
|
||||||
|
setFile(null);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
{p.docs.length ? (
|
||||||
|
<div className="tw">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Added</th>
|
||||||
|
<th>By</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{p.docs.map((d) => (
|
||||||
|
<tr key={d.id}>
|
||||||
|
<td>
|
||||||
|
<a className="lnk" href={d.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{d.title || "Untitled"}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="small">{kindLabel(d.kind)}</td>
|
||||||
|
<td className="small mono">{fmtTime(d.added_at)}</td>
|
||||||
|
<td className="small">{d.added_by?.display_name ?? "-"}</td>
|
||||||
|
<td className="nowrap">
|
||||||
|
{canRemove ? (
|
||||||
|
confirm === d.id ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn sm rejb"
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void mutate((g) => packetsApi.removeDoc(p.id, d.id, g), { ok: "Document removed" }).then(() => setConfirm(null))}
|
||||||
|
>
|
||||||
|
Confirm remove
|
||||||
|
</button>{" "}
|
||||||
|
<button className="btn sm ghost" type="button" onClick={() => setConfirm(null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="btn sm ghost" type="button" onClick={() => setConfirm(d.id)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty slim">No documents on this packet.</div>
|
||||||
|
)}
|
||||||
|
{canAdd ? (
|
||||||
|
<form className="panel stack" onSubmit={add} noValidate aria-labelledby="docadd-h">
|
||||||
|
<h3 id="docadd-h">Add a document</h3>
|
||||||
|
<div className="frow">
|
||||||
|
<label className="f">
|
||||||
|
File (PDF, PNG or JPEG)
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
className="in"
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,image/png,image/jpeg"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Kind
|
||||||
|
<select className="in" value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||||
|
{DOC_KINDS.map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Title
|
||||||
|
<input className="in" value={title} maxLength={160} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<div className="callout warn flush" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<button className="btn pri" type="submit" disabled={busy || uploading}>
|
||||||
|
{uploading ? "Uploading" : "Add document"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- History ---------- */
|
||||||
|
|
||||||
|
/** GET /log, newest first. IT edits and signatures are marked. */
|
||||||
|
export function HistoryTab({ p }: { p: PacketRead }) {
|
||||||
|
const [load] = useLoad((s) => packetsApi.log(p.id, { limit: 300 }, s), `${p.id}:${p.version}`);
|
||||||
|
if (load.state === "loading") return <div className="empty slim">Loading</div>;
|
||||||
|
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
|
||||||
|
const rows = load.data;
|
||||||
|
if (!rows.length) return <div className="empty slim">Nothing recorded yet.</div>;
|
||||||
|
return (
|
||||||
|
<div className="tw">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Who</th>
|
||||||
|
<th>What</th>
|
||||||
|
<th>Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className="small mono nowrap">{fmtTime(r.at)}</td>
|
||||||
|
<td className="small">
|
||||||
|
{r.by?.display_name ?? "System"}
|
||||||
|
{r.role ? <div className="sub">{r.role}</div> : null}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{r.action}
|
||||||
|
{r.signed ? <span className="pill st-ok">Signed</span> : null}
|
||||||
|
{r.it_reason ? <span className="pill st-warehouse">IT edit</span> : null}
|
||||||
|
</td>
|
||||||
|
<td className="small">
|
||||||
|
{r.note ?? ""}
|
||||||
|
{r.it_reason ? <div className="sub">IT reason: {r.it_reason}</div> : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import { Navigate, useParams } from "react-router";
|
import { Navigate, useParams } from "react-router";
|
||||||
import { homePath, isScreen, orderScreens } from "../auth/roles";
|
import { homePath, isScreen, orderScreens, type Screen } from "../auth/roles";
|
||||||
import { useAuth } from "../auth/useAuth";
|
import { useAuth } from "../auth/useAuth";
|
||||||
import { AdminPage } from "./admin/AdminPage";
|
import { AdminPage } from "./admin/AdminPage";
|
||||||
|
import { ClericalHome } from "./clerical/ClericalHome";
|
||||||
import { PlaceholderScreen } from "./PlaceholderScreen";
|
import { PlaceholderScreen } from "./PlaceholderScreen";
|
||||||
|
|
||||||
|
/** A page under a screen, like /clerical/projects/7. Users without the screen go to their first screen. */
|
||||||
|
export function RequireScreen({ screen, children }: { screen: Screen; children: ReactNode }) {
|
||||||
|
const { me } = useAuth();
|
||||||
|
if (!me) return null;
|
||||||
|
if (!orderScreens(me.screens).includes(screen)) return <Navigate to={homePath(me.screens)} replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
/** /:screen. An unknown screen, or one the user does not have, goes to the user's first screen. */
|
/** /:screen. An unknown screen, or one the user does not have, goes to the user's first screen. */
|
||||||
export function ScreenRoute() {
|
export function ScreenRoute() {
|
||||||
const { screen } = useParams();
|
const { screen } = useParams();
|
||||||
@@ -12,5 +22,6 @@ export function ScreenRoute() {
|
|||||||
const allowed = orderScreens(me.screens);
|
const allowed = orderScreens(me.screens);
|
||||||
if (!isScreen(screen) || !allowed.includes(screen)) return <Navigate to={homePath(me.screens)} replace />;
|
if (!isScreen(screen) || !allowed.includes(screen)) return <Navigate to={homePath(me.screens)} replace />;
|
||||||
if (screen === "admin") return <AdminPage />;
|
if (screen === "admin") return <AdminPage />;
|
||||||
|
if (screen === "clerical") return <ClericalHome />;
|
||||||
return <PlaceholderScreen key={screen} screen={screen} />;
|
return <PlaceholderScreen key={screen} screen={screen} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||||
|
import { callsTo, mockFetch } from "../../test/fetchMock";
|
||||||
|
import { makeCard, makeProject, makeUnit } from "../../test/packets";
|
||||||
|
|
||||||
|
const CARDS = [
|
||||||
|
makeCard({ id: 1, flag: "Made by hand - check it and send it on" }),
|
||||||
|
makeCard({ id: 2, unit: { id: 12, number: "L999002" }, op: "FIT-UP" }),
|
||||||
|
makeCard({ id: 3, status: "warehouse", holder: "Warehouse" }),
|
||||||
|
makeCard({ id: 4, status: "in_progress" }),
|
||||||
|
makeCard({ id: 5, status: "complete", routed_to: "databook", unit: { id: 13, number: "L999003" } }),
|
||||||
|
makeCard({ id: 6, status: "complete", routed_to: "databook", vclosed: { at: "2026-10-15T10:00:00Z", by: null }, unit: { id: 14, number: "L999004" } }),
|
||||||
|
makeCard({ id: 7, status: "filed", unit: { id: 15, number: "L999005" } }),
|
||||||
|
];
|
||||||
|
|
||||||
|
function base() {
|
||||||
|
return {
|
||||||
|
"/api/auth/me": { body: makeMe() },
|
||||||
|
"/api/departments": { body: DEPARTMENTS },
|
||||||
|
"/api/packets": { body: { packets: CARDS } },
|
||||||
|
"/api/projects": { body: [makeProject()] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function column(title: string): HTMLElement {
|
||||||
|
return screen.getByRole("heading", { name: new RegExp(`^${title}`) }).closest("section") as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ClericalHome", () => {
|
||||||
|
it("renders the boards and the numbers from the list answer", async () => {
|
||||||
|
mockFetch(base());
|
||||||
|
renderApp("/clerical");
|
||||||
|
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||||
|
await screen.findByRole("heading", { name: /^Flagged/ });
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Flagged: 1")).toHaveClass("kpi", "hot");
|
||||||
|
expect(screen.getByLabelText("With warehouse: 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("In the shop: 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Ready to close: 1")).toHaveClass("hot");
|
||||||
|
expect(screen.getByLabelText("Changes to approve: 0")).toHaveClass("zero");
|
||||||
|
|
||||||
|
expect(within(column("Changes to approve")).getByText("Arrives in phase 3 with the VISUAL feed.")).toBeInTheDocument();
|
||||||
|
const flagged = column("Flagged");
|
||||||
|
expect(within(flagged).getByText("L999001 · WELD-PART")).toBeInTheDocument();
|
||||||
|
expect(within(flagged).getByText(/Made by hand/)).toBeInTheDocument();
|
||||||
|
expect(within(column("Going to the warehouse")).getByText("L999002 · FIT-UP")).toBeInTheDocument();
|
||||||
|
expect(within(column("Ready to close")).getByText("L999003 · WELD-PART")).toBeInTheDocument();
|
||||||
|
expect(within(column("Ready for data book")).getByText("L999004 · WELD-PART")).toBeInTheDocument();
|
||||||
|
const filed = column("Filed");
|
||||||
|
expect(within(filed).getByText("L999005 · WELD-PART")).toBeInTheDocument();
|
||||||
|
expect(within(filed).getByText("Filed in data book")).toHaveClass("pill", "st-filed");
|
||||||
|
|
||||||
|
// The list view holds the same packets.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "List" }));
|
||||||
|
const table = await screen.findByRole("table");
|
||||||
|
expect(within(table).getAllByRole("row")).toHaveLength(1 + 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the picker loads the units after a project is chosen", async () => {
|
||||||
|
const fetch = mockFetch({
|
||||||
|
...base(),
|
||||||
|
"/api/units": { body: [makeUnit(), makeUnit({ id: 12, number: "L999002", lot: "002" })] },
|
||||||
|
});
|
||||||
|
renderApp("/clerical");
|
||||||
|
const bar = await screen.findByRole("region", { name: "Find jobs" });
|
||||||
|
expect(callsTo(fetch, "GET", "/api/units")).toHaveLength(0);
|
||||||
|
fireEvent.click(await within(bar).findByRole("button", { name: /L999/ }));
|
||||||
|
|
||||||
|
const unit = await within(bar).findByLabelText("Unit");
|
||||||
|
await within(unit).findByRole("option", { name: "L999002" });
|
||||||
|
const urls = fetch.mock.calls.map(([u]) => String(u));
|
||||||
|
expect(urls).toContain("/api/units?project_id=7");
|
||||||
|
// Choosing the project narrows the boards.
|
||||||
|
await waitFor(() => expect(urls.concat(fetch.mock.calls.map(([u]) => String(u)))).toContain("/api/packets?limit=500&project_id=7"));
|
||||||
|
|
||||||
|
fireEvent.change(unit, { target: { value: "12" } });
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&unit_id=12"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the new project sheet posts and then opens Add units", async () => {
|
||||||
|
const fetch = mockFetch({
|
||||||
|
...base(),
|
||||||
|
"POST /api/projects": { status: 201, body: makeProject({ id: 9, number: "L998", name: "New one", units: 0 }) },
|
||||||
|
"POST /api/projects/9/units": { status: 201, body: [makeUnit({ id: 21, project_id: 9, number: "L998001" })] },
|
||||||
|
"/api/projects/9": { body: makeProject({ id: 9, number: "L998", name: "New one", units: [makeUnit({ id: 21, project_id: 9, number: "L998001" })] }) },
|
||||||
|
});
|
||||||
|
renderApp("/clerical");
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "New project" }));
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "New project" });
|
||||||
|
fireEvent.change(within(sheet).getByLabelText("Project number"), { target: { value: "l998" } });
|
||||||
|
fireEvent.change(within(sheet).getByLabelText("Project name"), { target: { value: "New one" } });
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Create project" }));
|
||||||
|
|
||||||
|
const add = await screen.findByRole("dialog", { name: "Add units" });
|
||||||
|
expect(callsTo(fetch, "POST", "/api/projects")).toEqual([{ number: "L998", name: "New one", customer: "", po: "", due: null, notes: "" }]);
|
||||||
|
expect(within(add).getByLabelText("Lot")).toHaveValue("001");
|
||||||
|
|
||||||
|
// Several units: a preview of the numbers.
|
||||||
|
fireEvent.click(within(add).getByRole("button", { name: "Several units" }));
|
||||||
|
fireEvent.change(within(add).getByLabelText("How many"), { target: { value: "3" } });
|
||||||
|
expect(within(add).getByText("Numbers: L998001, L998002, L998003")).toBeInTheDocument();
|
||||||
|
fireEvent.click(within(add).getByRole("button", { name: "Add" }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: "L998 · New one" })).toBeInTheDocument();
|
||||||
|
expect(callsTo(fetch, "POST", "/api/projects/9/units")).toEqual([{ lots: ["001", "002", "003"], name: "", due: null, notes: "" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { packetsApi } from "../../api/packets";
|
||||||
|
import type { PacketCard } from "../../api/types";
|
||||||
|
import { useAuth } from "../../auth/useAuth";
|
||||||
|
import { Board } from "../../components/Board";
|
||||||
|
import { FindBar, type BoardView } from "../../components/FindBar";
|
||||||
|
import { Kpis } from "../../components/Kpis";
|
||||||
|
import type { UnitPick } from "../../components/PickUnit";
|
||||||
|
import { canEditProjects } from "../../lib/access";
|
||||||
|
import { useChanged } from "../../lib/changes";
|
||||||
|
import { fmtTime } from "../../lib/format";
|
||||||
|
import { STATUSES } from "../../api/types";
|
||||||
|
import { dueInfo, flagClass, jobName, pillClass, statusLabel } from "../../lib/packets";
|
||||||
|
import { useLoad } from "../../lib/useLoad";
|
||||||
|
import { byTime, clericalBoards } from "./boards";
|
||||||
|
import { ProjectsSection } from "./ProjectsSection";
|
||||||
|
import { NewPacketSheet } from "./sheets";
|
||||||
|
|
||||||
|
type SortKey = "updated" | "unit" | "due" | "status" | "dept";
|
||||||
|
|
||||||
|
const SORTS: ReadonlyArray<[SortKey, string]> = [
|
||||||
|
["updated", "Last updated"],
|
||||||
|
["unit", "Unit"],
|
||||||
|
["due", "Due date"],
|
||||||
|
["status", "Status"],
|
||||||
|
["dept", "Department"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function sorter(k: SortKey): (a: PacketCard, b: PacketCard) => number {
|
||||||
|
switch (k) {
|
||||||
|
case "unit":
|
||||||
|
return (a, b) => a.unit.number.localeCompare(b.unit.number, undefined, { numeric: true }) || (a.op ?? "").localeCompare(b.op ?? "");
|
||||||
|
case "due":
|
||||||
|
return (a, b) => (a.due_effective ?? "9999").localeCompare(b.due_effective ?? "9999");
|
||||||
|
case "status":
|
||||||
|
return (a, b) => STATUSES.indexOf(a.status) - STATUSES.indexOf(b.status);
|
||||||
|
case "dept":
|
||||||
|
return (a, b) => a.dept.code.localeCompare(b.dept.code);
|
||||||
|
default:
|
||||||
|
return (a, b) => byTime(b.updated_at, a.updated_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobList({ rows, sort, onSort }: { rows: PacketCard[]; sort: SortKey; onSort: (k: SortKey) => void }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const sorted = useMemo(() => [...rows].sort(sorter(sort)), [rows, sort]);
|
||||||
|
const onKey = (e: KeyboardEvent, id: number) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/packets/${id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<section className="sec" aria-labelledby="jobs-h">
|
||||||
|
<div className="sec-h">
|
||||||
|
<h2 id="jobs-h">Jobs</h2>
|
||||||
|
<span className="ct">
|
||||||
|
{sorted.length} packet{sorted.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<span className="grow" />
|
||||||
|
<label className="f">
|
||||||
|
Sort by
|
||||||
|
<select className="in" value={sort} onChange={(e) => onSort(e.target.value as SortKey)}>
|
||||||
|
{SORTS.map(([k, l]) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="tw">
|
||||||
|
<table className="joblist">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Job</th>
|
||||||
|
<th>Operation</th>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Drawing</th>
|
||||||
|
<th>Due</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th>Flags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((p) => {
|
||||||
|
const di = dueInfo(p.due_effective);
|
||||||
|
return (
|
||||||
|
<tr key={p.id} className="rowlink" tabIndex={0} onClick={() => navigate(`/packets/${p.id}`)} onKeyDown={(e) => onKey(e, p.id)}>
|
||||||
|
<td>
|
||||||
|
<b>{jobName(p)}</b>
|
||||||
|
<div className="sub">{p.project.name ?? ""}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{p.op ?? ""}
|
||||||
|
<div className="sub">{p.title ?? ""}</div>
|
||||||
|
</td>
|
||||||
|
<td className="small">{p.dept.name}</td>
|
||||||
|
<td>
|
||||||
|
<span className={pillClass(p.status)}>{statusLabel(p.status)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="mono small">
|
||||||
|
{p.drawing ?? ""} {p.rev ? `R${p.rev}` : ""}
|
||||||
|
</td>
|
||||||
|
<td className="small">
|
||||||
|
<span className={di.cls}>{di.txt.replace(/^Due /, "")}</span>
|
||||||
|
</td>
|
||||||
|
<td className="small mono">{fmtTime(p.updated_at)}</td>
|
||||||
|
<td>
|
||||||
|
{p.open_shorts ? <span className="pill st-review">{p.open_shorts} short</span> : null}
|
||||||
|
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="muted">
|
||||||
|
No packets match.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /clerical: numbers, attention row, find bar, boards or list, then projects. */
|
||||||
|
export function ClericalHome() {
|
||||||
|
const { me } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
|
||||||
|
const [view, setView] = useState<BoardView>("board");
|
||||||
|
const [sort, setSort] = useState<SortKey>("updated");
|
||||||
|
const [drafting, setDrafting] = useState(false);
|
||||||
|
const projectId = pick.project?.id;
|
||||||
|
const unitId = pick.unit?.id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setTimeout(() => setQuery(q.trim()), 250);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
const [load, reload] = useLoad(
|
||||||
|
(s) => packetsApi.list({ ...(query ? { q: query } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s),
|
||||||
|
`${query}|${projectId ?? ""}|${unitId ?? ""}`,
|
||||||
|
);
|
||||||
|
useChanged(reload, { delayMs: 500 });
|
||||||
|
|
||||||
|
const all = useMemo(() => (load.state === "ok" ? load.data : []), [load]);
|
||||||
|
const b = useMemo(() => clericalBoards(all), [all]);
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const ids = new Set<number>();
|
||||||
|
const out: PacketCard[] = [];
|
||||||
|
for (const c of b.columns) {
|
||||||
|
for (const p of c.cards) {
|
||||||
|
if (ids.has(p.id)) continue;
|
||||||
|
ids.add(p.id);
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [b]);
|
||||||
|
const editable = canEditProjects(me?.roles ?? []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="phead">
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow">Projects and job packets</div>
|
||||||
|
<h1>Clerical</h1>
|
||||||
|
</div>
|
||||||
|
{editable ? (
|
||||||
|
<div className="pacts">
|
||||||
|
<button className="btn pri" type="button" onClick={() => setDrafting(true)}>
|
||||||
|
New packet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Kpis
|
||||||
|
items={[
|
||||||
|
{ n: 0, label: "Changes to approve", alert: true },
|
||||||
|
{ n: b.flagged, label: "Flagged", alert: true },
|
||||||
|
{ n: b.warehouse, label: "With warehouse" },
|
||||||
|
{ n: b.shop, label: "In the shop" },
|
||||||
|
{ n: b.toClose, label: "Ready to close", alert: true },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{b.flagged || b.toClose ? (
|
||||||
|
<div className="attn" role="status">
|
||||||
|
{b.flagged ? (
|
||||||
|
<div className="callout warn flush">
|
||||||
|
<b>{b.flagged === 1 ? "1 draft is flagged." : `${b.flagged} drafts are flagged.`}</b> Check {b.flagged === 1 ? "it" : "them"} and send {b.flagged === 1 ? "it" : "them"} on.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{b.toClose ? (
|
||||||
|
<div className="callout flush">
|
||||||
|
<b>{b.toClose === 1 ? "1 packet is" : `${b.toClose} packets are`} back from the shop.</b> Close the job in VISUAL, then mark it closed on the packet.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} view={view} onView={setView} count={load.state === "ok" ? shown.length : null} />
|
||||||
|
|
||||||
|
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||||
|
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
||||||
|
{load.state === "ok" ? view === "board" ? <Board columns={b.columns} /> : <JobList rows={shown} sort={sort} onSort={setSort} /> : null}
|
||||||
|
|
||||||
|
<ProjectsSection />
|
||||||
|
|
||||||
|
{drafting ? (
|
||||||
|
<NewPacketSheet
|
||||||
|
initial={pick.unit ? pick : { project: pick.project, unit: null }}
|
||||||
|
onCancel={() => setDrafting(false)}
|
||||||
|
onCreated={(p) => {
|
||||||
|
setDrafting(false);
|
||||||
|
navigate(`/packets/${p.id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { useEffect, useState, type KeyboardEvent } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
|
import { projectsApi } from "../../api/projects";
|
||||||
|
import type { Project, Unit } from "../../api/types";
|
||||||
|
import { useAuth } from "../../auth/useAuth";
|
||||||
|
import { Kpis } from "../../components/Kpis";
|
||||||
|
import { useGuard } from "../../components/useGuard";
|
||||||
|
import { canEditProjects, canRenumber } from "../../lib/access";
|
||||||
|
import { useChanged } from "../../lib/changes";
|
||||||
|
import { dueInfo, PROJECT_STATUS_LABEL, stageCounts } from "../../lib/packets";
|
||||||
|
import { useLoad } from "../../lib/useLoad";
|
||||||
|
import { ProgressBar, ProjectPill } from "./common";
|
||||||
|
import { AddUnitsSheet, ProjectSheet, RenumberSheet } from "./sheets";
|
||||||
|
|
||||||
|
type UnitShow = "active" | "hold" | "closed" | "all";
|
||||||
|
|
||||||
|
const UNIT_SHOWS: ReadonlyArray<[UnitShow, string]> = [
|
||||||
|
["active", "Active"],
|
||||||
|
["hold", "On hold"],
|
||||||
|
["closed", "Closed"],
|
||||||
|
["all", "All"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function unitsOf(p: Project): Unit[] {
|
||||||
|
return Array.isArray(p.units) ? [...p.units].sort((a, b) => a.number.localeCompare(b.number, undefined, { numeric: true })) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumCounts(units: Unit[]) {
|
||||||
|
const c = stageCounts(null);
|
||||||
|
for (const u of units) {
|
||||||
|
const x = stageCounts(u.packets);
|
||||||
|
c.draft += x.draft;
|
||||||
|
c.warehouse += x.warehouse;
|
||||||
|
c.shop += x.shop;
|
||||||
|
c.qc += x.qc;
|
||||||
|
c.complete += x.complete;
|
||||||
|
c.filed += x.filed;
|
||||||
|
c.total += x.total;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnitTable({ project, units }: { project: Project; units: Unit[] }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [show, setShow] = useState<UnitShow>("active");
|
||||||
|
const t = q.trim().toLowerCase();
|
||||||
|
const rows = units.filter(
|
||||||
|
(u) =>
|
||||||
|
(show === "all" || (show === "active" ? u.status !== "closed" : u.status === show)) &&
|
||||||
|
(!t || [u.number, u.lot, u.name ?? "", u.notes ?? ""].join(" ").toLowerCase().includes(t)),
|
||||||
|
);
|
||||||
|
const onKey = (e: KeyboardEvent, id: number) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/clerical/units/${id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="unitbar">
|
||||||
|
<label className="f grow">
|
||||||
|
<span className="sr">Find a unit</span>
|
||||||
|
<input className="in" type="search" placeholder="Find a unit: number or description" autoComplete="off" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="chips" role="group" aria-label="Show units">
|
||||||
|
{UNIT_SHOWS.map(([k, l]) => (
|
||||||
|
<button key={k} type="button" className="chip sm" aria-pressed={show === k} onClick={() => setShow(k)}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="small muted">
|
||||||
|
{rows.length} of {units.length} unit{units.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="tw">
|
||||||
|
<table className="joblist unitlist">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Unit</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Due</th>
|
||||||
|
<th>Packets</th>
|
||||||
|
<th className="wide">Progress</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((u) => {
|
||||||
|
const c = stageCounts(u.packets);
|
||||||
|
const own = !!u.due;
|
||||||
|
const di = dueInfo(u.due_effective ?? project.due, u.status === "closed" || project.status === "closed");
|
||||||
|
return (
|
||||||
|
<tr key={u.id} className="rowlink" tabIndex={0} onClick={() => navigate(`/clerical/units/${u.id}`)} onKeyDown={(e) => onKey(e, u.id)}>
|
||||||
|
<td className="mono">
|
||||||
|
<b>{u.number}</b>
|
||||||
|
</td>
|
||||||
|
<td>{u.name || "-"}</td>
|
||||||
|
<td>
|
||||||
|
<ProjectPill status={u.status} />
|
||||||
|
</td>
|
||||||
|
<td className="small">
|
||||||
|
<span className={di.cls}>{di.txt.replace(/^Due /, "")}</span>
|
||||||
|
{own ? null : <span className="muted"> (project)</span>}
|
||||||
|
</td>
|
||||||
|
<td className="small">
|
||||||
|
<b>{c.total}</b>
|
||||||
|
{c.total ? <span className="muted"> · {c.filed} filed</span> : null}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ProgressBar c={c} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="muted">
|
||||||
|
No units match.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /clerical/projects/:projectId. */
|
||||||
|
export function ProjectPage() {
|
||||||
|
const { projectId } = useParams();
|
||||||
|
const id = Number(projectId);
|
||||||
|
const { me } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const guard = useGuard();
|
||||||
|
const [load, reload] = useLoad((s) => projectsApi.get(id, s), String(id));
|
||||||
|
const [sheet, setSheet] = useState<"edit" | "units" | "renumber" | null>(null);
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
const roles = me?.roles ?? [];
|
||||||
|
useChanged(reload, { delayMs: 500 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) return;
|
||||||
|
const t = window.setTimeout(() => setToast(null), 2600);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
const back = (
|
||||||
|
<Link className="back" to="/clerical">
|
||||||
|
← All projects
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
if (load.state === "loading") return <>{back}<div className="empty slim">Loading</div></>;
|
||||||
|
if (load.state === "error") return <>{back}<div className="callout warn">{load.message}</div></>;
|
||||||
|
|
||||||
|
const p = load.data;
|
||||||
|
const units = unitsOf(p);
|
||||||
|
const c = p.packets ? stageCounts(p.packets) : sumCounts(units);
|
||||||
|
const di = dueInfo(p.due, p.status === "closed");
|
||||||
|
const editable = canEditProjects(roles);
|
||||||
|
const aliases = (p.aliases ?? []).map((a) => (typeof a === "string" ? a : a.alias)).filter((a) => a && a !== p.number);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{back}
|
||||||
|
<div className="dhead">
|
||||||
|
<div className="grow0">
|
||||||
|
<div className="eyebrow">Project · {PROJECT_STATUS_LABEL[p.status]}</div>
|
||||||
|
<h1>
|
||||||
|
{p.number}
|
||||||
|
{p.name ? ` · ${p.name}` : ""}
|
||||||
|
</h1>
|
||||||
|
<div className="facts">
|
||||||
|
<div>
|
||||||
|
<span>Customer </span>
|
||||||
|
<b>{p.customer || "-"}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Customer PO </span>
|
||||||
|
<b>{p.po || "-"}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Due </span>
|
||||||
|
<b className={di.cls}>{di.txt.replace(/^Due /, "")}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Units </span>
|
||||||
|
<b>{units.length}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Packets </span>
|
||||||
|
<b>{c.total}</b>
|
||||||
|
</div>
|
||||||
|
{aliases.length ? (
|
||||||
|
<div>
|
||||||
|
<span>Old numbers </span>
|
||||||
|
<b>{aliases.join(", ")}</b>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{p.notes ? <div className="small pre">{p.notes}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="pacts">
|
||||||
|
<ProjectPill status={p.status} />
|
||||||
|
{editable ? (
|
||||||
|
<button className="btn sm" type="button" onClick={() => setSheet("edit")}>
|
||||||
|
Edit project
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canRenumber(roles) ? (
|
||||||
|
<button className="btn sm" type="button" onClick={() => setSheet("renumber")}>
|
||||||
|
Renumber
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Kpis
|
||||||
|
items={[
|
||||||
|
{ n: c.draft, label: "Ready to release" },
|
||||||
|
{ n: c.warehouse, label: "With warehouse" },
|
||||||
|
{ n: c.shop + c.qc, label: "In the shop / Q.C." },
|
||||||
|
{ n: c.complete, label: "Back for data book" },
|
||||||
|
{ n: c.filed, label: "Filed" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProgressBar c={c} />
|
||||||
|
<section className="sec" aria-labelledby="units-h">
|
||||||
|
<div className="sec-h">
|
||||||
|
<h2 id="units-h">Units</h2>
|
||||||
|
<span className="ct">each unit holds the packets for that piece of equipment</span>
|
||||||
|
<span className="grow" />
|
||||||
|
{editable && p.status !== "closed" ? (
|
||||||
|
<button className="btn pri" type="button" onClick={() => setSheet("units")}>
|
||||||
|
Add units
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{p.status === "closed" ? <div className="callout">This project is closed. Reopen it (Edit project) to add units or packets.</div> : null}
|
||||||
|
{units.length ? <UnitTable project={p} units={units} /> : <div className="empty">No units yet.{editable ? " Add one to start adding packets." : ""}</div>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{sheet === "edit" ? (
|
||||||
|
<ProjectSheet
|
||||||
|
project={p}
|
||||||
|
onCancel={() => setSheet(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setSheet(null);
|
||||||
|
setToast("Project saved");
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{sheet === "units" ? (
|
||||||
|
<AddUnitsSheet
|
||||||
|
project={p}
|
||||||
|
lots={units.map((u) => u.lot)}
|
||||||
|
onCancel={() => setSheet(null)}
|
||||||
|
onDone={(added) => {
|
||||||
|
setSheet(null);
|
||||||
|
setToast(added.length === 1 ? `Unit ${added[0]?.number ?? ""} added` : `${added.length} units added`);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{sheet === "renumber" ? (
|
||||||
|
<RenumberSheet
|
||||||
|
kind="project"
|
||||||
|
current={p.number}
|
||||||
|
onCancel={() => setSheet(null)}
|
||||||
|
onSave={async (number, reason) => {
|
||||||
|
const r = await guard.run((g) => projectsApi.renumber(p.id, { number, reason, ...g }), { sub: p.number });
|
||||||
|
if (r === undefined) return;
|
||||||
|
setSheet(null);
|
||||||
|
setToast(`Project renumbered to ${number}`);
|
||||||
|
reload();
|
||||||
|
if (r.id && r.id !== p.id) navigate(`/clerical/projects/${r.id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{guard.sheet}
|
||||||
|
{toast ? (
|
||||||
|
<div className="toast" role="status">
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { projectsApi, type ProjectShow } from "../../api/projects";
|
||||||
|
import type { Project } from "../../api/types";
|
||||||
|
import { useAuth } from "../../auth/useAuth";
|
||||||
|
import { canEditProjects } from "../../lib/access";
|
||||||
|
import { useChanged } from "../../lib/changes";
|
||||||
|
import { useLoad } from "../../lib/useLoad";
|
||||||
|
import { ProjectCard } from "./common";
|
||||||
|
import { AddUnitsSheet, ProjectSheet } from "./sheets";
|
||||||
|
|
||||||
|
const SHOWS: ReadonlyArray<[ProjectShow, string]> = [
|
||||||
|
["active", "Active"],
|
||||||
|
["open", "Open"],
|
||||||
|
["hold", "On hold"],
|
||||||
|
["closed", "Closed"],
|
||||||
|
["all", "All"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Projects on the Clerical home: find box, status chips, cards, New project then Add units. */
|
||||||
|
export function ProjectsSection() {
|
||||||
|
const { me } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [show, setShow] = useState<ProjectShow>("active");
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [adding, setAdding] = useState<Project | null>(null);
|
||||||
|
const [load, reload] = useLoad((s) => projectsApi.list(query, show, s), `${show}|${query}`);
|
||||||
|
const editable = canEditProjects(me?.roles ?? []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setTimeout(() => setQuery(q.trim()), 250);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
useChanged(reload, { delayMs: 500 });
|
||||||
|
|
||||||
|
const list = load.state === "ok" ? load.data : [];
|
||||||
|
return (
|
||||||
|
<section className="sec" aria-labelledby="projects-h">
|
||||||
|
<div className="sec-h">
|
||||||
|
<h2 id="projects-h">Projects</h2>
|
||||||
|
<span className="ct">project, then units, then packets</span>
|
||||||
|
<span className="grow" />
|
||||||
|
<div className="chips" role="group" aria-label="Show projects">
|
||||||
|
{SHOWS.map(([k, l]) => (
|
||||||
|
<button key={k} type="button" className="chip sm" aria-pressed={show === k} onClick={() => setShow(k)}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{editable ? (
|
||||||
|
<button className="btn pri" type="button" onClick={() => setCreating(true)}>
|
||||||
|
New project
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="unitbar">
|
||||||
|
<label className="f grow">
|
||||||
|
<span className="sr">Find a project</span>
|
||||||
|
<input
|
||||||
|
className="in"
|
||||||
|
type="search"
|
||||||
|
placeholder="Find a project: L670, customer, PO"
|
||||||
|
autoComplete="off"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||||
|
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
||||||
|
{load.state === "ok" ? (
|
||||||
|
list.length ? (
|
||||||
|
<div className="projgrid">
|
||||||
|
{list.map((p) => (
|
||||||
|
<ProjectCard key={p.id} p={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty">{query ? `Nothing matches "${query}".` : "No projects in this view. Set one up, add its units, then add packets into each unit."}</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{creating ? (
|
||||||
|
<ProjectSheet
|
||||||
|
onCancel={() => setCreating(false)}
|
||||||
|
onSaved={(p) => {
|
||||||
|
setCreating(false);
|
||||||
|
setAdding(p);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{adding ? (
|
||||||
|
<AddUnitsSheet
|
||||||
|
project={adding}
|
||||||
|
lots={[]}
|
||||||
|
onCancel={() => setAdding(null)}
|
||||||
|
onDone={() => {
|
||||||
|
const id = adding.id;
|
||||||
|
setAdding(null);
|
||||||
|
navigate(`/clerical/projects/${id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
|
import { packetsApi } from "../../api/packets";
|
||||||
|
import { projectsApi } from "../../api/projects";
|
||||||
|
import { useAuth } from "../../auth/useAuth";
|
||||||
|
import { Board } from "../../components/Board";
|
||||||
|
import { Kpis } from "../../components/Kpis";
|
||||||
|
import { useGuard } from "../../components/useGuard";
|
||||||
|
import { canEditProjects, canRenumber } from "../../lib/access";
|
||||||
|
import { useChanged } from "../../lib/changes";
|
||||||
|
import { countCards, dueInfo, IN_SHOP_OR_QC, PROJECT_STATUS_LABEL } from "../../lib/packets";
|
||||||
|
import { useLoad } from "../../lib/useLoad";
|
||||||
|
import { ProgressBar, ProjectPill } from "./common";
|
||||||
|
import { NewPacketSheet, RenumberSheet, UnitSheet } from "./sheets";
|
||||||
|
|
||||||
|
/** /clerical/units/:unitId: the unit's facts and its packets. */
|
||||||
|
export function UnitPage() {
|
||||||
|
const { unitId } = useParams();
|
||||||
|
const id = Number(unitId);
|
||||||
|
const { me } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const guard = useGuard();
|
||||||
|
const [unitLoad, reloadUnit] = useLoad((s) => projectsApi.unit(id, s), String(id));
|
||||||
|
const [packetsLoad, reloadPackets] = useLoad((s) => packetsApi.list({ unit_id: id }, s), String(id));
|
||||||
|
const [sheet, setSheet] = useState<"edit" | "renumber" | "packet" | null>(null);
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
const roles = me?.roles ?? [];
|
||||||
|
useChanged(
|
||||||
|
() => {
|
||||||
|
reloadUnit();
|
||||||
|
reloadPackets();
|
||||||
|
},
|
||||||
|
{ delayMs: 500 },
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) return;
|
||||||
|
const t = window.setTimeout(() => setToast(null), 2600);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
if (unitLoad.state === "loading") return <div className="empty slim">Loading</div>;
|
||||||
|
if (unitLoad.state === "error")
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link className="back" to="/clerical">
|
||||||
|
← All projects
|
||||||
|
</Link>
|
||||||
|
<div className="callout warn">{unitLoad.message}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const u = unitLoad.data;
|
||||||
|
const t = u.project ?? null;
|
||||||
|
const list = packetsLoad.state === "ok" ? packetsLoad.data : [];
|
||||||
|
const c = countCards(list);
|
||||||
|
const di = dueInfo(u.due_effective, u.status === "closed" || t?.status === "closed");
|
||||||
|
const open = u.status !== "closed" && t?.status !== "closed";
|
||||||
|
const editable = canEditProjects(roles);
|
||||||
|
const grp = (st: string) => list.filter((p) => p.status === st);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav className="crumbs" aria-label="Where you are">
|
||||||
|
<Link className="back" to="/clerical">
|
||||||
|
All projects
|
||||||
|
</Link>
|
||||||
|
<span>›</span>
|
||||||
|
{t ? (
|
||||||
|
<>
|
||||||
|
<Link className="back" to={`/clerical/projects/${t.id}`}>
|
||||||
|
{t.number} {t.name ?? ""}
|
||||||
|
</Link>
|
||||||
|
<span>›</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<b>{u.number}</b>
|
||||||
|
</nav>
|
||||||
|
<div className="dhead">
|
||||||
|
<div className="grow0">
|
||||||
|
<div className="eyebrow">
|
||||||
|
Unit{t ? ` of ${t.number}` : ""} · {PROJECT_STATUS_LABEL[u.status]}
|
||||||
|
</div>
|
||||||
|
<h1>
|
||||||
|
{u.number}
|
||||||
|
{u.name ? ` · ${u.name}` : ""}
|
||||||
|
</h1>
|
||||||
|
<div className="facts">
|
||||||
|
<div>
|
||||||
|
<span>Project </span>
|
||||||
|
<b>{t ? `${t.number} ${t.name ?? ""}` : "-"}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Customer </span>
|
||||||
|
<b>{t?.customer || "-"}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Customer PO </span>
|
||||||
|
<b>{t?.po || "-"}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Due </span>
|
||||||
|
<b className={di.cls}>
|
||||||
|
{di.txt.replace(/^Due /, "")}
|
||||||
|
{u.due ? "" : " (project)"}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Packets </span>
|
||||||
|
<b>{c.total}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{u.notes ? <div className="small pre">{u.notes}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="pacts">
|
||||||
|
<ProjectPill status={u.status} />
|
||||||
|
{editable ? (
|
||||||
|
<button className="btn sm" type="button" onClick={() => setSheet("edit")}>
|
||||||
|
Edit unit
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canRenumber(roles) ? (
|
||||||
|
<button className="btn sm" type="button" onClick={() => setSheet("renumber")}>
|
||||||
|
Renumber
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{editable && open ? (
|
||||||
|
<button className="btn pri" type="button" onClick={() => setSheet("packet")}>
|
||||||
|
Add packet
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Kpis
|
||||||
|
items={[
|
||||||
|
{ n: c.draft, label: "Ready to release" },
|
||||||
|
{ n: c.warehouse, label: "With warehouse" },
|
||||||
|
{ n: list.filter((p) => IN_SHOP_OR_QC.includes(p.status)).length, label: "In the shop / Q.C." },
|
||||||
|
{ n: c.complete, label: "Back for data book" },
|
||||||
|
{ n: c.filed, label: "Filed" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProgressBar c={c} />
|
||||||
|
{!open ? <div className="callout">This {u.status === "closed" ? "unit" : "project"} is closed. Reopen it to add packets.</div> : null}
|
||||||
|
{packetsLoad.state === "error" ? <div className="callout warn">{packetsLoad.message}</div> : null}
|
||||||
|
<Board
|
||||||
|
columns={[
|
||||||
|
{ key: "flagged", title: "Flagged", cards: grp("draft").filter((p) => p.flag), empty: "Nothing flagged." },
|
||||||
|
{ key: "draft", title: "Ready to release", cards: grp("draft").filter((p) => !p.flag), empty: "Nothing ready to release in this unit." },
|
||||||
|
{
|
||||||
|
key: "prog",
|
||||||
|
title: "In progress",
|
||||||
|
cards: list.filter((p) => p.status === "warehouse" || IN_SHOP_OR_QC.includes(p.status)),
|
||||||
|
empty: "Nothing in progress.",
|
||||||
|
},
|
||||||
|
{ key: "back", title: "Back for data book", cards: grp("complete"), empty: "Nothing accepted yet." },
|
||||||
|
{ key: "filed", title: "Filed", cards: grp("filed"), empty: "Nothing filed yet." },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sheet === "edit" ? (
|
||||||
|
<UnitSheet
|
||||||
|
unit={u}
|
||||||
|
onCancel={() => setSheet(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setSheet(null);
|
||||||
|
setToast("Unit saved");
|
||||||
|
reloadUnit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{sheet === "renumber" ? (
|
||||||
|
<RenumberSheet
|
||||||
|
kind="unit"
|
||||||
|
current={u.lot}
|
||||||
|
prefix={t?.number ?? ""}
|
||||||
|
onCancel={() => setSheet(null)}
|
||||||
|
onSave={async (lot, reason) => {
|
||||||
|
const r = await guard.run((g) => projectsApi.renumberUnit(u.id, { lot, reason, ...g }), { sub: u.number });
|
||||||
|
if (r === undefined) return;
|
||||||
|
setSheet(null);
|
||||||
|
setToast(`Unit renumbered to ${r.number ?? lot}`);
|
||||||
|
reloadUnit();
|
||||||
|
reloadPackets();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{sheet === "packet" ? (
|
||||||
|
<NewPacketSheet
|
||||||
|
initial={{ project: t, unit: u }}
|
||||||
|
onCancel={() => setSheet(null)}
|
||||||
|
onCreated={(p) => {
|
||||||
|
setSheet(null);
|
||||||
|
navigate(`/packets/${p.id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{guard.sheet}
|
||||||
|
{toast ? (
|
||||||
|
<div className="toast" role="status">
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// The Clerical home boards, from one list answer.
|
||||||
|
import type { ColumnDef } from "../../components/Board";
|
||||||
|
import type { PacketCard } from "../../api/types";
|
||||||
|
import { IN_SHOP_OR_QC } from "../../lib/packets";
|
||||||
|
|
||||||
|
export const byTime = (a: string | null, b: string | null) => (a ?? "").localeCompare(b ?? "");
|
||||||
|
|
||||||
|
/** The boards of the Clerical home, from one list answer. */
|
||||||
|
export function clericalBoards(all: PacketCard[]): { columns: ColumnDef[]; flagged: number; warehouse: number; shop: number; toClose: number } {
|
||||||
|
const drafts = all.filter((p) => p.status === "draft").sort((a, b) => byTime(a.updated_at, b.updated_at));
|
||||||
|
const back = all.filter((p) => p.status === "complete" && p.routed_to === "databook");
|
||||||
|
const flagged = drafts.filter((p) => p.flag);
|
||||||
|
const going = drafts.filter((p) => !p.flag);
|
||||||
|
const toClose = back.filter((p) => !p.vclosed).sort((a, b) => byTime(a.status_at, b.status_at));
|
||||||
|
const toBook = back.filter((p) => p.vclosed).sort((a, b) => byTime(a.vclosed?.at ?? null, b.vclosed?.at ?? null));
|
||||||
|
const filed = all
|
||||||
|
.filter((p) => p.status === "filed")
|
||||||
|
.sort((a, b) => byTime(b.status_at, a.status_at))
|
||||||
|
.slice(0, 25);
|
||||||
|
const columns: ColumnDef[] = [
|
||||||
|
{ key: "cos", title: "Changes to approve", cards: [], empty: "Arrives in phase 3 with the VISUAL feed." },
|
||||||
|
{ key: "flagged", title: "Flagged", cards: flagged, empty: "Nothing flagged." },
|
||||||
|
{ key: "going", title: "Going to the warehouse", cards: going, empty: "Drafts with nothing flagged wait here." },
|
||||||
|
{
|
||||||
|
key: "close",
|
||||||
|
title: "Ready to close",
|
||||||
|
cards: toClose,
|
||||||
|
empty: "Finished packets come back here. Close the job in VISUAL, then mark it closed on the packet.",
|
||||||
|
},
|
||||||
|
{ key: "book", title: "Ready for data book", cards: toBook, empty: "Jobs closed in VISUAL wait here for the data book." },
|
||||||
|
{ key: "filed", title: "Filed", cards: filed, empty: "Nothing filed yet." },
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
columns,
|
||||||
|
flagged: flagged.length,
|
||||||
|
warehouse: all.filter((p) => p.status === "warehouse").length,
|
||||||
|
shop: all.filter((p) => IN_SHOP_OR_QC.includes(p.status)).length,
|
||||||
|
toClose: toClose.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Link } from "react-router";
|
||||||
|
import type { Project, StageCounts } from "../../api/types";
|
||||||
|
import { dueInfo, projectPillClass, PROJECT_STATUS_LABEL, stageCounts } from "../../lib/packets";
|
||||||
|
|
||||||
|
/** The old stageChips(): how many packets sit at each stage. */
|
||||||
|
export function StageChips({ c }: { c: StageCounts }) {
|
||||||
|
const items: Array<[string, number]> = [
|
||||||
|
["Ready to release", c.draft],
|
||||||
|
["Warehouse", c.warehouse],
|
||||||
|
["Shop", c.shop],
|
||||||
|
["Q.C.", c.qc],
|
||||||
|
["Accepted", c.complete],
|
||||||
|
["Filed", c.filed],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="stg">
|
||||||
|
{items.map(([l, n]) => (
|
||||||
|
<span key={l} className={n ? undefined : "z"}>
|
||||||
|
<b>{n}</b> {l}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The old progressBar(): filed in cyan, accepted in green. */
|
||||||
|
export function ProgressBar({ c }: { c: StageCounts }) {
|
||||||
|
const n = c.total || 0;
|
||||||
|
const f = n ? (c.filed / n) * 100 : 0;
|
||||||
|
const a = n ? (c.complete / n) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div className="pbar" title={`${c.filed} filed, ${c.complete} accepted, ${Math.max(0, n - c.filed - c.complete)} in progress of ${n}`}>
|
||||||
|
<i className="f" style={{ width: `${f}%` }} />
|
||||||
|
<i className="c" style={{ width: `${a}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectPill({ status }: { status: Project["status"] }) {
|
||||||
|
return <span className={projectPillClass(status)}>{PROJECT_STATUS_LABEL[status]}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The old projCard(). */
|
||||||
|
export function ProjectCard({ p }: { p: Project }) {
|
||||||
|
const c = stageCounts(p.packets);
|
||||||
|
const di = dueInfo(p.due, p.status === "closed");
|
||||||
|
const units = typeof p.units === "number" ? p.units : p.units.length;
|
||||||
|
return (
|
||||||
|
<Link className="card pcard" to={`/clerical/projects/${p.id}`}>
|
||||||
|
<div className="r1">
|
||||||
|
<span className="job">{p.number}</span>
|
||||||
|
<ProjectPill status={p.status} />
|
||||||
|
</div>
|
||||||
|
<div className="tt">{p.name || "-"}</div>
|
||||||
|
<div className="small muted">
|
||||||
|
{p.customer ?? ""}
|
||||||
|
{p.po ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
· PO <span className="mono">{p.po}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={`small ${di.cls}`}>{di.txt}</div>
|
||||||
|
<StageChips c={c} />
|
||||||
|
<ProgressBar c={c} />
|
||||||
|
<div className="small muted">
|
||||||
|
{units} unit{units === 1 ? "" : "s"} · {c.total} packet{c.total === 1 ? "" : "s"} · {c.filed} filed
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { packetsApi } from "../../api/packets";
|
||||||
|
import { projectsApi, type ProjectFields, type UnitFields } from "../../api/projects";
|
||||||
|
import type { PacketCreate, PacketRead, Project, ProjectRef, ProjectStatus, Unit } from "../../api/types";
|
||||||
|
import { PickUnit, type UnitPick } from "../../components/PickUnit";
|
||||||
|
import { Sheet } from "../../components/Sheet";
|
||||||
|
import { useGuard } from "../../components/useGuard";
|
||||||
|
import { errorText } from "../../lib/format";
|
||||||
|
import { useShopDepartments } from "../../lib/useDepartments";
|
||||||
|
import { lotSequence, nextLot, normLot, PROJECT_STATUS_LABEL, RX_LOT, RX_PROJECT } from "../../lib/packets";
|
||||||
|
|
||||||
|
const STATUSES: ProjectStatus[] = ["open", "hold", "closed"];
|
||||||
|
|
||||||
|
function Problem({ text }: { text: string | null }) {
|
||||||
|
if (!text) return null;
|
||||||
|
return (
|
||||||
|
<div className="callout warn flush" role="alert">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The keys whose value changed, with the old values as the base. */
|
||||||
|
function diff<T extends object>(before: T, after: T): { set: Partial<T>; base: Partial<T> } {
|
||||||
|
const set: Partial<T> = {};
|
||||||
|
const base: Partial<T> = {};
|
||||||
|
for (const k of Object.keys(after) as Array<keyof T>) {
|
||||||
|
if ((before[k] ?? null) !== (after[k] ?? null)) {
|
||||||
|
set[k] = after[k];
|
||||||
|
base[k] = before[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { set, base };
|
||||||
|
}
|
||||||
|
|
||||||
|
const orNull = (s: string) => (s.trim() === "" ? null : s.trim());
|
||||||
|
|
||||||
|
/* ---------- Project: new or edit ---------- */
|
||||||
|
|
||||||
|
export function ProjectSheet({ project, onSaved, onCancel }: { project?: Project; onSaved: (p: Project) => void; onCancel: () => void }) {
|
||||||
|
const guard = useGuard();
|
||||||
|
const editing = !!project;
|
||||||
|
const [number, setNumber] = useState(project?.number ?? "");
|
||||||
|
const [name, setName] = useState(project?.name ?? "");
|
||||||
|
const [customer, setCustomer] = useState(project?.customer ?? "");
|
||||||
|
const [po, setPo] = useState(project?.po ?? "");
|
||||||
|
const [due, setDue] = useState(project?.due ?? "");
|
||||||
|
const [status, setStatus] = useState<ProjectStatus>(project?.status ?? "open");
|
||||||
|
const [notes, setNotes] = useState(project?.notes ?? "");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
const num = number.trim().toUpperCase();
|
||||||
|
if (!editing && !RX_PROJECT.test(num)) {
|
||||||
|
setError("Project number: 1 to 20 letters, numbers or dashes, for example L670.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Give the project a name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
let saved: Project | undefined;
|
||||||
|
if (project) {
|
||||||
|
const before: ProjectFields = { name: project.name, customer: project.customer, po: project.po, due: project.due, status: project.status, notes: project.notes };
|
||||||
|
const after: ProjectFields = { name: orNull(name), customer: orNull(customer), po: orNull(po), due: orNull(due), status, notes: orNull(notes) };
|
||||||
|
const cas = diff(before, after);
|
||||||
|
if (Object.keys(cas.set).length === 0) {
|
||||||
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saved = await guard.run((g) => projectsApi.patch(project.id, { ...cas, ...g }), { sub: project.number });
|
||||||
|
} else {
|
||||||
|
saved = await guard.run((g) =>
|
||||||
|
projectsApi.create({ number: num, name: name.trim(), customer: customer.trim(), po: po.trim(), due: orNull(due), notes: notes.trim(), ...g }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (saved) onSaved(saved);
|
||||||
|
else setBusy(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sheet title={editing ? "Edit project" : "New project"} sub={project?.number} onClose={onCancel} wide>
|
||||||
|
<form className="stack" onSubmit={submit} noValidate>
|
||||||
|
<div className="frow">
|
||||||
|
{editing ? null : (
|
||||||
|
<label className="f">
|
||||||
|
Project number
|
||||||
|
<input className="in" value={number} maxLength={20} placeholder="e.g. L670" autoCapitalize="characters" onChange={(e) => setNumber(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="f">
|
||||||
|
Project name
|
||||||
|
<input className="in" value={name} maxLength={120} placeholder="e.g. BPX separator package" onChange={(e) => setName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="frow">
|
||||||
|
<label className="f">
|
||||||
|
Customer
|
||||||
|
<input className="in" value={customer} maxLength={120} onChange={(e) => setCustomer(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Customer PO
|
||||||
|
<input className="in" value={po} maxLength={60} onChange={(e) => setPo(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Due / ship date
|
||||||
|
<input className="in" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
{editing ? (
|
||||||
|
<label className="f">
|
||||||
|
Status
|
||||||
|
<select className="in" value={status} onChange={(e) => setStatus(e.target.value as ProjectStatus)}>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{PROJECT_STATUS_LABEL[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<label className="f">
|
||||||
|
Notes
|
||||||
|
<textarea className="in" rows={3} maxLength={2000} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
{editing ? null : <div className="small muted">After creating the project, add its units. Packets go into a unit.</div>}
|
||||||
|
<Problem text={error} />
|
||||||
|
<div className="sheet-acts">
|
||||||
|
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn pri" type="submit" disabled={busy}>
|
||||||
|
{editing ? "Save changes" : "Create project"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Sheet>
|
||||||
|
{guard.sheet}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Add units: one, or several counting up ---------- */
|
||||||
|
|
||||||
|
export function AddUnitsSheet({
|
||||||
|
project,
|
||||||
|
lots,
|
||||||
|
onDone,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
project: ProjectRef;
|
||||||
|
/** Lots already in use, for the next free number. */
|
||||||
|
lots: string[];
|
||||||
|
onDone: (units: Unit[]) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const guard = useGuard();
|
||||||
|
const first = nextLot(lots);
|
||||||
|
const [many, setMany] = useState(false);
|
||||||
|
const [lot, setLot] = useState(first);
|
||||||
|
const [count, setCount] = useState("");
|
||||||
|
const [start, setStart] = useState(first);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [due, setDue] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const seq = many ? lotSequence(start, Number(count)) : null;
|
||||||
|
const preview = seq?.map((l) => `${project.number}${l}`) ?? [];
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
let body: { lot?: string; lots?: string[] };
|
||||||
|
if (many) {
|
||||||
|
const n = Number(count);
|
||||||
|
if (!Number.isInteger(n) || n < 1 || n > 200) {
|
||||||
|
setError("How many: 1 to 200.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!seq) {
|
||||||
|
setError("The starting lot has to be digits, for example 001, and the last one 999 or less.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body = { lots: seq };
|
||||||
|
} else {
|
||||||
|
const l = normLot(lot);
|
||||||
|
if (!RX_LOT.test(l)) {
|
||||||
|
setError("Lot: 1 to 3 letters or digits, for example 001 or 7A.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body = { lot: l };
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const units = await guard.run((g) => projectsApi.addUnits(project.id, { ...body, name: name.trim(), due: orNull(due), notes: notes.trim(), ...g }), { sub: project.number });
|
||||||
|
if (units) onDone(units);
|
||||||
|
else setBusy(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sheet title="Add units" sub={`Project ${project.number}${project.name ? ` · ${project.name}` : ""}`} onClose={onCancel} wide>
|
||||||
|
<form className="stack" onSubmit={submit} noValidate>
|
||||||
|
<div className="seg" role="group" aria-label="One or several">
|
||||||
|
<button type="button" aria-pressed={!many} onClick={() => setMany(false)}>
|
||||||
|
One unit
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-pressed={many} onClick={() => setMany(true)}>
|
||||||
|
Several units
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{many ? (
|
||||||
|
<>
|
||||||
|
<div className="frow">
|
||||||
|
<label className="f">
|
||||||
|
How many
|
||||||
|
<input className="in" type="number" min={1} max={200} inputMode="numeric" placeholder="e.g. 12" value={count} onChange={(e) => setCount(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Starting lot
|
||||||
|
<input className="in mono" value={start} maxLength={3} onChange={(e) => setStart(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="small muted" aria-live="polite">
|
||||||
|
{preview.length
|
||||||
|
? preview.length <= 12
|
||||||
|
? `Numbers: ${preview.join(", ")}`
|
||||||
|
: `Numbers: ${preview.slice(0, 6).join(", ")} and ${preview.length - 6} more, up to ${preview[preview.length - 1] ?? ""}`
|
||||||
|
: "Numbers count up from the starting lot."}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="frow">
|
||||||
|
<label className="f">
|
||||||
|
Lot
|
||||||
|
<input className="in mono" value={lot} maxLength={3} autoCapitalize="characters" onChange={(e) => setLot(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="end small muted">
|
||||||
|
Unit number <b className="mono">{`${project.number}${normLot(lot)}`}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="frow">
|
||||||
|
<label className="f">
|
||||||
|
{many ? "Description (all of them)" : "Description"}
|
||||||
|
<input className="in" value={name} maxLength={120} placeholder='e.g. 48" horizontal separator' onChange={(e) => setName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Due / ship date
|
||||||
|
<input className="in" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="small muted">Leave the date blank to use the project's date.</div>
|
||||||
|
<label className="f">
|
||||||
|
Notes
|
||||||
|
<textarea className="in" rows={2} maxLength={2000} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<Problem text={error} />
|
||||||
|
<div className="sheet-acts">
|
||||||
|
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn pri" type="submit" disabled={busy}>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Sheet>
|
||||||
|
{guard.sheet}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Edit a unit ---------- */
|
||||||
|
|
||||||
|
export function UnitSheet({ unit, onSaved, onCancel }: { unit: Unit; onSaved: (u: Unit) => void; onCancel: () => void }) {
|
||||||
|
const guard = useGuard();
|
||||||
|
const [name, setName] = useState(unit.name ?? "");
|
||||||
|
const [due, setDue] = useState(unit.due ?? "");
|
||||||
|
const [status, setStatus] = useState<ProjectStatus>(unit.status);
|
||||||
|
const [notes, setNotes] = useState(unit.notes ?? "");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const before: UnitFields = { name: unit.name, due: unit.due, status: unit.status, notes: unit.notes ?? null };
|
||||||
|
const cas = diff(before, { name: orNull(name), due: orNull(due), status, notes: orNull(notes) });
|
||||||
|
if (Object.keys(cas.set).length === 0) {
|
||||||
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const saved = await guard.run((g) => projectsApi.patchUnit(unit.id, { ...cas, ...g }), { sub: unit.number });
|
||||||
|
if (saved) onSaved(saved);
|
||||||
|
else setBusy(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sheet title="Edit unit" sub={unit.number} onClose={onCancel} wide>
|
||||||
|
<form className="stack" onSubmit={submit} noValidate>
|
||||||
|
<div className="frow">
|
||||||
|
<label className="f">
|
||||||
|
Description
|
||||||
|
<input className="in" value={name} maxLength={120} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Due / ship date
|
||||||
|
<input className="in" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Status
|
||||||
|
<select className="in" value={status} onChange={(e) => setStatus(e.target.value as ProjectStatus)}>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{PROJECT_STATUS_LABEL[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="f">
|
||||||
|
Notes
|
||||||
|
<textarea className="in" rows={2} maxLength={2000} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<Problem text={error} />
|
||||||
|
<div className="sheet-acts">
|
||||||
|
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn pri" type="submit" disabled={busy}>
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Sheet>
|
||||||
|
{guard.sheet}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Renumber a project or a unit ---------- */
|
||||||
|
|
||||||
|
export function RenumberSheet({
|
||||||
|
kind,
|
||||||
|
current,
|
||||||
|
prefix,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
kind: "project" | "unit";
|
||||||
|
/** The current project number, or the unit's lot. */
|
||||||
|
current: string;
|
||||||
|
/** For a unit: the project number, to show the new unit number. */
|
||||||
|
prefix?: string;
|
||||||
|
onSave: (value: string, reason: string) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
const v = kind === "project" ? value.trim().toUpperCase() : normLot(value);
|
||||||
|
if (kind === "project" ? !RX_PROJECT.test(v) : !RX_LOT.test(v)) {
|
||||||
|
setError(kind === "project" ? "Project number: 1 to 20 letters, numbers or dashes." : "Lot: 1 to 3 letters or digits.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v === current) {
|
||||||
|
setError("That is the current number.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!reason.trim()) {
|
||||||
|
setError("Give a reason.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await onSave(v, reason.trim());
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = kind === "project" ? "Renumber project" : "Renumber unit";
|
||||||
|
return (
|
||||||
|
<Sheet title={title} sub={kind === "project" ? current : `${prefix ?? ""}${current}`} onClose={onCancel}>
|
||||||
|
<div className="small">
|
||||||
|
{kind === "project"
|
||||||
|
? "The old number stays as an alias, so prints with it still find this project. Its units keep their lots and move with it."
|
||||||
|
: "The old number stays as an alias. The unit's packets move with it."}
|
||||||
|
</div>
|
||||||
|
<form className="stack" onSubmit={submit} noValidate>
|
||||||
|
<label className="f">
|
||||||
|
{kind === "project" ? "New project number" : "New lot"}
|
||||||
|
<input className="in mono" value={value} maxLength={kind === "project" ? 20 : 3} autoCapitalize="characters" onChange={(e) => setValue(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Reason
|
||||||
|
<input className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<Problem text={error} />
|
||||||
|
<div className="sheet-acts">
|
||||||
|
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn pri" type="submit" disabled={busy}>
|
||||||
|
Change number
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- New packet: a hand draft ---------- */
|
||||||
|
|
||||||
|
interface WoRow {
|
||||||
|
wo_base: string;
|
||||||
|
lot: string;
|
||||||
|
split: string;
|
||||||
|
sub: string;
|
||||||
|
qty: string;
|
||||||
|
target_hrs: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BomRow {
|
||||||
|
qty: string;
|
||||||
|
part_id: string;
|
||||||
|
description: string;
|
||||||
|
material: string;
|
||||||
|
length: string;
|
||||||
|
dwg_ref: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_WO: WoRow = { wo_base: "", lot: "", split: "", sub: "", qty: "", target_hrs: "" };
|
||||||
|
const EMPTY_BOM: BomRow = { qty: "", part_id: "", description: "", material: "", length: "", dwg_ref: "" };
|
||||||
|
|
||||||
|
const num = (s: string): number | null => {
|
||||||
|
const t = s.trim();
|
||||||
|
if (t === "") return null;
|
||||||
|
const n = Number(t);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NewPacketSheet({
|
||||||
|
initial,
|
||||||
|
onCreated,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
initial: UnitPick;
|
||||||
|
onCreated: (p: PacketRead) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const guard = useGuard();
|
||||||
|
const depts = useShopDepartments();
|
||||||
|
const [pick, setPick] = useState<UnitPick>(initial);
|
||||||
|
const [dept, setDept] = useState("");
|
||||||
|
const [op, setOp] = useState("");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [customer, setCustomer] = useState(initial.project?.customer ?? "");
|
||||||
|
const [drawing, setDrawing] = useState("");
|
||||||
|
const [rev, setRev] = useState("");
|
||||||
|
const [wps, setWps] = useState("");
|
||||||
|
const [wos, setWos] = useState<WoRow[]>([{ ...EMPTY_WO }]);
|
||||||
|
const [bom, setBom] = useState<BomRow[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const deptValue = dept || depts[0]?.code || "";
|
||||||
|
|
||||||
|
const onPick = (v: UnitPick) => {
|
||||||
|
setPick(v);
|
||||||
|
if (v.project?.customer && !customer) setCustomer(v.project.customer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setWo = (i: number, k: keyof WoRow, v: string) => setWos((rows) => rows.map((r, j) => (j === i ? { ...r, [k]: v } : r)));
|
||||||
|
const setBl = (i: number, k: keyof BomRow, v: string) => setBom((rows) => rows.map((r, j) => (j === i ? { ...r, [k]: v } : r)));
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!pick.unit) {
|
||||||
|
setError("Pick the project and the unit first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!deptValue) {
|
||||||
|
setError("Pick the first department.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!op.trim()) {
|
||||||
|
setError("Give the operation, for example WELD-PART.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const work_orders = wos
|
||||||
|
.filter((w) => w.wo_base.trim())
|
||||||
|
.map((w) => ({
|
||||||
|
wo_base: w.wo_base.trim().toUpperCase(),
|
||||||
|
lot: w.lot.trim().toUpperCase(),
|
||||||
|
split: w.split.trim().toUpperCase(),
|
||||||
|
sub: w.sub.trim().toUpperCase(),
|
||||||
|
qty: num(w.qty),
|
||||||
|
target_hrs: num(w.target_hrs),
|
||||||
|
}));
|
||||||
|
const lines = bom
|
||||||
|
.filter((b) => b.part_id.trim() || b.description.trim())
|
||||||
|
.map((b, i) => ({
|
||||||
|
item: i + 1,
|
||||||
|
qty: num(b.qty),
|
||||||
|
part_id: b.part_id.trim() || null,
|
||||||
|
description: b.description.trim() || null,
|
||||||
|
material: b.material.trim() || null,
|
||||||
|
length: b.length.trim() || null,
|
||||||
|
dwg_ref: b.dwg_ref.trim() || null,
|
||||||
|
}));
|
||||||
|
const body: PacketCreate = {
|
||||||
|
unit_id: pick.unit.id,
|
||||||
|
dept_code: deptValue,
|
||||||
|
op: op.trim().toUpperCase(),
|
||||||
|
title: title.trim(),
|
||||||
|
customer: customer.trim(),
|
||||||
|
drawing: drawing.trim(),
|
||||||
|
rev: rev.trim(),
|
||||||
|
wps: wps.trim(),
|
||||||
|
work_orders,
|
||||||
|
bom: lines,
|
||||||
|
};
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const created = await guard.run((g) => packetsApi.create({ ...body, ...g } as PacketCreate));
|
||||||
|
if (created) onCreated(created);
|
||||||
|
else setBusy(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sheet title="New packet" sub="Made by hand" onClose={onCancel} wide>
|
||||||
|
<form className="stack" onSubmit={submit} noValidate>
|
||||||
|
<PickUnit value={pick} onChange={onPick} requireUnit />
|
||||||
|
<div className="frow">
|
||||||
|
<label className="f">
|
||||||
|
First department
|
||||||
|
<select className="in" value={deptValue} onChange={(e) => setDept(e.target.value)}>
|
||||||
|
{depts.map((d) => (
|
||||||
|
<option key={d.code} value={d.code}>
|
||||||
|
{d.code} {d.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Operation
|
||||||
|
<input className="in" value={op} maxLength={40} placeholder="e.g. WELD-PART" autoCapitalize="characters" onChange={(e) => setOp(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Title
|
||||||
|
<input className="in" value={title} maxLength={200} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Customer
|
||||||
|
<input className="in" value={customer} maxLength={120} onChange={(e) => setCustomer(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="frow">
|
||||||
|
<label className="f">
|
||||||
|
Drawing no.
|
||||||
|
<input className="in" value={drawing} maxLength={60} onChange={(e) => setDrawing(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
Rev
|
||||||
|
<input className="in" value={rev} maxLength={10} onChange={(e) => setRev(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="f">
|
||||||
|
WPS
|
||||||
|
<input className="in" value={wps} maxLength={40} onChange={(e) => setWps(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
Work orders <span className="muted mono small">({wos.filter((w) => w.wo_base.trim()).length})</span>
|
||||||
|
</h3>
|
||||||
|
<div className="tw">
|
||||||
|
<table className="edit">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>WO</th>
|
||||||
|
<th>Lot</th>
|
||||||
|
<th>Split</th>
|
||||||
|
<th>Sub</th>
|
||||||
|
<th className="num">Qty</th>
|
||||||
|
<th className="num">Target hrs</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{wos.map((w, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>
|
||||||
|
<input className="in sm mono" aria-label={`Work order ${i + 1}`} value={w.wo_base} maxLength={30} onChange={(e) => setWo(i, "wo_base", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm mono" aria-label={`Work order ${i + 1} lot`} value={w.lot} maxLength={10} onChange={(e) => setWo(i, "lot", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm mono" aria-label={`Work order ${i + 1} split`} value={w.split} maxLength={10} onChange={(e) => setWo(i, "split", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm mono" aria-label={`Work order ${i + 1} sub`} value={w.sub} maxLength={10} onChange={(e) => setWo(i, "sub", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm num" aria-label={`Work order ${i + 1} quantity`} inputMode="decimal" value={w.qty} onChange={(e) => setWo(i, "qty", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm num" aria-label={`Work order ${i + 1} target hours`} inputMode="decimal" value={w.target_hrs} onChange={(e) => setWo(i, "target_hrs", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn sm ghost" type="button" aria-label={`Remove work order ${i + 1}`} onClick={() => setWos((rows) => rows.filter((_, j) => j !== i))}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button className="btn sm" type="button" onClick={() => setWos((rows) => [...rows, { ...EMPTY_WO }])}>
|
||||||
|
Add work order
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
Bill of materials <span className="muted mono small">(optional, {bom.length} lines)</span>
|
||||||
|
</h3>
|
||||||
|
{bom.length ? (
|
||||||
|
<div className="tw">
|
||||||
|
<table className="edit">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th className="num">Qty</th>
|
||||||
|
<th>Part ID</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Material</th>
|
||||||
|
<th>Length</th>
|
||||||
|
<th>Dwg ref</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bom.map((b, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="mono">{i + 1}</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm num" aria-label={`Line ${i + 1} quantity`} inputMode="decimal" value={b.qty} onChange={(e) => setBl(i, "qty", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm mono" aria-label={`Line ${i + 1} part ID`} value={b.part_id} maxLength={60} onChange={(e) => setBl(i, "part_id", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm" aria-label={`Line ${i + 1} description`} value={b.description} maxLength={240} onChange={(e) => setBl(i, "description", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm" aria-label={`Line ${i + 1} material`} value={b.material} maxLength={120} onChange={(e) => setBl(i, "material", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm" aria-label={`Line ${i + 1} length`} value={b.length} maxLength={40} onChange={(e) => setBl(i, "length", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="in sm" aria-label={`Line ${i + 1} drawing reference`} value={b.dwg_ref} maxLength={60} onChange={(e) => setBl(i, "dwg_ref", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn sm ghost" type="button" aria-label={`Remove line ${i + 1}`} onClick={() => setBom((rows) => rows.filter((_, j) => j !== i))}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<button className="btn sm" type="button" onClick={() => setBom((rows) => [...rows, { ...EMPTY_BOM }])}>
|
||||||
|
Add BOM line
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="small muted">The packet is saved as a draft. Attach the drawing and say whether it needs a heat map before you send it on.</div>
|
||||||
|
<Problem text={error} />
|
||||||
|
<div className="sheet-acts">
|
||||||
|
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
<button className="btn pri" type="submit" disabled={busy}>
|
||||||
|
Save packet as draft
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Sheet>
|
||||||
|
{guard.sheet}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Synthetic packet answers for the screen tests. No real job data.
|
||||||
|
import type { PacketCard, PacketRead, Project, Unit } from "../api/types";
|
||||||
|
|
||||||
|
export function makeCard(over: Partial<PacketCard> = {}): PacketCard {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
version: 1,
|
||||||
|
unit: { id: 11, number: "L999001" },
|
||||||
|
project: { id: 7, number: "L999", name: "CI project" },
|
||||||
|
dept: { code: "03", name: "Weld Parts" },
|
||||||
|
op: "WELD-PART",
|
||||||
|
title: "CI spool",
|
||||||
|
customer: "Test Co",
|
||||||
|
drawing: "CI-DWG-1",
|
||||||
|
rev: "0",
|
||||||
|
status: "draft",
|
||||||
|
status_at: "2026-10-16T08:00:00Z",
|
||||||
|
station: null,
|
||||||
|
welder: null,
|
||||||
|
routed_to: null,
|
||||||
|
vclosed: null,
|
||||||
|
source_kind: "hand",
|
||||||
|
flag: null,
|
||||||
|
open_cos: [],
|
||||||
|
open_shorts: 0,
|
||||||
|
wh_stage: null,
|
||||||
|
due_effective: null,
|
||||||
|
updated_at: "2026-10-16T08:00:00Z",
|
||||||
|
holder: "Clerical",
|
||||||
|
can: [],
|
||||||
|
blocked: {},
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makePacket(over: Partial<PacketRead> = {}): PacketRead {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
version: 3,
|
||||||
|
unit: {
|
||||||
|
id: 11,
|
||||||
|
number: "L999001",
|
||||||
|
lot: "001",
|
||||||
|
name: null,
|
||||||
|
project: { id: 7, number: "L999", name: "CI project", customer: "Test Co", po: null, status: "open" },
|
||||||
|
due_effective: null,
|
||||||
|
},
|
||||||
|
dept: { code: "03", name: "Weld Parts", weld: true },
|
||||||
|
op: "WELD-PART",
|
||||||
|
title: "CI spool",
|
||||||
|
customer: "Test Co",
|
||||||
|
drawing: "CI-DWG-1",
|
||||||
|
rev: "0",
|
||||||
|
wps: null,
|
||||||
|
status: "draft",
|
||||||
|
status_at: "2026-10-16T08:00:00Z",
|
||||||
|
station: null,
|
||||||
|
welder: null,
|
||||||
|
for_sup: null,
|
||||||
|
heat_map: null,
|
||||||
|
wh_skipped: false,
|
||||||
|
wh_sent_at: null,
|
||||||
|
routed_to: null,
|
||||||
|
return_note: null,
|
||||||
|
vclosed: null,
|
||||||
|
databook_at: null,
|
||||||
|
source_kind: "hand",
|
||||||
|
source: { kind: "hand" },
|
||||||
|
drawing_state: "none",
|
||||||
|
flags: ["hand"],
|
||||||
|
visual: {},
|
||||||
|
removed: null,
|
||||||
|
work_orders: [{ id: 1, wo_base: "L999001", lot: "001", split: "0", sub: "0", qty: 1, target_hrs: 2, row_version: 1 }],
|
||||||
|
pages: [],
|
||||||
|
bom: [],
|
||||||
|
welds: [],
|
||||||
|
heat_tags: [],
|
||||||
|
heat_table: [],
|
||||||
|
stage: null,
|
||||||
|
stages: [],
|
||||||
|
wh: null,
|
||||||
|
qc: null,
|
||||||
|
qc_history: [],
|
||||||
|
change_orders: [],
|
||||||
|
open_cos: [],
|
||||||
|
open_shorts: 0,
|
||||||
|
docs: [],
|
||||||
|
seal: null,
|
||||||
|
can: ["edit_header", "edit_drawing_ref", "edit_document_set", "move_unit", "remove"],
|
||||||
|
blocked: { send_to_warehouse: ["Say whether this packet needs a heat map."] },
|
||||||
|
holder: "Clerical",
|
||||||
|
checklist_items: [],
|
||||||
|
qc_items: [],
|
||||||
|
flag: null,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeProject(over: Partial<Project> = {}): Project {
|
||||||
|
return {
|
||||||
|
id: 7,
|
||||||
|
number: "L999",
|
||||||
|
name: "CI project",
|
||||||
|
customer: "Test Co",
|
||||||
|
po: null,
|
||||||
|
due: null,
|
||||||
|
status: "open",
|
||||||
|
notes: null,
|
||||||
|
units: 1,
|
||||||
|
packets: { draft: 1, warehouse: 0, shop: 0, qc: 0, complete: 0, filed: 0, total: 1 },
|
||||||
|
version: 1,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeUnit(over: Partial<Unit> = {}): Unit {
|
||||||
|
return {
|
||||||
|
id: 11,
|
||||||
|
project_id: 7,
|
||||||
|
number: "L999001",
|
||||||
|
lot: "001",
|
||||||
|
name: null,
|
||||||
|
due: null,
|
||||||
|
due_effective: null,
|
||||||
|
status: "open",
|
||||||
|
notes: null,
|
||||||
|
version: 1,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -263,3 +263,90 @@ tr.rowlink.on td { background: var(--sel); }
|
|||||||
tr.rowlink:focus-visible { outline: 2px solid var(--cyan); outline-offset: -2px; }
|
tr.rowlink:focus-visible { outline: 2px solid var(--cyan); outline-offset: -2px; }
|
||||||
td .pill + .pill { margin-left: 4px; }
|
td .pill + .pill { margin-left: 4px; }
|
||||||
.lock .auth-card { max-height: calc(100dvh - 32px); overflow: auto; }
|
.lock .auth-card { max-height: calc(100dvh - 32px); overflow: auto; }
|
||||||
|
|
||||||
|
/* ===== New in the rebuild (phase 2) ===== */
|
||||||
|
/* Clerical home, projects and units, the packet page. Ported from the old .dhead, .facts, .projgrid, .pcard,
|
||||||
|
.stg, .pbar, .unitbar, .projhits, .crumbs, .due, .fltbar, .qrs, .balloon blocks, plus a few new helpers. */
|
||||||
|
.sheet.narrow { width: min(560px, 100%); }
|
||||||
|
.sec-h.flush { margin: 0; }
|
||||||
|
.chip.sm { min-height: 34px; padding: 4px 12px; font-size: 13.5px; }
|
||||||
|
.grow0 { min-width: 0; flex: 1 1 420px; }
|
||||||
|
.pre { white-space: pre-wrap; margin-top: 6px; }
|
||||||
|
.dhead { display: flex; gap: 18px; align-items: flex-end; justify-content: space-between; flex-wrap: wrap; padding-block: 6px 14px; border-bottom: 1px solid var(--line); margin-bottom: 12px; }
|
||||||
|
.dhead .facts { display: flex; gap: 6px 22px; flex-wrap: wrap; margin-top: 8px; font-size: 13.5px; }
|
||||||
|
.dhead .facts span { color: var(--muted); }
|
||||||
|
.dhead .facts b { color: var(--near); font-weight: 600; font-family: var(--f-mono); font-size: 13px; }
|
||||||
|
.dhead .pacts { align-items: center; }
|
||||||
|
.dtitle { color: var(--near); font-weight: 500; font-size: 17px; margin-top: 2px; }
|
||||||
|
.crumbs2 { margin: 4px 0 6px; }
|
||||||
|
.crumbs2 b { color: var(--near); }
|
||||||
|
.crumbs { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; font-size: 14px; }
|
||||||
|
.crumbs span { color: var(--muted); }
|
||||||
|
.crumbs b { color: var(--near); }
|
||||||
|
.due { font-weight: 600; }
|
||||||
|
.due.over { color: var(--pink); font-weight: 700; }
|
||||||
|
.due.soon { color: var(--amber); font-weight: 700; }
|
||||||
|
.due.muted { opacity: .7; }
|
||||||
|
.flagline { margin: 4px 0 8px; }
|
||||||
|
.card .dept { color: var(--cyan); font-weight: 600; }
|
||||||
|
.attn { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.fltbar { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; margin: 8px 0 16px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.fltrow { display: flex; gap: 8px 12px; flex-wrap: wrap; align-items: flex-end; }
|
||||||
|
.fltrow .f { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.fltrow .f > span { font-size: 11.5px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
|
||||||
|
.fltrow .fltq { flex: 3 1 320px; }
|
||||||
|
.fltrow .fltp { flex: 1.4 1 260px; }
|
||||||
|
.fltbtns { display: flex; gap: 8px; align-items: flex-end; flex: none; }
|
||||||
|
.fltrow2 { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.pick { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.pickp { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; color: var(--near); }
|
||||||
|
.pickp .small { font-weight: 700; letter-spacing: .06em; text-transform: uppercase; font-size: 11.5px; }
|
||||||
|
.projhits { display: flex; flex-direction: column; gap: 6px; max-height: 280px; overflow: auto; }
|
||||||
|
.projhit { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; color: var(--near); cursor: pointer; font: inherit; }
|
||||||
|
.projhit:hover, .projhit:focus-visible { border-color: var(--cyan); }
|
||||||
|
.projhit .pill { margin-left: auto; }
|
||||||
|
.projgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||||
|
.pcard .job { font-size: 20px; }
|
||||||
|
.stg { display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 12px; margin-top: 6px; }
|
||||||
|
.stg span { color: var(--near); }
|
||||||
|
.stg span.z { color: var(--muted); opacity: .6; }
|
||||||
|
.pbar { height: 8px; border-radius: 5px; background: var(--line2); overflow: hidden; display: flex; margin: 6px 0 2px; }
|
||||||
|
.pbar i { display: block; height: 100%; }
|
||||||
|
.pbar i.f { background: var(--cyan); }
|
||||||
|
.pbar i.c { background: var(--green); }
|
||||||
|
.unitbar { display: flex; gap: 10px 14px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
|
.unitlist .pbar { margin: 0; }
|
||||||
|
th.wide { min-width: 160px; }
|
||||||
|
table.edit td { padding: 6px; }
|
||||||
|
input.in.sm { min-height: 36px; padding: 6px 8px; font-size: 14px; }
|
||||||
|
input.in.num { text-align: right; font-family: var(--f-mono); }
|
||||||
|
.sheet-acts.start { justify-content: flex-start; align-items: center; }
|
||||||
|
.sigmean { border-left: 3px solid var(--amber); background: var(--panel); border-radius: 0 10px 10px 0; padding: 8px 12px; }
|
||||||
|
.sigmean .tt { color: var(--near); font-weight: 600; font-size: 16px; margin-top: 2px; }
|
||||||
|
.actbox { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.hmq { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.hmq b { color: var(--near); }
|
||||||
|
.acts { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-start; }
|
||||||
|
.act { display: flex; flex-direction: column; gap: 4px; max-width: 280px; }
|
||||||
|
.act .why { color: var(--amber); }
|
||||||
|
label.btn input.sr { pointer-events: none; }
|
||||||
|
.balloon { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; border: 1.5px solid var(--cyan); color: var(--cyan); font-family: var(--f-mono); font-size: 12.5px; font-weight: 600; }
|
||||||
|
.qrs { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
|
||||||
|
.qr { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; display: flex; gap: 16px; align-items: center; }
|
||||||
|
.qr .code { background: var(--white); padding: 10px; border-radius: 8px; flex: none; width: 148px; height: 148px; display: grid; place-items: center; color: var(--bg); }
|
||||||
|
.qr .code canvas { width: 128px !important; height: 128px !important; }
|
||||||
|
.qr .wo { font-family: var(--f-disp); font-weight: 700; font-size: 26px; color: var(--white); letter-spacing: .03em; }
|
||||||
|
.pdfv { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.pdfv-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.pdfv-bar .seg button { min-width: 44px; font-weight: 700; }
|
||||||
|
.pdfv-body { display: grid; grid-template-columns: 150px 1fr; gap: 12px; align-items: start; }
|
||||||
|
.pdfv-pages { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.pdfv-pages button { width: 100%; text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; color: var(--near); cursor: pointer; font: inherit; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||||
|
.pdfv-pages button[aria-current="page"] { border-color: var(--cyan); background: var(--sel); }
|
||||||
|
.pdfv-frame { overflow: auto; background: var(--col); border: 1px solid var(--line); border-radius: 10px; padding: 1px; min-height: 240px; max-height: calc(100dvh - 160px); }
|
||||||
|
.pdfv-canvas { display: block; background: var(--white); }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.pdfv-body { grid-template-columns: 1fr; }
|
||||||
|
.pdfv-pages { flex-direction: row; flex-wrap: wrap; }
|
||||||
|
.pdfv-pages button { width: auto; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ dependencies = [
|
|||||||
"apscheduler>=3.11,<4",
|
"apscheduler>=3.11,<4",
|
||||||
"httpx>=0.28",
|
"httpx>=0.28",
|
||||||
"tzdata>=2026.5",
|
"tzdata>=2026.5",
|
||||||
|
"pypdf==6.21.0",
|
||||||
|
"python-multipart==0.0.32",
|
||||||
"visualkit==0.3.0",
|
"visualkit==0.3.0",
|
||||||
"mfileskit==0.1.2",
|
"mfileskit==0.1.2",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Stored files: upload (multipart), metadata, and the same-origin blob route.
|
||||||
|
|
||||||
|
The upload only needs a session; the attach endpoints (packet pages, documents) check the policy. The form is read
|
||||||
|
after the session check, so a request without a session never gets its body spooled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
from starlette.datastructures import UploadFile
|
||||||
|
|
||||||
|
from ..domain import assets
|
||||||
|
from .deps import Db, Principal, current_principal, get_db, settings_of
|
||||||
|
from .errors import ApiError
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/assets", tags=["assets"])
|
||||||
|
blob_router = APIRouter(tags=["assets"])
|
||||||
|
|
||||||
|
# Multipart overhead allowed on top of the file limit before the body is refused unread.
|
||||||
|
FORM_SLACK = 64 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def upload(
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
s = settings_of(request)
|
||||||
|
length = request.headers.get("content-length")
|
||||||
|
if length is not None and length.isdigit() and int(length) > s.upload_max_bytes + FORM_SLACK:
|
||||||
|
raise ApiError(413, "too_large", f"The file is over the {s.upload_max_bytes // (1024 * 1024)} MB limit.")
|
||||||
|
form = await request.form(max_files=1, max_fields=8)
|
||||||
|
try:
|
||||||
|
file = form.get("file")
|
||||||
|
if not isinstance(file, UploadFile):
|
||||||
|
raise ApiError(422, "invalid_request", "Send the file in the 'file' field.")
|
||||||
|
packet_id: int | None = None
|
||||||
|
raw_pid = form.get("packet_id")
|
||||||
|
if isinstance(raw_pid, str) and raw_pid.strip():
|
||||||
|
if not raw_pid.strip().isdigit():
|
||||||
|
raise ApiError(422, "invalid_request", "packet_id must be a number.")
|
||||||
|
packet_id = int(raw_pid.strip())
|
||||||
|
exists = await run_in_threadpool(
|
||||||
|
lambda: d.conn.execute("SELECT 1 FROM packets WHERE id = %s", (packet_id,)).fetchone()
|
||||||
|
)
|
||||||
|
if exists is None:
|
||||||
|
raise ApiError(404, "not_found", "No such packet.")
|
||||||
|
purpose = form.get("purpose")
|
||||||
|
ref = {"purpose": str(purpose)[:40]} if isinstance(purpose, str) and purpose.strip() else None
|
||||||
|
upload_file = file
|
||||||
|
return await run_in_threadpool(
|
||||||
|
lambda: assets.store_upload(
|
||||||
|
d.conn,
|
||||||
|
s,
|
||||||
|
upload_file.file,
|
||||||
|
original_name=upload_file.filename,
|
||||||
|
user=p.user_id,
|
||||||
|
packet_id=packet_id,
|
||||||
|
source_ref=None if ref is None else Jsonb(ref),
|
||||||
|
actor=p,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await form.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{asset_id}")
|
||||||
|
def metadata(
|
||||||
|
asset_id: str,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = assets.get(d.conn, asset_id)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "not_found", "No such file.")
|
||||||
|
return assets.public(row)
|
||||||
|
|
||||||
|
|
||||||
|
@blob_router.get("/_blob/{asset_id}")
|
||||||
|
def blob(
|
||||||
|
asset_id: str,
|
||||||
|
request: Request,
|
||||||
|
download: int = Query(0, ge=0, le=1),
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> Response:
|
||||||
|
row = assets.get(d.conn, asset_id)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "not_found", "No such file.")
|
||||||
|
path = assets.open_blob(settings_of(request), row)
|
||||||
|
etag = '"' + bytes(row["sha256"]).hex() + '"'
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": f'{"attachment" if download else "inline"}; filename="{assets.download_name(row)}"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Cache-Control": "private, max-age=3600",
|
||||||
|
"ETag": etag,
|
||||||
|
}
|
||||||
|
if request.headers.get("if-none-match") == etag:
|
||||||
|
return Response(status_code=304, headers=headers)
|
||||||
|
return FileResponse(path, media_type=str(row["mime"]), headers=headers)
|
||||||
@@ -15,12 +15,15 @@ router = APIRouter(prefix="/api", tags=["departments"])
|
|||||||
def departments(_: Principal = Depends(current_principal), d: Db = Depends(get_db)) -> list[dict[str, Any]]: # noqa: B008
|
def departments(_: Principal = Depends(current_principal), d: Db = Depends(get_db)) -> list[dict[str, Any]]: # noqa: B008
|
||||||
conn = d.conn
|
conn = d.conn
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT code, slug, name, kind::text FROM departments WHERE active ORDER BY sort, code"
|
"SELECT code, slug, name, kind::text, weld FROM departments WHERE active ORDER BY sort, code"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
stations = conn.execute(
|
stations = conn.execute(
|
||||||
"SELECT dept_code, name FROM stations WHERE active ORDER BY dept_code, sort, name"
|
"SELECT dept_code, id, name FROM stations WHERE active ORDER BY dept_code, sort, name"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
by_dept: dict[str, list[str]] = {}
|
by_dept: dict[str, list[dict[str, Any]]] = {}
|
||||||
for code, name in stations:
|
for code, sid, name in stations:
|
||||||
by_dept.setdefault(code, []).append(name)
|
by_dept.setdefault(code, []).append({"id": int(sid), "name": name})
|
||||||
return [{"code": r[0], "slug": r[1], "name": r[2], "kind": r[3], "stations": by_dept.get(r[0], [])} for r in rows]
|
return [
|
||||||
|
{"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
|
||||||
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,945 @@
|
|||||||
|
"""Packets: the boards, the read model, the hand draft, header edits, transitions, pages, documents, move, remove,
|
||||||
|
the VISUAL close, the data book date, reopen, the seal and the log.
|
||||||
|
|
||||||
|
Every write goes through domain.pipeline.packet_write: one transaction, the packet locked first, the policy decides.
|
||||||
|
Every mutating answer is the full read model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .. import audit, events, policy
|
||||||
|
from ..domain import assets as asset_store
|
||||||
|
from ..domain import packets
|
||||||
|
from ..domain.pipeline import (
|
||||||
|
Expect,
|
||||||
|
SignBody,
|
||||||
|
apply_field_cas,
|
||||||
|
apply_transition,
|
||||||
|
next_counter,
|
||||||
|
packet_write,
|
||||||
|
require_if_match,
|
||||||
|
verify_seal,
|
||||||
|
)
|
||||||
|
from ..text import LIMITS, MAX_BODY_BYTES, clean_opt, clean_text, reason_ok
|
||||||
|
from .deps import Db, Principal, current_principal, get_db, settings_of
|
||||||
|
from .errors import ApiError
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/packets", tags=["packets"])
|
||||||
|
|
||||||
|
A = policy.Action
|
||||||
|
|
||||||
|
MSG_FORBIDDEN = "Your access doesn't allow this step."
|
||||||
|
WO_PART_MAX = 40
|
||||||
|
|
||||||
|
|
||||||
|
def body_limit(request: Request) -> None:
|
||||||
|
"""413 too_large for a JSON body over 200 000 bytes, by its Content-Length, before it is read."""
|
||||||
|
length = request.headers.get("content-length")
|
||||||
|
if length is not None and length.strip().isdigit() and int(length) > MAX_BODY_BYTES:
|
||||||
|
raise ApiError(413, "too_large", "The request is too large.")
|
||||||
|
|
||||||
|
|
||||||
|
WRITE = [Depends(body_limit)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- bodies
|
||||||
|
|
||||||
|
|
||||||
|
class WorkOrderIn(BaseModel):
|
||||||
|
wo_base: str = Field(min_length=1, max_length=WO_PART_MAX)
|
||||||
|
lot: str = Field(default="", max_length=WO_PART_MAX)
|
||||||
|
split: str = Field(default="", max_length=WO_PART_MAX)
|
||||||
|
sub: str = Field(default="", max_length=WO_PART_MAX)
|
||||||
|
seq: int | None = None
|
||||||
|
qty: Decimal | None = None
|
||||||
|
target_hrs: Decimal | None = None
|
||||||
|
op: str | None = Field(default=None, max_length=200)
|
||||||
|
description: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class BomIn(BaseModel):
|
||||||
|
item: int = Field(ge=1, le=100_000)
|
||||||
|
qty: Decimal | None = None
|
||||||
|
part_id: str | None = Field(default=None, max_length=200)
|
||||||
|
description: str | None = Field(default=None, max_length=1000)
|
||||||
|
material: str | None = Field(default=None, max_length=500)
|
||||||
|
length: str | None = Field(default=None, max_length=200)
|
||||||
|
dwg_ref: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateBody(BaseModel):
|
||||||
|
unit_id: int
|
||||||
|
dept_code: str = Field(max_length=10)
|
||||||
|
op: str | None = Field(default=None, max_length=200)
|
||||||
|
title: str | None = Field(default=None, max_length=1000)
|
||||||
|
customer: str | None = Field(default=None, max_length=500)
|
||||||
|
drawing: str | None = Field(default=None, max_length=200)
|
||||||
|
rev: str | None = Field(default=None, max_length=100)
|
||||||
|
wps: str | None = Field(default=None, max_length=200)
|
||||||
|
heat_map: bool | None = None
|
||||||
|
work_orders: list[WorkOrderIn] = Field(default_factory=list, max_length=300)
|
||||||
|
bom: list[BomIn] = Field(default_factory=list, max_length=1000)
|
||||||
|
it_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class PatchBody(BaseModel):
|
||||||
|
set: dict[str, Any]
|
||||||
|
base: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
it_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class TransitionBody(BaseModel):
|
||||||
|
action: str = Field(max_length=40)
|
||||||
|
expect: Expect
|
||||||
|
reason: str | None = Field(default=None, max_length=2000)
|
||||||
|
note: str | None = Field(default=None, max_length=2000)
|
||||||
|
dept: str | None = Field(default=None, max_length=10)
|
||||||
|
station_id: int | None = None
|
||||||
|
welder_id: uuid.UUID | None = None
|
||||||
|
stamp: str | None = Field(default=None, max_length=40)
|
||||||
|
routed_to: Literal["databook", "shipping"] | None = None
|
||||||
|
book: bool | None = None
|
||||||
|
spool: bool | None = None
|
||||||
|
location: str | None = Field(default=None, max_length=500)
|
||||||
|
for_sup_id: uuid.UUID | None = None
|
||||||
|
sign: SignBody | None = None
|
||||||
|
it_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkOrdersBody(BaseModel):
|
||||||
|
work_orders: list[WorkOrderIn] = Field(max_length=300)
|
||||||
|
it_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class PagesBody(BaseModel):
|
||||||
|
asset_id: uuid.UUID
|
||||||
|
type: Literal["drawing"] = "drawing"
|
||||||
|
dwg: str | None = Field(default=None, max_length=200)
|
||||||
|
rev: str | None = Field(default=None, max_length=100)
|
||||||
|
sign: SignBody | None = None
|
||||||
|
it_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class ReasonBody(BaseModel):
|
||||||
|
reason: str | None = Field(default=None, max_length=2000)
|
||||||
|
it_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class DocBody(BaseModel):
|
||||||
|
asset_id: uuid.UUID
|
||||||
|
kind: str = Field(min_length=1, max_length=100)
|
||||||
|
title: str | None = Field(default=None, max_length=500)
|
||||||
|
it_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class MoveBody(BaseModel):
|
||||||
|
unit_id: int
|
||||||
|
reason: str | None = Field(default=None, max_length=2000)
|
||||||
|
it_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class VclosedBody(BaseModel):
|
||||||
|
closed: bool
|
||||||
|
it_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class ItBody(BaseModel):
|
||||||
|
it_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class ReopenBody(BaseModel):
|
||||||
|
reason: str | None = Field(default=None, max_length=2000)
|
||||||
|
sign: SignBody | None = None
|
||||||
|
it_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- validation helpers
|
||||||
|
|
||||||
|
|
||||||
|
def shop_dept(conn: Any, code: Any) -> str:
|
||||||
|
"""An active shop department code, or 422 invalid_dept."""
|
||||||
|
if isinstance(code, str):
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT code FROM departments WHERE code = %s AND kind = 'shop' AND active", (code.strip(),)
|
||||||
|
).fetchone()
|
||||||
|
if r is not None:
|
||||||
|
return str(r[0])
|
||||||
|
raise ApiError(422, "invalid_dept", "Choose an active shop department.")
|
||||||
|
|
||||||
|
|
||||||
|
def active_user(conn: Any, uid: Any, what: str) -> str:
|
||||||
|
if uid is not None:
|
||||||
|
try:
|
||||||
|
u = uuid.UUID(str(uid))
|
||||||
|
except ValueError:
|
||||||
|
u = None
|
||||||
|
if u is not None and conn.execute("SELECT 1 FROM users WHERE id = %s AND active", (u,)).fetchone():
|
||||||
|
return str(u)
|
||||||
|
raise ApiError(422, "invalid_user", f"Choose an active user for the {what}.")
|
||||||
|
|
||||||
|
|
||||||
|
def open_unit(conn: Any, unit_id: int) -> dict[str, Any]:
|
||||||
|
"""A live unit that is open in a project that is not closed. 404 unknown, 409 unit_closed."""
|
||||||
|
u = packets.one(
|
||||||
|
conn,
|
||||||
|
"SELECT u.id, u.status::text AS status, u.removed_at, pr.status::text AS project_status,"
|
||||||
|
" pr.removed_at AS project_removed, pr.number || u.lot AS number"
|
||||||
|
" FROM units u JOIN projects pr ON pr.id = u.project_id WHERE u.id = %s",
|
||||||
|
(unit_id,),
|
||||||
|
)
|
||||||
|
if u is None or u["removed_at"] is not None or u["project_removed"] is not None:
|
||||||
|
raise ApiError(404, "not_found", "No such unit.")
|
||||||
|
if u["status"] != "open" or u["project_status"] == "closed":
|
||||||
|
raise ApiError(409, "unit_closed", "That unit is not open. Open it or pick another unit.")
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
def clean_work_orders(items: list[WorkOrderIn]) -> list[dict[str, Any]]:
|
||||||
|
"""Work order keys stay text (leading zeros kept). Duplicate keys are 422."""
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str, str, str]] = set()
|
||||||
|
for i, w in enumerate(items):
|
||||||
|
key = (
|
||||||
|
clean_text(w.wo_base, WO_PART_MAX).upper(),
|
||||||
|
clean_text(w.lot, WO_PART_MAX).upper(),
|
||||||
|
clean_text(w.split, WO_PART_MAX).upper(),
|
||||||
|
clean_text(w.sub, WO_PART_MAX).upper(),
|
||||||
|
)
|
||||||
|
if not key[0]:
|
||||||
|
raise ApiError(422, "invalid_request", f"Work order {i + 1} needs a work order number.")
|
||||||
|
if key in seen:
|
||||||
|
raise ApiError(422, "duplicate_work_order", f"Work order {'/'.join(key)} is listed twice.")
|
||||||
|
seen.add(key)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"wo_base": key[0],
|
||||||
|
"lot": key[1],
|
||||||
|
"split": key[2],
|
||||||
|
"sub": key[3],
|
||||||
|
"seq": w.seq,
|
||||||
|
"qty": w.qty,
|
||||||
|
"target_hrs": w.target_hrs,
|
||||||
|
"op": clean_opt(w.op, LIMITS["op"]),
|
||||||
|
"description": clean_opt(w.description, LIMITS["description"]),
|
||||||
|
"sort": i,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def insert_work_orders(conn: Any, packet_id: int, wos: list[dict[str, Any]]) -> None:
|
||||||
|
for w in wos:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO packet_work_orders (packet_id, wo_base, lot, split, sub, seq, qty, target_hrs, op,"
|
||||||
|
" description, sort) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
|
||||||
|
(
|
||||||
|
packet_id,
|
||||||
|
w["wo_base"],
|
||||||
|
w["lot"],
|
||||||
|
w["split"],
|
||||||
|
w["sub"],
|
||||||
|
w["seq"],
|
||||||
|
w["qty"],
|
||||||
|
w["target_hrs"],
|
||||||
|
w["op"],
|
||||||
|
w["description"],
|
||||||
|
w["sort"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_bom(items: list[BomIn]) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for i, b in enumerate(items):
|
||||||
|
if b.item in seen:
|
||||||
|
raise ApiError(422, "duplicate_item", f"BOM item {b.item} is listed twice.")
|
||||||
|
seen.add(b.item)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"item": b.item,
|
||||||
|
"qty": b.qty,
|
||||||
|
"part_id": clean_opt(b.part_id, LIMITS["part_id"]),
|
||||||
|
"description": clean_opt(b.description, LIMITS["description"]),
|
||||||
|
"material": clean_opt(b.material, LIMITS["material"]),
|
||||||
|
"length": clean_opt(b.length, LIMITS["length"]),
|
||||||
|
"dwg_ref": clean_opt(b.dwg_ref, LIMITS["drawing"]),
|
||||||
|
"sort": i,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _read(d: Db, pp: policy.Principal, packet_id: int) -> dict[str, Any]:
|
||||||
|
return packets.read_packet(d.conn, packet_id, pp)
|
||||||
|
|
||||||
|
|
||||||
|
def _pp(d: Db, p: Principal) -> policy.Principal:
|
||||||
|
return packets.policy_principal_for(d.conn, p)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- list and read
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_packets(
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
status: str = Query("", max_length=200),
|
||||||
|
dept: str = Query("", max_length=10),
|
||||||
|
unit_id: int | None = Query(None),
|
||||||
|
project_id: int | None = Query(None),
|
||||||
|
q: str = Query("", max_length=200),
|
||||||
|
flag: str = Query("", max_length=20),
|
||||||
|
limit: int = Query(500, ge=1, le=2000),
|
||||||
|
removed: int = Query(0, ge=0, le=1),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
filters = {
|
||||||
|
"status": status,
|
||||||
|
"dept": dept.strip() or None,
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"q": clean_text(q, 200),
|
||||||
|
"flag": flag,
|
||||||
|
"limit": limit,
|
||||||
|
"removed": bool(removed) and p.is_admin,
|
||||||
|
}
|
||||||
|
return {"packets": packets.list_cards(d.conn, _pp(d, p), filters)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201, dependencies=WRITE)
|
||||||
|
def create(
|
||||||
|
body: CreateBody,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""A hand draft (Clerical; Admin with an IT reason)."""
|
||||||
|
conn = d.conn
|
||||||
|
pp = _pp(d, p)
|
||||||
|
it_reason: str | None = None
|
||||||
|
if "CL" not in pp.support:
|
||||||
|
if not pp.admin:
|
||||||
|
raise ApiError(403, "forbidden", MSG_FORBIDDEN)
|
||||||
|
it_reason = reason_ok(body.it_reason)
|
||||||
|
if it_reason is None:
|
||||||
|
raise ApiError(400, "it_reason_required", "Give an IT reason of at least 8 characters.")
|
||||||
|
dept = shop_dept(conn, body.dept_code)
|
||||||
|
wos = clean_work_orders(body.work_orders)
|
||||||
|
bom = clean_bom(body.bom)
|
||||||
|
with conn.transaction():
|
||||||
|
open_unit(conn, body.unit_id)
|
||||||
|
pid = int(
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO packets (unit_id, dept_code, op, title, customer, drawing, rev, wps, heat_map,"
|
||||||
|
" source_kind, source, drawing_state, flags, created_by)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'hand', %s, 'none', %s, %s) RETURNING id",
|
||||||
|
(
|
||||||
|
body.unit_id,
|
||||||
|
dept,
|
||||||
|
clean_opt(body.op, LIMITS["op"]),
|
||||||
|
clean_opt(body.title, LIMITS["title"]),
|
||||||
|
clean_opt(body.customer, LIMITS["customer"]),
|
||||||
|
clean_opt(body.drawing, LIMITS["drawing"]),
|
||||||
|
clean_opt(body.rev, LIMITS["rev"]),
|
||||||
|
clean_opt(body.wps, LIMITS["wps"]),
|
||||||
|
body.heat_map,
|
||||||
|
Jsonb({"kind": "hand"}),
|
||||||
|
["hand"],
|
||||||
|
p.user_id,
|
||||||
|
),
|
||||||
|
).fetchone()[0]
|
||||||
|
)
|
||||||
|
insert_work_orders(conn, pid, wos)
|
||||||
|
for b in bom:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO bom_lines (packet_id, item, qty, part_id, description, material, length, dwg_ref, src,"
|
||||||
|
" sort) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'hand', %s)",
|
||||||
|
(
|
||||||
|
pid,
|
||||||
|
b["item"],
|
||||||
|
b["qty"],
|
||||||
|
b["part_id"],
|
||||||
|
b["description"],
|
||||||
|
b["material"],
|
||||||
|
b["length"],
|
||||||
|
b["dwg_ref"],
|
||||||
|
b["sort"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO packet_log (packet_id, by, by_name, role, action, it_reason) VALUES (%s, %s, %s, %s, %s, %s)",
|
||||||
|
(pid, p.user_id, p.user["display_name"], p.role_snapshot, "Packet created by hand", it_reason),
|
||||||
|
)
|
||||||
|
audit.append(
|
||||||
|
conn,
|
||||||
|
action="packet.created",
|
||||||
|
user_id=p.user_id,
|
||||||
|
session_id=p.session_id,
|
||||||
|
role_snapshot=p.role_snapshot,
|
||||||
|
subject_type="packet",
|
||||||
|
subject_id=pid,
|
||||||
|
packet_id=pid,
|
||||||
|
detail={"source": "hand", "unit_id": body.unit_id, "dept": dept, "work_orders": len(wos), "bom": len(bom)},
|
||||||
|
it_reason=it_reason,
|
||||||
|
ip=p.ip,
|
||||||
|
)
|
||||||
|
events.publish(conn, {"event": "changed", "packet_id": pid, "version": 1, "status": "draft", "dept": dept})
|
||||||
|
return _read(d, pp, pid)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{packet_id}")
|
||||||
|
def read(
|
||||||
|
packet_id: int,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return _read(d, _pp(d, p), packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- header edits
|
||||||
|
|
||||||
|
|
||||||
|
HEADER_FIELDS: dict[str, tuple[policy.Action, str]] = {
|
||||||
|
"title": (A.EDIT_HEADER, "title"),
|
||||||
|
"customer": (A.EDIT_HEADER, "customer"),
|
||||||
|
"wps": (A.EDIT_HEADER, "wps"),
|
||||||
|
"op": (A.EDIT_HEADER, "op"),
|
||||||
|
"heat_map": (A.EDIT_HEADER, ""),
|
||||||
|
"for_sup_id": (A.EDIT_HEADER, ""),
|
||||||
|
"drawing": (A.EDIT_DRAWING_REF, "drawing"),
|
||||||
|
"rev": (A.EDIT_DRAWING_REF, "rev"),
|
||||||
|
"dept_code": (A.EDIT_DEPT, ""),
|
||||||
|
}
|
||||||
|
ACTION_ORDER = (A.EDIT_HEADER, A.EDIT_DEPT, A.EDIT_DRAWING_REF)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_header(conn: Any, key: str, value: Any) -> Any:
|
||||||
|
if key == "heat_map":
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
raise ApiError(422, "invalid_request", "heat_map must be true, false or null.")
|
||||||
|
if key == "for_sup_id":
|
||||||
|
return None if value in (None, "") else active_user(conn, value, "supervisor")
|
||||||
|
if key == "dept_code":
|
||||||
|
return shop_dept(conn, value)
|
||||||
|
if value is not None and not isinstance(value, str):
|
||||||
|
raise ApiError(422, "invalid_request", f"{key} must be text.")
|
||||||
|
return clean_opt(value, LIMITS[HEADER_FIELDS[key][1]])
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{packet_id}", dependencies=WRITE)
|
||||||
|
def patch_header(
|
||||||
|
packet_id: int,
|
||||||
|
body: PatchBody,
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not body.set:
|
||||||
|
raise ApiError(422, "invalid_request", "Nothing to change.")
|
||||||
|
unknown = sorted(k for k in body.set if k not in HEADER_FIELDS)
|
||||||
|
if unknown:
|
||||||
|
raise ApiError(422, "invalid_request", f"These fields cannot be changed here: {', '.join(unknown)}.")
|
||||||
|
conn = d.conn
|
||||||
|
pp = _pp(d, p)
|
||||||
|
wanted = {HEADER_FIELDS[k][0] for k in body.set}
|
||||||
|
actions = [a for a in ACTION_ORDER if a in wanted]
|
||||||
|
with packet_write(
|
||||||
|
d, p, packet_id, actions[0], request=request, it_reason=body.it_reason, also=actions[1:], policy_principal=pp
|
||||||
|
) as w:
|
||||||
|
cleaned = {k: _clean_header(conn, k, v) for k, v in body.set.items()}
|
||||||
|
row = w.packet
|
||||||
|
current = {k: (packets.uid_text(row[k]) if k == "for_sup_id" else row[k]) for k in HEADER_FIELDS}
|
||||||
|
changes = apply_field_cas(current, cleaned, body.base)
|
||||||
|
if not changes:
|
||||||
|
w.skip()
|
||||||
|
else:
|
||||||
|
cols = ", ".join(f"{k} = %s" for k in changes)
|
||||||
|
conn.execute(f"UPDATE packets SET {cols} WHERE id = %s", (*changes.values(), packet_id)) # noqa: S608
|
||||||
|
w.changed(*changes)
|
||||||
|
if set(changes) == {"heat_map"}:
|
||||||
|
w.log("Heat map required" if changes["heat_map"] else "Heat map not required")
|
||||||
|
else:
|
||||||
|
w.note = ", ".join(sorted(changes))
|
||||||
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- transitions
|
||||||
|
|
||||||
|
|
||||||
|
# The request fields each transition carries. Anything else in the body is ignored.
|
||||||
|
TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
|
||||||
|
A.SEND_TO_WAREHOUSE: ("note",),
|
||||||
|
A.RELEASE_TO_SHOP: ("dept", "for_sup_id", "note"),
|
||||||
|
A.SEND_DRAFT_TO_QC: ("note",),
|
||||||
|
A.WH_SEND_BACK: ("reason",),
|
||||||
|
A.RETURN_TO_CLERICAL: ("reason",),
|
||||||
|
A.WH_RELEASE: ("note",),
|
||||||
|
A.SEND_BACK_TO_WAREHOUSE: ("reason",),
|
||||||
|
A.ASSIGN: ("station_id", "welder_id", "stamp"),
|
||||||
|
A.UNASSIGN: ("note",),
|
||||||
|
A.PULL_BACK: ("note",),
|
||||||
|
A.START: ("note",),
|
||||||
|
A.SUBMIT: ("note",),
|
||||||
|
A.RETURN_TO_STATION: ("reason",),
|
||||||
|
A.SEND_TO_QC: ("book", "spool", "location", "note"),
|
||||||
|
A.ROUTE_FROM_REVIEW: ("dept", "note"),
|
||||||
|
A.QC_REJECT: ("reason",),
|
||||||
|
A.QC_ACCEPT: ("routed_to", "note"),
|
||||||
|
A.QC_ACCEPT_AND_ROUTE: ("dept", "note"),
|
||||||
|
A.FILE: ("note",),
|
||||||
|
A.ADMIN_REOPEN: ("reason",),
|
||||||
|
}
|
||||||
|
DEPT_ACTIONS = (A.RELEASE_TO_SHOP, A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE)
|
||||||
|
|
||||||
|
|
||||||
|
def _transition_extra(action: policy.Action, body: TransitionBody) -> dict[str, Any]:
|
||||||
|
extra: dict[str, Any] = {}
|
||||||
|
for name in TRANSITION_FIELDS[action]:
|
||||||
|
value = getattr(body, name)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
extra[name] = str(value) if isinstance(value, uuid.UUID) else value
|
||||||
|
return extra
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{packet_id}/transitions", dependencies=WRITE)
|
||||||
|
def transition(
|
||||||
|
packet_id: int,
|
||||||
|
body: TransitionBody,
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
action = A(body.action)
|
||||||
|
except ValueError:
|
||||||
|
action = None
|
||||||
|
if action is None or action not in TRANSITION_FIELDS:
|
||||||
|
raise ApiError(422, "invalid_action", "That is not a packet step.")
|
||||||
|
conn = d.conn
|
||||||
|
pp = _pp(d, p)
|
||||||
|
extra = _transition_extra(action, body)
|
||||||
|
if action in DEPT_ACTIONS and "dept" in extra:
|
||||||
|
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")
|
||||||
|
content = {"action": str(action), "packet_id": packet_id, "expect": body.expect.model_dump(), **extra}
|
||||||
|
with packet_write(
|
||||||
|
d,
|
||||||
|
p,
|
||||||
|
packet_id,
|
||||||
|
action,
|
||||||
|
request=request,
|
||||||
|
extra=extra,
|
||||||
|
it_reason=body.it_reason,
|
||||||
|
sign=body.sign,
|
||||||
|
expect=body.expect,
|
||||||
|
sign_content=content,
|
||||||
|
policy_principal=pp,
|
||||||
|
) as w:
|
||||||
|
if action is A.ASSIGN:
|
||||||
|
ok = conn.execute(
|
||||||
|
"SELECT 1 FROM stations WHERE id = %s AND dept_code = %s AND active",
|
||||||
|
(extra["station_id"], w.state.dept),
|
||||||
|
).fetchone()
|
||||||
|
if ok is None:
|
||||||
|
raise ApiError(422, "invalid_station", "Choose a station in this department.")
|
||||||
|
active_user(conn, extra["welder_id"], "welder")
|
||||||
|
apply_transition(w)
|
||||||
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{packet_id}/reopen", dependencies=WRITE)
|
||||||
|
def reopen(
|
||||||
|
packet_id: int,
|
||||||
|
body: ReopenBody,
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
pp = _pp(d, p)
|
||||||
|
extra = {} if body.reason is None else {"reason": body.reason}
|
||||||
|
content = {"action": str(A.ADMIN_REOPEN), "packet_id": packet_id, "reason": reason_ok(body.reason)}
|
||||||
|
with packet_write(
|
||||||
|
d,
|
||||||
|
p,
|
||||||
|
packet_id,
|
||||||
|
A.ADMIN_REOPEN,
|
||||||
|
request=request,
|
||||||
|
extra=extra,
|
||||||
|
it_reason=body.it_reason,
|
||||||
|
sign=body.sign,
|
||||||
|
sign_content=content,
|
||||||
|
policy_principal=pp,
|
||||||
|
) as w:
|
||||||
|
apply_transition(w)
|
||||||
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- work orders
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{packet_id}/work-orders", dependencies=WRITE)
|
||||||
|
def put_work_orders(
|
||||||
|
packet_id: int,
|
||||||
|
body: WorkOrdersBody,
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if_match = require_if_match(request)
|
||||||
|
wos = clean_work_orders(body.work_orders)
|
||||||
|
pp = _pp(d, p)
|
||||||
|
with packet_write(
|
||||||
|
d, p, packet_id, A.EDIT_WOS, request=request, it_reason=body.it_reason, if_match=if_match, policy_principal=pp
|
||||||
|
) as w:
|
||||||
|
d.conn.execute("DELETE FROM packet_work_orders WHERE packet_id = %s", (packet_id,))
|
||||||
|
insert_work_orders(d.conn, packet_id, wos)
|
||||||
|
w.changed("work_orders")
|
||||||
|
w.note = f"{len(wos)} work order{'s' if len(wos) != 1 else ''}"
|
||||||
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- drawing pages
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{packet_id}/pages", dependencies=WRITE)
|
||||||
|
def attach_pages(
|
||||||
|
packet_id: int,
|
||||||
|
body: PagesBody,
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
conn = d.conn
|
||||||
|
asset = asset_store.get(conn, body.asset_id)
|
||||||
|
if asset is None:
|
||||||
|
raise ApiError(404, "not_found", "No such file.")
|
||||||
|
if asset["mime"] != asset_store.PDF or not asset["pages"]:
|
||||||
|
raise ApiError(422, "not_pdf", "Attach a PDF drawing.")
|
||||||
|
pp = _pp(d, p)
|
||||||
|
dwg = clean_opt(body.dwg, LIMITS["drawing"])
|
||||||
|
rev = clean_opt(body.rev, LIMITS["rev"])
|
||||||
|
content = {
|
||||||
|
"action": str(A.EDIT_DOCUMENT_SET),
|
||||||
|
"packet_id": packet_id,
|
||||||
|
"asset_id": str(asset["id"]),
|
||||||
|
"sha256": bytes(asset["sha256"]).hex(),
|
||||||
|
"pages": asset["pages"],
|
||||||
|
"dwg": dwg,
|
||||||
|
"rev": rev,
|
||||||
|
}
|
||||||
|
with packet_write(
|
||||||
|
d,
|
||||||
|
p,
|
||||||
|
packet_id,
|
||||||
|
A.EDIT_DOCUMENT_SET,
|
||||||
|
request=request,
|
||||||
|
it_reason=body.it_reason,
|
||||||
|
sign=body.sign,
|
||||||
|
sign_content=content,
|
||||||
|
policy_principal=pp,
|
||||||
|
) as w:
|
||||||
|
dup = conn.execute(
|
||||||
|
"SELECT 1 FROM packet_pages WHERE packet_id = %s AND asset_id = %s AND void_at IS NULL",
|
||||||
|
(packet_id, asset["id"]),
|
||||||
|
).fetchone()
|
||||||
|
if dup is not None:
|
||||||
|
raise ApiError(409, "already_attached", "That drawing is already attached.")
|
||||||
|
count = int(asset["pages"])
|
||||||
|
first = next_counter(conn, packet_id, "page", count)
|
||||||
|
for i in range(count):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO packet_pages (packet_id, n, type, asset_id, pn, dwg, rev)"
|
||||||
|
" VALUES (%s, %s, 'drawing', %s, %s, %s, %s)",
|
||||||
|
(packet_id, first + i, asset["id"], i + 1, dwg or w.packet["drawing"], rev or w.packet["rev"]),
|
||||||
|
)
|
||||||
|
state = "hand" if w.packet["source_kind"] == "hand" else "attached"
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE packets SET drawing_state = %s::drawing_state, flags = array_remove(flags, 'drawing_needed')"
|
||||||
|
" WHERE id = %s",
|
||||||
|
(state, packet_id),
|
||||||
|
)
|
||||||
|
w.changed("pages", "drawing_state")
|
||||||
|
name = asset["original_name"] or "drawing"
|
||||||
|
w.note = f"{name}, {count} page{'s' if count != 1 else ''}"
|
||||||
|
w.detail["asset_id"] = str(asset["id"])
|
||||||
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{packet_id}/pages/{n}", dependencies=WRITE)
|
||||||
|
def remove_pages(
|
||||||
|
packet_id: int,
|
||||||
|
n: int,
|
||||||
|
request: Request,
|
||||||
|
body: ReasonBody | None = None,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
body = body or ReasonBody()
|
||||||
|
conn = d.conn
|
||||||
|
pp = _pp(d, p)
|
||||||
|
with packet_write(
|
||||||
|
d,
|
||||||
|
p,
|
||||||
|
packet_id,
|
||||||
|
A.EDIT_DOCUMENT_SET,
|
||||||
|
request=request,
|
||||||
|
it_reason=body.it_reason,
|
||||||
|
require_sign=False,
|
||||||
|
policy_principal=pp,
|
||||||
|
) as w:
|
||||||
|
if w.packet["status"] != "draft":
|
||||||
|
raise ApiError(403, "wrong_status", "Drawing pages can be removed only while the packet is a draft.")
|
||||||
|
page = packets.one(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM packet_pages WHERE packet_id = %s AND n = %s AND void_at IS NULL AND co_id IS NULL",
|
||||||
|
(packet_id, n),
|
||||||
|
)
|
||||||
|
if page is None or page["asset_id"] is None:
|
||||||
|
raise ApiError(404, "not_found", "No such page.")
|
||||||
|
gone = conn.execute(
|
||||||
|
"DELETE FROM packet_pages WHERE packet_id = %s AND asset_id = %s AND void_at IS NULL AND co_id IS NULL",
|
||||||
|
(packet_id, page["asset_id"]),
|
||||||
|
).rowcount
|
||||||
|
left = conn.execute(
|
||||||
|
"SELECT 1 FROM packet_pages WHERE packet_id = %s AND type = 'drawing' AND void_at IS NULL", (packet_id,)
|
||||||
|
).fetchone()
|
||||||
|
if left is None:
|
||||||
|
conn.execute("UPDATE packets SET drawing_state = 'none' WHERE id = %s", (packet_id,))
|
||||||
|
w.changed("pages")
|
||||||
|
w.log(
|
||||||
|
"Drawing removed",
|
||||||
|
", ".join(
|
||||||
|
x for x in (f"{gone} page{'s' if gone != 1 else ''}", clean_opt(body.reason, LIMITS["reason"])) if x
|
||||||
|
),
|
||||||
|
)
|
||||||
|
w.detail["asset_id"] = str(page["asset_id"])
|
||||||
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- documents
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{packet_id}/docs", dependencies=WRITE)
|
||||||
|
def add_doc(
|
||||||
|
packet_id: int,
|
||||||
|
body: DocBody,
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
conn = d.conn
|
||||||
|
asset = asset_store.get(conn, body.asset_id)
|
||||||
|
if asset is None:
|
||||||
|
raise ApiError(404, "not_found", "No such file.")
|
||||||
|
kind = clean_text(body.kind, 40)
|
||||||
|
if not kind:
|
||||||
|
raise ApiError(422, "invalid_request", "Say what kind of document this is.")
|
||||||
|
title = clean_opt(body.title, LIMITS["doc_title"])
|
||||||
|
pp = _pp(d, p)
|
||||||
|
with packet_write(d, p, packet_id, A.DOC_ADD, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
|
||||||
|
doc_id = conn.execute(
|
||||||
|
"INSERT INTO packet_docs (packet_id, asset_id, kind, title, added_by) VALUES (%s, %s, %s, %s, %s)"
|
||||||
|
" RETURNING id",
|
||||||
|
(packet_id, asset["id"], kind, title, p.user_id),
|
||||||
|
).fetchone()[0]
|
||||||
|
w.changed("docs")
|
||||||
|
w.note = title or kind
|
||||||
|
w.detail.update({"doc_id": int(doc_id), "asset_id": str(asset["id"]), "kind": kind})
|
||||||
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{packet_id}/docs/{doc_id}", dependencies=WRITE)
|
||||||
|
def remove_doc(
|
||||||
|
packet_id: int,
|
||||||
|
doc_id: int,
|
||||||
|
request: Request,
|
||||||
|
body: ReasonBody | None = None,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
body = body or ReasonBody()
|
||||||
|
conn = d.conn
|
||||||
|
pp = _pp(d, p)
|
||||||
|
with packet_write(
|
||||||
|
d, p, packet_id, A.DOC_REMOVE, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||||
|
) as w:
|
||||||
|
reason = clean_opt(body.reason, LIMITS["reason"])
|
||||||
|
doc = packets.one(
|
||||||
|
conn,
|
||||||
|
"UPDATE packet_docs SET removed_at = now(), removed_by = %s, removed_reason = %s"
|
||||||
|
" WHERE id = %s AND packet_id = %s AND removed_at IS NULL RETURNING title, kind",
|
||||||
|
(p.user_id, reason, doc_id, packet_id),
|
||||||
|
)
|
||||||
|
if doc is None:
|
||||||
|
raise ApiError(404, "not_found", "No such document.")
|
||||||
|
w.changed("docs")
|
||||||
|
w.note = ", ".join(x for x in (doc["title"] or doc["kind"], reason) if x)
|
||||||
|
w.detail["doc_id"] = doc_id
|
||||||
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- move, remove, close, data book
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{packet_id}/move", dependencies=WRITE)
|
||||||
|
def move(
|
||||||
|
packet_id: int,
|
||||||
|
body: MoveBody,
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
conn = d.conn
|
||||||
|
pp = _pp(d, p)
|
||||||
|
extra = {} if body.reason is None else {"reason": body.reason}
|
||||||
|
with packet_write(
|
||||||
|
d, p, packet_id, A.MOVE_UNIT, request=request, extra=extra, it_reason=body.it_reason, policy_principal=pp
|
||||||
|
) as w:
|
||||||
|
target = open_unit(conn, body.unit_id)
|
||||||
|
old = w.packet["unit_id"]
|
||||||
|
if int(old) == int(body.unit_id):
|
||||||
|
raise ApiError(422, "same_unit", "The packet is already in that unit.")
|
||||||
|
old_number = conn.execute(
|
||||||
|
"SELECT pr.number || u.lot FROM units u JOIN projects pr ON pr.id = u.project_id WHERE u.id = %s", (old,)
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.execute("UPDATE packets SET unit_id = %s WHERE id = %s", (body.unit_id, packet_id))
|
||||||
|
w.changed("unit_id")
|
||||||
|
reason = clean_opt(body.reason, LIMITS["reason"])
|
||||||
|
w.note = f"From {old_number} to {target['number']}" + (f". {reason}" if reason else "")
|
||||||
|
w.detail.update({"from_unit": int(old), "to_unit": int(body.unit_id)})
|
||||||
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{packet_id}", dependencies=WRITE)
|
||||||
|
def remove(
|
||||||
|
packet_id: int,
|
||||||
|
request: Request,
|
||||||
|
body: ReasonBody | None = None,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
body = body or ReasonBody()
|
||||||
|
pp = _pp(d, p)
|
||||||
|
extra = {} if body.reason is None else {"reason": body.reason}
|
||||||
|
with packet_write(
|
||||||
|
d, p, packet_id, A.REMOVE, request=request, extra=extra, it_reason=body.it_reason, policy_principal=pp
|
||||||
|
) as w:
|
||||||
|
reason = reason_ok(body.reason)
|
||||||
|
d.conn.execute(
|
||||||
|
"UPDATE packets SET removed_at = now(), removed_by = %s, removed_reason = %s WHERE id = %s",
|
||||||
|
(p.user_id, reason, packet_id),
|
||||||
|
)
|
||||||
|
w.changed("removed")
|
||||||
|
w.note = reason
|
||||||
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{packet_id}/vclosed", dependencies=WRITE)
|
||||||
|
def set_vclosed(
|
||||||
|
packet_id: int,
|
||||||
|
body: VclosedBody,
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
pp = _pp(d, p)
|
||||||
|
with packet_write(
|
||||||
|
d, p, packet_id, A.SET_VCLOSED, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||||
|
) as w:
|
||||||
|
is_closed = w.packet["vclosed_at"] is not None
|
||||||
|
if body.closed == is_closed:
|
||||||
|
w.skip()
|
||||||
|
elif body.closed:
|
||||||
|
d.conn.execute(
|
||||||
|
"UPDATE packets SET vclosed_at = now(), vclosed_by = %s WHERE id = %s", (p.user_id, packet_id)
|
||||||
|
)
|
||||||
|
w.changed("vclosed")
|
||||||
|
wos = [
|
||||||
|
r[0]
|
||||||
|
for r in d.conn.execute(
|
||||||
|
"SELECT DISTINCT wo_base FROM packet_work_orders WHERE packet_id = %s ORDER BY 1", (packet_id,)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
w.note = ", ".join(wos) or None
|
||||||
|
else:
|
||||||
|
d.conn.execute("UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL WHERE id = %s", (packet_id,))
|
||||||
|
w.changed("vclosed")
|
||||||
|
w.log("Moved back to ready to close")
|
||||||
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{packet_id}/databook", dependencies=WRITE)
|
||||||
|
def set_databook(
|
||||||
|
packet_id: int,
|
||||||
|
request: Request,
|
||||||
|
body: ItBody | None = None,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
body = body or ItBody()
|
||||||
|
pp = _pp(d, p)
|
||||||
|
with packet_write(
|
||||||
|
d, p, packet_id, A.SET_DATABOOK_AT, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||||
|
) as w:
|
||||||
|
d.conn.execute("UPDATE packets SET databook_at = now() WHERE id = %s", (packet_id,))
|
||||||
|
w.changed("databook_at")
|
||||||
|
return _read(d, pp, packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- seal and log
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{packet_id}/seal")
|
||||||
|
def seal(
|
||||||
|
packet_id: int,
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if packets.load_row(d.conn, packet_id) is None:
|
||||||
|
raise ApiError(404, "not_found", "No such packet.")
|
||||||
|
return verify_seal(d.conn, settings_of(request), packet_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{packet_id}/log")
|
||||||
|
def packet_log(
|
||||||
|
packet_id: int,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
limit: int = Query(300, ge=1, le=1000),
|
||||||
|
before: int | None = Query(None, ge=1),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
conn = d.conn
|
||||||
|
if conn.execute("SELECT 1 FROM packets WHERE id = %s", (packet_id,)).fetchone() is None:
|
||||||
|
raise ApiError(404, "not_found", "No such packet.")
|
||||||
|
sql = "SELECT id, at, by, by_name, role, action, note, it_reason, signature_id FROM packet_log WHERE packet_id = %s"
|
||||||
|
params: list[Any] = [packet_id]
|
||||||
|
if before is not None:
|
||||||
|
sql += " AND id < %s"
|
||||||
|
params.append(before)
|
||||||
|
sql += " ORDER BY id DESC LIMIT %s"
|
||||||
|
params.append(limit)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"at": r["at"],
|
||||||
|
"by": None if r["by"] is None else {"id": str(r["by"]), "display_name": r["by_name"]},
|
||||||
|
"role": r["role"],
|
||||||
|
"action": r["action"],
|
||||||
|
"note": r["note"],
|
||||||
|
"it_reason": r["it_reason"],
|
||||||
|
"signed": r["signature_id"] is not None,
|
||||||
|
}
|
||||||
|
for r in packets.rows(conn, sql, params)
|
||||||
|
]
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Projects: list, read, create, field edits, renumber, soft delete; and the number lookup.
|
||||||
|
|
||||||
|
Reads need any signed-in session (PM included). Writes need Clerical (clerical or clerical_supervisor);
|
||||||
|
renumber and delete need clerical_supervisor. An admin outside those roles may do any of them with an
|
||||||
|
`it_reason` of at least 8 characters, kept on the audit row.
|
||||||
|
|
||||||
|
The router has no prefix because it also serves GET /api/lookup/number; every path starts with /api.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from ..domain import projects as dom
|
||||||
|
from ..text import reason_ok
|
||||||
|
from .deps import Db, Principal, current_principal, get_db, require_password_session
|
||||||
|
from .errors import ApiError
|
||||||
|
|
||||||
|
router = APIRouter(tags=["projects"])
|
||||||
|
|
||||||
|
PREFIX = "/api/projects"
|
||||||
|
CLERICAL = frozenset({"clerical", "clerical_supervisor"})
|
||||||
|
CLERICAL_SUP = frozenset({"clerical_supervisor"})
|
||||||
|
|
||||||
|
# Generous input caps; clean_text cuts every value to its field limit.
|
||||||
|
TEXT = 4000
|
||||||
|
|
||||||
|
|
||||||
|
def authorize(p: Principal, *, supervisor: bool, it_reason: str | None) -> dom.Who:
|
||||||
|
"""The write check. Returns who is writing; it_reason is set only for an admin acting outside their roles."""
|
||||||
|
need = CLERICAL_SUP if supervisor else CLERICAL
|
||||||
|
if need & set(p.roles):
|
||||||
|
return dom.Who(user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, ip=p.ip)
|
||||||
|
if p.is_admin:
|
||||||
|
require_password_session(p)
|
||||||
|
why = reason_ok(it_reason)
|
||||||
|
if why is None:
|
||||||
|
raise ApiError(400, "it_reason_required", "Give an IT reason of at least 8 characters.")
|
||||||
|
return dom.Who(
|
||||||
|
user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, ip=p.ip, it_reason=why
|
||||||
|
)
|
||||||
|
if supervisor:
|
||||||
|
raise ApiError(403, "forbidden", "Only a Clerical Supervisor can do this.")
|
||||||
|
raise ApiError(403, "forbidden", "Only Clerical can change projects and units.")
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreate(BaseModel):
|
||||||
|
number: str = Field(max_length=200)
|
||||||
|
name: str | None = Field(None, max_length=TEXT)
|
||||||
|
customer: str | None = Field(None, max_length=TEXT)
|
||||||
|
po: str | None = Field(None, max_length=TEXT)
|
||||||
|
due: str | None = Field(None, max_length=40)
|
||||||
|
status: str | None = Field(None, max_length=20)
|
||||||
|
notes: str | None = Field(None, max_length=TEXT)
|
||||||
|
it_reason: str | None = Field(None, max_length=TEXT)
|
||||||
|
|
||||||
|
|
||||||
|
class FieldCas(BaseModel):
|
||||||
|
"""`{set, base}`: the new values and what the client last saw for them."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
values: dict[str, Any] = Field(alias="set")
|
||||||
|
base: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
it_reason: str | None = Field(None, max_length=TEXT)
|
||||||
|
|
||||||
|
|
||||||
|
class RenumberProject(BaseModel):
|
||||||
|
number: str = Field(max_length=200)
|
||||||
|
reason: str | None = Field(None, max_length=TEXT)
|
||||||
|
it_reason: str | None = Field(None, max_length=TEXT)
|
||||||
|
|
||||||
|
|
||||||
|
class ReasonBody(BaseModel):
|
||||||
|
reason: str | None = Field(None, max_length=TEXT)
|
||||||
|
it_reason: str | None = Field(None, max_length=TEXT)
|
||||||
|
|
||||||
|
|
||||||
|
class UnitsCreate(BaseModel):
|
||||||
|
lot: str | None = Field(None, max_length=40)
|
||||||
|
lots: list[str] | None = Field(None, max_length=dom.MAX_UNITS_AT_ONCE)
|
||||||
|
count: int | None = None
|
||||||
|
start: str | None = Field(None, max_length=40)
|
||||||
|
name: str | None = Field(None, max_length=TEXT)
|
||||||
|
due: str | None = Field(None, max_length=40)
|
||||||
|
status: str | None = Field(None, max_length=20)
|
||||||
|
notes: str | None = Field(None, max_length=TEXT)
|
||||||
|
it_reason: str | None = Field(None, max_length=TEXT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(PREFIX)
|
||||||
|
def list_projects(
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
q: str = Query("", max_length=200),
|
||||||
|
status: str = Query("active", max_length=20),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return dom.list_projects(d.conn, q=q, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(PREFIX, status_code=201)
|
||||||
|
def create_project(
|
||||||
|
body: ProjectCreate,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
who = authorize(p, supervisor=False, it_reason=body.it_reason)
|
||||||
|
return dom.create_project(d.conn, who, body.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(PREFIX + "/{project_id}")
|
||||||
|
def project_detail(
|
||||||
|
project_id: int,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return dom.project_detail(d.conn, project_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(PREFIX + "/{project_id}")
|
||||||
|
def update_project(
|
||||||
|
project_id: int,
|
||||||
|
body: FieldCas,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
who = authorize(p, supervisor=False, it_reason=body.it_reason)
|
||||||
|
return dom.update_project(d.conn, who, project_id, body.values, body.base)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(PREFIX + "/{project_id}/renumber")
|
||||||
|
def renumber_project(
|
||||||
|
project_id: int,
|
||||||
|
body: RenumberProject,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
who = authorize(p, supervisor=True, it_reason=body.it_reason)
|
||||||
|
return dom.renumber_project(d.conn, who, project_id, body.number, body.reason)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(PREFIX + "/{project_id}")
|
||||||
|
def remove_project(
|
||||||
|
project_id: int,
|
||||||
|
body: ReasonBody,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
who = authorize(p, supervisor=True, it_reason=body.it_reason)
|
||||||
|
return dom.remove_project(d.conn, who, project_id, body.reason)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(PREFIX + "/{project_id}/units", status_code=201)
|
||||||
|
def create_units(
|
||||||
|
project_id: int,
|
||||||
|
body: UnitsCreate,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
who = authorize(p, supervisor=False, it_reason=body.it_reason)
|
||||||
|
return dom.create_units(d.conn, who, project_id, body.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/lookup/number")
|
||||||
|
def lookup_number(
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
n: str = Query(..., min_length=1, max_length=40),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
found = dom.lookup(d.conn, n)
|
||||||
|
if found is None:
|
||||||
|
raise ApiError(404, "not_found", "No project or unit has that number.")
|
||||||
|
return found
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Units: the picker list, read, field edits, renumber (new lot) and soft delete.
|
||||||
|
|
||||||
|
New units are made through POST /api/projects/{id}/units. Same access rules as projects: reads need any
|
||||||
|
session; edits need Clerical; renumber and delete need clerical_supervisor; admin with an it_reason.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ..domain import projects as dom
|
||||||
|
from .deps import Db, Principal, current_principal, get_db
|
||||||
|
from .projects import TEXT, FieldCas, ReasonBody, authorize
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/units", tags=["units"])
|
||||||
|
|
||||||
|
|
||||||
|
class RenumberUnit(BaseModel):
|
||||||
|
lot: str = Field(max_length=40)
|
||||||
|
reason: str | None = Field(None, max_length=TEXT)
|
||||||
|
it_reason: str | None = Field(None, max_length=TEXT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_units(
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
project_id: int | None = Query(None),
|
||||||
|
q: str = Query("", max_length=200),
|
||||||
|
status: str = Query("active", max_length=20),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return dom.list_units(d.conn, project_id=project_id, q=q, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{unit_id}")
|
||||||
|
def unit_detail(
|
||||||
|
unit_id: int,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return dom.unit_detail(d.conn, unit_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{unit_id}")
|
||||||
|
def update_unit(
|
||||||
|
unit_id: int,
|
||||||
|
body: FieldCas,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
who = authorize(p, supervisor=False, it_reason=body.it_reason)
|
||||||
|
return dom.update_unit(d.conn, who, unit_id, body.values, body.base)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{unit_id}/renumber")
|
||||||
|
def renumber_unit(
|
||||||
|
unit_id: int,
|
||||||
|
body: RenumberUnit,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
who = authorize(p, supervisor=True, it_reason=body.it_reason)
|
||||||
|
return dom.renumber_unit(d.conn, who, unit_id, body.lot, body.reason)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{unit_id}")
|
||||||
|
def remove_unit(
|
||||||
|
unit_id: int,
|
||||||
|
body: ReasonBody,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
who = authorize(p, supervisor=True, it_reason=body.it_reason)
|
||||||
|
return dom.remove_unit(d.conn, who, unit_id, body.reason)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""The core domain: packets, projects and units, documents, the write pipeline. Pure SQL helpers over one connection."""
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Stored files. One row per file in `assets`; the bytes live at ASSETS_DIR/<id[:2]>/<id>.
|
||||||
|
|
||||||
|
An upload is streamed to a temp file in ASSETS_DIR in chunks (never held in memory whole), hashed as it goes,
|
||||||
|
typed by its first bytes (not by its name or the browser's claim), fsynced and renamed into place. The same bytes
|
||||||
|
uploaded twice (same sha256 and size) return the existing row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, BinaryIO
|
||||||
|
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from .. import audit
|
||||||
|
from ..api.errors import ApiError
|
||||||
|
from ..settings import Settings
|
||||||
|
from ..text import clean_text
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CHUNK = 1024 * 1024
|
||||||
|
HEAD = 16
|
||||||
|
PDF = "application/pdf"
|
||||||
|
MAGIC: tuple[tuple[bytes, str], ...] = (
|
||||||
|
(b"%PDF-", PDF),
|
||||||
|
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
||||||
|
(b"\xff\xd8\xff", "image/jpeg"),
|
||||||
|
)
|
||||||
|
EXT = {PDF: ".pdf", "image/png": ".png", "image/jpeg": ".jpg"}
|
||||||
|
NAME_MAX = 160
|
||||||
|
_UNSAFE_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
|
||||||
|
|
||||||
|
|
||||||
|
def sniff(head: bytes) -> str | None:
|
||||||
|
"""The mime type from the first bytes, or None when it is not a type Packet Station stores."""
|
||||||
|
for magic, mime in MAGIC:
|
||||||
|
if head.startswith(magic):
|
||||||
|
return mime
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def safe_name(name: Any) -> str | None:
|
||||||
|
"""The original file name, cleaned: no path, no separators or reserved characters, at most 160 characters."""
|
||||||
|
text = clean_text(name, 1000)
|
||||||
|
text = text.replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
|
text = _UNSAFE_NAME.sub("_", text).strip(" .")
|
||||||
|
return text[:NAME_MAX] or None
|
||||||
|
|
||||||
|
|
||||||
|
def storage_key(asset_id: uuid.UUID | str) -> str:
|
||||||
|
s = str(asset_id)
|
||||||
|
return f"{s[:2]}/{s}"
|
||||||
|
|
||||||
|
|
||||||
|
def path_of(settings: Settings, key: str) -> Path:
|
||||||
|
root = Path(settings.assets_dir).resolve()
|
||||||
|
p = (root / key).resolve()
|
||||||
|
if not p.is_relative_to(root):
|
||||||
|
raise ApiError(404, "not_found", "No such file.")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_pages(path: Path) -> int:
|
||||||
|
"""Page count through pypdf; 422 bad_pdf when it does not parse or has no pages."""
|
||||||
|
from pypdf import PdfReader
|
||||||
|
from pypdf.errors import PyPdfError
|
||||||
|
|
||||||
|
try:
|
||||||
|
n = len(PdfReader(str(path)).pages)
|
||||||
|
except (PyPdfError, ValueError, KeyError, TypeError, OSError, IndexError, AttributeError) as e:
|
||||||
|
log.info("upload is not a readable PDF: %s", type(e).__name__)
|
||||||
|
raise ApiError(422, "bad_pdf", "That PDF could not be read.") from None
|
||||||
|
if n < 1:
|
||||||
|
raise ApiError(422, "bad_pdf", "That PDF has no pages.")
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def public(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"mime": row["mime"],
|
||||||
|
"size": int(row["size"]),
|
||||||
|
"sha256": bytes(row["sha256"]).hex(),
|
||||||
|
"pages": row["pages"],
|
||||||
|
"original_name": row["original_name"],
|
||||||
|
"url": f"/_blob/{row['id']}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get(conn: Any, asset_id: Any) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
aid = uuid.UUID(str(asset_id))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
r = cur.execute("SELECT * FROM assets WHERE id = %s", (aid,)).fetchone()
|
||||||
|
return dict(r) if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def _fsync_dir(path: Path) -> None:
|
||||||
|
if os.name != "posix":
|
||||||
|
return
|
||||||
|
fd = os.open(str(path), os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
|
def store_upload(
|
||||||
|
conn: Any,
|
||||||
|
settings: Settings,
|
||||||
|
stream: BinaryIO,
|
||||||
|
*,
|
||||||
|
original_name: Any,
|
||||||
|
user: Any,
|
||||||
|
packet_id: int | None = None,
|
||||||
|
source: str = "upload",
|
||||||
|
source_ref: Any = None,
|
||||||
|
actor: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Store one upload and return its public row. Raises 413 too_large, 415 unsupported_type, 422 bad_pdf.
|
||||||
|
|
||||||
|
`user` is the uploader's user id; `actor` (the session Principal) is used for the audit row when given.
|
||||||
|
"""
|
||||||
|
root = Path(settings.assets_dir)
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
limit = int(settings.upload_max_bytes)
|
||||||
|
fd, tmp_name = tempfile.mkstemp(prefix=".up-", dir=str(root))
|
||||||
|
tmp = Path(tmp_name)
|
||||||
|
keep = False
|
||||||
|
placed: Path | None = None
|
||||||
|
try:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
size = 0
|
||||||
|
head = b""
|
||||||
|
mime: str | None = None
|
||||||
|
with os.fdopen(fd, "wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = stream.read(CHUNK)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if size > limit:
|
||||||
|
raise ApiError(413, "too_large", f"The file is over the {limit // (1024 * 1024)} MB limit.")
|
||||||
|
if mime is None and len(head) < HEAD:
|
||||||
|
head += chunk[: HEAD - len(head)]
|
||||||
|
if len(head) >= HEAD:
|
||||||
|
mime = sniff(head)
|
||||||
|
if mime is None:
|
||||||
|
raise ApiError(415, "unsupported_type", "Only PDF, PNG and JPEG files can be stored.")
|
||||||
|
digest.update(chunk)
|
||||||
|
out.write(chunk)
|
||||||
|
out.flush()
|
||||||
|
os.fsync(out.fileno())
|
||||||
|
if mime is None:
|
||||||
|
mime = sniff(head)
|
||||||
|
if mime is None or size == 0:
|
||||||
|
raise ApiError(415, "unsupported_type", "Only PDF, PNG and JPEG files can be stored.")
|
||||||
|
sha = digest.digest()
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
existing = cur.execute(
|
||||||
|
"SELECT * FROM assets WHERE sha256 = %s AND size = %s ORDER BY uploaded_at LIMIT 1", (sha, size)
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None:
|
||||||
|
return public(dict(existing))
|
||||||
|
pages = pdf_pages(tmp) if mime == PDF else None
|
||||||
|
aid = uuid.uuid4()
|
||||||
|
key = storage_key(aid)
|
||||||
|
final = path_of(settings, key)
|
||||||
|
final.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.replace(tmp, final)
|
||||||
|
keep = True
|
||||||
|
placed = final
|
||||||
|
_fsync_dir(final.parent)
|
||||||
|
name = safe_name(original_name)
|
||||||
|
with conn.transaction():
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
row = cur.execute(
|
||||||
|
"INSERT INTO assets (id, sha256, size, mime, original_name, storage_key, source, source_ref, pages,"
|
||||||
|
" uploaded_by, packet_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *",
|
||||||
|
(aid, sha, size, mime, name, key, source, source_ref, pages, user, packet_id),
|
||||||
|
).fetchone()
|
||||||
|
audit.append(
|
||||||
|
conn,
|
||||||
|
action="asset.uploaded",
|
||||||
|
user_id=user,
|
||||||
|
session_id=getattr(actor, "session_id", None),
|
||||||
|
role_snapshot=getattr(actor, "role_snapshot", None),
|
||||||
|
subject_type="asset",
|
||||||
|
subject_id=str(aid),
|
||||||
|
packet_id=packet_id,
|
||||||
|
detail={"mime": mime, "size": size, "pages": pages, "sha256": sha.hex()},
|
||||||
|
ip=getattr(actor, "ip", None),
|
||||||
|
)
|
||||||
|
return public(dict(row))
|
||||||
|
except BaseException:
|
||||||
|
if placed is not None: # the row was not written: do not leave an orphan file
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
placed.unlink()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if not keep:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
tmp.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def open_blob(settings: Settings, row: dict[str, Any]) -> Path:
|
||||||
|
"""The stored file of an asset row; 404 when it is missing on disk."""
|
||||||
|
p = path_of(settings, str(row["storage_key"]))
|
||||||
|
if not p.is_file():
|
||||||
|
log.error("asset %s is missing on disk", row["id"])
|
||||||
|
raise ApiError(404, "not_found", "No such file.")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def download_name(row: dict[str, Any]) -> str:
|
||||||
|
"""A safe file name for Content-Disposition (ASCII only, quotes removed)."""
|
||||||
|
name = row.get("original_name") or ""
|
||||||
|
name = name.encode("ascii", "ignore").decode("ascii").replace('"', "").replace(";", "_").strip()
|
||||||
|
if not name:
|
||||||
|
name = str(row["id"]) + EXT.get(str(row["mime"]), "")
|
||||||
|
return name
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""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 keys are the old keys, so a checklist stored as {key: {done, at, by, stamp}} reads the same.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
NO_REV = "-"
|
||||||
|
NDE_NONE = ("", "none", "none required")
|
||||||
|
NDE_OK = ("acceptable", "accept", "accepted")
|
||||||
|
REJECTS = ("reject", "rejected")
|
||||||
|
|
||||||
|
|
||||||
|
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]]:
|
||||||
|
"""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"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def qc_items_for(
|
||||||
|
drawing: str | None, rev: str | None, wps: str | None, welder_stamp: str | None
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""The Q.C. inspection items, as a flat list of {group, key, text}."""
|
||||||
|
g1, g2 = "Data book package", "Physical spool"
|
||||||
|
stamp = f"[{welder_stamp}] " if welder_stamp else ""
|
||||||
|
return [
|
||||||
|
_item(g1, "db_heats", "Heat number recorded for every BOM item"),
|
||||||
|
_item(g1, "db_mtr", "MTRs on file and match the heat numbers"),
|
||||||
|
_item(g1, "db_weldmap", "Weld map complete: every weld identified"),
|
||||||
|
_item(g1, "db_wps", f"Welds made to {wps or 'the WPS'}; welder {stamp}qualified"),
|
||||||
|
_item(g1, "db_ck", "Stall checklist and weld record complete"),
|
||||||
|
_item(g2, "ph_dim", f"Dimensions verified against {drawing or 'the drawing'} Rev {rev or NO_REV}"),
|
||||||
|
_item(g2, "ph_heat", "Heat numbers on material match the heat map"),
|
||||||
|
_item(g2, "ph_stamp", "Welds stamped with welder ID"),
|
||||||
|
_item(g2, "ph_clean", "Clean: no slag, spatter or arc strikes"),
|
||||||
|
_item(g2, "ph_qty", "Quantity matches the work orders"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def checklist_keys(weld: bool) -> list[str]:
|
||||||
|
return [i["key"] for i in checklist_for(weld, None, None, None)]
|
||||||
|
|
||||||
|
|
||||||
|
def qc_keys() -> list[str]:
|
||||||
|
return [i["key"] for i in qc_items_for(None, None, None, None)]
|
||||||
|
|
||||||
|
|
||||||
|
def is_done(value: Any) -> bool:
|
||||||
|
"""A checklist or Q.C. check entry: `true`, or `{"done": true, ...}`."""
|
||||||
|
if value is True:
|
||||||
|
return True
|
||||||
|
return isinstance(value, Mapping) and value.get("done") is True
|
||||||
|
|
||||||
|
|
||||||
|
def checklist_complete(checklist: Mapping[str, Any] | None, weld: bool) -> 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))
|
||||||
|
|
||||||
|
|
||||||
|
def _plural(n: int, word: str) -> str:
|
||||||
|
return f"{n} {word}{'s' if n > 1 else ''}"
|
||||||
|
|
||||||
|
|
||||||
|
def qc_missing(
|
||||||
|
packet: Mapping[str, Any], qc: Mapping[str, Any] | None, welds: Iterable[Mapping[str, Any]]
|
||||||
|
) -> list[str]:
|
||||||
|
"""What Q.C. still has to record before an accept. The old qcMissing().
|
||||||
|
|
||||||
|
`packet` is unused today (the item list does not depend on it for the keys) but kept for the old signature.
|
||||||
|
`qc` is the open inspection row (checks, weld_exam, nde_method, nde_result, inspector) or None.
|
||||||
|
`welds` are the live weld rows (weld_no).
|
||||||
|
"""
|
||||||
|
del packet
|
||||||
|
q = qc or {}
|
||||||
|
checks = q.get("checks") or {}
|
||||||
|
missing: list[str] = []
|
||||||
|
open_items = sum(1 for k in qc_keys() if not is_done(checks.get(k)))
|
||||||
|
if open_items:
|
||||||
|
missing.append(_plural(open_items, "inspection item"))
|
||||||
|
exam = q.get("weld_exam") or {}
|
||||||
|
results = []
|
||||||
|
for w in welds:
|
||||||
|
entry = exam.get(str(w.get("weld_no")))
|
||||||
|
result = entry.get("result") if isinstance(entry, Mapping) else None
|
||||||
|
results.append(str(result).lower() if result else "")
|
||||||
|
no_result = sum(1 for r in results if not r)
|
||||||
|
if no_result:
|
||||||
|
missing.append(_plural(no_result, "weld") + " without a visual result")
|
||||||
|
if any(r in REJECTS for r in results):
|
||||||
|
missing.append("a rejected weld (reject the packet instead)")
|
||||||
|
method = str(q.get("nde_method") or "").strip().lower()
|
||||||
|
if method not in NDE_NONE and str(q.get("nde_result") or "").strip().lower() not in NDE_OK:
|
||||||
|
missing.append("NDE result")
|
||||||
|
if not str(q.get("inspector") or "").strip():
|
||||||
|
missing.append("inspector name")
|
||||||
|
return missing
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Shared SQL for the packet child rows: guarded row updates, the warehouse stage history, and the departments a
|
||||||
|
packet touched (who verifies a change order).
|
||||||
|
|
||||||
|
Every function here runs inside `packet_write`, so the packet row is already locked FOR UPDATE and child writes on
|
||||||
|
the same packet serialise on it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg import sql
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from . import packets
|
||||||
|
|
||||||
|
# Tables update_row may touch. The column names come from the handlers' fixed field lists, never from a request.
|
||||||
|
TABLES = frozenset(
|
||||||
|
{"bom_lines", "bom_shorts", "welds", "heat_tags", "packet_stages", "qc_inspections", "change_orders"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Statuses past the warehouse: the packet has been in its department.
|
||||||
|
IN_DEPT = frozenset({"ready", "assigned", "in_progress", "review", "qc", "complete", "filed"})
|
||||||
|
QC_STATUSES = frozenset({"qc", "complete", "filed"})
|
||||||
|
|
||||||
|
# Departments that never verify a change order: Clerical issues them, PM has no supervisor.
|
||||||
|
NEVER_VERIFY = frozenset({"CL", "PM"})
|
||||||
|
|
||||||
|
|
||||||
|
def update_row(
|
||||||
|
conn: Any, table: str, row_id: Any, values: Mapping[str, Any], *, bump: bool = True
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""UPDATE one row by id with the given column values (and row_version + 1 when bump). Returns the new row."""
|
||||||
|
if table not in TABLES:
|
||||||
|
raise ValueError(f"update_row: table {table!r} is not allowed")
|
||||||
|
parts: list[sql.Composable] = [sql.SQL("{} = {}").format(sql.Identifier(k), sql.Placeholder()) for k in values]
|
||||||
|
if bump:
|
||||||
|
parts.append(sql.SQL("row_version = row_version + 1"))
|
||||||
|
if not parts:
|
||||||
|
raise ValueError("update_row: nothing to set")
|
||||||
|
query = sql.SQL("UPDATE {} SET {} WHERE id = {} RETURNING *").format(
|
||||||
|
sql.Identifier(table), sql.SQL(", ").join(parts), sql.Placeholder()
|
||||||
|
)
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
r = cur.execute(query, (*values.values(), row_id)).fetchone()
|
||||||
|
return dict(r) if r is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def current_wh(conn: Any, packet_id: int) -> dict[str, Any] | None:
|
||||||
|
return packets.one(
|
||||||
|
conn, "SELECT *, stage::text AS stage FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (packet_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wh_move(
|
||||||
|
conn: Any,
|
||||||
|
packet_id: int,
|
||||||
|
stage: str,
|
||||||
|
by: Any,
|
||||||
|
*,
|
||||||
|
reason: str | None = None,
|
||||||
|
note: str | None = None,
|
||||||
|
auto: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Close the current warehouse stage row and insert the new one."""
|
||||||
|
conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO packet_wh (packet_id, stage, by, reason, note, auto) VALUES (%s, %s::wh_stage, %s, %s, %s, %s)",
|
||||||
|
(packet_id, stage, by, reason, note, auto),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def co_touched(conn: Any, row: Mapping[str, Any]) -> list[str]:
|
||||||
|
"""The departments a packet has touched, in the old coTouched() order, without Clerical and PM.
|
||||||
|
|
||||||
|
WH once it left draft; its department once it reached the shop; every stage's department; QC once it reached
|
||||||
|
Q.C. (status qc, complete or filed, a Q.C. history row, or any inspection).
|
||||||
|
"""
|
||||||
|
pid = row["id"]
|
||||||
|
status = str(row["status"])
|
||||||
|
out: list[str] = []
|
||||||
|
|
||||||
|
def add(codes: Iterable[Any]) -> None:
|
||||||
|
for c in codes:
|
||||||
|
if c and str(c) not in out:
|
||||||
|
out.append(str(c))
|
||||||
|
|
||||||
|
if status != "draft":
|
||||||
|
add(["WH"])
|
||||||
|
if status in IN_DEPT:
|
||||||
|
add([row["dept_code"]])
|
||||||
|
add(r[0] for r in conn.execute("SELECT dept_code FROM packet_stages WHERE packet_id = %s ORDER BY id", (pid,)))
|
||||||
|
reached_qc = status in QC_STATUSES or bool(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM qc_history WHERE packet_id = %s)"
|
||||||
|
" OR EXISTS (SELECT 1 FROM qc_inspections WHERE packet_id = %s)",
|
||||||
|
(pid, pid),
|
||||||
|
).fetchone()[0]
|
||||||
|
)
|
||||||
|
if reached_qc:
|
||||||
|
add(["QC"])
|
||||||
|
return [c for c in out if c not in NEVER_VERIFY]
|
||||||
|
|
||||||
|
|
||||||
|
def active_depts(conn: Any, codes: Iterable[str]) -> set[str]:
|
||||||
|
"""The given codes that are active departments."""
|
||||||
|
want = sorted({str(c) for c in codes})
|
||||||
|
if not want:
|
||||||
|
return set()
|
||||||
|
return {r[0] for r in conn.execute("SELECT code FROM departments WHERE code = ANY(%s) AND active", (want,))}
|
||||||
@@ -0,0 +1,787 @@
|
|||||||
|
"""The packet read model, the policy's PacketState, and the board cards.
|
||||||
|
|
||||||
|
`read_packet` is the full answer of GET /api/packets/{id} and of every packet mutation. `packet_state` builds the
|
||||||
|
policy's view from the same child rows, so a write and a read decide from the same facts. `list_cards` serves the
|
||||||
|
boards from header columns, v_open_cos and the open short count only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from .. import policy
|
||||||
|
from ..api import deps
|
||||||
|
from ..api.errors import ApiError
|
||||||
|
from . import checklists
|
||||||
|
|
||||||
|
HAND_FLAG = "Made by hand - check it and send it on"
|
||||||
|
DRAWING_NEEDED_FLAG = "Drawing needed - looking it up in the vault"
|
||||||
|
DRAWING_STATES_ATTACHED = ("attached", "hand")
|
||||||
|
|
||||||
|
# Keys left out of the sealed content: they change without the packet changing (who can do what, the counter,
|
||||||
|
# the seal itself, the data book export date) or come from code and AD rather than the packet (checklist texts,
|
||||||
|
# the flag, the unit and project names, display names).
|
||||||
|
SEAL_EXCLUDE = (
|
||||||
|
"can",
|
||||||
|
"blocked",
|
||||||
|
"holder",
|
||||||
|
"version",
|
||||||
|
"log",
|
||||||
|
"seal",
|
||||||
|
"databook_at",
|
||||||
|
"checklist_items",
|
||||||
|
"qc_items",
|
||||||
|
"flag",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- small helpers
|
||||||
|
|
||||||
|
|
||||||
|
def rows(conn: Any, query: str, params: Any = ()) -> list[dict[str, Any]]:
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
return list(cur.execute(query, params).fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def one(conn: Any, query: str, params: Any = ()) -> dict[str, Any] | None:
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
r = cur.execute(query, params).fetchone()
|
||||||
|
return dict(r) if r is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def uid_text(value: Any) -> str | None:
|
||||||
|
return None if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def load_row(conn: Any, packet_id: int, *, lock: bool = False) -> dict[str, Any] | None:
|
||||||
|
"""The packets row, or None. With lock=True it is taken FOR UPDATE (inside a transaction)."""
|
||||||
|
sql = "SELECT *, status::text AS status, source_kind::text AS source_kind, routed_to::text AS routed_to,"
|
||||||
|
sql += " drawing_state::text AS drawing_state FROM packets WHERE id = %s"
|
||||||
|
if lock:
|
||||||
|
sql += " FOR UPDATE"
|
||||||
|
return one(conn, sql, (packet_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def flag_of(status: str, source_kind: str, flags: Iterable[str] | None, drawing_state: str | None = None) -> str | None:
|
||||||
|
"""The draft flag shown on cards and the packet page. The old packetFlag(), phase 2 part."""
|
||||||
|
if status != "draft":
|
||||||
|
return None
|
||||||
|
if source_kind == "hand":
|
||||||
|
return HAND_FLAG
|
||||||
|
if "drawing_needed" in (flags or ()):
|
||||||
|
return DRAWING_NEEDED_FLAG
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# SQL for the same rule, for the list filter.
|
||||||
|
FLAG_SQL = "(p.status = 'draft' AND (p.source_kind = 'hand' OR 'drawing_needed' = ANY(p.flags)))"
|
||||||
|
|
||||||
|
|
||||||
|
def policy_principal_for(conn: Any, p: deps.Principal) -> policy.Principal:
|
||||||
|
"""The policy Principal for a session: its roles, the user's station limits and the session kind."""
|
||||||
|
limits = [r[0] for r in conn.execute("SELECT station_id FROM user_station_limits WHERE user_id = %s", (p.user_id,))]
|
||||||
|
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:
|
||||||
|
parts: list[str] = []
|
||||||
|
for co in open_cos:
|
||||||
|
if str(co.get("review")) == "pending":
|
||||||
|
parts.append(f"Change order {co['co_no']} is on hold.")
|
||||||
|
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}.")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def dept_names(conn: Any) -> dict[str, str]:
|
||||||
|
return {r[0]: r[1] for r in conn.execute("SELECT code, name FROM departments")}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the child rows
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Children:
|
||||||
|
dept: dict[str, Any]
|
||||||
|
station: dict[str, Any] | None
|
||||||
|
work_orders: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
pages: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
bom: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
shorts: dict[int, dict[str, Any]] = field(default_factory=dict) # bom_line_id -> open short
|
||||||
|
receipts: dict[int, list[dict[str, Any]]] = field(default_factory=dict) # short id -> receipts
|
||||||
|
welds: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
heat_tags: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
heat_table: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
stages: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
wh: dict[str, Any] | None = None
|
||||||
|
qc: dict[str, Any] | None = None
|
||||||
|
qc_history: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
change_orders: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
co_items: dict[int, list[dict[str, Any]]] = field(default_factory=dict)
|
||||||
|
co_required: dict[int, list[str]] = field(default_factory=dict)
|
||||||
|
co_verified: dict[int, list[dict[str, Any]]] = field(default_factory=dict)
|
||||||
|
open_cos: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
docs: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
seal: dict[str, Any] | None = None
|
||||||
|
dept_names: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def open_stage(self) -> dict[str, Any] | None:
|
||||||
|
return next((s for s in self.stages if s["closed_at"] is None), None)
|
||||||
|
|
||||||
|
|
||||||
|
def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Children:
|
||||||
|
"""Every child row the read model and the policy need. full=False skips what only the read model shows."""
|
||||||
|
pid = row["id"]
|
||||||
|
dept = one(conn, "SELECT code, name, weld FROM departments WHERE code = %s", (row["dept_code"],)) or {
|
||||||
|
"code": row["dept_code"],
|
||||||
|
"name": row["dept_code"],
|
||||||
|
"weld": False,
|
||||||
|
}
|
||||||
|
station = (
|
||||||
|
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)
|
||||||
|
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,))
|
||||||
|
ch.shorts = {s["bom_line_id"]: s for s in shorts}
|
||||||
|
ch.welds = rows(conn, "SELECT * FROM welds WHERE packet_id = %s AND removed_at IS NULL ORDER BY weld_no", (pid,))
|
||||||
|
ch.stages = rows(conn, "SELECT * FROM packet_stages WHERE packet_id = %s ORDER BY id", (pid,))
|
||||||
|
ch.qc = one(conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (pid,))
|
||||||
|
ch.open_cos = rows(
|
||||||
|
conn,
|
||||||
|
"SELECT co_id, co_no, review::text AS review, hold, pending_depts FROM v_open_cos WHERE packet_id = %s"
|
||||||
|
" ORDER BY co_no",
|
||||||
|
(pid,),
|
||||||
|
)
|
||||||
|
if not full:
|
||||||
|
return ch
|
||||||
|
ch.work_orders = rows(conn, "SELECT * FROM packet_work_orders WHERE packet_id = %s ORDER BY sort, id", (pid,))
|
||||||
|
if shorts:
|
||||||
|
for r in rows(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM short_receipts WHERE short_id = ANY(%s) ORDER BY id",
|
||||||
|
([s["id"] for s in shorts],),
|
||||||
|
):
|
||||||
|
ch.receipts.setdefault(r["short_id"], []).append(r)
|
||||||
|
ch.heat_tags = rows(
|
||||||
|
conn, "SELECT * FROM heat_tags WHERE packet_id = %s AND removed_at IS NULL ORDER BY tag_no", (pid,)
|
||||||
|
)
|
||||||
|
ch.heat_table = rows(conn, "SELECT * FROM heat_table_placements WHERE packet_id = %s ORDER BY page", (pid,))
|
||||||
|
ch.wh = one(
|
||||||
|
conn, "SELECT *, stage::text AS stage FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (pid,)
|
||||||
|
)
|
||||||
|
ch.qc_history = rows(conn, "SELECT * FROM qc_history WHERE packet_id = %s ORDER BY id", (pid,))
|
||||||
|
ch.change_orders = rows(
|
||||||
|
conn,
|
||||||
|
"SELECT *, origin::text AS origin, review::text AS review FROM change_orders WHERE packet_id = %s"
|
||||||
|
" ORDER BY co_no",
|
||||||
|
(pid,),
|
||||||
|
)
|
||||||
|
co_ids = [c["id"] for c in ch.change_orders]
|
||||||
|
if co_ids:
|
||||||
|
for r in rows(conn, "SELECT * FROM co_items WHERE co_id = ANY(%s) ORDER BY id", (co_ids,)):
|
||||||
|
ch.co_items.setdefault(r["co_id"], []).append(r)
|
||||||
|
for r in rows(
|
||||||
|
conn, "SELECT co_id, dept_code FROM co_required_depts WHERE co_id = ANY(%s) ORDER BY dept_code", (co_ids,)
|
||||||
|
):
|
||||||
|
ch.co_required.setdefault(r["co_id"], []).append(r["dept_code"])
|
||||||
|
for r in rows(conn, "SELECT * FROM co_verifications WHERE co_id = ANY(%s) ORDER BY at", (co_ids,)):
|
||||||
|
ch.co_verified.setdefault(r["co_id"], []).append(r)
|
||||||
|
ch.docs = rows(
|
||||||
|
conn, "SELECT * FROM packet_docs WHERE packet_id = %s AND removed_at IS NULL ORDER BY added_at, id", (pid,)
|
||||||
|
)
|
||||||
|
ch.seal = one(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pid,))
|
||||||
|
return ch
|
||||||
|
|
||||||
|
|
||||||
|
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"]]
|
||||||
|
stage = ch.open_stage
|
||||||
|
return policy.PacketState(
|
||||||
|
id=int(row["id"]),
|
||||||
|
status=str(row["status"]),
|
||||||
|
dept=str(row["dept_code"]),
|
||||||
|
station_id=row["station_id"],
|
||||||
|
welder_id=uid_text(row["welder_id"]),
|
||||||
|
wh_skipped=bool(row["wh_skipped"]),
|
||||||
|
routed_to=row["routed_to"],
|
||||||
|
vclosed=row["vclosed_at"] is not None,
|
||||||
|
removed=row["removed_at"] is not None,
|
||||||
|
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),
|
||||||
|
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),
|
||||||
|
welds=len(ch.welds),
|
||||||
|
checklist_complete=stage is not None and checklists.checklist_complete(stage["checklist"], weld),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def packet_state(conn: Any, row: Mapping[str, Any]) -> policy.PacketState:
|
||||||
|
"""The policy's view of a packet row, from its child rows."""
|
||||||
|
return state_from(row, load_children(conn, row, full=False))
|
||||||
|
|
||||||
|
|
||||||
|
def holder_text(state: policy.PacketState, ch: Children) -> str:
|
||||||
|
return policy.holder_name(state, ch.dept.get("name"), (ch.station or {}).get("name"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- shaping
|
||||||
|
|
||||||
|
|
||||||
|
class People:
|
||||||
|
"""Display names for who references. In seal mode a reference is the id only."""
|
||||||
|
|
||||||
|
def __init__(self, conn: Any, ids: Iterable[Any], *, seal: bool = False):
|
||||||
|
self.seal = seal
|
||||||
|
want = sorted({str(i) for i in ids if i is not None})
|
||||||
|
self.names: dict[str, str] = {}
|
||||||
|
if want and not seal:
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT id, display_name FROM users WHERE id = ANY(%s)", ([uuid.UUID(i) for i in want],)
|
||||||
|
):
|
||||||
|
self.names[str(r[0])] = r[1]
|
||||||
|
|
||||||
|
def ref(self, uid: Any) -> dict[str, Any] | None:
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
if self.seal:
|
||||||
|
return {"id": str(uid)}
|
||||||
|
return {"id": str(uid), "display_name": self.names.get(str(uid))}
|
||||||
|
|
||||||
|
|
||||||
|
def _station_ref(conn: Any, station_id: Any, cache: dict[Any, dict[str, Any] | None]) -> dict[str, Any] | None:
|
||||||
|
if station_id is None:
|
||||||
|
return None
|
||||||
|
if station_id not in cache:
|
||||||
|
cache[station_id] = one(conn, "SELECT id, name FROM stations WHERE id = %s", (station_id,))
|
||||||
|
return cache[station_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _stage(conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any], *, closed: bool) -> dict[str, Any]:
|
||||||
|
welder = people.ref(s["welder_id"])
|
||||||
|
if welder is not None:
|
||||||
|
welder["stamp"] = s["welder_stamp"]
|
||||||
|
out = {
|
||||||
|
"id": s["id"],
|
||||||
|
"dept_code": s["dept_code"],
|
||||||
|
"station": _station_ref(conn, s["station_id"], cache),
|
||||||
|
"welder": welder,
|
||||||
|
"checklist": s["checklist"] or {},
|
||||||
|
"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"],
|
||||||
|
},
|
||||||
|
"opened_at": s["opened_at"],
|
||||||
|
"row_version": s["row_version"],
|
||||||
|
}
|
||||||
|
if closed:
|
||||||
|
out["closed_at"] = s["closed_at"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _qc(q: Mapping[str, Any], people: People) -> dict[str, Any]:
|
||||||
|
keys = (
|
||||||
|
"id",
|
||||||
|
"sent_at",
|
||||||
|
"book",
|
||||||
|
"spool",
|
||||||
|
"location",
|
||||||
|
"sent_note",
|
||||||
|
"nde_method",
|
||||||
|
"nde_result",
|
||||||
|
"nde_report",
|
||||||
|
"hardness",
|
||||||
|
"nace",
|
||||||
|
"inspector",
|
||||||
|
"stamp",
|
||||||
|
"notes",
|
||||||
|
"result",
|
||||||
|
"result_at",
|
||||||
|
"result_note",
|
||||||
|
"row_version",
|
||||||
|
)
|
||||||
|
out = {k: q[k] for k in keys}
|
||||||
|
out["sent_by"] = people.ref(q["sent_by"])
|
||||||
|
out["result_by"] = people.ref(q["result_by"])
|
||||||
|
out["checks"] = q["checks"] or {}
|
||||||
|
out["weld_exam"] = q["weld_exam"] or {}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
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 += [s["welder_id"] for s in ch.stages]
|
||||||
|
if ch.wh:
|
||||||
|
ids.append(ch.wh["by"])
|
||||||
|
if ch.qc:
|
||||||
|
ids += [ch.qc["sent_by"], ch.qc["result_by"]]
|
||||||
|
ids += [d["added_by"] for d in ch.docs]
|
||||||
|
for c in ch.change_orders:
|
||||||
|
ids += [c["issued_by"], c["approved_by"], c["rejected_by"]]
|
||||||
|
for vs in ch.co_verified.values():
|
||||||
|
ids += [v["by"] for v in vs]
|
||||||
|
for rs in ch.receipts.values():
|
||||||
|
ids += [r["by"] for r in rs]
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _unit(conn: Any, unit_id: int, *, seal: bool) -> dict[str, Any]:
|
||||||
|
if seal:
|
||||||
|
return {"id": unit_id}
|
||||||
|
u = one(
|
||||||
|
conn,
|
||||||
|
"SELECT u.id, u.lot, u.name, u.number, u.due_effective, p.id AS project_id, p.number AS project_number,"
|
||||||
|
" p.name AS project_name, p.customer, p.po, p.status::text AS project_status"
|
||||||
|
" FROM v_units u JOIN projects p ON p.id = u.project_id WHERE u.id = %s",
|
||||||
|
(unit_id,),
|
||||||
|
)
|
||||||
|
if u is None:
|
||||||
|
return {"id": unit_id, "number": None, "lot": None, "name": None, "project": None, "due_effective": None}
|
||||||
|
return {
|
||||||
|
"id": u["id"],
|
||||||
|
"number": u["number"],
|
||||||
|
"lot": u["lot"],
|
||||||
|
"name": u["name"],
|
||||||
|
"project": {
|
||||||
|
"id": u["project_id"],
|
||||||
|
"number": u["project_number"],
|
||||||
|
"name": u["project_name"],
|
||||||
|
"customer": u["customer"],
|
||||||
|
"po": u["po"],
|
||||||
|
"status": u["project_status"],
|
||||||
|
},
|
||||||
|
"due_effective": u["due_effective"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build(
|
||||||
|
conn: Any,
|
||||||
|
row: Mapping[str, Any],
|
||||||
|
pp: policy.Principal | None,
|
||||||
|
*,
|
||||||
|
include_children: bool = True,
|
||||||
|
seal: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The read model of one loaded packets row. pp None (seal mode) leaves out can, blocked and holder."""
|
||||||
|
ch = load_children(conn, row, full=True)
|
||||||
|
state = state_from(row, ch)
|
||||||
|
people = People(conn, _who_ids(row, ch), seal=seal)
|
||||||
|
cache: dict[Any, Any] = {}
|
||||||
|
welder = people.ref(row["welder_id"])
|
||||||
|
if welder is not None:
|
||||||
|
welder["stamp"] = row["welder_stamp"]
|
||||||
|
weld = bool(ch.dept.get("weld"))
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"id": row["id"],
|
||||||
|
"version": row["version"],
|
||||||
|
"unit": _unit(conn, row["unit_id"], seal=seal),
|
||||||
|
"dept": {"code": ch.dept["code"], "name": ch.dept["name"], "weld": weld},
|
||||||
|
"op": row["op"],
|
||||||
|
"title": row["title"],
|
||||||
|
"customer": row["customer"],
|
||||||
|
"drawing": row["drawing"],
|
||||||
|
"rev": row["rev"],
|
||||||
|
"wps": row["wps"],
|
||||||
|
"status": row["status"],
|
||||||
|
"status_at": row["status_at"],
|
||||||
|
"station": ch.station,
|
||||||
|
"welder": welder,
|
||||||
|
"for_sup": people.ref(row["for_sup_id"]),
|
||||||
|
"heat_map": row["heat_map"],
|
||||||
|
"wh_skipped": row["wh_skipped"],
|
||||||
|
"wh_sent_at": row["wh_sent_at"],
|
||||||
|
"routed_to": row["routed_to"],
|
||||||
|
"return_note": row["return_note"],
|
||||||
|
"vclosed": None
|
||||||
|
if row["vclosed_at"] is None
|
||||||
|
else {"at": row["vclosed_at"], "by": people.ref(row["vclosed_by"])},
|
||||||
|
"databook_at": row["databook_at"],
|
||||||
|
"source_kind": row["source_kind"],
|
||||||
|
"source": row["source"] or {},
|
||||||
|
"drawing_state": row["drawing_state"],
|
||||||
|
"flags": list(row["flags"] or []),
|
||||||
|
"visual": row["visual"] or {},
|
||||||
|
"removed": None
|
||||||
|
if row["removed_at"] is None
|
||||||
|
else {"at": row["removed_at"], "by": people.ref(row["removed_by"]), "reason": row["removed_reason"]},
|
||||||
|
"open_cos": [c["co_no"] for c in ch.open_cos],
|
||||||
|
"open_shorts": len(ch.shorts),
|
||||||
|
}
|
||||||
|
if include_children:
|
||||||
|
out["work_orders"] = [
|
||||||
|
{
|
||||||
|
k: w[k]
|
||||||
|
for k in (
|
||||||
|
"id",
|
||||||
|
"wo_base",
|
||||||
|
"lot",
|
||||||
|
"split",
|
||||||
|
"sub",
|
||||||
|
"seq",
|
||||||
|
"qty",
|
||||||
|
"target_hrs",
|
||||||
|
"op",
|
||||||
|
"description",
|
||||||
|
"visual_status",
|
||||||
|
"gone_at",
|
||||||
|
"row_version",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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["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["wh"] = (
|
||||||
|
None
|
||||||
|
if ch.wh is None
|
||||||
|
else {
|
||||||
|
"stage": ch.wh["stage"],
|
||||||
|
"by": people.ref(ch.wh["by"]),
|
||||||
|
"at": ch.wh["at"],
|
||||||
|
"reason": ch.wh["reason"],
|
||||||
|
"note": ch.wh["note"],
|
||||||
|
"auto": ch.wh["auto"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out["qc"] = _qc(ch.qc, people) if ch.qc else None
|
||||||
|
out["qc_history"] = [
|
||||||
|
{k: h[k] for k in ("id", "at", "result", "inspector", "note", "dept_code")} for h in ch.qc_history
|
||||||
|
]
|
||||||
|
out["change_orders"] = [_co(c, ch, people) for c in ch.change_orders]
|
||||||
|
out["docs"] = [
|
||||||
|
{
|
||||||
|
"id": d["id"],
|
||||||
|
"asset_id": str(d["asset_id"]),
|
||||||
|
"kind": d["kind"],
|
||||||
|
"title": d["title"],
|
||||||
|
"added_at": d["added_at"],
|
||||||
|
"added_by": people.ref(d["added_by"]),
|
||||||
|
"url": f"/_blob/{d['asset_id']}",
|
||||||
|
}
|
||||||
|
for d in ch.docs
|
||||||
|
]
|
||||||
|
out["seal"] = (
|
||||||
|
None
|
||||||
|
if ch.seal is None
|
||||||
|
else {"sealed_at": ch.seal["sealed_at"], "key_id": ch.seal["key_id"], "broken_at": ch.seal["broken_at"]}
|
||||||
|
)
|
||||||
|
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"])
|
||||||
|
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"])
|
||||||
|
if pp is not None:
|
||||||
|
allowed, blocked = policy.can(pp, state)
|
||||||
|
out["can"] = [str(a) for a in allowed]
|
||||||
|
out["blocked"] = {str(a): r for a, r in blocked.items()}
|
||||||
|
out["holder"] = holder_text(state, ch)
|
||||||
|
if seal:
|
||||||
|
for k in SEAL_EXCLUDE:
|
||||||
|
out.pop(k, None)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _bom(b: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
|
||||||
|
keys = (
|
||||||
|
"id",
|
||||||
|
"item",
|
||||||
|
"qty",
|
||||||
|
"part_id",
|
||||||
|
"description",
|
||||||
|
"dwg_ref",
|
||||||
|
"cl_item",
|
||||||
|
"length",
|
||||||
|
"material",
|
||||||
|
"heat",
|
||||||
|
"na",
|
||||||
|
"issued",
|
||||||
|
"loc",
|
||||||
|
"pulled",
|
||||||
|
"src",
|
||||||
|
"row_version",
|
||||||
|
)
|
||||||
|
out = {k: b[k] for k in keys}
|
||||||
|
out["mtr_asset_id"] = uid_text(b["mtr_asset_id"])
|
||||||
|
s = ch.shorts.get(b["id"])
|
||||||
|
out["short"] = (
|
||||||
|
None
|
||||||
|
if s is None
|
||||||
|
else {
|
||||||
|
"id": s["id"],
|
||||||
|
"qty": s["qty"],
|
||||||
|
"qty_open": s["qty_open"],
|
||||||
|
"reason": s["reason"],
|
||||||
|
"ref": s["ref"],
|
||||||
|
"eta": s["eta"],
|
||||||
|
"note": s["note"],
|
||||||
|
"opened_at": s["opened_at"],
|
||||||
|
"receipts": [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"qty": r["qty"],
|
||||||
|
"heat": r["heat"],
|
||||||
|
"note": r["note"],
|
||||||
|
"at": r["at"],
|
||||||
|
"by": people.ref(r["by"]),
|
||||||
|
}
|
||||||
|
for r in ch.receipts.get(s["id"], [])
|
||||||
|
],
|
||||||
|
"row_version": s["row_version"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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}
|
||||||
|
out["items"] = list(w["items"] or [])
|
||||||
|
out["welder"] = people.ref(w["welder_id"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _co(c: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
|
||||||
|
keys = (
|
||||||
|
"id",
|
||||||
|
"co_no",
|
||||||
|
"origin",
|
||||||
|
"review",
|
||||||
|
"minor",
|
||||||
|
"hold",
|
||||||
|
"ecn",
|
||||||
|
"reason",
|
||||||
|
"issued_at",
|
||||||
|
"approved_at",
|
||||||
|
"rejected_at",
|
||||||
|
"rejected_reason",
|
||||||
|
"row_version",
|
||||||
|
)
|
||||||
|
out = {k: c[k] for k in keys}
|
||||||
|
out["issued_by"] = people.ref(c["issued_by"])
|
||||||
|
out["approved_by"] = people.ref(c["approved_by"])
|
||||||
|
out["rejected_by"] = people.ref(c["rejected_by"])
|
||||||
|
out["items"] = [
|
||||||
|
{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"]}
|
||||||
|
for v in ch.co_verified.get(c["id"], [])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def read_packet(
|
||||||
|
conn: Any, packet_id: int, policy_principal: policy.Principal | None, *, include_children: bool = True
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The read model, or 404 not_found. A removed packet reads fine, with `removed` set."""
|
||||||
|
row = load_row(conn, packet_id)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "not_found", "No such packet.")
|
||||||
|
return build(conn, row, policy_principal, include_children=include_children)
|
||||||
|
|
||||||
|
|
||||||
|
def seal_content(conn: Any, row: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""What a filing seal covers: the read model without the volatile and derived keys (SEAL_EXCLUDE)."""
|
||||||
|
return build(conn, row, None, include_children=True, seal=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- board cards
|
||||||
|
|
||||||
|
|
||||||
|
CARD_SQL = """
|
||||||
|
SELECT p.id, p.version, p.unit_id, u.lot, pr.number || u.lot AS unit_number, pr.id AS project_id,
|
||||||
|
pr.number AS project_number, pr.name AS project_name, p.dept_code, d.name AS dept_name, d.weld,
|
||||||
|
p.op, p.title, p.customer, p.drawing, p.rev, p.status::text AS status, p.status_at, p.station_id,
|
||||||
|
st.name AS station_name, p.welder_id, wu.display_name AS welder_name, p.welder_stamp,
|
||||||
|
p.routed_to::text AS routed_to, p.vclosed_at, p.vclosed_by, vu.display_name AS vclosed_name,
|
||||||
|
p.source_kind::text AS source_kind, p.flags, p.drawing_state::text AS drawing_state, p.wh_skipped,
|
||||||
|
p.heat_map, p.removed_at, coalesce(u.due, pr.due) AS due_effective, p.updated_at,
|
||||||
|
(SELECT w.stage::text FROM packet_wh w WHERE w.packet_id = p.id AND w.closed_at IS NULL) AS wh_stage,
|
||||||
|
(SELECT count(*) FROM bom_shorts s WHERE s.packet_id = p.id AND s.closed_at IS NULL) AS open_shorts,
|
||||||
|
(SELECT count(*) FROM packet_work_orders wo WHERE wo.packet_id = p.id) AS wo_count,
|
||||||
|
(SELECT coalesce(json_agg(json_build_object('co_no', c.co_no, 'review', c.review,
|
||||||
|
'pending_depts', c.pending_depts) ORDER BY c.co_no), '[]')
|
||||||
|
FROM v_open_cos c WHERE c.packet_id = p.id) AS open_co_rows
|
||||||
|
FROM packets p
|
||||||
|
JOIN units u ON u.id = p.unit_id
|
||||||
|
JOIN projects pr ON pr.id = u.project_id
|
||||||
|
JOIN departments d ON d.code = p.dept_code
|
||||||
|
LEFT JOIN stations st ON st.id = p.station_id
|
||||||
|
LEFT JOIN users wu ON wu.id = p.welder_id
|
||||||
|
LEFT JOIN users vu ON vu.id = p.vclosed_by
|
||||||
|
"""
|
||||||
|
|
||||||
|
FLAG_ON = ("1", "true", "yes", "any", "flagged")
|
||||||
|
FLAG_OFF = ("0", "false", "no", "none")
|
||||||
|
|
||||||
|
|
||||||
|
def _like(word: str) -> str:
|
||||||
|
return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||||
|
|
||||||
|
|
||||||
|
def card_state(r: Mapping[str, Any], names: Mapping[str, str]) -> policy.PacketState:
|
||||||
|
"""PacketState from header columns and counts. Child-row facts the board does not load pass (the packet
|
||||||
|
page decides them): checklist, welds, filler, BOM pulled, Q.C. items."""
|
||||||
|
cos = r["open_co_rows"] or []
|
||||||
|
return policy.PacketState(
|
||||||
|
id=int(r["id"]),
|
||||||
|
status=str(r["status"]),
|
||||||
|
dept=str(r["dept_code"]),
|
||||||
|
station_id=r["station_id"],
|
||||||
|
welder_id=uid_text(r["welder_id"]),
|
||||||
|
wh_skipped=bool(r["wh_skipped"]),
|
||||||
|
routed_to=r["routed_to"],
|
||||||
|
vclosed=r["vclosed_at"] is not None,
|
||||||
|
removed=r["removed_at"] is not None,
|
||||||
|
source_kind=str(r["source_kind"]),
|
||||||
|
weld_dept=bool(r["weld"]),
|
||||||
|
co_hold=bool(cos),
|
||||||
|
co_hold_why=co_hold_why(cos, names),
|
||||||
|
open_shorts=int(r["open_shorts"] or 0),
|
||||||
|
drawing_attached=r["drawing_state"] in DRAWING_STATES_ATTACHED,
|
||||||
|
heat_map=r["heat_map"],
|
||||||
|
bom_total=0,
|
||||||
|
bom_ready=0,
|
||||||
|
welds=1,
|
||||||
|
checklist_complete=True,
|
||||||
|
filler_set=True,
|
||||||
|
qc_missing=(),
|
||||||
|
has_qc_sent=r["status"] == "qc",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
"""The board cards. Filters: status (comma list), dept, unit_id, project_id, q, flag, limit, removed."""
|
||||||
|
where: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if not filters.get("removed"):
|
||||||
|
where.append("p.removed_at IS NULL")
|
||||||
|
status = [s.strip() for s in str(filters.get("status") or "").split(",") if s.strip()]
|
||||||
|
if status:
|
||||||
|
where.append("p.status::text = ANY(%s)")
|
||||||
|
params.append(status)
|
||||||
|
if filters.get("dept"):
|
||||||
|
where.append("p.dept_code = %s")
|
||||||
|
params.append(str(filters["dept"]))
|
||||||
|
if filters.get("unit_id") is not None:
|
||||||
|
where.append("p.unit_id = %s")
|
||||||
|
params.append(int(filters["unit_id"]))
|
||||||
|
if filters.get("project_id") is not None:
|
||||||
|
where.append("u.project_id = %s")
|
||||||
|
params.append(int(filters["project_id"]))
|
||||||
|
flag = str(filters.get("flag") or "").strip().lower()
|
||||||
|
if flag in FLAG_ON:
|
||||||
|
where.append(FLAG_SQL)
|
||||||
|
elif flag in FLAG_OFF:
|
||||||
|
where.append("NOT " + FLAG_SQL)
|
||||||
|
elif flag == "hand":
|
||||||
|
where.append("(p.status = 'draft' AND p.source_kind = 'hand')")
|
||||||
|
for word in str(filters.get("q") or "").split()[:8]:
|
||||||
|
pat = _like(word)
|
||||||
|
where.append(
|
||||||
|
"(pr.number ILIKE %s OR pr.name ILIKE %s OR (pr.number || u.lot) ILIKE %s OR u.lot ILIKE %s"
|
||||||
|
" OR p.drawing ILIKE %s OR p.customer ILIKE %s OR pr.customer ILIKE %s OR pr.po ILIKE %s"
|
||||||
|
" OR p.title ILIKE %s OR EXISTS (SELECT 1 FROM packet_work_orders wo WHERE wo.packet_id = p.id"
|
||||||
|
" AND wo.wo_base ILIKE %s))"
|
||||||
|
)
|
||||||
|
params += [pat] * 10
|
||||||
|
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)
|
||||||
|
transitions = {str(a) for a in policy.TRANSITIONS}
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for r in rows(conn, sql, params):
|
||||||
|
state = card_state(r, names)
|
||||||
|
allowed, blocked = policy.can(policy_principal, state)
|
||||||
|
welder = (
|
||||||
|
None
|
||||||
|
if r["welder_id"] is None
|
||||||
|
else {"id": str(r["welder_id"]), "display_name": r["welder_name"], "stamp": r["welder_stamp"]}
|
||||||
|
)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"version": r["version"],
|
||||||
|
"unit": {"id": r["unit_id"], "number": r["unit_number"]},
|
||||||
|
"project": {"id": r["project_id"], "number": r["project_number"], "name": r["project_name"]},
|
||||||
|
"dept": {"code": r["dept_code"], "name": r["dept_name"]},
|
||||||
|
"op": r["op"],
|
||||||
|
"title": r["title"],
|
||||||
|
"customer": r["customer"],
|
||||||
|
"drawing": r["drawing"],
|
||||||
|
"rev": r["rev"],
|
||||||
|
"status": r["status"],
|
||||||
|
"status_at": r["status_at"],
|
||||||
|
"station": None if r["station_id"] is None else {"id": r["station_id"], "name": r["station_name"]},
|
||||||
|
"welder": welder,
|
||||||
|
"routed_to": r["routed_to"],
|
||||||
|
"vclosed": None
|
||||||
|
if r["vclosed_at"] is None
|
||||||
|
else {
|
||||||
|
"at": r["vclosed_at"],
|
||||||
|
"by": None
|
||||||
|
if r["vclosed_by"] is None
|
||||||
|
else {"id": str(r["vclosed_by"]), "display_name": r["vclosed_name"]},
|
||||||
|
},
|
||||||
|
"source_kind": r["source_kind"],
|
||||||
|
"flag": flag_of(r["status"], r["source_kind"], r["flags"], r["drawing_state"]),
|
||||||
|
"open_cos": [c["co_no"] for c in r["open_co_rows"] or []],
|
||||||
|
"open_shorts": int(r["open_shorts"] or 0),
|
||||||
|
"wh_stage": r["wh_stage"],
|
||||||
|
"due_effective": r["due_effective"],
|
||||||
|
"updated_at": r["updated_at"],
|
||||||
|
"holder": policy.holder_name(state, r["dept_name"], r["station_name"]),
|
||||||
|
"can": [str(a) for a in allowed if str(a) in transitions],
|
||||||
|
"blocked": {str(a): v for a, v in blocked.items() if str(a) in transitions},
|
||||||
|
"wo_count": int(r["wo_count"] or 0),
|
||||||
|
"removed": r["removed_at"] is not None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,704 @@
|
|||||||
|
"""The write pipeline: every packet write is one transaction through `packet_write`.
|
||||||
|
|
||||||
|
Inside one transaction: lock the packet row (SELECT ... FOR UPDATE), build the policy's PacketState, check the
|
||||||
|
caller's expected step (409 stale_step), check the packet version when If-Match is given (409 version_conflict),
|
||||||
|
ask the policy (403 or 400 with the decision's code), take the signature when the rule needs one, hand the caller a
|
||||||
|
`Write`, then bump the version, write the packet_log row, the transition row and its side effects, the audit row and
|
||||||
|
the NOTIFY. The policy decides; a handler never does.
|
||||||
|
|
||||||
|
A wrong signature secret is the one failure that commits: the step-up failure count and the attempt row must stay,
|
||||||
|
so the three-strike rule works. Nothing else has been written at that point.
|
||||||
|
|
||||||
|
Child-row endpoints build on the same pieces: `check_row_version`, `next_counter`, `apply_field_cas`, `parse_if_match`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
from collections.abc import Iterator, Mapping, Sequence
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .. import audit, events, policy
|
||||||
|
from ..api import deps
|
||||||
|
from ..api.errors import ApiError
|
||||||
|
from ..auth import signatures
|
||||||
|
from ..auth.signatures import Signature
|
||||||
|
from ..policy.engine import MSG_FILED
|
||||||
|
from ..settings import SecretFileError
|
||||||
|
from ..text import LIMITS, clean_opt, reason_ok
|
||||||
|
from . import packets
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
A = policy.Action
|
||||||
|
|
||||||
|
MSG_STALE = "Someone else already took this step."
|
||||||
|
MSG_VERSION = "The packet changed since you opened it. Reload and try again."
|
||||||
|
MSG_ROW = "Someone else changed this line. Reload and try again."
|
||||||
|
MSG_FIELD = "Someone else changed this since you opened it."
|
||||||
|
|
||||||
|
# Codes that answer 400 (the UI asks for input and retries); every other refusal answers 403.
|
||||||
|
ASK_CODES = frozenset({"it_reason_required", "reason_required"})
|
||||||
|
|
||||||
|
COUNTERS = {"weld": "next_weld_no", "heat": "next_heat_no", "page": "next_page_no", "co": "next_co_no"}
|
||||||
|
|
||||||
|
# The packet_log label per action, when the caller gives none.
|
||||||
|
LABELS: dict[policy.Action, str] = {
|
||||||
|
A.SEND_TO_WAREHOUSE: "Sent to warehouse",
|
||||||
|
A.RELEASE_TO_SHOP: "Sent straight to the shop",
|
||||||
|
A.SEND_DRAFT_TO_QC: "Sent straight to Q.C.",
|
||||||
|
A.WH_SEND_BACK: "Sent back to Clerical",
|
||||||
|
A.RETURN_TO_CLERICAL: "Sent back to Clerical",
|
||||||
|
A.WH_RELEASE: "Released to the shop",
|
||||||
|
A.SEND_BACK_TO_WAREHOUSE: "Sent back to the warehouse",
|
||||||
|
A.ASSIGN: "Sent to a station",
|
||||||
|
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.RETURN_TO_STATION: "Returned to the station",
|
||||||
|
A.SEND_TO_QC: "Sent to Q.C.",
|
||||||
|
A.ROUTE_FROM_REVIEW: "Routed to another department",
|
||||||
|
A.QC_REJECT: "Q.C. rejected",
|
||||||
|
A.QC_ACCEPT: "Q.C. accepted",
|
||||||
|
A.QC_ACCEPT_AND_ROUTE: "Q.C. accepted and routed",
|
||||||
|
A.FILE: "Filed in the data book",
|
||||||
|
A.ADMIN_REOPEN: "Reopened by IT",
|
||||||
|
A.EDIT_HEADER: "Details changed",
|
||||||
|
A.EDIT_DRAWING_REF: "Drawing number changed",
|
||||||
|
A.EDIT_DEPT: "Department changed",
|
||||||
|
A.MOVE_UNIT: "Moved to another unit",
|
||||||
|
A.EDIT_WOS: "Work orders changed",
|
||||||
|
A.EDIT_DOCUMENT_SET: "Drawing attached by hand",
|
||||||
|
A.DOC_ADD: "Document added",
|
||||||
|
A.DOC_REMOVE: "Document removed",
|
||||||
|
A.SET_VCLOSED: "Job closed in VISUAL",
|
||||||
|
A.SET_DATABOOK_AT: "Data book date set",
|
||||||
|
A.REMOVE: "Packet removed",
|
||||||
|
}
|
||||||
|
|
||||||
|
AUDIT_ACTIONS: dict[policy.Action, str] = {A.REMOVE: "packet.removed", A.MOVE_UNIT: "packet.moved"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- request bodies
|
||||||
|
|
||||||
|
|
||||||
|
class SignBody(BaseModel):
|
||||||
|
factor: Literal["pin", "password"]
|
||||||
|
secret: str = Field(max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class Expect(BaseModel):
|
||||||
|
status: str = Field(max_length=20)
|
||||||
|
dept: str = Field(max_length=10)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the Write object
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Write:
|
||||||
|
conn: Any # the request connection, inside one transaction
|
||||||
|
principal: deps.Principal # the session principal
|
||||||
|
policy_principal: policy.Principal
|
||||||
|
packet: dict[str, Any] # the packets row, locked FOR UPDATE
|
||||||
|
state: policy.PacketState
|
||||||
|
decision: policy.Decision
|
||||||
|
signature: Signature | None
|
||||||
|
version: int # the new version after the bump (set on exit)
|
||||||
|
action: policy.Action
|
||||||
|
extra: Mapping[str, Any]
|
||||||
|
it_reason: str | None # the cleaned IT reason, kept only when the admin bypass was used
|
||||||
|
request: Request
|
||||||
|
label: str | None = None
|
||||||
|
note: str | None = None
|
||||||
|
fields: list[str] = field(default_factory=list)
|
||||||
|
detail: dict[str, Any] = field(default_factory=dict)
|
||||||
|
transitioned: bool = False
|
||||||
|
transition_id: int | None = None
|
||||||
|
noop: bool = False
|
||||||
|
from_status: str = ""
|
||||||
|
from_dept: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> int:
|
||||||
|
return int(self.packet["id"])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_id(self) -> Any:
|
||||||
|
return self.principal.user_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rule(self) -> policy.Rule:
|
||||||
|
return policy.RULES[self.action]
|
||||||
|
|
||||||
|
def log(self, action_label: str, note: str | None = None) -> None:
|
||||||
|
"""Override the packet_log label (and note) for this write."""
|
||||||
|
self.label = action_label
|
||||||
|
if note is not None:
|
||||||
|
self.note = note
|
||||||
|
|
||||||
|
def changed(self, *names: str) -> None:
|
||||||
|
"""Record the fields this write changed (they go into the audit detail)."""
|
||||||
|
for n in names:
|
||||||
|
if n not in self.fields:
|
||||||
|
self.fields.append(n)
|
||||||
|
|
||||||
|
def skip(self) -> None:
|
||||||
|
"""Nothing changed: no version bump, no log, audit or event."""
|
||||||
|
self.noop = True
|
||||||
|
|
||||||
|
def check(self, action: policy.Action, extra: Mapping[str, Any] | None = None) -> policy.Decision:
|
||||||
|
"""Check one more action on the same locked packet (for a write that touches several rules)."""
|
||||||
|
d = policy.check(self.policy_principal, self.state, action, extra=extra or self.extra, it_reason=self.it_reason)
|
||||||
|
refuse(d)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- helpers for callers
|
||||||
|
|
||||||
|
|
||||||
|
def refuse(decision: policy.Decision) -> None:
|
||||||
|
"""Raise the API error for a failed policy decision."""
|
||||||
|
if decision.ok:
|
||||||
|
return
|
||||||
|
status = 400 if decision.code in ASK_CODES else 403
|
||||||
|
raise ApiError(status, decision.code, decision.why)
|
||||||
|
|
||||||
|
|
||||||
|
def current_of(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
return {"status": str(row["status"]), "dept": str(row["dept_code"]), "version": int(row["version"])}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_if_match(request: Request) -> int | None:
|
||||||
|
"""The If-Match header as a plain integer (quotes and a W/ prefix are tolerated), or None when absent."""
|
||||||
|
raw = request.headers.get("if-match")
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
text = raw.strip().removeprefix("W/").strip().strip('"')
|
||||||
|
try:
|
||||||
|
return int(text)
|
||||||
|
except ValueError:
|
||||||
|
raise ApiError(400, "bad_if_match", "If-Match must be a version number.") from None
|
||||||
|
|
||||||
|
|
||||||
|
def require_if_match(request: Request) -> int:
|
||||||
|
v = parse_if_match(request)
|
||||||
|
if v is None:
|
||||||
|
raise ApiError(428, "if_match_required", "Send If-Match with the version you started from.")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def check_row_version(row: Mapping[str, Any] | None, if_match: int | None, *, current: Any = None) -> None:
|
||||||
|
"""409 row_conflict with the current row when its row_version differs from If-Match.
|
||||||
|
|
||||||
|
`current` is what goes into the error (defaults to the row itself, JSON-shaped by the caller if needed).
|
||||||
|
A missing If-Match is 428 if_match_required.
|
||||||
|
"""
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "not_found", "No such line.")
|
||||||
|
if if_match is None:
|
||||||
|
raise ApiError(428, "if_match_required", "Send If-Match with the version you started from.")
|
||||||
|
if int(row["row_version"]) != int(if_match):
|
||||||
|
raise ApiError(
|
||||||
|
409, "row_conflict", MSG_ROW, extra={"current": jsonable(current if current is not None else row)}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def next_counter(conn: Any, packet_id: int, kind: str, n: int = 1) -> int:
|
||||||
|
"""Take `n` consecutive numbers from a packet counter (weld, heat, page, co); returns the first one.
|
||||||
|
|
||||||
|
Call it inside packet_write: the packet row is locked FOR UPDATE, so concurrent writers serialise on it.
|
||||||
|
Outside a pipeline the UPDATE itself takes the row lock for the rest of the transaction.
|
||||||
|
"""
|
||||||
|
col = COUNTERS.get(kind)
|
||||||
|
if col is None:
|
||||||
|
raise ValueError(f"unknown counter {kind!r}")
|
||||||
|
if n < 1:
|
||||||
|
raise ValueError("n must be at least 1")
|
||||||
|
row = conn.execute(
|
||||||
|
f"UPDATE packets SET {col} = {col} + %s WHERE id = %s RETURNING {col} - %s", # noqa: S608 (col from COUNTERS)
|
||||||
|
(n, packet_id, n),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "not_found", "No such packet.")
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: Any) -> Any:
|
||||||
|
"""Comparable form of a value: uuid and dates as text, numbers as numbers, '' as None."""
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool | int | float | str):
|
||||||
|
return value
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_field_cas(
|
||||||
|
current: Mapping[str, Any], set_: Mapping[str, Any], base: Mapping[str, Any] | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Field-level compare-and-set. For every key in set_ that base also has, base must equal current.
|
||||||
|
|
||||||
|
Conflicts raise 409 field_conflict with `current: {key: value}` for the conflicting keys. Keys without a base
|
||||||
|
entry are written without a check (the old merge3: mine wins). Returns the keys whose value actually changes.
|
||||||
|
"""
|
||||||
|
base = base or {}
|
||||||
|
conflicts = {k: current.get(k) for k in set_ if k in base and _norm(base[k]) != _norm(current.get(k))}
|
||||||
|
if conflicts:
|
||||||
|
raise ApiError(409, "field_conflict", MSG_FIELD, extra={"current": jsonable(conflicts)})
|
||||||
|
return {k: v for k, v in set_.items() if _norm(v) != _norm(current.get(k))}
|
||||||
|
|
||||||
|
|
||||||
|
def jsonable(value: Any) -> Any:
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
|
||||||
|
return jsonable_encoder(value)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the pipeline
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def packet_write(
|
||||||
|
d: deps.Db,
|
||||||
|
p: deps.Principal,
|
||||||
|
packet_id: int,
|
||||||
|
action: policy.Action,
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
extra: Mapping[str, Any] | None = None,
|
||||||
|
it_reason: str | None = None,
|
||||||
|
sign: SignBody | None = None,
|
||||||
|
expect: Expect | None = None,
|
||||||
|
sign_content: Any = None,
|
||||||
|
if_match: int | None = None,
|
||||||
|
also: Sequence[policy.Action] = (),
|
||||||
|
require_sign: bool | None = None,
|
||||||
|
policy_principal: policy.Principal | None = None,
|
||||||
|
) -> Iterator[Write]:
|
||||||
|
"""One packet write. See the module docstring for the order of steps.
|
||||||
|
|
||||||
|
`also` lists more actions the same write needs (each is checked; the first action is the one logged).
|
||||||
|
`require_sign` overrides the rule's signature: False skips it (removing a hand-attached drawing), True forces it.
|
||||||
|
"""
|
||||||
|
conn = d.conn
|
||||||
|
extra = dict(extra or {})
|
||||||
|
pp = policy_principal or packets.policy_principal_for(conn, p)
|
||||||
|
failure: ApiError | None = None
|
||||||
|
with conn.transaction():
|
||||||
|
row = packets.load_row(conn, packet_id, lock=True)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "not_found", "No such packet.")
|
||||||
|
ch = packets.load_children(conn, row, full=False)
|
||||||
|
state = packets.state_from(row, ch)
|
||||||
|
if expect is not None and (expect.status != row["status"] or expect.dept != row["dept_code"]):
|
||||||
|
raise ApiError(409, "stale_step", MSG_STALE, extra={"current": current_of(row)})
|
||||||
|
extra.setdefault("holder_name", packets.holder_text(state, ch))
|
||||||
|
rule = policy.RULES[action]
|
||||||
|
if not state.removed and state.status == "filed" and not rule.filed_ok:
|
||||||
|
# Say "filed" rather than "wrong status" or "give an IT reason": nothing but a reopen changes it.
|
||||||
|
raise ApiError(403, "filed", MSG_FILED)
|
||||||
|
cleaned_it = reason_ok(it_reason)
|
||||||
|
decision = policy.check(pp, state, action, extra=extra, it_reason=it_reason)
|
||||||
|
refuse(decision)
|
||||||
|
bypass = decision.admin_bypass
|
||||||
|
for other in also:
|
||||||
|
dd = policy.check(pp, state, other, extra=extra, it_reason=it_reason)
|
||||||
|
refuse(dd)
|
||||||
|
bypass = bypass or dd.admin_bypass
|
||||||
|
if if_match is not None and int(if_match) != int(row["version"]):
|
||||||
|
raise ApiError(409, "version_conflict", MSG_VERSION, extra={"current": current_of(row)})
|
||||||
|
w = Write(
|
||||||
|
conn=conn,
|
||||||
|
principal=p,
|
||||||
|
policy_principal=pp,
|
||||||
|
packet=row,
|
||||||
|
state=state,
|
||||||
|
decision=decision,
|
||||||
|
signature=None,
|
||||||
|
version=int(row["version"]),
|
||||||
|
action=action,
|
||||||
|
extra=extra,
|
||||||
|
it_reason=cleaned_it if bypass else None,
|
||||||
|
request=request,
|
||||||
|
from_status=str(row["status"]),
|
||||||
|
from_dept=str(row["dept_code"]),
|
||||||
|
)
|
||||||
|
meaning = decision.signature
|
||||||
|
needs = bool(meaning) if require_sign is None else require_sign
|
||||||
|
if needs:
|
||||||
|
if sign is None:
|
||||||
|
raise ApiError(
|
||||||
|
400,
|
||||||
|
"signature_required",
|
||||||
|
"This step needs your signature.",
|
||||||
|
extra={"meaning": meaning or LABELS.get(action, str(action))},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
w.signature = signatures.sign(
|
||||||
|
conn,
|
||||||
|
deps.directory_of(request),
|
||||||
|
p.actor,
|
||||||
|
factor=sign.factor,
|
||||||
|
secret=sign.secret,
|
||||||
|
action=f"packet.{action}",
|
||||||
|
meaning=meaning,
|
||||||
|
packet_id=packet_id,
|
||||||
|
subject_type="packet",
|
||||||
|
subject_id=packet_id,
|
||||||
|
content=sign_content
|
||||||
|
if sign_content is not None
|
||||||
|
else {"action": str(action), "packet_id": packet_id, "version": int(row["version"])},
|
||||||
|
ip=p.ip,
|
||||||
|
settings=deps.settings_of(request),
|
||||||
|
)
|
||||||
|
except ApiError as e:
|
||||||
|
failure = e # commit the step-up failure records, then raise below
|
||||||
|
if failure is None:
|
||||||
|
yield w
|
||||||
|
_finish(w)
|
||||||
|
if failure is not None:
|
||||||
|
raise failure
|
||||||
|
|
||||||
|
|
||||||
|
def _finish(w: Write) -> None:
|
||||||
|
if w.noop:
|
||||||
|
return
|
||||||
|
if w.rule.transition and not w.transitioned:
|
||||||
|
apply_transition(w)
|
||||||
|
conn = w.conn
|
||||||
|
w.version = int(
|
||||||
|
conn.execute("UPDATE packets SET version = version + 1 WHERE id = %s RETURNING version", (w.id,)).fetchone()[0]
|
||||||
|
)
|
||||||
|
after = conn.execute("SELECT status::text, dept_code FROM packets WHERE id = %s", (w.id,)).fetchone()
|
||||||
|
status, dept = str(after[0]), str(after[1])
|
||||||
|
label = w.label or LABELS.get(w.action, str(w.action).replace("_", " ").capitalize())
|
||||||
|
note = clean_opt(w.note, LIMITS["note"]) if w.note else None
|
||||||
|
sig_id = w.signature.id if w.signature else None
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note, it_reason, signature_id, transition_id)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
|
||||||
|
(
|
||||||
|
w.id,
|
||||||
|
w.user_id,
|
||||||
|
w.principal.user["display_name"],
|
||||||
|
w.principal.role_snapshot,
|
||||||
|
label,
|
||||||
|
note,
|
||||||
|
w.it_reason,
|
||||||
|
sig_id,
|
||||||
|
w.transition_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
detail: dict[str, Any] = {"from": w.from_status, "to": status, "dept": dept}
|
||||||
|
if w.from_dept != dept:
|
||||||
|
detail["from_dept"] = w.from_dept
|
||||||
|
if w.fields:
|
||||||
|
detail["fields"] = w.fields
|
||||||
|
if note:
|
||||||
|
detail["note"] = note
|
||||||
|
if sig_id:
|
||||||
|
detail["signature_id"] = sig_id
|
||||||
|
detail.update(w.detail)
|
||||||
|
audit.append(
|
||||||
|
conn,
|
||||||
|
action=AUDIT_ACTIONS.get(w.action, f"packet.{w.action}"),
|
||||||
|
user_id=w.user_id,
|
||||||
|
session_id=w.principal.session_id,
|
||||||
|
role_snapshot=w.principal.role_snapshot,
|
||||||
|
subject_type="packet",
|
||||||
|
subject_id=w.id,
|
||||||
|
packet_id=w.id,
|
||||||
|
detail=detail,
|
||||||
|
it_reason=w.it_reason,
|
||||||
|
ip=w.principal.ip,
|
||||||
|
)
|
||||||
|
events.publish(conn, {"event": "changed", "packet_id": w.id, "version": w.version, "status": status, "dept": dept})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- transitions and their side effects
|
||||||
|
|
||||||
|
|
||||||
|
CLOCK_STOP = (
|
||||||
|
"clock_elapsed_ms = clock_elapsed_ms + CASE WHEN clock_running_since IS NULL THEN 0"
|
||||||
|
" ELSE (extract(epoch FROM (now() - clock_running_since)) * 1000)::bigint END, clock_running_since = NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reason(w: Write) -> str | None:
|
||||||
|
return reason_ok(w.extra.get("reason"))
|
||||||
|
|
||||||
|
|
||||||
|
def _note(w: Write) -> str | None:
|
||||||
|
return clean_opt(w.extra.get("note"), LIMITS["note"])
|
||||||
|
|
||||||
|
|
||||||
|
def _wh_incoming(w: Write) -> None:
|
||||||
|
w.conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
||||||
|
w.conn.execute(
|
||||||
|
"INSERT INTO packet_wh (packet_id, stage, by, note) VALUES (%s, 'incoming', %s, %s)",
|
||||||
|
(w.id, w.user_id, _note(w)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wh_close(w: Write) -> None:
|
||||||
|
w.conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
||||||
|
|
||||||
|
|
||||||
|
def _open_stage(w: Write, dept: str) -> None:
|
||||||
|
w.conn.execute("INSERT INTO packet_stages (packet_id, dept_code) VALUES (%s, %s)", (w.id, dept))
|
||||||
|
|
||||||
|
|
||||||
|
def _close_stage(w: Write, qc_snapshot: Any = None) -> None:
|
||||||
|
w.conn.execute(
|
||||||
|
f"UPDATE packet_stages SET {CLOCK_STOP}, closed_at = now(), qc_snapshot = coalesce(%s, qc_snapshot)," # noqa: S608
|
||||||
|
" row_version = row_version + 1 WHERE packet_id = %s AND closed_at IS NULL",
|
||||||
|
(None if qc_snapshot is None else Jsonb(jsonable(qc_snapshot)), w.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_update(w: Write, assignments: str, params: Sequence[Any] = ()) -> None:
|
||||||
|
w.conn.execute(
|
||||||
|
f"UPDATE packet_stages SET {assignments}, row_version = row_version + 1" # noqa: S608 (fixed fragments)
|
||||||
|
" WHERE packet_id = %s AND closed_at IS NULL",
|
||||||
|
(*params, w.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_inspection(w: Write) -> dict[str, Any] | None:
|
||||||
|
return packets.one(w.conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
||||||
|
|
||||||
|
|
||||||
|
def _close_inspection(w: Write, insp: Mapping[str, Any] | None, dept: str) -> None:
|
||||||
|
"""Close the open inspection; when it had a result, keep it in qc_history."""
|
||||||
|
if insp is None:
|
||||||
|
return
|
||||||
|
w.conn.execute(
|
||||||
|
"UPDATE qc_inspections SET closed_at = now(), row_version = row_version + 1 WHERE id = %s", (insp["id"],)
|
||||||
|
)
|
||||||
|
if insp.get("result"):
|
||||||
|
w.conn.execute(
|
||||||
|
"INSERT INTO qc_history (packet_id, result, inspector, note, dept_code, snapshot)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s, %s)",
|
||||||
|
(w.id, insp["result"], insp.get("inspector"), insp.get("result_note"), dept, Jsonb(jsonable(dict(insp)))),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_result(w: Write, insp: Mapping[str, Any] | None, result: str, note: str | None) -> dict[str, Any] | None:
|
||||||
|
if insp is None:
|
||||||
|
return None
|
||||||
|
return packets.one(
|
||||||
|
w.conn,
|
||||||
|
"UPDATE qc_inspections SET result = %s, result_at = now(), result_by = %s, result_note = %s,"
|
||||||
|
" signature_id = %s, row_version = row_version + 1 WHERE id = %s RETURNING *",
|
||||||
|
(result, w.user_id, note, w.signature.id if w.signature else None, insp["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_packet(w: Write, assignments: str, params: Sequence[Any] = ()) -> None:
|
||||||
|
w.conn.execute(f"UPDATE packets SET {assignments} WHERE id = %s", (*params, w.id)) # noqa: S608 (fixed fragments)
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(w: Write) -> Any:
|
||||||
|
return deps.settings_of(w.request)
|
||||||
|
|
||||||
|
|
||||||
|
def seal_key_id(key: bytes) -> str:
|
||||||
|
return hashlib.sha256(key).hexdigest()[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def seal_hash(key: bytes, content: Any) -> bytes:
|
||||||
|
return hmac.new(key, signatures.canonical_json(content), hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _seal_key(w: Write) -> bytes:
|
||||||
|
try:
|
||||||
|
key: bytes = _settings(w).seal_key()
|
||||||
|
except SecretFileError as e:
|
||||||
|
log.error("seal key unavailable: %s", e)
|
||||||
|
raise ApiError(503, "server_misconfigured", "The server is missing its seal key.") from None
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
return
|
||||||
|
w.transitioned = True
|
||||||
|
a = w.action
|
||||||
|
rule = w.rule
|
||||||
|
to_status = rule.to_status
|
||||||
|
assert to_status is not None
|
||||||
|
ex = w.extra
|
||||||
|
reason = _reason(w)
|
||||||
|
from_dept = w.from_dept
|
||||||
|
to_dept = from_dept
|
||||||
|
if rule.dept_change != "no" and isinstance(ex.get("dept"), str) and ex.get("dept"):
|
||||||
|
to_dept = str(ex["dept"])
|
||||||
|
|
||||||
|
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)
|
||||||
|
elif a is A.RELEASE_TO_SHOP:
|
||||||
|
_set_packet(
|
||||||
|
w,
|
||||||
|
"wh_skipped = true, for_sup_id = %s, return_note = %s",
|
||||||
|
(ex.get("for_sup_id") or None, _note(w)),
|
||||||
|
)
|
||||||
|
_close_stage(w)
|
||||||
|
_open_stage(w, to_dept)
|
||||||
|
elif a is A.SEND_DRAFT_TO_QC:
|
||||||
|
_set_packet(w, "wh_skipped = true")
|
||||||
|
_close_inspection(w, _open_inspection(w), from_dept)
|
||||||
|
w.conn.execute(
|
||||||
|
"INSERT INTO qc_inspections (packet_id, sent_by, sent_note) VALUES (%s, %s, %s)",
|
||||||
|
(w.id, w.user_id, _note(w)),
|
||||||
|
)
|
||||||
|
elif a is A.WH_SEND_BACK:
|
||||||
|
_set_packet(w, "return_note = %s", (reason,))
|
||||||
|
_wh_close(w)
|
||||||
|
elif a is A.RETURN_TO_CLERICAL:
|
||||||
|
_set_packet(w, "return_note = %s", (reason,))
|
||||||
|
_close_stage(w)
|
||||||
|
elif a is A.WH_RELEASE:
|
||||||
|
_wh_close(w)
|
||||||
|
_close_stage(w)
|
||||||
|
_open_stage(w, to_dept)
|
||||||
|
elif a is A.SEND_BACK_TO_WAREHOUSE:
|
||||||
|
_set_packet(w, "return_note = %s, wh_skipped = false", (reason,))
|
||||||
|
_close_stage(w)
|
||||||
|
_wh_incoming(w)
|
||||||
|
elif a is A.ASSIGN:
|
||||||
|
station_id = int(ex["station_id"])
|
||||||
|
welder_id = str(ex["welder_id"])
|
||||||
|
stamp = clean_opt(ex.get("stamp"), LIMITS["stamp"])
|
||||||
|
if stamp is None:
|
||||||
|
r = w.conn.execute("SELECT welder_stamp FROM users WHERE id = %s", (welder_id,)).fetchone()
|
||||||
|
stamp = r[0] if r else None
|
||||||
|
_set_packet(w, "station_id = %s, welder_id = %s, welder_stamp = %s", (station_id, welder_id, stamp))
|
||||||
|
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))
|
||||||
|
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())")
|
||||||
|
elif a is A.SUBMIT:
|
||||||
|
_set_packet(w, "return_note = NULL")
|
||||||
|
_stage_update(w, CLOCK_STOP)
|
||||||
|
elif a is A.RETURN_TO_STATION:
|
||||||
|
_set_packet(w, "return_note = %s", (reason,))
|
||||||
|
elif a is A.SEND_TO_QC:
|
||||||
|
_close_inspection(w, _open_inspection(w), from_dept)
|
||||||
|
stage = packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
||||||
|
w.conn.execute(
|
||||||
|
"INSERT INTO qc_inspections (packet_id, stage_id, sent_by, book, spool, location, sent_note)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
||||||
|
(
|
||||||
|
w.id,
|
||||||
|
stage["id"] if stage else None,
|
||||||
|
w.user_id,
|
||||||
|
ex.get("book") is True,
|
||||||
|
ex.get("spool") is True,
|
||||||
|
clean_opt(ex.get("location"), LIMITS["location"]),
|
||||||
|
_note(w),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif a in (A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE):
|
||||||
|
insp = _open_inspection(w)
|
||||||
|
if a is A.QC_ACCEPT_AND_ROUTE:
|
||||||
|
insp = _set_result(w, insp, "accepted", _note(w))
|
||||||
|
_close_stage(w, qc_snapshot=insp)
|
||||||
|
_close_inspection(w, insp, from_dept)
|
||||||
|
_open_stage(w, to_dept)
|
||||||
|
_set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL, return_note = NULL, routed_to = NULL")
|
||||||
|
elif a is A.QC_REJECT:
|
||||||
|
_set_result(w, _open_inspection(w), "rejected", reason)
|
||||||
|
_set_packet(w, "return_note = %s", (reason,))
|
||||||
|
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
|
||||||
|
_open_stage(w, from_dept) # a draft sent straight to Q.C. has no stage yet
|
||||||
|
elif a is A.QC_ACCEPT:
|
||||||
|
insp = _set_result(w, _open_inspection(w), "accepted", _note(w))
|
||||||
|
_close_inspection(w, insp, from_dept)
|
||||||
|
_close_stage(w, qc_snapshot=insp)
|
||||||
|
_set_packet(w, "routed_to = %s::route_kind", (str(ex["routed_to"]),))
|
||||||
|
|
||||||
|
_set_packet(w, "status = %s::packet_status, status_at = now(), dept_code = %s", (to_status, to_dept))
|
||||||
|
|
||||||
|
if a is A.FILE:
|
||||||
|
key = _seal_key(w)
|
||||||
|
row = packets.load_row(w.conn, w.id)
|
||||||
|
assert row is not None
|
||||||
|
content = packets.seal_content(w.conn, row)
|
||||||
|
w.conn.execute(
|
||||||
|
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id) VALUES (%s, %s, %s, %s, %s)",
|
||||||
|
(w.id, seal_hash(key, content), seal_key_id(key), w.user_id, w.signature.id if w.signature else None),
|
||||||
|
)
|
||||||
|
elif a is A.ADMIN_REOPEN:
|
||||||
|
w.conn.execute(
|
||||||
|
"UPDATE packet_seals SET broken_at = now(), broken_by = %s, broken_reason = %s"
|
||||||
|
" WHERE packet_id = %s AND broken_at IS NULL",
|
||||||
|
(w.user_id, reason, w.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
tid = w.conn.execute(
|
||||||
|
"INSERT INTO packet_transitions (packet_id, by, action, from_status, to_status, from_dept, to_dept, reason,"
|
||||||
|
" signature_id, it_reason) VALUES (%s, %s, %s, %s::packet_status, %s::packet_status, %s, %s, %s, %s, %s)"
|
||||||
|
" RETURNING id",
|
||||||
|
(
|
||||||
|
w.id,
|
||||||
|
w.user_id,
|
||||||
|
str(a),
|
||||||
|
w.from_status,
|
||||||
|
to_status,
|
||||||
|
from_dept,
|
||||||
|
to_dept,
|
||||||
|
reason or _note(w),
|
||||||
|
w.signature.id if w.signature else None,
|
||||||
|
w.it_reason,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
w.transition_id = int(tid[0])
|
||||||
|
if w.note is None:
|
||||||
|
w.note = reason or _note(w)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_seal(conn: Any, settings: Any, packet_id: int) -> dict[str, Any]:
|
||||||
|
"""The seal history of a packet and whether the current unbroken seal still matches the packet."""
|
||||||
|
seals = packets.rows(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC", (packet_id,))
|
||||||
|
people = packets.People(conn, [s["sealed_by"] for s in seals] + [s["broken_by"] for s in seals])
|
||||||
|
current = next((s for s in seals if s["broken_at"] is None), None)
|
||||||
|
valid: bool | None = None
|
||||||
|
if current is not None:
|
||||||
|
try:
|
||||||
|
key = settings.seal_key()
|
||||||
|
except SecretFileError:
|
||||||
|
key = None
|
||||||
|
if key is not None and seal_key_id(key) == current["key_id"]:
|
||||||
|
row = packets.load_row(conn, packet_id)
|
||||||
|
assert row is not None
|
||||||
|
valid = hmac.compare_digest(seal_hash(key, packets.seal_content(conn, row)), bytes(current["hash"]))
|
||||||
|
|
||||||
|
def shape(s: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": s["id"],
|
||||||
|
"sealed_at": s["sealed_at"],
|
||||||
|
"sealed_by": people.ref(s["sealed_by"]),
|
||||||
|
"key_id": s["key_id"],
|
||||||
|
"hash": bytes(s["hash"]).hex(),
|
||||||
|
"signed": s["signature_id"] is not None,
|
||||||
|
"broken_at": s["broken_at"],
|
||||||
|
"broken_by": people.ref(s["broken_by"]),
|
||||||
|
"broken_reason": s["broken_reason"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"seal": shape(current) if current else None, "valid": valid, "seals": [shape(s) for s in seals]}
|
||||||
@@ -0,0 +1,876 @@
|
|||||||
|
"""Projects and units: numbers, lots, aliases, renumbering, field CAS, soft delete and packet counts.
|
||||||
|
|
||||||
|
Ports the old projects.js rules. A unit's number is its project's number plus its lot. Every old number
|
||||||
|
of a project or unit stays in project_aliases, so a lookup by any number still finds it. Packets point at
|
||||||
|
units by id, so a renumber never touches a packet. Numbers and lots are text: never cast them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg import errors as pgerr
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from .. import audit, events
|
||||||
|
from ..api.errors import ApiError
|
||||||
|
from ..text import LIMITS, RX_LOT, RX_PROJECT, clean_opt, clean_text
|
||||||
|
|
||||||
|
STATUSES = ("open", "hold", "closed")
|
||||||
|
LIST_FILTERS = ("active", "open", "hold", "closed", "all")
|
||||||
|
SHOP_STATUSES = ("ready", "assigned", "in_progress", "review")
|
||||||
|
COUNT_KEYS = ("draft", "warehouse", "shop", "qc", "complete", "filed", "total")
|
||||||
|
MAX_UNITS_AT_ONCE = 200
|
||||||
|
MAX_WORDS = 8
|
||||||
|
LIST_LIMIT = 500
|
||||||
|
|
||||||
|
PROJECT_FIELDS = ("name", "customer", "po", "due", "status", "notes")
|
||||||
|
UNIT_FIELDS = ("name", "due", "status", "notes")
|
||||||
|
|
||||||
|
_DIGITS = re.compile(r"^[0-9]+$")
|
||||||
|
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||||
|
|
||||||
|
COUNTS_SQL = (
|
||||||
|
"count(*) FILTER (WHERE k.status = 'draft') AS c_draft,"
|
||||||
|
" count(*) FILTER (WHERE k.status = 'warehouse') AS c_warehouse,"
|
||||||
|
" count(*) FILTER (WHERE k.status IN ('ready', 'assigned', 'in_progress', 'review')) AS c_shop,"
|
||||||
|
" count(*) FILTER (WHERE k.status = 'qc') AS c_qc,"
|
||||||
|
" count(*) FILTER (WHERE k.status = 'complete') AS c_complete,"
|
||||||
|
" count(*) FILTER (WHERE k.status = 'filed') AS c_filed,"
|
||||||
|
" count(*) AS c_total"
|
||||||
|
)
|
||||||
|
|
||||||
|
PROJECT_SELECT = (
|
||||||
|
"SELECT p.*," # noqa: S608
|
||||||
|
" (SELECT count(*) FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL) AS unit_count,"
|
||||||
|
" c.*"
|
||||||
|
" FROM projects p"
|
||||||
|
" LEFT JOIN LATERAL (SELECT " + COUNTS_SQL + " FROM packets k JOIN units u ON u.id = k.unit_id"
|
||||||
|
" WHERE u.project_id = p.id AND k.removed_at IS NULL) c ON true"
|
||||||
|
)
|
||||||
|
|
||||||
|
UNIT_SELECT = (
|
||||||
|
"SELECT v.*, c.* FROM v_units v" # noqa: S608
|
||||||
|
" LEFT JOIN LATERAL (SELECT " + COUNTS_SQL + " FROM packets k"
|
||||||
|
" WHERE k.unit_id = v.id AND k.removed_at IS NULL) c ON true"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Who:
|
||||||
|
"""Who is writing, for the audit row. it_reason is set only when an admin acts outside their roles."""
|
||||||
|
|
||||||
|
user_id: Any
|
||||||
|
session_id: Any = None
|
||||||
|
role_snapshot: str | None = None
|
||||||
|
ip: str | None = None
|
||||||
|
it_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- normalisation
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_number(raw: Any) -> str:
|
||||||
|
"""A project number: cleaned, upper-cased, RX_PROJECT. 422 invalid_number otherwise."""
|
||||||
|
n = clean_text(raw, 200).upper()
|
||||||
|
if not RX_PROJECT.match(n):
|
||||||
|
raise ApiError(
|
||||||
|
422,
|
||||||
|
"invalid_number",
|
||||||
|
"A project number is 1 to 20 letters, digits or dashes, starting with a letter or digit.",
|
||||||
|
)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_lot(raw: Any) -> str:
|
||||||
|
"""A unit lot: upper-cased, digits zero-padded to 3. `"7"` -> `"007"`, `"7a"` -> `"7A"`. 422 invalid_lot."""
|
||||||
|
lot = clean_text(raw, 40).upper()
|
||||||
|
if _DIGITS.match(lot) and len(lot) < 3:
|
||||||
|
lot = lot.zfill(3)
|
||||||
|
if not RX_LOT.match(lot) or (_DIGITS.match(lot) and len(lot) != 3):
|
||||||
|
raise ApiError(422, "invalid_lot", f"Lot {lot or '(blank)'}: 1 to 3 letters or digits, numbers as 001 to 999.")
|
||||||
|
return lot
|
||||||
|
|
||||||
|
|
||||||
|
def lot_input(project_number: str, raw: Any) -> str:
|
||||||
|
"""A lot as typed: a full unit number that starts with the project number is cut down to its lot."""
|
||||||
|
text = clean_text(raw, 40).upper()
|
||||||
|
pn = project_number.upper()
|
||||||
|
if len(text) > 3 and text.startswith(pn) and len(text) > len(pn):
|
||||||
|
text = text[len(pn) :]
|
||||||
|
return normalize_lot(text)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_due(raw: Any) -> date | None:
|
||||||
|
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
||||||
|
return None
|
||||||
|
if isinstance(raw, date):
|
||||||
|
return raw
|
||||||
|
text = str(raw).strip()
|
||||||
|
try:
|
||||||
|
if not _ISO_DATE.match(text):
|
||||||
|
raise ValueError(text)
|
||||||
|
return date.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
raise ApiError(422, "invalid_date", "Give the due date as YYYY-MM-DD.") from None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_status(raw: Any) -> str:
|
||||||
|
s = clean_text(raw, 20).lower()
|
||||||
|
if s not in STATUSES:
|
||||||
|
raise ApiError(422, "invalid_status", "Status is open, hold or closed.")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def clean_reason(raw: Any) -> str:
|
||||||
|
text = clean_text(raw, LIMITS["reason"])
|
||||||
|
if not text:
|
||||||
|
raise ApiError(400, "reason_required", "Give a reason.")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _like(word: str) -> str:
|
||||||
|
return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||||
|
|
||||||
|
|
||||||
|
def _words(q: str | None) -> list[str]:
|
||||||
|
return [w for w in clean_text(q, 200).split() if w][:MAX_WORDS]
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(v: Any) -> Any:
|
||||||
|
return v.isoformat() if isinstance(v, date) else v
|
||||||
|
|
||||||
|
|
||||||
|
def _counts(r: Mapping[str, Any]) -> dict[str, int]:
|
||||||
|
return {k: int(r.get("c_" + k) or 0) for k in COUNT_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def _removed(r: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||||
|
if r.get("removed_at") is None:
|
||||||
|
return None
|
||||||
|
by = r.get("removed_by")
|
||||||
|
return {"at": r["removed_at"], "by": str(by) if by else None, "reason": r.get("removed_reason")}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- shapes
|
||||||
|
|
||||||
|
|
||||||
|
def project_out(r: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": r["id"],
|
||||||
|
"number": r["number"],
|
||||||
|
"name": r["name"],
|
||||||
|
"customer": r["customer"],
|
||||||
|
"po": r["po"],
|
||||||
|
"due": r["due"],
|
||||||
|
"status": r["status"],
|
||||||
|
"notes": r["notes"],
|
||||||
|
"source": r["source"],
|
||||||
|
"needs_details": r["needs_details"],
|
||||||
|
"units": int(r.get("unit_count") or 0),
|
||||||
|
"packets": _counts(r),
|
||||||
|
"version": r["version"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"updated_at": r["updated_at"],
|
||||||
|
"removed": _removed(r),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def unit_out(r: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
status = r["status"]
|
||||||
|
return {
|
||||||
|
"id": r["id"],
|
||||||
|
"project_id": r["project_id"],
|
||||||
|
"number": r["number"],
|
||||||
|
"lot": r["lot"],
|
||||||
|
"name": r["name"],
|
||||||
|
"due": r["due"],
|
||||||
|
"due_effective": r["due_effective"],
|
||||||
|
"status": status,
|
||||||
|
"status_effective": "closed" if r["project_status"] == "closed" else status,
|
||||||
|
"notes": r["notes"],
|
||||||
|
"packets": _counts(r),
|
||||||
|
"version": r["version"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"updated_at": r["updated_at"],
|
||||||
|
"removed": _removed(r),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- lookups
|
||||||
|
|
||||||
|
|
||||||
|
def owners(conn: Any, number: str) -> list[dict[str, Any]]:
|
||||||
|
"""Everything that answers to `number`: live project and unit numbers first, then aliases of live ones."""
|
||||||
|
n = clean_text(number, 40).upper()
|
||||||
|
if not n:
|
||||||
|
return []
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
rows: list[dict[str, Any]] = cur.execute(
|
||||||
|
"SELECT * FROM ("
|
||||||
|
" SELECT 0 AS rank, 'project' AS kind, p.id, p.id AS project_id, p.number, false AS alias"
|
||||||
|
" FROM projects p WHERE upper(p.number) = %(n)s AND p.removed_at IS NULL"
|
||||||
|
" UNION ALL"
|
||||||
|
" SELECT 1, 'unit', u.id, u.project_id, p.number || u.lot, false"
|
||||||
|
" FROM units u JOIN projects p ON p.id = u.project_id"
|
||||||
|
" WHERE upper(p.number || u.lot) = %(n)s AND u.removed_at IS NULL AND p.removed_at IS NULL"
|
||||||
|
" UNION ALL"
|
||||||
|
" SELECT 2, CASE WHEN a.unit_id IS NULL THEN 'project' ELSE 'unit' END, coalesce(a.unit_id, a.project_id),"
|
||||||
|
" a.project_id, CASE WHEN a.unit_id IS NULL THEN p.number ELSE p.number || u.lot END, true"
|
||||||
|
" FROM project_aliases a JOIN projects p ON p.id = a.project_id AND p.removed_at IS NULL"
|
||||||
|
" LEFT JOIN units u ON u.id = a.unit_id"
|
||||||
|
" WHERE a.alias = %(n)s AND (a.unit_id IS NULL OR u.removed_at IS NULL)"
|
||||||
|
") x ORDER BY rank, id",
|
||||||
|
{"n": n},
|
||||||
|
).fetchall()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def lookup(conn: Any, number: str) -> dict[str, Any] | None:
|
||||||
|
"""`{kind, id, project_id, number, alias}` for a current or old number, or None."""
|
||||||
|
found = owners(conn, number)
|
||||||
|
if not found:
|
||||||
|
return None
|
||||||
|
o = found[0]
|
||||||
|
return {"kind": o["kind"], "id": o["id"], "project_id": o["project_id"], "number": o["number"], "alias": o["alias"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _taken(o: Mapping[str, Any]) -> ApiError:
|
||||||
|
if o["alias"]:
|
||||||
|
msg = f"That number is an old number of {o['number']}. Pick another number."
|
||||||
|
elif o["kind"] == "unit":
|
||||||
|
msg = f"{o['number']} already exists as a unit."
|
||||||
|
else:
|
||||||
|
msg = f"{o['number']} already exists as a project."
|
||||||
|
return ApiError(409, "number_taken", msg, extra={"taken": {"kind": o["kind"], "id": o["id"], "alias": o["alias"]}})
|
||||||
|
|
||||||
|
|
||||||
|
def assert_free(conn: Any, number: str, *, own_project: int | None = None, own_unit: int | None = None) -> None:
|
||||||
|
"""409 number_taken unless nothing answers to `number`, or only the entity's own old number does."""
|
||||||
|
for o in owners(conn, number):
|
||||||
|
if o["alias"] and own_unit is not None and o["kind"] == "unit" and o["id"] == own_unit:
|
||||||
|
continue
|
||||||
|
if o["alias"] and own_project is not None and o["kind"] == "project" and o["id"] == own_project:
|
||||||
|
continue
|
||||||
|
raise _taken(o)
|
||||||
|
|
||||||
|
|
||||||
|
def next_lot(conn: Any, project_id: int, number: str) -> str | None:
|
||||||
|
"""The old nextUnitNumber: highest numeric lot plus one, skipping numbers already used (old ones too)."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT lot FROM units WHERE project_id = %s AND removed_at IS NULL AND lot ~ '^[0-9]{3}$'", (project_id,)
|
||||||
|
).fetchall()
|
||||||
|
n = max((int(r[0]) for r in rows), default=0) + 1
|
||||||
|
while n <= 999:
|
||||||
|
lot = f"{n:03d}"
|
||||||
|
if not owners(conn, number + lot):
|
||||||
|
return lot
|
||||||
|
n += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_taken(e: Exception) -> ApiError:
|
||||||
|
return ApiError(409, "number_taken", "That number was just taken. Pick another number.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- reads
|
||||||
|
|
||||||
|
|
||||||
|
def get_project(conn: Any, project_id: int, *, removed: bool = False) -> dict[str, Any] | None:
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
r = cur.execute(PROJECT_SELECT + " WHERE p.id = %s", (project_id,)).fetchone()
|
||||||
|
if r is None or (r["removed_at"] is not None and not removed):
|
||||||
|
return None
|
||||||
|
return project_out(r)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_or_404(conn: Any, project_id: int) -> dict[str, Any]:
|
||||||
|
out = get_project(conn, project_id)
|
||||||
|
if out is None:
|
||||||
|
raise ApiError(404, "not_found", "No such project.")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def aliases_of(conn: Any, *, project_id: int | None = None, unit_id: int | None = None) -> list[dict[str, Any]]:
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
if unit_id is not None:
|
||||||
|
rows = cur.execute(
|
||||||
|
"SELECT alias, kind, unit_id, created_at FROM project_aliases WHERE unit_id = %s"
|
||||||
|
" ORDER BY created_at, alias",
|
||||||
|
(unit_id,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = cur.execute(
|
||||||
|
"SELECT alias, kind, unit_id, created_at FROM project_aliases WHERE project_id = %s"
|
||||||
|
" ORDER BY created_at, alias",
|
||||||
|
(project_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def project_detail(conn: Any, project_id: int) -> dict[str, Any]:
|
||||||
|
out = _project_or_404(conn, project_id)
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
rows = cur.execute(
|
||||||
|
UNIT_SELECT + " WHERE v.project_id = %s AND v.removed_at IS NULL ORDER BY v.lot", (project_id,)
|
||||||
|
).fetchall()
|
||||||
|
out["units"] = [unit_out(r) for r in rows]
|
||||||
|
out["unit_count"] = len(rows)
|
||||||
|
out["aliases"] = aliases_of(conn, project_id=project_id)
|
||||||
|
out["next_lot"] = next_lot(conn, project_id, out["number"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def list_projects(conn: Any, *, q: str | None = None, status: str = "active") -> list[dict[str, Any]]:
|
||||||
|
if status not in LIST_FILTERS:
|
||||||
|
raise ApiError(422, "invalid_status", "Status filter is active, open, hold, closed or all.")
|
||||||
|
where = ["p.removed_at IS NULL"]
|
||||||
|
params: list[Any] = []
|
||||||
|
if status == "active":
|
||||||
|
where.append("p.status <> 'closed'")
|
||||||
|
elif status != "all":
|
||||||
|
where.append("p.status = %s")
|
||||||
|
params.append(status)
|
||||||
|
for w in _words(q):
|
||||||
|
where.append(
|
||||||
|
"(p.number ILIKE %s OR p.name ILIKE %s OR p.customer ILIKE %s OR p.po ILIKE %s"
|
||||||
|
" OR EXISTS (SELECT 1 FROM project_aliases a WHERE a.project_id = p.id AND a.alias ILIKE %s))"
|
||||||
|
)
|
||||||
|
pat = _like(w)
|
||||||
|
params.extend([pat, pat, pat, pat, pat])
|
||||||
|
sql = PROJECT_SELECT + " WHERE " + " AND ".join(where) + f" ORDER BY p.number LIMIT {LIST_LIMIT}"
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
rows = cur.execute(sql, params).fetchall()
|
||||||
|
return [project_out(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_unit(conn: Any, unit_id: int, *, removed: bool = False) -> dict[str, Any] | None:
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
r = cur.execute(UNIT_SELECT + " WHERE v.id = %s", (unit_id,)).fetchone()
|
||||||
|
if r is None or (r["removed_at"] is not None and not removed):
|
||||||
|
return None
|
||||||
|
return unit_out(r)
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_or_404(conn: Any, unit_id: int) -> dict[str, Any]:
|
||||||
|
out = get_unit(conn, unit_id)
|
||||||
|
if out is None:
|
||||||
|
raise ApiError(404, "not_found", "No such unit.")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def unit_detail(conn: Any, unit_id: int) -> dict[str, Any]:
|
||||||
|
out = _unit_or_404(conn, unit_id)
|
||||||
|
p = get_project(conn, out["project_id"])
|
||||||
|
if p is None:
|
||||||
|
raise ApiError(404, "not_found", "No such unit.")
|
||||||
|
out["project"] = {k: p[k] for k in ("id", "number", "name", "customer", "po", "due", "status", "version")}
|
||||||
|
out["aliases"] = aliases_of(conn, unit_id=unit_id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def list_units(
|
||||||
|
conn: Any, *, project_id: int | None = None, q: str | None = None, status: str = "active"
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Units for the picker's second step. Status applies to the effective status (a closed project closes all)."""
|
||||||
|
if status not in LIST_FILTERS:
|
||||||
|
raise ApiError(422, "invalid_status", "Status filter is active, open, hold, closed or all.")
|
||||||
|
eff = "(CASE WHEN v.project_status = 'closed' THEN 'closed' ELSE v.status::text END)"
|
||||||
|
where = [
|
||||||
|
"v.removed_at IS NULL",
|
||||||
|
"EXISTS (SELECT 1 FROM projects p WHERE p.id = v.project_id AND p.removed_at IS NULL)",
|
||||||
|
]
|
||||||
|
params: list[Any] = []
|
||||||
|
if project_id is not None:
|
||||||
|
where.append("v.project_id = %s")
|
||||||
|
params.append(project_id)
|
||||||
|
if status == "active":
|
||||||
|
where.append(f"{eff} <> 'closed'")
|
||||||
|
elif status != "all":
|
||||||
|
where.append(f"{eff} = %s")
|
||||||
|
params.append(status)
|
||||||
|
for w in _words(q):
|
||||||
|
where.append(
|
||||||
|
"(v.number ILIKE %s OR v.lot ILIKE %s OR v.name ILIKE %s"
|
||||||
|
" OR EXISTS (SELECT 1 FROM project_aliases a WHERE a.unit_id = v.id AND a.alias ILIKE %s))"
|
||||||
|
)
|
||||||
|
pat = _like(w)
|
||||||
|
params.extend([pat, pat, pat, pat])
|
||||||
|
sql = UNIT_SELECT + " WHERE " + " AND ".join(where) + f" ORDER BY v.project_number, v.lot LIMIT {LIST_LIMIT}"
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
rows = cur.execute(sql, params).fetchall()
|
||||||
|
return [unit_out(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- writes: helpers
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(conn: Any, who: Who, action: str, subject_type: str, subject_id: Any, detail: Any) -> None:
|
||||||
|
audit.append(
|
||||||
|
conn,
|
||||||
|
action=action,
|
||||||
|
user_id=who.user_id,
|
||||||
|
session_id=who.session_id,
|
||||||
|
role_snapshot=who.role_snapshot,
|
||||||
|
subject_type=subject_type,
|
||||||
|
subject_id=subject_id,
|
||||||
|
detail=detail,
|
||||||
|
it_reason=who.it_reason,
|
||||||
|
ip=who.ip,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _publish(conn: Any, project_id: int, unit_id: int | None = None, action: str = "") -> None:
|
||||||
|
payload: dict[str, Any] = {"event": "changed", "kind": "project", "project_id": project_id, "action": action}
|
||||||
|
if unit_id is not None:
|
||||||
|
payload["unit_id"] = unit_id
|
||||||
|
events.publish(conn, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_project(conn: Any, project_id: int) -> dict[str, Any]:
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
r = cur.execute(
|
||||||
|
"SELECT * FROM projects WHERE id = %s AND removed_at IS NULL FOR UPDATE", (project_id,)
|
||||||
|
).fetchone()
|
||||||
|
if r is None:
|
||||||
|
raise ApiError(404, "not_found", "No such project.")
|
||||||
|
return dict(r)
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_unit(conn: Any, unit_id: int) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
"""Lock the unit's project, then the unit (always in that order). Returns (unit, project)."""
|
||||||
|
row = conn.execute("SELECT project_id FROM units WHERE id = %s AND removed_at IS NULL", (unit_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "not_found", "No such unit.")
|
||||||
|
try:
|
||||||
|
project = _lock_project(conn, int(row[0]))
|
||||||
|
except ApiError:
|
||||||
|
raise ApiError(404, "not_found", "No such unit.") from None
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
u = cur.execute("SELECT * FROM units WHERE id = %s AND removed_at IS NULL FOR UPDATE", (unit_id,)).fetchone()
|
||||||
|
if u is None:
|
||||||
|
raise ApiError(404, "not_found", "No such unit.")
|
||||||
|
return dict(u), project
|
||||||
|
|
||||||
|
|
||||||
|
def _cmp(v: Any) -> Any:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, date):
|
||||||
|
return v.isoformat()
|
||||||
|
text = str(v).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_field(key: str, value: Any, *, unit: bool) -> Any:
|
||||||
|
if key == "due":
|
||||||
|
return parse_due(value)
|
||||||
|
if key == "status":
|
||||||
|
return parse_status(value)
|
||||||
|
if key == "name":
|
||||||
|
return clean_opt(value, LIMITS["unit_name"] if unit else LIMITS["project_name"])
|
||||||
|
if key == "customer":
|
||||||
|
return clean_opt(value, LIMITS["customer"])
|
||||||
|
if key == "po":
|
||||||
|
return clean_opt(value, LIMITS["po"])
|
||||||
|
if key == "notes":
|
||||||
|
return clean_opt(value, LIMITS["notes"])
|
||||||
|
raise ApiError(422, "invalid_field", f"{key} cannot be changed here.")
|
||||||
|
|
||||||
|
|
||||||
|
def field_cas(
|
||||||
|
row: Mapping[str, Any], values: Mapping[str, Any], base: Mapping[str, Any], fields: Iterable[str], *, unit: bool
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The field-level CAS: returns {key: new value} for the keys that change; 409 field_conflict on a stale base.
|
||||||
|
|
||||||
|
A key whose base differs from the current value conflicts, unless the new value already equals the current one.
|
||||||
|
Keys without a base entry are written without a check.
|
||||||
|
"""
|
||||||
|
allowed = set(fields)
|
||||||
|
bad = [k for k in values if k not in allowed]
|
||||||
|
if bad:
|
||||||
|
raise ApiError(422, "invalid_field", f"{', '.join(sorted(bad))} cannot be changed here.")
|
||||||
|
new = {k: _norm_field(k, v, unit=unit) for k, v in values.items()}
|
||||||
|
conflicts = {
|
||||||
|
k: _iso(row[k]) for k in new if k in base and _cmp(base[k]) != _cmp(row[k]) and _cmp(new[k]) != _cmp(row[k])
|
||||||
|
}
|
||||||
|
if conflicts:
|
||||||
|
raise ApiError(
|
||||||
|
409,
|
||||||
|
"field_conflict",
|
||||||
|
"Someone else changed this since you opened it. Check the new values.",
|
||||||
|
extra={"current": conflicts},
|
||||||
|
)
|
||||||
|
return {k: v for k, v in new.items() if _cmp(v) != _cmp(row[k])}
|
||||||
|
|
||||||
|
|
||||||
|
def _change_detail(row: Mapping[str, Any], changes: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for k, v in changes.items():
|
||||||
|
out[k] = {"changed": True} if k == "notes" else {"from": _iso(row[k]), "to": _iso(v)}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- writes: projects
|
||||||
|
|
||||||
|
|
||||||
|
def create_project(conn: Any, who: Who, data: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
number = normalize_number(data.get("number"))
|
||||||
|
name = clean_opt(data.get("name"), LIMITS["project_name"])
|
||||||
|
customer = clean_opt(data.get("customer"), LIMITS["customer"])
|
||||||
|
po = clean_opt(data.get("po"), LIMITS["po"])
|
||||||
|
notes = clean_opt(data.get("notes"), LIMITS["notes"])
|
||||||
|
due = parse_due(data.get("due"))
|
||||||
|
status = parse_status(data.get("status") or "open")
|
||||||
|
try:
|
||||||
|
with conn.transaction():
|
||||||
|
conn.execute("SELECT pg_advisory_xact_lock(7312)") # one number allocation at a time
|
||||||
|
assert_free(conn, number)
|
||||||
|
pid = conn.execute(
|
||||||
|
"INSERT INTO projects (number, name, customer, customer_src, po, po_src, due, status, notes, source,"
|
||||||
|
" created_by) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'hand', %s) RETURNING id",
|
||||||
|
(
|
||||||
|
number,
|
||||||
|
name,
|
||||||
|
customer,
|
||||||
|
"hand" if customer else None,
|
||||||
|
po,
|
||||||
|
"hand" if po else None,
|
||||||
|
due,
|
||||||
|
status,
|
||||||
|
notes,
|
||||||
|
who.user_id,
|
||||||
|
),
|
||||||
|
).fetchone()[0]
|
||||||
|
_audit(
|
||||||
|
conn,
|
||||||
|
who,
|
||||||
|
"project.created",
|
||||||
|
"project",
|
||||||
|
pid,
|
||||||
|
{"number": number, "name": name, "customer": customer, "po": po, "due": _iso(due), "status": status},
|
||||||
|
)
|
||||||
|
_publish(conn, pid, action="project.created")
|
||||||
|
except pgerr.UniqueViolation as e:
|
||||||
|
raise _unique_taken(e) from None
|
||||||
|
return _project_or_404(conn, pid)
|
||||||
|
|
||||||
|
|
||||||
|
def update_project(
|
||||||
|
conn: Any, who: Who, project_id: int, values: Mapping[str, Any], base: Mapping[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with conn.transaction():
|
||||||
|
row = _lock_project(conn, project_id)
|
||||||
|
changes = field_cas(row, values, base, PROJECT_FIELDS, unit=False)
|
||||||
|
if changes:
|
||||||
|
cols = dict(changes)
|
||||||
|
if "customer" in cols:
|
||||||
|
cols["customer_src"] = "hand" if cols["customer"] else None
|
||||||
|
if "po" in cols:
|
||||||
|
cols["po_src"] = "hand" if cols["po"] else None
|
||||||
|
sets = ", ".join(f"{k} = %s" for k in cols)
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE projects SET {sets}, version = version + 1, updated_at = now() WHERE id = %s", # noqa: S608
|
||||||
|
[*cols.values(), project_id],
|
||||||
|
)
|
||||||
|
_audit(
|
||||||
|
conn,
|
||||||
|
who,
|
||||||
|
"project.updated",
|
||||||
|
"project",
|
||||||
|
project_id,
|
||||||
|
{"number": row["number"], "fields": _change_detail(row, changes)},
|
||||||
|
)
|
||||||
|
_publish(conn, project_id, action="project.updated")
|
||||||
|
return _project_or_404(conn, project_id)
|
||||||
|
|
||||||
|
|
||||||
|
def renumber_project(conn: Any, who: Who, project_id: int, number: Any, reason: Any) -> dict[str, Any]:
|
||||||
|
new = normalize_number(number)
|
||||||
|
why = clean_reason(reason)
|
||||||
|
try:
|
||||||
|
with conn.transaction():
|
||||||
|
conn.execute("SELECT pg_advisory_xact_lock(7312)")
|
||||||
|
row = _lock_project(conn, project_id)
|
||||||
|
old = row["number"]
|
||||||
|
if new == old.upper():
|
||||||
|
raise ApiError(400, "same_number", f"The project is already {old}.")
|
||||||
|
units = conn.execute(
|
||||||
|
"SELECT id, lot FROM units WHERE project_id = %s AND removed_at IS NULL ORDER BY lot FOR UPDATE",
|
||||||
|
(project_id,),
|
||||||
|
).fetchall()
|
||||||
|
assert_free(conn, new, own_project=project_id)
|
||||||
|
for uid, lot in units:
|
||||||
|
for o in owners(conn, new + lot):
|
||||||
|
own_live = o["kind"] == "unit" and not o["alias"] and o["project_id"] == project_id
|
||||||
|
own_old = o["kind"] == "unit" and o["alias"] and o["id"] == uid
|
||||||
|
if not (own_live or own_old):
|
||||||
|
raise _taken(o)
|
||||||
|
old_numbers = {old.upper(): None, **{(old + lot).upper(): uid for uid, lot in units}}
|
||||||
|
new_numbers = [new, *[new + lot for _, lot in units]]
|
||||||
|
if set(old_numbers) & set(new_numbers):
|
||||||
|
raise ApiError(409, "number_taken", "The new numbers overlap the old ones. Pick another number.")
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM project_aliases WHERE alias = ANY(%s) AND project_id = %s", (new_numbers, project_id)
|
||||||
|
)
|
||||||
|
for alias, uid in old_numbers.items():
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_aliases (alias, project_id, unit_id, kind) VALUES (%s, %s, %s, 'renumber')"
|
||||||
|
" ON CONFLICT (alias) DO UPDATE SET project_id = EXCLUDED.project_id, unit_id = EXCLUDED.unit_id,"
|
||||||
|
" kind = EXCLUDED.kind, created_at = now()",
|
||||||
|
(alias, project_id, uid),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE projects SET number = %s, version = version + 1, updated_at = now() WHERE id = %s",
|
||||||
|
(new, project_id),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_renumbers (by, project_id, unit_id, old_number, new_number, reason)"
|
||||||
|
" VALUES (%s, %s, NULL, %s, %s, %s)",
|
||||||
|
(who.user_id, project_id, old, new, why),
|
||||||
|
)
|
||||||
|
for uid, lot in units:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_renumbers (by, project_id, unit_id, old_number, new_number, reason)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s, %s)",
|
||||||
|
(who.user_id, project_id, uid, old + lot, new + lot, why),
|
||||||
|
)
|
||||||
|
_audit(
|
||||||
|
conn,
|
||||||
|
who,
|
||||||
|
"project.renumbered",
|
||||||
|
"project",
|
||||||
|
project_id,
|
||||||
|
{"from": old, "to": new, "reason": why, "units": len(units)},
|
||||||
|
)
|
||||||
|
_publish(conn, project_id, action="project.renumbered")
|
||||||
|
except pgerr.UniqueViolation as e:
|
||||||
|
raise _unique_taken(e) from None
|
||||||
|
return _project_or_404(conn, project_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _live_packets(conn: Any, unit_ids: list[int]) -> int:
|
||||||
|
if not unit_ids:
|
||||||
|
return 0
|
||||||
|
return int(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT count(*) FROM packets WHERE unit_id = ANY(%s) AND removed_at IS NULL", (unit_ids,)
|
||||||
|
).fetchone()[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_project(conn: Any, who: Who, project_id: int, reason: Any) -> dict[str, Any]:
|
||||||
|
"""Soft delete with its units. Refused (409 has_packets) while any of its units has a live packet."""
|
||||||
|
why = clean_reason(reason)
|
||||||
|
with conn.transaction():
|
||||||
|
row = _lock_project(conn, project_id)
|
||||||
|
ids = [
|
||||||
|
int(r[0])
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT id FROM units WHERE project_id = %s AND removed_at IS NULL ORDER BY id FOR UPDATE",
|
||||||
|
(project_id,),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
n = _live_packets(conn, ids)
|
||||||
|
if n:
|
||||||
|
raise ApiError(
|
||||||
|
409,
|
||||||
|
"has_packets",
|
||||||
|
f"{row['number']} still has {n} packet{'' if n == 1 else 's'}. Move or remove them first.",
|
||||||
|
extra={"packets": n},
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE units SET removed_at = now(), removed_by = %s, removed_reason = %s, version = version + 1,"
|
||||||
|
" updated_at = now() WHERE id = ANY(%s)",
|
||||||
|
(who.user_id, why, ids),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE projects SET removed_at = now(), removed_by = %s, removed_reason = %s, version = version + 1,"
|
||||||
|
" updated_at = now() WHERE id = %s",
|
||||||
|
(who.user_id, why, project_id),
|
||||||
|
)
|
||||||
|
_audit(
|
||||||
|
conn,
|
||||||
|
who,
|
||||||
|
"project.removed",
|
||||||
|
"project",
|
||||||
|
project_id,
|
||||||
|
{"number": row["number"], "reason": why, "units": ids},
|
||||||
|
)
|
||||||
|
_publish(conn, project_id, action="project.removed")
|
||||||
|
out = get_project(conn, project_id, removed=True)
|
||||||
|
assert out is not None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- writes: units
|
||||||
|
|
||||||
|
|
||||||
|
def lots_from_request(
|
||||||
|
project_number: str,
|
||||||
|
*,
|
||||||
|
lot: Any = None,
|
||||||
|
lots: list[Any] | None = None,
|
||||||
|
count: int | None = None,
|
||||||
|
start: Any = None,
|
||||||
|
suggest: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""The unit sheet's one-or-several mode: one lot, a list of lots, or `count` lots counting up from `start`."""
|
||||||
|
if lots:
|
||||||
|
out = [lot_input(project_number, x) for x in lots]
|
||||||
|
elif count is not None:
|
||||||
|
if not 1 <= count <= MAX_UNITS_AT_ONCE:
|
||||||
|
raise ApiError(422, "invalid_count", f"How many: 1 to {MAX_UNITS_AT_ONCE}.")
|
||||||
|
first = lot_input(project_number, start) if start not in (None, "") else suggest
|
||||||
|
if first is None or not _DIGITS.match(first):
|
||||||
|
raise ApiError(422, "invalid_lot", "The starting lot has to be a number, e.g. 001.")
|
||||||
|
b = int(first)
|
||||||
|
if b + count - 1 > 999:
|
||||||
|
raise ApiError(422, "invalid_lot", "Lots go up to 999. Start lower or add fewer.")
|
||||||
|
out = [f"{b + i:03d}" for i in range(count)]
|
||||||
|
elif lot not in (None, ""):
|
||||||
|
out = [lot_input(project_number, lot)]
|
||||||
|
else:
|
||||||
|
if suggest is None:
|
||||||
|
raise ApiError(422, "invalid_lot", "No free lot left in this project. Give one.")
|
||||||
|
out = [suggest]
|
||||||
|
if len(out) > MAX_UNITS_AT_ONCE:
|
||||||
|
raise ApiError(422, "invalid_count", f"How many: 1 to {MAX_UNITS_AT_ONCE}.")
|
||||||
|
dup = sorted({x for x in out if out.count(x) > 1})
|
||||||
|
if dup:
|
||||||
|
raise ApiError(409, "number_taken", f"Lot {dup[0]} is in the list twice.", extra={"lots": dup})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def create_units(conn: Any, who: Who, project_id: int, data: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
name = clean_opt(data.get("name"), LIMITS["unit_name"])
|
||||||
|
notes = clean_opt(data.get("notes"), LIMITS["notes"])
|
||||||
|
due = parse_due(data.get("due"))
|
||||||
|
status = parse_status(data.get("status") or "open")
|
||||||
|
try:
|
||||||
|
with conn.transaction():
|
||||||
|
conn.execute("SELECT pg_advisory_xact_lock(7312)")
|
||||||
|
project = _lock_project(conn, project_id)
|
||||||
|
if project["status"] == "closed":
|
||||||
|
raise ApiError(409, "project_closed", f"Project {project['number']} is closed. Reopen it to add units.")
|
||||||
|
pn = project["number"]
|
||||||
|
wants_suggest = not data.get("lots") and data.get("lot") in (None, "") and data.get("start") in (None, "")
|
||||||
|
lots = lots_from_request(
|
||||||
|
pn,
|
||||||
|
lot=data.get("lot"),
|
||||||
|
lots=data.get("lots"),
|
||||||
|
count=data.get("count"),
|
||||||
|
start=data.get("start"),
|
||||||
|
suggest=next_lot(conn, project_id, pn) if wants_suggest else None,
|
||||||
|
)
|
||||||
|
for lot in lots:
|
||||||
|
assert_free(conn, pn + lot)
|
||||||
|
ids: list[int] = []
|
||||||
|
for lot in lots:
|
||||||
|
uid = conn.execute(
|
||||||
|
"INSERT INTO units (project_id, lot, name, due, status, notes, created_by)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id",
|
||||||
|
(project_id, lot, name, due, status, notes, who.user_id),
|
||||||
|
).fetchone()[0]
|
||||||
|
ids.append(int(uid))
|
||||||
|
_audit(
|
||||||
|
conn,
|
||||||
|
who,
|
||||||
|
"unit.created",
|
||||||
|
"unit",
|
||||||
|
uid,
|
||||||
|
{"number": pn + lot, "project_id": project_id, "lot": lot, "name": name, "due": _iso(due)},
|
||||||
|
)
|
||||||
|
_publish(conn, project_id, action="unit.created")
|
||||||
|
except pgerr.UniqueViolation as e:
|
||||||
|
raise _unique_taken(e) from None
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
rows = cur.execute(UNIT_SELECT + " WHERE v.id = ANY(%s) ORDER BY v.lot", (ids,)).fetchall()
|
||||||
|
return [unit_out(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def update_unit(
|
||||||
|
conn: Any, who: Who, unit_id: int, values: Mapping[str, Any], base: Mapping[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with conn.transaction():
|
||||||
|
row, project = _lock_unit(conn, unit_id)
|
||||||
|
changes = field_cas(row, values, base, UNIT_FIELDS, unit=True)
|
||||||
|
if changes:
|
||||||
|
sets = ", ".join(f"{k} = %s" for k in changes)
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE units SET {sets}, version = version + 1, updated_at = now() WHERE id = %s", # noqa: S608
|
||||||
|
[*changes.values(), unit_id],
|
||||||
|
)
|
||||||
|
_audit(
|
||||||
|
conn,
|
||||||
|
who,
|
||||||
|
"unit.updated",
|
||||||
|
"unit",
|
||||||
|
unit_id,
|
||||||
|
{"number": project["number"] + row["lot"], "fields": _change_detail(row, changes)},
|
||||||
|
)
|
||||||
|
_publish(conn, project["id"], unit_id, action="unit.updated")
|
||||||
|
return _unit_or_404(conn, unit_id)
|
||||||
|
|
||||||
|
|
||||||
|
def renumber_unit(conn: Any, who: Who, unit_id: int, lot: Any, reason: Any) -> dict[str, Any]:
|
||||||
|
why = clean_reason(reason)
|
||||||
|
try:
|
||||||
|
with conn.transaction():
|
||||||
|
conn.execute("SELECT pg_advisory_xact_lock(7312)")
|
||||||
|
row, project = _lock_unit(conn, unit_id)
|
||||||
|
pn = project["number"]
|
||||||
|
new_lot = lot_input(pn, lot)
|
||||||
|
if new_lot == row["lot"]:
|
||||||
|
raise ApiError(400, "same_number", f"The unit is already {pn + new_lot}.")
|
||||||
|
old, new = pn + row["lot"], pn + new_lot
|
||||||
|
assert_free(conn, new, own_unit=unit_id)
|
||||||
|
conn.execute("DELETE FROM project_aliases WHERE alias = %s AND unit_id = %s", (new.upper(), unit_id))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_aliases (alias, project_id, unit_id, kind) VALUES (%s, %s, %s, 'unit_renumber')"
|
||||||
|
" ON CONFLICT (alias) DO UPDATE SET project_id = EXCLUDED.project_id, unit_id = EXCLUDED.unit_id,"
|
||||||
|
" kind = EXCLUDED.kind, created_at = now()",
|
||||||
|
(old.upper(), project["id"], unit_id),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE units SET lot = %s, version = version + 1, updated_at = now() WHERE id = %s", (new_lot, unit_id)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_renumbers (by, project_id, unit_id, old_number, new_number, reason)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s, %s)",
|
||||||
|
(who.user_id, project["id"], unit_id, old, new, why),
|
||||||
|
)
|
||||||
|
_audit(conn, who, "unit.renumbered", "unit", unit_id, {"from": old, "to": new, "reason": why})
|
||||||
|
_publish(conn, project["id"], unit_id, action="unit.renumbered")
|
||||||
|
except pgerr.UniqueViolation as e:
|
||||||
|
raise _unique_taken(e) from None
|
||||||
|
return _unit_or_404(conn, unit_id)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_unit(conn: Any, who: Who, unit_id: int, reason: Any) -> dict[str, Any]:
|
||||||
|
why = clean_reason(reason)
|
||||||
|
with conn.transaction():
|
||||||
|
row, project = _lock_unit(conn, unit_id)
|
||||||
|
n = _live_packets(conn, [unit_id])
|
||||||
|
number = project["number"] + row["lot"]
|
||||||
|
if n:
|
||||||
|
raise ApiError(
|
||||||
|
409,
|
||||||
|
"has_packets",
|
||||||
|
f"{number} still has {n} packet{'' if n == 1 else 's'}. Move or remove them first.",
|
||||||
|
extra={"packets": n},
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE units SET removed_at = now(), removed_by = %s, removed_reason = %s, version = version + 1,"
|
||||||
|
" updated_at = now() WHERE id = %s",
|
||||||
|
(who.user_id, why, unit_id),
|
||||||
|
)
|
||||||
|
_audit(conn, who, "unit.removed", "unit", unit_id, {"number": number, "reason": why})
|
||||||
|
_publish(conn, project["id"], unit_id, action="unit.removed")
|
||||||
|
out = get_unit(conn, unit_id, removed=True)
|
||||||
|
assert out is not None
|
||||||
|
return out
|
||||||
@@ -93,11 +93,13 @@ def create_app(
|
|||||||
app.state.groups_cache = None
|
app.state.groups_cache = None
|
||||||
api_errors.install(app)
|
api_errors.install(app)
|
||||||
|
|
||||||
from .api import account, admin, auth, departments
|
from .api import account, admin, assets, auth, departments, packet_children, packets, projects, units
|
||||||
from .api import events as events_api
|
from .api import events as events_api
|
||||||
|
|
||||||
for module in (auth, account, admin, departments, events_api):
|
# Routers go in before the static catch-all below, which answers every other GET.
|
||||||
|
for module in (auth, account, admin, departments, events_api, projects, units, packets, packet_children, assets):
|
||||||
app.include_router(module.router)
|
app.include_router(module.router)
|
||||||
|
app.include_router(assets.blob_router)
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
def healthz() -> dict[str, Any]:
|
def healthz() -> dict[str, Any]:
|
||||||
|
|||||||
@@ -0,0 +1,771 @@
|
|||||||
|
-- 0003_domain: the core domain. Projects and units, packets and their child tables, change
|
||||||
|
-- orders, documents and ITPs, plus the views v_units and v_open_cos. Runs as ps_migrate; 0001's
|
||||||
|
-- default privileges give ps_app SELECT, INSERT, UPDATE, DELETE on every table here.
|
||||||
|
-- Append-only tables revoke below. Text ID columns (work order base, lot, split, sub, part ids)
|
||||||
|
-- stay text: leading zeros are kept and lot 7A occurs.
|
||||||
|
|
||||||
|
CREATE TYPE packet_status AS ENUM (
|
||||||
|
'draft', 'warehouse', 'ready', 'assigned', 'in_progress', 'review', 'qc', 'complete', 'filed'
|
||||||
|
);
|
||||||
|
CREATE TYPE wh_stage AS ENUM ('incoming', 'pulling', 'hold', 'pulled');
|
||||||
|
CREATE TYPE co_review AS ENUM ('pending', 'approved', 'rejected');
|
||||||
|
CREATE TYPE co_origin AS ENUM ('visual', 'drawing', 'manual');
|
||||||
|
CREATE TYPE source_kind AS ENUM ('visual', 'hand');
|
||||||
|
CREATE TYPE project_status AS ENUM ('open', 'hold', 'closed');
|
||||||
|
CREATE TYPE route_kind AS ENUM ('databook', 'shipping');
|
||||||
|
CREATE TYPE drawing_state AS ENUM (
|
||||||
|
'none', 'needed', 'attached', 'rev_mismatch', 'not_released', 'not_found', 'lookup_failed', 'hand'
|
||||||
|
);
|
||||||
|
CREATE TYPE itp_status AS ENUM ('open', 'signed', 'reopened');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- organisation
|
||||||
|
|
||||||
|
-- Weld departments get the welding checklist and need welds and filler before submit.
|
||||||
|
ALTER TABLE departments ADD COLUMN weld bool NOT NULL DEFAULT false;
|
||||||
|
UPDATE departments SET weld = true WHERE code IN ('03', '04', '05', '06', '07', '10');
|
||||||
|
|
||||||
|
-- A worker with rows here may only work on packets at these stations. No rows = any station.
|
||||||
|
CREATE TABLE user_station_limits (
|
||||||
|
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
station_id bigint NOT NULL REFERENCES stations (id),
|
||||||
|
PRIMARY KEY (user_id, station_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- projects and units
|
||||||
|
|
||||||
|
-- *_src says where a value came from: visual, hand, or null when unset. A hand value is never
|
||||||
|
-- overwritten by the feed; *_visual keeps what VISUAL said beside it.
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
number text NOT NULL,
|
||||||
|
name text,
|
||||||
|
customer text,
|
||||||
|
po text,
|
||||||
|
due date,
|
||||||
|
status project_status NOT NULL DEFAULT 'open',
|
||||||
|
notes text,
|
||||||
|
source text NOT NULL DEFAULT 'hand' CHECK (source IN ('hand', 'visual')),
|
||||||
|
needs_details bool NOT NULL DEFAULT false,
|
||||||
|
customer_src text CHECK (customer_src IN ('visual', 'hand')),
|
||||||
|
customer_visual text,
|
||||||
|
po_src text CHECK (po_src IN ('visual', 'hand')),
|
||||||
|
po_visual text,
|
||||||
|
order_date date,
|
||||||
|
order_date_src text CHECK (order_date_src IN ('visual', 'hand')),
|
||||||
|
ship_date date,
|
||||||
|
ship_date_src text CHECK (ship_date_src IN ('visual', 'hand')),
|
||||||
|
version int NOT NULL DEFAULT 1,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
created_by uuid REFERENCES users (id),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
removed_at timestamptz,
|
||||||
|
removed_by uuid REFERENCES users (id),
|
||||||
|
removed_reason text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX projects_number_live ON projects (upper(number)) WHERE removed_at IS NULL;
|
||||||
|
|
||||||
|
-- Every current and old number of every project and unit, upper-cased, so a lookup by any
|
||||||
|
-- number resolves. unit_id is null for a project number. The unit_id FK is added after units.
|
||||||
|
CREATE TABLE project_aliases (
|
||||||
|
alias text PRIMARY KEY CHECK (alias = upper(alias)),
|
||||||
|
project_id bigint NOT NULL REFERENCES projects (id),
|
||||||
|
unit_id bigint,
|
||||||
|
kind text NOT NULL CHECK (kind IN ('renumber', 'unit_renumber', 'import')),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX project_aliases_project ON project_aliases (project_id);
|
||||||
|
|
||||||
|
-- lot is text: 1 to 3 characters, upper-cased, zero-padded to 3 when numeric (001, 7A).
|
||||||
|
CREATE TABLE units (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
project_id bigint NOT NULL REFERENCES projects (id),
|
||||||
|
lot text NOT NULL CHECK (lot ~ '^[A-Z0-9]{1,3}$' AND (lot !~ '^[0-9]+$' OR length(lot) = 3)),
|
||||||
|
name text,
|
||||||
|
due date,
|
||||||
|
status project_status NOT NULL DEFAULT 'open',
|
||||||
|
notes text,
|
||||||
|
customer_src text CHECK (customer_src IN ('visual', 'hand')),
|
||||||
|
po_src text CHECK (po_src IN ('visual', 'hand')),
|
||||||
|
order_date date,
|
||||||
|
order_date_src text CHECK (order_date_src IN ('visual', 'hand')),
|
||||||
|
ship_date date,
|
||||||
|
ship_date_src text CHECK (ship_date_src IN ('visual', 'hand')),
|
||||||
|
version int NOT NULL DEFAULT 1,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
created_by uuid REFERENCES users (id),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
removed_at timestamptz,
|
||||||
|
removed_by uuid REFERENCES users (id),
|
||||||
|
removed_reason text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX units_project_lot_live ON units (project_id, lot) WHERE removed_at IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE project_aliases
|
||||||
|
ADD CONSTRAINT project_aliases_unit_id_fkey FOREIGN KEY (unit_id) REFERENCES units (id);
|
||||||
|
|
||||||
|
CREATE TABLE project_renumbers (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
by uuid REFERENCES users (id),
|
||||||
|
project_id bigint NOT NULL REFERENCES projects (id),
|
||||||
|
unit_id bigint REFERENCES units (id),
|
||||||
|
old_number text NOT NULL,
|
||||||
|
new_number text NOT NULL,
|
||||||
|
reason text
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The unit number is the project number plus the lot. A unit's own due date wins over the project's.
|
||||||
|
CREATE VIEW v_units AS
|
||||||
|
SELECT u.*,
|
||||||
|
p.number || u.lot AS number,
|
||||||
|
p.number AS project_number,
|
||||||
|
p.name AS project_name,
|
||||||
|
p.status AS project_status,
|
||||||
|
coalesce(u.due, p.due) AS due_effective
|
||||||
|
FROM units u
|
||||||
|
JOIN projects p ON p.id = u.project_id;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- assets
|
||||||
|
|
||||||
|
-- One row per stored file. storage_key is the path under ASSETS_DIR. packet_id is the packet the
|
||||||
|
-- file was uploaded for, if any; the attach rows (packet_pages, packet_docs) are the real links.
|
||||||
|
CREATE TABLE assets (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
sha256 bytea NOT NULL,
|
||||||
|
size bigint NOT NULL,
|
||||||
|
mime text NOT NULL,
|
||||||
|
original_name text,
|
||||||
|
storage_key text NOT NULL UNIQUE,
|
||||||
|
source text NOT NULL DEFAULT 'upload',
|
||||||
|
source_ref jsonb,
|
||||||
|
pages int,
|
||||||
|
uploaded_by uuid REFERENCES users (id),
|
||||||
|
uploaded_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
packet_id bigint
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX assets_sha256 ON assets (sha256);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- packets
|
||||||
|
|
||||||
|
-- version goes up in every write transaction (the pipeline does it, never a trigger).
|
||||||
|
-- next_*_no are the counters for server-assigned child numbers, taken under the FOR UPDATE lock.
|
||||||
|
CREATE TABLE packets (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
unit_id bigint NOT NULL REFERENCES units (id),
|
||||||
|
dept_code text NOT NULL REFERENCES departments (code),
|
||||||
|
op text,
|
||||||
|
title text,
|
||||||
|
customer text,
|
||||||
|
drawing text,
|
||||||
|
rev text,
|
||||||
|
wps text,
|
||||||
|
status packet_status NOT NULL DEFAULT 'draft',
|
||||||
|
status_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
station_id bigint REFERENCES stations (id),
|
||||||
|
welder_id uuid REFERENCES users (id),
|
||||||
|
welder_stamp text,
|
||||||
|
for_sup_id uuid REFERENCES users (id),
|
||||||
|
heat_map bool,
|
||||||
|
wh_skipped bool NOT NULL DEFAULT false,
|
||||||
|
wh_sent_at timestamptz,
|
||||||
|
routed_to route_kind,
|
||||||
|
return_note text,
|
||||||
|
vclosed_at timestamptz,
|
||||||
|
vclosed_by uuid REFERENCES users (id),
|
||||||
|
databook_at timestamptz,
|
||||||
|
source_kind source_kind NOT NULL DEFAULT 'hand',
|
||||||
|
source jsonb NOT NULL DEFAULT '{}',
|
||||||
|
visual_key text,
|
||||||
|
visual_rel date,
|
||||||
|
visual_want date,
|
||||||
|
drawing_state drawing_state NOT NULL DEFAULT 'none',
|
||||||
|
flags text[] NOT NULL DEFAULT '{}',
|
||||||
|
visual_fp_applied text,
|
||||||
|
visual_closed_at timestamptz,
|
||||||
|
visual jsonb NOT NULL DEFAULT '{}',
|
||||||
|
next_weld_no int NOT NULL DEFAULT 1,
|
||||||
|
next_heat_no int NOT NULL DEFAULT 1,
|
||||||
|
next_page_no int NOT NULL DEFAULT 1,
|
||||||
|
next_co_no int NOT NULL DEFAULT 1,
|
||||||
|
version int NOT NULL DEFAULT 1,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
created_by uuid REFERENCES users (id),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
removed_at timestamptz,
|
||||||
|
removed_by uuid REFERENCES users (id),
|
||||||
|
removed_reason text
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One live packet per VISUAL key. A removed packet keeps its key so the feed never recreates it.
|
||||||
|
CREATE UNIQUE INDEX packets_visual_key ON packets (visual_key)
|
||||||
|
WHERE visual_key IS NOT NULL AND removed_at IS NULL;
|
||||||
|
CREATE INDEX packets_status_live ON packets (status) WHERE removed_at IS NULL;
|
||||||
|
CREATE INDEX packets_unit ON packets (unit_id);
|
||||||
|
CREATE INDEX packets_dept_status ON packets (dept_code, status);
|
||||||
|
|
||||||
|
-- Sets updated_at only. A read never bumps version; the write pipeline bumps it explicitly.
|
||||||
|
CREATE FUNCTION ps_packet_touch() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at := now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER packets_touch
|
||||||
|
BEFORE UPDATE ON packets
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ps_packet_touch();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- change orders
|
||||||
|
|
||||||
|
-- One pending VISUAL change order per packet at a time; the feed re-aims it rather than adding one.
|
||||||
|
CREATE TABLE change_orders (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
co_no int NOT NULL,
|
||||||
|
origin co_origin NOT NULL,
|
||||||
|
review co_review NOT NULL DEFAULT 'pending',
|
||||||
|
minor bool NOT NULL DEFAULT false,
|
||||||
|
hold bool NOT NULL DEFAULT false,
|
||||||
|
held_by uuid REFERENCES users (id),
|
||||||
|
held_at timestamptz,
|
||||||
|
ecn text,
|
||||||
|
reason text,
|
||||||
|
issued_at timestamptz DEFAULT now(),
|
||||||
|
issued_by uuid REFERENCES users (id),
|
||||||
|
issued_signature_id bigint REFERENCES signatures (id),
|
||||||
|
approved_at timestamptz,
|
||||||
|
approved_by uuid REFERENCES users (id),
|
||||||
|
approved_signature_id bigint REFERENCES signatures (id),
|
||||||
|
rejected_at timestamptz,
|
||||||
|
rejected_by uuid REFERENCES users (id),
|
||||||
|
rejected_reason text,
|
||||||
|
rejected_signature_id bigint REFERENCES signatures (id),
|
||||||
|
vis jsonb,
|
||||||
|
waiting jsonb,
|
||||||
|
staged jsonb,
|
||||||
|
target_fp text,
|
||||||
|
row_version int NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE (packet_id, co_no)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX change_orders_visual_pending ON change_orders (packet_id)
|
||||||
|
WHERE origin = 'visual' AND review = 'pending';
|
||||||
|
|
||||||
|
CREATE TABLE co_items (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
co_id bigint NOT NULL REFERENCES change_orders (id),
|
||||||
|
old_page int,
|
||||||
|
old_dwg text,
|
||||||
|
old_rev text,
|
||||||
|
new_page int,
|
||||||
|
new_dwg text,
|
||||||
|
new_rev text,
|
||||||
|
added bool NOT NULL DEFAULT false
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX co_items_co ON co_items (co_id);
|
||||||
|
|
||||||
|
CREATE TABLE co_required_depts (
|
||||||
|
co_id bigint NOT NULL REFERENCES change_orders (id),
|
||||||
|
dept_code text NOT NULL REFERENCES departments (code),
|
||||||
|
PRIMARY KEY (co_id, dept_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert-only. A primary key violation means the department already verified.
|
||||||
|
CREATE TABLE co_verifications (
|
||||||
|
co_id bigint NOT NULL REFERENCES change_orders (id),
|
||||||
|
dept_code text NOT NULL REFERENCES departments (code),
|
||||||
|
by uuid NOT NULL REFERENCES users (id),
|
||||||
|
at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
note text,
|
||||||
|
signature_id bigint NOT NULL REFERENCES signatures (id),
|
||||||
|
it_reason text,
|
||||||
|
PRIMARY KEY (co_id, dept_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The old openCOs(): an approved change order that some required department has not verified,
|
||||||
|
-- or a pending one Clerical put on hold. pending_depts lists the unverified departments, sorted.
|
||||||
|
CREATE VIEW v_open_cos AS
|
||||||
|
SELECT x.packet_id, x.co_id, x.co_no, x.review, x.hold, x.pending_depts
|
||||||
|
FROM (
|
||||||
|
SELECT co.packet_id,
|
||||||
|
co.id AS co_id,
|
||||||
|
co.co_no,
|
||||||
|
co.review,
|
||||||
|
co.hold,
|
||||||
|
ARRAY(
|
||||||
|
SELECT r.dept_code
|
||||||
|
FROM co_required_depts r
|
||||||
|
WHERE r.co_id = co.id
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM co_verifications v
|
||||||
|
WHERE v.co_id = r.co_id AND v.dept_code = r.dept_code
|
||||||
|
)
|
||||||
|
ORDER BY r.dept_code
|
||||||
|
) AS pending_depts
|
||||||
|
FROM change_orders co
|
||||||
|
WHERE co.review IN ('approved', 'pending')
|
||||||
|
) x
|
||||||
|
WHERE (x.review = 'approved' AND cardinality(x.pending_depts) > 0)
|
||||||
|
OR (x.review = 'pending' AND x.hold);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- packet children
|
||||||
|
|
||||||
|
-- All key parts are text. lot, split and sub default to '' so the unique key works without nulls.
|
||||||
|
CREATE TABLE packet_work_orders (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
wo_base text NOT NULL,
|
||||||
|
lot text NOT NULL DEFAULT '',
|
||||||
|
split text NOT NULL DEFAULT '',
|
||||||
|
sub text NOT NULL DEFAULT '',
|
||||||
|
seq int,
|
||||||
|
qty numeric,
|
||||||
|
target_hrs numeric,
|
||||||
|
op text,
|
||||||
|
description text,
|
||||||
|
visual_status text,
|
||||||
|
missing_runs int NOT NULL DEFAULT 0,
|
||||||
|
gone_at timestamptz,
|
||||||
|
sort int NOT NULL DEFAULT 0,
|
||||||
|
row_version int NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE (packet_id, wo_base, lot, split, sub)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- n comes from packets.next_page_no. A page replaced by a change order is voided, never deleted.
|
||||||
|
CREATE TABLE packet_pages (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
n int NOT NULL,
|
||||||
|
type text NOT NULL CHECK (type IN ('drawing', 'info', 'overlay')),
|
||||||
|
asset_id uuid REFERENCES assets (id),
|
||||||
|
pn int NOT NULL DEFAULT 1,
|
||||||
|
dwg text,
|
||||||
|
rev text,
|
||||||
|
co_id bigint REFERENCES change_orders (id),
|
||||||
|
void_co_id bigint REFERENCES change_orders (id),
|
||||||
|
void_at timestamptz,
|
||||||
|
void_by uuid REFERENCES users (id),
|
||||||
|
void_new_n int,
|
||||||
|
row_version int NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE (packet_id, n)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE bom_lines (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
item int NOT NULL,
|
||||||
|
qty numeric,
|
||||||
|
part_id text,
|
||||||
|
description text,
|
||||||
|
dwg_ref text,
|
||||||
|
cl_item text,
|
||||||
|
length text,
|
||||||
|
material text,
|
||||||
|
heat text,
|
||||||
|
na bool NOT NULL DEFAULT false,
|
||||||
|
issued numeric,
|
||||||
|
loc text,
|
||||||
|
pulled bool NOT NULL DEFAULT false,
|
||||||
|
pulled_at timestamptz,
|
||||||
|
pulled_by uuid REFERENCES users (id),
|
||||||
|
mtr_asset_id uuid REFERENCES assets (id),
|
||||||
|
src text CHECK (src IN ('visual', 'hand')),
|
||||||
|
vkey text,
|
||||||
|
whs text,
|
||||||
|
comm text,
|
||||||
|
cons text,
|
||||||
|
sub text,
|
||||||
|
grp text,
|
||||||
|
sort int NOT NULL DEFAULT 0,
|
||||||
|
row_version int NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE (packet_id, item)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- At most one open short per BOM line.
|
||||||
|
CREATE TABLE bom_shorts (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
bom_line_id bigint NOT NULL REFERENCES bom_lines (id) ON DELETE CASCADE,
|
||||||
|
qty numeric NOT NULL,
|
||||||
|
qty_open numeric NOT NULL,
|
||||||
|
reason text,
|
||||||
|
ref text,
|
||||||
|
eta date,
|
||||||
|
note text,
|
||||||
|
opened_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
opened_by uuid REFERENCES users (id),
|
||||||
|
closed_at timestamptz,
|
||||||
|
row_version int NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX bom_shorts_open ON bom_shorts (bom_line_id) WHERE closed_at IS NULL;
|
||||||
|
CREATE INDEX bom_shorts_packet_open ON bom_shorts (packet_id) WHERE closed_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE short_receipts (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
short_id bigint NOT NULL REFERENCES bom_shorts (id),
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
qty numeric NOT NULL,
|
||||||
|
heat text,
|
||||||
|
note text,
|
||||||
|
at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
by uuid REFERENCES users (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX short_receipts_short ON short_receipts (short_id);
|
||||||
|
|
||||||
|
-- One stage per visit to a shop department. The open stage has closed_at null.
|
||||||
|
-- checklist maps key -> {done, at, by, stamp}.
|
||||||
|
CREATE TABLE packet_stages (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
dept_code text NOT NULL REFERENCES departments (code),
|
||||||
|
station_id bigint REFERENCES stations (id),
|
||||||
|
welder_id uuid REFERENCES users (id),
|
||||||
|
welder_stamp text,
|
||||||
|
checklist jsonb NOT NULL DEFAULT '{}',
|
||||||
|
filler text,
|
||||||
|
filler_lot text,
|
||||||
|
preheat text,
|
||||||
|
notes text,
|
||||||
|
clock_elapsed_ms bigint NOT NULL DEFAULT 0,
|
||||||
|
clock_running_since timestamptz,
|
||||||
|
clock_started_at timestamptz,
|
||||||
|
qc_snapshot jsonb,
|
||||||
|
opened_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
closed_at timestamptz,
|
||||||
|
row_version int NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX packet_stages_open ON packet_stages (packet_id) WHERE closed_at IS NULL;
|
||||||
|
CREATE INDEX packet_stages_packet ON packet_stages (packet_id, id);
|
||||||
|
|
||||||
|
-- weld_no comes from packets.next_weld_no. fx and fy are fractions of the page.
|
||||||
|
CREATE TABLE welds (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
weld_no int NOT NULL,
|
||||||
|
page int,
|
||||||
|
fx double precision,
|
||||||
|
fy double precision,
|
||||||
|
wo_base text,
|
||||||
|
piece int,
|
||||||
|
items int[] NOT NULL DEFAULT '{}',
|
||||||
|
type text,
|
||||||
|
size text,
|
||||||
|
process text,
|
||||||
|
stamp text,
|
||||||
|
welder_id uuid REFERENCES users (id),
|
||||||
|
dept_code text REFERENCES departments (code),
|
||||||
|
stage_id bigint REFERENCES packet_stages (id),
|
||||||
|
carried_from int,
|
||||||
|
at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
by uuid REFERENCES users (id),
|
||||||
|
row_version int NOT NULL DEFAULT 1,
|
||||||
|
removed_at timestamptz,
|
||||||
|
removed_by uuid REFERENCES users (id),
|
||||||
|
removed_reason text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX welds_packet_no_live ON welds (packet_id, weld_no) WHERE removed_at IS NULL;
|
||||||
|
|
||||||
|
-- tag_no comes from packets.next_heat_no.
|
||||||
|
CREATE TABLE heat_tags (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
tag_no int NOT NULL,
|
||||||
|
page int,
|
||||||
|
item int,
|
||||||
|
fx double precision,
|
||||||
|
fy double precision,
|
||||||
|
carried_from int,
|
||||||
|
at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
by uuid REFERENCES users (id),
|
||||||
|
row_version int NOT NULL DEFAULT 1,
|
||||||
|
removed_at timestamptz,
|
||||||
|
removed_by uuid REFERENCES users (id),
|
||||||
|
removed_reason text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX heat_tags_packet_no_live ON heat_tags (packet_id, tag_no) WHERE removed_at IS NULL;
|
||||||
|
|
||||||
|
-- One heat table position per page; written as an idempotent upsert.
|
||||||
|
CREATE TABLE heat_table_placements (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
page int NOT NULL,
|
||||||
|
x double precision,
|
||||||
|
y double precision,
|
||||||
|
s double precision,
|
||||||
|
by uuid REFERENCES users (id),
|
||||||
|
at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (packet_id, page)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The warehouse stage history. The current row has closed_at null; a change closes it and inserts.
|
||||||
|
CREATE TABLE packet_wh (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
stage wh_stage NOT NULL,
|
||||||
|
by uuid REFERENCES users (id),
|
||||||
|
at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
reason text,
|
||||||
|
note text,
|
||||||
|
auto bool NOT NULL DEFAULT false,
|
||||||
|
closed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX packet_wh_current ON packet_wh (packet_id) WHERE closed_at IS NULL;
|
||||||
|
|
||||||
|
-- Every status change. Replaces the old hist and sentBack lists.
|
||||||
|
CREATE TABLE packet_transitions (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
by uuid REFERENCES users (id),
|
||||||
|
action text NOT NULL,
|
||||||
|
from_status packet_status,
|
||||||
|
to_status packet_status,
|
||||||
|
from_dept text,
|
||||||
|
to_dept text,
|
||||||
|
reason text,
|
||||||
|
signature_id bigint REFERENCES signatures (id),
|
||||||
|
it_reason text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX packet_transitions_packet ON packet_transitions (packet_id, id);
|
||||||
|
|
||||||
|
-- One open inspection per packet. weld_exam maps weld_no -> {result, note}.
|
||||||
|
CREATE TABLE qc_inspections (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
stage_id bigint REFERENCES packet_stages (id),
|
||||||
|
sent_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
sent_by uuid REFERENCES users (id),
|
||||||
|
book bool,
|
||||||
|
spool bool,
|
||||||
|
location text,
|
||||||
|
sent_note text,
|
||||||
|
checks jsonb NOT NULL DEFAULT '{}',
|
||||||
|
weld_exam jsonb NOT NULL DEFAULT '{}',
|
||||||
|
nde_method text,
|
||||||
|
nde_result text,
|
||||||
|
nde_report text,
|
||||||
|
hardness text,
|
||||||
|
nace bool,
|
||||||
|
inspector text,
|
||||||
|
stamp text,
|
||||||
|
notes text,
|
||||||
|
result text CHECK (result IN ('accepted', 'rejected')),
|
||||||
|
result_at timestamptz,
|
||||||
|
result_by uuid REFERENCES users (id),
|
||||||
|
result_note text,
|
||||||
|
signature_id bigint REFERENCES signatures (id),
|
||||||
|
row_version int NOT NULL DEFAULT 1,
|
||||||
|
closed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX qc_inspections_open ON qc_inspections (packet_id) WHERE closed_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE qc_history (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
result text,
|
||||||
|
inspector text,
|
||||||
|
note text,
|
||||||
|
dept_code text,
|
||||||
|
snapshot jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX qc_history_packet ON qc_history (packet_id, id);
|
||||||
|
|
||||||
|
-- HMAC seal written when a packet is filed. At most one unbroken seal per packet.
|
||||||
|
CREATE TABLE packet_seals (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
hash bytea NOT NULL,
|
||||||
|
key_id text NOT NULL,
|
||||||
|
sealed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
sealed_by uuid REFERENCES users (id),
|
||||||
|
signature_id bigint REFERENCES signatures (id),
|
||||||
|
broken_at timestamptz,
|
||||||
|
broken_by uuid REFERENCES users (id),
|
||||||
|
broken_reason text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX packet_seals_unbroken ON packet_seals (packet_id) WHERE broken_at IS NULL;
|
||||||
|
|
||||||
|
-- The user-visible history. by_name and role are snapshots at the time of writing.
|
||||||
|
CREATE TABLE packet_log (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
by uuid REFERENCES users (id),
|
||||||
|
by_name text,
|
||||||
|
role text,
|
||||||
|
action text NOT NULL,
|
||||||
|
note text,
|
||||||
|
it_reason text,
|
||||||
|
signature_id bigint REFERENCES signatures (id),
|
||||||
|
transition_id bigint REFERENCES packet_transitions (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX packet_log_packet ON packet_log (packet_id, id);
|
||||||
|
|
||||||
|
-- What the feed last observed per leg. Written by the worker only.
|
||||||
|
CREATE TABLE packet_visual_state (
|
||||||
|
packet_id bigint PRIMARY KEY REFERENCES packets (id),
|
||||||
|
legs jsonb NOT NULL DEFAULT '[]',
|
||||||
|
ticket_hours numeric,
|
||||||
|
order_ref jsonb,
|
||||||
|
observed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- documents and ITPs
|
||||||
|
|
||||||
|
CREATE TABLE packet_docs (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
packet_id bigint NOT NULL REFERENCES packets (id),
|
||||||
|
asset_id uuid NOT NULL REFERENCES assets (id),
|
||||||
|
kind text NOT NULL,
|
||||||
|
title text,
|
||||||
|
added_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
added_by uuid REFERENCES users (id),
|
||||||
|
removed_at timestamptz,
|
||||||
|
removed_by uuid REFERENCES users (id),
|
||||||
|
removed_reason text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX packet_docs_packet ON packet_docs (packet_id) WHERE removed_at IS NULL;
|
||||||
|
|
||||||
|
-- Q.C. documents for a project or a unit. A new revision points the old row at the new one.
|
||||||
|
CREATE TABLE qc_docs (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
project_id bigint REFERENCES projects (id),
|
||||||
|
unit_id bigint REFERENCES units (id),
|
||||||
|
type text NOT NULL,
|
||||||
|
rev text,
|
||||||
|
asset_id uuid NOT NULL REFERENCES assets (id),
|
||||||
|
auto bool NOT NULL DEFAULT false,
|
||||||
|
superseded_by bigint REFERENCES qc_docs (id),
|
||||||
|
added_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
added_by uuid REFERENCES users (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One ITP per unit. template is frozen when the ITP is made.
|
||||||
|
CREATE TABLE itps (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
unit_id bigint UNIQUE NOT NULL REFERENCES units (id),
|
||||||
|
template jsonb NOT NULL,
|
||||||
|
status itp_status NOT NULL DEFAULT 'open',
|
||||||
|
signoff_at timestamptz,
|
||||||
|
signoff_by uuid REFERENCES users (id),
|
||||||
|
signoff_signature_id bigint REFERENCES signatures (id),
|
||||||
|
signoff_asset_id uuid REFERENCES assets (id),
|
||||||
|
reopen_at timestamptz,
|
||||||
|
reopen_by uuid REFERENCES users (id),
|
||||||
|
reopen_reason text,
|
||||||
|
version int NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE itp_lines (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
itp_id bigint NOT NULL REFERENCES itps (id),
|
||||||
|
key text NOT NULL,
|
||||||
|
result text CHECK (result IN ('approve', 'reject', 'na')),
|
||||||
|
by uuid REFERENCES users (id),
|
||||||
|
at timestamptz,
|
||||||
|
ncr text,
|
||||||
|
note text,
|
||||||
|
signature_id bigint REFERENCES signatures (id),
|
||||||
|
row_version int NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE (itp_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Every reject keeps its NCR id, even after the line is approved later.
|
||||||
|
CREATE TABLE itp_rejects (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
itp_id bigint NOT NULL REFERENCES itps (id),
|
||||||
|
line_key text NOT NULL,
|
||||||
|
ncr text NOT NULL,
|
||||||
|
note text,
|
||||||
|
at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
by uuid REFERENCES users (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE itp_blocks (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
itp_id bigint NOT NULL REFERENCES itps (id),
|
||||||
|
block_key text NOT NULL,
|
||||||
|
signed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
signed_by uuid REFERENCES users (id),
|
||||||
|
signature_id bigint REFERENCES signatures (id),
|
||||||
|
UNIQUE (itp_id, block_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- append-only records
|
||||||
|
|
||||||
|
CREATE TRIGGER project_renumbers_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON project_renumbers
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
|
||||||
|
CREATE TRIGGER project_renumbers_no_truncate
|
||||||
|
BEFORE TRUNCATE ON project_renumbers
|
||||||
|
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
|
||||||
|
|
||||||
|
CREATE TRIGGER short_receipts_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON short_receipts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
|
||||||
|
CREATE TRIGGER short_receipts_no_truncate
|
||||||
|
BEFORE TRUNCATE ON short_receipts
|
||||||
|
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
|
||||||
|
|
||||||
|
CREATE TRIGGER packet_transitions_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON packet_transitions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
|
||||||
|
CREATE TRIGGER packet_transitions_no_truncate
|
||||||
|
BEFORE TRUNCATE ON packet_transitions
|
||||||
|
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
|
||||||
|
|
||||||
|
CREATE TRIGGER qc_history_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON qc_history
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
|
||||||
|
CREATE TRIGGER qc_history_no_truncate
|
||||||
|
BEFORE TRUNCATE ON qc_history
|
||||||
|
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
|
||||||
|
|
||||||
|
CREATE TRIGGER packet_log_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON packet_log
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
|
||||||
|
CREATE TRIGGER packet_log_no_truncate
|
||||||
|
BEFORE TRUNCATE ON packet_log
|
||||||
|
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
|
||||||
|
|
||||||
|
CREATE TRIGGER itp_rejects_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON itp_rejects
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
|
||||||
|
CREATE TRIGGER itp_rejects_no_truncate
|
||||||
|
BEFORE TRUNCATE ON itp_rejects
|
||||||
|
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
|
||||||
|
|
||||||
|
CREATE TRIGGER co_verifications_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON co_verifications
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
|
||||||
|
CREATE TRIGGER co_verifications_no_truncate
|
||||||
|
BEFORE TRUNCATE ON co_verifications
|
||||||
|
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
|
||||||
|
|
||||||
|
-- Explicit, after the default privileges: the app may only read and append these.
|
||||||
|
REVOKE UPDATE, DELETE, TRUNCATE ON project_renumbers, short_receipts, packet_transitions, qc_history,
|
||||||
|
packet_log, itp_rejects, co_verifications FROM ps_app;
|
||||||
|
GRANT SELECT, INSERT ON project_renumbers, short_receipts, packet_transitions, qc_history,
|
||||||
|
packet_log, itp_rejects, co_verifications TO ps_app;
|
||||||
|
-- Views are read-only to the app.
|
||||||
|
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON v_units, v_open_cos FROM ps_app;
|
||||||
|
GRANT SELECT ON v_units, v_open_cos TO ps_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Packet policy: who may do what to a packet, and when. Pure functions, no I/O. See docs/POLICY.md."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .actions import ACTIVE, RULES, SHOP, STATUSES, TRANSITIONS, Action, Rule, Who
|
||||||
|
from .custody import holder_name, holds, works_on
|
||||||
|
from .engine import Decision, can, check, evaluate
|
||||||
|
from .preconditions import INPUT_ONLY, MESSAGES, PRECONDITIONS
|
||||||
|
from .principal import SYSTEM_FEED, PacketState, Principal, principal_from_roles
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ACTIVE",
|
||||||
|
"INPUT_ONLY",
|
||||||
|
"MESSAGES",
|
||||||
|
"PRECONDITIONS",
|
||||||
|
"RULES",
|
||||||
|
"SHOP",
|
||||||
|
"STATUSES",
|
||||||
|
"SYSTEM_FEED",
|
||||||
|
"TRANSITIONS",
|
||||||
|
"Action",
|
||||||
|
"Decision",
|
||||||
|
"PacketState",
|
||||||
|
"Principal",
|
||||||
|
"Rule",
|
||||||
|
"Who",
|
||||||
|
"can",
|
||||||
|
"check",
|
||||||
|
"evaluate",
|
||||||
|
"holder_name",
|
||||||
|
"holds",
|
||||||
|
"principal_from_roles",
|
||||||
|
"works_on",
|
||||||
|
]
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""The Action enum, the Who predicates and the rule table. Plain data; the engine reads it.
|
||||||
|
|
||||||
|
A port of the old guard.js POLICY_TRANSITIONS, POLICY_FIELDS, SIGN_OFF and CO_HOLD tables, with the
|
||||||
|
tightenings listed in docs/POLICY.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
# Statuses in workflow order (the packet_status enum declaration order).
|
||||||
|
STATUSES: tuple[str, ...] = (
|
||||||
|
"draft",
|
||||||
|
"warehouse",
|
||||||
|
"ready",
|
||||||
|
"assigned",
|
||||||
|
"in_progress",
|
||||||
|
"review",
|
||||||
|
"qc",
|
||||||
|
"complete",
|
||||||
|
"filed",
|
||||||
|
)
|
||||||
|
SHOP: frozenset[str] = frozenset({"ready", "assigned", "in_progress", "review"})
|
||||||
|
ACTIVE: frozenset[str] = frozenset({"assigned", "in_progress"})
|
||||||
|
|
||||||
|
# Short labels for messages.
|
||||||
|
STATUS_LABELS: dict[str, str] = {
|
||||||
|
"draft": "Draft",
|
||||||
|
"warehouse": "With warehouse",
|
||||||
|
"ready": "Ready to assign",
|
||||||
|
"assigned": "At a station",
|
||||||
|
"in_progress": "In progress",
|
||||||
|
"review": "Supervisor review",
|
||||||
|
"qc": "At Q.C.",
|
||||||
|
"complete": "Complete",
|
||||||
|
"filed": "Filed",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Department codes that are not shop departments.
|
||||||
|
SUPPORT_CODES: frozenset[str] = frozenset({"CL", "WH", "QC", "PM"})
|
||||||
|
|
||||||
|
|
||||||
|
class Action(StrEnum):
|
||||||
|
# transitions
|
||||||
|
SEND_TO_WAREHOUSE = "send_to_warehouse"
|
||||||
|
RELEASE_TO_SHOP = "release_to_shop"
|
||||||
|
SEND_DRAFT_TO_QC = "send_draft_to_qc"
|
||||||
|
WH_SEND_BACK = "wh_send_back"
|
||||||
|
RETURN_TO_CLERICAL = "return_to_clerical"
|
||||||
|
WH_RELEASE = "wh_release"
|
||||||
|
SEND_BACK_TO_WAREHOUSE = "send_back_to_warehouse"
|
||||||
|
ASSIGN = "assign"
|
||||||
|
UNASSIGN = "unassign"
|
||||||
|
PULL_BACK = "pull_back"
|
||||||
|
START = "start"
|
||||||
|
SUBMIT = "submit"
|
||||||
|
RETURN_TO_STATION = "return_to_station"
|
||||||
|
SEND_TO_QC = "send_to_qc"
|
||||||
|
ROUTE_FROM_REVIEW = "route_from_review"
|
||||||
|
QC_REJECT = "qc_reject"
|
||||||
|
QC_ACCEPT = "qc_accept"
|
||||||
|
QC_ACCEPT_AND_ROUTE = "qc_accept_and_route"
|
||||||
|
FILE = "file"
|
||||||
|
ADMIN_REOPEN = "admin_reopen"
|
||||||
|
# edits (no status change)
|
||||||
|
EDIT_HEADER = "edit_header"
|
||||||
|
EDIT_DRAWING_REF = "edit_drawing_ref"
|
||||||
|
EDIT_DEPT = "edit_dept"
|
||||||
|
MOVE_UNIT = "move_unit"
|
||||||
|
EDIT_WOS = "edit_wos"
|
||||||
|
EDIT_BOM = "edit_bom"
|
||||||
|
WH_RECEIVE_SHORT = "wh_receive_short"
|
||||||
|
EDIT_HEATS = "edit_heats"
|
||||||
|
EDIT_HEAT_TABLE = "edit_heat_table"
|
||||||
|
EDIT_WELDS = "edit_welds"
|
||||||
|
EDIT_CHECKLIST = "edit_checklist"
|
||||||
|
EDIT_STAGE_FIELDS = "edit_stage_fields"
|
||||||
|
CLOCK = "clock"
|
||||||
|
EDIT_QC = "edit_qc"
|
||||||
|
SET_WH_STAGE = "set_wh_stage"
|
||||||
|
EDIT_DOCUMENT_SET = "edit_document_set"
|
||||||
|
DOC_ADD = "doc_add"
|
||||||
|
DOC_REMOVE = "doc_remove"
|
||||||
|
REV_APPROVE = "rev_approve"
|
||||||
|
SET_VCLOSED = "set_vclosed"
|
||||||
|
SET_DATABOOK_AT = "set_databook_at"
|
||||||
|
REMOVE = "remove"
|
||||||
|
CO_ISSUE = "co_issue"
|
||||||
|
CO_APPROVE = "co_approve"
|
||||||
|
CO_REJECT = "co_reject"
|
||||||
|
CO_HOLD_TOGGLE = "co_hold_toggle"
|
||||||
|
CO_VERIFY = "co_verify"
|
||||||
|
ITP_EDIT_LINE = "itp_edit_line"
|
||||||
|
ITP_BLOCK_SIGN = "itp_block_sign"
|
||||||
|
ITP_SIGN_OFF = "itp_sign_off"
|
||||||
|
ITP_REOPEN = "itp_reopen"
|
||||||
|
VIS_UPDATE_DRAFT = "vis_update_draft"
|
||||||
|
VIS_OPEN_CO = "vis_open_co"
|
||||||
|
VIS_AUTO_RELEASE = "vis_auto_release"
|
||||||
|
VIS_RETIRE_DRAFT = "vis_retire_draft"
|
||||||
|
|
||||||
|
|
||||||
|
class Who(StrEnum):
|
||||||
|
"""The closed set of who predicates. custody.allows() evaluates them."""
|
||||||
|
|
||||||
|
CL = "CL"
|
||||||
|
CL_SUP = "CL_SUP"
|
||||||
|
WH = "WH"
|
||||||
|
WH_SUP = "WH_SUP"
|
||||||
|
QC = "QC"
|
||||||
|
QC_SUP = "QC_SUP"
|
||||||
|
CL_OR_QC = "CL_OR_QC"
|
||||||
|
HOLDER = "HOLDER"
|
||||||
|
DEPT_LEAD = "DEPT_LEAD"
|
||||||
|
DEPT_SUP = "DEPT_SUP"
|
||||||
|
WORKS_ON = "WORKS_ON"
|
||||||
|
CO_VERIFIER = "CO_VERIFIER"
|
||||||
|
ADMIN = "ADMIN"
|
||||||
|
SYSTEM = "SYSTEM"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Rule:
|
||||||
|
who: Who
|
||||||
|
from_status: frozenset[str] # empty = any status (edits); the filed lock is applied separately
|
||||||
|
to_status: str | None # None for edits
|
||||||
|
dept_change: str = "no" # no | required | allowed
|
||||||
|
co_hold: bool = False
|
||||||
|
preconditions: tuple[str, ...] = ()
|
||||||
|
signature: str | None = None # the meaning text when a signature is required
|
||||||
|
reason_required: bool = False
|
||||||
|
filed_ok: bool = False # only SET_DATABOOK_AT and ADMIN_REOPEN
|
||||||
|
system_only: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def transition(self) -> bool:
|
||||||
|
return self.to_status is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _s(*statuses: str | frozenset[str]) -> frozenset[str]:
|
||||||
|
out: set[str] = set()
|
||||||
|
for s in statuses:
|
||||||
|
if isinstance(s, str):
|
||||||
|
out.add(s)
|
||||||
|
else:
|
||||||
|
out.update(s)
|
||||||
|
return frozenset(out)
|
||||||
|
|
||||||
|
|
||||||
|
ANY: frozenset[str] = frozenset()
|
||||||
|
_REASON = ("reason_given",)
|
||||||
|
_DRAFT_OUT = ("heat_map_answered", "drawing_attached")
|
||||||
|
|
||||||
|
A = Action
|
||||||
|
RULES: dict[Action, Rule] = {
|
||||||
|
# transitions
|
||||||
|
A.SEND_TO_WAREHOUSE: Rule(Who.CL, _s("draft"), "warehouse", preconditions=_DRAFT_OUT),
|
||||||
|
A.RELEASE_TO_SHOP: Rule(
|
||||||
|
Who.CL, _s("draft"), "ready", dept_change="allowed", preconditions=(*_DRAFT_OUT, "target_dept_given")
|
||||||
|
),
|
||||||
|
A.SEND_DRAFT_TO_QC: Rule(Who.CL, _s("draft"), "qc", preconditions=_DRAFT_OUT),
|
||||||
|
A.WH_SEND_BACK: Rule(Who.WH, _s("warehouse"), "draft", preconditions=_REASON, reason_required=True),
|
||||||
|
A.RETURN_TO_CLERICAL: Rule(Who.DEPT_LEAD, _s("ready"), "draft", preconditions=_REASON, reason_required=True),
|
||||||
|
A.WH_RELEASE: Rule(Who.WH, _s("warehouse"), "ready", co_hold=True, preconditions=("bom_all_pulled_or_short",)),
|
||||||
|
A.SEND_BACK_TO_WAREHOUSE: Rule(
|
||||||
|
Who.DEPT_LEAD, _s("ready"), "warehouse", preconditions=_REASON, reason_required=True
|
||||||
|
),
|
||||||
|
A.ASSIGN: Rule(Who.DEPT_LEAD, _s("ready"), "assigned", preconditions=("welder_set",)),
|
||||||
|
A.UNASSIGN: Rule(Who.DEPT_LEAD, _s("assigned"), "ready"),
|
||||||
|
A.PULL_BACK: Rule(Who.DEPT_LEAD, _s("in_progress"), "ready"),
|
||||||
|
A.START: Rule(Who.WORKS_ON, _s("assigned"), "in_progress"),
|
||||||
|
A.SUBMIT: Rule(
|
||||||
|
Who.WORKS_ON,
|
||||||
|
_s("assigned", "in_progress"),
|
||||||
|
"review",
|
||||||
|
co_hold=True,
|
||||||
|
preconditions=("checklist_complete", "welds_recorded", "filler_set"),
|
||||||
|
signature="Submit work to the supervisor",
|
||||||
|
),
|
||||||
|
A.RETURN_TO_STATION: Rule(Who.DEPT_SUP, _s("review"), "assigned", preconditions=_REASON, reason_required=True),
|
||||||
|
A.SEND_TO_QC: Rule(
|
||||||
|
Who.DEPT_SUP,
|
||||||
|
_s("review"),
|
||||||
|
"qc",
|
||||||
|
co_hold=True,
|
||||||
|
preconditions=("qc_book_spool_location",),
|
||||||
|
signature="Send to Q.C.",
|
||||||
|
),
|
||||||
|
A.ROUTE_FROM_REVIEW: Rule(
|
||||||
|
Who.DEPT_SUP,
|
||||||
|
_s("review"),
|
||||||
|
"ready",
|
||||||
|
dept_change="required",
|
||||||
|
co_hold=True,
|
||||||
|
preconditions=("target_dept_given",),
|
||||||
|
signature="Route to another department",
|
||||||
|
),
|
||||||
|
A.QC_REJECT: Rule(Who.QC, _s("qc"), "review", preconditions=_REASON, signature="Q.C. reject", reason_required=True),
|
||||||
|
A.QC_ACCEPT: Rule(
|
||||||
|
Who.QC,
|
||||||
|
_s("qc"),
|
||||||
|
"complete",
|
||||||
|
co_hold=True,
|
||||||
|
preconditions=("qc_missing_empty", "route_given"),
|
||||||
|
signature="Q.C. accept",
|
||||||
|
),
|
||||||
|
A.QC_ACCEPT_AND_ROUTE: Rule(
|
||||||
|
Who.QC,
|
||||||
|
_s("qc"),
|
||||||
|
"ready",
|
||||||
|
dept_change="required",
|
||||||
|
co_hold=True,
|
||||||
|
preconditions=("qc_missing_empty", "target_dept_given"),
|
||||||
|
signature="Q.C. accept and route",
|
||||||
|
),
|
||||||
|
A.FILE: Rule(
|
||||||
|
Who.CL,
|
||||||
|
_s("complete"),
|
||||||
|
"filed",
|
||||||
|
co_hold=True,
|
||||||
|
preconditions=("vclosed_if_databook",),
|
||||||
|
signature="File in the data book",
|
||||||
|
),
|
||||||
|
A.ADMIN_REOPEN: Rule(
|
||||||
|
Who.ADMIN,
|
||||||
|
_s("filed"),
|
||||||
|
"complete",
|
||||||
|
preconditions=_REASON,
|
||||||
|
signature="Reopen a filed packet",
|
||||||
|
reason_required=True,
|
||||||
|
filed_ok=True,
|
||||||
|
),
|
||||||
|
# edits
|
||||||
|
A.EDIT_HEADER: Rule(Who.CL, _s("draft"), None),
|
||||||
|
A.EDIT_DRAWING_REF: Rule(Who.CL, ANY, None),
|
||||||
|
A.EDIT_DEPT: Rule(Who.CL, _s("draft"), None),
|
||||||
|
A.MOVE_UNIT: Rule(Who.CL, ANY, None),
|
||||||
|
A.EDIT_WOS: Rule(Who.CL, _s("draft"), None),
|
||||||
|
A.EDIT_BOM: Rule(Who.HOLDER, _s("draft", "warehouse", SHOP), None),
|
||||||
|
A.WH_RECEIVE_SHORT: Rule(Who.WH, ANY, None, preconditions=("short_open",)),
|
||||||
|
A.EDIT_HEATS: Rule(Who.HOLDER, _s("warehouse", SHOP), None),
|
||||||
|
A.EDIT_HEAT_TABLE: Rule(Who.HOLDER, _s("warehouse", SHOP), None),
|
||||||
|
A.EDIT_WELDS: Rule(Who.HOLDER, SHOP, None),
|
||||||
|
A.EDIT_CHECKLIST: Rule(Who.HOLDER, SHOP, None),
|
||||||
|
A.EDIT_STAGE_FIELDS: Rule(Who.HOLDER, SHOP, None),
|
||||||
|
A.CLOCK: Rule(Who.HOLDER, SHOP, None),
|
||||||
|
A.EDIT_QC: Rule(Who.QC, _s("qc"), None),
|
||||||
|
A.SET_WH_STAGE: Rule(Who.WH, _s("warehouse"), None),
|
||||||
|
A.EDIT_DOCUMENT_SET: Rule(Who.CL, ANY, None, signature="Attach a drawing by hand"),
|
||||||
|
A.DOC_ADD: Rule(Who.CL_OR_QC, ANY, None),
|
||||||
|
A.DOC_REMOVE: Rule(Who.CL_OR_QC, ANY, None),
|
||||||
|
A.REV_APPROVE: Rule(
|
||||||
|
Who.CL_SUP,
|
||||||
|
_s("draft"),
|
||||||
|
None,
|
||||||
|
preconditions=_REASON,
|
||||||
|
signature="Approve the M-Files revision",
|
||||||
|
reason_required=True,
|
||||||
|
),
|
||||||
|
A.SET_VCLOSED: Rule(Who.CL, _s("complete"), None),
|
||||||
|
A.SET_DATABOOK_AT: Rule(Who.CL_OR_QC, ANY, None, filed_ok=True),
|
||||||
|
A.REMOVE: Rule(Who.CL, _s("draft"), None, preconditions=_REASON, reason_required=True),
|
||||||
|
A.CO_ISSUE: Rule(Who.CL, ANY, None, signature="Issue a change order"),
|
||||||
|
A.CO_APPROVE: Rule(Who.CL, ANY, None, signature="Approve a change order"),
|
||||||
|
A.CO_REJECT: Rule(
|
||||||
|
Who.CL, ANY, None, preconditions=_REASON, signature="Reject a change order", reason_required=True
|
||||||
|
),
|
||||||
|
A.CO_HOLD_TOGGLE: Rule(Who.CL, ANY, None),
|
||||||
|
A.CO_VERIFY: Rule(Who.CO_VERIFIER, ANY, None, signature="Verify a change order for the department"),
|
||||||
|
A.ITP_EDIT_LINE: Rule(Who.QC, ANY, None),
|
||||||
|
A.ITP_BLOCK_SIGN: Rule(Who.QC, ANY, None, signature="Sign an ITP block"),
|
||||||
|
A.ITP_SIGN_OFF: Rule(Who.QC, ANY, None, signature="Sign off the ITP"),
|
||||||
|
# QC supervisor; an admin passes through the bypass with an IT reason.
|
||||||
|
A.ITP_REOPEN: Rule(Who.QC_SUP, ANY, None, preconditions=_REASON, reason_required=True),
|
||||||
|
A.VIS_UPDATE_DRAFT: Rule(Who.SYSTEM, _s("draft"), None, system_only=True),
|
||||||
|
A.VIS_OPEN_CO: Rule(Who.SYSTEM, frozenset(STATUSES) - {"draft", "filed"}, None, system_only=True),
|
||||||
|
A.VIS_AUTO_RELEASE: Rule(Who.SYSTEM, _s("draft"), "warehouse", preconditions=_DRAFT_OUT, system_only=True),
|
||||||
|
A.VIS_RETIRE_DRAFT: Rule(Who.SYSTEM, _s("draft"), None, system_only=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
# The user transitions (not the system ones), in table order.
|
||||||
|
TRANSITIONS: tuple[Action, ...] = tuple(a for a, r in RULES.items() if r.transition and not r.system_only)
|
||||||
|
|
||||||
|
# What an edit changes, for the "Your access doesn't allow changing <what>" message.
|
||||||
|
EDIT_WHAT: dict[Action, str] = {
|
||||||
|
A.EDIT_HEADER: "the packet details",
|
||||||
|
A.EDIT_DRAWING_REF: "the drawing number",
|
||||||
|
A.EDIT_DEPT: "the department",
|
||||||
|
A.MOVE_UNIT: "the unit",
|
||||||
|
A.EDIT_WOS: "the work orders",
|
||||||
|
A.EDIT_BOM: "the materials",
|
||||||
|
A.WH_RECEIVE_SHORT: "short parts",
|
||||||
|
A.EDIT_HEATS: "heat tags",
|
||||||
|
A.EDIT_HEAT_TABLE: "the heat table",
|
||||||
|
A.EDIT_WELDS: "welds",
|
||||||
|
A.EDIT_CHECKLIST: "the checklist",
|
||||||
|
A.EDIT_STAGE_FIELDS: "the stage details",
|
||||||
|
A.CLOCK: "the clock",
|
||||||
|
A.EDIT_QC: "the Q.C. record",
|
||||||
|
A.SET_WH_STAGE: "the warehouse stage",
|
||||||
|
A.EDIT_DOCUMENT_SET: "the drawing pages",
|
||||||
|
A.DOC_ADD: "the documents",
|
||||||
|
A.DOC_REMOVE: "the documents",
|
||||||
|
A.REV_APPROVE: "the drawing revision",
|
||||||
|
A.SET_VCLOSED: "the VISUAL close",
|
||||||
|
A.SET_DATABOOK_AT: "the data book date",
|
||||||
|
A.REMOVE: "whether it is removed",
|
||||||
|
A.CO_ISSUE: "change orders",
|
||||||
|
A.CO_APPROVE: "change orders",
|
||||||
|
A.CO_REJECT: "change orders",
|
||||||
|
A.CO_HOLD_TOGGLE: "change orders",
|
||||||
|
A.CO_VERIFY: "change orders",
|
||||||
|
A.ITP_EDIT_LINE: "the ITP",
|
||||||
|
A.ITP_BLOCK_SIGN: "the ITP",
|
||||||
|
A.ITP_SIGN_OFF: "the ITP",
|
||||||
|
A.ITP_REOPEN: "the ITP",
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Custody: who holds a packet right now, and the Who predicates. A direct port of guard.js holds()."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .actions import ACTIVE, SUPPORT_CODES, Who
|
||||||
|
from .principal import PacketState, Principal
|
||||||
|
|
||||||
|
WORKER, LEAD, SUPERVISOR = 1, 2, 3
|
||||||
|
|
||||||
|
|
||||||
|
def works_on(principal: Principal, state: PacketState) -> bool:
|
||||||
|
"""A member of the packet's department whose station limits allow the packet's station.
|
||||||
|
|
||||||
|
Leads and supervisors always pass. A worker passes when they have no station limits or the limits hold
|
||||||
|
the packet's station. This is the old worksOn(); START and SUBMIT add the assigned-welder check.
|
||||||
|
"""
|
||||||
|
level = principal.level(state.dept)
|
||||||
|
if level == 0:
|
||||||
|
return False
|
||||||
|
if level >= LEAD:
|
||||||
|
return True
|
||||||
|
return not principal.station_limits or (
|
||||||
|
state.station_id is not None and state.station_id in principal.station_limits
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def holds(principal: Principal, state: PacketState) -> bool:
|
||||||
|
"""True when the principal holds the packet in its current status. Admin is not a holder; see the bypass."""
|
||||||
|
st = state.status
|
||||||
|
if st == "draft":
|
||||||
|
return "CL" in principal.support
|
||||||
|
if st == "warehouse":
|
||||||
|
return "WH" in principal.support
|
||||||
|
if st in ("ready", "review"):
|
||||||
|
return principal.level(state.dept) >= LEAD
|
||||||
|
if st in ACTIVE:
|
||||||
|
return principal.level(state.dept) >= LEAD or works_on(principal, state)
|
||||||
|
if st == "qc":
|
||||||
|
return "QC" in principal.support
|
||||||
|
if st == "complete":
|
||||||
|
return "CL" in principal.support
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def holder_name(state: PacketState, dept_name: str | None = None, station_name: str | None = None) -> str:
|
||||||
|
"""Who has the packet, for messages. The department name falls back to its code."""
|
||||||
|
st = state.status
|
||||||
|
if st == "draft":
|
||||||
|
return "Clerical"
|
||||||
|
if st == "warehouse":
|
||||||
|
return "Warehouse"
|
||||||
|
if st == "qc":
|
||||||
|
return "Q.C."
|
||||||
|
if st == "complete":
|
||||||
|
return "Clerical (data book)"
|
||||||
|
if st == "filed":
|
||||||
|
return "the data book (filed)"
|
||||||
|
name = dept_name or state.dept
|
||||||
|
if st in ACTIVE and station_name:
|
||||||
|
return f"{name} · {station_name}"
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _works_on_assigned(principal: Principal, state: PacketState) -> bool:
|
||||||
|
"""START and SUBMIT: a lead or above in the department, or the assigned welder within their station limits."""
|
||||||
|
level = principal.level(state.dept)
|
||||||
|
if level >= LEAD:
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
level == WORKER
|
||||||
|
and principal.user_id is not None
|
||||||
|
and state.welder_id is not None
|
||||||
|
and str(state.welder_id) == str(principal.user_id)
|
||||||
|
and works_on(principal, state)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _co_verifier(principal: Principal, extra: Mapping[str, Any]) -> bool:
|
||||||
|
"""A supervisor of the department named in the request (extra["dept"])."""
|
||||||
|
dept = extra.get("dept")
|
||||||
|
if not isinstance(dept, str) or not dept:
|
||||||
|
return False
|
||||||
|
if dept in ("WH", "QC"):
|
||||||
|
return dept in principal.support_sup
|
||||||
|
if dept in SUPPORT_CODES:
|
||||||
|
return False # Clerical issues change orders; it never verifies them. PM has no supervisor.
|
||||||
|
return principal.level(dept) >= SUPERVISOR
|
||||||
|
|
||||||
|
|
||||||
|
def allows(who: Who, principal: Principal, state: PacketState, extra: Mapping[str, Any]) -> bool:
|
||||||
|
"""Evaluate one Who predicate. Admin is never allowed here except by Who.ADMIN; the bypass is a rule."""
|
||||||
|
if who is Who.CL:
|
||||||
|
return "CL" in principal.support
|
||||||
|
if who is Who.CL_SUP:
|
||||||
|
return "CL" in principal.support_sup
|
||||||
|
if who is Who.WH:
|
||||||
|
return "WH" in principal.support
|
||||||
|
if who is Who.WH_SUP:
|
||||||
|
return "WH" in principal.support_sup
|
||||||
|
if who is Who.QC:
|
||||||
|
return "QC" in principal.support
|
||||||
|
if who is Who.QC_SUP:
|
||||||
|
return "QC" in principal.support_sup
|
||||||
|
if who is Who.CL_OR_QC:
|
||||||
|
return "CL" in principal.support or "QC" in principal.support
|
||||||
|
if who is Who.HOLDER:
|
||||||
|
return holds(principal, state)
|
||||||
|
if who is Who.DEPT_LEAD:
|
||||||
|
return principal.level(state.dept) >= LEAD
|
||||||
|
if who is Who.DEPT_SUP:
|
||||||
|
return principal.level(state.dept) >= SUPERVISOR
|
||||||
|
if who is Who.WORKS_ON:
|
||||||
|
return _works_on_assigned(principal, state)
|
||||||
|
if who is Who.CO_VERIFIER:
|
||||||
|
return _co_verifier(principal, extra)
|
||||||
|
if who is Who.ADMIN:
|
||||||
|
return principal.admin
|
||||||
|
if who is Who.SYSTEM:
|
||||||
|
return principal.system is not None
|
||||||
|
return False
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""The policy engine: check one action, or list what a principal can do on a packet.
|
||||||
|
|
||||||
|
Order of checks, exactly:
|
||||||
|
1. removed packet: nothing passes, Admin included.
|
||||||
|
2. status: the rule's from statuses and the status conditions (RETURN_TO_CLERICAL needs wh_skipped,
|
||||||
|
SET_VCLOSED needs routed_to databook). An unknown status fails here.
|
||||||
|
3. change-order hold, Admin included.
|
||||||
|
4. system-only rules refuse people; the system principal passes only system-only rules.
|
||||||
|
5. admin bypass of who and custody, with an IT reason (SET_DATABOOK_AT needs none).
|
||||||
|
6. filed lock: only SET_DATABOOK_AT and ADMIN_REOPEN, Admin included.
|
||||||
|
7. who and custody.
|
||||||
|
8. preconditions in rule order, never bypassed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..text import reason_ok
|
||||||
|
from .actions import EDIT_WHAT, RULES, STATUS_LABELS, STATUSES, Action, Rule
|
||||||
|
from .custody import allows, holder_name
|
||||||
|
from .preconditions import INPUT_ONLY, PRECONDITIONS
|
||||||
|
from .principal import PacketState, Principal
|
||||||
|
|
||||||
|
EMPTY: Mapping[str, Any] = MappingProxyType({})
|
||||||
|
|
||||||
|
MSG_REMOVED = "This packet was removed."
|
||||||
|
MSG_UNKNOWN_STATUS = "This packet has an unknown status."
|
||||||
|
MSG_CO_HOLD = "A change order holds this step."
|
||||||
|
MSG_SYSTEM_ONLY = "Only the VISUAL feed does this."
|
||||||
|
MSG_SYSTEM_FORBIDDEN = "The VISUAL feed cannot do this."
|
||||||
|
MSG_IT_REASON = "Give an IT reason of at least 8 characters."
|
||||||
|
MSG_FILED = "This packet is filed and locked. IT can reopen it if a correction is needed."
|
||||||
|
MSG_FORBIDDEN_STEP = "Your access doesn't allow this step."
|
||||||
|
MSG_NOT_WH_SKIPPED = "This packet came through the warehouse. Send it back to the warehouse instead."
|
||||||
|
MSG_NOT_DATABOOK = "Only packets routed to the data book are closed in VISUAL here."
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Decision:
|
||||||
|
ok: bool
|
||||||
|
code: str = "" # removed | wrong_status | co_hold | filed | it_reason_required | forbidden | precondition
|
||||||
|
# | reason_required | system_only
|
||||||
|
why: str = "" # the message for the user
|
||||||
|
admin_bypass: bool = False # an admin passed who and custody only through the bypass (an IT edit)
|
||||||
|
signature: str | None = None # the rule's signature meaning, when one is required
|
||||||
|
reasons: tuple[str, ...] = () # read mode only: every blocking reason (CO hold and state preconditions)
|
||||||
|
|
||||||
|
|
||||||
|
# Status conditions beyond the from statuses: (predicate, message when it fails).
|
||||||
|
STATUS_CONDITIONS: dict[Action, tuple[Callable[[PacketState], bool], str]] = {
|
||||||
|
Action.RETURN_TO_CLERICAL: (lambda s: s.wh_skipped, MSG_NOT_WH_SKIPPED),
|
||||||
|
Action.SET_VCLOSED: (lambda s: s.routed_to == "databook", MSG_NOT_DATABOOK),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _wrong_status(state: PacketState, action: Action, rule: Rule) -> str | None:
|
||||||
|
if state.status not in STATUSES:
|
||||||
|
return MSG_UNKNOWN_STATUS
|
||||||
|
if rule.from_status and state.status not in rule.from_status:
|
||||||
|
return f"This step isn't open while the packet is {STATUS_LABELS[state.status]}."
|
||||||
|
cond = STATUS_CONDITIONS.get(action)
|
||||||
|
if cond is not None and not cond[0](state):
|
||||||
|
return cond[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _forbidden_why(action: Action, rule: Rule, state: PacketState, extra: Mapping[str, Any]) -> str:
|
||||||
|
what = EDIT_WHAT.get(action)
|
||||||
|
if rule.transition or what is None:
|
||||||
|
return MSG_FORBIDDEN_STEP
|
||||||
|
holder = extra.get("holder_name")
|
||||||
|
if not isinstance(holder, str) or not holder:
|
||||||
|
holder = holder_name(state)
|
||||||
|
return f"Your access doesn't allow changing {what} on this packet. It's with {holder} right now."
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate(
|
||||||
|
principal: Principal,
|
||||||
|
state: PacketState,
|
||||||
|
action: Action,
|
||||||
|
extra: Mapping[str, Any],
|
||||||
|
it_reason: str | None,
|
||||||
|
*,
|
||||||
|
read: bool,
|
||||||
|
assume_it_reason: bool,
|
||||||
|
) -> Decision:
|
||||||
|
rule = RULES[action]
|
||||||
|
# (1) removed
|
||||||
|
if state.removed:
|
||||||
|
return Decision(False, "removed", MSG_REMOVED)
|
||||||
|
# (2) status
|
||||||
|
why = _wrong_status(state, action, rule)
|
||||||
|
if why is not None:
|
||||||
|
return Decision(False, "wrong_status", why)
|
||||||
|
# (3) change-order hold (deferred in read mode so the UI can show it as blocked after who passes)
|
||||||
|
blockers: list[str] = []
|
||||||
|
if rule.co_hold and state.co_hold:
|
||||||
|
hold_why = state.co_hold_why or MSG_CO_HOLD
|
||||||
|
if not read:
|
||||||
|
return Decision(False, "co_hold", hold_why)
|
||||||
|
blockers.append(hold_why)
|
||||||
|
# (4) system
|
||||||
|
if rule.system_only and principal.system is None:
|
||||||
|
return Decision(False, "system_only", MSG_SYSTEM_ONLY)
|
||||||
|
if principal.system is not None and not rule.system_only:
|
||||||
|
return Decision(False, "forbidden", MSG_SYSTEM_FORBIDDEN)
|
||||||
|
# (5) admin bypass
|
||||||
|
who_ok = allows(rule.who, principal, state, extra)
|
||||||
|
bypass = False
|
||||||
|
if not who_ok and principal.admin:
|
||||||
|
if action is Action.SET_DATABOOK_AT:
|
||||||
|
bypass = True
|
||||||
|
elif (read and assume_it_reason) or reason_ok(it_reason) is not None:
|
||||||
|
bypass = True
|
||||||
|
else:
|
||||||
|
return Decision(False, "it_reason_required", MSG_IT_REASON)
|
||||||
|
# (6) filed lock
|
||||||
|
if state.status == "filed" and not rule.filed_ok:
|
||||||
|
return Decision(False, "filed", MSG_FILED)
|
||||||
|
# (7) who and custody
|
||||||
|
if not who_ok and not bypass:
|
||||||
|
return Decision(False, "forbidden", _forbidden_why(action, rule, state, extra))
|
||||||
|
# (8) preconditions, never bypassed
|
||||||
|
for name in rule.preconditions:
|
||||||
|
if read and name in INPUT_ONLY:
|
||||||
|
continue
|
||||||
|
failed = PRECONDITIONS[name](state, extra)
|
||||||
|
if failed is None:
|
||||||
|
continue
|
||||||
|
if read:
|
||||||
|
blockers.append(failed)
|
||||||
|
continue
|
||||||
|
code = "reason_required" if rule.reason_required and name == "reason_given" else "precondition"
|
||||||
|
return Decision(False, code, failed, admin_bypass=bypass)
|
||||||
|
if blockers:
|
||||||
|
code = "co_hold" if rule.co_hold and state.co_hold else "precondition"
|
||||||
|
return Decision(False, code, blockers[0], admin_bypass=bypass, reasons=tuple(blockers))
|
||||||
|
return Decision(True, admin_bypass=bypass, signature=rule.signature)
|
||||||
|
|
||||||
|
|
||||||
|
def check(
|
||||||
|
principal: Principal,
|
||||||
|
state: PacketState,
|
||||||
|
action: Action,
|
||||||
|
*,
|
||||||
|
extra: Mapping[str, Any] = EMPTY,
|
||||||
|
it_reason: str | None = None,
|
||||||
|
) -> Decision:
|
||||||
|
"""Decide one action with the request input in `extra`. Never raises for an unknown status."""
|
||||||
|
return _evaluate(principal, state, action, extra, it_reason, read=False, assume_it_reason=False)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(principal: Principal, state: PacketState, action: Action, *, assume_it_reason: bool = True) -> Decision:
|
||||||
|
"""Read-mode decision: input-only preconditions skipped, CO hold and state preconditions collected in reasons.
|
||||||
|
|
||||||
|
For an admin the IT reason is assumed when `assume_it_reason` is true; admin_bypass says the UI will ask.
|
||||||
|
"""
|
||||||
|
return _evaluate(principal, state, action, EMPTY, None, read=True, assume_it_reason=assume_it_reason)
|
||||||
|
|
||||||
|
|
||||||
|
def can(
|
||||||
|
principal: Principal, state: PacketState, assume_it_reason: bool = True
|
||||||
|
) -> tuple[list[Action], dict[Action, list[str]]]:
|
||||||
|
"""What the principal can do now, and what is blocked with the reasons. System-only actions are never listed.
|
||||||
|
|
||||||
|
`can` holds the actions that pass with an empty extra (input-only preconditions skipped). `blocked` holds the
|
||||||
|
actions that pass who and custody but fail a state precondition or the CO hold.
|
||||||
|
"""
|
||||||
|
allowed: list[Action] = []
|
||||||
|
blocked: dict[Action, list[str]] = {}
|
||||||
|
for action, rule in RULES.items():
|
||||||
|
if rule.system_only:
|
||||||
|
continue
|
||||||
|
d = evaluate(principal, state, action, assume_it_reason=assume_it_reason)
|
||||||
|
if d.ok:
|
||||||
|
allowed.append(action)
|
||||||
|
elif d.reasons:
|
||||||
|
blocked[action] = list(d.reasons)
|
||||||
|
return allowed, blocked
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""Named preconditions. Each takes (state, extra) and returns the reason when it fails, else None.
|
||||||
|
|
||||||
|
They are checked on the server and never bypassed, including by Admin. Some read request input (extra) rather
|
||||||
|
than packet state; `can()` skips those, because they are inputs the user gives with the step.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..text import clean_text, reason_ok
|
||||||
|
from .actions import SUPPORT_CODES
|
||||||
|
from .principal import PacketState
|
||||||
|
|
||||||
|
Precondition = Callable[[PacketState, Mapping[str, Any]], str | None]
|
||||||
|
|
||||||
|
MESSAGES: dict[str, str] = {
|
||||||
|
"heat_map_answered": "Say whether this packet needs a heat map.",
|
||||||
|
"drawing_attached": "Attach the drawing first.",
|
||||||
|
"bom_all_pulled_or_short": "Pull every material line or mark it short first.",
|
||||||
|
"short_open": "No part is short on this packet.",
|
||||||
|
"welder_set": "Pick a station and a welder.",
|
||||||
|
"checklist_complete": "Finish the checklist first.",
|
||||||
|
"welds_recorded": "Record at least one weld first.",
|
||||||
|
"filler_set": "Enter the filler metal first.",
|
||||||
|
"qc_book_spool_location": "Confirm the book and the spool, and give the location.",
|
||||||
|
"qc_missing_empty": "Q.C. is not finished.",
|
||||||
|
"reason_given": "Give a reason of at least 8 characters.",
|
||||||
|
"vclosed_if_databook": "Close the job in VISUAL first.",
|
||||||
|
"co_staged_if_dwg_change": "Stage the new drawing for the change order first.",
|
||||||
|
"route_given": "Choose Data Book or Shipping.",
|
||||||
|
"target_dept_given": "Choose a different department.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Preconditions that read request input, not packet state. can() skips them.
|
||||||
|
INPUT_ONLY: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"welder_set",
|
||||||
|
"target_dept_given",
|
||||||
|
"route_given",
|
||||||
|
"reason_given",
|
||||||
|
"qc_book_spool_location",
|
||||||
|
"co_staged_if_dwg_change",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ROUTES = ("databook", "shipping")
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(name: str, ok: bool) -> str | None:
|
||||||
|
return None if ok else MESSAGES[name]
|
||||||
|
|
||||||
|
|
||||||
|
def heat_map_answered(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
return _fail("heat_map_answered", state.heat_map is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def drawing_attached(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
return _fail("drawing_attached", state.drawing_attached)
|
||||||
|
|
||||||
|
|
||||||
|
def bom_all_pulled_or_short(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
return _fail("bom_all_pulled_or_short", state.bom_ready >= state.bom_total)
|
||||||
|
|
||||||
|
|
||||||
|
def short_open(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
"""The old whCanReceive: the warehouse may only receive into a packet with an open short."""
|
||||||
|
return _fail("short_open", state.open_shorts > 0)
|
||||||
|
|
||||||
|
|
||||||
|
def welder_set(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
return _fail("welder_set", extra.get("station_id") not in (None, "") and extra.get("welder_id") not in (None, ""))
|
||||||
|
|
||||||
|
|
||||||
|
def checklist_complete(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
return _fail("checklist_complete", state.checklist_complete)
|
||||||
|
|
||||||
|
|
||||||
|
def welds_recorded(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
return _fail("welds_recorded", not state.weld_dept or state.welds > 0)
|
||||||
|
|
||||||
|
|
||||||
|
def filler_set(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
return _fail("filler_set", not state.weld_dept or state.filler_set)
|
||||||
|
|
||||||
|
|
||||||
|
def qc_book_spool_location(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
ok = extra.get("book") is True and extra.get("spool") is True and bool(clean_text(extra.get("location"), 120))
|
||||||
|
return _fail("qc_book_spool_location", ok)
|
||||||
|
|
||||||
|
|
||||||
|
def qc_missing_empty(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
if not state.qc_missing:
|
||||||
|
return None
|
||||||
|
return "Q.C. still needs: " + ", ".join(state.qc_missing) + "."
|
||||||
|
|
||||||
|
|
||||||
|
def reason_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
return _fail("reason_given", reason_ok(extra.get("reason")) is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def vclosed_if_databook(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
return _fail("vclosed_if_databook", state.routed_to != "databook" or state.vclosed)
|
||||||
|
|
||||||
|
|
||||||
|
def co_staged_if_dwg_change(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
return _fail("co_staged_if_dwg_change", extra.get("dwg_change") is not True or extra.get("staged") is True)
|
||||||
|
|
||||||
|
|
||||||
|
def route_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
return _fail("route_given", extra.get("routed_to") in ROUTES)
|
||||||
|
|
||||||
|
|
||||||
|
def is_shop_code(value: Any) -> bool:
|
||||||
|
"""A non-empty department code that is not a support code. The API checks it exists."""
|
||||||
|
return isinstance(value, str) and bool(value.strip()) and value not in SUPPORT_CODES
|
||||||
|
|
||||||
|
|
||||||
|
def target_dept_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||||
|
"""A shop department different from the packet's. For a draft (RELEASE_TO_SHOP) any shop department."""
|
||||||
|
dept = extra.get("dept")
|
||||||
|
if not is_shop_code(dept):
|
||||||
|
return "Choose a department."
|
||||||
|
if state.status != "draft" and dept == state.dept:
|
||||||
|
return MESSAGES["target_dept_given"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
PRECONDITIONS: dict[str, Precondition] = {
|
||||||
|
"heat_map_answered": heat_map_answered,
|
||||||
|
"drawing_attached": drawing_attached,
|
||||||
|
"bom_all_pulled_or_short": bom_all_pulled_or_short,
|
||||||
|
"short_open": short_open,
|
||||||
|
"welder_set": welder_set,
|
||||||
|
"checklist_complete": checklist_complete,
|
||||||
|
"welds_recorded": welds_recorded,
|
||||||
|
"filler_set": filler_set,
|
||||||
|
"qc_book_spool_location": qc_book_spool_location,
|
||||||
|
"qc_missing_empty": qc_missing_empty,
|
||||||
|
"reason_given": reason_given,
|
||||||
|
"vclosed_if_databook": vclosed_if_databook,
|
||||||
|
"co_staged_if_dwg_change": co_staged_if_dwg_change,
|
||||||
|
"route_given": route_given,
|
||||||
|
"target_dept_given": target_dept_given,
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""The policy's view of a person (Principal) and of a packet (PacketState)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from types import MappingProxyType
|
||||||
|
|
||||||
|
from ..auth.groups import SHOP_LEVELS, parse_role
|
||||||
|
|
||||||
|
SUPPORT = ("CL", "WH", "QC")
|
||||||
|
LEVELS: dict[str, int] = {level: i + 1 for i, level in enumerate(SHOP_LEVELS)} # worker 1, lead 2, supervisor 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Principal:
|
||||||
|
user_id: str | None # uuid as text; None for system
|
||||||
|
admin: bool = False
|
||||||
|
support: frozenset[str] = frozenset() # codes among CL, WH, QC the user is a member of (any level)
|
||||||
|
support_sup: frozenset[str] = frozenset() # codes among CL, WH, QC where the user is Supervisor
|
||||||
|
shop: Mapping[str, int] = field(default_factory=lambda: MappingProxyType({})) # code -> 1 worker, 2 lead, 3 sup
|
||||||
|
station_limits: frozenset[int] = frozenset() # station ids a worker is limited to; empty = any
|
||||||
|
pm: bool = False
|
||||||
|
session_kind: str = "password" # password | pin
|
||||||
|
system: str | None = None # "visual-feed" for the worker; never from a session
|
||||||
|
|
||||||
|
def level(self, dept: str) -> int:
|
||||||
|
"""Shop level in a department: 0 none, 1 worker, 2 lead, 3 supervisor."""
|
||||||
|
return int(self.shop.get(dept, 0))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PacketState:
|
||||||
|
id: int
|
||||||
|
status: str # packet_status value
|
||||||
|
dept: str # dept_code
|
||||||
|
station_id: int | None
|
||||||
|
welder_id: str | None # uuid text of the assigned welder
|
||||||
|
wh_skipped: bool
|
||||||
|
routed_to: str | None # databook | shipping | None
|
||||||
|
vclosed: bool
|
||||||
|
removed: bool
|
||||||
|
source_kind: str # visual | hand
|
||||||
|
weld_dept: bool # departments.weld
|
||||||
|
co_hold: bool # an approved CO with pending verifications, or a pending CO with hold=true
|
||||||
|
co_hold_why: str = "" # text for the error
|
||||||
|
open_shorts: int = 0
|
||||||
|
drawing_attached: bool = False
|
||||||
|
heat_map: bool | None = None
|
||||||
|
bom_total: int = 0 # lines not marked n/a
|
||||||
|
bom_ready: int = 0 # lines pulled, or with an open short
|
||||||
|
welds: int = 0
|
||||||
|
checklist_complete: bool = False # every key of the department checklist is done (empty list is complete)
|
||||||
|
filler_set: bool = False
|
||||||
|
qc_missing: tuple[str, ...] = () # the old qcMissing() list, computed by the read model
|
||||||
|
has_qc_sent: bool = False # qc_inspections row exists for the current stage
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_FEED = Principal(user_id=None, system="visual-feed")
|
||||||
|
|
||||||
|
|
||||||
|
def principal_from_roles(
|
||||||
|
user_id: str | None,
|
||||||
|
roles: Iterable[str],
|
||||||
|
station_limits: Iterable[int] = (),
|
||||||
|
session_kind: str = "password",
|
||||||
|
) -> Principal:
|
||||||
|
"""Build the policy Principal from phase 1 role strings. A role that does not parse is ignored."""
|
||||||
|
admin = False
|
||||||
|
pm = False
|
||||||
|
support: set[str] = set()
|
||||||
|
support_sup: set[str] = set()
|
||||||
|
shop: dict[str, int] = {}
|
||||||
|
for text in roles:
|
||||||
|
try:
|
||||||
|
role = parse_role(text)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if role.kind == "admin":
|
||||||
|
admin = True
|
||||||
|
elif role.kind == "pm":
|
||||||
|
pm = True
|
||||||
|
elif role.kind.startswith("shop_") and role.dept_code:
|
||||||
|
level = LEVELS[role.kind.removeprefix("shop_")]
|
||||||
|
shop[role.dept_code] = max(level, shop.get(role.dept_code, 0))
|
||||||
|
elif role.dept_code in SUPPORT:
|
||||||
|
support.add(role.dept_code)
|
||||||
|
if role.kind.endswith("_supervisor"):
|
||||||
|
support_sup.add(role.dept_code)
|
||||||
|
return Principal(
|
||||||
|
user_id=user_id,
|
||||||
|
admin=admin,
|
||||||
|
support=frozenset(support),
|
||||||
|
support_sup=frozenset(support_sup),
|
||||||
|
shop=MappingProxyType(shop),
|
||||||
|
station_limits=frozenset(int(s) for s in station_limits),
|
||||||
|
pm=pm,
|
||||||
|
session_kind=session_kind,
|
||||||
|
)
|
||||||
@@ -99,6 +99,7 @@ class Settings(BaseSettings):
|
|||||||
device_cookie_days: int = Field(default=365, ge=1)
|
device_cookie_days: int = Field(default=365, ge=1)
|
||||||
pin_min_digits: int = Field(default=6, ge=4)
|
pin_min_digits: int = Field(default=6, ge=4)
|
||||||
pin_max_digits: int = Field(default=12, ge=4)
|
pin_max_digits: int = Field(default=12, ge=4)
|
||||||
|
upload_max_bytes: int = Field(default=25 * 1024 * 1024, ge=1024)
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -159,6 +160,13 @@ class Settings(BaseSettings):
|
|||||||
raise SecretFileError(f"SESSION_KEY_FILE file {self.session_key_file} holds a key that is too short")
|
raise SecretFileError(f"SESSION_KEY_FILE file {self.session_key_file} holds a key that is too short")
|
||||||
return key.encode("utf-8")
|
return key.encode("utf-8")
|
||||||
|
|
||||||
|
def seal_key(self) -> bytes:
|
||||||
|
"""The HMAC key for filing seals, from SEAL_KEY_FILE."""
|
||||||
|
key = read_secret(self.seal_key_file, "SEAL_KEY_FILE").strip()
|
||||||
|
if len(key) < 16:
|
||||||
|
raise SecretFileError(f"SEAL_KEY_FILE file {self.seal_key_file} holds a key that is too short")
|
||||||
|
return key.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Input cleaning and limits: the old build's cleanText, cleanDeep and validation regexes, now on the server.
|
||||||
|
|
||||||
|
Every string that enters the database goes through `clean_text` with the field's limit. Control characters
|
||||||
|
(except tab, LF and CR), DEL, the zero-width and bidi override characters are removed, the text is NFC
|
||||||
|
normalised, trimmed and cut to the limit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# C0 controls except tab (09), LF (0A) and CR (0D); DEL; zero-width and bidi marks; isolates.
|
||||||
|
_STRIP = re.compile("[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f---]")
|
||||||
|
|
||||||
|
RX_HEAT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ./#_-]{0,39}$")
|
||||||
|
RX_STAMP = re.compile(r"^[A-Za-z0-9-]{1,10}$")
|
||||||
|
RX_EMP = re.compile(r"^[A-Z0-9-]{1,20}$")
|
||||||
|
RX_PROJECT = re.compile(r"^[A-Z0-9][A-Z0-9-]{0,19}$")
|
||||||
|
RX_LOT = re.compile(r"^[A-Z0-9]{1,3}$")
|
||||||
|
|
||||||
|
# Character limits per field. Reasons for IT edits and send-backs need at least REASON_MIN.
|
||||||
|
LIMITS: dict[str, int] = {
|
||||||
|
"title": 200,
|
||||||
|
"customer": 120,
|
||||||
|
"drawing": 60,
|
||||||
|
"rev": 10,
|
||||||
|
"wps": 40,
|
||||||
|
"op": 40,
|
||||||
|
"notes": 2000,
|
||||||
|
"reason": 500,
|
||||||
|
"note": 500,
|
||||||
|
"heat": 40,
|
||||||
|
"stamp": 10,
|
||||||
|
"location": 120,
|
||||||
|
"part_id": 60,
|
||||||
|
"description": 240,
|
||||||
|
"material": 120,
|
||||||
|
"length": 40,
|
||||||
|
"project_number": 20,
|
||||||
|
"project_name": 120,
|
||||||
|
"po": 60,
|
||||||
|
"lot": 3,
|
||||||
|
"unit_name": 120,
|
||||||
|
"doc_title": 160,
|
||||||
|
"ecn": 40,
|
||||||
|
"name": 160,
|
||||||
|
}
|
||||||
|
REASON_MIN = 8
|
||||||
|
MAX_BODY_BYTES = 200_000
|
||||||
|
|
||||||
|
|
||||||
|
def clean_text(value: Any, max_len: int = 2000) -> str:
|
||||||
|
"""The old cleanText: strip controls and invisible marks, NFC, trim, cut. None becomes ''."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
text = value if isinstance(value, str) else str(value)
|
||||||
|
text = _STRIP.sub("", text)
|
||||||
|
text = unicodedata.normalize("NFC", text).strip()
|
||||||
|
return text[:max_len]
|
||||||
|
|
||||||
|
|
||||||
|
def clean_opt(value: Any, max_len: int) -> str | None:
|
||||||
|
"""clean_text, but an empty result is None (for nullable columns)."""
|
||||||
|
text = clean_text(value, max_len)
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def clean_deep(value: Any, max_len: int = 4000, depth: int = 0) -> Any:
|
||||||
|
"""Clean every string inside a JSON-like value. Depth is capped at 12 like the old build."""
|
||||||
|
if depth > 12:
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return clean_text(value, max_len)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [clean_deep(x, max_len, depth + 1) for x in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(k)[:120]: clean_deep(v, max_len, depth + 1) for k, v in value.items()}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def limit(field: str) -> int:
|
||||||
|
return LIMITS.get(field, 2000)
|
||||||
|
|
||||||
|
|
||||||
|
def reason_ok(value: Any) -> str | None:
|
||||||
|
"""A cleaned reason of at least REASON_MIN characters, else None."""
|
||||||
|
text = clean_text(value, LIMITS["reason"])
|
||||||
|
return text if len(text) >= REASON_MIN else None
|
||||||
@@ -15,6 +15,8 @@ argon2-cffi==25.1.0
|
|||||||
apscheduler==3.11.3
|
apscheduler==3.11.3
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
tzdata==2026.5
|
tzdata==2026.5
|
||||||
|
pypdf==6.21.0
|
||||||
|
python-multipart==0.0.32
|
||||||
|
|
||||||
# needed by visualkit 0.3.0 and mfileskit 0.1.2
|
# needed by visualkit 0.3.0 and mfileskit 0.1.2
|
||||||
pyodbc==5.3.0
|
pyodbc==5.3.0
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { dcUser, signInPassword } from "./helpers";
|
||||||
|
|
||||||
|
// Phase 2 Clerical scenario on the CI stack. Runs after signin.spec.ts on the one worker.
|
||||||
|
// clerk.clara sets up project L999 with unit 001, makes a hand draft, attaches a drawing with a
|
||||||
|
// signature, answers the heat map question and sends it to the warehouse. wh.wade then sees a
|
||||||
|
// Clerical draft as view only, and the warehouse packet without the Clerical steps.
|
||||||
|
|
||||||
|
/** A minimal valid one-page PDF, built from raw bytes with a correct xref table. */
|
||||||
|
function onePagePdf(): Buffer {
|
||||||
|
const content = "0 0 m 612 792 l S";
|
||||||
|
const objects = [
|
||||||
|
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||||
|
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||||
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>",
|
||||||
|
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
|
||||||
|
];
|
||||||
|
let out = "%PDF-1.4\n";
|
||||||
|
const offsets: number[] = [];
|
||||||
|
objects.forEach((body, i) => {
|
||||||
|
offsets.push(out.length);
|
||||||
|
out += `${i + 1} 0 obj\n${body}\nendobj\n`;
|
||||||
|
});
|
||||||
|
const xref = out.length;
|
||||||
|
out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||||
|
out += offsets.map((o) => `${String(o).padStart(10, "0")} 00000 n \n`).join("");
|
||||||
|
out += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||||||
|
return Buffer.from(out, "latin1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePdf(): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "ps-e2e-"));
|
||||||
|
const file = join(dir, "CI-DWG-1.pdf");
|
||||||
|
writeFileSync(file, onePagePdf());
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** From the unit page: New packet sheet, filled and saved. Returns the packet page URL. */
|
||||||
|
async function newDraft(page: Page, d: { op: string; title: string; drawing: string; rev: string; wo?: [string, string] }): Promise<string> {
|
||||||
|
await page.getByRole("button", { name: "Add packet" }).click();
|
||||||
|
const sheet = page.getByRole("dialog", { name: "New packet" });
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
// The unit comes from the unit page. Wait until the picker shows it.
|
||||||
|
await expect(sheet.getByLabel("Unit", { exact: true })).toHaveValue(/\d+/);
|
||||||
|
await sheet.getByLabel("First department").selectOption("03");
|
||||||
|
await sheet.getByLabel("Operation", { exact: true }).fill(d.op);
|
||||||
|
await sheet.getByLabel("Title", { exact: true }).fill(d.title);
|
||||||
|
await sheet.getByLabel("Drawing no.", { exact: true }).fill(d.drawing);
|
||||||
|
await sheet.getByLabel("Rev", { exact: true }).fill(d.rev);
|
||||||
|
if (d.wo) {
|
||||||
|
await sheet.getByLabel("Work order 1", { exact: true }).fill(d.wo[0]);
|
||||||
|
await sheet.getByLabel("Work order 1 lot", { exact: true }).fill(d.wo[1]);
|
||||||
|
}
|
||||||
|
await sheet.getByRole("button", { name: "Save packet as draft" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/packets\/\d+$/);
|
||||||
|
await expect(page.getByRole("heading", { name: `L999001 · ${d.op}` })).toBeVisible();
|
||||||
|
return page.url();
|
||||||
|
}
|
||||||
|
|
||||||
|
let unitUrl = "";
|
||||||
|
let warehousePacketUrl = "";
|
||||||
|
let warehousePacketId = 0;
|
||||||
|
|
||||||
|
test.describe.serial("clerical hand draft", () => {
|
||||||
|
test("clerk.clara makes a hand draft and sends it to the warehouse", async ({ page }) => {
|
||||||
|
await signInPassword(page, "clerk.clara");
|
||||||
|
await expect(page).toHaveURL(/\/clerical/);
|
||||||
|
|
||||||
|
// Project L999 with one unit, lot 001.
|
||||||
|
await page.getByRole("button", { name: "New project" }).click();
|
||||||
|
const proj = page.getByRole("dialog", { name: "New project" });
|
||||||
|
await proj.getByLabel("Project number").fill("L999");
|
||||||
|
await proj.getByLabel("Project name").fill("CI project");
|
||||||
|
await proj.getByRole("button", { name: "Create project" }).click();
|
||||||
|
|
||||||
|
const units = page.getByRole("dialog", { name: "Add units" });
|
||||||
|
await expect(units).toBeVisible();
|
||||||
|
await units.getByLabel("Lot", { exact: true }).fill("001");
|
||||||
|
await units.getByRole("button", { name: "Add", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/clerical\/projects\/\d+$/);
|
||||||
|
await expect(page.getByRole("heading", { name: "L999 · CI project" })).toBeVisible();
|
||||||
|
|
||||||
|
// Open the unit.
|
||||||
|
await page.getByRole("row", { name: /L999001/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/clerical\/units\/\d+$/);
|
||||||
|
unitUrl = page.url();
|
||||||
|
|
||||||
|
// The hand draft.
|
||||||
|
warehousePacketUrl = await newDraft(page, { op: "WELD-PART", title: "CI spool", drawing: "CI-DWG-1", rev: "0", wo: ["L999001", "001"] });
|
||||||
|
warehousePacketId = Number(new URL(warehousePacketUrl).pathname.split("/").pop());
|
||||||
|
await expect(page.getByTestId("status-pill")).toHaveText("Ready to release");
|
||||||
|
// Not sendable yet: no heat map answer and no drawing.
|
||||||
|
await expect(page.getByRole("button", { name: "Send to warehouse" })).toBeDisabled();
|
||||||
|
|
||||||
|
// Attach the drawing, signed with Clara's password.
|
||||||
|
await page.getByLabel("Attach drawing (PDF)").setInputFiles(writePdf());
|
||||||
|
const sign = page.getByRole("dialog", { name: "Sign" });
|
||||||
|
await expect(sign).toBeVisible();
|
||||||
|
await expect(sign.getByText("You are signing as Clara Clerk.")).toBeVisible();
|
||||||
|
await sign.getByLabel("Password").fill(dcUser("clerk.clara").password);
|
||||||
|
await sign.getByRole("button", { name: "Sign", exact: true }).click();
|
||||||
|
await expect(sign).toBeHidden();
|
||||||
|
await expect(page.getByText(/Drawing attached/)).toBeVisible();
|
||||||
|
|
||||||
|
// Heat map: Yes.
|
||||||
|
const hm = page.getByRole("group", { name: "Heat map required?" });
|
||||||
|
await hm.getByRole("button", { name: "Yes" }).click();
|
||||||
|
await expect(hm.getByRole("button", { name: "Yes" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
|
||||||
|
// Send it on.
|
||||||
|
const send = page.getByRole("button", { name: "Send to warehouse" });
|
||||||
|
await expect(send).toBeEnabled();
|
||||||
|
await send.click();
|
||||||
|
await expect(page.getByTestId("status-pill")).toHaveText("With warehouse");
|
||||||
|
|
||||||
|
const res = await page.request.get("/api/packets?status=warehouse");
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const body = (await res.json()) as { packets: Array<{ id: number; status: string }> };
|
||||||
|
expect(body.packets.some((p) => p.id === warehousePacketId && p.status === "warehouse")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wh.wade sees a Clerical draft as view only, and no Send button", async ({ browser }) => {
|
||||||
|
// A second draft that stays with Clerical.
|
||||||
|
const clara = await browser.newContext();
|
||||||
|
const cp = await clara.newPage();
|
||||||
|
await signInPassword(cp, "clerk.clara");
|
||||||
|
await cp.goto(unitUrl);
|
||||||
|
const draftUrl = await newDraft(cp, { op: "FIT-UP", title: "CI view only", drawing: "CI-DWG-2", rev: "0" });
|
||||||
|
await clara.close();
|
||||||
|
|
||||||
|
const wade = await browser.newContext();
|
||||||
|
const page = await wade.newPage();
|
||||||
|
await signInPassword(page, "wh.wade");
|
||||||
|
|
||||||
|
await page.goto(draftUrl);
|
||||||
|
await expect(page.getByTestId("status-pill")).toHaveText("Ready to release");
|
||||||
|
await expect(page.getByText("View only.")).toBeVisible();
|
||||||
|
await expect(page.locator(".viewonly")).toContainText("This packet is with Clerical");
|
||||||
|
await expect(page.getByRole("button", { name: /^Send/ })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("group", { name: "Heat map required?" })).toHaveCount(0);
|
||||||
|
|
||||||
|
// The packet in the warehouse shows its status, and none of the Clerical steps.
|
||||||
|
await page.goto(warehousePacketUrl);
|
||||||
|
await expect(page.getByTestId("status-pill")).toHaveText("With warehouse");
|
||||||
|
await expect(page.getByRole("button", { name: "Send to warehouse" })).toHaveCount(0);
|
||||||
|
await wade.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""Helpers for the packet API tests: projects and units by SQL, hand drafts, PDFs, transitions, the happy path."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import secrets
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fakedir import FAKE_PASSWORD, FakeDirectory
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pshelpers import ORIGIN_HEADERS, app_conn, sign_in
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from packetstation.domain import checklists
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
SIGN = {"factor": "password", "secret": FAKE_PASSWORD}
|
||||||
|
USERS = ("admin.ann", "clerk.clara", "wh.wade", "qc.quinn", "pm.paula", "weld.andy", "lead.lou", "sup.wes", "dup.dave")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def phase2_client(settings: Settings, fake_dir: FakeDirectory) -> Iterator[TestClient]:
|
||||||
|
from packetstation.main import create_app
|
||||||
|
|
||||||
|
app = create_app(settings, directory=fake_dir) # main.py registers every phase 2 router
|
||||||
|
with TestClient(app, headers=ORIGIN_HEADERS) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
def seed_users(client: TestClient, users: tuple[str, ...] = USERS) -> None:
|
||||||
|
"""Sign each test user in once so their users rows exist."""
|
||||||
|
for sam in users:
|
||||||
|
assert sign_in(client, sam).status_code == 200, sam
|
||||||
|
|
||||||
|
|
||||||
|
def as_user(client: TestClient, sam: str) -> None:
|
||||||
|
r = sign_in(client, sam)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def user_id(conn: Any, sam: str) -> str:
|
||||||
|
row = conn.execute("SELECT id FROM users WHERE sam_account = %s", (sam,)).fetchone()
|
||||||
|
assert row is not None, sam
|
||||||
|
return str(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def station_id(conn: Any, dept: str = "03", name: str = "Stall 1") -> int:
|
||||||
|
row = conn.execute("SELECT id FROM stations WHERE dept_code = %s AND name = %s", (dept, name)).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def make_unit(conn: Any, *, number: str | None = None, lot: str = "001", status: str = "open") -> tuple[int, int]:
|
||||||
|
"""A project and one unit by SQL. Returns (project_id, unit_id)."""
|
||||||
|
number = number or "L9" + secrets.token_hex(3).upper()
|
||||||
|
pid = conn.execute(
|
||||||
|
"INSERT INTO projects (number, name, customer, po) VALUES (%s, %s, %s, %s) RETURNING id",
|
||||||
|
(number, "Test project", "Acme Refining", "PO-77"),
|
||||||
|
).fetchone()[0]
|
||||||
|
uid = conn.execute(
|
||||||
|
"INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, %s, %s::project_status) RETURNING id",
|
||||||
|
(pid, lot, "Unit one", status),
|
||||||
|
).fetchone()[0]
|
||||||
|
return int(pid), int(uid)
|
||||||
|
|
||||||
|
|
||||||
|
def add_unit(conn: Any, project_id: int, lot: str, status: str = "open") -> int:
|
||||||
|
return int(
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO units (project_id, lot, status) VALUES (%s, %s, %s::project_status) RETURNING id",
|
||||||
|
(project_id, lot, status),
|
||||||
|
).fetchone()[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def draft_body(unit_id: int, **over: Any) -> dict[str, Any]:
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"dept_code": "03",
|
||||||
|
"op": "WELD-PART",
|
||||||
|
"title": "Spool 4 inlet",
|
||||||
|
"customer": "Acme Refining",
|
||||||
|
"drawing": "D-1001",
|
||||||
|
"rev": "B",
|
||||||
|
"wps": "WPS-11",
|
||||||
|
"work_orders": [
|
||||||
|
{"wo_base": "L670001", "lot": "001", "split": "0", "sub": "0", "qty": 2, "target_hrs": 1.5},
|
||||||
|
],
|
||||||
|
"bom": [],
|
||||||
|
}
|
||||||
|
body.update(over)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def make_draft(client: TestClient, unit_id: int, **over: Any) -> dict[str, Any]:
|
||||||
|
"""A hand draft through the API as whoever is signed in (Clerical). Returns the read model."""
|
||||||
|
r = client.post("/api/packets", json=draft_body(unit_id, **over))
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
out: dict[str, Any] = r.json()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def tiny_pdf(pages: int = 1) -> bytes:
|
||||||
|
from pypdf import PdfWriter
|
||||||
|
|
||||||
|
w = PdfWriter()
|
||||||
|
for _ in range(pages):
|
||||||
|
w.add_blank_page(width=612, height=792)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
w.write(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def upload(client: TestClient, data: bytes, name: str = "drawing.pdf", mime: str = "application/pdf") -> Any:
|
||||||
|
return client.post("/api/assets", files={"file": (name, data, mime)})
|
||||||
|
|
||||||
|
|
||||||
|
def attach_drawing(client: TestClient, packet_id: int, pages: int = 1) -> dict[str, Any]:
|
||||||
|
a = upload(client, tiny_pdf(pages))
|
||||||
|
assert a.status_code == 201, a.text
|
||||||
|
r = client.post(
|
||||||
|
f"/api/packets/{packet_id}/pages", json={"asset_id": a.json()["id"], "type": "drawing", "sign": SIGN}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
out: dict[str, Any] = r.json()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def set_header(client: TestClient, packet_id: int, **fields: Any) -> Any:
|
||||||
|
return client.patch(f"/api/packets/{packet_id}", json={"set": fields, "base": {}})
|
||||||
|
|
||||||
|
|
||||||
|
def ready_draft(client: TestClient, unit_id: int, **over: Any) -> dict[str, Any]:
|
||||||
|
"""A hand draft with the heat map answered and a drawing attached: it can leave Clerical."""
|
||||||
|
pkt = make_draft(client, unit_id, **over)
|
||||||
|
assert set_header(client, pkt["id"], heat_map=True).status_code == 200
|
||||||
|
return attach_drawing(client, pkt["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def step(client: TestClient, pkt: dict[str, Any], action: str, **fields: Any) -> Any:
|
||||||
|
"""POST a transition with the expect taken from the packet answer."""
|
||||||
|
body = {"action": action, "expect": {"status": pkt["status"], "dept": pkt["dept"]["code"]}, **fields}
|
||||||
|
return client.post(f"/api/packets/{pkt['id']}/transitions", json=body)
|
||||||
|
|
||||||
|
|
||||||
|
def ok_step(client: TestClient, pkt: dict[str, Any], action: str, **fields: Any) -> dict[str, Any]:
|
||||||
|
r = step(client, pkt, action, **fields)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
out: dict[str, Any] = r.json()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get(client: TestClient, packet_id: int) -> dict[str, Any]:
|
||||||
|
r = client.get(f"/api/packets/{packet_id}")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
out: dict[str, Any] = r.json()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def finish_stage_by_sql(settings: Settings, packet_id: int) -> None:
|
||||||
|
"""The checklist, filler and one weld, which the child endpoints (built later) would write."""
|
||||||
|
keys = checklists.checklist_keys(True)
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE packet_stages SET checklist = %s, filler = 'ER70S-6' WHERE packet_id = %s AND closed_at IS NULL",
|
||||||
|
(Jsonb({k: {"done": True, "stamp": "A7"} for k in keys}), packet_id),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO welds (packet_id, weld_no, items, type, size) VALUES (%s, 1, '{1}', 'Fillet', '1/4')",
|
||||||
|
(packet_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def finish_qc_by_sql(settings: Settings, packet_id: int) -> None:
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE qc_inspections SET checks = %s, weld_exam = %s, inspector = 'Quinn QC'"
|
||||||
|
" WHERE packet_id = %s AND closed_at IS NULL",
|
||||||
|
(Jsonb({k: {"done": True} for k in checklists.qc_keys()}), Jsonb({"1": {"result": "accept"}}), packet_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def happy_path(client: TestClient, settings: Settings, unit_id: int) -> dict[str, Any]:
|
||||||
|
"""Draft -> warehouse -> ready -> assigned -> in progress -> review -> Q.C. -> complete (data book) -> filed."""
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
stall = station_id(conn)
|
||||||
|
andy = user_id(conn, "weld.andy")
|
||||||
|
as_user(client, "clerk.clara")
|
||||||
|
pkt = ready_draft(client, unit_id)
|
||||||
|
pkt = ok_step(client, pkt, "send_to_warehouse")
|
||||||
|
assert pkt["status"] == "warehouse" and pkt["wh"]["stage"] == "incoming"
|
||||||
|
|
||||||
|
as_user(client, "wh.wade")
|
||||||
|
pkt = ok_step(client, pkt, "wh_release")
|
||||||
|
assert pkt["status"] == "ready" and pkt["stage"]["dept_code"] == "03" and pkt["wh"] is None
|
||||||
|
|
||||||
|
as_user(client, "lead.lou")
|
||||||
|
r = step(client, pkt, "return_to_clerical", reason="Wrong department for this one")
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "wrong_status", r.text
|
||||||
|
|
||||||
|
as_user(client, "sup.wes")
|
||||||
|
pkt = ok_step(client, pkt, "assign", station_id=stall, welder_id=andy)
|
||||||
|
assert pkt["status"] == "assigned" and pkt["station"]["name"] == "Stall 1"
|
||||||
|
assert pkt["welder"]["id"] == andy and pkt["stage"]["welder"]["id"] == andy
|
||||||
|
|
||||||
|
as_user(client, "dup.dave")
|
||||||
|
r = step(client, pkt, "start")
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text
|
||||||
|
|
||||||
|
as_user(client, "weld.andy")
|
||||||
|
pkt = ok_step(client, pkt, "start")
|
||||||
|
assert pkt["status"] == "in_progress" and pkt["stage"]["clock"]["running_since"] is not None
|
||||||
|
r = step(client, pkt, "submit", sign=SIGN)
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "precondition", r.text
|
||||||
|
finish_stage_by_sql(settings, pkt["id"])
|
||||||
|
r = step(client, pkt, "submit")
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "signature_required", r.text
|
||||||
|
pkt = ok_step(client, pkt, "submit", sign=SIGN)
|
||||||
|
assert pkt["status"] == "review" and pkt["stage"]["clock"]["running_since"] is None
|
||||||
|
|
||||||
|
as_user(client, "sup.wes")
|
||||||
|
r = step(client, pkt, "send_to_qc", book=True, spool=True, sign=SIGN)
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "precondition", r.text
|
||||||
|
pkt = ok_step(client, pkt, "send_to_qc", book=True, spool=True, location="Bay 3 rack B", sign=SIGN)
|
||||||
|
assert pkt["status"] == "qc" and pkt["qc"]["location"] == "Bay 3 rack B" and pkt["stage"] is not None
|
||||||
|
|
||||||
|
as_user(client, "qc.quinn")
|
||||||
|
r = step(client, pkt, "qc_accept", routed_to="databook", sign=SIGN)
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "precondition", r.text
|
||||||
|
assert "Q.C. still needs" in r.json()["detail"]
|
||||||
|
finish_qc_by_sql(settings, pkt["id"])
|
||||||
|
pkt = ok_step(client, pkt, "qc_accept", routed_to="databook", sign=SIGN)
|
||||||
|
assert pkt["status"] == "complete" and pkt["routed_to"] == "databook" and pkt["qc"] is None
|
||||||
|
assert pkt["stage"] is None and len(pkt["stages"]) == 1 and pkt["qc_history"][0]["result"] == "accepted"
|
||||||
|
|
||||||
|
as_user(client, "clerk.clara")
|
||||||
|
r = step(client, pkt, "file", sign=SIGN)
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "precondition"
|
||||||
|
assert r.json()["detail"] == "Close the job in VISUAL first."
|
||||||
|
r = client.post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True})
|
||||||
|
assert r.status_code == 200 and r.json()["vclosed"]["by"]["display_name"] == "Clara Clerk", r.text
|
||||||
|
pkt = ok_step(client, r.json(), "file", sign=SIGN)
|
||||||
|
assert pkt["status"] == "filed" and pkt["seal"]["broken_at"] is None and len(pkt["seal"]["key_id"]) == 8
|
||||||
|
return pkt
|
||||||
@@ -12,6 +12,7 @@ import pytest
|
|||||||
from packetstation.settings import Settings
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
TEST_SESSION_KEY = "test-session-key-0123456789abcdef0123456789"
|
TEST_SESSION_KEY = "test-session-key-0123456789abcdef0123456789"
|
||||||
|
TEST_SEAL_KEY = "test-seal-key-fedcba9876543210fedcba9876543210"
|
||||||
ORIGIN_HEADERS = {"Origin": "http://testserver", "X-Requested-With": "PacketStation"}
|
ORIGIN_HEADERS = {"Origin": "http://testserver", "X-Requested-With": "PacketStation"}
|
||||||
|
|
||||||
|
|
||||||
@@ -23,6 +24,12 @@ def make_settings(tmp: Path, **over: Any) -> Settings:
|
|||||||
key_file.parent.mkdir(parents=True, exist_ok=True)
|
key_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
key_file.write_text(TEST_SESSION_KEY + "\n")
|
key_file.write_text(TEST_SESSION_KEY + "\n")
|
||||||
over["session_key_file"] = key_file
|
over["session_key_file"] = key_file
|
||||||
|
if "seal_key_file" not in over:
|
||||||
|
seal_file = tmp / "seal_key"
|
||||||
|
if not seal_file.exists():
|
||||||
|
seal_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
seal_file.write_text(TEST_SEAL_KEY + "\n")
|
||||||
|
over["seal_key_file"] = seal_file
|
||||||
values: dict[str, Any] = {
|
values: dict[str, Any] = {
|
||||||
"_env_file": None,
|
"_env_file": None,
|
||||||
"stack": "ci",
|
"stack": "ci",
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ def test_placeholder_when_static_missing(client: TestClient) -> None:
|
|||||||
|
|
||||||
def test_api_and_blob_never_fall_back(client: TestClient) -> None:
|
def test_api_and_blob_never_fall_back(client: TestClient) -> None:
|
||||||
assert client.get("/api/nope").status_code == 404
|
assert client.get("/api/nope").status_code == 404
|
||||||
assert client.get("/_blob/abc").status_code == 404
|
# /_blob needs a session first (phase 2), so an anonymous call is refused, never the SPA page
|
||||||
|
assert client.get("/_blob/abc").status_code in (401, 404)
|
||||||
|
|
||||||
|
|
||||||
def test_static_and_spa_fallback(tmp_path: Path, dev_visual: None) -> None:
|
def test_static_and_spa_fallback(tmp_path: Path, dev_visual: None) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,658 @@
|
|||||||
|
"""Packet child rows: the BOM and shorts, welds, heat tags, the heat table, the stage, the warehouse board, Q.C.
|
||||||
|
and change orders. Every write goes through the pipeline; these tests check the policy, the row and state checks,
|
||||||
|
and what each write leaves behind."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
from fakedir import FakeDirectory
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pkthelpers import (
|
||||||
|
SIGN,
|
||||||
|
as_user,
|
||||||
|
finish_stage_by_sql,
|
||||||
|
get,
|
||||||
|
make_draft,
|
||||||
|
make_unit,
|
||||||
|
ok_step,
|
||||||
|
phase2_client,
|
||||||
|
ready_draft,
|
||||||
|
seed_users,
|
||||||
|
station_id,
|
||||||
|
step,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
from pshelpers import app_conn, needs_pg
|
||||||
|
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
BOM = [
|
||||||
|
{"item": 1, "qty": 4, "part_id": "PIPE-4", "description": "4in pipe SCH40"},
|
||||||
|
{"item": 2, "qty": 2, "part_id": "FLG-4", "description": "4in flange"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pg_client2(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
|
||||||
|
with phase2_client(pg_settings, fake_dir) as client:
|
||||||
|
seed_users(client)
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
conn.execute("UPDATE users SET welder_stamp = 'A7' WHERE sam_account = 'weld.andy'")
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def unit(pg_settings: Settings) -> tuple[int, int]:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
return make_unit(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _one(settings: Settings, sql: str, params: Any = ()) -> Any:
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
return conn.execute(sql, params).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _line(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)}
|
||||||
|
|
||||||
|
|
||||||
|
def warehouse_packet(client: TestClient, unit_id: int) -> dict[str, Any]:
|
||||||
|
as_user(client, "clerk.clara")
|
||||||
|
pkt = ready_draft(client, unit_id, bom=BOM)
|
||||||
|
return ok_step(client, pkt, "send_to_warehouse")
|
||||||
|
|
||||||
|
|
||||||
|
def pull_all(client: TestClient, pkt: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
for b in pkt["bom"]:
|
||||||
|
if not b["pulled"] and b["short"] is None:
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/packets/{pkt['id']}/bom/{b['item']}", json={"pulled": True}, headers=_im(b["row_version"])
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
pkt = r.json()
|
||||||
|
return pkt
|
||||||
|
|
||||||
|
|
||||||
|
def shop_packet(client: TestClient, settings: Settings, unit_id: int) -> dict[str, Any]:
|
||||||
|
"""A packet in progress at Stall 1 with weld.andy, every BOM line pulled."""
|
||||||
|
pkt = warehouse_packet(client, unit_id)
|
||||||
|
as_user(client, "wh.wade")
|
||||||
|
pkt = pull_all(client, pkt)
|
||||||
|
pkt = ok_step(client, pkt, "wh_release")
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
stall = station_id(conn)
|
||||||
|
andy = user_id(conn, "weld.andy")
|
||||||
|
as_user(client, "sup.wes")
|
||||||
|
pkt = ok_step(client, pkt, "assign", station_id=stall, welder_id=andy)
|
||||||
|
as_user(client, "weld.andy")
|
||||||
|
pkt = ok_step(client, pkt, "start")
|
||||||
|
assert pkt["status"] == "in_progress"
|
||||||
|
return pkt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- BOM
|
||||||
|
|
||||||
|
|
||||||
|
def test_bom_full_replace_on_a_draft(pg_client2: TestClient, unit: tuple[int, int]) -> None:
|
||||||
|
c = pg_client2
|
||||||
|
as_user(c, "clerk.clara")
|
||||||
|
pkt = ready_draft(c, unit[1])
|
||||||
|
url = f"/api/packets/{pkt['id']}/bom"
|
||||||
|
body = {"bom": [{**BOM[0], "cl_item": "A", "material": " A106-B ", "length": "20ft"}, BOM[1]]}
|
||||||
|
assert c.put(url, json=body).status_code == 428
|
||||||
|
r = c.put(url, json=body, headers=_im(pkt["version"]))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
out = r.json()
|
||||||
|
assert [b["item"] for b in out["bom"]] == [1, 2] and out["version"] == pkt["version"] + 1
|
||||||
|
assert _line(out, 1)["material"] == "A106-B" and _line(out, 1)["src"] == "hand"
|
||||||
|
stale = c.put(url, json={"bom": [BOM[0]]}, headers=_im(pkt["version"]))
|
||||||
|
assert stale.status_code == 409 and stale.json()["code"] == "version_conflict", stale.text
|
||||||
|
dup = c.put(url, json={"bom": [BOM[0], BOM[0]]}, headers=_im(out["version"]))
|
||||||
|
assert dup.status_code == 422 and dup.json()["code"] == "duplicate_item"
|
||||||
|
zero = c.put(url, json={"bom": [{"item": 0, "qty": 1}]}, headers=_im(out["version"]))
|
||||||
|
assert zero.status_code == 422
|
||||||
|
r = c.put(url, json={"bom": [BOM[1]]}, headers=_im(out["version"]))
|
||||||
|
assert r.status_code == 200 and [b["item"] for b in r.json()["bom"]] == [2]
|
||||||
|
log = c.get(f"/api/packets/{pkt['id']}/log").json()
|
||||||
|
assert log[0]["action"] == "BOM replaced" and log[0]["note"] == "1 line"
|
||||||
|
|
||||||
|
wh = ok_step(c, r.json(), "send_to_warehouse")
|
||||||
|
r = c.put(url, json={"bom": BOM}, headers=_im(wh["version"]))
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text
|
||||||
|
as_user(c, "wh.wade")
|
||||||
|
r = c.put(url, json={"bom": BOM}, headers=_im(wh["version"]))
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "wrong_status", r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_bom_line_patch_in_the_warehouse(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
c = pg_client2
|
||||||
|
pkt = warehouse_packet(c, unit[1])
|
||||||
|
assert pkt["wh"]["stage"] == "incoming"
|
||||||
|
url = f"/api/packets/{pkt['id']}/bom/1"
|
||||||
|
r = c.patch(url, json={"heat": "H123"}, headers=_im(1))
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden"
|
||||||
|
assert "It's with Warehouse right now." in r.json()["detail"]
|
||||||
|
|
||||||
|
as_user(c, "wh.wade")
|
||||||
|
r = c.patch(url, json={"heat": "H123", "loc": "Rack 4"}, headers=_im(1))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
line = _line(r.json(), 1)
|
||||||
|
assert line["heat"] == "H123" and line["loc"] == "Rack 4" and line["row_version"] == 2
|
||||||
|
assert r.json()["wh"]["stage"] == "incoming"
|
||||||
|
stale = c.patch(url, json={"heat": "H999"}, headers=_im(1))
|
||||||
|
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
|
||||||
|
assert stale.json()["current"]["row_version"] == 2 and stale.json()["current"]["heat"] == "H123"
|
||||||
|
bad = c.patch(url, json={"heat": "@@ bad"}, headers=_im(2))
|
||||||
|
assert bad.status_code == 422 and bad.json()["code"] == "invalid_heat"
|
||||||
|
nomtr = c.patch(url, json={"mtr_asset_id": "6f1c1d1e-0000-4000-8000-000000000000"}, headers=_im(2))
|
||||||
|
assert nomtr.status_code == 422 and nomtr.json()["code"] == "invalid_asset"
|
||||||
|
|
||||||
|
r = c.patch(url, json={"pulled": True}, headers=_im(2))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
out = r.json()
|
||||||
|
line = _line(out, 1)
|
||||||
|
assert line["pulled"] is True and float(line["issued"]) == 4 and line["row_version"] == 3
|
||||||
|
assert out["wh"]["stage"] == "pulling" and out["wh"]["auto"] is True and out["wh"]["note"] == "started pulling"
|
||||||
|
row = _one(
|
||||||
|
pg_settings, "SELECT pulled_at, pulled_by FROM bom_lines WHERE packet_id = %s AND item = 1", (pkt["id"],)
|
||||||
|
)
|
||||||
|
assert row[0] is not None and row[1] is not None
|
||||||
|
r = c.patch(f"/api/packets/{pkt['id']}/bom/2", json={"pulled": True}, headers=_im(1))
|
||||||
|
assert r.json()["wh"]["stage"] == "pulling"
|
||||||
|
n = _one(pg_settings, "SELECT count(*) FROM packet_wh WHERE packet_id = %s", (pkt["id"],))[0]
|
||||||
|
assert n == 2
|
||||||
|
|
||||||
|
r = c.patch(url, json={"na": True}, headers=_im(3))
|
||||||
|
assert _line(r.json(), 1)["na"] is True and _line(r.json(), 1)["heat"] is None
|
||||||
|
r = c.patch(url, json={"pulled": False}, headers=_im(4))
|
||||||
|
assert _line(r.json(), 1)["pulled"] is False
|
||||||
|
row = _one(pg_settings, "SELECT pulled_at FROM bom_lines WHERE packet_id = %s AND item = 1", (pkt["id"],))
|
||||||
|
assert row[0] is None
|
||||||
|
same = c.patch(url, json={"pulled": False}, headers=_im(5))
|
||||||
|
assert same.status_code == 200 and same.json()["version"] == r.json()["version"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wh_hold_and_first_pull_resumes(pg_client2: TestClient, unit: tuple[int, int]) -> None:
|
||||||
|
c = pg_client2
|
||||||
|
pkt = warehouse_packet(c, unit[1])
|
||||||
|
as_user(c, "wh.wade")
|
||||||
|
r = c.post(
|
||||||
|
f"/api/packets/{pkt['id']}/wh",
|
||||||
|
json={"stage": "hold", "reason": "Waiting on purchasing / PO", "expect_stage": "incoming"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["wh"]["stage"] == "hold", r.text
|
||||||
|
r = c.patch(f"/api/packets/{pkt['id']}/bom/1", json={"pulled": True}, headers=_im(1))
|
||||||
|
assert r.json()["wh"]["stage"] == "pulling" and r.json()["wh"]["note"] == "pulling resumed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shorts_and_receipts(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
c = pg_client2
|
||||||
|
pkt = warehouse_packet(c, unit[1])
|
||||||
|
pid = pkt["id"]
|
||||||
|
as_user(c, "wh.wade")
|
||||||
|
url = f"/api/packets/{pid}/bom/1/short"
|
||||||
|
body = {"qty": 3, "reason": "Not in stock", "ref": "PO-991", "eta": "2026-11-02", "expect_open": False}
|
||||||
|
r = c.post(url, json=body)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
out = r.json()
|
||||||
|
line = _line(out, 1)
|
||||||
|
assert float(line["short"]["qty"]) == 3 and float(line["short"]["qty_open"]) == 3
|
||||||
|
assert line["short"]["ref"] == "PO-991"
|
||||||
|
assert line["pulled"] is True and float(line["issued"]) == 1 and out["open_shorts"] == 1
|
||||||
|
assert out["wh"]["stage"] == "pulling" and out["wh"]["auto"] is True
|
||||||
|
again = c.post(url, json=body)
|
||||||
|
assert again.status_code == 409 and again.json()["code"] == "short_conflict"
|
||||||
|
assert c.post(url, json={**body, "qty": 9, "expect_open": True}).status_code == 422
|
||||||
|
r = c.post(url, json={**body, "qty": 2, "expect_open": True})
|
||||||
|
assert r.status_code == 200 and float(_line(r.json(), 1)["short"]["qty_open"]) == 2
|
||||||
|
assert float(_line(r.json(), 1)["issued"]) == 2
|
||||||
|
rv = _line(r.json(), 1)["row_version"]
|
||||||
|
blocked = c.patch(f"/api/packets/{pid}/bom/1", json={"heat": "H1"}, headers=_im(rv))
|
||||||
|
assert blocked.status_code == 409 and blocked.json()["code"] == "short_open"
|
||||||
|
|
||||||
|
# A short on item 2, then cleared: the whole quantity counts as issued.
|
||||||
|
r = c.post(f"/api/packets/{pid}/bom/2/short", json={"qty": 1, "reason": "Damaged", "expect_open": False})
|
||||||
|
assert _line(r.json(), 2)["short"] is not None
|
||||||
|
r = c.request("DELETE", f"/api/packets/{pid}/bom/2/short", json={"expect_open": True})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert _line(r.json(), 2)["short"] is None and float(_line(r.json(), 2)["issued"]) == 2
|
||||||
|
assert c.request("DELETE", f"/api/packets/{pid}/bom/2/short", json={"expect_open": True}).status_code == 409
|
||||||
|
|
||||||
|
pkt = ok_step(c, r.json(), "wh_release")
|
||||||
|
assert pkt["status"] == "ready" and pkt["open_shorts"] == 1
|
||||||
|
rec = f"/api/packets/{pid}/bom/1/short/receipts"
|
||||||
|
r = c.post(rec, json={"qty": 1, "heat": "H100", "note": "First piece"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
line = _line(r.json(), 1)
|
||||||
|
assert float(line["short"]["qty_open"]) == 1 and len(line["short"]["receipts"]) == 1
|
||||||
|
assert line["short"]["receipts"][0]["by"]["display_name"] == "Wade Warehouse"
|
||||||
|
assert line["heat"] == "H100" and float(line["issued"]) == 3
|
||||||
|
assert c.post(f"/api/packets/{pid}/bom/2/short/receipts", json={"qty": 1, "heat": "H1"}).status_code == 404
|
||||||
|
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
stall = station_id(conn)
|
||||||
|
andy = user_id(conn, "weld.andy")
|
||||||
|
as_user(c, "sup.wes")
|
||||||
|
pkt = ok_step(c, r.json(), "assign", station_id=stall, welder_id=andy)
|
||||||
|
as_user(c, "weld.andy")
|
||||||
|
pkt = ok_step(c, pkt, "start")
|
||||||
|
no = c.post(rec, json={"qty": 1, "heat": "H200"})
|
||||||
|
assert no.status_code == 403 and no.json()["code"] == "forbidden"
|
||||||
|
as_user(c, "wh.wade")
|
||||||
|
assert c.post(rec, json={"qty": 2, "heat": "H200"}).status_code == 422
|
||||||
|
nh = c.post(rec, json={"qty": 1})
|
||||||
|
assert nh.status_code == 422 and nh.json()["code"] == "heat_required"
|
||||||
|
r = c.post(rec, json={"qty": 1, "heat": "H200"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
line = _line(r.json(), 1)
|
||||||
|
assert line["short"] is None and r.json()["open_shorts"] == 0 and r.json()["status"] == "in_progress"
|
||||||
|
assert line["heat"] == "H100 / H200" and float(line["issued"]) == 4
|
||||||
|
closed = _one(
|
||||||
|
pg_settings,
|
||||||
|
"SELECT s.closed_at, s.qty_open FROM bom_shorts s JOIN bom_lines b ON b.id = s.bom_line_id"
|
||||||
|
" WHERE s.packet_id = %s AND b.item = 1",
|
||||||
|
(pid,),
|
||||||
|
)
|
||||||
|
assert closed[0] is not None and closed[1] == 0
|
||||||
|
with app_conn(pg_settings) as conn, pytest.raises(psycopg.Error):
|
||||||
|
conn.execute("UPDATE short_receipts SET qty = 9 WHERE packet_id = %s", (pid,))
|
||||||
|
with app_conn(pg_settings) as conn, pytest.raises(psycopg.Error):
|
||||||
|
conn.execute("DELETE FROM short_receipts WHERE packet_id = %s", (pid,))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- welds, heat tags, heat table
|
||||||
|
|
||||||
|
|
||||||
|
def test_welds(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
c = pg_client2
|
||||||
|
pkt = shop_packet(c, pg_settings, unit[1])
|
||||||
|
pid = pkt["id"]
|
||||||
|
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"})
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
welds = r2.json()["welds"]
|
||||||
|
assert [w["weld_no"] for w in welds] == [1, 2]
|
||||||
|
assert welds[0]["items"] == [1, 2] and welds[0]["stamp"] == "A7"
|
||||||
|
assert welds[0]["welder"]["display_name"] == "Andy Welder"
|
||||||
|
row = _one(pg_settings, "SELECT dept_code, stage_id FROM welds WHERE packet_id = %s AND weld_no = 1", (pid,))
|
||||||
|
assert row[0] == "03" and row[1] == pkt["stage"]["id"]
|
||||||
|
bad = c.post(f"/api/packets/{pid}/welds", json={**weld, "items": [9]})
|
||||||
|
assert bad.status_code == 422 and bad.json()["code"] == "invalid_items"
|
||||||
|
assert c.post(f"/api/packets/{pid}/welds", json={**weld, "items": []}).status_code == 422
|
||||||
|
assert c.post(f"/api/packets/{pid}/welds", json={**weld, "stamp": "no stamp!"}).status_code == 422
|
||||||
|
|
||||||
|
r = c.patch(f"/api/packets/{pid}/welds/1", json={"size": "3/8"}, headers=_im(1))
|
||||||
|
assert r.status_code == 200 and r.json()["welds"][0]["size"] == "3/8" and r.json()["welds"][0]["row_version"] == 2
|
||||||
|
stale = c.patch(f"/api/packets/{pid}/welds/1", json={"size": "1/2"}, headers=_im(1))
|
||||||
|
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
|
||||||
|
r = c.request("DELETE", f"/api/packets/{pid}/welds/2", json={"reason": "Marked twice"})
|
||||||
|
assert r.status_code == 200 and [w["weld_no"] for w in r.json()["welds"]] == [1]
|
||||||
|
row = _one(pg_settings, "SELECT removed_reason FROM welds WHERE packet_id = %s AND weld_no = 2", (pid,))
|
||||||
|
assert row[0] == "Marked twice"
|
||||||
|
assert c.patch(f"/api/packets/{pid}/welds/2", json={"size": "1"}, headers=_im(1)).status_code == 404
|
||||||
|
r = c.post(f"/api/packets/{pid}/welds", json=weld)
|
||||||
|
assert r.json()["welds"][-1]["weld_no"] == 3
|
||||||
|
|
||||||
|
# Another worker of the department, limited to another stall, does not hold this packet.
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO user_station_limits (user_id, station_id) VALUES (%s, %s)",
|
||||||
|
(user_id(conn, "dup.dave"), station_id(conn, "03", "Stall 2")),
|
||||||
|
)
|
||||||
|
as_user(c, "dup.dave")
|
||||||
|
r = c.post(f"/api/packets/{pid}/welds", json=weld)
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text
|
||||||
|
as_user(c, "clerk.clara")
|
||||||
|
assert c.post(f"/api/packets/{pid}/welds", json=weld).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_heat_tags_and_heat_table(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
c = pg_client2
|
||||||
|
pkt = warehouse_packet(c, unit[1])
|
||||||
|
pid = pkt["id"]
|
||||||
|
assert c.post(f"/api/packets/{pid}/heat-tags", json={"page": 1, "item": 1}).status_code == 403
|
||||||
|
as_user(c, "wh.wade")
|
||||||
|
for item in (1, 2):
|
||||||
|
r = c.post(f"/api/packets/{pid}/heat-tags", json={"page": 1, "item": item, "fx": 0.1, "fy": 0.2})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
tags = r.json()["heat_tags"]
|
||||||
|
assert [(t["tag_no"], t["item"]) for t in tags] == [(1, 1), (2, 2)]
|
||||||
|
assert c.post(f"/api/packets/{pid}/heat-tags", json={"page": 1, "item": 7}).status_code == 422
|
||||||
|
r = c.patch(f"/api/packets/{pid}/heat-tags/2", json={"item": 1}, headers=_im(1))
|
||||||
|
assert r.status_code == 200 and r.json()["heat_tags"][1]["item"] == 1
|
||||||
|
stale = c.patch(f"/api/packets/{pid}/heat-tags/2", json={"item": 2}, headers=_im(1))
|
||||||
|
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
|
||||||
|
r = c.request("DELETE", f"/api/packets/{pid}/heat-tags/1", json={})
|
||||||
|
assert r.status_code == 200 and [t["tag_no"] for t in r.json()["heat_tags"]] == [2]
|
||||||
|
|
||||||
|
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}]
|
||||||
|
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})
|
||||||
|
assert r3.json()["heat_table"][0]["x"] == 0.6 and r3.json()["version"] == r1.json()["version"] + 1
|
||||||
|
n = _one(pg_settings, "SELECT count(*) FROM heat_table_placements WHERE packet_id = %s", (pid,))[0]
|
||||||
|
assert n == 1
|
||||||
|
assert c.put(url, json={"x": 0.6, "y": 0.1, "s": 0}).status_code == 422
|
||||||
|
r = c.request("DELETE", url, json={})
|
||||||
|
assert r.status_code == 200 and r.json()["heat_table"] == []
|
||||||
|
assert c.request("DELETE", url, json={}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the stage
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_fields_and_checklist(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
c = pg_client2
|
||||||
|
pkt = shop_packet(c, pg_settings, unit[1])
|
||||||
|
pid = pkt["id"]
|
||||||
|
rv = pkt["stage"]["row_version"]
|
||||||
|
r = c.patch(f"/api/packets/{pid}/stage", json={"filler": "ER70S-6", "preheat": "50F"}, headers=_im(rv))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
st = r.json()["stage"]
|
||||||
|
assert st["filler"] == "ER70S-6" and st["preheat"] == "50F" and st["row_version"] == rv + 1
|
||||||
|
stale = c.patch(f"/api/packets/{pid}/stage", json={"filler": "E7018"}, headers=_im(rv))
|
||||||
|
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
|
||||||
|
|
||||||
|
andy = user_id_of(pg_settings, "weld.andy")
|
||||||
|
r = c.put(f"/api/packets/{pid}/stage/checklist/dwg", json={"done": True})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
entry = r.json()["stage"]["checklist"]["dwg"]
|
||||||
|
assert entry["done"] is True and entry["by"] == andy and entry["stamp"] == "A7" and entry["at"]
|
||||||
|
assert r.json()["stage"]["row_version"] == st["row_version"]
|
||||||
|
bad = c.put(f"/api/packets/{pid}/stage/checklist/nope", json={"done": True})
|
||||||
|
assert bad.status_code == 422 and bad.json()["code"] == "invalid_key"
|
||||||
|
as_user(c, "lead.lou")
|
||||||
|
r = c.put(f"/api/packets/{pid}/stage/checklist/heat", json={"done": True})
|
||||||
|
assert r.status_code == 200 and r.json()["stage"]["checklist"]["heat"]["by"] == user_id_of(pg_settings, "lead.lou")
|
||||||
|
r = c.put(f"/api/packets/{pid}/stage/checklist/dwg", json={"done": False})
|
||||||
|
assert r.json()["stage"]["checklist"]["dwg"] == {"done": False}
|
||||||
|
as_user(c, "wh.wade")
|
||||||
|
assert c.put(f"/api/packets/{pid}/stage/checklist/dwg", json={"done": True}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def user_id_of(settings: Settings, sam: str) -> str:
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
return user_id(conn, sam)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_clock(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
c = pg_client2
|
||||||
|
pkt = shop_packet(c, pg_settings, unit[1])
|
||||||
|
url = f"/api/packets/{pkt['id']}/stage/clock"
|
||||||
|
assert pkt["stage"]["clock"]["running_since"] is not None
|
||||||
|
r = c.post(url, json={"op": "start"})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "clock_state"
|
||||||
|
r = c.post(url, json={"op": "pause"})
|
||||||
|
assert r.status_code == 200 and r.json()["stage"]["clock"]["running_since"] is None, r.text
|
||||||
|
assert r.json()["stage"]["clock"]["elapsed_ms"] >= 0
|
||||||
|
assert c.post(url, json={"op": "pause"}).json()["code"] == "clock_state"
|
||||||
|
r = c.post(url, json={"op": "resume"})
|
||||||
|
assert r.status_code == 200 and r.json()["stage"]["clock"]["running_since"] is not None
|
||||||
|
assert c.post(url, json={"op": "resume"}).status_code == 409
|
||||||
|
assert c.post(url, json={"op": "stop"}).status_code == 422
|
||||||
|
as_user(c, "clerk.clara")
|
||||||
|
assert c.post(url, json={"op": "pause"}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_stage(pg_client2: TestClient, unit: tuple[int, int]) -> None:
|
||||||
|
c = pg_client2
|
||||||
|
as_user(c, "clerk.clara")
|
||||||
|
pkt = make_draft(c, unit[1])
|
||||||
|
r = c.post(f"/api/packets/{pkt['id']}/stage/clock", json={"op": "start"})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "wrong_status"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the warehouse board
|
||||||
|
|
||||||
|
|
||||||
|
def test_wh_stage_board(pg_client2: TestClient, unit: tuple[int, int]) -> None:
|
||||||
|
c = pg_client2
|
||||||
|
pkt = warehouse_packet(c, unit[1])
|
||||||
|
url = f"/api/packets/{pkt['id']}/wh"
|
||||||
|
r = c.post(url, json={"stage": "pulling", "expect_stage": "incoming"})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden"
|
||||||
|
as_user(c, "wh.wade")
|
||||||
|
r = c.post(url, json={"stage": "pulling", "expect_stage": "incoming"})
|
||||||
|
assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulling" and r.json()["wh"]["auto"] is False
|
||||||
|
assert r.json()["wh"]["by"]["display_name"] == "Wade Warehouse"
|
||||||
|
stale = c.post(url, json={"stage": "pulling", "expect_stage": "incoming"})
|
||||||
|
assert stale.status_code == 409 and stale.json()["code"] == "stale_step"
|
||||||
|
assert stale.json()["current"]["stage"] == "pulling"
|
||||||
|
r = c.post(url, json={"stage": "hold", "expect_stage": "pulling"})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "reason_required"
|
||||||
|
r = c.post(url, json={"stage": "hold", "reason": "Other", "expect_stage": "pulling"})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "reason_required"
|
||||||
|
hold = {"stage": "hold", "reason": "Drawing / BOM problem", "note": "Asked Leo", "expect_stage": "pulling"}
|
||||||
|
r = c.post(url, json=hold)
|
||||||
|
assert r.status_code == 200 and r.json()["wh"]["reason"] == "Drawing / BOM problem"
|
||||||
|
assert r.json()["wh"]["note"] == "Asked Leo"
|
||||||
|
bad = c.post(url, json={"stage": "pulled", "expect_stage": "hold"})
|
||||||
|
assert bad.status_code == 409 and bad.json()["code"] == "wh_transition"
|
||||||
|
r = c.post(url, json={"stage": "pulling", "expect_stage": "hold"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = c.post(url, json={"stage": "pulled", "expect_stage": "pulling"})
|
||||||
|
assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulled"
|
||||||
|
log = c.get(f"/api/packets/{pkt['id']}/log").json()
|
||||||
|
assert log[0]["action"] == "Warehouse: Marked pulled, ready for production"
|
||||||
|
assert log[1]["action"] == "Warehouse: Pulling resumed"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Q.C.
|
||||||
|
|
||||||
|
|
||||||
|
def test_qc_patch(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
c = pg_client2
|
||||||
|
pkt = shop_packet(c, pg_settings, unit[1])
|
||||||
|
pid = pkt["id"]
|
||||||
|
r = c.patch(f"/api/packets/{pid}/qc", json={"inspector": "Quinn"}, headers=_im(1))
|
||||||
|
assert r.status_code == 403
|
||||||
|
finish_stage_by_sql(pg_settings, pid)
|
||||||
|
pkt = ok_step(c, get(c, pid), "submit", sign=SIGN)
|
||||||
|
as_user(c, "sup.wes")
|
||||||
|
pkt = ok_step(c, pkt, "send_to_qc", book=True, spool=True, location="Bay 3", sign=SIGN)
|
||||||
|
rv = pkt["qc"]["row_version"]
|
||||||
|
as_user(c, "qc.quinn")
|
||||||
|
url = f"/api/packets/{pid}/qc"
|
||||||
|
body = {"checks": {"db_heats": True}, "weld_exam": {"1": {"result": "accept"}}, "inspector": "Quinn QC"}
|
||||||
|
r = c.patch(url, json={**body, "stamp": "Q5"}, headers=_im(rv))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
qc = r.json()["qc"]
|
||||||
|
assert qc["checks"]["db_heats"]["done"] is True and qc["checks"]["db_heats"]["stamp"] == "Q5"
|
||||||
|
assert qc["checks"]["db_heats"]["by"] == user_id_of(pg_settings, "qc.quinn")
|
||||||
|
assert qc["weld_exam"]["1"]["result"] == "accept" and qc["inspector"] == "Quinn QC"
|
||||||
|
assert qc["row_version"] == rv + 1
|
||||||
|
r = c.patch(
|
||||||
|
url, json={"checks": {"db_mtr": True}, "weld_exam": {"1": {"note": "Good tie-in"}}}, headers=_im(rv + 1)
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
qc = r.json()["qc"]
|
||||||
|
assert qc["checks"]["db_heats"]["done"] and qc["checks"]["db_mtr"]["done"]
|
||||||
|
assert qc["weld_exam"]["1"]["result"] == "accept"
|
||||||
|
assert qc["weld_exam"]["1"]["note"] == "Good tie-in"
|
||||||
|
stale = c.patch(url, json={"notes": "x"}, headers=_im(rv))
|
||||||
|
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
|
||||||
|
assert c.patch(url, json={"checks": {"nope": True}}, headers=_im(rv + 2)).status_code == 422
|
||||||
|
assert c.patch(url, json={"weld_exam": {"1": {"result": "maybe"}}}, headers=_im(rv + 2)).status_code == 422
|
||||||
|
assert c.patch(url, json={"weld_exam": {"8": {"result": "accept"}}}, headers=_im(rv + 2)).status_code == 422
|
||||||
|
r = c.patch(url, json={"checks": {"db_mtr": False}}, headers=_im(rv + 2))
|
||||||
|
assert r.json()["qc"]["checks"]["db_mtr"] == {"done": False}
|
||||||
|
as_user(c, "wh.wade")
|
||||||
|
assert c.patch(url, json={"notes": "x"}, headers=_im(rv + 3)).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- change orders
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_orders(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
c = pg_client2
|
||||||
|
pkt = shop_packet(c, pg_settings, unit[1])
|
||||||
|
pid = pkt["id"]
|
||||||
|
finish_stage_by_sql(pg_settings, pid)
|
||||||
|
as_user(c, "clerk.clara")
|
||||||
|
body = {"reason": "Rev C moves nozzle N2", "ecn": "ECN-55", "items": [{"old_page": 1, "new_page": 1}]}
|
||||||
|
r = c.post(f"/api/packets/{pid}/change-orders", json=body)
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "signature_required"
|
||||||
|
r = c.post(f"/api/packets/{pid}/change-orders", json={**body, "sign": SIGN})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
co = r.json()["change_orders"][0]
|
||||||
|
assert co["co_no"] == 1 and co["origin"] == "manual" and co["review"] == "approved"
|
||||||
|
assert co["required"] == ["03", "WH"] and co["issued_by"]["display_name"] == "Clara Clerk"
|
||||||
|
assert co["items"][0]["old_page"] == 1 and r.json()["open_cos"] == [1]
|
||||||
|
bad = c.post(f"/api/packets/{pid}/change-orders", json={**body, "required_depts": ["CL"], "sign": SIGN})
|
||||||
|
assert bad.status_code == 422 and bad.json()["code"] == "invalid_dept"
|
||||||
|
listed = c.get(f"/api/packets/{pid}/change-orders")
|
||||||
|
assert listed.status_code == 200 and [x["co_no"] for x in listed.json()] == [1]
|
||||||
|
|
||||||
|
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"]
|
||||||
|
vurl = f"/api/packets/{pid}/change-orders/1/verify"
|
||||||
|
assert c.post(vurl, json={"dept": "03", "sign": SIGN}).status_code == 403
|
||||||
|
|
||||||
|
as_user(c, "sup.wes")
|
||||||
|
r = c.post(vurl, json={"dept": "03", "note": "Checked N2", "sign": SIGN})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["change_orders"][0]["verified"]["03"]["note"] == "Checked N2"
|
||||||
|
assert r.json()["open_cos"] == [1]
|
||||||
|
again = c.post(vurl, json={"dept": "03", "sign": SIGN})
|
||||||
|
assert again.status_code == 409 and again.json()["code"] == "already_verified"
|
||||||
|
assert c.post(vurl, json={"dept": "WH", "sign": SIGN}).status_code == 403
|
||||||
|
|
||||||
|
as_user(c, "wh.wade")
|
||||||
|
r = c.post(vurl, json={"dept": "WH", "sign": SIGN})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden"
|
||||||
|
|
||||||
|
as_user(c, "admin.ann")
|
||||||
|
r = c.post(vurl, json={"dept": "WH", "sign": SIGN})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "it_reason_required"
|
||||||
|
r = c.post(vurl, json={"dept": "QC", "sign": SIGN, "it_reason": "Warehouse lead is out today"})
|
||||||
|
assert r.status_code == 422 and r.json()["code"] == "invalid_dept"
|
||||||
|
r = c.post(vurl, json={"dept": "WH", "sign": SIGN, "it_reason": "Warehouse lead is out today"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["open_cos"] == []
|
||||||
|
row = _one(pg_settings, "SELECT it_reason FROM co_verifications WHERE dept_code = 'WH'")
|
||||||
|
assert row[0] == "Warehouse lead is out today"
|
||||||
|
|
||||||
|
as_user(c, "weld.andy")
|
||||||
|
r = step(c, get(c, pid), "submit", sign=SIGN)
|
||||||
|
assert r.status_code == 200 and r.json()["status"] == "review", r.text
|
||||||
|
|
||||||
|
# A pending change from VISUAL holds nothing until Clerical holds it.
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO change_orders (packet_id, co_no, origin, review, reason) VALUES (%s, 2, 'visual', 'pending',"
|
||||||
|
" 'Drawing rev changed in VISUAL')",
|
||||||
|
(pid,),
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE packets SET next_co_no = 3 WHERE id = %s", (pid,))
|
||||||
|
as_user(c, "clerk.clara")
|
||||||
|
assert get(c, pid)["open_cos"] == []
|
||||||
|
hurl = f"/api/packets/{pid}/change-orders/2/hold"
|
||||||
|
r = c.post(hurl, json={"hold": True})
|
||||||
|
assert r.status_code == 200 and r.json()["open_cos"] == [2], r.text
|
||||||
|
assert r.json()["change_orders"][1]["hold"] is True
|
||||||
|
as_user(c, "sup.wes")
|
||||||
|
r = step(c, get(c, pid), "send_to_qc", book=True, spool=True, location="Bay 3", sign=SIGN)
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "co_hold"
|
||||||
|
as_user(c, "clerk.clara")
|
||||||
|
r = c.post(hurl, json={"hold": False})
|
||||||
|
assert r.status_code == 200 and r.json()["open_cos"] == []
|
||||||
|
assert c.post(f"/api/packets/{pid}/change-orders/1/hold", json={"hold": True}).json()["code"] == "co_state"
|
||||||
|
rurl = f"/api/packets/{pid}/change-orders/2/reject"
|
||||||
|
r = c.post(rurl, json={"sign": SIGN})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "reason_required"
|
||||||
|
r = c.post(rurl, json={"reason": "Not a real change", "sign": SIGN})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
co2 = r.json()["change_orders"][1]
|
||||||
|
assert co2["review"] == "rejected" and co2["rejected_reason"] == "Not a real change"
|
||||||
|
r = c.post(f"/api/packets/{pid}/change-orders/2/approve", json={"sign": SIGN})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "co_state"
|
||||||
|
assert c.post(f"/api/packets/{pid}/change-orders/9/approve", json={"sign": SIGN}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_co_approve_pending(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
c = pg_client2
|
||||||
|
pkt = warehouse_packet(c, unit[1])
|
||||||
|
pid = pkt["id"]
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO change_orders (packet_id, co_no, origin, review) VALUES (%s, 1, 'visual', 'pending')", (pid,)
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE packets SET next_co_no = 2 WHERE id = %s", (pid,))
|
||||||
|
r = c.post(f"/api/packets/{pid}/change-orders/1/approve", json={"sign": SIGN})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
co = r.json()["change_orders"][0]
|
||||||
|
assert co["review"] == "approved" and co["approved_by"]["display_name"] == "Clara Clerk"
|
||||||
|
assert co["required"] == ["WH"] and r.json()["open_cos"] == [1]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- concurrency
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_welds_get_distinct_numbers(
|
||||||
|
pg_client2: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, unit: tuple[int, int]
|
||||||
|
) -> None:
|
||||||
|
pkt = shop_packet(pg_client2, pg_settings, unit[1])
|
||||||
|
with phase2_client(pg_settings, fake_dir) as other:
|
||||||
|
as_user(other, "weld.andy")
|
||||||
|
results: list[Any] = [None, None]
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
|
||||||
|
def run(i: int, client: TestClient) -> None:
|
||||||
|
barrier.wait()
|
||||||
|
results[i] = client.post(f"/api/packets/{pkt['id']}/welds", json={"items": [1], "type": "Fillet"})
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=run, args=(i, cl)) for i, cl in enumerate((pg_client2, other))]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(30)
|
||||||
|
assert [r.status_code for r in results] == [200, 200], [r.text for r in results]
|
||||||
|
nos = [r[0] for r in _all(pg_settings, "SELECT weld_no FROM welds WHERE packet_id = %s", (pkt["id"],))]
|
||||||
|
assert sorted(nos) == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_bom_patch_one_wins(
|
||||||
|
pg_client2: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, unit: tuple[int, int]
|
||||||
|
) -> None:
|
||||||
|
pkt = warehouse_packet(pg_client2, unit[1])
|
||||||
|
as_user(pg_client2, "wh.wade")
|
||||||
|
with phase2_client(pg_settings, fake_dir) as other:
|
||||||
|
as_user(other, "wh.wade")
|
||||||
|
results: list[Any] = [None, None]
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
|
||||||
|
def run(i: int, client: TestClient) -> None:
|
||||||
|
barrier.wait()
|
||||||
|
results[i] = client.patch(f"/api/packets/{pkt['id']}/bom/1", json={"heat": f"H{i}"}, headers=_im(1))
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=run, args=(i, cl)) for i, cl in enumerate((pg_client2, other))]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(30)
|
||||||
|
codes = sorted(r.status_code for r in results)
|
||||||
|
assert codes == [200, 409], [r.text for r in results]
|
||||||
|
loser = next(r for r in results if r.status_code == 409)
|
||||||
|
assert loser.json()["code"] == "row_conflict" and loser.json()["current"]["row_version"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def _all(settings: Settings, sql: str, params: Any = ()) -> list[Any]:
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
return conn.execute(sql, params).fetchall()
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
"""The packet API: hand drafts, header CAS, drawing pages, transitions, filing and the seal, the boards, assets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fakedir import FakeDirectory
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pkthelpers import (
|
||||||
|
SIGN,
|
||||||
|
as_user,
|
||||||
|
attach_drawing,
|
||||||
|
get,
|
||||||
|
happy_path,
|
||||||
|
make_draft,
|
||||||
|
make_unit,
|
||||||
|
ok_step,
|
||||||
|
phase2_client,
|
||||||
|
ready_draft,
|
||||||
|
seed_users,
|
||||||
|
set_header,
|
||||||
|
step,
|
||||||
|
tiny_pdf,
|
||||||
|
upload,
|
||||||
|
)
|
||||||
|
from pshelpers import app_conn, needs_pg
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from packetstation import events
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pg_client2(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
|
||||||
|
with phase2_client(pg_settings, fake_dir) as client:
|
||||||
|
seed_users(client)
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def unit(pg_settings: Settings) -> tuple[int, int]:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
return make_unit(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _one(settings: Settings, sql: str, params: Any = ()) -> Any:
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
return conn.execute(sql, params).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _all(settings: Settings, sql: str, params: Any = ()) -> list[Any]:
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
return conn.execute(sql, params).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- hand drafts
|
||||||
|
|
||||||
|
|
||||||
|
def test_hand_draft_by_clerical(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
pkt = make_draft(pg_client2, unit[1])
|
||||||
|
assert pkt["status"] == "draft" and pkt["source_kind"] == "hand" and pkt["source"] == {"kind": "hand"}
|
||||||
|
assert pkt["flags"] == ["hand"] and pkt["drawing_state"] == "none" and pkt["version"] == 1
|
||||||
|
assert pkt["flag"] == "Made by hand - check it and send it on"
|
||||||
|
assert pkt["holder"] == "Clerical" and pkt["dept"] == {"code": "03", "name": "Weld Parts", "weld": True}
|
||||||
|
wo = pkt["work_orders"][0]
|
||||||
|
assert (wo["wo_base"], wo["lot"], wo["split"], wo["sub"]) == ("L670001", "001", "0", "0")
|
||||||
|
assert pkt["unit"]["number"].endswith("001") and pkt["unit"]["project"]["customer"] == "Acme Refining"
|
||||||
|
assert [i["key"] for i in pkt["checklist_items"]][:2] == ["dwg", "heat"]
|
||||||
|
assert "D-1001 Rev B" in pkt["checklist_items"][0]["text"] and len(pkt["qc_items"]) == 10
|
||||||
|
assert "edit_header" in pkt["can"] and "send_to_warehouse" not in pkt["can"]
|
||||||
|
assert pkt["blocked"]["send_to_warehouse"] == [
|
||||||
|
"Say whether this packet needs a heat map.",
|
||||||
|
"Attach the drawing first.",
|
||||||
|
]
|
||||||
|
log = pg_client2.get(f"/api/packets/{pkt['id']}/log").json()
|
||||||
|
assert [r["action"] for r in log] == ["Packet created by hand"] and log[0]["by"]["display_name"] == "Clara Clerk"
|
||||||
|
row = _one(pg_settings, "SELECT action, detail FROM audit_events WHERE action = 'packet.created'")
|
||||||
|
assert row[1]["source"] == "hand" and row[1]["work_orders"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_hand_draft_rules(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
from pkthelpers import draft_body
|
||||||
|
|
||||||
|
as_user(pg_client2, "wh.wade")
|
||||||
|
r = pg_client2.post("/api/packets", json=draft_body(unit[1]))
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden"
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
r = pg_client2.post("/api/packets", json=draft_body(unit[1], dept_code="QC"))
|
||||||
|
assert r.status_code == 422 and r.json()["code"] == "invalid_dept"
|
||||||
|
r = pg_client2.post("/api/packets", json=draft_body(unit[1], work_orders=[{"wo_base": 670001}]))
|
||||||
|
assert r.status_code == 422 # work order keys are text, never numbers
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
conn.execute("UPDATE units SET status = 'closed' WHERE id = %s", (unit[1],))
|
||||||
|
r = pg_client2.post("/api/packets", json=draft_body(unit[1]))
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "unit_closed"
|
||||||
|
r = pg_client2.post("/api/packets", json=draft_body(unit[1]) | {"pad": "x" * 210_000})
|
||||||
|
assert r.status_code == 413 and r.json()["code"] == "too_large"
|
||||||
|
assert pg_client2.get("/api/packets/999999").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- header CAS
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_header_cas(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
pkt = make_draft(pg_client2, unit[1])
|
||||||
|
pid = pkt["id"]
|
||||||
|
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"title": "New title"}, "base": {"title": pkt["title"]}})
|
||||||
|
assert r.status_code == 200 and r.json()["title"] == "New title" and r.json()["version"] == 2
|
||||||
|
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"title": "Third"}, "base": {"title": "Spool 4 inlet"}})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "field_conflict"
|
||||||
|
assert r.json()["current"] == {"title": "New title"}
|
||||||
|
# heat map chips
|
||||||
|
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"heat_map": True}, "base": {"heat_map": None}})
|
||||||
|
assert r.status_code == 200 and r.json()["heat_map"] is True
|
||||||
|
assert pg_client2.get(f"/api/packets/{pid}/log").json()[0]["action"] == "Heat map required"
|
||||||
|
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"heat_map": False}, "base": {"heat_map": True}})
|
||||||
|
assert r.status_code == 200 and r.json()["heat_map"] is False
|
||||||
|
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"status": "filed"}})
|
||||||
|
assert r.status_code == 422
|
||||||
|
as_user(pg_client2, "wh.wade")
|
||||||
|
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"title": "Mine"}})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden"
|
||||||
|
assert r.json()["detail"] == (
|
||||||
|
"Your access doesn't allow changing the packet details on this packet. It's with Clerical right now."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- drawing pages and assets
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_drawing_needs_signature(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
pkt = make_draft(pg_client2, unit[1])
|
||||||
|
a = upload(pg_client2, tiny_pdf(2), name="D-1001 B.pdf")
|
||||||
|
assert a.status_code == 201, a.text
|
||||||
|
asset = a.json()
|
||||||
|
assert asset["mime"] == "application/pdf" and asset["pages"] == 2 and asset["url"] == f"/_blob/{asset['id']}"
|
||||||
|
url = f"/api/packets/{pkt['id']}/pages"
|
||||||
|
r = pg_client2.post(url, json={"asset_id": asset["id"], "type": "drawing"})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "signature_required"
|
||||||
|
assert r.json()["meaning"] == "Attach a drawing by hand"
|
||||||
|
r = pg_client2.post(url, json={"asset_id": asset["id"], "sign": {"factor": "password", "secret": "nope"}})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "wrong_secret" and r.json()["attempts_left"] == 2
|
||||||
|
fails = _one(pg_settings, "SELECT stepup_failures FROM sessions WHERE revoked_at IS NULL ORDER BY created_at DESC")
|
||||||
|
assert fails[0] == 1 # the wrong attempt was kept although the write was refused
|
||||||
|
r = pg_client2.post(url, json={"asset_id": asset["id"], "sign": SIGN})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
out = r.json()
|
||||||
|
assert [(p["n"], p["pn"], p["dwg"]) for p in out["pages"]] == [(1, 1, "D-1001"), (2, 2, "D-1001")]
|
||||||
|
assert out["drawing_state"] == "hand" and out["pages"][0]["url"] == f"/_blob/{asset['id']}"
|
||||||
|
log = pg_client2.get(f"/api/packets/{pkt['id']}/log").json()[0]
|
||||||
|
assert log["action"] == "Drawing attached by hand" and log["signed"] is True
|
||||||
|
r = pg_client2.post(url, json={"asset_id": asset["id"], "sign": SIGN})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "already_attached"
|
||||||
|
r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}/pages/2", json={"reason": "wrong sheet"})
|
||||||
|
assert r.status_code == 200 and r.json()["pages"] == [] and r.json()["drawing_state"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_assets_blob_and_limits(
|
||||||
|
pg_client2: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None
|
||||||
|
) -> None:
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
data = tiny_pdf(1)
|
||||||
|
a = upload(pg_client2, data, name="..\\evil/name.pdf").json()
|
||||||
|
assert a["original_name"] == "name.pdf" and a["size"] == len(data)
|
||||||
|
again = upload(pg_client2, data, name="other.pdf").json()
|
||||||
|
assert again["id"] == a["id"]
|
||||||
|
assert pg_client2.get(f"/api/assets/{a['id']}").json()["sha256"] == a["sha256"]
|
||||||
|
b = pg_client2.get(a["url"])
|
||||||
|
assert b.status_code == 200 and b.content == data
|
||||||
|
assert b.headers["x-content-type-options"] == "nosniff"
|
||||||
|
assert b.headers["content-disposition"] == 'inline; filename="name.pdf"'
|
||||||
|
assert b.headers["cache-control"] == "private, max-age=3600" and a["sha256"] in b.headers["etag"]
|
||||||
|
assert pg_client2.get(a["url"] + "?download=1").headers["content-disposition"].startswith("attachment;")
|
||||||
|
assert pg_client2.get("/_blob/00000000-0000-0000-0000-000000000000").status_code == 404
|
||||||
|
r = upload(pg_client2, b"just some text, not a document", name="x.txt", mime="text/plain")
|
||||||
|
assert r.status_code == 415 and r.json()["code"] == "unsupported_type"
|
||||||
|
r = upload(pg_client2, b"%PDF-1.7\nthis is not really a pdf at all\n%%EOF\n", name="bad.pdf")
|
||||||
|
assert r.status_code == 422 and r.json()["code"] == "bad_pdf"
|
||||||
|
small = pg_settings.model_copy(update={"upload_max_bytes": 2048})
|
||||||
|
with phase2_client(small, fake_dir) as c2:
|
||||||
|
as_user(c2, "clerk.clara")
|
||||||
|
r = upload(c2, b"%PDF-1.4\n" + b"0" * 5000)
|
||||||
|
assert r.status_code == 413 and r.json()["code"] == "too_large"
|
||||||
|
with TestClient(c2.app) as anon:
|
||||||
|
assert anon.get(a["url"]).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- transitions
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_to_warehouse(
|
||||||
|
pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int], monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
pkt = make_draft(pg_client2, unit[1])
|
||||||
|
r = step(pg_client2, pkt, "send_to_warehouse")
|
||||||
|
assert r.status_code == 403 and r.json() == {
|
||||||
|
"detail": "Say whether this packet needs a heat map.",
|
||||||
|
"code": "precondition",
|
||||||
|
}
|
||||||
|
pkt = set_header(pg_client2, pkt["id"], heat_map=False).json()
|
||||||
|
r = step(pg_client2, pkt, "send_to_warehouse")
|
||||||
|
assert r.status_code == 403 and r.json()["detail"] == "Attach the drawing first."
|
||||||
|
pkt = attach_drawing(pg_client2, pkt["id"])
|
||||||
|
assert "send_to_warehouse" in pkt["can"] and "send_to_warehouse" not in pkt["blocked"]
|
||||||
|
r = pg_client2.post(
|
||||||
|
f"/api/packets/{pkt['id']}/transitions",
|
||||||
|
json={"action": "send_to_warehouse", "expect": {"status": "warehouse", "dept": "03"}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "stale_step"
|
||||||
|
assert r.json()["current"] == {"status": "draft", "dept": "03", "version": pkt["version"]}
|
||||||
|
published: list[dict[str, Any]] = []
|
||||||
|
real = events.publish
|
||||||
|
monkeypatch.setattr(events, "publish", lambda conn, payload: (published.append(payload), real(conn, payload)))
|
||||||
|
out = ok_step(pg_client2, pkt, "send_to_warehouse")
|
||||||
|
assert out["status"] == "warehouse" and out["version"] == pkt["version"] + 1 and out["holder"] == "Warehouse"
|
||||||
|
assert out["wh"]["stage"] == "incoming" and out["wh"]["by"]["display_name"] == "Clara Clerk"
|
||||||
|
assert out["wh_sent_at"] and out["wh_skipped"] is False
|
||||||
|
assert published == [
|
||||||
|
{"event": "changed", "packet_id": pkt["id"], "version": out["version"], "status": "warehouse", "dept": "03"}
|
||||||
|
]
|
||||||
|
tr = _one(
|
||||||
|
pg_settings,
|
||||||
|
"SELECT action, from_status, to_status, by FROM packet_transitions WHERE packet_id = %s",
|
||||||
|
(pkt["id"],),
|
||||||
|
)
|
||||||
|
assert tr[:3] == ("send_to_warehouse", "draft", "warehouse")
|
||||||
|
lg = _one(
|
||||||
|
pg_settings, "SELECT action, transition_id FROM packet_log WHERE packet_id = %s ORDER BY id DESC", (pkt["id"],)
|
||||||
|
)
|
||||||
|
assert lg[0] == "Sent to warehouse" and lg[1] is not None
|
||||||
|
au = _one(pg_settings, "SELECT detail FROM audit_events WHERE action = 'packet.send_to_warehouse'")
|
||||||
|
assert au[0]["from"] == "draft" and au[0]["to"] == "warehouse"
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
r = pg_client2.patch(f"/api/packets/{pkt['id']}", json={"set": {"title": "x"}})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "wrong_status"
|
||||||
|
|
||||||
|
|
||||||
|
def test_happy_path_file_and_reopen(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
pkt = happy_path(pg_client2, pg_settings, unit[1])
|
||||||
|
pid = pkt["id"]
|
||||||
|
seal = _one(pg_settings, "SELECT key_id, signature_id, broken_at FROM packet_seals WHERE packet_id = %s", (pid,))
|
||||||
|
assert seal[1] is not None and seal[2] is None
|
||||||
|
view = pg_client2.get(f"/api/packets/{pid}/seal").json()
|
||||||
|
assert view["valid"] is True and view["seal"]["key_id"] == seal[0]
|
||||||
|
actions = [
|
||||||
|
r[0]
|
||||||
|
for r in _all(pg_settings, "SELECT action FROM packet_transitions WHERE packet_id = %s ORDER BY id", (pid,))
|
||||||
|
]
|
||||||
|
assert actions == [
|
||||||
|
"send_to_warehouse",
|
||||||
|
"wh_release",
|
||||||
|
"assign",
|
||||||
|
"start",
|
||||||
|
"submit",
|
||||||
|
"send_to_qc",
|
||||||
|
"qc_accept",
|
||||||
|
"file",
|
||||||
|
]
|
||||||
|
assert pg_client2.get(f"/api/packets/{pid}/log").json()[0]["action"] == "Filed in the data book"
|
||||||
|
# filed: every write is refused, Admin included
|
||||||
|
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"drawing": "D-2"}})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "filed"
|
||||||
|
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"title": "x"}})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "filed"
|
||||||
|
as_user(pg_client2, "admin.ann")
|
||||||
|
r = pg_client2.patch(
|
||||||
|
f"/api/packets/{pid}", json={"set": {"drawing": "D-2"}, "it_reason": "Fix a typo in the drawing"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "filed"
|
||||||
|
# the data book date: Clerical and Q.C., not the warehouse
|
||||||
|
for sam, code in (("clerk.clara", 200), ("qc.quinn", 200), ("wh.wade", 403)):
|
||||||
|
as_user(pg_client2, sam)
|
||||||
|
r = pg_client2.post(f"/api/packets/{pid}/databook", json={})
|
||||||
|
assert r.status_code == code, (sam, r.text)
|
||||||
|
assert pg_client2.get(f"/api/packets/{pid}/seal").json()["valid"] is True
|
||||||
|
# reopen by Admin with a reason and a signature breaks the seal
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
r = pg_client2.post(f"/api/packets/{pid}/reopen", json={"reason": "Wrong heat number filed", "sign": SIGN})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden"
|
||||||
|
as_user(pg_client2, "admin.ann")
|
||||||
|
r = pg_client2.post(f"/api/packets/{pid}/reopen", json={"reason": "short", "sign": SIGN})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "reason_required"
|
||||||
|
r = pg_client2.post(f"/api/packets/{pid}/reopen", json={"reason": "Wrong heat number filed"})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "signature_required"
|
||||||
|
r = pg_client2.post(f"/api/packets/{pid}/reopen", json={"reason": "Wrong heat number filed", "sign": SIGN})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["status"] == "complete" and r.json()["seal"]["broken_at"] is not None
|
||||||
|
view = pg_client2.get(f"/api/packets/{pid}/seal").json()
|
||||||
|
assert view["seal"] is None and view["seals"][0]["broken_reason"] == "Wrong heat number filed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_co_hold_blocks_filing(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
pkt = make_draft(pg_client2, unit[1])
|
||||||
|
pid = pkt["id"]
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
conn.execute("UPDATE packets SET status = 'complete', routed_to = 'shipping' WHERE id = %s", (pid,))
|
||||||
|
co = conn.execute(
|
||||||
|
"INSERT INTO change_orders (packet_id, co_no, origin, review) VALUES (%s, 1, 'manual', 'approved')"
|
||||||
|
" RETURNING id",
|
||||||
|
(pid,),
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, '03')", (co,))
|
||||||
|
pkt = get(pg_client2, pid)
|
||||||
|
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."
|
||||||
|
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"
|
||||||
|
card = pg_client2.get("/api/packets", params={"status": "complete"}).json()["packets"][0]
|
||||||
|
assert card["open_cos"] == [1] and "file" in card["blocked"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_bypass_needs_it_reason(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
pkt = make_draft(pg_client2, unit[1])
|
||||||
|
as_user(pg_client2, "admin.ann")
|
||||||
|
r = pg_client2.patch(f"/api/packets/{pkt['id']}", json={"set": {"title": "IT title"}})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "it_reason_required"
|
||||||
|
r = pg_client2.patch(
|
||||||
|
f"/api/packets/{pkt['id']}", json={"set": {"title": "IT title"}, "it_reason": "Ticket 4411 fix"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["title"] == "IT title"
|
||||||
|
log = pg_client2.get(f"/api/packets/{pkt['id']}/log").json()[0]
|
||||||
|
assert log["it_reason"] == "Ticket 4411 fix" and log["by"]["display_name"] == "Ann Admin"
|
||||||
|
au = _one(pg_settings, "SELECT it_reason, detail FROM audit_events WHERE action = 'packet.edit_header'")
|
||||||
|
assert au[0] == "Ticket 4411 fix" and au[1]["fields"] == ["title"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_to_clerical_only_when_wh_skipped(
|
||||||
|
pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]
|
||||||
|
) -> None:
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
pkt = ready_draft(pg_client2, unit[1])
|
||||||
|
r = step(pg_client2, pkt, "release_to_shop", dept="QC")
|
||||||
|
assert r.status_code == 422 and r.json()["code"] == "invalid_dept"
|
||||||
|
pkt = ok_step(pg_client2, pkt, "release_to_shop", dept="06", note="Rush job")
|
||||||
|
assert pkt["status"] == "ready" and pkt["dept"]["code"] == "06" and pkt["wh_skipped"] is True
|
||||||
|
assert pkt["stage"]["dept_code"] == "06" and pkt["return_note"] == "Rush job"
|
||||||
|
as_user(pg_client2, "lead.lou")
|
||||||
|
r = step(pg_client2, pkt, "return_to_clerical", reason="Not ours")
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden" # lead.lou leads 03, the packet is in 06
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
conn.execute("UPDATE packets SET dept_code = '03' WHERE id = %s", (pkt["id"],))
|
||||||
|
pkt = get(pg_client2, pkt["id"])
|
||||||
|
r = step(pg_client2, pkt, "return_to_clerical", reason="short")
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "reason_required"
|
||||||
|
out = ok_step(pg_client2, pkt, "return_to_clerical", reason="Missing the second sheet")
|
||||||
|
assert out["status"] == "draft" and out["return_note"] == "Missing the second sheet" and out["stage"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_assign_validation_and_draft_to_qc(
|
||||||
|
pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]
|
||||||
|
) -> None:
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
pkt = ok_step(pg_client2, ready_draft(pg_client2, unit[1]), "release_to_shop", dept="03")
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
other = conn.execute("SELECT id FROM stations WHERE dept_code = '06' LIMIT 1").fetchone()[0]
|
||||||
|
andy = conn.execute("SELECT id FROM users WHERE sam_account = 'weld.andy'").fetchone()[0]
|
||||||
|
as_user(pg_client2, "sup.wes")
|
||||||
|
r = step(pg_client2, pkt, "assign", station_id=other, welder_id=str(andy))
|
||||||
|
assert r.status_code == 422 and r.json()["code"] == "invalid_station"
|
||||||
|
r = step(pg_client2, pkt, "assign")
|
||||||
|
assert r.status_code == 403 and r.json()["detail"] == "Pick a station and a welder."
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
q = ok_step(pg_client2, ready_draft(pg_client2, unit[1]), "send_draft_to_qc", note="Inspect as is")
|
||||||
|
assert q["status"] == "qc" and q["wh_skipped"] is True and q["qc"]["sent_note"] == "Inspect as is"
|
||||||
|
assert q["holder"] == "Q.C."
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_and_remove(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
from pkthelpers import add_unit
|
||||||
|
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
u2 = add_unit(conn, unit[0], "002")
|
||||||
|
u3 = add_unit(conn, unit[0], "7A", status="closed")
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
pkt = make_draft(pg_client2, unit[1])
|
||||||
|
r = pg_client2.post(f"/api/packets/{pkt['id']}/move", json={"unit_id": u3})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "unit_closed"
|
||||||
|
r = pg_client2.post(f"/api/packets/{pkt['id']}/move", json={"unit_id": u2, "reason": "Belongs to unit two"})
|
||||||
|
assert r.status_code == 200 and r.json()["unit"]["id"] == u2 and r.json()["unit"]["lot"] == "002"
|
||||||
|
assert pg_client2.get(f"/api/packets/{pkt['id']}/log").json()[0]["action"] == "Moved to another unit"
|
||||||
|
assert _one(pg_settings, "SELECT 1 FROM audit_events WHERE action = 'packet.moved'") is not None
|
||||||
|
r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}", json={"reason": "dup"})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "reason_required"
|
||||||
|
r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}", json={"reason": "Made twice by mistake"})
|
||||||
|
assert r.status_code == 200 and r.json()["removed"]["reason"] == "Made twice by mistake"
|
||||||
|
assert get(pg_client2, pkt["id"])["removed"] is not None and get(pg_client2, pkt["id"])["can"] == []
|
||||||
|
assert pg_client2.get("/api/packets").json()["packets"] == []
|
||||||
|
r = pg_client2.patch(f"/api/packets/{pkt['id']}", json={"set": {"title": "x"}})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "removed"
|
||||||
|
assert pg_client2.get("/api/packets", params={"removed": 1}).json()["packets"] == []
|
||||||
|
as_user(pg_client2, "admin.ann")
|
||||||
|
cards = pg_client2.get("/api/packets", params={"removed": 1}).json()["packets"]
|
||||||
|
assert [c["id"] for c in cards] == [pkt["id"]]
|
||||||
|
r = pg_client2.patch(f"/api/packets/{pkt['id']}", json={"set": {"title": "x"}, "it_reason": "Restore the title"})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "removed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_filters_and_cards(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
_, other_unit = make_unit(conn, number="L777")
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
a = make_draft(pg_client2, unit[1], drawing="D-55", work_orders=[{"wo_base": "007123", "lot": "7A"}])
|
||||||
|
b = ready_draft(pg_client2, other_unit, title="Other one")
|
||||||
|
ok_step(pg_client2, b, "send_to_warehouse")
|
||||||
|
cards = pg_client2.get("/api/packets").json()["packets"]
|
||||||
|
assert {c["id"] for c in cards} == {a["id"], b["id"]}
|
||||||
|
card = next(c for c in cards if c["id"] == a["id"])
|
||||||
|
assert card["holder"] == "Clerical" and card["flag"] == "Made by hand - check it and send it on"
|
||||||
|
assert card["can"] == [] and set(card["blocked"]) == {"send_to_warehouse", "release_to_shop", "send_draft_to_qc"}
|
||||||
|
assert card["unit"]["id"] == unit[1] and card["dept"] == {"code": "03", "name": "Weld Parts"}
|
||||||
|
assert card["wo_count"] == 1 and card["open_shorts"] == 0 and card["open_cos"] == []
|
||||||
|
bcard = next(c for c in cards if c["id"] == b["id"])
|
||||||
|
assert bcard["wh_stage"] == "incoming" and bcard["flag"] is None and bcard["holder"] == "Warehouse"
|
||||||
|
|
||||||
|
def ids(**params: Any) -> list[int]:
|
||||||
|
return [c["id"] for c in pg_client2.get("/api/packets", params=params).json()["packets"]]
|
||||||
|
|
||||||
|
assert ids(status="warehouse") == [b["id"]]
|
||||||
|
assert ids(status="draft,warehouse", unit_id=unit[1]) == [a["id"]]
|
||||||
|
assert ids(q="d-55") == [a["id"]] and ids(q="007123") == [a["id"]]
|
||||||
|
assert ids(q="L777 other") == [b["id"]] and ids(q="L777 D-55") == []
|
||||||
|
assert ids(flag="1") == [a["id"]] and ids(flag="0") == [b["id"]]
|
||||||
|
as_user(pg_client2, "wh.wade")
|
||||||
|
wcard = next(c for c in pg_client2.get("/api/packets", params={"status": "warehouse"}).json()["packets"])
|
||||||
|
assert set(wcard["can"]) == {"wh_send_back", "wh_release"} # transitions only, no edits
|
||||||
|
|
||||||
|
|
||||||
|
def test_pm_reads_but_cannot_write(pg_client2: TestClient, unit: tuple[int, int]) -> None:
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
pkt = make_draft(pg_client2, unit[1])
|
||||||
|
as_user(pg_client2, "pm.paula")
|
||||||
|
view = get(pg_client2, pkt["id"])
|
||||||
|
assert view["can"] == [] and view["holder"] == "Clerical"
|
||||||
|
assert pg_client2.get("/api/packets").json()["packets"][0]["id"] == pkt["id"]
|
||||||
|
r = pg_client2.patch(f"/api/packets/{pkt['id']}", json={"set": {"title": "x"}})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden"
|
||||||
|
r = step(pg_client2, view, "send_to_warehouse")
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_work_orders_put_and_docs(pg_client2: TestClient, unit: tuple[int, int]) -> None:
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
pkt = make_draft(pg_client2, unit[1])
|
||||||
|
url = f"/api/packets/{pkt['id']}/work-orders"
|
||||||
|
wos = {"work_orders": [{"wo_base": "0070001", "lot": "7A", "split": "", "sub": "01"}]}
|
||||||
|
assert pg_client2.put(url, json=wos).status_code == 428
|
||||||
|
r = pg_client2.put(url, json=wos, headers={"If-Match": str(pkt["version"] + 5)})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "version_conflict"
|
||||||
|
r = pg_client2.put(url, json=wos, headers={"If-Match": str(pkt["version"])})
|
||||||
|
assert r.status_code == 200
|
||||||
|
wo = r.json()["work_orders"]
|
||||||
|
assert [(w["wo_base"], w["lot"], w["sub"]) for w in wo] == [("0070001", "7A", "01")]
|
||||||
|
a = upload(pg_client2, tiny_pdf(1)).json()
|
||||||
|
r = pg_client2.post(f"/api/packets/{pkt['id']}/docs", json={"asset_id": a["id"], "kind": "mtr", "title": "MTR 1"})
|
||||||
|
assert r.status_code == 200 and r.json()["docs"][0]["title"] == "MTR 1"
|
||||||
|
doc_id = r.json()["docs"][0]["id"]
|
||||||
|
as_user(pg_client2, "wh.wade")
|
||||||
|
r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}/docs/{doc_id}", json={})
|
||||||
|
assert r.status_code == 403
|
||||||
|
as_user(pg_client2, "qc.quinn")
|
||||||
|
r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}/docs/{doc_id}", json={"reason": "wrong file"})
|
||||||
|
assert r.status_code == 200 and r.json()["docs"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_seal_detects_tampering(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||||
|
pkt = happy_path(pg_client2, pg_settings, unit[1])
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
conn.execute("UPDATE packets SET title = 'changed behind the app' WHERE id = %s", (pkt["id"],))
|
||||||
|
conn.execute("UPDATE bom_lines SET heat = 'X' WHERE packet_id = %s", (pkt["id"],))
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE packet_stages SET checklist = %s WHERE packet_id = %s", (Jsonb({"dwg": {"done": False}}), pkt["id"])
|
||||||
|
)
|
||||||
|
assert pg_client2.get(f"/api/packets/{pkt['id']}/seal").json()["valid"] is False
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""The pipeline under concurrency: the FOR UPDATE lock serialises writers; counters never collide; the audit chain
|
||||||
|
holds after a full packet life; packet_log is append-only for the app role."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
from fakedir import FakeDirectory
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pkthelpers import as_user, happy_path, make_unit, phase2_client, ready_draft, seed_users, step
|
||||||
|
from pshelpers import app_conn, needs_pg
|
||||||
|
|
||||||
|
from packetstation import audit
|
||||||
|
from packetstation.domain.pipeline import next_counter
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pg_client2(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
|
||||||
|
with phase2_client(pg_settings, fake_dir) as client:
|
||||||
|
seed_users(client)
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_step_twice_one_wins(pg_client2: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
_, unit_id = make_unit(conn)
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
pkt = ready_draft(pg_client2, unit_id)
|
||||||
|
with phase2_client(pg_settings, fake_dir) as other:
|
||||||
|
as_user(other, "clerk.clara")
|
||||||
|
results: list[Any] = [None, None]
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
|
||||||
|
def run(i: int, client: TestClient) -> None:
|
||||||
|
barrier.wait()
|
||||||
|
results[i] = step(client, pkt, "send_to_warehouse")
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=run, args=(i, c)) for i, c in enumerate((pg_client2, other))]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(30)
|
||||||
|
codes = sorted(r.status_code for r in results)
|
||||||
|
assert codes == [200, 409], [r.text for r in results]
|
||||||
|
loser = next(r for r in results if r.status_code == 409)
|
||||||
|
assert loser.json()["code"] == "stale_step"
|
||||||
|
assert loser.json()["current"]["status"] == "warehouse" and loser.json()["current"]["version"] == pkt["version"] + 1
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
n = conn.execute("SELECT count(*) FROM packet_transitions WHERE packet_id = %s", (pkt["id"],)).fetchone()[0]
|
||||||
|
wh = conn.execute("SELECT count(*) FROM packet_wh WHERE packet_id = %s", (pkt["id"],)).fetchone()[0]
|
||||||
|
assert n == 1 and wh == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_counter_concurrent(pg_client2: TestClient, pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
_, unit_id = make_unit(conn)
|
||||||
|
pid = conn.execute(
|
||||||
|
"INSERT INTO packets (unit_id, dept_code) VALUES (%s, '03') RETURNING id", (unit_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
got: list[int] = []
|
||||||
|
lock = threading.Lock()
|
||||||
|
barrier = threading.Barrier(8)
|
||||||
|
|
||||||
|
def take() -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
barrier.wait()
|
||||||
|
with conn.transaction():
|
||||||
|
n = next_counter(conn, pid, "weld")
|
||||||
|
with lock:
|
||||||
|
got.append(n)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=take) for _ in range(8)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(30)
|
||||||
|
assert sorted(got) == list(range(1, 9))
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
assert conn.execute("SELECT next_weld_no FROM packets WHERE id = %s", (pid,)).fetchone()[0] == 9
|
||||||
|
with conn.transaction():
|
||||||
|
assert next_counter(conn, pid, "page", 3) == 1
|
||||||
|
assert next_counter(conn, pid, "page") == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_chain_after_happy_path(pg_client2: TestClient, pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
_, unit_id = make_unit(conn)
|
||||||
|
happy_path(pg_client2, pg_settings, unit_id)
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
ok, n, detail = audit.verify_chain(conn)
|
||||||
|
actions = {r[0] for r in conn.execute("SELECT action FROM audit_events")}
|
||||||
|
assert ok, detail
|
||||||
|
assert n > 20
|
||||||
|
assert {"packet.created", "packet.file", "packet.qc_accept", "sig.signed", "asset.uploaded"} <= actions
|
||||||
|
|
||||||
|
|
||||||
|
def test_packet_log_append_only(pg_client2: TestClient, pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
_, unit_id = make_unit(conn)
|
||||||
|
as_user(pg_client2, "clerk.clara")
|
||||||
|
pkt = ready_draft(pg_client2, unit_id)
|
||||||
|
for sql in (
|
||||||
|
"UPDATE packet_log SET note = 'x' WHERE packet_id = %s",
|
||||||
|
"DELETE FROM packet_log WHERE packet_id = %s",
|
||||||
|
"UPDATE packet_transitions SET reason = 'x' WHERE packet_id = %s",
|
||||||
|
):
|
||||||
|
with app_conn(pg_settings) as conn, pytest.raises(psycopg.Error):
|
||||||
|
conn.execute(sql, (pkt["id"],))
|
||||||
|
with app_conn(pg_settings) as conn, pytest.raises(psycopg.Error):
|
||||||
|
conn.execute("TRUNCATE packet_log")
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
"""The policy matrix: every role against every status for every transition, then the rules one by one.
|
||||||
|
|
||||||
|
The expected table below is written from the rule table in the phase 2 design contract, not from the code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from packetstation.policy import (
|
||||||
|
RULES,
|
||||||
|
STATUSES,
|
||||||
|
Action,
|
||||||
|
PacketState,
|
||||||
|
Principal,
|
||||||
|
can,
|
||||||
|
check,
|
||||||
|
holder_name,
|
||||||
|
holds,
|
||||||
|
principal_from_roles,
|
||||||
|
works_on,
|
||||||
|
)
|
||||||
|
from packetstation.policy.preconditions import INPUT_ONLY, MESSAGES
|
||||||
|
|
||||||
|
A = Action
|
||||||
|
WELDER = "00000000-0000-0000-0000-00000000a11d"
|
||||||
|
OTHER = "00000000-0000-0000-0000-0000000000b0"
|
||||||
|
IT = "Ticket 4411: fix a typo"
|
||||||
|
|
||||||
|
# Every input a step can need. The default state satisfies every state precondition.
|
||||||
|
FULL_EXTRA: dict[str, Any] = {
|
||||||
|
"reason": "Needs another look at the fit-up.",
|
||||||
|
"station_id": 1,
|
||||||
|
"welder_id": WELDER,
|
||||||
|
"dept": "06",
|
||||||
|
"routed_to": "databook",
|
||||||
|
"book": True,
|
||||||
|
"spool": True,
|
||||||
|
"location": "Rack 4",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def P(roles: list[str], user_id: str = OTHER, limits: tuple[int, ...] = ()) -> Principal:
|
||||||
|
return principal_from_roles(user_id, roles, station_limits=limits)
|
||||||
|
|
||||||
|
|
||||||
|
ROLES: dict[str, Principal] = {
|
||||||
|
"admin": P(["admin"]),
|
||||||
|
"clerical": P(["clerical"]),
|
||||||
|
"clerical_sup": P(["clerical_supervisor"]),
|
||||||
|
"warehouse": P(["warehouse"]),
|
||||||
|
"warehouse_sup": P(["warehouse_supervisor"]),
|
||||||
|
"qc": P(["qc"]),
|
||||||
|
"qc_sup": P(["qc_supervisor"]),
|
||||||
|
"pm": P(["pm"]),
|
||||||
|
"worker03": P(["shop:03:worker"]),
|
||||||
|
"lead03": P(["shop:03:lead"]),
|
||||||
|
"sup03": P(["shop:03:supervisor"]),
|
||||||
|
"worker06": P(["shop:06:worker"], user_id=WELDER),
|
||||||
|
# the assigned welder, but limited to a station that is not the packet's
|
||||||
|
"worker03_limited": P(["shop:03:worker"], user_id=WELDER, limits=(99,)),
|
||||||
|
"welder": P(["shop:03:worker"], user_id=WELDER),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def state(status: str, **kw: Any) -> PacketState:
|
||||||
|
base = PacketState(
|
||||||
|
id=1,
|
||||||
|
status=status,
|
||||||
|
dept="03",
|
||||||
|
station_id=1,
|
||||||
|
welder_id=WELDER,
|
||||||
|
wh_skipped=True,
|
||||||
|
routed_to="databook",
|
||||||
|
vclosed=True,
|
||||||
|
removed=False,
|
||||||
|
source_kind="hand",
|
||||||
|
weld_dept=True,
|
||||||
|
co_hold=False,
|
||||||
|
drawing_attached=True,
|
||||||
|
heat_map=True,
|
||||||
|
bom_total=3,
|
||||||
|
bom_ready=3,
|
||||||
|
welds=2,
|
||||||
|
checklist_complete=True,
|
||||||
|
filler_set=True,
|
||||||
|
open_shorts=1,
|
||||||
|
)
|
||||||
|
return dataclasses.replace(base, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
CL = {"clerical", "clerical_sup"}
|
||||||
|
WH = {"warehouse", "warehouse_sup"}
|
||||||
|
QC = {"qc", "qc_sup"}
|
||||||
|
LEADS = {"lead03", "sup03"}
|
||||||
|
SUP = {"sup03"}
|
||||||
|
|
||||||
|
# action -> (from statuses, roles allowed). From the contract rule table.
|
||||||
|
EXPECTED: dict[Action, tuple[set[str], set[str]]] = {
|
||||||
|
A.SEND_TO_WAREHOUSE: ({"draft"}, CL),
|
||||||
|
A.RELEASE_TO_SHOP: ({"draft"}, CL),
|
||||||
|
A.SEND_DRAFT_TO_QC: ({"draft"}, CL),
|
||||||
|
A.WH_SEND_BACK: ({"warehouse"}, WH),
|
||||||
|
A.RETURN_TO_CLERICAL: ({"ready"}, LEADS),
|
||||||
|
A.WH_RELEASE: ({"warehouse"}, WH),
|
||||||
|
A.SEND_BACK_TO_WAREHOUSE: ({"ready"}, LEADS),
|
||||||
|
A.ASSIGN: ({"ready"}, LEADS),
|
||||||
|
A.UNASSIGN: ({"assigned"}, LEADS),
|
||||||
|
A.PULL_BACK: ({"in_progress"}, LEADS),
|
||||||
|
A.START: ({"assigned"}, {*LEADS, "welder"}),
|
||||||
|
A.SUBMIT: ({"assigned", "in_progress"}, {*LEADS, "welder"}),
|
||||||
|
A.RETURN_TO_STATION: ({"review"}, SUP),
|
||||||
|
A.SEND_TO_QC: ({"review"}, SUP),
|
||||||
|
A.ROUTE_FROM_REVIEW: ({"review"}, SUP),
|
||||||
|
A.QC_REJECT: ({"qc"}, QC),
|
||||||
|
A.QC_ACCEPT: ({"qc"}, QC),
|
||||||
|
A.QC_ACCEPT_AND_ROUTE: ({"qc"}, QC),
|
||||||
|
A.FILE: ({"complete"}, CL),
|
||||||
|
A.ADMIN_REOPEN: ({"filed"}, {"admin"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def expected(role: str, status: str, action: Action) -> str:
|
||||||
|
"""'' when the step passes, else the expected code."""
|
||||||
|
froms, allowed = EXPECTED[action]
|
||||||
|
if status not in froms:
|
||||||
|
return "wrong_status"
|
||||||
|
if role in allowed:
|
||||||
|
return ""
|
||||||
|
return "it_reason_required" if role == "admin" else "forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
MATRIX = [(r, s, a) for r in ROLES for s in STATUSES for a in EXPECTED]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_table_covers_every_user_transition() -> None:
|
||||||
|
transitions = {a for a, r in RULES.items() if r.to_status is not None and not r.system_only}
|
||||||
|
assert transitions == set(EXPECTED)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("role", "status", "action"), MATRIX, ids=[f"{r}-{s}-{a}" for r, s, a in MATRIX])
|
||||||
|
def test_matrix(role: str, status: str, action: Action) -> None:
|
||||||
|
d = check(ROLES[role], state(status), action, extra=FULL_EXTRA)
|
||||||
|
want = expected(role, status, action)
|
||||||
|
assert d.ok is (want == ""), (d.code, d.why)
|
||||||
|
assert d.code == want
|
||||||
|
if d.ok:
|
||||||
|
assert d.signature == RULES[action].signature
|
||||||
|
assert d.admin_bypass is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- custody ----------
|
||||||
|
|
||||||
|
HOLDERS: dict[str, set[str]] = {
|
||||||
|
"draft": {"clerical", "clerical_sup"},
|
||||||
|
"warehouse": {"warehouse", "warehouse_sup"},
|
||||||
|
"ready": {"lead03", "sup03"},
|
||||||
|
"assigned": {"lead03", "sup03", "worker03", "welder"},
|
||||||
|
"in_progress": {"lead03", "sup03", "worker03", "welder"},
|
||||||
|
"review": {"lead03", "sup03"},
|
||||||
|
"qc": {"qc", "qc_sup"},
|
||||||
|
"complete": {"clerical", "clerical_sup"},
|
||||||
|
"filed": set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", STATUSES)
|
||||||
|
def test_custody_per_status(status: str) -> None:
|
||||||
|
got = {name for name, p in ROLES.items() if holds(p, state(status))}
|
||||||
|
assert got == HOLDERS[status]
|
||||||
|
|
||||||
|
|
||||||
|
def test_holder_name() -> None:
|
||||||
|
assert holder_name(state("draft")) == "Clerical"
|
||||||
|
assert holder_name(state("warehouse")) == "Warehouse"
|
||||||
|
assert holder_name(state("qc")) == "Q.C."
|
||||||
|
assert holder_name(state("complete")) == "Clerical (data book)"
|
||||||
|
assert holder_name(state("filed")) == "the data book (filed)"
|
||||||
|
assert holder_name(state("ready"), "Weld Parts") == "Weld Parts"
|
||||||
|
assert holder_name(state("assigned"), "Weld Parts", "Stall 1") == "Weld Parts · Stall 1"
|
||||||
|
assert holder_name(state("review"), "Weld Parts", "Stall 1") == "Weld Parts"
|
||||||
|
assert holder_name(state("ready")) == "03"
|
||||||
|
|
||||||
|
|
||||||
|
def test_works_on_station_limits() -> None:
|
||||||
|
s = state("assigned", station_id=4)
|
||||||
|
assert works_on(P(["shop:03:worker"]), s)
|
||||||
|
assert works_on(P(["shop:03:worker"], limits=(4, 5)), s)
|
||||||
|
assert not works_on(P(["shop:03:worker"], limits=(5,)), s)
|
||||||
|
assert not works_on(P(["shop:06:worker"]), s)
|
||||||
|
# leads and supervisors ignore station limits
|
||||||
|
assert works_on(P(["shop:03:lead"], limits=(5,)), s)
|
||||||
|
assert works_on(P(["shop:03:supervisor"], limits=(5,)), s)
|
||||||
|
# a limited worker with no station on the packet does not work on it
|
||||||
|
assert not works_on(P(["shop:03:worker"], limits=(5,)), state("assigned", station_id=None))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", [A.START, A.SUBMIT])
|
||||||
|
def test_start_and_submit_need_the_assigned_welder_for_a_worker(action: Action) -> None:
|
||||||
|
s = state("assigned")
|
||||||
|
assert check(P(["shop:03:worker"], user_id=WELDER), s, action).ok
|
||||||
|
other = check(P(["shop:03:worker"], user_id=OTHER), s, action)
|
||||||
|
assert (other.ok, other.code) == (False, "forbidden")
|
||||||
|
assert other.why == "Your access doesn't allow this step."
|
||||||
|
unassigned = check(P(["shop:03:worker"], user_id=WELDER), state("assigned", welder_id=None), action)
|
||||||
|
assert unassigned.code == "forbidden"
|
||||||
|
limited = check(P(["shop:03:worker"], user_id=WELDER, limits=(2,)), s, action)
|
||||||
|
assert limited.code == "forbidden"
|
||||||
|
# a lead does not need to be the welder
|
||||||
|
assert check(P(["shop:03:lead"], user_id=OTHER), s, action).ok
|
||||||
|
assert check(P(["shop:03:lead"], user_id=OTHER), state("assigned", welder_id=None), action).ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_who_is_not_the_welder_still_holds_for_edits() -> None:
|
||||||
|
s = state("in_progress")
|
||||||
|
worker = P(["shop:03:worker"], user_id=OTHER)
|
||||||
|
assert check(worker, s, A.EDIT_WELDS).ok
|
||||||
|
assert check(worker, s, A.CLOCK).ok
|
||||||
|
assert check(P(["shop:03:worker"], limits=(9,)), s, A.EDIT_WELDS).code == "forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leads_write_shop_fields_only_while_they_hold() -> None:
|
||||||
|
lead = ROLES["lead03"]
|
||||||
|
for status in ("ready", "assigned", "in_progress", "review"):
|
||||||
|
assert check(lead, state(status), A.EDIT_WELDS).ok
|
||||||
|
d = check(ROLES["lead03"], state("qc"), A.EDIT_WELDS)
|
||||||
|
assert d.code == "wrong_status"
|
||||||
|
other_dept = check(P(["shop:06:lead"]), state("review"), A.EDIT_BOM)
|
||||||
|
assert other_dept.code == "forbidden"
|
||||||
|
assert other_dept.why == "Your access doesn't allow changing the materials on this packet. It's with 03 right now."
|
||||||
|
named = check(P(["shop:06:lead"]), state("review"), A.EDIT_BOM, extra={"holder_name": "Weld Parts"})
|
||||||
|
assert named.why.endswith("It's with Weld Parts right now.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- change-order hold ----------
|
||||||
|
|
||||||
|
CO_HOLD_ACTIONS = {a for a, r in RULES.items() if r.co_hold}
|
||||||
|
|
||||||
|
|
||||||
|
def test_co_hold_set_is_the_contract_set() -> None:
|
||||||
|
assert {
|
||||||
|
A.WH_RELEASE,
|
||||||
|
A.SUBMIT,
|
||||||
|
A.SEND_TO_QC,
|
||||||
|
A.ROUTE_FROM_REVIEW,
|
||||||
|
A.QC_ACCEPT,
|
||||||
|
A.QC_ACCEPT_AND_ROUTE,
|
||||||
|
A.FILE,
|
||||||
|
} == CO_HOLD_ACTIONS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", list(EXPECTED))
|
||||||
|
def test_co_hold_blocks_exactly_the_co_hold_actions(action: Action) -> None:
|
||||||
|
froms, allowed = EXPECTED[action]
|
||||||
|
status = sorted(froms)[0]
|
||||||
|
role = sorted(allowed)[0]
|
||||||
|
why = "Change order 2 must be verified first by: WH."
|
||||||
|
d = check(ROLES[role], state(status, co_hold=True, co_hold_why=why), action, extra=FULL_EXTRA)
|
||||||
|
if action in CO_HOLD_ACTIONS:
|
||||||
|
assert (d.ok, d.code, d.why) == (False, "co_hold", why)
|
||||||
|
else:
|
||||||
|
assert d.ok, (d.code, d.why)
|
||||||
|
|
||||||
|
|
||||||
|
def test_co_hold_applies_to_admin() -> None:
|
||||||
|
d = check(ROLES["admin"], state("warehouse", co_hold=True), A.WH_RELEASE, it_reason=IT)
|
||||||
|
assert (d.code, d.why) == ("co_hold", "A change order holds this step.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- admin bypass ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_bypass_needs_an_it_reason() -> None:
|
||||||
|
admin = ROLES["admin"]
|
||||||
|
d = check(admin, state("draft"), A.SEND_TO_WAREHOUSE)
|
||||||
|
assert (d.ok, d.code) == (False, "it_reason_required")
|
||||||
|
short = check(admin, state("draft"), A.SEND_TO_WAREHOUSE, it_reason="too sh")
|
||||||
|
assert short.code == "it_reason_required"
|
||||||
|
ok = check(admin, state("draft"), A.SEND_TO_WAREHOUSE, it_reason=IT)
|
||||||
|
assert ok.ok and ok.admin_bypass
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_bypass_passes_custody_and_is_marked() -> None:
|
||||||
|
admin = ROLES["admin"]
|
||||||
|
for status in ("ready", "assigned", "in_progress", "review"):
|
||||||
|
d = check(admin, state(status), A.EDIT_WELDS, it_reason=IT)
|
||||||
|
assert d.ok and d.admin_bypass
|
||||||
|
d = check(admin, state("in_progress"), A.SUBMIT, it_reason=IT)
|
||||||
|
assert d.ok and d.admin_bypass and d.signature == "Submit work to the supervisor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_who_also_holds_is_not_a_bypass() -> None:
|
||||||
|
both = P(["admin", "clerical"])
|
||||||
|
d = check(both, state("draft"), A.EDIT_HEADER)
|
||||||
|
assert d.ok and not d.admin_bypass
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_reopen_is_admin_only_and_not_a_bypass() -> None:
|
||||||
|
d = check(ROLES["admin"], state("filed"), A.ADMIN_REOPEN, extra=FULL_EXTRA)
|
||||||
|
assert d.ok and not d.admin_bypass and d.signature == "Reopen a filed packet"
|
||||||
|
for name, p in ROLES.items():
|
||||||
|
if name != "admin":
|
||||||
|
assert check(p, state("filed"), A.ADMIN_REOPEN, extra=FULL_EXTRA).code == "forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_set_databook_at_needs_no_reason() -> None:
|
||||||
|
d = check(ROLES["admin"], state("filed"), A.SET_DATABOOK_AT)
|
||||||
|
assert d.ok and d.admin_bypass
|
||||||
|
|
||||||
|
|
||||||
|
def test_itp_reopen_qc_supervisor_or_admin_with_it_reason() -> None:
|
||||||
|
extra = {"reason": "Wrong line signed by mistake."}
|
||||||
|
assert check(ROLES["qc_sup"], state("complete"), A.ITP_REOPEN, extra=extra).ok
|
||||||
|
assert check(ROLES["qc"], state("complete"), A.ITP_REOPEN, extra=extra).code == "forbidden"
|
||||||
|
assert check(ROLES["admin"], state("complete"), A.ITP_REOPEN, extra=extra).code == "it_reason_required"
|
||||||
|
assert check(ROLES["admin"], state("complete"), A.ITP_REOPEN, extra=extra, it_reason=IT).ok
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- filed lock and removed ----------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", list(RULES))
|
||||||
|
def test_filed_lock(action: Action) -> None:
|
||||||
|
s = state("filed")
|
||||||
|
for name, p in ROLES.items():
|
||||||
|
d = check(p, s, action, extra={**FULL_EXTRA, "dept": "03"}, it_reason=IT)
|
||||||
|
if action is A.SET_DATABOOK_AT and name in {"admin", "clerical", "clerical_sup", "qc", "qc_sup"}:
|
||||||
|
assert d.ok, (name, d.code)
|
||||||
|
elif action is A.ADMIN_REOPEN and name == "admin":
|
||||||
|
assert d.ok
|
||||||
|
else:
|
||||||
|
assert not d.ok, (name, action)
|
||||||
|
if not RULES[action].from_status and not RULES[action].system_only and name == "admin":
|
||||||
|
assert d.code == "filed"
|
||||||
|
assert d.why == "This packet is filed and locked. IT can reopen it if a correction is needed."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", list(RULES))
|
||||||
|
def test_removed_refuses_everything(action: Action) -> None:
|
||||||
|
for status in STATUSES:
|
||||||
|
s = state(status, removed=True)
|
||||||
|
for p in [*ROLES.values(), Principal(user_id=None, system="visual-feed")]:
|
||||||
|
d = check(p, s, action, extra=FULL_EXTRA, it_reason=IT)
|
||||||
|
assert (d.ok, d.code) == (False, "removed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_status_is_wrong_status() -> None:
|
||||||
|
for action in RULES:
|
||||||
|
d = check(ROLES["admin"], state("shipped"), action, extra=FULL_EXTRA, it_reason=IT)
|
||||||
|
assert d.code == "wrong_status"
|
||||||
|
allowed, blocked = can(ROLES["clerical"], state("shipped"))
|
||||||
|
assert allowed == [] and blocked == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- preconditions ----------
|
||||||
|
|
||||||
|
# (action, status, role, state change or extra change, precondition name)
|
||||||
|
PRECONDITION_CASES: list[tuple[Action, str, str, dict[str, Any], dict[str, Any], str]] = [
|
||||||
|
(A.SEND_TO_WAREHOUSE, "draft", "clerical", {"heat_map": None}, {}, "heat_map_answered"),
|
||||||
|
(A.SEND_TO_WAREHOUSE, "draft", "clerical", {"drawing_attached": False}, {}, "drawing_attached"),
|
||||||
|
(A.SEND_DRAFT_TO_QC, "draft", "clerical", {"drawing_attached": False}, {}, "drawing_attached"),
|
||||||
|
(A.RELEASE_TO_SHOP, "draft", "clerical", {}, {"dept": ""}, "target_dept_given"),
|
||||||
|
(A.WH_RELEASE, "warehouse", "warehouse", {"bom_ready": 2}, {}, "bom_all_pulled_or_short"),
|
||||||
|
(A.ASSIGN, "ready", "lead03", {}, {"welder_id": None}, "welder_set"),
|
||||||
|
(A.SUBMIT, "in_progress", "welder", {"checklist_complete": False}, {}, "checklist_complete"),
|
||||||
|
(A.SUBMIT, "in_progress", "welder", {"welds": 0}, {}, "welds_recorded"),
|
||||||
|
(A.SUBMIT, "in_progress", "welder", {"filler_set": False}, {}, "filler_set"),
|
||||||
|
(A.SEND_TO_QC, "review", "sup03", {}, {"spool": False}, "qc_book_spool_location"),
|
||||||
|
(A.SEND_TO_QC, "review", "sup03", {}, {"location": " "}, "qc_book_spool_location"),
|
||||||
|
(A.ROUTE_FROM_REVIEW, "review", "sup03", {}, {"dept": "03"}, "target_dept_given"),
|
||||||
|
(A.QC_ACCEPT, "qc", "qc", {"qc_missing": ("weld exam",)}, {}, "qc_missing_empty"),
|
||||||
|
(A.QC_ACCEPT, "qc", "qc", {}, {"routed_to": "scrap"}, "route_given"),
|
||||||
|
(A.QC_ACCEPT_AND_ROUTE, "qc", "qc", {}, {"dept": "WH"}, "target_dept_given"),
|
||||||
|
(A.FILE, "complete", "clerical", {"vclosed": False}, {}, "vclosed_if_databook"),
|
||||||
|
(A.WH_SEND_BACK, "warehouse", "warehouse", {}, {"reason": "short"}, "reason_given"),
|
||||||
|
(A.QC_REJECT, "qc", "qc", {}, {"reason": None}, "reason_given"),
|
||||||
|
(A.REMOVE, "draft", "clerical", {}, {"reason": " x "}, "reason_given"),
|
||||||
|
(A.WH_RECEIVE_SHORT, "ready", "warehouse", {"open_shorts": 0}, {}, "short_open"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("action", "status", "role", "change", "extra", "name"),
|
||||||
|
PRECONDITION_CASES,
|
||||||
|
ids=[f"{c[0]}-{c[5]}" for c in PRECONDITION_CASES],
|
||||||
|
)
|
||||||
|
def test_each_precondition_fails_alone(
|
||||||
|
action: Action, status: str, role: str, change: dict[str, Any], extra: dict[str, Any], name: str
|
||||||
|
) -> None:
|
||||||
|
s = state(status, **change)
|
||||||
|
x = {**FULL_EXTRA, **extra}
|
||||||
|
assert check(ROLES[role], state(status), action, extra=FULL_EXTRA).ok
|
||||||
|
d = check(ROLES[role], s, action, extra=x)
|
||||||
|
want = "reason_required" if name == "reason_given" else "precondition"
|
||||||
|
assert (d.ok, d.code) == (False, want)
|
||||||
|
if name == "qc_missing_empty":
|
||||||
|
assert d.why == "Q.C. still needs: weld exam."
|
||||||
|
elif name == "target_dept_given" and x["dept"] in ("", "WH"):
|
||||||
|
assert d.why == "Choose a department."
|
||||||
|
else:
|
||||||
|
assert d.why == MESSAGES[name]
|
||||||
|
# never bypassed by admin
|
||||||
|
a = check(ROLES["admin"], s, action, extra=x, it_reason=IT)
|
||||||
|
assert (a.ok, a.code, a.why) == (False, want, d.why)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_named_precondition_is_tested() -> None:
|
||||||
|
used = {n for r in RULES.values() for n in r.preconditions}
|
||||||
|
assert used <= {c[5] for c in PRECONDITION_CASES}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filler_and_welds_only_in_weld_departments() -> None:
|
||||||
|
s = state("in_progress", weld_dept=False, welds=0, filler_set=False)
|
||||||
|
assert check(ROLES["welder"], s, A.SUBMIT).ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_vclosed_only_needed_for_the_data_book_route() -> None:
|
||||||
|
assert check(ROLES["clerical"], state("complete", routed_to="shipping", vclosed=False), A.FILE).ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_co_staged_if_dwg_change() -> None:
|
||||||
|
from packetstation.policy.preconditions import co_staged_if_dwg_change
|
||||||
|
|
||||||
|
s = state("draft")
|
||||||
|
assert co_staged_if_dwg_change(s, {}) is None
|
||||||
|
assert co_staged_if_dwg_change(s, {"dwg_change": True, "staged": True}) is None
|
||||||
|
assert co_staged_if_dwg_change(s, {"dwg_change": True}) == MESSAGES["co_staged_if_dwg_change"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- specific rules ----------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [s for s in STATUSES if s != "filed"])
|
||||||
|
def test_wh_receive_short_passes_in_every_non_filed_status(status: str) -> None:
|
||||||
|
assert check(ROLES["warehouse"], state(status), A.WH_RECEIVE_SHORT).ok
|
||||||
|
assert check(ROLES["warehouse_sup"], state(status), A.WH_RECEIVE_SHORT).ok
|
||||||
|
assert check(ROLES["lead03"], state(status), A.WH_RECEIVE_SHORT).code == "forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wh_receive_short_refused_when_filed() -> None:
|
||||||
|
assert check(ROLES["warehouse"], state("filed"), A.WH_RECEIVE_SHORT).code == "filed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wh_receive_short_needs_an_open_short() -> None:
|
||||||
|
d = check(ROLES["warehouse"], state("ready", open_shorts=0), A.WH_RECEIVE_SHORT)
|
||||||
|
assert d.code == "precondition" and d.why == MESSAGES["short_open"]
|
||||||
|
allowed, blocked = can(ROLES["warehouse"], state("draft", open_shorts=0))
|
||||||
|
assert allowed == [] and blocked == {A.WH_RECEIVE_SHORT: [MESSAGES["short_open"]]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_to_clerical_needs_wh_skipped() -> None:
|
||||||
|
assert check(ROLES["lead03"], state("ready"), A.RETURN_TO_CLERICAL, extra=FULL_EXTRA).ok
|
||||||
|
d = check(ROLES["lead03"], state("ready", wh_skipped=False), A.RETURN_TO_CLERICAL, extra=FULL_EXTRA)
|
||||||
|
assert d.code == "wrong_status"
|
||||||
|
assert "warehouse" in d.why
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_vclosed_needs_the_data_book_route() -> None:
|
||||||
|
assert check(ROLES["clerical"], state("complete"), A.SET_VCLOSED).ok
|
||||||
|
for route in ("shipping", None):
|
||||||
|
d = check(ROLES["clerical"], state("complete", routed_to=route), A.SET_VCLOSED)
|
||||||
|
assert d.code == "wrong_status"
|
||||||
|
assert check(ROLES["qc"], state("complete"), A.SET_VCLOSED).code == "forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", STATUSES)
|
||||||
|
def test_set_databook_at_is_clerical_or_qc(status: str) -> None:
|
||||||
|
s = state(status)
|
||||||
|
for role in ("clerical", "clerical_sup", "qc", "qc_sup"):
|
||||||
|
assert check(ROLES[role], s, A.SET_DATABOOK_AT).ok
|
||||||
|
for role in ("warehouse", "warehouse_sup", "sup03", "lead03", "pm", "welder"):
|
||||||
|
assert check(ROLES[role], s, A.SET_DATABOOK_AT).code == "forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rev_approve_needs_clerical_supervisor() -> None:
|
||||||
|
extra = {"reason": "Drafting confirmed rev C."}
|
||||||
|
assert check(ROLES["clerical_sup"], state("draft"), A.REV_APPROVE, extra=extra).ok
|
||||||
|
assert check(ROLES["clerical"], state("draft"), A.REV_APPROVE, extra=extra).code == "forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_co_verify_needs_a_supervisor_of_the_named_department() -> None:
|
||||||
|
s = state("in_progress")
|
||||||
|
assert check(ROLES["sup03"], s, A.CO_VERIFY, extra={"dept": "03"}).ok
|
||||||
|
assert check(ROLES["sup03"], s, A.CO_VERIFY, extra={"dept": "06"}).code == "forbidden"
|
||||||
|
assert check(ROLES["lead03"], s, A.CO_VERIFY, extra={"dept": "03"}).code == "forbidden"
|
||||||
|
assert check(ROLES["warehouse_sup"], s, A.CO_VERIFY, extra={"dept": "WH"}).ok
|
||||||
|
assert check(ROLES["warehouse"], s, A.CO_VERIFY, extra={"dept": "WH"}).code == "forbidden"
|
||||||
|
assert check(ROLES["qc_sup"], s, A.CO_VERIFY, extra={"dept": "QC"}).ok
|
||||||
|
assert check(ROLES["clerical_sup"], s, A.CO_VERIFY, extra={"dept": "CL"}).code == "forbidden"
|
||||||
|
assert check(ROLES["sup03"], s, A.CO_VERIFY).code == "forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", list(RULES))
|
||||||
|
def test_pm_can_do_nothing(action: Action) -> None:
|
||||||
|
pm = ROLES["pm"]
|
||||||
|
for status in STATUSES:
|
||||||
|
assert not check(pm, state(status), action, extra=FULL_EXTRA).ok
|
||||||
|
for status in STATUSES:
|
||||||
|
assert can(pm, state(status)) == ([], {})
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM = Principal(user_id=None, system="visual-feed")
|
||||||
|
VIS = {A.VIS_UPDATE_DRAFT, A.VIS_OPEN_CO, A.VIS_AUTO_RELEASE, A.VIS_RETIRE_DRAFT}
|
||||||
|
VIS_FROM = {
|
||||||
|
A.VIS_UPDATE_DRAFT: {"draft"},
|
||||||
|
A.VIS_RETIRE_DRAFT: {"draft"},
|
||||||
|
A.VIS_AUTO_RELEASE: {"draft"},
|
||||||
|
A.VIS_OPEN_CO: set(STATUSES) - {"draft", "filed"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", list(RULES))
|
||||||
|
def test_system_principal(action: Action) -> None:
|
||||||
|
assert {a for a, r in RULES.items() if r.system_only} == VIS
|
||||||
|
for status in STATUSES:
|
||||||
|
d = check(SYSTEM, state(status), action)
|
||||||
|
if action in VIS:
|
||||||
|
assert d.ok is (status in VIS_FROM[action]), (status, d.code)
|
||||||
|
if not d.ok:
|
||||||
|
assert d.code == "wrong_status"
|
||||||
|
elif not d.ok and d.code != "wrong_status":
|
||||||
|
assert d.code == "forbidden"
|
||||||
|
else:
|
||||||
|
assert not d.ok
|
||||||
|
assert can(SYSTEM, state("draft")) == ([], {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", sorted(VIS))
|
||||||
|
def test_people_cannot_do_vis_actions(action: Action) -> None:
|
||||||
|
s = state(sorted(VIS_FROM[action])[0])
|
||||||
|
for p in ROLES.values():
|
||||||
|
d = check(p, s, action, it_reason=IT)
|
||||||
|
assert (d.ok, d.code) == (False, "system_only")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vis_auto_release_preconditions() -> None:
|
||||||
|
d = check(SYSTEM, state("draft", heat_map=None), A.VIS_AUTO_RELEASE)
|
||||||
|
assert (d.code, d.why) == ("precondition", MESSAGES["heat_map_answered"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- can and blocked ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_and_blocked_on_a_bare_draft() -> None:
|
||||||
|
s = state("draft", heat_map=None, drawing_attached=False)
|
||||||
|
allowed, blocked = can(ROLES["clerical"], s)
|
||||||
|
reasons = [MESSAGES["heat_map_answered"], MESSAGES["drawing_attached"]]
|
||||||
|
assert blocked[A.SEND_TO_WAREHOUSE] == reasons
|
||||||
|
assert blocked[A.SEND_DRAFT_TO_QC] == reasons
|
||||||
|
assert blocked[A.RELEASE_TO_SHOP] == reasons
|
||||||
|
assert A.SEND_TO_WAREHOUSE not in allowed
|
||||||
|
assert {A.EDIT_HEADER, A.EDIT_BOM, A.EDIT_WOS, A.REMOVE, A.CO_ISSUE, A.DOC_ADD} <= set(allowed)
|
||||||
|
assert A.REV_APPROVE not in allowed # clerical supervisor only
|
||||||
|
assert all(not RULES[a].system_only for a in allowed)
|
||||||
|
wh_allowed, wh_blocked = can(ROLES["warehouse"], s)
|
||||||
|
assert A.SEND_TO_WAREHOUSE not in wh_allowed
|
||||||
|
assert A.SEND_TO_WAREHOUSE not in wh_blocked
|
||||||
|
assert wh_allowed == [A.WH_RECEIVE_SHORT]
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_skips_input_only_preconditions() -> None:
|
||||||
|
assert {"welder_set", "target_dept_given", "route_given", "reason_given"} <= INPUT_ONLY
|
||||||
|
allowed, _ = can(ROLES["lead03"], state("ready"))
|
||||||
|
assert {A.ASSIGN, A.SEND_BACK_TO_WAREHOUSE, A.RETURN_TO_CLERICAL} <= set(allowed)
|
||||||
|
allowed, _ = can(ROLES["sup03"], state("review"))
|
||||||
|
assert {A.SEND_TO_QC, A.ROUTE_FROM_REVIEW, A.RETURN_TO_STATION} <= set(allowed)
|
||||||
|
allowed, _ = can(ROLES["qc"], state("qc"))
|
||||||
|
assert {A.QC_ACCEPT, A.QC_ACCEPT_AND_ROUTE, A.QC_REJECT} <= set(allowed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocked_by_co_hold_only_after_who_passes() -> None:
|
||||||
|
s = state("warehouse", co_hold=True, co_hold_why="Held for CO 1.", bom_ready=1)
|
||||||
|
_, blocked = can(ROLES["warehouse"], s)
|
||||||
|
assert blocked[A.WH_RELEASE] == ["Held for CO 1.", MESSAGES["bom_all_pulled_or_short"]]
|
||||||
|
_, other = can(ROLES["clerical"], s)
|
||||||
|
assert A.WH_RELEASE not in other
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_for_admin_assumes_an_it_reason() -> None:
|
||||||
|
from packetstation.policy import evaluate
|
||||||
|
|
||||||
|
allowed, _ = can(ROLES["admin"], state("draft"))
|
||||||
|
assert A.SEND_TO_WAREHOUSE in allowed and A.ADMIN_REOPEN not in allowed
|
||||||
|
assert evaluate(ROLES["admin"], state("draft"), A.SEND_TO_WAREHOUSE).admin_bypass
|
||||||
|
no_assume, _ = can(ROLES["admin"], state("draft"), assume_it_reason=False)
|
||||||
|
assert A.SEND_TO_WAREHOUSE not in no_assume
|
||||||
|
filed, _ = can(ROLES["admin"], state("filed"))
|
||||||
|
assert set(filed) == {A.ADMIN_REOPEN, A.SET_DATABOOK_AT}
|
||||||
|
assert not evaluate(ROLES["admin"], state("filed"), A.ADMIN_REOPEN).admin_bypass
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_for_the_welder() -> None:
|
||||||
|
allowed, blocked = can(ROLES["welder"], state("in_progress", checklist_complete=False))
|
||||||
|
assert A.SUBMIT in blocked and blocked[A.SUBMIT] == [MESSAGES["checklist_complete"]]
|
||||||
|
assert {A.EDIT_WELDS, A.CLOCK, A.EDIT_CHECKLIST} <= set(allowed)
|
||||||
|
assert A.PULL_BACK not in allowed
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- principal_from_roles ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_principal_from_roles() -> None:
|
||||||
|
cs = principal_from_roles("u1", ["clerical_supervisor"])
|
||||||
|
assert cs.support == {"CL"} and cs.support_sup == {"CL"}
|
||||||
|
c = principal_from_roles("u1", ["clerical"])
|
||||||
|
assert c.support == {"CL"} and c.support_sup == frozenset()
|
||||||
|
w = principal_from_roles("u1", ["warehouse_supervisor", "qc"])
|
||||||
|
assert w.support == {"WH", "QC"} and w.support_sup == {"WH"}
|
||||||
|
shop = principal_from_roles("u1", ["shop:03:worker", "shop:06:supervisor", "shop:03:lead", "shop:10:worker"])
|
||||||
|
assert dict(shop.shop) == {"03": 2, "06": 3, "10": 1}
|
||||||
|
assert isinstance(shop.shop, MappingProxyType)
|
||||||
|
admin = principal_from_roles("u1", ["admin"])
|
||||||
|
assert admin.admin and not admin.support and not admin.shop and not admin.pm
|
||||||
|
pm = principal_from_roles("u1", ["pm"], session_kind="pin")
|
||||||
|
assert pm.pm and pm.session_kind == "pin" and not pm.support
|
||||||
|
limited = principal_from_roles("u1", ["shop:03:worker"], station_limits=[3, 4])
|
||||||
|
assert limited.station_limits == {3, 4}
|
||||||
|
assert principal_from_roles("u1", ["nonsense", "shop:03:boss"]) == Principal(user_id="u1")
|
||||||
|
assert principal_from_roles("u1", []).system is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_role_user() -> None:
|
||||||
|
mia = principal_from_roles("u9", ["clerical", "shop:06:supervisor"])
|
||||||
|
assert check(mia, state("draft"), A.SEND_TO_WAREHOUSE).ok
|
||||||
|
assert check(mia, state("review", dept="06"), A.SEND_TO_QC, extra=FULL_EXTRA).ok
|
||||||
|
assert check(mia, state("review"), A.SEND_TO_QC, extra=FULL_EXTRA).code == "forbidden"
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
"""Projects and units API: numbers, lots, aliases, renumbering, field CAS, soft delete, counts and access."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fakedir import FakeDirectory
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg, sign_in
|
||||||
|
|
||||||
|
from packetstation.api import projects, units
|
||||||
|
from packetstation.domain.projects import normalize_lot
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
IT = "Ticket 4411: fixing a typo for Clerical"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
|
||||||
|
from packetstation.main import create_app
|
||||||
|
|
||||||
|
app = create_app(pg_settings, directory=fake_dir)
|
||||||
|
app.include_router(projects.router)
|
||||||
|
app.include_router(units.router)
|
||||||
|
# the static catch-all must stay last, or it answers the GET routes added here
|
||||||
|
app.router.routes.sort(key=lambda r: getattr(r, "path", "") == "/{path:path}")
|
||||||
|
with TestClient(app, headers=ORIGIN_HEADERS) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def as_user(client: TestClient, sam: str) -> None:
|
||||||
|
client.post("/api/auth/signout")
|
||||||
|
r = sign_in(client, sam)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def audit_rows(settings: Settings, prefix: str) -> list[dict[str, Any]]:
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
with app_conn(settings) as conn, conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
return cur.execute(
|
||||||
|
"SELECT action, subject_type, subject_id, it_reason, detail FROM audit_events WHERE action LIKE %s"
|
||||||
|
" ORDER BY id",
|
||||||
|
(prefix + "%",),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def add_packet(settings: Settings, unit_id: int, status: str = "draft") -> int:
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
return int(
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO packets (unit_id, dept_code, status) VALUES (%s, '03', %s) RETURNING id", (unit_id, status)
|
||||||
|
).fetchone()[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_project(client: TestClient, number: str = "L670", **extra: Any) -> dict[str, Any]:
|
||||||
|
r = client.post("/api/projects", json={"number": number, "name": "Separator skids", **extra})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
out: dict[str, Any] = r.json()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def add_units(client: TestClient, project_id: int, **body: Any) -> list[dict[str, Any]]:
|
||||||
|
r = client.post(f"/api/projects/{project_id}/units", json=body)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
out: list[dict[str, Any]] = r.json()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_lot() -> None:
|
||||||
|
assert normalize_lot("7") == "007"
|
||||||
|
assert normalize_lot("7a") == "7A"
|
||||||
|
assert normalize_lot(" 12 ") == "012"
|
||||||
|
assert normalize_lot("A") == "A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_rules(client: TestClient, pg_settings: Settings) -> None:
|
||||||
|
as_user(client, "clerk.clara")
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects",
|
||||||
|
json={"number": " l670 ", "name": "Separator skids", "customer": "Acme", "po": "PO-1", "due": "2026-12-01"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
p = r.json()
|
||||||
|
assert p["number"] == "L670" and p["status"] == "open" and p["due"] == "2026-12-01" and p["version"] == 1
|
||||||
|
assert p["units"] == 0 and p["packets"]["total"] == 0 and p["source"] == "hand"
|
||||||
|
rows = audit_rows(pg_settings, "project.")
|
||||||
|
assert rows[-1]["action"] == "project.created" and rows[-1]["subject_type"] == "project"
|
||||||
|
assert rows[-1]["subject_id"] == str(p["id"]) and rows[-1]["it_reason"] is None
|
||||||
|
|
||||||
|
r = client.post("/api/projects", json={"number": "L670"})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "number_taken"
|
||||||
|
r = client.post("/api/projects", json={"number": "L6 70!"})
|
||||||
|
assert r.status_code == 422 and r.json()["code"] == "invalid_number"
|
||||||
|
r = client.post("/api/projects", json={"number": "L680", "due": "12/01/2026"})
|
||||||
|
assert r.status_code == 422 and r.json()["code"] == "invalid_date"
|
||||||
|
|
||||||
|
as_user(client, "wh.wade")
|
||||||
|
r = client.post("/api/projects", json={"number": "L680"})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden"
|
||||||
|
|
||||||
|
as_user(client, "admin.ann")
|
||||||
|
r = client.post("/api/projects", json={"number": "L680"})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "it_reason_required"
|
||||||
|
r = client.post("/api/projects", json={"number": "L680", "it_reason": "short"})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "it_reason_required"
|
||||||
|
r = client.post("/api/projects", json={"number": "L680", "it_reason": IT})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
last = audit_rows(pg_settings, "project.")[-1]
|
||||||
|
assert last["action"] == "project.created" and last["it_reason"] == IT
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_units(client: TestClient, pg_settings: Settings) -> None:
|
||||||
|
as_user(client, "clerk.clara")
|
||||||
|
p = make_project(client)
|
||||||
|
one = add_units(client, p["id"], lot="1", name="Skid A")
|
||||||
|
assert [(u["lot"], u["number"], u["name"]) for u in one] == [("001", "L670001", "Skid A")]
|
||||||
|
several = add_units(client, p["id"], count=3, start="005", name="Skid B")
|
||||||
|
assert [u["number"] for u in several] == ["L670005", "L670006", "L670007"]
|
||||||
|
assert add_units(client, p["id"], lot="7a")[0]["lot"] == "7A"
|
||||||
|
# a full unit number is cut to its lot; with nothing given the next free lot is used
|
||||||
|
assert add_units(client, p["id"], lot="L670012")[0]["lot"] == "012"
|
||||||
|
assert add_units(client, p["id"])[0]["lot"] == "013"
|
||||||
|
assert [u["lot"] for u in add_units(client, p["id"], lots=["20", "21"])] == ["020", "021"]
|
||||||
|
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/units", json={"lot": "0001"})
|
||||||
|
assert r.status_code == 422 and r.json()["code"] == "invalid_lot"
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/units", json={"lot": "001"})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "number_taken"
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/units", json={"count": 3, "start": "004"})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "number_taken"
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/units", json={"count": 3, "start": "998"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/units", json={"count": 0, "start": "100"})
|
||||||
|
assert r.status_code == 422 and r.json()["code"] == "invalid_count"
|
||||||
|
|
||||||
|
created = [r for r in audit_rows(pg_settings, "unit.") if r["action"] == "unit.created"]
|
||||||
|
assert len(created) == 9 and created[0]["subject_type"] == "unit" and created[0]["detail"]["number"] == "L670001"
|
||||||
|
|
||||||
|
detail = client.get(f"/api/projects/{p['id']}").json()
|
||||||
|
assert detail["next_lot"] == "022" and len(detail["units"]) == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_counts_and_search(client: TestClient, pg_settings: Settings) -> None:
|
||||||
|
as_user(client, "clerk.clara")
|
||||||
|
p = make_project(client, customer="Acme Gas", po="PO-77")
|
||||||
|
make_project(client, "L700", name="Heater treaters")
|
||||||
|
u1, u2 = add_units(client, p["id"], count=2, start="001")
|
||||||
|
for status in ("draft", "ready", "in_progress", "qc", "filed"):
|
||||||
|
add_packet(pg_settings, u1["id"], status)
|
||||||
|
add_packet(pg_settings, u2["id"], "warehouse")
|
||||||
|
gone = add_packet(pg_settings, u2["id"], "complete")
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
conn.execute("UPDATE packets SET removed_at = now() WHERE id = %s", (gone,))
|
||||||
|
|
||||||
|
listing = {x["number"]: x for x in client.get("/api/projects").json()}
|
||||||
|
assert set(listing) == {"L670", "L700"}
|
||||||
|
assert listing["L670"]["units"] == 2
|
||||||
|
assert listing["L670"]["packets"] == {
|
||||||
|
"draft": 1,
|
||||||
|
"warehouse": 1,
|
||||||
|
"shop": 2,
|
||||||
|
"qc": 1,
|
||||||
|
"complete": 0,
|
||||||
|
"filed": 1,
|
||||||
|
"total": 6,
|
||||||
|
}
|
||||||
|
detail = client.get(f"/api/projects/{p['id']}").json()
|
||||||
|
by_lot = {u["lot"]: u for u in detail["units"]}
|
||||||
|
assert by_lot["001"]["packets"]["total"] == 5 and by_lot["002"]["packets"]["total"] == 1
|
||||||
|
assert detail["aliases"] == []
|
||||||
|
|
||||||
|
def numbers(**params: str) -> list[str]:
|
||||||
|
return [x["number"] for x in client.get("/api/projects", params=params).json()]
|
||||||
|
|
||||||
|
assert numbers(q="separator") == ["L670"]
|
||||||
|
assert numbers(q="acme po-77") == ["L670"]
|
||||||
|
assert numbers(q="heater") == ["L700"]
|
||||||
|
assert numbers(q="acme heater") == []
|
||||||
|
assert client.get("/api/projects", params={"status": "bogus"}).status_code == 422
|
||||||
|
|
||||||
|
picker = client.get("/api/units", params={"project_id": p["id"]}).json()
|
||||||
|
assert [u["number"] for u in picker] == ["L670001", "L670002"]
|
||||||
|
assert [u["number"] for u in client.get("/api/units", params={"q": "L670002"}).json()] == ["L670002"]
|
||||||
|
one = client.get(f"/api/units/{u1['id']}").json()
|
||||||
|
assert one["project"]["number"] == "L670" and one["packets"]["shop"] == 2 and one["due_effective"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_field_cas(client: TestClient, pg_settings: Settings) -> None:
|
||||||
|
as_user(client, "clerk.clara")
|
||||||
|
p = make_project(client, customer="Acme")
|
||||||
|
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"customer": "Acme Gas"}, "base": {"customer": "Acme"}})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["customer"] == "Acme Gas" and r.json()["version"] == 2
|
||||||
|
# a stale base conflicts and returns the current value
|
||||||
|
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"customer": "Acme Oil"}, "base": {"customer": "Acme"}})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "field_conflict"
|
||||||
|
assert r.json()["current"] == {"customer": "Acme Gas"}
|
||||||
|
# no base: mine wins; a value equal to the current one is not a conflict
|
||||||
|
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"due": "2027-01-15", "status": "hold"}})
|
||||||
|
assert r.status_code == 200 and r.json()["due"] == "2027-01-15" and r.json()["status"] == "hold"
|
||||||
|
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"customer": "Acme Gas"}, "base": {"customer": "x"}})
|
||||||
|
assert r.status_code == 200 and r.json()["version"] == 3
|
||||||
|
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"number": "L999"}})
|
||||||
|
assert r.status_code == 422 and r.json()["code"] == "invalid_field"
|
||||||
|
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"status": "done"}})
|
||||||
|
assert r.status_code == 422 and r.json()["code"] == "invalid_status"
|
||||||
|
updated = [x for x in audit_rows(pg_settings, "project.") if x["action"] == "project.updated"]
|
||||||
|
assert len(updated) == 2 and updated[0]["detail"]["fields"]["customer"] == {"from": "Acme", "to": "Acme Gas"}
|
||||||
|
|
||||||
|
(u,) = add_units(client, p["id"], lot="1")
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/units/{u['id']}", json={"set": {"name": "Skid A", "due": "2027-02-01"}, "base": {"name": None}}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["name"] == "Skid A" and r.json()["due_effective"] == "2027-02-01"
|
||||||
|
r = client.patch(f"/api/units/{u['id']}", json={"set": {"name": "Skid Z"}, "base": {"name": "Skid Q"}})
|
||||||
|
assert r.status_code == 409 and r.json()["current"] == {"name": "Skid A"}
|
||||||
|
assert audit_rows(pg_settings, "unit.updated")[-1]["subject_id"] == str(u["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_renumber_project(client: TestClient, pg_settings: Settings) -> None:
|
||||||
|
as_user(client, "clerk.clara")
|
||||||
|
p = make_project(client)
|
||||||
|
u1, u2 = add_units(client, p["id"], count=2, start="001")
|
||||||
|
pk = add_packet(pg_settings, u1["id"])
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L671", "reason": "Wrong number"})
|
||||||
|
assert r.status_code == 403 and r.json()["code"] == "forbidden"
|
||||||
|
|
||||||
|
as_user(client, "clerk.leo")
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L671"})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "reason_required"
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "l671", "reason": "Wrong number"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["number"] == "L671" and r.json()["version"] == 2
|
||||||
|
|
||||||
|
def look(n: str) -> dict[str, Any]:
|
||||||
|
res = client.get("/api/lookup/number", params={"n": n})
|
||||||
|
assert res.status_code == 200, (n, res.text)
|
||||||
|
out: dict[str, Any] = res.json()
|
||||||
|
return out
|
||||||
|
|
||||||
|
assert look("L670") == {"kind": "project", "id": p["id"], "project_id": p["id"], "number": "L671", "alias": True}
|
||||||
|
assert look("L671")["alias"] is False and look("L671")["id"] == p["id"]
|
||||||
|
assert look("l670002") == {
|
||||||
|
"kind": "unit",
|
||||||
|
"id": u2["id"],
|
||||||
|
"project_id": p["id"],
|
||||||
|
"number": "L671002",
|
||||||
|
"alias": True,
|
||||||
|
}
|
||||||
|
assert look("L671001")["id"] == u1["id"] and look("L671001")["alias"] is False
|
||||||
|
assert client.get("/api/lookup/number", params={"n": "L999"}).status_code == 404
|
||||||
|
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT unit_id, old_number, new_number, reason FROM project_renumbers ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
assert rows == [
|
||||||
|
(None, "L670", "L671", "Wrong number"),
|
||||||
|
(u1["id"], "L670001", "L671001", "Wrong number"),
|
||||||
|
(u2["id"], "L670002", "L671002", "Wrong number"),
|
||||||
|
]
|
||||||
|
# the packet still points at its unit
|
||||||
|
assert conn.execute("SELECT unit_id FROM packets WHERE id = %s", (pk,)).fetchone()[0] == u1["id"]
|
||||||
|
detail = client.get(f"/api/projects/{p['id']}").json()
|
||||||
|
assert [u["number"] for u in detail["units"]] == ["L671001", "L671002"]
|
||||||
|
assert {a["alias"] for a in detail["aliases"]} == {"L670", "L670001", "L670002"}
|
||||||
|
assert [x["number"] for x in client.get("/api/projects", params={"q": "L670"}).json()] == ["L671"]
|
||||||
|
last = audit_rows(pg_settings, "project.renumbered")[-1]
|
||||||
|
assert last["detail"] == {"from": "L670", "to": "L671", "reason": "Wrong number", "units": 2}
|
||||||
|
|
||||||
|
# old numbers stay taken; renumbering back to its own old number is allowed
|
||||||
|
r = client.post("/api/projects", json={"number": "L670"})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "number_taken"
|
||||||
|
r = client.post("/api/projects", json={"number": "L670001"})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "number_taken"
|
||||||
|
make_project(client, "L800")
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L800", "reason": "Wrong number"})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "number_taken"
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L671", "reason": "Same"})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "same_number"
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L670", "reason": "Back again"})
|
||||||
|
assert r.status_code == 200 and r.json()["number"] == "L670"
|
||||||
|
assert look("L671")["alias"] is True and look("L670")["alias"] is False
|
||||||
|
|
||||||
|
as_user(client, "admin.ann")
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L672", "reason": "Fix"})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "it_reason_required"
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L672", "reason": "Fix", "it_reason": IT})
|
||||||
|
assert r.status_code == 200 and audit_rows(pg_settings, "project.renumbered")[-1]["it_reason"] == IT
|
||||||
|
|
||||||
|
|
||||||
|
def test_renumber_unit(client: TestClient, pg_settings: Settings) -> None:
|
||||||
|
as_user(client, "clerk.clara")
|
||||||
|
p = make_project(client)
|
||||||
|
u1, _u2 = add_units(client, p["id"], count=2, start="001")
|
||||||
|
r = client.post(f"/api/units/{u1['id']}/renumber", json={"lot": "10", "reason": "Lot was wrong"})
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
as_user(client, "clerk.leo")
|
||||||
|
r = client.post(f"/api/units/{u1['id']}/renumber", json={"lot": "2", "reason": "Lot was wrong"})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "number_taken"
|
||||||
|
r = client.post(f"/api/units/{u1['id']}/renumber", json={"lot": "10", "reason": "Lot was wrong"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["lot"] == "010" and r.json()["number"] == "L670010" and r.json()["version"] == 2
|
||||||
|
found = client.get("/api/lookup/number", params={"n": "L670001"}).json()
|
||||||
|
assert found == {"kind": "unit", "id": u1["id"], "project_id": p["id"], "number": "L670010", "alias": True}
|
||||||
|
aliases = client.get(f"/api/units/{u1['id']}").json()["aliases"]
|
||||||
|
assert [(a["alias"], a["kind"]) for a in aliases] == [("L670001", "unit_renumber")]
|
||||||
|
# the old lot is taken for a new unit, but the unit itself may go back to it
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/units", json={"lot": "001"})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "number_taken"
|
||||||
|
r = client.post(f"/api/units/{u1['id']}/renumber", json={"lot": "001", "reason": "Back again"})
|
||||||
|
assert r.status_code == 200 and r.json()["number"] == "L670001"
|
||||||
|
assert audit_rows(pg_settings, "unit.renumbered")[0]["detail"]["to"] == "L670010"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_units_and_projects(client: TestClient, pg_settings: Settings) -> None:
|
||||||
|
as_user(client, "clerk.clara")
|
||||||
|
p = make_project(client)
|
||||||
|
u1, u2 = add_units(client, p["id"], count=2, start="001")
|
||||||
|
add_packet(pg_settings, u1["id"], "filed")
|
||||||
|
r = client.request("DELETE", f"/api/units/{u2['id']}", json={"reason": "Made by mistake"})
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
as_user(client, "clerk.leo")
|
||||||
|
r = client.request("DELETE", f"/api/units/{u2['id']}", json={})
|
||||||
|
assert r.status_code == 400 and r.json()["code"] == "reason_required"
|
||||||
|
r = client.request("DELETE", f"/api/units/{u2['id']}", json={"reason": "Made by mistake"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["removed"]["reason"] == "Made by mistake"
|
||||||
|
assert client.get(f"/api/units/{u2['id']}").status_code == 404
|
||||||
|
r = client.request("DELETE", f"/api/units/{u1['id']}", json={"reason": "Made by mistake"})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "has_packets" and r.json()["packets"] == 1
|
||||||
|
r = client.request("DELETE", f"/api/projects/{p['id']}", json={"reason": "Made by mistake"})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "has_packets"
|
||||||
|
# the removed unit's lot is free again
|
||||||
|
assert add_units(client, p["id"], lot="2")[0]["number"] == "L670002"
|
||||||
|
|
||||||
|
q = make_project(client, "L700")
|
||||||
|
v1, v2 = add_units(client, q["id"], count=2, start="001")
|
||||||
|
r = client.request("DELETE", f"/api/projects/{q['id']}", json={"reason": "Duplicate project"})
|
||||||
|
assert r.status_code == 200 and r.json()["removed"]["reason"] == "Duplicate project"
|
||||||
|
assert client.get(f"/api/projects/{q['id']}").status_code == 404
|
||||||
|
assert client.get(f"/api/units/{v1['id']}").status_code == 404
|
||||||
|
assert "L700" not in [x["number"] for x in client.get("/api/projects", params={"status": "all"}).json()]
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
removed = conn.execute(
|
||||||
|
"SELECT count(*) FROM units WHERE project_id = %s AND removed_at IS NOT NULL", (q["id"],)
|
||||||
|
).fetchone()[0]
|
||||||
|
assert removed == 2
|
||||||
|
assert audit_rows(pg_settings, "project.removed")[-1]["detail"]["units"] == [v1["id"], v2["id"]]
|
||||||
|
assert [x["action"] for x in audit_rows(pg_settings, "unit.removed")] == ["unit.removed"]
|
||||||
|
# the number can be used again
|
||||||
|
make_project(client, "L700")
|
||||||
|
|
||||||
|
|
||||||
|
def test_closed_project(client: TestClient) -> None:
|
||||||
|
as_user(client, "clerk.clara")
|
||||||
|
p = make_project(client, due="2026-12-01")
|
||||||
|
(u,) = add_units(client, p["id"], lot="1")
|
||||||
|
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"status": "closed"}, "base": {"status": "open"}})
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = client.post(f"/api/projects/{p['id']}/units", json={"lot": "2"})
|
||||||
|
assert r.status_code == 409 and r.json()["code"] == "project_closed"
|
||||||
|
one = client.get(f"/api/units/{u['id']}").json()
|
||||||
|
assert one["status"] == "open" and one["status_effective"] == "closed" and one["due_effective"] == "2026-12-01"
|
||||||
|
assert client.get("/api/units", params={"project_id": p["id"]}).json() == []
|
||||||
|
closed = client.get("/api/units", params={"project_id": p["id"], "status": "closed"}).json()
|
||||||
|
assert [x["number"] for x in closed] == ["L670001"]
|
||||||
|
assert client.get("/api/projects").json() == []
|
||||||
|
assert [x["number"] for x in client.get("/api/projects", params={"status": "closed"}).json()] == ["L670"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pm_reads_only(client: TestClient) -> None:
|
||||||
|
as_user(client, "clerk.clara")
|
||||||
|
p = make_project(client)
|
||||||
|
(u,) = add_units(client, p["id"], lot="1")
|
||||||
|
as_user(client, "pm.paula")
|
||||||
|
assert client.get("/api/projects").status_code == 200
|
||||||
|
assert client.get(f"/api/projects/{p['id']}").status_code == 200
|
||||||
|
assert client.get(f"/api/units/{u['id']}").status_code == 200
|
||||||
|
assert client.get("/api/units", params={"project_id": p["id"]}).status_code == 200
|
||||||
|
assert client.get("/api/lookup/number", params={"n": "L670001"}).status_code == 200
|
||||||
|
assert client.post("/api/projects", json={"number": "L900"}).status_code == 403
|
||||||
|
assert client.patch(f"/api/projects/{p['id']}", json={"set": {"name": "x"}}).status_code == 403
|
||||||
|
assert client.post(f"/api/projects/{p['id']}/units", json={"lot": "2"}).status_code == 403
|
||||||
|
assert client.patch(f"/api/units/{u['id']}", json={"set": {"name": "x"}}).status_code == 403
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""Migration 0003_domain: enums, constraints, append-only tables, views and app-role grants."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
from pshelpers import app_conn, needs_pg
|
||||||
|
from psycopg import sql
|
||||||
|
|
||||||
|
from packetstation import db, migrate
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
ENUMS = {
|
||||||
|
"packet_status": [
|
||||||
|
"draft",
|
||||||
|
"warehouse",
|
||||||
|
"ready",
|
||||||
|
"assigned",
|
||||||
|
"in_progress",
|
||||||
|
"review",
|
||||||
|
"qc",
|
||||||
|
"complete",
|
||||||
|
"filed",
|
||||||
|
],
|
||||||
|
"wh_stage": ["incoming", "pulling", "hold", "pulled"],
|
||||||
|
"co_review": ["pending", "approved", "rejected"],
|
||||||
|
"co_origin": ["visual", "drawing", "manual"],
|
||||||
|
"source_kind": ["visual", "hand"],
|
||||||
|
"project_status": ["open", "hold", "closed"],
|
||||||
|
"route_kind": ["databook", "shipping"],
|
||||||
|
"drawing_state": [
|
||||||
|
"none",
|
||||||
|
"needed",
|
||||||
|
"attached",
|
||||||
|
"rev_mismatch",
|
||||||
|
"not_released",
|
||||||
|
"not_found",
|
||||||
|
"lookup_failed",
|
||||||
|
"hand",
|
||||||
|
],
|
||||||
|
"itp_status": ["open", "signed", "reopened"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# One minimal INSERT per append-only table, with ids from seed().
|
||||||
|
APPEND_INSERTS = {
|
||||||
|
"project_renumbers": (
|
||||||
|
"INSERT INTO project_renumbers (project_id, old_number, new_number, by) "
|
||||||
|
"VALUES (%(project)s, 'L998', 'L999', %(user)s)"
|
||||||
|
),
|
||||||
|
"short_receipts": (
|
||||||
|
"INSERT INTO short_receipts (short_id, packet_id, qty, by) VALUES (%(short)s, %(packet)s, 1, %(user)s)"
|
||||||
|
),
|
||||||
|
"packet_transitions": (
|
||||||
|
"INSERT INTO packet_transitions (packet_id, action, by) VALUES (%(packet)s, 'send_to_warehouse', %(user)s)"
|
||||||
|
),
|
||||||
|
"qc_history": "INSERT INTO qc_history (packet_id, result) VALUES (%(packet)s, 'accepted')",
|
||||||
|
"packet_log": (
|
||||||
|
"INSERT INTO packet_log (packet_id, action, by) VALUES (%(packet)s, 'Packet created by hand', %(user)s)"
|
||||||
|
),
|
||||||
|
"itp_rejects": "INSERT INTO itp_rejects (itp_id, line_key, ncr) VALUES (%(itp)s, 'k1', 'NCR-1')",
|
||||||
|
"co_verifications": (
|
||||||
|
"INSERT INTO co_verifications (co_id, dept_code, by, signature_id) VALUES (%(co)s, 'WH', %(user)s, %(sig)s)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
APPEND_ONLY = list(APPEND_INSERTS)
|
||||||
|
|
||||||
|
|
||||||
|
def q(template: str, name: str) -> sql.Composed:
|
||||||
|
return sql.SQL(template).format(sql.Identifier(name))
|
||||||
|
|
||||||
|
|
||||||
|
def one(conn: Any, query: Any, params: Any = None) -> Any:
|
||||||
|
row = conn.execute(query, params).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
def seed(conn: Any) -> dict[str, Any]:
|
||||||
|
"""A user, a signature, project L999 with unit 001, one packet with a BOM line, a short, a CO and an ITP."""
|
||||||
|
ids: dict[str, Any] = {}
|
||||||
|
ids["user"] = uid = uuid.uuid4()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (id, sam_account, display_name, welder_stamp) VALUES (%s, %s, %s, %s)",
|
||||||
|
(uid, f"weld.{uid.hex[:6]}", "Andy Welder", "A7"),
|
||||||
|
)
|
||||||
|
ids["sig"] = one(conn, "INSERT INTO signatures (user_id, action) VALUES (%s, 'test') RETURNING id", (uid,))
|
||||||
|
ids["project"] = pid = one(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO projects (number, name, due, created_by) VALUES ('L999', 'Test', '2026-12-01', %s) RETURNING id",
|
||||||
|
(uid,),
|
||||||
|
)
|
||||||
|
ids["unit"] = unit = one(conn, "INSERT INTO units (project_id, lot) VALUES (%s, '001') RETURNING id", (pid,))
|
||||||
|
ids["packet"] = pk = one(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO packets (unit_id, dept_code, op, created_by) VALUES (%s, '03', 'WELD-PART', %s) RETURNING id",
|
||||||
|
(unit, uid),
|
||||||
|
)
|
||||||
|
ids["bom"] = bom = one(
|
||||||
|
conn, "INSERT INTO bom_lines (packet_id, item, qty, part_id) VALUES (%s, 1, 2, '00123') RETURNING id", (pk,)
|
||||||
|
)
|
||||||
|
ids["short"] = one(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open) VALUES (%s, %s, 2, 2) RETURNING id",
|
||||||
|
(pk, bom),
|
||||||
|
)
|
||||||
|
ids["co"] = new_co(conn, pk, 1, "manual", "approved")
|
||||||
|
ids["itp"] = one(conn, "INSERT INTO itps (unit_id, template) VALUES (%s, '{}') RETURNING id", (unit,))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def new_co(conn: Any, packet_id: int, co_no: int, origin: str, review: str, hold: bool = False) -> int:
|
||||||
|
co_id: int = one(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO change_orders (packet_id, co_no, origin, review, hold) VALUES (%s, %s, %s, %s, %s) RETURNING id",
|
||||||
|
(packet_id, co_no, origin, review, hold),
|
||||||
|
)
|
||||||
|
return co_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrations_apply_from_zero(pg_settings: Settings) -> None:
|
||||||
|
with db.connect_as("migrate", pg_settings, autocommit=True) as conn:
|
||||||
|
assert migrate.run(conn, db.MIGRATIONS_DIR) == []
|
||||||
|
names = [r[0] for r in conn.execute("SELECT name FROM schema_migrations ORDER BY name").fetchall()]
|
||||||
|
assert names[:3] == ["0001_base.sql", "0002_identity.sql", "0003_domain.sql"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_values_in_order(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
for name, values in ENUMS.items():
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT e.enumlabel FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid "
|
||||||
|
"WHERE t.typname = %s ORDER BY e.enumsortorder",
|
||||||
|
(name,),
|
||||||
|
).fetchall()
|
||||||
|
assert [r[0] for r in rows] == values, name
|
||||||
|
# Declaration order is workflow order, so comparisons work.
|
||||||
|
assert one(conn, "SELECT 'qc'::packet_status > 'review'::packet_status") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_departments_weld(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
rows = conn.execute("SELECT code FROM departments WHERE weld ORDER BY code").fetchall()
|
||||||
|
assert [r[0] for r in rows] == ["03", "04", "05", "06", "07", "10"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_only_tables_refuse_update_and_delete(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
ids = seed(conn)
|
||||||
|
for table in APPEND_ONLY:
|
||||||
|
conn.execute(APPEND_INSERTS[table], ids)
|
||||||
|
assert one(conn, q("SELECT count(*) FROM {}", table)) == 1, table
|
||||||
|
# The app role lacks the privilege.
|
||||||
|
for stmt in ("UPDATE {} SET at = at", "DELETE FROM {}", "TRUNCATE {}"):
|
||||||
|
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
||||||
|
conn.execute(q(stmt, table))
|
||||||
|
# The owner has the privilege, so the trigger is what refuses it.
|
||||||
|
with db.connect_as("migrate", pg_settings, autocommit=True) as conn:
|
||||||
|
for table in APPEND_ONLY:
|
||||||
|
for stmt in ("UPDATE {} SET at = at", "DELETE FROM {}", "TRUNCATE {} CASCADE"):
|
||||||
|
with pytest.raises(psycopg.errors.InsufficientPrivilege, match="append-only"):
|
||||||
|
conn.execute(q(stmt, table))
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_only_set_matches_triggers(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT DISTINCT c.relname FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid "
|
||||||
|
"JOIN pg_proc p ON p.oid = t.tgfoid WHERE p.proname = 'ps_append_only'"
|
||||||
|
).fetchall()
|
||||||
|
assert {r[0] for r in rows} == {*APPEND_ONLY, "audit_events", "signin_attempts", "signatures"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_packets_visual_key_live_only(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
ids = seed(conn)
|
||||||
|
ins = (
|
||||||
|
"INSERT INTO packets (unit_id, dept_code, source_kind, visual_key, removed_at) "
|
||||||
|
"VALUES (%s, '03', 'visual', 'L999001|03|D-100', %s)"
|
||||||
|
)
|
||||||
|
conn.execute(ins, (ids["unit"], "2026-10-01T00:00:00Z"))
|
||||||
|
conn.execute(ins, (ids["unit"], "2026-10-02T00:00:00Z"))
|
||||||
|
conn.execute(ins, (ids["unit"], None))
|
||||||
|
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||||
|
conn.execute(ins, (ids["unit"], None))
|
||||||
|
# Packets without a key never collide.
|
||||||
|
conn.execute("INSERT INTO packets (unit_id, dept_code) VALUES (%s, '03')", (ids["unit"],))
|
||||||
|
conn.execute("INSERT INTO packets (unit_id, dept_code) VALUES (%s, '03')", (ids["unit"],))
|
||||||
|
|
||||||
|
|
||||||
|
def test_soft_delete_unique_indexes(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
ids = seed(conn)
|
||||||
|
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||||
|
conn.execute("INSERT INTO projects (number) VALUES ('l999')")
|
||||||
|
conn.execute("UPDATE projects SET removed_at = now() WHERE id = %s", (ids["project"],))
|
||||||
|
conn.execute("INSERT INTO projects (number) VALUES ('l999')")
|
||||||
|
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||||
|
conn.execute("INSERT INTO units (project_id, lot) VALUES (%s, '001')", (ids["project"],))
|
||||||
|
weld = "INSERT INTO welds (packet_id, weld_no, removed_at) VALUES (%s, 1, %s)"
|
||||||
|
conn.execute(weld, (ids["packet"], "2026-10-01T00:00:00Z"))
|
||||||
|
conn.execute(weld, (ids["packet"], None))
|
||||||
|
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||||
|
conn.execute(weld, (ids["packet"], None))
|
||||||
|
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open) VALUES (%s, %s, 1, 1)",
|
||||||
|
(ids["packet"], ids["bom"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_lot_is_text(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
ids = seed(conn)
|
||||||
|
conn.execute("INSERT INTO units (project_id, lot) VALUES (%s, '7A')", (ids["project"],))
|
||||||
|
conn.execute("INSERT INTO units (project_id, lot) VALUES (%s, '010')", (ids["project"],))
|
||||||
|
for bad in ("7", "7a", "0001", ""):
|
||||||
|
with pytest.raises(psycopg.errors.CheckViolation):
|
||||||
|
conn.execute("INSERT INTO units (project_id, lot) VALUES (%s, %s)", (ids["project"], bad))
|
||||||
|
numbers = [r[0] for r in conn.execute("SELECT number FROM v_units ORDER BY id").fetchall()]
|
||||||
|
assert numbers == ["L999001", "L9997A", "L999010"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_v_units(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
ids = seed(conn)
|
||||||
|
conn.execute("INSERT INTO units (project_id, lot, due) VALUES (%s, '002', '2026-11-15')", (ids["project"],))
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT lot, number, project_number, project_name, project_status::text, due_effective::text "
|
||||||
|
"FROM v_units ORDER BY lot"
|
||||||
|
).fetchall()
|
||||||
|
assert rows == [
|
||||||
|
("001", "L999001", "L999", "Test", "open", "2026-12-01"),
|
||||||
|
("002", "L999002", "L999", "Test", "open", "2026-11-15"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_v_open_cos(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
ids = seed(conn)
|
||||||
|
co, pk = ids["co"], ids["packet"]
|
||||||
|
verify = "INSERT INTO co_verifications (co_id, dept_code, by, signature_id) VALUES (%s, %s, %s, %s)"
|
||||||
|
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, 'WH'), (%s, '03')", (co, co))
|
||||||
|
conn.execute(verify, (co, "WH", ids["user"], ids["sig"]))
|
||||||
|
held = new_co(conn, pk, 2, "visual", "pending", hold=True)
|
||||||
|
new_co(conn, pk, 3, "drawing", "pending", hold=False)
|
||||||
|
rejected = new_co(conn, pk, 4, "manual", "rejected", hold=True)
|
||||||
|
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, '03')", (rejected,))
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT packet_id, co_id, co_no, review::text, hold, pending_depts FROM v_open_cos ORDER BY co_no"
|
||||||
|
).fetchall()
|
||||||
|
assert rows == [(pk, co, 1, "approved", False, ["03"]), (pk, held, 2, "pending", True, [])]
|
||||||
|
# Once the last department verifies, the approved CO is no longer open.
|
||||||
|
conn.execute(verify, (co, "03", ids["user"], ids["sig"]))
|
||||||
|
assert [r[0] for r in conn.execute("SELECT co_no FROM v_open_cos").fetchall()] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_v_open_cos_pending_depts_sorted(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
ids = seed(conn)
|
||||||
|
co = ids["co"]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, 'WH'), (%s, 'QC'), (%s, '06')", (co, co, co)
|
||||||
|
)
|
||||||
|
assert one(conn, "SELECT pending_depts FROM v_open_cos WHERE co_id = %s", (co,)) == ["06", "QC", "WH"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_pending_visual_co_per_packet(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
ids = seed(conn)
|
||||||
|
new_co(conn, ids["packet"], 2, "visual", "pending")
|
||||||
|
new_co(conn, ids["packet"], 3, "visual", "approved")
|
||||||
|
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||||
|
new_co(conn, ids["packet"], 4, "visual", "pending")
|
||||||
|
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||||
|
new_co(conn, ids["packet"], 2, "manual", "approved")
|
||||||
|
|
||||||
|
|
||||||
|
def test_co_verifications_insert_only(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
ids = seed(conn)
|
||||||
|
conn.execute(APPEND_INSERTS["co_verifications"], ids)
|
||||||
|
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||||
|
conn.execute(APPEND_INSERTS["co_verifications"], ids)
|
||||||
|
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
||||||
|
conn.execute("UPDATE co_verifications SET note = 'x'")
|
||||||
|
with pytest.raises(psycopg.errors.NotNullViolation):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO co_verifications (co_id, dept_code, by) VALUES (%s, '03', %s)", (ids["co"], ids["user"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_packet_touch_sets_updated_at_only(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
ids = seed(conn)
|
||||||
|
conn.execute("UPDATE packets SET title = 'Frame' WHERE id = %s", (ids["packet"],))
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT version, updated_at > created_at, updated_at > now() - interval '1 minute' FROM packets "
|
||||||
|
"WHERE id = %s",
|
||||||
|
(ids["packet"],),
|
||||||
|
).fetchone()
|
||||||
|
assert row == (1, True, True)
|
||||||
|
# Even an explicit old value is replaced: the trigger owns updated_at.
|
||||||
|
conn.execute("UPDATE packets SET updated_at = '2000-01-01T00:00:00Z' WHERE id = %s", (ids["packet"],))
|
||||||
|
assert one(conn, "SELECT updated_at > '2001-01-01' FROM packets WHERE id = %s", (ids["packet"],)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_can_write_every_table_and_read_every_view(pg_settings: Settings) -> None:
|
||||||
|
with app_conn(pg_settings) as conn:
|
||||||
|
ids = seed(conn)
|
||||||
|
ids["station"] = one(conn, "SELECT id FROM stations WHERE dept_code = '03' ORDER BY sort LIMIT 1")
|
||||||
|
ids["asset"] = one(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO assets (sha256, size, mime, storage_key, pages, uploaded_by) "
|
||||||
|
"VALUES ('\\x00', 10, 'application/pdf', 'ab/abc', 1, %(user)s) RETURNING id",
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
ids["stage"] = one(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO packet_stages (packet_id, dept_code, station_id, welder_id) "
|
||||||
|
"VALUES (%(packet)s, '03', %(station)s, %(user)s) RETURNING id",
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
statements = [
|
||||||
|
"INSERT INTO user_station_limits (user_id, station_id) VALUES (%(user)s, %(station)s)",
|
||||||
|
"INSERT INTO project_aliases (alias, project_id, unit_id, kind) "
|
||||||
|
"VALUES ('L998001', %(project)s, %(unit)s, 'import')",
|
||||||
|
"INSERT INTO co_items (co_id, old_page, new_page, added) VALUES (%(co)s, 1, 2, true)",
|
||||||
|
"INSERT INTO co_required_depts (co_id, dept_code) VALUES (%(co)s, 'QC')",
|
||||||
|
"INSERT INTO packet_work_orders (packet_id, wo_base, lot, split, sub, seq) "
|
||||||
|
"VALUES (%(packet)s, '0670001', '7A', '0', '0', 10)",
|
||||||
|
"INSERT INTO packet_pages (packet_id, n, type, asset_id, co_id) "
|
||||||
|
"VALUES (%(packet)s, 1, 'drawing', %(asset)s, %(co)s)",
|
||||||
|
"INSERT INTO welds (packet_id, weld_no, items, welder_id, dept_code, stage_id, by) "
|
||||||
|
"VALUES (%(packet)s, 1, '{1,2}', %(user)s, '03', %(stage)s, %(user)s)",
|
||||||
|
"INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy) VALUES (%(packet)s, 1, 1, 1, 0.5, 0.5)",
|
||||||
|
"INSERT INTO heat_table_placements (packet_id, page, x, y, s) VALUES (%(packet)s, 1, 0.1, 0.2, 1)",
|
||||||
|
"INSERT INTO packet_wh (packet_id, stage, by) VALUES (%(packet)s, 'incoming', %(user)s)",
|
||||||
|
"INSERT INTO qc_inspections (packet_id, stage_id, sent_by, book, spool, location, signature_id) "
|
||||||
|
"VALUES (%(packet)s, %(stage)s, %(user)s, true, true, 'Bay 2', %(sig)s)",
|
||||||
|
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by) "
|
||||||
|
"VALUES (%(packet)s, '\\x01', 'abcd1234', %(user)s)",
|
||||||
|
"INSERT INTO packet_visual_state (packet_id) VALUES (%(packet)s)",
|
||||||
|
"INSERT INTO packet_docs (packet_id, asset_id, kind, title) VALUES (%(packet)s, %(asset)s, 'mtr', 'MTR')",
|
||||||
|
"INSERT INTO qc_docs (project_id, unit_id, type, asset_id) "
|
||||||
|
"VALUES (%(project)s, %(unit)s, 'wps', %(asset)s)",
|
||||||
|
"INSERT INTO itp_lines (itp_id, key, result, by) VALUES (%(itp)s, 'k1', 'approve', %(user)s)",
|
||||||
|
"INSERT INTO itp_blocks (itp_id, block_key, signed_by, signature_id) "
|
||||||
|
"VALUES (%(itp)s, 'b1', %(user)s, %(sig)s)",
|
||||||
|
]
|
||||||
|
for stmt in statements:
|
||||||
|
conn.execute(stmt, ids)
|
||||||
|
conn.execute("UPDATE bom_lines SET pulled = true, row_version = row_version + 1 WHERE id = %(bom)s", ids)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE packets SET version = version + 1, next_weld_no = next_weld_no + 1 WHERE id = %(packet)s", ids
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %(packet)s", ids)
|
||||||
|
conn.execute("DELETE FROM co_items WHERE co_id = %(co)s", ids)
|
||||||
|
wo = conn.execute("SELECT wo_base, lot, split, sub FROM packet_work_orders").fetchone()
|
||||||
|
assert wo == ("0670001", "7A", "0", "0")
|
||||||
|
for view in ("v_units", "v_open_cos"):
|
||||||
|
conn.execute(q("SELECT * FROM {}", view)).fetchall()
|
||||||
|
for priv in ("SELECT", "INSERT", "UPDATE", "DELETE"):
|
||||||
|
allowed = one(conn, "SELECT has_table_privilege('ps_app', %s, %s)", (view, priv))
|
||||||
|
assert allowed is (priv == "SELECT"), (view, priv)
|
||||||
@@ -158,12 +158,14 @@ def test_departments(pg_client: TestClient) -> None:
|
|||||||
deps = pg_client.get("/api/departments").json()
|
deps = pg_client.get("/api/departments").json()
|
||||||
assert [d["code"] for d in deps][:5] == ["CL", "WH", "QC", "PM", "01"]
|
assert [d["code"] for d in deps][:5] == ["CL", "WH", "QC", "PM", "01"]
|
||||||
weld = next(d for d in deps if d["code"] == "03")
|
weld = next(d for d in deps if d["code"] == "03")
|
||||||
assert weld == {
|
assert {k: v for k, v in weld.items() if k != "stations"} == {
|
||||||
"code": "03",
|
"code": "03",
|
||||||
"slug": "WeldParts",
|
"slug": "WeldParts",
|
||||||
"name": "Weld Parts",
|
"name": "Weld Parts",
|
||||||
"kind": "shop",
|
"kind": "shop",
|
||||||
"stations": [f"Stall {i}" for i in range(1, 7)],
|
"weld": True,
|
||||||
}
|
}
|
||||||
|
assert [s["name"] for s in weld["stations"]] == [f"Stall {i}" for i in range(1, 7)]
|
||||||
|
assert all(isinstance(s["id"], int) for s in weld["stations"])
|
||||||
hydro = next(d for d in deps if d["code"] == "09")
|
hydro = next(d for d in deps if d["code"] == "09")
|
||||||
assert hydro["stations"] == ["Hydro Bay 1", "Hydro Bay 2"]
|
assert [s["name"] for s in hydro["stations"]] == ["Hydro Bay 1", "Hydro Bay 2"] and hydro["weld"] is False
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""clean_text and the input limits."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from packetstation.text import RX_HEAT, RX_LOT, RX_PROJECT, RX_STAMP, clean_deep, clean_opt, clean_text, reason_ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_text_strips_controls_and_invisible_marks() -> None:
|
||||||
|
assert clean_text(" a\u0000bcde\u007f ") == "abcde"
|
||||||
|
assert clean_text("tab\tkept\nand\r\nnewlines") == "tab\tkept\nand\r\nnewlines"
|
||||||
|
assert clean_text(None) == ""
|
||||||
|
assert clean_text(42) == "42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_text_normalises_and_cuts() -> None:
|
||||||
|
assert clean_text("é") == "é" # NFC
|
||||||
|
assert clean_text("x" * 50, 10) == "x" * 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_opt_and_reason() -> None:
|
||||||
|
assert clean_opt(" ", 10) is None
|
||||||
|
assert clean_opt(" a ", 10) == "a"
|
||||||
|
assert reason_ok("short") is None
|
||||||
|
assert reason_ok(" ticket INC-1234 ") == "ticket INC-1234"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_deep() -> None:
|
||||||
|
out = clean_deep({"a": [" x\u0000 ", {"b": "y"}], "n": 3})
|
||||||
|
assert out == {"a": ["x", {"b": "y"}], "n": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def test_regexes() -> None:
|
||||||
|
assert RX_HEAT.match("AB12 3/4-#7") and not RX_HEAT.match("-x") and not RX_HEAT.match("a" * 41)
|
||||||
|
assert RX_STAMP.match("W-12") and not RX_STAMP.match("W 12")
|
||||||
|
assert RX_PROJECT.match("L670") and not RX_PROJECT.match("-L")
|
||||||
|
assert RX_LOT.match("7A") and RX_LOT.match("001") and not RX_LOT.match("0001")
|
||||||
Reference in New Issue
Block a user