Phase 4: Warehouse screen, pull table, shorts, MTRs and the upload pipeline
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m30s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 1m40s

The Warehouse home with the numbers, the Open shorts table, the find bar and the five-column task board
(waiting, being pulled, on hold, pulled, released in the last 3 days) with buttons, hold reasons and drag.
Every role sees the warehouse stage on a packet as the WH pill and the line under it. The pull table on the
Materials tab: Pulled with auto-start, issued, location, heat with Enter to the next line, shorts with
receipts, N/A, MTR per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays
blocked until every line is pulled or short and every pulled line has a heat.

Server: the heats_recorded precondition on WH_RELEASE, receive by custody, server-validated hold and short
reasons, the old packet history texts, GET /api/warehouse/board, cards with the warehouse stage and pull
counts. Uploads typed by content per kind (drawing, mtr, document, bom with XLSX and CSV), limits per kind,
optional ClamAV behind the clamav compose profile, a storage quota alert on the new notification_events
table (migration 0005) shown on the Admin Storage tab.

Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat
pattern, upload sniffing and limits, the ClamAV path with a fake scanner, the quota alert; frontend board
and pull table tests; Playwright scenarios for the board and the pull flow. Verified on docker-1 in the CI
stack: 3557 server tests, 106 frontend tests, 17 Playwright scenarios. Docs updated; D5 closed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 17:13:31 -06:00
co-authored by Claude Fable 5.1
parent 591fe90076
commit 39a4eb5213
65 changed files with 6213 additions and 130 deletions
+9
View File
@@ -11,3 +11,12 @@
# DRAWING_SOURCE=mfiles mfiles | fixture
# FIXTURE_DIR=/fixtures where the fixture sources read feed.<N>.json, drawings.<N>.json and state
# fixture reads synthetic files from tests/fixtures/generator.py. STACK=prod refuses it.
#
# Uploads and storage (phase 4). See deploy/env/*.env.example for the comments:
# UPLOAD_MAX_BYTES=26214400 drawings, MTRs and documents (25 MB)
# UPLOAD_MAX_BYTES_BOM=5242880 BOM uploads, XLSX or CSV (5 MB)
# ASSETS_QUOTA_WARN_PCT=85 the storage alert threshold, percent
# ASSETS_QUOTA_BYTES=0 0 measures the assets volume
# CLAMAV_HOST= empty: no virus scan. Set it and start the stack with --profile clamav
# CLAMAV_PORT=3310
# CLAMAV_TIMEOUT_SECONDS=30
+11
View File
@@ -6,6 +6,17 @@ All notable changes to this project are recorded here. The format follows [Keep
### Added
- Phase 4: the Warehouse. The Warehouse home with the numbers (Waiting to pull, Being pulled, On hold, Ready for production, Open shorts, Released last 24 h), the Open shorts table with Receive, the find bar, and the task board: Waiting to pull, Being pulled, On hold, Pulled, ready for production, and Released to shop (3 days) built from the release transitions. Cards are grouped by department, oldest first by the day they were sent then VISUAL's release date, and move by button (Start pulling, Hold, Mark pulled with a warning when lines remain, Resume pulling, Back to pulling, Release…) or by dragging; a drop on the released column is refused. Hold asks for one of five reasons and a note ("Other" needs the note). Every role sees the real stage on a warehouse packet: the pill reads WH · waiting, being pulled, on hold or pulled, with who, how long and the hold reason under it.
- The pull table on the Materials tab for the warehouse: Pulled (the first tick starts the pulling stage), issued quantity, location, heat number typed or scanned with Enter moving to the next line, Short (record, edit, Not short after all) with the seven reasons, quantity, PO or requisition, ETA and note, receive with quantity, heat and note as an append-only receipt, N/A for parts with no traceability, and an MTR (PDF, PNG or JPG) per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays disabled with the server's reasons until every line not N/A is pulled or on an open short and every pulled line has a heat or is N/A or a sub-assembly.
- `GET /api/warehouse/board` (numbers, the five columns, the open shorts). Board cards carry the warehouse stage with who and when, the sent date, VISUAL's release date and the pull counts.
- The server validates the heat number pattern, the hold reasons and the short reasons, and writes the packet history as the old build did: "Heat recorded · item 10", "Short recorded · item 10", "Short received · item 10", "Warehouse: Put on hold", "Released to supervisor with 1 short".
- Receiving a short on a packet the warehouse no longer holds stays a warehouse right; the department that holds the packet can receive too, through custody.
- Uploads are typed by content per kind: drawings are PDF, MTRs and documents PDF, PNG or JPG (25 MB), hand BOMs XLSX or CSV (5 MB). XLSX is checked as a real workbook, CSV as clean text. Names are sanitised, files stored by id, served with Content-Disposition (attachment for spreadsheets) and nosniff.
- Optional ClamAV: a `clamav` service in compose behind the `clamav` profile. When `CLAMAV_HOST` is set every upload and every drawing the worker downloads is scanned before it is accepted; a hit is refused and audited, and an unreachable scanner refuses uploads.
- A storage quota alert: at 85 percent of the assets volume (configurable) the app raises a notification event, shown on the new Admin Storage tab with the usage, the ClamAV status and an Acknowledge button. Migration 0005 adds the `notification_events` table that phase 9 builds the delivery on.
- Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat pattern, upload sniffing and limits per kind, the ClamAV path with a fake scanner, the quota alert; frontend tests for the board and the pull table; Playwright scenarios for the warehouse board and the pull flow against the CI stack.
- docs/SCHEMA.md, POLICY.md, SECURITY.md (D5 closed) and DEPLOY.md updated.
- Phase 3: the VISUAL feed and M-Files drawings run in the worker. Migration 0004 with the VISUAL cache (batches, rows, materials, orders and order links, labor, per-batch change rows), the go-live ignored list, the resource map seeded from the old build's map (fold and ignore lines included), drawing lookups, job requests, job runs, job schedule and the worker heartbeat, plus the order-derived columns with provenance on projects and units. The work order key on a packet now includes the operation sequence, so one packet can hold two operations of the same work order.
- The real worker: APScheduler with a settings-driven trigger that re-reads the feed settings on every fire, shop-hours and off-hours intervals, pause, LISTEN on `ps_settings` and `ps_job_request`, one advisory lock per job, `job_runs` rows with counts and redacted errors, a heartbeat row and file, the stack guard before every VISUAL read, and a housekeeping job that prunes the caches at 90 days and the runs at 180.
- The `visual-feed` job: five reads through VisualKit (released work orders in the window with `until` always passed, every tracked work order by key with no status or date filter, operations and part specs and parts and materials with `mat_known` per leg, order links by base and by project, labor tickets at most every 30 minutes), COPY and MERGE into the caches with change rows, the suspect-batch guard, and the apply in one transaction: candidates by the old rules (one packet is drawing plus lot plus operation; fold and ignore; project from the base prefix; unit is project plus the three-digit lot), the go-live boundary with the ignored list and pull-in, matching on the VISUAL key across live and removed packets (a removed packet is never recreated), new drafts, drafts updated in place, change orders opened, re-aimed, withdrawn or followed on for packets past draft (minor when only target hours or the want date changed), left-behind drafts retired, work order gone after two misses, closed and cancelled observed per leg, projects and units created with customer, PO and dates from the order links and the several-open-orders rule in one function with the provenance stored, auto-release of clean drafts up to 25 per run, audit rows and NOTIFY.
+4 -3
View File
@@ -6,9 +6,9 @@ This repo is the rebuild of Packet Station as a server application: Docker Compo
## Status
**Rebuild in progress. Phase 3 done: the VISUAL feed and M-Files drawing lookups run in the worker.**
**Rebuild in progress. Phase 4 done: the Warehouse screen, the pull table, shorts and MTRs, and the hardened upload pipeline.**
People can sign in, set up projects and units, make hand drafts, attach a drawing, and send a packet to the warehouse, a department or Q.C. The worker reads released work orders from VISUAL on a schedule, makes and updates drafts, opens change orders on packets already in the shop, looks drawings up in M-Files and attaches them at the rev VISUAL calls. Clerical sees it all on the From VISUAL panel and in the Flagged and Changes to approve columns; Admin sets the feed up on the Feed tab. 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:.
People can sign in, set up projects and units, make hand drafts, attach a drawing, and send a packet to the warehouse, a department or Q.C. The worker reads released work orders from VISUAL on a schedule, makes and updates drafts, opens change orders on packets already in the shop, looks drawings up in M-Files and attaches them at the rev VISUAL calls. Clerical sees it all on the From VISUAL panel and in the Flagged and Changes to approve columns; Admin sets the feed up on the Feed tab. Warehouse works its task board (waiting, being pulled, on hold, pulled, released), pulls parts line by line with heat numbers, shorts, receipts and MTRs, and releases to the supervisor once every line is pulled or short and every pulled line has a heat. Every packet write goes through the server policy. The shop and Q.C. screens are still placeholders; their API exists. The old build on `M:\Production\Packet Station` stays the live system until go-live. The new system starts empty; nothing is migrated from M:.
| Area | Exists yet? |
|---|---|
@@ -17,7 +17,8 @@ People can sign in, set up projects and units, make hand drafts, attach a drawin
| 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) |
| Schema, policy engine, packet API, projects and units, assets, Clerical screen, packet page | Yes (phase 2, verified against PostgreSQL and the Samba DC in the CI stack on docker-1) |
| VISUAL feed, M-Files drawings, change orders from the feed, From VISUAL panel, Admin Feed tab and resource map | Yes (phase 3, run once against VISUAL DEV and the vault from a developer machine; fixture-driven in CI) |
| Warehouse, shop, Q.C., close and file, PDFs | API only (phase 2); screens in phases 4 to 7 |
| Warehouse screen, pull table, shorts and receipts, MTRs, release gate, upload kinds, optional ClamAV, storage alert | Yes (phase 4, verified in the CI stack on docker-1) |
| Shop, Q.C., close and file, PDFs | API only (phase 2); screens in phases 5 to 7 |
| Project Management, Admin, notifications, backups | No (phases 8 and 9) |
| Regression bench and go-live | No (phase 10) |
+31
View File
@@ -10,6 +10,9 @@
#
# No service publishes a port. NPM reaches the app over npm_default (compose.proxy.yaml).
# PostgreSQL is only on the internal backend network.
#
# ClamAV is optional. Set CLAMAV_HOST=packetstation-clamav in the env file and add `--profile clamav`
# to every `docker compose` command of that stack. Without the profile the clamav service does not run.
# The project name comes from the env file so prod and test can never share volumes by accident.
# `docker compose -p` still wins when given.
@@ -164,6 +167,33 @@ services:
restart: unless-stopped
logging: *logging
clamav:
# Optional virus scanner for uploads (profile clamav). clamd listens on 3310 on the backend network.
# Pinned to the 1.4 minor tag, not a digest: the tag follows the 1.4 patch releases.
# To pin a digest: docker buildx imagetools inspect clamav/clamav:1.4 and use the top-level "Digest:".
image: clamav/clamav:1.4
profiles: ["clamav"]
environment:
CLAMAV_NO_MILTERD: "true"
volumes:
- clamav-db:/var/lib/clamav # Signatures. The first start downloads them (a few minutes).
networks:
backend:
aliases:
- packetstation-clamav # CLAMAV_HOST. Unique so it cannot collide with another stack.
egress: {} # freshclam needs the internet for signature updates.
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "clamdcheck.sh"]
interval: 60s
timeout: 10s
retries: 3
start_period: 10m # Room for the first signature download and load.
mem_limit: 2g
restart: unless-stopped
logging: *logging
networks:
backend:
internal: true # No route out. postgres lives only here.
@@ -173,6 +203,7 @@ networks:
volumes:
pgdata: {}
assets: {}
clamav-db: {}
# Host files, root:10001 mode 0440, one value per file, no trailing carriage return.
secrets:
+19
View File
@@ -69,6 +69,25 @@ ASSETS_DIR=/data/assets
# Built frontend. Compose sets it too.
STATIC_DIR=/app/static
# ---- Uploads and storage ----
# Largest drawing, MTR or document upload, in bytes (25 MB).
UPLOAD_MAX_BYTES=26214400
# Largest BOM upload (XLSX or CSV), in bytes (5 MB).
UPLOAD_MAX_BYTES_BOM=5242880
# Open the storage alert when the assets store is this full, in percent (1 to 99).
ASSETS_QUOTA_WARN_PCT=85
# 0 measures the assets volume. A byte count measures the stored files against that many bytes.
ASSETS_QUOTA_BYTES=0
# ---- ClamAV (optional virus scan of uploads) ----
# Empty means no scan. When set, start the stack with --profile clamav on every docker compose command.
# An unreachable scanner then refuses uploads until it answers.
#CLAMAV_HOST=packetstation-clamav
# clamd port.
CLAMAV_PORT=3310
# Seconds to wait for a scan.
CLAMAV_TIMEOUT_SECONDS=30
# ---- LDAP (PESCO Active Directory; CI gets the Samba DC in phase 1) ----
# Both domain controllers, tried in order.
LDAP_URL="ldaps://DC-02.PESCO.COM:636 ldaps://PDC-1.PESCO.COM:636"
+19
View File
@@ -69,6 +69,25 @@ ASSETS_DIR=/data/assets
# Built frontend. Compose sets it too.
STATIC_DIR=/app/static
# ---- Uploads and storage ----
# Largest drawing, MTR or document upload, in bytes (25 MB).
UPLOAD_MAX_BYTES=26214400
# Largest BOM upload (XLSX or CSV), in bytes (5 MB).
UPLOAD_MAX_BYTES_BOM=5242880
# Open the storage alert when the assets store is this full, in percent (1 to 99).
ASSETS_QUOTA_WARN_PCT=85
# 0 measures the assets volume. A byte count measures the stored files against that many bytes.
ASSETS_QUOTA_BYTES=0
# ---- ClamAV (optional virus scan of uploads) ----
# Empty means no scan. When set, start the stack with --profile clamav on every docker compose command.
# An unreachable scanner then refuses uploads until it answers.
#CLAMAV_HOST=packetstation-clamav
# clamd port.
CLAMAV_PORT=3310
# Seconds to wait for a scan.
CLAMAV_TIMEOUT_SECONDS=30
# ---- LDAP (PESCO Active Directory; CI gets the Samba DC in phase 1) ----
# Both domain controllers, tried in order.
LDAP_URL="ldaps://DC-02.PESCO.COM:636 ldaps://PDC-1.PESCO.COM:636"
+33
View File
@@ -33,6 +33,7 @@ Both stacks run from the same image as separate compose projects.
- `migrate`: one-shot, runs `packetstation migrate` as the migrate role.
- `app`: `packetstation app` (uvicorn). `backend` network plus egress plus `npm_default` through the proxy overlay. Assets volume, CA bundle bind, secrets, healthcheck.
- `worker`: `packetstation worker`. Same image. Runs the VISUAL feed, the drawing lookup and housekeeping (see [FEED.md](FEED.md)). `backend` network plus egress (VISUAL, M-Files), no `npm_default`, no published port. Assets volume (fetched drawings land there), CA bundle bind, backup folder read-only (used from phase 9). Its healthcheck reads the heartbeat file.
- `clamav` (optional, profile `clamav`, phase 4): `clamav/clamav:1.4`, pinned by tag (with a comment in the compose file), not by digest. clamd on port 3310 for the app and the worker, reached by the network alias `packetstation-clamav` (as `postgres` has its own alias). `backend` network plus egress (freshclam downloads the signatures), no `npm_default`, no published port. Volume `clamav-db` at `/var/lib/clamav`, `mem_limit: 2g`, healthcheck `clamdcheck.sh`. Started only with `--profile clamav`. See "Virus scan (ClamAV)".
- All services restart unless stopped, log to stdout (json-file), and have healthchecks.
## Secrets
@@ -69,6 +70,8 @@ Key settings:
- `APP_ALIAS` per stack.
- Identity timing and limits, all optional with these defaults: `SESSION_IDLE_MINUTES=15`, `SESSION_LOCK_MINUTES=3`, `SESSION_HIDDEN_LOCK_SECONDS=60`, `SESSION_PASSWORD_HOURS=12`, `SESSION_PIN_HOURS=10`, `ROLES_REFRESH_MINUTES=15`, `SIGNIN_ACCOUNT_LIMIT=5`, `SIGNIN_ACCOUNT_WINDOW_MINUTES=5`, `SIGNIN_ACCOUNT_COOLDOWN_MINUTES=10`, `SIGNIN_IP_LIMIT=30`, `SIGNIN_IP_WINDOW_MINUTES=5`, `PIN_MAX_FAILURES=5`, `STEPUP_MAX_FAILURES=3`, `DEVICE_COOKIE_DAYS=365`, `PIN_MIN_DIGITS=6`, `PIN_MAX_DIGITS=12`.
- Worker sources, all optional: `FEED_SOURCE` (`visualkit`, the default, or `fixture`), `DRAWING_SOURCE` (`mfiles`, the default, or `fixture`) and `FIXTURE_DIR` (default `/fixtures`). A fixture source reads files instead of VISUAL or M-Files (see [FEED.md](FEED.md), "Fixture sources"). `STACK=prod` refuses a fixture source at start. The test and production stacks leave all three unset. Only the CI env file sets them.
- Uploads and storage, all optional: `UPLOAD_MAX_BYTES` (default 25 MB, drawings, MTRs and documents), `UPLOAD_MAX_BYTES_BOM` (default 5 MB, hand BOMs), `ASSETS_QUOTA_WARN_PCT` (default 85, 1 to 99) and `ASSETS_QUOTA_BYTES` (default 0, which means the size of the assets volume; any other value is a byte budget for the stored files). See "Storage quota".
- Virus scan, all optional: `CLAMAV_HOST` (empty by default, which means no scan; `packetstation-clamav` for the profile service), `CLAMAV_PORT` (default 3310) and `CLAMAV_TIMEOUT_SECONDS` (default 30). The env examples carry `#CLAMAV_HOST=packetstation-clamav` commented out. See "Virus scan (ClamAV)".
- `SITE_ID` (default `PESCO`) and `SITE_TZ` (default `America/Denver`): the VISUAL site in every call, and the time zone for shop hours and the schedule.
- The session cookie is marked `Secure` when the request arrived over https (as reported by NPM through `X-Forwarded-Proto`). Behind the proxy that is always the case.
@@ -89,6 +92,35 @@ Production (planned, phase 10): the same command with `-p packetstation` and the
Images come from `gt.pescoinc.biz` by tag. CI tags each tested image `:sha-<sha>`; a release re-tags it `vX.Y.Z`. Never `latest`. A deploy is a new image with a short downtime window.
## Virus scan (ClamAV, phase 4)
ClamAV is optional and off by default. It closes requirement D5. Turn it on in the test stack first, check it, then the production stack.
1. Set `CLAMAV_HOST=packetstation-clamav` in the stack's env file (`/etc/packetstation/test/stack.env`). Leave `CLAMAV_PORT` and `CLAMAV_TIMEOUT_SECONDS` at their defaults.
2. Start the stack with the profile:
```bash
docker compose -p packetstation-test --env-file /etc/packetstation/test/stack.env \
-f deploy/compose.yaml -f deploy/compose.proxy.yaml --profile clamav up -d
```
3. Wait for `clamav` to turn healthy. On the first start the volume `packetstation-test_clamav-db` is empty, so freshclam downloads the full signature set (a few hundred MB) before clamd answers. That takes several minutes. Later starts reuse the volume and only fetch updates. freshclam needs egress to the ClamAV mirrors, which is why the service is on the `egress` network.
4. Check it: Admin, Storage tab, the ClamAV line reads "packetstation-clamav: reachable". `packetstation doctor` also has a `clamav` check (a PING to clamd; SKIP when `CLAMAV_HOST` is empty). Upload a small PDF to a draft and confirm it is stored.
Every later `up` for that stack must carry `--profile clamav`. Without it the service is not started.
Fail closed. While `CLAMAV_HOST` is set, an upload the scanner cannot check is refused with "The virus scanner is not reachable. Try again in a minute." (503 `scan_unavailable`). That covers clamd still loading signatures, stopped, or out of memory. The worker's drawing fetches are refused the same way and tried again later. A file with a signature hit is refused (422 `infected`), never stored, and audited as `asset.rejected` with the signature name.
To turn it off: remove `CLAMAV_HOST` from the env file, recreate `app` and `worker` (`up -d` without the profile), then stop the service with `docker compose ... --profile clamav stop clamav`. The volume can stay for the next time. The CI overlay never runs ClamAV.
clamd holds the signatures in memory, about 1.2 GB with the full set and more while it reloads them, so the service has a 2 GB limit. Check docker-1 has the headroom for both stacks before turning it on in production.
## Storage quota (phase 4)
Every stored file and the housekeeping job check the assets volume. With `ASSETS_QUOTA_BYTES=0` (the default) the check reads the volume's own used and total bytes; with a byte budget it adds up the stored files' sizes against it. At `ASSETS_QUOTA_WARN_PCT` percent (85 by default) the app opens one `notification_events` alert of kind `assets_quota` and audits `storage.quota_warning`. When usage drops back under, the alert is resolved. A failed check is logged and never fails an upload.
The Admin page, Storage tab, shows the usage bar (used of total, percent, the threshold), the open quota alert with an Acknowledge button, the ClamAV line ("Not configured", or the host with reachable or not reachable) and every open alert. Phase 9 sends the alert by email. Until then, check the Storage tab.
## Python packages and the Gitea index
The Gitea PyPI index `https://gt.pescoinc.biz/api/packages/spencerm/pypi/simple/` serves only `visualkit`, `mfileskit` and their dependencies.
@@ -148,6 +180,7 @@ It checks:
- Assets volume and free space. Backup folder (planned, phase 9).
- LDAP TLS bind on both controllers, reading `memberOf`, `employeeID` and `userAccountControl` as the service account, the 50-group count and one nested-group query.
- SMTP STARTTLS with certificate verification.
- ClamAV (phase 4): clamd answers PING at `CLAMAV_HOST`. SKIP when no host is set.
- tzdata.
Use `--skip visual,mfiles,ldap,smtp` to run only the local checks. The network checks need the secrets and the CA bundle in place.
+8
View File
@@ -237,6 +237,14 @@ Smaller questions from the design, with the default the build takes unless told
17. SET_DATABOOK_AT is writable by anyone in the old build, even when filed: default restrict to Clerical and Q.C.
18. CO verifications cannot be withdrawn; ITP line signatures can be undone by the same inspector or an Admin before the block is signed.
From phase 4 (2026-10-16), for Seth to veto:
19. The release gate has two server rules: every line not N/A is pulled or on an open short, and every pulled line that issued pieces has a heat unless it is N/A or a sub-assembly. The old build enforced both on the screen only. Default: both on the server.
20. Hold and short reasons: the old build accepted any text. Default: closed sets on the server; "Other" on a hold needs a note.
21. Receiving a short on a packet the warehouse no longer holds: default it stays a warehouse right (WH_RECEIVE_SHORT, any status but filed), and the holder of the packet (department lead or supervisor, Q.C., Clerical) can receive too, through edit_bom.
22. Upload kinds and limits: default an MTR is PDF, PNG or JPG up to 25 MB, a hand BOM is XLSX or CSV up to 5 MB. ClamAV is optional and off unless CLAMAV_HOST is set; when set, an unreachable scanner refuses uploads (fail closed).
23. The quota alert: default a `notification_events` row (phase 9 builds the delivery on this table) at 85 percent of the assets volume.
## Verification
- Every phase: `pytest` server suite, `npm test` and `npm run build` for the frontend, `docker compose -f deploy/compose.ci.yml up` with the Samba DC and the Playwright scenarios for that phase, `packetstation doctor` inside the image.
+101 -1
View File
@@ -79,7 +79,7 @@ Statuses in workflow order: draft, warehouse, ready, assigned, in_progress, revi
| 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 | | |
| wh_release | WH | warehouse | ready | no | yes | bom_all_pulled_or_short, heats_recorded | | |
| 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 | | | | |
@@ -101,6 +101,8 @@ Statuses in workflow order: draft, warehouse, ready, assigned, in_progress, revi
| edit_wos | CL | draft | | | | | | |
| edit_bom | HOLDER | draft, warehouse, SHOP | | | | | | |
| wh_receive_short | WH | any | | | | short_open | | |
A holder of the packet who is not in the warehouse receives a short through edit_bom (see "Receive by custody").
| edit_heats | HOLDER | warehouse, SHOP | | | | | | |
| edit_heat_table | HOLDER | warehouse, SHOP | | | | | | |
| edit_welds | HOLDER | SHOP | | | | | | |
@@ -141,6 +143,7 @@ Each precondition is a named predicate over the packet state and the request inp
| 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. |
| heats_recorded | no line needs a heat: every line that is not N/A, not a sub-assembly, is pulled and issued pieces has a heat | Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability. |
| 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. |
@@ -199,6 +202,101 @@ Every packet read returns:
`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.
## Warehouse (phase 4)
### Stages
The warehouse stage is `packet_wh`, separate from the packet status. SET_WH_STAGE (WH, status warehouse) moves it, compare-and-set on `expect_stage` (409 `stale_step` when it moved on).
| From | To |
|---|---|
| incoming | pulling, hold |
| pulling | pulled, hold |
| hold | pulling |
| pulled | pulling |
A move to the same stage, or one not in the table, is 409 `wh_transition`. A packet with no `packet_wh` row is at incoming. The first Pulled tick on a line while the stage is incoming or on hold starts pulling by itself (`auto` true).
### Hold reasons
A closed set, checked on the server:
- Waiting on material (not in stock)
- Waiting on purchasing / PO
- Question for engineering or clerical
- Drawing / BOM problem
- Other
Any other text is 400 `invalid_reason` "Pick one of the hold reasons." "Other" needs a note, else 400 `reason_required` "Say what it's waiting on." The note is cleaned and cut to 200 characters.
### Short reasons
A closed set, checked on the server:
- Not in stock
- On order
- Back-ordered
- Damaged / rejected
- Wrong part received
- Needs cutting / prep
- Other
Any other text is 400 `invalid_reason` "Pick one of the short reasons." A short has a quantity from 1 to the line quantity, a reason, an optional PO or requisition, an optional expected date, an optional note, and the optional heat of the pieces that were pulled (never on an N/A line). Opening, editing and clearing a short is edit_bom, so the holder decides. In practice that is the warehouse.
### The release gate
WH_RELEASE checks two preconditions, in this order:
1. `bom_all_pulled_or_short`: every line not N/A is pulled or has an open short.
2. `heats_recorded`: no line needs a heat. A line needs a heat when it is not N/A, not a sub-assembly (`sub` is `Y`; its heat is on its own packet), is pulled, has issued more than 0 and has no heat.
Both are state preconditions, so `blocked.wh_release` lists the failing message and the screen shows Release disabled with it. Heat numbers are checked against the old pattern: letters, numbers, space . / # _ - only, up to 40 characters, starting with a letter or a number. A bad heat is 422 `invalid_heat`.
### Receive by custody
Receiving short parts (`POST .../bom/{item}/short/receipts`) picks its action from the person:
- A member of the warehouse (WH in `support`) receives through WH_RECEIVE_SHORT, in any status but filed, while the line has an open short. The warehouse does not need to hold the packet.
- Anyone else receives through EDIT_BOM, so only the holder may: the lead or supervisor of the department that has the packet, Q.C. at Q.C., Clerical in draft.
- Everyone else is refused by the policy (403 `forbidden`).
The warehouse still cannot change any other field of a packet it no longer holds. EDIT_BOM stays the holder's. A receipt needs a heat unless the line is N/A, and its quantity is 1 to what is still open.
### MTR files
The MTR on a line must be an asset whose type is PDF, PNG or JPEG. Anything else, or an asset that is not there, is 422 `invalid_asset`. Clearing the MTR is allowed.
### Packet log texts
The `action` column of `packet_log` for each warehouse write. `{item}` is the BOM item number.
| Write | action | note |
|---|---|---|
| Heat typed or scanned | `Heat recorded · item {item}` | the heat |
| Heat cleared | `Heat cleared · item {item}` | |
| Pulled ticked | `Pulled · item {item}` | |
| Pulled unticked | `Pulled undone · item {item}` | |
| N/A ticked | `Marked N/A · item {item}` | |
| N/A unticked | `Traceable again · item {item}` | |
| Issued only | `Issued qty changed · item {item}` | `{issued} of {qty}` |
| Location only | `Location set · item {item}` | the location |
| Several fields | `Line changed · item {item}` | |
| MTR attached | `MTR attached · item {item}` | |
| MTR removed | `MTR removed · item {item}` | |
| Short opened | `Short recorded · item {item}` | `{qty} of {need} short · {reason} · {ref} · ETA {eta}`, parts present only |
| Short edited | `Short updated · item {item}` | the same as recorded |
| Not short after all | `Short cleared · item {item}` | |
| Short received | `Short received · item {item}` | `{qty} pc · heat {heat} · {still} still short` or `... · short closed`, then ` · packet {status}` (lower case) and ` in {station}` when at a station |
| Stage incoming to pulling | `Warehouse: Started pulling` | |
| Stage to hold | `Warehouse: Put on hold` | `{reason} · {note}` |
| Stage pulling to pulled | `Warehouse: Marked pulled, ready for production` | |
| Stage hold to pulling | `Warehouse: Pulling resumed` | |
| Stage pulled to pulling | `Warehouse: Back to pulling` | |
| wh_release | `Released to supervisor`, or `Released to supervisor with {n} short` when shorts are open | `{heats} heats recorded`, plus ` · SHORT: item {item} ×{qty_open} ({reason}), ...` when shorts are open |
| wh_send_back | `Sent back to Clerical` | the reason |
When a Pulled tick starts pulling by itself, there is no separate stage row in the log: the line's own row gets ` · started pulling` or ` · pulling resumed` on its note. Every write above also writes its audit row through the write pipeline, with `detail.item`, `wh_from` and `wh_to` as before, and `detail.qty` on short writes.
## Worker writes (phase 3)
The worker has no session. It writes through the same policy as a person, as the system principal `Principal(user_id=None, system="visual-feed")`. Each write locks the packet row (`FOR UPDATE SKIP LOCKED`; a row locked by someone else is left for the next run), builds the packet state, runs `policy.check()`, makes its change, bumps `version`, and writes a `packet_log` row (by nobody, by name "VISUAL feed", role `system`), an audit row (`packet.<action>`, role `system`) and a change event. See [FEED.md](FEED.md) for when each write happens.
@@ -256,6 +354,8 @@ The old policy was looser than the old screens in places. The rebuild follows th
| 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 |
| The old screen asked for a heat number on every pulled line before release, but the policy did not check it. | WH_RELEASE also needs a heat on every pulled line that issued pieces, unless the line is N/A or a sub-assembly (`heats_recorded`), checked on the server. | Traceability is the point of the pull. The screen alone could be skipped. | Yes |
| Hold reasons and short reasons were any text the screen sent. | Both are closed sets checked on the server. "Other" on a hold needs a note. | The board and the shorts table group and report on the reason, so it must be one of the list. | Yes |
| 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 |
+31 -3
View File
@@ -1,6 +1,6 @@
# Packet Station schema
Status: phases 1 to 3 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches). Phase 9 adds the notification tables. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema".
Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 9 adds the notification rules and delivery. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema".
## Conventions
@@ -82,6 +82,19 @@ The trigger `ps_packet_touch` sets `updated_at` on every UPDATE. It does not tou
- `bom_lines`: `item`, `qty`, `part_id` (text), `description`, `dwg_ref`, `cl_item`, `length`, `material`, `heat`, `na`, `issued`, `loc`, `pulled`, `pulled_at`, `pulled_by`, `mtr_asset_id`, `src`, `vkey`, `whs`, `comm`, `cons`, `sub`, `grp`, `sort`, `row_version`. Unique on (`packet_id`, `item`).
- `bom_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`.
What the warehouse columns mean in practice (phase 4):
- `bom_lines.pulled`: the line is off the shelf. The first tick sets `pulled_at` and `pulled_by`, and sets `issued` to `qty` when it was empty. Unticking clears all three of `pulled`, `pulled_at` and `pulled_by`. The first tick while the warehouse stage is incoming or on hold also starts pulling (a `packet_wh` row with `auto` true).
- `bom_lines.issued`: how many pieces went out. Less than `qty` means the rest should be on a short.
- `bom_lines.loc`: where the parts were taken from, as the warehouse types it.
- `bom_lines.heat`: the heat number of the pieces issued, checked against the heat pattern. A receipt adds its heat to the line, so a line with parts from two heats reads `H1 / H2` (heats joined with " / ", no repeats).
- `bom_lines.na`: the part has no traceability. An N/A line needs no heat, takes no heat, and does not count toward the release gate.
- `bom_lines.mtr_asset_id`: the mill test report for the line, an `assets` row that must be a PDF, PNG or JPEG. Null clears it.
- A line "needs a heat" when it is not N/A, not a sub-assembly (`sub` is `Y`), is pulled, has `issued` above 0 and has no heat. The release gate counts these lines (see [POLICY.md](POLICY.md)).
- `bom_shorts`: one open short per line at most. Opening a short sets the line's `issued` to `qty` minus the short quantity and ticks it pulled. `qty` is what was short in total, `qty_open` what is still missing. `reason` is one of the closed short reasons, `ref` the PO or requisition, `eta` the expected date. Editing an open short keeps what was already received in `qty`. "Not short after all" closes it (`closed_at`) and sets `issued` to the line's `qty`. While a short is open, the line's heat, issued and location change only through the short.
- `short_receipts`: one row per delivery, never changed. Each receipt lowers `qty_open`, adds its pieces to the line's `issued` and its heat to the line's `heat`. The receipt that brings `qty_open` to 0 closes the short.
- `packet_wh`: `reason` is one of the closed hold reasons when the stage is `hold`, and `note` is up to 200 characters.
- `packet_stages`: one row per visit to a shop department. `dept_code`, `station_id`, `welder_id`, `welder_stamp`, `checklist` jsonb (key to `{done, at, by, stamp}`, written per key with `jsonb_set`), `filler`, `filler_lot`, `preheat`, `notes`, the clock (`clock_elapsed_ms`, `clock_running_since`, `clock_started_at`), `qc_snapshot`, `opened_at`, `closed_at`, `row_version`. At most one open stage per packet.
- `welds`: `weld_no` (from `next_weld_no`), `page`, `fx` and `fy` (fractions of the page), `wo_base`, `piece`, `items` int[], `type`, `size`, `process`, `stamp`, `welder_id`, `dept_code`, `stage_id`, `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `weld_no`) among live rows.
- `heat_tags`: `tag_no` (from `next_heat_no`), `page`, `item`, `fx`, `fy`, `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `tag_no`) among live rows.
@@ -107,6 +120,17 @@ Every who column references `users (id)`. Every `signature_id` references `signa
## Documents (phases 2, 4 and 6)
- `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).
Asset kinds (phase 4). An upload names its kind in the `purpose` field, and `source_ref` keeps `{"purpose": kind}`. No purpose means `document`. The type is read from the content, never from the name or the browser.
| Kind | Types | Limit (setting) |
|---|---|---|
| drawing | PDF | 25 MB (`upload_max_bytes`) |
| mtr | PDF, PNG, JPEG | 25 MB (`upload_max_bytes`) |
| document | PDF, PNG, JPEG | 25 MB (`upload_max_bytes`) |
| bom | XLSX, CSV | 5 MB (`upload_max_bytes_bom`) |
`mime` is one of `application/pdf`, `image/png`, `image/jpeg`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` and `text/csv`. An XLSX is a zip that holds `[Content_Types].xml` and `xl/workbook.xml`; a plain zip is refused. A CSV is UTF-8 text with no NUL and no control character other than tab, CR and LF. An upload the virus scanner refuses is never stored, so it has no row.
- `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`.
@@ -134,9 +158,13 @@ Phase 3, migration 0004:
- `job_schedule`: one row per job (`job` PK). `next_run_at`, `interval_seconds`, `paused`, `updated_at`. Written by the worker after each run for the Admin tab. Seeded for `visual-feed`, `drawing-lookup` and `housekeeping`.
- `worker_heartbeat`: a single row (`id` = 1 by CHECK). `at`, `build`, `host`, `pid`, `running` text[] (the jobs running now). Upserted every 30 seconds.
## Notifications (phase 9)
## Notifications (phases 4 and 9)
Rules, recipients, exclusions, outbox, sent log, state.
Phase 4, migration 0005:
- `notification_events`: one row per alert. `id` bigserial, `kind` (text, for example `assets_quota`), `severity` (`info`, `warning` or `critical` by CHECK, default `warning`), `subject` (what the alert is about, for example `assets`), `detail` jsonb (for the quota alert, the volume usage: `total`, `used`, `free`, `pct`, `warn`), `created_at`, `resolved_at` (set when the condition clears), `acknowledged_at` and `acknowledged_by` (an Admin saw it). The unique index `notification_events_open` on (`kind`, `coalesce(subject, '')`) where `resolved_at` is null keeps one open alert per kind and subject, so a check that runs again does not stack rows. The table is not append-only: the app sets `resolved_at` and the acknowledgement. 0005 also grants `ps_app` USAGE and SELECT on every sequence in the schema.
Phase 9 builds the delivery on this table: rules, recipients, exclusions, outbox, sent log, state.
## VISUAL and drawing caches (phase 3)
+23 -5
View File
@@ -2,7 +2,7 @@
This document is rewritten for the server build. The old browser build's security document is in the old repo (`C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md`). The design comes from [BRIEF.md](BRIEF.md) and [PLAN.md](PLAN.md).
Status: phase 3. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) and the policy controls (server policy on every write, workflow steps as compare-and-set, server-side validation and limits, IT edit reasons, the filed lock, the upload pipeline for drawings and documents) are built and tested. Phase 3 adds the worker controls (read-only VISUAL and M-Files reads, stack guard, advisory locks, redacted errors, audited system writes). No requirement ID closes in phase 3. Every other control names the phase that builds it.
Status: phase 4. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) and the policy controls (server policy on every write, workflow steps as compare-and-set, server-side validation and limits, IT edit reasons, the filed lock, the upload pipeline for drawings and documents) are built and tested. Phase 3 adds the worker controls (read-only VISUAL and M-Files reads, stack guard, advisory locks, redacted errors, audited system writes). No requirement ID closes in phase 3. Phase 4 completes the upload pipeline (a kind per upload with its own types and limit, XLSX and CSV by content, the optional ClamAV scan, the assets volume quota alert) and adds the warehouse rules (the heat gate on release, closed hold and short reasons, receive by custody). Phase 4 closes D5. Every other control names the phase that builds it.
Contents:
@@ -29,7 +29,7 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
| 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 |
| 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 |
| 14 | Input and upload validation | On the server: field limits on every write, uploads typed by content, size limits, sanitised names. | 2, 4 |
| 14 | Input and upload validation | On the server: field limits on every write, closed reason sets, uploads typed by content per kind (drawing PDF; MTR and document PDF, PNG or JPEG; BOM XLSX or CSV), a size limit per kind (25 MB, BOM 5 MB), sanitised names, an optional ClamAV scan before anything is stored, and a quota alert on the assets volume. Built. | 2, 4 |
| 15 | Second-admin approval | Superseded. Roles come only from AD groups and the group-to-role map is derived, so there is nothing to grant in the app. | 1 |
| 16 | Security alerts and automatic integrity check | Worker jobs and the Notifications tab (email through app-03). | 9 |
| 17 | Controlled-copy stamp on data books | Applied to server-built PDFs and signed with the seal key. | 7 |
@@ -74,14 +74,31 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
- One policy in Python on the server (`server/packetstation/policy/`), pure functions with no I/O. Every write goes: session, policy, transition, validation, version check, packet log row, audit row, NOTIFY, in one transaction with the packet row locked. Nothing in the browser decides.
- Every packet read returns `can: [...]`, `blocked: {...}` and `holder`, so the browser enables controls from the server's answer. The server re-checks on the write.
- A workflow step taken twice is refused with 409 and the current state (compare-and-set on the expected status and department). Child rows carry a row version checked with If-Match. Weld, heat tag, page and change order numbers are assigned by the server under the lock.
- 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.
- Preconditions (heat map answered, drawing attached, BOM pulled or short, heats recorded on pulled lines, checklist complete, welds recorded, Q.C. items complete, closed in VISUAL before filing) run on the server and are never bypassed, Admin included.
- 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`.
- Uploads: typed by content (PDF, PNG, JPEG), 25 MB limit read in chunks, sha256, stored by id under the assets volume, served only with a session and with `Content-Disposition` and `X-Content-Type-Options: nosniff`. Phase 4 completes it (see "Uploads and storage").
- The filed lock: a filed packet accepts only the data book date and the Admin reopen. Filing writes an HMAC-SHA256 seal with the server seal key; a reopen keeps the old seal with who broke it and why.
- Details in [POLICY.md](POLICY.md).
### Uploads and storage (phase 4)
- Every upload names a kind (`purpose`): drawing, mtr, document or bom. No purpose means document. An unknown kind is 422.
- The type is read from the content, never from the file name or the browser's type. A drawing is a PDF. An MTR or a document is a PDF, PNG or JPEG. A BOM is an XLSX or a CSV. A wrong type for the kind is 415 `unsupported_type` with a message that names the allowed types.
- An XLSX must be a zip that lists `[Content_Types].xml` and `xl/workbook.xml`; a plain zip is refused. A CSV has no magic, so it is accepted only as a BOM, and only when its first 64 KB decode as UTF-8 with no NUL and no control character other than tab, CR and LF.
- Limits per kind: 25 MB (`upload_max_bytes`) for drawing, mtr and document, 5 MB (`upload_max_bytes_bom`) for a BOM. The Content-Length is checked first, then the bytes as they are read. Over the limit is 413 `too_large`.
- XLSX and CSV are always served as an attachment, never inline. Every blob keeps `Content-Disposition` and `nosniff`.
- Optional ClamAV (requirement D5). When `CLAMAV_HOST` is set, every upload and every file the worker stores (drawings from M-Files) is streamed to clamd with `zINSTREAM` after it is read in full and before it is deduped or placed. A hit is 422 `infected`, nothing is stored, and an `asset.rejected` audit row keeps the signature, size and type. A scanner that cannot be reached refuses the upload with 503 `scan_unavailable` (fail closed). With `CLAMAV_HOST` empty there is no scan.
- Quota alert. After every stored file and in the housekeeping job, the assets volume usage is checked against `ASSETS_QUOTA_WARN_PCT` (85 by default). Over it, one open `notification_events` row of kind `assets_quota` is written and audited as `storage.quota_warning`. Back under, the row is resolved. A failed check never fails an upload. Admin sees usage, the alert and the scanner on the Storage tab and can acknowledge an alert (`alert.acknowledged`). Phase 9 sends the email.
### Warehouse rules (phase 4)
- Release to the supervisor is refused on the server until every line is pulled or short and every pulled line that issued pieces has a heat (N/A and sub-assembly lines excepted). Heat numbers are checked against the old pattern.
- Hold reasons and short reasons are closed sets on the server.
- Short receipts go to the warehouse in any status but filed, or to the holder of the packet through the BOM edit rule. Nobody else.
- An MTR on a line must be a PDF, PNG or JPEG asset.
### Worker (phase 3)
- The worker publishes no port and joins no proxy network. It reaches out to VISUAL and M-Files only; nothing reaches in. Admin and Clerical reach it only through rows in PostgreSQL (`job_requests`, the `feed` settings row) and NOTIFY.
@@ -99,6 +116,7 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
- `packetstation doctor` checks that an audit UPDATE fails.
- Every identity event is a row: sign-in, failed sign-in with a reason, sign-out, lock, unlock, session ended, PIN set, disabled or cleared, device registered or revoked, signature, AD refresh. Each carries the user, session, roles, client address and a small detail object, never a secret.
- `signin_attempts` and `signatures` are append-only in the same way.
- Phase 4 audit actions: `asset.rejected` (an upload the virus scan refused, with `reason`, `signature`, `size` and `mime`), `storage.quota_warning` (the assets volume went over the warning threshold, with the usage) and `alert.acknowledged` (an Admin acknowledged an open alert). Every warehouse write is a `packet.<action>` row with `detail.item`, the warehouse stage before and after, and `detail.qty` on short writes.
### CI identity fixtures (phase 1)
@@ -142,7 +160,7 @@ The IDs come from Part 3 of the old SECURITY.md. The status follows the approved
| A4 | Closed, phase 0 |
| D1, D2, D4 | Deferred (envelope encryption, Key Vault) |
| D3 | IT's (host disk encryption on docker-1) |
| D5 | Closed, phase 4 (optional ClamAV) |
| D5 | Closed, phase 4 (optional ClamAV: a `clamav` compose profile; when `CLAMAV_HOST` is set every upload and every stored drawing is scanned before it is kept, a hit is refused and audited, and an unreachable scanner refuses uploads; off when the host is not set) |
| D6, D7 | Deferred (retention period and customer copy need a decision from Seth and Q.C.) |
| M1 | Closed, phase 2 (append-only audit and packet log written only by the server; every packet write carries both) |
| M2 | Closed, phase 7 |
+18
View File
@@ -4,6 +4,7 @@ import { asList, ifMatch, qs } from "./query";
import type {
AttachPagesBody,
BomLine,
BomPatch,
FieldCas,
Guarded,
LogRow,
@@ -12,7 +13,10 @@ import type {
PacketHeader,
PacketListQuery,
PacketRead,
ReceiptBody,
ShortBody,
TransitionBody,
WhStageBody,
WorkOrder,
} from "./types";
@@ -68,4 +72,18 @@ export const packetsApi = {
api.post<PacketRead>(`${base(id)}/change-orders/${no}/reject`, body),
coHold: (id: number, no: number, hold: boolean, extra: Guarded = {}) =>
api.post<PacketRead>(`${base(id)}/change-orders/${no}/hold`, { hold, ...extra }),
/* Phase 4: the warehouse. */
/** One BOM line. If-Match is the line's row_version. */
patchBomLine: (id: number, item: number, rowVersion: number, body: BomPatch, g: Guarded = {}) =>
request<PacketRead>(`${base(id)}/bom/${item}`, { method: "PATCH", body: { ...body, ...g }, headers: ifMatch(rowVersion) }),
/** Opens the short on a line, or updates the open one. expect_open says which the caller saw. */
saveShort: (id: number, item: number, body: ShortBody, g: Guarded = {}) =>
api.post<PacketRead>(`${base(id)}/bom/${item}/short`, { ...body, ...g }),
/** "Not short after all": closes the open short. */
clearShort: (id: number, item: number, body: { expect_open: boolean }, g: Guarded = {}) =>
api.delWithBody<PacketRead>(`${base(id)}/bom/${item}/short`, { ...body, ...g }),
receiveShort: (id: number, item: number, body: ReceiptBody, g: Guarded = {}) =>
api.post<PacketRead>(`${base(id)}/bom/${item}/short/receipts`, { ...body, ...g }),
setWhStage: (id: number, body: WhStageBody, g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/wh`, { ...body, ...g }),
};
+10
View File
@@ -0,0 +1,10 @@
// Admin: the assets volume, the scanner and the open alerts (phase 4 contract, api/admin.py).
import { api } from "./client";
import { asList } from "./query";
import type { AlertRow, StorageInfo } from "./types";
export const storageApi = {
storage: (signal?: AbortSignal) => api.get<StorageInfo>("/api/admin/storage", signal),
alerts: async (signal?: AbortSignal): Promise<AlertRow[]> => asList<AlertRow>(await api.get<unknown>("/api/admin/alerts", signal), "alerts"),
ack: (id: number) => api.post<AlertRow | null>(`/api/admin/alerts/${encodeURIComponent(String(id))}/ack`, {}),
};
+152 -2
View File
@@ -175,6 +175,17 @@ export interface PacketCard {
co_pending?: boolean;
/** Phase 3: every leg is closed in VISUAL (observed). */
visual_closed?: boolean;
/** Phase 4: the current warehouse row, on every card. */
wh?: WhInfo | null;
wh_sent_at?: string | null;
visual_rel?: string | null;
/** Lines not N/A. */
bom_total?: number;
/** Lines not N/A that are pulled or have an open short. */
bom_pulled?: number;
bom_missing_heat?: number;
/** The released column only: the newest wh_release transition. */
released_at?: string | null;
}
export interface PacketList {
@@ -229,7 +240,16 @@ export interface BomShort {
eta: string | null;
note: string | null;
opened_at: string | null;
receipts?: unknown[];
receipts?: ShortReceipt[];
row_version?: number;
}
export interface ShortReceipt {
qty: number | string;
heat: string | null;
note: string | null;
at: string | null;
by?: WhoRef | null;
}
export interface BomLine {
@@ -249,6 +269,8 @@ export interface BomLine {
pulled?: boolean;
mtr_asset_id?: string | null;
src?: string | null;
/** "Y" on a sub-assembly line: its heats are recorded on its own packet. */
sub?: string | null;
short?: BomShort | null;
row_version?: number;
}
@@ -310,7 +332,7 @@ export interface PacketRead {
heat_table: unknown[];
stage: Record<string, unknown> | null;
stages: unknown[];
wh: Record<string, unknown> | null;
wh: WhInfo | null;
qc: Record<string, unknown> | null;
qc_history: unknown[];
change_orders: ChangeOrder[];
@@ -654,3 +676,131 @@ export interface ResourceMapRow {
rows_last_run: number | null;
updated_at: string | null;
}
/* ---------- Phase 4: warehouse ---------- */
export type WhStage = "incoming" | "pulling" | "hold" | "pulled";
/** The current packet_wh row. */
export interface WhInfo {
stage: WhStage;
by: WhoRef | null;
at: string | null;
reason: string | null;
note: string | null;
auto: boolean;
}
/** POST /api/packets/{id}/wh. */
export interface WhStageBody {
stage: WhStage;
expect_stage: WhStage;
reason?: string;
note?: string;
}
/** POST /api/packets/{id}/bom/{item}/short. */
export interface ShortBody {
qty: number;
reason: string;
ref: string | null;
eta: string | null;
note: string | null;
heat?: string;
expect_open: boolean;
}
/** POST /api/packets/{id}/bom/{item}/short/receipts. */
export interface ReceiptBody {
qty: number;
heat: string | null;
note: string | null;
}
/** PATCH /api/packets/{id}/bom/{item}. */
export interface BomPatch {
heat?: string | null;
na?: boolean;
issued?: number | null;
loc?: string | null;
pulled?: boolean;
mtr_asset_id?: string | null;
}
/** One open short on GET /api/warehouse/board. */
export interface ShortRow {
packet: {
id: number;
unit_number: string;
op: string | null;
drawing: string | null;
status: PacketStatus;
wh_stage: WhStage | null;
station: StationRef | null;
holder: string;
dept: DeptRef;
};
item: number;
bom_line_id: number;
description: string | null;
part_id: string | null;
material: string | null;
length: string | null;
qty: number | string;
qty_open: number | string;
reason: string | null;
ref: string | null;
eta: string | null;
note: string | null;
opened_at: string | null;
age_days: number;
late: boolean;
short_id: number;
row_version: number;
/** Not in the contract. A receipt needs no heat on an N/A line. */
na?: boolean;
}
export type BoardColumn = WhStage | "released";
export interface BoardResponse {
kpis: { waiting: number; pulling: number; hold: number; pulled: number; open_shorts: number; released_24h: number };
columns: Record<BoardColumn, PacketCard[]>;
shorts: ShortRow[];
}
export interface BoardQuery {
q?: string;
project_id?: number;
unit_id?: number;
dept?: string;
}
/* ---------- Phase 4: storage and alerts (Admin) ---------- */
export interface VolumeUsage {
total: number;
used: number;
free: number;
pct: number;
warn: boolean;
}
export interface AlertRow {
id: number;
kind: string;
severity: "info" | "warning" | "critical" | string;
subject: string | null;
detail: Record<string, unknown>;
created_at: string;
resolved_at: string | null;
acknowledged_at: string | null;
acknowledged_by: WhoRef | string | null;
}
export interface StorageInfo {
usage: VolumeUsage;
quota_warn_pct: number;
alert: AlertRow | null;
clamav: { configured: boolean; host: string | null; reachable: boolean | null };
}
+22
View File
@@ -0,0 +1,22 @@
// The warehouse board (phase 4 contract, api/warehouse.py).
import { api } from "./client";
import { qs } from "./query";
import type { BoardQuery, BoardResponse, PacketCard } from "./types";
const COLUMNS = ["incoming", "pulling", "hold", "pulled", "released"] as const;
/** Every column and number present, even when the server leaves one out. */
function normalise(raw: Partial<BoardResponse> | null): BoardResponse {
const cols = (raw?.columns ?? {}) as Partial<Record<(typeof COLUMNS)[number], PacketCard[]>>;
const columns = Object.fromEntries(COLUMNS.map((c) => [c, Array.isArray(cols[c]) ? cols[c] : []])) as BoardResponse["columns"];
return {
kpis: { waiting: 0, pulling: 0, hold: 0, pulled: 0, open_shorts: 0, released_24h: 0, ...(raw?.kpis ?? {}) },
columns,
shorts: Array.isArray(raw?.shorts) ? raw.shorts : [],
};
}
export const warehouseApi = {
board: async (q: BoardQuery = {}, signal?: AbortSignal): Promise<BoardResponse> =>
normalise(await api.get<Partial<BoardResponse> | null>(`/api/warehouse/board${qs({ ...q })}`, signal)),
};
+36
View File
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { makeCard } from "../test/packets";
import { Card } from "./Card";
const who = { id: "u1", display_name: "Wade Warehouse" };
describe("Card", () => {
it("shows the WH pill and line on a warehouse packet, for every role", () => {
render(
<MemoryRouter>
<Card
p={makeCard({
status: "warehouse",
wh_stage: "hold",
wh: { stage: "hold", by: who, at: new Date().toISOString(), reason: "Drawing / BOM problem", note: "Rev B on the floor", auto: false },
})}
/>
</MemoryRouter>,
);
expect(screen.getByText("WH · on hold")).toHaveClass("pill", "st-warehouse", "wh-hold");
expect(screen.getByText("On hold: Drawing / BOM problem · Rev B on the floor · <1 min")).toHaveClass("whline");
expect(screen.queryByText("With warehouse")).not.toBeInTheDocument();
});
it("keeps the status pill and no line outside the warehouse", () => {
render(
<MemoryRouter>
<Card p={makeCard({ status: "ready", wh_stage: null })} />
</MemoryRouter>,
);
expect(screen.getByText("Ready to assign")).toBeInTheDocument();
expect(screen.queryByText(/^WH ·/)).not.toBeInTheDocument();
});
});
+6 -1
View File
@@ -2,16 +2,20 @@ 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";
import { pillClassFor, pillText, WH_STAGES, whLine, whStage } from "../warehouse/stages";
/** 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);
// Every role sees where a warehouse packet really is: the stage pill and the line under it.
const atWh = p.status === "warehouse" && p.wh_stage !== undefined;
const stage = atWh ? whStage(p) : null;
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>
{stage ? <span className={pillClassFor(stage)}>{pillText(stage)}</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>
@@ -39,6 +43,7 @@ export function Card({ p, extra }: { p: PacketCard; extra?: ReactNode }) {
{shorts ? <span className="pill st-review">{shorts} short</span> : null}
{cos ? <span className="pill st-review">CO {cos}</span> : null}
</div>
{stage ? <div className={`whline ${WH_STAGES[stage].cls}`}>{whLine(p)}</div> : null}
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
{p.co_pending ? <div className="small due soon">Change from VISUAL to approve</div> : null}
{p.visual_closed && p.status !== "filed" ? <div className="small muted">Closed in VISUAL</div> : null}
+15 -12
View File
@@ -8,8 +8,9 @@ interface FindBarProps {
onQ: (q: string) => void;
pick: UnitPick;
onPick: (v: UnitPick) => void;
view: BoardView;
onView: (v: BoardView) => void;
/** The Board | List toggle. Hidden when onView is not given. */
view?: BoardView;
onView?: (v: BoardView) => void;
/** "Showing n packets". */
count: number | null;
/** The flag kind filter. Shown when onFlag is given. */
@@ -18,7 +19,7 @@ interface FindBarProps {
}
/** 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, flag = "", onFlag }: FindBarProps) {
export function FindBar({ q, onQ, pick, onPick, view = "board", onView, count, flag = "", onFlag }: FindBarProps) {
return (
<section className="fltbar" aria-label="Find jobs">
<div className="fltrow">
@@ -55,16 +56,18 @@ export function FindBar({ q, onQ, pick, onPick, view, onView, count, flag = "",
</select>
</label>
) : null}
<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>
{onView ? (
<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>
) : null}
</div>
{count !== null ? (
<div className="fltrow2">
+13
View File
@@ -81,3 +81,16 @@ export function fmtDay(iso: string | null | undefined): string {
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
/** "1.2 GB" from bytes. */
export function fmtBytes(n: number | null | undefined): string {
if (n === null || n === undefined || !Number.isFinite(n)) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${i === 0 ? String(Math.round(v)) : v.toFixed(v < 10 ? 1 : 0)} ${units[i] ?? "B"}`;
}
+118 -5
View File
@@ -15,6 +15,9 @@ import { MoveSheet, SendToDeptSheet } from "./sheets";
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
import { coSummary, coToReview } from "../lib/changeOrders";
import { ChangesTab, VisualBox } from "./visual";
import type { Write } from "../warehouse/PullTable";
import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whStage } from "../warehouse/stages";
import { useWhMove } from "../warehouse/useWhMove";
type Tab = "drawing" | "materials" | "workorders" | "changes" | "docs" | "history";
@@ -122,15 +125,18 @@ export function PacketPage() {
const versionRef = useRef<number | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
// A read that was sent before a write committed can answer after the write's own answer: never go back.
const take = useCallback((p: PacketRead) => setPacket((cur) => (cur && cur.id === p.id && cur.version > p.version ? cur : p)), []);
const reload = useCallback(async () => {
try {
const p = await packetsApi.get(id);
setPacket(p);
take(p);
setLoadError(null);
} catch (err) {
setLoadError(errorText(err));
}
}, [id]);
}, [id, take]);
useEffect(() => {
setPacket(null);
@@ -150,6 +156,31 @@ export function PacketPage() {
const job = packet ? jobName(packet) : "";
// Line writes: the answer replaces the packet; the error goes back to the row that made it.
const write: Write = useCallback(
async (fn) => {
try {
const r = await run(fn, { sub: job });
if (r === undefined) return { ok: false };
if (r && typeof r === "object" && "id" in r) take(r as PacketRead);
else await reload();
return { ok: true };
} catch (err) {
return { ok: false, err };
}
},
[run, reload, job, take],
);
const wh = useWhMove({
run,
onDone: setPacket,
onError: (err) => {
if (err instanceof ApiError && err.status === 409) void reload();
setMsg({ ok: false, text: errorText(err) });
},
});
const mutate: Mutate = useCallback(
async (fn, opts = {}) => {
setBusy(true);
@@ -157,7 +188,7 @@ export function PacketPage() {
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);
if (r && typeof r === "object" && "id" in r) take(r as PacketRead);
else await reload();
if (opts.ok) setMsg({ ok: true, text: opts.ok });
return true;
@@ -170,7 +201,7 @@ export function PacketPage() {
setBusy(false);
}
},
[run, reload, job],
[run, reload, job, take],
);
if (!packet) {
@@ -272,6 +303,22 @@ export function PacketPage() {
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 atWh = p.status === "warehouse";
const whCan = atWh && can("set_wh_stage");
const stage = whStage(p);
const whMsg = whCan ? packetMessage(p) : null;
const whTarget = { id: p.id, stage, job, progress: progress(p) };
const sendBack = async () => {
const reason = await askReason({
title: "Send back to Clerical",
sub: job,
note: "The packet goes back to Clerical as a draft. Say what needs fixing.",
min: 8,
submitLabel: "Send back to Clerical",
});
if (reason === null) return;
await transition("wh_send_back", { reason }, "Sent back to Clerical.");
};
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]);
@@ -473,6 +520,71 @@ export function PacketPage() {
</section>
) : null}
{atWh ? (
<section className={whCan ? "panel actbox" : "callout"} aria-label="Warehouse">
<div className="whstep">
<span className={pillClassFor(stage)}>{pillText(stage)}</span>
<div className={`whline ${WH_STAGES[stage].cls}`}>{whLine(p)}</div>
</div>
{whMsg ? (
<div>
<b>{whMsg.head}</b> {whMsg.rest}
</div>
) : null}
{whCan || can("wh_send_back") || can("wh_release") || p.blocked.wh_release ? (
<div className="acts">
<Act p={p} action="wh_send_back" busy={busy} className="btn ghost" onClick={() => void sendBack()}>
Send back to Clerical
</Act>
{whCan && stage === "incoming" ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "pulling")}>
Start pulling
</button>
</div>
) : null}
{whCan && stage === "hold" ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "pulling")}>
Resume pulling
</button>
</div>
) : null}
{whCan && stage === "pulling" ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "pulled")}>
Mark pulled
</button>
</div>
) : null}
{whCan && stage === "pulled" ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "pulling")}>
Back to pulling
</button>
</div>
) : null}
{whCan && (stage === "incoming" || stage === "pulling") ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "hold")}>
Hold
</button>
</div>
) : null}
<Act
p={p}
action="wh_release"
busy={busy || wh.busy}
className="btn pri"
onClick={() => void transition("wh_release", {}, "Released to supervisor.")}
>
Release to supervisor{p.open_shorts ? ` (${p.open_shorts} short)` : ""}
</Act>
</div>
) : null}
</section>
) : null}
{!draft && (can("set_vclosed") || can("file") || p.blocked.file) ? (
<section className="panel actbox" aria-label="Close and file">
<div>
@@ -536,7 +648,7 @@ export function PacketPage() {
<DrawingViewer pages={drawingPages} />
</div>
) : null}
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} /> : null}
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} write={write} reload={reload} /> : null}
{shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null}
{shownTab === "changes" ? <ChangesTab p={p} roles={me?.roles ?? []} sub={job} mutate={mutate} busy={busy} askReason={askReason} /> : null}
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} /> : null}
@@ -565,6 +677,7 @@ export function PacketPage() {
}}
/>
) : null}
{wh.sheet}
{guardSheet}
</>
);
+63 -6
View File
@@ -5,6 +5,9 @@ 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";
import { MtrViewer } from "../warehouse/MtrViewer";
import { PullTable, ShortChip, type Write } from "../warehouse/PullTable";
import { ShortSheet } from "../warehouse/ShortSheet";
export type Mutate = (
fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
@@ -35,10 +38,28 @@ const toDraft = (b: BomLine): LineDraft => ({
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 }) {
/**
* The BOM. Clerical may edit the lines of a hand draft (PUT /bom, full replace). The warehouse gets the pull table
* while the packet is with it. Anyone who may receive a short gets Receive on the line.
*/
export function MaterialsTab({
p,
mutate,
busy,
write,
reload,
}: {
p: PacketRead;
mutate: Mutate;
busy: boolean;
write?: Write | undefined;
reload?: (() => Promise<void>) | undefined;
}) {
const editable = p.status === "draft" && p.source_kind === "hand" && p.can.includes("edit_bom");
const [lines, setLines] = useState<LineDraft[] | null>(null);
const [receiveFor, setReceiveFor] = useState<number | null>(null);
const [viewFor, setViewFor] = useState<number | null>(null);
const canReceive = p.can.includes("wh_receive_short") || p.can.includes("edit_bom");
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) => {
@@ -124,6 +145,19 @@ export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutat
);
}
if (p.status === "warehouse" && p.can.includes("edit_bom") && write && reload) {
return <PullTable p={p} write={write} reload={reload} busy={busy} />;
}
const receiving = receiveFor === null ? undefined : p.bom.find((b) => b.item === receiveFor);
const viewed = viewFor === null ? undefined : p.bom.find((b) => b.item === viewFor);
const receive = async (item: number, body: Parameters<typeof packetsApi.receiveShort>[2]): Promise<string | null> => {
if (!write) return "Receiving is not available here.";
const r = await write((g) => packetsApi.receiveShort(p.id, item, body, g));
if (r.ok || r.err === undefined) return null;
return errorText(r.err);
};
return (
<div className="stack">
{editable ? (
@@ -164,12 +198,29 @@ export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutat
</td>
<td className="small">{show(b.material)}</td>
<td className="small">{show(b.length)}</td>
<td className="mono small">{show(b.heat)}</td>
<td className="mono small">
{show(b.heat)}
{b.mtr_asset_id ? (
<>
{" "}
<button type="button" className="lnk small" onClick={() => setViewFor(b.item)}>
MTR
</button>
</>
) : null}
</td>
<td>
{b.na ? (
{b.na && !b.short ? (
<span className="pill st-filed">N/A</span>
) : b.short ? (
<span className="pill st-review">{String(b.short.qty_open)} short</span>
<div className="stack tight">
<ShortChip s={b.short} />
{canReceive && write ? (
<button className="btn sm pri" type="button" disabled={busy} onClick={() => setReceiveFor(b.item)}>
Receive
</button>
) : null}
</div>
) : b.pulled ? (
<span className="pill st-ok">Pulled</span>
) : (
@@ -184,6 +235,12 @@ export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutat
) : (
<div className="empty">No BOM lines on this packet.</div>
)}
{receiving ? (
<ShortSheet mode="receive" line={receiving} onClose={() => setReceiveFor(null)} onReceive={(body) => receive(receiving.item, body)} />
) : null}
{viewed?.mtr_asset_id ? (
<MtrViewer assetId={viewed.mtr_asset_id} title={`MTR · item ${viewed.item}${viewed.heat ? ` · ${viewed.heat}` : ""}`} onClose={() => setViewFor(null)} />
) : null}
</div>
);
}
@@ -305,7 +362,7 @@ export function DocumentsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutat
}
setUploading(true);
try {
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "doc" });
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "document" });
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("");
+2
View File
@@ -5,6 +5,7 @@ import { useAuth } from "../auth/useAuth";
import { AdminPage } from "./admin/AdminPage";
import { ClericalHome } from "./clerical/ClericalHome";
import { PlaceholderScreen } from "./PlaceholderScreen";
import { WarehouseHome } from "./warehouse/WarehouseHome";
/** 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 }) {
@@ -23,5 +24,6 @@ export function ScreenRoute() {
if (!isScreen(screen) || !allowed.includes(screen)) return <Navigate to={homePath(me.screens)} replace />;
if (screen === "admin") return <AdminPage />;
if (screen === "clerical") return <ClericalHome />;
if (screen === "warehouse") return <WarehouseHome />;
return <PlaceholderScreen key={screen} screen={screen} />;
}
+4 -1
View File
@@ -6,8 +6,9 @@ import { useAuth } from "../../auth/useAuth";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad, type Load } from "../../lib/useLoad";
import { FeedTab, ResourcesTab } from "./FeedTabs";
import { StorageTab } from "./StorageTab";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts" | "feed" | "resources";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts" | "feed" | "resources" | "storage";
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "users", label: "Users" },
@@ -17,6 +18,7 @@ const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "attempts", label: "Sign-in attempts" },
{ id: "feed", label: "Feed" },
{ id: "resources", label: "VISUAL resources" },
{ id: "storage", label: "Storage" },
];
const ROLES_NOTE = "Roles come from AD groups. Ask IT to change them.";
@@ -701,6 +703,7 @@ export function AdminPage() {
{tab === "attempts" ? <AttemptsTab /> : null}
{tab === "feed" ? <FeedTab /> : null}
{tab === "resources" ? <ResourcesTab /> : null}
{tab === "storage" ? <StorageTab /> : null}
</div>
</>
);
@@ -0,0 +1,61 @@
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";
const ADMIN = makeMe({
user: { display_name: "Ann Admin", sam_account: "admin.ann" },
roles: ["admin"],
screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"],
});
const GB = 1024 ** 3;
const ALERT = {
id: 3,
kind: "assets_quota",
severity: "warning",
subject: "assets",
detail: { total: 100 * GB, used: 90 * GB, free: 10 * GB, pct: 90, warn: true },
created_at: "2026-10-16T07:00:00Z",
resolved_at: null,
acknowledged_at: null,
acknowledged_by: null,
};
function base(clamav: { configured: boolean; host: string | null; reachable: boolean | null }) {
return {
"/api/auth/me": { body: ADMIN },
"/api/departments": { body: DEPARTMENTS },
"/api/admin/users": { body: [] },
"/api/admin/storage": {
body: { usage: { total: 100 * GB, used: 90 * GB, free: 10 * GB, pct: 90, warn: true }, quota_warn_pct: 85, alert: ALERT, clamav },
},
"/api/admin/alerts": { body: { alerts: [ALERT] } },
};
}
describe("Admin Storage tab", () => {
it("shows the usage, the warning threshold, the scanner and the open alert, and acknowledges it", async () => {
const fetch = mockFetch({ ...base({ configured: true, host: "clamav", reachable: true }), "POST /api/admin/alerts/3/ack": { body: { ...ALERT, acknowledged_at: "2026-10-16T08:00:00Z" } } });
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Storage" }));
expect(await screen.findByText(/^Used/)).toHaveTextContent("Used 90 GB of 100 GB (90%).");
expect(screen.getByRole("meter", { name: "Assets volume used" })).toHaveAttribute("aria-valuenow", "90");
expect(screen.getByText("Warning at 85%.")).toBeInTheDocument();
expect(screen.getByText("clamav: reachable")).toBeInTheDocument();
const status = screen.getByRole("status");
expect(status).toHaveTextContent("The assets volume is 90% full.");
const table = await screen.findByRole("table", { name: "Open alerts" });
expect(within(table).getByText("warning")).toBeInTheDocument();
fireEvent.click(within(status).getByRole("button", { name: "Acknowledge" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/admin/alerts/3/ack")).toEqual([{}]));
expect(await screen.findByText("Alert acknowledged.")).toBeInTheDocument();
});
it("says Not configured when no scanner is set", async () => {
mockFetch(base({ configured: false, host: null, reachable: null }));
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Storage" }));
expect(await screen.findByText("Not configured")).toBeInTheDocument();
});
});
+141
View File
@@ -0,0 +1,141 @@
// Admin: the assets volume, the virus scanner and the open alerts (phase 4).
import { useState } from "react";
import { storageApi } from "../../api/storage";
import type { AlertRow } from "../../api/types";
import { errorText, fmtBytes, fmtTime } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
const whoName = (a: AlertRow["acknowledged_by"]) => (a && typeof a === "object" ? a.display_name : (a ?? ""));
function alertText(a: AlertRow): string {
if (a.kind === "assets_quota") {
const pct = Number((a.detail as { pct?: unknown }).pct);
return Number.isFinite(pct) ? `The assets volume is ${Math.round(pct)}% full.` : "The assets volume is nearly full.";
}
return a.subject ? `${a.kind.replace(/_/g, " ")}: ${a.subject}` : a.kind.replace(/_/g, " ");
}
export function StorageTab() {
const [load, reload] = useLoad((s) => storageApi.storage(s));
const [alerts, reloadAlerts] = useLoad((s) => storageApi.alerts(s));
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const ack = async (id: number) => {
setBusy(true);
setMsg(null);
try {
await storageApi.ack(id);
setMsg({ ok: true, text: "Alert acknowledged." });
reload();
reloadAlerts();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
const s = load.data;
const u = s.usage;
const pct = Math.max(0, Math.min(100, Number(u.pct) || 0));
const scan = s.clamav;
return (
<div className="stack">
{msg ? (
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
) : null}
<section className="panel stack" aria-labelledby="stor-h">
<h3 id="stor-h">Assets volume</h3>
<div>
Used <b>{fmtBytes(u.used)}</b> of <b>{fmtBytes(u.total)}</b> ({Math.round(pct)}%). <span className="muted">{fmtBytes(u.free)} free.</span>
</div>
<div
className="bar usage"
role="meter"
aria-label="Assets volume used"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(pct)}
>
<i className={u.warn ? "over" : undefined} style={{ width: `${pct}%` }} />
<span className="mark" style={{ left: `${s.quota_warn_pct}%` }} aria-hidden="true" />
</div>
<div className="small muted">Warning at {s.quota_warn_pct}%.</div>
{s.alert ? (
<div className="callout warn flush" role="status">
<b>{alertText(s.alert)}</b> Raised {fmtTime(s.alert.created_at)}.
{s.alert.acknowledged_at ? (
<> Acknowledged {fmtTime(s.alert.acknowledged_at)}{whoName(s.alert.acknowledged_by) ? ` by ${whoName(s.alert.acknowledged_by)}` : ""}.</>
) : (
<>
{" "}
<button className="btn sm" type="button" disabled={busy} onClick={() => void ack(s.alert?.id ?? 0)}>
Acknowledge
</button>
</>
)}
</div>
) : null}
</section>
<section className="panel" aria-label="Virus scanner">
<b>Virus scanner (ClamAV): </b>
{!scan.configured ? (
<span className="muted">Not configured</span>
) : (
<span className={scan.reachable ? "st-ok" : "st-bad"}>
{scan.host ?? "clamav"}: {scan.reachable ? "reachable" : "not reachable"}
</span>
)}
</section>
<section className="sec" aria-labelledby="alerts-h">
<div className="sec-h">
<h3 id="alerts-h">Open alerts</h3>
</div>
{alerts.state === "loading" ? <div className="empty slim">Loading</div> : null}
{alerts.state === "error" ? <div className="callout warn">{alerts.message}</div> : null}
{alerts.state === "ok" && !alerts.data.length ? <div className="empty slim">No open alerts.</div> : null}
{alerts.state === "ok" && alerts.data.length ? (
<div className="tw">
<table aria-label="Open alerts">
<thead>
<tr>
<th>Raised</th>
<th>Severity</th>
<th>What</th>
<th>Acknowledged</th>
<th />
</tr>
</thead>
<tbody>
{alerts.data.map((a) => (
<tr key={a.id}>
<td className="small mono nowrap">{fmtTime(a.created_at)}</td>
<td className="small">{a.severity}</td>
<td>{alertText(a)}</td>
<td className="small">{a.acknowledged_at ? `${fmtTime(a.acknowledged_at)} ${whoName(a.acknowledged_by)}` : "-"}</td>
<td>
{a.acknowledged_at ? null : (
<button className="btn sm" type="button" disabled={busy} onClick={() => void ack(a.id)}>
Acknowledge
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</section>
</div>
);
}
@@ -0,0 +1,88 @@
import { Link } from "react-router";
import type { ShortRow } from "../../api/types";
import { fmtDay } from "../../lib/format";
import { pillClass, statusLabel } from "../../lib/packets";
import { pillClassFor, pillText, whStage } from "../../warehouse/stages";
export const NO_SHORTS = "No open shorts. When a part isn't on the shelf, mark it Short on the pull and it lands here.";
const jobOf = (r: ShortRow) => (r.packet.op ? `${r.packet.unit_number} · ${r.packet.op}` : r.packet.unit_number);
/** Every open short on every live packet, soonest ETA first (the old Open shorts table). */
export function ShortsTable({ rows, busy, onReceive }: { rows: ShortRow[]; busy: boolean; onReceive: (r: ShortRow) => void }) {
return (
<section className="sec" aria-labelledby="shorts-h">
<div className="sec-h">
<h2 id="shorts-h">Open shorts</h2>
<span className="ct">parts that went on without the full pull. Receive them here when they come in.</span>
</div>
{rows.length ? (
<div className="tw">
<table aria-label="Open shorts">
<thead>
<tr>
<th>Job</th>
<th>Item</th>
<th className="num">Short</th>
<th>Reason</th>
<th>PO / req</th>
<th>ETA</th>
<th className="num">Age</th>
<th>Packet is at</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((r) => {
const atWh = r.packet.status === "warehouse";
const stage = atWh ? whStage({ wh_stage: r.packet.wh_stage }) : null;
return (
<tr key={r.short_id}>
<td>
<Link className="lnk" to={`/packets/${r.packet.id}`}>
{jobOf(r)}
</Link>
<div className="sub">
{r.packet.op ?? ""} · {r.packet.drawing ?? ""}
</div>
</td>
<td>
<span className="balloon">{r.item}</span> {r.description ?? ""}
<div className="sub mono">{[r.part_id, r.material, r.length].filter(Boolean).join(" ")}</div>
</td>
<td className="num shortqty">{String(r.qty_open)}</td>
<td>
{r.reason ?? ""}
{r.note ? <div className="sub">{r.note}</div> : null}
</td>
<td className="mono">{r.ref || "-"}</td>
<td className={r.late ? "mono late" : "mono"}>{r.eta ? `${fmtDay(r.eta)}${r.late ? " · late" : ""}` : "-"}</td>
<td className="num">{r.age_days} d</td>
<td>
{stage ? (
<span className={pillClassFor(stage)}>{pillText(stage)}</span>
) : (
<span className={pillClass(r.packet.status)}>{statusLabel(r.packet.status)}</span>
)}
<div className="sub">
{r.packet.holder}
{r.packet.station ? ` · ${r.packet.station.name}` : ""}
</div>
</td>
<td>
<button className="btn sm pri" type="button" disabled={busy} onClick={() => onReceive(r)}>
Receive
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="empty slim">{NO_SHORTS}</div>
)}
</section>
);
}
@@ -0,0 +1,249 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { BoardResponse, PacketCard, ShortRow } from "../../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
import { makeCard, makePacket } from "../../test/packets";
const WADE = makeMe({ roles: ["warehouse"], screens: ["warehouse"], user: { sam_account: "wh.wade", display_name: "Wade Warehouse" } });
const who = { id: "u-wade", display_name: "Wade Warehouse" };
const WELD = { code: "03", name: "Weld Parts" };
const ELEC = { code: "14", name: "Electrical" };
function whCard(id: number, over: Partial<PacketCard> = {}): PacketCard {
return makeCard({
id,
status: "warehouse",
holder: "Warehouse",
unit: { id: 100 + id, number: `L99900${id}` },
wh_stage: "incoming",
wh: null,
wh_sent_at: "2026-10-15T08:00:00Z",
visual_rel: null,
bom_total: 3,
bom_pulled: 0,
bom_missing_heat: 0,
...over,
});
}
const SHORT: ShortRow = {
packet: {
id: 9,
unit_number: "L999009",
op: "FIT-UP",
drawing: "D-9",
status: "ready",
wh_stage: null,
station: null,
holder: "Weld Parts",
dept: WELD,
},
item: 2,
bom_line_id: 92,
description: "Pipe 2in",
part_id: "P-2",
material: "A106",
length: "20 ft",
qty: 4,
qty_open: 1,
reason: "Not in stock",
ref: "PO 4471",
eta: "2026-10-01",
note: null,
opened_at: "2026-10-10T08:00:00Z",
age_days: 6,
late: true,
short_id: 5,
row_version: 1,
};
function board(over: Partial<BoardResponse> = {}): BoardResponse {
return {
kpis: { waiting: 3, pulling: 1, hold: 1, pulled: 1, open_shorts: 1, released_24h: 2 },
columns: {
incoming: [
whCard(1, { dept: WELD, wh_sent_at: "2026-10-15T10:00:00Z" }),
whCard(2, { dept: ELEC, wh_sent_at: "2026-10-12T10:00:00Z" }),
whCard(3, { dept: WELD, wh_sent_at: "2026-10-14T10:00:00Z" }),
],
pulling: [whCard(4, { wh_stage: "pulling", wh: { stage: "pulling", by: who, at: "2026-10-16T07:00:00Z", reason: null, note: null, auto: true }, bom_pulled: 1, bom_missing_heat: 1 })],
hold: [
whCard(5, {
wh_stage: "hold",
wh: { stage: "hold", by: who, at: "2026-10-16T07:00:00Z", reason: "Waiting on purchasing / PO", note: "PO 4471 due Fri", auto: false },
}),
],
pulled: [whCard(6, { wh_stage: "pulled", wh: { stage: "pulled", by: who, at: "2026-10-16T07:00:00Z", reason: null, note: null, auto: false }, bom_pulled: 3 })],
released: [makeCard({ id: 7, status: "ready", holder: "Weld Parts", unit: { id: 107, number: "L999007" }, released_at: "2026-10-16T06:00:00Z" })],
},
shorts: [SHORT],
...over,
};
}
function base(b: BoardResponse = board(), me = WADE) {
return {
"/api/auth/me": { body: me },
"/api/departments": { body: DEPARTMENTS },
"/api/warehouse/board": { body: b },
"/api/projects": { body: [] },
};
}
const column = (title: string) => screen.getByRole("heading", { name: new RegExp(`^${title.replace(/[()]/g, "\\$&")}`) }).closest("section") as HTMLElement;
const cardOf = (name: string) => screen.getByRole("group", { name: new RegExp(`^${name}`) });
describe("WarehouseHome", () => {
it("shows the numbers, the five columns, and groups cards by department oldest first", async () => {
mockFetch(base());
renderApp("/warehouse");
expect(await screen.findByRole("heading", { name: /^Waiting to pull/ })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Warehouse" })).toBeInTheDocument();
expect(screen.getByText("Parts pull")).toBeInTheDocument();
for (const t of ["Waiting to pull", "Being pulled", "On hold", "Pulled, ready for production", "Released to shop (3 days)"]) {
expect(column(t)).toBeInTheDocument();
}
expect(screen.getByLabelText("Waiting to pull: 3")).toBeInTheDocument();
expect(screen.getByLabelText("On hold: 1")).toHaveClass("hot");
expect(screen.getByLabelText("Ready for production: 1")).toHaveClass("hot");
expect(screen.getByLabelText("Open shorts: 1")).toHaveClass("hot");
expect(screen.getByLabelText("Released last 24 h: 2")).toBeInTheDocument();
const waiting = column("Waiting to pull");
// Electrical has the oldest packet, so it comes first; Weld Parts cards go oldest first.
const heads = Array.from(waiting.querySelectorAll(".whgrp")).map((g) => g.textContent);
expect(heads).toEqual(["Electrical1", "Weld Parts2"]);
const jobs = Array.from(waiting.querySelectorAll(".whcard .job")).map((j) => j.textContent);
expect(jobs).toEqual(["L999002 · WELD-PART", "L999003 · WELD-PART", "L999001 · WELD-PART"]);
expect(within(column("Released to shop (3 days)")).getByRole("link", { name: /L999007/ })).toBeInTheDocument();
});
it("shows the pill, the line and the buttons for each stage", async () => {
mockFetch(base());
renderApp("/warehouse");
const inc = await screen.findByRole("group", { name: "L999002 · WELD-PART, Waiting to pull" });
expect(within(inc).getByText("WH · waiting")).toBeInTheDocument();
expect(within(inc).getByText(/^Waiting .*, not started yet$/)).toBeInTheDocument();
expect(within(inc).getByRole("button", { name: "Start pulling" })).toHaveClass("pri");
expect(within(inc).getByRole("button", { name: "Hold" })).toBeInTheDocument();
expect(within(inc).getByText((_, el) => el?.tagName === "SPAN" && el.textContent === "0/3 lines pulled")).toBeInTheDocument();
const pul = cardOf("L999004");
expect(within(pul).getByText("WH · being pulled")).toBeInTheDocument();
expect(within(pul).getByText(/^Being pulled by Wade Warehouse/)).toBeInTheDocument();
expect(within(pul).getByRole("button", { name: "Mark pulled" })).not.toHaveClass("pri");
expect(within(pul).getByText("1 heat missing")).toBeInTheDocument();
const hold = cardOf("L999005");
expect(within(hold).getByText("WH · on hold")).toBeInTheDocument();
expect(within(hold).getByText(/^On hold: Waiting on purchasing \/ PO · PO 4471 due Fri/)).toBeInTheDocument();
expect(within(hold).getByRole("button", { name: "Resume pulling" })).toHaveClass("pri");
expect(within(hold).queryByRole("button", { name: "Hold" })).not.toBeInTheDocument();
const done = cardOf("L999006");
expect(within(done).getByText("WH · pulled")).toBeInTheDocument();
expect(within(done).getByRole("button", { name: "Release…" })).toBeInTheDocument();
expect(within(done).getByRole("button", { name: "Back to pulling" })).toBeInTheDocument();
});
it("shows no stage buttons to a role outside the warehouse", async () => {
const wes = makeMe({ roles: ["shop:03:supervisor"], screens: ["warehouse", "supervisor"] });
mockFetch(base(board(), wes));
renderApp("/warehouse");
const inc = await screen.findByRole("group", { name: /^L999002/ });
expect(within(inc).getByText("WH · waiting")).toBeInTheDocument();
expect(within(inc).queryByRole("button")).not.toBeInTheDocument();
});
it("Start pulling posts the stage with the stage it saw", async () => {
const fetch = mockFetch({ ...base(), "POST /api/packets/2/wh": { body: makePacket({ id: 2, status: "warehouse" }) } });
renderApp("/warehouse");
const inc = await screen.findByRole("group", { name: /^L999002/ });
fireEvent.click(within(inc).getByRole("button", { name: "Start pulling" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/2/wh")).toEqual([{ stage: "pulling", expect_stage: "incoming" }]));
});
it("the hold sheet refuses Other without a note, then posts the reason and note", async () => {
const fetch = mockFetch({ ...base(), "POST /api/packets/2/wh": { body: makePacket({ id: 2, status: "warehouse" }) } });
renderApp("/warehouse");
const inc = await screen.findByRole("group", { name: /^L999002/ });
fireEvent.click(within(inc).getByRole("button", { name: "Hold" }));
const sheet = await screen.findByRole("dialog", { name: "Put on hold" });
fireEvent.click(within(sheet).getByLabelText("Other"));
fireEvent.click(within(sheet).getByRole("button", { name: "Put on hold" }));
expect(within(sheet).getByRole("alert")).toHaveTextContent("Say what it's waiting on.");
expect(callsTo(fetch, "POST", "/api/packets/2/wh")).toHaveLength(0);
fireEvent.click(within(sheet).getByLabelText("Waiting on purchasing / PO"));
fireEvent.change(within(sheet).getByLabelText(/^Note/), { target: { value: "PO 4471 due Fri" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Put on hold" }));
await waitFor(() =>
expect(callsTo(fetch, "POST", "/api/packets/2/wh")).toEqual([
{ stage: "hold", expect_stage: "incoming", reason: "Waiting on purchasing / PO", note: "PO 4471 due Fri" },
]),
);
expect(screen.queryByRole("dialog", { name: "Put on hold" })).not.toBeInTheDocument();
});
it("Mark pulled asks first when lines remain", async () => {
const fetch = mockFetch({ ...base(), "POST /api/packets/4/wh": { body: makePacket({ id: 4, status: "warehouse" }) } });
renderApp("/warehouse");
const pul = await screen.findByRole("group", { name: /^L999004/ });
fireEvent.click(within(pul).getByRole("button", { name: "Mark pulled" }));
const sheet = await screen.findByRole("dialog", { name: "Not everything is pulled yet" });
expect(sheet).toHaveTextContent("2 lines not ticked Pulled and 1 heat number missing. Mark it pulled anyway?");
expect(callsTo(fetch, "POST", "/api/packets/4/wh")).toHaveLength(0);
fireEvent.click(within(sheet).getByRole("button", { name: "Mark pulled" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/4/wh")).toEqual([{ stage: "pulled", expect_stage: "pulling" }]));
});
it("a drop on the released column is refused with the toast; a drop on a stage moves the card", async () => {
const fetch = mockFetch({ ...base(), "POST /api/packets/6/wh": { body: makePacket({ id: 6, status: "warehouse" }) } });
renderApp("/warehouse");
const done = await screen.findByRole("group", { name: /^L999006/ });
const drag = (to: string) => {
const col = document.querySelector(`[data-whcol="${to}"]`) as HTMLElement;
const spy = vi.fn(() => [col]);
Object.defineProperty(document, "elementsFromPoint", { value: spy, configurable: true });
fireEvent.pointerDown(done, { clientX: 10, clientY: 10, button: 0, pointerType: "mouse" });
fireEvent.pointerMove(window, { clientX: 60, clientY: 40 });
fireEvent.pointerUp(window, { clientX: 60, clientY: 40 });
};
drag("released");
expect(await screen.findByRole("status")).toHaveTextContent("Open the packet and use Release to supervisor. It checks heats and shorts first.");
expect(callsTo(fetch, "POST", "/api/packets/6/wh")).toHaveLength(0);
// A drag never counts as a click: the packet does not open.
fireEvent.click(done);
expect(screen.getByRole("heading", { name: "Warehouse" })).toBeInTheDocument();
drag("pulling");
await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/6/wh")).toEqual([{ stage: "pulling", expect_stage: "pulled" }]));
});
it("lists the open shorts and receives one with a heat", async () => {
const fetch = mockFetch({
...base(),
"POST /api/packets/9/bom/2/short/receipts": { body: makePacket({ id: 9, status: "ready" }) },
});
renderApp("/warehouse");
const table = await screen.findByRole("table", { name: "Open shorts" });
expect(within(table).getByText("PO 4471")).toBeInTheDocument();
expect(within(table).getByText(/· late$/)).toBeInTheDocument();
fireEvent.click(within(table).getByRole("button", { name: "Receive" }));
const sheet = await screen.findByRole("dialog", { name: "Receive short part" });
fireEvent.click(within(sheet).getByRole("button", { name: "Receive" }));
expect(within(sheet).getByRole("alert")).toHaveTextContent("Scan or type the heat number of the part received.");
fireEvent.change(within(sheet).getByLabelText("Heat"), { target: { value: "HT-9" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Receive" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/9/bom/2/short/receipts")).toEqual([{ qty: 1, heat: "HT-9", note: null }]));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Receive short part" })).not.toBeInTheDocument());
});
it("says so when there are no open shorts", async () => {
mockFetch(base(board({ shorts: [] })));
renderApp("/warehouse");
expect(await screen.findByText("No open shorts. When a part isn't on the shelf, mark it Short on the pull and it lands here.")).toBeInTheDocument();
});
});
@@ -0,0 +1,139 @@
import { useCallback, useEffect, useState } from "react";
import { ApiError } from "../../api/client";
import { packetsApi } from "../../api/packets";
import type { BoardColumn, PacketCard, ReceiptBody, ShortRow, WhStage } from "../../api/types";
import { warehouseApi } from "../../api/warehouse";
import { useAuth } from "../../auth/useAuth";
import { FindBar } from "../../components/FindBar";
import { Kpis } from "../../components/Kpis";
import type { UnitPick } from "../../components/PickUnit";
import { useGuard } from "../../components/useGuard";
import { useChanged } from "../../lib/changes";
import { errorText } from "../../lib/format";
import { jobName } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { ShortSheet } from "../../warehouse/ShortSheet";
import { cardProgress, isWarehouseUser, whStage } from "../../warehouse/stages";
import { useWhMove } from "../../warehouse/useWhMove";
import { ShortsTable } from "./ShortsTable";
import { RELEASE_REFUSED, WhBoard } from "./WhBoard";
/** /warehouse (the old homeWarehouse): numbers, the open shorts, the find bar and the five-column board. */
export function WarehouseHome() {
const { me } = useAuth();
const { run, sheet: guardSheet } = useGuard();
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
const [toast, setToast] = useState<string | null>(null);
const [receiving, setReceiving] = useState<ShortRow | null>(null);
const projectId = pick.project?.id;
const unitId = pick.unit?.id;
useEffect(() => {
const t = window.setTimeout(() => setQuery(q.trim()), 250);
return () => window.clearTimeout(t);
}, [q]);
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 4000);
return () => window.clearTimeout(t);
}, [toast]);
const [load, reload] = useLoad(
(s) => warehouseApi.board({ ...(query ? { q: query } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s),
`${query}|${projectId ?? ""}|${unitId ?? ""}`,
);
useChanged(reload, { delayMs: 500 });
const onError = useCallback(
(err: unknown) => {
if (err instanceof ApiError && err.status === 409) reload();
setToast(errorText(err));
},
[reload],
);
const wh = useWhMove({ run, onDone: reload, onError });
const target = (p: PacketCard) => ({ id: p.id, stage: whStage(p), job: jobName(p), progress: cardProgress(p) });
const onMove = (p: PacketCard, to: WhStage) => wh.move(target(p), to);
const onDrop = (p: PacketCard, to: BoardColumn) => {
if (to === "released") {
setToast(RELEASE_REFUSED);
return;
}
wh.move(target(p), to);
};
const receive = async (r: ShortRow, body: ReceiptBody): Promise<string | null> => {
try {
const out = await run((g) => packetsApi.receiveShort(r.packet.id, r.item, body, g), { sub: r.packet.unit_number });
if (out !== undefined) reload();
return null;
} catch (err) {
if (err instanceof ApiError && err.status === 409) reload();
return errorText(err);
}
};
const data = load.state === "ok" ? load.data : null;
const k = data?.kpis;
const canMove = isWarehouseUser(me?.roles ?? []);
const count = data ? (["incoming", "pulling", "hold", "pulled"] as const).reduce((n, c) => n + data.columns[c].length, 0) : null;
return (
<>
<header className="phead">
<div>
<div className="eyebrow">Parts pull</div>
<h1>Warehouse</h1>
</div>
</header>
<Kpis
items={[
{ n: k?.waiting ?? 0, label: "Waiting to pull" },
{ n: k?.pulling ?? 0, label: "Being pulled" },
{ n: k?.hold ?? 0, label: "On hold", alert: true },
{ n: k?.pulled ?? 0, label: "Ready for production", alert: true },
{ n: k?.open_shorts ?? 0, label: "Open shorts", alert: true },
{ n: k?.released_24h ?? 0, label: "Released last 24 h" },
]}
/>
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
{data ? <ShortsTable rows={data.shorts} busy={wh.busy} onReceive={setReceiving} /> : null}
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} count={count} />
{data ? <WhBoard columns={data.columns} canMove={canMove} busy={wh.busy} onMove={onMove} onDrop={onDrop} /> : null}
{receiving ? (
<ShortSheet
mode="receive"
sub={receiving.packet.op ? `${receiving.packet.unit_number} · ${receiving.packet.op}` : receiving.packet.unit_number}
line={{
item: receiving.item,
description: receiving.description,
part_id: receiving.part_id,
qty: receiving.qty,
na: receiving.na,
short: { qty_open: receiving.qty_open, reason: receiving.reason, ref: receiving.ref, eta: receiving.eta, note: receiving.note },
}}
onClose={() => setReceiving(null)}
onReceive={(body) => receive(receiving, body)}
/>
) : null}
{wh.sheet}
{guardSheet}
{toast ? (
<div className="toast" role="status">
{toast}
</div>
) : null}
</>
);
}
+227
View File
@@ -0,0 +1,227 @@
import { useRef, type KeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react";
import { useNavigate } from "react-router";
import type { BoardColumn, BoardResponse, PacketCard, WhStage } from "../../api/types";
import { Card } from "../../components/Card";
import { fmtDay } from "../../lib/format";
import { jobName } from "../../lib/packets";
import { cardProgress, daysSince, pillClassFor, pillText, WH_STAGES, whLine, whStage } from "../../warehouse/stages";
import { groupByDept, WH_COLUMNS } from "./boards";
export const RELEASE_REFUSED = "Open the packet and use Release to supervisor. It checks heats and shorts first.";
/** Pixels the pointer must travel before a press becomes a drag. */
const DRAG_START = 10;
interface CardProps {
p: PacketCard;
canMove: boolean;
busy: boolean;
onMove: (p: PacketCard, to: WhStage) => void;
onDrop: (p: PacketCard, to: BoardColumn) => void;
}
/** A warehouse card (the old whCard): progress, dates, the line, the stage buttons, and drag to another column. */
export function WhCard({ p, canMove, busy, onMove, onDrop }: CardProps) {
const navigate = useNavigate();
const dragged = useRef(false);
const s = whStage(p);
const g = cardProgress(p);
const waited = daysSince(p.wh_sent_at);
const open = () => navigate(`/packets/${p.id}`);
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!canMove) return;
if ((e.target as HTMLElement).closest("button, a, input, select, textarea, label")) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
const card = e.currentTarget;
const sx = e.clientX;
const sy = e.clientY;
let ghost: HTMLElement | null = null;
let over: HTMLElement | null = null;
const move = (ev: PointerEvent) => {
if (!ghost && Math.hypot(ev.clientX - sx, ev.clientY - sy) < DRAG_START) return;
if (!ghost) {
ghost = card.cloneNode(true) as HTMLElement;
ghost.classList.add("whghost");
ghost.removeAttribute("id");
ghost.setAttribute("aria-hidden", "true");
ghost.style.width = `${card.offsetWidth}px`;
document.body.appendChild(ghost);
card.classList.add("whsrc");
}
ev.preventDefault();
ghost.style.left = `${ev.clientX}px`;
ghost.style.top = `${ev.clientY}px`;
const hits = typeof document.elementsFromPoint === "function" ? document.elementsFromPoint(ev.clientX, ev.clientY) : [];
const col = hits.map((x) => x.closest<HTMLElement>("[data-whcol]")).find((x): x is HTMLElement => !!x) ?? null;
if (col !== over) {
over?.classList.remove("whover");
over = col;
if (over && over.dataset.whcol !== "released") over.classList.add("whover");
}
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
if (!ghost) return;
ghost.remove();
card.classList.remove("whsrc");
over?.classList.remove("whover");
// The click that follows a drag is not a click.
dragged.current = true;
window.setTimeout(() => {
dragged.current = false;
}, 60);
const to = over?.dataset.whcol as BoardColumn | undefined;
if (to) onDrop(p, to);
};
window.addEventListener("pointermove", move, { passive: false });
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
};
const onClick = (e: MouseEvent<HTMLDivElement>) => {
if (dragged.current) {
dragged.current = false;
return;
}
if ((e.target as HTMLElement).closest("button, a, input, select, textarea, label")) return;
open();
};
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" && e.target === e.currentTarget) open();
};
const btn = (to: WhStage, label: string, pri = false) => (
<button className={pri ? "btn sm pri" : "btn sm"} type="button" disabled={busy} onClick={() => onMove(p, to)}>
{label}
</button>
);
return (
<div
className={canMove ? "card whcard whdrag" : "card whcard"}
data-whpid={p.id}
tabIndex={0}
role="group"
aria-label={`${jobName(p)}, ${WH_STAGES[s].name}`}
onPointerDown={onPointerDown}
onClick={onClick}
onKeyDown={onKey}
>
<div className="r1">
<span className="job">{jobName(p)}</span>
<span className={pillClassFor(s)}>{pillText(s)}</span>
</div>
<div className="tt">
{p.op ?? ""} · {p.title ?? ""}
</div>
<div className="small whdates">
<span>
Sent <b>{p.wh_sent_at ? fmtDay(p.wh_sent_at) : "-"}</b>
</span>
{p.visual_rel ? (
<span>
Release <b>{fmtDay(p.visual_rel)}</b>
</span>
) : null}
{waited !== null && waited >= 1 ? <span className={waited >= 3 ? "due over" : "muted"}>{waited} d waiting</span> : null}
</div>
<div className="meta">
<span>
Dwg <b className="mono">{p.drawing || "-"}</b>
</span>
<span>
<b>{g.pulled}</b>/{g.total} lines pulled
</span>
{g.missingHeat ? (
<span className="due over">
{g.missingHeat} heat{g.missingHeat === 1 ? "" : "s"} missing
</span>
) : null}
{g.shorts ? <span className="pill st-review">{g.shorts} short</span> : null}
</div>
<div className="bar" title={`${g.pulled} of ${g.total} lines pulled`}>
<i style={{ width: `${g.total ? Math.min(100, (g.pulled / g.total) * 100) : 0}%` }} />
</div>
<div className={`whline ${WH_STAGES[s].cls}`}>{whLine(p)}</div>
{canMove ? (
<div className="whacts">
{s === "incoming" ? (
<>
{btn("pulling", "Start pulling", true)}
{btn("hold", "Hold")}
</>
) : s === "pulling" ? (
<>
{btn("pulled", "Mark pulled", g.done)}
{btn("hold", "Hold")}
</>
) : s === "hold" ? (
btn("pulling", "Resume pulling", true)
) : (
<>
<button className="btn sm pri" type="button" onClick={open}>
Release…
</button>
{btn("pulling", "Back to pulling")}
</>
)}
</div>
) : null}
</div>
);
}
/** The five columns of the warehouse board. The four stage columns group by department. */
export function WhBoard({
columns,
canMove,
busy,
onMove,
onDrop,
}: {
columns: BoardResponse["columns"];
canMove: boolean;
busy: boolean;
onMove: (p: PacketCard, to: WhStage) => void;
onDrop: (p: PacketCard, to: BoardColumn) => void;
}) {
return (
<div className="board whboard">
{WH_COLUMNS.map((c) => {
const list = columns[c.key];
const id = `whcol-${c.key}`;
return (
<section key={c.key} className="col whcol" data-whcol={c.key} aria-labelledby={id}>
<div className="col-h">
<h3 id={id}>{c.title}</h3>
<span className="c" aria-label={`${list.length} packet${list.length === 1 ? "" : "s"}`}>
{list.length}
</span>
</div>
{!list.length ? (
<div className="empty">{c.empty}</div>
) : c.key === "released" ? (
list.map((p) => <Card key={p.id} p={p} />)
) : (
groupByDept(list).map((grp) => (
<div key={grp.dept.code} className="whgrpbox">
<div className="whgrp">
<span>{grp.dept.name}</span>
<b>{grp.cards.length}</b>
</div>
{grp.cards.map((p) => (
<WhCard key={p.id} p={p} canMove={canMove} busy={busy} onMove={onMove} onDrop={onDrop} />
))}
</div>
))
)}
</section>
);
})}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
// The warehouse board columns, grouped by department (the old whColumn).
import type { BoardColumn, DeptRef, PacketCard } from "../../api/types";
import { whOrder } from "../../warehouse/stages";
export interface WhColumnDef {
key: BoardColumn;
title: string;
empty: string;
}
export const WH_COLUMNS: readonly WhColumnDef[] = [
{ key: "incoming", title: "Waiting to pull", empty: "Nothing waiting. Clerical sends packets here when they're printed." },
{ key: "pulling", title: "Being pulled", empty: "Nobody is pulling right now. Drag a card here or tap Start pulling." },
{ key: "hold", title: "On hold", empty: "Nothing on hold." },
{ key: "pulled", title: "Pulled, ready for production", empty: "Nothing pulled and waiting on production." },
{ key: "released", title: "Released to shop (3 days)", empty: "Nothing released in the last 3 days." },
];
export interface DeptGroup {
dept: DeptRef;
cards: PacketCard[];
}
/** Cards by department, oldest first in each; the department with the oldest packet first. */
export function groupByDept(cards: readonly PacketCard[]): DeptGroup[] {
const groups = new Map<string, DeptGroup>();
for (const p of [...cards].sort(whOrder)) {
const g = groups.get(p.dept.code);
if (g) g.cards.push(p);
else groups.set(p.dept.code, { dept: p.dept, cards: [p] });
}
return [...groups.values()].sort((a, b) => whOrder(a.cards[0] as PacketCard, b.cards[0] as PacketCard));
}
+54
View File
@@ -350,3 +350,57 @@ label.btn input.sr { pointer-events: none; }
.pdfv-pages { flex-direction: row; flex-wrap: wrap; }
.pdfv-pages button { width: auto; }
}
/* ===== New in the rebuild (phase 4) ===== */
/* The warehouse board, the stage pill and line, the pull table. From the old .wh-*, .whline, .whacts, .whcard,
.whboard, .whcol, .whover, .whsrc, .whghost, .whstep, .whgrp, .whdates, .visbadge, .heat and the pullTable rows. */
.col-h h3 { font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit; }
.wh-in { color: #d9b25e; border-style: dashed; }
.wh-pull { color: var(--amber); }
.wh-hold { color: var(--pink); }
.wh-done { color: var(--green); }
.whline { font-size: 12.5px; font-weight: 600; padding: 4px 8px; border-radius: 6px; background: rgba(255, 255, 255, .04); border-left: 3px solid currentColor; }
.whline.wh-in { border-left-style: dashed; }
.whacts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; }
.whcard { cursor: pointer; }
.whcard:hover, .whcard:focus-visible { border-color: var(--cyan); }
.whcard.whdrag { touch-action: pan-y; user-select: none; }
.whboard { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
.whcol { transition: border-color .12s, background .12s; }
.whcol.whover { border-color: var(--cyan); background: #12303a; }
.whsrc { opacity: .35; }
.whghost { position: fixed; z-index: 80; pointer-events: none; transform: translate(-50%, -30%) rotate(1.5deg); box-shadow: 0 10px 30px rgba(0, 0, 0, .5); opacity: .95; }
.whstep { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
.whgrpbox { display: flex; flex-direction: column; gap: 10px; }
.whgrp { display: flex; justify-content: space-between; align-items: center; margin: 10px 2px 0; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--cyan); }
.whgrpbox:first-of-type .whgrp { margin-top: 2px; }
.whgrp b { color: var(--muted); }
.whdates { display: flex; gap: 10px; flex-wrap: wrap; color: var(--muted); }
.whdates b { color: var(--body); }
.visbadge { display: inline-block; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; padding: 2px 7px; border-radius: 5px; border: 1px solid var(--cyan); color: var(--cyan); margin-right: 6px; }
.heat { font-family: var(--f-mono); font-weight: 600; color: var(--amber); }
.heat.none { color: var(--muted); font-weight: 400; }
input.in.heatIn { font-family: var(--f-mono); min-width: 170px; }
input.in.heatIn.need { border-color: var(--amber); box-shadow: 0 0 0 1px var(--amber) inset; }
input.in.w80 { width: 80px; }
input.in.w110 { width: 110px; }
table.pull td.need { font-size: 18px; font-weight: 600; }
tr.pullrow.pulled td { background: #13261d; }
tr.pullrow.short td { background: #2a1a24; }
tr.pullerr td { border-top: 0; padding-top: 0; }
.pullck { display: flex; gap: 8px; align-items: center; }
.pullck input { width: 26px; height: 26px; accent-color: var(--green); }
.nack { display: inline-flex; gap: 6px; align-items: center; white-space: nowrap; }
.nack input { width: 20px; height: 20px; }
td.heatcell { min-width: 190px; }
td.shortcell { min-width: 150px; }
.stack.tight { gap: 4px; align-items: flex-start; }
.shortchip { display: inline-flex; flex-wrap: wrap; gap: 4px; align-items: center; }
td.shortqty { font-size: 18px; font-weight: 700; color: var(--pink); }
td.late { color: var(--red); }
.btn.dim { opacity: .55; pointer-events: none; }
.btn.pinktxt { color: var(--pink); }
.mtrframe { width: 100%; height: min(75vh, 900px); border: 1px solid var(--line); border-radius: 10px; background: var(--white); }
.mtrimg { max-width: 100%; height: auto; border-radius: 10px; background: var(--white); align-self: center; }
.bar.usage { position: relative; height: 12px; border-radius: 6px; }
.bar.usage .mark { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--amber); }
+91
View File
@@ -0,0 +1,91 @@
import { useState, type FormEvent } from "react";
import { Sheet } from "../components/Sheet";
import { HOLD_NOTE_MAX, WH_REASONS } from "./stages";
/** Put a packet on hold: one of the hold reasons and a note. "Other" needs the note. */
export function HoldSheet({
sub,
onHold,
onCancel,
}: {
sub?: string | undefined;
onHold: (reason: string, note: string) => void;
onCancel: () => void;
}) {
const [reason, setReason] = useState<string>(WH_REASONS[0]);
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const submit = (e: FormEvent) => {
e.preventDefault();
const n = note.trim().slice(0, HOLD_NOTE_MAX);
if (reason === "Other" && !n) {
setError("Say what it's waiting on.");
return;
}
onHold(reason, n);
};
return (
<Sheet title="Put on hold" sub={sub} onClose={onCancel}>
<div className="small">Everyone who follows this packet sees it's on hold and why.</div>
<form className="stack" onSubmit={submit} noValidate>
<div className="chips" role="radiogroup" aria-label="Reason">
{WH_REASONS.map((r) => (
<label key={r} className="chip">
<input type="radio" name="wh_reason" value={r} checked={reason === r} onChange={() => setReason(r)} /> {r}
</label>
))}
</div>
<label className="f">
Note (what you're waiting on, PO no., who you asked)
<input className="in" value={note} maxLength={HOLD_NOTE_MAX} onChange={(e) => setNote(e.target.value)} />
</label>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div className="sheet-acts start">
<button className="btn pink" type="submit">
Put on hold
</button>
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</form>
</Sheet>
);
}
/** A yes or no question in a sheet. */
export function ConfirmSheet({
title,
sub,
text,
okLabel,
onOk,
onCancel,
}: {
title: string;
sub?: string | undefined;
text: string;
okLabel: string;
onOk: () => void;
onCancel: () => void;
}) {
return (
<Sheet title={title} sub={sub} onClose={onCancel}>
<div>{text}</div>
<div className="sheet-acts start">
<button className="btn pri" type="button" onClick={onOk}>
{okLabel}
</button>
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</Sheet>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { assetsApi, blobUrl } from "../api/assets";
import { Sheet } from "../components/Sheet";
import { useLoad } from "../lib/useLoad";
/** Shows an MTR file: a PDF in a frame, an image as itself, and a link to open it in a new tab. */
export function MtrViewer({ assetId, title, onClose }: { assetId: string; title: string; onClose: () => void }) {
const [meta] = useLoad((s) => assetsApi.get(assetId, s), assetId);
const url = blobUrl(assetId);
const mime = meta.state === "ok" ? meta.data.mime : null;
return (
<Sheet title={title} onClose={onClose} wide>
<div className="sheet-acts start">
<a className="btn sm" href={url} target="_blank" rel="noopener noreferrer">
Open in new tab
</a>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onClose}>
Close
</button>
</div>
{meta.state === "loading" ? <div className="empty slim">Loading</div> : null}
{mime?.startsWith("image/") ? (
<img className="mtrimg" src={url} alt={title} />
) : meta.state !== "loading" ? (
<iframe className="mtrframe" src={url} title={title} />
) : null}
</Sheet>
);
}
+278
View File
@@ -0,0 +1,278 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { BomLine, PacketRead } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch } from "../test/fetchMock";
import { makePacket } from "../test/packets";
const WADE = makeMe({ roles: ["warehouse"], screens: ["warehouse"], user: { sam_account: "wh.wade", display_name: "Wade Warehouse" } });
const who = { id: "u-wade", display_name: "Wade Warehouse" };
const PULL_BLOCK = "Every line must be pulled or marked short before release.";
function line(item: number, over: Partial<BomLine> = {}): BomLine {
return {
id: 100 + item,
item,
qty: 2,
part_id: `P-${item}`,
description: `Part ${item}`,
dwg_ref: null,
length: "10 ft",
material: "A36",
heat: null,
na: false,
issued: null,
loc: null,
pulled: false,
mtr_asset_id: null,
short: null,
row_version: 4,
...over,
};
}
function whPacket(over: Partial<PacketRead> = {}): PacketRead {
return makePacket({
status: "warehouse",
holder: "Warehouse",
heat_map: true,
wh: { stage: "incoming", by: null, at: "2026-10-16T08:00:00Z", reason: null, note: null, auto: false },
bom: [line(1), line(2), line(3)],
can: ["set_wh_stage", "edit_bom", "edit_heats", "wh_send_back", "wh_receive_short"],
blocked: { wh_release: [PULL_BLOCK] },
...over,
});
}
function base(packet: PacketRead, me = WADE) {
return {
"/api/auth/me": { body: me },
"/api/departments": { body: DEPARTMENTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
};
}
function headerOf(fetch: ReturnType<typeof mockFetch>, method: string, path: string, name: string): Array<string | undefined> {
return fetch.mock.calls
.filter(([input, init]) => String(input).split("?")[0] === path && (init?.method ?? "GET") === method)
.map(([, init]) => (init?.headers as Record<string, string> | undefined)?.[name]);
}
describe("Pull table and the warehouse packet page", () => {
it("the first Pulled tick sends PATCH with If-Match and pulled true, and the stage follows the answer", async () => {
const after = whPacket({
version: 4,
bom: [line(1, { pulled: true, issued: 2, row_version: 5 }), line(2), line(3)],
wh: { stage: "pulling", by: who, at: "2026-10-16T08:01:00Z", reason: null, note: null, auto: true },
});
const fetch = mockFetch({ ...base(whPacket()), "PATCH /api/packets/1/bom/1": { body: after } });
renderApp("/packets/1");
const box = await screen.findByRole("region", { name: "Warehouse" });
expect(within(box).getByText("WH · waiting")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Line 1 pulled"));
await waitFor(() => expect(within(box).getByText("WH · being pulled")).toBeInTheDocument());
expect(callsTo(fetch, "PATCH", "/api/packets/1/bom/1")).toEqual([{ pulled: true }]);
expect(headerOf(fetch, "PATCH", "/api/packets/1/bom/1", "If-Match")).toEqual(["4"]);
expect(screen.getByLabelText("Line 1 pulled")).toBeChecked();
expect(within(box).getByText(/^Being pulled by Wade Warehouse/)).toBeInTheDocument();
});
it("Enter in a heat input saves it and moves to the next line's heat", async () => {
const start = whPacket({ bom: [line(1, { pulled: true, issued: 2 }), line(2), line(3)] });
const after = whPacket({ version: 4, bom: [line(1, { pulled: true, issued: 2, heat: "HT-1234/7A", row_version: 5 }), line(2), line(3)] });
const fetch = mockFetch({ ...base(start), "PATCH /api/packets/1/bom/1": { body: after } });
renderApp("/packets/1");
const heat1 = await screen.findByLabelText("Line 1 heat");
expect(heat1).toHaveClass("need");
heat1.focus();
fireEvent.change(heat1, { target: { value: "HT-1234/7A" } });
fireEvent.keyDown(heat1, { key: "Enter" });
expect(screen.getByLabelText("Line 2 heat")).toHaveFocus();
await waitFor(() => expect(screen.getByLabelText("Line 1 heat")).not.toHaveClass("need"));
expect(callsTo(fetch, "PATCH", "/api/packets/1/bom/1")).toEqual([{ heat: "HT-1234/7A" }]);
expect(headerOf(fetch, "PATCH", "/api/packets/1/bom/1", "If-Match")).toEqual(["4"]);
});
it("a bad heat shows the server's text under the row and keeps the value", async () => {
const msg = 'Heat number "<img src=x>" isn\'t valid: letters, numbers, space . / # _ - only, up to 40 characters.';
mockFetch({ ...base(whPacket()), "PATCH /api/packets/1/bom/2": { status: 422, body: { detail: msg, code: "invalid_heat" } } });
renderApp("/packets/1");
const heat2 = await screen.findByLabelText("Line 2 heat");
heat2.focus();
fireEvent.change(heat2, { target: { value: "<img src=x>" } });
fireEvent.keyDown(heat2, { key: "Enter" });
expect(await screen.findByText(`Item 2: ${msg}`)).toBeInTheDocument();
expect(screen.getByLabelText("Line 2 heat")).toHaveValue("<img src=x>");
});
it("a row conflict reads the line again and retries once; a second conflict reloads and says so", async () => {
let gets = 0;
const fetch = mockFetch({
...base(whPacket()),
"/api/packets/1": () => {
gets++;
return { body: gets === 1 ? whPacket() : whPacket({ version: 5, bom: [line(1, { loc: "R-9", row_version: 6 }), line(2), line(3)] }) };
},
"PATCH /api/packets/1/bom/1": { status: 409, body: { detail: "Changed.", code: "row_conflict" } },
});
renderApp("/packets/1");
const loc = await screen.findByLabelText("Line 1 location");
fireEvent.change(loc, { target: { value: "A-1" } });
fireEvent.blur(loc);
expect(await screen.findByText("Item 1: Someone else changed this line. Reloaded.")).toBeInTheDocument();
// The first try, then once more with the row version read back (6)
expect(callsTo(fetch, "PATCH", "/api/packets/1/bom/1")).toEqual([{ loc: "A-1" }, { loc: "A-1" }]);
const ifMatch = fetch.mock.calls
.filter((c) => String(c[0]).endsWith("/api/packets/1/bom/1"))
.map((c) => new Headers((c[1] as RequestInit).headers).get("If-Match"));
expect(ifMatch).toEqual(["4", "6"]);
await waitFor(() => expect(screen.getByLabelText("Line 1 location")).toHaveValue("R-9"));
});
it("records a short through the short sheet", async () => {
const fetch = mockFetch({ ...base(whPacket()), "POST /api/packets/1/bom/2/short": { body: whPacket({ version: 4 }) } });
renderApp("/packets/1");
const row = await screen.findByRole("row", { name: /^Line 2/ });
fireEvent.click(within(row).getByRole("button", { name: "Short" }));
const sheet = await screen.findByRole("dialog", { name: "Record short" });
fireEvent.change(within(sheet).getByLabelText("Qty short"), { target: { value: "1" } });
fireEvent.change(within(sheet).getByLabelText("PO / requisition"), { target: { value: "PO 4471" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Save short" }));
await waitFor(() =>
expect(callsTo(fetch, "POST", "/api/packets/1/bom/2/short")).toEqual([
{ qty: 1, reason: "Not in stock", ref: "PO 4471", eta: null, note: null, expect_open: false },
]),
);
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Record short" })).not.toBeInTheDocument());
});
it("Release is disabled with the blocked text, and enabled when can has it", async () => {
mockFetch(base(whPacket()));
renderApp("/packets/1");
const rel = await screen.findByRole("button", { name: "Release to supervisor" });
expect(rel).toBeDisabled();
expect(screen.getByText(PULL_BLOCK)).toHaveClass("why");
expect(screen.getByText("3 lines not pulled.")).toBeInTheDocument();
});
it("Release posts wh_release when can has it, with the short count on the label", async () => {
const ready = whPacket({
open_shorts: 1,
wh: { stage: "pulled", by: who, at: "2026-10-16T09:00:00Z", reason: null, note: null, auto: false },
bom: [
line(1, { pulled: true, issued: 2, heat: "H1" }),
line(2, { pulled: true, issued: 1, heat: "H2", short: { id: 7, qty: 1, qty_open: 1, reason: "Not in stock", ref: null, eta: null, note: null, opened_at: null } }),
line(3, { pulled: true, issued: 2, heat: "H3" }),
],
can: ["set_wh_stage", "edit_bom", "wh_send_back", "wh_release"],
blocked: {},
});
const fetch = mockFetch({
...base(ready),
"POST /api/packets/1/transitions": { body: makePacket({ status: "ready", holder: "Weld Parts", can: [], blocked: {} }) },
});
renderApp("/packets/1");
expect(await screen.findByText("Ready to release with 1 short line.")).toBeInTheDocument();
const rel = screen.getByRole("button", { name: "Release to supervisor (1 short)" });
expect(rel).toBeEnabled();
fireEvent.click(rel);
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to assign"));
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "wh_release", expect: { status: "warehouse", dept: "03" } }]);
});
it("Send back to Clerical asks for a reason", async () => {
const fetch = mockFetch({
...base(whPacket()),
"POST /api/packets/1/transitions": { body: makePacket({ status: "draft", return_note: "Wrong BOM revision on line 2" }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Send back to Clerical" }));
const sheet = await screen.findByRole("dialog", { name: "Send back to Clerical" });
fireEvent.change(within(sheet).getByLabelText("Reason"), { target: { value: "Wrong BOM revision on line 2" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Send back to Clerical" }));
await waitFor(() =>
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "wh_send_back", expect: { status: "warehouse", dept: "03" }, reason: "Wrong BOM revision on line 2" },
]),
);
});
it("another role sees the pill and the line but no warehouse buttons or pull table", async () => {
const wes = makeMe({ roles: ["shop:03:supervisor"], screens: ["supervisor"] });
const held = whPacket({
wh: { stage: "hold", by: who, at: "2026-10-16T08:00:00Z", reason: "Waiting on purchasing / PO", note: "PO 4471 due Fri", auto: false },
can: [],
blocked: {},
});
mockFetch(base(held, wes));
renderApp("/packets/1");
const box = await screen.findByRole("region", { name: "Warehouse" });
expect(within(box).getByText("WH · on hold")).toBeInTheDocument();
expect(within(box).getByText(/^On hold: Waiting on purchasing \/ PO · PO 4471 due Fri/)).toBeInTheDocument();
expect(within(box).queryByRole("button")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Line 1 pulled")).not.toBeInTheDocument();
});
it("Receive appears for the holder of a packet with a short and posts the receipt", async () => {
const lou = makeMe({ roles: ["shop:03:lead"], screens: ["supervisor"] });
const ready = makePacket({
status: "ready",
holder: "Weld Parts",
open_shorts: 1,
bom: [
line(1, { pulled: true, issued: 2, heat: "H1", mtr_asset_id: "mtr-1" }),
line(2, { pulled: true, issued: 1, heat: "H2", short: { id: 7, qty: 1, qty_open: 1, reason: "Not in stock", ref: "PO 4471", eta: null, note: null, opened_at: null } }),
],
can: ["edit_bom", "edit_heats"],
blocked: {},
});
const fetch = mockFetch({ ...base(ready, lou), "POST /api/packets/1/bom/2/short/receipts": { body: ready } });
renderApp("/packets/1");
const recv = await screen.findByRole("button", { name: "Receive" });
expect(screen.getByRole("button", { name: "MTR" })).toBeInTheDocument();
expect(screen.queryByLabelText("Line 1 pulled")).not.toBeInTheDocument();
fireEvent.click(recv);
const sheet = await screen.findByRole("dialog", { name: "Receive short part" });
fireEvent.change(within(sheet).getByLabelText("Heat"), { target: { value: "HT-77" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Receive" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/1/bom/2/short/receipts")).toEqual([{ qty: 1, heat: "HT-77", note: null }]));
});
it("no Receive for a role that cannot receive", async () => {
const quinn = makeMe({ roles: ["qc"], screens: ["qc"] });
const ready = makePacket({
status: "ready",
holder: "Weld Parts",
bom: [line(2, { pulled: true, issued: 1, short: { id: 7, qty: 1, qty_open: 1, reason: "Not in stock", ref: null, eta: null, note: null, opened_at: null } })],
can: [],
blocked: {},
});
mockFetch(base(ready, quinn));
renderApp("/packets/1");
expect(await screen.findByText("Short 1")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Receive" })).not.toBeInTheDocument();
});
it("uploads an MTR with purpose mtr and patches the line", async () => {
const after = whPacket({ version: 4, bom: [line(1, { mtr_asset_id: "a-mtr", row_version: 5 }), line(2), line(3)] });
const fetch = mockFetch({
...base(whPacket()),
"POST /api/assets": { status: 201, body: { id: "a-mtr", mime: "image/png", size: 9, sha256: "00", pages: null, original_name: "m.png", url: "/_blob/a-mtr" } },
"PATCH /api/packets/1/bom/1": { body: after },
"/api/assets/a-mtr": { body: { id: "a-mtr", mime: "image/png", size: 9, sha256: "00", pages: null, original_name: "m.png", url: "/_blob/a-mtr" } },
});
renderApp("/packets/1");
const input = await screen.findByLabelText("Line 1 MTR file");
fireEvent.change(input, { target: { files: [new File(["\x89PNG"], "m.png", { type: "image/png" })] } });
const view = await screen.findByRole("button", { name: "MTR" });
const up = fetch.mock.calls.find(([u, init]) => u === "/api/assets" && init?.method === "POST");
expect((up?.[1]?.body as FormData).get("purpose")).toBe("mtr");
expect(callsTo(fetch, "PATCH", "/api/packets/1/bom/1")).toEqual([{ mtr_asset_id: "a-mtr" }]);
expect(screen.getByText("Replace")).toBeInTheDocument();
fireEvent.click(view);
const sheet = await screen.findByRole("dialog", { name: /^MTR · item 1/ });
expect(within(sheet).getByRole("link", { name: "Open in new tab" })).toHaveAttribute("href", "/_blob/a-mtr");
expect(await within(sheet).findByRole("img")).toHaveAttribute("src", "/_blob/a-mtr");
});
});
+376
View File
@@ -0,0 +1,376 @@
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { assetsApi } from "../api/assets";
import { errorCode } from "../api/client";
import { packetsApi } from "../api/packets";
import type { BomLine, BomPatch, Guarded, PacketRead } from "../api/types";
import { errorText, fmtDay } from "../lib/format";
import { MtrViewer } from "./MtrViewer";
import { ShortSheet } from "./ShortSheet";
import { isSub, issuedLess, needsHeat, num } from "./stages";
export type WriteResult = { ok: true } | { ok: false; err?: unknown };
/** Runs a packet call through the guard and takes the packet answer. Errors come back, not thrown. */
export type Write = (fn: (g: Guarded) => Promise<PacketRead | null | undefined>) => Promise<WriteResult>;
export const ROW_CONFLICT = "Someone else changed this line. Reloaded.";
const show = (v: unknown) => (v === null || v === undefined || v === "" ? "" : String(v));
/** The text for a failed line write. A row conflict reloads the packet first. */
async function failText(err: unknown, reload: () => Promise<void>): Promise<string> {
const code = errorCode(err);
if (code === "row_conflict") {
await reload();
return ROW_CONFLICT;
}
if (code === "short_conflict" || code === "stale_step") await reload();
return errorText(err);
}
/** The old shortChip: the open quantity, the reason, the PO and the ETA. */
export function ShortChip({ s }: { s: NonNullable<BomLine["short"]> }) {
const late = !!s.eta && new Date(`${s.eta.slice(0, 10)}T23:59:59`).getTime() < Date.now();
return (
<span className="shortchip">
<span className="pill st-review">Short {String(s.qty_open)}</span>{" "}
<span className="small">
{s.reason ?? ""}
{s.ref ? (
<>
{" "}
· <span className="mono">{s.ref}</span>
</>
) : null}
{s.eta ? (
<>
{" "}
· ETA <b className={late ? "mono due over" : "mono"}>{fmtDay(s.eta)}</b>
</>
) : null}
</span>
</span>
);
}
function heatPlaceholder(b: BomLine): string {
if (isSub(b)) return "Sub-assy (heat on its own packet)";
if (b.na) return "N/A";
if (b.short) return num(b.issued) ? `Heat for the ${show(b.issued)} pulled` : "Nothing issued";
return "Scan / type heat";
}
interface RowProps {
b: BomLine;
busy: boolean;
patch: (b: BomLine, body: BomPatch) => Promise<string | null>;
upload: (b: BomLine, file: File) => Promise<string | null>;
onShort: (b: BomLine) => void;
onView: (b: BomLine) => void;
}
/** One line of the pull. Text fields save on blur or Enter when changed; Enter in Heat moves to the next heat. */
function PullRow({ b, busy, patch, upload, onShort, onView }: RowProps) {
const n = b.item;
const [issued, setIssued] = useState(show(b.issued));
const [loc, setLoc] = useState(show(b.loc));
const [heat, setHeat] = useState(show(b.heat));
// The boxes flip at once and the server's answer settles them (a tick that fails flips back).
const [pulled, setPulled] = useState(!!b.pulled);
const [na, setNa] = useState(!!b.na);
const [error, setError] = useState<string | null>(null);
const sent = useRef({ issued: show(b.issued), loc: show(b.loc), heat: show(b.heat) });
const fileRef = useRef<HTMLInputElement>(null);
// A new row version is the server's word on the line: take its values, but keep a field the person is still
// typing in (its text differs from what was last synced), so a tick's answer never wipes a half-typed heat.
useEffect(() => {
const prev = sent.current;
const next = { issued: show(b.issued), loc: show(b.loc), heat: show(b.heat) };
sent.current = next;
setIssued((cur) => (cur === prev.issued ? next.issued : cur));
setLoc((cur) => (cur === prev.loc ? next.loc : cur));
setHeat((cur) => (cur === prev.heat ? next.heat : cur));
setPulled(!!b.pulled);
setNa(!!b.na);
}, [b.row_version, b.issued, b.loc, b.heat, b.pulled, b.na]);
// A commit while another write of this packet is in flight waits for it, so it carries the fresh row version.
const pending = useRef<Set<"issued" | "loc" | "heat">>(new Set());
useEffect(() => {
if (busy || !pending.current.size) return;
const fields = [...pending.current];
pending.current.clear();
for (const f of fields) commit(f);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [busy]);
const run = async (body: BomPatch) => {
setError(null);
const err = await patch(b, body);
if (err) {
setError(err);
setPulled(!!b.pulled);
setNa(!!b.na);
}
};
const tick = (field: "pulled" | "na", value: boolean) => {
(field === "pulled" ? setPulled : setNa)(value);
void run({ [field]: value });
};
const commit = (field: "issued" | "loc" | "heat") => {
if (busy) {
pending.current.add(field);
return;
}
const value = field === "issued" ? issued.trim() : field === "loc" ? loc.trim() : heat.trim();
if (value === sent.current[field]) return;
sent.current = { ...sent.current, [field]: value };
if (field === "issued") {
const v = value === "" ? null : Number(value);
if (v !== null && (!Number.isFinite(v) || v < 0)) {
setError("Issued is a number of pieces.");
return;
}
void run({ issued: v });
} else if (field === "loc") {
void run({ loc: value || null });
} else {
void run({ heat: value || null });
}
};
const onEnter = (field: "issued" | "loc" | "heat") => (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key !== "Enter") return;
e.preventDefault();
commit(field);
if (field !== "heat") return;
// A scanner sends Enter after the number: go on to the next line's heat.
const all = Array.from(e.currentTarget.closest("table")?.querySelectorAll<HTMLInputElement>("input[data-heat]") ?? []);
const i = all.indexOf(e.currentTarget);
const next = all.slice(i + 1).find((x) => !x.disabled);
next?.focus();
};
const cls = b.short ? "pullrow short" : b.pulled ? "pullrow pulled" : "pullrow";
return (
<>
<tr className={cls} aria-label={`Line ${n}`}>
<td>
<label className="pullck">
<input type="checkbox" aria-label={`Line ${n} pulled`} checked={pulled} disabled={busy} onChange={(e) => tick("pulled", e.target.checked)} />
<span className="balloon">{n}</span>
</label>
</td>
<td>
{show(b.description)}
{isSub(b) ? (
<>
{" "}
<span className="visbadge" title="Sub-assembly made on its own packet. Its heats are recorded there.">
SUB-ASSY
</span>
</>
) : null}
{b.part_id ? <div className="sub mono">{b.part_id}</div> : null}
{b.material || b.length ? (
<div className="sub mono">
{show(b.material)}
{b.length ? ` · ${b.length}` : ""}
</div>
) : null}
</td>
<td className="num need">{show(b.qty)}</td>
<td>
<input
className="in sm num w80"
inputMode="decimal"
aria-label={`Line ${n} issued`}
value={issued}
placeholder={show(b.qty)}
onChange={(e) => setIssued(e.target.value)}
onBlur={() => commit("issued")}
onKeyDown={onEnter("issued")}
/>
</td>
<td>
<input
className="in sm w110"
aria-label={`Line ${n} location`}
value={loc}
maxLength={120}
placeholder="Bin / rack"
onChange={(e) => setLoc(e.target.value)}
onBlur={() => commit("loc")}
onKeyDown={onEnter("loc")}
/>
</td>
<td className="heatcell">
<input
className={needsHeat(b) ? "in sm heatIn need" : "in sm heatIn"}
data-heat={n}
aria-label={`Line ${n} heat`}
value={heat}
maxLength={40}
autoComplete="off"
spellCheck={false}
placeholder={heatPlaceholder(b)}
disabled={!!b.na}
onChange={(e) => setHeat(e.target.value)}
onBlur={() => commit("heat")}
onKeyDown={onEnter("heat")}
/>
</td>
<td className="shortcell">
{b.short ? (
<div className="stack tight">
<ShortChip s={b.short} />
<button className="btn sm ghost" type="button" disabled={busy} onClick={() => onShort(b)}>
Edit short
</button>
</div>
) : issuedLess(b) ? (
<button className="btn sm pink" type="button" disabled={busy} onClick={() => onShort(b)}>
Record short
</button>
) : (
<button className="btn sm ghost" type="button" disabled={busy} onClick={() => onShort(b)}>
Short
</button>
)}
</td>
<td className="nowrap">
<label className="small nack">
<input type="checkbox" aria-label={`Line ${n} N/A`} checked={na} disabled={busy} onChange={(e) => tick("na", e.target.checked)} /> N/A
</label>{" "}
{b.mtr_asset_id ? (
<>
<button type="button" className="lnk small" onClick={() => onView(b)}>
MTR
</button>{" "}
</>
) : null}
<label className={busy ? "btn sm ghost dim" : "btn sm ghost"}>
{b.mtr_asset_id ? "Replace" : "MTR"}
<input
ref={fileRef}
className="sr"
type="file"
accept=".pdf,.png,.jpg,.jpeg"
aria-label={`Line ${n} MTR file`}
disabled={busy}
onChange={(e) => {
const f = e.target.files?.[0];
if (!f) return;
setError(null);
void upload(b, f).then((err) => {
if (err) setError(err);
if (fileRef.current) fileRef.current.value = "";
});
}}
/>
</label>
</td>
</tr>
{error ? (
<tr className="pullerr">
<td colSpan={8}>
<div className="callout warn flush" role="alert">
Item {n}: {error}
</div>
</td>
</tr>
) : null}
</>
);
}
/** The warehouse pull (the old pullTable): tick Pulled, issued, location, heat, shorts, N/A and the MTR. */
export function PullTable({ p, write, reload, busy }: { p: PacketRead; write: Write; reload: () => Promise<void>; busy: boolean }) {
const [shortFor, setShortFor] = useState<number | null>(null);
const [viewFor, setViewFor] = useState<number | null>(null);
const result = async (r: WriteResult): Promise<string | null> => (r.ok || r.err === undefined ? null : failText(r.err, reload));
// Writes run one after another, each with the line's row version from the latest packet, so a heat typed while
// the Pulled tick is still in flight does not race it into a row conflict.
// The newest packet this table has seen: the prop, or a write's own answer before React has re-rendered with it.
const latest = useRef(p);
if (latest.current.id !== p.id || p.version >= latest.current.version) latest.current = p;
const chain = useRef<Promise<unknown>>(Promise.resolve());
const patch = (b: BomLine, body: BomPatch): Promise<string | null> => {
const next = chain.current.then(async () => {
const send = (rv: number) =>
write((g) =>
packetsApi.patchBomLine(p.id, b.item, rv, body, g).then((r) => {
if (r.version >= latest.current.version) latest.current = r;
return r;
}),
);
const rv = () => (latest.current.bom.find((x) => x.item === b.item) ?? b).row_version ?? 1;
let r = await send(rv());
if (!r.ok && r.err !== undefined && errorCode(r.err) === "row_conflict") {
// Someone else (or an earlier write of ours) moved the row on: read it again and try once more.
const fresh = await packetsApi.get(p.id);
if (fresh.version >= latest.current.version) latest.current = fresh;
r = await send(rv());
}
return result(r);
});
chain.current = next.catch(() => undefined);
return next;
};
const upload = async (b: BomLine, file: File): Promise<string | null> => {
let asset;
try {
asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "mtr" });
} catch (err) {
return errorText(err);
}
return patch(b, { mtr_asset_id: asset.id });
};
const line = shortFor === null ? undefined : p.bom.find((b) => b.item === shortFor);
const viewed = viewFor === null ? undefined : p.bom.find((b) => b.item === viewFor);
if (!p.bom.length) return <div className="empty">No BOM lines on this packet.</div>;
return (
<>
<div className="tw">
<table className="pull" aria-label="Pull">
<thead>
<tr>
<th>Pulled</th>
<th>Part</th>
<th className="num">Need</th>
<th>Issued</th>
<th>Location</th>
<th>Heat no.</th>
<th>Short</th>
<th>Non-trace / MTR</th>
</tr>
</thead>
<tbody>
{p.bom.map((b) => (
<PullRow key={b.item} b={b} busy={busy} patch={patch} upload={upload} onShort={(x) => setShortFor(x.item)} onView={(x) => setViewFor(x.item)} />
))}
</tbody>
</table>
</div>
{line ? (
<ShortSheet
mode="record"
line={line}
onClose={() => setShortFor(null)}
onSave={async (body) => result(await write((g) => packetsApi.saveShort(p.id, line.item, body, g)))}
onClear={async () => result(await write((g) => packetsApi.clearShort(p.id, line.item, { expect_open: true }, g)))}
/>
) : null}
{viewed?.mtr_asset_id ? (
<MtrViewer assetId={viewed.mtr_asset_id} title={`MTR · item ${viewed.item}${viewed.heat ? ` · ${viewed.heat}` : ""}`} onClose={() => setViewFor(null)} />
) : null}
</>
);
}
+208
View File
@@ -0,0 +1,208 @@
import { useState, type FormEvent } from "react";
import type { ReceiptBody, ShortBody } from "../api/types";
import { Sheet } from "../components/Sheet";
import { num, SHORT_REASONS } from "./stages";
/** The line the sheet is about. A receipt from the Open shorts table has no line, only the short's numbers. */
export interface ShortLine {
item: number;
description: string | null;
part_id: string | null;
qty: number | string | null;
issued?: number | string | null | undefined;
heat?: string | null | undefined;
na?: boolean | undefined;
short?: { qty_open: number | string; reason: string | null; ref: string | null; eta: string | null; note: string | null } | null | undefined;
}
/** Each handler answers null when it worked, or the text to show. */
interface ShortSheetProps {
mode: "record" | "receive";
line: ShortLine;
sub?: string | undefined;
onSave?: (body: ShortBody) => Promise<string | null>;
onClear?: () => Promise<string | null>;
onReceive?: (body: ReceiptBody) => Promise<string | null>;
onClose: () => void;
}
const LIMIT_REF = 60;
const LIMIT_NOTE = 500;
const LIMIT_HEAT = 40;
/** Record or edit a short on a line, or receive the missing pieces (the old short sheet). */
export function ShortSheet({ mode, line, sub, onSave, onClear, onReceive, onClose }: ShortSheetProps) {
const need = num(line.qty) ?? 0;
const os = line.short;
const open = num(os?.qty_open) ?? 0;
const issued = num(line.issued);
const defQty = os ? open : Math.max(1, need - Math.min(issued ?? 0, need));
const [qty, setQty] = useState(String(mode === "receive" ? open : defQty));
const [reason, setReason] = useState<string>(os?.reason && (SHORT_REASONS as readonly string[]).includes(os.reason) ? os.reason : SHORT_REASONS[0]);
const [ref, setRef] = useState(os?.ref ?? "");
const [eta, setEta] = useState(os?.eta ?? "");
const [note, setNote] = useState(mode === "receive" ? "" : (os?.note ?? ""));
const [heat, setHeat] = useState(mode === "receive" ? "" : (line.heat ?? ""));
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const done = async (p: Promise<string | null>) => {
setBusy(true);
setError(null);
try {
const err = await p;
if (err === null) onClose();
else setError(err);
} finally {
setBusy(false);
}
};
const submit = (e: FormEvent) => {
e.preventDefault();
const q = Number(qty);
if (mode === "receive") {
if (!Number.isInteger(q) || q < 1 || q > open) {
setError(`Received qty must be 1 to ${open}.`);
return;
}
const h = heat.trim();
if (!line.na && !h) {
setError("Scan or type the heat number of the part received.");
return;
}
if (onReceive) void done(onReceive({ qty: q, heat: h || null, note: note.trim() || null }));
return;
}
if (!Number.isInteger(q) || q < 1 || q > need) {
setError(`Short qty must be 1 to ${need}.`);
return;
}
const body: ShortBody = {
qty: q,
reason,
ref: ref.trim() || null,
eta: eta || null,
note: note.trim() || null,
expect_open: !!os,
};
const h = heat.trim();
if (!line.na && h) body.heat = h;
if (onSave) void done(onSave(body));
};
const title = mode === "receive" ? "Receive short part" : os ? "Edit short" : "Record short";
return (
<Sheet title={title} sub={sub ? `${sub} · item ${line.item}` : `item ${line.item}`} onClose={onClose}>
<form className="stack" onSubmit={submit} noValidate>
{mode === "receive" ? (
<>
<div className="small">
{line.description ?? ""} · <b className="mono">{open}</b> still short{os?.ref ? <> · {os.ref}</> : null}
</div>
<div className="frow">
<label className="f">
Qty received
<input className="in" type="number" min={1} max={open} value={qty} onChange={(e) => setQty(e.target.value)} />
</label>
<label className="f">
Heat
<input
className="in"
value={heat}
maxLength={LIMIT_HEAT}
autoComplete="off"
placeholder={line.na ? "N/A line, no heat needed" : "Scan / type heat"}
onChange={(e) => setHeat(e.target.value)}
/>
</label>
</div>
<label className="f">
Note
<input className="in" value={note} maxLength={LIMIT_NOTE} placeholder="Optional" onChange={(e) => setNote(e.target.value)} />
</label>
</>
) : (
<>
<div className="small">
{line.description ?? ""} · need <b className="mono">{need}</b>
{line.part_id ? (
<>
{" "}
· <span className="mono">{line.part_id}</span>
</>
) : null}
</div>
<div className="frow">
<label className="f">
Qty short
<input className="in" type="number" min={1} max={need} value={qty} onChange={(e) => setQty(e.target.value)} />
</label>
<label className="f">
Reason
<select className="in" aria-label="Reason" value={reason} onChange={(e) => setReason(e.target.value)}>
{SHORT_REASONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<label className="f">
PO / requisition
<input className="in" value={ref} maxLength={LIMIT_REF} placeholder="Optional" onChange={(e) => setRef(e.target.value)} />
</label>
<label className="f">
Expected
<input className="in" type="date" value={eta} onChange={(e) => setEta(e.target.value)} />
</label>
</div>
<label className="f">
Note
<input
className="in"
value={note}
maxLength={LIMIT_NOTE}
placeholder="For example: purchasing notified, cut from next stick"
onChange={(e) => setNote(e.target.value)}
/>
</label>
{line.na ? null : (
<label className="f">
Heat no. for the pulled pieces
<input
className="in"
value={heat}
maxLength={LIMIT_HEAT}
autoComplete="off"
placeholder="Scan / type. Leave blank if none pulled."
onChange={(e) => setHeat(e.target.value)}
/>
</label>
)}
</>
)}
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div className="sheet-acts start">
<button className="btn pri" type="submit" disabled={busy}>
{mode === "receive" ? "Receive" : "Save short"}
</button>
<button className="btn ghost" type="button" onClick={onClose}>
Cancel
</button>
<span className="grow" />
{mode === "record" && os && onClear ? (
<button className="btn ghost pinktxt" type="button" disabled={busy} onClick={() => void done(onClear())}>
Not short after all
</button>
) : null}
</div>
</form>
</Sheet>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { makeCard } from "../test/packets";
import { markPulledWarning, needsHeat, packetMessage, pillText, whLine, whOrder, whStage } from "./stages";
const NOW = new Date("2026-10-16T12:00:00Z");
const who = { id: "u1", display_name: "Wade Warehouse" };
describe("warehouse stages", () => {
it("gives the pill and the line for each stage", () => {
const at = "2026-10-16T11:48:00Z";
expect(pillText(whStage({ wh_stage: null }))).toBe("WH · waiting");
expect(whLine({ wh_stage: "incoming", wh: null, wh_sent_at: "2026-10-16T10:00:00Z" }, NOW)).toBe("Waiting 2 h, not started yet");
const pulling = { wh: { stage: "pulling" as const, by: who, at, reason: null, note: null, auto: false } };
expect(pillText(whStage(pulling))).toBe("WH · being pulled");
expect(whLine(pulling, NOW)).toBe("Being pulled by Wade Warehouse · 12 min");
const hold = { wh: { stage: "hold" as const, by: who, at, reason: "Waiting on purchasing / PO", note: "PO 4471 due Fri", auto: false } };
expect(pillText(whStage(hold))).toBe("WH · on hold");
expect(whLine(hold, NOW)).toBe("On hold: Waiting on purchasing / PO · PO 4471 due Fri · 12 min");
const pulled = { wh: { stage: "pulled" as const, by: who, at: "2026-10-14T12:00:00Z", reason: null, note: null, auto: false } };
expect(pillText(whStage(pulled))).toBe("WH · pulled");
expect(whLine(pulled, NOW)).toBe("Pulled by Wade Warehouse · 2 d ago, ready for production");
});
it("orders by the day sent, then the VISUAL release date, then the time", () => {
const a = makeCard({ id: 1, wh_sent_at: "2026-10-15T15:00:00", visual_rel: "2026-10-20" });
const b = makeCard({ id: 2, wh_sent_at: "2026-10-15T09:00:00", visual_rel: null });
const c = makeCard({ id: 3, wh_sent_at: "2026-10-14T16:00:00", visual_rel: "2026-11-01" });
const d = makeCard({ id: 4, wh_sent_at: "2026-10-15T08:00:00", visual_rel: "2026-10-20" });
expect([a, b, c, d].sort(whOrder).map((x) => x.id)).toEqual([3, 4, 1, 2]);
});
it("needs a heat only on a pulled, issued, traceable line that is not a sub-assembly", () => {
const base = { na: false, sub: null, pulled: true, issued: 2, heat: null };
expect(needsHeat(base)).toBe(true);
expect(needsHeat({ ...base, heat: "H1" })).toBe(false);
expect(needsHeat({ ...base, na: true })).toBe(false);
expect(needsHeat({ ...base, sub: "Y" })).toBe(false);
expect(needsHeat({ ...base, pulled: false })).toBe(false);
expect(needsHeat({ ...base, issued: 0 })).toBe(false);
});
it("warns before Mark pulled when lines remain", () => {
expect(markPulledWarning({ total: 3, pulled: 1, missingHeat: 1 })).toBe(
"2 lines not ticked Pulled and 1 heat number missing. Mark it pulled anyway? You'll still need to finish those lines before it can be released.",
);
expect(markPulledWarning({ total: 3, pulled: 3, missingHeat: 0 })).toBeNull();
});
it("says what is open on the packet, or that it is ready", () => {
const line = { qty: 2, part_id: null, description: null, dwg_ref: null, length: null, material: null };
expect(
packetMessage({
bom: [
{ ...line, item: 10, pulled: true, issued: 2, heat: null },
{ ...line, item: 20, pulled: false },
{ ...line, item: 30, pulled: true, issued: 1, heat: "H" },
],
}).head,
).toBe("1 line not pulled · 1 issued less than needed, record the short · heat number needed for item 10 (2 pulled).");
expect(
packetMessage({
bom: [{ ...line, item: 1, pulled: true, issued: 1, heat: "H", short: { id: 1, qty: 1, qty_open: 1, reason: "On order", ref: null, eta: null, note: null, opened_at: null } }],
}).head,
).toBe("Ready to release with 1 short line.");
expect(packetMessage({ bom: [{ ...line, item: 1, pulled: true, issued: 2, heat: "H" }] }).head).toBe("All parts pulled and heats recorded.");
});
});
+208
View File
@@ -0,0 +1,208 @@
// Warehouse stages, reasons, the pill and line everyone sees, the board order and the pull progress.
// Ported from the old build's warehouse.js and app.js (pullTable, needsHeat, shortChip).
import type { BomLine, PacketCard, PacketRead, WhInfo, WhStage } from "../api/types";
export const WH_STAGES: Record<WhStage, { name: string; pill: string; cls: string }> = {
incoming: { name: "Waiting to pull", pill: "WH · waiting", cls: "wh-in" },
pulling: { name: "Being pulled", pill: "WH · being pulled", cls: "wh-pull" },
hold: { name: "On hold", pill: "WH · on hold", cls: "wh-hold" },
pulled: { name: "Pulled, ready for production", pill: "WH · pulled", cls: "wh-done" },
};
export const WH_STAGE_KEYS: readonly WhStage[] = ["incoming", "pulling", "hold", "pulled"];
/** The hold reasons. The server holds the same closed set. "Other" needs a note. */
export const WH_REASONS = [
"Waiting on material (not in stock)",
"Waiting on purchasing / PO",
"Question for engineering or clerical",
"Drawing / BOM problem",
"Other",
] as const;
/** The short reasons. The server holds the same closed set. */
export const SHORT_REASONS = [
"Not in stock",
"On order",
"Back-ordered",
"Damaged / rejected",
"Wrong part received",
"Needs cutting / prep",
"Other",
] as const;
export const HOLD_NOTE_MAX = 200;
type WhCarrier = { wh_stage?: string | null; wh?: WhInfo | null };
const isStage = (s: unknown): s is WhStage => typeof s === "string" && s in WH_STAGES;
/** The stage of a warehouse packet. A packet with no warehouse row is waiting. */
export function whStage(p: WhCarrier): WhStage {
if (isStage(p.wh?.stage)) return p.wh.stage;
if (isStage(p.wh_stage)) return p.wh_stage;
return "incoming";
}
export function pillText(stage: WhStage): string {
return WH_STAGES[stage].pill;
}
export function pillClassFor(stage: WhStage): string {
return `pill st-warehouse ${WH_STAGES[stage].cls}`;
}
/** The old ago(): "<1 min", "12 min", "3 h", "2 d". Empty without a time. */
export function span(iso: string | null | undefined, now: Date = new Date()): string {
if (!iso) return "";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return "";
const m = Math.max(0, Math.round((now.getTime() - t) / 60_000));
if (m < 1) return "<1 min";
if (m < 60) return `${m} min`;
if (m < 1440) return `${Math.round(m / 60)} h`;
return `${Math.round(m / 1440)} d`;
}
/** The line under the pill (the old whLine): who, how long, why. */
export function whLine(p: WhCarrier & { wh_sent_at?: string | null }, now: Date = new Date()): string {
const s = whStage(p);
const w: Partial<WhInfo> = p.wh ?? {};
const ago = span(w.at ?? p.wh_sent_at ?? null, now);
const who = w.by?.display_name || "warehouse";
switch (s) {
case "incoming":
return `Waiting${ago ? ` ${ago}` : ""}, not started yet`;
case "pulling":
return `Being pulled by ${who}${ago ? ` · ${ago}` : ""}`;
case "hold":
return `On hold: ${w.reason || "no reason given"}${w.note ? ` · ${w.note}` : ""}${ago ? ` · ${ago}` : ""}`;
default:
return `Pulled by ${who}${ago ? ` · ${ago} ago` : ""}, ready for production`;
}
}
/** Local calendar day of a time, for the board order. */
function day(iso: string | null | undefined): string {
if (!iso) return "9999";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "9999";
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
type Ordered = Pick<PacketCard, "wh_sent_at" | "visual_rel">;
const timeOf = (x: Ordered) => {
const t = x.wh_sent_at ? new Date(x.wh_sent_at).getTime() : NaN;
return Number.isNaN(t) ? Number.MAX_SAFE_INTEGER : t;
};
/** Oldest first: the day it was sent, then VISUAL's release date, then the exact time (the old whOrder). */
export function whOrder(a: Ordered, b: Ordered): number {
return (
day(a.wh_sent_at).localeCompare(day(b.wh_sent_at)) ||
String(a.visual_rel || "9999").localeCompare(String(b.visual_rel || "9999")) ||
timeOf(a) - timeOf(b)
);
}
export const num = (v: unknown): number | null => {
if (v === null || v === undefined || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
export function isSub(b: Pick<BomLine, "sub">): boolean {
return String(b.sub ?? "").trim().toUpperCase() === "Y";
}
/** The old needsHeat for a pulled line: not N/A, not a sub-assembly, pieces issued and no heat. */
export function needsHeat(b: Pick<BomLine, "na" | "sub" | "pulled" | "issued" | "heat">): boolean {
return !b.na && !isSub(b) && !!b.pulled && (num(b.issued) ?? 0) > 0 && !(b.heat ?? "").trim();
}
/** Pulled with fewer pieces issued than needed and no short recorded yet. */
export function issuedLess(b: Pick<BomLine, "pulled" | "issued" | "qty" | "short">): boolean {
const iss = num(b.issued);
return !!b.pulled && iss !== null && iss < (num(b.qty) ?? 0) && !b.short;
}
export interface Progress {
/** Lines not N/A. */
total: number;
/** Lines not N/A that are pulled or have an open short. */
pulled: number;
missingHeat: number;
shorts: number;
done: boolean;
}
/** Pull progress from a card's counts. */
export function cardProgress(p: PacketCard): Progress {
const total = p.bom_total ?? 0;
const pulled = p.bom_pulled ?? 0;
const missingHeat = p.bom_missing_heat ?? 0;
return { total, pulled, missingHeat, shorts: p.open_shorts || 0, done: total > 0 && pulled === total && missingHeat === 0 };
}
/** The same from a packet's BOM. */
export function progress(p: Pick<PacketRead, "bom">): Progress {
const bom = p.bom.filter((b) => !b.na);
const total = bom.length;
const pulled = bom.filter((b) => b.pulled || b.short).length;
const missingHeat = bom.filter(needsHeat).length;
const shorts = bom.filter((b) => b.short).length;
return { total, pulled, missingHeat, shorts, done: total > 0 && pulled === total && missingHeat === 0 };
}
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
/** The confirm text for Mark pulled when lines remain. Null when nothing remains. */
export function markPulledWarning(g: Pick<Progress, "total" | "pulled" | "missingHeat">): string | null {
const left = Math.max(0, g.total - g.pulled);
if (!left && !g.missingHeat) return null;
const parts = [left ? `${plural(left, "line")} not ticked Pulled` : "", g.missingHeat ? `${plural(g.missingHeat, "heat number")} missing` : ""].filter(Boolean);
return `${parts.join(" and ")}. Mark it pulled anyway? You'll still need to finish those lines before it can be released.`;
}
/** The warehouse message on the packet page: what is still open, or that it is ready. */
export function packetMessage(p: Pick<PacketRead, "bom">): { open: boolean; head: string; rest: string } {
const bom = p.bom.filter((b) => !b.na);
const unp = bom.filter((b) => !b.pulled && !b.short).length;
const less = bom.filter(issuedLess).length;
const noh = bom.filter(needsHeat);
const sh = bom.filter((b) => b.short).length;
const parts = [
unp ? `${plural(unp, "line")} not pulled` : "",
less ? `${less} issued less than needed, record the short` : "",
noh.length ? `heat number needed for ${noh.map((b) => `item ${b.item} (${String(b.issued ?? 0)} pulled)`).join(", ")}` : "",
].filter(Boolean);
if (parts.length) {
return {
open: true,
head: `${parts.join(" · ")}.`,
rest: `${noh.length ? "Parts that were pulled still need their heat, even when the rest of the line is short. " : ""}Tick Pulled as parts come off the rack. If a part isn't here, use Short so it's tracked and the packet can still move.`,
};
}
if (sh) {
return {
open: false,
head: `Ready to release with ${plural(sh, "short line")}.`,
rest: "The shorts stay open on the Shorts list until you receive them, and the supervisor and stall can see them.",
};
}
return { open: false, head: "All parts pulled and heats recorded.", rest: "Release the packet to the supervisor." };
}
/** Whole days since a time, or null. */
export function daysSince(iso: string | null | undefined, now: Date = new Date()): number | null {
if (!iso) return null;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return null;
return Math.floor((now.getTime() - t) / 86_400_000);
}
/** The roles that move cards on the board. The server checks again on every move. */
export function isWarehouseUser(roles: readonly string[]): boolean {
return roles.some((r) => r === "warehouse" || r === "warehouse_supervisor" || r === "admin");
}
+99
View File
@@ -0,0 +1,99 @@
import { useCallback, useState, type ReactNode } from "react";
import { packetsApi } from "../api/packets";
import type { PacketRead, WhStage } from "../api/types";
import type { Guard } from "../components/useGuard";
import { ConfirmSheet, HoldSheet } from "./HoldSheet";
import { markPulledWarning, type Progress } from "./stages";
/** What a stage move needs to know about the packet. */
export interface WhTarget {
id: number;
stage: WhStage;
job: string;
progress: Pick<Progress, "total" | "pulled" | "missingHeat" | "done">;
}
type Pending = { kind: "hold"; t: WhTarget } | { kind: "confirm"; t: WhTarget; text: string };
/**
* Moves a packet on the warehouse board. Hold asks for a reason first; Mark pulled asks when lines remain.
* `onDone` gets the packet answer; `onError` gets the failure. Render `sheet` once in the screen.
*/
export function useWhMove({
run,
onDone,
onError,
}: {
run: Guard["run"];
onDone: (p: PacketRead) => void;
onError: (err: unknown) => void;
}): { move: (t: WhTarget, to: WhStage) => void; busy: boolean; sheet: ReactNode } {
const [pending, setPending] = useState<Pending | null>(null);
const [busy, setBusy] = useState(false);
const send = useCallback(
async (t: WhTarget, to: WhStage, extra: { reason?: string; note?: string } = {}) => {
setBusy(true);
try {
const r = await run((g) => packetsApi.setWhStage(t.id, { stage: to, expect_stage: t.stage, ...extra }, g), { sub: t.job });
if (r) onDone(r);
} catch (err) {
onError(err);
} finally {
setBusy(false);
}
},
[run, onDone, onError],
);
const move = useCallback(
(t: WhTarget, to: WhStage) => {
if (t.stage === to) return;
if (to === "hold") {
setPending({ kind: "hold", t });
return;
}
if (to === "pulled" && !t.progress.done) {
const text = markPulledWarning(t.progress);
if (text) {
setPending({ kind: "confirm", t, text });
return;
}
}
void send(t, to);
},
[send],
);
let sheet: ReactNode = null;
if (pending?.kind === "hold") {
const t = pending.t;
sheet = (
<HoldSheet
sub={t.job}
onCancel={() => setPending(null)}
onHold={(reason, note) => {
setPending(null);
void send(t, "hold", note ? { reason, note } : { reason });
}}
/>
);
} else if (pending?.kind === "confirm") {
const t = pending.t;
sheet = (
<ConfirmSheet
title="Not everything is pulled yet"
sub={t.job}
text={pending.text}
okLabel="Mark pulled"
onCancel={() => setPending(null)}
onOk={() => {
setPending(null);
void send(t, "pulled");
}}
/>
);
}
return { move, busy, sheet };
}
+103 -2
View File
@@ -1,4 +1,5 @@
"""Admin: users and their AD view (read-only), PIN clearing, devices, the group map, AD refresh, attempts.
"""Admin: users and their AD view (read-only), PIN clearing, devices, the group map, AD refresh, attempts, storage
and alerts.
Every route needs the admin role and a password session. Roles cannot be changed here: they come from AD.
"""
@@ -21,8 +22,9 @@ from ..auth import groups as groupmap
from ..auth import pin as pins
from ..auth.ldap import LdapUnavailable
from ..auth.signin import ldap_down
from ..domain import assets, scan
from .account import active_sessions
from .deps import Db, Principal, admin_principal, directory_of, get_db
from .deps import Db, Principal, admin_principal, directory_of, get_db, settings_of
from .errors import ApiError
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -307,3 +309,102 @@ def signin_attempts(
}
for r in rows
]
# ---------------------------------------------------------------- storage and alerts
PING_MAX_SECONDS = 5.0
ALERT_SELECT = (
"SELECT n.*, u.display_name AS acknowledged_name FROM notification_events n"
" LEFT JOIN users u ON u.id = n.acknowledged_by"
)
def _alert_row(r: dict[str, Any]) -> dict[str, Any]:
by = r.get("acknowledged_by")
return {
"id": r["id"],
"kind": r["kind"],
"severity": r["severity"],
"subject": r["subject"],
"detail": r["detail"] or {},
"created_at": r["created_at"],
"resolved_at": r["resolved_at"],
"acknowledged_at": r["acknowledged_at"],
"acknowledged_by": {"id": str(by), "display_name": r.get("acknowledged_name")} if by else None,
}
@router.get("/storage")
def storage(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
s = settings_of(request)
usage = assets.usage_for(d.conn, s)
with d.conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
ALERT_SELECT + " WHERE n.kind = %s AND coalesce(n.subject, '') = %s AND n.resolved_at IS NULL"
" ORDER BY n.id LIMIT 1",
(assets.QUOTA_KIND, assets.QUOTA_SUBJECT),
).fetchone()
scanner = scan.scanner_for(s)
clamav: dict[str, Any] = {"configured": scanner is not None, "host": None, "port": None, "reachable": None}
if scanner is not None:
clamav.update(
host=scanner.host,
port=scanner.port,
reachable=scanner.ping(timeout=min(scanner.timeout, PING_MAX_SECONDS)),
)
return {
"usage": usage,
"quota_warn_pct": int(s.assets_quota_warn_pct),
"quota_bytes": int(s.assets_quota_bytes),
"alert": _alert_row(dict(r)) if r else None,
"clamav": clamav,
}
@router.get("/alerts")
def alerts(
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
with d.conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
ALERT_SELECT + " WHERE n.resolved_at IS NULL ORDER BY n.created_at DESC, n.id DESC"
).fetchall()
return {"alerts": [_alert_row(dict(r)) for r in rows]}
@router.post("/alerts/{alert_id}/ack")
def ack_alert(
alert_id: int,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
with conn.transaction():
done = conn.execute(
"UPDATE notification_events SET acknowledged_at = now(), acknowledged_by = %s"
" WHERE id = %s AND acknowledged_at IS NULL RETURNING kind, subject",
(p.user_id, alert_id),
).fetchone()
if done is not None:
audit.append(
conn,
action="alert.acknowledged",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="notification",
subject_id=str(alert_id),
detail={"kind": done[0], "subject": done[1]},
ip=p.ip,
)
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(ALERT_SELECT + " WHERE n.id = %s", (alert_id,)).fetchone()
if r is None:
raise ApiError(404, "not_found", "No such alert.")
return _alert_row(dict(r))
+11 -7
View File
@@ -10,7 +10,6 @@ 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
@@ -32,9 +31,12 @@ async def upload(
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
s = settings_of(request)
# The purpose field is inside the body, so the pre-check uses the largest kind limit. The store applies the
# kind's own limit while it streams.
pre = assets.max_upload_limit(s)
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.")
if length is not None and length.isdigit() and int(length) > pre + FORM_SLACK:
raise assets.too_large(pre)
form = await request.form(max_files=1, max_fields=8)
try:
file = form.get("file")
@@ -51,8 +53,10 @@ async def upload(
)
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
raw_purpose = form.get("purpose")
if raw_purpose is not None and not isinstance(raw_purpose, str):
raise ApiError(422, "invalid_request", "purpose must be text.", extra={"field": "purpose"})
kind = assets.kind_of(raw_purpose)
upload_file = file
return await run_in_threadpool(
lambda: assets.store_upload(
@@ -62,8 +66,8 @@ async def upload(
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,
purpose=kind.name,
)
)
finally:
@@ -96,7 +100,7 @@ def blob(
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)}"',
"Content-Disposition": assets.disposition(row, bool(download)),
"X-Content-Type-Options": "nosniff",
"Cache-Control": "private, max-age=3600",
"ETag": etag,
+103 -38
View File
@@ -30,8 +30,10 @@ from ..domain.pipeline import (
next_counter,
packet_write,
parse_if_match,
qty_text,
require_if_match,
)
from ..policy.actions import STATUS_LABELS
from ..text import LIMITS, RX_HEAT, RX_STAMP, clean_opt, clean_text
from .deps import Db, Principal, current_principal, get_db
from .errors import ApiError
@@ -45,7 +47,7 @@ TEXT_SHORT = 40 # weld type, size, process, wo_base
FILLER_MAX = 120
QC_FIELD_MAX = 120
# The old warehouse hold reasons. Any other text is taken as given; "Other" needs a note.
# The warehouse hold reasons, a closed set (the old build took any text). "Other" needs a note.
WH_HOLD_REASONS = (
"Waiting on material (not in stock)",
"Waiting on purchasing / PO",
@@ -53,6 +55,19 @@ WH_HOLD_REASONS = (
"Drawing / BOM problem",
"Other",
)
# The short reasons, a closed set (the old build took any text).
SHORT_REASONS = (
"Not in stock",
"On order",
"Back-ordered",
"Damaged / rejected",
"Wrong part received",
"Needs cutting / prep",
"Other",
)
WH_NOTE_MAX = 200
# The file types an MTR may be.
MTR_MIMES = frozenset({"application/pdf", "image/png", "image/jpeg"})
# The old warehouse board: which stage may follow which.
WH_NEXT: dict[str, frozenset[str]] = {
"incoming": frozenset({"pulling", "hold"}),
@@ -65,6 +80,8 @@ WH_LABELS = {
"hold": "Put on hold",
"pulled": "Marked pulled, ready for production",
}
# Labels for a move into pulling from a stage other than incoming.
WH_PULLING_FROM = {"hold": "Pulling resumed", "pulled": "Back to pulling"}
MSG_NO_STAGE = "This packet has no open stage."
MSG_NO_INSPECTION = "This packet has no open Q.C. inspection."
@@ -260,12 +277,20 @@ def _read(d: Db, pp: policy.Principal, packet_id: int) -> dict[str, Any]:
def _heat(value: Any) -> str | None:
"""A cleaned heat number, None when empty, 422 invalid_heat when it does not fit RX_HEAT."""
"""A cleaned heat number, None when empty, 422 invalid_heat when it does not fit RX_HEAT.
Trailing white space is trimmed (a scanner may add it). A leading space is refused, as the pattern says.
"""
text = clean_text(value, 200)
if not text:
return None
if len(text) > LIMITS["heat"] or not RX_HEAT.match(text):
raise ApiError(422, "invalid_heat", "Heat numbers are letters, digits, spaces and . / # _ - only.")
lead = isinstance(value, str) and value[:1].isspace()
if lead or len(text) > LIMITS["heat"] or not RX_HEAT.match(text):
raise ApiError(
422,
"invalid_heat",
f'Heat number "{text}" isn\'t valid: letters, numbers, space . / # _ - only, up to 40 characters.',
)
return text
@@ -323,6 +348,16 @@ def _num(value: Any) -> Decimal | None:
return None if value is None else Decimal(str(value))
def _status_tail(conn: Any, row: dict[str, Any]) -> str:
"""The end of the receive note: " · packet ready to assign", plus " in {station}" when at a station."""
out = f" · packet {STATUS_LABELS.get(str(row['status']), str(row['status'])).lower()}"
if row["status"] in policy.ACTIVE and row["station_id"] is not None:
st = conn.execute("SELECT name FROM stations WHERE id = %s", (row["station_id"],)).fetchone()
if st and st[0]:
out += f" in {st[0]}"
return out
def _diff(row: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
"""The values that differ from the row."""
out: dict[str, Any] = {}
@@ -510,10 +545,12 @@ def patch_bom_line(
check_row_version(line, if_match)
if _open_short(conn, line["id"]) is not None and given & {"heat", "issued", "loc"}:
raise ApiError(409, "short_open", MSG_SHORT_OPEN)
if values.get("mtr_asset_id") is not None and (
conn.execute("SELECT 1 FROM assets WHERE id = %s", (values["mtr_asset_id"],)).fetchone() is None
):
raise ApiError(422, "invalid_asset", "That MTR file is not on the server. Upload it first.")
if values.get("mtr_asset_id") is not None:
asset = conn.execute("SELECT mime FROM assets WHERE id = %s", (values["mtr_asset_id"],)).fetchone()
if asset is None:
raise ApiError(422, "invalid_asset", "That MTR file is not on the server. Upload it first.")
if asset[0] not in MTR_MIMES:
raise ApiError(422, "invalid_asset", "An MTR is a PDF, PNG or JPG file.")
newly_pulled = False
if "pulled" in given:
if body.pulled:
@@ -534,24 +571,43 @@ def patch_bom_line(
_apply_line(w, line, changes)
w.changed(*[f"bom.{k}" for k in changes if k not in ("pulled_at", "pulled_by")])
w.detail["item"] = item
if "pulled" in changes:
w.log(f"Item {item} pulled" if changes["pulled"] else f"Item {item} no longer pulled")
elif "heat" in changes and len(changes) == 1:
w.log(f"Heat recorded on item {item}" if changes["heat"] else f"Heat cleared on item {item}")
w.note = changes["heat"]
elif "na" in changes:
w.log(f"Item {item} marked N/A (not traceable)" if changes["na"] else f"Item {item} marked traceable")
elif "issued" in changes and len(changes) == 1:
w.log(f"Issued qty changed on item {item}", f"{changes['issued']} of {line['qty']}")
elif "mtr_asset_id" in changes and len(changes) == 1:
w.log(f"MTR attached to item {item}" if changes["mtr_asset_id"] else f"MTR removed from item {item}")
else:
w.log(f"Item {item} changed")
_log_line(w, item, line, changes, given)
if newly_pulled:
_auto_pull(w)
return _read(d, pp, packet_id)
def _log_line(w: Write, item: int, line: dict[str, Any], changes: dict[str, Any], given: set[str]) -> None:
"""The packet_log text of a line edit. Fields the server set along the way (issued on a Pulled tick, the heat
cleared by N/A) do not count as a second field."""
kinds = {k for k in changes if k not in ("pulled_at", "pulled_by")}
if "pulled" in kinds and "issued" not in given:
kinds.discard("issued")
if "na" in kinds and "heat" not in given:
kinds.discard("heat")
if len(kinds) != 1:
w.log(f"Line changed · item {item}")
return
kind = next(iter(kinds))
if kind == "pulled":
w.log(f"Pulled · item {item}" if changes["pulled"] else f"Pulled undone · item {item}")
elif kind == "heat":
if changes["heat"]:
w.log(f"Heat recorded · item {item}", changes["heat"])
else:
w.log(f"Heat cleared · item {item}")
elif kind == "na":
w.log(f"Marked N/A · item {item}" if changes["na"] else f"Traceable again · item {item}")
elif kind == "issued":
w.log(f"Issued qty changed · item {item}", f"{qty_text(changes['issued'])} of {qty_text(line['qty'])}")
elif kind == "loc":
w.log(f"Location set · item {item}", changes["loc"] or None)
elif kind == "mtr_asset_id":
w.log(f"MTR attached · item {item}" if changes["mtr_asset_id"] else f"MTR removed · item {item}")
else:
w.log(f"Line changed · item {item}")
@router.post("/{packet_id}/bom/{item}/short", dependencies=WRITE)
def save_short(
packet_id: int,
@@ -563,6 +619,8 @@ def save_short(
) -> dict[str, Any]:
"""Open a short on a line, or update the open one. expect_open says which the caller saw."""
reason = clean_opt(body.reason, LIMITS["reason"])
if reason not in SHORT_REASONS:
raise ApiError(400, "invalid_reason", "Pick one of the short reasons.")
ref = clean_opt(body.ref, LIMITS["po"])
note = clean_opt(body.note, LIMITS["note"])
eta = clean_opt(body.eta, 40)
@@ -595,7 +653,7 @@ def save_short(
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
(packet_id, line["id"], body.qty, body.qty, reason, ref, eta, note, w.user_id),
)
label = f"Short recorded on item {item}"
label = f"Short recorded · item {item}"
else:
received = (_num(short["qty"]) or Decimal(0)) - (_num(short["qty_open"]) or Decimal(0))
update_row(
@@ -611,7 +669,7 @@ def save_short(
"note": note,
},
)
label = f"Short updated on item {item}"
label = f"Short updated · item {item}"
values: dict[str, Any] = {"issued": need - body.qty}
_set_pulled(w, line, values)
if heat is not None and not line["na"]:
@@ -621,8 +679,8 @@ def save_short(
if changes:
_apply_line(w, line, changes)
w.changed("bom.short")
w.detail["item"] = item
parts = [f"{body.qty} of {need} short"]
w.detail.update({"item": item, "qty": body.qty})
parts = [f"{body.qty} of {qty_text(need)} short"]
parts += [x for x in (reason, ref, f"ETA {eta}" if eta else None) if x]
w.log(label, " · ".join(parts))
if newly_pulled:
@@ -660,8 +718,8 @@ def clear_short(
if changes:
_apply_line(w, line, changes)
w.changed("bom.short")
w.detail["item"] = item
w.log(f"Short cleared on item {item}")
w.detail.update({"item": item, "qty": float(short["qty_open"])})
w.log(f"Short cleared · item {item}")
return _read(d, pp, packet_id)
@@ -674,14 +732,17 @@ def receive_short(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The warehouse receives short parts, in any status but filed. The short closes when nothing is open."""
"""Receive short parts. The short closes when nothing is open.
Receive by custody: a warehouse member receives in any status but filed (WH_RECEIVE_SHORT); anyone else
receives as the holder of the packet (EDIT_BOM), and the policy refuses everyone who is neither.
"""
heat = _heat(body.heat)
note = clean_opt(body.note, LIMITS["note"])
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.WH_RECEIVE_SHORT, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
action = A.WH_RECEIVE_SHORT if "WH" in pp.support else A.EDIT_BOM
with packet_write(d, p, packet_id, action, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
line = _bom_line(conn, packet_id, item)
short = _open_short(conn, line["id"])
if short is None:
@@ -710,10 +771,11 @@ def receive_short(
_apply_line(w, line, values)
w.changed("bom.short")
w.detail.update({"item": item, "qty": body.qty})
tail = f"{still} still short" if still > 0 else "short closed"
tail = f"{qty_text(still)} still short" if still > 0 else "short closed"
w.log(
f"Short received on item {item}",
" · ".join(x for x in (f"{body.qty} pc", heat and f"heat {heat}", tail) if x),
f"Short received · item {item}",
" · ".join(x for x in (f"{body.qty} pc", heat and f"heat {heat}", tail) if x)
+ _status_tail(conn, w.packet),
)
return _read(d, pp, packet_id)
@@ -1141,10 +1203,10 @@ def set_wh_stage(
) -> dict[str, Any]:
"""Move the warehouse board: incoming, pulling, hold, pulled. expect_stage is what the caller saw."""
reason = clean_opt(body.reason, LIMITS["reason"])
note = clean_opt(body.note, LIMITS["note"])
note = clean_opt(body.note, WH_NOTE_MAX)
if body.stage == "hold":
if reason is None:
raise ApiError(400, "reason_required", "Say why it is on hold.")
if reason not in WH_HOLD_REASONS:
raise ApiError(400, "invalid_reason", "Pick one of the hold reasons.")
if reason == "Other" and note is None:
raise ApiError(400, "reason_required", "Say what it's waiting on.")
pp = _pp(d, p)
@@ -1173,7 +1235,10 @@ def set_wh_stage(
note=note,
auto=False,
)
label = "Pulling resumed" if stage == "hold" and body.stage == "pulling" else WH_LABELS.get(body.stage, "")
if body.stage == "pulling" and stage in WH_PULLING_FROM:
label = WH_PULLING_FROM[stage]
else:
label = WH_LABELS.get(body.stage, "")
w.changed("wh")
w.detail.update({"wh_from": stage, "wh_to": body.stage})
w.log(
+185
View File
@@ -0,0 +1,185 @@
"""The warehouse board: KPIs, the four stage columns, the released column and the open shorts.
Any signed-in session reads it. It reads PostgreSQL only. The cards are the list_cards shape, so the pill, the wh
line and the pull progress read the same on every screen.
"""
from __future__ import annotations
from datetime import UTC, date, datetime
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, Query, Request
from ..domain import packets
from ..text import clean_text
from .deps import Db, Principal, current_principal, get_db, settings_of
router = APIRouter(prefix="/api/warehouse", tags=["warehouse"])
STAGES = ("incoming", "pulling", "hold", "pulled")
RELEASED_DAYS = 3
BOARD_LIMIT = 5000
# Packets with a wh_release transition in the window; a packet back in the warehouse is on a stage column instead.
RELEASED_SQL = """
SELECT t.packet_id, max(t.at) AS released_at
FROM packet_transitions t JOIN packets p ON p.id = t.packet_id JOIN units u ON u.id = p.unit_id
WHERE t.action = 'wh_release' AND t.at > now() - %s * interval '1 day' AND p.status <> 'warehouse'
AND p.removed_at IS NULL {scope}
GROUP BY t.packet_id
"""
SHORTS_SQL = """
SELECT s.id AS short_id, s.packet_id, s.bom_line_id, s.qty_open, s.reason, s.ref, s.eta, s.note, s.opened_at,
s.row_version, b.item, b.description, b.part_id, b.material, b.length, b.qty, b.na
FROM bom_shorts s JOIN bom_lines b ON b.id = s.bom_line_id
WHERE s.closed_at IS NULL AND s.packet_id = ANY(%s)
ORDER BY s.eta NULLS LAST, s.opened_at, s.id
"""
# The KPIs. {scope} is the fixed project and unit terms from _scope, never request text.
KPI_NAMES = {"incoming": "waiting", "pulling": "pulling", "hold": "hold", "pulled": "pulled"}
KPI_STAGES_SQL = """
SELECT coalesce(w.stage::text, 'incoming'), count(*)
FROM packets p JOIN units u ON u.id = p.unit_id LEFT JOIN packet_wh w ON w.packet_id = p.id AND w.closed_at IS NULL
WHERE p.status = 'warehouse' AND p.removed_at IS NULL {scope}
GROUP BY 1
"""
KPI_SHORTS_SQL = """
SELECT count(*) FROM bom_shorts s JOIN packets p ON p.id = s.packet_id JOIN units u ON u.id = p.unit_id
WHERE s.closed_at IS NULL AND p.removed_at IS NULL {scope}
"""
KPI_RELEASED_SQL = """
SELECT count(DISTINCT t.packet_id)
FROM packet_transitions t JOIN packets p ON p.id = t.packet_id JOIN units u ON u.id = p.unit_id
WHERE t.action = 'wh_release' AND t.at > now() - interval '24 hours' AND p.status <> 'warehouse'
AND p.removed_at IS NULL {scope}
"""
def _tz(request: Request) -> Any:
try:
return ZoneInfo(settings_of(request).site_tz)
except (ZoneInfoNotFoundError, ValueError):
return UTC
def _scope(filters: dict[str, Any]) -> tuple[str, list[Any]]:
"""The project and unit terms (the KPIs follow these only), over packets p and units u."""
terms: list[str] = []
params: list[Any] = []
if filters.get("unit_id") is not None:
terms.append("p.unit_id = %s")
params.append(int(filters["unit_id"]))
if filters.get("project_id") is not None:
terms.append("u.project_id = %s")
params.append(int(filters["project_id"]))
return "".join(f" AND {t}" for t in terms), params
def _order_key(card: dict[str, Any], tz: Any) -> tuple[str, str, float]:
"""The old whOrder: the day it was sent, then VISUAL's release date (none last), then the exact time."""
sent = card.get("wh_sent_at")
day = sent.astimezone(tz).date().isoformat() if isinstance(sent, datetime) else "9999"
rel = card.get("visual_rel")
rel_text = rel.isoformat() if isinstance(rel, date) else "9999"
exact = sent.timestamp() if isinstance(sent, datetime) else float("inf")
return day, rel_text, exact
def _kpis(conn: Any, filters: dict[str, Any]) -> dict[str, int]:
scope, params = _scope(filters)
out = {"waiting": 0, "pulling": 0, "hold": 0, "pulled": 0, "open_shorts": 0, "released_24h": 0}
for stage, n in conn.execute(KPI_STAGES_SQL.format(scope=scope), params):
if stage in KPI_NAMES:
out[KPI_NAMES[stage]] = int(n)
out["open_shorts"] = int(conn.execute(KPI_SHORTS_SQL.format(scope=scope), params).fetchone()[0])
out["released_24h"] = int(conn.execute(KPI_RELEASED_SQL.format(scope=scope), params).fetchone()[0])
return out
def _short_row(s: dict[str, Any], card: dict[str, Any], today: date, now: datetime) -> dict[str, Any]:
eta = s["eta"]
return {
"packet": {
"id": card["id"],
"unit_number": card["unit"]["number"],
"op": card["op"],
"drawing": card["drawing"],
"status": card["status"],
"wh_stage": card["wh_stage"],
"station": card["station"],
"holder": card["holder"],
"dept": card["dept"],
},
"item": s["item"],
"bom_line_id": s["bom_line_id"],
"description": s["description"],
"part_id": s["part_id"],
"material": s["material"],
"length": s["length"],
"qty": s["qty"],
"qty_open": s["qty_open"],
"na": bool(s["na"]),
"reason": s["reason"],
"ref": s["ref"],
"eta": eta,
"note": s["note"],
"opened_at": s["opened_at"],
"age_days": max(0, (now - s["opened_at"]).days),
"late": eta is not None and eta < today,
"short_id": s["short_id"],
"row_version": s["row_version"],
}
@router.get("/board")
def board(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
q: str = Query("", max_length=200),
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
dept: str = Query("", max_length=10),
) -> dict[str, Any]:
conn = d.conn
pp = packets.policy_principal_for(conn, p)
tz = _tz(request)
filters: dict[str, Any] = {
"q": clean_text(q, 200),
"project_id": project_id,
"unit_id": unit_id,
"dept": dept.strip() or None,
"limit": BOARD_LIMIT,
}
columns: dict[str, list[dict[str, Any]]] = {st: [] for st in (*STAGES, "released")}
for card in packets.list_cards(conn, pp, {**filters, "status": "warehouse"}):
columns[card["wh_stage"] if card["wh_stage"] in STAGES else "incoming"].append(card)
for st in STAGES:
columns[st].sort(key=lambda c: _order_key(c, tz))
scope, params = _scope(filters)
released = {int(r[0]): r[1] for r in conn.execute(RELEASED_SQL.format(scope=scope), [RELEASED_DAYS, *params])}
if released:
cards = packets.list_cards(conn, pp, {**filters, "ids": list(released)})
for card in cards:
card["released_at"] = released[int(card["id"])]
cards.sort(key=lambda c: (c["released_at"], c["id"]), reverse=True)
columns["released"] = cards
short_ids = [int(r[0]) for r in conn.execute("SELECT DISTINCT packet_id FROM bom_shorts WHERE closed_at IS NULL")]
shorts: list[dict[str, Any]] = []
if short_ids:
by_id = {int(c["id"]): c for c in packets.list_cards(conn, pp, {**filters, "ids": short_ids})}
if by_id:
now = datetime.now(UTC)
today = now.astimezone(tz).date()
for sr in packets.rows(conn, SHORTS_SQL, (list(by_id),)):
shorts.append(_short_row(sr, by_id[int(sr["packet_id"])], today, now))
return {"kpis": _kpis(conn, filters), "columns": columns, "shorts": shorts}
+24 -1
View File
@@ -23,7 +23,7 @@ from . import __version__
from .settings import SecretFileError, Settings
PASS, WARN, FAIL, SKIP = "PASS", "WARN", "FAIL", "SKIP"
SKIPPABLE = ("visual", "mfiles", "ldap", "smtp", "db", "volume")
SKIPPABLE = ("visual", "mfiles", "ldap", "smtp", "db", "volume", "clamav")
FREE_WARN_BYTES = 2 * 1024**3
EXPECTED_PS_GROUPS = 50
IN_CHAIN = "1.2.840.113556.1.4.1941"
@@ -261,6 +261,28 @@ def check_volume(s: Settings) -> list[Check]:
return out
# ---------------------------------------------------------------- clamav
def check_clamav(s: Settings) -> list[Check]:
from .domain import scan
scanner = scan.scanner_for(s)
if scanner is None:
return [Check("clamav", SKIP, "CLAMAV_HOST not set: uploads are not virus scanned")]
where = f"{scanner.host}:{scanner.port}"
if scanner.ping(timeout=min(scanner.timeout, 5.0)):
return [Check("clamav", PASS, f"clamd at {where} answers PING")]
return [
Check(
"clamav",
FAIL,
f"clamd at {where} does not answer; uploads are refused until it does",
"start the stack with --profile clamav, or clear CLAMAV_HOST",
)
]
# ---------------------------------------------------------------- ldap
@@ -416,6 +438,7 @@ def run(
("mfiles", check_mfiles),
("db", check_db),
("volume", check_volume),
("clamav", check_clamav),
("ldap", check_ldap),
("smtp", lambda st: check_smtp(st, send_test)),
]
+344 -28
View File
@@ -1,19 +1,25 @@
"""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.
typed by its content (not by its name or the browser's claim), fsynced, scanned when ClamAV is configured and
renamed into place. The same bytes uploaded twice (same sha256 and size) return the existing row.
Each upload has a kind (its purpose) that sets the types it may be and its size limit. See KINDS.
"""
from __future__ import annotations
import codecs
import contextlib
import hashlib
import logging
import os
import re
import shutil
import tempfile
import uuid
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, BinaryIO
@@ -24,30 +30,159 @@ from .. import audit
from ..api.errors import ApiError
from ..settings import Settings
from ..text import clean_text
from . import scan
log = logging.getLogger(__name__)
CHUNK = 1024 * 1024
HEAD = 16
MIB = 1024 * 1024
PDF = "application/pdf"
PNG = "image/png"
JPEG = "image/jpeg"
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
CSV = "text/csv"
ZIP_MAGIC = b"PK\x03\x04"
MAGIC: tuple[tuple[bytes, str], ...] = (
(b"%PDF-", PDF),
(b"\x89PNG\r\n\x1a\n", "image/png"),
(b"\xff\xd8\xff", "image/jpeg"),
(b"\x89PNG\r\n\x1a\n", PNG),
(b"\xff\xd8\xff", JPEG),
(ZIP_MAGIC, XLSX),
)
EXT = {PDF: ".pdf", "image/png": ".png", "image/jpeg": ".jpg"}
EXT = {PDF: ".pdf", PNG: ".png", JPEG: ".jpg", XLSX: ".xlsx", CSV: ".csv"}
# Served as a download only, never shown in the browser.
ATTACHMENT_ONLY = frozenset({XLSX, CSV})
XLSX_PARTS = ("[Content_Types].xml", "xl/workbook.xml")
CSV_SAMPLE = 64 * 1024
_CSV_ALLOWED_C0 = frozenset("\t\r\n")
NAME_MAX = 160
_UNSAFE_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
MSG_DOCUMENT_TYPE = "Only PDF, PNG and JPEG files can be stored."
def sniff(head: bytes) -> str | None:
"""The mime type from the first bytes, or None when it is not a type Packet Station stores."""
@dataclass(frozen=True)
class Kind:
"""An upload kind: the types it may be (by content) and the setting that holds its size limit."""
name: str
mimes: frozenset[str]
limit_setting: str
wrong_type: str
KINDS: dict[str, Kind] = {
"drawing": Kind("drawing", frozenset({PDF}), "upload_max_bytes", "A drawing is a PDF file."),
"mtr": Kind("mtr", frozenset({PDF, PNG, JPEG}), "upload_max_bytes", "An MTR is a PDF, PNG or JPG file."),
"document": Kind("document", frozenset({PDF, PNG, JPEG}), "upload_max_bytes", MSG_DOCUMENT_TYPE),
"bom": Kind("bom", frozenset({XLSX, CSV}), "upload_max_bytes_bom", "A BOM is an Excel (.xlsx) or CSV file."),
}
DEFAULT_KIND = "document"
# Older client names for a kind.
KIND_ALIASES = {"doc": "document", "docs": "document"}
def kind_of(purpose: Any) -> Kind:
"""The upload kind for a `purpose` value. Missing or blank is `document`; anything unknown is 422."""
if purpose is None:
return KINDS[DEFAULT_KIND]
name = str(purpose).strip().lower()
if not name:
return KINDS[DEFAULT_KIND]
name = KIND_ALIASES.get(name, name)
found = KINDS.get(name)
if found is None:
raise ApiError(
422, "invalid_request", f"purpose must be one of {', '.join(KINDS)}.", extra={"field": "purpose"}
)
return found
def kind_limit(settings: Settings, kind: Kind) -> int:
return int(getattr(settings, kind.limit_setting))
def max_upload_limit(settings: Settings) -> int:
"""The largest limit of any kind (the multipart pre-check, before the purpose field is read)."""
return max(kind_limit(settings, k) for k in KINDS.values())
def limit_text(limit: int) -> str:
if limit >= MIB:
mb = limit / MIB
return f"{mb:.0f} MB" if mb == int(mb) else f"{mb:.1f} MB"
return f"{max(1, limit // 1024)} KB"
def too_large(limit: int) -> ApiError:
return ApiError(413, "too_large", f"The file is over the {limit_text(limit)} limit.", extra={"limit": limit})
def _magic(head: bytes) -> str | None:
for magic, mime in MAGIC:
if head.startswith(magic):
return mime
return None
def is_xlsx(source: Path | bytes) -> bool:
"""A zip that lists the parts of an Excel workbook. A plain zip (or a .docx) is not one."""
try:
if isinstance(source, bytes | bytearray):
import io
zf = zipfile.ZipFile(io.BytesIO(bytes(source)))
else:
zf = zipfile.ZipFile(source)
with zf:
names = set(zf.namelist())
except (zipfile.BadZipFile, zipfile.LargeZipFile, OSError, ValueError, EOFError, KeyError):
return False
return all(part in names for part in XLSX_PARTS)
def is_csv_text(sample: bytes, *, complete: bool) -> bool:
"""UTF-8 text (a byte order mark is allowed) with no NUL and no control character other than tab, CR, LF.
`complete` is False when the sample is a cut from a longer file: a character split at the end is allowed.
"""
if not sample or b"\x00" in sample:
return False
if sample.startswith(codecs.BOM_UTF8):
sample = sample[len(codecs.BOM_UTF8) :]
try:
text = codecs.getincrementaldecoder("utf-8")("strict").decode(sample, final=complete)
except UnicodeDecodeError:
return False
return not any(ord(c) < 0x20 and c not in _CSV_ALLOWED_C0 for c in text)
def _read_sample(path: Path) -> tuple[bytes, bool]:
with Path(path).open("rb") as f:
sample = f.read(CSV_SAMPLE + 1)
return sample[:CSV_SAMPLE], len(sample) <= CSV_SAMPLE
def sniff(data: bytes, path: Path | None = None, *, purpose: Any = None) -> str | None:
"""The mime type from the content, or None when it is not a type Packet Station stores.
`data` is the first bytes (or all of them). PDF, PNG and JPEG go by their magic. A zip is XLSX only when it lists
the workbook parts: the file at `path` is opened, or `data` when no path is given. CSV has no magic: it is
considered only for purpose `bom`, from the first 64 KB of `path` (or of `data`).
"""
mime = _magic(data)
if mime == XLSX:
return XLSX if is_xlsx(path if path is not None else data) else None
if mime is not None:
return mime
if purpose is None or kind_of(purpose).name != "bom":
return None
if path is not None:
sample, complete = _read_sample(path)
else:
sample, complete = data[:CSV_SAMPLE], len(data) <= CSV_SAMPLE
return CSV if is_csv_text(sample, complete=complete) else 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)
@@ -176,6 +311,63 @@ def _insert(conn: Any, values: tuple[Any, ...]) -> dict[str, Any]:
return dict(row)
def _type_error(kind: Kind) -> ApiError:
return ApiError(415, "unsupported_type", kind.wrong_type, extra={"allowed": sorted(EXT[m] for m in kind.mimes)})
def _content_type(path: Path, head: bytes, kind: Kind) -> str | None:
"""The mime of a complete file for this kind, or None when it is not one of the kind's types."""
mime = sniff(head, path, purpose=kind.name if CSV in kind.mimes else None)
return mime if mime in kind.mimes else None
def _audit_rejected(
conn: Any, err: ApiError, *, user: Any, actor: Any, packet_id: int | None, size: int, mime: str | None
) -> None:
"""The audit row for an upload the virus scan refused. A failure here never hides the refusal."""
try:
audit.append(
conn,
action="asset.rejected",
user_id=user,
session_id=getattr(actor, "session_id", None),
role_snapshot=getattr(actor, "role_snapshot", None),
subject_type="asset",
packet_id=packet_id,
detail={"reason": "infected", "signature": err.extra.get("signature"), "size": size, "mime": mime},
ip=getattr(actor, "ip", None),
)
except Exception:
log.exception("could not write the asset.rejected audit row")
def _scan(
conn: Any,
settings: Settings,
path: Path,
*,
user: Any,
actor: Any,
packet_id: int | None,
size: int,
mime: str | None,
) -> None:
try:
scan.check(settings, path)
except ApiError as e:
if e.code == "infected":
_audit_rejected(conn, e, user=user, actor=actor, packet_id=packet_id, size=size, mime=mime)
raise
def _with_purpose(source_ref: Any, kind: Kind) -> Any:
if source_ref is None:
return {"purpose": kind.name}
if isinstance(source_ref, dict):
return {"purpose": kind.name, **source_ref}
return source_ref
def store_upload(
conn: Any,
settings: Settings,
@@ -187,14 +379,20 @@ def store_upload(
source: str = "upload",
source_ref: Any = None,
actor: Any = None,
purpose: Any = None,
) -> dict[str, Any]:
"""Store one upload and return its public row. Raises 413 too_large, 415 unsupported_type, 422 bad_pdf.
"""Store one upload and return its public row.
`user` is the uploader's user id; `actor` (the session Principal) is used for the audit row when given.
`purpose` picks the kind (see KINDS; missing is `document`: PDF, PNG or JPEG up to UPLOAD_MAX_BYTES). The type
is decided by content. When ClamAV is configured the complete file is scanned before the dedupe lookup and the
rename. Raises 422 invalid_request (unknown purpose), 413 too_large, 415 unsupported_type, 422 bad_pdf,
422 infected and 503 scan_unavailable. `user` is the uploader's user id; `actor` (the session Principal) is used
for the audit row when given. `source_ref` records the kind as `{"purpose": kind}`.
"""
kind = kind_of(purpose)
root = Path(settings.assets_dir)
root.mkdir(parents=True, exist_ok=True)
limit = int(settings.upload_max_bytes)
limit = kind_limit(settings, kind)
fd, tmp_name = tempfile.mkstemp(prefix=".up-", dir=str(root))
tmp = Path(tmp_name)
keep = False
@@ -203,7 +401,7 @@ def store_upload(
digest = hashlib.sha256()
size = 0
head = b""
mime: str | None = None
checked = False
with os.fdopen(fd, "wb") as out:
while True:
chunk = stream.read(CHUNK)
@@ -211,21 +409,25 @@ def store_upload(
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:
raise too_large(limit)
if not checked 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.")
checked = True
early = _magic(head)
# Refuse a wrong type before the rest is read. CSV has no magic, so it waits for the end.
if early not in kind.mimes and not (early is None and CSV in kind.mimes):
raise _type_error(kind)
digest.update(chunk)
out.write(chunk)
out.flush()
os.fsync(out.fileno())
if size == 0:
raise _type_error(kind)
mime = _content_type(tmp, head, kind)
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.")
raise _type_error(kind)
_scan(conn, settings, tmp, user=user, actor=actor, packet_id=packet_id, size=size, mime=mime)
sha = digest.digest()
existing = _existing(conn, sha, size)
if existing is not None:
@@ -234,8 +436,9 @@ def store_upload(
aid, key, placed = _place(settings, tmp)
keep = True
name = safe_name(original_name)
ref = _with_purpose(source_ref, kind)
with conn.transaction():
row = _insert(conn, (aid, sha, size, mime, name, key, source, source_ref, pages, user, packet_id))
row = _insert(conn, (aid, sha, size, mime, name, key, source, ref, pages, user, packet_id))
audit.append(
conn,
action="asset.uploaded",
@@ -245,10 +448,10 @@ def store_upload(
subject_type="asset",
subject_id=str(aid),
packet_id=packet_id,
detail={"mime": mime, "size": size, "pages": pages, "sha256": sha.hex()},
detail={"mime": mime, "size": size, "pages": pages, "sha256": sha.hex(), "purpose": kind.name},
ip=getattr(actor, "ip", None),
)
return public(dict(row))
placed = None # the row is written: the file stays
except BaseException:
if placed is not None: # the row was not written: do not leave an orphan file
with contextlib.suppress(OSError):
@@ -258,6 +461,8 @@ def store_upload(
if not keep:
with contextlib.suppress(OSError):
tmp.unlink()
quota_after_store(conn, settings)
return public(dict(row))
def store_file(
@@ -276,9 +481,10 @@ def store_file(
The file is consumed: renamed into ASSETS_DIR when its bytes are new, deleted when the same bytes are already
stored (same sha256 and size; that row comes back). Write it under ASSETS_DIR (ASSETS_DIR/tmp) so the rename
stays on one file system. It is hashed in chunks, its first bytes must match `mime`, and it is fsynced before
the rename. The row carries one extra key, `reused`. No audit row: the caller writes its own.
Raises 413 too_large, 415 unsupported_type, 422 bad_pdf.
stays on one file system. It is hashed in chunks, its content must match `mime`, it is fsynced, and it is
scanned when ClamAV is configured, before the dedupe lookup and the rename. The row carries one extra key,
`reused`. No audit row for a stored file (the caller writes its own); a virus hit writes `asset.rejected`.
Raises 413 too_large, 415 unsupported_type, 422 bad_pdf, 422 infected, 503 scan_unavailable.
"""
path = Path(path)
limit = int(settings.upload_max_bytes)
@@ -297,11 +503,13 @@ def store_file(
head += chunk[: HEAD - len(head)]
size += len(chunk)
if size > limit:
raise ApiError(413, "too_large", f"The file is over the {limit // (1024 * 1024)} MB limit.")
raise too_large(limit)
digest.update(chunk)
os.fsync(f.fileno())
if size == 0 or sniff(head) != mime:
found = sniff(head, path, purpose="bom" if mime == CSV else None) if size else None
if found != mime:
raise ApiError(415, "unsupported_type", f"The file is not {EXT.get(mime, mime)}.")
_scan(conn, settings, path, user=uploaded_by, actor=None, packet_id=packet_id, size=size, mime=mime)
sha = digest.digest()
existing = _existing(conn, sha, size)
if existing is not None:
@@ -312,7 +520,6 @@ def store_file(
with conn.transaction():
row = _insert(conn, (aid, sha, size, mime, name, key, source, source_ref, pages, uploaded_by, packet_id))
done = True
return {**row, "reused": False}
except BaseException:
if placed is not None and not done: # the row was not written: do not leave an orphan file
with contextlib.suppress(OSError):
@@ -321,6 +528,109 @@ def store_file(
finally:
with contextlib.suppress(OSError):
path.unlink()
quota_after_store(conn, settings)
return {**row, "reused": False}
# ---------------------------------------------------------------- quota
QUOTA_KIND = "assets_quota"
QUOTA_SUBJECT = "assets"
def _stored_bytes(conn: Any) -> int:
r = conn.execute("SELECT coalesce(sum(size), 0) FROM assets").fetchone()
return int(r[0]) if r else 0
def volume_usage(settings: Settings, conn: Any = None) -> dict[str, Any]:
"""How full the assets store is: {"total", "used", "free", "pct", "warn"} in bytes and percent.
With ASSETS_QUOTA_BYTES at 0 this is the assets volume (shutil.disk_usage). Otherwise the total is the quota
and used is the sum of assets.size (read through `conn`; without one, the bytes on disk under ASSETS_DIR).
`warn` is pct at or over ASSETS_QUOTA_WARN_PCT.
"""
root = Path(settings.assets_dir)
probe = root if root.exists() else root.parent
quota = int(settings.assets_quota_bytes)
if quota <= 0:
du = shutil.disk_usage(probe)
total, used, free = int(du.total), int(du.used), int(du.free)
else:
used = _stored_bytes(conn) if conn is not None else _bytes_on_disk(root)
total = quota
free = max(0, quota - used)
pct = round(used * 100.0 / total, 1) if total > 0 else 0.0
return {"total": total, "used": used, "free": free, "pct": pct, "warn": pct >= int(settings.assets_quota_warn_pct)}
def _bytes_on_disk(root: Path) -> int:
n = 0
for dirpath, _dirs, files in os.walk(root):
for f in files:
with contextlib.suppress(OSError):
n += os.stat(os.path.join(dirpath, f)).st_size
return n
def usage_for(conn: Any, settings: Settings) -> dict[str, Any]:
"""volume_usage, given the connection only when the quota is a byte count (the sum of assets.size needs it)."""
if int(settings.assets_quota_bytes) > 0:
return volume_usage(settings, conn)
return volume_usage(settings)
def open_quota_alert(conn: Any) -> dict[str, Any] | None:
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
"SELECT * FROM notification_events WHERE kind = %s AND coalesce(subject, '') = %s"
" AND resolved_at IS NULL ORDER BY id LIMIT 1",
(QUOTA_KIND, QUOTA_SUBJECT),
).fetchone()
return dict(r) if r else None
def check_quota(conn: Any, settings: Settings) -> dict[str, Any]:
"""Open or resolve the storage alert. Returns the usage with `alert`: "opened", "resolved" or None.
Over the threshold with no open `assets_quota` row: insert one (severity warning, subject assets, detail the
usage) and audit `storage.quota_warning`. Under it with one open: set its resolved_at.
"""
usage = usage_for(conn, settings)
change: str | None = None
with conn.transaction():
open_row = open_quota_alert(conn)
if usage["warn"] and open_row is None:
r = conn.execute(
"INSERT INTO notification_events (kind, severity, subject, detail) VALUES (%s, 'warning', %s, %s)"
" ON CONFLICT (kind, coalesce(subject, '')) WHERE resolved_at IS NULL DO NOTHING RETURNING id",
(QUOTA_KIND, QUOTA_SUBJECT, Jsonb(usage)),
).fetchone()
if r is not None:
change = "opened"
audit.append(
conn,
action="storage.quota_warning",
subject_type="notification",
subject_id=str(r[0]),
detail={**usage, "warn_pct": int(settings.assets_quota_warn_pct)},
)
log.warning(
"assets storage is %.1f%% full (warning at %s%%)", usage["pct"], settings.assets_quota_warn_pct
)
elif not usage["warn"] and open_row is not None:
conn.execute("UPDATE notification_events SET resolved_at = now() WHERE id = %s", (open_row["id"],))
change = "resolved"
log.info("assets storage is back under the warning: %.1f%%", usage["pct"])
return {**usage, "alert": change}
def quota_after_store(conn: Any, settings: Settings) -> None:
"""check_quota after a store. A failure is logged and never fails the store."""
try:
check_quota(conn, settings)
except Exception:
log.exception("the storage quota check failed")
def open_blob(settings: Settings, row: dict[str, Any]) -> Path:
@@ -339,3 +649,9 @@ def download_name(row: dict[str, Any]) -> str:
if not name:
name = str(row["id"]) + EXT.get(str(row["mime"]), "")
return name
def disposition(row: dict[str, Any], download: bool = False) -> str:
"""inline for PDF and images unless a download is asked for; XLSX and CSV are always attachment."""
kind = "attachment" if download or str(row["mime"]) in ATTACHMENT_ONLY else "inline"
return f'{kind}; filename="{download_name(row)}"'
+67 -4
View File
@@ -288,6 +288,23 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
return ch
def is_sub(line: Mapping[str, Any]) -> bool:
"""A sub-assembly line: made on its own packet, so its heats are recorded there."""
return str(line.get("sub") or "").strip().upper() == "Y"
def needs_heat(line: Mapping[str, Any]) -> bool:
"""The old needsHeat for a pulled line: not N/A, not a sub-assembly, pieces issued and no heat."""
if line.get("na") or is_sub(line) or not line.get("pulled"):
return False
if str(line.get("heat") or "").strip():
return False
try:
return float(line.get("issued") or 0) > 0
except (TypeError, ValueError):
return False
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"]]
@@ -311,6 +328,7 @@ def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
heat_map=row["heat_map"],
bom_total=len(live_bom),
bom_ready=sum(1 for b in live_bom if b["pulled"] or b["id"] in ch.shorts),
bom_missing_heat=sum(1 for b in live_bom if needs_heat(b)),
welds=len(ch.welds),
checklist_complete=stage is not None and checklists.checklist_complete(stage["checklist"], weld),
filler_set=stage is not None and bool((stage["filler"] or "").strip()),
@@ -661,6 +679,9 @@ def build(
if seal:
for k in SEAL_EXCLUDE:
out.pop(k, None)
# `sub` joined the BOM lines in phase 4; seals made before that do not cover it.
for b in out.get("bom") or []:
b.pop("sub", None)
return out
@@ -681,6 +702,7 @@ def _bom(b: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
"loc",
"pulled",
"src",
"sub",
"row_version",
)
out = {k: b[k] for k in keys}
@@ -788,7 +810,9 @@ SELECT p.id, p.version, p.unit_id, u.lot, pr.number || u.lot AS unit_number, pr.
p.visual_closed_at, p.visual_cancelled_at, p.wo_gone_at,
EXISTS (SELECT 1 FROM change_orders c WHERE c.packet_id = p.id AND c.review = 'pending'
AND c.origin IN ('visual', 'drawing')) AS co_pending,
(SELECT w.stage::text FROM packet_wh w WHERE w.packet_id = p.id AND w.closed_at IS NULL) AS wh_stage,
pw.stage::text AS wh_stage, pw.by AS wh_by, whu.display_name AS wh_by_name, pw.at AS wh_at,
pw.reason AS wh_reason, pw.note AS wh_note, pw.auto AS wh_auto, p.wh_sent_at, p.visual_rel,
bc.bom_total, bc.bom_pulled, bc.bom_missing_heat,
(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,
@@ -801,6 +825,16 @@ 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
LEFT JOIN packet_wh pw ON pw.packet_id = p.id AND pw.closed_at IS NULL
LEFT JOIN users whu ON whu.id = pw.by
LEFT JOIN LATERAL (
SELECT count(*) FILTER (WHERE NOT b.na) AS bom_total,
count(*) FILTER (WHERE NOT b.na AND (b.pulled OR EXISTS (SELECT 1 FROM bom_shorts s
WHERE s.bom_line_id = b.id AND s.closed_at IS NULL))) AS bom_pulled,
count(*) FILTER (WHERE NOT b.na AND upper(btrim(coalesce(b.sub, ''))) <> 'Y' AND b.pulled
AND coalesce(b.issued, 0) > 0 AND btrim(coalesce(b.heat, '')) = '') AS bom_missing_heat
FROM bom_lines b WHERE b.packet_id = p.id
) bc ON true
"""
FLAG_ON = ("1", "true", "yes", "any", "flagged")
@@ -842,11 +876,27 @@ def card_state(r: Mapping[str, Any], names: Mapping[str, str]) -> policy.PacketS
)
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 (1|0|hand|hand_upload|
<kind>), co (pending), limit, removed."""
def wh_card(r: Mapping[str, Any]) -> dict[str, Any] | None:
"""The current warehouse stage row of a card, in the read model's `wh` shape."""
if r["wh_stage"] is None:
return None
return {
"stage": r["wh_stage"],
"by": None if r["wh_by"] is None else {"id": str(r["wh_by"]), "display_name": r["wh_by_name"]},
"at": r["wh_at"],
"reason": r["wh_reason"],
"note": r["wh_note"],
"auto": bool(r["wh_auto"]),
}
def card_where(filters: Mapping[str, Any]) -> tuple[list[str], list[Any]]:
"""The WHERE terms and parameters of the card filters, over CARD_SQL's aliases (p, u, pr)."""
where: list[str] = []
params: list[Any] = []
if filters.get("ids") is not None:
where.append("p.id = ANY(%s)")
params.append([int(i) for i in filters["ids"]])
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()]
@@ -895,6 +945,13 @@ def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[s
" AND wo.wo_base ILIKE %s))"
)
params += [pat] * 10
return where, params
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 (1|0|hand|hand_upload|
<kind>), co (pending), ids (packet ids), limit, removed."""
where, params = card_where(filters)
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))
@@ -943,6 +1000,12 @@ def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[s
"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"],
"wh": wh_card(r),
"wh_sent_at": r["wh_sent_at"],
"visual_rel": r["visual_rel"],
"bom_total": int(r["bom_total"] or 0),
"bom_pulled": int(r["bom_pulled"] or 0),
"bom_missing_heat": int(r["bom_missing_heat"] or 0),
"due_effective": r["due_effective"],
"updated_at": r["updated_at"],
"holder": policy.holder_name(state, r["dept_name"], r["station_name"]),
+37 -1
View File
@@ -57,7 +57,7 @@ LABELS: dict[policy.Action, str] = {
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.WH_RELEASE: "Released to supervisor",
A.SEND_BACK_TO_WAREHOUSE: "Sent back to the warehouse",
A.ASSIGN: "Sent to a station",
A.UNASSIGN: "Taken off the station",
@@ -528,6 +528,41 @@ def _seal_key(w: Write) -> bytes:
return key
def qty_text(value: Any) -> str:
"""A quantity without trailing zeros: 4, 2.5."""
if value is None:
return "0"
try:
f = float(value)
except (TypeError, ValueError):
return str(value)
return str(int(f)) if f.is_integer() else f"{f:g}"
def _release_text(w: Write) -> None:
"""The old release label and note.
Label "Released to supervisor", or "... with {n} short"; note "{heats} heats recorded", plus " · SHORT: ..."
when shorts are open.
"""
lines = packets.rows(
w.conn,
"SELECT b.item, b.heat, s.qty_open, s.reason FROM bom_lines b LEFT JOIN bom_shorts s"
" ON s.bom_line_id = b.id AND s.closed_at IS NULL WHERE b.packet_id = %s ORDER BY b.item",
(w.id,),
)
heats = sum(1 for b in lines if str(b["heat"] or "").strip())
shorts = [b for b in lines if b["qty_open"] is not None]
note = f"{heats} heats recorded"
if shorts:
note += " · SHORT: " + ", ".join(
f"item {b['item']} ×{qty_text(b['qty_open'])} ({b['reason'] or ''})" for b in shorts
)
w.log(f"{LABELS[A.WH_RELEASE]} with {len(shorts)} short", note)
else:
w.log(LABELS[A.WH_RELEASE], note)
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:
@@ -569,6 +604,7 @@ def apply_transition(w: Write) -> None:
_set_packet(w, "return_note = %s", (reason,))
_close_stage(w)
elif a is A.WH_RELEASE:
_release_text(w)
_wh_close(w)
_close_stage(w)
_open_stage(w, to_dept)
+130
View File
@@ -0,0 +1,130 @@
"""Virus scan through clamd (ClamAV), optional. Off when CLAMAV_HOST is empty.
The file is streamed to clamd with zINSTREAM: the command, then chunks each led by a 4-byte big-endian length, then a
zero length. clamd answers `stream: OK` or `stream: <signature> FOUND`. When a scanner is configured and cannot be
reached, the upload is refused (fail closed).
"""
from __future__ import annotations
import logging
import socket
import struct
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..api.errors import ApiError
log = logging.getLogger(__name__)
CHUNK = 256 * 1024
REPLY_MAX = 4096
MSG_INFECTED = "That file failed the virus scan and was not stored."
MSG_UNAVAILABLE = "The virus scanner is not reachable. Try again in a minute."
class ScanError(Exception):
"""The scanner could not be reached or answered badly."""
@dataclass(frozen=True)
class ScanResult:
clean: bool
signature: str | None
class ClamdScanner:
def __init__(self, host: str, port: int = 3310, timeout: float = 30.0):
self.host = host
self.port = int(port)
self.timeout = float(timeout)
def _connect(self, timeout: float | None = None) -> socket.socket:
try:
return socket.create_connection((self.host, self.port), timeout=timeout or self.timeout)
except OSError as e:
raise ScanError(f"can't reach clamd at {self.host}:{self.port}: {type(e).__name__}") from None
@staticmethod
def _reply(sock: socket.socket) -> str:
"""Read one NUL-terminated reply (or up to the close)."""
buf = b""
while b"\0" not in buf and len(buf) < REPLY_MAX:
part = sock.recv(1024)
if not part:
break
buf += part
return buf.split(b"\0", 1)[0].decode("utf-8", "replace").strip()
def ping(self, timeout: float | None = None) -> bool:
"""True when clamd answers PING with PONG."""
try:
with self._connect(timeout) as sock:
sock.sendall(b"zPING\0")
return self._reply(sock) == "PONG"
except (ScanError, OSError) as e:
log.info("clamd ping failed: %s", type(e).__name__)
return False
def scan_path(self, path: Path) -> ScanResult:
"""Stream the file to clamd. Raises ScanError when clamd is down or its answer is not OK or FOUND."""
try:
with self._connect() as sock, Path(path).open("rb") as f:
sock.sendall(b"zINSTREAM\0")
while True:
chunk = f.read(CHUNK)
if not chunk:
break
sock.sendall(struct.pack(">I", len(chunk)) + chunk)
sock.sendall(struct.pack(">I", 0))
reply = self._reply(sock)
except ScanError:
raise
except OSError as e:
raise ScanError(f"clamd scan failed: {type(e).__name__}") from None
return parse_reply(reply)
def parse_reply(reply: str) -> ScanResult:
"""`stream: OK` is clean; `stream: <sig> FOUND` is a hit; anything else is a ScanError."""
text = reply.strip()
if text.startswith("stream:"):
text = text[len("stream:") :].strip()
if text == "OK":
return ScanResult(clean=True, signature=None)
if text.endswith(" FOUND"):
sig = text[: -len(" FOUND")].strip()[:200]
return ScanResult(clean=False, signature=sig or "unknown")
raise ScanError(f"clamd answered: {text[:200]}")
def scanner_for(settings: Any) -> ClamdScanner | None:
"""The configured scanner, or None when CLAMAV_HOST is empty."""
host = str(getattr(settings, "clamav_host", "") or "").strip()
if not host:
return None
return ClamdScanner(
host,
int(getattr(settings, "clamav_port", 3310)),
float(getattr(settings, "clamav_timeout_seconds", 30.0)),
)
def check(settings: Any, path: Path) -> ScanResult | None:
"""Scan a complete file. None when no scanner is configured.
Raises 422 infected (with the signature) on a hit and 503 scan_unavailable when the scanner fails.
"""
scanner = scanner_for(settings)
if scanner is None:
return None
try:
result = scanner.scan_path(path)
except ScanError as e:
log.warning("virus scan unavailable: %s", e)
raise ApiError(503, "scan_unavailable", MSG_UNAVAILABLE) from None
if not result.clean:
log.warning("upload refused by the virus scan: %s", result.signature)
raise ApiError(422, "infected", MSG_INFECTED, extra={"signature": result.signature})
return result
+2
View File
@@ -106,6 +106,7 @@ def create_app(
packets,
projects,
units,
warehouse,
)
from .api import events as events_api
@@ -123,6 +124,7 @@ def create_app(
assets,
jobs,
feed_settings,
warehouse,
):
app.include_router(module.router)
app.include_router(assets.blob_router)
@@ -0,0 +1,24 @@
-- 0005_warehouse: the notification placeholder. Phase 4 writes the storage quota alert here; phase 9 builds the
-- delivery (mail, the bell) on this table. Runs as ps_migrate; 0001's default privileges give ps_app SELECT,
-- INSERT, UPDATE, DELETE on it.
-- One row per alert. An alert is open until resolved_at is set. Acknowledging it does not resolve it: the
-- condition that raised it has to clear.
CREATE TABLE notification_events (
id bigserial PRIMARY KEY,
kind text NOT NULL,
severity text NOT NULL DEFAULT 'warning' CHECK (severity IN ('info', 'warning', 'critical')),
subject text,
detail jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz,
acknowledged_at timestamptz,
acknowledged_by uuid REFERENCES users (id)
);
-- At most one open alert per kind and subject.
CREATE UNIQUE INDEX notification_events_open ON notification_events (kind, coalesce(subject, ''))
WHERE resolved_at IS NULL;
CREATE INDEX notification_events_created ON notification_events (created_at DESC);
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
+3 -1
View File
@@ -162,7 +162,9 @@ RULES: dict[Action, Rule] = {
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.WH_RELEASE: Rule(
Who.WH, _s("warehouse"), "ready", co_hold=True, preconditions=("bom_all_pulled_or_short", "heats_recorded")
),
A.SEND_BACK_TO_WAREHOUSE: Rule(
Who.DEPT_LEAD, _s("ready"), "warehouse", preconditions=_REASON, reason_required=True
),
@@ -19,6 +19,9 @@ 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.",
"heats_recorded": (
"Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability."
),
"short_open": "No part is short on this packet.",
"welder_set": "Pick a station and a welder.",
"checklist_complete": "Finish the checklist first.",
@@ -64,6 +67,11 @@ def bom_all_pulled_or_short(state: PacketState, extra: Mapping[str, Any]) -> str
return _fail("bom_all_pulled_or_short", state.bom_ready >= state.bom_total)
def heats_recorded(state: PacketState, extra: Mapping[str, Any]) -> str | None:
"""Every pulled line that issued pieces has a heat, unless it is N/A or a sub-assembly."""
return _fail("heats_recorded", state.bom_missing_heat <= 0)
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)
@@ -131,6 +139,7 @@ PRECONDITIONS: dict[str, Precondition] = {
"heat_map_answered": heat_map_answered,
"drawing_attached": drawing_attached,
"bom_all_pulled_or_short": bom_all_pulled_or_short,
"heats_recorded": heats_recorded,
"short_open": short_open,
"welder_set": welder_set,
"checklist_complete": checklist_complete,
+1
View File
@@ -49,6 +49,7 @@ class PacketState:
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
bom_missing_heat: int = 0 # pulled lines with pieces issued and no heat (not n/a, not a sub-assembly)
welds: int = 0
checklist_complete: bool = False # every key of the department checklist is done (empty list is complete)
filler_set: bool = False
+11
View File
@@ -102,6 +102,15 @@ class Settings(BaseSettings):
pin_min_digits: int = Field(default=6, ge=4)
pin_max_digits: int = Field(default=12, ge=4)
upload_max_bytes: int = Field(default=25 * 1024 * 1024, ge=1024)
upload_max_bytes_bom: int = Field(default=5 * 1024 * 1024, ge=1024)
# ClamAV (clamd). Empty host means no virus scan. When set, an unreachable scanner refuses uploads.
clamav_host: str = ""
clamav_port: int = Field(default=3310, ge=1, le=65535)
clamav_timeout_seconds: float = Field(default=30.0, gt=0, le=600)
# The storage alert: warn at this percent of the assets volume, or of ASSETS_QUOTA_BYTES when that is set.
assets_quota_warn_pct: int = Field(default=85, ge=1, le=99)
assets_quota_bytes: int = Field(default=0, ge=0)
# Where the worker reads VISUAL and M-Files from. `fixture` reads synthetic files under FIXTURE_DIR (CI and tests).
feed_source: FeedSource = "visualkit"
@@ -119,6 +128,8 @@ class Settings(BaseSettings):
for key in list(data):
if key.lower() in ("stack", "feed_source", "drawing_source") and isinstance(data[key], str):
data[key] = data[key].strip().lower()
elif key.lower() == "clamav_host" and isinstance(data[key], str):
data[key] = data[key].strip()
return data
@field_validator("log_level")
@@ -1,10 +1,12 @@
"""Housekeeping, nightly at 03:30 site time: prune the VISUAL change rows and gone cache rows, old job runs and
old claimed run requests. It never touches packets, the audit log or anything append-only."""
old claimed run requests. It never touches packets, the audit log or anything append-only. Then it checks the assets
storage against its warning threshold (assets.check_quota opens or resolves the alert)."""
from __future__ import annotations
from datetime import datetime, time
from ...domain import assets
from ...feed.config import FeedSettings
from . import Counts, JobContext, JobSpec, seconds_until
@@ -50,6 +52,16 @@ def run(ctx: JobContext) -> Counts:
for name, sql, days in STEPS:
with ctx.conn.transaction():
counts[name] = ctx.conn.execute(sql, (days,)).rowcount
try:
usage = assets.check_quota(ctx.conn, ctx.settings)
except Exception as e: # the storage check never fails the pruning
ctx.log.exception("the storage quota check failed")
counts["quota_error"] = type(e).__name__
else:
counts["quota_pct"] = usage["pct"]
counts["quota_warn"] = usage["warn"]
if usage["alert"]:
counts["quota_alert"] = usage["alert"]
return counts
+3 -2
View File
@@ -141,12 +141,13 @@ test.describe.serial("VISUAL feed", () => {
test("wh.wade releases it to the shop", async ({ page }) => {
await signInPassword(page, "wh.wade");
let pkt = await getJson(page.request, `/api/packets/${packetId}`);
// Release needs every material line pulled or short: pull each one, as the warehouse would
// Release needs every material line pulled or short, with a heat on every pulled line: pull each one
// with a heat, as the warehouse would
for (const line of (pkt.bom ?? []) as Json[]) {
if (line.pulled || line.na) continue;
const pr = await page.request.patch(`/api/packets/${packetId}/bom/${line.item}`, {
headers: { ...writeHeaders(page), "If-Match": String(line.row_version) },
data: { pulled: true },
data: { pulled: true, heat: `HT-${String(line.item)}` },
});
expect(pr.status(), await pr.text()).toBe(200);
}
+199
View File
@@ -0,0 +1,199 @@
import { expect, test, type Page } from "@playwright/test";
import { signInPassword } from "./helpers";
import { L, card, column, drag, getJson, makeWarehousePacket, tinyPng, whStage, type MadePacket } from "./whhelpers";
// Phase 4 warehouse scenarios on the CI stack. Runs after the other specs on the one worker.
// clerk.clara makes two packets by API (a hand draft on a fresh unit, BOM lines 1 to 3, a tiny PDF drawing,
// heat map answered, sent to the warehouse). "warehouse board": wh.wade moves the first one across the board
// with the card buttons, the hold sheet and drag, and a drop on Released to shop is refused with a toast;
// sup.wes sees the pill and the line but no warehouse buttons. "pull flow": wh.wade pulls the second one on
// its Materials tab (Pulled tick, heat with Enter, a short, an MTR), releases it, then receives the short
// from the Open shorts table on /warehouse.
// Every label used here is in L (whhelpers.ts), copied from the phase 4 design contract.
const RUN = Date.now().toString(36).toUpperCase().slice(-5);
const PROJECT = `W4${RUN}`;
const BOARD_TITLE = `Board spool ${RUN}`;
const PULL_TITLE = `Pull spool ${RUN}`;
const HOLD_REASON = "Waiting on purchasing / PO";
const HOLD_NOTE = "PO 4471 due Fri";
let boardPkt: MadePacket;
let pullPkt: MadePacket;
function row(page: Page, description: string) {
return page.getByRole("row").filter({ hasText: description }).first();
}
test.describe.serial("warehouse", () => {
test.setTimeout(180_000);
test("clerk.clara makes two warehouse packets by API", async ({ page }) => {
await signInPassword(page, "clerk.clara");
boardPkt = await makeWarehousePacket(page, PROJECT, BOARD_TITLE);
pullPkt = await makeWarehousePacket(page, PROJECT, PULL_TITLE);
expect(boardPkt.id).toBeGreaterThan(0);
expect(pullPkt.id).toBeGreaterThan(0);
});
test("warehouse board", async ({ page }) => {
await signInPassword(page, "wh.wade");
await page.goto("/warehouse");
await expect(page.getByRole("heading", { name: L.pageHead, exact: true })).toBeVisible();
await expect(page.getByText(L.eyebrow, { exact: true }).first()).toBeVisible();
for (const title of [L.colIncoming, L.colPulling, L.colHold, L.colPulled, L.colReleased]) {
await expect(column(page, title)).toBeVisible();
}
// Waiting to pull, then Start pulling
let c = card(column(page, L.colIncoming), BOARD_TITLE);
await expect(c).toBeVisible();
await expect(c.getByText(L.pillWaiting, { exact: true })).toBeVisible();
await expect(c.getByText(/^Waiting .*not started yet$/)).toBeVisible();
await c.getByRole("button", { name: L.startPulling, exact: true }).click();
c = card(column(page, L.colPulling), BOARD_TITLE);
await expect(c).toBeVisible();
await expect(c.getByText(L.pillPulling, { exact: true })).toBeVisible();
await expect(c.getByText(/^Being pulled by Wade Warehouse/)).toBeVisible();
// Hold with a reason and a note
await c.getByRole("button", { name: L.hold, exact: true }).click();
const sheet = page.getByRole("dialog", { name: L.holdSheet });
await expect(sheet).toBeVisible();
await sheet.getByRole("radio", { name: HOLD_REASON }).check();
await sheet.getByLabel(L.holdNote).fill(HOLD_NOTE);
await sheet.getByRole("button", { name: L.putOnHold, exact: true }).click();
await expect(sheet).toBeHidden();
c = card(column(page, L.colHold), BOARD_TITLE);
await expect(c).toBeVisible();
await expect(c.getByText(L.pillHold, { exact: true })).toBeVisible();
await expect(c.getByText(`On hold: ${HOLD_REASON} · ${HOLD_NOTE}`, { exact: false })).toBeVisible();
expect(await whStage(page, boardPkt.id)).toBe("hold");
// Resume pulling by button
await c.getByRole("button", { name: L.resumePulling, exact: true }).click();
c = card(column(page, L.colPulling), BOARD_TITLE);
await expect(c).toBeVisible();
expect(await whStage(page, boardPkt.id)).toBe("pulling");
// Drag from Being pulled to Pulled, ready for production. Lines are still unticked: confirm it.
await drag(page, c, column(page, L.colPulled));
const confirm = page.getByRole("dialog", { name: L.notAllPulled });
await expect(confirm).toBeVisible();
await confirm.getByRole("button", { name: L.markPulled, exact: true }).click();
await expect(confirm).toBeHidden();
c = card(column(page, L.colPulled), BOARD_TITLE);
await expect(c).toBeVisible();
await expect(c.getByText(L.pillPulled, { exact: true })).toBeVisible();
await expect.poll(() => whStage(page, boardPkt.id)).toBe("pulled");
await expect(page).toHaveURL(/\/warehouse/); // a drag is not a click: the packet did not open
// A drop on Released to shop is refused with the toast; the card stays where it was
await drag(page, c, column(page, L.colReleased));
await expect(page.getByText(L.dropReleasedToast)).toBeVisible();
await expect(card(column(page, L.colPulled), BOARD_TITLE)).toBeVisible();
await expect(card(column(page, L.colReleased), BOARD_TITLE)).toHaveCount(0);
const pkt = await getJson(page.request, `/api/packets/${boardPkt.id}`);
expect(pkt.status).toBe("warehouse");
expect(pkt.wh.stage).toBe("pulled");
});
test("sup.wes sees the warehouse pill and line but no warehouse buttons", async ({ page }) => {
await signInPassword(page, "sup.wes");
await page.goto(`/packets/${boardPkt.id}`);
await expect(page.getByText(L.pillPulled, { exact: true }).first()).toBeVisible();
await expect(page.getByText(/^Pulled by Wade Warehouse .*ready for production$/).first()).toBeVisible();
for (const name of [L.startPulling, L.markPulled, L.resumePulling, L.backToPulling, L.putOnHold, L.sendBack]) {
await expect(page.getByRole("button", { name, exact: true })).toHaveCount(0);
}
await expect(page.getByRole("button", { name: new RegExp(`^${L.releaseToSupervisor}`) })).toHaveCount(0);
});
test("pull flow", async ({ page }) => {
await signInPassword(page, "wh.wade");
await page.goto(`/packets/${pullPkt.id}`);
await page
.getByRole("tab", { name: new RegExp(`^${L.materialsTab}`) })
.or(page.getByRole("button", { name: new RegExp(`^${L.materialsTab}`) }))
.first()
.click();
const r1 = row(page, "4in pipe SCH40");
const r2 = row(page, "4in flange");
await expect(r1).toBeVisible();
// Tick line 1 Pulled: the server starts pulling
await page.getByLabel(L.line(1, "pulled"), { exact: true }).check();
await expect(page.getByText(L.pillPulling, { exact: true }).first()).toBeVisible();
await expect.poll(() => whStage(page, pullPkt.id)).toBe("pulling");
// A heat and Enter: saved, and the focus moves to line 2's heat input (a scanner sends Enter)
const h1 = page.getByLabel(L.line(1, "heat"), { exact: true });
await expect(h1).toHaveAttribute("placeholder", L.heatPlaceholder);
await h1.fill("HT-E2E-1");
await h1.press("Enter");
await expect(page.getByLabel(L.line(2, "heat"), { exact: true })).toBeFocused();
await expect
.poll(async () => {
const p = await getJson(page.request, `/api/packets/${pullPkt.id}`);
return (p.bom as Array<Record<string, any>>).find((b) => b.item === 1)?.heat ?? null;
})
.toBe("HT-E2E-1");
// Record a short on line 2: 1 of 2, Not in stock, with the heat of the piece that was pulled
await r2
.getByRole("button", { name: L.recordShort, exact: true })
.or(r2.getByRole("button", { name: L.shortButton, exact: true }))
.first()
.click();
const ss = page.getByRole("dialog", { name: L.recordShortSheet });
await expect(ss).toBeVisible();
await ss.getByLabel(L.qtyShort).fill("1");
await ss.getByLabel(L.reason, { exact: true }).selectOption("Not in stock");
await ss.getByLabel(L.heatForPulled).fill("HT-E2E-2");
await ss.getByRole("button", { name: L.saveShort, exact: true }).click();
await expect(ss).toBeHidden();
await expect(r2.getByText(/short/i).first()).toBeVisible();
// A small PNG as the MTR on line 1
await page
.getByLabel(L.line(1, "MTR file"), { exact: true })
.setInputFiles({ name: "mtr-photo.png", mimeType: "image/png", buffer: tinyPng() });
await expect(r1.getByRole("button", { name: "MTR", exact: true })).toBeVisible();
await expect(r1.getByText("Replace", { exact: true })).toBeVisible();
// Line 3 is not pulled: Release to supervisor is disabled with the server's reason under it
const release = page.getByRole("button", { name: new RegExp(`^${L.releaseToSupervisor}`) });
await expect(release).toBeDisabled();
await expect(page.getByText(L.msgPull).first()).toBeVisible();
// Tick line 3 and give it a heat
await page.getByLabel(L.line(3, "pulled"), { exact: true }).check();
const h3 = page.getByLabel(L.line(3, "heat"), { exact: true });
await expect(h3).toBeEnabled();
await h3.fill("HT-E2E-3");
await h3.press("Enter");
await expect(release).toBeEnabled();
await expect(release).toHaveText(new RegExp(`${L.releaseToSupervisor} \\(1 short\\)`));
await release.click();
await expect(page.getByText(L.readyToAssign).first()).toBeVisible();
const pkt = await getJson(page.request, `/api/packets/${pullPkt.id}`);
expect(pkt.status).toBe("ready");
expect(pkt.open_shorts).toBe(1);
// Receive the short from the Open shorts table on the warehouse home
await page.goto("/warehouse");
const shortRow = page.getByRole("row").filter({ hasText: pullPkt.unitNumber || PULL_TITLE }).filter({ hasText: "Not in stock" });
await expect(shortRow.first()).toBeVisible();
await shortRow.first().getByRole("button", { name: L.receive, exact: true }).click();
const rs = page.getByRole("dialog", { name: L.receiveSheet });
await expect(rs).toBeVisible();
await rs.getByLabel(L.qtyReceived).fill("1");
await rs.getByLabel(L.heat, { exact: true }).fill("HT-E2E-R");
await rs.getByRole("button", { name: L.receive, exact: true }).click();
await expect(rs).toBeHidden();
await expect(page.getByText(L.noShorts)).toBeVisible();
const after = await getJson(page.request, `/api/packets/${pullPkt.id}`);
expect(after.open_shorts).toBe(0);
});
});
+202
View File
@@ -0,0 +1,202 @@
import { expect, type APIRequestContext, type Locator, type Page } from "@playwright/test";
import { dcUser } from "./helpers";
// Helpers for the phase 4 warehouse scenarios: packets made through the API, and the board locators.
// The labels are the exact texts of the phase 4 design contract (Frontend (C)). The frontend matches them.
export type Json = Record<string, any>;
export const L = {
pageHead: "Warehouse",
eyebrow: "Parts pull",
colIncoming: "Waiting to pull",
colPulling: "Being pulled",
colHold: "On hold",
colPulled: "Pulled, ready for production",
colReleased: "Released to shop (3 days)",
pillWaiting: "WH · waiting",
pillPulling: "WH · being pulled",
pillHold: "WH · on hold",
pillPulled: "WH · pulled",
startPulling: "Start pulling",
hold: "Hold",
markPulled: "Mark pulled",
resumePulling: "Resume pulling",
backToPulling: "Back to pulling",
releaseCard: "Release…",
putOnHold: "Put on hold",
cancel: "Cancel",
dropReleasedToast: "Open the packet and use Release to supervisor. It checks heats and shorts first.",
sendBack: "Send back to Clerical",
releaseToSupervisor: "Release to supervisor",
materialsTab: "Materials",
heatPlaceholder: "Scan / type heat",
recordShort: "Record short",
shortButton: "Short",
line: (n: number, what: "pulled" | "issued" | "location" | "heat" | "N/A" | "MTR file") => `Line ${n} ${what}`,
qtyShort: "Qty short",
reason: "Reason",
heatForPulled: "Heat no. for the pulled pieces",
holdSheet: "Put on hold",
holdNote: /^Note/,
recordShortSheet: "Record short",
receiveSheet: "Receive short part",
notAllPulled: "Not everything is pulled yet",
saveShort: "Save short",
receive: "Receive",
qtyReceived: "Qty received",
heat: "Heat",
note: "Note",
openShorts: "Open shorts",
noShorts: "No open shorts. When a part isn't on the shelf, mark it Short on the pull and it lands here.",
readyToAssign: "Ready to assign",
msgPull: "Pull every material line or mark it short first.",
msgHeat: "Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability.",
} as const;
export function writeHeaders(page: Page): Record<string, string> {
const base = new URL(page.url()).origin;
return { Origin: base, "X-Requested-With": "PacketStation" };
}
export async function getJson(req: APIRequestContext, url: string): Promise<Json> {
const r = await req.get(url);
expect(r.status(), `${url}: ${await r.text()}`).toBe(200);
return (await r.json()) as Json;
}
async function sendJson(page: Page, method: "post" | "patch", url: string, data: Json, status = 200): Promise<Json> {
const r = await page.request[method](url, { headers: writeHeaders(page), data });
expect(r.status(), `${method} ${url}: ${await r.text()}`).toBe(status);
return (await r.json()) as Json;
}
/** A minimal valid one-page PDF, built from raw bytes with a correct xref table. */
export 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");
}
/** A 1x1 PNG. */
export function tinyPng(): Buffer {
return Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
"base64",
);
}
export interface MadePacket {
id: number;
title: string;
unitNumber: string;
}
/**
* As the signed-in Clerical user: a project with one unit, a hand draft with BOM lines 1 to 3, a drawing
* attached with a password signature, the heat map answered, sent to the warehouse.
*/
export async function makeWarehousePacket(page: Page, projectNumber: string, title: string): Promise<MadePacket> {
const projects = (await getJson(page.request, "/api/projects?status=all")) as unknown as Json[];
let project = projects.find((p) => p.number === projectNumber);
if (!project) {
project = await sendJson(page, "post", "/api/projects", { number: projectNumber, name: "CI warehouse" }, 201);
}
const units = (await sendJson(page, "post", `/api/projects/${project.id}/units`, {}, 201)) as unknown as Json[];
const unit = units[0];
const draft = await sendJson(
page,
"post",
"/api/packets",
{
unit_id: unit.id,
dept_code: "03",
op: "WELD-PART",
title,
customer: "CI Customer",
drawing: `D-${projectNumber}`,
rev: "A",
work_orders: [],
bom: [
{ item: 1, qty: 1, part_id: "PIPE-4", description: "4in pipe SCH40" },
{ item: 2, qty: 2, part_id: "FLG-4", description: "4in flange" },
{ item: 3, qty: 1, part_id: "STUD-58", description: "5/8 stud bolt" },
],
},
201,
);
const up = await page.request.post("/api/assets", {
headers: writeHeaders(page),
multipart: {
file: { name: `${projectNumber}.pdf`, mimeType: "application/pdf", buffer: onePagePdf() },
purpose: "drawing",
},
});
expect(up.status(), await up.text()).toBe(201);
const asset = (await up.json()) as Json;
await sendJson(page, "post", `/api/packets/${draft.id}/pages`, {
asset_id: asset.id,
type: "drawing",
sign: { factor: "password", secret: dcUser("clerk.clara").password },
});
await sendJson(page, "patch", `/api/packets/${draft.id}`, { set: { heat_map: true }, base: {} });
const pkt = await getJson(page.request, `/api/packets/${draft.id}`);
const sent = await sendJson(page, "post", `/api/packets/${draft.id}/transitions`, {
action: "send_to_warehouse",
expect: { status: pkt.status, dept: pkt.dept.code },
});
expect(sent.status).toBe("warehouse");
return { id: Number(draft.id), title, unitNumber: String(unit.number ?? pkt.unit?.number ?? "") };
}
/** A board column by its title: a region named by the title, or a block that has it as a heading. */
export function column(page: Page, title: string): Locator {
return page
.getByRole("region", { name: title, exact: true })
.or(page.locator("section, [data-whcol]").filter({ has: page.getByRole("heading", { name: title, exact: true }) }))
.first();
}
/** A packet card, found by its unique title, anywhere under `scope`. */
export function card(scope: Page | Locator, title: string): Locator {
// Warehouse cards are role=group named "{job}, {stage name}"; the released column shows plain cards.
return scope
.getByRole("group")
.filter({ hasText: title })
.or(scope.locator(".card").filter({ hasText: title }))
.first();
}
/** Drag with the pointer from the middle of `from` to the middle of `to`, in steps (a real drag, not a click). */
export async function drag(page: Page, from: Locator, to: Locator): Promise<void> {
await from.scrollIntoViewIfNeeded();
const a = await from.boundingBox();
const b = await to.boundingBox();
if (!a || !b) throw new Error("drag: element not visible");
// Grab the card on its title line, away from its buttons.
await page.mouse.move(a.x + a.width / 2, a.y + 12);
await page.mouse.down();
await page.mouse.move(a.x + a.width / 2 + 15, a.y + 20, { steps: 4 });
await page.mouse.move(b.x + b.width / 2, b.y + Math.min(b.height / 2, 120), { steps: 16 });
await page.mouse.up();
}
export async function whStage(page: Page, id: number): Promise<string | null> {
const p = await getJson(page.request, `/api/packets/${id}`);
return p.wh ? String(p.wh.stage) : null;
}
+168
View File
@@ -0,0 +1,168 @@
"""A fake clamd for tests: a threading TCP server that speaks the part of the clamd protocol Packet Station uses.
PING answers PONG. INSTREAM reads 4-byte big-endian chunk lengths and the chunks until a zero length, then answers
`stream: OK`, or `stream: Eicar-Test-Signature FOUND` when the bytes hold the EICAR test string. A command sent as
`z<CMD>\0` gets its answer ended by NUL, `n<CMD>\n` or a bare `<CMD>\n` by a newline.
`FakeClam(refuse=True)` reserves a port and closes it, so a connection is refused. `mode="garbage"` answers
INSTREAM with a line that is not a clamd answer.
"""
from __future__ import annotations
import socket
import socketserver
import struct
import threading
from typing import Any
# Built from parts so the source file itself does not hold the test string (a desktop scanner would flag it).
EICAR = rb"X5O!P%@AP[4\PZX54(P^)7CC)7}$" + b"EICAR-STANDARD-" + b"ANTIVIRUS-TEST-FILE!$H+H*"
SIGNATURE = "Eicar-Test-Signature"
def _read_exact(sock: socket.socket, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("client closed the stream early")
buf += chunk
return buf
def _read_command(sock: socket.socket) -> tuple[str, bytes]:
"""The command name and the terminator its answer uses."""
first = _read_exact(sock, 1)
if first == b"z":
end = b"\0"
elif first == b"n":
end = b"\n"
else:
end = b"\n"
buf = b"" if first in (b"z", b"n") else first
while True:
c = _read_exact(sock, 1)
if c in (b"\0", b"\n"):
break
buf += c
if len(buf) > 64:
break
return buf.decode("ascii", "replace"), end
class _Handler(socketserver.BaseRequestHandler):
server: _Server
def handle(self) -> None:
sock: socket.socket = self.request
sock.settimeout(10)
try:
cmd, end = _read_command(sock)
except (ConnectionError, OSError):
return
fake = self.server.fake
with fake.lock:
fake.commands.append(cmd)
if cmd == "PING":
sock.sendall(b"PONG" + end)
return
if cmd == "INSTREAM":
data = b""
try:
while True:
(n,) = struct.unpack(">I", _read_exact(sock, 4))
if n == 0:
break
data += _read_exact(sock, n)
except (ConnectionError, OSError):
return
with fake.lock:
fake.scanned.append(data)
if fake.mode == "garbage":
sock.sendall(b"what is this" + end)
elif EICAR in data:
sock.sendall(f"stream: {SIGNATURE} FOUND".encode() + end)
else:
sock.sendall(b"stream: OK" + end)
return
sock.sendall(b"UNKNOWN COMMAND" + end)
class _Server(socketserver.ThreadingTCPServer):
daemon_threads = True
allow_reuse_address = True
fake: FakeClam
class FakeClam:
"""Start with `with FakeClam() as clam:`; point settings at `clam.host` and `clam.port`."""
def __init__(self, *, refuse: bool = False, mode: str = "normal") -> None:
self.host = "127.0.0.1"
self.refuse = refuse
self.mode = mode
self.lock = threading.Lock()
self.commands: list[str] = []
self.scanned: list[bytes] = []
self._server: _Server | None = None
self._thread: threading.Thread | None = None
self.port = 0
@property
def calls(self) -> int:
with self.lock:
return len(self.commands)
def start(self) -> FakeClam:
if self.refuse:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((self.host, 0))
self.port = int(s.getsockname()[1])
s.close() # nothing listens there now: a connect is refused
return self
self._server = _Server((self.host, 0), _Handler)
self._server.fake = self
self.port = int(self._server.server_address[1])
self._thread = threading.Thread(target=self._server.serve_forever, name="fakeclam", daemon=True)
self._thread.start()
return self
def stop(self) -> None:
if self._server is not None:
self._server.shutdown()
self._server.server_close()
self._server = None
if self._thread is not None:
self._thread.join(5)
self._thread = None
def __enter__(self) -> FakeClam:
return self.start()
def __exit__(self, *exc: Any) -> None:
self.stop()
def ping(host: str, port: int) -> bytes:
"""A raw zPING, for checking the fake itself."""
with socket.create_connection((host, port), timeout=5) as s:
s.sendall(b"zPING\0")
return s.recv(64)
def instream(host: str, port: int, data: bytes, chunk: int = 7) -> bytes:
"""A raw zINSTREAM in small chunks, for checking the fake itself."""
with socket.create_connection((host, port), timeout=5) as s:
s.sendall(b"zINSTREAM\0")
for i in range(0, len(data), chunk):
part = data[i : i + chunk]
s.sendall(struct.pack(">I", len(part)) + part)
s.sendall(struct.pack(">I", 0))
out = b""
while not out.endswith(b"\0"):
got = s.recv(256)
if not got:
break
out += got
return out
+21 -6
View File
@@ -77,7 +77,9 @@ 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"])
f"/api/packets/{pkt['id']}/bom/{b['item']}",
json={"pulled": True, "heat": f"HT-{b['item']}"},
headers=_im(b["row_version"]),
)
assert r.status_code == 200, r.text
pkt = r.json()
@@ -202,7 +204,14 @@ def test_shorts_and_receipts(pg_client2: TestClient, pg_settings: Settings, unit
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}
body = {
"qty": 3,
"reason": "Not in stock",
"ref": "PO-991",
"eta": "2026-11-02",
"heat": "H0",
"expect_open": False,
}
r = c.post(url, json=body)
assert r.status_code == 200, r.text
out = r.json()
@@ -222,12 +231,16 @@ def test_shorts_and_receipts(pg_client2: TestClient, pg_settings: Settings, unit
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})
r = c.post(f"/api/packets/{pid}/bom/2/short", json={"qty": 1, "reason": "Damaged / rejected", "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
# The release gate needs a heat on every pulled line.
rv2 = _line(r.json(), 2)["row_version"]
r = c.patch(f"/api/packets/{pid}/bom/2", json={"heat": "H2"}, headers=_im(rv2))
assert r.status_code == 200, r.text
pkt = ok_step(c, r.json(), "wh_release")
assert pkt["status"] == "ready" and pkt["open_shorts"] == 1
@@ -237,7 +250,7 @@ def test_shorts_and_receipts(pg_client2: TestClient, pg_settings: Settings, unit
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 line["heat"] == "H0 / 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:
@@ -247,6 +260,8 @@ def test_shorts_and_receipts(pg_client2: TestClient, pg_settings: Settings, unit
pkt = ok_step(c, r.json(), "assign", station_id=stall, welder_id=andy)
as_user(c, "weld.andy")
pkt = ok_step(c, pkt, "start")
# Receive by custody: someone who neither is in the warehouse nor holds the packet is refused.
as_user(c, "qc.quinn")
no = c.post(rec, json={"qty": 1, "heat": "H200"})
assert no.status_code == 403 and no.json()["code"] == "forbidden"
as_user(c, "wh.wade")
@@ -257,7 +272,7 @@ def test_shorts_and_receipts(pg_client2: TestClient, pg_settings: Settings, unit
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
assert line["heat"] == "H0 / 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"
@@ -434,7 +449,7 @@ def test_wh_stage_board(pg_client2: TestClient, unit: tuple[int, int]) -> None:
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"
assert r.status_code == 400 and r.json()["code"] == "invalid_reason"
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"}
+1
View File
@@ -365,6 +365,7 @@ PRECONDITION_CASES: list[tuple[Action, str, str, dict[str, Any], dict[str, Any],
(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.WH_RELEASE, "warehouse", "warehouse", {"bom_missing_heat": 1}, {}, "heats_recorded"),
(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"),
+176
View File
@@ -0,0 +1,176 @@
"""The phase 4 release gate in the policy: heats_recorded over PacketState values, WH_RELEASE with both rules,
and that can() and check() agree. No database."""
from __future__ import annotations
import dataclasses
import itertools
from typing import Any
import pytest
from packetstation.policy import RULES, Action, PacketState, Principal, can, check, principal_from_roles
from packetstation.policy.preconditions import INPUT_ONLY, MESSAGES, PRECONDITIONS, heats_recorded
A = Action
USER = "00000000-0000-0000-0000-0000000000c1"
IT = "Ticket 4411: release by hand"
MSG_PULL = "Pull every material line or mark it short first."
MSG_HEAT = "Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability."
def P(*roles: str) -> Principal:
return principal_from_roles(USER, list(roles))
WH = P("warehouse")
WH_SUP = P("warehouse_supervisor")
ADMIN = P("admin")
LEAD = P("shop:03:lead")
SUP = P("shop:03:supervisor")
QC = P("qc")
CL = P("clerical")
def state(status: str = "warehouse", **kw: Any) -> PacketState:
base = PacketState(
id=1,
status=status,
dept="03",
station_id=None,
welder_id=None,
wh_skipped=False,
routed_to=None,
vclosed=False,
removed=False,
source_kind="hand",
weld_dept=True,
co_hold=False,
drawing_attached=True,
heat_map=True,
bom_total=3,
bom_ready=3,
bom_missing_heat=0,
)
return dataclasses.replace(base, **kw)
def test_packet_state_defaults_to_no_missing_heat() -> None:
s = PacketState(
id=1,
status="warehouse",
dept="03",
station_id=None,
welder_id=None,
wh_skipped=False,
routed_to=None,
vclosed=False,
removed=False,
source_kind="hand",
weld_dept=True,
co_hold=False,
)
assert s.bom_missing_heat == 0
def test_heats_recorded_is_a_state_precondition() -> None:
assert MESSAGES["heats_recorded"] == MSG_HEAT
assert PRECONDITIONS["heats_recorded"] is heats_recorded
assert "heats_recorded" not in INPUT_ONLY
assert RULES[A.WH_RELEASE].preconditions == ("bom_all_pulled_or_short", "heats_recorded")
@pytest.mark.parametrize(("missing", "fails"), [(0, False), (1, True), (2, True), (40, True)])
def test_heats_recorded_over_state(missing: int, fails: bool) -> None:
got = heats_recorded(state(bom_missing_heat=missing), {})
assert got == (MSG_HEAT if fails else None)
def test_heats_recorded_ignores_input() -> None:
assert heats_recorded(state(bom_missing_heat=1), {"heat": "H1", "na": True}) == MSG_HEAT
assert heats_recorded(state(bom_missing_heat=0), {"anything": 1}) is None
@pytest.mark.parametrize("who", [WH, WH_SUP])
def test_release_blocked_by_a_missing_heat(who: Principal) -> None:
d = check(who, state(bom_missing_heat=1), A.WH_RELEASE)
assert not d.ok and d.code == "precondition" and d.why == MSG_HEAT
assert check(who, state(bom_missing_heat=0), A.WH_RELEASE).ok
def test_release_rules_in_order() -> None:
d = check(WH, state(bom_ready=2, bom_missing_heat=1), A.WH_RELEASE)
assert not d.ok and d.why == MSG_PULL
allowed, blocked = can(WH, state(bom_ready=2, bom_missing_heat=1))
assert A.WH_RELEASE not in allowed
assert blocked[A.WH_RELEASE] == [MSG_PULL, MSG_HEAT]
allowed, blocked = can(WH, state(bom_ready=3, bom_missing_heat=1))
assert A.WH_RELEASE not in allowed and blocked[A.WH_RELEASE] == [MSG_HEAT]
allowed, blocked = can(WH, state())
assert A.WH_RELEASE in allowed and A.WH_RELEASE not in blocked
def test_admin_cannot_bypass_the_heat_rule() -> None:
d = check(ADMIN, state(bom_missing_heat=1), A.WH_RELEASE, it_reason=IT)
assert not d.ok and d.code == "precondition" and d.why == MSG_HEAT
d = check(ADMIN, state(bom_missing_heat=0), A.WH_RELEASE, it_reason=IT)
assert d.ok and d.admin_bypass
d = check(ADMIN, state(bom_missing_heat=0), A.WH_RELEASE)
assert not d.ok and d.code == "it_reason_required"
@pytest.mark.parametrize("who", [CL, QC, LEAD, SUP])
def test_others_are_refused_before_the_gate(who: Principal) -> None:
d = check(who, state(bom_missing_heat=1), A.WH_RELEASE)
assert not d.ok and d.code == "forbidden"
allowed, blocked = can(who, state(bom_missing_heat=1))
assert A.WH_RELEASE not in allowed and A.WH_RELEASE not in blocked
def test_the_gate_only_touches_wh_release() -> None:
"""A missing heat blocks nothing else the warehouse can do on a warehouse packet."""
ok_allowed, ok_blocked = can(WH, state())
bad_allowed, bad_blocked = can(WH, state(bom_missing_heat=3))
assert set(ok_allowed) - set(bad_allowed) == {A.WH_RELEASE}
assert set(bad_blocked) - set(ok_blocked) == {A.WH_RELEASE}
PRINCIPALS = {"wh": WH, "wh_sup": WH_SUP, "admin": ADMIN, "lead": LEAD, "sup": SUP, "qc": QC, "cl": CL}
@pytest.mark.parametrize(
("name", "status", "total", "ready", "missing", "co_hold"),
[
(n, st, t, r, m, h)
for n, st, (t, r), m, h in itertools.product(
PRINCIPALS, ("warehouse", "ready", "draft"), ((3, 3), (3, 2), (0, 0)), (0, 1), (False, True)
)
],
)
def test_can_and_check_agree(name: str, status: str, total: int, ready: int, missing: int, co_hold: bool) -> None:
who = PRINCIPALS[name]
s = state(status, bom_total=total, bom_ready=ready, bom_missing_heat=missing, co_hold=co_hold)
allowed, blocked = can(who, s)
d = check(who, s, A.WH_RELEASE, it_reason=IT if who.admin else None)
assert (A.WH_RELEASE in allowed) == d.ok
who_passes = who in (WH, WH_SUP, ADMIN) and status == "warehouse"
if not d.ok and who_passes:
assert d.code in ("precondition", "co_hold")
assert A.WH_RELEASE in blocked and d.why == blocked[A.WH_RELEASE][0]
if d.ok:
assert A.WH_RELEASE not in blocked
assert total <= ready and missing == 0 and not co_hold
def test_receive_by_custody_in_the_policy() -> None:
"""The warehouse receives anywhere but filed (WH_RECEIVE_SHORT); the holder through EDIT_BOM."""
for status in ("warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete"):
assert check(WH, state(status, open_shorts=1), A.WH_RECEIVE_SHORT).ok, status
assert check(WH, state("filed", open_shorts=1), A.WH_RECEIVE_SHORT).code == "filed"
assert check(WH, state("ready", open_shorts=0), A.WH_RECEIVE_SHORT).why == MESSAGES["short_open"]
ready = state("ready", open_shorts=1)
assert check(LEAD, ready, A.EDIT_BOM).ok and check(SUP, ready, A.EDIT_BOM).ok
assert check(WH, ready, A.EDIT_BOM).code == "forbidden"
for who in (QC, CL):
assert check(who, ready, A.EDIT_BOM).code == "forbidden"
assert check(who, ready, A.WH_RECEIVE_SHORT).code == "forbidden"
+32
View File
@@ -465,3 +465,35 @@ def test_two_operations_of_one_work_order_on_one_packet(pg_settings: Settings) -
"INSERT INTO packet_work_orders (packet_id, wo_base, lot, split, sub) VALUES (%s, 'H1', '1', '0', '0')",
(ids["packet"],),
)
def test_notification_events(pg_settings: Settings) -> None:
"""0005: the notification placeholder. One open row per kind and subject; the app may write it."""
with db.connect_as("migrate", pg_settings, autocommit=True) as conn:
names = [r[0] for r in conn.execute("SELECT name FROM schema_migrations ORDER BY name").fetchall()]
assert "0005_warehouse.sql" in names
with app_conn(pg_settings) as conn:
for priv in ("SELECT", "INSERT", "UPDATE", "DELETE"):
assert one(conn, "SELECT has_table_privilege('ps_app', 'notification_events', %s)", (priv,)) is True
ids = seed(conn)
first = one(
conn,
"INSERT INTO notification_events (kind, subject, detail) VALUES ('t_kind', 'assets', '{\"pct\": 90}')"
" RETURNING id",
)
assert one(conn, "SELECT severity FROM notification_events WHERE id = %s", (first,)) == "warning"
with pytest.raises(psycopg.errors.UniqueViolation), conn.transaction():
conn.execute("INSERT INTO notification_events (kind, subject) VALUES ('t_kind', 'assets')")
with pytest.raises(psycopg.errors.CheckViolation), conn.transaction():
conn.execute("INSERT INTO notification_events (kind, severity) VALUES ('t_other', 'loud')")
# A null subject is one subject: a second open row for it is refused too.
conn.execute("INSERT INTO notification_events (kind) VALUES ('t_null')")
with pytest.raises(psycopg.errors.UniqueViolation), conn.transaction():
conn.execute("INSERT INTO notification_events (kind) VALUES ('t_null')")
conn.execute(
"UPDATE notification_events SET resolved_at = now(), acknowledged_at = now(), acknowledged_by = %s"
" WHERE id = %s",
(ids["user"], first),
)
conn.execute("INSERT INTO notification_events (kind, subject) VALUES ('t_kind', 'assets')")
assert one(conn, "SELECT count(*) FROM notification_events WHERE kind = 't_kind'") == 2
+465
View File
@@ -0,0 +1,465 @@
"""Phase 4 upload hardening: content sniffing (PDF, PNG, JPEG, XLSX, CSV), the purpose and type matrix, the limit
per kind, safe names, the blob headers, the optional ClamAV scan (with tests/server/fakeclam.py), the storage quota
alert, and the admin storage and alerts endpoints."""
from __future__ import annotations
import io
import struct
import zipfile
import zlib
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from fakeclam import EICAR, SIGNATURE, FakeClam
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import as_user, phase2_client, seed_users, tiny_pdf
from pshelpers import PG_URL, app_conn, make_settings
from packetstation.api.errors import ApiError
from packetstation.domain import assets, scan
from packetstation.settings import Settings
needs_pg = pytest.mark.skipif(not PG_URL, reason="TEST_PG_SUPERUSER_URL not set (no PostgreSQL)")
XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
WARN = {"total": 1000, "used": 900, "free": 100, "pct": 90.0, "warn": True}
OK = {"total": 1000, "used": 100, "free": 900, "pct": 10.0, "warn": False}
# ---------------------------------------------------------------- sample files
def png_bytes(extra: bytes = b"") -> bytes:
def chunk(kind: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
body = chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(b"\x00\x10\x20\x30"))
if extra:
body += chunk(b"tEXt", b"Comment\x00" + extra)
return b"\x89PNG\r\n\x1a\n" + body + chunk(b"IEND", b"")
def jpeg_bytes() -> bytes:
return b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + b"\x00" * 32 + b"\xff\xd9"
def xlsx_bytes() -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", '<?xml version="1.0"?><Types/>')
z.writestr("xl/workbook.xml", '<?xml version="1.0"?><workbook/>')
z.writestr("xl/worksheets/sheet1.xml", '<?xml version="1.0"?><worksheet/>')
return buf.getvalue()
def zip_bytes() -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr("readme.txt", "just a zip")
return buf.getvalue()
CSV = b"item,qty,part\r\n1,4,PIPE-4\r\n2,2,FLG-4\r\n"
CSV_BOM = b"\xef\xbb\xbf" + CSV
# ---------------------------------------------------------------- sniffing (no database)
def test_sniff_by_content() -> None:
assert assets.sniff(tiny_pdf()) == "application/pdf"
assert assets.sniff(png_bytes()) == "image/png"
assert assets.sniff(jpeg_bytes()) == "image/jpeg"
assert assets.sniff(xlsx_bytes()) == XLSX_MIME
assert assets.sniff(zip_bytes()) is None # a plain zip is not a workbook
assert assets.sniff(b"just text") is None
def test_sniff_xlsx_from_a_path(tmp_path: Path) -> None:
good = tmp_path / "a.bin"
good.write_bytes(xlsx_bytes())
plain = tmp_path / "b.bin"
plain.write_bytes(zip_bytes())
assert assets.sniff(good.read_bytes()[:16], good) == XLSX_MIME
assert assets.sniff(plain.read_bytes()[:16], plain) is None
def test_sniff_csv_only_for_a_bom() -> None:
assert assets.sniff(CSV, purpose="bom") == "text/csv"
assert assets.sniff(CSV_BOM, purpose="bom") == "text/csv"
assert assets.sniff(b"a\tb\n1\t2\n", purpose="bom") == "text/csv"
assert assets.sniff(CSV) is None and assets.sniff(CSV, purpose="document") is None
assert assets.sniff(b"a,b\n\x00\x01,2\n", purpose="bom") is None
assert assets.sniff(b"a,b\n\x07,2\n", purpose="bom") is None
assert assets.sniff(b"a,b\n\xff\xfe,2\n", purpose="bom") is None # not UTF-8
def test_safe_name_and_download_name() -> None:
name = assets.safe_name("..\\..\\x<y>.pdf")
assert name is not None and name.endswith(".pdf")
assert not any(ch in name for ch in '\\/<>:"|?*') and ".." not in name
row = {"id": "0f0e", "mime": XLSX_MIME, "original_name": None}
assert assets.download_name(row).endswith(".xlsx")
assert assets.download_name({"id": "0f0e", "mime": "text/csv", "original_name": None}).endswith(".csv")
assert assets.disposition({"id": "1", "mime": XLSX_MIME, "original_name": "b.xlsx"}).startswith("attachment;")
assert assets.disposition({"id": "1", "mime": "text/csv", "original_name": "b.csv"}).startswith("attachment;")
assert assets.disposition({"id": "1", "mime": "application/pdf", "original_name": "d.pdf"}).startswith("inline;")
# ---------------------------------------------------------------- the scanner (no database)
def test_fake_clamd_and_scanner(tmp_path: Path) -> None:
clean = tmp_path / "clean.png"
clean.write_bytes(png_bytes())
bad = tmp_path / "bad.png"
bad.write_bytes(png_bytes(EICAR))
with FakeClam() as clam:
s = scan.ClamdScanner(clam.host, clam.port, timeout=5)
assert s.ping() is True
assert s.scan_path(clean) == scan.ScanResult(clean=True, signature=None)
assert s.scan_path(bad) == scan.ScanResult(clean=False, signature=SIGNATURE)
assert clam.commands == ["PING", "INSTREAM", "INSTREAM"]
assert clam.scanned[0] == clean.read_bytes()
with FakeClam(refuse=True) as down:
s = scan.ClamdScanner(down.host, down.port, timeout=2)
assert s.ping() is False
with pytest.raises(scan.ScanError):
s.scan_path(clean)
with FakeClam(mode="garbage") as odd:
with pytest.raises(scan.ScanError):
scan.ClamdScanner(odd.host, odd.port, timeout=2).scan_path(clean)
def test_scan_check(tmp_path: Path) -> None:
clean = tmp_path / "clean.png"
clean.write_bytes(png_bytes())
bad = tmp_path / "bad.png"
bad.write_bytes(png_bytes(EICAR))
off = make_settings(tmp_path)
assert off.clamav_host == "" and off.clamav_port == 3310 and off.clamav_timeout_seconds == 30
assert scan.scanner_for(off) is None and scan.check(off, bad) is None
with FakeClam() as clam:
on = off.model_copy(update={"clamav_host": clam.host, "clamav_port": clam.port})
assert isinstance(scan.scanner_for(on), scan.ClamdScanner)
assert scan.check(on, clean) == scan.ScanResult(clean=True, signature=None)
with pytest.raises(ApiError) as e:
scan.check(on, bad)
assert e.value.status == 422 and e.value.code == "infected"
assert e.value.detail == "That file failed the virus scan and was not stored."
assert e.value.extra == {"signature": SIGNATURE}
with FakeClam(refuse=True) as down:
dead = off.model_copy(update={"clamav_host": down.host, "clamav_port": down.port, "clamav_timeout_seconds": 2})
with pytest.raises(ApiError) as e:
scan.check(dead, clean)
assert e.value.status == 503 and e.value.code == "scan_unavailable"
assert e.value.detail == "The virus scanner is not reachable. Try again in a minute."
# ---------------------------------------------------------------- the API (PostgreSQL)
@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)
as_user(client, "clerk.clara")
yield client
def up(
c: TestClient, data: bytes, name: str, purpose: str | None = None, mime: str = "application/octet-stream"
) -> Any:
form = {} if purpose is None else {"purpose": purpose}
return c.post("/api/assets", files={"file": (name, data, mime)}, data=form)
def _one(settings: Settings, sql: str, params: Any = ()) -> Any:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchone()
def stored_files(settings: Settings) -> list[Path]:
root = Path(settings.assets_dir)
return sorted(p for p in root.rglob("*") if p.is_file()) if root.exists() else []
SAMPLES = {
"pdf": (tiny_pdf, "d.pdf"),
"png": (png_bytes, "p.png"),
"jpeg": (jpeg_bytes, "j.jpg"),
"xlsx": (xlsx_bytes, "b.xlsx"),
"csv": (lambda: CSV, "b.csv"),
"zip": (zip_bytes, "z.zip"),
}
# purpose -> what is accepted (everything else in SAMPLES is 415)
ACCEPTS = {
"drawing": {"pdf"},
"mtr": {"pdf", "png", "jpeg"},
"document": {"pdf", "png", "jpeg"},
None: {"pdf", "png", "jpeg"},
"bom": {"xlsx", "csv"},
}
WRONG_TYPE = {
"drawing": "A drawing is a PDF file.",
"mtr": "An MTR is a PDF, PNG or JPG file.",
"bom": "A BOM is an Excel (.xlsx) or CSV file.",
}
@needs_pg
@pytest.mark.parametrize("purpose", ["drawing", "mtr", "document", None, "bom"])
def test_purpose_and_type_matrix(pg_client2: TestClient, pg_settings: Settings, purpose: str | None) -> None:
for kind, (make, name) in SAMPLES.items():
r = up(pg_client2, make(), name, purpose)
if kind in ACCEPTS[purpose]:
assert r.status_code == 201, (purpose, kind, r.text)
ref = _one(pg_settings, "SELECT source_ref FROM assets WHERE id = %s", (r.json()["id"],))[0]
# the same bytes may have been stored before under another purpose (dedupe returns that row)
assert ref is None or "purpose" in ref, ref
assert r.json()["mime"] in ("application/pdf", "image/png", "image/jpeg", XLSX_MIME, "text/csv")
else:
assert r.status_code == 415 and r.json()["code"] == "unsupported_type", (purpose, kind, r.text)
if purpose in WRONG_TYPE:
assert r.json()["detail"] == WRONG_TYPE[purpose]
assert isinstance(r.json()["allowed"], list) and r.json()["allowed"]
@needs_pg
def test_purpose_recorded_and_unknown_refused(pg_client2: TestClient, pg_settings: Settings) -> None:
r = up(pg_client2, png_bytes(b"mtr one"), "m.png", "mtr")
assert r.status_code == 201
ref = _one(pg_settings, "SELECT source_ref FROM assets WHERE id = %s", (r.json()["id"],))[0]
assert ref == {"purpose": "mtr"}
r = up(pg_client2, png_bytes(b"doc one"), "d.png")
ref = _one(pg_settings, "SELECT source_ref FROM assets WHERE id = %s", (r.json()["id"],))[0]
assert ref == {"purpose": "document"}
r = up(pg_client2, xlsx_bytes(), "b.xlsx", "bom")
assert r.status_code == 201 and r.json()["mime"] == XLSX_MIME
r = up(pg_client2, CSV_BOM, "b.csv", "bom")
assert r.status_code == 201 and r.json()["mime"] == "text/csv"
r = up(pg_client2, png_bytes(b"doc alias"), "a.png", "doc")
assert r.status_code == 201, r.text
ref = _one(pg_settings, "SELECT source_ref FROM assets WHERE id = %s", (r.json()["id"],))[0]
assert ref == {"purpose": "document"}
bad = up(pg_client2, tiny_pdf(), "d.pdf", "selfie")
assert bad.status_code == 422 and bad.json()["code"] == "invalid_request", bad.text
nul = up(pg_client2, b"a,b\n\x00,1\n", "n.csv", "bom")
assert nul.status_code == 415 and nul.json()["code"] == "unsupported_type"
@needs_pg
def test_limits_per_kind(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> None:
small = pg_settings.model_copy(update={"upload_max_bytes": 4096, "upload_max_bytes_bom": 1024})
with phase2_client(small, fake_dir) as c:
seed_users(c, ("clerk.clara",))
as_user(c, "clerk.clara")
big_png = png_bytes(b"x" * 2000)
assert 1024 < len(big_png) < 4096
assert up(c, big_png, "p.png", "mtr").status_code == 201
csv = b"item,qty\n" + b"1,4\n" * 400
assert 1024 < len(csv) < 4096
r = up(c, csv, "b.csv", "bom")
assert r.status_code == 413 and r.json()["code"] == "too_large", r.text
assert r.json()["limit"] == 1024 and "limit" in r.json()["detail"]
assert up(c, b"item,qty\n1,4\n", "s.csv", "bom").status_code == 201
huge = png_bytes(b"y" * 6000)
r = up(c, huge, "h.png", "document")
assert r.status_code == 413 and r.json()["code"] == "too_large" and r.json()["limit"] == 4096
r = up(c, b"%PDF-1.4\n" + b"0" * 200_000, "h.pdf", "drawing") # over the pre-check too
assert r.status_code == 413 and r.json()["code"] == "too_large"
assert not [p for p in stored_files(small) if p.name.startswith(".up-")]
@needs_pg
def test_names_and_blob_headers(pg_client2: TestClient) -> None:
r = up(pg_client2, tiny_pdf(2), "..\\..\\x<y>.pdf", "drawing", "application/pdf")
assert r.status_code == 201, r.text
a = r.json()
name = a["original_name"]
assert name and name.endswith(".pdf") and not any(ch in name for ch in "\\/<>") and not name.startswith(".")
b = pg_client2.get(a["url"])
assert b.status_code == 200 and b.headers["content-disposition"].startswith("inline;")
assert b.headers["x-content-type-options"] == "nosniff"
x = up(pg_client2, xlsx_bytes(), "bom list.xlsx", "bom").json()
b = pg_client2.get(x["url"])
assert b.status_code == 200 and b.headers["content-disposition"] == 'attachment; filename="bom list.xlsx"'
assert b.headers["x-content-type-options"] == "nosniff" and b.headers["content-type"].startswith(XLSX_MIME)
cv = up(pg_client2, CSV, "...", "bom").json() # no usable name: the id and the extension
b = pg_client2.get(cv["url"])
assert b.headers["content-disposition"].startswith("attachment;")
assert b.headers["content-disposition"].endswith('.csv"') and b.headers["x-content-type-options"] == "nosniff"
# ---------------------------------------------------------------- ClamAV through the API
@pytest.fixture
def clam() -> Iterator[FakeClam]:
with FakeClam() as fake:
yield fake
@needs_pg
def test_scan_on_upload(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None, clam: FakeClam) -> None:
on = pg_settings.model_copy(update={"clamav_host": clam.host, "clamav_port": clam.port})
with phase2_client(on, fake_dir) as c:
seed_users(c, ("clerk.clara",))
as_user(c, "clerk.clara")
clean = png_bytes(b"clean one")
r = up(c, clean, "ok.png", "mtr")
assert r.status_code == 201, r.text
assert "INSTREAM" in clam.commands and clean in clam.scanned
before = stored_files(on)
bad = png_bytes(EICAR)
r = up(c, bad, "bad.png", "mtr")
assert r.status_code == 422 and r.json()["code"] == "infected", r.text
assert r.json()["detail"] == "That file failed the virus scan and was not stored."
assert r.json()["signature"] == SIGNATURE
assert stored_files(on) == before # nothing placed, no temp file left
assert _one(on, "SELECT count(*) FROM assets WHERE size = %s", (len(bad),))[0] == 0
au = _one(on, "SELECT detail, user_id FROM audit_events WHERE action = 'asset.rejected' ORDER BY id DESC")
assert au is not None
assert au[0]["reason"] == "infected" and au[0]["signature"] == SIGNATURE
assert au[0]["size"] == len(bad) and au[0]["mime"] == "image/png" and au[1] is not None
# The same infected bytes are never deduped against a stored row: a second try is refused again
assert up(c, bad, "bad2.png", "document").status_code == 422
@needs_pg
def test_scanner_down_fails_closed(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> None:
with FakeClam(refuse=True) as down:
dead = pg_settings.model_copy(
update={"clamav_host": down.host, "clamav_port": down.port, "clamav_timeout_seconds": 2}
)
with phase2_client(dead, fake_dir) as c:
seed_users(c, ("clerk.clara",))
as_user(c, "clerk.clara")
r = up(c, png_bytes(b"down"), "p.png", "mtr")
assert r.status_code == 503 and r.json()["code"] == "scan_unavailable", r.text
assert r.json()["detail"] == "The virus scanner is not reachable. Try again in a minute."
assert stored_files(dead) == []
@needs_pg
def test_no_scanner_means_no_call(pg_client2: TestClient, clam: FakeClam) -> None:
r = up(pg_client2, png_bytes(EICAR), "p.png", "mtr")
assert r.status_code == 201, r.text # ClamAV is off: nothing scans
assert clam.calls == 0
@needs_pg
def test_store_file_is_scanned(pg_settings: Settings, clam: FakeClam) -> None:
on = pg_settings.model_copy(update={"clamav_host": clam.host, "clamav_port": clam.port})
tmp = Path(on.assets_dir) / "tmp"
tmp.mkdir(parents=True, exist_ok=True)
bad = tmp / "dl-bad.png"
bad.write_bytes(png_bytes(EICAR))
with app_conn(on) as conn:
with pytest.raises(ApiError) as e:
assets.store_file(conn, on, bad, mime="image/png", original_name="m.png", source="mfiles")
assert e.value.code == "infected"
assert not bad.exists()
assert conn.execute("SELECT count(*) FROM assets").fetchone()[0] == 0
assert conn.execute("SELECT count(*) FROM audit_events WHERE action = 'asset.rejected'").fetchone()[0] == 1
good = tmp / "dl-good.pdf"
good.write_bytes(tiny_pdf())
row = assets.store_file(conn, on, good, original_name="d.pdf", source="mfiles")
assert row["reused"] is False and row["mime"] == "application/pdf"
assert clam.commands.count("INSTREAM") == 2
assert [p for p in stored_files(on) if p.parent.name != "tmp"]
# ---------------------------------------------------------------- the quota alert
@needs_pg
def test_quota_alert_opens_and_resolves(
pg_client2: TestClient, pg_settings: Settings, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(assets, "volume_usage", lambda *a, **k: dict(WARN))
assert up(pg_client2, png_bytes(b"q1"), "q1.png", "mtr").status_code == 201
row = _one(
pg_settings,
"SELECT id, kind, severity, subject, detail, resolved_at FROM notification_events WHERE kind = 'assets_quota'",
)
assert row is not None and row[1:4] == ("assets_quota", "warning", "assets") and row[5] is None
assert row[4]["pct"] == 90.0 and row[4]["warn"] is True
assert _one(pg_settings, "SELECT count(*) FROM audit_events WHERE action = 'storage.quota_warning'")[0] == 1
# A second upload while it is open adds nothing
assert up(pg_client2, png_bytes(b"q2"), "q2.png", "mtr").status_code == 201
assert _one(pg_settings, "SELECT count(*) FROM notification_events WHERE resolved_at IS NULL")[0] == 1
assert _one(pg_settings, "SELECT count(*) FROM audit_events WHERE action = 'storage.quota_warning'")[0] == 1
as_user(pg_client2, "admin.ann")
st = pg_client2.get("/api/admin/storage")
assert st.status_code == 200, st.text
body = st.json()
assert body["usage"] == WARN and body["quota_warn_pct"] == 85
assert body["alert"]["id"] == row[0] and body["alert"]["acknowledged_at"] is None
assert body["clamav"]["configured"] is False and body["clamav"]["reachable"] is None
assert body["clamav"]["host"] is None
al = pg_client2.get("/api/admin/alerts")
assert al.status_code == 200 and [a["id"] for a in al.json()["alerts"]] == [row[0]]
ack = pg_client2.post(f"/api/admin/alerts/{row[0]}/ack")
assert ack.status_code == 200, ack.text
assert ack.json()["acknowledged_at"] and ack.json()["acknowledged_by"]["display_name"] == "Ann Admin"
assert _one(pg_settings, "SELECT count(*) FROM audit_events WHERE action = 'alert.acknowledged'")[0] == 1
assert pg_client2.post("/api/admin/alerts/999999/ack").status_code == 404
monkeypatch.setattr(assets, "volume_usage", lambda *a, **k: dict(OK))
with app_conn(pg_settings) as conn:
assets.check_quota(conn, pg_settings)
resolved = _one(pg_settings, "SELECT resolved_at FROM notification_events WHERE id = %s", (row[0],))[0]
assert resolved is not None
assert pg_client2.get("/api/admin/storage").json()["alert"] is None
assert pg_client2.get("/api/admin/alerts").json()["alerts"] == []
@needs_pg
def test_quota_check_failure_never_fails_the_upload(
pg_client2: TestClient, pg_settings: Settings, monkeypatch: pytest.MonkeyPatch
) -> None:
def boom(*a: Any, **k: Any) -> dict[str, Any]:
raise OSError("disk usage unavailable")
monkeypatch.setattr(assets, "volume_usage", boom)
r = up(pg_client2, png_bytes(b"still stored"), "s.png", "mtr")
assert r.status_code == 201, r.text
@needs_pg
def test_admin_storage_and_alerts_are_admin_only(pg_client2: TestClient) -> None:
for path in ("/api/admin/storage", "/api/admin/alerts"):
r = pg_client2.get(path)
assert r.status_code == 403 and r.json()["code"] == "forbidden", path
assert pg_client2.post("/api/admin/alerts/1/ack").status_code == 403
as_user(pg_client2, "admin.ann")
assert pg_client2.get("/api/admin/storage").status_code == 200
assert pg_client2.get("/api/admin/alerts").json() == {"alerts": []}
@needs_pg
def test_admin_storage_shows_clamav(
pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None, clam: FakeClam
) -> None:
on = pg_settings.model_copy(update={"clamav_host": clam.host, "clamav_port": clam.port})
with phase2_client(on, fake_dir) as c:
seed_users(c, ("admin.ann",))
as_user(c, "admin.ann")
cl = c.get("/api/admin/storage").json()["clamav"]
assert cl["configured"] is True and cl["host"] == clam.host and cl["reachable"] is True
assert "PING" in clam.commands
with FakeClam(refuse=True) as down:
off = pg_settings.model_copy(
update={"clamav_host": down.host, "clamav_port": down.port, "clamav_timeout_seconds": 2}
)
with phase2_client(off, fake_dir) as c:
as_user(c, "admin.ann")
cl = c.get("/api/admin/storage").json()["clamav"]
assert cl["configured"] is True and cl["reachable"] is False
+780
View File
@@ -0,0 +1,780 @@
"""Phase 4 warehouse API: every warehouse action as every role, hold reasons, the Pulled auto-start, the heat
pattern, the release gate (pulled-or-short and heats), receiving a short by custody, the board endpoint, the card
fields, and the exact packet_log texts and audit rows of each write."""
from __future__ import annotations
import struct
import zlib
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
from typing import Any
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import as_user, ok_step, phase2_client, ready_draft, seed_users, station_id, step, user_id
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg, sign_in
from packetstation.settings import Settings
pytestmark = needs_pg
IT = "Ticket 5120: warehouse lead is out"
MSG_PULL = "Pull every material line or mark it short first."
MSG_HEAT = "Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability."
HOLD_REASONS = (
"Waiting on material (not in stock)",
"Waiting on purchasing / PO",
"Question for engineering or clerical",
"Drawing / BOM problem",
"Other",
)
# Everyone but the warehouse and the admin, on a packet the warehouse holds.
REFUSED_IN_WH = ("clerk.clara", "qc.quinn", "pm.paula", "weld.andy", "lead.lou", "sup.wes")
# On a ready packet, lead.lou and sup.wes hold it and may receive; these may not.
REFUSED_IN_READY = ("clerk.clara", "qc.quinn", "pm.paula", "weld.andy")
# A normal line, an N/A line (set by SQL), a sub-assembly line (sub = 'Y' by SQL) and a line with qty 4.
BOM = [
{"item": 1, "qty": 1, "part_id": "PIPE-4", "description": "4in pipe SCH40", "material": "A106-B", "length": "20ft"},
{"item": 2, "qty": 2, "part_id": "GSKT-4", "description": "4in gasket"},
{"item": 3, "qty": 1, "part_id": "SUB-7", "description": "Nozzle sub-assembly"},
{"item": 4, "qty": 4, "part_id": "STUD-58", "description": "5/8 stud bolt"},
]
# ---------------------------------------------------------------- helpers
@dataclass
class Shop:
"""One app, one signed-in TestClient per user (separate cookie jars)."""
main: TestClient
settings: Settings
clients: dict[str, TestClient] = field(default_factory=dict)
def __call__(self, sam: str) -> TestClient:
c = self.clients.get(sam)
if c is None:
c = TestClient(self.main.app, headers=ORIGIN_HEADERS)
r = sign_in(c, sam)
assert r.status_code == 200, r.text
self.clients[sam] = c
return c
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
with phase2_client(pg_settings, fake_dir) as client:
seed_users(client)
yield Shop(client, pg_settings)
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()
def _im(v: int) -> dict[str, str]:
return {"If-Match": str(v)}
def line(pkt: dict[str, Any], item: int) -> dict[str, Any]:
return next(b for b in pkt["bom"] if b["item"] == item)
def fresh(c: TestClient, pid: int) -> dict[str, Any]:
r = c.get(f"/api/packets/{pid}")
assert r.status_code == 200, r.text
out: dict[str, Any] = r.json()
return out
def log_rows(c: TestClient, pid: int) -> list[dict[str, Any]]:
r = c.get(f"/api/packets/{pid}/log")
assert r.status_code == 200, r.text
out: list[dict[str, Any]] = r.json()
return out
def last_log(c: TestClient, pid: int) -> dict[str, Any]:
return log_rows(c, pid)[0]
def last_audit(settings: Settings, pid: int) -> Any:
return _one(
settings,
"SELECT action, detail, it_reason FROM audit_events WHERE packet_id = %s ORDER BY id DESC LIMIT 1",
(pid,),
)
def audit_count(settings: Settings, pid: int) -> int:
return int(_one(settings, "SELECT count(*) FROM audit_events WHERE packet_id = %s", (pid,))[0])
def make_unit_sql(settings: Settings, number: str, lot: str = "001") -> tuple[int, int]:
with app_conn(settings) as conn:
pid = conn.execute(
"INSERT INTO projects (number, name, customer, po) VALUES (%s, 'Test project', 'Acme Refining', 'PO-77')"
" RETURNING id",
(number,),
).fetchone()[0]
uid = conn.execute(
"INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, 'Unit one', 'open') RETURNING id",
(pid, lot),
).fetchone()[0]
return int(pid), int(uid)
def warehouse_packet(shop: Shop, unit_id: int, **over: Any) -> dict[str, Any]:
"""A hand draft by clerk.clara with the four BOM lines, drawing attached, heat map answered, sent to the
warehouse. Item 2 is N/A and item 3 a sub-assembly (both by SQL)."""
c = shop("clerk.clara")
pkt = ready_draft(c, unit_id, bom=BOM, **over)
with app_conn(shop.settings) as conn:
conn.execute("UPDATE bom_lines SET na = true WHERE packet_id = %s AND item = 2", (pkt["id"],))
conn.execute("UPDATE bom_lines SET sub = 'Y' WHERE packet_id = %s AND item = 3", (pkt["id"],))
pkt = fresh(c, pkt["id"])
out = ok_step(c, pkt, "send_to_warehouse")
assert out["status"] == "warehouse" and out["wh"]["stage"] == "incoming"
return out
def tiny_png() -> bytes:
def chunk(kind: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
idat = zlib.compress(b"\x00\xff\x00\x00")
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
def upload_mtr(c: TestClient, data: bytes, name: str = "mtr.png", mime: str = "image/png") -> dict[str, Any]:
r = c.post("/api/assets", files={"file": (name, data, mime)}, data={"purpose": "mtr"})
assert r.status_code == 201, r.text
out: dict[str, Any] = r.json()
return out
def _it(body: dict[str, Any], it: str | None) -> dict[str, Any]:
return {**body, "it_reason": it} if it else body
Call = Callable[[TestClient, dict[str, Any], str | None], Any]
def wh_move(stage: str, expect: str, **kw: Any) -> Call:
def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any:
return c.post(f"/api/packets/{pkt['id']}/wh", json=_it({"stage": stage, "expect_stage": expect, **kw}, it))
return call
def bom_patch(item: int, **fields: Any) -> Call:
def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any:
rv = line(pkt, item)["row_version"]
return c.patch(f"/api/packets/{pkt['id']}/bom/{item}", json=_it(fields, it), headers=_im(rv))
return call
def short_save(item: int, **body: Any) -> Call:
def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any:
return c.post(f"/api/packets/{pkt['id']}/bom/{item}/short", json=_it(body, it))
return call
def short_clear(item: int) -> Call:
def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any:
return c.request("DELETE", f"/api/packets/{pkt['id']}/bom/{item}/short", json=_it({"expect_open": True}, it))
return call
def receipt(item: int, **body: Any) -> Call:
def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any:
return c.post(f"/api/packets/{pkt['id']}/bom/{item}/short/receipts", json=_it(body, it))
return call
def transition(action: str, **kw: Any) -> Call:
def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any:
return step(c, pkt, action, **_it(kw, it))
return call
def refused_then_done(
shop: Shop,
pkt: dict[str, Any],
call: Call,
performer: str,
refused: tuple[str, ...] = REFUSED_IN_WH,
) -> dict[str, Any]:
"""Every refused role gets 403 forbidden, admin.ann without an IT reason 400 it_reason_required; then the
performer (wh.wade, or admin.ann with an IT reason) does it. Returns the new read model."""
for sam in refused:
r = call(shop(sam), pkt, None)
assert r.status_code == 403 and r.json()["code"] == "forbidden", (sam, r.text)
r = call(shop("admin.ann"), pkt, None)
assert r.status_code == 400 and r.json()["code"] == "it_reason_required", r.text
it = IT if performer == "admin.ann" else None
r = call(shop(performer), pkt, it)
assert r.status_code == 200, (performer, r.text)
out: dict[str, Any] = r.json()
return out
# ---------------------------------------------------------------- every action, every role, the log texts
# (call, log action, log note or None for "do not check", audit action)
def walk_steps(mtr_id: str) -> list[tuple[Call, str, str | None, str]]:
return [
(wh_move("pulling", "incoming"), "Warehouse: Started pulling", None, "packet.set_wh_stage"),
(
wh_move("hold", "pulling", reason="Drawing / BOM problem", note="Asked Leo"),
"Warehouse: Put on hold",
"Drawing / BOM problem · Asked Leo",
"packet.set_wh_stage",
),
(wh_move("pulling", "hold"), "Warehouse: Pulling resumed", None, "packet.set_wh_stage"),
(bom_patch(1, heat="HT-1"), "Heat recorded · item 1", "HT-1", "packet.edit_bom"),
(bom_patch(1, pulled=True), "Pulled · item 1", None, "packet.edit_bom"),
(bom_patch(1, pulled=False), "Pulled undone · item 1", None, "packet.edit_bom"),
(bom_patch(1, pulled=True), "Pulled · item 1", None, "packet.edit_bom"),
(bom_patch(2, na=False), "Traceable again · item 2", None, "packet.edit_bom"),
(bom_patch(2, na=True), "Marked N/A · item 2", None, "packet.edit_bom"),
(bom_patch(4, issued=2), "Issued qty changed · item 4", "2 of 4", "packet.edit_bom"),
(bom_patch(4, loc="Rack 4"), "Location set · item 4", "Rack 4", "packet.edit_bom"),
(bom_patch(1, heat=None), "Heat cleared · item 1", None, "packet.edit_bom"),
(bom_patch(1, mtr_asset_id=mtr_id), "MTR attached · item 1", None, "packet.edit_bom"),
(bom_patch(1, mtr_asset_id=None), "MTR removed · item 1", None, "packet.edit_bom"),
(bom_patch(1, heat="HT-2", loc="Bin 2"), "Line changed · item 1", None, "packet.edit_bom"),
(
short_save(4, qty=2, reason="On order", ref="PO-4471", eta="2026-11-20", expect_open=False),
"Short recorded · item 4",
"2 of 4 short · On order · PO-4471 · ETA 2026-11-20",
"packet.edit_bom",
),
(
short_save(4, qty=1, reason="Back-ordered", expect_open=True),
"Short updated · item 4",
"1 of 4 short · Back-ordered",
"packet.edit_bom",
),
(short_clear(4), "Short cleared · item 4", None, "packet.edit_bom"),
(
short_save(4, qty=1, reason="Not in stock", heat="HT-4", expect_open=False),
"Short recorded · item 4",
"1 of 4 short · Not in stock",
"packet.edit_bom",
),
(bom_patch(3, pulled=True), "Pulled · item 3", None, "packet.edit_bom"),
(wh_move("pulled", "pulling"), "Warehouse: Marked pulled, ready for production", None, "packet.set_wh_stage"),
(wh_move("pulling", "pulled"), "Warehouse: Back to pulling", None, "packet.set_wh_stage"),
(wh_move("pulled", "pulling"), "Warehouse: Marked pulled, ready for production", None, "packet.set_wh_stage"),
(
transition("wh_release"),
"Released to supervisor with 1 short",
"2 heats recorded · SHORT: item 4 ×1 (Not in stock)",
"packet.wh_release",
),
]
@pytest.mark.parametrize("performer", ["wh.wade", "admin.ann"])
def test_every_warehouse_action_as_every_role(shop: Shop, performer: str) -> None:
s = shop.settings
_, uid = make_unit_sql(s, "L9W" + ("1" if performer == "wh.wade" else "2"))
pkt = warehouse_packet(shop, uid)
pid = pkt["id"]
mtr = upload_mtr(shop("wh.wade"), tiny_png())
for call, action, note, audit_action in walk_steps(mtr["id"]):
before = audit_count(s, pid)
# The refusals are checked once, on the wh.wade walk; the admin walk checks the IT reason only.
pkt = refused_then_done(shop, pkt, call, performer, REFUSED_IN_WH if performer == "wh.wade" else ())
lg = last_log(shop("wh.wade"), pid)
assert lg["action"] == action, (action, lg)
if note is not None:
assert lg["note"] == note, (action, lg)
if action.startswith("Heat recorded"):
assert lg["note"] == "HT-1"
assert audit_count(s, pid) == before + 1, action
au = last_audit(s, pid)
assert au[0] == audit_action, (action, au)
if performer == "admin.ann":
assert lg["it_reason"] == IT and lg["by"]["display_name"] == "Ann Admin", lg
assert au[2] == IT
else:
assert lg["it_reason"] is None and lg["by"]["display_name"] == "Wade Warehouse"
if audit_action == "packet.edit_bom" and "item" in action:
assert int(au[1]["item"]) == int(action.rsplit(" ", 1)[1])
if action.startswith("Short recorded") or action.startswith("Short updated"):
assert float(au[1]["qty"]) in (1.0, 2.0), au
if audit_action == "packet.set_wh_stage":
assert au[1]["wh_from"] and au[1]["wh_to"]
assert pkt["status"] == "ready" and pkt["open_shorts"] == 1
assert line(pkt, 1)["heat"] == "HT-2" and line(pkt, 1)["loc"] == "Bin 2" and line(pkt, 1)["mtr_asset_id"] is None
assert line(pkt, 2)["na"] is True and line(pkt, 4)["heat"] == "HT-4"
# The short receipt on the ready packet: the warehouse (or IT) receives; Clerical, Q.C., PM and a welder cannot.
pkt = refused_then_done(shop, pkt, receipt(4, qty=1, heat="HT-5"), performer, REFUSED_IN_READY)
lg = last_log(shop("wh.wade"), pid)
assert lg["action"] == "Short received · item 4"
assert lg["note"] == "1 pc · heat HT-5 · short closed · packet ready to assign"
au = last_audit(s, pid)
assert float(au[1]["qty"]) == 1 and int(au[1]["item"]) == 4
assert au[0] == ("packet.wh_receive_short" if performer == "wh.wade" else au[0])
assert pkt["open_shorts"] == 0 and line(pkt, 4)["short"] is None
@pytest.mark.parametrize("performer", ["wh.wade", "admin.ann"])
def test_send_back_to_clerical(shop: Shop, performer: str) -> None:
_, uid = make_unit_sql(shop.settings, "L9S" + ("1" if performer == "wh.wade" else "2"))
pkt = warehouse_packet(shop, uid)
reason = "The BOM is the old revision"
short = shop("wh.wade").post(f"/api/packets/{pkt['id']}/transitions", json={"action": "wh_send_back"})
assert short.status_code in (400, 422), short.text
r = step(shop("wh.wade"), pkt, "wh_send_back", reason="too few")
assert r.status_code == 400 and r.json()["code"] == "reason_required", r.text
pkt = refused_then_done(shop, pkt, transition("wh_send_back", reason=reason), performer)
assert pkt["status"] == "draft"
lg = last_log(shop("clerk.clara"), pkt["id"])
assert lg["action"] == "Sent back to Clerical" and lg["note"] == reason
assert last_audit(shop.settings, pkt["id"])[0] == "packet.wh_send_back"
# ---------------------------------------------------------------- hold reasons and stage moves
def test_hold_reasons_and_stage_moves(shop: Shop) -> None:
_, uid = make_unit_sql(shop.settings, "L9H1")
pkt = warehouse_packet(shop, uid)
c = shop("wh.wade")
url = f"/api/packets/{pkt['id']}/wh"
assert c.post(url, json={"stage": "pulling", "expect_stage": "incoming"}).status_code == 200
same = c.post(url, json={"stage": "pulling", "expect_stage": "pulling"})
assert same.status_code == 409 and same.json()["code"] == "wh_transition", same.text
stale = c.post(url, json={"stage": "hold", "reason": "Other", "note": "x", "expect_stage": "incoming"})
assert stale.status_code == 409 and stale.json()["code"] == "stale_step", stale.text
assert stale.json()["current"]["stage"] == "pulling"
bad = c.post(url, json={"stage": "hold", "reason": "Lunch break", "expect_stage": "pulling"})
assert bad.status_code == 400 and bad.json()["code"] == "invalid_reason", bad.text
assert bad.json()["detail"] == "Pick one of the hold reasons."
other = c.post(url, json={"stage": "hold", "reason": "Other", "expect_stage": "pulling"})
assert other.status_code == 400 and other.json()["code"] == "reason_required", other.text
assert other.json()["detail"] == "Say what it's waiting on."
none = c.post(url, json={"stage": "hold", "expect_stage": "pulling"})
assert none.status_code == 400, none.text
for reason in HOLD_REASONS:
body: dict[str, Any] = {"stage": "hold", "reason": reason, "expect_stage": "pulling"}
if reason == "Other":
body["note"] = "Crane is down"
r = c.post(url, json=body)
assert r.status_code == 200, (reason, r.text)
assert r.json()["wh"]["stage"] == "hold" and r.json()["wh"]["reason"] == reason
lg = last_log(c, pkt["id"])
assert lg["action"] == "Warehouse: Put on hold"
assert lg["note"] == (f"{reason} · Crane is down" if reason == "Other" else reason)
r = c.post(url, json={"stage": "pulling", "expect_stage": "hold"})
assert r.status_code == 200 and last_log(c, pkt["id"])["action"] == "Warehouse: Pulling resumed"
long = c.post(url, json={"stage": "hold", "reason": "Other", "note": "n" * 300, "expect_stage": "pulling"})
assert long.status_code == 200, long.text
assert len(long.json()["wh"]["note"]) == 200
c.post(url, json={"stage": "pulling", "expect_stage": "hold"})
r = c.post(url, json={"stage": "pulled", "expect_stage": "pulling"})
assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulled"
r = c.post(url, json={"stage": "pulling", "expect_stage": "pulled"})
assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulling"
assert last_log(c, pkt["id"])["action"] == "Warehouse: Back to pulling"
bad = c.post(url, json={"stage": "incoming", "expect_stage": "pulling"})
assert bad.status_code in (409, 422), bad.text
def test_pulled_tick_auto_starts(shop: Shop) -> None:
s = shop.settings
_, uid = make_unit_sql(s, "L9A1")
c = shop("wh.wade")
# From incoming
a = warehouse_packet(shop, uid)
r = bom_patch(1, pulled=True)(c, a, None)
assert r.status_code == 200, r.text
assert r.json()["wh"]["stage"] == "pulling" and r.json()["wh"]["auto"] is True
lg = last_log(c, a["id"])
assert lg["action"] == "Pulled · item 1" and lg["note"].endswith("started pulling"), lg
row = _one(s, "SELECT stage, auto FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (a["id"],))
assert row == ("pulling", True)
# A second tick does not move the stage again
r = bom_patch(4, pulled=True)(c, r.json(), None)
assert last_log(c, a["id"])["action"] == "Pulled · item 4"
assert int(_one(s, "SELECT count(*) FROM packet_wh WHERE packet_id = %s", (a["id"],))[0]) == 2
# From hold
_, uid2 = make_unit_sql(s, "L9A2")
b = warehouse_packet(shop, uid2)
r = c.post(
f"/api/packets/{b['id']}/wh",
json={"stage": "hold", "reason": "Waiting on purchasing / PO", "expect_stage": "incoming"},
)
assert r.status_code == 200, r.text
r = bom_patch(1, pulled=True)(c, r.json(), None)
assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulling" and r.json()["wh"]["auto"] is True
lg = last_log(c, b["id"])
assert lg["action"] == "Pulled · item 1" and lg["note"].endswith("pulling resumed"), lg
row = _one(s, "SELECT stage, auto FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (b["id"],))
assert row == ("pulling", True)
# ---------------------------------------------------------------- heats and MTRs
BAD_HEATS = ("<img src=x>", "A" * 41, " HT1", "HT@1", "-HT1")
def test_bad_heat_is_refused(shop: Shop) -> None:
_, uid = make_unit_sql(shop.settings, "L9B1")
pkt = warehouse_packet(shop, uid)
for value in BAD_HEATS:
r = bom_patch(1, heat=value)(shop("wh.wade"), pkt, None)
assert r.status_code == 422 and r.json()["code"] == "invalid_heat", (value, r.text)
if value in ("<img src=x>", "HT@1"):
assert r.json()["detail"] == (
f'Heat number "{value}" isn\'t valid: letters, numbers, space . / # _ - only, up to 40 characters.'
)
assert fresh(shop("wh.wade"), pkt["id"])["bom"][0]["heat"] is None
def test_good_heats_are_recorded(shop: Shop) -> None:
_, uid = make_unit_sql(shop.settings, "L9G1")
pkt = warehouse_packet(shop, uid)
c = shop("wh.wade")
for value in ("HT-1234/7A", "7A", "A 1.2#3_x", "A" * 40):
r = bom_patch(1, heat=value)(c, pkt, None)
assert r.status_code == 200, (value, r.text)
pkt = r.json()
assert line(pkt, 1)["heat"] == value
lg = last_log(c, pkt["id"])
assert lg["action"] == "Heat recorded · item 1" and lg["note"] == value
# A trailing space from a scanner is trimmed
r = bom_patch(1, heat="HT-9 ")(c, pkt, None)
assert r.status_code == 200 and line(r.json(), 1)["heat"] == "HT-9"
def test_mtr_must_be_a_pdf_or_image(shop: Shop) -> None:
_, uid = make_unit_sql(shop.settings, "L9M1")
pkt = warehouse_packet(shop, uid)
c = shop("wh.wade")
missing = bom_patch(1, mtr_asset_id="6f1c1d1e-0000-4000-8000-000000000000")(c, pkt, None)
assert missing.status_code == 422 and missing.json()["code"] == "invalid_asset"
csv = shop("clerk.clara").post(
"/api/assets", files={"file": ("bom.csv", b"item,qty\n1,4\n", "text/csv")}, data={"purpose": "bom"}
)
assert csv.status_code == 201, csv.text
wrong = bom_patch(1, mtr_asset_id=csv.json()["id"])(c, pkt, None)
assert wrong.status_code == 422 and wrong.json()["code"] == "invalid_asset", wrong.text
assert wrong.json()["detail"] == "An MTR is a PDF, PNG or JPG file."
png = upload_mtr(c, tiny_png())
r = bom_patch(1, mtr_asset_id=png["id"])(c, pkt, None)
assert r.status_code == 200 and line(r.json(), 1)["mtr_asset_id"] == png["id"], r.text
r = bom_patch(1, mtr_asset_id=None)(c, r.json(), None)
assert r.status_code == 200 and line(r.json(), 1)["mtr_asset_id"] is None
# ---------------------------------------------------------------- the release gate
def test_release_gate(shop: Shop) -> None:
s = shop.settings
_, uid = make_unit_sql(s, "L9R1")
pkt = warehouse_packet(shop, uid)
pid = pkt["id"]
c = shop("wh.wade")
pkt = bom_patch(1, heat="HT-1")(c, pkt, None).json()
pkt = bom_patch(1, pulled=True)(c, pkt, None).json()
pkt = bom_patch(3, pulled=True)(c, pkt, None).json() # the sub line needs no heat
# Item 4 is not pulled: the first rule only (the N/A line and the sub line need nothing)
assert "wh_release" not in pkt["can"] and pkt["blocked"]["wh_release"] == [MSG_PULL], pkt["blocked"]
r = step(c, pkt, "wh_release")
assert r.status_code == 403 and r.json()["code"] == "precondition" and r.json()["detail"] == MSG_PULL
# Every line pulled, item 4 has no heat
pkt = bom_patch(4, pulled=True)(c, pkt, None).json()
assert pkt["blocked"]["wh_release"] == [MSG_HEAT], pkt["blocked"]
r = step(c, pkt, "wh_release")
assert r.status_code == 403 and r.json()["code"] == "precondition" and r.json()["detail"] == MSG_HEAT
card = next(x for x in c.get("/api/packets", params={"status": "warehouse"}).json()["packets"] if x["id"] == pid)
assert card["bom_total"] == 3 and card["bom_pulled"] == 3 and card["bom_missing_heat"] == 1
# Unpull item 4 and record a short instead: the first rule passes, the heat of the pulled pieces is missing
pkt = bom_patch(4, pulled=False)(c, pkt, None).json()
assert pkt["blocked"]["wh_release"] == [MSG_PULL]
r = short_save(4, qty=1, reason="Not in stock", expect_open=False)(c, pkt, None)
assert r.status_code == 200, r.text
pkt = r.json()
assert pkt["blocked"]["wh_release"] == [MSG_HEAT], pkt["blocked"]
r = short_save(4, qty=1, reason="Not in stock", heat="HT-4", expect_open=True)(c, pkt, None)
assert r.status_code == 200, r.text
pkt = r.json()
assert "wh_release" in pkt["can"] and "wh_release" not in pkt["blocked"]
pkt = ok_step(c, pkt, "wh_release")
assert pkt["status"] == "ready"
lg = last_log(c, pid)
assert lg["action"] == "Released to supervisor with 1 short"
assert lg["note"] == "2 heats recorded · SHORT: item 4 ×1 (Not in stock)"
board = c.get("/api/warehouse/board").json()
rel = [x for x in board["columns"]["released"] if x["id"] == pid]
assert len(rel) == 1 and rel[0]["released_at"], board["columns"]["released"]
assert board["kpis"]["released_24h"] >= 1
assert all(pid not in [x["id"] for x in board["columns"][k]] for k in ("incoming", "pulling", "hold", "pulled"))
# Sent back and released again: it shows once in the released column
pkt = ok_step(shop("lead.lou"), pkt, "send_back_to_warehouse", reason="Wrong studs were pulled")
assert pkt["status"] == "warehouse"
pkt = ok_step(c, fresh(c, pid), "wh_release")
lg = last_log(c, pid)
assert lg["action"] == "Released to supervisor with 1 short"
board = c.get("/api/warehouse/board").json()
assert [x["id"] for x in board["columns"]["released"]].count(pid) == 1
# Without shorts the label has no count
_, uid2 = make_unit_sql(s, "L9R2")
other = warehouse_packet(shop, uid2)
for item in (1, 3, 4):
other = bom_patch(item, pulled=True)(c, other, None).json()
for item in (1, 4):
other = bom_patch(item, heat=f"HT-{item}0")(c, other, None).json()
other = ok_step(c, other, "wh_release")
lg = last_log(c, other["id"])
assert lg["action"] == "Released to supervisor" and lg["note"] == "2 heats recorded"
def test_release_gate_without_traceable_lines(shop: Shop) -> None:
"""A packet whose lines are all N/A or sub-assemblies needs no heat at all."""
_, uid = make_unit_sql(shop.settings, "L9R3")
pkt = warehouse_packet(shop, uid)
c = shop("wh.wade")
pkt = bom_patch(1, na=True)(c, pkt, None).json()
pkt = bom_patch(4, na=True)(c, pkt, None).json()
assert pkt["blocked"]["wh_release"] == [MSG_PULL]
pkt = bom_patch(3, pulled=True)(c, pkt, None).json()
assert "wh_release" in pkt["can"], pkt["blocked"]
# ---------------------------------------------------------------- receiving a short by custody
def test_receive_short_by_custody(shop: Shop) -> None:
s = shop.settings
_, uid = make_unit_sql(s, "L9C1")
pkt = warehouse_packet(shop, uid)
pid = pkt["id"]
wade = shop("wh.wade")
pkt = bom_patch(1, heat="HT-1")(wade, pkt, None).json()
pkt = bom_patch(1, pulled=True)(wade, pkt, None).json()
pkt = bom_patch(3, pulled=True)(wade, pkt, None).json()
pkt = short_save(4, qty=3, reason="On order", ref="PO-88", heat="HT-4", expect_open=False)(wade, pkt, None).json()
pkt = ok_step(wade, pkt, "wh_release")
assert pkt["status"] == "ready" and pkt["open_shorts"] == 1
r = receipt(4, qty=1, heat="HT-R1")(wade, pkt, None)
assert r.status_code == 200, r.text
pkt = r.json()
lg = last_log(wade, pid)
assert lg["action"] == "Short received · item 4"
assert lg["note"] == "1 pc · heat HT-R1 · 2 still short · packet ready to assign"
assert last_audit(s, pid)[0] == "packet.wh_receive_short"
# The warehouse does not hold the ready packet: no other line edit
r = bom_patch(1, heat="HT-X")(wade, pkt, None)
assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text
r = bom_patch(1, loc="Rack 9")(wade, pkt, None)
assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text
for sam in ("qc.quinn", "clerk.clara", "pm.paula", "weld.andy"):
r = receipt(4, qty=1, heat="HT-R2")(shop(sam), pkt, None)
assert r.status_code == 403 and r.json()["code"] == "forbidden", (sam, r.text)
lou = shop("lead.lou")
over = receipt(4, qty=3, heat="HT-R2")(lou, pkt, None)
assert over.status_code == 422, over.text
noheat = receipt(4, qty=1)(lou, pkt, None)
assert noheat.status_code == 422 and noheat.json()["code"] == "heat_required", noheat.text
r = receipt(4, qty=1, heat="HT-R2")(lou, pkt, None)
assert r.status_code == 200, r.text
pkt = r.json()
assert last_log(lou, pid)["by"]["display_name"] == "Lou Lead"
assert last_audit(s, pid)[0] == "packet.edit_bom"
r = receipt(4, qty=1, heat="HT-R3")(shop("sup.wes"), pkt, None)
assert r.status_code == 200, r.text
pkt = r.json()
assert pkt["open_shorts"] == 0 and line(pkt, 4)["short"] is None
assert line(pkt, 4)["heat"] == "HT-4 / HT-R1 / HT-R2 / HT-R3"
assert last_log(wade, pid)["note"] == "1 pc · heat HT-R3 · short closed · packet ready to assign"
closed = _one(
s,
"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 = 4",
(pid,),
)
assert closed[0] is not None and float(closed[1]) == 0
r = receipt(4, qty=1, heat="HT-R4")(wade, pkt, None)
assert r.status_code in (403, 404), r.text # nothing is short now
# The holder may open a short on a ready packet; a receipt on an N/A line needs no heat.
r = short_save(2, qty=2, reason="Damaged / rejected", expect_open=False)(lou, pkt, None)
assert r.status_code == 200, r.text
pkt = r.json()
with app_conn(s) as conn:
stall = station_id(conn)
andy = user_id(conn, "weld.andy")
pkt = ok_step(shop("sup.wes"), pkt, "assign", station_id=stall, welder_id=andy)
r = receipt(2, qty=2)(wade, pkt, None)
assert r.status_code == 200, r.text
lg = last_log(wade, pid)
assert lg["action"] == "Short received · item 2"
assert lg["note"] == "2 pc · short closed · packet at a station in Stall 1"
def test_short_reasons_are_a_closed_set(shop: Shop) -> None:
_, uid = make_unit_sql(shop.settings, "L9Q1")
pkt = warehouse_packet(shop, uid)
c = shop("wh.wade")
bad = short_save(4, qty=1, reason="Lost it", expect_open=False)(c, pkt, None)
assert bad.status_code == 400 and bad.json()["code"] == "invalid_reason", bad.text
assert bad.json()["detail"] == "Pick one of the short reasons."
for q in (0, 5):
r = short_save(4, qty=q, reason="Other", note="x", expect_open=False)(c, pkt, None)
assert r.status_code == 422, (q, r.text)
reasons = (
"Not in stock",
"On order",
"Back-ordered",
"Damaged / rejected",
"Wrong part received",
"Needs cutting / prep",
"Other",
)
open_ = False
for reason in reasons:
r = short_save(4, qty=1, reason=reason, note="see PO", expect_open=open_)(c, pkt, None)
assert r.status_code == 200, (reason, r.text)
open_ = True
assert line(r.json(), 4)["short"]["reason"] == reason
# ---------------------------------------------------------------- the board endpoint
def test_board(shop: Shop) -> None:
s = shop.settings
proj_a, unit_a = make_unit_sql(s, "L9K1")
proj_b, unit_b = make_unit_sql(s, "L9K2")
p1 = warehouse_packet(shop, unit_a, drawing="D-OLD")
p2 = warehouse_packet(shop, unit_a, drawing="D-NEW")
p5 = warehouse_packet(shop, unit_a, drawing="D-REL")
p3 = warehouse_packet(shop, unit_a, drawing="D-HOLD")
p4 = warehouse_packet(shop, unit_b, drawing="D-OTHER")
with app_conn(s) as conn:
conn.execute("UPDATE packets SET wh_sent_at = now() - interval '3 days' WHERE id = %s", (p1["id"],))
conn.execute(
"UPDATE packets SET wh_sent_at = date_trunc('day', now()) - interval '1 day' + interval '9 hours'"
" WHERE id = %s",
(p2["id"],),
)
conn.execute(
"UPDATE packets SET wh_sent_at = date_trunc('day', now()) - interval '1 day' + interval '10 hours',"
" visual_rel = '2026-11-01' WHERE id = %s",
(p5["id"],),
)
wade = shop("wh.wade")
r = wade.post(
f"/api/packets/{p3['id']}/wh",
json={"stage": "hold", "reason": "Drawing / BOM problem", "note": "Asked Leo", "expect_stage": "incoming"},
)
assert r.status_code == 200, r.text
r = wade.post(f"/api/packets/{p4['id']}/wh", json={"stage": "pulling", "expect_stage": "incoming"})
assert r.status_code == 200, r.text
p4 = r.json()
p4 = short_save(4, qty=1, reason="On order", ref="PO-1", eta="2020-01-02", expect_open=False)(wade, p4, None).json()
p1 = short_save(1, qty=1, reason="Not in stock", expect_open=False)(wade, p1, None).json()
assert p1["wh"]["stage"] == "pulling" # the short auto-started it
r = shop("pm.paula").get("/api/warehouse/board")
assert r.status_code == 200, r.text
b = r.json()
assert set(b) >= {"kpis", "columns", "shorts"}
assert b["kpis"] == {"waiting": 2, "pulling": 2, "hold": 1, "pulled": 0, "open_shorts": 2, "released_24h": 0}
cols = {k: [x["id"] for x in v] for k, v in b["columns"].items()}
assert set(cols) == {"incoming", "pulling", "hold", "pulled", "released"}
assert cols["incoming"] == [p5["id"], p2["id"]] # same day: the earlier VISUAL release date first
assert cols["pulling"] == [p1["id"], p4["id"]] # the older day first
assert cols["hold"] == [p3["id"]] and cols["pulled"] == [] and cols["released"] == []
hold = b["columns"]["hold"][0]
assert hold["wh"]["stage"] == "hold" and hold["wh"]["reason"] == "Drawing / BOM problem"
assert hold["wh"]["note"] == "Asked Leo" and hold["wh"]["auto"] is False
assert hold["wh"]["by"]["display_name"] == "Wade Warehouse" and hold["wh"]["at"]
inc = b["columns"]["incoming"][0]
assert inc["visual_rel"] == "2026-11-01" and inc["wh_sent_at"]
assert inc["bom_total"] == 3 and inc["bom_pulled"] == 0 and inc["bom_missing_heat"] == 0
shorts = b["shorts"]
assert [(x["packet"]["id"], x["item"]) for x in shorts] == [(p4["id"], 4), (p1["id"], 1)]
late, plain = shorts
assert late["late"] is True and plain["late"] is False
assert float(late["qty"]) == 4 and float(late["qty_open"]) == 1 and late["reason"] == "On order"
assert late["ref"] == "PO-1" and late["eta"] == "2020-01-02" and late["age_days"] == 0
assert late["part_id"] == "STUD-58" and late["description"] == "5/8 stud bolt"
assert late["short_id"] and late["row_version"] >= 1 and late["bom_line_id"] and late["opened_at"]
pk = late["packet"]
assert pk["status"] == "warehouse" and pk["wh_stage"] == "pulling" and pk["holder"] == "Warehouse"
assert pk["dept"]["code"] == "03" and pk["station"] is None and pk["drawing"] == "D-OTHER"
assert plain["eta"] is None and plain["material"] == "A106-B" and plain["length"] == "20ft"
def board(**params: Any) -> dict[str, Any]:
r = wade.get("/api/warehouse/board", params=params)
assert r.status_code == 200, r.text
out: dict[str, Any] = r.json()
return out
def ids(bd: dict[str, Any]) -> set[int]:
return {x["id"] for v in bd["columns"].values() for x in v}
by_project = board(project_id=proj_b)
assert ids(by_project) == {p4["id"]} and [x["packet"]["id"] for x in by_project["shorts"]] == [p4["id"]]
assert by_project["kpis"]["waiting"] == 0 and by_project["kpis"]["pulling"] == 1
assert by_project["kpis"]["hold"] == 0 and by_project["kpis"]["open_shorts"] == 1
by_unit = board(unit_id=unit_a)
assert ids(by_unit) == {p1["id"], p2["id"], p3["id"], p5["id"]}
assert by_unit["kpis"]["waiting"] == 2 and by_unit["kpis"]["pulling"] == 1 and by_unit["kpis"]["hold"] == 1
by_q = board(q="d-hold")
assert ids(by_q) == {p3["id"]} and by_q["shorts"] == []
assert by_q["kpis"] == b["kpis"] # the KPIs follow the project and unit only
by_dept = board(dept="06")
assert ids(by_dept) == set() and by_dept["shorts"] == [] and by_dept["kpis"] == b["kpis"]
assert ids(board(dept="03")) == ids(b)
assert proj_a != proj_b
# Every card of the packet list carries the warehouse fields
cards = {x["id"]: x for x in wade.get("/api/packets", params={"status": "warehouse"}).json()["packets"]}
c4 = cards[p4["id"]]
assert c4["wh"]["stage"] == "pulling" and c4["wh_stage"] == "pulling"
assert c4["bom_total"] == 3 and c4["bom_pulled"] == 1 and c4["bom_missing_heat"] == 1
assert "wh_sent_at" in c4 and "visual_rel" in c4
# A removed packet's shorts are not listed
with app_conn(s) as conn:
conn.execute("UPDATE packets SET removed_at = now() WHERE id = %s", (p4["id"],))
after = board()
assert p4["id"] not in [x["packet"]["id"] for x in after["shorts"]]
assert p4["id"] not in ids(after)
def test_board_needs_a_session(shop: Shop) -> None:
as_user(shop.main, "weld.andy")
r = shop.main.get("/api/warehouse/board")
assert r.status_code == 200 and r.json()["kpis"]["waiting"] == 0
anon = TestClient(shop.main.app, headers=ORIGIN_HEADERS)
assert anon.get("/api/warehouse/board").status_code == 401