Phase 4: Warehouse screen, pull table, shorts, MTRs and the upload pipeline
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Vendored
+19
@@ -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"
|
||||
|
||||
Vendored
+19
@@ -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,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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"}`;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
@@ -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)}"'
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user