Phase 3: VISUAL feed and M-Files drawings in the worker
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m12s
ci / image (push) Successful in 46s
ci / e2e (push) Successful in 1m24s

Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource
map, job tables, order-derived columns with provenance; work order key
includes the operation sequence). The real worker on APScheduler with
settings re-read each fire, pause, run now, advisory locks, job_runs and a
heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache,
suspect-batch guard, the apply in one transaction: drafts, change orders,
retire-moved, work order gone, closed and cancelled observed, projects and
units from order links, auto-release) and the fingerprint. The drawing-lookup
job (queue, budget, download on version change, sha256 into assets, decision
table, staging for rev approval, drawing-origin change orders). Clerical From
VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin
Feed settings and resource map. Synthetic fixture generator and a
fixture-driven worker in CI with a Playwright scenario. Docs updated.

Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI
stack, 76 frontend tests, doctor at 0004; one feed and one drawing run
against VISUAL DEV and the vault from the developer machine (counts in the
changelog).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 14:45:38 -06:00
co-authored by Claude Fable 5.1
parent 16950055e3
commit 591fe90076
103 changed files with 16063 additions and 151 deletions
+6
View File
@@ -5,3 +5,9 @@
# deploy/ci/ci.env -> CI and local end-to-end runs (committed, no secrets)
# Passwords and keys are never put in an env file. They are files in SECRETS_DIR,
# mounted as compose secrets. See docs/DEPLOY.md.
#
# Worker sources (phase 3). The defaults read VISUAL through VisualKit and M-Files through MFilesKit:
# FEED_SOURCE=visualkit visualkit | fixture
# 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.
+14
View File
@@ -150,6 +150,20 @@ jobs:
- name: Throwaway secrets
run: bash deploy/ci/make-secrets.sh
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# The worker reads these through FEED_SOURCE=fixture and DRAWING_SOURCE=fixture (deploy/ci/ci.env).
# The generator needs pypdf only, so nothing else is installed here.
- name: Generate feed fixtures
run: |
set -euo pipefail
python -m pip install pypdf==6.21.0
python -m tests.fixtures.generator --out tests/fixtures/generated
# The Playwright container writes state; the worker reads as uid 10001.
chmod -R a+rwX tests/fixtures/generated
- name: Stack up (app, worker, postgres, Samba DC)
run: $COMPOSE up -d --build --wait --wait-timeout 300
+1
View File
@@ -28,6 +28,7 @@ secrets/
# Real job data stays out of git
tests/fixtures/local/
tests/fixtures/generated/
reference/
recordings/
+13
View File
@@ -6,6 +6,19 @@ All notable changes to this project are recorded here. The format follows [Keep
### Added
- 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.
- `feed/fingerprint.py`: a canonical hash over legs, quantity, rev, drawing and BOM (major) and target hours and want date (minor), with per-field tests and invariance tests.
- The `drawing-lookup` job: a prioritised queue, a per-run budget, a new MFilesKit lookup per run, download only when the M-Files object or file version changed, sha256 into assets, pages through pypdfium2, the decision table (attach, rev mismatch staged for Clerical Supervisor approval, not released with the drafting status, not found, lookup failed with backoff, non-numeric rev), the H-sheet note, drawing-origin change orders for a re-released file or a newer rev on an in-shop packet, and the hand drawing taken off on a new VISUAL rev.
- Clerical: the From VISUAL panel (run now, status, last ten runs, ignored work orders with pull in, unmapped resources, projects without names), the Changes to approve column and KPI, flag kinds in the Flagged column and the find bar, rev-mismatch approval with reason and signature, hand upload of a drawing or a BOM (XLSX or CSV parsed on the server) on flagged drafts with reason and signature, change-order approve, reject and hold with signature, and the project's VISUAL orders with a pin.
- Admin: the Feed settings tab (intervals, shop hours, window, go-live date, departments, drawing recheck and budget, pause, next run, last ten runs) and the VISUAL resources editor. Every change is audited.
- `/api/feed/*`, `/api/jobs/*`, `/api/admin/feed/*`, and under `/api/packets/{id}`: `rev-approve`, `hand/drawing`, `hand/bom`. `GET /api/packets` gains `co=pending` and `flag=<kind>`.
- `packetstation job run <name>` and `packetstation fixtures use N`. Settings `FEED_SOURCE`, `DRAWING_SOURCE` and `FIXTURE_DIR`; a production stack refuses a fixture source.
- `tests/fixtures/generator.py` writes a synthetic feed in the exact VisualKit contract shape with the edge cases from the plan; CI feeds the worker from it and the Playwright scenario drives a feed run, a drawing attach and a change order on an in-shop packet.
- docs/FEED.md written in full; docs/SCHEMA.md, DEPLOY.md, POLICY.md and SECURITY.md updated.
- Integration run from a developer machine against VISUAL DEV and the vault (read-only, 2026-10-16): `visual-feed` ok against `VSQL\DEV / PESCOINCDEV`: 994 firmed work orders in the window, 4,090 operation rows, 13,465 material lines, 100 customer orders with 102 links, 474 new drafts, 47 units and 18 projects created, 232 lettered-lot rows skipped (lots such as 6A make no unit, as in the old build), no unmapped resources, no errors. `drawing-lookup` ok against the vault: 60 drawings looked up and fetched (the per-run budget), 213 drafts got their drawing attached, 68 flagged rev mismatch, none not found or failed. Nothing was written to VISUAL or M-Files.
- Phase 2: core domain. Migration 0003 with projects, project aliases and renumber history, units, packets and every child table (work orders, pages, BOM lines, shorts and receipts, welds, heat tags, heat table placements, stages, warehouse stage rows, transitions, Q.C. inspections and history, seals, packet log, VISUAL state), change orders with items, required departments and verifications, assets, packet documents, Q.C. documents, ITPs, user station limits and the department weld flag. Append-only triggers, soft-delete columns with partial unique indexes, server-assigned counters on packets.
- The policy engine in Python (`server/packetstation/policy/`): Principal, PacketState, the Action enum, one rule per action, custody, named preconditions, the change-order hold, and the order of checks from the plan. Every packet read returns `can`, `blocked` with reasons, and `holder`. docs/POLICY.md lists the rules and the tightenings against the old build for Seth.
- The write pipeline: one transaction per write with the packet row locked, the policy decision, compare-and-set on the expected status and department (409 "someone else already took this step" with the current state), If-Match row versions on child rows, server-assigned weld, heat tag, page and change order numbers, field-level compare-and-set on header scalars, packet version bump, packet log and audit rows, NOTIFY. Signatures through the phase 1 helper on every action the plan marks. Admin edits need an IT reason and are marked in the packet log and the audit. Filed packets are locked; filing writes an HMAC seal and Admin reopen breaks it with a reason.
+8 -6
View File
@@ -6,9 +6,9 @@ This repo is the rebuild of Packet Station as a server application: Docker Compo
## Status
**Rebuild in progress. Phase 2 done: core domain, policy engine, packet API, Clerical screen.**
**Rebuild in progress. Phase 3 done: the VISUAL feed and M-Files drawing lookups run in the worker.**
People can sign in, set up projects and units, make hand drafts, attach a drawing, and send a packet to the warehouse, a department or Q.C. Every packet write goes through the server policy. The warehouse, shop and Q.C. screens are still placeholders; their API exists. The old build on `M:\Production\Packet Station` stays the live system until go-live. The new system starts empty; nothing is migrated from M:.
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:.
| Area | Exists yet? |
|---|---|
@@ -16,7 +16,7 @@ People can sign in, set up projects and units, make hand drafts, attach a drawin
| Image, compose, CI, `doctor`, migration runner, security headers, build version endpoint | Yes (phase 0, image and stack verified on docker-1) |
| Sign-in (AD password, PIN on registered devices), sessions, lock screen, Account settings, Admin shell, audit, SSE | Yes (phase 1, verified against the Samba DC in the CI stack on docker-1) |
| Schema, policy engine, packet API, projects and units, assets, Clerical screen, packet page | Yes (phase 2, verified against PostgreSQL and the Samba DC in the CI stack on docker-1) |
| VISUAL feed and drawings | No (phase 3) |
| VISUAL feed, 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 |
| Project Management, Admin, notifications, backups | No (phases 8 and 9) |
| Regression bench and go-live | No (phase 10) |
@@ -78,7 +78,9 @@ docker compose -p packetstation-test --env-file /etc/packetstation/test/stack.en
Phase 2 adds `/api/projects`, `/api/units`, `/api/packets` (header, transitions, work orders, pages, documents, BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse stage, Q.C., change orders, log, seal), `/api/assets` and `/_blob/{id}`. Every packet read carries `can`, `blocked` and `holder` from the policy. See [docs/POLICY.md](docs/POLICY.md) and [docs/SCHEMA.md](docs/SCHEMA.md).
The `packetstation` command has `doctor`, `migrate`, `app`, `worker`, `ad-groups --list` (the 50 AD group names the app expects) and `ad-refresh` (create or refresh the users from the groups' members).
The `packetstation` command has `doctor`, `migrate`, `app`, `worker`, `job run <name>` (one job in the foreground), `fixtures use N` (switch the fixture state, never on prod), `ad-groups --list` (the 50 AD group names the app expects) and `ad-refresh` (create or refresh the users from the groups' members).
Phase 3 adds the worker (`packetstation worker`: APScheduler, the `visual-feed`, `drawing-lookup` and `housekeeping` jobs, settings re-read on every fire, run now through `job_requests`), the VISUAL and drawing cache tables (migration 0004), `/api/feed/*`, `/api/jobs/*`, `/api/admin/feed/*`, rev approval and hand uploads under `/api/packets/{id}`, and the synthetic fixture generator (`python -m tests.fixtures.generator --out DIR`) that CI feeds the worker with. See [docs/FEED.md](docs/FEED.md).
CI stack with the Samba domain controller and the Playwright scenario, on a Docker host:
@@ -97,9 +99,9 @@ Server tests that need PostgreSQL run when `TEST_PG_SUPERUSER_URL` is set; the o
- [docs/PLAN.md](docs/PLAN.md): the approved phased plan and open questions.
- [docs/SECURITY.md](docs/SECURITY.md): old layers and where they live now, the new controls, the requirement ID table.
- [docs/DEPLOY.md](docs/DEPLOY.md): host, stacks, secrets, NPM proxy host, doctor, backups.
- [docs/FEED.md](docs/FEED.md): the VISUAL feed and drawing jobs (phase 3).
- [docs/FEED.md](docs/FEED.md): the worker, the VISUAL feed, the packet rules, the fingerprint, the drawing decision table, the flags and the feed settings.
- [docs/POLICY.md](docs/POLICY.md): who may do what, the preconditions, the order of checks, concurrency, and the tightenings against the old build.
- [docs/SCHEMA.md](docs/SCHEMA.md): the database schema (phases 1 and 2; phase 3 adds the caches).
- [docs/SCHEMA.md](docs/SCHEMA.md): the database schema (phases 1 to 3).
- [docs/GO-LIVE.md](docs/GO-LIVE.md): go-live procedure (phase 10).
- [docs/PAPER-FALLBACK.md](docs/PAPER-FALLBACK.md): what the shop does when the server is down (phase 10).
- [CHANGELOG.md](CHANGELOG.md): what changed.
+9
View File
@@ -94,6 +94,15 @@ SMTP_CA_BUNDLE=/etc/ssl/pesco/ca.pem
# true logs notifications and sends nothing.
NOTIFY_DRY_RUN=true
# ---- Worker sources ----
# CI reads the synthetic fixtures, never VISUAL or M-Files. The e2e job generates them into
# tests/fixtures/generated, which compose.ci.yaml mounts at /fixtures.
FEED_SOURCE=fixture
# fixture: drawings.<state>.json and the PDFs it names, under FIXTURE_DIR.
DRAWING_SOURCE=fixture
# Fixture folder inside the containers. The scenario writes /fixtures/state to switch states.
FIXTURE_DIR=/fixtures
# ---- VisualKit (VISUAL ERP, read only) ----
# SQL Server instance. Full name: containers have no search domain.
VISUAL_SERVER=
+7
View File
@@ -10,6 +10,10 @@
# docker compose -p packetstation-ci --env-file deploy/ci/ci.env \
# -f deploy/compose.yaml -f deploy/compose.ci.yaml down -v
#
# The worker reads the synthetic feed and drawings fixtures (FEED_SOURCE=fixture, DRAWING_SOURCE=fixture in
# ci.env) from ../tests/fixtures/generated, mounted at /fixtures. Generate them first:
# python -m tests.fixtures.generator --out tests/fixtures/generated
#
# Never combine this overlay with compose.proxy.yaml.
x-logging: &logging
@@ -55,6 +59,7 @@ services:
pull_policy: build
volumes:
- ci-dc-export:/etc/ssl/pstest:ro
- ../tests/fixtures/generated:/fixtures:ro # synthetic feed and drawings; the scenario switches state
# Throwaway Samba AD DC (PSTEST.LOCAL) so LDAP sign-in runs over real TLS in CI.
# Built from deploy/samba/. Runs as root, as Samba needs. No published port.
@@ -101,9 +106,11 @@ services:
environment:
E2E_BASE_URL: http://ps-app:8000
E2E_DC_USERS_FILE: /export/users.json
E2E_FIXTURE_DIR: /fixtures
CI: "true"
volumes:
- ../tests/e2e:/work/tests/e2e
- ../tests/fixtures/generated:/fixtures # read-write: the scenario writes /fixtures/state
- /work/tests/e2e/node_modules # anonymous volume: node_modules stays out of the checkout
- ci-dc-export:/export:ro
networks:
+6
View File
@@ -36,6 +36,12 @@ TRUSTED_PROXY_HOST=npm_app_1
LOG_LEVEL=INFO
# Worker heartbeat file read by its healthcheck. /tmp is a tmpfs.
WORKER_BEAT_FILE=/tmp/packetstation-worker.beat
# Where the worker reads work orders: visualkit (the real VISUAL through VisualKit) or fixture (CI only; prod refuses it).
FEED_SOURCE=visualkit
# Where the worker looks up drawings: mfiles (the vault through MFilesKit) or fixture (CI only; prod refuses it).
DRAWING_SOURCE=mfiles
# Fixture folder inside the containers. Only read when a source is fixture.
FIXTURE_DIR=/fixtures
# ---- PostgreSQL (roles made by deploy/postgres/initdb/01-roles.sh) ----
# postgres alias on the internal backend network.
+6
View File
@@ -36,6 +36,12 @@ TRUSTED_PROXY_HOST=npm_app_1
LOG_LEVEL=INFO
# Worker heartbeat file read by its healthcheck. /tmp is a tmpfs.
WORKER_BEAT_FILE=/tmp/packetstation-worker.beat
# Where the worker reads work orders: visualkit (the real VISUAL through VisualKit) or fixture (CI only; prod refuses it).
FEED_SOURCE=visualkit
# Where the worker looks up drawings: mfiles (the vault through MFilesKit) or fixture (CI only; prod refuses it).
DRAWING_SOURCE=mfiles
# Fixture folder inside the containers. Only read when a source is fixture.
FIXTURE_DIR=/fixtures
# ---- PostgreSQL (roles made by deploy/postgres/initdb/01-roles.sh) ----
# postgres alias on the internal backend network.
+1 -1
View File
@@ -39,7 +39,7 @@ CREATE ROLE ps_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION P
CREATE DATABASE packetstation OWNER ps_migrate ENCODING 'UTF8' TEMPLATE template0;
REVOKE ALL ON DATABASE packetstation FROM PUBLIC;
GRANT CONNECT ON DATABASE packetstation TO ps_app;
GRANT CONNECT, TEMPORARY ON DATABASE packetstation TO ps_app; -- TEMP: the feed COPYs into temp tables
\connect packetstation
-- PostgreSQL 15+ already denies CREATE on public to PUBLIC. Make it explicit and give
+2 -1
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Container entrypoint. tini runs this as PID 1's child.
# Usage: app | worker | migrate [--bootstrap-roles] | doctor | version | healthcheck app|worker
# Usage: app | worker | job run <name> | fixtures use <N> [--dir DIR] | migrate [--bootstrap-roles] | doctor
# | version | healthcheck app|worker
set -euo pipefail
# MFilesKit reads its password from the environment only, so export it from the secret file.
+20 -1
View File
@@ -32,7 +32,7 @@ Both stacks run from the same image as separate compose projects.
- `postgres`: PostgreSQL 16, named volume, `backend` network only. Its init creates the database roles from secrets.
- `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. Assets volume, backup folder read-only (planned, phase 9).
- `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.
- All services restart unless stopped, log to stdout (json-file), and have healthchecks.
## Secrets
@@ -52,6 +52,8 @@ Docker secrets, one file each, under `/etc/packetstation/<stack>/secrets/`. Owne
Never put a secret in the repo, the env file, a log or chat. Settings read secrets through `*_PASSWORD_FILE` paths.
The worker gets `postgres_app_password` (it logs in as the app role, with application name `ps-worker`), `visualkit_password`, `mfileskit_password`, `ldap_bind_password` and `seal_key`. The worker never logs a password: job errors are redacted before they are stored, and the redaction also strips the VISUAL and M-Files passwords themselves.
The PESCO CA chain is mounted into the containers once and referenced by `LDAP_CA_BUNDLE`, `MFILES_CA_BUNDLE` and `SMTP_CA_BUNDLE`.
## Env files
@@ -66,8 +68,14 @@ Key settings:
- `SMTP_HOST=app-03.pesco.com`, `SMTP_PORT=25`, `SMTP_STARTTLS=true`, `[email protected]`, `SMTP_CA_BUNDLE`.
- `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.
- `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.
## Feed settings live in the database
The feed's schedule and rules are not env settings. Intervals, shop hours, the window, the go-live date, departments, the drawing recheck and budget, labor tickets, auto-release and the pause switch are one `settings` row (key `feed`), edited by an Admin on the Admin page, Feed tab. The resource map is the `visual_resource_map` table, edited on the "VISUAL resources" tab. A save notifies the worker, which applies it without a restart. A new stack starts with the defaults in [FEED.md](FEED.md), "Settings". Set the go-live date before the production feed's first run.
## Deploy command
Test stack:
@@ -158,6 +166,17 @@ The DC runs with `cap_add: [SYS_ADMIN]`, which Samba needs to set the sysvol ACL
The real PESCO domain controllers are never used by CI. The test and production stacks do not run the DC.
### CI feed fixtures (phase 3)
CI never reads VISUAL or M-Files. `deploy/ci/ci.env` sets `FEED_SOURCE=fixture`, `DRAWING_SOURCE=fixture` and `FIXTURE_DIR=/fixtures`. The image does not contain `tests/`, so the e2e job generates the fixtures on the runner before the stack starts:
```bash
python -m pip install pypdf==6.21.0
python -m tests.fixtures.generator --out tests/fixtures/generated
```
The generator is deterministic and writes `feed.1.json`, `feed.2.json`, `drawings.1.json`, `drawings.2.json`, the PDFs they name and `state` (`1`). `tests/fixtures/generated/` is ignored by git. `deploy/compose.ci.yaml` binds it at `/fixtures` into the worker (read-only) and the Playwright container (read-write, so the scenario can write `state` = `2` and press Run now again). `packetstation fixtures use N --dir DIR` switches the state by hand. The CI feed runs at the default intervals; the scenario does not wait for them, it presses Run now.
## Gitea Actions runner (phase 2)
`.gitea/workflows/ci.yml` runs on a Gitea Actions runner named `docker-1`, installed on docker-1 at
+394 -11
View File
@@ -1,43 +1,426 @@
# Packet Station feed and drawings
Status: planned, phase 3. This is a stub. It lists what the approved plan says this document will contain. See [PLAN.md](PLAN.md), "Worker, feed and drawings".
Status: phase 3. This file says how the worker reads VISUAL and M-Files and what it does with what it reads. The design is in [PLAN.md](PLAN.md), "Worker, feed and drawings". When this file and the code disagree, the code is the rule and this file is out of date.
## Purpose
The worker reads VISUAL through VisualKit and M-Files through MFilesKit on a schedule. It caches the rows, applies the packet rules and works the drawing queue. Screens read PostgreSQL only.
The worker is the only part of Packet Station that talks to VISUAL and M-Files. It reads VISUAL through VisualKit and M-Files through MFilesKit, both read-only, on a schedule. It stores what it read in cache tables, turns the rows into projects, units, draft packets and change orders, and works the drawing queue. Screens read PostgreSQL only.
There are three reasons for a scheduled job instead of reads per screen:
- VISUAL production must not be queried on page loads. One modest query every few minutes from one place is fine. One per user per screen is not.
- Turning VISUAL rows into packets is a state change. It runs in one place, in one transaction, with an audit row.
- Drawing lookups and downloads are slow. They belong in the background.
The worker never writes to VISUAL or M-Files.
## Worker process
`packetstation worker` runs APScheduler. One run per job at a time, guarded by a PostgreSQL advisory lock per job. Settings are re-read on every tick, so an Admin change applies without a restart. The pause switch stops scheduled feed and drawing runs. "Run now" still works.
`packetstation worker` starts the worker. It runs the stack guard, builds the scheduler and blocks. The main thread writes the heartbeat and waits for a stop signal.
### Scheduler
- APScheduler 3 with a pool of 4 threads. Each job has `coalesce` on, `max_instances` 1 and a misfire grace of 300 seconds.
- Times are scheduled in site time (`SITE_TZ`, default `America/Denver`) and stored in UTC.
- Jobs are registered in `worker/jobs/__init__.py`. Each job has a name, a run function, a cadence function and a `pausable` flag. A job can name others to chain after it: `visual-feed` chains `drawing-lookup`, so every feed run is followed by a drawing run.
- The cadence function returns the seconds to the next run, from the feed settings and the current site time.
### Settings re-read
The feed settings live in the database (see "Settings"), not in the env file. The worker keeps one connection that listens on two channels:
- `ps_settings`: the app sends it when an Admin saves the Feed tab. The worker reloads the settings and recomputes the next run of every job. A new interval or the pause switch applies at once, without a restart.
- `ps_job_request`: see "Run now" below.
The listening connection reconnects with backoff when it drops.
### Pause
When `paused` is on, scheduled fires of the pausable jobs (`visual-feed`, `drawing-lookup`) are skipped. Each skip is recorded as a `job_runs` row with status `skipped` and counts `{"reason": "paused"}`. Auto-release does not run while paused. "Run now" still works while paused. Housekeeping is not paused.
### Run now and job_requests
1. Clerical presses **Run now** on the From VISUAL panel (or an Admin on the Feed tab). The app inserts a `job_requests` row and sends `NOTIFY ps_job_request` with `{"job": "<name>"}` in the same transaction.
2. The worker moves that job's next run to now.
3. The run claims every pending request for the job at its start. Ten clicks make one run. The run's trigger is `request` when it claimed any.
4. A request that arrives while the job is running is left pending, and the job runs again straight after.
### Advisory lock
Each run opens its own connection and takes `pg_try_advisory_lock(7310, hashtext(<job name>))`. When the lock is held, the run records status `skipped` with counts `{"reason": "already running"}` and stops. One run per job at a time, whether the trigger was the schedule, a request, a chain or the command line.
### job_runs
Every run writes one `job_runs` row:
- `trigger`: `schedule`, `request`, `chain` or `cli`.
- `requested_by`: the users whose requests the run claimed.
- `started_at`, `finished_at`.
- `status`: `running`, then `ok`, `degraded` (finished but part of it failed, for example the order read), `failed` (an exception), `skipped` or `abandoned`. On start the worker marks its own host's rows still `running` as `abandoned`.
- `counts`: a JSON object (below).
- `errors`: at most 20 strings of at most 500 characters each, redacted. Redaction strips anything that looks like `password=...`, `pwd=...` or an `Authorization:` header, and the VISUAL and M-Files passwords themselves when they are in the environment.
- `build_version`, `host`, and `visual_target` (`"<server> / <database>"`) on feed runs.
Feed run counts, all integers: `wos_window`, `wos_tracked`, `rows`, `materials`, `orders`, `labor_legs`, `new_drafts`, `drafts_updated`, `cos_opened`, `cos_reaimed`, `cos_withdrawn`, `retired`, `wo_gone_flagged`, `closed_observed`, `ignored_golive`, `projects_created`, `units_created`, `auto_released`, `unmapped_resources`, `skipped_removed`, `skipped_closed` and `suspect` (0 or 1). `counts["unmapped"]` holds `{resource: rows}` for the top 30 unmapped resources.
Drawing run counts: `checked`, `fetched`, `unchanged`, `attached`, `flagged` (`{kind: n}`), `lookup_failed`, `cos_opened`, `staged` and `budget_left`.
`packetstation job run <name>` runs one job once in the foreground with trigger `cli`, prints its `job_runs` row as JSON, and exits 1 when the run failed.
### Heartbeat
The worker writes the `worker_heartbeat` row every 30 seconds (time, build, host, pid and the jobs running now), and touches the beat file the container healthcheck reads. The From VISUAL panel shows "Worker not running" when the heartbeat is stale.
### Stack guard
At start the worker runs the same stack guard as the app: the test stack refuses a production VISUAL target, and the production stack refuses a VISUAL target that was not set explicitly. Before every feed run the guard runs again with fresh VisualKit settings. A failed guard fails the run. The run records the target it read.
## Jobs and cadence
visual-feed (default every 5 minutes inside shop hours, 30 outside), drawing-lookup (after every feed run, plus its own cadence), session-refresh (15 minutes), ad-reconcile (nightly), integrity, backup-check, notify-send, notify-digest and housekeeping. Each run is recorded in `job_runs` with its counts and errors.
| Job | Cadence | Pausable | What it does |
|---|---|---|---|
| `visual-feed` | `interval_shop_minutes` inside shop hours (default 5), `interval_off_minutes` outside (default 30) | yes | The five reads, the cache, the packet rules, auto-release. Chains `drawing-lookup`. |
| `drawing-lookup` | After every feed run, plus every `drawing_interval_minutes` (default 30) | yes | The drawing queue (see "Drawings"). |
| `housekeeping` | Daily at 03:30 site time | no | Prunes old rows. The plan keeps the caches 90 days and `job_runs` 180 days. |
Planned, not built in phase 3:
- `session-refresh` (every 15 minutes): re-resolve groups and the account state for every live session.
- `ad-reconcile` (nightly 01:30 site time, plus "refresh from AD now"), phase 9.
- `integrity` (04:00): seals, asset hashes, missing and orphan files, the audit hash chain. Phase 9.
- `backup-check` (07:15): the newest dump is under 26 hours old. Phase 9.
- `notify-send` (every 60 seconds) and `notify-digest` (daily at the digest time). Phase 9.
`job_schedule` holds each job's next run, interval and pause state for the Admin tab. Migration 0004 seeds rows for `visual-feed`, `drawing-lookup` and `housekeeping`.
## Feed reads
Five reads per run: released work orders in the window (firmed only, `until` always passed), every unfiled packet's work orders by key, materials for both sets, order links, and labor tickets if the timer decision keeps them. A work order that leaves the window is not a removal.
Each feed run opens one VisualKit connection (`Visual.from_env()`), does five reads outside any database transaction, and closes it. Every value goes through one parser, so VisualKit and the fixture source produce the same data. The site is `SITE_ID` (default `PESCO`) in every call.
The window comes from today in site time: `since` is today minus `window_back_days` (default 14), `until` is today plus `window_ahead_days` (default 30).
1. **Released work orders in the window.** `v.work_orders.released(site, since, until, status=("F",))`, paged. Firmed only. `until` is always passed. This read creates and updates drafts.
2. **Tracked work orders by key.** `v.work_orders.get_many(keys)` for every work order key on a live packet that is not filed, plus every ignored work order Clerical pulled in. No status filter and no date filter, so a packet already in the shop keeps getting changes and its closed state. Keys VISUAL no longer returns are collected as missing.
3. **Operations, text, parts and materials** for the union of reads 1 and 2: `operations_for`, `operation_text_for` (the planner instructions, called partspecs), `parts.get_many` for descriptions. Materials: `work_orders.for_bases(bases, type="W", status=("F", "R"))`, `requirements_for`, `requirement_text_for`, `parts.get_many` and `parts.sites_for`. Materials are known per leg: the legs `for_bases` returned are the legs whose BOM the run knows (`mat_known`). A closed leg's materials are not read, and that does not look like a removed BOM.
4. **Order links** by work order base and by project, through `v.links.orders_for_bases`, `v.links.orders_for_projects`, `v.orders.get_many` and `v.orders.customers`. When this read fails, the run is degraded and the apply keeps using the last cached orders.
5. **Labor tickets**, when `labor_tickets` is on and the last labor read is more than 30 minutes old: `v.labor.tickets_for_work_orders(keys, since=...)`, summed to hours and ticket count per leg and operation. Employee 9999 is left out (VisualKit's default). No cost column is ever read.
A work order that leaves the window is not a removal. Once a packet tracks it, read 2 keeps reading it whatever its date or status.
### Suspect batch
A batch is suspect when:
- the window read returned zero rows and the previous batch had some, or
- more than 5 tracked keys are missing and they are more than 10% of the tracked keys.
A suspect batch is still cached and applied, but without the "gone" marking in the cache and without the "work order gone" step. The run is degraded and the reason is in its counts.
## Cache
`feed/cache.py` writes one batch per run, in one transaction:
- **Batch.** A `visual_batches` row: run, source (server and database, or the fixture state), site, window, window rows, tracked keys, missing keys, suspect, counts.
- **Rows.** The rows are copied into a temporary table and merged into `visual_rows`, keyed on the typed key (base, lot, split, sub, seq). A new row gets `first_batch` and `last_batch`. A row whose hash changed is updated. A row seen again moves `last_batch`. `row_hash` is a sha256 of the canonical row (the same canonical form as the fingerprint).
- **Materials.** The same for `visual_materials`, keyed on the typed key plus piece, with `mat_hash`.
- **Change rows.** Each insert, change, return and disappearance writes a `visual_row_changes` or `visual_material_changes` row for the batch, with the row before and after. Kinds: `added`, `changed`, `gone`, `returned`. Admin reads these as "what VISUAL changed".
- **Gone marking.** A cached row that is in play (its key is tracked, or it is firmed with its release date inside the window) and was not in this batch gets `gone_batch` and a `gone` change row. Materials are in play when their base is. A row that comes back clears `gone_batch` and gets a `returned` change row. A suspect batch marks nothing gone.
- **Orders.** `visual_orders` is upserted and `visual_order_links` is replaced for the bases and projects read.
- **Labor.** `visual_labor` is upserted per leg and operation.
The apply then reads the rows back from the cache, so it works on one consistent snapshot.
## Packet rules
One packet is drawing + lot + operation. The project comes from the work order prefix. The unit is project + 3-digit lot. New drafts are created, drafts are updated in place, packets past draft get change orders, removed packets are never recreated, and untouched drafts whose legs all moved are retired. A work order gone from VISUAL is flagged, not removed.
### One packet
One packet is one drawing, one lot and one operation (resource). Its match key, `packets.visual_key`, is `<unit>|<resource>|<drawing>`, with `(no drawing)` when the drawing is blank. Several work order operations (legs) can share one packet.
### Resource map
Each VISUAL resource maps to a shop department code, `fold` or `ignore` (`visual_resource_map`, seeded from the old build's `visual_map.json`, edited on the Admin "VISUAL resources" tab).
- A department code makes packets in that department.
- `fold` makes no packet. The row's operation is listed on the packets of the same work order and lot as a folded operation. A resource starting `MAT_` with no map entry is fold.
- `ignore` makes nothing.
- A resource with no entry makes nothing and is counted under `unmapped`, so IT can assign it.
- When `departments` is set in the feed settings, rows routed to other departments are skipped silently.
### Project and unit
- The project number is the work order base's prefix as the old build took it.
- The unit is the project number plus the lot zero-padded to 3 digits (`L900` and lot `1` make `L900001`).
- A lot that is not all digits (lot `7A`) makes no candidate. The old build dropped them the same way. Hand units may still use such a lot.
- A project or unit that does not exist is created (see the apply, step 1).
### Which rows take part
A row takes part when it routes to a department and either its key is tracked (any status, any date), or it is firmed with a release date inside the window. A row that is not tracked and was released before the go-live date is set aside (see "Go-live boundary").
### The candidate
Rows are grouped by match key. For each group:
- Legs are deduplicated on (key, seq).
- `rel` is the earliest release date, `want` the latest want date.
- `rev` is the highest numeric rev among the legs. A non-numeric rev only wins when there is no rev yet.
- `title` is the first leg's description.
- `folded` lists the fold rows of the same work order and lot. `partspecs` lists the distinct planner instructions of every row of the same work order and lot, in first-seen order.
- The BOM is built from the materials (the old `matBom`):
- A line belongs to a leg when its drawing is blank or matches the leg's drawing, ignoring spaces and case.
- Per piece the best status wins: R, then F, then U, then anything else.
- Lines without a part are dropped, except as group headers: a piece that is a multiple of 10 heads the next 9 pieces.
- The item is the piece number, with `-<last part of the work order>` added on a repeat.
- A line is a sub-assembly and n/a when its commodity is WIP or its warehouse is ASY.
- The location is the warehouse (unless MAIN) and the location, joined by a space.
- When no leg of the candidate has known materials, the BOM is unknown and the packet's BOM is left alone.
### The apply
The apply runs in one transaction with a 5-second lock timeout. A packet row locked by someone else is skipped and picked up next run. Steps, in order:
1. **Projects and units.** Find each candidate's project through the alias table, so renames resolve. Create a missing project (source `visual`, no name, `needs_details` on, due = the latest want date) and its alias. Create a missing unit the same way. Fill the order-derived fields (see "Several open orders"). Candidates under a closed project or unit are skipped (`skipped_closed`).
2. **Match.** Look up every candidate key among packets, removed ones included. A key whose only packets are removed makes nothing, ever (`skipped_removed`). A key with a live packet is existing. Anything else is new.
3. **New drafts.** Apply the go-live boundary first. Then insert the draft: header, work orders per leg (qty, target hours, op, status, partspecs, drawing, rev), BOM lines (source `visual`), the visual state row, `drawing_state` `needed` and flag `drawing_needed` (or `none` and flag `no_drawing` when the drawing is blank), the fingerprint. Log "Packet created from VISUAL" by "VISUAL feed". Audit `packet.created` with role `system`.
4. **Drafts updated in place** when the fingerprint or the status facts changed (action `vis_update_draft`):
- Work orders are replaced, keeping rows whose key and seq match.
- The BOM is replaced unless Clerical uploaded one by hand.
- When the drawing or the rev moved: `drawing_state` goes back to `needed`, every drawing page is taken off (the file stays in assets), a hand drawing is taken off, any approved rev is cleared, the drawing flags are cleared and `drawing_needed` is added.
- The fingerprint, want and release dates, folded operations and partspecs are stored.
- Log "Updated from VISUAL" with the lines that changed ("work order X added", "X target 1.5 h -> 2 h", "drawing rev 1 -> 2", want date, material lines).
5. **Past draft: change orders** (action `vis_open_co`). See "Change orders from the feed". Filed packets get nothing.
6. **Retire moved drafts** (action `vis_retire_draft`). A live draft made by the feed, with no drawing pages and no hand uploads, that is not among this run's candidates, and whose every leg now sits on another live candidate, is soft-removed with reason `moved`. `visual.moved` records where the legs went. Log "Retired: work orders moved to another packet". It is hidden from the boards and, being removed, is never recreated.
7. **Work order gone.** Skipped on a suspect batch. Each tracked leg VISUAL did not return gets `missing_runs + 1`. At 2 the leg gets `gone_at`, the packet gets flag `wo_gone` and `wo_gone_at`, and the log says "Work order <key> no longer in VISUAL". A leg that comes back resets to 0. When no leg is gone any more the flag goes and the log says "Work order back in VISUAL". The feed never removes a packet for this.
8. **Closed and cancelled.** Each leg's status code is stored, with ticket hours and the time observed. When every leg is C, `visual_closed_at` is set and the log says "Closed in VISUAL (observed)". Any leg X sets flag `cancelled` and `visual_cancelled_at`. The flag goes when no leg is X. A cancelled draft is not removed.
9. **Auto-release** when `auto_release` is on and the feed is not paused: up to `auto_release_max` (default 25) live drafts made by the feed with the drawing attached, no flags at all, and the rev confirmed by the drawing job. A draft whose heat map is unanswered gets "yes" when it has a real material line (not a sub-assembly, not n/a, not consumable) and "no" otherwise. Then action `vis_auto_release` moves it to the warehouse with an automatic warehouse stage row and a transition row. The policy's preconditions (heat map answered, drawing attached) still apply.
10. **Summary.** One audit row `feed.run` with the counts, and one `feed` event to the screens after commit.
Every packet write in the apply writes a `packet_log` row (by "VISUAL feed", role `system`), an audit row (`packet.<action>`, role `system`) and a change event, through the same rules as a person's write (see [POLICY.md](POLICY.md), "Worker writes").
## Fingerprint
A canonical hash over the fields a packet is built from. Major parts (legs, qty, rev, drawing, BOM) open a change order. Minor parts (tgt, want) need Clerical approval only. Tests show what each field does.
The fingerprint is a canonical hash over the fields a packet is built from. `feed/fingerprint.py` computes it per candidate. `packets.visual_fp_applied` stores the full hash, and `packets.visual.fp_parts` stores the hash of each part, so the next run can say which parts changed.
Canonical form: text in NFC, line ends as LF, trimmed; null and empty text alike; IDs (work order base, part, drawing, lot, split, sub) upper-cased; lot, split and sub kept as text; seq and piece as integers; numbers normalised (`2` and `2.0` are the same); dates in ISO form; lists sorted (legs by base, lot, split, sub, seq; BOM by item). JSON with sorted keys, then sha256.
| Field | Part | What a change does past draft |
|---|---|---|
| Legs (work order keys and operations on the packet) | major | Opens or re-aims a change order |
| Quantity per leg | major | Opens or re-aims a change order |
| Rev | major | Opens or re-aims a change order, waiting for the drawing at the new rev |
| Drawing number (upper-cased, spaces removed) | major | Opens or re-aims a change order, waiting for the new drawing |
| BOM (item, part, qty per line) | major | Opens or re-aims a change order |
| Target hours per leg | minor | Opens a minor change order: Clerical approval only, no department verifies |
| Want date | minor | Opens a minor change order |
| Release date | never | Nothing |
| Folded operations | never | Nothing |
| Partspecs (planner instructions) | never | Nothing |
| Title | never | Nothing |
| Work order status | never | Nothing (status is observed separately: closed, cancelled) |
| Issued quantity, warehouse, location | never | Nothing |
| Orders, labor, department, operation | never | Nothing |
On a draft any fingerprint change updates the draft in place, major or minor. A change order is only for packets past draft. Tests in `tests/server/test_fingerprint.py` show each row.
## Go-live boundary
Work orders released before the go-live date are ignored and listed. Clerical can pull one in by hand.
With no migration from the M: install, the first run would make drafts for every firmed work order in the window, including jobs already running on paper or on M:. The `golive_date` setting keeps them out:
- A row that is not tracked and was released before the go-live date makes no candidate.
- A candidate none of whose legs is tracked and whose earliest release is before the go-live date is not created. Its legs go into `visual_ignored_wos` (`ignored_golive`).
- The From VISUAL panel lists the ignored work orders, newest release first. **Pull in** asks a reason, records who and when, and requests a feed run. A pulled-in work order is tracked from then on, so the next run makes its draft whatever its release date.
Jobs already open on M: finish there.
## Change orders from the feed
A packet past draft (warehouse to complete) is never changed silently. A fingerprint change opens a change order instead.
- **Open.** A changed fingerprint opens a change order with origin `visual`, review `pending`, ECN `VISUAL`, issued by nobody. It carries `vis` (from and to drawing and rev, the list of changes in words, the legs, the BOM, want and release dates, folded operations, partspecs and the fingerprint parts), `target_fp`, `minor`, and `waiting` (`{dwg, rev, since}`) when the drawing or rev changed and the new drawing is still needed.
- **One at a time.** At most one pending feed change order per packet (origin `visual` or `drawing`). A further change re-aims it: reason, `vis`, `waiting`, `target_fp` and `minor` are updated, the move is appended to `vis.moved`, and a staged drawing is kept when the target drawing and rev did not change.
- **Unchanged.** A pending change order with the same target does nothing. A rejected one with the same target does nothing either: Clerical rejected exactly this.
- **Withdraw.** When VISUAL goes back to what the packet has, the pending change order is withdrawn: review `rejected`, rejected by nobody, with the reason in the log.
- **Follow on.** An approved change order with the same target does nothing. A change after an approval opens a fresh change order.
- **Minor.** When only target hours or the want date changed, the change order is minor. It needs Clerical approval only. On approval no department is asked to verify.
- **Hold.** A pending change order holds nothing unless Clerical puts it on hold. While held it blocks the packet's held steps (see `v_open_cos` in [SCHEMA.md](SCHEMA.md)).
- **Approval.** Clerical approves with a signature. When the drawing changed, approval needs the new drawing staged first ("The new drawing has not been fetched from M-Files yet."). Approval applies `vis`: work orders from `vis.legs`, the BOM unless it was uploaded by hand, the header drawing and rev, the fingerprint, want and release dates. A staged drawing replaces the old drawing pages: the old pages are voided (never deleted) and the new pages added, with the page changes in `co_items`. A major change order then asks the departments it touches to verify, and holds the packet until they have. A minor one asks nobody.
- **Reject.** Clerical rejects with a reason and a signature.
A packet at `complete` still gets a change order. A filed packet gets nothing.
## Drawings
Drawing lookups go through MFilesKit with the matching rules in MFilesKit's `docs/packet-station.md`. Those docs are stale in two lines (drafting rev precedence and the project-folder rule). The code is the rule, and this document will state the code's behaviour.
The `drawing-lookup` job finds each packet's drawing in M-Files through MFilesKit, downloads it, and attaches it to drafts.
### How MFilesKit finds a drawing
This is what `mfileskit/drawings.py` does. MFilesKit's own `docs/packet-station.md` is stale in two places, noted below.
- **Key.** Hyphens and spaces removed, upper-cased: `L073-76-2` becomes `L073762`.
- **Search by Name property, not folders.** The released PDF is searched in class "Drawing Production (PDF)" by the Name property starting with the key, and a hit counts only when its name is the key or the key plus `X`. There is no project-folder rule any more (the `P-521` to `H777` mapping in the MFilesKit docs is gone).
- **Released rev.** Only a document whose Controlled Status (or workflow state) is Effective counts. The rev is its Controlled Version Label, else the number after `R` in its title. The highest released rev wins.
- **Drafting rev.** Working copies are searched by Controlled Version Title. Copies in a done status (released, effective, retired, obsolete) or a done state (drawing released to shop, drawing already exists, cancel drawing) are skipped. The drafting rev is the highest label among the rest. Its workflow state maps to "still being drawn (unapproved)" or "in the drafting check cycle". (The MFilesKit docs still say "the higher of the Unapproved rev and the Check Cycle rev"; that is stale.)
- **In drafting.** A Drafting Assignment named for the key means the drawing is in drafting.
- **H sheets.** A drawing like `K978-01-6H13` has no object of its own. When nothing is found for it (no released PDF, no working copy, no assignment), the lookup tries the base drawing `K978-01-6` and reports `base`. The packet note then says "sheet of drawing K978-01-6".
- A search that hits the 500-result cap is a failed lookup, so a wrong rev is never reported.
### Queue and priorities
The queue covers live packets made by the feed, not filed, with a drawing number, in the departments in scope. It is distinct by drawing number, and the first item's priority wins:
1. The VISUAL rev changed since the last lookup of that drawing, or a pending change order waits for a drawing at a target rev.
2. Drafts with `drawing_state` `needed`.
3. Failed lookups whose backoff is over (15 minutes doubled per failure, at most 24 hours).
4. Rechecks due: last checked more than `drawing_recheck_hours` ago (default 4).
### Budget
At most `drawing_budget` lookups per run (default 60). The rest wait for the next run. The worker opens one M-Files vault per process and a new `DrawingLookup` per run, so its cache never outlives a run. When the vault cannot be opened, the run is skipped and degraded with one error.
### Download
The file is downloaded only when the object id and version or the file id and version differ from the last lookup, or nothing is stored yet. The download goes to a temporary file, is checked for the PDF magic bytes and the upload size limit, hashed with sha256 while streaming, then fsynced and renamed into the assets volume. A file with the same sha256 and size reuses the existing asset. Pages are counted with pypdfium2 (pypdf as a fallback). Each fetch writes an audit row `drawing.fetched`.
### Decision table
`drawings/decide.py` decides per packet sharing the drawing. Rows are checked top to bottom.
| Case | Decision | Flag | What happens |
|---|---|---|---|
| The lookup failed | `lookup_failed` | `lookup_failed` | What is attached stays. `drawing_state` becomes `lookup_failed` only if it was `needed`. Retried with backoff. |
| No released rev, but a drafting rev or a drafting assignment | `not_released` | `not_released` | Note "Not released in M-Files yet (sheet of <base>) - drafting status: rev N <where>". |
| No released rev and nothing in drafting | `not_found` | `not_found` | Note "Drawing not in M-Files - check the drawing number in VISUAL". |
| VISUAL's rev is not a whole number | `bad_rev` | `bad_rev` | Not attached. |
| Revs differ, and no approved rev matches M-Files | `rev_mismatch` | `rev_mismatch` | Note "Rev mismatch: VISUAL calls rev V, M-Files has rev N", plus the drafting status. The M-Files copy is fetched and staged for Clerical Supervisor approval. No change order: VISUAL did not change. |
| Revs match (or the approved rev matches), draft | `attach` | none | Drawing pages attached, `drawing_state` `attached`, drawing flags cleared, rev confirmed. Log "Drawing attached from M-Files". |
| Revs match, draft with a hand drawing at the same rev | `unchanged` | none | The hand drawing stays. |
| Revs match, past draft, same file (same sha256) | `unchanged` | none | Nothing. |
| Revs match, past draft, same rev but a new file | `open_co` | none | Drawing-origin change order: "M-Files re-released rev N of <dwg> (new file)". |
| Past draft, M-Files released a higher rev than the one attached | `open_co` | none | Drawing-origin change order: "M-Files released rev N of <dwg>; this packet has rev M". Never a silent swap. |
| A pending feed change order waits for this drawing at the rev M-Files now has | `stage_for_co` | none | The fetched file is staged on the change order and `waiting` is cleared. Log "Drawing for change order N ready for Clerical". |
A flag decision sets `drawing_state`, replaces the drawing flags with the decision's flag, stores the M-Files answer in `visual.appr`, and clears the rev confirmation. On a draft it is a `vis_update_draft` write. Past draft it is a `vis_open_co` write logged "Drawing status from M-Files", since that is the only system action allowed past draft.
"Drawing needed" blocks the warehouse step through the `drawing_attached` precondition.
### Rev mismatch staging and approval
When M-Files has a different released rev from VISUAL, the M-Files copy is fetched and its asset is kept on the `drawing_lookups` row. A Clerical Supervisor can approve it for one draft with a reason and a signature (action `rev_approve`). The pages are attached, the drawing flags are cleared, and `visual.rev_ok` records the rev, the VISUAL rev, who, when and why. The log says "Different rev approved by hand" with "rev N in place of VISUAL rev M". Without a staged copy the answer is "nothing staged".
### Hand drawing taken off on a new rev
A draft whose VISUAL drawing or rev moves loses every drawing page, a hand drawing included (step 4 of the apply). The drawing job then looks up the new rev. A hand drawing at the same rev is left alone.
### Drawing-origin change orders
A drawing change on a packet past draft opens a change order with origin `drawing`, ECN `M-FILES`, the new file already staged, and no target fingerprint. It counts as the packet's one pending feed change order. Approval voids the old drawing pages and adds the new ones, as for a feed change order.
## Flags
What each flag means and what Clerical does about it: rev mismatch, not released, not found, lookup failed, non-numeric rev, work order gone, cancelled, closed in VISUAL.
Flags live in `packets.flags`. A draft with any flag shows in Clerical's Flagged column. Auto-release never takes a packet with a flag. The find bar filters by flag kind.
| Flag | Text shown | What Clerical does |
|---|---|---|
| `drawing_needed` | Made from VISUAL - drawing not attached yet | Usually nothing: the drawing job attaches it. Run now to hurry it. Upload by hand only if it stays stuck. |
| `rev_mismatch` | The M-Files note, for example "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3" | Get the rev corrected in VISUAL, or a Clerical Supervisor approves the staged M-Files rev with a reason and a signature. |
| `not_released` | The note: "Not released in M-Files yet ... drafting status: rev N <where>" | Wait for drafting to release it, or upload the drawing by hand with a reason and a signature. |
| `not_found` | Drawing not in M-Files - check the drawing number in VISUAL | Check the drawing number in VISUAL and have it fixed there. Or upload by hand. |
| `lookup_failed` | M-Files lookup failed: (the reason) | Nothing at first: the job retries with backoff. If it persists, tell IT. |
| `bad_rev` | VISUAL rev is not a number - drawing not attached | Have the rev fixed in VISUAL, or upload by hand. |
| `no_drawing` | (the draft has no drawing number) | Have the drawing number added in VISUAL, or upload by hand. |
| `wo_gone` | Work order no longer in VISUAL | Check VISUAL. If the job was deleted on purpose, remove the draft by hand (or handle the in-shop packet). The flag clears itself if the work order comes back. |
| `cancelled` | Work order cancelled in VISUAL | Decide: remove the draft by hand, or leave it if the cancel was a mistake. The feed never removes it. |
| `hand_drawing` | Drawing uploaded by hand - check it and send it on | Check the drawing and send the draft on by hand. |
| `hand_bom` | BOM uploaded by hand - check it and send it on | Check the BOM and send it on. "Use VISUAL's BOM again" puts the feed's BOM back. |
With both hand flags the text is "Drawing and BOM uploaded by hand - check it and send it on". Hand drafts keep their phase 2 text.
"Closed in VISUAL" is not a flag. It is an observed state (`visual_closed_at`), shown on the packet. Filing a Data Book packet needs it, as the `vclosed_if_databook` precondition says.
### Hand uploads
On a flagged draft made by the feed (or one already uploaded by hand), Clerical can:
- **Upload a drawing** (PDF) with a reason and a signature. It replaces the drawing pages, sets `drawing_state` `hand`, clears the drawing flags and adds `hand_drawing`.
- **Upload a BOM** (XLSX or CSV) with a reason and a signature. The server parses it: the header row is found in the first 20 rows by the old build's column patterns (item, qty, part, description, length, material, drawing reference); qty and a description or part are needed; empty rows are skipped; items default to the row number. It replaces the BOM lines (source `hand`) and adds `hand_bom`. The feed then leaves the BOM alone.
- **Use VISUAL's BOM again**: rebuilds the BOM from the cache and removes `hand_bom`.
An unflagged draft refuses a hand upload ("not flagged").
## Several open orders
Open question 5 in the plan. Proposed: the open order whose top job is the packet's work order base, else the newest open order, with the source shown and a Clerical override.
A project can have several customer orders in VISUAL. One function, `feed/orders.choose`, picks the order for a project or a unit. The first rule that finds one wins:
1. **Pin.** Clerical pinned an order (a customer order ID among the project's orders) on the project or unit, with a reason. Via `pin`.
2. **Base.** The order linked to the work order base itself. Via `base`.
3. **Unit top job.** The project order whose line's top job is the unit number (open orders first). Via `unit_top_job`.
4. **Project open.** The first open order of the project (status F, H or R), newest first. Via `project_open`.
5. **Project newest.** The first order of the project, open or not. Via `project_newest`.
When nothing matches there is no order.
### Provenance
The chosen order is stored as `order_ref` on the project and the unit: order ID, `via`, status, customer name, customer PO, order, desired ship and promise dates, and when it was chosen. The fields derived from it:
| Field | From the order |
|---|---|
| customer | customer name |
| po | customer PO reference |
| order date | order date |
| ship date | promise date, else desired ship date |
For each field the feed always writes `<field>_visual`. It writes the field itself, with `<field>_src = 'visual'`, only when `<field>_src` is empty or already `visual`. A value Clerical typed (`_src = 'hand'`) is never overwritten. Each change is audited as `project.updated` with `src: visual`.
### The pin
Clerical can pin an order on a project or a unit (`order_pin`, with a reason, who and when) from the project page, which lists every cached order of the project. The feed re-derives on its next run. Clearing the pin goes back to the rules.
## Settings
The feed settings are one `settings` row, key `feed`, a JSON object. An Admin edits them on the Admin page, Feed tab (password session only). Unknown keys are ignored and missing keys take the default. Every save is validated, bumps the row version, is audited as `feed.settings_changed` with each changed key's before and after, and notifies the worker.
| Setting | Default | Range | Meaning |
|---|---|---|---|
| `interval_shop_minutes` | 5 | 1 to 1440 | Feed interval inside shop hours |
| `interval_off_minutes` | 30 | 1 to 1440 | Feed interval outside shop hours |
| `shop_days` | Monday to Friday (`[0,1,2,3,4]`) | 0 (Monday) to 6 | Shop days, site time |
| `shop_start` | `06:00` | HH:MM | Start of shop hours, site time |
| `shop_end` | `18:00` | HH:MM | End of shop hours, site time |
| `window_back_days` | 14 | 0 to 120 | Window start, days before today |
| `window_ahead_days` | 30 | 0 to 365 | Window end, days after today |
| `golive_date` | none | a date | Work orders released before it are ignored |
| `departments` | all (`[]`) | shop codes | Departments to make packets for. Empty means every department the resource map routes to. |
| `drawing_recheck_hours` | 4 | 1 to 168 | Recheck each drawing after this long |
| `drawing_budget` | 60 | 1 to 500 | Drawing lookups per run |
| `drawing_interval_minutes` | 30 | not limited | The drawing job's own cadence |
| `labor_tickets` | on | on or off | Read labor tickets, at most every 30 minutes |
| `auto_release` | on | on or off | Release ready drafts to the warehouse |
| `auto_release_max` | 25 | not limited | Most drafts auto-released per run |
| `paused` | off | on or off | Stops scheduled feed and drawing runs and auto-release. Run now still works. |
The Feed tab also shows the next scheduled run and the last ten runs. The "VISUAL resources" tab edits the resource map: each resource with its rows in the last batch, target (a shop department, Fold, Ignore or not assigned) and a confirmed tick. Every map change is audited.
## Fixture sources
For CI and local runs the worker can read files instead of VISUAL and M-Files:
- `FEED_SOURCE=fixture` reads `FIXTURE_DIR/feed.<state>.json`, a file in the exact shape of VisualKit's `examples/packet_station_feed.py` output (`meta`, `rows`, `materials`, `orders_by_base`, `orders_by_project`) plus an optional `labor` list. The state is read from `FIXTURE_DIR/state` (default `1`). It returns the firmed rows released inside the window, plus every row whose key is tracked; tracked keys with no rows are missing.
- `DRAWING_SOURCE=fixture` reads `FIXTURE_DIR/drawings.<state>.json`, a map from drawing number to its rev, file name, drafting state and ids. A drawing not in the file is not found. A download copies `FIXTURE_DIR/<file>`.
- `FIXTURE_DIR` defaults to `/fixtures`.
- The production stack refuses a fixture source at start.
`tests/fixtures/generator.py` writes the files: `python -m tests.fixtures.generator --out DIR` makes `feed.1.json`, `feed.2.json`, `drawings.1.json`, `drawings.2.json`, the one-page PDFs they name, and `state` = `1`. It is deterministic. State 1 covers the packet rules (fold, ignore, an unmapped resource, a lot `7A`, two operations on one work order, no drawing, a non-numeric rev, the BOM rules, a work order before go-live, closed and cancelled legs, several open orders). State 2 changes a rev, a quantity and a target, removes one leg and moves another, so the change order, minor, gone and retire rules run. `packetstation fixtures use N --dir DIR` switches the state. The Playwright scenario writes the state file itself. See [DEPLOY.md](DEPLOY.md) for the CI step and [tests/fixtures/README.md](../tests/fixtures/README.md).
Real job data never enters git or CI. Recordings from VISUAL DEV stay local, in `tests/fixtures/local/` and `recordings/`.
## What the screens read
Screens read PostgreSQL only, never VISUAL or M-Files per request:
- Packets, flags, work order status, closed and cancelled state, ticket hours and the chosen order come from the packet tables and `packet_visual_state`.
- The From VISUAL panel reads `/api/feed/overview`: pause state, next run, the heartbeat, the last ten feed and drawing runs, the ignored work orders, unmapped resources and projects without names.
- Admin reads the cache tables (`/api/feed/batches`, `/api/feed/changes`) for "what VISUAL changed".
- The project page reads the cached orders from `visual_order_links` and `visual_orders`.
Scheduled runs come from the worker's own clock, not from an open browser.
+1 -1
View File
@@ -219,7 +219,7 @@ Need an answer before the named phase. Defaults apply if none arrives in time.
2. **PDFs on the server** (confirm before phase 7): reportlab + pypdf + pypdfium2. Default: yes.
3. **Frontend stack** (confirm before phase 1): TypeScript + React + Vite. Default: yes. Plain JavaScript would not be faster for parity: the old 7,000 lines are screen code that must be rewritten either way, and typed API models cut rework.
4. **PIN defaults** (confirm): 6 to 12 digits, five attempts, 10-hour shop session, device cookie. Genuinely open (Seth and Q.C., before phase 6): whether Q.C. accept and filing require the domain password even with a PIN set. Default: PIN is enough. IT: device certificate later.
5. **Several open orders on a project** (before phase 3): which order a packet shows. Proposed rule: the open order whose line's top job is the packet's work order base, else the newest open order, else the newest closed one; the packet shows "via base" or "via project" and Clerical can override. Needs Seth's view.
5. **Several open orders on a project** (before phase 3): which order a packet shows. Proposed rule: the open order whose line's top job is the packet's work order base, else the newest open order, else the newest closed one; the packet shows "via base" or "via project" and Clerical can override. Needs Seth's view. Phase 3 (2026-10-16) built the default in one function (`feed/orders.py`): a Clerical pin, else the base's own linked order, else the project order whose top job is the unit, else the project's first open order, else its newest; the source (`via`) is stored on the project and unit and shown on the project page, which lists every open order. Seth may change the rule later in that one place.
6. **VISUAL_ENCRYPT** (IT, before production): yes once vsql has a trusted certificate; `no` until then.
7. **Policy tightenings** listed under "Challenges" 1 to 3 (Seth, before phase 2).
8. **Repo location**: PacketStation2 as proposed, or another name.
+47
View File
@@ -199,6 +199,49 @@ 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.
## 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.
| Action | From | What the worker does |
|---|---|---|
| vis_update_draft | draft | Updates a draft made by the feed: work orders, BOM (unless uploaded by hand), drawing and rev, dates, flags, drawing pages from M-Files. |
| vis_retire_draft | draft | Soft-removes an untouched draft whose work orders all moved to another packet, reason `moved`. |
| vis_open_co | any but draft and filed | Opens, re-aims or withdraws the packet's one pending feed change order, stages a drawing on it, and records drawing flags on a packet past draft. |
| vis_auto_release | draft to warehouse | Sends a ready draft to the warehouse. Same preconditions as send_to_warehouse (heat map answered, drawing attached). The feed only picks drafts with no flags and the rev confirmed by the drawing job. |
What the feed may write:
- Drafts it made, in place, through vis_update_draft and vis_retire_draft.
- New drafts, projects and units (`created_by` null, audit role `system`).
- The order-derived fields of projects and units, only where the value came from VISUAL. A value Clerical typed is never overwritten.
- On packets past draft: only a change order (vis_open_co), the observed VISUAL state (leg statuses, closed, cancelled, work order gone, ticket hours) and the drawing flags.
- Its own tables: the caches, `drawing_lookups`, the job tables.
What the feed may never write:
- Shop-side tables: stages, welds, heat tags, checklists, the clock, warehouse pulls and shorts, Q.C. records, seals. In-shop packets only get a change order.
- A removed packet. A key whose packets are all removed is never made again.
- A filed packet.
- Any person's action. The system principal passes only the vis_* actions, and people can never take them.
- VISUAL or M-Files. Both libraries are read-only.
### Rev approval
When M-Files has a different released rev from VISUAL, the drawing job stages the M-Files copy. A Clerical Supervisor (PS-Clerical-Supervisor) can approve it on a draft with rev_approve: a reason of at least 8 characters and a signature ("Approve the M-Files revision"). The pages are attached and the approval is kept on the packet (`visual.rev_ok`). With nothing staged the answer is 409 `nothing_staged`.
### Hand uploads on flagged drafts
On a draft made by the feed that is flagged (or already has a hand upload), Clerical can upload a drawing (edit_document_set) or a BOM (edit_bom) by hand. Both need a reason and a signature. A hand drawing sets `drawing_state` `hand` and flag `hand_drawing`; a hand BOM sets flag `hand_bom` and the feed stops replacing the BOM. An unflagged draft refuses with 409 `not_flagged`. Auto-release never takes a hand-flagged draft, so a person sends it on. "Use VISUAL's BOM again" removes the hand BOM.
### Change orders from the feed
- A pending feed change order holds nothing, unless Clerical puts it on hold (co_hold_toggle). Then it blocks the held steps until released or decided.
- Approval (co_approve, Clerical, signature) applies the staged change to the packet: work orders, BOM, drawing and rev. When the drawing changed, the new drawing must be staged first (`co_staged_if_dwg_change`, 409 `co_not_staged`). The old drawing pages are voided, not deleted.
- After approval a major change order asks the departments it touches to verify (co_verify), and holds the packet until they have.
- A minor change order (only target hours or the want date changed) needs Clerical approval only. No department is asked to verify, so it holds nothing after approval.
- Rejection (co_reject) needs a reason and a signature. The feed does not reopen a change order Clerical rejected for the same target.
## Tightenings against the old build
The old policy was looser than the old screens in places. The rebuild follows the screens where they were stricter, and the README where it states the intent. Each row is a change Seth can veto.
@@ -217,3 +260,7 @@ The old policy was looser than the old screens in places. The rebuild follows th
| Attaching drawing pages by hand needed no signature. | Attaching a drawing by hand needs a signature ("Attach a drawing by hand"). | It replaces what M-Files would supply, so a person stands behind it. | Yes |
| Admin could write anything on a filed packet with an IT reason. | Admin cannot write a filed packet except through ADMIN_REOPEN (with a reason and a signature), and SET_DATABOOK_AT. | A filed packet is sealed. A change breaks the seal on purpose, on record. | No |
| Admin passed every check after the change-order hold, so an admin could make a step from any status. | Admin goes through the same status check. The bypass covers who and custody only. Preconditions still apply. | Admin fixes data, not the workflow order. | Yes |
| A hand drawing or BOM upload on a flagged VISUAL draft took a reason of 3 characters and a PIN sign-off checked in the browser. | Both need a reason of at least 8 characters and a signature verified on the server, kept on the packet with who and when. | A hand upload stands in for VISUAL or M-Files, so a person stands behind it. | Yes |
| Approving a different M-Files rev was open to a Clerical lead. | rev_approve needs Clerical Supervisor, with a reason and a signature. | There is no Clerical lead AD group, and the approval overrides VISUAL. | Yes |
| The Sync on a Clerical PC skipped removed packets ("removed here, not re-made"), but only as the browser saw them. | A removed packet's key is never recreated, whatever VISUAL says. The match runs on the server across live and removed rows, in the feed's transaction. | A removal is a decision. The feed does not undo it. | No |
| The Sync saved through the same packet document that held the shop's work, so nothing but the code kept it off the shop's fields. | The feed never writes shop-side tables. Packets past draft only get a change order, observed VISUAL state and drawing flags. | The shop's record is the shop's. | No |
+32 -5
View File
@@ -1,6 +1,6 @@
# Packet Station schema
Status: phases 1 and 2 built (identity, organisation, projects, packets, change orders, documents). Phase 3 adds the job and VISUAL tables, phase 9 the notification tables. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema".
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".
## Conventions
@@ -8,7 +8,7 @@ Identity bigints, except users, keyed on the AD objectGUID. Postgres enums for w
## Database roles
A superuser for bootstrap, the migrate role `ps_migrate` (owns the schema, runs migrations) and the app role `ps_app`. The plan's deployment section also names a worker role `ps_worker`. Phase 0 creates only the first three; whether the worker gets its own role is still to settle.
A superuser for bootstrap, the migrate role `ps_migrate` (owns the database and the schema, runs migrations) and the app role `ps_app`. The worker shares `ps_app` and sets `application_name` to `ps-worker`, so the two are told apart in `pg_stat_activity`; a separate `ps_worker` role was not needed (decided in phase 3). `ps_app` holds CONNECT and TEMPORARY on the database (the feed COPYs each batch into temp tables) and no CREATE on the schema.
## Identity (phase 1, migration 0002)
@@ -77,7 +77,7 @@ The trigger `ps_packet_touch` sets `updated_at` on every UPDATE. It does not tou
### Children
- `packet_work_orders`: `wo_base`, `lot`, `split`, `sub` (all text; the last three default to `''` so the key has no nulls), `seq`, `qty`, `target_hrs`, `op`, `description`, `visual_status`, `missing_runs` and `gone_at` (the feed's "work order gone" check), `sort`, `row_version`. Unique on (`packet_id`, `wo_base`, `lot`, `split`, `sub`).
- `packet_work_orders`: `wo_base`, `lot`, `split`, `sub` (all text; the last three default to `''` so the key has no nulls), `seq`, `qty`, `target_hrs`, `op`, `description`, `visual_status`, `missing_runs` and `gone_at` (the feed's "work order gone" check), `sort`, `row_version`. Phase 3 adds `partspecs`, `rev` and `dwg`. Unique on (`packet_id`, `wo_base`, `lot`, `split`, `sub`, `seq`) with nulls not distinct, since migration 0004 (see "VISUAL and drawing caches").
- `packet_pages`: `n` (the packet page number, from `next_page_no`), `type`, `asset_id`, `pn` (page within the asset), `dwg`, `rev`, `co_id` (the change order that added it), `void_co_id`, `void_at`, `void_by`, `void_new_n` (a replaced page is voided, never deleted), `row_version`. Unique on (`packet_id`, `n`).
- `bom_lines`: `item`, `qty`, `part_id` (text), `description`, `dwg_ref`, `cl_item`, `length`, `material`, `heat`, `na`, `issued`, `loc`, `pulled`, `pulled_at`, `pulled_by`, `mtr_asset_id`, `src`, `vkey`, `whs`, `comm`, `cons`, `sub`, `grp`, `sort`, `row_version`. Unique on (`packet_id`, `item`).
- `bom_shorts`: `bom_line_id` (cascades with the line), `qty`, `qty_open`, `reason`, `ref`, `eta`, `note`, `opened_at`, `opened_by`, `closed_at`, `row_version`. At most one open short per line (unique on `bom_line_id` where `closed_at` is null).
@@ -124,7 +124,15 @@ The app role is refused by privilege first. The migrate role owns the tables, so
## Audit, settings, jobs (phases 1 and 3)
audit_events with a hash chain, settings, job_requests, job_runs, job_schedule, worker_heartbeat.
- `audit_events` (migration 0001): append-only. `at`, `user_id`, `session_id`, `role_snapshot` (`system` for the worker), `action`, `subject_type`, `subject_id`, `packet_id`, `detail` jsonb, `it_reason`, `ip`. A hash chain: `row_hash` is sha256 over `prev_hash` and the canonical JSON of the row, and `prev_hash` is the row before it. BRIN index on `at`.
- `settings` (migration 0001): `key` (text PK), `value` jsonb, `version`, `updated_at`, `updated_by`. Phase 3 adds the row `feed`, the feed settings as one JSON object (see [FEED.md](FEED.md), "Settings"). A missing key takes its default. A save bumps `version` and sends `ps_notify('ps_settings', ...)` so the worker reschedules.
Phase 3, migration 0004:
- `job_requests`: one row per "Run now". `job`, `requested_at`, `requested_by`, `note`, `claimed_run_id` and `claimed_at` (set by the run that took it). Index on `job` where `claimed_at` is null. The app inserts the row and NOTIFYs `ps_job_request` in the same transaction.
- `job_runs`: one row per run. `job`, `trigger` (schedule, request, chain, cli), `requested_by` uuid[] (the requests claimed), `started_at`, `finished_at`, `status` (running, ok, degraded, failed, skipped, abandoned), `counts` jsonb, `errors` jsonb (a list of redacted strings), `build_version`, `visual_target` (`"<server> / <database>"`), `host`. Index on (`job`, `id DESC`).
- `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)
@@ -132,7 +140,26 @@ Rules, recipients, exclusions, outbox, sent log, state.
## VISUAL and drawing caches (phase 3)
visual_batches, visual_rows, visual_materials, order links, labor, change rows, ignored work orders, resource map, drawing_lookups.
Migration 0004. Every ID part is text (`wo_base`, `lot`, `split`, `sub`, part IDs, order IDs), with `''` in place of null in a key. `seq` and `piece` are int. Closed sets are text with a CHECK. The worker fills these tables. Screens read them, never VISUAL. [FEED.md](FEED.md) says how each is written.
- `visual_batches`: one row per feed run. `run_id`, `started_at`, `finished_at`, `server`, `database`, `site`, `since` and `until` (the window), `window_rows`, `tracked_keys`, `missing_keys`, `suspect`, `counts` jsonb.
- `visual_rows`: one row per work order operation (TYPE W), unique on (`wo_base`, `lot`, `split`, `sub`, `seq`). The contract fields: `res`, `dwg`, `rev`, `ps` (partspecs), `descr`, `qty`, `tgt`, `rel`, `want`, `st` (the status code F, R, U, C, X or `''`), `part`, `barcode`. `row_hash` (sha256 of the canonical row), `first_batch`, `last_batch` and `gone_batch` (set when a row in play was not read; cleared when it returns). Indexes on (`wo_base`, `lot`), on `dwg`, and on `rel` for rows not gone.
- `visual_materials`: one row per material line, unique on (`wo_base`, `lot`, `split`, `sub`, `seq`, `piece`). `part` (null for a line with no part), `qty`, `issued`, `whs`, `loc`, `st`, `istat`, `dwg`, `descr`, `comm`, `cons`, `mat_hash`, `first_batch`, `last_batch`, `gone_batch`. Index on (`wo_base`, `lot`).
- `visual_orders`: one row per customer order (`cust_order_id` PK). `status`, `customer_id`, `customer_name`, `customer_po_ref`, `order_date`, `desired_ship_date`, `promise_date`, `last_batch`.
- `visual_order_links`: `kind` (base or project), `key` (the base or the project number), `cust_order_id`, `rank` (open first, newest first), `lines` int[], `top_jobs` text[], `last_batch`. Unique on (`kind`, `key`, `cust_order_id`). Replaced for every key read.
- `visual_labor`: ticket hours per leg, unique on (`wo_base`, `lot`, `split`, `sub`, `seq`). `hours`, `tickets`, `last_ticket`, `observed_at`. No cost or pay column.
- `visual_row_changes`: what VISUAL changed, per batch. `batch_id`, the key and `seq`, `kind` (added, changed, gone, returned), `before` and `after` jsonb (the row as text values), `at`. Index on `batch_id`. `visual_material_changes` is the same plus `piece`.
- `visual_ignored_wos`: work orders kept out by the go-live date, unique on (`wo_base`, `lot`, `split`, `sub`). `rel`, `project`, `unit_number`, `res`, `dwg`, `first_seen_batch`, `last_seen_at`, and `pulled_in_at`, `pulled_in_by`, `pulled_in_reason` when Clerical pulls one in.
- `visual_resource_map`: `resource` (PK), `target` (`fold`, `ignore` or a two-digit department code, by CHECK), `confirmed`, `updated_at`, `updated_by`. Seeded from the old build's `visual_map.json`, every entry unconfirmed. "Electical" is spelled that way in VISUAL.
- `drawing_lookups`: one row per drawing number as VISUAL writes it (`dwg` PK, trimmed). The last answer: `checked_at`, `ok`, `rev` (released), `name`, `obj_id`, `obj_version`, `file_id`, `file_version`, `file_size`, `base` (H sheet), `draft_rev`, `draft_where`, `in_drafting`, `note`. Retry state: `fail_count`, `failing_since`, `next_check_at`. The fetched file: `asset_id`, `sha256`. `visual_rev_seen` is the VISUAL rev at the last lookup, so a rev change moves the drawing to the front of the queue. A rev mismatch keeps the staged M-Files copy here for approval.
Column additions in 0004:
- `packets`: `visual_cancelled_at` and `wo_gone_at`, so boards can filter on cancelled and gone without reading `flags`.
- `packet_work_orders`: `partspecs`, `rev` and `dwg`, the leg's own planner instructions, rev and drawing as VISUAL has them. The header keeps the highest rev. The unique key now includes `seq`: 0004 drops the 0003 unique on (`packet_id`, `wo_base`, `lot`, `split`, `sub`) and adds `UNIQUE NULLS NOT DISTINCT (packet_id, wo_base, lot, split, sub, seq)`, because the feed puts two operations of one work order on one packet. Hand drafts keep `seq` null, so they still have one row per key.
- `projects`: `order_date_visual`, `ship_date_visual`, `order_ref` jsonb (the chosen order and how it was chosen), and the Clerical pin `order_pin` (a customer order ID), `order_pin_by`, `order_pin_at`, `order_pin_reason`.
- `units`: `customer`, `po`, `customer_visual`, `po_visual`, `order_date_visual`, `ship_date_visual`, `order_ref`, and the same pin columns.
- `change_orders`: nothing new. `vis`, `waiting`, `staged`, `target_fp` and `minor` came in 0003.
## Versioning
+11 -1
View File
@@ -2,7 +2,7 @@
This document is rewritten for the server build. The old browser build's security document is in the old repo (`C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md`). The design comes from [BRIEF.md](BRIEF.md) and [PLAN.md](PLAN.md).
Status: phase 2. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) and the policy controls (server policy on every write, workflow steps as compare-and-set, server-side validation and limits, IT edit reasons, the filed lock, the upload pipeline for drawings and documents) are built and tested. Every other control names the phase that builds it.
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.
Contents:
@@ -82,6 +82,16 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
- 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).
### 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.
- It reads VISUAL through VisualKit as `visualkit_ro` and M-Files through MFilesKit as `mfileskitRO`. Both libraries are read-only in their own code. Nothing in Packet Station writes to either.
- Stack guard at start and again before every feed run: the test stack refuses a production VISUAL target, production refuses an implicit one, and production refuses a fixture source.
- One run per job at a time, by a PostgreSQL advisory lock per job name. Feed writes run in one transaction with a lock timeout and skip rows a person has locked.
- Job errors are redacted (connection strings, `password=`, `Authorization:` and the VISUAL and M-Files passwords themselves) and cut to 20 entries of 500 characters before they are stored or shown.
- Every feed write goes through the server policy as the system principal, which can take only the `vis_*` actions. Each one writes a packet log row and an audit row with role `system`. Each run writes a `feed.run` audit row with its counts, and each drawing fetch a `drawing.fetched` row with the sha256. Feed settings and resource map changes are audited with before and after.
- Downloaded drawings go through the same checks as uploads: PDF magic, size limit, streamed sha256, stored by id.
### Audit (phase 1)
- Append-only `audit_events`, with a hash chain (`prev_hash`, `row_hash`).
+15
View File
@@ -0,0 +1,15 @@
// The VISUAL feed endpoints for Clerical (phase 3 contract, api/feed.py).
import { api } from "./client";
import { asList, qs } from "./query";
import type { FeedOverview, IgnoredWo } from "./types";
export type FeedJob = "visual-feed" | "drawing-lookup";
export const feedApi = {
overview: (signal?: AbortSignal) => api.get<FeedOverview>("/api/feed/overview", signal),
run: (job?: FeedJob) => api.post<{ requested: boolean; pending: number }>("/api/feed/run", job ? { job } : {}),
pullIn: (id: number, reason: string) =>
api.post<IgnoredWo>(`/api/feed/ignored/${encodeURIComponent(String(id))}/pull-in`, { reason }),
changes: async (opts: { batch_id?: number; limit?: number } = {}, signal?: AbortSignal): Promise<unknown[]> =>
asList<unknown>(await api.get<unknown>(`/api/feed/changes${qs({ batch_id: opts.batch_id, limit: opts.limit ?? 200 })}`, signal), "changes"),
};
+30
View File
@@ -0,0 +1,30 @@
// The worker's jobs (phase 3 contract, api/jobs.py) and the Admin feed settings (api/feed_settings.py).
import { api } from "./client";
import { asList, qs } from "./query";
import type { FeedSettings, FeedSettingsAnswer, JobInfo, JobRun, JobsAnswer, ResourceMapRow, WorkerStatus } from "./types";
const enc = encodeURIComponent;
/** GET /api/jobs may come as {jobs, heartbeat} or as a bare list. Both give the same answer. */
function asJobs(raw: unknown): JobsAnswer {
const jobs = asList<JobInfo>(raw, "jobs");
const hb = raw && typeof raw === "object" && !Array.isArray(raw) ? ((raw as { heartbeat?: WorkerStatus | null }).heartbeat ?? null) : null;
return { jobs, heartbeat: hb };
}
export const jobsApi = {
list: async (signal?: AbortSignal): Promise<JobsAnswer> => asJobs(await api.get<unknown>("/api/jobs", signal)),
runs: async (job: string, limit = 10, signal?: AbortSignal): Promise<JobRun[]> =>
asList<JobRun>(await api.get<unknown>(`/api/jobs/${enc(job)}/runs${qs({ limit })}`, signal), "runs"),
run: (job: string) => api.post<{ requested: boolean; pending: number }>(`/api/jobs/${enc(job)}/run`, {}),
};
export const feedAdminApi = {
settings: (signal?: AbortSignal) => api.get<FeedSettingsAnswer>("/api/admin/feed/settings", signal),
saveSettings: (patch: Partial<FeedSettings>) => api.patch<FeedSettingsAnswer>("/api/admin/feed/settings", patch),
resourceMap: async (signal?: AbortSignal): Promise<ResourceMapRow[]> =>
asList<ResourceMapRow>(await api.get<unknown>("/api/admin/feed/resource-map", signal), "resources"),
mapResource: (resource: string, target: string) =>
api.put<ResourceMapRow>(`/api/admin/feed/resource-map/${enc(resource)}`, { target }),
unmapResource: (resource: string) => api.del<null>(`/api/admin/feed/resource-map/${enc(resource)}`),
};
+25
View File
@@ -43,4 +43,29 @@ export const packetsApi = {
setVclosed: (id: number, closed: boolean, extra: Guarded = {}) =>
api.post<PacketRead>(`${base(id)}/vclosed`, { closed, ...extra }),
databook: (id: number) => api.post<PacketRead>(`${base(id)}/databook`, {}),
/* Phase 3: VISUAL packets. */
revApprove: (id: number, body: { reason: string } & Guarded) => api.post<PacketRead>(`${base(id)}/rev-approve`, body),
handDrawing: (id: number, body: { asset_id: string; reason: string } & Guarded) =>
api.post<PacketRead>(`${base(id)}/hand/drawing`, body),
/** Multipart cannot carry JSON, so the signature rides as sign_factor and sign_secret. */
handBom: (id: number, file: File, reason: string, extra: Guarded = {}) => {
const fd = new FormData();
fd.append("file", file, file.name);
fd.append("reason", reason);
if (extra.sign) {
fd.append("sign_factor", extra.sign.factor);
fd.append("sign_secret", extra.sign.secret);
}
if (extra.it_reason) fd.append("it_reason", extra.it_reason);
return request<PacketRead>(`${base(id)}/hand/bom`, { method: "POST", body: fd });
},
removeHandBom: (id: number, extra: Guarded = {}) => api.delWithBody<PacketRead>(`${base(id)}/hand/bom`, extra),
/* Change orders. */
coApprove: (id: number, no: number, extra: Guarded = {}) => api.post<PacketRead>(`${base(id)}/change-orders/${no}/approve`, extra),
coReject: (id: number, no: number, body: { reason: string } & Guarded) =>
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 }),
};
+4
View File
@@ -12,6 +12,8 @@ export interface ProjectFields {
due: string | null;
status: Project["status"];
notes: string | null;
order_pin?: string | null;
order_pin_reason?: string | null;
}
export interface UnitFields {
@@ -19,6 +21,8 @@ export interface UnitFields {
due: string | null;
status: Unit["status"];
notes: string | null;
order_pin?: string | null;
order_pin_reason?: string | null;
}
const pid = (id: number) => `/api/projects/${encodeURIComponent(String(id))}`;
+262 -2
View File
@@ -72,6 +72,9 @@ export interface Unit {
packets?: StageCounts;
version: number;
project?: ProjectRef;
order_ref?: OrderRef | null;
order_pin?: string | null;
order_pin_reason?: string | null;
}
/** One row of GET /api/projects. GET /api/projects/{id} adds units and aliases. */
@@ -91,6 +94,11 @@ export interface Project {
packets?: StageCounts;
aliases?: Array<string | { alias: string; kind?: string }>;
version: number;
/** Phase 3: cached VISUAL orders for the project and its bases. */
orders?: VisualOrder[];
order_ref?: OrderRef | null;
order_pin?: string | null;
order_pin_reason?: string | null;
}
export interface ProjectCreate {
@@ -161,6 +169,12 @@ export interface PacketCard {
blocked: Record<string, string[]>;
/** Not in the contract's card shape. Shown when the server sends it. */
wo_count?: number;
/** Phase 3: the raw flag kinds (drawing_needed, rev_mismatch, ...). */
flag_kinds?: string[];
/** Phase 3: a feed change order waits for Clerical. */
co_pending?: boolean;
/** Phase 3: every leg is closed in VISUAL (observed). */
visual_closed?: boolean;
}
export interface PacketList {
@@ -174,6 +188,8 @@ export interface PacketListQuery {
project_id?: number;
q?: string;
flag?: string;
/** "pending": packets with a pending feed change order. */
co?: string;
limit?: number;
}
@@ -282,7 +298,9 @@ export interface PacketRead {
source: Record<string, unknown>;
drawing_state: string;
flags: string[];
visual: Record<string, unknown>;
visual: VisualInfo;
/** Phase 3: what the feed last observed in VISUAL. */
visual_state?: VisualState | null;
removed: { at: string; by: WhoRef | null; reason: string | null } | null;
work_orders: WorkOrder[];
pages: PacketPage[];
@@ -295,7 +313,7 @@ export interface PacketRead {
wh: Record<string, unknown> | null;
qc: Record<string, unknown> | null;
qc_history: unknown[];
change_orders: unknown[];
change_orders: ChangeOrder[];
open_cos: number[];
open_shorts: number;
docs: PacketDoc[];
@@ -394,3 +412,245 @@ export interface Guarded {
it_reason?: string;
sign?: SignBody;
}
/* ---------- Phase 3: VISUAL feed on the packet ---------- */
/** visual.appr: the last M-Files answer for the drawing. */
export interface VisualAppr {
ok?: boolean;
rev?: number | string | null;
vrev?: string | null;
name?: string | null;
note?: string | null;
base?: string | null;
draft_rev?: number | null;
draft_where?: string | null;
in_drafting?: boolean;
at?: string | null;
}
/** visual.manual_dwg and visual.manual_bom. `by` may be a who reference or a name. */
export interface HandUpload {
asset_id?: string;
name?: string | null;
reason?: string | null;
by?: WhoRef | string | null;
at?: string | null;
vis_rev?: string | null;
lines?: number;
}
/** One folded operation (MFG, inspection and the like) kept on the packet for reference. */
export interface FoldedOp {
key?: string | { base?: string; lot?: string; split?: string; sub?: string };
wo?: string;
wo_base?: string;
lot?: string;
seq?: number;
op?: string;
tgt?: number | string;
}
/** The packet's `visual` jsonb. Every key is optional; hand drafts have {}. */
export interface VisualInfo {
appr?: VisualAppr;
/** Set by the read model from drawing_lookups when M-Files has a staged copy. */
staged_rev?: { rev: number | string; name: string | null };
partspecs?: string[];
folded?: FoldedOp[];
manual_dwg?: HandUpload;
manual_bom?: HandUpload;
rev_ok?: { rev: string | number; vis_rev?: string | null; by?: WhoRef | string | null; at?: string | null; reason?: string | null };
rev_confirmed?: boolean;
[key: string]: unknown;
}
export interface VisualLeg {
key?: string;
seq?: number;
st?: string;
}
export interface VisualState {
legs: VisualLeg[];
ticket_hours: number | string | null;
order_ref: OrderRef | null;
observed_at: string | null;
closed_at: string | null;
cancelled_at: string | null;
}
/** change_orders[].vis for feed change orders. */
export interface CoVis {
from_dwg?: string | null;
from_rev?: string | null;
to_dwg?: string | null;
to_rev?: string | null;
dwg_change?: boolean;
changes?: string[];
[key: string]: unknown;
}
export interface ChangeOrderItem {
old_page: number | null;
old_dwg: string | null;
old_rev: string | null;
new_page: number | null;
new_dwg: string | null;
new_rev: string | null;
added?: boolean;
}
export interface ChangeOrder {
id: number;
co_no: number;
origin: string;
review: string;
minor: boolean;
hold: boolean;
ecn: string | null;
reason: string | null;
issued_at: string | null;
issued_by: WhoRef | null;
approved_at?: string | null;
approved_by?: WhoRef | null;
rejected_at?: string | null;
rejected_by?: WhoRef | null;
rejected_reason?: string | null;
items: ChangeOrderItem[];
required: string[];
verified: Record<string, { by: WhoRef | null; at: string; note: string | null }>;
vis?: CoVis | null;
waiting?: { dwg: string; rev: string; since?: string | null; err?: string | null } | null;
staged?: { asset_id: string; name: string | null; rev: string; dwg: string; sha256?: string; pages?: number | null } | null;
target_fp?: string | null;
row_version?: number;
}
/* ---------- Phase 3: orders ---------- */
/** One cached VISUAL customer order. */
export interface VisualOrder {
cust_order_id: string;
status: string | null;
customer_id?: string | null;
customer_name: string | null;
customer_po_ref: string | null;
order_date: string | null;
desired_ship_date: string | null;
promise_date: string | null;
/** "project" or "base" link, when the server says. */
kind?: string;
key?: string;
top_jobs?: string[];
}
/** The chosen order stored on projects and units. */
export interface OrderRef {
cust_order_id: string;
via: string;
status: string | null;
customer_name: string | null;
customer_po_ref: string | null;
order_date: string | null;
desired_ship_date: string | null;
promise_date: string | null;
chosen_at?: string | null;
}
/* ---------- Phase 3: jobs and the feed ---------- */
export interface JobRun {
id: number;
job: string;
trigger: string;
requested_by?: string[];
started_at: string;
finished_at: string | null;
status: string;
counts: Record<string, unknown>;
errors: string[];
build_version?: string | null;
visual_target?: string | null;
host?: string | null;
}
export interface WorkerStatus {
at: string | null;
build: string | null;
running: string[];
stale: boolean;
}
export interface IgnoredWo {
id: number;
wo_base: string;
lot: string;
split: string;
sub: string;
rel: string | null;
project: string | null;
unit_number: string | null;
res: string | null;
dwg: string | null;
pulled_in_at: string | null;
}
export interface FeedOverview {
status: { paused: boolean; next_run_at: string | null; worker: WorkerStatus | null };
last_run: JobRun | null;
runs: JobRun[];
drawing_runs: JobRun[];
ignored: IgnoredWo[];
unmapped: Array<{ resource: string; rows: number }>;
projects_without_names: Array<{ id: number; number: string; units: number; packets: number }>;
golive_date: string | null;
departments: string[];
}
/** One row of GET /api/jobs. The contract leaves the shape open. */
export interface JobInfo {
job: string;
next_run_at: string | null;
interval_seconds: number | null;
paused: boolean;
last_run: JobRun | null;
}
export interface JobsAnswer {
jobs: JobInfo[];
heartbeat: WorkerStatus | null;
}
/** The feed settings row (feed.config.FeedSettings). */
export interface FeedSettings {
interval_shop_minutes: number;
interval_off_minutes: number;
shop_days: number[];
shop_start: string;
shop_end: string;
window_back_days: number;
window_ahead_days: number;
golive_date: string | null;
departments: string[];
drawing_recheck_hours: number;
drawing_budget: number;
drawing_interval_minutes: number;
labor_tickets: boolean;
auto_release: boolean;
auto_release_max: number;
paused: boolean;
}
export interface FeedSettingsAnswer extends FeedSettings {
next_run_at?: string | null;
runs?: JobRun[];
}
export interface ResourceMapRow {
resource: string;
target: string | null;
confirmed: boolean;
rows_last_run: number | null;
updated_at: string | null;
}
+5
View File
@@ -49,6 +49,11 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers): Pr
es.addEventListener("changed", (ev) => {
ref.current.onChanged?.(parse((ev as MessageEvent).data));
});
// Phase 3: the worker finished a feed run. Fanned out with the changed events, marked event "feed".
es.addEventListener("feed", (ev) => {
const p = parse((ev as MessageEvent).data);
ref.current.onChanged?.({ ...(p && typeof p === "object" ? p : {}), event: "feed" });
});
return () => {
es.close();
+2
View File
@@ -40,6 +40,8 @@ export function Card({ p, extra }: { p: PacketCard; extra?: ReactNode }) {
{cos ? <span className="pill st-review">CO {cos}</span> : null}
</div>
{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}
{extra}
</Link>
);
+17 -1
View File
@@ -1,3 +1,4 @@
import { FLAG_FILTERS } from "../lib/packets";
import { PickUnit, type UnitPick } from "./PickUnit";
export type BoardView = "board" | "list";
@@ -11,10 +12,13 @@ interface FindBarProps {
onView: (v: BoardView) => void;
/** "Showing n packets". */
count: number | null;
/** The flag kind filter. Shown when onFlag is given. */
flag?: string;
onFlag?: (v: string) => void;
}
/** Find a job: the search box, the project then unit picker, and the Board | List toggle. */
export function FindBar({ q, onQ, pick, onPick, view, onView, count }: FindBarProps) {
export function FindBar({ q, onQ, pick, onPick, view, onView, count, flag = "", onFlag }: FindBarProps) {
return (
<section className="fltbar" aria-label="Find jobs">
<div className="fltrow">
@@ -39,6 +43,18 @@ export function FindBar({ q, onQ, pick, onPick, view, onView, count }: FindBarPr
<div className="f fltp">
<PickUnit value={pick} onChange={onPick} />
</div>
{onFlag ? (
<label className="f">
<span>Flags</span>
<select className="in" value={flag} onChange={(e) => onFlag(e.target.value)}>
{FLAG_FILTERS.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</label>
) : null}
<div className="fltbtns">
<div className="seg" role="group" aria-label="View">
<button type="button" aria-pressed={view === "board"} onClick={() => onView("board")}>
+50
View File
@@ -0,0 +1,50 @@
import type { JobRun } from "../api/types";
import { count, runPillClass, RUN_STATUS_LABEL, TRIGGER_LABEL } from "../lib/feed";
import { fmtTime } from "../lib/format";
/** The last runs of the VISUAL feed as a small table. `attached` maps a run id to the drawings attached after it. */
export function RunsTable({ runs, attached, label = "Last runs" }: { runs: JobRun[]; attached?: Map<number, number>; label?: string }) {
if (!runs.length) return <div className="empty slim">No runs yet.</div>;
return (
<div className="tw">
<table aria-label={label}>
<thead>
<tr>
<th>When</th>
<th>Trigger</th>
<th>Status</th>
<th className="num">New</th>
<th className="num">Updated</th>
<th className="num">Change orders</th>
<th className="num">Drawings attached</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
{runs.map((r) => (
<tr key={r.id}>
<td className="small mono nowrap">{fmtTime(r.started_at)}</td>
<td className="small">{TRIGGER_LABEL[r.trigger] ?? r.trigger}</td>
<td>
<span className={runPillClass(r.status)}>{RUN_STATUS_LABEL[r.status] ?? r.status}</span>
</td>
<td className="num">{count(r, "new_drafts")}</td>
<td className="num">{count(r, "drafts_updated")}</td>
<td className="num">{count(r, "cos_opened") + count(r, "cos_reaimed")}</td>
<td className="num">{attached?.get(r.id) ?? count(r, "attached")}</td>
<td className="small">
{r.errors.length ? (
<span className="due over" title={r.errors.join("\n")}>
{r.errors.length === 1 ? r.errors[0] : `${r.errors.length} errors: ${r.errors[0] ?? ""}`}
</span>
) : (
<span className="muted">None</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
// Change order words shared by the packet page and the boards.
import type { ChangeOrder, PacketRead } from "../api/types";
export const feedOrigin = (co: ChangeOrder) => co.origin === "visual" || co.origin === "drawing";
/** The old coSummary(): "M00543 Rev 1 -> Rev 2 + 2 other changes", or the first changes. */
export function coSummary(co: ChangeOrder): string {
if (feedOrigin(co) && co.review !== "approved") {
const v = co.vis ?? {};
const ch = v.changes ?? [];
if (v.dwg_change) {
const from = v.from_dwg && v.to_dwg && v.from_dwg.replace(/\s/g, "").toUpperCase() !== v.to_dwg.replace(/\s/g, "").toUpperCase() ? `${v.from_dwg} -> ` : "";
return `${from}${v.to_dwg ?? ""} Rev ${v.from_rev || "?"} -> Rev ${v.to_rev ?? "?"}${ch.length ? ` + ${ch.length} other change${ch.length > 1 ? "s" : ""}` : ""}`;
}
return ch.slice(0, 2).join("; ") + (ch.length > 2 ? ` (+${ch.length - 2} more)` : "") || co.reason || "change from VISUAL";
}
const parts = co.items.filter((i) => !i.added).map((i) => `${i.old_dwg || "sheet"} Rev ${i.old_rev || "?"} -> Rev ${i.new_rev || "?"}`);
const added = co.items.filter((i) => i.added).length;
if (added) parts.push(`${added} sheet${added > 1 ? "s" : ""} added`);
return parts.join(", ") || co.reason || "documents changed";
}
/** The pending feed change order Clerical should review, if any. */
export function coToReview(p: PacketRead): ChangeOrder | undefined {
return (p.change_orders ?? []).find((c) => c.review === "pending" && feedOrigin(c));
}
+90
View File
@@ -0,0 +1,90 @@
// Words for the worker's job runs and the feed status line. Shared by Clerical and Admin.
import type { FeedOverview, JobRun } from "../api/types";
import { fmtAgo, fmtIn } from "./format";
/** One count from a run, as a number. Missing or odd values are 0. */
export function count(run: Pick<JobRun, "counts"> | null | undefined, key: string): number {
const v = run?.counts?.[key];
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
/** "4 new, 2 updated, 1 change order" or "no changes". */
export function runSummary(run: JobRun): string {
if (run.status === "skipped") {
const why = run.counts?.reason;
return typeof why === "string" && why ? `skipped (${why})` : "skipped";
}
if (run.status === "failed") return run.errors[0] ? `failed: ${run.errors[0]}` : "failed";
if (run.status === "running") return "running";
if (run.status === "abandoned") return "stopped when the worker restarted";
const parts: string[] = [];
const nw = count(run, "new_drafts");
const up = count(run, "drafts_updated");
const co = count(run, "cos_opened") + count(run, "cos_reaimed");
if (nw) parts.push(`${nw} new`);
if (up) parts.push(`${up} updated`);
if (co) parts.push(plural(co, "change order"));
const att = count(run, "attached");
if (att) parts.push(`${att} drawing${att === 1 ? "" : "s"} attached`);
const text = parts.length ? parts.join(", ") : "no changes";
return run.status === "degraded" ? `${text} (some reads failed)` : text;
}
export const RUN_STATUS_LABEL: Record<string, string> = {
running: "Running",
ok: "OK",
degraded: "Degraded",
failed: "Failed",
skipped: "Skipped",
abandoned: "Abandoned",
};
export function runPillClass(status: string): string {
if (status === "ok") return "pill st-ok";
if (status === "failed" || status === "abandoned") return "pill st-bad";
if (status === "degraded") return "pill st-review";
if (status === "running") return "pill st-in_progress";
return "pill st-filed";
}
export const TRIGGER_LABEL: Record<string, string> = {
schedule: "Schedule",
request: "Run now",
chain: "After the feed",
cli: "Command line",
};
/** Is the feed running right now, by the heartbeat or the last run row. */
export function feedRunning(ov: FeedOverview): boolean {
return !!ov.status.worker?.running?.includes("visual-feed") || ov.last_run?.status === "running";
}
/** The status line on the Clerical panel. */
export function statusLine(ov: FeedOverview, now: Date = new Date()): { text: string; warn: boolean } {
const w = ov.status.worker;
if (!w || w.stale) return { text: "Worker not running. VISUAL is not being read. Tell IT.", warn: true };
if (feedRunning(ov)) return { text: "Reading VISUAL now.", warn: false };
const last = ov.last_run;
const lastText = last ? `last run ${fmtAgo(last.finished_at ?? last.started_at, now)}: ${runSummary(last)}` : "no runs yet";
if (ov.status.paused) return { text: `Paused by the admin · ${lastText}`, warn: true };
const next = ov.status.next_run_at ? `next run ${fmtIn(ov.status.next_run_at, now)}` : "next run not scheduled";
return { text: `On · ${next} · ${lastText}`, warn: last?.status === "failed" };
}
/** Drawings attached by the drawing runs that followed each feed run. Runs are newest first. */
export function drawingsAfter(runs: JobRun[], drawingRuns: JobRun[]): Map<number, number> {
const out = new Map<number, number>();
runs.forEach((r, i) => {
const from = r.started_at;
const to = i > 0 ? runs[i - 1]?.started_at : undefined;
let n = 0;
for (const d of drawingRuns) {
if (d.started_at >= from && (to === undefined || d.started_at < to)) n += count(d, "attached");
}
out.set(r.id, n);
});
return out;
}
+91
View File
@@ -0,0 +1,91 @@
// The Admin feed settings form: fields, ranges and the patch of changed fields.
import type { FeedSettings } from "../api/types";
export const DAYS: ReadonlyArray<[number, string]> = [
[0, "Mon"],
[1, "Tue"],
[2, "Wed"],
[3, "Thu"],
[4, "Fri"],
[5, "Sat"],
[6, "Sun"],
];
export type NumKey =
| "interval_shop_minutes"
| "interval_off_minutes"
| "window_back_days"
| "window_ahead_days"
| "drawing_recheck_hours"
| "drawing_budget"
| "drawing_interval_minutes"
| "auto_release_max";
/** Each number field: label, lowest, highest. The server checks the same ranges. */
export const NUMS: ReadonlyArray<[NumKey, string, number, number]> = [
["interval_shop_minutes", "Every (min) in shop hours", 1, 1440],
["interval_off_minutes", "Every (min) outside shop hours", 1, 1440],
["window_back_days", "Released up to (days back)", 0, 120],
["window_ahead_days", "and (days ahead)", 0, 365],
["drawing_interval_minutes", "Drawing lookups every (min)", 1, 1440],
["drawing_recheck_hours", "Recheck a drawing after (hours)", 1, 168],
["drawing_budget", "Drawing lookups per run", 1, 500],
["auto_release_max", "Auto-release at most (per run)", 1, 500],
];
export interface Form {
nums: Record<NumKey, string>;
shop_days: number[];
shop_start: string;
shop_end: string;
golive_date: string;
departments: string[];
labor_tickets: boolean;
auto_release: boolean;
paused: boolean;
}
export function toForm(s: FeedSettings): Form {
const nums = {} as Record<NumKey, string>;
for (const [k] of NUMS) nums[k] = String(s[k] ?? "");
return {
nums,
shop_days: [...(s.shop_days ?? [])].sort(),
shop_start: s.shop_start ?? "",
shop_end: s.shop_end ?? "",
golive_date: s.golive_date ?? "",
departments: [...(s.departments ?? [])].sort(),
labor_tickets: !!s.labor_tickets,
auto_release: !!s.auto_release,
paused: !!s.paused,
};
}
const sameList = <T,>(a: readonly T[], b: readonly T[]) => a.length === b.length && a.every((x, i) => x === b[i]);
/** The changed fields only, or an error text. */
export function feedPatch(was: FeedSettings, f: Form): { patch: Partial<FeedSettings> } | { error: string } {
const patch: Partial<FeedSettings> = {};
for (const [k, label, lo, hi] of NUMS) {
const raw = f.nums[k].trim();
const n = Number(raw);
if (raw === "" || !Number.isInteger(n) || n < lo || n > hi) return { error: `${label}: a whole number from ${lo} to ${hi}.` };
if (n !== was[k]) patch[k] = n;
}
if (!/^\d{2}:\d{2}$/.test(f.shop_start) || !/^\d{2}:\d{2}$/.test(f.shop_end)) return { error: "Shop hours need a start and an end time." };
if (f.shop_start >= f.shop_end) return { error: "Shop hours must end after they start." };
if (!f.shop_days.length) return { error: "Pick at least one shop day." };
const days = [...f.shop_days].sort();
if (!sameList(days, [...(was.shop_days ?? [])].sort())) patch.shop_days = days;
if (f.shop_start !== was.shop_start) patch.shop_start = f.shop_start;
if (f.shop_end !== was.shop_end) patch.shop_end = f.shop_end;
const golive = f.golive_date || null;
if (golive !== (was.golive_date ?? null)) patch.golive_date = golive;
const depts = [...f.departments].sort();
if (!sameList(depts, [...(was.departments ?? [])].sort())) patch.departments = depts;
if (f.labor_tickets !== !!was.labor_tickets) patch.labor_tickets = f.labor_tickets;
if (f.auto_release !== !!was.auto_release) patch.auto_release = f.auto_release;
if (f.paused !== !!was.paused) patch.paused = f.paused;
return { patch };
}
+39
View File
@@ -42,3 +42,42 @@ export function errorText(err: unknown): string {
}
export const DIGITS = /^\d+$/;
/** Whole minutes between two times, rounded, never negative. */
function minutesBetween(a: number, b: number): number {
return Math.max(0, Math.round((b - a) / 60_000));
}
function spanText(mins: number): string {
if (mins < 1) return "under a minute";
if (mins < 90) return `${mins} min`;
const h = Math.round(mins / 60);
if (h < 36) return `${h} h`;
return `${Math.round(h / 24)} d`;
}
/** "2 min ago", "just now", or a dash. */
export function fmtAgo(iso: string | null | undefined, now: Date = new Date()): string {
if (!iso) return "-";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return iso;
const m = minutesBetween(t, now.getTime());
return m < 1 ? "just now" : `${spanText(m)} ago`;
}
/** "in 3 min", "now", or a dash. */
export function fmtIn(iso: string | null | undefined, now: Date = new Date()): string {
if (!iso) return "-";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return iso;
const m = minutesBetween(now.getTime(), t);
return m < 1 ? "now" : `in ${spanText(m)}`;
}
/** A date-only ISO string as "Oct 16 2026". */
export function fmtDay(iso: string | null | undefined): string {
if (!iso) return "-";
const d = new Date(`${iso.slice(0, 10)}T12:00:00`);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
+38 -1
View File
@@ -39,9 +39,46 @@ export function projectPillClass(s: ProjectStatus): string {
/** A flag text that names a hard problem shows in pink, the rest in amber. */
export function flagClass(flag: string): string {
return /couldn't|not in|mismatch|No approved|Not released/i.test(flag) ? "due over" : "due soon";
return /couldn't|not in|mismatch|No approved|Not released|failed|cancelled|not a number/i.test(flag) ? "due over" : "due soon";
}
/** The flag kinds the find bar filters on. The value goes to GET /api/packets?flag=. "1" is any flag. */
export const FLAG_FILTERS: ReadonlyArray<[string, string]> = [
["", "All packets"],
["1", "All flags"],
["drawing_needed", "Drawing needed"],
["rev_mismatch", "Rev mismatch"],
["not_released", "Not released"],
["not_found", "Not found"],
["lookup_failed", "Lookup failed"],
["wo_gone", "Work order gone"],
["cancelled", "Cancelled"],
["hand", "Hand upload"],
];
/** Short labels for flag kinds on cards and lists. */
export const FLAG_KIND_LABEL: Record<string, string> = {
drawing_needed: "Drawing needed",
rev_mismatch: "Rev mismatch",
not_released: "Not released",
not_found: "Not found",
lookup_failed: "Lookup failed",
bad_rev: "Bad rev",
no_drawing: "No drawing",
wo_gone: "Work order gone",
cancelled: "Cancelled",
hand: "Made by hand",
hand_drawing: "Hand drawing",
hand_bom: "Hand BOM",
};
export function flagKindLabel(k: string): string {
return FLAG_KIND_LABEL[k] ?? k.replace(/_/g, " ");
}
/** The drawing flags a VISUAL draft carries until M-Files gives a usable drawing. */
export const DRAWING_FLAGS = ["drawing_needed", "rev_mismatch", "not_released", "not_found", "lookup_failed", "bad_rev"] as const;
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** The old dueInfo(): "Due Oct 20 '26 · 4 d left", with a class for colour. */
+18 -1
View File
@@ -13,8 +13,10 @@ import { dueInfo, flagClass, jobName, pillClass, statusLabel, targetHours } from
import { DrawingViewer } from "./DrawingViewer";
import { MoveSheet, SendToDeptSheet } from "./sheets";
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
import { coSummary, coToReview } from "../lib/changeOrders";
import { ChangesTab, VisualBox } from "./visual";
type Tab = "drawing" | "materials" | "workorders" | "docs" | "history";
type Tab = "drawing" | "materials" | "workorders" | "changes" | "docs" | "history";
/** The rule's signature meaning for attaching a drawing by hand. */
const SIGN_ATTACH = "Attach a drawing by hand";
@@ -259,6 +261,7 @@ export function PacketPage() {
const fileIt = () => mutate((g) => packetsApi.transition(p.id, { action: "file", expect: { status: p.status, dept: p.dept.code }, ...g }), { sign: SIGN_FILE, ok: "Filed in the data book." });
const drawingPages = p.pages.filter((x) => x.type === "drawing");
const review = coToReview(p);
const shownTab: Tab = tab ?? (drawingPages.length ? "drawing" : "materials");
// The warehouse short-parts exception and the data book date are not edits of the packet itself.
const passive = new Set(["wh_receive_short", "set_databook_at"]);
@@ -276,6 +279,7 @@ export function PacketPage() {
{ id: "drawing", label: "Drawing", count: drawingPages.length },
{ id: "materials", label: "Materials", count: p.bom.length },
{ id: "workorders", label: "Work orders", count: p.work_orders.length },
{ id: "changes", label: "Changes", count: (p.change_orders ?? []).length },
{ id: "docs", label: "Documents", count: p.docs.length },
{ id: "history", label: "History" },
];
@@ -401,6 +405,18 @@ export function PacketPage() {
</div>
) : null}
{review && (me?.roles ?? []).some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin") ? (
<div className="callout warn" role="status">
<b>Change order {review.co_no}: a change from VISUAL is waiting for your approval</b> - {coSummary(review)}. Nothing has gone to the shop packet yet.
{review.hold ? <b> Shop packet on hold.</b> : null}{" "}
<button className="btn sm pri" type="button" onClick={() => setTab("changes")}>
Review change
</button>
</div>
) : null}
<VisualBox p={p} roles={me?.roles ?? []} sub={job} mutate={mutate} busy={busy} askReason={askReason} onError={(text) => setMsg({ ok: false, text })} />
{editing ? <HeaderEdit key={p.version} p={p} mutate={mutate} busy={busy} onDone={() => setEditing(false)} /> : null}
{hasDraftControls ? (
@@ -522,6 +538,7 @@ export function PacketPage() {
) : null}
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} /> : 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}
{shownTab === "history" ? <HistoryTab p={p} /> : null}
</div>
+227
View File
@@ -0,0 +1,227 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { ChangeOrder, PacketRead } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch } from "../test/fetchMock";
import { makePacket } from "../test/packets";
const CLARA = makeMe();
const LEO = makeMe({ user: { display_name: "Leo Lead", sam_account: "clerk.leo" }, roles: ["clerical_supervisor"] });
function visualDraft(over: Partial<PacketRead> = {}): PacketRead {
return makePacket({
source_kind: "visual",
source: { kind: "visual", batch: 1 },
drawing: "M00543",
rev: "2",
drawing_state: "needed",
flags: ["rev_mismatch"],
flag: "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3",
visual: {
appr: { ok: true, rev: 3, vrev: "2", name: "M00543 r3.pdf", note: "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3" },
staged_rev: { rev: 3, name: "M00543 r3.pdf" },
partspecs: ["Grind welds flush.", "Paint after test."],
folded: [{ wo: "L900001", seq: 30, op: "MFG" }],
},
can: ["edit_header", "edit_document_set", "edit_bom", "remove"],
blocked: {},
...over,
});
}
function feedCo(over: Partial<ChangeOrder> = {}): ChangeOrder {
return {
id: 51,
co_no: 2,
origin: "visual",
review: "pending",
minor: false,
hold: false,
ecn: "VISUAL",
reason: "VISUAL changed",
issued_at: "2026-10-16T08:00:00Z",
issued_by: null,
items: [],
required: [],
verified: {},
vis: { from_dwg: "M00543", from_rev: "1", to_dwg: "M00543", to_rev: "2", dwg_change: true, changes: ["L900001 qty 2 -> 3"] },
waiting: null,
staged: { asset_id: "as-2", name: "M00543 r2.pdf", rev: "2", dwg: "M00543", pages: 1 },
target_fp: "fp",
...over,
};
}
function inShop(co: ChangeOrder): PacketRead {
return makePacket({
status: "in_progress",
source_kind: "visual",
source: { kind: "visual" },
drawing_state: "attached",
flags: [],
change_orders: [co],
open_cos: [co.co_no],
can: [],
blocked: {},
holder: "03 Weld Parts",
});
}
function base(packet: PacketRead, me = CLARA) {
return {
"/api/auth/me": { body: me },
"/api/departments": { body: DEPARTMENTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
};
}
async function sign(secret = "pw-1") {
const sheet = await screen.findByRole("dialog", { name: "Sign" });
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: secret } });
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
}
describe("Packet page, VISUAL box", () => {
it("shows the flag, the planner instructions and folded operations; Clerical sees no rev approve", async () => {
mockFetch(base(visualDraft()));
renderApp("/packets/1");
expect(await screen.findByText("Made from VISUAL - drawing not attached yet.")).toBeInTheDocument();
expect(screen.getByText(/M-Files: Rev mismatch: VISUAL calls rev 2/)).toBeInTheDocument();
expect(screen.getByText("A Clerical supervisor can approve rev 3 for this packet.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Approve rev 3 for this packet" })).not.toBeInTheDocument();
expect(screen.getByText("Planner instructions from VISUAL (2)")).toBeInTheDocument();
expect(screen.getByText("Grind welds flush.")).toBeInTheDocument();
expect(screen.getByText("Also on these work orders: L900001 seq 30 MFG")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Upload drawing" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Use VISUAL's BOM again" })).not.toBeInTheDocument();
});
it("a Clerical supervisor approves rev N with a reason and a signature", async () => {
const fetch = mockFetch({
...base(visualDraft(), LEO),
"POST /api/packets/1/rev-approve": { body: visualDraft({ version: 4, flags: [], flag: null, drawing_state: "attached" }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Approve rev 3 for this packet" }));
const ask = await screen.findByRole("dialog", { name: "Approve rev 3 for M00543" });
fireEvent.change(within(ask).getByLabelText("Reason"), { target: { value: "Build to rev 3 per Drafting" } });
fireEvent.click(within(ask).getByRole("button", { name: "Approve and attach" }));
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Approve the M-Files revision")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/rev-approve")).toEqual([
{ reason: "Build to rev 3 per Drafting", sign: { factor: "password", secret: "pw-1" } },
]);
expect(await screen.findByText("Rev 3 approved and attached.")).toBeInTheDocument();
});
it("uploads a hand BOM as multipart with the signature fields", async () => {
const fetch = mockFetch({
...base(visualDraft()),
"POST /api/packets/1/hand/bom": { body: visualDraft({ version: 4, visual: { manual_bom: { name: "bom.csv", reason: "No materials yet", lines: 3 } } }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Upload BOM" }));
const sheet = await screen.findByRole("dialog", { name: "Upload the BOM" });
const file = new File(["Item,Qty,Description\n1,2,Plate\n"], "bom.csv", { type: "text/csv" });
fireEvent.change(within(sheet).getByLabelText("File (XLSX or CSV, up to 25 MB)"), { target: { files: [file] } });
fireEvent.change(within(sheet).getByLabelText("Why by hand (kept on the packet)"), { target: { value: "No materials yet" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Upload and sign" }));
await sign("pw-9");
const call = fetch.mock.calls.find(([u, init]) => String(u) === "/api/packets/1/hand/bom" && init?.method === "POST");
const fd = call?.[1]?.body as FormData;
expect(fd).toBeInstanceOf(FormData);
expect(fd.get("reason")).toBe("No materials yet");
expect(fd.get("sign_factor")).toBe("password");
expect(fd.get("sign_secret")).toBe("pw-9");
expect((fd.get("file") as File).name).toBe("bom.csv");
expect(await screen.findByRole("button", { name: "Use VISUAL's BOM again" })).toBeInTheDocument();
expect(screen.getByText("BOM uploaded by hand:", { exact: false })).toBeInTheDocument();
});
it("uploads a hand drawing: the asset first, then the attach with reason and sign", async () => {
const fetch = mockFetch({
...base(visualDraft()),
"POST /api/assets": { status: 201, body: { id: "as-9", mime: "application/pdf", size: 9, sha256: "00", pages: 1, original_name: "d.pdf", url: "/_blob/as-9" } },
"POST /api/packets/1/hand/drawing": { body: visualDraft({ version: 4, flags: ["hand_drawing"] }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Upload drawing" }));
const sheet = await screen.findByRole("dialog", { name: "Upload the drawing" });
fireEvent.change(within(sheet).getByLabelText("File (PDF, up to 25 MB)"), { target: { files: [new File(["%PDF-1.4"], "d.pdf", { type: "application/pdf" })] } });
fireEvent.change(within(sheet).getByLabelText("Why by hand (kept on the packet)"), { target: { value: "Drafting sent it" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Upload and sign" }));
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/hand/drawing")).toEqual([
{ asset_id: "as-9", reason: "Drafting sent it", sign: { factor: "password", secret: "pw-1" } },
]);
});
});
describe("Packet page, Changes tab", () => {
it("Clerical approves a staged feed change order with a signature", async () => {
const co = feedCo();
const fetch = mockFetch({
...base(inShop(co)),
"POST /api/packets/1/change-orders/2/approve": { body: inShop({ ...co, review: "approved", approved_by: { id: "u", display_name: "Clara Clerk" }, required: ["03"] }) },
});
renderApp("/packets/1");
expect(await screen.findByText(/Change order 2: a change from VISUAL is waiting for your approval/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Review change" }));
const sec = await screen.findByRole("region", { name: "Change order 2" });
expect(within(sec).getByText("M00543 Rev 1 -> Rev 2 + 1 other change")).toBeInTheDocument();
expect(within(sec).getByText("L900001 qty 2 -> 3")).toBeInTheDocument();
expect(within(sec).getByRole("link", { name: "Open the new drawing" })).toHaveAttribute("href", "/_blob/as-2");
fireEvent.click(within(sec).getByRole("button", { name: "Approve - update the shop packet" }));
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Approve a change order")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/approve")).toEqual([{ sign: { factor: "password", secret: "pw-1" } }]);
expect(await screen.findByText("Change order 2 approved. The shop packet is updated.")).toBeInTheDocument();
expect(await screen.findByRole("table", { name: "Change order 2 verification" })).toBeInTheDocument();
});
it("Approve is disabled with the reason while the drawing waits; reject and hold post", async () => {
const co = feedCo({ staged: null, waiting: { dwg: "M00543", rev: "2" } });
const fetch = mockFetch({
...base(inShop(co)),
"POST /api/packets/1/change-orders/2/hold": { body: inShop({ ...co, hold: true }) },
"POST /api/packets/1/change-orders/2/reject": { body: inShop({ ...co, hold: true, review: "rejected", rejected_reason: "Not for this unit" }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
const sec = await screen.findByRole("region", { name: "Change order 2" });
expect(within(sec).getByRole("button", { name: "Approve - update the shop packet" })).toBeDisabled();
expect(within(sec).getByText("Waits on that rev in M-Files.")).toHaveClass("why");
expect(within(sec).getByText(/That rev isn't released in M-Files yet/)).toBeInTheDocument();
fireEvent.click(within(sec).getByRole("button", { name: "Hold the shop packet now" }));
expect(await within(sec).findByRole("button", { name: "Release the hold" })).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/hold")).toEqual([{ hold: true }]);
fireEvent.click(within(sec).getByRole("button", { name: "Reject" }));
const ask = await screen.findByRole("dialog", { name: "Reject change order 2" });
fireEvent.change(within(ask).getByLabelText("Reason"), { target: { value: "Not for this unit" } });
fireEvent.click(within(ask).getByRole("button", { name: "Reject" }));
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/reject")).toEqual([
{ reason: "Not for this unit", sign: { factor: "password", secret: "pw-1" } },
]);
});
it("shows the co_not_staged answer as the callout text", async () => {
const co = feedCo();
mockFetch({
...base(inShop(co)),
"POST /api/packets/1/change-orders/2/approve": {
status: 409,
body: { detail: "The new drawing has not been fetched from M-Files yet.", code: "co_not_staged" },
},
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Review change" }));
fireEvent.click(await screen.findByRole("button", { name: "Approve - update the shop packet" }));
await sign();
expect(await screen.findByRole("alert")).toHaveTextContent("The new drawing has not been fetched from M-Files yet.");
});
});
+562
View File
@@ -0,0 +1,562 @@
// The VISUAL box and the Changes tab on the packet page (phase 3).
import { useState, type FormEvent } from "react";
import { assetsApi, blobUrl } from "../api/assets";
import { packetsApi } from "../api/packets";
import type { ChangeOrder, FoldedOp, HandUpload, PacketRead, WhoRef } from "../api/types";
import type { ReasonOptions } from "../components/ReasonSheet";
import { Sheet } from "../components/Sheet";
import { errorText, fmtTime } from "../lib/format";
import { coSummary, feedOrigin } from "../lib/changeOrders";
import { DRAWING_FLAGS } from "../lib/packets";
import type { Mutate } from "./tabs";
/** Signature meanings, as the rules state them. */
export const SIGN_REV_APPROVE = "Approve the M-Files revision";
export const SIGN_HAND_DRAWING = "Attach a drawing by hand";
export const SIGN_HAND_BOM = "Upload the BOM by hand";
export const SIGN_CO_APPROVE = "Approve a change order";
export const SIGN_CO_REJECT = "Reject a change order";
type AskReason = (opts: ReasonOptions) => Promise<string | null>;
const isCl = (roles: readonly string[]) => roles.some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin");
const isClSup = (roles: readonly string[]) => roles.some((r) => r === "clerical_supervisor" || r === "admin");
function whoName(by: WhoRef | string | null | undefined): string {
if (!by) return "";
return typeof by === "string" ? by : by.display_name;
}
function foldedText(f: FoldedOp): string {
const k = f.key;
const wo = f.wo ?? f.wo_base ?? (k && typeof k === "object" ? [k.base, k.lot].filter(Boolean).join("/") : typeof k === "string" ? k : "");
return [wo, f.seq !== undefined ? `seq ${f.seq}` : "", f.op ?? ""].filter(Boolean).join(" ");
}
/* ---------- Hand upload sheet ---------- */
function HandSheet({
p,
kind,
sub,
onCancel,
onSubmit,
}: {
p: PacketRead;
kind: "drawing" | "bom";
sub: string;
onCancel: () => void;
onSubmit: (file: File, reason: string) => void;
}) {
const dwg = kind === "drawing";
const [file, setFile] = useState<File | null>(null);
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const submit = (e: FormEvent) => {
e.preventDefault();
if (!file) {
setError("Choose the file.");
return;
}
if (reason.trim().length < 3) {
setError("Say why it is uploaded by hand.");
return;
}
if (dwg && !/\.pdf$/i.test(file.name) && file.type !== "application/pdf") {
setError("The drawing must be a PDF.");
return;
}
onSubmit(file, reason.trim());
};
return (
<Sheet title={dwg ? "Upload the drawing" : "Upload the BOM"} sub={sub} onClose={onCancel}>
<div className="small">
{dwg ? (
<>
VISUAL calls{" "}
<b>
{p.drawing || "-"} rev {p.rev || "-"}
</b>
. Upload that drawing as a PDF. It replaces any drawing on this draft.
</>
) : (
<>Excel or CSV with a header row (Item, Qty, Part ID, Description, Length, Material, any of those names). It replaces the BOM on this draft. The feed will not overwrite it.</>
)}
</div>
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
{dwg ? "File (PDF, up to 25 MB)" : "File (XLSX or CSV, up to 25 MB)"}
<input
className="in"
type="file"
accept={dwg ? "application/pdf" : ".xlsx,.csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv"}
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
<label className="f">
Why by hand (kept on the packet)
<input
className="in"
value={reason}
maxLength={200}
placeholder={dwg ? "e.g. rev 2 not released in M-Files yet, Drafting sent the PDF" : "e.g. no materials in VISUAL yet, BOM from Planning"}
onChange={(e) => setReason(e.target.value)}
/>
</label>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit">
Upload and sign
</button>
</div>
</form>
</Sheet>
);
}
function HandLine({ what, m }: { what: string; m: HandUpload }) {
return (
<div className="small">
{what} uploaded by hand: <span className="mono">{m.name ?? ""}</span>
{m.by ? ` - ${whoName(m.by)}` : ""} {m.at ? fmtTime(m.at) : ""}
{m.reason ? ` · ${m.reason}` : ""}
{typeof m.lines === "number" ? ` · ${m.lines} line${m.lines === 1 ? "" : "s"}` : ""}
</div>
);
}
/* ---------- The VISUAL box ---------- */
export function VisualBox({
p,
roles,
sub,
mutate,
busy,
askReason,
onError,
}: {
p: PacketRead;
roles: readonly string[];
sub: string;
mutate: Mutate;
busy: boolean;
askReason: AskReason;
onError: (text: string) => void;
}) {
const [hand, setHand] = useState<"drawing" | "bom" | null>(null);
const [uploading, setUploading] = useState(false);
if (p.source_kind !== "visual") return null;
const v = p.visual ?? {};
const flags = p.flags ?? [];
const draft = p.status === "draft";
const hasDwg = p.pages.some((x) => x.type === "drawing" && !x.void);
const drawingFlag = flags.some((f) => (DRAWING_FLAGS as readonly string[]).includes(f));
const appr = v.appr;
const staged = v.staged_rev;
const mismatch = flags.includes("rev_mismatch");
const md = v.manual_dwg;
const mb = v.manual_bom;
const canHand = draft && !p.removed && (isCl(roles) || p.can.includes("edit_document_set")) && (flags.length > 0 || !!md || !!mb);
const canApproveRev = draft && !p.removed && (p.can.includes("rev_approve") || isClSup(roles));
const partspecs = v.partspecs ?? [];
const folded = v.folded ?? [];
const vs = p.visual_state;
const approveRev = async () => {
if (!staged) return;
const reason = await askReason({
title: `Approve rev ${staged.rev} for ${p.drawing ?? "this drawing"}`,
sub,
note: `VISUAL calls rev ${p.rev || "-"}. The drawing in M-Files is rev ${staged.rev}${staged.name ? `: ${staged.name}` : ""}. That drawing is attached to this packet. It is recorded with your name, signature and the reason. If VISUAL's rev changes, the packet is flagged again.`,
placeholder: "e.g. rev 2 not released yet - build to rev 1 per Drafting",
min: 3,
max: 400,
submitLabel: "Approve and attach",
});
if (reason === null) return;
await mutate((g) => packetsApi.revApprove(p.id, { reason, ...g }), { sign: SIGN_REV_APPROVE, ok: `Rev ${staged.rev} approved and attached.` });
};
const upload = async (kind: "drawing" | "bom", file: File, reason: string) => {
setHand(null);
if (kind === "bom") {
await mutate((g) => packetsApi.handBom(p.id, file, reason, g), { sign: SIGN_HAND_BOM, ok: "BOM uploaded. Check it, then send it on." });
return;
}
setUploading(true);
let asset;
try {
asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "drawing" });
} catch (err) {
onError(errorText(err));
return;
} finally {
setUploading(false);
}
if (asset.mime !== "application/pdf") {
onError("The drawing must be a PDF.");
return;
}
await mutate((g) => packetsApi.handDrawing(p.id, { asset_id: asset.id, reason, ...g }), {
sign: SIGN_HAND_DRAWING,
ok: "Drawing attached. Check it, then send it on.",
});
};
const bomOff = () => mutate((g) => packetsApi.removeHandBom(p.id, g), { ok: "Hand BOM taken off. VISUAL's BOM is back." });
const working = busy || uploading;
return (
<section className="stack" aria-label="From VISUAL">
{v.rev_ok && hasDwg ? (
<div className="callout flush">
<b>Rev {String(v.rev_ok.rev)} approved by hand</b> in place of VISUAL rev {v.rev_ok.vis_rev || "-"}
{v.rev_ok.by ? ` - ${whoName(v.rev_ok.by)}` : ""} {v.rev_ok.at ? fmtTime(v.rev_ok.at) : ""}
{v.rev_ok.reason ? `: ${v.rev_ok.reason}` : ""}.
</div>
) : null}
{!hasDwg || drawingFlag ? (
<div className="callout warn flush">
<b>{hasDwg ? "Made from VISUAL - drawing needs a look." : "Made from VISUAL - drawing not attached yet."}</b> {p.drawing || "No drawing number"} rev {p.rev || "-"}. The drawing
is attached from M-Files automatically when its rev matches the rev VISUAL calls. Only Clerical can upload one by hand, and only while the packet is flagged.
{appr?.note ? <div className="small">M-Files: {appr.note}{appr.at ? ` (looked ${fmtTime(appr.at)})` : ""}</div> : null}
{mismatch ? (
<div className="small">
{staged ? (
<>
In M-Files: <span className="mono">{staged.name ?? `rev ${staged.rev}`}</span>.{" "}
{canApproveRev ? (
<button className="btn sm pri" type="button" disabled={working} onClick={() => void approveRev()}>
Approve rev {String(staged.rev)} for this packet
</button>
) : draft ? (
<span className="muted">A Clerical supervisor can approve rev {String(staged.rev)} for this packet.</span>
) : null}
</>
) : (
<span className="muted">The M-Files drawing is being copied. Check back after the next drawing run.</span>
)}
</div>
) : null}
</div>
) : null}
{canHand ? (
<div className="callout flush">
<b>Flagged - Clerical can upload the drawing or the BOM by hand.</b> Only if it cannot come from M-Files or VISUAL in time. It is recorded with your name,
signature and reason. The packet stays in Flagged until you send it on.
<div className="acts">
<button className="btn sm pri" type="button" disabled={working} onClick={() => setHand("drawing")}>
{md ? "Replace drawing" : "Upload drawing"}
</button>
<button className="btn sm" type="button" disabled={working} onClick={() => setHand("bom")}>
{mb ? "Replace BOM" : "Upload BOM"}
</button>
{mb ? (
<button className="btn sm ghost" type="button" disabled={working} onClick={() => void bomOff()}>
Use VISUAL's BOM again
</button>
) : null}
</div>
{md ? <HandLine what="Drawing" m={md} /> : null}
{mb ? <HandLine what="BOM" m={mb} /> : null}
</div>
) : null}
{vs && (vs.closed_at || vs.cancelled_at || vs.ticket_hours) ? (
<div className="small">
{vs.closed_at ? <span>Closed in VISUAL (seen {fmtTime(vs.closed_at)}). </span> : null}
{vs.cancelled_at ? <span className="due over">Cancelled in VISUAL (seen {fmtTime(vs.cancelled_at)}). </span> : null}
{vs.ticket_hours !== null && vs.ticket_hours !== undefined && Number(vs.ticket_hours) > 0 ? (
<span className="muted">Labor tickets in VISUAL: {Number(vs.ticket_hours).toFixed(1)} h.</span>
) : null}
</div>
) : null}
{partspecs.length || folded.length ? (
<details>
<summary className="small">
{partspecs.length ? `Planner instructions from VISUAL (${partspecs.length})` : `Also on these work orders (${folded.length})`}
</summary>
{partspecs.map((s, i) => (
<pre key={i} className="small pre">
{s}
</pre>
))}
{folded.length ? <div className="small muted">Also on these work orders: {folded.map(foldedText).join(", ")}</div> : null}
</details>
) : null}
{hand ? <HandSheet p={p} kind={hand} sub={sub} onCancel={() => setHand(null)} onSubmit={(f, r) => void upload(hand, f, r)} /> : null}
</section>
);
}
/* ---------- Change orders ---------- */
function coPill(co: ChangeOrder) {
if (co.review === "rejected") return <span className="pill st-filed">Rejected</span>;
if (co.review === "pending") return <span className="pill st-review">Waiting on Clerical</span>;
const open = co.required.filter((d) => !co.verified[d]);
if (open.length) return <span className="pill st-review">Open - {open.length} to verify</span>;
return <span className="pill st-in_progress">Verified</span>;
}
function ReviewPanel({
p,
co,
sub,
mutate,
busy,
askReason,
canReview,
}: {
p: PacketRead;
co: ChangeOrder;
sub: string;
mutate: Mutate;
busy: boolean;
askReason: AskReason;
canReview: boolean;
}) {
const v = co.vis ?? {};
const needsDrawing = !!v.dwg_change || !!co.waiting;
const ready = !needsDrawing || !!co.staged;
const why = ready ? "" : "Waits on that rev in M-Files.";
const old = p.pages.find((x) => x.type === "drawing" && !x.void);
const approve = () =>
mutate((g) => packetsApi.coApprove(p.id, co.co_no, g), { sign: SIGN_CO_APPROVE, ok: `Change order ${co.co_no} approved. The shop packet is updated.` });
const reject = async () => {
const reason = await askReason({
title: `Reject change order ${co.co_no}`,
sub,
note: "The shop packet is not changed. If VISUAL changes again, a new change order comes in.",
min: 3,
max: 400,
danger: true,
submitLabel: "Reject",
});
if (reason === null) return;
await mutate((g) => packetsApi.coReject(p.id, co.co_no, { reason, ...g }), { sign: SIGN_CO_REJECT, ok: `Change order ${co.co_no} rejected.` });
};
const hold = () =>
mutate((g) => packetsApi.coHold(p.id, co.co_no, !co.hold, g), { ok: co.hold ? "Hold released." : "Shop packet held." });
return (
<div className="panel stack">
<h3>Clerical review - nothing has gone to the shop yet</h3>
{needsDrawing ? (
co.staged ? (
<div className="small">
<span className="pill st-filed">NOW</span> {old?.dwg || v.from_dwg || "sheet"} Rev {old?.rev || v.from_rev || "?"} <span className="muted">-&gt;</span>{" "}
<span className="pill st-in_progress">NEW</span> {co.staged.dwg} Rev {co.staged.rev} <span className="muted small">{co.staged.name ?? ""}</span>{" "}
<a className="lnk" href={blobUrl(co.staged.asset_id)} target="_blank" rel="noopener noreferrer">
Open the new drawing
</a>
</div>
) : (
<div className="callout warn flush">
VISUAL calls {co.waiting?.dwg ?? v.to_dwg ?? ""} Rev {co.waiting?.rev ?? v.to_rev ?? "?"}. That rev isn't released in M-Files yet. It shows up here when it is.
{co.waiting?.err ? <div className="small">Last try: {co.waiting.err}</div> : null}
</div>
)
) : null}
{v.changes?.length ? (
<div className="small">
<b>Also changing on approval:</b>
<ul>
{v.changes.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
<span className="muted">BOM lines keep the heats, pulls and issues already recorded on them.</span>
</div>
) : null}
{co.minor ? <div className="small muted">A minor change (target hours or want date). No department needs to verify it.</div> : null}
{canReview ? (
<>
<div className="acts">
<div className="act">
<button className="btn pri" type="button" disabled={busy || !ready} title={why || undefined} onClick={() => void approve()}>
Approve - update the shop packet
</button>
{why ? <div className="why small">{why}</div> : null}
</div>
<div className="act">
<button className="btn pink" type="button" disabled={busy} onClick={() => void reject()}>
Reject
</button>
</div>
<div className="act">
<button className="btn ghost" type="button" disabled={busy} onClick={() => void hold()}>
{co.hold ? "Release the hold" : "Hold the shop packet now"}
</button>
</div>
</div>
<div className="small muted">
{co.hold
? "Held. Sign-offs on this packet are stopped until you approve or reject."
: "The shop keeps working on the current drawing until you approve. Hold it if they should stop now."}
</div>
</>
) : (
<div className="small muted">A Clerical user approves or rejects this change.</div>
)}
</div>
);
}
/** The Changes tab: every change order, newest first. Clerical reviews the ones from VISUAL and M-Files here. */
export function ChangesTab({
p,
roles,
sub,
mutate,
busy,
askReason,
}: {
p: PacketRead;
roles: readonly string[];
sub: string;
mutate: Mutate;
busy: boolean;
askReason: AskReason;
}) {
const list = [...(p.change_orders ?? [])].sort((a, b) => b.co_no - a.co_no);
if (!list.length) return <div className="empty">No change orders on this packet.</div>;
const canReview = isCl(roles) || p.can.includes("co_approve");
return (
<div className="stack">
{list.map((co) => {
const feed = feedOrigin(co);
return (
<section key={co.id} className="sec" aria-label={`Change order ${co.co_no}`}>
<div className="sec-h flush">
<h2>
Change order {co.co_no}
{co.ecn ? ` · ${co.ecn}` : ""}
</h2>
{coPill(co)}
<span className="ct">
{feed ? (co.origin === "drawing" ? "from M-Files" : "from VISUAL") : `issued by ${co.issued_by?.display_name ?? "-"}`} {fmtTime(co.issued_at)}
{co.hold ? " · shop packet on hold" : ""}
</span>
</div>
<div className="small">
<b>{coSummary(co)}</b>
</div>
{co.review === "pending" && feed ? (
canReview ? (
<ReviewPanel p={p} co={co} sub={sub} mutate={mutate} busy={busy} askReason={askReason} canReview={canReview} />
) : (
<div className="callout warn flush">
<b>Clerical is reviewing a change from VISUAL.</b> Keep working to the current drawing. If Clerical approves it, the new drawing and changes come onto
this packet and you will be asked to verify.
</div>
)
) : null}
{co.review === "rejected" ? (
<div className="callout flush">
{co.rejected_by ? (
<>
<b>Rejected by Clerical</b> - {co.rejected_by.display_name} {fmtTime(co.rejected_at)}: {co.rejected_reason ?? ""}. The shop packet was not changed.
</>
) : (
<>
<b>Withdrawn</b> {fmtTime(co.rejected_at)}: {co.rejected_reason ?? "VISUAL went back to what the packet has"}. The shop packet was not changed.
</>
)}
</div>
) : null}
{co.review === "approved" && co.approved_by ? (
<div className="small">
<b>Approved by Clerical:</b> {co.approved_by.display_name} {fmtTime(co.approved_at)}
</div>
) : null}
{co.review === "approved" && feed && co.vis?.changes?.length ? (
<div className="small">
<b>Also changed from VISUAL:</b> {co.vis.changes.join("; ")}
</div>
) : null}
<div className="small">
<b>Reason:</b> {co.reason || "-"}
</div>
{co.items.length ? (
<div className="tw">
<table aria-label={`Change order ${co.co_no} sheets`}>
<thead>
<tr>
<th>Old sheet</th>
<th>New sheet</th>
</tr>
</thead>
<tbody>
{co.items.map((it, i) => (
<tr key={i}>
<td className="small">
{it.added ? (
<span className="muted">Added</span>
) : (
<>
<span className="pill st-filed">VOID</span> {it.old_dwg || "sheet"} Rev {it.old_rev || "?"}
{it.old_page ? <span className="muted"> · page {it.old_page}</span> : null}
</>
)}
</td>
<td className="small">
<span className="pill st-in_progress">{it.added ? "ADDED" : "NEW"}</span> {it.new_dwg || "sheet"} Rev {it.new_rev || "?"}
{it.new_page ? <span className="muted"> · page {it.new_page}</span> : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{co.review === "approved" && co.required.length ? (
<div className="tw">
<table aria-label={`Change order ${co.co_no} verification`}>
<thead>
<tr>
<th>Department</th>
<th>Verified</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{co.required.map((d) => {
const ver = co.verified[d];
return (
<tr key={d}>
<td className="mono">{d}</td>
<td className="small">{ver ? `${ver.by?.display_name ?? "-"} ${fmtTime(ver.at)}` : <span className="due soon">Pending</span>}</td>
<td className="small">{ver?.note ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</section>
);
})}
</div>
);
}
+6 -1
View File
@@ -5,8 +5,9 @@ import type { AdLookup, AdRefreshResult, AdminUser, GroupRow, PinState } from ".
import { useAuth } from "../../auth/useAuth";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad, type Load } from "../../lib/useLoad";
import { FeedTab, ResourcesTab } from "./FeedTabs";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts" | "feed" | "resources";
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "users", label: "Users" },
@@ -14,6 +15,8 @@ const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "devices", label: "Devices" },
{ id: "groups", label: "Group map" },
{ id: "attempts", label: "Sign-in attempts" },
{ id: "feed", label: "Feed" },
{ id: "resources", label: "VISUAL resources" },
];
const ROLES_NOTE = "Roles come from AD groups. Ask IT to change them.";
@@ -696,6 +699,8 @@ export function AdminPage() {
{tab === "devices" ? <DevicesTab /> : null}
{tab === "groups" ? <GroupsTab /> : null}
{tab === "attempts" ? <AttemptsTab /> : null}
{tab === "feed" ? <FeedTab /> : null}
{tab === "resources" ? <ResourcesTab /> : null}
</div>
</>
);
@@ -0,0 +1,113 @@
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 SETTINGS = {
interval_shop_minutes: 5,
interval_off_minutes: 30,
shop_days: [0, 1, 2, 3, 4],
shop_start: "06:00",
shop_end: "18:00",
window_back_days: 14,
window_ahead_days: 30,
golive_date: null,
departments: [],
drawing_recheck_hours: 4,
drawing_budget: 60,
drawing_interval_minutes: 30,
labor_tickets: true,
auto_release: true,
auto_release_max: 25,
paused: false,
next_run_at: "2026-10-16T08:05:00Z",
};
const RUN = {
id: 7,
job: "visual-feed",
trigger: "schedule",
started_at: "2026-10-16T07:58:00Z",
finished_at: "2026-10-16T07:58:10Z",
status: "ok",
counts: { new_drafts: 1 },
errors: [],
};
function base() {
return {
"/api/auth/me": { body: ADMIN },
"/api/departments": { body: DEPARTMENTS },
"/api/admin/users": { body: [] },
"/api/admin/feed/settings": { body: SETTINGS },
"/api/jobs/visual-feed/runs": { body: [RUN] },
"/api/jobs": { body: { jobs: [], heartbeat: { at: "2026-10-16T07:59:00Z", build: "1.0", running: [], stale: false } } },
"/api/admin/feed/resource-map": {
body: [
{ resource: "WELD-PART", target: "03", confirmed: true, rows_last_run: 40, updated_at: null },
{ resource: "NEWRES", target: null, confirmed: false, rows_last_run: 3, updated_at: null },
{ resource: "MFG", target: "fold", confirmed: false, rows_last_run: 10, updated_at: null },
],
},
};
}
describe("Admin feed tabs", () => {
it("the Feed tab PATCHes only the changed fields", async () => {
const fetch = mockFetch({ ...base(), "PATCH /api/admin/feed/settings": { body: { ...SETTINGS, paused: true, interval_shop_minutes: 10, shop_days: [0, 1, 2, 3, 4, 5], departments: ["03"] } } });
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Feed" }));
const form = await screen.findByRole("form", { name: "Feed settings" });
expect(screen.getByRole("table", { name: "Last feed runs" })).toBeInTheDocument();
fireEvent.change(within(form).getByLabelText("Every (min) in shop hours"), { target: { value: "10" } });
fireEvent.click(within(within(form).getByRole("group", { name: "Shop days" })).getByRole("button", { name: "Sat" }));
fireEvent.click(await within(within(form).getByRole("group", { name: "Departments" })).findByRole("button", { name: "03 Weld Parts" }));
fireEvent.click(within(form).getByLabelText(/^Paused/));
fireEvent.click(within(form).getByRole("button", { name: "Save" }));
expect(await screen.findByText("Saved. The worker picks up the new settings at once.")).toBeInTheDocument();
expect(callsTo(fetch, "PATCH", "/api/admin/feed/settings")).toEqual([
{ interval_shop_minutes: 10, shop_days: [0, 1, 2, 3, 4, 5], departments: ["03"], paused: true },
]);
});
it("the Feed tab refuses a number out of range and sends nothing", async () => {
const fetch = mockFetch(base());
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Feed" }));
const form = await screen.findByRole("form", { name: "Feed settings" });
fireEvent.change(within(form).getByLabelText("Drawing lookups per run"), { target: { value: "900" } });
fireEvent.click(within(form).getByRole("button", { name: "Save" }));
expect(await screen.findByText("Drawing lookups per run: a whole number from 1 to 500.")).toBeInTheDocument();
expect(callsTo(fetch, "PATCH", "/api/admin/feed/settings")).toHaveLength(0);
});
it("the VISUAL resources tab saves a row with PUT and an unassign with DELETE", async () => {
const fetch = mockFetch({
...base(),
"PUT /api/admin/feed/resource-map/NEWRES": { body: { resource: "NEWRES", target: "14", confirmed: true, rows_last_run: 3, updated_at: null } },
"DELETE /api/admin/feed/resource-map/MFG": { status: 204 },
});
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "VISUAL resources" }));
const table = await screen.findByRole("table", { name: "VISUAL resources" });
expect(screen.getByText("1 resource is not assigned. Their work orders are left out of the feed.")).toBeInTheDocument();
const rows = within(table).getAllByRole("row");
expect(rows.map((r) => within(r).queryAllByRole("cell")[0]?.textContent ?? "")).toEqual(["", "WELD-PART", "MFG", "NEWRES"]);
fireEvent.change(await within(table).findByLabelText("NEWRES goes to"), { target: { value: "14" } });
fireEvent.click(within(table).getByRole("button", { name: "Save NEWRES" }));
await waitFor(() => expect(callsTo(fetch, "PUT", "/api/admin/feed/resource-map/NEWRES")).toEqual([{ target: "14" }]));
fireEvent.change(within(table).getByLabelText("MFG goes to"), { target: { value: "" } });
fireEvent.click(await screen.findByRole("button", { name: "Save 1 change" }));
await waitFor(() => expect(callsTo(fetch, "DELETE", "/api/admin/feed/resource-map/MFG")).toHaveLength(1));
});
});
+350
View File
@@ -0,0 +1,350 @@
// Admin: the VISUAL feed settings and the resource map (phase 3).
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { feedAdminApi, jobsApi } from "../../api/jobs";
import type { ResourceMapRow } from "../../api/types";
import { DAYS, feedPatch, NUMS, toForm, type Form, type NumKey } from "../../lib/feedSettings";
import { RunsTable } from "../../components/RunsTable";
import { useChanged } from "../../lib/changes";
import { errorText, fmtAgo, fmtIn, fmtTime } from "../../lib/format";
import { useShopDepartments } from "../../lib/useDepartments";
import { useLoad } from "../../lib/useLoad";
const toggle = <T,>(list: T[], v: T) => (list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
function Msg({ msg }: { msg: { ok: boolean; text: string } | null }) {
if (!msg) return null;
return (
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
);
}
/* ---------- Feed ---------- */
export function FeedTab() {
const depts = useShopDepartments();
const [load, reload] = useLoad((s) => feedAdminApi.settings(s));
const [runs, reloadRuns] = useLoad((s) => jobsApi.runs("visual-feed", 10, s));
const [jobs, reloadJobs] = useLoad((s) => jobsApi.list(s));
const [form, setForm] = useState<Form | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const settings = load.state === "ok" ? load.data : null;
useEffect(() => {
if (settings) setForm(toForm(settings));
}, [settings]);
useChanged(
() => {
reloadRuns();
reloadJobs();
},
{ delayMs: 1000, filter: (ev) => ev.event === "feed" },
);
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
if (!settings || !form) return null;
const setNum = (k: NumKey, v: string) => setForm((f) => (f ? { ...f, nums: { ...f.nums, [k]: v } } : f));
const set = <K extends keyof Form>(k: K, v: Form[K]) => setForm((f) => (f ? { ...f, [k]: v } : f));
const save = async (e: FormEvent) => {
e.preventDefault();
const r = feedPatch(settings, form);
if ("error" in r) {
setMsg({ ok: false, text: r.error });
return;
}
if (!Object.keys(r.patch).length) {
setMsg({ ok: true, text: "Nothing changed." });
return;
}
setBusy(true);
setMsg(null);
try {
await feedAdminApi.saveSettings(r.patch);
setMsg({ ok: true, text: "Saved. The worker picks up the new settings at once." });
reload();
reloadJobs();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const runNow = async (job: string) => {
setBusy(true);
setMsg(null);
try {
await jobsApi.run(job);
setMsg({ ok: true, text: job === "visual-feed" ? "Feed run requested." : "Drawing lookup requested." });
reloadJobs();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const hb = jobs.state === "ok" ? jobs.data.heartbeat : null;
const feedJob = jobs.state === "ok" ? jobs.data.jobs.find((j) => j.job === "visual-feed") : undefined;
const next = settings.next_run_at ?? feedJob?.next_run_at ?? null;
return (
<div className="stack">
<div className="sec-h">
<span className="small">
{hb ? (
hb.stale ? (
<span className="due over">Worker not running (last seen {fmtAgo(hb.at)}).</span>
) : (
<>Worker seen {fmtAgo(hb.at)}{hb.build ? ` · build ${hb.build}` : ""}. </>
)
) : null}
{settings.paused ? <b className="due over">Paused. </b> : null}
Next feed run: <b>{next ? `${fmtTime(next)} (${fmtIn(next)})` : "not scheduled"}</b>
</span>
<span className="grow" />
<button className="btn sm" type="button" disabled={busy} onClick={() => void runNow("drawing-lookup")}>
Look up drawings now
</button>
<button className="btn sm pri" type="button" disabled={busy} onClick={() => void runNow("visual-feed")}>
Run now
</button>
</div>
<Msg msg={msg} />
<form className="panel stack" onSubmit={save} noValidate aria-label="Feed settings">
<label className="ckl">
<input type="checkbox" checked={form.paused} onChange={(e) => set("paused", e.target.checked)} />
Paused: no scheduled feed, drawing or auto-release runs. Run now still works.
</label>
<h3>Schedule</h3>
<div className="chips" role="group" aria-label="Shop days">
{DAYS.map(([d, l]) => (
<button key={d} type="button" className="chip sm" aria-pressed={form.shop_days.includes(d)} onClick={() => set("shop_days", toggle(form.shop_days, d).sort())}>
{l}
</button>
))}
</div>
<div className="frow">
<label className="f">
Shop hours start
<input className="in" type="time" value={form.shop_start} onChange={(e) => set("shop_start", e.target.value)} />
</label>
<label className="f">
Shop hours end
<input className="in" type="time" value={form.shop_end} onChange={(e) => set("shop_end", e.target.value)} />
</label>
{NUMS.slice(0, 2).map(([k, l, lo, hi]) => (
<label key={k} className="f">
{l}
<input className="in" type="number" min={lo} max={hi} value={form.nums[k]} onChange={(e) => setNum(k, e.target.value)} />
</label>
))}
</div>
<h3>What is read</h3>
<div className="frow">
{NUMS.slice(2, 4).map(([k, l, lo, hi]) => (
<label key={k} className="f">
{l}
<input className="in" type="number" min={lo} max={hi} value={form.nums[k]} onChange={(e) => setNum(k, e.target.value)} />
</label>
))}
<label className="f">
Go-live date
<input className="in" type="date" value={form.golive_date} onChange={(e) => set("golive_date", e.target.value)} />
</label>
</div>
<div className="small muted">Work orders released before the go-live date stay on paper. Clerical can pull one in.</div>
<div className="small">
<b>Departments</b> <span className="muted">Only work orders for these become packets. None picked means every department the resource map routes to.</span>
</div>
<div className="chips" role="group" aria-label="Departments">
{depts.map((d) => (
<button
key={d.code}
type="button"
className="chip sm"
aria-pressed={form.departments.includes(d.code)}
onClick={() => set("departments", toggle(form.departments, d.code).sort())}
>
{d.code} {d.name}
</button>
))}
</div>
<label className="ckl">
<input type="checkbox" checked={form.labor_tickets} onChange={(e) => set("labor_tickets", e.target.checked)} />
Read labor ticket hours (at most every 30 minutes)
</label>
<h3>Drawings and release</h3>
<div className="frow">
{NUMS.slice(4).map(([k, l, lo, hi]) => (
<label key={k} className="f">
{l}
<input className="in" type="number" min={lo} max={hi} value={form.nums[k]} onChange={(e) => setNum(k, e.target.value)} />
</label>
))}
</div>
<label className="ckl">
<input type="checkbox" checked={form.auto_release} onChange={(e) => set("auto_release", e.target.checked)} />
Send drafts to the warehouse on their own when the drawing is attached and nothing is flagged
</label>
<div>
<button className="btn pri" type="submit" disabled={busy}>
Save
</button>
</div>
</form>
<h3>Last ten feed runs</h3>
{runs.state === "ok" ? <RunsTable runs={runs.data} label="Last feed runs" /> : runs.state === "error" ? <div className="callout warn">{runs.message}</div> : null}
</div>
);
}
/* ---------- VISUAL resources ---------- */
export function ResourcesTab() {
const depts = useShopDepartments();
const [load, reload] = useLoad((s) => feedAdminApi.resourceMap(s));
const [edits, setEdits] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const rows = useMemo(
() => (load.state === "ok" ? [...load.data].sort((a, b) => (b.rows_last_run ?? 0) - (a.rows_last_run ?? 0) || a.resource.localeCompare(b.resource)) : []),
[load],
);
const byName = useMemo(() => new Map(rows.map((r) => [r.resource, r])), [rows]);
const pending = Object.keys(edits).filter((k) => (byName.get(k)?.target ?? "") !== edits[k]);
const saveOne = async (r: ResourceMapRow, target: string) => {
if (target === "") {
if (r.target) await feedAdminApi.unmapResource(r.resource);
} else {
await feedAdminApi.mapResource(r.resource, target);
}
};
const save = async (names: string[]) => {
setBusy(true);
setMsg(null);
let done = 0;
try {
for (const k of names) {
const r = byName.get(k);
const t = edits[k];
if (!r || t === undefined) continue;
await saveOne(r, t);
done++;
setEdits((e) => {
const n = { ...e };
delete n[k];
return n;
});
}
setMsg({ ok: true, text: done === 1 ? "Saved. The feed uses it from its next run." : `${done} saved. The feed uses them from its next run.` });
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
reload();
}
};
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
const unassigned = rows.filter((r) => !r.target).length;
return (
<div className="stack">
<div className="sec-h">
<span className="small muted">
Which department gets the packet for each VISUAL resource (RESOURCE_ID). Starting assignments are a best guess from the resource names. Check each one.
Lines are counted from the last feed run.
</span>
<span className="grow" />
{pending.length ? (
<button className="btn sm pri" type="button" disabled={busy} onClick={() => void save(pending)}>
Save {pending.length} change{pending.length === 1 ? "" : "s"}
</button>
) : null}
</div>
<Msg msg={msg} />
{unassigned ? (
<div className="callout warn flush">
{unassigned === 1 ? "1 resource is" : `${unassigned} resources are`} not assigned. Their work orders are left out of the feed.
</div>
) : null}
<div className="tw">
<table aria-label="VISUAL resources">
<thead>
<tr>
<th>Resource</th>
<th className="num">Lines in last run</th>
<th>Goes to</th>
<th>Confirmed</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((r) => {
const cur = edits[r.resource] ?? r.target ?? "";
const dirty = cur !== (r.target ?? "");
return (
<tr key={r.resource}>
<td className="mono">{r.resource}</td>
<td className="num">{r.rows_last_run ?? "-"}</td>
<td>
<select
className="in"
aria-label={`${r.resource} goes to`}
value={cur}
onChange={(e) => setEdits((x) => ({ ...x, [r.resource]: e.target.value }))}
>
<option value="">Not assigned</option>
{depts.map((d) => (
<option key={d.code} value={d.code}>
{d.code} {d.name}
</option>
))}
{cur && cur !== "fold" && cur !== "ignore" && !depts.some((d) => d.code === cur) ? <option value={cur}>{cur}</option> : null}
<option value="fold">Fold into the packet (MFG, inspection, material)</option>
<option value="ignore">Ignore</option>
</select>
{dirty ? <span className="small due soon"> unsaved</span> : null}
</td>
<td className="small">{r.confirmed ? "✓ confirmed" : r.target ? <span className="muted">guess</span> : <span className="due over">not assigned</span>}</td>
<td>
{dirty ? (
<button className="btn sm" type="button" disabled={busy} onClick={() => void save([r.resource])} aria-label={`Save ${r.resource}`}>
Save
</button>
) : null}
</td>
</tr>
);
})}
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="muted">
No resources yet. They appear after the first feed run.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{rows.some((r) => r.updated_at) ? (
<div className="small muted">Last change {fmtTime(rows.map((r) => r.updated_at ?? "").sort().pop() ?? null)}.</div>
) : null}
</div>
);
}
@@ -12,14 +12,62 @@ const CARDS = [
makeCard({ id: 5, status: "complete", routed_to: "databook", unit: { id: 13, number: "L999003" } }),
makeCard({ id: 6, status: "complete", routed_to: "databook", vclosed: { at: "2026-10-15T10:00:00Z", by: null }, unit: { id: 14, number: "L999004" } }),
makeCard({ id: 7, status: "filed", unit: { id: 15, number: "L999005" } }),
makeCard({ id: 8, status: "in_progress", co_pending: true, source_kind: "visual", unit: { id: 16, number: "L999006" } }),
];
const OVERVIEW = {
status: { paused: false, next_run_at: "2026-10-16T08:03:00Z", worker: { at: "2026-10-16T07:59:50Z", build: "1.0", running: [], stale: false } },
last_run: {
id: 42,
job: "visual-feed",
trigger: "schedule",
started_at: "2026-10-16T07:58:00Z",
finished_at: "2026-10-16T07:58:20Z",
status: "ok",
counts: { new_drafts: 4, drafts_updated: 2, cos_opened: 1 },
errors: [],
},
runs: [
{
id: 42,
job: "visual-feed",
trigger: "schedule",
started_at: "2026-10-16T07:58:00Z",
finished_at: "2026-10-16T07:58:20Z",
status: "ok",
counts: { new_drafts: 4, drafts_updated: 2, cos_opened: 1 },
errors: [],
},
{
id: 41,
job: "visual-feed",
trigger: "request",
started_at: "2026-10-16T07:50:00Z",
finished_at: "2026-10-16T07:50:30Z",
status: "degraded",
counts: {},
errors: ["orders read failed"],
},
],
drawing_runs: [
{ id: 90, job: "drawing-lookup", trigger: "chain", started_at: "2026-10-16T07:58:21Z", finished_at: null, status: "ok", counts: { attached: 3 }, errors: [] },
],
ignored: [
{ id: 5, wo_base: "L900099", lot: "1", split: "0", sub: "0", rel: "2026-09-01", project: "L900", unit_number: "L900001", res: "WELD-PART", dwg: "D-1", pulled_in_at: null },
],
unmapped: [{ resource: "NEWRES", rows: 3 }],
projects_without_names: [{ id: 70, number: "L900", units: 2, packets: 4 }],
golive_date: "2026-10-01",
departments: [],
};
function base() {
return {
"/api/auth/me": { body: makeMe() },
"/api/departments": { body: DEPARTMENTS },
"/api/packets": { body: { packets: CARDS } },
"/api/projects": { body: [makeProject()] },
"/api/feed/overview": { body: OVERVIEW },
};
}
@@ -36,11 +84,13 @@ describe("ClericalHome", () => {
expect(screen.getByLabelText("Flagged: 1")).toHaveClass("kpi", "hot");
expect(screen.getByLabelText("With warehouse: 1")).toBeInTheDocument();
expect(screen.getByLabelText("In the shop: 1")).toBeInTheDocument();
expect(screen.getByLabelText("In the shop: 2")).toBeInTheDocument();
expect(screen.getByLabelText("Ready to close: 1")).toHaveClass("hot");
expect(screen.getByLabelText("Changes to approve: 0")).toHaveClass("zero");
expect(screen.getByLabelText("Changes to approve: 1")).toHaveClass("hot");
expect(within(column("Changes to approve")).getByText("Arrives in phase 3 with the VISUAL feed.")).toBeInTheDocument();
const cos = column("Changes to approve");
expect(within(cos).getByText("L999006 · WELD-PART")).toBeInTheDocument();
expect(within(cos).getByText("Change from VISUAL to approve")).toBeInTheDocument();
const flagged = column("Flagged");
expect(within(flagged).getByText("L999001 · WELD-PART")).toBeInTheDocument();
expect(within(flagged).getByText(/Made by hand/)).toBeInTheDocument();
@@ -53,8 +103,8 @@ describe("ClericalHome", () => {
// The list view holds the same packets.
fireEvent.click(screen.getByRole("button", { name: "List" }));
const table = await screen.findByRole("table");
expect(within(table).getAllByRole("row")).toHaveLength(1 + 5);
const table = await screen.findByRole("table", { name: "Jobs" });
expect(within(table).getAllByRole("row")).toHaveLength(1 + 6);
});
it("the picker loads the units after a project is chosen", async () => {
@@ -105,4 +155,74 @@ describe("ClericalHome", () => {
expect(await screen.findByRole("heading", { name: "L998 · New one" })).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/projects/9/units")).toEqual([{ lots: ["001", "002", "003"], name: "", due: null, notes: "" }]);
});
it("the From VISUAL panel shows the status and the runs, and Run now posts a request", async () => {
const fetch = mockFetch({ ...base(), "POST /api/feed/run": { body: { requested: true, pending: 1 } } });
renderApp("/clerical");
const status = await screen.findByTestId("feed-status");
expect(status).toHaveTextContent(/^On · next run .* · last run .*: 4 new, 2 updated, 1 change order$/);
const runs = screen.getByRole("table", { name: "Last feed runs" });
const rows = within(runs).getAllByRole("row");
expect(rows).toHaveLength(3);
expect(within(rows[1] as HTMLElement).getByText("OK")).toHaveClass("pill", "st-ok");
// The chained drawing run after run 42 attached 3 drawings.
expect(within(rows[1] as HTMLElement).getAllByRole("cell")[6]).toHaveTextContent("3");
expect(within(rows[2] as HTMLElement).getByText("orders read failed")).toBeInTheDocument();
expect(screen.getByText("NEWRES")).toBeInTheDocument();
expect(screen.getByText(/IT assigns them on the Admin page/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "L900" })).toHaveAttribute("href", "/clerical/projects/70");
fireEvent.click(screen.getByRole("button", { name: "Run now" }));
expect(await screen.findByText("Run requested. The boards update when it finishes.")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/feed/run")).toEqual([{ job: "visual-feed" }]);
});
it("shows Worker not running and Paused from the overview", async () => {
mockFetch({ ...base(), "/api/feed/overview": { body: { ...OVERVIEW, status: { ...OVERVIEW.status, worker: { ...OVERVIEW.status.worker, stale: true } } } } });
renderApp("/clerical");
expect(await screen.findByTestId("feed-status")).toHaveTextContent(/^Worker not running/);
});
it("Pull in asks a reason and posts it", async () => {
const fetch = mockFetch({ ...base(), "POST /api/feed/ignored/5/pull-in": { body: { ...OVERVIEW.ignored[0], pulled_in_at: "2026-10-16T08:00:00Z" } } });
renderApp("/clerical");
fireEvent.click(await screen.findByRole("button", { name: "Pull in L900099/1/0/0" }));
const sheet = await screen.findByRole("dialog", { name: "Pull in work order" });
fireEvent.change(within(sheet).getByLabelText("Reason"), { target: { value: "Customer wants it here" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Pull in" }));
expect(await screen.findByText("L900099/1/0/0 pulled in. It comes through on the next run.")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/feed/ignored/5/pull-in")).toEqual([{ reason: "Customer wants it here" }]);
});
it("the flag kind select filters the list and the Flagged column shows the flag text", async () => {
const flagged = makeCard({ id: 9, source_kind: "visual", flag: "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3", flag_kinds: ["rev_mismatch"] });
const fetch = mockFetch({
...base(),
"/api/packets?limit=500&flag=rev_mismatch": { body: { packets: [flagged] } },
});
renderApp("/clerical");
const bar = await screen.findByRole("region", { name: "Find jobs" });
const select = within(bar).getByLabelText("Flags");
expect(within(select).getAllByRole("option").map((o) => o.textContent)).toEqual([
"All packets",
"All flags",
"Drawing needed",
"Rev mismatch",
"Not released",
"Not found",
"Lookup failed",
"Work order gone",
"Cancelled",
"Hand upload",
]);
fireEvent.change(select, { target: { value: "rev_mismatch" } });
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&flag=rev_mismatch"));
const col = await waitFor(() => {
const c = column("Flagged");
expect(within(c).getByText(/Rev mismatch: VISUAL calls rev 2/)).toHaveClass("due", "over");
return c;
});
expect(within(col).queryByText("L999001 · WELD-PART", { exact: false })).toBeInTheDocument();
expect(screen.getByLabelText("Flagged: 1")).toBeInTheDocument();
});
});
+20 -6
View File
@@ -14,6 +14,7 @@ import { STATUSES } from "../../api/types";
import { dueInfo, flagClass, jobName, pillClass, statusLabel } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { byTime, clericalBoards } from "./boards";
import { FromVisualPanel } from "./FromVisualPanel";
import { ProjectsSection } from "./ProjectsSection";
import { NewPacketSheet } from "./sheets";
@@ -71,7 +72,7 @@ function JobList({ rows, sort, onSort }: { rows: PacketCard[]; sort: SortKey; on
</label>
</div>
<div className="tw">
<table className="joblist">
<table className="joblist" aria-label="Jobs">
<thead>
<tr>
<th>Job</th>
@@ -139,6 +140,7 @@ export function ClericalHome() {
const [view, setView] = useState<BoardView>("board");
const [sort, setSort] = useState<SortKey>("updated");
const [drafting, setDrafting] = useState(false);
const [flag, setFlag] = useState("");
const projectId = pick.project?.id;
const unitId = pick.unit?.id;
@@ -148,8 +150,12 @@ export function ClericalHome() {
}, [q]);
const [load, reload] = useLoad(
(s) => packetsApi.list({ ...(query ? { q: query } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s),
`${query}|${projectId ?? ""}|${unitId ?? ""}`,
(s) =>
packetsApi.list(
{ ...(query ? { q: query } : {}), ...(flag ? { flag } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) },
s,
),
`${query}|${flag}|${projectId ?? ""}|${unitId ?? ""}`,
);
useChanged(reload, { delayMs: 500 });
@@ -187,7 +193,7 @@ export function ClericalHome() {
<Kpis
items={[
{ n: 0, label: "Changes to approve", alert: true },
{ n: b.cos, label: "Changes to approve", alert: true },
{ n: b.flagged, label: "Flagged", alert: true },
{ n: b.warehouse, label: "With warehouse" },
{ n: b.shop, label: "In the shop" },
@@ -195,8 +201,16 @@ export function ClericalHome() {
]}
/>
{b.flagged || b.toClose ? (
{editable ? <FromVisualPanel /> : null}
{b.cos || b.flagged || b.toClose ? (
<div className="attn" role="status">
{b.cos ? (
<div className="callout warn flush">
<b>{b.cos === 1 ? "1 change from VISUAL waits" : `${b.cos} changes from VISUAL wait`} for your approval.</b> Nothing goes to the shop
packet until you approve it.
</div>
) : null}
{b.flagged ? (
<div className="callout warn flush">
<b>{b.flagged === 1 ? "1 draft is flagged." : `${b.flagged} drafts are flagged.`}</b> Check {b.flagged === 1 ? "it" : "them"} and send {b.flagged === 1 ? "it" : "them"} on.
@@ -210,7 +224,7 @@ export function ClericalHome() {
</div>
) : null}
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} view={view} onView={setView} count={load.state === "ok" ? shown.length : null} />
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} view={view} onView={setView} count={load.state === "ok" ? shown.length : null} flag={flag} onFlag={setFlag} />
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
@@ -0,0 +1,207 @@
import { useEffect, useState } from "react";
import { Link } from "react-router";
import { feedApi } from "../../api/feed";
import type { IgnoredWo } from "../../api/types";
import { useGuard } from "../../components/useGuard";
import { RunsTable } from "../../components/RunsTable";
import { useChanged } from "../../lib/changes";
import { drawingsAfter, feedRunning, statusLine } from "../../lib/feed";
import { errorText, fmtDay } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
const IGNORED_SHOWN = 25;
const woText = (w: Pick<IgnoredWo, "wo_base" | "lot" | "split" | "sub">) => `${w.wo_base}/${w.lot}/${w.split}/${w.sub}`;
/** From VISUAL on the Clerical home: the worker's status, Run now, the last runs and what needs a hand. */
export function FromVisualPanel() {
const [load, reload] = useLoad((s) => feedApi.overview(s));
const { askReason, sheet } = useGuard();
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [allIgnored, setAllIgnored] = useState(false);
const [, setTick] = useState(0);
// A feed run or a packet change refreshes the panel. Bursts give one reload.
useChanged(reload, { delayMs: 1000 });
// The "in 3 min" and "2 min ago" words age without a reload.
useEffect(() => {
const t = window.setInterval(() => setTick((n) => n + 1), 30_000);
return () => window.clearInterval(t);
}, []);
const runNow = async () => {
setBusy(true);
setMsg(null);
try {
const r = await feedApi.run("visual-feed");
setMsg({ ok: true, text: r.pending > 1 ? "Run requested. One is already waiting, so they run together." : "Run requested. The boards update when it finishes." });
reload();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const pullIn = async (w: IgnoredWo) => {
const reason = await askReason({
title: "Pull in work order",
sub: woText(w),
note: "It was released before the go-live date, so the feed left it out. Pulled in, it becomes a packet on the next run.",
min: 3,
max: 300,
submitLabel: "Pull in",
});
if (reason === null) return;
setBusy(true);
setMsg(null);
try {
await feedApi.pullIn(w.id, reason);
setMsg({ ok: true, text: `${woText(w)} pulled in. It comes through on the next run.` });
reload();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const head = (running: boolean) => (
<div className="sec-h flush">
<h2 id="fromvis-h">From VISUAL</h2>
<span className="ct">read by the server, nothing to upload</span>
<span className="grow" />
<button className="btn pri" type="button" disabled={busy || running} onClick={() => void runNow()}>
{running ? "Running" : "Run now"}
</button>
</div>
);
if (load.state !== "ok") {
return (
<section className="sec" aria-labelledby="fromvis-h">
<div className="panel stack">
{head(false)}
{load.state === "loading" ? <div className="small muted">Loading</div> : <div className="small due over">The feed status could not be read: {load.message}</div>}
{msg ? (
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
) : null}
</div>
</section>
);
}
const ov = load.data;
const running = feedRunning(ov);
const line = statusLine(ov);
const attached = drawingsAfter(ov.runs, ov.drawing_runs ?? []);
const ignored = ov.ignored ?? [];
const shownIgnored = allIgnored ? ignored : ignored.slice(0, IGNORED_SHOWN);
const unmapped = ov.unmapped ?? [];
const noName = ov.projects_without_names ?? [];
return (
<section className="sec" aria-labelledby="fromvis-h">
<div className="panel stack">
{head(running)}
<div className={line.warn ? "small due over" : "small"} data-testid="feed-status">
{line.text}
</div>
<div className="small muted">
Released work orders become packets ready to release under their unit, one per drawing, lot and operation. Drawings found in M-Files are attached
automatically. Anything that needs a hand goes to the <b>Flagged</b> column.
{ov.departments?.length ? ` Departments read: ${ov.departments.join(", ")}.` : ""}
</div>
{msg ? (
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
) : null}
{noName.length ? (
<div className="small">
{noName.length === 1 ? "1 project" : `${noName.length} projects`} came in from VISUAL without a name, customer or PO:{" "}
{noName.slice(0, 12).map((p, i) => (
<span key={p.id}>
{i ? " · " : ""}
<Link className="lnk mono" to={`/clerical/projects/${p.id}`}>
{p.number}
</Link>
</span>
))}
{noName.length > 12 ? ` and ${noName.length - 12} more` : ""}.
</div>
) : null}
{unmapped.length ? (
<div className="callout warn flush">
<b>
{unmapped.length === 1 ? "1 VISUAL resource isn't" : `${unmapped.length} VISUAL resources aren't`} assigned to a department yet
</b>
, so their work orders were left out:{" "}
{unmapped.slice(0, 10).map((u, i) => (
<span key={u.resource}>
{i ? ", " : ""}
<span className="mono">{u.resource}</span> ({u.rows})
</span>
))}
{unmapped.length > 10 ? " and more" : ""}. IT assigns them on the Admin page under VISUAL resources.
</div>
) : null}
<details>
<summary className="small">Last {ov.runs.length === 1 ? "run" : `${ov.runs.length} runs`}</summary>
<RunsTable runs={ov.runs} attached={attached} label="Last feed runs" />
</details>
{ignored.length ? (
<details>
<summary className="small">
{ignored.length === 1 ? "1 work order" : `${ignored.length} work orders`} released before go-live{ov.golive_date ? ` (${fmtDay(ov.golive_date)})` : ""}, left out
</summary>
<div className="small muted">These stay on paper. Pull one in when it should get a packet here.</div>
<div className="tw">
<table aria-label="Work orders left out">
<thead>
<tr>
<th>Work order</th>
<th>Unit</th>
<th>Resource</th>
<th>Drawing</th>
<th>Released</th>
<th />
</tr>
</thead>
<tbody>
{shownIgnored.map((w) => (
<tr key={w.id}>
<td className="mono">{woText(w)}</td>
<td className="mono small">{w.unit_number ?? w.project ?? "-"}</td>
<td className="mono small">{w.res ?? "-"}</td>
<td className="mono small">{w.dwg || "-"}</td>
<td className="small">{fmtDay(w.rel)}</td>
<td>
<button className="btn sm" type="button" disabled={busy} onClick={() => void pullIn(w)} aria-label={`Pull in ${woText(w)}`}>
Pull in
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{ignored.length > IGNORED_SHOWN && !allIgnored ? (
<button className="lnk small" type="button" onClick={() => setAllIgnored(true)}>
Show all {ignored.length}
</button>
) : null}
</details>
) : null}
</div>
{sheet}
</section>
);
}
@@ -0,0 +1,130 @@
import { useState } from "react";
import { projectsApi } from "../../api/projects";
import type { Project, VisualOrder } from "../../api/types";
import { useGuard } from "../../components/useGuard";
import { errorText, fmtDay } from "../../lib/format";
/** How the feed chose the order (orders.choose `via`). */
const VIA_LABEL: Record<string, string> = {
pin: "pinned by Clerical",
base: "linked to the work order",
unit_top_job: "names this unit",
project_open: "first open order",
project_newest: "newest order",
};
const ORDER_STATUS: Record<string, string> = { F: "Firmed", R: "Released", H: "On hold", C: "Closed", X: "Cancelled", U: "Unreleased" };
/** The project's orders as VISUAL has them, which one the feed uses, and the pin. */
export function OrdersSection({ project, editable, onSaved }: { project: Project; editable: boolean; onSaved: (text: string) => void }) {
const guard = useGuard();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const orders: VisualOrder[] = project.orders ?? [];
const ref = project.order_ref ?? null;
const pin = project.order_pin ?? null;
if (!orders.length && !ref) return null;
const setPin = async (next: string | null) => {
const reason = await guard.askReason({
title: next ? `Pin order ${next}` : "Unpin the order",
sub: project.number,
note: next
? "The feed uses this order for the customer, PO and dates from its next run. A value typed by hand is never overwritten."
: "The feed goes back to choosing the order itself on its next run.",
min: 3,
max: 300,
submitLabel: next ? "Pin" : "Unpin",
});
if (reason === null) return;
setBusy(true);
setError(null);
try {
const r = await guard.run(
(g) => projectsApi.patch(project.id, { set: { order_pin: next, order_pin_reason: reason }, base: { order_pin: pin }, ...g }),
{ sub: project.number },
);
if (r !== undefined) onSaved(next ? `Order ${next} pinned` : "Order unpinned");
} catch (err) {
setError(errorText(err));
} finally {
setBusy(false);
}
};
return (
<section className="sec" aria-labelledby="orders-h">
<div className="sec-h">
<h2 id="orders-h">Orders in VISUAL</h2>
<span className="ct">
{ref ? (
<>
the feed uses <b className="mono">{ref.cust_order_id}</b> ({VIA_LABEL[ref.via] ?? ref.via})
</>
) : (
"no order chosen yet"
)}
</span>
<span className="grow" />
{editable && pin ? (
<button className="btn sm ghost" type="button" disabled={busy} onClick={() => void setPin(null)}>
Unpin
</button>
) : null}
</div>
{project.order_pin_reason && pin ? <div className="small muted">Pinned: {project.order_pin_reason}</div> : null}
{error ? (
<div className="callout warn" role="alert">
{error}
</div>
) : null}
{orders.length ? (
<div className="tw">
<table aria-label="Orders in VISUAL">
<thead>
<tr>
<th>Order</th>
<th>Status</th>
<th>Customer</th>
<th>Customer PO</th>
<th>Ordered</th>
<th>Ship</th>
<th />
</tr>
</thead>
<tbody>
{orders.map((o) => {
const used = ref?.cust_order_id === o.cust_order_id;
const pinned = pin === o.cust_order_id;
return (
<tr key={o.cust_order_id}>
<td className="mono">
<b>{o.cust_order_id}</b>
{used ? <div className="sub">used by the feed</div> : null}
</td>
<td className="small">{o.status ? (ORDER_STATUS[o.status] ?? o.status) : "-"}</td>
<td className="small">{o.customer_name ?? "-"}</td>
<td className="mono small">{o.customer_po_ref ?? "-"}</td>
<td className="small">{fmtDay(o.order_date)}</td>
<td className="small">{fmtDay(o.promise_date ?? o.desired_ship_date)}</td>
<td className="nowrap">
{pinned ? <span className="pill st-ok">Pinned</span> : null}
{editable && !pinned ? (
<button className="btn sm" type="button" disabled={busy} onClick={() => void setPin(o.cust_order_id)} aria-label={`Pin order ${o.cust_order_id}`}>
Pin
</button>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="empty slim">No orders cached for this project.</div>
)}
{guard.sheet}
</section>
);
}
@@ -0,0 +1,64 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { OrderRef, VisualOrder } from "../../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
import { makeProject, makeUnit } from "../../test/packets";
const ORDERS: VisualOrder[] = [
{
cust_order_id: "CO-100",
status: "F",
customer_name: "Test Co",
customer_po_ref: "PO-1",
order_date: "2026-09-01",
desired_ship_date: "2026-12-01",
promise_date: null,
},
{
cust_order_id: "CO-101",
status: "F",
customer_name: "Test Co",
customer_po_ref: "PO-2",
order_date: "2026-09-15",
desired_ship_date: null,
promise_date: "2026-12-20",
},
];
const REF: OrderRef = { ...ORDERS[0]!, via: "project_open", chosen_at: "2026-10-16T08:00:00Z" };
describe("ProjectPage orders", () => {
it("lists the VISUAL orders, marks the one in use, and pins another with a reason", async () => {
const project = makeProject({ units: [makeUnit()], orders: ORDERS, order_ref: REF, order_pin: null });
const fetch = mockFetch({
"/api/auth/me": { body: makeMe() },
"/api/departments": { body: DEPARTMENTS },
"/api/projects/7": { body: project },
"PATCH /api/projects/7": { body: { ...project, order_pin: "CO-101", version: 2 } },
});
renderApp("/clerical/projects/7");
const table = await screen.findByRole("table", { name: "Orders in VISUAL" });
expect(screen.getByText("first open order", { exact: false })).toBeInTheDocument();
expect(within(table).getByText("used by the feed")).toBeInTheDocument();
fireEvent.click(within(table).getByRole("button", { name: "Pin order CO-101" }));
const ask = await screen.findByRole("dialog", { name: "Pin order CO-101" });
fireEvent.change(within(ask).getByLabelText("Reason"), { target: { value: "Second order covers this unit" } });
fireEvent.click(within(ask).getByRole("button", { name: "Pin" }));
expect(await screen.findByText("Order CO-101 pinned")).toBeInTheDocument();
expect(callsTo(fetch, "PATCH", "/api/projects/7")).toEqual([
{ set: { order_pin: "CO-101", order_pin_reason: "Second order covers this unit" }, base: { order_pin: null } },
]);
});
it("shows no orders section for a project without VISUAL orders", async () => {
mockFetch({
"/api/auth/me": { body: makeMe() },
"/api/departments": { body: DEPARTMENTS },
"/api/projects/7": { body: makeProject({ units: [makeUnit()] }) },
});
renderApp("/clerical/projects/7");
await screen.findByRole("heading", { name: "Units" });
expect(screen.queryByRole("heading", { name: "Orders in VISUAL" })).not.toBeInTheDocument();
});
});
@@ -10,6 +10,7 @@ import { useChanged } from "../../lib/changes";
import { dueInfo, PROJECT_STATUS_LABEL, stageCounts } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { ProgressBar, ProjectPill } from "./common";
import { OrdersSection } from "./OrdersSection";
import { AddUnitsSheet, ProjectSheet, RenumberSheet } from "./sheets";
type UnitShow = "active" | "hold" | "closed" | "all";
@@ -241,6 +242,15 @@ export function ProjectPage() {
{units.length ? <UnitTable project={p} units={units} /> : <div className="empty">No units yet.{editable ? " Add one to start adding packets." : ""}</div>}
</section>
<OrdersSection
project={p}
editable={editable}
onSaved={(text) => {
setToast(text);
reload();
}}
/>
{sheet === "edit" ? (
<ProjectSheet
project={p}
+16 -2
View File
@@ -6,7 +6,15 @@ import { IN_SHOP_OR_QC } from "../../lib/packets";
export const byTime = (a: string | null, b: string | null) => (a ?? "").localeCompare(b ?? "");
/** The boards of the Clerical home, from one list answer. */
export function clericalBoards(all: PacketCard[]): { columns: ColumnDef[]; flagged: number; warehouse: number; shop: number; toClose: number } {
export function clericalBoards(all: PacketCard[]): {
columns: ColumnDef[];
cos: number;
flagged: number;
warehouse: number;
shop: number;
toClose: number;
} {
const cos = all.filter((p) => p.co_pending).sort((a, b) => byTime(a.updated_at, b.updated_at));
const drafts = all.filter((p) => p.status === "draft").sort((a, b) => byTime(a.updated_at, b.updated_at));
const back = all.filter((p) => p.status === "complete" && p.routed_to === "databook");
const flagged = drafts.filter((p) => p.flag);
@@ -18,7 +26,12 @@ export function clericalBoards(all: PacketCard[]): { columns: ColumnDef[]; flagg
.sort((a, b) => byTime(b.status_at, a.status_at))
.slice(0, 25);
const columns: ColumnDef[] = [
{ key: "cos", title: "Changes to approve", cards: [], empty: "Arrives in phase 3 with the VISUAL feed." },
{
key: "cos",
title: "Changes to approve",
cards: cos,
empty: "Changes from VISUAL to packets already past draft wait here for your approval.",
},
{ key: "flagged", title: "Flagged", cards: flagged, empty: "Nothing flagged." },
{ key: "going", title: "Going to the warehouse", cards: going, empty: "Drafts with nothing flagged wait here." },
{
@@ -32,6 +45,7 @@ export function clericalBoards(all: PacketCard[]): { columns: ColumnDef[]; flagg
];
return {
columns,
cos: cos.length,
flagged: flagged.length,
warehouse: all.filter((p) => p.status === "warehouse").length,
shop: all.filter((p) => IN_SHOP_OR_QC.includes(p.status)).length,
+2
View File
@@ -24,6 +24,8 @@ dependencies = [
"tzdata>=2026.5",
"pypdf==6.21.0",
"python-multipart==0.0.32",
"pypdfium2==5.14.0",
"openpyxl==3.1.5",
"visualkit==0.3.0",
"mfileskit==0.1.2",
]
+308
View File
@@ -0,0 +1,308 @@
"""The VISUAL feed for Clerical: the From VISUAL panel (status, runs, ignored work orders, unmapped resources,
projects without names), Run now, pulling in a work order released before go-live, and what VISUAL changed.
Clerical (clerical or clerical_supervisor) or an admin. Pulling in a work order changes what the feed tracks, so an
admin outside Clerical gives an IT reason for it. Screens read PostgreSQL only: nothing here calls VISUAL. Run now
inserts a job request and NOTIFYs the worker, which does the reading.
"""
from __future__ import annotations
import json
from typing import Any, Literal
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from .. import audit
from ..domain import packets
from ..domain.pipeline import jsonable
from ..text import LIMITS, clean_opt, reason_ok
from .deps import Db, Principal, current_principal, get_db, require_password_session
from .errors import ApiError
from .packets import WRITE
router = APIRouter(prefix="/api/feed", tags=["feed"])
CLERICAL = frozenset({"clerical", "clerical_supervisor"})
JOBS = ("visual-feed", "drawing-lookup")
IGNORED_MAX = 200
UNMAPPED_MAX = 30
STALE_DEFAULT = 90
PULL_IN_REASON_MIN = 3
PULL_IN_REASON_MAX = 300
class RunBody(BaseModel):
job: Literal["visual-feed", "drawing-lookup"] = "visual-feed"
note: str | None = Field(default=None, max_length=1000)
class PullInBody(BaseModel):
reason: str | None = Field(default=None, max_length=2000)
it_reason: str | None = Field(default=None, max_length=1000)
# ---------------------------------------------------------------- access
def require_clerical(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008
if CLERICAL & set(p.roles) or p.is_admin:
return p
raise ApiError(403, "forbidden", "Only Clerical can see the VISUAL feed.")
def _it_reason(p: Principal, it_reason: str | None) -> str | None:
"""None for Clerical. An admin outside Clerical needs a password session and an IT reason."""
if CLERICAL & set(p.roles):
return None
require_password_session(p)
why = reason_ok(it_reason)
if why is None:
raise ApiError(400, "it_reason_required", "Give an IT reason of at least 8 characters.")
return why
# ---------------------------------------------------------------- shapes
def _run_out(r: dict[str, Any]) -> dict[str, Any]:
return {
"id": r["id"],
"job": r["job"],
"trigger": r["trigger"],
"requested_by": [str(u) for u in r.get("requested_by") or []],
"started_at": r["started_at"],
"finished_at": r["finished_at"],
"status": r["status"],
"counts": r["counts"] or {},
"errors": r["errors"] or [],
"build_version": r.get("build_version"),
"visual_target": r.get("visual_target"),
"host": r.get("host"),
}
def _runs(conn: Any, job: str, limit: int) -> list[dict[str, Any]]:
return [
_run_out(r)
for r in packets.rows(conn, "SELECT * FROM job_runs WHERE job = %s ORDER BY id DESC LIMIT %s", (job, limit))
]
def _ignored_out(r: dict[str, Any]) -> dict[str, Any]:
keys = ("id", "wo_base", "lot", "split", "sub", "rel", "project", "unit_number", "res", "dwg", "last_seen_at")
out = {k: r[k] for k in keys}
out["pulled_in_at"] = r["pulled_in_at"]
out["pulled_in_by"] = str(r["pulled_in_by"]) if r["pulled_in_by"] else None
out["pulled_in_reason"] = r["pulled_in_reason"]
return out
def _stale_seconds() -> int:
try:
from ..worker import STALE_SECONDS
except ImportError: # the worker package is agent A's; the panel still answers without it
return STALE_DEFAULT
return int(STALE_SECONDS)
def _feed_settings(conn: Any) -> Any:
from ..feed import config
return config.load(conn)
def _request_run(conn: Any, p: Principal, job: str, note: str | None) -> int:
"""Insert a job request and NOTIFY the worker in the same transaction. Returns the pending request count."""
conn.execute(
"INSERT INTO job_requests (job, requested_by, note) VALUES (%s, %s, %s)",
(job, p.user_id, clean_opt(note, LIMITS["job_note"])),
)
conn.execute("SELECT ps_notify('ps_job_request', %s)", (json.dumps({"job": job}),))
return int(
conn.execute("SELECT count(*) FROM job_requests WHERE job = %s AND claimed_at IS NULL", (job,)).fetchone()[0]
)
# ---------------------------------------------------------------- endpoints
@router.get("/overview")
def overview(
p: Principal = Depends(require_clerical), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Everything the From VISUAL panel shows, from PostgreSQL only."""
conn = d.conn
fs = _feed_settings(conn)
sched = packets.one(conn, "SELECT next_run_at, paused FROM job_schedule WHERE job = 'visual-feed'")
beat = packets.one(
conn,
"SELECT at, build, running, now() - at > make_interval(secs => %s) AS stale FROM worker_heartbeat WHERE id = 1",
(_stale_seconds(),),
)
runs = _runs(conn, "visual-feed", 10)
last_run = runs[0] if runs else None
finished = next((r for r in runs if r["status"] in ("ok", "degraded")), None)
unmapped_raw = (finished or {}).get("counts", {}).get("unmapped") or {}
unmapped: list[dict[str, Any]] = []
if isinstance(unmapped_raw, dict):
for k, v in unmapped_raw.items():
if isinstance(v, int) or (isinstance(v, str) and v.isdigit()):
unmapped.append({"resource": str(k), "rows": int(v)})
unmapped.sort(key=lambda x: (-int(x["rows"]), str(x["resource"])))
unmapped = unmapped[:UNMAPPED_MAX]
ignored = packets.rows(
conn,
"SELECT * FROM visual_ignored_wos WHERE pulled_in_at IS NULL ORDER BY rel DESC NULLS LAST, wo_base, lot, id"
" LIMIT %s",
(IGNORED_MAX,),
)
nameless = packets.rows(
conn,
"SELECT p.id, p.number,"
" (SELECT count(*) FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL) AS units,"
" (SELECT count(*) FROM packets k JOIN units u ON u.id = k.unit_id"
" WHERE u.project_id = p.id AND k.removed_at IS NULL) AS packets"
" FROM projects p WHERE p.source = 'visual' AND p.needs_details AND p.name IS NULL AND p.removed_at IS NULL"
" ORDER BY p.number LIMIT 200",
)
return {
"status": {
"paused": bool(fs.paused),
"next_run_at": (sched or {}).get("next_run_at"),
"worker": None
if beat is None
else {
"at": beat["at"],
"build": beat["build"],
"running": list(beat["running"] or []),
"stale": bool(beat["stale"]),
},
},
"last_run": last_run,
"runs": runs,
"drawing_runs": _runs(conn, "drawing-lookup", 10),
"pending_requests": int(
conn.execute(
"SELECT count(*) FROM job_requests WHERE job = 'visual-feed' AND claimed_at IS NULL"
).fetchone()[0]
),
"ignored": [_ignored_out(r) for r in ignored],
"unmapped": unmapped,
"projects_without_names": [
{"id": r["id"], "number": r["number"], "units": int(r["units"]), "packets": int(r["packets"])}
for r in nameless
],
"golive_date": fs.golive_date,
"departments": list(fs.departments),
}
@router.post("/run", dependencies=WRITE)
def run_now(
body: RunBody | None = None,
p: Principal = Depends(require_clerical), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Run now: ask the worker for a feed (or drawing) run. Requests made while one is pending share that run."""
body = body or RunBody()
conn = d.conn
with conn.transaction():
pending = _request_run(conn, p, body.job, body.note)
audit.append(
conn,
action="feed.run_requested",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="job",
subject_id=body.job,
detail={"job": body.job, "pending": pending},
ip=p.ip,
)
return {"requested": True, "job": body.job, "pending": pending}
@router.post("/ignored/{row_id}/pull-in", dependencies=WRITE)
def pull_in(
row_id: int,
body: PullInBody,
p: Principal = Depends(require_clerical), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Track a work order released before the go-live date after all. The next feed run makes its packet."""
it_reason = _it_reason(p, body.it_reason)
reason = clean_opt(body.reason, PULL_IN_REASON_MAX)
if reason is None or len(reason) < PULL_IN_REASON_MIN:
raise ApiError(400, "reason_required", "Say why this work order is pulled in (at least 3 characters).")
conn = d.conn
with conn.transaction():
row = packets.one(conn, "SELECT * FROM visual_ignored_wos WHERE id = %s FOR UPDATE", (row_id,))
if row is None:
raise ApiError(404, "not_found", "No such work order on the ignored list.")
if row["pulled_in_at"] is not None:
raise ApiError(
409,
"already_pulled_in",
"This work order was already pulled in.",
extra={"current": jsonable(_ignored_out(row))},
)
row = packets.one(
conn,
"UPDATE visual_ignored_wos SET pulled_in_at = now(), pulled_in_by = %s, pulled_in_reason = %s"
" WHERE id = %s RETURNING *",
(p.user_id, reason, row_id),
)
assert row is not None
key = "/".join(str(row[k]) for k in ("wo_base", "lot", "split", "sub"))
audit.append(
conn,
action="feed.pulled_in",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="work_order",
subject_id=key,
detail={"id": row_id, "key": key, "rel": str(row["rel"]) if row["rel"] else None, "reason": reason},
it_reason=it_reason,
ip=p.ip,
)
_request_run(conn, p, "visual-feed", f"pull in {key}")
return _ignored_out(row)
@router.get("/batches")
def batches(
limit: int = Query(20, ge=1, le=200),
p: Principal = Depends(require_clerical), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> list[dict[str, Any]]:
return packets.rows(d.conn, "SELECT * FROM visual_batches ORDER BY id DESC LIMIT %s", (limit,))
@router.get("/changes")
def changes(
batch_id: int | None = Query(None),
limit: int = Query(200, ge=1, le=2000),
p: Principal = Depends(require_clerical), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""What VISUAL changed in one batch (the latest when none is given): work order rows and material lines."""
conn = d.conn
if batch_id is None:
batch = packets.one(conn, "SELECT * FROM visual_batches ORDER BY id DESC LIMIT 1")
else:
batch = packets.one(conn, "SELECT * FROM visual_batches WHERE id = %s", (batch_id,))
if batch is None:
raise ApiError(404, "not_found", "No such feed batch.")
if batch is None:
return {"batch": None, "rows": [], "materials": []}
rows = packets.rows(
conn, "SELECT * FROM visual_row_changes WHERE batch_id = %s ORDER BY id LIMIT %s", (batch["id"], limit)
)
mats = packets.rows(
conn, "SELECT * FROM visual_material_changes WHERE batch_id = %s ORDER BY id LIMIT %s", (batch["id"], limit)
)
return {"batch": batch, "rows": rows, "materials": mats}
+204
View File
@@ -0,0 +1,204 @@
"""Admin: the feed settings and the VISUAL resource map. Every route needs the admin role and a password session.
A settings save NOTIFYs `ps_settings` so the worker reschedules at once. Resource map changes apply on the next
feed run. Every change is audited with before and after.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, Response
from psycopg.rows import dict_row
from pydantic import BaseModel, Field
from .. import audit
from ..domain.projects import Who
from ..feed import config
from .deps import Db, Principal, admin_principal, get_db
from .errors import ApiError
from .jobs import recent_runs
from .packets import WRITE
router = APIRouter(prefix="/api/admin/feed", tags=["admin"])
FEED_JOB = "visual-feed"
SPECIAL_TARGETS = ("fold", "ignore")
RESOURCE_MAX = 100
def _who(p: Principal) -> Who:
return Who(user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, ip=p.ip)
def _shop_codes(conn: Any) -> set[str]:
rows = conn.execute("SELECT code FROM departments WHERE kind = 'shop' AND active").fetchall()
return {r[0] for r in rows}
def _settings_out(conn: Any) -> dict[str, Any]:
fs, version, updated_at, updated_by = config.load_versioned(conn)
nxt = conn.execute("SELECT next_run_at FROM job_schedule WHERE job = %s", (FEED_JOB,)).fetchone()
return {
**fs.model_dump(mode="json"),
"version": version,
"updated_at": updated_at,
"updated_by": str(updated_by) if updated_by else None,
"next_run_at": nxt[0] if nxt else None,
"runs": recent_runs(conn, FEED_JOB, 10),
}
@router.get("/settings")
def get_settings(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
return _settings_out(d.conn)
@router.patch("/settings", dependencies=WRITE)
def patch_settings(
patch: dict[str, Any] = Body(...), # noqa: B008
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
if "departments" in patch and isinstance(patch["departments"], list):
shop = _shop_codes(conn)
bad = sorted({str(x).strip() for x in patch["departments"]} - shop)
if bad:
raise ApiError(
422,
"bad_settings",
f"Not an active shop department: {', '.join(bad)}.",
extra={"errors": [f"departments: {b} is not an active shop department" for b in bad]},
)
try:
config.save(conn, who=_who(p), patch=patch)
except config.SettingsError as e:
raise ApiError(422, "bad_settings", str(e), extra={"errors": e.errors}) from None
return _settings_out(conn)
# ---------------------------------------------------------------- resource map
class MapBody(BaseModel):
target: str = Field(max_length=20)
confirmed: bool = True
def _resource(resource: str) -> str:
r = resource.strip()
if not r or len(r) > RESOURCE_MAX:
raise ApiError(400, "bad_resource", "Give the VISUAL resource name.")
return r
def _map_row(conn: Any, resource: str) -> dict[str, Any] | None:
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
"SELECT resource, target, confirmed, updated_at, updated_by FROM visual_resource_map WHERE resource = %s",
(resource,),
).fetchone()
return dict(r) if r else None
def _row_out(r: dict[str, Any], rows_last_run: int | None) -> dict[str, Any]:
return {
"resource": r["resource"],
"target": r.get("target"),
"confirmed": bool(r.get("confirmed")),
"rows_last_run": rows_last_run,
"updated_at": r.get("updated_at"),
}
def _last_counts(conn: Any) -> dict[str, int]:
rows = conn.execute(
"SELECT res, count(*) FROM visual_rows"
" WHERE last_batch = (SELECT max(id) FROM visual_batches) AND res <> '' GROUP BY res"
).fetchall()
return {r[0]: int(r[1]) for r in rows}
@router.get("/resource-map")
def resource_map(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> list[dict[str, Any]]: # noqa: B008
conn = d.conn
counts = _last_counts(conn)
with conn.cursor(row_factory=dict_row) as cur:
mapped = cur.execute("SELECT resource, target, confirmed, updated_at FROM visual_resource_map").fetchall()
out = {r["resource"]: _row_out(r, counts.get(r["resource"])) for r in mapped}
for res, n in counts.items():
if res not in out:
out[res] = {"resource": res, "target": None, "confirmed": False, "rows_last_run": n, "updated_at": None}
return sorted(out.values(), key=lambda r: (r["target"] is not None, r["resource"].lower()))
def _public(r: dict[str, Any] | None) -> dict[str, Any] | None:
if r is None:
return None
return {"target": r["target"], "confirmed": r["confirmed"]}
@router.put("/resource-map/{resource}", dependencies=WRITE)
def map_resource(
resource: str,
body: MapBody,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
res = _resource(resource)
target = body.target.strip()
if target.lower() in SPECIAL_TARGETS:
target = target.lower()
elif target not in _shop_codes(conn):
raise ApiError(422, "bad_target", "The target is Fold, Ignore or an active shop department.")
with conn.transaction():
before = _map_row(conn, res)
conn.execute(
"INSERT INTO visual_resource_map (resource, target, confirmed, updated_at, updated_by)"
" VALUES (%s, %s, %s, now(), %s) ON CONFLICT (resource) DO UPDATE SET target = EXCLUDED.target,"
" confirmed = EXCLUDED.confirmed, updated_at = now(), updated_by = EXCLUDED.updated_by",
(res, target, body.confirmed, p.user_id),
)
after = _map_row(conn, res)
audit.append(
conn,
action="feed.resource_mapped",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="visual_resource",
subject_id=res,
detail={"resource": res, "before": _public(before), "after": _public(after)},
ip=p.ip,
)
assert after is not None
return _row_out(after, _last_counts(conn).get(res))
@router.delete("/resource-map/{resource}", status_code=204, dependencies=WRITE)
def unmap_resource(
resource: str,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
conn = d.conn
res = _resource(resource)
with conn.transaction():
before = _map_row(conn, res)
if before is None:
raise ApiError(404, "not_found", "That resource is not mapped.")
conn.execute("DELETE FROM visual_resource_map WHERE resource = %s", (res,))
audit.append(
conn,
action="feed.resource_unmapped",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="visual_resource",
subject_id=res,
detail={"resource": res, "before": _public(before), "after": None},
ip=p.ip,
)
return Response(status_code=204)
+170
View File
@@ -0,0 +1,170 @@
"""The worker's jobs: schedule, heartbeat, recent runs, and "run now".
Admin (password session) or Clerical (clerical or clerical_supervisor). "Run now" inserts a job_requests row and
NOTIFYs `ps_job_request` in the same transaction; the worker claims every pending request in one run.
`run_out`, `worker_status` and `request_run` are shared with api/feed.py.
"""
from __future__ import annotations
import json
import re
from typing import Any
from fastapi import APIRouter, Depends, Query
from psycopg.rows import dict_row
from pydantic import BaseModel, Field
from .. import audit
from ..worker.heartbeat import STALE_SECONDS
from .deps import Db, Principal, current_principal, get_db, require_password_session
from .errors import ApiError
from .packets import WRITE
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
CLERICAL = frozenset({"clerical", "clerical_supervisor"})
JOB_CHANNEL = "ps_job_request"
_JOB_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
def jobs_principal(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008
"""Clerical in any session, or an admin with a password session."""
if CLERICAL & set(p.roles):
return p
if p.is_admin:
return require_password_session(p)
raise ApiError(403, "forbidden", "Only Clerical or an admin can see and run the worker's jobs.")
class RunBody(BaseModel):
note: str | None = Field(None, max_length=500)
def run_out(r: dict[str, Any] | None) -> dict[str, Any] | None:
"""One job_runs row as JSON."""
if r is None:
return None
return {
"id": r["id"],
"job": r["job"],
"trigger": r["trigger"],
"requested_by": [str(u) for u in (r.get("requested_by") or [])],
"started_at": r["started_at"],
"finished_at": r["finished_at"],
"status": r["status"],
"counts": r.get("counts") or {},
"errors": r.get("errors") or [],
"build_version": r.get("build_version"),
"visual_target": r.get("visual_target"),
"host": r.get("host"),
}
def recent_runs(conn: Any, job: str, limit: int = 10) -> list[dict[str, Any]]:
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute("SELECT * FROM job_runs WHERE job = %s ORDER BY id DESC LIMIT %s", (job, limit)).fetchall()
return [o for o in (run_out(r) for r in rows) if o is not None]
def worker_status(conn: Any) -> dict[str, Any]:
"""{at, build, host, running, stale}: stale when the worker has not beaten for STALE_SECONDS or never did."""
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
"SELECT at, build, host, running, now() - at > make_interval(secs => %s) AS stale"
" FROM worker_heartbeat WHERE id = 1",
(STALE_SECONDS,),
).fetchone()
if r is None:
return {"at": None, "build": None, "host": None, "running": [], "stale": True}
return {
"at": r["at"],
"build": r["build"],
"host": r["host"],
"running": list(r["running"] or []),
"stale": bool(r["stale"]),
}
def known_job(conn: Any, job: str) -> bool:
if not _JOB_NAME.match(job):
return False
return conn.execute("SELECT 1 FROM job_schedule WHERE job = %s", (job,)).fetchone() is not None
def request_run(
conn: Any, p: Principal, job: str, *, note: str | None = None, action: str = "job.run_requested"
) -> dict[str, Any]:
"""Insert a request and NOTIFY the worker in one transaction. Returns {requested, pending}."""
with conn.transaction():
conn.execute(
"INSERT INTO job_requests (job, requested_by, note) VALUES (%s, %s, %s)",
(job, p.user_id, (note or "").strip() or None),
)
conn.execute("SELECT ps_notify(%s, %s)", (JOB_CHANNEL, json.dumps({"job": job}, separators=(",", ":"))))
pending = conn.execute(
"SELECT count(*) FROM job_requests WHERE job = %s AND claimed_at IS NULL", (job,)
).fetchone()[0]
audit.append(
conn,
action=action,
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="job",
subject_id=job,
detail={"job": job, "pending": int(pending)},
ip=p.ip,
)
return {"requested": True, "pending": int(pending)}
@router.get("")
def list_jobs(p: Principal = Depends(jobs_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
conn = d.conn
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
"SELECT s.job, s.next_run_at, s.interval_seconds, s.paused, s.updated_at,"
" (SELECT count(*) FROM job_requests q WHERE q.job = s.job AND q.claimed_at IS NULL) AS pending,"
" CASE WHEN r.id IS NULL THEN NULL ELSE to_jsonb(r) END AS last_run"
" FROM job_schedule s"
" LEFT JOIN LATERAL (SELECT * FROM job_runs j WHERE j.job = s.job ORDER BY j.id DESC LIMIT 1) r ON true"
" ORDER BY s.job"
).fetchall()
jobs = [
{
"job": r["job"],
"next_run_at": r["next_run_at"],
"interval_seconds": r["interval_seconds"],
"paused": r["paused"],
"updated_at": r["updated_at"],
"pending": int(r["pending"]),
"last_run": run_out(r["last_run"]),
}
for r in rows
]
return {"jobs": jobs, "heartbeat": worker_status(conn)}
@router.get("/{job}/runs")
def job_runs(
job: str,
p: Principal = Depends(jobs_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
limit: int = Query(10, ge=1, le=100),
) -> list[dict[str, Any]]:
if not known_job(d.conn, job):
raise ApiError(404, "not_found", "No such job.")
return recent_runs(d.conn, job, limit)
@router.post("/{job}/run", dependencies=WRITE)
def run_now(
job: str,
body: RunBody | None = None,
p: Principal = Depends(jobs_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
if not known_job(d.conn, job):
raise ApiError(404, "not_found", "No such job.")
return request_run(d.conn, p, job, note=body.note if body else None)
+203 -2
View File
@@ -1392,6 +1392,191 @@ def issue_co(
return _read(d, pp, packet_id)
FEED_ORIGINS = ("visual", "drawing")
MSG_CO_NOT_STAGED = "The new drawing has not been fetched from M-Files yet."
def _vis_text(leg: Any, *names: str) -> str:
for n in names:
v = leg.get(n) if isinstance(leg, dict) else None
if v is not None and v != "":
return str(v)
return ""
def _leg_row(leg: Any) -> dict[str, Any]:
"""One work order row from a change order's `vis.legs` entry. Accepts a flat leg ({wo|wo_base|base, lot, split,
sub, seq, ...}) or one with a nested key ({key: {base, lot, split, sub}, seq, ...})."""
if not isinstance(leg, dict):
raise ApiError(422, "bad_change_order", "The change order's work order list cannot be read.")
key = leg.get("key") if isinstance(leg.get("key"), dict) else leg
base = _vis_text(key, "base", "wo_base", "wo")
if not base:
raise ApiError(422, "bad_change_order", "The change order's work order list cannot be read.")
seq_raw = leg.get("seq")
return {
"wo_base": base,
"lot": _vis_text(key, "lot"),
"split": _vis_text(key, "split"),
"sub": _vis_text(key, "sub"),
"seq": int(seq_raw) if seq_raw not in (None, "") else None,
"qty": _num(leg.get("qty")),
"target_hrs": _num(leg.get("tgt", leg.get("target_hrs"))),
"op": clean_opt(leg.get("op"), LIMITS["op"]),
"dwg": clean_opt(leg.get("dwg"), LIMITS["drawing"]),
"rev": clean_opt(leg.get("rev"), LIMITS["rev"]),
"partspecs": clean_opt(leg.get("partspecs"), 4000),
"visual_status": clean_opt(leg.get("st", leg.get("visual_status")), 10),
}
def _apply_legs(conn: Any, packet_id: int, legs: list[Any], title: str | None) -> dict[str, int]:
"""Replace the packet's work orders with the change order's legs. Rows whose key and seq match are updated in
place (their ids and gone marks stay), new ones are added, the rest are deleted."""
want = [_leg_row(lg) for lg in legs]
have = packets.rows(conn, "SELECT * FROM packet_work_orders WHERE packet_id = %s", (packet_id,))
def k(r: dict[str, Any]) -> tuple[Any, ...]:
return (r["wo_base"], r["lot"], r["split"], r["sub"], r["seq"])
by_key = {k(r): r for r in have}
seen: set[tuple[Any, ...]] = set()
counts = {"added": 0, "updated": 0, "removed": 0}
for sort, r in enumerate(want):
key = k(r)
if key in seen:
continue
seen.add(key)
cur = by_key.get(key)
if cur is not None:
conn.execute(
"UPDATE packet_work_orders SET qty = %s, target_hrs = %s, op = %s, dwg = %s, rev = %s, partspecs = %s,"
" visual_status = %s, sort = %s, row_version = row_version + 1 WHERE id = %s",
(
r["qty"],
r["target_hrs"],
r["op"],
r["dwg"],
r["rev"],
r["partspecs"],
r["visual_status"],
sort,
cur["id"],
),
)
counts["updated"] += 1
else:
conn.execute(
"INSERT INTO packet_work_orders (packet_id, wo_base, lot, split, sub, seq, qty, target_hrs, op,"
" description, visual_status, dwg, rev, partspecs, sort)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
(
packet_id,
r["wo_base"],
r["lot"],
r["split"],
r["sub"],
r["seq"],
r["qty"],
r["target_hrs"],
r["op"],
title,
r["visual_status"],
r["dwg"],
r["rev"],
r["partspecs"],
sort,
),
)
counts["added"] += 1
gone = [r["id"] for r in have if k(r) not in seen]
if gone:
conn.execute("DELETE FROM packet_work_orders WHERE id = ANY(%s)", (gone,))
counts["removed"] = len(gone)
return counts
def _plural(n: int, word: str) -> str:
return f"{n} {word}{'' if n == 1 else 's'}"
def _apply_feed_co(w: Write, co: dict[str, Any]) -> str | None:
"""Apply a VISUAL or M-Files change order on approval: work orders, BOM, header drawing and rev, the fingerprint,
and the staged drawing (old pages voided, new pages carry the change order). Returns a note for the log."""
from . import packet_visual as pv
conn = w.conn
pid = w.id
vis = co["vis"] if isinstance(co["vis"], dict) else {}
staged = co["staged"] if isinstance(co["staged"], dict) else None
row = w.packet
visual = dict(row["visual"] or {})
notes: list[str] = []
sets: dict[str, Any] = {}
if isinstance(vis.get("legs"), list) and vis["legs"]:
c = _apply_legs(conn, pid, vis["legs"], row["title"])
notes.append(f"work orders: {c['added']} added, {c['updated']} kept, {c['removed']} removed")
if isinstance(vis.get("bom"), list) and not visual.get("manual_bom"):
c = pv.merge_visual_bom(conn, pid, vis["bom"])
notes.append(f"BOM: {c['added']} added, {c['updated']} kept, {c['removed'] + c['na']} dropped")
if co["origin"] == "visual":
if vis.get("to_dwg") not in (None, ""):
sets["drawing"] = clean_opt(vis["to_dwg"], LIMITS["drawing"])
if vis.get("to_rev") not in (None, ""):
sets["rev"] = clean_opt(vis["to_rev"], LIMITS["rev"])
if co["target_fp"]:
sets["visual_fp_applied"] = co["target_fp"]
for col, key in (("visual_want", "want"), ("visual_rel", "rel")):
if key in vis:
sets[col] = vis[key] or None
for key in ("fp_parts", "folded", "partspecs"):
if key in vis:
visual[key] = vis[key]
flags = list(row["flags"] or [])
if staged and staged.get("asset_id"):
asset = pv.pdf_asset(conn, staged["asset_id"])
dwg = sets.get("drawing") or staged.get("dwg") or row["drawing"]
rev = str(staged["rev"]) if staged.get("rev") not in (None, "") else (sets.get("rev") or row["rev"])
first, count, old = pv.replace_drawing_pages(
conn, pid, asset, dwg=dwg, rev=rev, co_id=int(co["id"]), void_by=w.user_id
)
for i in range(max(len(old), count)):
o = old[i] if i < len(old) else None
new = i < count
conn.execute(
"INSERT INTO co_items (co_id, old_page, old_dwg, old_rev, new_page, new_dwg, new_rev, added)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
(
co["id"],
o["n"] if o else None,
o["dwg"] if o else None,
o["rev"] if o else None,
first + i if new else None,
dwg if new else None,
rev if new else None,
o is None,
),
)
sets["drawing_state"] = "attached"
flags = pv.flags_without(flags, (*packets.DRAWING_FLAGS, "hand_drawing"))
visual["rev_confirmed"] = True
visual["vault"] = {
"asset_id": str(asset["id"]),
"name": staged.get("name"),
"rev": rev,
"at": pv.now_iso(),
"co_no": co["co_no"],
}
visual.pop("manual_dwg", None)
notes.append(f"drawing {dwg} rev {rev}, {_plural(count, 'page')}; {_plural(len(old), 'old page')} voided")
sets["flags"] = flags
sets["visual"] = Jsonb(visual)
cols = ", ".join(f"{c} = %s" + ("::drawing_state" if c == "drawing_state" else "") for c in sets)
conn.execute(f"UPDATE packets SET {cols} WHERE id = %s", (*sets.values(), pid)) # noqa: S608 (fixed names)
w.changed(*(c for c in sets if c not in ("flags", "visual")), "change_orders")
return "; ".join(notes) or None
@router.post("/{packet_id}/change-orders/{no}/approve", dependencies=WRITE)
def approve_co(
packet_id: int,
@@ -1401,9 +1586,19 @@ def approve_co(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Approve a pending change order. A VISUAL or M-Files one is applied to the packet here; a minor one (target
hours or want date only) needs no department verification."""
pp = _pp(d, p)
conn = d.conn
content = {"action": str(A.CO_APPROVE), "packet_id": packet_id, "co_no": no}
pre = packets.one(
conn,
"SELECT origin::text AS origin, staged, target_fp FROM change_orders WHERE packet_id = %s AND co_no = %s",
(packet_id, no),
)
content: dict[str, Any] = {"action": str(A.CO_APPROVE), "packet_id": packet_id, "co_no": no}
if pre is not None and pre["origin"] in FEED_ORIGINS:
content["target_fp"] = pre["target_fp"]
content["staged_sha256"] = pre["staged"].get("sha256") if isinstance(pre["staged"], dict) else None
with packet_write(
d,
p,
@@ -1417,6 +1612,12 @@ def approve_co(
) as w:
co = _co(conn, packet_id, no)
_co_state(co, "pending", "approved")
note: str | None = None
if co["origin"] in FEED_ORIGINS:
vis = co["vis"] if isinstance(co["vis"], dict) else {}
if vis.get("dwg_change") is True and not co["staged"]:
raise ApiError(409, "co_not_staged", MSG_CO_NOT_STAGED, extra={"waiting": co["waiting"]})
note = _apply_feed_co(w, co)
update_row(
conn,
"change_orders",
@@ -1433,7 +1634,7 @@ def approve_co(
for code in co_touched(conn, w.packet):
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, %s)", (co["id"], code))
_co_log_detail(w, no)
w.log(f"Change order {no} approved")
w.log(f"Change order {no} approved", note)
return _read(d, pp, packet_id)
+580
View File
@@ -0,0 +1,580 @@
"""VISUAL packets by hand: approve the M-Files rev, upload the drawing or the BOM on a flagged draft, and go back to
VISUAL's BOM.
Every write goes through domain.pipeline.packet_write with its policy action and a signature. The hand uploads are
for a VISUAL draft that is flagged (or already has a hand upload): Clerical checks it and sends it on; auto-release
never takes it because its flags are not empty. Every answer is the full read model.
"""
from __future__ import annotations
import hashlib
import uuid
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, Request
from psycopg.types.json import Jsonb
from pydantic import BaseModel, Field
from starlette.concurrency import run_in_threadpool
from starlette.datastructures import UploadFile
from .. import policy
from ..domain import assets as asset_store
from ..domain import handbom, packets
from ..domain.pipeline import SignBody, Write, next_counter, packet_write
from ..text import LIMITS, clean_opt, reason_ok
from .deps import Db, Principal, current_principal, get_db
from .errors import ApiError
from .packets import WRITE
router = APIRouter(prefix="/api/packets", tags=["packet-visual"])
A = policy.Action
# A hand BOM file is a small sheet. The multipart overhead on top is allowed before the body is refused unread.
BOM_MAX_BYTES = 5 * 1024 * 1024
FORM_SLACK = 64 * 1024
MSG_NOT_FLAGGED = (
"A drawing or BOM is uploaded by hand only on a flagged VISUAL draft. This one is not flagged: send it on as it is."
)
MSG_NOT_VISUAL = "This is not a packet made from VISUAL."
MSG_DRAFT_ONLY = "Only a draft takes a hand upload. Once it leaves Clerical, changes come in as change orders."
# ---------------------------------------------------------------- bodies
class RevApproveBody(BaseModel):
reason: str | None = Field(default=None, max_length=2000)
sign: SignBody | None = None
it_reason: str | None = Field(default=None, max_length=1000)
class HandDrawingBody(BaseModel):
asset_id: uuid.UUID
reason: str | None = Field(default=None, max_length=2000)
sign: SignBody | None = None
it_reason: str | None = Field(default=None, max_length=1000)
class HandBomOffBody(BaseModel):
sign: SignBody | None = None
it_reason: str | None = Field(default=None, max_length=1000)
# ---------------------------------------------------------------- shared helpers
def _pp(d: Db, p: Principal) -> policy.Principal:
return packets.policy_principal_for(d.conn, p)
def _read(d: Db, pp: policy.Principal, packet_id: int) -> dict[str, Any]:
return packets.read_packet(d.conn, packet_id, pp)
def now_iso() -> str:
return datetime.now(UTC).isoformat()
def who_of(w: Write) -> dict[str, Any]:
return {"by": str(w.user_id), "by_name": w.principal.user.get("display_name")}
def hand_flagged(row: Mapping[str, Any]) -> bool:
"""The hand-upload guard: a VISUAL draft that is flagged, still needs its drawing, or already has a hand upload."""
if row["source_kind"] != "visual" or row["status"] != "draft":
return False
visual = row["visual"] or {}
return bool(
list(row["flags"] or [])
or row["drawing_state"] in packets.DRAWING_STATES_FLAGGED
or visual.get("manual_dwg")
or visual.get("manual_bom")
)
def _hand_guard(w: Write) -> None:
row = w.packet
if row["source_kind"] != "visual":
raise ApiError(409, "not_visual", MSG_NOT_VISUAL)
if row["status"] != "draft":
raise ApiError(403, "wrong_status", MSG_DRAFT_ONLY)
if not hand_flagged(row):
raise ApiError(409, "not_flagged", MSG_NOT_FLAGGED)
def pdf_asset(conn: Any, asset_id: Any) -> dict[str, Any]:
asset = asset_store.get(conn, asset_id)
if asset is None:
raise ApiError(404, "not_found", "No such file.")
if asset["mime"] != asset_store.PDF or not asset["pages"]:
raise ApiError(422, "not_pdf", "Attach a PDF drawing.")
return asset
def flags_without(flags: Any, drop: tuple[str, ...], add: tuple[str, ...] = ()) -> list[str]:
out = [f for f in (flags or []) if f not in drop]
for f in add:
if f not in out:
out.append(f)
return out
def replace_drawing_pages(
conn: Any,
packet_id: int,
asset: Mapping[str, Any],
*,
dwg: str | None,
rev: str | None,
co_id: int | None = None,
void_by: Any = None,
) -> tuple[int, int, list[dict[str, Any]]]:
"""Put an asset's pages on the packet as its drawing.
Without `co_id` (a draft) the current drawing pages are deleted (the assets stay). With `co_id` (a change order
approval) they are voided and point at the first new page. Returns (first new page, page count, old pages).
"""
old = packets.rows(
conn,
"SELECT * FROM packet_pages WHERE packet_id = %s AND type = 'drawing' AND void_at IS NULL ORDER BY n",
(packet_id,),
)
count = int(asset["pages"] or 0)
if count < 1:
raise ApiError(422, "not_pdf", "That drawing has no pages.")
first = next_counter(conn, packet_id, "page", count)
if co_id is None:
conn.execute(
"DELETE FROM packet_pages WHERE packet_id = %s AND type = 'drawing' AND void_at IS NULL", (packet_id,)
)
elif old:
conn.execute(
"UPDATE packet_pages SET void_co_id = %s, void_at = now(), void_by = %s, void_new_n = %s,"
" row_version = row_version + 1 WHERE id = ANY(%s)",
(co_id, void_by, first, [pg["id"] for pg in old]),
)
for i in range(count):
conn.execute(
"INSERT INTO packet_pages (packet_id, n, type, asset_id, pn, dwg, rev, co_id)"
" VALUES (%s, %s, 'drawing', %s, %s, %s, %s, %s)",
(packet_id, first + i, asset["id"], i + 1, dwg, rev, co_id),
)
return first, count, old
def set_visual(conn: Any, packet_id: int, visual: Mapping[str, Any]) -> None:
conn.execute("UPDATE packets SET visual = %s WHERE id = %s", (Jsonb(dict(visual)), packet_id))
def _sha(asset: Mapping[str, Any]) -> str:
return bytes(asset["sha256"]).hex()
# ---------------------------------------------------------------- approve the M-Files rev
@router.post("/{packet_id}/rev-approve", dependencies=WRITE)
def rev_approve(
packet_id: int,
body: RevApproveBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""A Clerical Supervisor approves the rev M-Files has in place of the rev VISUAL calls, for this draft only."""
conn = d.conn
reason = reason_ok(body.reason)
pp = _pp(d, p)
row = packets.load_row(conn, packet_id)
staged = packets.staged_lookup(conn, row["drawing"]) if row else None
content = {
"action": str(A.REV_APPROVE),
"packet_id": packet_id,
"drawing": row["drawing"] if row else None,
"visual_rev": row["rev"] if row else None,
"rev": staged["rev"] if staged else None,
"sha256": bytes(staged["sha256"]).hex() if staged and staged["sha256"] else None,
"reason": reason,
}
with packet_write(
d,
p,
packet_id,
A.REV_APPROVE,
request=request,
extra={"reason": body.reason},
it_reason=body.it_reason,
sign=body.sign,
sign_content=content,
policy_principal=pp,
) as w:
if w.packet["source_kind"] != "visual":
raise ApiError(409, "not_visual", MSG_NOT_VISUAL)
lk = packets.staged_lookup(conn, w.packet["drawing"])
if lk is None:
raise ApiError(
409, "nothing_staged", "No M-Files copy of this drawing is ready yet. Try after the next run."
)
asset = asset_store.get(conn, lk["asset_id"])
if asset is None or not asset["pages"]:
raise ApiError(
409, "nothing_staged", "The M-Files copy of this drawing is missing. Try after the next run."
)
rev = str(lk["rev"])
vis_rev = w.packet["rev"] or ""
replace_drawing_pages(conn, packet_id, asset, dwg=w.packet["drawing"], rev=rev)
visual = dict(w.packet["visual"] or {})
visual["rev_ok"] = {"rev": rev, "vis_rev": vis_rev, **who_of(w), "at": now_iso(), "reason": reason}
visual["rev_confirmed"] = True
visual["vault"] = {
"asset_id": str(asset["id"]),
"name": lk["name"],
"rev": rev,
"note": lk["note"],
"at": now_iso(),
}
visual.pop("manual_dwg", None)
set_visual(conn, packet_id, visual)
conn.execute(
"UPDATE packets SET drawing_state = 'attached', flags = %s WHERE id = %s",
(flags_without(w.packet["flags"], (*packets.DRAWING_FLAGS, "hand_drawing")), packet_id),
)
w.changed("pages", "drawing_state", "flags", "visual")
w.detail.update(asset_id=str(asset["id"]), rev=rev, vis_rev=vis_rev)
w.log("Different rev approved by hand", f"rev {rev} in place of VISUAL rev {vis_rev or '-'} · {reason}")
return _read(d, pp, packet_id)
# ---------------------------------------------------------------- hand drawing
@router.post("/{packet_id}/hand/drawing", dependencies=WRITE)
def hand_drawing(
packet_id: int,
body: HandDrawingBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Clerical uploads the drawing by hand on a flagged VISUAL draft. It replaces the drawing pages."""
conn = d.conn
reason = clean_opt(body.reason, LIMITS["reason"])
if reason is None:
raise ApiError(400, "reason_required", "Say why the drawing is uploaded by hand.")
asset = pdf_asset(conn, body.asset_id)
pp = _pp(d, p)
content = {
"action": "hand_drawing",
"packet_id": packet_id,
"asset_id": str(asset["id"]),
"sha256": _sha(asset),
"pages": asset["pages"],
"reason": reason,
}
with packet_write(
d,
p,
packet_id,
A.EDIT_DOCUMENT_SET,
request=request,
it_reason=body.it_reason,
sign=body.sign,
sign_content=content,
require_sign=True,
policy_principal=pp,
) as w:
_hand_guard(w)
vis_rev = w.packet["rev"] or ""
first, count, _ = replace_drawing_pages(conn, packet_id, asset, dwg=w.packet["drawing"], rev=w.packet["rev"])
name = asset["original_name"] or "drawing.pdf"
visual = dict(w.packet["visual"] or {})
visual["manual_dwg"] = {
"asset_id": str(asset["id"]),
"name": name,
"reason": reason,
**who_of(w),
"at": now_iso(),
"vis_rev": vis_rev,
}
visual["rev_confirmed"] = False
set_visual(conn, packet_id, visual)
conn.execute(
"UPDATE packets SET drawing_state = 'hand', flags = %s WHERE id = %s",
(flags_without(w.packet["flags"], packets.DRAWING_FLAGS, ("hand_drawing",)), packet_id),
)
w.changed("pages", "drawing_state", "flags", "visual")
w.detail.update(asset_id=str(asset["id"]), pages=count, first_page=first)
w.log("Drawing uploaded by hand", f"{name}, {count} page{'s' if count != 1 else ''} · {reason}")
return _read(d, pp, packet_id)
# ---------------------------------------------------------------- hand BOM
def _insert_hand_bom(conn: Any, packet_id: int, lines: list[dict[str, Any]]) -> None:
conn.execute("DELETE FROM bom_lines WHERE packet_id = %s", (packet_id,))
for i, b in enumerate(lines):
conn.execute(
"INSERT INTO bom_lines (packet_id, item, qty, part_id, description, dwg_ref, cl_item, length, material,"
" src, sort) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'hand', %s)",
(
packet_id,
b["item"],
b["qty"],
b["part_id"],
b["description"],
b["dwg_ref"],
b["cl_item"],
b["length"],
b["material"],
i,
),
)
def _hand_bom_write(
d: Db,
p: Principal,
request: Request,
packet_id: int,
*,
data: bytes,
name: str | None,
reason: str,
sign: SignBody,
it_reason: str | None,
) -> dict[str, Any]:
conn = d.conn
try:
lines = handbom.parse(data, name)
except handbom.BadFile as e:
raise ApiError(422, "bad_file", str(e)) from None
pp = _pp(d, p)
sha = hashlib.sha256(data).hexdigest()
fname = clean_opt(name, LIMITS["file_name"]) or "bom"
content = {
"action": "hand_bom",
"packet_id": packet_id,
"file": fname,
"sha256": sha,
"lines": len(lines),
"reason": reason,
}
with packet_write(
d,
p,
packet_id,
A.EDIT_BOM,
request=request,
it_reason=it_reason,
sign=sign,
sign_content=content,
require_sign=True,
policy_principal=pp,
) as w:
_hand_guard(w)
_insert_hand_bom(conn, packet_id, lines)
visual = dict(w.packet["visual"] or {})
visual["manual_bom"] = {
"name": fname,
"reason": reason,
**who_of(w),
"at": now_iso(),
"lines": len(lines),
"sha256": sha,
}
set_visual(conn, packet_id, visual)
conn.execute(
"UPDATE packets SET flags = %s WHERE id = %s",
(flags_without(w.packet["flags"], (), ("hand_bom",)), packet_id),
)
w.changed("bom", "flags", "visual")
w.detail.update(file=fname, sha256=sha, lines=len(lines))
n = len(lines)
w.log("BOM uploaded by hand", f"{fname} · {n} line{'s' if n != 1 else ''} · {reason}")
return _read(d, pp, packet_id)
@router.post("/{packet_id}/hand/bom")
async def hand_bom(
packet_id: int,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Clerical uploads the BOM by hand (multipart: file, reason, sign_factor, sign_secret, it_reason)."""
length = request.headers.get("content-length")
if length is not None and length.isdigit() and int(length) > BOM_MAX_BYTES + FORM_SLACK:
raise ApiError(413, "too_large", f"The BOM file is over the {BOM_MAX_BYTES // (1024 * 1024)} MB limit.")
form = await request.form(max_files=1, max_fields=8)
try:
file = form.get("file")
if not isinstance(file, UploadFile):
raise ApiError(422, "invalid_request", "Send the BOM file in the 'file' field.")
def field(name: str, cap: int) -> str | None:
v = form.get(name)
return v[:cap] if isinstance(v, str) else None
reason = clean_opt(field("reason", 2000), LIMITS["reason"])
if reason is None:
raise ApiError(400, "reason_required", "Say why the BOM is uploaded by hand.")
factor, secret = field("sign_factor", 20), field("sign_secret", 1024)
if not factor or secret is None:
raise ApiError(
400, "signature_required", "This step needs your signature.", extra={"meaning": "Edit the BOM"}
)
if factor not in ("pin", "password"):
raise ApiError(422, "invalid_request", "sign_factor is pin or password.")
sign = SignBody(factor=factor, secret=secret) # type: ignore[arg-type]
data = await file.read(BOM_MAX_BYTES + 1)
if len(data) > BOM_MAX_BYTES:
raise ApiError(413, "too_large", f"The BOM file is over the {BOM_MAX_BYTES // (1024 * 1024)} MB limit.")
name = file.filename
it_reason = field("it_reason", 1000)
finally:
await form.close()
return await run_in_threadpool(
lambda: _hand_bom_write(
d, p, request, packet_id, data=data, name=name, reason=reason, sign=sign, it_reason=it_reason
)
)
@router.delete("/{packet_id}/hand/bom", dependencies=WRITE)
def hand_bom_off(
packet_id: int,
request: Request,
body: HandBomOffBody | None = None,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Drop the hand BOM and use VISUAL's BOM again, rebuilt from the cached materials."""
body = body or HandBomOffBody()
conn = d.conn
pp = _pp(d, p)
with packet_write(
d,
p,
packet_id,
A.EDIT_BOM,
request=request,
it_reason=body.it_reason,
sign=body.sign,
sign_content={"action": "hand_bom_off", "packet_id": packet_id},
require_sign=body.sign is not None,
policy_principal=pp,
) as w:
if w.packet["source_kind"] != "visual":
raise ApiError(409, "not_visual", MSG_NOT_VISUAL)
if w.packet["status"] != "draft":
raise ApiError(403, "wrong_status", MSG_DRAFT_ONLY)
visual = dict(w.packet["visual"] or {})
old = visual.pop("manual_bom", None)
if not old:
raise ApiError(409, "no_hand_bom", "This packet has no BOM uploaded by hand.")
from ..feed import materials as feed_materials # agent B's module, imported where it is used
lines = feed_materials.bom_for_packet(conn, packet_id)
conn.execute("DELETE FROM bom_lines WHERE packet_id = %s", (packet_id,))
feed_materials.insert_bom(conn, packet_id, lines)
set_visual(conn, packet_id, visual)
conn.execute(
"UPDATE packets SET flags = %s WHERE id = %s",
(flags_without(w.packet["flags"], ("hand_bom",)), packet_id),
)
w.changed("bom", "flags", "visual")
n = len(lines)
name = old.get("name") if isinstance(old, dict) else None
note = f"{n} line{'s' if n != 1 else ''} from VISUAL" if n else "VISUAL has no materials for it yet"
w.log("Hand-uploaded BOM taken off", " · ".join(x for x in (name, note) if x))
return _read(d, pp, packet_id)
# ---------------------------------------------------------------- VISUAL BOM lines (change order approval)
def merge_visual_bom(conn: Any, packet_id: int, raw_lines: list[Any]) -> dict[str, int]:
"""Bring a packet's BOM to VISUAL's lines (change order `vis.bom`, dicts or BomLine objects) without losing work.
When nothing has been recorded on the lines (no pulled mark, heat, MTR or short) the BOM is replaced through
feed.materials.insert_bom, exactly as the feed writes it. Otherwise lines are matched on vkey and updated in
place, so pulled marks, heats and shorts stay. New lines are added with the same encoding (item numbers from
feed.materials.db_items, the label in cl_item, cons Y/N, sub Y or NULL). Lines VISUAL dropped are deleted, or
marked n/a when they were pulled or have a short.
"""
from ..feed.materials import BomLine, db_items, insert_bom
lines = [ln if isinstance(ln, BomLine) else BomLine.from_dict(ln) for ln in raw_lines]
counts = {"added": 0, "updated": 0, "removed": 0, "na": 0}
existing = packets.rows(conn, "SELECT * FROM bom_lines WHERE packet_id = %s ORDER BY item", (packet_id,))
worked = (
any(e["pulled"] or e["heat"] or e["mtr_asset_id"] for e in existing)
or conn.execute("SELECT 1 FROM bom_shorts WHERE packet_id = %s LIMIT 1", (packet_id,)).fetchone()
)
if not worked:
# Nothing recorded on the lines yet: write them exactly as the feed does.
conn.execute("DELETE FROM bom_lines WHERE packet_id = %s", (packet_id,))
insert_bom(conn, packet_id, lines)
counts["added"] = len(lines)
counts["removed"] = len(existing)
return counts
by_vkey = {e["vkey"]: e for e in existing if e["vkey"]}
used = {int(e["item"]) for e in existing}
wanted = db_items(lines)
kept: set[int] = set()
for sort, (b, want_item) in enumerate(zip(lines, wanted, strict=True)):
values = (
b.qty,
b.part_id[:60] or None,
b.desc[:240] or None,
b.loc[:120] or None,
b.whs or None,
b.comm or None,
"Y" if b.cons else "N",
"Y" if b.sub else None,
b.na,
b.grp,
b.issued,
sort,
)
match = by_vkey.get(b.vkey) if b.vkey else None
if match is not None and int(match["id"]) not in kept:
kept.add(int(match["id"]))
conn.execute(
"UPDATE bom_lines SET qty = %s, part_id = %s, description = %s, loc = %s, whs = %s, comm = %s,"
" cons = %s, sub = %s, na = %s, grp = %s, issued = coalesce(%s, issued), sort = %s, cl_item = %s,"
" row_version = row_version + 1 WHERE id = %s",
(*values, b.item, match["id"]),
)
counts["updated"] += 1
continue
item = want_item
while item in used:
item += 1
used.add(item)
new_id = conn.execute(
"INSERT INTO bom_lines (packet_id, item, cl_item, qty, part_id, description, loc, whs, comm, cons, sub, na,"
" grp, issued, sort, vkey, src) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,"
" 'visual') RETURNING id",
(packet_id, item, b.item, *values, b.vkey),
).fetchone()[0]
kept.add(int(new_id))
counts["added"] += 1
for e in existing:
if int(e["id"]) in kept:
continue
busy = e["pulled"] or conn.execute("SELECT 1 FROM bom_shorts WHERE bom_line_id = %s", (e["id"],)).fetchone()
if busy:
if not e["na"]:
conn.execute("UPDATE bom_lines SET na = true, row_version = row_version + 1 WHERE id = %s", (e["id"],))
counts["na"] += 1
else:
conn.execute("DELETE FROM bom_lines WHERE id = %s", (e["id"],))
counts["removed"] += 1
return counts
+2
View File
@@ -299,6 +299,7 @@ def list_packets(
project_id: int | None = Query(None),
q: str = Query("", max_length=200),
flag: str = Query("", max_length=20),
co: str = Query("", max_length=20),
limit: int = Query(500, ge=1, le=2000),
removed: int = Query(0, ge=0, le=1),
) -> dict[str, Any]:
@@ -309,6 +310,7 @@ def list_packets(
"project_id": project_id,
"q": clean_text(q, 200),
"flag": flag,
"co": co.strip().lower() or None,
"limit": limit,
"removed": bool(removed) and p.is_admin,
}
+58 -2
View File
@@ -1,4 +1,4 @@
"""`packetstation` command line: app, worker, migrate, doctor, version, healthcheck."""
"""`packetstation` command line: app, worker, job, fixtures, migrate, doctor, version, healthcheck."""
from __future__ import annotations
@@ -55,7 +55,52 @@ def cmd_worker(_: argparse.Namespace) -> int:
_setup_logging(s.log_level)
guard.startup_checks(s)
beat = Path(os.environ.get("WORKER_BEAT_FILE") or s.worker_beat_file)
return worker.run(beat, s.ps_build)
return worker.run(beat, s.ps_build, settings=s)
def cmd_job_run(a: argparse.Namespace) -> int:
"""Run one job once in the foreground (trigger `cli`) and print its job_runs row as JSON."""
from . import guard
from .worker.jobs import load_jobs
from .worker.runs import run_job
from .worker.scheduler import Scheduler
s = _settings()
_setup_logging(s.log_level)
guard.startup_checks(s)
jobs = load_jobs()
if a.name not in jobs:
print(f"packetstation job run: no job {a.name!r}; jobs: {', '.join(sorted(jobs))}", file=sys.stderr)
return 2
sched = Scheduler(s, jobs=jobs)
try:
row = run_job(sched, a.name, "cli")
except Exception as e:
from .worker.redaction import redact
print(f"packetstation job run: {redact(f'{type(e).__name__}: {e}')}", file=sys.stderr)
return 1
print(json.dumps(row, indent=2, default=str))
return 1 if row.get("status") == "failed" else 0
def cmd_fixtures_use(a: argparse.Namespace) -> int:
"""Point the fixture sources at state N: writes DIR/state. Never on STACK=prod."""
if os.environ.get("STACK", "").strip().lower() == "prod":
print("packetstation fixtures: refused on STACK=prod", file=sys.stderr)
return 2
if a.state < 1:
print("packetstation fixtures use: the state is a number from 1", file=sys.stderr)
return 2
d = Path(a.dir or os.environ.get("FIXTURE_DIR") or "/fixtures")
if not (d / f"feed.{a.state}.json").is_file():
print(f"packetstation fixtures use: {d / f'feed.{a.state}.json'} not found", file=sys.stderr)
return 1
tmp = d / "state.tmp"
tmp.write_text(str(a.state) + "\n", encoding="utf-8")
tmp.replace(d / "state")
print(f"fixture state {a.state} in {d}")
return 0
def cmd_migrate(a: argparse.Namespace) -> int:
@@ -148,6 +193,17 @@ def build_parser() -> argparse.ArgumentParser:
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("app", help="Run the web app (uvicorn on 0.0.0.0:8000)").set_defaults(func=cmd_app)
sub.add_parser("worker", help="Run the background worker").set_defaults(func=cmd_worker)
j = sub.add_parser("job", help="Worker jobs")
jsub = j.add_subparsers(dest="job_cmd", required=True)
jr = jsub.add_parser("run", help="Run one job once now and print its run as JSON (exit 1 when it failed)")
jr.add_argument("name", help="visual-feed, drawing-lookup or housekeeping")
jr.set_defaults(func=cmd_job_run)
f = sub.add_parser("fixtures", help="Synthetic feed fixtures (CI and tests)")
fsub = f.add_subparsers(dest="fixtures_cmd", required=True)
fu = fsub.add_parser("use", help="Point the fixture sources at state N (writes DIR/state)")
fu.add_argument("state", type=int)
fu.add_argument("--dir", help="The fixture directory (default FIXTURE_DIR or /fixtures)")
fu.set_defaults(func=cmd_fixtures_use)
m = sub.add_parser("migrate", help="Apply pending SQL migrations as the migrate role")
m.add_argument(
"--bootstrap-roles",
+131 -21
View File
@@ -18,6 +18,7 @@ from pathlib import Path
from typing import Any, BinaryIO
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from .. import audit
from ..api.errors import ApiError
@@ -69,7 +70,35 @@ def path_of(settings: Settings, key: str) -> Path:
def pdf_pages(path: Path) -> int:
"""Page count through pypdf; 422 bad_pdf when it does not parse or has no pages."""
"""Page count through pypdfium2, or pypdf when pypdfium2 is missing or cannot read the file.
422 bad_pdf when neither parses it or it has no pages.
"""
n = _pages_pdfium(path)
if n is None:
n = _pages_pypdf(path)
if n < 1:
raise ApiError(422, "bad_pdf", "That PDF has no pages.")
return n
def _pages_pdfium(path: Path) -> int | None:
try:
import pypdfium2
except ImportError:
return None
try:
doc = pypdfium2.PdfDocument(str(path))
except Exception as e: # any pdfium failure falls back to pypdf
log.info("pypdfium2 could not read the PDF (%s); trying pypdf", type(e).__name__)
return None
try:
return len(doc)
finally:
doc.close()
def _pages_pypdf(path: Path) -> int:
from pypdf import PdfReader
from pypdf.errors import PyPdfError
@@ -78,8 +107,6 @@ def pdf_pages(path: Path) -> int:
except (PyPdfError, ValueError, KeyError, TypeError, OSError, IndexError, AttributeError) as e:
log.info("upload is not a readable PDF: %s", type(e).__name__)
raise ApiError(422, "bad_pdf", "That PDF could not be read.") from None
if n < 1:
raise ApiError(422, "bad_pdf", "That PDF has no pages.")
return n
@@ -115,6 +142,40 @@ def _fsync_dir(path: Path) -> None:
os.close(fd)
def _existing(conn: Any, sha: bytes, size: int) -> dict[str, Any] | None:
"""The stored asset with the same bytes (same sha256 and size), oldest first."""
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
"SELECT * FROM assets WHERE sha256 = %s AND size = %s ORDER BY uploaded_at LIMIT 1", (sha, size)
).fetchone()
return dict(r) if r else None
def _place(settings: Settings, tmp: Path) -> tuple[uuid.UUID, str, Path]:
"""Rename a fsynced file to ASSETS_DIR/<id[:2]>/<id>, then fsync the directory. Returns (id, key, path)."""
aid = uuid.uuid4()
key = storage_key(aid)
final = path_of(settings, key)
final.parent.mkdir(parents=True, exist_ok=True)
os.replace(tmp, final)
_fsync_dir(final.parent)
return aid, key, final
def _insert(conn: Any, values: tuple[Any, ...]) -> dict[str, Any]:
"""Insert one assets row from (id, sha256, size, mime, name, key, source, source_ref, pages, by, packet_id)."""
v = list(values)
if v[7] is not None and not isinstance(v[7], Jsonb):
v[7] = Jsonb(v[7])
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(
"INSERT INTO assets (id, sha256, size, mime, original_name, storage_key, source, source_ref, pages,"
" uploaded_by, packet_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *",
v,
).fetchone()
return dict(row)
def store_upload(
conn: Any,
settings: Settings,
@@ -166,29 +227,15 @@ def store_upload(
if mime is None or size == 0:
raise ApiError(415, "unsupported_type", "Only PDF, PNG and JPEG files can be stored.")
sha = digest.digest()
with conn.cursor(row_factory=dict_row) as cur:
existing = cur.execute(
"SELECT * FROM assets WHERE sha256 = %s AND size = %s ORDER BY uploaded_at LIMIT 1", (sha, size)
).fetchone()
existing = _existing(conn, sha, size)
if existing is not None:
return public(dict(existing))
return public(existing)
pages = pdf_pages(tmp) if mime == PDF else None
aid = uuid.uuid4()
key = storage_key(aid)
final = path_of(settings, key)
final.parent.mkdir(parents=True, exist_ok=True)
os.replace(tmp, final)
aid, key, placed = _place(settings, tmp)
keep = True
placed = final
_fsync_dir(final.parent)
name = safe_name(original_name)
with conn.transaction():
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(
"INSERT INTO assets (id, sha256, size, mime, original_name, storage_key, source, source_ref, pages,"
" uploaded_by, packet_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *",
(aid, sha, size, mime, name, key, source, source_ref, pages, user, packet_id),
).fetchone()
row = _insert(conn, (aid, sha, size, mime, name, key, source, source_ref, pages, user, packet_id))
audit.append(
conn,
action="asset.uploaded",
@@ -213,6 +260,69 @@ def store_upload(
tmp.unlink()
def store_file(
conn: Any,
settings: Settings,
path: Path,
*,
mime: str = PDF,
original_name: Any,
source: str,
source_ref: Any = None,
uploaded_by: Any = None,
packet_id: int | None = None,
) -> dict[str, Any]:
"""Store a file the server wrote itself (the worker's M-Files download). Returns the full assets row.
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.
"""
path = Path(path)
limit = int(settings.upload_max_bytes)
placed: Path | None = None
done = False
try:
digest = hashlib.sha256()
size = 0
head = b""
with path.open("r+b") as f:
while True:
chunk = f.read(CHUNK)
if not chunk:
break
if len(head) < HEAD:
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.")
digest.update(chunk)
os.fsync(f.fileno())
if size == 0 or sniff(head) != mime:
raise ApiError(415, "unsupported_type", f"The file is not {EXT.get(mime, mime)}.")
sha = digest.digest()
existing = _existing(conn, sha, size)
if existing is not None:
return {**existing, "reused": True}
pages = pdf_pages(path) if mime == PDF else None
aid, key, placed = _place(settings, path)
name = safe_name(original_name)
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):
placed.unlink()
raise
finally:
with contextlib.suppress(OSError):
path.unlink()
def open_blob(settings: Settings, row: dict[str, Any]) -> Path:
"""The stored file of an asset row; 404 when it is missing on disk."""
p = path_of(settings, str(row["storage_key"]))
+178
View File
@@ -0,0 +1,178 @@
"""A BOM uploaded by hand: an Excel (XLSX) or CSV sheet read into BOM lines.
Port of the old handdocs.js handBomFromSheet. The header row is the first row, within the first 20, that has a Qty
column and a Description or Part ID column, found by the HAND_COLS patterns. Rows without a description and part id
are skipped. Item numbers come from the item column when it holds a whole number not used yet, else the next free
number; the item text as written is kept in cl_item when it differs. The file's contents are data, never instructions.
"""
from __future__ import annotations
import csv
import io
import re
from decimal import Decimal, InvalidOperation
from typing import Any
from ..text import LIMITS, clean_text
MAX_ROWS = 500
HEADER_SCAN = 20
MAX_COLS = 60
# Column patterns, in the old build's order. The first unused column matching a pattern wins.
HAND_COLS: tuple[tuple[str, re.Pattern[str]], ...] = (
("item", re.compile(r"^(item|itm|piece|find|balloon|line|no\.?|#|item\s*no\.?)$", re.I)),
("qty", re.compile(r"^(qty|quantity|qty\.?\s*(req|required|per)?|req'?d)", re.I)),
("part_id", re.compile(r"part\s*(id|no\.?|num|number|#)|^part$|pesco", re.I)),
("desc", re.compile(r"desc", re.I)),
("length", re.compile(r"^len|length|cut", re.I)),
("material", re.compile(r"material|mat'?l|grade", re.I)),
("dwg_ref", re.compile(r"dwg|drawing|ref", re.I)),
)
MSG_NO_HEADER = "No header row found. It needs a Qty column and a Description or Part ID column."
MSG_NO_LINES = "No BOM lines found in that file."
MSG_TYPE = "Upload the BOM as an Excel (.xlsx) or CSV file."
class BadFile(ValueError):
"""The file cannot be read as a BOM. The message is for the user."""
def kind_of(data: bytes, name: str | None) -> str:
"""xlsx (a ZIP) or csv (text). Anything else is refused."""
if data[:4] == b"PK\x03\x04":
return "xlsx"
lower = (name or "").lower()
if data[:5] == b"%PDF-" or data[:8] == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1":
raise BadFile(MSG_TYPE)
if lower.endswith((".csv", ".txt")) or b"\x00" not in data[:4096]:
return "csv"
raise BadFile(MSG_TYPE)
def _cell(value: Any) -> str:
if value is None:
return ""
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def _rows_xlsx(data: bytes) -> list[list[str]]:
try:
from openpyxl import load_workbook
except ImportError as e: # pragma: no cover - openpyxl is a dependency
raise BadFile("The server cannot read Excel files right now. Use a CSV file.") from e
try:
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
except Exception as e: # openpyxl raises many types on a bad file
raise BadFile("That Excel file could not be read.") from e
try:
ws = wb.worksheets[0] if wb.worksheets else None
if ws is None:
raise BadFile(MSG_NO_LINES)
out: list[list[str]] = []
for row in ws.iter_rows(values_only=True):
out.append([_cell(v) for v in row[:MAX_COLS]])
if len(out) > MAX_ROWS + HEADER_SCAN:
break
return out
finally:
wb.close()
def _rows_csv(data: bytes) -> list[list[str]]:
try:
text = data.decode("utf-8-sig")
except UnicodeDecodeError:
text = data.decode("cp1252", errors="replace")
sample = text[:8192]
try:
dialect: Any = csv.Sniffer().sniff(sample, delimiters=",;\t|")
except csv.Error:
dialect = csv.excel
out: list[list[str]] = []
for row in csv.reader(io.StringIO(text), dialect):
out.append([_cell(v) for v in row[:MAX_COLS]])
if len(out) > MAX_ROWS + HEADER_SCAN:
break
return out
def find_header(rows: list[list[str]]) -> tuple[int, dict[str, int]]:
"""The header row index and the column map, or BadFile."""
for i, row in enumerate(rows[:HEADER_SCAN]):
m: dict[str, int] = {}
for key, rx in HAND_COLS:
for j, h in enumerate(row):
if h and rx.search(h) and j not in m.values():
m[key] = j
break
if "qty" in m and ("desc" in m or "part_id" in m):
return i, m
raise BadFile(MSG_NO_HEADER)
def _qty(text: str) -> Decimal:
digits = re.sub(r"[^\d.\-]", "", text)
try:
q = Decimal(digits) if digits else Decimal(0)
except InvalidOperation:
return Decimal(0)
return q if q.is_finite() else Decimal(0)
def lines_from_rows(rows: list[list[str]]) -> list[dict[str, Any]]:
hi, m = find_header(rows)
def cell(r: list[str], key: str) -> str:
j = m.get(key)
return r[j].strip() if j is not None and j < len(r) else ""
out: list[dict[str, Any]] = []
used: set[int] = set()
for r in rows[hi + 1 :]:
desc, pid, qty = cell(r, "desc"), cell(r, "part_id"), cell(r, "qty")
if not desc and not pid:
continue # blank rows, totals, notes
raw_item = cell(r, "item")
item: int | None = None
if re.fullmatch(r"\d{1,5}", raw_item) and 1 <= int(raw_item) <= 100_000 and int(raw_item) not in used:
item = int(raw_item)
if item is None:
item = len(out) + 1
while item in used:
item += 1
used.add(item)
cl_item = clean_text(raw_item, LIMITS["part_id"]) or None if raw_item != str(item) else None
out.append(
{
"item": item,
"cl_item": cl_item,
"qty": _qty(qty),
"part_id": clean_text(pid, LIMITS["part_id"]) or None,
"description": clean_text(desc, LIMITS["description"]) or None,
"length": clean_text(cell(r, "length"), LIMITS["length"]) or None,
"material": clean_text(cell(r, "material"), LIMITS["material"]) or None,
"dwg_ref": clean_text(cell(r, "dwg_ref"), LIMITS["drawing"]) or None,
}
)
if len(out) >= MAX_ROWS:
break
if not out:
raise BadFile(MSG_NO_LINES)
return out
def parse(data: bytes, name: str | None) -> list[dict[str, Any]]:
"""BOM lines from an XLSX or CSV file: [{item, cl_item, qty, part_id, description, length, material, dwg_ref}].
Raises BadFile with the reason for the user.
"""
if not data:
raise BadFile("The file is empty.")
kind = kind_of(data, name)
rows = _rows_xlsx(data) if kind == "xlsx" else _rows_csv(data)
return lines_from_rows(rows)
+182 -14
View File
@@ -7,6 +7,7 @@ boards from header columns, v_open_cos and the open short count only.
from __future__ import annotations
import re
import uuid
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
@@ -20,7 +21,7 @@ from ..api.errors import ApiError
from . import checklists
HAND_FLAG = "Made by hand - check it and send it on"
DRAWING_NEEDED_FLAG = "Drawing needed - looking it up in the vault"
DRAWING_NEEDED_FLAG = "Made from VISUAL - drawing not attached yet"
DRAWING_STATES_ATTACHED = ("attached", "hand")
# Keys left out of the sealed content: they change without the packet changing (who can do what, the counter,
@@ -37,6 +38,7 @@ SEAL_EXCLUDE = (
"checklist_items",
"qc_items",
"flag",
"visual_state",
)
@@ -67,19 +69,102 @@ def load_row(conn: Any, packet_id: int, *, lock: bool = False) -> dict[str, Any]
return one(conn, sql, (packet_id,))
def flag_of(status: str, source_kind: str, flags: Iterable[str] | None, drawing_state: str | None = None) -> str | None:
"""The draft flag shown on cards and the packet page. The old packetFlag(), phase 2 part."""
# Flags the drawing job owns; a hand drawing or an approved rev clears them.
DRAWING_FLAGS = ("drawing_needed", "rev_mismatch", "not_released", "not_found", "lookup_failed", "bad_rev")
# drawing_state values that mean the drawing still needs attention.
DRAWING_STATES_FLAGGED = ("needed", "rev_mismatch", "not_released", "not_found", "lookup_failed")
HAND_UPLOAD_FLAGS = ("hand_drawing", "hand_bom")
# Flag kinds in display priority (the first one present gives the text).
FLAG_PRIORITY = (
"hand_drawing",
"hand_bom",
"rev_mismatch",
"not_released",
"not_found",
"lookup_failed",
"bad_rev",
"drawing_needed",
"no_drawing",
"wo_gone",
"cancelled",
)
FLAG_TEXTS = {
"not_found": "Drawing not in M-Files - check the drawing number in VISUAL",
"bad_rev": "VISUAL rev is not a number - drawing not attached",
"lookup_failed": "M-Files lookup failed",
"drawing_needed": DRAWING_NEEDED_FLAG,
"no_drawing": "No drawing number in VISUAL - add it there or upload the drawing by hand",
"wo_gone": "Work order no longer in VISUAL",
"cancelled": "Work order cancelled in VISUAL",
}
# drawing_state -> flag kind, for a visual draft whose flags list does not name the problem.
STATE_KINDS = {
"needed": "drawing_needed",
"rev_mismatch": "rev_mismatch",
"not_released": "not_released",
"not_found": "not_found",
"lookup_failed": "lookup_failed",
}
RX_FLAG_KIND = re.compile(r"^[a-z_]{1,30}$")
def _appr_note(visual: Mapping[str, Any] | None) -> str:
appr = (visual or {}).get("appr") if isinstance(visual, Mapping) else None
if isinstance(appr, Mapping):
note = appr.get("note")
if isinstance(note, str) and note.strip():
return note.strip()
return ""
def flag_of(
status: str,
source_kind: str,
flags: Iterable[str] | None,
drawing_state: str | None = None,
visual: Mapping[str, Any] | None = None,
) -> str | None:
"""The draft flag shown on cards and the packet page: the old packetFlag(), handFlag() and revMismatch() texts.
Only drafts carry a flag. A hand draft keeps the phase 2 text. A VISUAL draft shows its most important flag kind.
"""
if status != "draft":
return None
if source_kind == "hand":
return HAND_FLAG
if "drawing_needed" in (flags or ()):
return DRAWING_NEEDED_FLAG
return None
kinds = list(flags or ())
if not kinds and drawing_state in STATE_KINDS:
kinds = [STATE_KINDS[drawing_state]]
if not kinds:
return None
dwg, bom = "hand_drawing" in kinds, "hand_bom" in kinds
if dwg or bom:
what = "Drawing and BOM" if dwg and bom else "Drawing" if dwg else "BOM"
return f"{what} uploaded by hand - check it and send it on"
note = _appr_note(visual)
for kind in FLAG_PRIORITY:
if kind not in kinds:
continue
if kind == "rev_mismatch":
return note or "Rev mismatch: VISUAL and M-Files show different revs"
if kind == "not_released":
return note or "Not released in M-Files yet"
if kind in ("lookup_failed", "bad_rev"):
# The drawing job writes the whole text ("M-Files lookup failed: ...", "VISUAL rev A is not a number;
# M-Files has rev 3 - not attached").
return note or FLAG_TEXTS[kind]
return FLAG_TEXTS[kind]
return "Flagged: " + ", ".join(k.replace("_", " ") for k in kinds)
# SQL for the same rule, for the list filter.
FLAG_SQL = "(p.status = 'draft' AND (p.source_kind = 'hand' OR 'drawing_needed' = ANY(p.flags)))"
FLAG_SQL = "(p.status = 'draft' AND (p.source_kind = 'hand' OR cardinality(p.flags) > 0))"
# A pending change order from the feed (VISUAL or M-Files): the "Changes to approve" board.
CO_PENDING_SQL = (
"EXISTS (SELECT 1 FROM change_orders c WHERE c.packet_id = p.id AND c.review = 'pending'"
" AND c.origin IN ('visual', 'drawing'))"
)
def policy_principal_for(conn: Any, p: deps.Principal) -> policy.Principal:
@@ -378,6 +463,57 @@ def _unit(conn: Any, unit_id: int, *, seal: bool) -> dict[str, Any]:
}
def _visual(conn: Any, row: Mapping[str, Any], *, seal: bool) -> dict[str, Any]:
"""The packet's visual jsonb. On a VISUAL draft (outside seal mode) it gains `staged_rev`: the M-Files copy the
drawing job staged for this drawing ({rev, name}), or None. It changes without the packet changing, so the seal
leaves it out."""
out = dict(row["visual"] or {})
if seal or row["source_kind"] != "visual" or row["status"] != "draft":
return out
out["staged_rev"] = staged_rev(conn, row["drawing"])
return out
def staged_rev(conn: Any, drawing: Any) -> dict[str, Any] | None:
lk = staged_lookup(conn, drawing)
if lk is None:
return None
return {"rev": lk["rev"], "name": lk["name"]}
def staged_lookup(conn: Any, drawing: Any) -> dict[str, Any] | None:
"""The drawing_lookups row for a drawing number when it holds a fetched copy (asset and rev set)."""
dwg = str(drawing or "").strip()
if not dwg:
return None
return one(
conn,
"SELECT * FROM drawing_lookups WHERE dwg = %s AND asset_id IS NOT NULL AND rev IS NOT NULL",
(dwg,),
)
def visual_state(conn: Any, row: Mapping[str, Any]) -> dict[str, Any] | None:
"""What the feed last observed for a VISUAL packet: leg statuses, ticket hours, the chosen order, and the closed,
cancelled and gone times. None for a hand packet."""
if row["source_kind"] != "visual":
return None
vs = one(
conn,
"SELECT legs, ticket_hours, order_ref, observed_at FROM packet_visual_state WHERE packet_id = %s",
(row["id"],),
)
return {
"legs": (vs or {}).get("legs") or [],
"ticket_hours": (vs or {}).get("ticket_hours"),
"order_ref": (vs or {}).get("order_ref"),
"observed_at": (vs or {}).get("observed_at"),
"closed_at": row.get("visual_closed_at"),
"cancelled_at": row.get("visual_cancelled_at"),
"wo_gone_at": row.get("wo_gone_at"),
}
def build(
conn: Any,
row: Mapping[str, Any],
@@ -424,7 +560,7 @@ def build(
"source": row["source"] or {},
"drawing_state": row["drawing_state"],
"flags": list(row["flags"] or []),
"visual": row["visual"] or {},
"visual": _visual(conn, row, seal=seal),
"removed": None
if row["removed_at"] is None
else {"at": row["removed_at"], "by": people.ref(row["removed_by"]), "reason": row["removed_reason"]},
@@ -451,6 +587,7 @@ def build(
"row_version",
)
}
| {k: w.get(k) for k in ("dwg", "rev", "partspecs")}
for w in ch.work_orders
]
out["pages"] = [
@@ -513,7 +650,9 @@ def build(
)
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"])
out["qc_items"] = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"])
out["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"])
out["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"], row["visual"])
if not seal:
out["visual_state"] = visual_state(conn, row)
if pp is not None:
allowed, blocked = policy.can(pp, state)
out["can"] = [str(a) for a in allowed]
@@ -599,8 +738,12 @@ def _co(c: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
"rejected_at",
"rejected_reason",
"row_version",
"vis",
"waiting",
"staged",
"target_fp",
)
out = {k: c[k] for k in keys}
out = {k: c.get(k) for k in keys}
out["issued_by"] = people.ref(c["issued_by"])
out["approved_by"] = people.ref(c["approved_by"])
out["rejected_by"] = people.ref(c["rejected_by"])
@@ -641,7 +784,10 @@ SELECT p.id, p.version, p.unit_id, u.lot, pr.number || u.lot AS unit_number, pr.
st.name AS station_name, p.welder_id, wu.display_name AS welder_name, p.welder_stamp,
p.routed_to::text AS routed_to, p.vclosed_at, p.vclosed_by, vu.display_name AS vclosed_name,
p.source_kind::text AS source_kind, p.flags, p.drawing_state::text AS drawing_state, p.wh_skipped,
p.heat_map, p.removed_at, coalesce(u.due, pr.due) AS due_effective, p.updated_at,
p.heat_map, p.removed_at, coalesce(u.due, pr.due) AS due_effective, p.updated_at, p.visual,
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,
(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,
@@ -697,7 +843,8 @@ 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, limit, removed."""
"""The board cards. Filters: status (comma list), dept, unit_id, project_id, q, flag (1|0|hand|hand_upload|
<kind>), co (pending), limit, removed."""
where: list[str] = []
params: list[Any] = []
if not filters.get("removed"):
@@ -721,7 +868,24 @@ def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[s
elif flag in FLAG_OFF:
where.append("NOT " + FLAG_SQL)
elif flag == "hand":
where.append("(p.status = 'draft' AND p.source_kind = 'hand')")
# Made by hand, or a VISUAL draft with a drawing or BOM uploaded by hand.
where.append("(p.status = 'draft' AND (p.source_kind = 'hand' OR p.flags && %s::text[]))")
params.append(list(HAND_UPLOAD_FLAGS))
elif flag in ("hand_upload", "hand_uploads"):
where.append("(p.status = 'draft' AND p.flags && %s::text[])")
params.append(list(HAND_UPLOAD_FLAGS))
elif flag and RX_FLAG_KIND.match(flag):
# One flag kind on the Flagged board (drafts). A visual draft whose flags are empty but whose drawing state
# still names the problem counts too, the same way flag_of reads it.
states = [st for st, k in STATE_KINDS.items() if k == flag]
where.append(
"(p.status = 'draft' AND (%s = ANY(p.flags) OR (cardinality(p.flags) = 0 AND p.source_kind = 'visual'"
" AND p.drawing_state::text = ANY(%s))))"
)
params += [flag, states]
co = str(filters.get("co") or "").strip().lower()
if co == "pending":
where.append(CO_PENDING_SQL)
for word in str(filters.get("q") or "").split()[:8]:
pat = _like(word)
where.append(
@@ -771,7 +935,11 @@ def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[s
else {"id": str(r["vclosed_by"]), "display_name": r["vclosed_name"]},
},
"source_kind": r["source_kind"],
"flag": flag_of(r["status"], r["source_kind"], r["flags"], r["drawing_state"]),
"flag": flag_of(r["status"], r["source_kind"], r["flags"], r["drawing_state"], r["visual"]),
"flag_kinds": list(r["flags"] or []),
"co_pending": bool(r["co_pending"]),
"visual_closed": r["visual_closed_at"] is not None,
"drawing_state": r["drawing_state"],
"open_cos": [c["co_no"] for c in r["open_co_rows"] or []],
"open_shorts": int(r["open_shorts"] or 0),
"wh_stage": r["wh_stage"],
+141 -10
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from datetime import date
from datetime import UTC, date, datetime
from typing import Any
from psycopg import errors as pgerr
@@ -28,8 +28,11 @@ MAX_UNITS_AT_ONCE = 200
MAX_WORDS = 8
LIST_LIMIT = 500
PROJECT_FIELDS = ("name", "customer", "po", "due", "status", "notes")
UNIT_FIELDS = ("name", "due", "status", "notes")
PROJECT_FIELDS = ("name", "customer", "po", "due", "status", "notes", "order_pin", "order_pin_reason")
UNIT_FIELDS = ("name", "customer", "po", "due", "status", "notes", "order_pin", "order_pin_reason")
# Order-derived fields: the value in use, where it came from (visual | hand) and what VISUAL last said.
ORDER_FIELDS = ("customer", "po", "order_date", "ship_date")
RX_ORDER_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ./_-]{0,39}$")
_DIGITS = re.compile(r"^[0-9]+$")
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
@@ -159,6 +162,28 @@ def _removed(r: Mapping[str, Any]) -> dict[str, Any] | None:
# ---------------------------------------------------------------- shapes
def _order_out(r: Mapping[str, Any]) -> dict[str, Any]:
"""The order-derived columns with their provenance, and the order pin. Shared by projects and units."""
pin_by = r.get("order_pin_by")
return {
"customer_src": r.get("customer_src"),
"customer_visual": r.get("customer_visual"),
"po_src": r.get("po_src"),
"po_visual": r.get("po_visual"),
"order_date": r.get("order_date"),
"order_date_src": r.get("order_date_src"),
"order_date_visual": r.get("order_date_visual"),
"ship_date": r.get("ship_date"),
"ship_date_src": r.get("ship_date_src"),
"ship_date_visual": r.get("ship_date_visual"),
"order_ref": r.get("order_ref"),
"order_pin": r.get("order_pin"),
"order_pin_by": str(pin_by) if pin_by else None,
"order_pin_at": r.get("order_pin_at"),
"order_pin_reason": r.get("order_pin_reason"),
}
def project_out(r: Mapping[str, Any]) -> dict[str, Any]:
return {
"id": r["id"],
@@ -171,6 +196,7 @@ def project_out(r: Mapping[str, Any]) -> dict[str, Any]:
"notes": r["notes"],
"source": r["source"],
"needs_details": r["needs_details"],
**_order_out(r),
"units": int(r.get("unit_count") or 0),
"packets": _counts(r),
"version": r["version"],
@@ -193,6 +219,9 @@ def unit_out(r: Mapping[str, Any]) -> dict[str, Any]:
"status": status,
"status_effective": "closed" if r["project_status"] == "closed" else status,
"notes": r["notes"],
"customer": r.get("customer"),
"po": r.get("po"),
**_order_out(r),
"packets": _counts(r),
"version": r["version"],
"created_at": r["created_at"],
@@ -322,6 +351,7 @@ def project_detail(conn: Any, project_id: int) -> dict[str, Any]:
out["unit_count"] = len(rows)
out["aliases"] = aliases_of(conn, project_id=project_id)
out["next_lot"] = next_lot(conn, project_id, out["number"])
out["orders"] = cached_orders(conn, out["number"], _project_bases(conn, project_id))
return out
@@ -370,6 +400,7 @@ def unit_detail(conn: Any, unit_id: int) -> dict[str, Any]:
raise ApiError(404, "not_found", "No such unit.")
out["project"] = {k: p[k] for k in ("id", "number", "name", "customer", "po", "due", "status", "version")}
out["aliases"] = aliases_of(conn, unit_id=unit_id)
out["orders"] = cached_orders(conn, p["number"], _unit_bases(conn, unit_id))
return out
@@ -479,6 +510,13 @@ def _norm_field(key: str, value: Any, *, unit: bool) -> Any:
return clean_opt(value, LIMITS["po"])
if key == "notes":
return clean_opt(value, LIMITS["notes"])
if key == "order_pin":
pin = clean_opt(value, LIMITS["order_pin"] + 20)
if pin is not None and not RX_ORDER_ID.match(pin):
raise ApiError(422, "invalid_order", "Give a VISUAL customer order ID, or nothing to clear the pin.")
return pin
if key == "order_pin_reason":
return clean_opt(value, LIMITS["reason"])
raise ApiError(422, "invalid_field", f"{key} cannot be changed here.")
@@ -515,6 +553,100 @@ def _change_detail(row: Mapping[str, Any], changes: Mapping[str, Any]) -> dict[s
return out
def _columns(row: Mapping[str, Any], changes: Mapping[str, Any], who: Who) -> dict[str, Any]:
"""The columns a field edit writes: a hand customer or po marks its source, a pin records who and when."""
cols = dict(changes)
for k in ("customer", "po"):
if k in cols:
cols[f"{k}_src"] = "hand" if cols[k] else None
if "order_pin" in cols:
if cols["order_pin"] is None:
cols.update(order_pin_by=None, order_pin_at=None, order_pin_reason=None)
else:
cols["order_pin_by"] = who.user_id
cols["order_pin_at"] = datetime.now(UTC)
return cols
def _check_pin(
conn: Any, row: Mapping[str, Any], changes: Mapping[str, Any], *, project_number: str, bases: list[str]
) -> None:
"""A new pin needs a reason and, when VISUAL's orders are cached for this project, must name one of them."""
if "order_pin" not in changes and "order_pin_reason" in changes and row.get("order_pin") is None:
raise ApiError(422, "invalid_field", "There is no order pin to give a reason for.")
pin = changes.get("order_pin")
if pin is None:
return
if not changes.get("order_pin_reason"):
raise ApiError(400, "reason_required", "Say why this order is pinned.")
known = [o["cust_order_id"] for o in cached_orders(conn, project_number, bases)]
if known and pin.upper() not in {k.upper() for k in known}:
raise ApiError(
422,
"unknown_order",
f"Order {pin} is not one of the VISUAL orders for {project_number}.",
extra={"orders": known},
)
def _project_bases(conn: Any, project_id: int) -> list[str]:
return [
r[0]
for r in conn.execute(
"SELECT DISTINCT wo.wo_base FROM packet_work_orders wo JOIN packets k ON k.id = wo.packet_id"
" JOIN units u ON u.id = k.unit_id WHERE u.project_id = %s ORDER BY 1",
(project_id,),
)
]
def _unit_bases(conn: Any, unit_id: int) -> list[str]:
return [
r[0]
for r in conn.execute(
"SELECT DISTINCT wo.wo_base FROM packet_work_orders wo JOIN packets k ON k.id = wo.packet_id"
" WHERE k.unit_id = %s ORDER BY 1",
(unit_id,),
)
]
def cached_orders(conn: Any, project_number: str, bases: Iterable[str]) -> list[dict[str, Any]]:
"""Every cached VISUAL order of a project: the ones linked to the project number, then the ones linked to its
work order bases. Each once, with `via` (project | base) and the link's lines and top jobs."""
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
"SELECT o.*, l.kind AS via, l.key, l.rank, l.lines, l.top_jobs FROM visual_order_links l"
" JOIN visual_orders o ON o.cust_order_id = l.cust_order_id"
" WHERE (l.kind = 'project' AND upper(l.key) = upper(%s)) OR (l.kind = 'base' AND l.key = ANY(%s))"
" ORDER BY CASE l.kind WHEN 'project' THEN 0 ELSE 1 END, l.rank, l.key, o.cust_order_id",
(project_number, list(bases)),
).fetchall()
out: list[dict[str, Any]] = []
seen: set[str] = set()
for r in rows:
if r["cust_order_id"] in seen:
continue
seen.add(r["cust_order_id"])
out.append(
{
"cust_order_id": r["cust_order_id"],
"status": r["status"],
"customer_id": r["customer_id"],
"customer_name": r["customer_name"],
"customer_po_ref": r["customer_po_ref"],
"order_date": r["order_date"],
"desired_ship_date": r["desired_ship_date"],
"promise_date": r["promise_date"],
"via": r["via"],
"key": r["key"],
"lines": list(r["lines"] or []),
"top_jobs": list(r["top_jobs"] or []),
}
)
return out
# ---------------------------------------------------------------- writes: projects
@@ -567,11 +699,8 @@ def update_project(
row = _lock_project(conn, project_id)
changes = field_cas(row, values, base, PROJECT_FIELDS, unit=False)
if changes:
cols = dict(changes)
if "customer" in cols:
cols["customer_src"] = "hand" if cols["customer"] else None
if "po" in cols:
cols["po_src"] = "hand" if cols["po"] else None
_check_pin(conn, row, changes, project_number=row["number"], bases=_project_bases(conn, project_id))
cols = _columns(row, changes, who)
sets = ", ".join(f"{k} = %s" for k in cols)
conn.execute(
f"UPDATE projects SET {sets}, version = version + 1, updated_at = now() WHERE id = %s", # noqa: S608
@@ -800,10 +929,12 @@ def update_unit(
row, project = _lock_unit(conn, unit_id)
changes = field_cas(row, values, base, UNIT_FIELDS, unit=True)
if changes:
sets = ", ".join(f"{k} = %s" for k in changes)
_check_pin(conn, row, changes, project_number=project["number"], bases=_unit_bases(conn, unit_id))
cols = _columns(row, changes, who)
sets = ", ".join(f"{k} = %s" for k in cols)
conn.execute(
f"UPDATE units SET {sets}, version = version + 1, updated_at = now() WHERE id = %s", # noqa: S608
[*changes.values(), unit_id],
[*cols.values(), unit_id],
)
_audit(
conn,
+17
View File
@@ -0,0 +1,17 @@
"""Drawings from M-Files, read-only: the lookup, the decision table, the queue and the fetch into assets.
Packet Station never writes to M-Files. MFilesKit enforces that in its own code; this package only calls its
read methods (`DrawingLookup.status`, `DrawingLookup.download`).
"""
from __future__ import annotations
# The packet flags this package owns. The drawing job replaces only these and keeps every other flag.
DRAWING_FLAGS: tuple[str, ...] = (
"drawing_needed",
"rev_mismatch",
"not_released",
"not_found",
"lookup_failed",
"bad_rev",
)
+190
View File
@@ -0,0 +1,190 @@
"""The drawing decision table. Pure: no database, no M-Files.
`decide(target, found, same_file=...)` says what the drawing job does to one packet for one lookup. Note texts are
the app's own (from the old revMismatch and draftLine in visual.js, "-" in place of the em-dash), never M-Files'
note passed through, except inside the lookup_failed text.
Rows, in the order they are tried:
1. lookup failed -> flag lookup_failed, keep what is attached (state lookup_failed only when it was needed).
2. past draft, a pending feed change order waits for this drawing at the released rev -> stage_for_co.
Past draft, the pending change order already stages this drawing at the released rev -> unchanged, or
stage_for_co again when M-Files has a different file for that rev.
3. no released rev, a draft rev or a drafting assignment -> flag not_released with the drafting status.
4. no released rev otherwise -> flag not_found.
5. VISUAL rev not a number (and no approved rev equal to M-Files') -> flag bad_rev, not attached.
6. revs differ (and no approved rev equal to M-Files') -> flag rev_mismatch. The job stages the M-Files copy
for Clerical Supervisor approval on drafts only.
7. revs match, draft with a hand drawing -> unchanged (the feed already took the hand drawing off on a rev move).
8. revs match, draft with this very file attached at this rev -> unchanged.
9. revs match, draft -> attach ("sheet of drawing <base>" for an H sheet).
10. revs match, past draft, nothing attached -> unchanged.
11. revs match, past draft, same file as attached -> unchanged.
12. revs match, past draft, a pending change order already stages this file, or Clerical rejected a change order
for this file -> unchanged.
13. revs match, past draft, attached rev lower -> open_co "M-Files released rev N of <dwg>; this packet has rev M".
14. revs match, past draft, attached rev equal, new file -> open_co "M-Files re-released rev N of <dwg> (new file)".
15. revs match, past draft, attached rev higher or not a number -> unchanged.
"""
from __future__ import annotations
from dataclasses import dataclass
from .lookup import Found
LOOKUP_FAILED = "lookup_failed"
NOT_RELEASED = "not_released"
NOT_FOUND = "not_found"
BAD_REV = "bad_rev"
REV_MISMATCH = "rev_mismatch"
ATTACH = "attach"
UNCHANGED = "unchanged"
OPEN_CO = "open_co"
STAGE_FOR_CO = "stage_for_co"
KINDS = (ATTACH, UNCHANGED, REV_MISMATCH, NOT_RELEASED, NOT_FOUND, LOOKUP_FAILED, BAD_REV, OPEN_CO, STAGE_FOR_CO)
FLAG_KINDS = (LOOKUP_FAILED, NOT_RELEASED, NOT_FOUND, BAD_REV, REV_MISMATCH)
# drawing_state enum values (0003). bad_rev has no state of its own: it is a rev mismatch Clerical can approve.
STATE_OF = {NOT_RELEASED: "not_released", NOT_FOUND: "not_found", BAD_REV: "rev_mismatch", REV_MISMATCH: "rev_mismatch"}
HELD_STATES = ("attached", "hand")
NOT_FOUND_NOTE = "Drawing not in M-Files - check the drawing number in VISUAL"
_FAILED_PREFIX = "M-Files lookup failed: "
@dataclass(frozen=True)
class Target:
dwg: str
visual_rev: str
attached_rev: str | None # rev of the live drawing pages; None when no drawing page is attached
attached_sha: bytes | None
status: str # packet status
drawing_state: str
has_hand_drawing: bool
rev_ok: str | None # the rev Clerical approved for this VISUAL rev, if any
# Additions to the contract, all defaulted: the pending feed change order (origin visual or drawing), if any.
co_waiting: tuple[str, str] | None = None # (dwg, rev) the pending CO waits for
co_staged: tuple[str, str] | None = None # (dwg, rev) the pending CO already stages
co_pending: bool = False
co_staged_sha: bytes | None = None # sha256 of the file the pending CO already stages
rejected_sha: bytes | None = None # sha256 staged on the last drawing-origin CO Clerical rejected
@dataclass(frozen=True)
class Decision:
kind: str
flag: str | None
drawing_state: str
note: str
def _int(v: object) -> int | None:
s = "" if v is None else str(v).strip()
return int(s) if s.isdigit() else None
def draft_line(f: Found) -> str:
"""The old draftLine: "rev N <where>", or "in drafting, no released PDF yet", or ""."""
if f.draft_rev is not None and f.draft_where:
return f"rev {f.draft_rev} {f.draft_where}"
if f.in_drafting:
return "in drafting, no released PDF yet"
if f.draft_rev is not None:
return f"rev {f.draft_rev}"
return ""
def failed_note(f: Found) -> str:
detail = (f.note or "").strip()
if detail.startswith(_FAILED_PREFIX): # MFilesKit's own note already carries the prefix
detail = detail[len(_FAILED_PREFIX) :]
return f"{_FAILED_PREFIX}{detail or 'no detail'}"
def rev_confirmed(t: Target, f: Found) -> bool:
"""The old revConfirmed: M-Files' released rev is the rev VISUAL calls, or Clerical approved that rev."""
if not f.ok or f.rev is None:
return False
v = _int(t.visual_rev)
if v is not None and v == f.rev:
return True
return t.rev_ok is not None and str(t.rev_ok).strip() == str(f.rev)
def decide(t: Target, f: Found, *, same_file: bool, fetched_sha: bytes | None = None) -> Decision:
"""What to do to one packet.
`same_file`: the M-Files file has the sha256 of the attached drawing asset. `fetched_sha`: the sha256 of the
M-Files file, when it was fetched (row 12 compares it with the pending and the rejected change orders).
"""
held = t.drawing_state in HELD_STATES
draft = t.status == "draft"
def flag(kind: str, note: str) -> Decision:
return Decision(kind, kind, t.drawing_state if held else STATE_OF[kind], note)
def keep(kind: str, note: str = "") -> Decision:
return Decision(kind, None, t.drawing_state, note)
# 1
if not f.ok:
return Decision(
LOOKUP_FAILED,
LOOKUP_FAILED,
LOOKUP_FAILED if t.drawing_state == "needed" else t.drawing_state,
failed_note(f),
)
# 2
if not draft and f.rev is not None and t.co_waiting is not None:
w_dwg, w_rev = t.co_waiting
if w_dwg.strip() == t.dwg.strip() and str(w_rev).strip() == str(f.rev):
return keep(STAGE_FOR_CO, f"Drawing for the change order is in M-Files (rev {f.rev})")
if not draft and f.rev is not None and t.co_staged is not None:
s_dwg, s_rev = t.co_staged
if s_dwg.strip() == t.dwg.strip() and str(s_rev).strip() == str(f.rev):
if fetched_sha is not None and t.co_staged_sha is not None and fetched_sha != t.co_staged_sha:
return keep(STAGE_FOR_CO, f"New file for rev {f.rev} in M-Files; staged again for the change order")
return keep(UNCHANGED, f"Rev {f.rev} is staged on the pending change order")
# 3, 4
if f.rev is None:
d = draft_line(f)
if d:
on = f" (sheet of {f.base})" if f.base else ""
return flag(NOT_RELEASED, f"Not released in M-Files yet{on} - drafting status: {d}")
return flag(NOT_FOUND, NOT_FOUND_NOTE)
approved = t.rev_ok is not None and str(t.rev_ok).strip() == str(f.rev)
vrev = (t.visual_rev or "").strip()
v = _int(vrev)
if not approved:
# 5
if v is None:
return flag(BAD_REV, f"VISUAL rev {vrev or '-'} is not a number; M-Files has rev {f.rev} - not attached")
# 6
if v != f.rev:
d = f"; drafting status: {draft_line(f)}" if f.draft_rev is not None and f.draft_rev > f.rev else ""
return flag(REV_MISMATCH, f"Rev mismatch: VISUAL calls rev {vrev}, M-Files has rev {f.rev}{d}")
sheet = f"sheet of drawing {f.base}" if f.base else ""
if draft:
# 7
if t.has_hand_drawing:
return keep(UNCHANGED, "A drawing uploaded by hand stays at the same rev")
# 8
if t.drawing_state == "attached" and same_file and t.attached_rev == str(f.rev):
return keep(UNCHANGED, sheet)
# 9
return Decision(ATTACH, None, "attached", sheet)
# 10, 11
if t.attached_rev is None or same_file:
return keep(UNCHANGED, sheet)
# 12
if fetched_sha is not None and fetched_sha in (t.co_staged_sha, t.rejected_sha):
return keep(UNCHANGED, sheet)
att = _int(t.attached_rev)
if att is None or att > f.rev:
return keep(UNCHANGED, sheet) # 15
if att < f.rev:
reason = f"M-Files released rev {f.rev} of {t.dwg}; this packet has rev {att}"
else:
reason = f"M-Files re-released rev {f.rev} of {t.dwg} (new file)"
return keep(OPEN_CO, reason)
+118
View File
@@ -0,0 +1,118 @@
"""The fixture lookup for CI and tests: `dir/drawings.<state>.json` and the PDFs it names.
`dir/state` holds the state number (default "1"). The JSON maps a drawing number to
`{"rev": 3, "file": "L073762X r3.pdf", "draft_rev": null, "draft_where": "", "in_drafting": false, "ok": true,
"note": "", "obj_id": 1, "obj_version": 1, "file_id": 1, "file_version": 1}`; every key is optional. A drawing
not in the file is not found, after the same H-sheet fallback MFilesKit uses (K978-01-6H13 -> K978-01-6).
`download` copies `dir/<file>`.
"""
from __future__ import annotations
import json
import re
import shutil
from pathlib import Path
from typing import Any
from .lookup import DrawingFetchError, Found, failed
NOT_FOUND_NOTE = "not in M-Files - check the drawing number in VISUAL"
_H_SHEET = re.compile(r"^(.*\d)H\d{1,3}$")
def base_drawing(dwg: str) -> str | None:
m = _H_SHEET.match(dwg)
return m.group(1) if m else None
def _int(v: Any) -> int | None:
if v is None or isinstance(v, bool):
return None
try:
return int(str(v).strip())
except ValueError:
return None
class FixtureLookup:
def __init__(self, directory: Path, state: str | None = None) -> None:
self.dir = Path(directory)
if state is None:
try:
state = (self.dir / "state").read_text(encoding="utf-8").strip() or "1"
except OSError:
state = "1"
self.state = state
self._data: dict[str, dict[str, Any]] | None = None
self._error: str | None = None
def _load(self) -> dict[str, dict[str, Any]]:
if self._data is None:
path = self.dir / f"drawings.{self.state}.json"
try:
raw = json.loads(path.read_text(encoding="utf-8"))
self._data = {str(k).strip(): v for k, v in raw.items() if isinstance(v, dict)}
except (OSError, ValueError, AttributeError) as e:
self._error = f"fixture {path.name} could not be read: {type(e).__name__}"
self._data = {}
return self._data
def status(self, dwg: str) -> Found:
dwg = (dwg or "").strip()
data = self._load()
if self._error:
return failed(dwg, self._error)
if not dwg:
return Found(dwg="", note="no drawing number")
entry, base = data.get(dwg), None
if entry is None:
b = base_drawing(dwg)
if b and b in data:
entry, base = data[b], b
if entry is None:
return Found(dwg=dwg, note=NOT_FOUND_NOTE)
if entry.get("ok") is False:
return failed(dwg, str(entry.get("note") or "M-Files lookup failed: fixture says so"))
rev = _int(entry.get("rev"))
file = entry.get("file") or None
size = None
if file and rev is not None:
p = self._file(str(file))
size = p.stat().st_size if p is not None and p.is_file() else None
has_file = rev is not None and bool(file)
return Found(
dwg=dwg,
ok=True,
rev=rev,
name=str(entry.get("name") or file or "") or None if rev is not None else None,
obj_id=_int(entry.get("obj_id", 1)) if has_file else None,
obj_version=_int(entry.get("obj_version", 1)) if has_file else None,
file_id=_int(entry.get("file_id", 1)) if has_file else None,
file_version=_int(entry.get("file_version", 1)) if has_file else None,
file_size=size,
base=base,
draft_rev=_int(entry.get("draft_rev")),
draft_where=str(entry.get("draft_where") or ""),
in_drafting=bool(entry.get("in_drafting")),
note=str(entry.get("note") or ""),
)
def _file(self, name: str) -> Path | None:
root = self.dir.resolve()
p = (root / name).resolve()
return p if p.is_relative_to(root) else None
def download(self, found: Found, dest: Path) -> int:
entry = self._load().get(found.base or found.dwg) or self._load().get(found.dwg) or {}
name = entry.get("file")
src = self._file(str(name)) if name else None
if src is None or not src.is_file():
raise DrawingFetchError(f"{found.dwg}: fixture file {name!r} is missing")
dest = Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dest)
return dest.stat().st_size
def describe(self) -> str:
return f"fixture {self.dir} state {self.state}"
+125
View File
@@ -0,0 +1,125 @@
"""Copy a released drawing PDF from M-Files into assets.
Downloads only when the M-Files identity (object id and version, file id and version) differs from what the
`drawing_lookups` row last stored, or that row has no asset (or the asset's file is gone). The download goes to
ASSETS_DIR/tmp/<uuid>; the PDF magic and the size limit are checked, then `assets.store_file` hashes it in chunks,
fsyncs and renames it into place, and reuses an existing asset row with the same sha256 and size.
Read-only to M-Files: only `Lookup.download` is called.
"""
from __future__ import annotations
import contextlib
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from psycopg.rows import dict_row
from ..api.errors import ApiError
from ..domain import assets
from .lookup import Found, Lookup
PDF_MAGIC = b"%PDF-"
class FetchError(Exception):
"""The drawing could not be copied. The message is safe to log."""
@dataclass(frozen=True, slots=True)
class FetchResult:
asset_id: uuid.UUID
sha256: bytes
pages: int
downloaded: bool # False when the stored asset was still current
name: str | None = None
reused: bool = False # downloaded, but the same bytes were already stored
def _current(conn: Any, settings: Any, found: Found, dwg: str) -> FetchResult | None:
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(
"SELECT l.obj_id, l.obj_version, l.file_id, l.file_version, l.asset_id, a.sha256, a.pages,"
" a.storage_key, a.original_name FROM drawing_lookups l LEFT JOIN assets a ON a.id = l.asset_id"
" WHERE l.dwg = %s",
(dwg,),
).fetchone()
if row is None or row["asset_id"] is None or row["sha256"] is None:
return None
if (row["obj_id"], row["obj_version"], row["file_id"], row["file_version"]) != found.identity:
return None
if None in found.identity:
return None
try:
if not assets.path_of(settings, str(row["storage_key"])).is_file():
return None
except ApiError:
return None
return FetchResult(
asset_id=row["asset_id"],
sha256=bytes(row["sha256"]),
pages=int(row["pages"] or 0),
downloaded=False,
name=row["original_name"],
)
def fetch(conn: Any, settings: Any, lookup: Lookup, found: Found, *, dwg: str) -> FetchResult:
"""The asset for M-Files' released file of `dwg`. Raises FetchError."""
if not found.ok or found.rev is None:
raise FetchError(f"{dwg}: no released file in M-Files")
cur = _current(conn, settings, found, dwg)
if cur is not None:
return cur
limit = int(settings.upload_max_bytes)
if found.file_size is not None and found.file_size > limit:
raise FetchError(f"{dwg}: the M-Files file is {found.file_size} bytes, over the {limit} byte limit")
tmp_dir = Path(settings.assets_dir) / "tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
tmp = tmp_dir / uuid.uuid4().hex
try:
try:
lookup.download(found, tmp)
except LookupError as e:
raise FetchError(str(e)) from None
except OSError as e:
raise FetchError(f"{dwg}: could not write the download: {type(e).__name__}") from None
try:
size = tmp.stat().st_size
with tmp.open("rb") as f:
head = f.read(len(PDF_MAGIC))
except OSError:
raise FetchError(f"{dwg}: the download left no file") from None
if size > limit:
raise FetchError(f"{dwg}: the M-Files file is {size} bytes, over the {limit} byte limit")
if head != PDF_MAGIC:
raise FetchError(f"{dwg}: the M-Files file is not a PDF")
name = found.name or f"{dwg} r{found.rev}.pdf"
source_ref = {
"dwg": dwg,
"obj_id": found.obj_id,
"obj_version": found.obj_version,
"file_id": found.file_id,
"file_version": found.file_version,
"rev": found.rev,
}
try:
row = assets.store_file(
conn, settings, tmp, mime=assets.PDF, original_name=name, source="mfiles", source_ref=source_ref
)
except ApiError as e:
raise FetchError(f"{dwg}: {e.detail}") from None
return FetchResult(
asset_id=row["id"],
sha256=bytes(row["sha256"]),
pages=int(row["pages"] or 0),
downloaded=True,
name=row.get("original_name") or name,
reused=bool(row.get("reused")),
)
finally:
with contextlib.suppress(OSError):
tmp.unlink()
+183
View File
@@ -0,0 +1,183 @@
"""The drawing lookup the job talks to: M-Files through MFilesKit, or the fixture files (fake.py).
One M-Files `Vault` per worker process (each sign-in can use a licence), kept in the dict the scheduler passes
(`JobContext.shared`) or, when there is none, in this module's own cache keyed by MFILES_URL. A new
`DrawingLookup` per run, because its result cache lives on the instance.
Read-only. Nothing here writes to M-Files.
"""
from __future__ import annotations
import logging
import os
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from mfileskit.drawings import DrawingStatus
from mfileskit.vault import Vault
from ..settings import Settings
log = logging.getLogger(__name__)
VAULT_KEY = "mfiles_vault"
NOTE_MAX = 500
class DrawingFetchError(LookupError):
"""A download did not work. The message is safe to log (no credentials)."""
@dataclass(slots=True)
class Found:
"""What M-Files has for one drawing number: the subset the decision needs, plus identity for change detection."""
dwg: str
ok: bool = True
rev: int | None = None
name: str | None = None
obj_id: int | None = None
obj_version: int | None = None
file_id: int | None = None
file_version: int | None = None
file_size: int | None = None
base: str | None = None
draft_rev: int | None = None
draft_where: str = ""
in_drafting: bool = False
note: str = ""
@property
def identity(self) -> tuple[int | None, int | None, int | None, int | None]:
return (self.obj_id, self.obj_version, self.file_id, self.file_version)
def failed(dwg: str, note: str) -> Found:
return Found(dwg=dwg, ok=False, note=note[:NOTE_MAX])
class Lookup(Protocol):
def status(self, dwg: str) -> Found: ...
def download(self, found: Found, dest: Path) -> int:
"""Write the released PDF to `dest`; bytes written. Raises DrawingFetchError (a LookupError)."""
...
def describe(self) -> str: ...
def from_status(st: DrawingStatus) -> Found:
"""MFilesKit's DrawingStatus -> Found."""
ov, fi = st.obj_ver, st.file
return Found(
dwg=st.drawing,
ok=bool(st.ok),
rev=st.rev,
name=st.name,
obj_id=ov.id if ov is not None else None,
obj_version=ov.version if ov is not None else None,
file_id=fi.id if fi is not None else None,
file_version=fi.version if fi is not None else None,
file_size=fi.size if fi is not None else None,
base=st.base,
draft_rev=st.draft_rev,
draft_where=st.draft_where or "",
in_drafting=bool(st.in_drafting),
note=(st.note or "")[:NOTE_MAX],
)
class MFilesLookup:
"""M-Files through `mfileskit.drawings.DrawingLookup`. Read-only."""
def __init__(self, vault: Vault) -> None:
from mfileskit.drawings import DrawingLookup
self.vault = vault
self._lookup = DrawingLookup(vault) # reads the vault schema; raises MFilesError when the vault is down
self._seen: dict[str, DrawingStatus] = {}
def status(self, dwg: str) -> Found:
from mfileskit.errors import MFilesError
dwg = (dwg or "").strip()
try:
st = self._lookup.status(dwg)
except MFilesError as e: # status() catches these itself; this is a second net
return failed(dwg, f"M-Files lookup failed: {e}")
self._seen[dwg] = st
return from_status(st)
def download(self, found: Found, dest: Path) -> int:
from mfileskit.errors import MFilesError
st = self._seen.get(found.dwg)
if st is None or st.obj_ver is None or st.file is None:
raise DrawingFetchError(f"{found.dwg}: no released file to download")
try:
return int(self._lookup.download(st, dest))
except MFilesError as e:
raise DrawingFetchError(f"{found.dwg}: download failed: {e}"[:NOTE_MAX]) from None
def describe(self) -> str:
url = getattr(getattr(self.vault, "settings", None), "url", "") or ""
return f"M-Files {url}".strip()
# ---------------------------------------------------------------- one vault per process
_LOCK = threading.Lock()
_CACHE: dict[str, Any] = {}
def _cache_for(cache: dict[str, Any] | None) -> tuple[dict[str, Any], str]:
if cache is not None:
return cache, VAULT_KEY
return _CACHE, f"{VAULT_KEY}:{os.environ.get('MFILES_URL', '')}"
def vault_for(settings: Settings, cache: dict[str, Any] | None = None) -> Vault:
"""The process's Vault, made once with `Vault.from_env()` (MFILES_* from the environment; the entrypoint
exports MFILES_PASSWORD from its secret file) and signed in."""
from mfileskit.vault import Vault
store, key = _cache_for(cache)
with _LOCK:
vault = store.get(key)
if vault is None:
vault = Vault.from_env().connect()
store[key] = vault
return vault
def forget_vault(cache: dict[str, Any] | None = None) -> None:
"""Drop (and close) the cached Vault, so the next run signs in again."""
store, key = _cache_for(cache)
with _LOCK:
vault = store.pop(key, None)
if vault is not None:
try:
vault.close()
except Exception as e: # closing a broken connection may fail; nothing to do
log.info("closing the M-Files vault failed: %s", type(e).__name__)
def make(settings: Settings, cache: dict[str, Any] | None = None) -> Lookup:
"""The lookup for one run. `drawing_source = fixture` reads `fixture_dir`; otherwise M-Files.
Raises when M-Files cannot be opened (the job then records a degraded run). A failed open drops the cached
Vault so the next run tries a fresh sign-in.
"""
if getattr(settings, "drawing_source", "mfiles") == "fixture":
from .fake import FixtureLookup
return FixtureLookup(Path(settings.fixture_dir))
try:
return MFilesLookup(vault_for(settings, cache))
except Exception:
forget_vault(cache)
raise
+113
View File
@@ -0,0 +1,113 @@
"""Which drawings the drawing job looks up this run, in order.
Over live packets made from VISUAL that are not filed, have a drawing number, and sit in a department in scope
(feed setting `departments`; empty = every department). One item per drawing number (trimmed), the best priority
winning:
1. the VISUAL rev changed since the last lookup (`drawing_lookups.visual_rev_seen`), or a pending feed change
order waits for a drawing (the item is the CO's target drawing and rev; at most every 15 minutes);
2. drafts still needing a drawing (`drawing_state = 'needed'`, or never looked up), unless a failed fetch set a
later `next_check_at`;
3. failed lookups whose backoff ran out (`ok = false and next_check_at <= now`);
4. rechecks due (`checked_at` older than `drawing_recheck_hours`).
A packet with a hand drawing joins only through (1) or (4) once the feed has taken the hand drawing off; while
`drawing_state = 'hand'` it is skipped.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
BACKOFF_FIRST = timedelta(minutes=15)
BACKOFF_MAX = timedelta(hours=24)
CO_RECHECK = timedelta(minutes=15) # a change order's target drawing is looked up at most this often
QUEUE_SQL = """
WITH own AS (
SELECT p.id AS packet_id, btrim(p.drawing) AS dwg, coalesce(btrim(p.rev), '') AS visual_rev, NULL::bigint AS co_id,
CASE
WHEN d.dwg IS NOT NULL AND d.checked_at IS NOT NULL
AND d.visual_rev_seen IS DISTINCT FROM coalesce(btrim(p.rev), '') THEN 1
WHEN p.status = 'draft' AND (d.dwg IS NULL OR p.drawing_state = 'needed')
AND (d.next_check_at IS NULL OR d.next_check_at <= %(now)s) THEN 2
WHEN d.ok = false AND (d.next_check_at IS NULL OR d.next_check_at <= %(now)s) THEN 3
WHEN d.checked_at IS NULL OR d.checked_at < %(recheck_before)s THEN 4
END AS priority,
d.checked_at
FROM packets p
LEFT JOIN drawing_lookups d ON d.dwg = btrim(p.drawing)
WHERE p.removed_at IS NULL AND p.source_kind = 'visual' AND p.status <> 'filed'
AND coalesce(btrim(p.drawing), '') <> '' AND p.drawing_state <> 'hand'
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
),
co AS (
SELECT p.id AS packet_id, btrim(c.waiting->>'dwg') AS dwg, coalesce(btrim(c.waiting->>'rev'), '') AS visual_rev,
c.id AS co_id, 1 AS priority, d.checked_at
FROM change_orders c
JOIN packets p ON p.id = c.packet_id
LEFT JOIN drawing_lookups d ON d.dwg = btrim(c.waiting->>'dwg')
WHERE c.review = 'pending' AND c.origin IN ('visual', 'drawing') AND c.waiting IS NOT NULL
AND jsonb_typeof(c.waiting) = 'object' AND coalesce(btrim(c.waiting->>'dwg'), '') <> ''
AND p.removed_at IS NULL AND p.status NOT IN ('draft', 'filed')
AND (d.checked_at IS NULL OR d.checked_at < %(co_before)s)
AND (d.ok IS NOT false OR d.next_check_at IS NULL OR d.next_check_at <= %(now)s)
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
),
items AS (
SELECT * FROM own WHERE priority IS NOT NULL
UNION ALL
SELECT * FROM co
),
best AS (
SELECT DISTINCT ON (dwg) packet_id, dwg, visual_rev, priority, co_id, checked_at
FROM items
ORDER BY dwg, priority, co_id NULLS LAST, packet_id
)
SELECT packet_id, dwg, visual_rev, priority, co_id
FROM best
ORDER BY priority, checked_at NULLS FIRST, dwg
"""
@dataclass(frozen=True, slots=True)
class QueueItem:
packet_id: int
dwg: str
visual_rev: str
priority: int
co_id: int | None
def backoff(fail_count: int) -> timedelta:
"""15 minutes x 2^(fail_count - 1), at most 24 hours. fail_count counts this failure (1 = first)."""
n = max(0, int(fail_count) - 1)
if n >= 10:
return BACKOFF_MAX
return min(BACKOFF_FIRST * (2**n), BACKOFF_MAX)
def params(feed_settings: Any, now: datetime) -> dict[str, Any]:
hours = int(getattr(feed_settings, "drawing_recheck_hours", 4))
depts = [str(d) for d in (getattr(feed_settings, "departments", None) or [])]
return {
"now": now,
"recheck_before": now - timedelta(hours=hours),
"co_before": now - CO_RECHECK,
"depts": depts,
}
def queue(conn: Any, feed_settings: Any, now: datetime, *, limit: int | None = None) -> list[QueueItem]:
"""The ordered lookup list. `limit` cuts it (the job passes the budget)."""
sql = QUEUE_SQL
p = params(feed_settings, now)
if limit is not None:
sql += " LIMIT %(limit)s"
p["limit"] = max(0, int(limit))
rows = conn.execute(sql, p).fetchall()
return [
QueueItem(int(r[0]), str(r[1]), str(r[2] or ""), int(r[3]), None if r[4] is None else int(r[4])) for r in rows
]
+1
View File
@@ -0,0 +1 @@
"""The VISUAL feed: settings, the read, the cache, candidates, fingerprints and the apply."""
File diff suppressed because it is too large Load Diff
+564
View File
@@ -0,0 +1,564 @@
"""The VISUAL cache in PostgreSQL: one batch per read, the rows and material lines as VISUAL last showed them,
the orders and their links, labor per operation, and a change row for everything VISUAL added, changed, dropped
or brought back.
`write_batch` runs in one transaction. Rows and materials go in through COPY into a temp table, then an upsert
keyed on the typed key (wo_base, lot, split, sub, seq[, piece]), all text but seq and piece. Rows in play that this
read did not return get `gone_batch`, unless the batch is suspect: no window rows where the previous batch had
some, or more than 5 tracked keys missing and more than 10 % of them.
The apply reads back from here (`rows_for_apply`, `orders`, `labor_hours`), so it works on one consistent snapshot
and keeps the last good orders when this run's order read failed.
"""
from __future__ import annotations
import hashlib
from collections.abc import Collection, Iterable, Mapping
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import Any
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from .fingerprint import canonical, norm_decimal
from .materials import material_from_row
from .types import FeedData, Material, Order, Row, WoKey, num
ROW_COLS = (
"wo_base",
"lot",
"split",
"sub",
"seq",
"res",
"dwg",
"rev",
"ps",
"descr",
"qty",
"tgt",
"rel",
"want",
"st",
"part",
"barcode",
)
MAT_COLS = (
"wo_base",
"lot",
"split",
"sub",
"seq",
"piece",
"part",
"qty",
"issued",
"whs",
"loc",
"st",
"istat",
"dwg",
"descr",
"comm",
"cons",
)
SUSPECT_MISSING_MIN = 5
SUSPECT_MISSING_SHARE = Decimal("0.10")
@dataclass
class Batch:
id: int
suspect: bool
counts: dict[str, Any] = field(default_factory=dict)
reason: str = ""
# ---------------------------------------------------------------- row dicts and hashes
def _d(v: Any) -> str | None:
return v.isoformat() if isinstance(v, date) else None
def row_doc(r: Row) -> dict[str, Any]:
"""The row as a change-row dict: text values, numbers as text, dates ISO."""
return {
"wo": r.wo,
"lot": r.lot,
"split": r.split,
"sub": r.sub,
"seq": r.seq,
"res": r.res,
"dwg": r.dwg,
"rev": r.rev,
"ps": r.ps,
"desc": r.desc,
"qty": norm_decimal(r.qty),
"tgt": norm_decimal(r.tgt),
"rel": _d(r.rel),
"want": _d(r.want),
"st": r.st,
"part": r.part,
"barcode": r.barcode,
}
def mat_doc(m: Material) -> dict[str, Any]:
return {
"wo": m.wo,
"lot": m.lot,
"split": m.split,
"sub": m.sub,
"seq": m.seq,
"piece": m.piece,
"part": m.part,
"qty": norm_decimal(m.qty),
"issued": norm_decimal(m.issued),
"whs": m.whs,
"loc": m.loc,
"st": m.st,
"istat": m.istat,
"dwg": m.dwg,
"desc": m.desc,
"comm": m.comm,
"cons": m.cons,
}
def doc_hash(doc: Mapping[str, Any]) -> bytes:
return hashlib.sha256(canonical(dict(doc))).digest()
# SQL that rebuilds the same dict from a cached row, for the change rows' `before`.
_ROW_BEFORE = (
"jsonb_build_object('wo', v.wo_base, 'lot', v.lot, 'split', v.split, 'sub', v.sub, 'seq', v.seq, 'res', v.res,"
" 'dwg', v.dwg, 'rev', v.rev, 'ps', v.ps, 'desc', v.descr, 'qty', v.qty::text, 'tgt', v.tgt::text,"
" 'rel', v.rel, 'want', v.want, 'st', v.st, 'part', v.part, 'barcode', v.barcode)"
)
_MAT_BEFORE = (
"jsonb_build_object('wo', v.wo_base, 'lot', v.lot, 'split', v.split, 'sub', v.sub, 'seq', v.seq,"
" 'piece', v.piece, 'part', v.part, 'qty', v.qty::text, 'issued', v.issued::text, 'whs', v.whs, 'loc', v.loc,"
" 'st', v.st, 'istat', v.istat, 'dwg', v.dwg, 'desc', v.descr, 'comm', v.comm, 'cons', v.cons)"
)
def _key_arrays(keys: Iterable[WoKey]) -> tuple[list[str], list[str], list[str], list[str]]:
ks = list(dict.fromkeys(keys))
return [k.base for k in ks], [k.lot for k in ks], [k.split for k in ks], [k.sub for k in ks]
_KEYS_UNNEST = "unnest(%s::text[], %s::text[], %s::text[], %s::text[]) AS k(b, l, sp, su)"
# ---------------------------------------------------------------- write
def is_suspect(window_rows: int, prev_window_rows: int | None, missing: int, tracked: int) -> str:
"""The reason a batch is suspect, or ''."""
if window_rows == 0 and (prev_window_rows or 0) > 0:
return f"the window read returned nothing (the previous read had {prev_window_rows})"
if missing > SUSPECT_MISSING_MIN and Decimal(missing) > SUSPECT_MISSING_SHARE * tracked:
return f"{missing} of {tracked} tracked work orders missing"
return ""
def write_batch(conn: Any, run_id: int | None, data: FeedData, source_desc: str, window: tuple[date, date]) -> Batch:
"""Record one read: the batch row, rows, materials, orders, labor and the change rows. One transaction."""
since, until = window
with conn.transaction():
prev = conn.execute("SELECT window_rows FROM visual_batches ORDER BY id DESC LIMIT 1").fetchone()
missing = len(set(data.tracked_missing))
tracked_n = len(set(data.tracked)) or data.tracked_count
reason = is_suspect(data.window_count, prev[0] if prev else None, missing, tracked_n)
suspect = bool(reason)
server, _, database = source_desc.partition(" / ")
bid = int(
conn.execute(
"INSERT INTO visual_batches (run_id, server, database, site, since, until, window_rows, tracked_keys,"
" missing_keys, suspect) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
(
run_id,
str(data.meta.get("server") or server),
str(data.meta.get("database") or database) or None,
data.meta.get("site"),
since,
until,
data.window_count,
tracked_n,
missing,
suspect,
),
).fetchone()[0]
)
counts: dict[str, Any] = {"rows": len(data.rows), "suspect": int(suspect)}
if reason:
counts["suspect_reason"] = reason
counts.update(_write_rows(conn, bid, data, window, suspect))
if data.materials is not None:
counts.update(_write_materials(conn, bid, data, suspect))
counts["materials"] = len(data.materials)
if data.orders_by_base is not None and data.orders_by_project is not None:
counts["orders"] = _write_orders(conn, bid, data)
if data.labor is not None:
counts["labor_legs"] = _write_labor(conn, data)
counts["labor_read"] = 1
conn.execute("UPDATE visual_batches SET counts = %s WHERE id = %s", (Jsonb(counts), bid))
return Batch(id=bid, suspect=suspect, counts=counts, reason=reason)
def _write_rows(conn: Any, bid: int, data: FeedData, window: tuple[date, date], suspect: bool) -> dict[str, int]:
uniq: dict[tuple[WoKey, int], Row] = {}
for r in data.rows:
uniq[(r.key, r.seq)] = r
conn.execute(
"CREATE TEMP TABLE t_rows (wo_base text, lot text, split text, sub text, seq int, res text, dwg text,"
" rev text, ps text, descr text, qty numeric, tgt numeric, rel date, want date, st text, part text,"
" barcode text, row_hash bytea, doc jsonb) ON COMMIT DROP"
)
with (
conn.cursor() as cur,
cur.copy(
"COPY t_rows (wo_base, lot, split, sub, seq, res, dwg, rev, ps, descr, qty, tgt, rel, want, st, part,"
" barcode, row_hash, doc) FROM STDIN"
) as cp,
):
for r in uniq.values():
doc = row_doc(r)
cp.write_row(
(
r.wo,
r.lot,
r.split,
r.sub,
r.seq,
r.res,
r.dwg,
r.rev,
r.ps,
r.desc,
r.qty,
r.tgt,
r.rel,
r.want,
r.st,
r.part,
r.barcode or None,
doc_hash(doc),
Jsonb(doc),
)
)
out: dict[str, int] = {}
join = (
"visual_rows v ON v.wo_base = t.wo_base AND v.lot = t.lot AND v.split = t.split AND v.sub = t.sub"
" AND v.seq = t.seq"
)
out["rows_added"] = conn.execute(
"INSERT INTO visual_row_changes (batch_id, wo_base, lot, split, sub, seq, kind, after)" # noqa: S608
f" SELECT %s, t.wo_base, t.lot, t.split, t.sub, t.seq, 'added', t.doc FROM t_rows t LEFT JOIN {join}"
" WHERE v.id IS NULL",
(bid,),
).rowcount
out["rows_returned"] = conn.execute(
"INSERT INTO visual_row_changes (batch_id, wo_base, lot, split, sub, seq, kind, before, after)" # noqa: S608
f" SELECT %s, t.wo_base, t.lot, t.split, t.sub, t.seq, 'returned', {_ROW_BEFORE}, t.doc FROM t_rows t"
f" JOIN {join} WHERE v.gone_batch IS NOT NULL",
(bid,),
).rowcount
out["rows_changed"] = conn.execute(
"INSERT INTO visual_row_changes (batch_id, wo_base, lot, split, sub, seq, kind, before, after)" # noqa: S608
f" SELECT %s, t.wo_base, t.lot, t.split, t.sub, t.seq, 'changed', {_ROW_BEFORE}, t.doc FROM t_rows t"
f" JOIN {join} WHERE v.gone_batch IS NULL AND v.row_hash <> t.row_hash",
(bid,),
).rowcount
conn.execute(
"INSERT INTO visual_rows (wo_base, lot, split, sub, seq, res, dwg, rev, ps, descr, qty, tgt, rel, want, st,"
" part, barcode, row_hash, first_batch, last_batch)"
" SELECT wo_base, lot, split, sub, seq, res, dwg, rev, ps, descr, qty, tgt, rel, want, st, part, barcode,"
" row_hash, %s, %s FROM t_rows"
" ON CONFLICT (wo_base, lot, split, sub, seq) DO UPDATE SET res = EXCLUDED.res, dwg = EXCLUDED.dwg,"
" rev = EXCLUDED.rev, ps = EXCLUDED.ps, descr = EXCLUDED.descr, qty = EXCLUDED.qty, tgt = EXCLUDED.tgt,"
" rel = EXCLUDED.rel, want = EXCLUDED.want, st = EXCLUDED.st, part = EXCLUDED.part,"
" barcode = EXCLUDED.barcode, row_hash = EXCLUDED.row_hash, last_batch = EXCLUDED.last_batch,"
" gone_batch = NULL",
(bid, bid),
)
out["rows_gone"] = 0
if not suspect:
b, lot, sp, su = _key_arrays(data.tracked)
out["rows_gone"] = conn.execute(
"WITH gone AS (UPDATE visual_rows v SET gone_batch = %s" # noqa: S608
" WHERE v.gone_batch IS NULL AND v.last_batch <> %s"
f" AND (EXISTS (SELECT 1 FROM {_KEYS_UNNEST} WHERE k.b = v.wo_base AND k.l = v.lot AND k.sp = v.split"
" AND k.su = v.sub) OR (v.st = 'F' AND v.rel BETWEEN %s AND %s)) RETURNING v.*)"
" INSERT INTO visual_row_changes (batch_id, wo_base, lot, split, sub, seq, kind, before)"
f" SELECT %s, v.wo_base, v.lot, v.split, v.sub, v.seq, 'gone', {_ROW_BEFORE} FROM gone v",
(bid, bid, b, lot, sp, su, window[0], window[1], bid),
).rowcount
return out
def _write_materials(conn: Any, bid: int, data: FeedData, suspect: bool) -> dict[str, int]:
uniq: dict[tuple[WoKey, int, int], Material] = {}
for m in data.materials or []:
uniq[(m.key, m.seq, m.piece)] = m
conn.execute(
"CREATE TEMP TABLE t_mats (wo_base text, lot text, split text, sub text, seq int, piece int, part text,"
" qty numeric, issued numeric, whs text, loc text, st text, istat int, dwg text, descr text, comm text,"
" cons text, mat_hash bytea, doc jsonb) ON COMMIT DROP"
)
with (
conn.cursor() as cur,
cur.copy(
"COPY t_mats (wo_base, lot, split, sub, seq, piece, part, qty, issued, whs, loc, st, istat, dwg, descr,"
" comm, cons, mat_hash, doc) FROM STDIN"
) as cp,
):
for m in uniq.values():
doc = mat_doc(m)
cp.write_row(
(
m.wo,
m.lot,
m.split,
m.sub,
m.seq,
m.piece,
m.part,
m.qty,
m.issued,
m.whs,
m.loc,
m.st,
m.istat,
m.dwg,
m.desc,
m.comm,
m.cons,
doc_hash(doc),
Jsonb(doc),
)
)
join = (
"visual_materials v ON v.wo_base = t.wo_base AND v.lot = t.lot AND v.split = t.split AND v.sub = t.sub"
" AND v.seq = t.seq AND v.piece = t.piece"
)
cols = "batch_id, wo_base, lot, split, sub, seq, piece, kind"
out: dict[str, int] = {}
out["mats_added"] = conn.execute(
f"INSERT INTO visual_material_changes ({cols}, after)" # noqa: S608
f" SELECT %s, t.wo_base, t.lot, t.split, t.sub, t.seq, t.piece, 'added', t.doc FROM t_mats t LEFT JOIN {join}"
" WHERE v.id IS NULL",
(bid,),
).rowcount
out["mats_returned"] = conn.execute(
f"INSERT INTO visual_material_changes ({cols}, before, after)" # noqa: S608
f" SELECT %s, t.wo_base, t.lot, t.split, t.sub, t.seq, t.piece, 'returned', {_MAT_BEFORE}, t.doc"
f" FROM t_mats t JOIN {join} WHERE v.gone_batch IS NOT NULL",
(bid,),
).rowcount
out["mats_changed"] = conn.execute(
f"INSERT INTO visual_material_changes ({cols}, before, after)" # noqa: S608
f" SELECT %s, t.wo_base, t.lot, t.split, t.sub, t.seq, t.piece, 'changed', {_MAT_BEFORE}, t.doc"
f" FROM t_mats t JOIN {join} WHERE v.gone_batch IS NULL AND v.mat_hash <> t.mat_hash",
(bid,),
).rowcount
conn.execute(
"INSERT INTO visual_materials (wo_base, lot, split, sub, seq, piece, part, qty, issued, whs, loc, st, istat,"
" dwg, descr, comm, cons, mat_hash, first_batch, last_batch)"
" SELECT wo_base, lot, split, sub, seq, piece, part, qty, issued, whs, loc, st, istat, dwg, descr, comm,"
" cons, mat_hash, %s, %s FROM t_mats"
" ON CONFLICT (wo_base, lot, split, sub, seq, piece) DO UPDATE SET part = EXCLUDED.part, qty = EXCLUDED.qty,"
" issued = EXCLUDED.issued, whs = EXCLUDED.whs, loc = EXCLUDED.loc, st = EXCLUDED.st,"
" istat = EXCLUDED.istat, dwg = EXCLUDED.dwg, descr = EXCLUDED.descr, comm = EXCLUDED.comm,"
" cons = EXCLUDED.cons, mat_hash = EXCLUDED.mat_hash, last_batch = EXCLUDED.last_batch, gone_batch = NULL",
(bid, bid),
)
out["mats_gone"] = 0
if not suspect:
bases = sorted({r.wo for r in data.rows} | {m.wo for m in data.materials or []})
out["mats_gone"] = conn.execute(
"WITH gone AS (UPDATE visual_materials v SET gone_batch = %s WHERE v.gone_batch IS NULL" # noqa: S608
" AND v.last_batch <> %s AND v.wo_base = ANY(%s) RETURNING v.*)"
f" INSERT INTO visual_material_changes ({cols}, before)"
f" SELECT %s, v.wo_base, v.lot, v.split, v.sub, v.seq, v.piece, 'gone', {_MAT_BEFORE} FROM gone v",
(bid, bid, bases, bid),
).rowcount
return out
def _write_orders(conn: Any, bid: int, data: FeedData) -> int:
by_base = data.orders_by_base or {}
by_project = data.orders_by_project or {}
every: dict[str, Order] = {}
for lists in (by_base, by_project):
for orders in lists.values():
for o in orders:
every.setdefault(o.cust_order_id, o)
for o in every.values():
conn.execute(
"INSERT INTO visual_orders (cust_order_id, status, customer_id, customer_name, customer_po_ref,"
" order_date, desired_ship_date, promise_date, last_batch) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)"
" ON CONFLICT (cust_order_id) DO UPDATE SET status = EXCLUDED.status, customer_id = EXCLUDED.customer_id,"
" customer_name = EXCLUDED.customer_name, customer_po_ref = EXCLUDED.customer_po_ref,"
" order_date = coalesce(EXCLUDED.order_date, visual_orders.order_date),"
" desired_ship_date = EXCLUDED.desired_ship_date, promise_date = EXCLUDED.promise_date,"
" last_batch = EXCLUDED.last_batch",
(
o.cust_order_id,
o.status,
o.customer_id,
o.customer_name,
o.customer_po_ref,
o.order_date,
o.desired_ship_date,
o.promise_date,
bid,
),
)
bases = sorted({r.wo for r in data.rows} | set(by_base))
projects = sorted({r.wo.partition("-")[0] or r.wo for r in data.rows} | set(by_project))
conn.execute(
"DELETE FROM visual_order_links WHERE (kind = 'base' AND key = ANY(%s))"
" OR (kind = 'project' AND key = ANY(%s))",
(bases, projects),
)
n = 0
for kind, lists in (("base", by_base), ("project", by_project)):
for key, orders in lists.items():
seen: set[str] = set()
for rank, o in enumerate(orders):
if o.cust_order_id in seen:
continue
seen.add(o.cust_order_id)
conn.execute(
"INSERT INTO visual_order_links (kind, key, cust_order_id, rank, lines, top_jobs, last_batch)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
(kind, key, o.cust_order_id, rank, o.lines, o.top_jobs, bid),
)
n += 1
return len(every)
def _write_labor(conn: Any, data: FeedData) -> int:
n = 0
for lg in data.labor or []:
conn.execute(
"INSERT INTO visual_labor (wo_base, lot, split, sub, seq, hours, tickets, last_ticket, observed_at)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, now())"
" ON CONFLICT (wo_base, lot, split, sub, seq) DO UPDATE SET hours = EXCLUDED.hours,"
" tickets = EXCLUDED.tickets, last_ticket = EXCLUDED.last_ticket, observed_at = now()",
(lg.wo, lg.lot, lg.split, lg.sub, lg.seq, lg.hours, lg.tickets, lg.last_ticket),
)
n += 1
return n
# ---------------------------------------------------------------- read back
def row_from_cache(r: Mapping[str, Any]) -> Row:
return Row(
wo=r["wo_base"],
lot=r["lot"],
split=r["split"],
sub=r["sub"],
seq=int(r["seq"]),
res=r["res"],
dwg=r["dwg"],
rev=r["rev"],
ps=r["ps"],
desc=r["descr"],
qty=num(r["qty"]),
tgt=num(r["tgt"]),
rel=r["rel"],
want=r["want"],
st=r["st"],
part=r["part"],
barcode=r["barcode"] or "",
)
def rows_for_apply(conn: Any, keys_in_play: Collection[WoKey]) -> tuple[list[Row], list[Material], set[WoKey]]:
"""Live cached rows and material lines of every work order (base and lot) among these keys, so folded
operations and planner instructions of the same work order come along. material_legs = legs with live
material lines whose status is F or R."""
pairs = sorted({(k.base, k.lot) for k in keys_in_play})
if not pairs:
return [], [], set()
bases = [p[0] for p in pairs]
lots = [p[1] for p in pairs]
sql_join = "JOIN unnest(%s::text[], %s::text[]) AS k(b, l) ON v.wo_base = k.b AND v.lot = k.l"
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
f"SELECT v.* FROM visual_rows v {sql_join} WHERE v.gone_batch IS NULL" # noqa: S608
" ORDER BY v.wo_base, v.lot, v.split, v.sub, v.seq",
(bases, lots),
).fetchall()
mats = cur.execute(
f"SELECT v.* FROM visual_materials v {sql_join} WHERE v.gone_batch IS NULL" # noqa: S608
" ORDER BY v.wo_base, v.lot, v.split, v.sub, v.seq, v.piece",
(bases, lots),
).fetchall()
materials = [material_from_row(m) for m in mats]
legs = {m.key for m in materials if m.st in ("F", "R")}
return [row_from_cache(r) for r in rows], materials, legs
def orders(
conn: Any, *, bases: Iterable[str] | None = None, projects: Iterable[str] | None = None
) -> tuple[dict[str, list[Order]], dict[str, list[Order]]]:
"""The cached order links as (by_base, by_project), in rank order. Without filters: every link."""
params: list[Any] = []
where = ""
if bases is not None or projects is not None:
where = " WHERE (l.kind = 'base' AND l.key = ANY(%s)) OR (l.kind = 'project' AND l.key = ANY(%s))"
params = [sorted(set(bases or ())), sorted(set(projects or ()))]
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
"SELECT l.kind, l.key, l.rank, l.lines, l.top_jobs, o.* FROM visual_order_links l" # noqa: S608
f" JOIN visual_orders o ON o.cust_order_id = l.cust_order_id{where} ORDER BY l.kind, l.key, l.rank",
params,
).fetchall()
by_base: dict[str, list[Order]] = {}
by_project: dict[str, list[Order]] = {}
for r in rows:
o = Order(
cust_order_id=r["cust_order_id"],
status=r["status"],
customer_id=r["customer_id"],
customer_name=r["customer_name"],
customer_po_ref=r["customer_po_ref"],
order_date=r["order_date"],
desired_ship_date=r["desired_ship_date"],
promise_date=r["promise_date"],
lines=list(r["lines"] or []),
top_jobs=list(r["top_jobs"] or []),
)
(by_base if r["kind"] == "base" else by_project).setdefault(r["key"], []).append(o)
return by_base, by_project
def labor_hours(conn: Any) -> dict[tuple[WoKey, int], Decimal]:
"""Cached ticket hours per (leg, seq)."""
out: dict[tuple[WoKey, int], Decimal] = {}
for r in conn.execute("SELECT wo_base, lot, split, sub, seq, hours FROM visual_labor"):
out[(WoKey(r[0], r[1], r[2], r[3]), int(r[4]))] = num(r[5])
return out
def last_labor_read(conn: Any) -> Any:
"""When labor was last read (a batch that carried labor), or None."""
r = conn.execute("SELECT max(started_at) FROM visual_batches WHERE counts ? 'labor_read'").fetchone()
return r[0] if r else None
def finish_batch(conn: Any, batch_id: int, counts: Mapping[str, Any]) -> None:
"""Merge the apply's counts into the batch row and set finished_at."""
conn.execute(
"UPDATE visual_batches SET finished_at = now(), counts = counts || %s WHERE id = %s",
(Jsonb(dict(counts)), batch_id),
)
+243
View File
@@ -0,0 +1,243 @@
"""VISUAL rows to packet candidates: the old visBuild, typed.
One candidate = unit + resource + drawing, holding every work order operation on that drawing. The key is
`f"{unit}|{res}|{dwg or '(no drawing)'}"`, which is `packets.visual_key`. Project = the work order base before its
first dash; unit = project + the lot zero-padded to 3. A non-numeric lot (7A) makes no candidate, as the old
build: it is counted in `Built.skipped_lots`. Resources route through the resource map: a shop department code,
`fold` (rides along on the packet of the same work order and lot), `ignore`, or no entry (counted as unmapped).
`MAT_*` with no map entry folds.
"""
from __future__ import annotations
from collections.abc import Collection, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from ..text import RX_LOT, RX_PROJECT
from . import materials as mats
from .materials import BomLine
from .types import Material, Row, WoKey
FOLD = "fold"
IGNORE = "ignore"
NO_DRAWING = "(no drawing)"
__all__ = [
"FOLD",
"IGNORE",
"NO_DRAWING",
"BomLine",
"Built",
"Candidate",
"Folded",
"Leg",
"build",
"project_of",
"route",
"unit_number",
]
@dataclass(slots=True)
class Leg:
"""One work order operation on the packet."""
key: WoKey
seq: int
op: str
qty: Decimal
tgt: Decimal
dwg: str
rev: str
partspecs: str
st: str
@dataclass(slots=True)
class Folded:
key: WoKey
seq: int
op: str
tgt: Decimal
@dataclass(slots=True)
class Candidate:
key: str # f"{unit}|{res}|{dwg or '(no drawing)'}" == packets.visual_key
project: str
unit: str
lot: str
dept: str
op: str
drawing: str
rev: str
title: str
legs: list[Leg]
folded: list[Folded]
partspecs: list[str]
rel: date | None
want: date | None
bom: list[BomLine] | None # None when materials are unknown for this candidate
statuses: dict[WoKey, str] # header status per leg key
base: str # the first leg's wo base, for the order lookup
in_window: bool # at least one leg came from the window read
@property
def mat_known(self) -> bool:
return self.bom is not None
@property
def leg_keys(self) -> set[WoKey]:
return {lg.key for lg in self.legs}
@dataclass(slots=True)
class Built:
candidates: dict[str, Candidate]
unmapped: dict[str, int]
ignored: list[Row] # rows below the go-live date (not tracked)
skipped_lots: dict[str, int] = field(default_factory=dict) # f"{wo}/{lot}" -> rows (non-numeric lot)
def route(res: str, resource_map: Mapping[str, str]) -> str | None:
"""'fold' | 'ignore' | a dept code | None (no map entry). MAT_* with no entry folds."""
if res in resource_map:
return resource_map[res]
if res.upper().startswith("MAT_"):
return FOLD
return None
def project_of(wo: str) -> str:
"""'L489-05-5GA' -> 'L489' (upper-cased); a base with no dash is its own project."""
head = wo.partition("-")[0].strip()
return (head or wo).upper()
def unit_number(project: str, lot: str) -> str | None:
"""project + lot zero-padded to 3 when the lot is numeric; None for a non-numeric lot (the old unit_no)."""
lot = lot.strip()
if not lot.isdigit():
return None
padded = lot.zfill(3)
if not RX_LOT.match(padded):
return None
return project + padded
def _rev_wins(new: str, cur: str) -> bool:
"""The old rule: a numeric rev higher than the current wins; anything wins over an empty current."""
if not new or new == cur:
return False
if not cur:
return True
try:
return float(new) > float(cur)
except ValueError:
return False
def build(
rows: Sequence[Row],
materials: Iterable[Material] | None,
material_legs: Collection[WoKey] | None,
*,
resource_map: Mapping[str, str],
window: tuple[date, date],
golive: date | None,
departments: Collection[str],
tracked_keys: Collection[WoKey],
today: date | None = None,
) -> Built:
"""Group rows into candidates. See the module docstring and phase3-design "Candidates"."""
del today # kept for the contract signature; the window already carries it
tracked = set(tracked_keys)
depts = set(departments)
by_wo_lot: dict[tuple[str, str], list[Row]] = {}
for r in rows:
by_wo_lot.setdefault((r.wo, r.lot), []).append(r)
cands: dict[str, Candidate] = {}
unmapped: dict[str, int] = {}
ignored: list[Row] = []
skipped_lots: dict[str, int] = {}
leg_seen: dict[str, set[tuple[WoKey, int]]] = {}
fold_seen: dict[str, set[tuple[WoKey, int]]] = {}
for r in rows:
target = route(r.res, resource_map)
if target in (FOLD, IGNORE):
continue
is_tracked = r.key in tracked
in_window = r.st == "F" and r.rel is not None and window[0] <= r.rel <= window[1]
if not is_tracked and not in_window:
continue
if target is None:
unmapped[r.res] = unmapped.get(r.res, 0) + 1
continue
if depts and target not in depts:
continue
if not is_tracked and golive is not None and r.rel is not None and r.rel < golive:
ignored.append(r)
continue
project = project_of(r.wo)
unit = unit_number(project, r.lot) if RX_PROJECT.match(project) else None
if unit is None:
k = f"{r.wo}/{r.lot}"
skipped_lots[k] = skipped_lots.get(k, 0) + 1
continue
key = f"{unit}|{r.res}|{r.dwg or NO_DRAWING}"
c = cands.get(key)
if c is None:
c = Candidate(
key=key,
project=project,
unit=unit,
lot=unit[len(project) :],
dept=target,
op=r.res,
drawing=r.dwg,
rev=r.rev,
title=r.desc,
legs=[],
folded=[],
partspecs=[],
rel=r.rel,
want=r.want,
bom=None,
statuses={},
base=r.wo,
in_window=False,
)
cands[key] = c
leg_seen[key] = set()
fold_seen[key] = set()
c.in_window = c.in_window or in_window
if (r.key, r.seq) not in leg_seen[key]:
leg_seen[key].add((r.key, r.seq))
c.legs.append(
Leg(key=r.key, seq=r.seq, op=r.res, qty=r.qty, tgt=r.tgt, dwg=r.dwg, rev=r.rev, partspecs=r.ps, st=r.st)
)
c.statuses[r.key] = r.st
if r.rel is not None and (c.rel is None or r.rel < c.rel):
c.rel = r.rel
if r.want is not None and (c.want is None or r.want > c.want):
c.want = r.want
if _rev_wins(r.rev, c.rev):
c.rev = r.rev
for o in by_wo_lot.get((r.wo, r.lot), []):
if route(o.res, resource_map) == FOLD and (o.key, o.seq) not in fold_seen[key]:
fold_seen[key].add((o.key, o.seq))
c.folded.append(Folded(key=o.key, seq=o.seq, op=o.res, tgt=o.tgt))
if o.ps and o.ps not in c.partspecs:
c.partspecs.append(o.ps)
if material_legs is not None:
idx = mats.index(materials)
known = set(material_legs)
for c in cands.values():
if any(lg.key in known for lg in c.legs):
c.bom = mats.bom_for(c.legs, idx)
return Built(candidates=cands, unmapped=unmapped, ignored=ignored, skipped_lots=skipped_lots)
+382
View File
@@ -0,0 +1,382 @@
"""Change orders opened by the feed (origin visual) and by the drawing job (origin drawing). The old visCoNeed,
coAutoChanges and visDiff.
At most one pending feed change order per packet, whatever its origin (visual or drawing): when one is pending
it is re-aimed (reason, vis, waiting, staged, target, minor) instead of a second one being opened. A drawing
re-release that meets a pending visual change order stages the new drawing on it and adds its line to the reason.
The required departments are filled at approval (the API's co_touched), not here.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, date, datetime
from typing import TYPE_CHECKING, Any
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from ..domain.pipeline import next_counter
from ..text import LIMITS, clean_text
from .fingerprint import bom_sig, drawing_key, leg_key, norm_decimal
from .writes import SysWrite, packets_jsonable
if TYPE_CHECKING:
from .candidates import Candidate
FEED_ORIGINS = ("visual", "drawing")
ECN = {"visual": "VISUAL", "drawing": "M-FILES"}
@dataclass
class CoResult:
co_id: int
co_no: int
fresh: bool
reaimed: bool
def _now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
def pending_visual(conn: Any, packet_id: int, *, lock: bool = False) -> dict[str, Any] | None:
"""The single pending change order with origin visual or drawing, or None."""
sql = (
"SELECT *, origin::text AS origin, review::text AS review FROM change_orders"
" WHERE packet_id = %s AND origin IN ('visual', 'drawing') AND review = 'pending' ORDER BY co_no DESC LIMIT 1"
)
if lock:
sql += " FOR UPDATE"
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(sql, (packet_id,)).fetchone()
return dict(r) if r else None
def last_feed_co(conn: Any, packet_id: int) -> dict[str, Any] | None:
"""The newest change order with origin visual, any review."""
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
"SELECT *, origin::text AS origin, review::text AS review FROM change_orders"
" WHERE packet_id = %s AND origin = 'visual' ORDER BY co_no DESC LIMIT 1",
(packet_id,),
).fetchone()
return dict(r) if r else None
def _j(value: Any) -> Any:
return None if value is None else Jsonb(packets_jsonable(value))
def _same_target(old_vis: Mapping[str, Any] | None, vis: Mapping[str, Any]) -> bool:
o = old_vis or {}
return drawing_key(o.get("to_dwg")) == drawing_key(vis.get("to_dwg")) and str(o.get("to_rev") or "") == str(
vis.get("to_rev") or ""
)
def open_or_reaim(
sw: SysWrite,
*,
origin: str,
reason: str,
vis: dict[str, Any],
waiting: dict[str, Any] | None,
staged: dict[str, Any] | None,
target_fp: str | None,
minor: bool,
required: list[str] | None = None,
) -> CoResult:
"""Open the packet's feed change order, or re-aim the pending one. Call it inside `system_write`."""
del required # filled at approval by the API (co_touched)
if origin not in FEED_ORIGINS:
raise ValueError(f"origin must be visual or drawing, not {origin!r}")
conn = sw.conn
reason = clean_text(reason, LIMITS["reason"])
pend = pending_visual(conn, sw.id, lock=True)
if pend is not None:
old_vis = dict(pend.get("vis") or {})
moved = list(old_vis.get("moved") or [])
if origin == "drawing" and pend["origin"] == "visual":
# A drawing re-release meets a pending VISUAL change: stage the drawing on it, keep its content.
new_vis = old_vis
new_vis["moved"] = [*moved, {"at": _now(), "rev": vis.get("to_rev"), "dwg": vis.get("to_dwg")}]
old_reason = str(pend.get("reason") or "")
new_reason = (
old_reason if reason in old_reason else clean_text(f"{old_reason} · {reason}", LIMITS["reason"])
)
conn.execute(
"UPDATE change_orders SET reason = %s, vis = %s, staged = coalesce(%s, staged),"
" waiting = CASE WHEN %s::jsonb IS NULL THEN waiting ELSE NULL END, row_version = row_version + 1"
" WHERE id = %s",
(new_reason, _j(new_vis), _j(staged), _j(staged), pend["id"]),
)
label = f"Change order {pend['co_no']}: drawing from M-Files staged"
else:
new_vis = dict(vis)
new_vis["moved"] = [*moved, {"at": _now(), "rev": vis.get("to_rev"), "dwg": vis.get("to_dwg")}]
keep = staged is None and pend.get("staged") is not None and _same_target(old_vis, vis)
new_staged = staged if staged is not None else (pend.get("staged") if keep else None)
new_waiting = None if (keep or staged is not None) else waiting
conn.execute(
"UPDATE change_orders SET origin = %s::co_origin, reason = %s, vis = %s, waiting = %s, staged = %s,"
" target_fp = %s, minor = %s, ecn = %s, row_version = row_version + 1 WHERE id = %s",
(
origin,
reason,
_j(new_vis),
_j(new_waiting),
_j(new_staged),
target_fp,
minor,
ECN[origin],
pend["id"],
),
)
label = (
f"Change order {pend['co_no']} updated: VISUAL changed again before Clerical approved"
if origin == "visual"
else f"Change order {pend['co_no']} updated: new drawing from M-Files"
)
sw.log(label, reason)
sw.changed("change_orders")
sw.detail.update({"co_no": pend["co_no"], "co_id": pend["id"], "reaimed": True})
return CoResult(co_id=int(pend["id"]), co_no=int(pend["co_no"]), fresh=False, reaimed=True)
co_no = next_counter(conn, sw.id, "co")
row = conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review, minor, ecn, reason, issued_by, vis, waiting,"
" staged, target_fp) VALUES (%s, %s, %s::co_origin, 'pending', %s, %s, %s, NULL, %s, %s, %s, %s) RETURNING id",
(sw.id, co_no, origin, minor, ECN[origin], reason, _j(vis), _j(waiting), _j(staged), target_fp),
).fetchone()
label = (
f"Change order {co_no}: change from VISUAL waiting on Clerical approval"
if origin == "visual"
else f"Change order {co_no}: new drawing from M-Files waiting on Clerical approval"
)
sw.log(label, reason)
sw.changed("change_orders")
sw.detail.update({"co_no": co_no, "co_id": int(row[0]), "fresh": True})
return CoResult(co_id=int(row[0]), co_no=co_no, fresh=True, reaimed=False)
def withdraw(sw: SysWrite, co_id: int, reason: str) -> None:
"""A pending feed change order VISUAL no longer asks for: review -> rejected by nobody, with the reason."""
reason = clean_text(reason, LIMITS["reason"])
r = sw.conn.execute(
"UPDATE change_orders SET review = 'rejected', rejected_at = now(), rejected_by = NULL, rejected_reason = %s,"
" row_version = row_version + 1 WHERE id = %s AND packet_id = %s AND review = 'pending' RETURNING co_no",
(reason, co_id, sw.id),
).fetchone()
if r is None:
sw.skip()
return
sw.log(f"Change order {r[0]} withdrawn", reason)
sw.changed("change_orders")
sw.detail.update({"co_no": int(r[0]), "co_id": co_id, "withdrawn": True})
# ---------------------------------------------------------------- the human diff (the old visDiff + matDiff)
def packet_view(conn: Any, packet_id: int) -> dict[str, Any]:
"""What the packet holds now, for `diff_text`: legs, rev, drawing, want, the VISUAL BOM and manual_bom."""
with conn.cursor(row_factory=dict_row) as cur:
p = cur.execute(
"SELECT rev, drawing, visual_want, visual, status::text AS status FROM packets WHERE id = %s",
(packet_id,),
).fetchone()
wos = cur.execute(
"SELECT wo_base, lot, split, sub, seq, qty, target_hrs FROM packet_work_orders WHERE packet_id = %s"
" ORDER BY sort, id",
(packet_id,),
).fetchall()
bom = cur.execute(
"SELECT coalesce(cl_item, item::text) AS item, part_id, qty, src FROM bom_lines WHERE packet_id = %s"
" ORDER BY sort, item",
(packet_id,),
).fetchall()
p = dict(p or {})
visual = p.get("visual") or {}
return {
"status": p.get("status"),
"rev": p.get("rev"),
"drawing": p.get("drawing"),
"want": p.get("visual_want"),
"legs": [dict(w) for w in wos],
"bom": [dict(b) for b in bom],
"manual_bom": bool(visual.get("manual_bom")),
}
def _n(v: Any) -> str:
return norm_decimal(v) or "0"
def _items(items: Sequence[str]) -> str:
head = ", ".join(items[:8])
return head + ("..." if len(items) > 8 else "")
def mat_diff(old: Sequence[Mapping[str, Any]], new: Sequence[Any]) -> list[str]:
"""The old matDiff: materials added, removed and changed, item by item."""
o = {str(b["item"]): b for b in old if b.get("src") == "visual"}
n = {b.item: b for b in new}
hand = sum(1 for b in old if b.get("src") != "visual")
if not o:
if not (hand or n):
return []
if hand:
return [f"BOM by hand ({hand} line{'' if hand == 1 else 's'}) replaced by VISUAL ({len(n)} lines)"]
return [f"materials: {len(n)} line{'' if len(n) == 1 else 's'} from VISUAL"]
add = [k for k in n if k not in o]
rem = [k for k in o if k not in n]
chg = [
k
for k in n
if k in o and (str(o[k].get("part_id") or "") != n[k].part_id or _n(o[k].get("qty")) != _n(n[k].qty))
]
d: list[str] = []
if add:
d.append(f"materials added: item{'s' if len(add) > 1 else ''} {_items(add)}")
if rem:
d.append(f"materials removed in VISUAL: item{'s' if len(rem) > 1 else ''} {_items(rem)}")
for k in chg[:8]:
a, b = o[k], n[k]
if str(a.get("part_id") or "") != b.part_id:
d.append(f"item {k}: {a.get('part_id') or '-'} -> {b.part_id or '-'}")
else:
d.append(f"item {k}: qty {_n(a.get('qty'))} -> {_n(b.qty)}")
if len(chg) > 8:
more = len(chg) - 8
d.append(f"{more} more material change{'' if more == 1 else 's'}")
return d
def diff_text(before: Mapping[str, Any], c: Candidate) -> list[str]:
"""Human lines for what VISUAL changed on a packet (`before` is `packet_view`)."""
d: list[str] = []
old = {
leg_key(w["wo_base"], w["lot"], w["split"], w["sub"], w["seq"]): w
for w in before.get("legs") or []
if w.get("seq") is not None
}
new_keys: set[str] = set()
for lg in c.legs:
k = leg_key(lg.key.base, lg.key.lot, lg.key.split, lg.key.sub, lg.seq)
new_keys.add(k)
o = old.get(k)
if o is None:
d.append(f"work order {lg.key.base} added")
continue
if _n(o.get("target_hrs")) != _n(lg.tgt):
d.append(f"{lg.key.base} target {_n(o.get('target_hrs'))} h -> {_n(lg.tgt)} h")
if _n(o.get("qty")) != _n(lg.qty):
d.append(f"{lg.key.base} qty {_n(o.get('qty'))} -> {_n(lg.qty)}")
for k, o in old.items():
if k not in new_keys:
d.append(f"work order {o['wo_base']} no longer in VISUAL")
if drawing_key(before.get("drawing")) != drawing_key(c.drawing) and c.drawing:
d.append(f"drawing {before.get('drawing') or '-'} -> {c.drawing}")
if str(before.get("rev") or "") != str(c.rev or ""):
d.append(f"drawing rev {before.get('rev') or '-'} -> {c.rev or '-'}")
want_old = before.get("want")
want_old_s = want_old.isoformat() if isinstance(want_old, date) else str(want_old or "")
want_new_s = c.want.isoformat() if c.want else ""
if want_old_s != want_new_s:
d.append(f"want date {want_old_s or '-'} -> {want_new_s or '-'}")
hand = bool(before.get("manual_bom")) and (before.get("status") == "draft" or not c.bom)
if c.bom is not None and not hand and bom_sig(view_bom(before)) != bom_sig(c.bom):
d.extend(mat_diff(before.get("bom") or [], c.bom))
return d
def view_bom(before: Mapping[str, Any]) -> list[dict[str, Any]]:
"""The packet's BOM in matSig shape. Hand lines get a prefix so they always differ from VISUAL's."""
out = []
for b in before.get("bom") or []:
item = str(b.get("item"))
out.append(
{
"item": item if b.get("src") == "visual" else f"dwg:{item}",
"part_id": b.get("part_id"),
"qty": b.get("qty"),
}
)
return out
def vis_for(
c: Candidate, fp_parts: Mapping[str, str], before: Mapping[str, Any], changes: list[str], target_fp: str
) -> dict[str, Any]:
"""The change order `vis` payload that approval applies."""
from_dwg = str(before.get("drawing") or "")
from_rev = str(before.get("rev") or "")
to_dwg = c.drawing or from_dwg
to_rev = c.rev or ""
dwg_change = (bool(to_rev) and to_rev != from_rev) or (
bool(c.drawing) and drawing_key(c.drawing) != drawing_key(from_dwg)
)
return {
"from_dwg": from_dwg,
"from_rev": from_rev,
"to_dwg": to_dwg,
"to_rev": to_rev,
"dwg_change": dwg_change,
"changes": [x for x in changes if not x.startswith("drawing rev ")],
"sig": target_fp,
"legs": legs_json(c),
"bom": [b.as_dict() for b in c.bom] if c.bom is not None else None,
"want": c.want.isoformat() if c.want else None,
"rel": c.rel.isoformat() if c.rel else None,
"folded": folded_json(c),
"partspecs": list(c.partspecs),
"fp_parts": dict(fp_parts),
}
def co_reason(vis: Mapping[str, Any]) -> str:
"""The change order reason, as the old coAutoChanges wrote it."""
if vis.get("dwg_change"):
fd, td = str(vis.get("from_dwg") or ""), str(vis.get("to_dwg") or "")
what = f"{fd} -> {td}" if fd and drawing_key(fd) != drawing_key(td) else td
return f"VISUAL changed the drawing: {what} Rev {vis.get('from_rev') or '?'} -> Rev {vis.get('to_rev')}"
other = list(vis.get("changes") or [])
more = f" (+{len(other) - 3} more)" if len(other) > 3 else ""
return f"VISUAL changed the packet: {'; '.join(other[:3])}{more}"
def legs_json(c: Candidate) -> list[dict[str, Any]]:
return [
{
"wo_base": lg.key.base,
"lot": lg.key.lot,
"split": lg.key.split,
"sub": lg.key.sub,
"seq": lg.seq,
"op": lg.op,
"qty": str(lg.qty),
"target_hrs": str(lg.tgt),
"dwg": lg.dwg,
"rev": lg.rev,
"partspecs": lg.partspecs,
"st": lg.st,
}
for lg in c.legs
]
def folded_json(c: Candidate) -> list[dict[str, Any]]:
return [
{
"wo_base": f.key.base,
"lot": f.key.lot,
"split": f.key.split,
"sub": f.key.sub,
"seq": f.seq,
"op": f.op,
"target_hrs": str(f.tgt),
}
for f in c.folded
]
+226
View File
@@ -0,0 +1,226 @@
"""Feed settings: one `settings` row with key `feed`, a JSON object read by the worker on every fire.
Unknown keys are ignored on load and missing keys take the default, so the row may be partial. A save
validates, bumps the row version, writes an audit row and NOTIFYs `ps_settings` so the worker reschedules.
"""
from __future__ import annotations
import json
import re
from datetime import date, datetime, time, timedelta
from typing import Any
from psycopg.types.json import Jsonb
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
from .. import audit
KEY = "feed"
SETTINGS_CHANNEL = "ps_settings"
_HHMM = re.compile(r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
_DEPT = re.compile(r"^[A-Za-z0-9]{1,10}$")
class SettingsError(ValueError):
"""A patch the model refuses. `errors` holds one short line per problem."""
def __init__(self, errors: list[str]):
super().__init__("; ".join(errors))
self.errors = errors
class FeedSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
interval_shop_minutes: int = Field(default=5, ge=1, le=1440)
interval_off_minutes: int = Field(default=30, ge=1, le=1440)
shop_days: list[int] = Field(default_factory=lambda: [0, 1, 2, 3, 4]) # Monday = 0, site time
shop_start: str = "06:00" # HH:MM site time
shop_end: str = "18:00"
window_back_days: int = Field(default=14, ge=0, le=120)
window_ahead_days: int = Field(default=30, ge=0, le=365)
golive_date: date | None = None # work orders released before this date are ignored
departments: list[str] = Field(default_factory=list) # shop codes; empty = every department the map routes to
drawing_recheck_hours: int = Field(default=4, ge=1, le=168)
drawing_budget: int = Field(default=60, ge=1, le=500)
drawing_interval_minutes: int = Field(default=30, ge=1, le=1440)
labor_tickets: bool = True # read labor tickets (at most every 30 minutes)
auto_release: bool = True
auto_release_max: int = Field(default=25, ge=0, le=500)
paused: bool = False # stops scheduled feed, drawing and auto-release runs; "run now" still works
@field_validator("shop_days")
@classmethod
def _days(cls, v: list[int]) -> list[int]:
if any(d < 0 or d > 6 for d in v):
raise ValueError("shop days are 0 (Monday) to 6 (Sunday)")
return sorted(set(v))
@field_validator("shop_start", "shop_end")
@classmethod
def _hhmm(cls, v: str) -> str:
v = v.strip()
if not _HHMM.match(v):
raise ValueError("a time is HH:MM, 00:00 to 23:59")
return v
@field_validator("departments")
@classmethod
def _depts(cls, v: list[str]) -> list[str]:
out: list[str] = []
for d in v:
d = str(d).strip()
if not _DEPT.match(d):
raise ValueError("a department is its short code, like 03")
if d not in out:
out.append(d)
return out
@model_validator(mode="after")
def _hours(self) -> FeedSettings:
if self.shop_start == self.shop_end:
raise ValueError("shop_start and shop_end must differ")
return self
FIELDS = tuple(FeedSettings.model_fields)
def _clock(hhmm: str) -> time:
h, m = hhmm.split(":")
return time(int(h), int(m))
def _errors(e: ValidationError) -> list[str]:
out = []
for err in e.errors():
loc = ".".join(str(x) for x in err.get("loc", ()))
msg = str(err.get("msg", "invalid")).removeprefix("Value error, ")
out.append(f"{loc}: {msg}" if loc else msg)
return out
def from_value(value: Any) -> FeedSettings:
"""The model from a stored value. A bad stored value keeps its good fields; the rest take the default."""
if not isinstance(value, dict):
return FeedSettings()
try:
return FeedSettings.model_validate(value)
except ValidationError:
good: dict[str, Any] = {}
for k in FIELDS:
if k not in value:
continue
try:
FeedSettings.model_validate({**good, k: value[k]})
except ValidationError:
continue
good[k] = value[k]
return FeedSettings.model_validate(good)
def load(conn: Any) -> FeedSettings:
"""The current feed settings; the defaults when the row is missing."""
row = conn.execute("SELECT value FROM settings WHERE key = %s", (KEY,)).fetchone()
return from_value(row[0] if row else None)
def load_versioned(conn: Any) -> tuple[FeedSettings, int, Any, Any]:
"""(settings, version, updated_at, updated_by); version 0 when the row is missing."""
row = conn.execute("SELECT value, version, updated_at, updated_by FROM settings WHERE key = %s", (KEY,)).fetchone()
if row is None:
return FeedSettings(), 0, None, None
return from_value(row[0]), int(row[1]), row[2], row[3]
def seed(conn: Any) -> bool:
"""Write the defaults when the row is missing. True when it wrote."""
cur = conn.execute(
"INSERT INTO settings (key, value, version, updated_at) VALUES (%s, %s, 1, now()) ON CONFLICT (key) DO NOTHING",
(KEY, Jsonb(FeedSettings().model_dump(mode="json"))),
)
return bool(cur.rowcount)
def validate_patch(current: FeedSettings, patch: dict[str, Any]) -> FeedSettings:
"""The settings after a partial change. Raises SettingsError, also for a key that is not a setting."""
unknown = sorted(k for k in patch if k not in FIELDS)
if unknown:
raise SettingsError([f"{k}: not a feed setting" for k in unknown])
try:
return FeedSettings.model_validate({**current.model_dump(mode="json"), **patch})
except ValidationError as e:
raise SettingsError(_errors(e)) from None
def save(conn: Any, *, who: Any, patch: dict[str, Any]) -> FeedSettings:
"""Validate and store a partial change. Raises SettingsError. A patch that changes nothing writes nothing.
`who` has user_id, session_id, role_snapshot, ip and it_reason (domain.projects.Who).
"""
with conn.transaction():
row = conn.execute("SELECT value FROM settings WHERE key = %s FOR UPDATE", (KEY,)).fetchone()
current = from_value(row[0] if row else None)
new = validate_patch(current, patch)
before, after = current.model_dump(mode="json"), new.model_dump(mode="json")
changed = {k: {"from": before[k], "to": after[k]} for k in FIELDS if before[k] != after[k]}
if not changed and row is not None:
return new
version = conn.execute(
"INSERT INTO settings (key, value, version, updated_at, updated_by) VALUES (%s, %s, 1, now(), %s)"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1,"
" updated_at = now(), updated_by = EXCLUDED.updated_by RETURNING version",
(KEY, Jsonb(after), who.user_id),
).fetchone()[0]
audit.append(
conn,
action="feed.settings_changed",
user_id=who.user_id,
session_id=getattr(who, "session_id", None),
role_snapshot=getattr(who, "role_snapshot", None),
subject_type="settings",
subject_id=KEY,
detail={"changed": changed},
it_reason=getattr(who, "it_reason", None),
ip=getattr(who, "ip", None),
)
notify_settings(conn, int(version))
return new
def notify_settings(conn: Any, version: int | None = None) -> None:
"""Tell the worker the settings changed. Inside a transaction it is delivered on commit."""
payload = json.dumps({"key": KEY, "version": version}, separators=(",", ":"))
conn.execute("SELECT ps_notify(%s, %s)", (SETTINGS_CHANNEL, payload))
# ---------------------------------------------------------------- derived values (pure)
def window(settings: FeedSettings, today: date) -> tuple[date, date]:
"""(since, until) for the released read, from the site-local date."""
return today - timedelta(days=settings.window_back_days), today + timedelta(days=settings.window_ahead_days)
def in_shop_hours(settings: FeedSettings, now_site: datetime) -> bool:
"""True on a shop day from shop_start up to shop_end, site time.
An end before the start spans midnight; the hours after midnight belong to the day the shift started.
"""
start, end = _clock(settings.shop_start), _clock(settings.shop_end)
t = now_site.time().replace(tzinfo=None)
day = now_site.weekday()
if start < end:
return day in settings.shop_days and start <= t < end
if t >= start:
return day in settings.shop_days
if t < end:
return (day - 1) % 7 in settings.shop_days
return False
def interval_seconds(settings: FeedSettings, now_site: datetime) -> int:
"""The feed's cadence now: the shop interval inside shop hours, the off interval outside."""
minutes = settings.interval_shop_minutes if in_shop_hours(settings, now_site) else settings.interval_off_minutes
return minutes * 60
+210
View File
@@ -0,0 +1,210 @@
"""The candidate fingerprint: what VISUAL says about a packet, hashed per part, to decide update, CO or nothing.
Parts (each the sha256 hex of its canonical JSON):
- major: `legs` (the sorted leg keys), `qty` (per leg), `rev`, `drawing` (upper-cased, spaces removed), `bom`
(sorted `[item, part_id, qty]`, the old matSig);
- minor: `tgt` (target hours per leg), `want` (the want date).
Never in it: rel, folded, partspecs, title, statuses, issued, whs, loc, orders, labor, dept, op. A candidate whose
materials are unknown (`bom is None`) has no `bom` part; `classify` skips a part the new side lacks and `merge`
carries the old one forward, so a failed materials read never looks like a BOM change.
"""
from __future__ import annotations
import hashlib
import json
import unicodedata
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .candidates import Candidate
MAJOR = ("legs", "qty", "rev", "drawing", "bom")
MINOR = ("tgt", "want")
PARTS = MAJOR + MINOR
# ---------------------------------------------------------------- normalisation
def norm_text(value: Any) -> str | None:
"""NFC, CRLF and CR to LF, trimmed; '' and None both None."""
if value is None:
return None
s = value if isinstance(value, str) else str(value)
s = unicodedata.normalize("NFC", s.replace("\r\n", "\n").replace("\r", "\n")).strip()
return s or None
def norm_id(value: Any) -> str | None:
"""An ID (base, lot, split, sub, part, drawing): normalised text, upper-cased. Stays text."""
s = norm_text(value)
return s.upper() if s is not None else None
def norm_decimal(value: Any) -> str | None:
"""A number as text with no exponent and no trailing zeros: 2, 2.0 and Decimal('2.00') are all '2'."""
if value is None or value == "":
return None
d = value if isinstance(value, Decimal) else Decimal(str(value))
if d.is_nan() or d.is_infinite():
return None
if d == 0:
return "0"
s = format(d.normalize(), "f")
if "." in s:
s = s.rstrip("0").rstrip(".")
return s
def normalise(value: Any) -> Any:
"""The canonical form of a JSON-like value."""
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, str):
return norm_text(value)
if isinstance(value, int | float | Decimal):
return norm_decimal(value)
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, Mapping):
return {str(k): normalise(v) for k, v in value.items()}
if isinstance(value, list | tuple | set | frozenset):
return [normalise(v) for v in value]
return norm_text(str(value))
def canonical(value: Any) -> bytes:
"""json.dumps(sort_keys, compact separators, ensure_ascii False) of the normalised value, UTF-8."""
return json.dumps(normalise(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def sha256_hex(value: Any) -> str:
return hashlib.sha256(canonical(value)).hexdigest()
def drawing_key(dwg: Any) -> str | None:
"""A drawing number for comparison: upper-cased, every whitespace removed."""
s = norm_text(dwg)
if s is None:
return None
s = "".join(s.split()).upper()
return s or None
def leg_key(base: Any, lot: Any, split: Any, sub: Any, seq: Any) -> str:
"""'BASE|LOT|SPLIT|SUB|SEQ' with the ID parts upper-cased (as text) and seq as an integer."""
parts = [norm_id(x) or "" for x in (base, lot, split, sub)]
return "|".join([*parts, str(int(seq)) if seq is not None and seq != "" else ""])
# ---------------------------------------------------------------- the fingerprint
@dataclass(frozen=True)
class Fingerprint:
full: str
parts: dict[str, str]
@dataclass(frozen=True)
class Change:
major: frozenset[str]
minor: frozenset[str]
@property
def changed(self) -> bool:
return bool(self.major or self.minor)
@property
def is_minor(self) -> bool:
return bool(self.minor) and not self.major
def part_values(c: Candidate) -> dict[str, Any]:
"""The normalised value of every part, before hashing (tests and the diff text read these)."""
legs = sorted(
(
[
norm_id(lg.key.base) or "",
norm_id(lg.key.lot) or "",
norm_id(lg.key.split) or "",
norm_id(lg.key.sub) or "",
int(lg.seq),
]
for lg in c.legs
),
key=lambda x: (x[0], x[1], x[2], x[3], x[4]),
)
qty: dict[str, Any] = {}
tgt: dict[str, Any] = {}
for lg in c.legs:
k = leg_key(lg.key.base, lg.key.lot, lg.key.split, lg.key.sub, lg.seq)
qty[k] = norm_decimal(lg.qty)
tgt[k] = norm_decimal(lg.tgt)
values: dict[str, Any] = {
"legs": legs,
"qty": qty,
"rev": norm_text(c.rev),
"drawing": drawing_key(c.drawing),
"tgt": tgt,
"want": c.want.isoformat() if c.want else None,
}
if c.bom is not None:
values["bom"] = bom_sig(c.bom)
return values
def bom_sig(bom: Any) -> list[list[Any]]:
"""The old matSig: sorted [item, part_id, qty] of every line (BomLine objects or dicts)."""
out: list[list[Any]] = []
for b in bom or []:
get = b.get if isinstance(b, Mapping) else lambda k, _b=b: getattr(_b, k, None)
out.append([norm_text(get("item")) or "", norm_id(get("part_id")) or "", norm_decimal(get("qty"))])
out.sort(key=lambda x: (x[0], x[1], x[2] or ""))
return out
def _full(parts: Mapping[str, str]) -> str:
return sha256_hex(dict(parts))
def compute(c: Candidate) -> Fingerprint:
parts = {name: sha256_hex(v) for name, v in part_values(c).items()}
return Fingerprint(full=_full(parts), parts=parts)
def merge(before: Mapping[str, str] | None, after: Fingerprint) -> Fingerprint:
"""Carry forward parts the new side does not know (the BOM when materials are unknown this run)."""
if not before:
return after
parts = dict(after.parts)
for name in PARTS:
if name not in parts and name in before:
parts[name] = str(before[name])
if parts == after.parts:
return after
return Fingerprint(full=_full(parts), parts=parts)
def classify(before: Mapping[str, str] | None, after: Mapping[str, str]) -> Change:
"""Which parts changed. A part the new side lacks (unknown this run) is skipped. No `before`: all changed."""
major: set[str] = set()
minor: set[str] = set()
for name in PARTS:
if name not in after:
continue
if before is not None and before.get(name) == after[name]:
continue
(major if name in MAJOR else minor).add(name)
return Change(frozenset(major), frozenset(minor))
+291
View File
@@ -0,0 +1,291 @@
"""A packet's BOM from VISUAL's material lines: the old matBom, typed.
Rules (Seth, 2026-10-08, as the old build):
- a line belongs to a leg when its work order key and operation seq match the leg and its drawing is blank or
equals the leg's drawing ignoring spaces and case;
- the same piece can come back more than once (one per header status): keep the best, R before F before U before
anything else;
- lines without a part are not BOM lines; a part-less line whose piece is a multiple of 10 is the group header of
the next nine pieces of the same work order (`grp`);
- the item is the piece number, suffixed `-<last dash part of the work order>` when the piece repeats;
- WIP sub-assemblies (commodity WIP or warehouse ASY) are listed, marked sub and n/a (no heat needed);
- `loc` is the warehouse (unless MAIN) and the location, distinct and non-empty, joined by a space.
`bom_lines.item` is an int in the database while the BOM item label is text ("10", "10-5GA"). `db_items` gives
the int per line: the piece number when the label is a plain number used once, else the next free number above
every piece. The text label goes into `cl_item`. `insert_bom` writes lines that way; agent D's "use VISUAL's BOM
again" calls `bom_for_packet` and `insert_bom` so both paths store the same thing.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from psycopg.rows import dict_row
from .types import Material, WoKey, integer, num, text
STATUS_RANK = {"R": 0, "F": 1, "U": 2}
MatIndex = dict[tuple[WoKey, int], list[Material]]
@dataclass(slots=True)
class BomLine:
item: str
qty: Decimal
part_id: str
desc: str
loc: str
whs: str
comm: str
cons: bool
sub: bool
na: bool
grp: str | None
vkey: str
issued: Decimal
istat: int | None
def as_dict(self) -> dict[str, Any]:
"""JSON-ready (numbers as text) for change order `vis.bom`."""
return {
"item": self.item,
"qty": str(self.qty),
"part_id": self.part_id,
"desc": self.desc,
"loc": self.loc,
"whs": self.whs,
"comm": self.comm,
"cons": self.cons,
"sub": self.sub,
"na": self.na,
"grp": self.grp,
"vkey": self.vkey,
"issued": str(self.issued),
"istat": self.istat,
}
@classmethod
def from_dict(cls, d: Mapping[str, Any]) -> BomLine:
return cls(
item=text(d.get("item")),
qty=num(d.get("qty")),
part_id=text(d.get("part_id")),
desc=text(d.get("desc")),
loc=text(d.get("loc")),
whs=text(d.get("whs")),
comm=text(d.get("comm")),
cons=bool(d.get("cons")),
sub=bool(d.get("sub")),
na=bool(d.get("na")),
grp=text(d.get("grp")) or None,
vkey=text(d.get("vkey")),
issued=num(d.get("issued")),
istat=None if d.get("istat") is None else integer(d.get("istat")),
)
def dwg_norm(dwg: Any) -> str:
return "".join(text(dwg).split()).upper()
def index(materials: Iterable[Material] | None) -> MatIndex:
"""Material lines by (leg key, seq)."""
idx: MatIndex = {}
for m in materials or ():
idx.setdefault((m.key, m.seq), []).append(m)
return idx
def _leg_parts(leg: Any) -> tuple[WoKey, int, str]:
"""(key, seq, dwg) from a candidates.Leg, or from a packet_work_orders row dict (wo_base, lot, split, sub, seq,
dwg)."""
if isinstance(leg, Mapping):
key = WoKey.of(leg.get("wo_base"), leg.get("lot"), leg.get("split"), leg.get("sub"))
return key, integer(leg.get("seq")), text(leg.get("dwg"))
return leg.key, int(leg.seq), text(leg.dwg)
def _rank(m: Material) -> int:
return STATUS_RANK.get(text(m.st).upper()[:1], 3)
def bom_for(legs: Sequence[Any], idx: MatIndex) -> list[BomLine]:
"""The BOM of these legs (candidates.Leg objects or packet_work_orders dicts) from the material index."""
lines: list[Material] = []
seen: set[tuple[WoKey, int]] = set()
for leg in legs:
key, seq, dwg = _leg_parts(leg)
k = (key, seq)
if k in seen:
continue
seen.add(k)
best: dict[int, Material] = {}
for m in idx.get(k, []):
if m.dwg and dwg and dwg_norm(m.dwg) != dwg_norm(dwg):
continue
o = best.get(m.piece)
if o is None or _rank(m) < _rank(o):
best[m.piece] = m
lines.extend(best.values())
lines.sort(key=lambda m: (m.wo, m.piece)) # stable: ties keep leg order
out: list[BomLine] = []
used: set[str] = set()
for m in lines:
if not m.part:
continue
grp = next(
(
h
for h in lines
if not h.part and h.wo == m.wo and h.piece % 10 == 0 and h.piece < m.piece < h.piece + 10
),
None,
)
item = str(m.piece)
if item in used:
item = f"{item}-{m.wo.split('-')[-1]}"
used.add(item)
sub = m.comm.upper() == "WIP" or m.whs.upper() == "ASY"
locs: list[str] = []
for part in ("" if m.whs.upper() == "MAIN" else m.whs, m.loc):
if part and part not in locs:
locs.append(part)
out.append(
BomLine(
item=item,
qty=m.qty,
part_id=m.part,
desc=m.desc,
loc=" ".join(locs),
whs=m.whs,
comm=m.comm,
cons=m.cons.upper() == "Y",
sub=sub,
na=sub,
grp=str(grp.piece) if grp is not None else None,
vkey=f"{m.wo}|{m.lot}|{m.sub}|{m.seq}|{m.piece}",
issued=m.issued,
istat=m.istat,
)
)
return out
def db_items(bom: Sequence[BomLine]) -> list[int]:
"""The int `bom_lines.item` per line: the piece when the label is a plain number used once, else the next
free number above every piece number."""
plain = [b.item for b in bom if b.item.isdigit()]
taken: set[int] = set()
out: list[int | None] = []
for b in bom:
if b.item.isdigit() and plain.count(b.item) == 1 and int(b.item) not in taken:
n = int(b.item)
taken.add(n)
out.append(n)
else:
out.append(None)
nxt = max(taken, default=0)
for b in bom:
head = b.item.split("-", 1)[0]
if head.isdigit():
nxt = max(nxt, int(head))
result: list[int] = []
for slot in out:
if slot is not None:
result.append(slot)
continue
nxt += 1
while nxt in taken:
nxt += 1
taken.add(nxt)
result.append(nxt)
return result
def insert_bom(conn: Any, packet_id: int, bom: Sequence[BomLine]) -> int:
"""Insert VISUAL BOM lines (src 'visual'). cons is stored 'Y'/'N', sub 'Y' or NULL. Returns the count."""
items = db_items(bom)
for sort, (b, item) in enumerate(zip(bom, items, strict=True)):
conn.execute(
"INSERT INTO bom_lines (packet_id, item, cl_item, qty, part_id, description, loc, whs, comm, cons, sub,"
" grp, vkey, na, issued, src, sort) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,"
" 'visual', %s)",
(
packet_id,
item,
b.item,
b.qty,
b.part_id[:60],
b.desc[:240] or None,
b.loc[:120] or None,
b.whs or None,
b.comm or None,
"Y" if b.cons else "N",
"Y" if b.sub else None,
b.grp,
b.vkey,
b.na,
b.issued,
sort,
),
)
return len(bom)
# ---------------------------------------------------------------- from the cache
def materials_for(conn: Any, keys: Iterable[WoKey]) -> list[Material]:
"""Live cached material lines (gone_batch NULL) of these leg keys."""
ks = list(dict.fromkeys(keys))
if not ks:
return []
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
"SELECT m.* FROM visual_materials m"
" JOIN unnest(%s::text[], %s::text[], %s::text[], %s::text[]) AS k(b, l, sp, su)"
" ON m.wo_base = k.b AND m.lot = k.l AND m.split = k.sp AND m.sub = k.su"
" WHERE m.gone_batch IS NULL ORDER BY m.wo_base, m.lot, m.split, m.sub, m.seq, m.piece",
([k.base for k in ks], [k.lot for k in ks], [k.split for k in ks], [k.sub for k in ks]),
).fetchall()
return [material_from_row(r) for r in rows]
def material_from_row(r: Mapping[str, Any]) -> Material:
return Material(
wo=r["wo_base"],
lot=r["lot"],
split=r["split"],
sub=r["sub"],
seq=int(r["seq"]),
piece=int(r["piece"]),
part=r["part"],
qty=num(r["qty"]),
issued=num(r["issued"]),
whs=r["whs"],
loc=r["loc"],
st=r["st"],
istat=r["istat"],
dwg=r["dwg"],
desc=r["descr"],
comm=r["comm"],
cons=r["cons"],
)
def bom_for_packet(conn: Any, packet_id: int) -> list[BomLine]:
"""VISUAL's BOM for a packet's legs, rebuilt from the cache (for "use VISUAL's BOM again")."""
with conn.cursor(row_factory=dict_row) as cur:
legs = cur.execute(
"SELECT wo_base, lot, split, sub, seq, coalesce(dwg, '') AS dwg FROM packet_work_orders"
" WHERE packet_id = %s AND seq IS NOT NULL ORDER BY sort, id",
(packet_id,),
).fetchall()
keys = [WoKey.of(r["wo_base"], r["lot"], r["split"], r["sub"]) for r in legs]
return bom_for(legs, index(materials_for(conn, keys)))
+104
View File
@@ -0,0 +1,104 @@
"""Which customer order a project or unit takes its customer, PO and dates from, when VISUAL has several.
The one rule, in order:
1. a Clerical pin that names one of the project's orders (`pin`);
2. the base's own linked order, the first of `by_base[base]` (`base`);
3. the project order whose top jobs name the unit, open orders first (`unit_top_job`);
4. the project's first open order, status F, H or R (`project_open`);
5. the project's first order, the newest (`project_newest`).
`derive` maps the chosen order to the fields a project or unit keeps: customer, po, order_date, ship_date.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any
from .types import Order
OPEN_STATUSES = ("F", "H", "R")
VIA = ("pin", "base", "unit_top_job", "project_open", "project_newest")
FIELDS = ("customer", "po", "order_date", "ship_date")
@dataclass(frozen=True)
class Chosen:
order: Order
via: str # pin | base | unit_top_job | project_open | project_newest
def _is_open(o: Order) -> bool:
return (o.status or "").strip().upper() in OPEN_STATUSES
def choose(
*,
unit_number: str,
base: str,
by_base: Mapping[str, list[Order]],
by_project: Mapping[str, list[Order]],
project: str,
pin: str | None,
) -> Chosen | None:
proj = list(by_project.get(project) or [])
own = list(by_base.get(base) or []) if base else []
if pin:
p = pin.strip().upper()
for o in [*proj, *own]:
if o.cust_order_id.strip().upper() == p:
return Chosen(o, "pin")
if own:
return Chosen(own[0], "base")
if unit_number:
u = unit_number.upper()
hits = [o for o in proj if any(t.upper() == u for t in o.top_jobs)]
hits.sort(key=lambda o: not _is_open(o)) # stable: open first, the list's own order within
if hits:
return Chosen(hits[0], "unit_top_job")
for o in proj:
if _is_open(o):
return Chosen(o, "project_open")
if proj:
return Chosen(proj[0], "project_newest")
return None
def derive(order: Order) -> dict[str, Any]:
"""customer <- customer_name, po <- customer_po_ref, order_date, ship_date <- promise or desired ship date."""
return {
"customer": order.customer_name,
"po": order.customer_po_ref,
"order_date": order.order_date,
"ship_date": order.promise_date or order.desired_ship_date,
}
def _iso(v: date | None) -> str | None:
return v.isoformat() if v else None
def order_ref(chosen: Chosen, at: datetime) -> dict[str, Any]:
"""The jsonb kept on projects.order_ref and units.order_ref."""
o = chosen.order
return {
"cust_order_id": o.cust_order_id,
"via": chosen.via,
"status": o.status,
"customer_name": o.customer_name,
"customer_po_ref": o.customer_po_ref,
"order_date": _iso(o.order_date),
"desired_ship_date": _iso(o.desired_ship_date),
"promise_date": _iso(o.promise_date),
"chosen_at": at.isoformat(),
}
def same_ref(a: Mapping[str, Any] | None, b: Mapping[str, Any] | None) -> bool:
"""Two order_refs are the same when everything but chosen_at matches."""
if a is None or b is None:
return a is None and b is None
return {k: v for k, v in a.items() if k != "chosen_at"} == {k: v for k, v in b.items() if k != "chosen_at"}
+491
View File
@@ -0,0 +1,491 @@
"""Where the feed reads VISUAL from: VisualKit (DEV unless the stack says otherwise) or a synthetic fixture file.
`VisualKitSource` copies the read calls and output shapes of VisualKit's examples/packet_station_feed.py: the
window read, the tracked keys by key, operations, operation text and parts for rows; for_bases, requirements,
requirement text, parts and part sites for materials; DEMAND_SUPPLY_LINK orders by base and by project; labor
tickets summed per operation. Read-only: VisualKit enforces it. No cost, price, amount or pay column is read.
`FixtureSource` reads `<dir>/state` and `<dir>/feed.<state>.json` in the same contract shape (CI and tests).
Every value goes through `types.parse_row` / `parse_material` / `parse_order`, so both sources give identical
FeedData.
"""
from __future__ import annotations
import json
import re
from collections.abc import Iterable, Sequence
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any, Protocol
from .types import (
FeedData,
LaborLeg,
Material,
Order,
Row,
WoKey,
parse_labor,
parse_material,
parse_order,
parse_orders,
parse_row,
status_code,
text,
)
STATUS_WORDS = {"F": "Firmed", "R": "Released", "U": "Unreleased", "C": "Closed", "X": "Cancelled"}
MATERIAL_STATUSES = ("F", "R")
OPEN_ORDER_STATUSES = ("F", "H", "R")
QTY_EMPLOYEE = "9999"
_SECRETISH = re.compile(r"(?i)(password|pwd|secret|token)\s*[=:]\s*\S+")
def redact(message: object) -> str:
"""An exception as one short line with anything password-like removed."""
s = _SECRETISH.sub(
r"\1=***", f"{type(message).__name__}: {message}" if isinstance(message, BaseException) else str(message)
)
return s.replace("\r", " ").replace("\n", " ")[:500]
class FeedSource(Protocol):
def read(
self,
*,
site: str,
since: date,
until: date,
tracked: Sequence[WoKey],
with_materials: bool,
with_orders: bool,
with_labor: bool,
labor_since: date | None,
) -> FeedData: ...
def describe(self) -> str: ...
# ---------------------------------------------------------------- value helpers (as the VisualKit example)
def _to_number(v: Any) -> Any:
if v is None or isinstance(v, bool):
return v
if isinstance(v, Decimal):
return v
return Decimal(str(v))
def _fmt_date(v: date | datetime | None) -> str | None:
if v is None:
return None
if isinstance(v, datetime):
v = v.date()
return v.isoformat()
def _clean(s: str | None) -> str:
if not s:
return ""
return s.replace("\r\n", "\n").replace("\r", "\n")
def _istat(calc_qty: Any, issued_qty: Any, line_status: str | None) -> int:
"""Job Pull's issue state: 3 over-issued, 0 line closed, 2 nothing issued, 1 partly."""
calc = Decimal(str(calc_qty or 0))
issued = Decimal(str(issued_qty or 0))
if issued > calc:
return 3
if (line_status or "").strip().upper() == "C":
return 0
if issued == 0:
return 2
return 1
def _yes_no(flag: bool | None) -> str:
return "" if flag is None else ("Y" if flag else "N")
def project_of(base: str) -> str:
"""VisualKit's project_of: the base before its first dash, or the whole base."""
head = base.partition("-")[0]
return head or base
# ---------------------------------------------------------------- VisualKit
class VisualKitSource:
"""The five reads through one `visualkit.Visual` (made by the job per run, closed after)."""
def __init__(self, visual: Any) -> None:
self.v = visual
def describe(self) -> str:
s = self.v.settings
return f"{s.server} / {s.database}"
def _key(self, k: WoKey) -> Any:
from visualkit import WorkOrderKey
return WorkOrderKey(k.base, k.lot, k.split, k.sub, "W")
def _load_parts(self, part_ids: Iterable[str | None], cache: dict[str, Any]) -> None:
missing = sorted({p for p in part_ids if p and p not in cache})
if missing:
cache.update(self.v.parts.get_many(missing))
def read(
self,
*,
site: str,
since: date,
until: date,
tracked: Sequence[WoKey],
with_materials: bool,
with_orders: bool,
with_labor: bool,
labor_since: date | None,
) -> FeedData:
v = self.v
s = v.settings
meta: dict[str, Any] = {
"source": "visualkit",
"server": s.server,
"database": s.database,
"site": site,
"since": since.isoformat(),
"until": until.isoformat(),
"generated_at": datetime.now().isoformat(timespec="seconds"),
}
data = FeedData(meta=meta, rows=[], tracked=list(tracked), tracked_count=len(tracked))
parts: dict[str, Any] = {}
# 1. The window: firmed TYPE W work orders released in [since, until], paged.
wos: list[Any] = []
offset: int | None = 0
while offset is not None:
page = v.work_orders.released(site, since, until, status=("F",), offset=offset)
wos.extend(page.items)
offset = page.next_offset
data.window_count = len(wos)
# 2. The tracked keys, whatever their status or date. Keys VISUAL lacks are missing.
asked = {self._key(k): k for k in dict.fromkeys(tracked)}
found = v.work_orders.get_many(list(asked)) if asked else {}
data.tracked_missing = [k for vk, k in asked.items() if vk not in found]
have = {wo.key for wo in wos}
for wo in found.values():
if wo.key not in have:
wos.append(wo)
have.add(wo.key)
# 3. Rows for the union.
keys = [wo.key for wo in wos]
ops = v.work_orders.operations_for(keys) if keys else {}
specs = v.work_orders.operation_text_for(keys) if keys else {}
self._load_parts((wo.part_id for wo in wos), parts)
for wo in wos:
part = parts.get(wo.part_id) if wo.part_id else None
desc = _clean(part.description if part else None)
qty = wo.desired_qty or Decimal(0)
for op in ops.get(wo.key, []):
data.rows.append(
parse_row(
{
"wo": wo.base_id,
"lot": wo.lot_id,
"split": wo.split_id,
"sub": wo.sub_id,
"seq": op.sequence_no,
"res": op.resource_id or "",
"dwg": wo.drawing_id or "",
"rev": wo.drawing_rev_no or "",
"ps": specs.get((wo.key, op.sequence_no), ""),
"desc": desc,
"qty": _to_number(qty),
"tgt": _to_number((op.run or Decimal(0)) * qty),
"rel": _fmt_date(wo.desired_rls_date),
"want": _fmt_date(wo.desired_want_date),
"st": STATUS_WORDS.get((wo.status or "").strip().upper(), wo.status or ""),
"barcode": op.barcode(),
"part": wo.part_id or "",
}
)
)
bases = sorted({wo.base_id for wo in wos})
if with_materials:
try:
data.materials, data.material_legs = self._materials(bases, site, parts)
except Exception as e: # a failed materials read keeps the BOMs as they are
data.materials, data.material_legs = None, None
data.errors.append("materials: " + redact(e))
if with_orders:
try:
data.orders_by_base = self._orders_by_base(bases)
data.orders_by_project = self._orders_by_project({project_of(b) for b in bases})
except Exception as e: # the apply keeps the last cached orders
data.orders_by_base, data.orders_by_project = None, None
data.errors.append("orders: " + redact(e))
if with_labor:
try:
data.labor = self._labor(list(asked) or keys)
except Exception as e:
data.labor = None
data.errors.append("labor: " + redact(e))
return data
def _materials(self, bases: Sequence[str], site: str, parts: dict[str, Any]) -> tuple[list[Material], set[WoKey]]:
v = self.v
if not bases:
return [], set()
legs = v.work_orders.for_bases(bases, type="W", status=MATERIAL_STATUSES, site_id=site)
keys = [wo.key for wo in legs]
reqs = v.work_orders.requirements_for(keys) if keys else {}
text_keys = [k for k in keys if any(not r.part_id for r in reqs.get(k, []))]
texts = v.work_orders.requirement_text_for(text_keys) if text_keys else {}
part_ids = {r.part_id for rs in reqs.values() for r in rs if r.part_id}
self._load_parts(part_ids, parts)
sites = v.parts.sites_for(sorted(part_ids), site) if part_ids else {}
out: list[Material] = []
covered: set[WoKey] = set()
for wo in legs:
covered.add(WoKey.of(wo.base_id, wo.lot_id, wo.split_id, wo.sub_id))
for r in reqs.get(wo.key, []):
part = parts.get(r.part_id) if r.part_id else None
ps = sites.get(r.part_id) if r.part_id else None
desc = (
_clean(part.description if part else None)
if r.part_id
else _clean(texts.get((wo.key, r.operation_seq_no, r.piece_no)))
)
out.append(
parse_material(
{
"wo": wo.base_id,
"lot": wo.lot_id,
"split": wo.split_id,
"sub": wo.sub_id,
"seq": r.operation_seq_no,
"piece": r.piece_no,
"part": r.part_id or None,
"qty": _to_number(r.calc_qty or Decimal(0)),
"issued": _to_number(r.issued_qty or Decimal(0)),
"whs": (ps.primary_whs_id or "").upper() if ps else "",
"loc": (ps.primary_loc_id or "").upper() if ps else "",
"st": wo.status,
"istat": _istat(r.calc_qty, r.issued_qty, r.status),
"dwg": wo.drawing_id or "",
"desc": desc,
"comm": (part.commodity_code or "") if part else "",
"cons": _yes_no(part.consumable) if part else "",
}
)
)
return out, covered
def _orders_by_base(self, bases: Sequence[str]) -> dict[str, list[Order]]:
v = self.v
if not bases:
return {}
pairs = v.links.orders_for_bases(list(bases))
order_ids = sorted({oid for ps in pairs.values() for oid, _ in ps})
orders = v.orders.get_many(order_ids) if order_ids else {}
customers = v.orders.customers(sorted({o.customer_id for o in orders.values()})) if orders else {}
out: dict[str, list[Order]] = {}
for base in bases:
lines_by_order: dict[str, list[int]] = {}
for oid, line_no in pairs.get(base, []):
lines_by_order.setdefault(oid, [])
if line_no is not None:
lines_by_order[oid].append(line_no)
if not lines_by_order:
continue
out[base] = []
for oid, lines in sorted(lines_by_order.items()):
o = orders.get(oid)
cust = customers.get(o.customer_id) if o else None
out[base].append(
parse_order(
{
"cust_order_id": oid,
"lines": sorted(set(lines)),
"status": o.status if o else None,
"customer_id": o.customer_id if o else None,
"customer_name": cust.name if cust else None,
"customer_po_ref": o.customer_po_ref if o else None,
"order_date": _fmt_date(getattr(o, "order_date", None)) if o else None,
"desired_ship_date": _fmt_date(o.desired_ship_date) if o else None,
"promise_date": _fmt_date(o.promise_date) if o else None,
}
)
)
return out
def _orders_by_project(self, projects: Iterable[str]) -> dict[str, list[Order]]:
v = self.v
wanted = sorted(set(projects))
if not wanted:
return {}
found = v.links.orders_for_projects(wanted)
cust_ids = sorted({po.customer_id for pos in found.values() for po in pos if po.customer_id})
customers = v.orders.customers(cust_ids) if cust_ids else {}
out: dict[str, list[Order]] = {}
for project in wanted:
entries: dict[str, dict[str, Any]] = {}
for po in found.get(project, []):
e = entries.get(po.cust_order_id)
if e is None:
cust = customers.get(po.customer_id) if po.customer_id else None
e = entries[po.cust_order_id] = {
"cust_order_id": po.cust_order_id,
"lines": [],
"top_jobs": [],
"status": po.status,
"customer_id": po.customer_id,
"customer_name": cust.name if cust else None,
"customer_po_ref": po.customer_po_ref,
"order_date": _fmt_date(po.order_date),
"desired_ship_date": _fmt_date(po.desired_ship_date),
"promise_date": _fmt_date(po.promise_date),
}
if po.line_no is not None and po.line_no not in e["lines"]:
e["lines"].append(po.line_no)
if po.supply_base_id not in e["top_jobs"]:
e["top_jobs"].append(po.supply_base_id)
if entries:
for e in entries.values():
e["lines"].sort()
e["top_jobs"].sort()
ordered = sorted(entries.values(), key=lambda e: e["status"] not in OPEN_ORDER_STATUSES)
out[project] = [parse_order(e) for e in ordered]
return out
def _labor(self, keys: Sequence[Any]) -> list[LaborLeg]:
"""Hours and ticket counts per (leg, seq). Employee 9999's quantity-only tickets and indirect tickets
are left out. Hours only: no cost or pay column is read. VisualKit has no date filter on this call, so
every ticket of the legs is read and summed (the totals need them all)."""
if not keys:
return []
tickets = self.v.labor.tickets_for_work_orders(list(keys))
sums: dict[tuple[WoKey, int], LaborLeg] = {}
for vk, ts in tickets.items():
k = WoKey.of(vk.base_id, vk.lot_id, vk.split_id, vk.sub_id)
for t in ts:
if t.operation_seq_no is None or t.is_quantity_only or t.is_indirect:
continue
seq = int(t.operation_seq_no)
leg = sums.get((k, seq))
if leg is None:
leg = sums[(k, seq)] = LaborLeg(k.base, k.lot, k.split, k.sub, seq, Decimal(0), 0, None)
leg.hours += Decimal(str(t.hours_worked or 0))
leg.tickets += 1
d = t.transaction_date.date() if isinstance(t.transaction_date, datetime) else t.transaction_date
if d is not None and (leg.last_ticket is None or d > leg.last_ticket):
leg.last_ticket = d
return list(sums.values())
# ---------------------------------------------------------------- fixtures
class FixtureSource:
"""Synthetic VISUAL: `<dir>/state` (default "1") picks `<dir>/feed.<state>.json`."""
def __init__(self, directory: Path) -> None:
self.dir = Path(directory)
def state(self) -> str:
try:
s = (self.dir / "state").read_text(encoding="utf-8").strip()
except OSError:
s = ""
return s or "1"
def describe(self) -> str:
return f"fixture state {self.state()}"
def read(
self,
*,
site: str,
since: date,
until: date,
tracked: Sequence[WoKey],
with_materials: bool,
with_orders: bool,
with_labor: bool,
labor_since: date | None,
) -> FeedData:
state = self.state()
raw = json.loads((self.dir / f"feed.{state}.json").read_text(encoding="utf-8"))
all_rows: list[Row] = [parse_row(d) for d in raw.get("rows") or []]
tracked_set = set(tracked)
window_keys = {r.key for r in all_rows if r.st == "F" and r.rel is not None and since <= r.rel <= until}
keep = window_keys | (tracked_set & {r.key for r in all_rows})
rows = [r for r in all_rows if r.key in keep]
present = {r.key for r in all_rows}
meta = dict(raw.get("meta") or {})
meta.update(
{"source": "fixture", "state": state, "site": site, "since": since.isoformat(), "until": until.isoformat()}
)
data = FeedData(
meta=meta,
rows=rows,
tracked=list(tracked),
tracked_missing=[k for k in dict.fromkeys(tracked) if k not in present],
window_count=len(window_keys),
tracked_count=len(tracked),
)
bases = {r.wo for r in rows}
if with_materials:
if raw.get("materials") is None:
data.materials, data.material_legs = None, None
else:
lines = [parse_material(d) for d in raw.get("materials") or []]
data.materials = [m for m in lines if m.wo in bases]
data.material_legs = {m.key for m in data.materials if m.st in MATERIAL_STATUSES}
# A leg the file lists with status F or R counts as covered even when it has no lines.
for r in all_rows:
if r.wo in bases and r.st in MATERIAL_STATUSES:
data.material_legs.add(r.key)
if with_orders:
obb = parse_orders(raw.get("orders_by_base"))
obp = parse_orders(raw.get("orders_by_project"))
if obb is None or obp is None:
data.orders_by_base, data.orders_by_project = None, None
else:
projects = {project_of(b) for b in bases}
data.orders_by_base = {b: v for b, v in obb.items() if b in bases}
data.orders_by_project = {p: v for p, v in obp.items() if p in projects}
if with_labor and raw.get("labor") is not None:
data.labor = [parse_labor(d) for d in raw.get("labor") or []]
return data
def make(settings: Any, visual: Any = None) -> FeedSource:
"""The source the settings name. `visualkit` needs the open `visual` the job made."""
if settings.feed_source == "fixture":
return FixtureSource(Path(settings.fixture_dir))
if visual is None:
raise ValueError("the visualkit feed source needs an open visualkit.Visual")
return VisualKitSource(visual)
__all__ = [
"FeedSource",
"FixtureSource",
"VisualKitSource",
"make",
"project_of",
"redact",
"status_code",
"text",
]
+306
View File
@@ -0,0 +1,306 @@
"""The feed's typed records: one VISUAL operation row, one material line, one customer order, labor per leg.
Both feed sources (VisualKit and the fixture files) build these through `parse_row` and `parse_material`, so they
produce identical data. Every ID part (work order base, lot, split, sub, part id, order id) is text and is never
cast to a number: leading zeros stay and lot `7A` occurs. `seq` and `piece` are int. Quantities are Decimal.
`None` and `""` are the same for text.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
STATUS_CODES = {
"FIRMED": "F",
"RELEASED": "R",
"UNRELEASED": "U",
"CLOSED": "C",
"CANCELLED": "X",
"CANCELED": "X",
}
@dataclass(frozen=True, slots=True)
class WoKey:
"""A work order leg key, text exactly as VISUAL has it ('' never None)."""
base: str
lot: str
split: str
sub: str
def __str__(self) -> str:
return f"{self.base}/{self.lot}/{self.split}/{self.sub}"
def as_tuple(self) -> tuple[str, str, str, str]:
return (self.base, self.lot, self.split, self.sub)
@classmethod
def of(cls, base: Any, lot: Any, split: Any, sub: Any) -> WoKey:
return cls(text(base), text(lot), text(split), text(sub))
@dataclass(slots=True)
class Row:
"""One TYPE W operation, as Job Release Information carries it."""
wo: str
lot: str
split: str
sub: str
seq: int
res: str
dwg: str
rev: str
ps: str
desc: str
qty: Decimal
tgt: Decimal
rel: date | None
want: date | None
st: str # the status CODE: F R U C X, or ''
part: str = ""
barcode: str = ""
@property
def key(self) -> WoKey:
return WoKey(self.wo, self.lot, self.split, self.sub)
@dataclass(slots=True)
class Material:
"""One requirement line, as Job Pull carries it. part is None for a line with no part (a group header)."""
wo: str
lot: str
split: str
sub: str
seq: int
piece: int
part: str | None
qty: Decimal
issued: Decimal
whs: str
loc: str
st: str
istat: int | None
dwg: str
desc: str
comm: str
cons: str
@property
def key(self) -> WoKey:
return WoKey(self.wo, self.lot, self.split, self.sub)
@dataclass(slots=True)
class Order:
cust_order_id: str
status: str | None
customer_id: str | None
customer_name: str | None
customer_po_ref: str | None
order_date: date | None
desired_ship_date: date | None
promise_date: date | None
lines: list[int] = field(default_factory=list)
top_jobs: list[str] = field(default_factory=list)
@dataclass(slots=True)
class LaborLeg:
wo: str
lot: str
split: str
sub: str
seq: int
hours: Decimal
tickets: int
last_ticket: date | None
@property
def key(self) -> WoKey:
return WoKey(self.wo, self.lot, self.split, self.sub)
@dataclass(slots=True)
class FeedData:
"""One read of VISUAL (or of a fixture file). None on a section means that read failed or was not asked for."""
meta: dict[str, Any]
rows: list[Row]
materials: list[Material] | None = None
material_legs: set[WoKey] | None = None # legs the materials read covered (header status F or R)
orders_by_base: dict[str, list[Order]] | None = None
orders_by_project: dict[str, list[Order]] | None = None
labor: list[LaborLeg] | None = None
tracked: list[WoKey] = field(default_factory=list) # the tracked keys this read was asked for
tracked_missing: list[WoKey] = field(default_factory=list)
window_count: int = 0 # work orders the window read returned
tracked_count: int = 0
errors: list[str] = field(default_factory=list)
# ---------------------------------------------------------------- value helpers
def text(value: Any) -> str:
"""Text as stored: None and '' alike become ''. CRLF and CR become LF. Never a number cast."""
if value is None:
return ""
s = value if isinstance(value, str) else str(value)
return s.replace("\r\n", "\n").replace("\r", "\n").strip()
def opt_text(value: Any) -> str | None:
s = text(value)
return s or None
def num(value: Any) -> Decimal:
"""A quantity as Decimal, through str so 0.1 stays 0.1. None, '' and junk become 0."""
if value is None or value == "" or isinstance(value, bool):
return Decimal(0)
if isinstance(value, Decimal):
return value
try:
return Decimal(str(value))
except (InvalidOperation, ValueError):
return Decimal(0)
def opt_num(value: Any) -> Decimal | None:
if value is None or value == "":
return None
return num(value)
def day(value: Any) -> date | None:
"""A date from a date, datetime or ISO text (the first 10 characters). Anything else is None."""
if value is None or value == "":
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
try:
return date.fromisoformat(str(value).strip()[:10])
except ValueError:
return None
def integer(value: Any, default: int = 0) -> int:
"""seq and piece: whole numbers. 10, 10.0, '10' and Decimal('10') are all 10."""
if value is None or value == "" or isinstance(value, bool):
return default
if isinstance(value, int):
return value
try:
return int(Decimal(str(value).strip()))
except (InvalidOperation, ValueError):
return default
def opt_int(value: Any) -> int | None:
if value is None or value == "":
return None
return integer(value)
def status_code(word_or_code: Any) -> str:
"""'Firmed' or 'F' -> 'F', and so on. Unknown -> its first letter upper-cased. None and '' -> ''."""
s = text(word_or_code).upper()
if not s:
return ""
if s in STATUS_CODES:
return STATUS_CODES[s]
return s[0]
# ---------------------------------------------------------------- parsing contract dicts
def parse_row(d: Mapping[str, Any]) -> Row:
"""A Row from a feed-contract dict (examples/packet_station_feed.py `rows`)."""
return Row(
wo=text(d.get("wo")),
lot=text(d.get("lot")),
split=text(d.get("split")),
sub=text(d.get("sub")),
seq=integer(d.get("seq")),
res=text(d.get("res")),
dwg=text(d.get("dwg")),
rev=text(d.get("rev")),
ps=text(d.get("ps")),
desc=text(d.get("desc")),
qty=num(d.get("qty")),
tgt=num(d.get("tgt")),
rel=day(d.get("rel")),
want=day(d.get("want")),
st=status_code(d.get("st")),
part=text(d.get("part")),
barcode=text(d.get("barcode")),
)
def parse_material(d: Mapping[str, Any]) -> Material:
"""A Material from a feed-contract dict (`materials`). A missing part stays None."""
return Material(
wo=text(d.get("wo")),
lot=text(d.get("lot")),
split=text(d.get("split")),
sub=text(d.get("sub")),
seq=integer(d.get("seq")),
piece=integer(d.get("piece")),
part=opt_text(d.get("part")),
qty=num(d.get("qty")),
issued=num(d.get("issued")),
whs=text(d.get("whs")),
loc=text(d.get("loc")),
st=status_code(d.get("st")),
istat=opt_int(d.get("istat")),
dwg=text(d.get("dwg")),
desc=text(d.get("desc")),
comm=text(d.get("comm")),
cons=text(d.get("cons")),
)
def parse_order(d: Mapping[str, Any]) -> Order:
"""An Order from an orders_by_base or orders_by_project entry."""
return Order(
cust_order_id=text(d.get("cust_order_id")),
status=opt_text(d.get("status")),
customer_id=opt_text(d.get("customer_id")),
customer_name=opt_text(d.get("customer_name")),
customer_po_ref=opt_text(d.get("customer_po_ref")),
order_date=day(d.get("order_date")),
desired_ship_date=day(d.get("desired_ship_date")),
promise_date=day(d.get("promise_date")),
lines=[integer(x) for x in (d.get("lines") or []) if x is not None and x != ""],
top_jobs=[text(x) for x in (d.get("top_jobs") or []) if text(x)],
)
def parse_orders(m: Mapping[str, Any] | None) -> dict[str, list[Order]] | None:
if m is None:
return None
return {text(k): [parse_order(e) for e in (v or [])] for k, v in m.items() if text(k)}
def parse_labor(d: Mapping[str, Any]) -> LaborLeg:
return LaborLeg(
wo=text(d.get("wo")),
lot=text(d.get("lot")),
split=text(d.get("split")),
sub=text(d.get("sub")),
seq=integer(d.get("seq")),
hours=num(d.get("hours")),
tickets=integer(d.get("tickets")),
last_ticket=day(d.get("last_ticket")),
)
+216
View File
@@ -0,0 +1,216 @@
"""Packet writes by the worker: the pipeline's packet_write for the system principal, with no session.
`system_write` locks the packet row (FOR UPDATE SKIP LOCKED: a row a person is writing right now is skipped and
retried next run, `Busy`), builds the policy's PacketState from the same child rows the API uses, asks the policy
as `Principal(user_id=None, system="visual-feed")` (`Refused` when it says no), yields a `SysWrite`, then bumps
the version, writes the packet_log row (by NULL, by_name 'VISUAL feed', role 'system'), the audit row
(`packet.<action>`, role_snapshot 'system') and the NOTIFY. Everything runs in a savepoint (or a transaction when
none is open), so a refusal or an error inside the body leaves nothing behind.
VIS_AUTO_RELEASE carries the SEND_TO_WAREHOUSE side effects here, locally: status warehouse, status_at,
wh_skipped false, wh_sent_at, a packet_wh incoming row (by NULL, auto true) and a packet_transitions row (by NULL,
action 'vis_auto_release').
"""
from __future__ import annotations
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any
from psycopg.rows import dict_row
from .. import audit, events, policy
from ..domain import packets
from ..text import LIMITS, clean_opt
A = policy.Action
SYSTEM = policy.Principal(user_id=None, system="visual-feed")
BY_NAME = "VISUAL feed"
ROLE = "system"
LABELS: dict[policy.Action, str] = {
A.VIS_UPDATE_DRAFT: "Updated from VISUAL",
A.VIS_OPEN_CO: "Change from VISUAL",
A.VIS_AUTO_RELEASE: "Sent to warehouse automatically",
A.VIS_RETIRE_DRAFT: "Retired: work orders moved to another packet",
}
_SELECT = (
"SELECT *, status::text AS status, source_kind::text AS source_kind, routed_to::text AS routed_to,"
" drawing_state::text AS drawing_state FROM packets WHERE id = %s FOR UPDATE SKIP LOCKED"
)
EMPTY: Mapping[str, Any] = {}
class Busy(Exception):
"""Someone else holds the packet row right now. Retry next run."""
class Missing(LookupError):
"""No such packet."""
class Refused(Exception):
"""The policy said no."""
def __init__(self, decision: policy.Decision) -> None:
super().__init__(f"{decision.code}: {decision.why}")
self.decision = decision
@dataclass
class SysWrite:
conn: Any
packet: dict[str, Any] # the packets row, locked
state: policy.PacketState
action: policy.Action
label: str
note: str | None = None
detail: dict[str, Any] = field(default_factory=dict)
fields: list[str] = field(default_factory=list)
noop: bool = False
version: int = 0
transition_id: int | None = None
@property
def id(self) -> int:
return int(self.packet["id"])
def changed(self, *names: str) -> None:
for n in names:
if n not in self.fields:
self.fields.append(n)
def log(self, label: str, note: str | None = None) -> None:
"""Override the packet_log label (and note)."""
self.label = label
if note is not None:
self.note = note
def skip(self) -> None:
"""Nothing changed: no version bump, no log, audit or event."""
self.noop = True
def lock(conn: Any, packet_id: int) -> dict[str, Any]:
"""The packets row locked FOR UPDATE SKIP LOCKED. Busy when another transaction holds it."""
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(_SELECT, (packet_id,)).fetchone()
if row is not None:
return dict(row)
if conn.execute("SELECT 1 FROM packets WHERE id = %s", (packet_id,)).fetchone() is None:
raise Missing(packet_id)
raise Busy(packet_id)
@contextmanager
def system_write(
conn: Any,
packet_id: int,
action: policy.Action,
*,
label: str | None = None,
note: str | None = None,
detail: dict[str, Any] | None = None,
extra: Mapping[str, Any] = EMPTY,
) -> Iterator[SysWrite]:
with conn.transaction():
row = lock(conn, packet_id)
ch = packets.load_children(conn, row, full=False)
state = packets.state_from(row, ch)
decision = policy.check(SYSTEM, state, action, extra=extra)
if not decision.ok:
raise Refused(decision)
sw = SysWrite(
conn=conn,
packet=row,
state=state,
action=action,
label=label or LABELS.get(action, str(action)),
note=note,
detail=dict(detail or {}),
version=int(row["version"]),
)
yield sw
_finish(sw)
def _finish(sw: SysWrite) -> None:
if sw.noop:
return
conn = sw.conn
from_status = str(sw.packet["status"])
from_dept = str(sw.packet["dept_code"])
if sw.action is A.VIS_AUTO_RELEASE:
_auto_release(sw)
sw.version = int(
conn.execute("UPDATE packets SET version = version + 1 WHERE id = %s RETURNING version", (sw.id,)).fetchone()[0]
)
after = conn.execute("SELECT status::text, dept_code FROM packets WHERE id = %s", (sw.id,)).fetchone()
status, dept = str(after[0]), str(after[1])
note = clean_opt(sw.note, LIMITS["note"]) if sw.note else None
conn.execute(
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note, transition_id)"
" VALUES (%s, NULL, %s, %s, %s, %s, %s)",
(sw.id, BY_NAME, ROLE, sw.label, note, sw.transition_id),
)
detail: dict[str, Any] = {"from": from_status, "to": status, "dept": dept}
if from_dept != dept:
detail["from_dept"] = from_dept
if sw.fields:
detail["fields"] = sw.fields
if note:
detail["note"] = note
detail.update(sw.detail)
audit.append(
conn,
action=f"packet.{sw.action}",
role_snapshot=ROLE,
subject_type="packet",
subject_id=sw.id,
packet_id=sw.id,
detail=packets_jsonable(detail),
)
events.publish(
conn, {"event": "changed", "packet_id": sw.id, "version": sw.version, "status": status, "dept": dept}
)
def _auto_release(sw: SysWrite) -> None:
conn = sw.conn
conn.execute(
"UPDATE packets SET status = 'warehouse', status_at = now(), wh_skipped = false, wh_sent_at = now(),"
" return_note = NULL WHERE id = %s",
(sw.id,),
)
conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (sw.id,))
conn.execute(
"INSERT INTO packet_wh (packet_id, stage, by, note, auto) VALUES (%s, 'incoming', NULL, %s, true)",
(sw.id, "Sent automatically: no flags"),
)
dept = str(sw.packet["dept_code"])
tid = conn.execute(
"INSERT INTO packet_transitions (packet_id, by, action, from_status, to_status, from_dept, to_dept, reason)"
" VALUES (%s, NULL, 'vis_auto_release', %s::packet_status, 'warehouse', %s, %s, %s) RETURNING id",
(sw.id, str(sw.packet["status"]), dept, dept, sw.note),
).fetchone()
sw.transition_id = int(tid[0])
sw.changed("status")
def packets_jsonable(value: Any) -> Any:
"""Audit detail as plain JSON (dates and Decimals as text)."""
from fastapi.encoders import jsonable_encoder
return jsonable_encoder(value)
def log_only(conn: Any, packet_id: int, label: str, note: str | None = None) -> None:
"""A packet_log row by the feed with no version bump (for observations that change nothing a user edits)."""
conn.execute(
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note) VALUES (%s, NULL, %s, %s, %s, %s)",
(packet_id, BY_NAME, ROLE, label, clean_opt(note, LIMITS["note"]) if note else None),
)
+38 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import contextlib
import importlib
import logging
from collections.abc import AsyncIterator
from pathlib import Path
@@ -93,13 +94,48 @@ def create_app(
app.state.groups_cache = None
api_errors.install(app)
from .api import account, admin, assets, auth, departments, packet_children, packets, projects, units
from .api import (
account,
admin,
assets,
auth,
departments,
feed_settings,
jobs,
packet_children,
packets,
projects,
units,
)
from .api import events as events_api
# Routers go in before the static catch-all below, which answers every other GET.
for module in (auth, account, admin, departments, events_api, projects, units, packets, packet_children, assets):
for module in (
auth,
account,
admin,
departments,
events_api,
projects,
units,
packets,
packet_children,
assets,
jobs,
feed_settings,
):
app.include_router(module.router)
app.include_router(assets.blob_router)
for name in ("feed", "packet_visual"):
modname = f"{__package__}.api.{name}"
try:
extra = importlib.import_module(modname)
except ModuleNotFoundError as e:
if e.name != modname:
raise
log.warning("router api.%s not there yet; not loaded", name)
continue
app.include_router(extra.router)
@app.get("/healthz")
def healthz() -> dict[str, Any]:
@@ -0,0 +1,422 @@
-- 0004_feed: the VISUAL and drawing caches, the go-live ignored list, the resource map, the worker's
-- job tables and the order-derived columns on projects and units. Runs as ps_migrate; 0001's default
-- privileges give ps_app SELECT, INSERT, UPDATE, DELETE on every table here. Every work order ID part
-- (base, lot, split, sub) and every part ID is text: leading zeros are kept and lot 7A occurs.
-- ---------------------------------------------------------------- VISUAL cache
-- One row per feed read. suspect batches skip the "gone" marking.
CREATE TABLE visual_batches (
id bigserial PRIMARY KEY,
run_id bigint,
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
server text,
database text,
site text,
since date,
until date,
window_rows int,
tracked_keys int,
missing_keys int,
suspect bool NOT NULL DEFAULT false,
counts jsonb NOT NULL DEFAULT '{}'
);
-- One row per TYPE W operation as VISUAL last showed it. st is the status code (F R U C X) or ''.
CREATE TABLE visual_rows (
id bigserial PRIMARY KEY,
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
seq int NOT NULL,
res text NOT NULL DEFAULT '',
dwg text NOT NULL DEFAULT '',
rev text NOT NULL DEFAULT '',
ps text NOT NULL DEFAULT '',
descr text NOT NULL DEFAULT '',
qty numeric,
tgt numeric,
rel date,
want date,
st text NOT NULL DEFAULT '',
part text NOT NULL DEFAULT '',
barcode text,
row_hash bytea NOT NULL,
first_batch bigint NOT NULL REFERENCES visual_batches (id),
last_batch bigint NOT NULL REFERENCES visual_batches (id),
gone_batch bigint REFERENCES visual_batches (id),
UNIQUE (wo_base, lot, split, sub, seq)
);
CREATE INDEX visual_rows_wo ON visual_rows (wo_base, lot);
CREATE INDEX visual_rows_dwg ON visual_rows (dwg);
CREATE INDEX visual_rows_rel_live ON visual_rows (rel) WHERE gone_batch IS NULL;
-- Material requirement lines. part is NULL on a line with no part (a group header).
CREATE TABLE visual_materials (
id bigserial PRIMARY KEY,
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
seq int NOT NULL,
piece int NOT NULL,
part text,
qty numeric,
issued numeric,
whs text NOT NULL DEFAULT '',
loc text NOT NULL DEFAULT '',
st text NOT NULL DEFAULT '',
istat int,
dwg text NOT NULL DEFAULT '',
descr text NOT NULL DEFAULT '',
comm text NOT NULL DEFAULT '',
cons text NOT NULL DEFAULT '',
mat_hash bytea NOT NULL,
first_batch bigint NOT NULL REFERENCES visual_batches (id),
last_batch bigint NOT NULL REFERENCES visual_batches (id),
gone_batch bigint REFERENCES visual_batches (id),
UNIQUE (wo_base, lot, split, sub, seq, piece)
);
CREATE INDEX visual_materials_wo ON visual_materials (wo_base, lot);
CREATE TABLE visual_orders (
cust_order_id text PRIMARY KEY,
status text,
customer_id text,
customer_name text,
customer_po_ref text,
order_date date,
desired_ship_date date,
promise_date date,
last_batch bigint
);
-- kind base: key is a work order base. kind project: key is a project number.
CREATE TABLE visual_order_links (
id bigserial PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('base', 'project')),
key text NOT NULL,
cust_order_id text NOT NULL REFERENCES visual_orders (cust_order_id),
rank int NOT NULL,
lines int[] NOT NULL DEFAULT '{}',
top_jobs text[] NOT NULL DEFAULT '{}',
last_batch bigint,
UNIQUE (kind, key, cust_order_id)
);
CREATE INDEX visual_order_links_key ON visual_order_links (kind, key);
-- Hours and ticket counts per operation. No cost columns, ever.
CREATE TABLE visual_labor (
id bigserial PRIMARY KEY,
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
seq int NOT NULL,
hours numeric NOT NULL DEFAULT 0,
tickets int NOT NULL DEFAULT 0,
last_ticket date,
observed_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (wo_base, lot, split, sub, seq)
);
-- What VISUAL changed, per batch, for the Admin view. Pruned at 90 days.
CREATE TABLE visual_row_changes (
id bigserial PRIMARY KEY,
batch_id bigint NOT NULL REFERENCES visual_batches (id),
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
seq int,
kind text NOT NULL CHECK (kind IN ('added', 'changed', 'gone', 'returned')),
before jsonb,
after jsonb,
at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX visual_row_changes_batch ON visual_row_changes (batch_id);
CREATE INDEX visual_row_changes_at_brin ON visual_row_changes USING brin (at);
CREATE TABLE visual_material_changes (
id bigserial PRIMARY KEY,
batch_id bigint NOT NULL REFERENCES visual_batches (id),
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
seq int,
piece int,
kind text NOT NULL CHECK (kind IN ('added', 'changed', 'gone', 'returned')),
before jsonb,
after jsonb,
at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX visual_material_changes_batch ON visual_material_changes (batch_id);
CREATE INDEX visual_material_changes_at_brin ON visual_material_changes USING brin (at);
-- Work orders released before the go-live date. A row pulled in by hand becomes tracked.
CREATE TABLE visual_ignored_wos (
id bigserial PRIMARY KEY,
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
rel date,
project text,
unit_number text,
res text,
dwg text,
first_seen_batch bigint,
last_seen_at timestamptz NOT NULL DEFAULT now(),
pulled_in_at timestamptz,
pulled_in_by uuid REFERENCES users (id),
pulled_in_reason text,
UNIQUE (wo_base, lot, split, sub)
);
-- VISUAL resource to shop department, or fold (an operation folded into the packet) or ignore.
CREATE TABLE visual_resource_map (
resource text PRIMARY KEY,
target text NOT NULL CHECK (target = 'fold' OR target = 'ignore' OR target ~ '^[0-9]{2}$'),
confirmed bool NOT NULL DEFAULT false,
updated_at timestamptz,
updated_by uuid
);
-- Seeded from the old build's sync/visual_map.json. "Electical" is spelled that way in VISUAL.
INSERT INTO visual_resource_map (resource, target) VALUES
('LAYOUT', '01'),
('LAYOUT-BT', '01'),
('LAYOUT-SM BAND', '01'),
('LAYOUT-BEVELER', '01'),
('LAYOUT-ATT', '01'),
('LAYOUT-ROLL', '01'),
('LAYOUT-PRESS', '01'),
('LAYOUT-THREADER', '01'),
('LAYOUT-HAND CUT', '01'),
('LAYOUT-WEST', '02'),
('LT-SS', '02'),
('WELD-PART', '03'),
('NOVARC_1', '03'),
('WELD-PART-WEST', '04'),
('PART-WELD-WEST', '04'),
('WP-SS', '05'),
('WELD-CODE', '06'),
('WELD-CODE-BT', '07'),
('SUBASSEMBLY', '08'),
('WEST-SA', '08'),
('HYDROTEST', '09'),
('WELD-ASY', '10'),
('FINAL ASY-NORTH', '11'),
('PREP-NORTH', '11'),
('FINAL ASSEMBLY', '11'),
('FINAL ASY-SOUTH', '12'),
('PREP-SOUTH', '12'),
('WEST-FA', '12'),
('PAINT', '13'),
('SANDBLAST', '13'),
('FINAL-ASY-ELEC', '14'),
('Electical', '14'),
('MFG', 'fold'),
('INSPECTION', 'fold'),
('REWORK', 'fold'),
('REWORK-CUSTOMER', 'fold'),
('OUTSIDE SERVICE', 'fold'),
('PTU', 'fold'),
('FORKLIFT', 'ignore'),
('STEAM', 'ignore'),
('WIPLOAD', 'ignore'),
('FOREMAN', 'ignore'),
('ENGINEERING', 'ignore'),
('TRAINING', 'ignore'),
('F.S. TECH.', 'ignore'),
('F.S. TECH. ASST', 'ignore'),
('F.S. STEAM', 'ignore'),
('F.S. WELDER', 'ignore'),
('F.S. SANDBLAST', 'ignore'),
('F.S. PAINT', 'ignore'),
('F.S. SAFETY', 'ignore'),
('REP. TECH. A', 'ignore'),
('REP. TECH. B', 'ignore'),
('REP. WELDER A', 'ignore'),
('REP. WELDER B', 'ignore');
-- ---------------------------------------------------------------- drawing cache
-- One row per drawing number as VISUAL writes it (trimmed). asset_id is the last fetched copy.
CREATE TABLE drawing_lookups (
dwg text PRIMARY KEY,
checked_at timestamptz,
ok bool NOT NULL DEFAULT true,
rev int,
name text,
obj_id int,
obj_version int,
file_id int,
file_version int,
file_size bigint,
base text,
draft_rev int,
draft_where text,
in_drafting bool NOT NULL DEFAULT false,
note text,
fail_count int NOT NULL DEFAULT 0,
failing_since timestamptz,
asset_id uuid REFERENCES assets (id),
sha256 bytea,
visual_rev_seen text,
next_check_at timestamptz
);
CREATE INDEX drawing_lookups_next_check ON drawing_lookups (next_check_at) WHERE next_check_at IS NOT NULL;
-- ---------------------------------------------------------------- worker jobs
-- "Run now": the app inserts a row and NOTIFYs ps_job_request; one run claims every pending row.
CREATE TABLE job_requests (
id bigserial PRIMARY KEY,
job text NOT NULL,
requested_at timestamptz NOT NULL DEFAULT now(),
requested_by uuid REFERENCES users (id),
note text,
claimed_run_id bigint,
claimed_at timestamptz
);
CREATE INDEX job_requests_pending ON job_requests (job) WHERE claimed_at IS NULL;
CREATE TABLE job_runs (
id bigserial PRIMARY KEY,
job text NOT NULL,
trigger text NOT NULL CHECK (trigger IN ('schedule', 'request', 'chain', 'cli')),
requested_by uuid[] NOT NULL DEFAULT '{}',
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
status text NOT NULL DEFAULT 'running'
CHECK (status IN ('running', 'ok', 'degraded', 'failed', 'skipped', 'abandoned')),
counts jsonb NOT NULL DEFAULT '{}',
errors jsonb NOT NULL DEFAULT '[]',
build_version text,
visual_target text,
host text
);
CREATE INDEX job_runs_job ON job_runs (job, id DESC);
-- The next run per job as the worker last scheduled it, for the Admin and Clerical screens.
CREATE TABLE job_schedule (
job text PRIMARY KEY,
next_run_at timestamptz,
interval_seconds int,
paused bool NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO job_schedule (job) VALUES ('visual-feed'), ('drawing-lookup'), ('housekeeping');
-- One row, written every 30 seconds by the running worker.
CREATE TABLE worker_heartbeat (
id int PRIMARY KEY CHECK (id = 1),
at timestamptz NOT NULL,
build text,
host text,
pid int,
running text[] NOT NULL DEFAULT '{}'
);
-- ---------------------------------------------------------------- column additions
ALTER TABLE packets
ADD COLUMN visual_cancelled_at timestamptz,
ADD COLUMN wo_gone_at timestamptz;
-- The leg's own drawing, rev and planner instructions as VISUAL carries them.
ALTER TABLE packet_work_orders
ADD COLUMN partspecs text,
ADD COLUMN rev text,
ADD COLUMN dwg text;
-- One row per leg: two operations of one work order may sit on one packet. Hand rows keep seq NULL,
-- and NULLS NOT DISTINCT still allows only one hand row per key.
DO $$
DECLARE
c text;
BEGIN
SELECT con.conname INTO c
FROM pg_constraint con
WHERE con.conrelid = 'packet_work_orders'::regclass
AND con.contype = 'u'
AND (
SELECT array_agg(a.attname::text ORDER BY a.attname)
FROM unnest(con.conkey) k
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k
) = ARRAY['lot', 'packet_id', 'split', 'sub', 'wo_base'];
IF c IS NULL THEN
RAISE EXCEPTION 'packet_work_orders unique key (packet_id, wo_base, lot, split, sub) not found';
END IF;
EXECUTE format('ALTER TABLE packet_work_orders DROP CONSTRAINT %I', c);
END;
$$;
ALTER TABLE packet_work_orders
ADD CONSTRAINT packet_work_orders_leg_key UNIQUE NULLS NOT DISTINCT (packet_id, wo_base, lot, split, sub, seq);
-- One pending feed change order per packet, whether VISUAL or M-Files opened it.
DROP INDEX change_orders_visual_pending;
CREATE UNIQUE INDEX change_orders_feed_pending ON change_orders (packet_id)
WHERE origin IN ('visual', 'drawing') AND review = 'pending';
-- order_ref is the order the feed chose; order_pin is one Clerical pinned (with a reason).
ALTER TABLE projects
ADD COLUMN order_date_visual date,
ADD COLUMN ship_date_visual date,
ADD COLUMN order_ref jsonb,
ADD COLUMN order_pin text,
ADD COLUMN order_pin_by uuid REFERENCES users (id),
ADD COLUMN order_pin_at timestamptz,
ADD COLUMN order_pin_reason text;
ALTER TABLE units
ADD COLUMN customer text,
ADD COLUMN po text,
ADD COLUMN customer_visual text,
ADD COLUMN po_visual text,
ADD COLUMN order_date_visual date,
ADD COLUMN ship_date_visual date,
ADD COLUMN order_ref jsonb,
ADD COLUMN order_pin text,
ADD COLUMN order_pin_by uuid REFERENCES users (id),
ADD COLUMN order_pin_at timestamptz,
ADD COLUMN order_pin_reason text;
-- v_units expanded u.* when it was made, so it is rebuilt to carry the new unit columns.
DROP VIEW v_units;
CREATE VIEW v_units AS
SELECT u.*,
p.number || u.lot AS number,
p.number AS project_number,
p.name AS project_name,
p.status AS project_status,
coalesce(u.due, p.due) AS due_effective
FROM units u
JOIN projects p ON p.id = u.project_id;
-- Explicit, after the default privileges: the view is read-only to the app.
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON v_units FROM ps_app;
GRANT SELECT ON v_units TO ps_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
-- The feed COPYs each batch into temp tables before the MERGE. ps_migrate owns the database (initdb and
-- bootstrap), so it may grant TEMP; stacks made before this migration get the grant here.
DO $$
BEGIN
EXECUTE format('GRANT TEMPORARY ON DATABASE %I TO ps_app', current_database());
END;
$$;
+14 -1
View File
@@ -12,6 +12,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
ENV_FILE_VAR = "PS_ENV_FILE"
Stack = Literal["prod", "test", "ci", "dev"]
FeedSource = Literal["visualkit", "fixture"]
DrawingSource = Literal["mfiles", "fixture"]
class SecretFileError(Exception):
@@ -101,6 +103,11 @@ class Settings(BaseSettings):
pin_max_digits: int = Field(default=12, ge=4)
upload_max_bytes: int = Field(default=25 * 1024 * 1024, ge=1024)
# Where the worker reads VISUAL and M-Files from. `fixture` reads synthetic files under FIXTURE_DIR (CI and tests).
feed_source: FeedSource = "visualkit"
drawing_source: DrawingSource = "mfiles"
fixture_dir: Path = Path("/fixtures")
@model_validator(mode="before")
@classmethod
def _blank_is_unset(cls, data: Any) -> Any:
@@ -110,7 +117,7 @@ class Settings(BaseSettings):
if key.lower() in _OPTIONAL_PATHS and data[key] in ("", None):
data[key] = None
for key in list(data):
if key.lower() == "stack" and isinstance(data[key], str):
if key.lower() in ("stack", "feed_source", "drawing_source") and isinstance(data[key], str):
data[key] = data[key].strip().lower()
return data
@@ -128,6 +135,12 @@ class Settings(BaseSettings):
raise ValueError("LDAP_ALLOW_INSECURE=true is refused on STACK=prod")
return self
@model_validator(mode="after")
def _fixture_sources(self) -> Settings:
if self.stack == "prod" and (self.feed_source == "fixture" or self.drawing_source == "fixture"):
raise ValueError("FEED_SOURCE=fixture and DRAWING_SOURCE=fixture are refused on STACK=prod")
return self
@model_validator(mode="after")
def _pin_digits(self) -> Settings:
if self.pin_min_digits > self.pin_max_digits:
+3
View File
@@ -46,6 +46,9 @@ LIMITS: dict[str, int] = {
"doc_title": 160,
"ecn": 40,
"name": 160,
"order_pin": 40,
"file_name": 160,
"job_note": 200,
}
REASON_MIN = 8
MAX_BODY_BYTES = 200_000
-51
View File
@@ -1,51 +0,0 @@
"""Phase 0 worker stub: a heartbeat line every 30 s and a touched beat file; exits on SIGTERM."""
from __future__ import annotations
import logging
import os
import signal
import threading
import time
from pathlib import Path
log = logging.getLogger("packetstation.worker")
BEAT_SECONDS = 30
STALE_SECONDS = 90
def touch(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.touch(exist_ok=True)
now = time.time()
os.utime(path, (now, now))
def run(beat_file: Path, build: str, stop: threading.Event | None = None, interval: float = BEAT_SECONDS) -> int:
stop = stop or threading.Event()
def _stop(signum: int, _frame: object) -> None:
log.info("worker stopping on signal %s", signum)
stop.set()
if threading.current_thread() is threading.main_thread():
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
log.info("worker started", extra={"ps": {"build": build, "beat_file": str(beat_file)}})
while not stop.is_set():
try:
touch(beat_file)
except OSError as e:
log.error("can't touch beat file %s: %s", beat_file, e.strerror)
log.info("heartbeat", extra={"ps": {"build": build}})
stop.wait(interval)
log.info("worker stopped")
return 0
def beat_age(beat_file: Path) -> float | None:
try:
return time.time() - beat_file.stat().st_mtime
except OSError:
return None
+63
View File
@@ -0,0 +1,63 @@
"""The background worker: the job scheduler plus a heartbeat (beat file and worker_heartbeat row).
`run(beat_file, build, stop=None, interval=BEAT_SECONDS, settings=None)` blocks until SIGTERM, SIGINT or `stop`.
With settings it builds and starts a Scheduler; without (the CLI tests) it only beats the file.
"""
from __future__ import annotations
import logging
import signal
import threading
from pathlib import Path
from typing import TYPE_CHECKING
from .heartbeat import BEAT_SECONDS, STALE_SECONDS, beat_age, touch
if TYPE_CHECKING:
from ..settings import Settings
log = logging.getLogger("packetstation.worker")
__all__ = ["BEAT_SECONDS", "STALE_SECONDS", "beat_age", "run", "touch"]
def run(
beat_file: Path,
build: str,
stop: threading.Event | None = None,
interval: float = BEAT_SECONDS,
settings: Settings | None = None,
) -> int:
stop = stop or threading.Event()
def _stop(signum: int, _frame: object) -> None:
log.info("worker stopping on signal %s", signum)
stop.set()
if threading.current_thread() is threading.main_thread():
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
log.info("worker started", extra={"ps": {"build": build, "beat_file": str(beat_file)}})
sched = None
if settings is not None:
from .scheduler import Scheduler
sched = Scheduler(settings, build=build)
sched.start()
try:
if sched is not None:
sched.serve(beat_file, stop, interval)
else:
while not stop.is_set():
try:
touch(beat_file)
except OSError as e:
log.error("can't touch beat file %s: %s", beat_file, e.strerror)
log.info("heartbeat", extra={"ps": {"build": build}})
stop.wait(interval)
finally:
if sched is not None:
sched.shutdown(wait=True)
log.info("worker stopped")
return 0
+36
View File
@@ -0,0 +1,36 @@
"""The worker's heartbeat: a touched beat file for the container healthcheck and a worker_heartbeat row."""
from __future__ import annotations
import os
import time
from collections.abc import Iterable
from pathlib import Path
from typing import Any
BEAT_SECONDS = 30
STALE_SECONDS = 90
def touch(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.touch(exist_ok=True)
now = time.time()
os.utime(path, (now, now))
def beat_age(beat_file: Path) -> float | None:
try:
return time.time() - beat_file.stat().st_mtime
except OSError:
return None
def write_row(conn: Any, *, build: str, host: str, pid: int, running: Iterable[str]) -> None:
"""Upsert the single worker_heartbeat row."""
conn.execute(
"INSERT INTO worker_heartbeat (id, at, build, host, pid, running) VALUES (1, now(), %s, %s, %s, %s)"
" ON CONFLICT (id) DO UPDATE SET at = EXCLUDED.at, build = EXCLUDED.build, host = EXCLUDED.host,"
" pid = EXCLUDED.pid, running = EXCLUDED.running",
(build, host, pid, sorted(running)),
)
@@ -0,0 +1,146 @@
"""The job registry and the shapes every job uses.
Jobs import `from packetstation.worker.jobs import JobContext, JobSpec, register` and expose `SPEC`. The feed and
drawing jobs are imported lazily by `load_jobs`, so the worker runs before they exist.
"""
from __future__ import annotations
import importlib
import logging
import threading
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, date, datetime, time, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from ...feed.config import FeedSettings
from ...settings import Settings
from ..redaction import MAX_ERRORS, clip, redact
log = logging.getLogger(__name__)
Counts = dict[str, Any]
Cadence = Callable[[FeedSettings, datetime], int | None]
TRIGGERS = ("schedule", "request", "chain", "cli")
@dataclass
class Stack:
"""Per-process state shared by every run: the startup guard result and long-lived objects (the M-Files vault)."""
guard: Any = None
cache: dict[str, Any] = field(default_factory=dict)
lock: threading.Lock = field(default_factory=threading.Lock)
def cached(self, key: str, factory: Callable[[], Any]) -> Any:
"""The object under `key`, made once by `factory`. A factory that raises leaves nothing cached."""
with self.lock:
if key not in self.cache:
self.cache[key] = factory()
return self.cache[key]
def drop(self, key: str) -> None:
with self.lock:
self.cache.pop(key, None)
@dataclass
class JobContext:
"""What a run gets. `conn` is the run's own autocommit connection as the worker role."""
conn: Any
settings: Settings
feed_settings: FeedSettings
run_id: int
trigger: str
started_at: datetime
log: logging.Logger
stack: Stack
degraded: bool = False
errors: list[str] = field(default_factory=list)
visual_target: str | None = None
def error(self, text: str, *, degrade: bool = True) -> None:
"""Record one error (redacted, clipped). It makes the run degraded unless degrade is False."""
if len(self.errors) < MAX_ERRORS:
self.errors.extend(clip([text]))
if degrade:
self.degraded = True
def degrade(self, reason: str | None = None) -> None:
self.degraded = True
if reason:
self.error(reason)
@property
def tz(self) -> ZoneInfo:
return ZoneInfo(self.settings.site_tz)
def now_site(self) -> datetime:
return datetime.now(self.tz)
def today_site(self) -> date:
return self.now_site().date()
@dataclass(frozen=True)
class JobSpec:
name: str
run: Callable[[JobContext], Counts]
cadence: Cadence # seconds to the next run, or None for "no schedule"
pausable: bool # the feed pause skips scheduled fires (requests still run)
chain: tuple[str, ...] = () # moved to now after a run that did not fail
JOBS: dict[str, JobSpec] = {}
_loaded = False
_load_lock = threading.Lock()
LAZY = ("visual_feed", "drawing_lookup")
def register(spec: JobSpec) -> JobSpec:
JOBS[spec.name] = spec
return spec
def load_jobs() -> dict[str, JobSpec]:
"""Register every job once and return the registry. A job module that cannot import is logged and left out."""
global _loaded
with _load_lock:
if not _loaded:
from . import housekeeping
register(housekeeping.SPEC)
for mod in LAZY:
try:
m = importlib.import_module(f"{__name__}.{mod}")
register(m.SPEC)
except ImportError as e:
log.warning("job module %s not loaded: %s", mod, redact(str(e)))
_loaded = True
return JOBS
def seconds_until(now_site: datetime, at: time) -> int:
"""Seconds from now_site to the next `at` (site wall clock), at least 1."""
target = datetime.combine(now_site.date(), at, tzinfo=now_site.tzinfo)
if target <= now_site:
target = datetime.combine(now_site.date() + timedelta(days=1), at, tzinfo=now_site.tzinfo)
# Aware datetimes sharing a tzinfo subtract as wall clock; through UTC a DST change counts right
return max(1, int((target.astimezone(UTC) - now_site.astimezone(UTC)).total_seconds()))
__all__ = [
"JOBS",
"TRIGGERS",
"Cadence",
"Counts",
"JobContext",
"JobSpec",
"Stack",
"load_jobs",
"register",
"seconds_until",
]
@@ -0,0 +1,536 @@
"""The drawing-lookup job: look drawings up in M-Files (read-only), copy released PDFs into assets, and attach,
flag, stage or open a change order on the packets that use them.
Per run: the queue (drawings/queue.py) cut to `drawing_budget`; per drawing one `Lookup.status`, a fetch when a
released file exists (drawings/fetch.py downloads only when its M-Files identity changed), the `drawing_lookups`
row, then `decide` for every live packet that uses the drawing (its own drawing, or the target of its pending feed
change order), applied through `feed.writes.system_write`. A busy packet is left for the next run.
Never writes to M-Files.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from ... import audit, policy
from ...domain import pipeline
from ...drawings import DRAWING_FLAGS, decide, queue
from ...drawings import lookup as lookups
from ...drawings.fetch import FetchError, FetchResult, fetch
from ...drawings.lookup import Found, Lookup
from ...feed import changes, writes
from . import Counts, JobContext, JobSpec
log = logging.getLogger(__name__)
A = policy.Action
D = decide
FEED_ORIGINS = ("visual", "drawing")
class _Stale(Exception):
"""The packet changed between the read and the lock; the next run decides again."""
def new_counts() -> Counts:
return {
"checked": 0,
"fetched": 0,
"unchanged": 0,
"attached": 0,
"flagged": {},
"lookup_failed": 0,
"cos_opened": 0,
"cos_reaimed": 0,
"staged": 0,
"budget_left": 0,
"fetch_failed": 0,
"busy": 0,
"refused": 0,
"no_file": 0,
}
# ---------------------------------------------------------------- the run
def run(ctx: JobContext) -> Counts:
counts = new_counts()
fs = ctx.feed_settings
budget = int(getattr(fs, "drawing_budget", 60))
try:
lk = lookups.make(ctx.settings, ctx.stack.cache)
except Exception as e: # any failure to open M-Files skips the run
ctx.error(f"M-Files could not be opened: {type(e).__name__}: {e}")
counts["reason"] = "mfiles unavailable"
counts["budget_left"] = budget
return counts
now = datetime.now(UTC)
items = queue.queue(ctx.conn, fs, now, limit=budget)
counts["budget_left"] = max(0, budget - len(items))
counts["source"] = lk.describe()
for item in items:
try:
_drawing(ctx, lk, item, counts, now)
except Exception as e: # one bad drawing must not stop the others
log.exception("drawing %s failed", item.dwg)
ctx.error(f"drawing {item.dwg}: {type(e).__name__}: {e}")
return counts
def _drawing(ctx: JobContext, lk: Lookup, item: queue.QueueItem, counts: Counts, now: datetime) -> None:
conn = ctx.conn
try:
found = lk.status(item.dwg)
except Exception as e: # a lookup error is a failed lookup, never a crash
found = lookups.failed(item.dwg, f"M-Files lookup failed: {type(e).__name__}: {e}")
counts["checked"] += 1
fetched: FetchResult | None = None
fetch_failed = False
if found.ok and found.rev is not None:
try:
fetched = fetch(conn, ctx.settings, lk, found, dwg=item.dwg)
except FetchError as e:
fetch_failed = True
counts["fetch_failed"] += 1
ctx.error(str(e))
else:
if fetched.downloaded:
counts["fetched"] += 1
audit.append(
conn,
action="drawing.fetched",
role_snapshot="system",
subject_type="asset",
subject_id=str(fetched.asset_id),
detail={
"dwg": item.dwg,
"rev": found.rev,
"sha256": fetched.sha256.hex(),
"reused": fetched.reused,
},
)
if not found.ok:
counts["lookup_failed"] += 1
ctx.error(f"drawing {item.dwg}: {D.failed_note(found)}")
vrev_seen = item.visual_rev if item.co_id is None else own_rev(conn, item.dwg)
save_lookup(conn, item, found, fetched, now, fetch_failed=fetch_failed, visual_rev_seen=vrev_seen)
scope = [str(d) for d in (getattr(ctx.feed_settings, "departments", None) or [])]
for pid, relations in sharing(conn, item.dwg, scope):
_packet(ctx, item.dwg, found, fetched, pid, relations, counts)
# ---------------------------------------------------------------- drawing_lookups
def save_lookup(
conn: Any,
item: queue.QueueItem,
found: Found,
fetched: FetchResult | None,
now: datetime,
*,
fetch_failed: bool,
visual_rev_seen: str | None,
) -> None:
"""Upsert the drawing_lookups row for this lookup.
A failed lookup keeps everything known before (rev, file, asset) and backs off. A good one stores what M-Files
has; the file identity and the asset are stored together, and cleared when there is no released file or the
copy failed (so the next run downloads again).
"""
with conn.transaction():
prev = conn.execute("SELECT fail_count FROM drawing_lookups WHERE dwg = %s FOR UPDATE", (item.dwg,)).fetchone()
if not found.ok:
fails = (int(prev[0]) if prev else 0) + 1
conn.execute(
"INSERT INTO drawing_lookups (dwg, checked_at, ok, note, fail_count, failing_since, next_check_at,"
" visual_rev_seen) VALUES (%(dwg)s, %(now)s, false, %(note)s, %(fails)s, %(now)s, %(next)s, %(vrev)s)"
" ON CONFLICT (dwg) DO UPDATE SET checked_at = EXCLUDED.checked_at, ok = false, note = EXCLUDED.note,"
" fail_count = EXCLUDED.fail_count,"
" failing_since = coalesce(drawing_lookups.failing_since, EXCLUDED.failing_since),"
" next_check_at = EXCLUDED.next_check_at, visual_rev_seen = EXCLUDED.visual_rev_seen",
{
"dwg": item.dwg,
"now": now,
"note": D.failed_note(found),
"fails": fails,
"next": now + queue.backoff(fails),
"vrev": visual_rev_seen,
},
)
return
has_file = fetched is not None
conn.execute(
"INSERT INTO drawing_lookups (dwg, checked_at, ok, rev, name, obj_id, obj_version, file_id, file_version,"
" file_size, base, draft_rev, draft_where, in_drafting, note, fail_count, failing_since, asset_id, sha256,"
" visual_rev_seen, next_check_at) VALUES (%(dwg)s, %(now)s, true, %(rev)s, %(name)s, %(obj_id)s,"
" %(obj_version)s, %(file_id)s, %(file_version)s, %(file_size)s, %(base)s, %(draft_rev)s,"
" %(draft_where)s, %(in_drafting)s, %(note)s, 0, NULL, %(asset_id)s, %(sha)s, %(vrev)s, %(next)s)"
" ON CONFLICT (dwg) DO UPDATE SET checked_at = EXCLUDED.checked_at, ok = true, rev = EXCLUDED.rev,"
" name = EXCLUDED.name, obj_id = EXCLUDED.obj_id, obj_version = EXCLUDED.obj_version,"
" file_id = EXCLUDED.file_id, file_version = EXCLUDED.file_version, file_size = EXCLUDED.file_size,"
" base = EXCLUDED.base, draft_rev = EXCLUDED.draft_rev, draft_where = EXCLUDED.draft_where,"
" in_drafting = EXCLUDED.in_drafting, note = EXCLUDED.note, fail_count = 0, failing_since = NULL,"
" asset_id = EXCLUDED.asset_id, sha256 = EXCLUDED.sha256,"
" visual_rev_seen = coalesce(EXCLUDED.visual_rev_seen, drawing_lookups.visual_rev_seen),"
" next_check_at = EXCLUDED.next_check_at",
{
"dwg": item.dwg,
"now": now,
"rev": found.rev,
"name": found.name,
"obj_id": found.obj_id if has_file else None,
"obj_version": found.obj_version if has_file else None,
"file_id": found.file_id if has_file else None,
"file_version": found.file_version if has_file else None,
"file_size": found.file_size,
"base": found.base,
"draft_rev": found.draft_rev,
"draft_where": found.draft_where or None,
"in_drafting": found.in_drafting,
"note": found.note or None,
"asset_id": fetched.asset_id if fetched else None,
"sha": fetched.sha256 if fetched else None,
"vrev": visual_rev_seen,
"next": now + queue.backoff(1) if fetch_failed else None,
},
)
# ---------------------------------------------------------------- packets
def own_rev(conn: Any, dwg: str) -> str | None:
"""The rev a live VISUAL packet with this drawing carries (for visual_rev_seen when the item came from a CO)."""
row = conn.execute(
"SELECT coalesce(btrim(rev), '') FROM packets WHERE removed_at IS NULL AND source_kind = 'visual'"
" AND status <> 'filed' AND btrim(drawing) = %s ORDER BY id LIMIT 1",
(dwg,),
).fetchone()
return None if row is None else str(row[0])
SHARING_SQL = """
SELECT p.id, 'own' AS rel FROM packets p
WHERE p.removed_at IS NULL AND p.source_kind = 'visual' AND p.status <> 'filed' AND btrim(p.drawing) = %(dwg)s
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
UNION
SELECT p.id, 'co' AS rel FROM change_orders c JOIN packets p ON p.id = c.packet_id
WHERE c.review = 'pending' AND c.origin IN ('visual', 'drawing') AND jsonb_typeof(c.waiting) = 'object'
AND btrim(c.waiting->>'dwg') = %(dwg)s AND p.removed_at IS NULL AND p.status NOT IN ('draft', 'filed')
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
ORDER BY 1
"""
def sharing(conn: Any, dwg: str, depts: list[str]) -> list[tuple[int, set[str]]]:
"""Live packets that use the drawing: as their own drawing ('own') or as a pending feed CO's target ('co')."""
out: dict[int, set[str]] = {}
for pid, rel in conn.execute(SHARING_SQL, {"dwg": dwg, "depts": depts}).fetchall():
out.setdefault(int(pid), set()).add(str(rel))
return sorted(out.items())
@dataclass
class PacketFacts:
row: dict[str, Any]
visual: dict[str, Any]
attached_rev: str | None
attached_shas: set[bytes]
co: dict[str, Any] | None
rejected_sha: bytes | None
def _sha(value: Any) -> bytes | None:
if not isinstance(value, str) or not value:
return None
try:
return bytes.fromhex(value)
except ValueError:
return None
def facts(conn: Any, pid: int) -> PacketFacts | None:
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(
"SELECT id, status::text AS status, drawing, rev, drawing_state::text AS drawing_state, flags, visual,"
" version FROM packets WHERE id = %s AND removed_at IS NULL",
(pid,),
).fetchone()
if row is None:
return None
pages = cur.execute(
"SELECT pg.rev, a.sha256 FROM packet_pages pg LEFT JOIN assets a ON a.id = pg.asset_id"
" WHERE pg.packet_id = %s AND pg.type = 'drawing' AND pg.void_at IS NULL ORDER BY pg.n",
(pid,),
).fetchall()
co = cur.execute(
"SELECT id, co_no, origin::text AS origin, waiting, staged FROM change_orders"
" WHERE packet_id = %s AND review = 'pending' AND origin IN ('visual', 'drawing') ORDER BY id DESC LIMIT 1",
(pid,),
).fetchone()
rej = cur.execute(
"SELECT staged FROM change_orders WHERE packet_id = %s AND origin = 'drawing' AND review = 'rejected'"
" ORDER BY id DESC LIMIT 1",
(pid,),
).fetchone()
visual = row["visual"] if isinstance(row["visual"], dict) else {}
attached_rev = None
if pages:
attached_rev = str(pages[0]["rev"] or "").strip()
shas = {bytes(p["sha256"]) for p in pages if p["sha256"] is not None}
rejected = None
if rej is not None and isinstance(rej["staged"], dict):
rejected = _sha(rej["staged"].get("sha256"))
return PacketFacts(dict(row), dict(visual), attached_rev, shas, dict(co) if co else None, rejected)
def _dwg_rev(value: Any) -> tuple[str, str] | None:
if isinstance(value, dict) and value.get("dwg") and value.get("rev") is not None:
return (str(value["dwg"]).strip(), str(value["rev"]).strip())
return None
def target_for(dwg: str, pf: PacketFacts, *, own: bool) -> D.Target:
row, visual, co = pf.row, pf.visual, pf.co
waiting = co.get("waiting") if co else None
staged = co.get("staged") if co else None
co_waiting = _dwg_rev(waiting)
co_staged = _dwg_rev(staged)
packet_rev = str(row["rev"] or "").strip()
rev_ok = None
ro = visual.get("rev_ok")
if isinstance(ro, dict) and ro.get("rev") is not None and str(ro.get("vis_rev") or "").strip() == packet_rev:
rev_ok = str(ro["rev"]).strip()
visual_rev = packet_rev if own or co_waiting is None else co_waiting[1]
return D.Target(
dwg=dwg,
visual_rev=visual_rev,
attached_rev=pf.attached_rev,
attached_sha=next(iter(sorted(pf.attached_shas)), None),
status=str(row["status"]),
drawing_state=str(row["drawing_state"]),
has_hand_drawing=row["drawing_state"] == "hand" or bool(visual.get("manual_dwg")),
rev_ok=rev_ok,
co_waiting=co_waiting,
co_staged=co_staged,
co_pending=co is not None,
co_staged_sha=_sha(staged.get("sha256")) if isinstance(staged, dict) else None,
rejected_sha=pf.rejected_sha,
)
def appr_of(t: D.Target, f: Found, now: datetime) -> dict[str, Any]:
return {
"ok": f.ok,
"rev": f.rev,
"vrev": t.visual_rev,
"name": f.name,
"note": f.note or "",
"base": f.base,
"draft_rev": f.draft_rev,
"draft_where": f.draft_where,
"in_drafting": f.in_drafting,
"at": now.isoformat(),
}
def _same_appr(a: Any, b: dict[str, Any]) -> bool:
if not isinstance(a, dict):
return False
return {k: v for k, v in a.items() if k != "at"} == {k: v for k, v in b.items() if k != "at"}
def new_flags(flags: list[str], d: D.Decision) -> list[str]:
"""Every flag but the drawing ones, then the decision's flag."""
out = [f for f in flags if f not in DRAWING_FLAGS]
if d.flag and d.flag not in out:
out.append(d.flag)
return out
def _packet(
ctx: JobContext,
dwg: str,
found: Found,
fetched: FetchResult | None,
pid: int,
relations: set[str],
counts: Counts,
) -> None:
conn = ctx.conn
pf = facts(conn, pid)
if pf is None:
return
own = "own" in relations
t = target_for(dwg, pf, own=own)
same_file = fetched is not None and fetched.sha256 in pf.attached_shas
d = D.decide(t, found, same_file=same_file, fetched_sha=fetched.sha256 if fetched else None)
if not own and d.kind != D.STAGE_FOR_CO:
return # only the CO's target drawing: nothing to do until it can be staged
if d.kind in (D.ATTACH, D.OPEN_CO, D.STAGE_FOR_CO) and fetched is None:
counts["no_file"] += 1 # the copy failed this run; try again next run
return
now = datetime.now(UTC)
row, visual = pf.row, pf.visual
flags = list(row["flags"] or [])
flags_after = new_flags(flags, d) if own else flags
patch: dict[str, Any] = {}
if own:
appr = appr_of(t, found, now)
if not _same_appr(visual.get("appr"), appr):
patch["appr"] = appr
if found.ok:
confirmed = D.rev_confirmed(t, found)
if bool(visual.get("rev_confirmed")) != confirmed:
patch["rev_confirmed"] = confirmed
state_after = d.drawing_state if own else str(row["drawing_state"])
material = (
d.kind in (D.ATTACH, D.OPEN_CO, D.STAGE_FOR_CO)
or flags_after != flags
or state_after != row["drawing_state"]
or bool(patch)
)
if not material:
counts["unchanged"] += 1
return
action = A.VIS_UPDATE_DRAFT if row["status"] == "draft" else A.VIS_OPEN_CO
label, note = "Drawing status from M-Files", d.note or None
if d.kind == D.ATTACH and fetched is not None:
label = "Drawing attached from M-Files"
note = "; ".join(x for x in (fetched.name or found.name or "", d.note) if x) or None
detail = {"dwg": dwg, "rev": found.rev, "kind": d.kind, "asset_id": str(fetched.asset_id) if fetched else None}
try:
with writes.system_write(conn, pid, action, label=label, note=note, detail=detail) as sw:
if int(sw.packet["version"]) != int(row["version"]):
raise _Stale
if d.kind == D.ATTACH and fetched is not None:
_attach(sw, pid, dwg, found, fetched, d, flags_after, patch, now)
counts["attached"] += 1
else:
_status(sw, pid, state_after, flags_after, patch)
if d.flag:
fl = counts["flagged"]
fl[d.kind] = fl.get(d.kind, 0) + 1
if d.kind == D.OPEN_CO and fetched is not None:
_open_co(sw, t, dwg, found, fetched, d, counts)
elif d.kind == D.STAGE_FOR_CO and fetched is not None and pf.co is not None:
_stage(sw, pf.co, dwg, found, fetched)
counts["staged"] += 1
elif d.kind == D.UNCHANGED:
counts["unchanged"] += 1
except (_Stale, writes.Busy, writes.Missing):
counts["busy"] += 1
except writes.Refused as e:
counts["refused"] += 1
ctx.error(f"packet {pid}: {d.kind} refused: {e}", degrade=False)
def _staged(dwg: str, found: Found, fetched: FetchResult) -> dict[str, Any]:
return {
"asset_id": str(fetched.asset_id),
"name": fetched.name or found.name,
"rev": str(found.rev),
"dwg": dwg,
"sha256": fetched.sha256.hex(),
"pages": fetched.pages,
}
def _attach(
sw: Any,
pid: int,
dwg: str,
found: Found,
fetched: FetchResult,
d: D.Decision,
flags: list[str],
patch: dict[str, Any],
now: datetime,
) -> None:
conn = sw.conn
pages = max(1, int(fetched.pages))
conn.execute("DELETE FROM packet_pages WHERE packet_id = %s AND type = 'drawing'", (pid,))
n0 = pipeline.next_counter(conn, pid, "page", pages)
rev = str(found.rev)
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO packet_pages (packet_id, n, type, asset_id, pn, dwg, rev) VALUES (%s, %s, 'drawing', %s, %s,"
" %s, %s)",
[(pid, n0 + i, fetched.asset_id, i + 1, dwg, rev) for i in range(pages)],
)
vault = {
"asset_id": str(fetched.asset_id),
"name": fetched.name or found.name,
"rev": found.rev,
"note": d.note,
"at": now.isoformat(),
}
merged = {**patch, "rev_confirmed": True, "vault": vault}
conn.execute(
"UPDATE packets SET drawing_state = 'attached', flags = %s, visual = (visual - 'manual_dwg') || %s"
" WHERE id = %s",
(flags, Jsonb(merged), pid),
)
sw.changed("pages", "drawing_state", "flags", "visual")
def _status(sw: Any, pid: int, state: str, flags: list[str], patch: dict[str, Any]) -> None:
sw.conn.execute(
"UPDATE packets SET drawing_state = %s::drawing_state, flags = %s, visual = visual || %s WHERE id = %s",
(state, flags, Jsonb(patch), pid),
)
sw.changed("drawing_state", "flags", "visual")
def _open_co(sw: Any, t: D.Target, dwg: str, found: Found, fetched: FetchResult, d: D.Decision, counts: Counts) -> None:
vis = {
"from_dwg": dwg,
"to_dwg": dwg,
"dwg": dwg,
"from_rev": t.attached_rev,
"to_rev": str(found.rev),
"dwg_change": True,
"changes": [d.note],
}
res = changes.open_or_reaim(
sw,
origin="drawing",
reason=d.note,
vis=vis,
waiting=None,
staged=_staged(dwg, found, fetched),
target_fp=None,
minor=False,
)
# open_or_reaim writes the packet_log label itself
counts["cos_opened" if res.fresh else "cos_reaimed"] += 1
def _stage(sw: Any, co: dict[str, Any], dwg: str, found: Found, fetched: FetchResult) -> None:
staged = _staged(dwg, found, fetched)
sw.conn.execute(
"UPDATE change_orders SET staged = %s, waiting = NULL, row_version = row_version + 1"
" WHERE id = %s AND review = 'pending'",
(Jsonb(staged), co["id"]),
)
sw.changed("change_orders")
sw.log(f"Drawing for change order {co['co_no']} ready for Clerical", staged["name"])
def _cadence(fs: Any, now: datetime) -> int | None:
return int(fs.drawing_interval_minutes) * 60
SPEC = JobSpec(name="drawing-lookup", run=run, cadence=_cadence, pausable=True)
__all__ = ["SPEC", "new_flags", "run", "save_lookup", "sharing", "target_for"]
@@ -0,0 +1,60 @@
"""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."""
from __future__ import annotations
from datetime import datetime, time
from ...feed.config import FeedSettings
from . import Counts, JobContext, JobSpec, seconds_until
AT = time(3, 30)
CACHE_DAYS = 90
RUNS_DAYS = 180
REQUESTS_DAYS = 30
STEPS: tuple[tuple[str, str, int], ...] = (
("row_changes", "DELETE FROM visual_row_changes WHERE at < now() - make_interval(days => %s)", CACHE_DAYS),
(
"material_changes",
"DELETE FROM visual_material_changes WHERE at < now() - make_interval(days => %s)",
CACHE_DAYS,
),
(
"gone_rows",
"DELETE FROM visual_rows WHERE gone_batch IN"
" (SELECT id FROM visual_batches WHERE started_at < now() - make_interval(days => %s))",
CACHE_DAYS,
),
(
"gone_materials",
"DELETE FROM visual_materials WHERE gone_batch IN"
" (SELECT id FROM visual_batches WHERE started_at < now() - make_interval(days => %s))",
CACHE_DAYS,
),
(
"job_runs",
"DELETE FROM job_runs WHERE started_at < now() - make_interval(days => %s) AND status <> 'running'",
RUNS_DAYS,
),
(
"job_requests",
"DELETE FROM job_requests WHERE claimed_at IS NOT NULL AND claimed_at < now() - make_interval(days => %s)",
REQUESTS_DAYS,
),
)
def run(ctx: JobContext) -> Counts:
counts: Counts = {}
for name, sql, days in STEPS:
with ctx.conn.transaction():
counts[name] = ctx.conn.execute(sql, (days,)).rowcount
return counts
def cadence(_: FeedSettings, now_site: datetime) -> int:
return seconds_until(now_site, AT)
SPEC = JobSpec(name="housekeeping", run=run, cadence=cadence, pausable=False)
@@ -0,0 +1,141 @@
"""The visual-feed job: read VISUAL (or the fixture files), record the read in the cache, build the candidates
from the cache and apply them.
Per run: the feed settings; the tracked keys (every work order of a live VISUAL packet that is not filed, plus the
go-live rows Clerical pulled in); the window from today in site time; one read through the source (labor at most
every 30 minutes); `cache.write_batch`; `cache.rows_for_apply`; `candidates.build`; `apply.apply`. The run is
degraded when a read section failed or the batch is suspect.
VisualKit: one `Visual.from_env()` per run, closed after, and the stack guard checked against its settings before
the first read. Read-only: VisualKit enforces it.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from ... import guard
from ...feed import apply as feed_apply
from ...feed import cache, candidates, config
from ...feed.config import FeedSettings
from ...feed.source import FeedSource, FixtureSource, VisualKitSource
from ...feed.types import FeedData, WoKey
from . import Counts, JobContext, JobSpec
LABOR_EVERY = timedelta(minutes=30)
def tracked_keys(conn: Any) -> list[WoKey]:
"""Work orders of live VISUAL packets that are not filed, plus pulled-in go-live rows."""
rows = conn.execute(
"SELECT DISTINCT w.wo_base, w.lot, w.split, w.sub FROM packet_work_orders w JOIN packets p"
" ON p.id = w.packet_id WHERE p.removed_at IS NULL AND p.status <> 'filed' AND p.source_kind = 'visual'"
" UNION SELECT wo_base, lot, split, sub FROM visual_ignored_wos WHERE pulled_in_at IS NOT NULL"
).fetchall()
return sorted({WoKey(r[0], r[1], r[2], r[3]) for r in rows}, key=lambda k: k.as_tuple())
def resource_map(conn: Any) -> dict[str, str]:
return {r[0]: r[1] for r in conn.execute("SELECT resource, target FROM visual_resource_map")}
def _labor_due(conn: Any, fs: FeedSettings) -> bool:
if not fs.labor_tickets:
return False
last = cache.last_labor_read(conn)
return last is None or datetime.now(UTC) - last >= LABOR_EVERY
def _read(source: FeedSource, ctx: JobContext, tracked: list[WoKey], window: tuple[Any, Any], labor: bool) -> FeedData:
return source.read(
site=ctx.settings.site_id,
since=window[0],
until=window[1],
tracked=tracked,
with_materials=True,
with_orders=True,
with_labor=labor,
labor_since=None,
)
def run(ctx: JobContext) -> Counts:
conn = ctx.conn
fs = ctx.feed_settings
today = ctx.today_site()
window = config.window(fs, today)
tracked = tracked_keys(conn)
labor_due = _labor_due(conn, fs)
if ctx.settings.feed_source == "fixture":
fixture = FixtureSource(ctx.settings.fixture_dir)
describe = fixture.describe()
data = _read(fixture, ctx, tracked, window, labor_due)
else:
from visualkit import Visual
with Visual.from_env() as v:
# The stack guard against this run's own VisualKit settings, before the first read.
result = guard.check_stack(ctx.settings, v.settings)
ctx.visual_target = ctx.visual_target or result.target
vk = VisualKitSource(v)
describe = vk.describe()
data = _read(vk, ctx, tracked, window, labor_due)
for e in data.errors:
ctx.error(e)
batch = cache.write_batch(conn, ctx.run_id, data, describe, window)
if batch.suspect:
ctx.degrade(f"suspect read: {batch.reason}; nothing marked gone")
in_play = {r.key for r in data.rows} | set(tracked)
rows, mats, mat_legs = cache.rows_for_apply(conn, in_play)
if data.material_legs:
mat_legs |= data.material_legs
built = candidates.build(
rows,
mats,
mat_legs,
resource_map=resource_map(conn),
window=window,
golive=fs.golive_date,
departments=fs.departments,
tracked_keys=set(tracked),
today=today,
)
bases = {r.wo for r in rows}
projects = {candidates.project_of(b) for b in bases} | {c.project for c in built.candidates.values()}
orders = cache.orders(conn, bases=bases | projects, projects=projects)
counts = feed_apply.apply(
conn,
ctx=ctx,
batch=batch,
feed_settings=fs,
built=built,
orders=orders,
labor=cache.labor_hours(conn),
today=today,
seen={r.key: r.st for r in data.rows},
missing=() if batch.suspect else data.tracked_missing,
)
counts["wos_window"] = data.window_count
counts["wos_tracked"] = len(tracked)
counts["rows"] = len(data.rows)
counts["materials"] = len(data.materials) if data.materials is not None else 0
counts["orders"] = int(batch.counts.get("orders", 0))
counts["labor_legs"] = int(batch.counts.get("labor_legs", 0))
counts["batch_id"] = batch.id
if batch.suspect:
counts["suspect_reason"] = batch.reason
cache.finish_batch(conn, batch.id, counts)
return counts
def _cadence(fs: FeedSettings, now_site: datetime) -> int | None:
return config.interval_seconds(fs, now_site)
SPEC = JobSpec(name="visual-feed", run=run, cadence=_cadence, pausable=True, chain=("drawing-lookup",))
__all__ = ["SPEC", "resource_map", "run", "tracked_keys"]
+62
View File
@@ -0,0 +1,62 @@
"""Redaction for job errors and log lines: no password, key or token ever reaches job_runs or a log."""
from __future__ import annotations
import os
import re
MAX_ERRORS = 20
MAX_ERROR_CHARS = 500
# Environment variables whose values are secrets when set (MFilesKit reads MFILES_PASSWORD; VisualKit may read
# VISUAL_PASSWORD). They are read only to strip them from text, never logged.
SECRET_ENV = ("VISUAL_PASSWORD", "MFILES_PASSWORD", "PGPASSWORD", "LDAP_PASSWORD")
SECRET_FILE_ENV = ("VISUAL_PASSWORD_FILE", "MFILES_PASSWORD_FILE")
_PATTERNS = (
re.compile(r"(?i)\b(password|passwd|pwd|secret|token|api[_-]?key)\s*[=:]\s*(\"[^\"]*\"|'[^']*'|[^\s;,&]+)"),
re.compile(r"(?i)\bauthorization\s*:\s*[^\r\n]*"),
re.compile(r"(?i)\bx-authentication\s*:\s*[^\r\n]*"),
re.compile(r"(?i)(\w+://[^:/\s@]+:)([^@\s]+)(@)"),
)
def _secrets() -> list[str]:
out = []
for name in SECRET_ENV:
v = os.environ.get(name)
if v and len(v) >= 4:
out.append(v)
for name in SECRET_FILE_ENV:
path = os.environ.get(name)
if not path:
continue
try:
with open(path, encoding="utf-8") as f:
v = f.read().rstrip("\r\n")
except OSError:
continue
if v and len(v) >= 4:
out.append(v)
return out
def redact(text: str) -> str:
"""Strip anything that looks like a credential, plus the VISUAL and M-Files passwords if set."""
s = str(text)
for secret in _secrets():
s = s.replace(secret, "[redacted]")
s = _PATTERNS[0].sub(lambda m: f"{m.group(1)}=[redacted]", s)
s = _PATTERNS[1].sub("Authorization: [redacted]", s)
s = _PATTERNS[2].sub("X-Authentication: [redacted]", s)
s = _PATTERNS[3].sub(lambda m: f"{m.group(1)}[redacted]{m.group(3)}", s)
return s
def clip(errors: list[str]) -> list[str]:
"""At most MAX_ERRORS redacted strings of at most MAX_ERROR_CHARS each."""
out = []
for e in errors[:MAX_ERRORS]:
r = redact(e)
out.append(r if len(r) <= MAX_ERROR_CHARS else r[: MAX_ERROR_CHARS - 3] + "...")
return out
+173
View File
@@ -0,0 +1,173 @@
"""One run of one job: advisory lock, job_runs row, request claiming, the job itself, the result.
`run_job` works with a started Scheduler (the worker) or an unstarted one (the CLI, the tests): it only needs
`settings`, `jobs`, `stack`, `host`, `build`, `connect()`, `set_feed()`, `running_add()`, `running_discard()`
and `after_run()`.
"""
from __future__ import annotations
import json
import logging
import traceback
from datetime import datetime
from typing import TYPE_CHECKING, Any
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from .. import guard
from ..feed import config
from .jobs import JobContext, JobSpec
from .redaction import clip, redact
if TYPE_CHECKING:
from .scheduler import Scheduler
log = logging.getLogger("packetstation.worker.runs")
LOCK_SPACE = 7310
FEED_JOB = "visual-feed"
__all__ = ["LOCK_SPACE", "redact", "run_job"]
def _jsonable(value: Any) -> Any:
return json.loads(json.dumps(value if value is not None else {}, default=str))
def _row(conn: Any, run_id: int) -> dict[str, Any]:
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute("SELECT * FROM job_runs WHERE id = %s", (run_id,)).fetchone()
return dict(row) if row else {"id": run_id}
def _insert(conn: Any, sch: Scheduler, name: str, trigger: str, *, status: str = "running", counts: Any = None) -> int:
rid = conn.execute(
"INSERT INTO job_runs (job, trigger, status, counts, build_version, host, finished_at)"
" VALUES (%s, %s, %s, %s, %s, %s, CASE WHEN %s THEN now() END) RETURNING id",
(name, trigger, status, Jsonb(_jsonable(counts)), sch.build, sch.host, status != "running"),
).fetchone()[0]
return int(rid)
def _finish(conn: Any, run_id: int, *, status: str, counts: Any, errors: list[str], visual_target: str | None) -> None:
conn.execute(
"UPDATE job_runs SET status = %s, counts = %s, errors = %s, visual_target = coalesce(%s, visual_target),"
" finished_at = now() WHERE id = %s",
(status, Jsonb(_jsonable(counts)), Jsonb(clip(errors)), visual_target, run_id),
)
def _target(result: Any) -> str:
""" "<server> / <database>" from a guard result, without the (DEV) or (PRODUCTION) suffix."""
text = str(getattr(result, "target", "") or "")
return text.rsplit(" (", 1)[0] if text.endswith(")") else text
def run_job(sch: Scheduler, name: str, trigger: str) -> dict[str, Any]:
"""Run `name` once and return its job_runs row. Never raises for a job failure; raises when the database
cannot be reached at all (the caller logs it)."""
spec: JobSpec = sch.jobs[name]
s = sch.settings
conn = sch.connect()
locked = False
run_id: int | None = None
status, counts, errors = "failed", {}, [] # type: tuple[str, Any, list[str]]
visual_target: str | None = None
pending_after = False
sch.running_add(name)
try:
feed = config.load(conn)
sch.set_feed(feed)
locked = bool(conn.execute("SELECT pg_try_advisory_lock(%s, hashtext(%s))", (LOCK_SPACE, name)).fetchone()[0])
if not locked:
rid = _insert(conn, sch, name, trigger, status="skipped", counts={"reason": "already running"})
log.info("job %s skipped: already running", name)
return _row(conn, rid)
with conn.transaction():
run_id = _insert(conn, sch, name, trigger)
claimed = conn.execute(
"UPDATE job_requests SET claimed_run_id = %s, claimed_at = now()"
" WHERE job = %s AND claimed_at IS NULL RETURNING requested_by",
(run_id, name),
).fetchall()
if claimed:
trigger = "request"
who = sorted({r[0] for r in claimed if r[0] is not None}, key=str)
conn.execute("UPDATE job_runs SET trigger = 'request', requested_by = %s WHERE id = %s", (who, run_id))
if trigger == "schedule" and spec.pausable and feed.paused:
status, counts = "skipped", {"reason": "paused"}
else:
ctx = JobContext(
conn=conn,
settings=s,
feed_settings=feed,
run_id=run_id,
trigger=trigger,
started_at=datetime.now(sch.tz),
log=logging.getLogger(f"packetstation.worker.jobs.{name}"),
stack=sch.stack,
)
log.info("job %s started", name, extra={"ps": {"run_id": run_id, "trigger": trigger}})
try:
if name == FEED_JOB and s.feed_source == "visualkit":
# The stack guard again with fresh VisualKit settings: a test stack never reads production
ctx.visual_target = _target(guard.check_stack(s))
counts = spec.run(ctx) or {}
status = "degraded" if ctx.degraded else "ok"
except Exception as e:
status = "failed"
ctx.errors.append(f"{type(e).__name__}: {e}")
log.error("job %s failed: %s", name, redact(traceback.format_exc()))
errors, visual_target = ctx.errors, ctx.visual_target
try:
_finish(conn, run_id, status=status, counts=counts, errors=errors, visual_target=visual_target)
except Exception:
# The run's connection is broken: write the result on a fresh one
with sch.connect() as c2:
_finish(c2, run_id, status=status, counts=counts, errors=errors, visual_target=visual_target)
try:
pending_after = bool(
conn.execute(
"SELECT EXISTS (SELECT 1 FROM job_requests WHERE job = %s AND claimed_at IS NULL)", (name,)
).fetchone()[0]
)
row = _row(conn, run_id)
except Exception:
with sch.connect() as c2:
row = _row(c2, run_id)
log.info(
"job %s finished: %s", name, status, extra={"ps": {"run_id": run_id, "status": status, "counts": counts}}
)
return row
except Exception as e:
status = "failed"
if run_id is not None:
try:
with sch.connect() as c2:
_finish(
c2,
run_id,
status="failed",
counts=counts,
errors=[f"{type(e).__name__}: {e}"],
visual_target=visual_target,
)
except Exception as e2:
log.error("job %s: can't record the failure: %s", name, type(e2).__name__)
raise
finally:
if locked:
try:
conn.execute("SELECT pg_advisory_unlock(%s, hashtext(%s))", (LOCK_SPACE, name))
except Exception as e:
log.warning("job %s: unlock failed (%s); closing the connection releases it", name, type(e).__name__)
try:
conn.close()
finally:
sch.running_discard(name)
if run_id is not None:
sch.after_run(name, status, pending_after)
+395
View File
@@ -0,0 +1,395 @@
"""The worker's scheduler: APScheduler 3 in site time, settings read on every fire, "run now" over NOTIFY.
Each job has a SettingsTrigger whose next fire time comes from the job's cadence and the current feed settings,
so a changed interval applies on the next fire. A save NOTIFYs `ps_settings` and the listener calls
`reschedule()` so it applies at once. A "run now" NOTIFYs `ps_job_request` and the listener moves the job to now.
After a run the job's next fire is measured from the end of the run.
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
import socket
import threading
from collections.abc import Callable, Iterable
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.base import BaseTrigger
from .. import db
from ..feed import config
from ..feed.config import FeedSettings
from ..settings import Settings
from . import heartbeat, runs
from .jobs import JobSpec, Stack, load_jobs
from .redaction import redact
log = logging.getLogger("packetstation.worker")
CHANNELS = ("ps_settings", "ps_job_request")
IDLE_RECHECK_SECONDS = 3600 # a job with no schedule wakes this often and does nothing
BACKOFF_MAX = 30.0
THREADS = 4
MISFIRE_GRACE = 300
class SettingsTrigger(BaseTrigger):
"""Fires `seconds(now)` after now. `seconds` reads the current settings, so a change applies on the next fire.
None means "no schedule": the trigger wakes again after an hour and the fire does nothing."""
__slots__ = ("seconds",)
def __init__(self, seconds: Callable[[datetime], int | None]):
self.seconds = seconds
def get_next_fire_time(self, previous_fire_time: datetime | None, now: datetime) -> datetime:
secs = self.seconds(now)
return now + timedelta(seconds=max(1, IDLE_RECHECK_SECONDS if secs is None else int(secs)))
def __str__(self) -> str:
return "settings"
def next_due(
spec: JobSpec, feed: FeedSettings, now_site: datetime, last_end_site: datetime | None = None
) -> datetime | None:
"""When the job is next due: its cadence measured from the end of its last run (or from now when it never ran),
never before now. None when the cadence says "no schedule"."""
base = last_end_site if last_end_site is not None and last_end_site <= now_site else now_site
secs = spec.cadence(feed, base)
if secs is None:
return None
due = base + timedelta(seconds=max(1, int(secs)))
return due if due > now_site else now_site
class Scheduler:
def __init__(
self,
settings: Settings,
*,
build: str | None = None,
jobs: dict[str, JobSpec] | None = None,
connect: Callable[[], Any] | None = None,
):
self.settings = settings
self.build = build or settings.ps_build
self.jobs = dict(jobs) if jobs is not None else dict(load_jobs())
self.tz = ZoneInfo(settings.site_tz)
self._connect = connect or (lambda: db.connect_as("worker", settings, autocommit=True))
self.stack = Stack()
self.host = socket.gethostname()
self.pid = os.getpid()
self.feed = FeedSettings()
self._lock = threading.Lock()
self._running: set[str] = set()
self._next_trigger: dict[str, str] = {}
self._again: dict[str, str] = {}
self._last_end: dict[str, datetime] = {}
self._db_ready = False
self._stop = threading.Event()
self._listener: threading.Thread | None = None
self.listening = False
self.aps = BackgroundScheduler(
executors={"default": ThreadPoolExecutor(THREADS)},
job_defaults={"coalesce": True, "max_instances": 1, "misfire_grace_time": MISFIRE_GRACE},
timezone=self.tz,
)
self.started = False
# ------------------------------------------------ small helpers used by runs.run_job
def connect(self) -> Any:
return self._connect()
def now(self) -> datetime:
return datetime.now(self.tz)
def set_feed(self, feed: FeedSettings) -> None:
self.feed = feed
def running(self) -> list[str]:
with self._lock:
return sorted(self._running)
def running_add(self, name: str) -> None:
with self._lock:
self._running.add(name)
def running_discard(self, name: str) -> None:
with self._lock:
self._running.discard(name)
# ------------------------------------------------ lifecycle
def start(self) -> None:
"""Set up the database side when it is reachable, add the jobs and start the scheduler and listener."""
self._setup_db()
now = self.now()
for name, spec in self.jobs.items():
self.aps.add_job(
self._fire,
trigger=SettingsTrigger(self._seconds_for(spec)),
args=(name,),
id=name,
name=name,
next_run_time=self._next_time(spec, now),
replace_existing=True,
)
self.aps.start()
self.started = True
self._write_schedule(self.jobs)
self._stop.clear()
self._listener = threading.Thread(target=self._listen, name="ps-worker-listen", daemon=True)
self._listener.start()
log.info("scheduler started", extra={"ps": {"jobs": sorted(self.jobs), "host": self.host}})
def shutdown(self, wait: bool = True) -> None:
self._stop.set()
if self.started:
self.aps.shutdown(wait=wait)
self.started = False
if self._listener is not None:
self._listener.join(5.0)
self._listener = None
def _setup_db(self) -> bool:
"""Seed the feed settings, mark this host's leftover runs abandoned, load the settings and the last run
ends. Safe to call again; returns False when the database is not reachable."""
if self._db_ready:
return True
try:
with self.connect() as conn:
with conn.transaction():
if config.seed(conn):
log.info("feed settings seeded with the defaults")
self._abandon(conn)
self.feed = config.load(conn)
for job, end in conn.execute(
"SELECT job, max(finished_at) FROM job_runs WHERE finished_at IS NOT NULL"
" AND status <> 'skipped' GROUP BY job"
).fetchall():
if end is not None:
self._last_end[job] = end.astimezone(self.tz)
except Exception as e:
log.warning("worker database setup deferred: %s", redact(f"{type(e).__name__}: {e}"))
return False
self._db_ready = True
return True
def _abandon(self, conn: Any) -> None:
"""Runs left `running` by a dead worker become `abandoned`: this host's always, another host's when no
session holds that job's advisory lock (a container recreated with a new host name)."""
rows = conn.execute("SELECT id, job, host FROM job_runs WHERE status = 'running'").fetchall()
for rid, job, host in rows:
if host != self.host:
got = conn.execute("SELECT pg_try_advisory_lock(%s, hashtext(%s))", (runs.LOCK_SPACE, job)).fetchone()[
0
]
if not got:
continue
conn.execute("SELECT pg_advisory_unlock(%s, hashtext(%s))", (runs.LOCK_SPACE, job))
conn.execute(
"UPDATE job_runs SET status = 'abandoned', finished_at = now(),"
" errors = errors || '[\"The worker stopped during this run.\"]'::jsonb"
" WHERE id = %s AND status = 'running'",
(rid,),
)
log.warning("job run %s (%s) marked abandoned", rid, job)
# ------------------------------------------------ timing
def _seconds_for(self, spec: JobSpec) -> Callable[[datetime], int | None]:
def seconds(now: datetime) -> int | None:
return spec.cadence(self.feed, now.astimezone(self.tz))
return seconds
def _next_time(self, spec: JobSpec, now: datetime) -> datetime:
due = next_due(spec, self.feed, now, self._last_end.get(spec.name))
return due if due is not None else now + timedelta(seconds=IDLE_RECHECK_SECONDS)
def reschedule(self, names: Iterable[str] | None = None) -> None:
"""Recompute the next run of every job (or of `names`) from the current settings."""
if not self.started:
return
now = self.now()
todo = list(names) if names is not None else list(self.jobs)
for name in todo:
spec = self.jobs.get(name)
if spec is None:
continue
with contextlib.suppress(Exception): # JobLookupError during shutdown
self.aps.modify_job(name, next_run_time=self._next_time(spec, now))
self._write_schedule(todo)
def run_soon(self, name: str, trigger: str = "request") -> None:
"""Move a job to now. A job that is running now runs again right after."""
if name not in self.jobs:
log.warning("ignoring a run request for unknown job %r", name[:60])
return
with self._lock:
if name in self._running:
self._again[name] = trigger
return
self._next_trigger[name] = trigger
if self.started:
with contextlib.suppress(Exception):
self.aps.modify_job(name, next_run_time=self.now())
def after_run(self, name: str, status: str, pending: bool) -> None:
"""Called by run_job when a run ends: chain, run again for new requests, else time the next run from now."""
self._last_end[name] = self.now()
spec = self.jobs[name]
with self._lock:
again = self._again.pop(name, None)
if status in ("ok", "degraded"):
for c in spec.chain:
self.run_soon(c, "chain")
if pending or again:
self.run_soon(name, again or "request")
self._write_schedule([name])
else:
self.reschedule([name])
def _fire(self, name: str) -> None:
with self._lock:
trigger = self._next_trigger.pop(name, "schedule")
spec = self.jobs[name]
if not self._db_ready and not self._setup_db():
log.warning("job %s not run: database not reachable", name)
return
if trigger == "schedule" and spec.cadence(self.feed, self.now()) is None:
self._write_schedule([name])
return
try:
runs.run_job(self, name, trigger)
except Exception as e:
log.error("job %s could not run: %s", name, redact(f"{type(e).__name__}: {e}"))
def _write_schedule(self, names: Iterable[str]) -> None:
"""job_schedule rows for the Admin and Clerical screens. Best effort."""
if not self.started:
return
rows = []
now = self.now()
for name in names:
spec = self.jobs.get(name)
if spec is None:
continue
job = self.aps.get_job(name)
nxt = job.next_run_time if job is not None else None
secs = spec.cadence(self.feed, now)
if secs is None:
nxt = None
rows.append((name, nxt, secs, bool(spec.pausable and self.feed.paused)))
if not rows:
return
try:
with self.connect() as conn:
for r in rows:
conn.execute(
"INSERT INTO job_schedule (job, next_run_at, interval_seconds, paused, updated_at)"
" VALUES (%s, %s, %s, %s, now()) ON CONFLICT (job) DO UPDATE SET"
" next_run_at = EXCLUDED.next_run_at, interval_seconds = EXCLUDED.interval_seconds,"
" paused = EXCLUDED.paused, updated_at = now()",
r,
)
except Exception as e:
log.warning("job_schedule not written: %s", type(e).__name__)
# ------------------------------------------------ settings
def reload_settings(self, conn: Any | None = None) -> None:
try:
if conn is not None:
self.feed = config.load(conn)
else:
with self.connect() as c:
self.feed = config.load(c)
except Exception as e:
log.warning("feed settings not reloaded: %s", type(e).__name__)
return
self.reschedule()
# ------------------------------------------------ listener
def _on_connect(self, conn: Any) -> None:
"""After every (re)connect: catch up on what was missed while not listening."""
self._setup_db()
self.reload_settings(conn)
for (job,) in conn.execute("SELECT DISTINCT job FROM job_requests WHERE claimed_at IS NULL").fetchall():
self.run_soon(job, "request")
def _notified(self, channel: str, payload: str) -> None:
if channel == "ps_settings":
self.reload_settings()
return
try:
data = json.loads(payload)
except ValueError:
log.warning("ignoring a non-JSON ps_job_request payload")
return
job = data.get("job") if isinstance(data, dict) else None
if isinstance(job, str):
self.run_soon(job, "request")
def _listen(self) -> None:
delay = 1.0
while not self._stop.is_set():
conn = None
try:
conn = self.connect()
for ch in CHANNELS:
conn.execute(f"LISTEN {ch}")
self.listening = True
delay = 1.0
log.info("worker listening on %s", ", ".join(CHANNELS))
self._on_connect(conn)
while not self._stop.is_set():
for n in conn.notifies(timeout=1.0):
self._notified(n.channel, n.payload)
except Exception as e:
if not self._stop.is_set():
log.warning("worker listener: %s; retrying in %.0f s", type(e).__name__, delay)
finally:
self.listening = False
if conn is not None:
with contextlib.suppress(Exception):
conn.close()
if self._stop.wait(delay):
break
delay = min(delay * 2, BACKOFF_MAX)
# ------------------------------------------------ heartbeat loop (main thread)
def serve(self, beat_file: Path, stop: threading.Event, interval: float) -> None:
"""Beat until `stop`: the beat file and the worker_heartbeat row."""
conn = None
while not stop.is_set():
try:
heartbeat.touch(beat_file)
except OSError as e:
log.error("can't touch beat file %s: %s", beat_file, e.strerror)
try:
if conn is None or conn.closed:
conn = self.connect()
heartbeat.write_row(conn, build=self.build, host=self.host, pid=self.pid, running=self.running())
except Exception as e:
log.warning("heartbeat row not written: %s", type(e).__name__)
if conn is not None:
with contextlib.suppress(Exception):
conn.close()
conn = None
stop.wait(interval)
if conn is not None:
with contextlib.suppress(Exception):
conn.close()
+3
View File
@@ -17,6 +17,8 @@ httpx==0.28.1
tzdata==2026.5
pypdf==6.21.0
python-multipart==0.0.32
pypdfium2==5.14.0
openpyxl==3.1.5
# needed by visualkit 0.3.0 and mfileskit 0.1.2
pyodbc==5.3.0
@@ -32,6 +34,7 @@ cffi==2.1.1
click==8.5.0
colorama==0.4.6; platform_system == "Windows"
cryptography==50.0.2; sys_platform == "linux"
et-xmlfile==2.0.0
h11==0.16.0
httpcore==1.0.9
httptools==0.9.0
+1 -1
View File
@@ -38,7 +38,7 @@ test("3. a user with no PS group sees the no-role message", async ({ page }) =>
test("4. clerk.clara lands on Clerical with no role switcher", async ({ page }) => {
await signInPassword(page, "clerk.clara");
await expect(page).toHaveURL(/\/clerical/);
await expect(page.getByText(/Arrives in phase/i)).toBeVisible();
await expect(page.getByRole("heading", { name: /^Changes to approve/ })).toBeVisible();
await expect(page.locator(".roles")).toHaveCount(0);
await expect(topBar(page).getByRole("button", { name: /^Supervisor$/ })).toHaveCount(0);
});
+211
View File
@@ -0,0 +1,211 @@
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import { dcUser, signInPassword } from "./helpers";
// Phase 3 VISUAL feed scenario on the CI stack. The worker reads the synthetic fixture files
// (tests/fixtures/generator.py) mounted at /fixtures; this container mounts them read-write so the
// scenario can switch the state. clerk.clara presses Run now, the feed makes project L900 and its
// drafts, the chained drawing run attaches the WELD-PART drawing, the packet goes to the warehouse and
// wh.wade releases it to the shop. State 2 moves that drawing to rev 2: the next run opens a change
// order, the drawing run stages the rev 2 PDF, and clara approves it with her password.
const FIXTURE_DIR = process.env.FIXTURE_DIR ?? "/fixtures";
const KEY_WELD_PART = "L900001|WELD-PART|L900-0101";
const POLL_MS = 60_000;
type Json = Record<string, any>;
function writeHeaders(page: Page): Record<string, string> {
const base = new URL(page.url()).origin;
return { Origin: base, "X-Requested-With": "PacketStation" };
}
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 lastRunId(page: Page): Promise<number> {
const o = await getJson(page.request, "/api/feed/overview");
return o.last_run ? Number(o.last_run.id) : 0;
}
/** Wait until a visual-feed run newer than `after` has finished ok (or degraded). Returns it. */
async function waitForRun(page: Page, after: number): Promise<Json> {
let run: Json | null = null;
await expect
.poll(
async () => {
const o = await getJson(page.request, "/api/feed/overview");
run = (o.runs as Json[]).find((r) => Number(r.id) > after && r.status !== "running") ?? null;
return run ? run.status : "waiting";
},
{ timeout: POLL_MS, intervals: [1_000, 2_000] },
)
.toMatch(/^(ok|degraded)$/);
return run as unknown as Json;
}
async function pressRunNow(page: Page): Promise<void> {
const button = page.getByRole("button", { name: /run now/i }).first();
await expect(button).toBeEnabled({ timeout: POLL_MS });
await button.click();
}
/** The live packet for a candidate key "<unit>|<op>|<drawing>", found by its card fields. */
async function packetByKey(page: Page, key: string): Promise<Json> {
const [unit, op, drawing] = key.split("|");
const list = await getJson(page.request, "/api/packets?limit=2000");
const card = (list.packets as Json[]).find(
(p) => p.unit?.number === unit && p.op === op && (p.drawing ?? "") === drawing && p.source_kind === "visual",
);
if (!card) throw new Error(`no packet for ${key}`);
return getJson(page.request, `/api/packets/${card.id}`);
}
async function transition(page: Page, pkt: Json, action: string, extra: Json = {}) {
return page.request.post(`/api/packets/${pkt.id}/transitions`, {
headers: writeHeaders(page),
data: { action, expect: { status: pkt.status, dept: pkt.dept?.code ?? pkt.dept_code }, ...extra },
});
}
let packetId = 0;
test.describe.serial("VISUAL feed", () => {
test.setTimeout(300_000);
test("Run now makes project L900, its drafts and attaches the WELD-PART drawing", async ({ page }) => {
writeFileSync(join(FIXTURE_DIR, "state"), "1");
await signInPassword(page, "clerk.clara");
await expect(page).toHaveURL(/\/clerical/);
await expect(page.getByText(/from visual/i).first()).toBeVisible();
const before = await lastRunId(page);
await pressRunNow(page);
const run = await waitForRun(page, before);
expect(run.counts.new_drafts).toBeGreaterThan(0);
const projects = await getJson(page.request, "/api/projects");
const list = projects as unknown as Json[];
const l900 = list.find((p) => p.number === "L900");
expect(l900, "project L900").toBeTruthy();
const proj = await getJson(page.request, `/api/projects/${l900!.id}`);
const unitNumbers = ((proj.units ?? []) as Json[]).map((u) => u.number);
expect(unitNumbers).toEqual(expect.arrayContaining(["L900001", "L900002"]));
let pkt = await packetByKey(page, KEY_WELD_PART);
packetId = Number(pkt.id);
expect(pkt.status).toBe("draft");
// Either still waiting for its drawing, or the chained drawing job already attached it
const flags = pkt.flags as string[];
expect(flags.includes("drawing_needed") || pkt.drawing_state === "attached").toBe(true);
// The drawing job runs right after the feed (chained)
await expect
.poll(async () => (await getJson(page.request, `/api/packets/${packetId}`)).drawing_state, {
timeout: POLL_MS,
intervals: [1_000, 2_000],
})
.toBe("attached");
await page.goto(`/packets/${packetId}`);
// The Drawing tab shows the pages the feed attached from M-Files (the fixture vault)
const full = await getJson(page.request, `/api/packets/${packetId}`);
expect(((full.pages ?? []) as Json[]).length).toBeGreaterThan(0);
await expect(page.getByRole("tab", { name: /^Drawing/ }).or(page.getByRole("button", { name: /^Drawing/ })).first()).toBeVisible();
// Send it on, unless the feed auto-released it already
pkt = await getJson(page.request, `/api/packets/${packetId}`);
if (pkt.status === "draft") {
if (pkt.heat_map === null || pkt.heat_map === undefined) {
const r = await page.request.patch(`/api/packets/${packetId}`, {
headers: writeHeaders(page),
data: { set: { heat_map: true }, base: {} },
});
expect(r.status(), await r.text()).toBe(200);
pkt = await getJson(page.request, `/api/packets/${packetId}`);
}
const r = await transition(page, pkt, "send_to_warehouse");
if (r.status() !== 200) {
// the worker may have auto-released it between the two reads
pkt = await getJson(page.request, `/api/packets/${packetId}`);
expect(pkt.status, await r.text()).toBe("warehouse");
}
}
pkt = await getJson(page.request, `/api/packets/${packetId}`);
expect(pkt.status).toBe("warehouse");
});
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
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 },
});
expect(pr.status(), await pr.text()).toBe(200);
}
pkt = await getJson(page.request, `/api/packets/${packetId}`);
const r = await transition(page, pkt, "wh_release");
expect(r.status(), await r.text()).toBe(200);
expect((await r.json()).status).toBe("ready");
});
test("VISUAL moves the drawing to rev 2: a change order, staged, approved", async ({ page }) => {
await signInPassword(page, "clerk.clara");
await expect(page).toHaveURL(/\/clerical/);
writeFileSync(join(FIXTURE_DIR, "state"), "2");
const before = await lastRunId(page);
await pressRunNow(page);
const run = await waitForRun(page, before);
expect(run.counts.cos_opened).toBeGreaterThan(0);
const cos = await getJson(page.request, `/api/packets/${packetId}/change-orders`);
const list = cos as unknown as Json[];
const co = list.find((c) => c.review === "pending");
expect(co, "a pending change order").toBeTruthy();
// The Changes tab on the packet and the Clerical home both show it
await page.goto(`/packets/${packetId}`);
await page.getByRole("tab", { name: /changes/i }).or(page.getByRole("button", { name: /^changes/i })).first().click();
await expect(page.getByText(/waiting on clerical/i).first()).toBeVisible();
await page.goto("/clerical");
await expect(page.getByText(/changes to approve/i).first()).toBeVisible();
await expect(page.getByText("L900001").first()).toBeVisible();
// The chained drawing run stages the rev 2 PDF
await expect
.poll(
async () => {
const all = await getJson(page.request, `/api/packets/${packetId}/change-orders`);
const c = (all as unknown as Json[]).find((x) => x.co_no === co!.co_no);
return c?.staged ? "staged" : "waiting";
},
{ timeout: POLL_MS, intervals: [1_000, 2_000] },
)
.toBe("staged");
const clara = dcUser("clerk.clara");
const r = await page.request.post(`/api/packets/${packetId}/change-orders/${co!.co_no}/approve`, {
headers: writeHeaders(page),
data: { sign: { factor: "password", secret: clara.password } },
});
expect(r.status(), await r.text()).toBe(200);
const pkt = await getJson(page.request, `/api/packets/${packetId}`);
expect(pkt.rev).toBe("2");
// The old rev 1 pages are kept and voided; the rev 2 pages carry the change order
const pages = (pkt.pages ?? []) as Json[];
expect(pages.some((pg) => pg.void)).toBe(true);
expect(pages.some((pg) => !pg.void && pg.rev === "2")).toBe(true);
await page.goto(`/packets/${packetId}`);
await expect(page.getByText(`${pkt.drawing} R2`, { exact: true }).first()).toBeVisible();
// The Drawing tab lists the voided rev 1 page beside the new rev 2 page
await expect(page.getByRole("button", { name: /\bvoid\b/i }).first()).toBeVisible();
});
});
+67
View File
@@ -0,0 +1,67 @@
# Synthetic VISUAL and M-Files fixtures
Everything here is made up. Real job data never goes in git: recordings from DEV stay in
`tests/fixtures/local/` (ignored).
```
python -m tests.fixtures.generator --out tests/fixtures/generated
```
writes `feed.1.json`, `feed.2.json`, `drawings.1.json`, `drawings.2.json`, the one-page PDFs the drawing files
name, and `state` (`1`). `tests/fixtures/generated/` is ignored by git. CI runs the command before
`compose up` and mounts the folder at `/fixtures` in the worker (read-only) and Playwright (read-write).
With `FEED_SOURCE=fixture` and `DRAWING_SOURCE=fixture` the worker reads `FIXTURE_DIR/state` and the
matching files instead of VISUAL and M-Files. Write `2` to `state` to switch.
The feed files have the shape of VisualKit's `examples/packet_station_feed.py`: rows carry the status as a
word (Firmed, Released, Unreleased, Closed, Cancelled), materials carry the status code, numbers are JSON
numbers, dates are ISO, lot, split and sub are text, seq and piece are integers. An extra `labor` list holds
hours per operation.
Dates are offsets from the day the generator runs (`--today` overrides), so the window rows sit inside the
default window. The output is the same for the same day.
## State 1
Project L900, units L900001 and L900002, plus L901001 under project L901.
| Work order | Lot | What it covers |
| --- | --- | --- |
| L900-01 | 1 | LAYOUT and WELD-PART on drawing L900-0101 rev 1 (two packets), MFG and MAT_PIPE fold, FORKLIFT ignored; split-item header (piece 10 with 11 and 12), MAIN warehouse line, WIP sub-assembly, a line on another drawing (dropped) |
| L900-01 split 1 | 1 | Unreleased (only on a packet when tracked) |
| L900-02 | 1 | two WELD-CODE operations on one drawing: two legs on one packet; VISUAL rev 2, M-Files rev 3 (rev mismatch) |
| L900-03 | 1 | no drawing (`(no drawing)` key) |
| L900-04, L900-08-1 | 2 | one packet, rev `A` (bad rev), piece 1 on both legs (items `1` and `1-1`), a base linked to order C24-0901 |
| L900-05 | 2 | drawing in drafting (not released) |
| L900-06 | 2 | Released, with materials: only a packet when tracked |
| L900-07 | 2 | drawing not in M-Files |
| L900-09, L900-10 | 1 | close and cancel in state 2 |
| L900-11, L900-12 | 1 | Closed and Cancelled, only when tracked |
| L900-13 | 2 | released 10 days back: ignored when the tests set go-live 5 days back |
| L900-14 | 7A | a lettered lot: no unit, no packet |
| L900-15 | 2 | resource NEWRES, not in the map |
| L900-16 | 2 | moves to drawing L900-1602 in state 2 |
| L901-01 | 1 | project L901, only a closed order |
Orders: L900 has two open orders. C24-0901 (Acme Refining) names top job L900002 and is linked to base
L900-04. C24-0902 (Bayou Chemical) names L900001, so the unit rule picks it for L900001. L901 has one closed
order (Delta Gas).
## State 2
- L900-01 rev 1 to 2 (a change order on an in-shop packet; the LAYOUT draft updates in place).
- L900-02 qty 2 to 3 (a draft updated in place).
- L900-07 target hours only (a minor change order once past draft).
- L900-05 gone from VISUAL (flagged after two runs).
- L900-16 on drawing L900-1602 (the old draft retires, a new one is made).
- L900-09 Closed, L900-10 Cancelled.
- NEWRES still unmapped.
## Drawings
`drawings.1.json`: L900-0101 rev 1 (attach), L900-0201 rev 3 (VISUAL calls 2), L900-0401 rev 1 (VISUAL calls
A), L900-0501 in drafting, L900-0701 and L900-1601 missing (not found), the rest at rev 1.
`drawings.2.json`: the same with L900-0101 at rev 2 (a new file, so the change order gets staged) and L900-1602.
`generate(out_dir)`, `feed_state(n)` and `drawings_state(n)` are importable; `tests/server/feedhelpers.py`
uses them.
+672
View File
@@ -0,0 +1,672 @@
"""Synthetic VISUAL feed and M-Files drawing fixtures for tests and CI. No real job data.
python -m tests.fixtures.generator --out tests/fixtures/generated
Writes feed.1.json, feed.2.json, drawings.1.json, drawings.2.json, the one-page PDFs the drawing files name, and
`state` = "1". The feed files have the exact shape of VisualKit's examples/packet_station_feed.py: rows carry the
status as a word, materials carry the status code, numbers are JSON numbers, dates are ISO, lot/split/sub are text,
seq and piece are ints. An optional `labor` list follows the FixtureSource contract.
Dates are offsets from `today` (default: the date the generator runs), so the window rows stay inside the feed's
default window (14 days back, 30 ahead) on the day the files are made. Everything else is fixed: the output is the
same for the same `today`.
"""
from __future__ import annotations
import argparse
import io
import json
import sys
from datetime import date, timedelta
from pathlib import Path
from typing import Any
SITE = "PESCO"
STATUS_WORDS = {"F": "Firmed", "R": "Released", "U": "Unreleased", "C": "Closed", "X": "Cancelled"}
# The part of VISUAL's resource map the fixtures use (the seed in migration 0004 has these too). NEWRES is unmapped.
RESOURCE_MAP: dict[str, str] = {
"LAYOUT": "01",
"WELD-PART": "03",
"WELD-CODE": "06",
"MFG": "fold",
"FORKLIFT": "ignore",
}
# Go-live: the tests set feed settings golive_date = today + GOLIVE_OFFSET. Work order L900-13 is released before it.
GOLIVE_OFFSET = -5
# Names the tests and the e2e scenario refer to.
PROJECT = "L900"
PROJECT_CLOSED_ORDER = "L901"
UNIT1, UNIT2 = "L900001", "L900002"
KEY_WELD_PART = "L900001|WELD-PART|L900-0101" # rev 1 -> 2 in state 2; the in-shop packet in the scenario
KEY_LAYOUT_A = "L900001|LAYOUT|L900-0101" # same work order, another resource: a second packet
KEY_TWO_OPS = "L900001|WELD-CODE|L900-0201" # two operations, same resource and drawing; qty change in state 2
KEY_NO_DWG = "L900001|WELD-PART|(no drawing)"
KEY_REV_A = "L900002|WELD-PART|L900-0401" # non-numeric rev; two legs with duplicate pieces
KEY_DRAFTING = "L900002|LAYOUT|L900-0501" # drawing in drafting; this leg is gone from VISUAL in state 2
KEY_NOT_FOUND = "L900002|WELD-PART|L900-0701" # drawing not in M-Files; tgt-only change in state 2 (minor)
KEY_CLOSES = "L900001|WELD-CODE|L900-0901" # closed in VISUAL in state 2
KEY_CANCELS = "L900001|WELD-CODE|L900-1001" # cancelled in VISUAL in state 2
KEY_MOVES = "L900002|WELD-CODE|L900-1601" # moves to drawing L900-1602 in state 2 (retire-moved)
KEY_MOVED_TO = "L900002|WELD-CODE|L900-1602"
KEY_GOLIVE = "L900002|WELD-PART|L900-1301" # released before go-live when the tests set it
KEY_L901 = "L901001|WELD-PART|L901-0101"
KEY_RELEASED = "L900002|WELD-CODE|L900-0601" # Released (not Firmed): only a candidate when tracked
DWG_WELD_PART = "L900-0101"
# Every candidate state 1 makes on the first run (no tracked keys, no go-live).
STATE1_KEYS = frozenset(
{
KEY_WELD_PART,
KEY_LAYOUT_A,
KEY_TWO_OPS,
KEY_NO_DWG,
KEY_REV_A,
KEY_DRAFTING,
KEY_NOT_FOUND,
KEY_CLOSES,
KEY_CANCELS,
KEY_MOVES,
KEY_GOLIVE,
KEY_L901,
}
)
ORDER_ACME = "C24-0901" # open, top job L900002, also linked to base L900-04
ORDER_BAYOU = "C24-0902" # open, top job L900001 (the unit rule must pick it for L900001)
ORDER_L901 = "C23-0450" # closed, the only order of L901
def _d(today: date, days: int | None) -> str | None:
return None if days is None else (today + timedelta(days=days)).isoformat()
def _num(x: float | int) -> int | float:
"""A JSON number as the feed writes it: integers without '.0'."""
return int(x) if float(x).is_integer() else float(x)
class _Wo:
"""One work order leg with its operations, as the generator describes it."""
def __init__(
self,
base: str,
lot: str,
ops: list[tuple[int, str, float]],
*,
dwg: str,
rev: str,
st: str,
rel: int,
want: int,
qty: float,
desc: str,
split: str = "0",
sub: str = "0",
part: str = "",
ps: dict[int, str] | None = None,
) -> None:
self.base, self.lot, self.split, self.sub = base, lot, split, sub
self.ops = ops # (seq, resource, run hours per piece)
self.dwg, self.rev, self.st, self.rel, self.want, self.qty = dwg, rev, st, rel, want, qty
self.desc, self.part, self.ps = desc, part or f"FG-{base}", ps or {}
def rows(self, today: date) -> list[dict[str, Any]]:
out = []
for seq, res, run in self.ops:
out.append(
{
"wo": self.base,
"lot": self.lot,
"split": self.split,
"sub": self.sub,
"seq": seq,
"res": res,
"dwg": self.dwg,
"rev": self.rev,
"ps": self.ps.get(seq, ""),
"desc": self.desc,
"qty": _num(self.qty),
"tgt": _num(round(run * self.qty, 4)),
"rel": _d(today, self.rel),
"want": _d(today, self.want),
"st": STATUS_WORDS[self.st],
"barcode": f"*%2{self.base}${self.lot}${self.split}${self.sub}${seq}%*",
"part": self.part,
"site": SITE,
"order": None,
}
)
return out
def _mat(
wo: _Wo,
seq: int,
piece: int,
part: str | None,
qty: float,
*,
issued: float = 0,
whs: str = "SHOP",
loc: str = "R1",
comm: str = "PIPE",
cons: str = "N",
desc: str | None = None,
dwg: str | None = None,
st: str | None = None,
closed: bool = False,
) -> dict[str, Any]:
calc = qty
if issued > calc:
istat = 3
elif closed:
istat = 0
elif issued == 0:
istat = 2
else:
istat = 1
return {
"wo": wo.base,
"lot": wo.lot,
"split": wo.split,
"sub": wo.sub,
"seq": seq,
"piece": piece,
"part": part,
"qty": _num(qty),
"issued": _num(issued),
"whs": whs if part else "",
"loc": loc if part else "",
"st": st or wo.st,
"istat": istat,
"dwg": wo.dwg if dwg is None else dwg,
"desc": desc if desc is not None else (f"Part {part}" if part else "Split item"),
"comm": comm if part else "",
"cons": cons if part else "",
}
def _work_orders() -> dict[str, _Wo]:
"""State 1's work orders by a short name."""
w: dict[str, _Wo] = {}
# A: two operations at different resources on one drawing (two packets, one BOM each), plus fold and ignore rows
w["A"] = _Wo(
"L900-01",
"1",
[(10, "LAYOUT", 0.5), (20, "WELD-PART", 1.5), (30, "MFG", 0.25), (40, "FORKLIFT", 0.1), (50, "MAT_PIPE", 0)],
dwg="L900-0101",
rev="1",
st="F",
rel=-3,
want=20,
qty=2,
desc="Inlet spool 6 in",
ps={10: "Cut to length per sheet 1", 20: "Weld per WPS-11\r\nBack purge", 30: "Weld per WPS-11\r\nBack purge"},
)
# A2: an Unreleased split of A's base (only on a packet when tracked)
w["A2"] = _Wo(
"L900-01",
"1",
[(20, "WELD-PART", 1.5)],
dwg="L900-0101",
rev="1",
st="U",
rel=-3,
want=25,
qty=1,
desc="Inlet spool 6 in",
split="1",
)
# B: two operations at the same resource and drawing (two legs with different seq on one packet)
w["B"] = _Wo(
"L900-02",
"1",
[(10, "WELD-CODE", 2), (20, "WELD-CODE", 1)],
dwg="L900-0201",
rev="2",
st="F",
rel=-2,
want=21,
qty=2,
desc="Header box",
)
# C: no drawing
w["C"] = _Wo(
"L900-03", "1", [(10, "WELD-PART", 0.75)], dwg="", rev="", st="F", rel=-1, want=18, qty=4, desc="Pipe supports"
)
# D and H: one candidate (same unit, resource, drawing) with a non-numeric rev; duplicate piece 1 across the legs
w["D"] = _Wo(
"L900-04",
"2",
[(10, "WELD-PART", 1)],
dwg="L900-0401",
rev="A",
st="F",
rel=-4,
want=22,
qty=1,
desc="Outlet nozzle",
)
w["H"] = _Wo(
"L900-08-1",
"2",
[(10, "WELD-PART", 0.5)],
dwg="L900-0401",
rev="",
st="F",
rel=-2,
want=24,
qty=1,
desc="Outlet nozzle",
)
# E: drawing in drafting; gone from VISUAL in state 2
w["E"] = _Wo(
"L900-05", "2", [(10, "LAYOUT", 1)], dwg="L900-0501", rev="1", st="F", rel=-1, want=19, qty=3, desc="Skid frame"
)
# F: Released, with materials (R legs count as mat_known); not in the window set
w["F"] = _Wo(
"L900-06",
"2",
[(10, "WELD-CODE", 1)],
dwg="L900-0601",
rev="1",
st="R",
rel=-2,
want=20,
qty=1,
desc="Drain leg",
)
# G: drawing not in M-Files; tgt-only change in state 2
w["G"] = _Wo(
"L900-07",
"2",
[(10, "WELD-PART", 2)],
dwg="L900-0701",
rev="1",
st="F",
rel=-3,
want=23,
qty=2,
desc="Vent stack",
)
# J closes in state 2, K is cancelled in state 2
w["J"] = _Wo(
"L900-09",
"1",
[(10, "WELD-CODE", 1)],
dwg="L900-0901",
rev="1",
st="F",
rel=-5,
want=15,
qty=1,
desc="Gauge board",
)
w["K"] = _Wo(
"L900-10",
"1",
[(10, "WELD-CODE", 1)],
dwg="L900-1001",
rev="1",
st="F",
rel=-5,
want=15,
qty=1,
desc="Sample cooler",
)
# A Closed and a Cancelled leg outside the window set (only on a packet when tracked)
w["L"] = _Wo(
"L900-11",
"1",
[(10, "WELD-CODE", 1)],
dwg="L900-1101",
rev="1",
st="C",
rel=-6,
want=10,
qty=1,
desc="Closed spool",
)
w["X"] = _Wo(
"L900-12",
"1",
[(10, "WELD-CODE", 1)],
dwg="L900-1201",
rev="1",
st="X",
rel=-6,
want=10,
qty=1,
desc="Cancelled spool",
)
# Go-live: released before golive_date = today + GOLIVE_OFFSET (still inside the window)
w["P"] = _Wo(
"L900-13",
"2",
[(10, "WELD-PART", 1)],
dwg="L900-1301",
rev="1",
st="F",
rel=-10,
want=12,
qty=1,
desc="Old release",
)
# A non-numeric lot: no unit number, no candidate
w["Q"] = _Wo(
"L900-14",
"7A",
[(10, "WELD-PART", 1)],
dwg="L900-1401",
rev="1",
st="F",
rel=-2,
want=20,
qty=1,
desc="Lettered lot",
)
# An unmapped resource
w["N"] = _Wo(
"L900-15",
"2",
[(10, "NEWRES", 1), (20, "NEWRES", 1)],
dwg="L900-1501",
rev="1",
st="F",
rel=-2,
want=20,
qty=1,
desc="New resource work",
)
# M: moves to drawing L900-1602 in state 2
w["M"] = _Wo(
"L900-16",
"2",
[(10, "WELD-CODE", 1)],
dwg="L900-1601",
rev="1",
st="F",
rel=-2,
want=26,
qty=1,
desc="Bypass loop",
)
# Project L901: only a closed order
w["Z"] = _Wo(
"L901-01",
"1",
[(10, "WELD-PART", 1)],
dwg="L901-0101",
rev="1",
st="F",
rel=-2,
want=28,
qty=1,
desc="Closed-order spool",
)
return w
def _materials(w: dict[str, _Wo]) -> list[dict[str, Any]]:
a, d, h, f, b = w["A"], w["D"], w["H"], w["F"], w["B"]
lines = [
# A seq 10 (LAYOUT): a split-item header (piece 10, no part) with lines 11 and 12
_mat(a, 10, 10, None, 0, desc="Split item: flange set"),
_mat(a, 10, 11, "FLG-6-150", 2, issued=1),
_mat(a, 10, 12, "GSK-6-150", 2, cons="Y", comm="GASKET"),
# A seq 20 (WELD-PART): a MAIN warehouse line, a WIP sub-assembly, a line on another drawing (dropped)
_mat(a, 20, 1, "PIPE-6-SCH40", 12.5, issued=12.5, whs="MAIN", loc="A-01"),
_mat(a, 20, 2, "L900-01-SUB", 1, whs="ASY", loc="", comm="WIP"),
_mat(a, 20, 3, "ELB-6-90", 2, dwg="L900-9999"),
_mat(a, 20, 4, "WOL-1-6", 1, dwg=""), # blank drawing on the line: it belongs
# B (two operations)
_mat(b, 10, 1, "PLT-0.5-A36", 4),
_mat(b, 20, 2, "CPL-2-3000", 2, issued=3),
# D and H: piece 1 on both legs (the second becomes item 1-1)
_mat(d, 10, 1, "NOZ-4-300", 1),
_mat(h, 10, 1, "NOZ-4-300", 1),
_mat(h, 10, 2, "PAD-4", 1, closed=True, issued=1),
# F: a Released leg's line
_mat(f, 10, 1, "PIPE-1-SCH80", 3, st="R"),
# G
_mat(w["G"], 10, 1, "PIPE-8-SCH40", 6),
# Unreleased split of A: not F or R, so not a material leg
_mat(w["A2"], 20, 1, "PIPE-6-SCH40", 6, st="U"),
]
return lines
def _orders(today: date) -> tuple[dict[str, list[dict[str, Any]]], dict[str, list[dict[str, Any]]]]:
acme = {
"cust_order_id": ORDER_ACME,
"lines": [1],
"top_jobs": ["L900002"],
"status": "R",
"customer_id": "ACME01",
"customer_name": "Acme Refining",
"customer_po_ref": "PO-1001",
"order_date": _d(today, -40),
"desired_ship_date": _d(today, 60),
"promise_date": _d(today, 55),
}
bayou = {
"cust_order_id": ORDER_BAYOU,
"lines": [1, 2],
"top_jobs": ["L900001"],
"status": "F",
"customer_id": "BAYOU01",
"customer_name": "Bayou Chemical",
"customer_po_ref": "PO-2002",
"order_date": _d(today, -45),
"desired_ship_date": _d(today, 70),
"promise_date": None,
}
closed = {
"cust_order_id": ORDER_L901,
"lines": [1],
"top_jobs": ["L901001"],
"status": "C",
"customer_id": "DELTA01",
"customer_name": "Delta Gas",
"customer_po_ref": "PO-3003",
"order_date": _d(today, -200),
"desired_ship_date": _d(today, -100),
"promise_date": _d(today, -105),
}
base_entry = {k: v for k, v in acme.items() if k not in ("top_jobs", "order_date")}
by_base = {"L900-04": [base_entry]}
by_project = {PROJECT: [acme, bayou], PROJECT_CLOSED_ORDER: [closed]}
return by_base, by_project
def _labor(today: date) -> list[dict[str, Any]]:
return [
{
"wo": "L900-01",
"lot": "1",
"split": "0",
"sub": "0",
"seq": 20,
"hours": 1.25,
"tickets": 2,
"last_ticket": _d(today, -1),
},
{
"wo": "L900-02",
"lot": "1",
"split": "0",
"sub": "0",
"seq": 10,
"hours": 0.5,
"tickets": 1,
"last_ticket": _d(today, -1),
},
]
def _meta(today: date, state: int) -> dict[str, Any]:
return {
"source": "fixture",
"version": "synthetic",
"server": "fixture",
"database": f"state {state}",
"site": SITE,
"since": _d(today, -14),
"until": _d(today, 30),
"statuses": ["F", "R", "U", "C"],
"generated_at": f"{today.isoformat()}T06:00:00",
}
def feed_state(n: int, today: date | None = None) -> dict[str, Any]:
"""The feed file for state 1 or 2 as a dict (the contract shape plus `labor`)."""
if n not in (1, 2):
raise ValueError("state is 1 or 2")
today = today or date.today()
w = _work_orders()
if n == 2:
w["A"].rev = "2" # rev 1 -> 2 on the WELD-PART drawing (and A's LAYOUT draft)
w["A2"].rev = "2"
w["B"].qty = 3 # qty change (also scales tgt)
w["G"].ops = [(10, "WELD-PART", 2.5)] # tgt-only change: minor
del w["E"] # gone from VISUAL
w["M"].dwg = "L900-1602" # moved to another drawing
w["J"].st = "C"
w["K"].st = "X"
rows = [r for wo in w.values() for r in wo.rows(today)]
mats = _materials(w) if n == 1 else _materials_state2(w)
by_base, by_project = _orders(today)
return {
"meta": _meta(today, n),
"rows": rows,
"materials": mats,
"orders_by_base": by_base,
"orders_by_project": by_project,
"labor": _labor(today),
}
def _materials_state2(w: dict[str, _Wo]) -> list[dict[str, Any]]:
# Same lines as state 1 on the state 2 work orders (A's drawing moved with it, E is gone)
full = _work_orders()
full.update(w)
lines = _materials(full)
return [m for m in lines if m["wo"] != "L900-05"]
# ------------------------------------------------------------------------------------------------ drawings
def _dwg(
rev: int | None,
file: str | None,
*,
n: int,
version: int = 1,
draft_rev: int | None = None,
draft_where: str = "",
in_drafting: bool = False,
base: str | None = None,
) -> dict[str, Any]:
return {
"rev": rev,
"file": file,
"name": file[:-4] if file else None,
"draft_rev": draft_rev,
"draft_where": draft_where,
"in_drafting": in_drafting,
"ok": True,
"note": "",
"base": base,
"obj_id": n,
"obj_version": version,
"file_id": 100 + n,
"file_version": version,
}
def drawings_state(n: int) -> dict[str, Any]:
"""drawings.<n>.json as a dict. A drawing missing from it is not in M-Files."""
if n not in (1, 2):
raise ValueError("state is 1 or 2")
d: dict[str, Any] = {
"L900-0101": _dwg(1, "L900-0101 r1.pdf", n=1),
"L900-0201": _dwg(3, "L900-0201 r3.pdf", n=2), # VISUAL calls rev 2: rev mismatch
"L900-0401": _dwg(1, "L900-0401 r1.pdf", n=4), # VISUAL calls rev A: bad rev
"L900-0501": _dwg(
None, None, n=5, draft_rev=1, draft_where="in Drafting review", in_drafting=True, base="L900-05"
), # not released
# L900-0701 is missing: not found. L900-1601 is missing too (so its draft has no pages and can retire).
"L900-0601": _dwg(1, "L900-0601 r1.pdf", n=6),
"L900-0901": _dwg(1, "L900-0901 r1.pdf", n=9),
"L900-1001": _dwg(1, "L900-1001 r1.pdf", n=10),
"L900-1301": _dwg(1, "L900-1301 r1.pdf", n=13),
"L900-1401": _dwg(1, "L900-1401 r1.pdf", n=14),
"L901-0101": _dwg(1, "L901-0101 r1.pdf", n=21),
}
if n == 2:
d["L900-0101"] = _dwg(2, "L900-0101 r2.pdf", n=1, version=2) # the CO gets staged
d["L900-1602"] = _dwg(1, "L900-1602 r1.pdf", n=16)
return d
def pdf_bytes(label: str) -> bytes:
"""A one-page PDF. The label goes into the document info so each file has its own sha256."""
from pypdf import PdfWriter
w = PdfWriter()
w.add_blank_page(width=792, height=612)
w.add_metadata({"/Title": label, "/Producer": "Packet Station fixtures"})
buf = io.BytesIO()
w.write(buf)
return buf.getvalue()
def write_state(out_dir: Path, n: int) -> None:
"""Point the fixture sources at state n (what `packetstation fixtures use N` does)."""
(Path(out_dir) / "state").write_text(str(n), encoding="utf-8")
def generate(out_dir: Path | str, today: date | None = None) -> Path:
"""Write every fixture file into out_dir and set the state to 1. Returns out_dir."""
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
files: set[str] = set()
for n in (1, 2):
feed = feed_state(n, today)
(out / f"feed.{n}.json").write_text(json.dumps(feed, indent=1, ensure_ascii=False), encoding="utf-8")
dwgs = drawings_state(n)
(out / f"drawings.{n}.json").write_text(json.dumps(dwgs, indent=1, ensure_ascii=False), encoding="utf-8")
files.update(v["file"] for v in dwgs.values() if v.get("file"))
for name in sorted(files):
(out / name).write_bytes(pdf_bytes(name))
write_state(out, 1)
return out
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Write Packet Station's synthetic VISUAL and M-Files fixtures")
ap.add_argument("--out", type=Path, required=True, help="directory to write into")
ap.add_argument("--today", type=date.fromisoformat, default=None, help="date the offsets count from")
args = ap.parse_args(argv)
out = generate(args.out, args.today)
print(f"Wrote fixtures to {out} (state 1)", file=sys.stderr)
return 0
__all__ = [
"RESOURCE_MAP",
"drawings_state",
"feed_state",
"generate",
"pdf_bytes",
"write_state",
]
if __name__ == "__main__":
sys.exit(main())
+241
View File
@@ -0,0 +1,241 @@
"""Helpers for the feed and drawing tests: the generator, fixture dirs, feed settings and one job run.
`run_feed(settings, state)` and `run_drawings(settings)` run the real job functions (visual-feed, drawing-lookup)
once, the way the worker would, over a connection as the worker role, against the fixture files in
`settings.fixture_dir`. Never VISUAL or M-Files.
"""
from __future__ import annotations
import importlib
import sys
from datetime import date, timedelta
from pathlib import Path
from typing import Any
from pshelpers import make_settings
from psycopg.types.json import Jsonb
from packetstation.settings import Settings
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
gen = importlib.import_module("tests.fixtures.generator")
FEED_JOB = "visual-feed"
DRAWING_JOB = "drawing-lookup"
FEED_COUNT_KEYS = (
"wos_window",
"wos_tracked",
"rows",
"materials",
"orders",
"labor_legs",
"new_drafts",
"drafts_updated",
"cos_opened",
"cos_reaimed",
"cos_withdrawn",
"retired",
"wo_gone_flagged",
"closed_observed",
"ignored_golive",
"projects_created",
"units_created",
"auto_released",
"unmapped_resources",
"skipped_removed",
"skipped_closed",
"suspect",
)
# The counts that are zero when VISUAL did not change between two runs.
CHANGE_KEYS = (
"new_drafts",
"drafts_updated",
"cos_opened",
"cos_reaimed",
"cos_withdrawn",
"retired",
"wo_gone_flagged",
"closed_observed",
"projects_created",
"units_created",
"auto_released",
)
def fixture_dir(tmp: Path, today: date | None = None) -> Path:
"""Generate the fixtures into tmp/fixtures (state 1)."""
out: Path = gen.generate(tmp / "fixtures", today)
return out
def switch_state(directory: Path, n: int | str) -> None:
(Path(directory) / "state").write_text(str(n), encoding="utf-8")
def feed_settings_over(base: Settings, tmp: Path) -> Settings:
"""`base` (a pg_settings) with the fixture sources pointed at freshly generated files under tmp."""
d = fixture_dir(tmp)
return base.model_copy(update={"feed_source": "fixture", "drawing_source": "fixture", "fixture_dir": d})
def fixture_settings(tmp: Path, **over: Any) -> Settings:
"""Plain test settings (no database) with fixture sources."""
return make_settings(tmp, feed_source="fixture", drawing_source="fixture", fixture_dir=tmp / "fixtures", **over)
def set_feed(settings: Settings, **values: Any) -> None:
"""Merge values into the `feed` settings row by SQL (no audit: test setup)."""
from pshelpers import app_conn
from packetstation.feed import config
with app_conn(settings) as conn:
cur = config.load(conn).model_dump(mode="json")
cur.update({k: (v.isoformat() if isinstance(v, date) else v) for k, v in values.items()})
conn.execute(
"INSERT INTO settings (key, value, version, updated_at) VALUES ('feed', %s, 1, now())"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1",
(Jsonb(cur),),
)
def golive_today_offset() -> date:
return date.today() + timedelta(days=gen.GOLIVE_OFFSET)
# ---------------------------------------------------------------- one job run
def scheduler(settings: Settings) -> Any:
"""An unstarted worker Scheduler: what runs.run_job needs, without APScheduler or the listener."""
from packetstation.worker.scheduler import Scheduler
return Scheduler(settings, build="pytest")
def run_job(settings: Settings, job: str, *, requested: bool = False, allow_fail: bool = False) -> dict[str, Any]:
"""Run one registered job once through runs.run_job, as the worker does. Returns the job_runs row.
`requested` inserts a job_requests row first, so the run is a "run now". A failed run raises AssertionError
with its errors unless allow_fail.
"""
from packetstation import db
from packetstation.worker import runs
if requested:
with db.connect_as("worker", settings, autocommit=True) as conn:
conn.execute("INSERT INTO job_requests (job, note) VALUES (%s, 'pytest')", (job,))
row = runs.run_job(scheduler(settings), job, "schedule")
if not allow_fail:
assert row["status"] != "failed", row.get("errors")
return dict(row)
def _counts(row: dict[str, Any]) -> dict[str, Any]:
counts = dict(row.get("counts") or {})
counts.update(
{"_status": row["status"], "_errors": row.get("errors") or [], "_run_id": row["id"], "_trigger": row["trigger"]}
)
return counts
def run_feed(
settings: Settings, state: int | None = None, *, requested: bool = False, allow_fail: bool = False
) -> dict[str, Any]:
"""Point the fixtures at `state` (when given) and run the visual-feed job once. Returns the run's counts with
`_status`, `_errors`, `_run_id` and `_trigger` added."""
if state is not None:
switch_state(settings.fixture_dir, state)
return _counts(run_job(settings, FEED_JOB, requested=requested, allow_fail=allow_fail))
def run_drawings(settings: Settings, state: int | None = None, *, requested: bool = False) -> dict[str, Any]:
if state is not None:
switch_state(settings.fixture_dir, state)
return _counts(run_job(settings, DRAWING_JOB, requested=requested))
# ---------------------------------------------------------------- reading back
def packet_by_key(conn: Any, key: str, *, live: bool = True) -> dict[str, Any] | None:
from psycopg.rows import dict_row
with conn.cursor(row_factory=dict_row) as cur:
live_sql = "SELECT * FROM packets WHERE visual_key = %s AND removed_at IS NULL ORDER BY id DESC LIMIT 1"
any_sql = "SELECT * FROM packets WHERE visual_key = %s ORDER BY id DESC LIMIT 1"
row = cur.execute(live_sql if live else any_sql, (key,)).fetchone()
return dict(row) if row else None
def packet_id(conn: Any, key: str) -> int:
p = packet_by_key(conn, key)
assert p is not None, key
return int(p["id"])
def legs_of(conn: Any, pid: int) -> list[tuple[Any, ...]]:
return [
tuple(r)
for r in conn.execute(
"SELECT wo_base, lot, split, sub, seq, qty, target_hrs, visual_status, missing_runs, gone_at IS NOT NULL"
" FROM packet_work_orders WHERE packet_id = %s ORDER BY wo_base, lot, split, sub, seq",
(pid,),
).fetchall()
]
def cos_of(conn: Any, pid: int) -> list[dict[str, Any]]:
from psycopg.rows import dict_row
with conn.cursor(row_factory=dict_row) as cur:
return [
dict(r)
for r in cur.execute("SELECT * FROM change_orders WHERE packet_id = %s ORDER BY co_no", (pid,)).fetchall()
]
def move_status(conn: Any, pid: int, status: str) -> None:
"""Move a packet past draft by SQL (test setup: the transitions are tested elsewhere)."""
conn.execute(
"UPDATE packets SET status = %s::packet_status, status_at = now(), heat_map = coalesce(heat_map, true),"
" version = version + 1 WHERE id = %s",
(status, pid),
)
def system_audit(conn: Any, action: str) -> list[tuple[Any, ...]]:
return [
tuple(r)
for r in conn.execute(
"SELECT role_snapshot, user_id, subject_type, subject_id, packet_id FROM audit_events WHERE action = %s"
" ORDER BY id",
(action,),
).fetchall()
]
__all__ = [
"CHANGE_KEYS",
"FEED_COUNT_KEYS",
"cos_of",
"feed_settings_over",
"fixture_dir",
"fixture_settings",
"gen",
"golive_today_offset",
"legs_of",
"move_status",
"packet_by_key",
"packet_id",
"run_drawings",
"run_feed",
"run_job",
"set_feed",
"switch_state",
"system_audit",
]
+343
View File
@@ -0,0 +1,343 @@
"""Candidates from the generator's state 1: packet keys, routing, go-live, tracked keys, revs and the BOM rules."""
from __future__ import annotations
from datetime import date, timedelta
from decimal import Decimal
from typing import Any
import pytest
from feedhelpers import gen
from packetstation.feed import candidates, orders
from packetstation.feed.materials import index
from packetstation.feed.types import Material, WoKey, parse_material, parse_orders, parse_row
TODAY = date.today()
WINDOW = (TODAY - timedelta(days=14), TODAY + timedelta(days=30))
def k(base: str, lot: str, split: str = "0", sub: str = "0") -> WoKey:
return WoKey(base, lot, split, sub)
def state(n: int = 1) -> dict[str, Any]:
return gen.feed_state(n, TODAY)
def material_legs(feed: dict[str, Any]) -> set[WoKey]:
return {k(m["wo"], m["lot"], m["split"], m["sub"]) for m in feed["materials"] if m["st"] in ("F", "R")}
def build(
n: int = 1,
*,
tracked: set[WoKey] | None = None,
golive: date | None = None,
departments: tuple[str, ...] = (),
mats: bool = True,
) -> candidates.Built:
feed = state(n)
rows = [parse_row(r) for r in feed["rows"]]
materials = [parse_material(m) for m in feed["materials"]] if mats else None
return candidates.build(
rows,
materials,
material_legs(feed) if mats else None,
resource_map=gen.RESOURCE_MAP,
window=WINDOW,
golive=golive,
departments=departments,
tracked_keys=tracked or set(),
today=TODAY,
)
def test_feed_file_shape() -> None:
feed = state(1)
assert set(feed) >= {"meta", "rows", "materials", "orders_by_base", "orders_by_project"}
row = feed["rows"][0]
assert set(row) >= {
"wo",
"lot",
"split",
"sub",
"seq",
"res",
"dwg",
"rev",
"ps",
"desc",
"qty",
"tgt",
"rel",
"want",
"st",
"barcode",
"part",
}
words = {r["st"] for r in feed["rows"]}
assert words <= {"Firmed", "Released", "Unreleased", "Closed", "Cancelled"}
assert {m["st"] for m in feed["materials"]} <= {"F", "R", "U", "C", "X"}
for r in feed["rows"]:
assert isinstance(r["seq"], int) and isinstance(r["lot"], str) and isinstance(r["split"], str)
assert isinstance(r["qty"], int | float) and not isinstance(r["qty"], bool)
assert r["rel"] is None or date.fromisoformat(r["rel"])
for m in feed["materials"]:
assert isinstance(m["piece"], int) and (m["part"] is None or isinstance(m["part"], str))
assert any(r["lot"] == "7A" for r in feed["rows"])
assert any(m["part"] is None for m in feed["materials"])
def test_generator_is_deterministic(tmp_path: Any) -> None:
a, b = tmp_path / "a", tmp_path / "b"
gen.generate(a, TODAY)
gen.generate(b, TODAY)
for name in ("feed.1.json", "feed.2.json", "drawings.1.json", "drawings.2.json", "state"):
assert (a / name).read_bytes() == (b / name).read_bytes(), name
assert (a / "state").read_text() == "1"
assert (a / "L900-0101 r1.pdf").read_bytes().startswith(b"%PDF-")
assert (a / "L900-0101 r1.pdf").read_bytes() != (a / "L900-0101 r2.pdf").read_bytes()
def test_packet_keys() -> None:
built = build()
assert set(built.candidates) == set(gen.STATE1_KEYS)
c = built.candidates[gen.KEY_WELD_PART]
assert (c.project, c.unit, c.lot, c.dept, c.op, c.drawing) == (
"L900",
"L900001",
"001",
"03",
"WELD-PART",
"L900-0101",
)
assert c.base == "L900-01" and c.in_window and c.title == "Inlet spool 6 in"
assert built.candidates[gen.KEY_LAYOUT_A].dept == "01"
nd = built.candidates[gen.KEY_NO_DWG]
assert nd.drawing == "" and nd.key.endswith("|(no drawing)")
def test_two_operations_at_one_resource_are_two_legs() -> None:
c = build().candidates[gen.KEY_TWO_OPS]
assert [(lg.key, lg.seq) for lg in c.legs] == [(k("L900-02", "1"), 10), (k("L900-02", "1"), 20)]
assert [lg.tgt for lg in c.legs] == [Decimal(4), Decimal(2)]
def test_fold_and_ignore() -> None:
c = build().candidates[gen.KEY_WELD_PART]
folded = {(f.seq, f.op) for f in c.folded}
assert folded == {(30, "MFG"), (50, "MAT_PIPE")}
assert all(lg.op not in ("MFG", "FORKLIFT", "MAT_PIPE") for cand in build().candidates.values() for lg in cand.legs)
# the LAYOUT packet of the same work order and lot folds the same operations
assert {f.seq for f in build().candidates[gen.KEY_LAYOUT_A].folded} == {30, 50}
assert candidates.route("MAT_STEEL", {}) == "fold" and candidates.route("FORKLIFT", gen.RESOURCE_MAP) == "ignore"
assert candidates.route("NEWRES", gen.RESOURCE_MAP) is None
def test_partspecs_distinct_in_first_seen_order() -> None:
c = build().candidates[gen.KEY_WELD_PART]
assert c.partspecs == ["Cut to length per sheet 1", "Weld per WPS-11\nBack purge"]
def test_unmapped_counts() -> None:
assert build().unmapped == {"NEWRES": 2}
def test_lettered_lot_makes_no_candidate() -> None:
built = build()
assert built.skipped_lots == {"L900-14/7A": 1}
assert candidates.unit_number("L900", "7A") is None
assert candidates.unit_number("L900", "1") == "L900001"
assert candidates.unit_number("L900", "012") == "L900012"
def test_golive_ignores_old_releases() -> None:
golive = TODAY + timedelta(days=gen.GOLIVE_OFFSET)
built = build(golive=golive)
assert gen.KEY_GOLIVE not in built.candidates
assert [(r.wo, r.lot, r.seq) for r in built.ignored] == [("L900-13", "2", 10)]
# tracked keys come through whatever their release date
built = build(golive=golive, tracked={k("L900-13", "2")})
assert gen.KEY_GOLIVE in built.candidates and built.ignored == []
def test_departments_filter() -> None:
built = build(departments=("06",))
assert built.candidates and {c.dept for c in built.candidates.values()} == {"06"}
def test_tracked_keys_outside_the_window_and_status() -> None:
assert gen.KEY_RELEASED not in build().candidates # Released: not in the window set
tracked = {k("L900-06", "2"), k("L900-01", "1", "1"), k("L900-11", "1"), k("L900-12", "1")}
built = build(tracked=tracked)
rel = built.candidates[gen.KEY_RELEASED]
assert not rel.in_window and rel.statuses == {k("L900-06", "2"): "R"}
wp = built.candidates[gen.KEY_WELD_PART]
assert wp.statuses == {k("L900-01", "1"): "F", k("L900-01", "1", "1"): "U"}
assert {(lg.key.split, lg.seq) for lg in wp.legs} == {("0", 20), ("1", 20)}
assert built.candidates["L900001|WELD-CODE|L900-1101"].statuses == {k("L900-11", "1"): "C"}
assert built.candidates["L900001|WELD-CODE|L900-1201"].statuses == {k("L900-12", "1"): "X"}
def test_rel_min_want_max() -> None:
c = build().candidates[gen.KEY_REV_A] # legs L900-04 (rel -4, want 22) and L900-08-1 (rel -2, want 24)
assert c.rel == TODAY - timedelta(days=4) and c.want == TODAY + timedelta(days=24)
def test_rev_highest_numeric() -> None:
c = build().candidates[gen.KEY_REV_A]
assert c.rev == "A" # the non-numeric rev holds against an empty rev
built = build(2, tracked={k("L900-01", "1", "1")})
assert built.candidates[gen.KEY_WELD_PART].rev == "2"
def _row(seq: int, rev: str) -> Any:
return parse_row(
{
"wo": "L900-20",
"lot": "1",
"split": "0",
"sub": "0",
"seq": seq,
"res": "WELD-PART",
"dwg": "D-1",
"rev": rev,
"qty": 1,
"tgt": 1,
"rel": TODAY.isoformat(),
"st": "Firmed",
}
)
@pytest.mark.parametrize(
"revs,expect",
[(["1", "3", "2"], "3"), (["", "2"], "2"), (["2", "A"], "2"), (["A", "2"], "A"), (["10", "9"], "10")],
)
def test_rev_rule(revs: list[str], expect: str) -> None:
rows = [_row(10 * (i + 1), r) for i, r in enumerate(revs)]
built = candidates.build(
rows,
None,
None,
resource_map=gen.RESOURCE_MAP,
window=WINDOW,
golive=None,
departments=(),
tracked_keys=set(),
today=TODAY,
)
assert built.candidates["L900001|WELD-PART|D-1"].rev == expect
def _bom(key: str, n: int = 1, tracked: set[WoKey] | None = None) -> dict[str, Any]:
c = build(n, tracked=tracked).candidates[key]
assert c.bom is not None
return {b.item: b for b in c.bom}
def test_bom_split_items() -> None:
bom = _bom(gen.KEY_LAYOUT_A)
assert set(bom) == {"11", "12"} # the piece 10 header has no part: not a line
assert bom["11"].grp == "10" and bom["12"].grp == "10"
assert bom["12"].cons is True and bom["11"].cons is False
assert bom["11"].vkey == "L900-01|1|0|10|11"
def test_bom_main_loc_wip_sub_and_dwg_filter() -> None:
bom = _bom(gen.KEY_WELD_PART)
assert set(bom) == {"1", "2", "4"} # piece 3 is on another drawing: dropped; piece 4 has a blank drawing
assert bom["1"].loc == "A-01" and bom["1"].whs == "MAIN" # MAIN is left out of loc
assert bom["4"].loc == "SHOP R1"
assert bom["2"].sub is True and bom["2"].na is True
assert bom["1"].sub is False and bom["1"].issued == Decimal("12.5") and bom["1"].qty == Decimal("12.5")
assert all(b.grp is None for b in bom.values())
def test_bom_duplicate_items() -> None:
bom = _bom(gen.KEY_REV_A)
assert set(bom) == {"1", "1-1", "2"}
assert bom["1"].vkey.startswith("L900-04|") and bom["1-1"].vkey.startswith("L900-08-1|")
assert bom["2"].istat == 0
def test_bom_two_operations() -> None:
bom = _bom(gen.KEY_TWO_OPS)
assert set(bom) == {"1", "2"} and bom["2"].istat == 3
def test_released_leg_counts_as_known() -> None:
bom = _bom(gen.KEY_RELEASED, tracked={k("L900-06", "2")})
assert set(bom) == {"1"}
def test_unreleased_leg_alone_is_unknown() -> None:
# A2 (Unreleased) is not a material leg; on the WELD-PART packet the firmed leg still makes it known
built = build(tracked={k("L900-01", "1", "1")})
assert built.candidates[gen.KEY_WELD_PART].bom is not None
def test_mat_known_none() -> None:
built = build()
assert built.candidates[gen.KEY_NO_DWG].bom is None # C has no material lines: not a material leg
assert all(c.bom is None for c in build(mats=False).candidates.values())
def test_best_status_per_piece() -> None:
key = k("L900-30", "1")
def m(st: str, qty: int) -> Material:
return parse_material(
{
"wo": "L900-30",
"lot": "1",
"split": "0",
"sub": "0",
"seq": 10,
"piece": 1,
"part": "P-1",
"qty": qty,
"issued": 0,
"st": st,
"dwg": "D-9",
}
)
leg = candidates.Leg(
key=key, seq=10, op="WELD-PART", qty=Decimal(1), tgt=Decimal(1), dwg="D-9", rev="1", partspecs="", st="F"
)
from packetstation.feed.materials import bom_for
for lines, qty in (
([m("U", 3), m("F", 2), m("R", 1)], 1),
([m("U", 3), m("F", 2)], 2),
([m("C", 4), m("U", 3)], 3),
):
bom = bom_for([leg], index(lines))
assert [(b.item, b.qty) for b in bom] == [("1", Decimal(qty))]
def test_orders_unit_rule_on_fixture_orders() -> None:
feed = state(1)
by_base, by_project = parse_orders(feed["orders_by_base"]), parse_orders(feed["orders_by_project"])
assert by_base is not None and by_project is not None
def pick(unit: str, base: str, project: str = "L900", pin: str | None = None) -> tuple[str, str] | None:
ch = orders.choose(
unit_number=unit, base=base, by_base=by_base, by_project=by_project, project=project, pin=pin
)
return None if ch is None else (ch.order.cust_order_id, ch.via)
assert pick("L900001", "L900-01") == (gen.ORDER_BAYOU, "unit_top_job")
assert pick("L900002", "L900-04") == (gen.ORDER_ACME, "base")
assert pick("L900002", "L900-07") == (gen.ORDER_ACME, "unit_top_job")
assert pick("L900003", "L900-99") == (gen.ORDER_ACME, "project_open")
assert pick("L901001", "L901-01", "L901") == (gen.ORDER_L901, "unit_top_job")
assert pick("L901002", "L901-02", "L901") == (gen.ORDER_L901, "project_newest")
assert pick("L900001", "L900-01", pin=gen.ORDER_ACME) == (gen.ORDER_ACME, "pin")
assert pick("L900001", "L900-01", pin="C99-0000") == (gen.ORDER_BAYOU, "unit_top_job") # not the project's
assert pick("L902001", "L902-01", "L902") is None
d = orders.derive(by_project["L900"][1])
assert d["customer"] == "Bayou Chemical" and d["po"] == "PO-2002"
assert d["ship_date"] == TODAY + timedelta(days=70) # no promise date: the desired ship date
+351
View File
@@ -0,0 +1,351 @@
"""The drawing decision table (pure), then the drawing job against the fixture lookup (PostgreSQL)."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from feedhelpers import (
cos_of,
feed_settings_over,
gen,
move_status,
packet_by_key,
packet_id,
run_drawings,
run_feed,
system_audit,
)
from pshelpers import app_conn, needs_pg
from packetstation.drawings.decide import Decision, Target, decide
from packetstation.drawings.fake import FixtureLookup
from packetstation.drawings.lookup import Found
from packetstation.settings import Settings
DRAWING_FLAGS = {"drawing_needed", "rev_mismatch", "not_released", "not_found", "lookup_failed", "bad_rev"}
def target(**over: Any) -> Target:
values: dict[str, Any] = {
"dwg": "L900-0101",
"visual_rev": "1",
"attached_rev": None,
"attached_sha": None,
"status": "draft",
"drawing_state": "needed",
"has_hand_drawing": False,
"rev_ok": None,
}
values.update(over)
return Target(**values)
def found(**over: Any) -> Found:
values: dict[str, Any] = {
"dwg": "L900-0101",
"ok": True,
"rev": 1,
"name": "L900-0101 r1",
"obj_id": 1,
"obj_version": 1,
"file_id": 101,
"file_version": 1,
"file_size": 900,
"base": None,
"draft_rev": None,
"draft_where": "",
"in_drafting": False,
"note": "",
}
values.update(over)
return Found(**values)
def d(t: Target, f: Found, same_file: bool = False) -> Decision:
return decide(t, f, same_file=same_file)
# ---------------------------------------------------------------- the table
def test_lookup_failed_on_a_needed_drawing() -> None:
r = d(target(), found(ok=False, rev=None, note="M-Files lookup failed: timeout"))
assert (r.kind, r.flag, r.drawing_state) == ("lookup_failed", "lookup_failed", "lookup_failed")
assert "timeout" in r.note
def test_lookup_failed_keeps_what_is_attached() -> None:
r = d(target(drawing_state="attached", attached_rev="1"), found(ok=False, rev=None, note="503"))
assert (r.kind, r.flag, r.drawing_state) == ("lookup_failed", "lookup_failed", "attached")
def test_not_released() -> None:
r = d(target(), found(rev=None, draft_rev=1, draft_where="in Drafting review", in_drafting=True, base="L900-05"))
assert (r.kind, r.flag, r.drawing_state) == ("not_released", "not_released", "not_released")
assert r.note == "Not released in M-Files yet (sheet of L900-05) - drafting status: rev 1 in Drafting review"
def test_not_released_in_drafting_without_a_rev() -> None:
r = d(target(), found(rev=None, in_drafting=True))
assert r.kind == "not_released" and r.note.endswith("in drafting, no released PDF yet")
def test_not_found() -> None:
r = d(target(), found(rev=None, name=None))
assert (r.kind, r.flag, r.drawing_state) == ("not_found", "not_found", "not_found")
assert r.note == "Drawing not in M-Files - check the drawing number in VISUAL"
def test_bad_rev() -> None:
r = d(target(visual_rev="A"), found(rev=1))
assert (r.kind, r.flag) == ("bad_rev", "bad_rev")
assert "A" in r.note and "M-Files has rev 1" in r.note and "not attached" in r.note
def test_rev_mismatch() -> None:
r = d(target(visual_rev="2"), found(rev=3))
assert (r.kind, r.flag, r.drawing_state) == ("rev_mismatch", "rev_mismatch", "rev_mismatch")
assert r.note == "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3"
def test_rev_mismatch_with_a_newer_draft() -> None:
r = d(target(visual_rev="2"), found(rev=3, draft_rev=4, draft_where="in Drafting"))
assert r.note == "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3; drafting status: rev 4 in Drafting"
def test_rev_mismatch_on_an_in_shop_packet_opens_nothing() -> None:
r = d(target(visual_rev="2", status="ready", drawing_state="attached", attached_rev="2"), found(rev=3))
assert r.kind == "rev_mismatch" and r.drawing_state == "attached"
def test_approved_rev_attaches() -> None:
r = d(target(visual_rev="2", rev_ok="3"), found(rev=3))
assert (r.kind, r.flag, r.drawing_state) == ("attach", None, "attached")
def test_approved_other_rev_still_mismatches() -> None:
assert d(target(visual_rev="2", rev_ok="4"), found(rev=3)).kind == "rev_mismatch"
def test_match_on_a_draft_attaches() -> None:
r = d(target(), found())
assert (r.kind, r.flag, r.drawing_state) == ("attach", None, "attached")
def test_attach_names_the_sheet() -> None:
r = d(target(dwg="L900-0101H2"), found(dwg="L900-0101H2", base="L900-0101"))
assert r.kind == "attach" and r.note == "sheet of drawing L900-0101"
def test_hand_drawing_at_the_same_rev_stays() -> None:
r = d(target(has_hand_drawing=True, drawing_state="hand"), found())
assert r.kind == "unchanged" and r.drawing_state == "hand"
def test_past_draft_same_file_unchanged() -> None:
r = d(target(status="ready", drawing_state="attached", attached_rev="1", attached_sha=b"\x01"), found(), True)
assert r.kind == "unchanged" and r.flag is None
def test_past_draft_new_file_same_rev_opens_a_co() -> None:
r = d(target(status="in_progress", drawing_state="attached", attached_rev="1", attached_sha=b"\x01"), found())
assert r.kind == "open_co"
assert r.note == "M-Files re-released rev 1 of L900-0101 (new file)"
def test_past_draft_higher_rev_opens_a_co() -> None:
t = target(status="ready", visual_rev="2", drawing_state="attached", attached_rev="1", attached_sha=b"\x01")
r = d(t, found(rev=2))
assert r.kind == "open_co"
assert r.note == "M-Files released rev 2 of L900-0101; this packet has rev 1"
def test_past_draft_waiting_co_gets_staged() -> None:
t = target(
status="ready",
visual_rev="2",
drawing_state="attached",
attached_rev="1",
attached_sha=b"\x01",
co_waiting=("L900-0101", "2"),
co_pending=True,
)
assert d(t, found(rev=2)).kind == "stage_for_co"
def test_complete_packet_still_gets_a_co() -> None:
t = target(status="complete", drawing_state="attached", attached_rev="1", attached_sha=b"\x01")
assert d(t, found()).kind == "open_co"
# ---------------------------------------------------------------- the fixture lookup
def test_fixture_lookup_states(tmp_path: Path) -> None:
gen.generate(tmp_path)
one = FixtureLookup(tmp_path)
assert one.status("L900-0101").rev == 1
assert one.status("L900-0201").rev == 3
nr = one.status("L900-0501")
assert nr.rev is None and nr.in_drafting and nr.draft_rev == 1
assert one.status("L900-0701").rev is None and one.status("L900-0701").ok
dest = tmp_path / "out" / "x.pdf"
assert one.download(one.status("L900-0101"), dest) > 0 and dest.read_bytes().startswith(b"%PDF-")
(tmp_path / "state").write_text("2")
two = FixtureLookup(tmp_path)
f2 = two.status("L900-0101")
assert f2.rev == 2 and f2.identity != one.status("L900-0101").identity
# ---------------------------------------------------------------- the job (PostgreSQL)
@pytest.fixture
def fx(pg_settings: Settings, tmp_path: Path) -> Settings:
return feed_settings_over(pg_settings, tmp_path)
def _drawing_pages(conn: Any, pid: int) -> list[tuple[Any, ...]]:
return [
tuple(r)
for r in conn.execute(
"SELECT n, pn, dwg, rev, asset_id IS NOT NULL, void_at IS NOT NULL FROM packet_pages"
" WHERE packet_id = %s AND type = 'drawing' ORDER BY n",
(pid,),
).fetchall()
]
@needs_pg
def test_job_attaches_and_flags(fx: Settings) -> None:
run_feed(fx, 1)
counts = run_drawings(fx)
assert counts["_status"] in ("ok", "degraded"), counts
assert counts["checked"] >= 8 and counts["attached"] >= 1
with app_conn(fx) as conn:
wp = packet_by_key(conn, gen.KEY_WELD_PART)
assert wp is not None
assert wp["drawing_state"] == "attached"
assert not set(wp["flags"]) & DRAWING_FLAGS
assert wp["visual"].get("rev_confirmed") is True
pages = _drawing_pages(conn, wp["id"])
assert pages and all(p[2] == "L900-0101" and p[3] == "1" and p[4] for p in pages)
lk = conn.execute(
"SELECT ok, rev, asset_id IS NOT NULL, sha256 IS NOT NULL, checked_at IS NOT NULL FROM drawing_lookups"
" WHERE dwg = 'L900-0101'"
).fetchone()
assert lk == (True, 1, True, True, True)
# the LAYOUT packet shares the drawing: attached from the same asset
lay = packet_by_key(conn, gen.KEY_LAYOUT_A)
assert lay is not None and lay["drawing_state"] == "attached"
mm = packet_by_key(conn, gen.KEY_TWO_OPS) # VISUAL rev 2, M-Files rev 3
assert mm is not None and "rev_mismatch" in mm["flags"] and mm["drawing_state"] == "rev_mismatch"
assert mm["visual"]["appr"]["rev"] == 3
staged = conn.execute(
"SELECT asset_id IS NOT NULL, rev FROM drawing_lookups WHERE dwg = 'L900-0201'"
).fetchone()
assert staged == (True, 3) # staged for Clerical Supervisor approval
assert _drawing_pages(conn, mm["id"]) == []
nr = packet_by_key(conn, gen.KEY_DRAFTING)
assert nr is not None and "not_released" in nr["flags"] and nr["drawing_state"] == "not_released"
nf = packet_by_key(conn, gen.KEY_NOT_FOUND)
assert nf is not None and "not_found" in nf["flags"] and nf["drawing_state"] == "not_found"
br = packet_by_key(conn, gen.KEY_REV_A)
assert br is not None and "bad_rev" in br["flags"]
nd = packet_by_key(conn, gen.KEY_NO_DWG)
assert nd is not None and nd["drawing_state"] == "none"
log = conn.execute(
"SELECT by, by_name, role FROM packet_log WHERE packet_id = %s"
" AND action = 'Drawing attached from M-Files'",
(wp["id"],),
).fetchall()
assert len(log) == 1 and log[0][0] is None and log[0][2] == "system"
assert all(r[0] == "system" and r[1] is None for r in system_audit(conn, "packet.vis_update_draft"))
assert all(r[0] == "system" for r in system_audit(conn, "drawing.fetched"))
assert system_audit(conn, "drawing.fetched")
@needs_pg
def test_job_second_run_changes_nothing(fx: Settings) -> None:
run_feed(fx, 1)
run_drawings(fx)
with app_conn(fx) as conn:
conn.execute(
"UPDATE drawing_lookups SET next_check_at = now() - interval '1 minute',"
" checked_at = now() - interval '1 day'"
)
before = conn.execute("SELECT count(*) FROM packet_pages").fetchone()[0]
counts = run_drawings(fx)
assert counts.get("attached", 0) == 0 and counts.get("fetched", 0) == 0, counts
with app_conn(fx) as conn:
assert conn.execute("SELECT count(*) FROM packet_pages").fetchone()[0] == before
@needs_pg
def test_stage_for_co_after_state_2(fx: Settings) -> None:
run_feed(fx, 1)
run_drawings(fx)
with app_conn(fx) as conn:
pid = packet_id(conn, gen.KEY_WELD_PART)
move_status(conn, pid, "ready")
counts = run_feed(fx, 2)
assert counts["cos_opened"] >= 1, counts
with app_conn(fx) as conn:
co = [c for c in cos_of(conn, pid) if c["review"] == "pending"]
assert len(co) == 1 and co[0]["origin"] == "visual"
assert co[0]["waiting"] is not None and co[0]["waiting"]["rev"] == "2"
assert co[0]["staged"] is None
counts = run_drawings(fx)
assert counts.get("staged", 0) >= 1, counts
with app_conn(fx) as conn:
co = [c for c in cos_of(conn, pid) if c["review"] == "pending"]
assert len(co) == 1
assert co[0]["waiting"] is None
staged = co[0]["staged"]
assert staged["rev"] == "2" and staged["dwg"] == "L900-0101" and staged["asset_id"]
# the in-shop packet keeps its rev 1 pages until the CO is approved
assert all(p[3] == "1" for p in _drawing_pages(conn, pid))
assert (
conn.execute(
"SELECT count(*) FROM packet_log WHERE packet_id = %s AND action LIKE 'Drawing for change order%%'",
(pid,),
).fetchone()[0]
== 1
)
@needs_pg
def test_new_file_on_an_in_shop_packet_opens_a_drawing_co(fx: Settings) -> None:
run_feed(fx, 1)
run_drawings(fx)
with app_conn(fx) as conn:
pid = packet_id(conn, gen.KEY_CLOSES) # L900-0901 rev 1, attached
assert _drawing_pages(conn, pid)
move_status(conn, pid, "in_progress")
conn.execute(
"UPDATE drawing_lookups SET checked_at = now() - interval '2 days', next_check_at = now()"
" WHERE dwg = 'L900-0901'"
)
# M-Files re-releases rev 1 of L900-0901 as a new file (same rev, new version and bytes)
d1 = fx.fixture_dir / "drawings.1.json"
import json
data = json.loads(d1.read_text())
data["L900-0901"].update({"file": "L900-0901 r1b.pdf", "obj_version": 2, "file_version": 2})
d1.write_text(json.dumps(data))
(fx.fixture_dir / "L900-0901 r1b.pdf").write_bytes(gen.pdf_bytes("L900-0901 r1b"))
counts = run_drawings(fx)
assert counts.get("cos_opened", 0) == 1, counts
with app_conn(fx) as conn:
cos = cos_of(conn, pid)
assert len(cos) == 1
co = cos[0]
assert (co["origin"], co["review"], co["ecn"], co["issued_by"]) == ("drawing", "pending", "M-FILES", None)
assert co["reason"] == "M-Files re-released rev 1 of L900-0901 (new file)"
assert co["staged"]["asset_id"] and co["vis"]["dwg_change"] is True
assert all(r[0] == "system" for r in system_audit(conn, "packet.vis_open_co"))
assert system_audit(conn, "packet.vis_open_co")
+438
View File
@@ -0,0 +1,438 @@
"""The Clerical feed API (/api/feed), rev approval, hand uploads and feed change order approval, on the generator's
fixture files run through the real feed and drawing jobs."""
from __future__ import annotations
import io
import json
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from fakedir import FAKE_PASSWORD, FakeDirectory
from fastapi.testclient import TestClient
from feedhelpers import (
cos_of,
feed_settings_over,
gen,
golive_today_offset,
move_status,
packet_id,
run_drawings,
run_feed,
set_feed,
)
from pkthelpers import SIGN, seed_users, tiny_pdf, upload
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg, sign_in
from packetstation import db
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def fx(pg_settings: Settings, tmp_path: Path) -> Settings:
s = feed_settings_over(pg_settings, tmp_path)
set_feed(s, auto_release=False)
return s
@pytest.fixture
def client(fx: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
from packetstation.main import create_app
with TestClient(create_app(fx, directory=fake_dir), headers=ORIGIN_HEADERS) as c:
seed_users(c, ("admin.ann", "clerk.clara", "clerk.leo", "wh.wade"))
yield c
def as_user(c: TestClient, sam: str) -> None:
assert sign_in(c, sam).status_code == 200, sam
def drawing_pages(conn: Any, pid: int) -> list[tuple[Any, ...]]:
return [
tuple(r)
for r in conn.execute(
"SELECT n, rev, co_id IS NOT NULL, void_at IS NOT NULL, void_co_id IS NOT NULL FROM packet_pages"
" WHERE packet_id = %s AND type = 'drawing' ORDER BY n",
(pid,),
).fetchall()
]
def packet_row(conn: Any, pid: int) -> dict[str, Any]:
from psycopg.rows import dict_row
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute("SELECT * FROM packets WHERE id = %s", (pid,)).fetchone()
assert r is not None
return dict(r)
# ---------------------------------------------------------------- the From VISUAL panel
def test_overview_shape(client: TestClient, fx: Settings) -> None:
set_feed(fx, golive_date=golive_today_offset())
run_feed(fx, 1)
run_drawings(fx)
as_user(client, "clerk.clara")
r = client.get("/api/feed/overview")
assert r.status_code == 200, r.text
o = r.json()
assert set(o) >= {
"status",
"last_run",
"runs",
"drawing_runs",
"ignored",
"unmapped",
"projects_without_names",
"golive_date",
"departments",
}
assert o["status"]["paused"] is False
assert o["status"]["worker"] is None or o["status"]["worker"]["stale"] is True
assert o["last_run"]["status"] == "ok" and o["last_run"]["counts"]["new_drafts"] == len(gen.STATE1_KEYS) - 1
assert len(o["runs"]) == 1 and len(o["drawing_runs"]) == 1
assert o["unmapped"] == [{"resource": "NEWRES", "rows": 2}]
assert [(i["wo_base"], i["lot"]) for i in o["ignored"]] == [("L900-13", "2")]
assert {p["number"] for p in o["projects_without_names"]} == {"L900", "L901"}
l900 = next(p for p in o["projects_without_names"] if p["number"] == "L900")
assert l900["units"] == 2 and l900["packets"] == len(gen.STATE1_KEYS) - 2
assert o["golive_date"] == golive_today_offset().isoformat() and o["departments"] == []
def test_feed_api_is_clerical_only(client: TestClient) -> None:
as_user(client, "wh.wade")
assert client.get("/api/feed/overview").status_code == 403
assert client.post("/api/feed/run", json={}).status_code == 403
assert client.get("/api/feed/batches").status_code == 403
def test_run_now_inserts_a_request_and_notifies(client: TestClient, fx: Settings) -> None:
listener = db.connect_as("worker", fx, autocommit=True)
listener.execute("LISTEN ps_job_request")
try:
as_user(client, "clerk.clara")
r = client.post("/api/feed/run", json={})
assert r.status_code == 200 and r.json()["requested"] is True and r.json()["pending"] == 1, r.text
got = list(listener.notifies(timeout=3, stop_after=1))
assert got and json.loads(got[0].payload) == {"job": "visual-feed"}
r = client.post("/api/feed/run", json={"job": "drawing-lookup"})
assert r.status_code == 200 and r.json()["job"] == "drawing-lookup"
assert client.post("/api/feed/run", json={"job": "housekeeping"}).status_code == 422
finally:
listener.close()
with app_conn(fx) as conn:
rows = conn.execute("SELECT job FROM job_requests WHERE claimed_at IS NULL ORDER BY id").fetchall()
assert [r[0] for r in rows] == ["visual-feed", "drawing-lookup"]
assert conn.execute("SELECT count(*) FROM audit_events WHERE action = 'feed.run_requested'").fetchone()[0] == 2
# the worker's next run claims the request and records the trigger
c = run_feed(fx, 1)
assert c["_trigger"] == "request"
assert client.get("/api/feed/overview").json()["last_run"]["trigger"] == "request"
def test_pull_in(client: TestClient, fx: Settings) -> None:
set_feed(fx, golive_date=golive_today_offset())
run_feed(fx, 1)
as_user(client, "clerk.clara")
ig = client.get("/api/feed/overview").json()["ignored"][0]
r = client.post(f"/api/feed/ignored/{ig['id']}/pull-in", json={"reason": ""})
assert r.status_code == 400 and r.json()["code"] == "reason_required"
r = client.post(f"/api/feed/ignored/{ig['id']}/pull-in", json={"reason": "Customer still wants it"})
assert r.status_code == 200 and r.json()["pulled_in_at"], r.text
r = client.post(f"/api/feed/ignored/{ig['id']}/pull-in", json={"reason": "again please"})
assert r.status_code == 409
assert client.post("/api/feed/ignored/999999/pull-in", json={"reason": "nothing"}).status_code == 404
with app_conn(fx) as conn:
assert conn.execute("SELECT count(*) FROM job_requests WHERE claimed_at IS NULL").fetchone()[0] == 1
assert conn.execute("SELECT count(*) FROM audit_events WHERE action = 'feed.pulled_in'").fetchone()[0] == 1
c = run_feed(fx, 1)
assert c["new_drafts"] == 1 and c["_trigger"] == "request"
assert client.get("/api/feed/overview").json()["ignored"] == []
def test_batches_and_changes(client: TestClient, fx: Settings) -> None:
run_feed(fx, 1)
run_feed(fx, 2)
as_user(client, "clerk.clara")
batches = client.get("/api/feed/batches").json()
assert len(batches) == 2 and batches[0]["id"] > batches[1]["id"]
ch = client.get("/api/feed/changes").json()
assert ch["batch"]["id"] == batches[0]["id"]
kinds = {(r["wo_base"], r["kind"]) for r in ch["rows"]}
assert ("L900-01", "changed") in kinds and ("L900-05", "gone") in kinds
first = client.get(f"/api/feed/changes?batch_id={batches[1]['id']}").json()
assert all(r["kind"] == "added" for r in first["rows"]) and first["rows"]
assert client.get("/api/feed/changes?batch_id=999999").status_code == 404
# ---------------------------------------------------------------- rev approval
def test_rev_approve(client: TestClient, fx: Settings) -> None:
run_feed(fx, 1)
run_drawings(fx)
with app_conn(fx) as conn:
pid = packet_id(conn, gen.KEY_TWO_OPS) # VISUAL rev 2, M-Files rev 3: staged
nf = packet_id(conn, gen.KEY_NOT_FOUND)
body = {"reason": "Engineering confirmed rev 3 is right", "sign": SIGN}
as_user(client, "clerk.clara")
r = client.post(f"/api/packets/{pid}/rev-approve", json=body)
assert r.status_code == 403, r.text
as_user(client, "clerk.leo")
r = client.post(f"/api/packets/{pid}/rev-approve", json={"reason": "no signature"})
assert r.status_code == 400 and r.json()["code"] == "signature_required", r.text
r = client.post(f"/api/packets/{nf}/rev-approve", json=body)
assert r.status_code == 409 and r.json()["code"] == "nothing_staged", r.text
r = client.post(f"/api/packets/{pid}/rev-approve", json=body)
assert r.status_code == 200, r.text
with app_conn(fx) as conn:
p = packet_row(conn, pid)
assert p["drawing_state"] == "attached" and "rev_mismatch" not in p["flags"]
assert p["visual"]["rev_ok"]["rev"] == "3" and p["visual"]["rev_ok"]["vis_rev"] == "2"
assert p["visual"]["rev_confirmed"] is True
pages = drawing_pages(conn, pid)
assert pages and all(pg[1] == "3" for pg in pages)
assert (
conn.execute(
"SELECT count(*) FROM packet_log WHERE packet_id = %s AND action = 'Different rev approved by hand'",
(pid,),
).fetchone()[0]
== 1
)
assert (
conn.execute(
"SELECT count(*) FROM audit_events WHERE action = 'packet.rev_approve' AND packet_id = %s", (pid,)
).fetchone()[0]
== 1
)
# the drawing job leaves the approved rev alone
with app_conn(fx) as conn:
conn.execute("UPDATE drawing_lookups SET checked_at = now() - interval '2 days', next_check_at = now()")
run_drawings(fx)
with app_conn(fx) as conn:
p = packet_row(conn, pid)
assert p["drawing_state"] == "attached" and "rev_mismatch" not in p["flags"]
# ---------------------------------------------------------------- hand uploads
def test_hand_drawing(client: TestClient, fx: Settings) -> None:
run_feed(fx, 1)
run_drawings(fx)
with app_conn(fx) as conn:
nf = packet_id(conn, gen.KEY_NOT_FOUND)
wp = packet_id(conn, gen.KEY_WELD_PART)
as_user(client, "clerk.clara")
a = upload(client, tiny_pdf(2), "L900-0701 hand.pdf")
assert a.status_code == 201, a.text
asset = a.json()["id"]
r = client.post(f"/api/packets/{wp}/hand/drawing", json={"asset_id": asset, "reason": "Have it", "sign": SIGN})
assert r.status_code == 409 and r.json()["code"] == "not_flagged", r.text
r = client.post(f"/api/packets/{nf}/hand/drawing", json={"asset_id": asset, "reason": "From drafting"})
assert r.status_code == 400 and r.json()["code"] == "signature_required", r.text
r = client.post(
f"/api/packets/{nf}/hand/drawing", json={"asset_id": asset, "reason": "From drafting by email", "sign": SIGN}
)
assert r.status_code == 200, r.text
with app_conn(fx) as conn:
p = packet_row(conn, nf)
assert p["drawing_state"] == "hand" and "hand_drawing" in p["flags"] and "not_found" not in p["flags"]
assert p["visual"]["manual_dwg"]["reason"] == "From drafting by email"
assert len(drawing_pages(conn, nf)) == 2
assert (
conn.execute(
"SELECT count(*) FROM packet_log WHERE packet_id = %s AND action = 'Drawing uploaded by hand'", (nf,)
).fetchone()[0]
== 1
)
# a hand flag keeps it off auto-release, and the drawing job does not take it away
run_drawings(fx)
with app_conn(fx) as conn:
assert packet_row(conn, nf)["drawing_state"] == "hand"
def _xlsx(rows: list[list[Any]]) -> bytes:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
assert ws is not None
for r in rows:
ws.append(r)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _hand_bom(client: TestClient, pid: int, data: bytes, name: str, **form: str) -> Any:
fields = {"reason": "Engineering sent the BOM", "sign_factor": "password", "sign_secret": FAKE_PASSWORD}
fields.update(form)
return client.post(
f"/api/packets/{pid}/hand/bom", files={"file": (name, data, "application/octet-stream")}, data=fields
)
@pytest.mark.parametrize("kind", ["xlsx", "csv"])
def test_hand_bom(client: TestClient, fx: Settings, kind: str) -> None:
run_feed(fx, 1)
run_drawings(fx)
with app_conn(fx) as conn:
mm = packet_id(conn, gen.KEY_TWO_OPS) # rev mismatch: flagged
wp = packet_id(conn, gen.KEY_WELD_PART) # attached, no flags
visual_lines = conn.execute("SELECT count(*) FROM bom_lines WHERE packet_id = %s", (mm,)).fetchone()[0]
if kind == "xlsx":
data, name = (
_xlsx(
[
["Item", "Qty", "Part ID", "Description"],
[1, 4, "PLT-0.5-A36", "Plate"],
[2, 2, "CPL-2-3000", "Coupling"],
[3, 1, None, "Gusset"],
]
),
"bom.xlsx",
)
else:
data, name = (
b"Item,Qty,Part ID,Description\r\n1,4,PLT-0.5-A36,Plate\r\n2,2,CPL-2-3000,Coupling\r\n3,1,,Gusset\r\n",
"bom.csv",
)
as_user(client, "clerk.clara")
r = _hand_bom(client, wp, data, name)
assert r.status_code == 409 and r.json()["code"] == "not_flagged", r.text
r = _hand_bom(client, mm, data, name, sign_secret="wrong password")
assert r.status_code in (400, 401, 403), r.text
r = _hand_bom(client, mm, b"Name,Colour\nPipe,Red\n", "bad.csv")
assert r.status_code == 422 and r.json()["code"] == "bad_file", r.text
r = _hand_bom(client, mm, data, name)
assert r.status_code == 200, r.text
with app_conn(fx) as conn:
lines = conn.execute(
"SELECT item, qty, part_id, description, src FROM bom_lines WHERE packet_id = %s ORDER BY item", (mm,)
).fetchall()
assert [(ln[0], ln[2], ln[4]) for ln in lines] == [
(1, "PLT-0.5-A36", "hand"),
(2, "CPL-2-3000", "hand"),
(3, None, "hand"),
]
p = packet_row(conn, mm)
assert "hand_bom" in p["flags"] and p["visual"]["manual_bom"]["lines"] == 3
assert (
conn.execute(
"SELECT count(*) FROM packet_log WHERE packet_id = %s AND action = 'BOM uploaded by hand'", (mm,)
).fetchone()[0]
== 1
)
# the feed keeps a hand BOM on a draft
run_feed(fx, 2)
with app_conn(fx) as conn:
assert (
conn.execute("SELECT count(*) FROM bom_lines WHERE packet_id = %s AND src = 'hand'", (mm,)).fetchone()[0]
== 3
)
# back to VISUAL's BOM
r = client.request("DELETE", f"/api/packets/{mm}/hand/bom", json={"sign": SIGN})
assert r.status_code == 200, r.text
with app_conn(fx) as conn:
src = conn.execute("SELECT DISTINCT src FROM bom_lines WHERE packet_id = %s", (mm,)).fetchall()
assert src == [("visual",)]
assert conn.execute("SELECT count(*) FROM bom_lines WHERE packet_id = %s", (mm,)).fetchone()[0] == visual_lines
p = packet_row(conn, mm)
assert "hand_bom" not in p["flags"] and "manual_bom" not in p["visual"]
# ---------------------------------------------------------------- feed change orders
def test_co_approve_applies_vis_and_voids_pages(client: TestClient, fx: Settings) -> None:
run_feed(fx, 1)
run_drawings(fx)
with app_conn(fx) as conn:
wp = packet_id(conn, gen.KEY_WELD_PART)
assert packet_row(conn, wp)["drawing_state"] == "attached"
move_status(conn, wp, "ready")
old_pages = drawing_pages(conn, wp)
assert old_pages and all(pg[1] == "1" for pg in old_pages)
run_feed(fx, 2)
with app_conn(fx) as conn:
co = cos_of(conn, wp)[0]
assert co["waiting"] is not None and co["staged"] is None
as_user(client, "clerk.clara")
r = client.post(f"/api/packets/{wp}/change-orders/{co['co_no']}/approve", json={"sign": SIGN})
assert r.status_code == 409 and r.json()["code"] == "co_not_staged", r.text
assert "not been fetched" in r.json()["detail"]
r = client.get(f"/api/packets/{wp}")
assert r.status_code == 200
assert r.json()["status"] == "ready"
run_drawings(fx, 2)
with app_conn(fx) as conn:
co = cos_of(conn, wp)[0]
assert co["staged"] is not None and co["staged"]["rev"] == "2" and co["waiting"] is None
r = client.post(f"/api/packets/{wp}/change-orders/{co['co_no']}/approve", json={})
assert r.status_code == 400 and r.json()["code"] == "signature_required", r.text
r = client.post(f"/api/packets/{wp}/change-orders/{co['co_no']}/approve", json={"sign": SIGN})
assert r.status_code == 200, r.text
with app_conn(fx) as conn:
p = packet_row(conn, wp)
co = cos_of(conn, wp)[0]
assert co["review"] == "approved" and co["approved_by"] is not None
assert p["rev"] == "2" and p["status"] == "ready"
assert p["visual_fp_applied"] == co["target_fp"]
assert p["visual"]["fp_parts"] == co["vis"]["fp_parts"]
pages = drawing_pages(conn, wp)
void = [pg for pg in pages if pg[3]]
live = [pg for pg in pages if not pg[3]]
assert len(void) == len(old_pages) and all(pg[4] for pg in void) # VOID by this CO
assert live and all(pg[1] == "2" and pg[2] for pg in live)
items = conn.execute("SELECT old_rev, new_rev FROM co_items WHERE co_id = %s", (co["id"],)).fetchall()
assert ("1", "2") in [tuple(i) for i in items]
required = {
r[0]
for r in conn.execute("SELECT dept_code FROM co_required_depts WHERE co_id = %s", (co["id"],)).fetchall()
}
assert "03" in required
# the next feed run sees the packet matches VISUAL: no new change order
c = run_feed(fx, 2)
assert c["cos_opened"] == 0, c
with app_conn(fx) as conn:
assert len(cos_of(conn, wp)) == 1
def test_minor_co_approve_needs_no_departments(client: TestClient, fx: Settings) -> None:
run_feed(fx, 1)
with app_conn(fx) as conn:
g = packet_id(conn, gen.KEY_NOT_FOUND)
move_status(conn, g, "warehouse")
run_feed(fx, 2)
with app_conn(fx) as conn:
co = cos_of(conn, g)[0]
assert co["minor"] is True
as_user(client, "clerk.clara")
r = client.post(f"/api/packets/{g}/change-orders/{co['co_no']}/approve", json={"sign": SIGN})
assert r.status_code == 200, r.text
with app_conn(fx) as conn:
assert conn.execute("SELECT count(*) FROM co_required_depts WHERE co_id = %s", (co["id"],)).fetchone()[0] == 0
assert conn.execute("SELECT target_hrs FROM packet_work_orders WHERE packet_id = %s", (g,)).fetchone()[0] == 5
def test_changes_to_approve_filter(client: TestClient, fx: Settings) -> None:
run_feed(fx, 1)
with app_conn(fx) as conn:
wp = packet_id(conn, gen.KEY_WELD_PART)
move_status(conn, wp, "ready")
run_feed(fx, 2)
as_user(client, "clerk.clara")
r = client.get("/api/packets?co=pending")
assert r.status_code == 200, r.text
body = r.json()
items = body["packets"]
assert [i["id"] for i in items] == [wp]
assert items[0]["co_pending"] is True
+507
View File
@@ -0,0 +1,507 @@
"""The VISUAL feed end to end on PostgreSQL: the generator's fixture files, the FixtureSource, the cache, the
candidates and the apply, through the visual-feed job as the worker runs it."""
from __future__ import annotations
import json
from datetime import date, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Any
import pytest
from feedhelpers import (
CHANGE_KEYS,
cos_of,
feed_settings_over,
gen,
legs_of,
move_status,
packet_by_key,
packet_id,
run_drawings,
run_feed,
set_feed,
switch_state,
system_audit,
)
from pshelpers import app_conn, needs_pg
from psycopg.rows import dict_row
from packetstation.feed import fingerprint
from packetstation.settings import Settings
pytestmark = needs_pg
TODAY = date.today()
@pytest.fixture
def fx(pg_settings: Settings, tmp_path: Path) -> Settings:
s = feed_settings_over(pg_settings, tmp_path)
set_feed(s, auto_release=False) # most tests look at drafts; auto-release has its own test
return s
def row(conn: Any, sql: str, params: Any = None) -> dict[str, Any]:
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(sql, params).fetchone()
assert r is not None, sql
return dict(r)
def no_human_writes(conn: Any) -> None:
"""Every audit row the feed wrote has role_snapshot 'system' and no user."""
bad = conn.execute(
"SELECT action, role_snapshot FROM audit_events WHERE user_id IS NULL"
" AND (action LIKE 'packet.%' OR action LIKE 'feed.%' OR action LIKE 'project.%' OR action LIKE 'unit.%'"
" OR action LIKE 'drawing.%') AND role_snapshot IS DISTINCT FROM 'system'"
).fetchall()
assert bad == []
logs = conn.execute("SELECT DISTINCT by_name, role FROM packet_log WHERE by IS NULL ORDER BY 1").fetchall()
assert all(r[1] == "system" for r in logs), logs
def window_firmed_wos(state: int = 1) -> int:
"""Firmed work order legs released inside the default window (what visual_batches.window_rows counts)."""
since, until = TODAY - timedelta(days=14), TODAY + timedelta(days=30)
return len(
{
(r["wo"], r["lot"], r["split"], r["sub"])
for r in gen.feed_state(state, TODAY)["rows"]
if r["st"] == "Firmed" and r["rel"] and since <= date.fromisoformat(r["rel"]) <= until
}
)
def window_firmed_rows(state: int = 1) -> int:
since, until = TODAY - timedelta(days=14), TODAY + timedelta(days=30)
return sum(
1
for r in gen.feed_state(state, TODAY)["rows"]
if r["st"] == "Firmed" and r["rel"] and since <= date.fromisoformat(r["rel"]) <= until
)
# ---------------------------------------------------------------- state 1
def test_state_1_creates_projects_units_and_drafts(fx: Settings) -> None:
c = run_feed(fx, 1)
assert c["_status"] == "ok", c
assert c["new_drafts"] == len(gen.STATE1_KEYS)
assert c["projects_created"] == 2 and c["units_created"] == 3
assert c["unmapped_resources"] >= 1 and c["unmapped"] == {"NEWRES": 2}
assert c["suspect"] == 0 and c["rows"] >= window_firmed_rows()
with app_conn(fx) as conn:
keys = {r[0] for r in conn.execute("SELECT visual_key FROM packets WHERE removed_at IS NULL").fetchall()}
assert keys == set(gen.STATE1_KEYS)
p = row(conn, "SELECT * FROM projects WHERE number = 'L900'")
assert p["source"] == "visual" and p["needs_details"] is True and p["name"] is None
assert p["created_by"] is None
assert (p["customer"], p["customer_src"], p["customer_visual"]) == ("Acme Refining", "visual", "Acme Refining")
assert p["po"] == "PO-1001" and p["order_ref"]["cust_order_id"] == gen.ORDER_ACME
u1 = row(
conn,
"SELECT u.* FROM units u JOIN projects p ON p.id = u.project_id WHERE p.number = 'L900' AND u.lot = '001'",
)
assert u1["customer"] == "Bayou Chemical" and u1["po"] == "PO-2002" and u1["customer_src"] == "visual"
assert u1["order_ref"]["cust_order_id"] == gen.ORDER_BAYOU and u1["order_ref"]["via"] == "unit_top_job"
assert u1["ship_date"] == TODAY + timedelta(days=70) and u1["ship_date_visual"] == TODAY + timedelta(days=70)
u2 = row(
conn,
"SELECT u.* FROM units u JOIN projects p ON p.id = u.project_id WHERE p.number = 'L900' AND u.lot = '002'",
)
assert u2["customer"] == "Acme Refining" and u2["order_ref"]["cust_order_id"] == gen.ORDER_ACME
z = row(conn, "SELECT u.* FROM units u JOIN projects p ON p.id = u.project_id WHERE p.number = 'L901'")
assert z["customer"] == "Delta Gas" and z["order_ref"]["status"] == "C"
assert row(conn, "SELECT order_ref FROM projects WHERE number = 'L901'")["order_ref"]["via"] in (
"project_newest",
"unit_top_job",
"base",
)
aliases = {r[0] for r in conn.execute("SELECT alias FROM project_aliases WHERE kind = 'import'").fetchall()}
assert {"L900", "L900001", "L900002", "L901", "L901001"} <= aliases
assert conn.execute("SELECT count(*) FROM units WHERE lot = '7A'").fetchone()[0] == 0
wp = packet_by_key(conn, gen.KEY_WELD_PART)
assert wp is not None
assert (wp["status"], wp["source_kind"], wp["dept_code"], wp["op"]) == ("draft", "visual", "03", "WELD-PART")
assert (wp["drawing"], wp["rev"], wp["drawing_state"]) == ("L900-0101", "1", "needed")
assert wp["flags"] == ["drawing_needed"] and wp["created_by"] is None
assert wp["title"] == "Inlet spool 6 in" and wp["customer"] == "Acme Refining"
assert wp["visual_rel"] == TODAY - timedelta(days=3) and wp["visual_want"] == TODAY + timedelta(days=20)
assert wp["visual_fp_applied"] and set(wp["visual"]["fp_parts"]) >= {"legs", "qty", "rev", "drawing", "bom"}
assert wp["visual_fp_applied"] == fingerprint._full(wp["visual"]["fp_parts"])
assert {f["seq"] for f in wp["visual"]["folded"]} == {30, 50}
assert wp["visual"]["partspecs"] == ["Cut to length per sheet 1", "Weld per WPS-11\nBack purge"]
assert wp["source"]["kind"] == "visual"
legs = legs_of(conn, wp["id"])
assert len(legs) == 1 and legs[0][:5] == ("L900-01", "1", "0", "0", 20)
assert (legs[0][5], legs[0][6], legs[0][7]) == (Decimal(2), Decimal(3), "F")
wo = row(conn, "SELECT * FROM packet_work_orders WHERE packet_id = %s", (wp["id"],))
assert (wo["dwg"], wo["rev"], wo["op"]) == ("L900-0101", "1", "WELD-PART")
bom = conn.execute(
"SELECT coalesce(cl_item, item::text), part_id, qty, loc, sub, na, src, vkey FROM bom_lines"
" WHERE packet_id = %s ORDER BY sort",
(wp["id"],),
).fetchall()
assert {b[1] for b in bom} == {"PIPE-6-SCH40", "L900-01-SUB", "WOL-1-6"}
assert all(b[6] == "visual" for b in bom)
sub = next(b for b in bom if b[1] == "L900-01-SUB")
assert sub[5] is True
main = next(b for b in bom if b[1] == "PIPE-6-SCH40")
assert main[3] == "A-01" and main[7] == "L900-01|1|0|20|1"
vs = row(conn, "SELECT * FROM packet_visual_state WHERE packet_id = %s", (wp["id"],))
assert vs["ticket_hours"] == Decimal("1.25") and vs["observed_at"] is not None
assert {(lg["seq"], lg["st"]) for lg in vs["legs"]} == {(20, "F")}
two = packet_by_key(conn, gen.KEY_TWO_OPS)
assert two is not None
assert [lg[4] for lg in legs_of(conn, two["id"])] == [10, 20] # two operations, one packet
nd = packet_by_key(conn, gen.KEY_NO_DWG)
assert nd is not None and nd["drawing_state"] == "none" and "no_drawing" in nd["flags"]
assert nd["drawing"] in (None, "")
rev_a = packet_by_key(conn, gen.KEY_REV_A)
assert rev_a is not None and rev_a["rev"] == "A"
items = [
r[0]
for r in conn.execute(
"SELECT coalesce(cl_item, item::text) FROM bom_lines WHERE packet_id = %s ORDER BY sort", (rev_a["id"],)
).fetchall()
]
assert sorted(items) == ["1", "1-1", "2"]
log = conn.execute(
"SELECT by, by_name, role FROM packet_log WHERE packet_id = %s AND action = 'Packet created from VISUAL'",
(wp["id"],),
).fetchall()
assert log == [(None, "VISUAL feed", "system")]
created = system_audit(conn, "packet.created")
assert len(created) == len(gen.STATE1_KEYS) and all(r[0] == "system" and r[1] is None for r in created)
assert all(r[0] == "system" for r in system_audit(conn, "project.created"))
assert len(system_audit(conn, "project.created")) == 2
assert len(system_audit(conn, "unit.created")) == 3
assert len(system_audit(conn, "feed.run")) == 1
no_human_writes(conn)
batch = row(conn, "SELECT * FROM visual_batches ORDER BY id DESC LIMIT 1")
assert batch["suspect"] is False and batch["window_rows"] == window_firmed_wos()
assert conn.execute("SELECT count(*) FROM visual_rows WHERE gone_batch IS NULL").fetchone()[0] >= (
window_firmed_rows()
)
assert conn.execute("SELECT count(*) FROM visual_orders").fetchone()[0] == 3
assert conn.execute("SELECT count(*) FROM visual_rows WHERE lot = '7A'").fetchone()[0] >= 1
assert conn.execute("SELECT count(*) FROM visual_labor").fetchone()[0] == 2
def test_second_run_changes_nothing(fx: Settings) -> None:
run_feed(fx, 1)
with app_conn(fx) as conn:
before = conn.execute("SELECT id, version FROM packets ORDER BY id").fetchall()
logs = conn.execute("SELECT count(*) FROM packet_log").fetchone()[0]
c = run_feed(fx, 1)
assert c["_status"] == "ok"
assert {k: c.get(k, 0) for k in CHANGE_KEYS} == dict.fromkeys(CHANGE_KEYS, 0)
with app_conn(fx) as conn:
assert conn.execute("SELECT id, version FROM packets ORDER BY id").fetchall() == before
assert conn.execute("SELECT count(*) FROM packet_log").fetchone()[0] == logs
assert conn.execute("SELECT count(*) FROM visual_row_changes WHERE kind <> 'added'").fetchone()[0] == 0
def test_hand_customer_is_never_overwritten(fx: Settings) -> None:
run_feed(fx, 1)
with app_conn(fx) as conn:
conn.execute(
"UPDATE units SET customer = 'Hand Co', customer_src = 'hand' WHERE lot = '001'"
" AND project_id = (SELECT id FROM projects WHERE number = 'L900')"
)
run_feed(fx, 1)
with app_conn(fx) as conn:
u = row(
conn,
"SELECT u.* FROM units u JOIN projects p ON p.id = u.project_id WHERE p.number = 'L900' AND u.lot = '001'",
)
assert (u["customer"], u["customer_src"], u["customer_visual"]) == ("Hand Co", "hand", "Bayou Chemical")
def test_order_pin_wins(fx: Settings) -> None:
run_feed(fx, 1)
with app_conn(fx) as conn:
conn.execute(
"UPDATE units SET order_pin = %s, order_pin_reason = 'Spool is for Acme' WHERE lot = '001'"
" AND project_id = (SELECT id FROM projects WHERE number = 'L900')",
(gen.ORDER_ACME,),
)
run_feed(fx, 1)
with app_conn(fx) as conn:
u = row(
conn,
"SELECT u.* FROM units u JOIN projects p ON p.id = u.project_id WHERE p.number = 'L900' AND u.lot = '001'",
)
assert u["order_ref"]["cust_order_id"] == gen.ORDER_ACME and u["order_ref"]["via"] == "pin"
assert u["customer"] == "Acme Refining"
def test_golive_ignores_and_pull_in(fx: Settings) -> None:
set_feed(fx, golive_date=TODAY + timedelta(days=gen.GOLIVE_OFFSET))
c = run_feed(fx, 1)
assert c["ignored_golive"] == 1 and c["new_drafts"] == len(gen.STATE1_KEYS) - 1
with app_conn(fx) as conn:
assert packet_by_key(conn, gen.KEY_GOLIVE, live=False) is None
ig = row(conn, "SELECT * FROM visual_ignored_wos")
assert (ig["wo_base"], ig["lot"], ig["split"], ig["sub"]) == ("L900-13", "2", "0", "0")
assert ig["pulled_in_at"] is None and ig["rel"] == TODAY - timedelta(days=10)
conn.execute(
"UPDATE visual_ignored_wos SET pulled_in_at = now(), pulled_in_reason = 'needed' WHERE id = %s", (ig["id"],)
)
c = run_feed(fx, 1)
assert c["new_drafts"] == 1
with app_conn(fx) as conn:
assert packet_by_key(conn, gen.KEY_GOLIVE) is not None
# ---------------------------------------------------------------- state 2
def _shop(fx: Settings) -> tuple[int, int]:
"""Run state 1, then put the WELD-PART packet in the shop (ready) and the not-found one at the warehouse."""
run_feed(fx, 1)
with app_conn(fx) as conn:
wp, g = packet_id(conn, gen.KEY_WELD_PART), packet_id(conn, gen.KEY_NOT_FOUND)
move_status(conn, wp, "ready")
move_status(conn, g, "warehouse")
return wp, g
def test_state_2_change_orders_updates_gone_and_moved(fx: Settings) -> None:
wp, g = _shop(fx)
with app_conn(fx) as conn:
fp1 = row(conn, "SELECT visual_fp_applied FROM packets WHERE id = %s", (wp,))["visual_fp_applied"]
two_id = packet_id(conn, gen.KEY_TWO_OPS)
moves_id = packet_id(conn, gen.KEY_MOVES)
c = run_feed(fx, 2)
assert c["_status"] == "ok", c
assert c["cos_opened"] == 2 and c["drafts_updated"] >= 2 and c["retired"] == 1, c
assert c["wo_gone_flagged"] == 0 # the first miss only counts
with app_conn(fx) as conn:
# rev 1 -> 2 on an in-shop packet: a change order waiting for the rev 2 drawing; the packet stays as it was
p = row(conn, "SELECT * FROM packets WHERE id = %s", (wp,))
assert (p["status"], p["rev"], p["visual_fp_applied"]) == ("ready", "1", fp1)
cos = cos_of(conn, wp)
assert len(cos) == 1
co = cos[0]
assert (co["origin"], co["review"], co["minor"], co["ecn"], co["issued_by"]) == (
"visual",
"pending",
False,
"VISUAL",
None,
)
assert co["target_fp"] and co["target_fp"] != fp1
assert co["vis"]["to_rev"] == "2" and co["vis"]["from_rev"] == "1" and co["vis"]["fp_parts"]
assert co["waiting"]["dwg"] == "L900-0101" and co["waiting"]["rev"] == "2"
assert co["staged"] is None
# tgt only: a minor change order with nothing to wait for
gco = cos_of(conn, g)
assert len(gco) == 1 and gco[0]["minor"] is True and gco[0]["waiting"] is None
# qty change on a draft: updated in place, both operations
two = row(conn, "SELECT * FROM packets WHERE id = %s", (two_id,))
assert two["status"] == "draft" and cos_of(conn, two_id) == []
assert [lg[5] for lg in legs_of(conn, two_id)] == [Decimal(3), Decimal(3)]
assert (
conn.execute(
"SELECT count(*) FROM packet_log WHERE packet_id = %s AND action = 'Updated from VISUAL'", (two_id,)
).fetchone()[0]
== 1
)
# the LAYOUT draft shares the drawing: rev moved in place
lay = packet_by_key(conn, gen.KEY_LAYOUT_A)
assert lay is not None and lay["rev"] == "2" and lay["drawing_state"] == "needed"
# retire-moved
old = row(conn, "SELECT * FROM packets WHERE id = %s", (moves_id,))
assert old["removed_at"] is not None and old["removed_by"] is None and old["removed_reason"] == "moved"
assert old["visual"]["moved"]["to"]
assert packet_by_key(conn, gen.KEY_MOVED_TO) is not None
# gone: one miss counted, no flag yet
e = packet_id(conn, gen.KEY_DRAFTING)
assert [lg[8] for lg in legs_of(conn, e)] == [1]
assert "wo_gone" not in row(conn, "SELECT flags FROM packets WHERE id = %s", (e,))["flags"]
# closed and cancelled observed
j = row(conn, "SELECT * FROM packets WHERE visual_key = %s", (gen.KEY_CLOSES,))
assert j["visual_closed_at"] is not None and legs_of(conn, j["id"])[0][7] == "C"
k = row(conn, "SELECT * FROM packets WHERE visual_key = %s", (gen.KEY_CANCELS,))
assert "cancelled" in k["flags"] and k["visual_cancelled_at"] is not None and k["removed_at"] is None
assert all(r[0] == "system" for r in system_audit(conn, "packet.vis_open_co"))
assert all(r[0] == "system" for r in system_audit(conn, "packet.vis_update_draft"))
assert len(system_audit(conn, "packet.vis_retire_draft")) == 1
no_human_writes(conn)
# the second miss flags the work order gone; nothing else moves
c = run_feed(fx, 2)
assert c["wo_gone_flagged"] == 1 and c["cos_opened"] == 0 and c["cos_reaimed"] == 0, c
assert c["drafts_updated"] == 0 and c["retired"] == 0 and c["new_drafts"] == 0
with app_conn(fx) as conn:
e = row(conn, "SELECT * FROM packets WHERE visual_key = %s AND removed_at IS NULL", (gen.KEY_DRAFTING,))
assert "wo_gone" in e["flags"] and e["wo_gone_at"] is not None
assert [lg[8:] for lg in legs_of(conn, e["id"])] == [(2, True)]
assert len(cos_of(conn, wp)) == 1 # the same CO, not a second one
assert (
conn.execute(
"SELECT count(*) FROM packet_log WHERE packet_id = %s"
" AND action LIKE 'Work order%%no longer in VISUAL'",
(e["id"],),
).fetchone()[0]
== 1
)
# VISUAL reverts: the pending feed change orders are withdrawn and the gone work order is back
c = run_feed(fx, 1)
assert c["cos_withdrawn"] == 2, c
with app_conn(fx) as conn:
co = cos_of(conn, wp)[0]
assert co["review"] == "rejected" and co["rejected_by"] is None and co["rejected_reason"]
e = row(conn, "SELECT * FROM packets WHERE visual_key = %s AND removed_at IS NULL", (gen.KEY_DRAFTING,))
assert "wo_gone" not in e["flags"] and e["wo_gone_at"] is None
assert [lg[8:] for lg in legs_of(conn, e["id"])] == [(0, False)]
k = row(conn, "SELECT * FROM packets WHERE visual_key = %s", (gen.KEY_CANCELS,))
assert "cancelled" not in k["flags"]
# the retired key is never made again
assert (
conn.execute(
"SELECT count(*) FROM packets WHERE visual_key = %s AND removed_at IS NULL", (gen.KEY_MOVES,)
).fetchone()[0]
== 0
)
assert c["skipped_removed"] >= 1
def test_rejected_target_is_not_reopened(fx: Settings) -> None:
wp, _ = _shop(fx)
run_feed(fx, 2)
with app_conn(fx) as conn:
conn.execute(
"UPDATE change_orders SET review = 'rejected', rejected_at = now(), rejected_reason = 'not this one',"
" row_version = row_version + 1 WHERE packet_id = %s",
(wp,),
)
c = run_feed(fx, 2)
assert c["cos_opened"] == 0, c
with app_conn(fx) as conn:
assert len(cos_of(conn, wp)) == 1
def test_complete_packet_gets_a_change_order(fx: Settings) -> None:
run_feed(fx, 1)
with app_conn(fx) as conn:
wp = packet_id(conn, gen.KEY_WELD_PART)
move_status(conn, wp, "complete")
fl = packet_id(conn, gen.KEY_TWO_OPS)
move_status(conn, fl, "filed")
c = run_feed(fx, 2)
with app_conn(fx) as conn:
assert len(cos_of(conn, wp)) == 1
assert cos_of(conn, fl) == [] # filed: nothing
assert [lg[5] for lg in legs_of(conn, fl)] == [Decimal(2), Decimal(2)]
assert c["cos_opened"] >= 1
def test_removed_packet_key_is_never_recreated(fx: Settings) -> None:
run_feed(fx, 1)
with app_conn(fx) as conn:
pid = packet_id(conn, gen.KEY_NO_DWG)
conn.execute("UPDATE packets SET removed_at = now(), removed_reason = 'not needed' WHERE id = %s", (pid,))
c = run_feed(fx, 1)
assert c["new_drafts"] == 0 and c["skipped_removed"] >= 1
with app_conn(fx) as conn:
assert packet_by_key(conn, gen.KEY_NO_DWG) is None
def test_closed_unit_is_skipped(fx: Settings) -> None:
run_feed(fx, 1)
with app_conn(fx) as conn:
conn.execute(
"UPDATE units SET status = 'closed' WHERE project_id = (SELECT id FROM projects WHERE number = 'L901')"
)
c = run_feed(fx, 2)
assert c["skipped_closed"] >= 1
# ---------------------------------------------------------------- pause, suspect, auto-release
def test_paused_skips_a_scheduled_run_but_a_request_runs(fx: Settings) -> None:
set_feed(fx, paused=True)
c = run_feed(fx, 1)
assert c["_status"] == "skipped" and c["reason"] == "paused"
with app_conn(fx) as conn:
assert conn.execute("SELECT count(*) FROM packets").fetchone()[0] == 0
c = run_feed(fx, 1, requested=True)
assert c["_status"] == "ok" and c["_trigger"] == "request" and c["new_drafts"] == len(gen.STATE1_KEYS)
def test_suspect_batch_marks_nothing_gone(fx: Settings) -> None:
run_feed(fx, 1)
empty = gen.feed_state(1, TODAY)
empty.update({"rows": [], "materials": [], "labor": []})
(fx.fixture_dir / "feed.3.json").write_text(json.dumps(empty), encoding="utf-8")
c = run_feed(fx, 3)
assert c["_status"] == "degraded" and c["suspect"] == 1, c
assert c["_errors"]
with app_conn(fx) as conn:
assert conn.execute("SELECT count(*) FROM visual_rows WHERE gone_batch IS NOT NULL").fetchone()[0] == 0
assert conn.execute("SELECT max(missing_runs) FROM packet_work_orders").fetchone()[0] == 0
assert row(conn, "SELECT suspect FROM visual_batches ORDER BY id DESC LIMIT 1")["suspect"] is True
assert conn.execute("SELECT count(*) FROM packets WHERE removed_at IS NOT NULL").fetchone()[0] == 0
# back to normal: nothing was lost
c = run_feed(fx, 1)
assert c["suspect"] == 0 and c["new_drafts"] == 0
def test_auto_release(fx: Settings) -> None:
run_feed(fx, 1)
run_drawings(fx)
set_feed(fx, auto_release=True, auto_release_max=1)
c = run_feed(fx, 1)
assert c["auto_released"] == 1, c
set_feed(fx, auto_release_max=25)
c = run_feed(fx, 1)
assert c["auto_released"] >= 1
with app_conn(fx) as conn:
wp = packet_by_key(conn, gen.KEY_WELD_PART)
assert wp is not None and wp["status"] == "warehouse" and wp["heat_map"] is True
wh = row(conn, "SELECT * FROM packet_wh WHERE packet_id = %s", (wp["id"],))
assert wh["stage"] == "incoming" and wh["by"] is None
tr = row(conn, "SELECT * FROM packet_transitions WHERE packet_id = %s", (wp["id"],))
assert tr["action"] == "vis_auto_release" and tr["by"] is None
# flagged drafts stay with Clerical
for key in (gen.KEY_TWO_OPS, gen.KEY_NOT_FOUND, gen.KEY_DRAFTING, gen.KEY_NO_DWG):
p = packet_by_key(conn, key)
assert p is not None and p["status"] == "draft", key
assert all(r[0] == "system" for r in system_audit(conn, "packet.vis_auto_release"))
no_human_writes(conn)
def test_paused_stops_auto_release(fx: Settings) -> None:
run_feed(fx, 1)
run_drawings(fx)
set_feed(fx, auto_release=True, paused=True)
c = run_feed(fx, 1, requested=True)
assert c.get("auto_released", 0) == 0
with app_conn(fx) as conn:
assert conn.execute("SELECT count(*) FROM packets WHERE status = 'warehouse'").fetchone()[0] == 0
def test_switch_state_file(fx: Settings) -> None:
switch_state(fx.fixture_dir, 2)
assert (fx.fixture_dir / "state").read_text() == "2"
+215
View File
@@ -0,0 +1,215 @@
"""The candidate fingerprint: which changes are major, minor or nothing, and what never counts."""
from __future__ import annotations
import dataclasses
from collections.abc import Callable
from datetime import date
from decimal import Decimal
from typing import Any
import pytest
from packetstation.feed import fingerprint as fp
from packetstation.feed.candidates import BomLine, Candidate, Folded, Leg
from packetstation.feed.types import WoKey
K1 = WoKey("L900-01", "1", "0", "0")
K2 = WoKey("L900-02", "1", "0", "0")
def leg(key: WoKey = K1, seq: int = 20, qty: Any = "2", tgt: Any = "3", **over: Any) -> Leg:
values: dict[str, Any] = {
"key": key,
"seq": seq,
"op": "WELD-PART",
"qty": Decimal(str(qty)),
"tgt": Decimal(str(tgt)),
"dwg": "L900-0101",
"rev": "1",
"partspecs": "",
"st": "F",
}
values.update(over)
return Leg(**values)
def line(item: str = "1", part: str | None = "PIPE-6", qty: Any = "2", **over: Any) -> BomLine:
values: dict[str, Any] = {
"item": item,
"qty": Decimal(str(qty)),
"part_id": part or "",
"desc": "Pipe",
"loc": "R1",
"whs": "SHOP",
"comm": "PIPE",
"cons": False,
"sub": False,
"na": False,
"grp": None,
"vkey": f"L900-01|1|0|20|{item}",
"issued": Decimal(0),
"istat": 2,
}
values.update(over)
return BomLine(**values)
def cand(**over: Any) -> Candidate:
values: dict[str, Any] = {
"key": "L900001|WELD-PART|L900-0101",
"project": "L900",
"unit": "L900001",
"lot": "1",
"dept": "03",
"op": "WELD-PART",
"drawing": "L900-0101",
"rev": "1",
"title": "Inlet spool",
"legs": [leg()],
"folded": [],
"partspecs": [],
"rel": date(2026, 10, 10),
"want": date(2026, 11, 5),
"bom": [line("1"), line("2", "ELB-6", "1")],
"statuses": {K1: "F"},
"base": "L900-01",
"in_window": True,
}
values.update(over)
return Candidate(**values)
def classify(before: Candidate, after: Candidate) -> fp.Change:
return fp.classify(fp.compute(before).parts, fp.compute(after).parts)
MAJOR_EDITS: dict[str, tuple[str, Callable[[], dict[str, Any]]]] = {
"leg added": ("legs", lambda: {"legs": [leg(), leg(K2, 10)]}),
"leg removed": ("legs", lambda: {"legs": []}),
"leg seq": ("legs", lambda: {"legs": [leg(seq=30)]}),
"qty": ("qty", lambda: {"legs": [leg(qty="3")]}),
"rev": ("rev", lambda: {"rev": "2"}),
"drawing": ("drawing", lambda: {"drawing": "L900-0102"}),
"bom item": ("bom", lambda: {"bom": [line("1"), line("3", "ELB-6", "1")]}),
"bom part": ("bom", lambda: {"bom": [line("1"), line("2", "ELB-6-SR", "1")]}),
"bom qty": ("bom", lambda: {"bom": [line("1"), line("2", "ELB-6", "2")]}),
"bom line added": ("bom", lambda: {"bom": [line("1"), line("2", "ELB-6", "1"), line("3", "TEE-6", "1")]}),
}
@pytest.mark.parametrize("name", list(MAJOR_EDITS))
def test_major_change(name: str) -> None:
part, edit = MAJOR_EDITS[name]
ch = classify(cand(), cand(**edit()))
assert ch.changed and not ch.is_minor
assert part in ch.major
MINOR_EDITS: dict[str, tuple[str, Callable[[], dict[str, Any]]]] = {
"tgt": ("tgt", lambda: {"legs": [leg(tgt="3.5")]}),
"want": ("want", lambda: {"want": date(2026, 11, 12)}),
"want cleared": ("want", lambda: {"want": None}),
}
@pytest.mark.parametrize("name", list(MINOR_EDITS))
def test_minor_change(name: str) -> None:
part, edit = MINOR_EDITS[name]
ch = classify(cand(), cand(**edit()))
assert ch.changed and ch.is_minor
assert ch.major == frozenset() and ch.minor == frozenset({part})
def test_minor_and_major_together_is_major() -> None:
ch = classify(cand(), cand(rev="2", want=date(2026, 12, 1)))
assert ch.major == {"rev"} and ch.minor == {"want"} and not ch.is_minor
NEVER_EDITS: dict[str, Callable[[], dict[str, Any]]] = {
"rel": lambda: {"rel": date(2026, 10, 1)},
"folded": lambda: {"folded": [Folded(K1, 30, "MFG", Decimal("0.5"))]},
"partspecs": lambda: {"partspecs": ["Weld per WPS-11"]},
"title": lambda: {"title": "Another title"},
"statuses": lambda: {"statuses": {K1: "R"}},
"issued": lambda: {"bom": [line("1", issued=Decimal(2)), line("2", "ELB-6", "1")]},
"whs": lambda: {"bom": [line("1", whs="MAIN"), line("2", "ELB-6", "1")]},
"loc": lambda: {"bom": [line("1", loc="A-01"), line("2", "ELB-6", "1")]},
"dept and op": lambda: {"dept": "06", "op": "WELD-CODE"},
"leg status": lambda: {"legs": [leg(st="C")]},
"leg partspecs": lambda: {"legs": [leg(partspecs="Back purge")]},
"in_window": lambda: {"in_window": False},
}
@pytest.mark.parametrize("name", list(NEVER_EDITS))
def test_never_in_the_fingerprint(name: str) -> None:
before, after = cand(), cand(**NEVER_EDITS[name]())
assert not classify(before, after).changed
assert fp.compute(before).full == fp.compute(after).full
INVARIANT: dict[str, tuple[Callable[[], dict[str, Any]], Callable[[], dict[str, Any]]]] = {
"2 vs 2.0": (lambda: {"legs": [leg(qty="2")]}, lambda: {"legs": [leg(qty="2.0")]}),
"trailing zeros": (lambda: {"legs": [leg(tgt="1.5")]}, lambda: {"legs": [leg(tgt="1.500")]}),
"empty vs None rev": (lambda: {"rev": ""}, lambda: {"rev": None}),
"list order": (
lambda: {"legs": [leg(), leg(K2, 10)], "bom": [line("1"), line("2", "ELB-6", "1")]},
lambda: {"legs": [leg(K2, 10), leg()], "bom": [line("2", "ELB-6", "1"), line("1")]},
),
"drawing case and spaces": (lambda: {"drawing": "m00543"}, lambda: {"drawing": " M00 543 "}),
"part id case": (
lambda: {"bom": [line("1", "m00543")]},
lambda: {"bom": [line("1", "M00543")]},
),
"CRLF": (lambda: {"rev": "1\r\n"}, lambda: {"rev": "1\n"}),
}
@pytest.mark.parametrize("name", list(INVARIANT))
def test_invariance(name: str) -> None:
a, b = INVARIANT[name]
assert fp.compute(cand(**a())).full == fp.compute(cand(**b())).full
def test_lot_7a_stays_text() -> None:
k = WoKey("L900-14", "7A", "0", "0")
c = cand(legs=[leg(k, 10)], statuses={k: "F"})
values = fp.part_values(c)
assert values["legs"] == [["L900-14", "7A", "0", "0", 10]]
# A lot with leading zeros is not a number either
k0 = WoKey("L900-14", "007", "0", "0")
other = fp.part_values(cand(legs=[leg(k0, 10)]))
assert other["legs"][0][1] == "007"
assert fp.compute(c).full != fp.compute(cand(legs=[leg(k0, 10)])).full
def test_canonical_is_stable_json() -> None:
assert fp.canonical({"b": Decimal("2.0"), "a": ["x\r\n", None, ""]}) == b'{"a":["x",null,null],"b":"2"}'
assert fp.canonical({"d": date(2026, 10, 16)}) == b'{"d":"2026-10-16"}'
assert fp.canonical("é") == fp.canonical("é") # NFC
def test_parts_and_full() -> None:
f = fp.compute(cand())
assert set(f.parts) == set(fp.MAJOR) | set(fp.MINOR)
assert all(len(v) == 64 for v in f.parts.values()) and len(f.full) == 64
assert fp.compute(cand()) == f # deterministic
def test_no_before_is_all_changed() -> None:
ch = fp.classify(None, fp.compute(cand()).parts)
assert ch.major == frozenset(fp.MAJOR) and ch.minor == frozenset(fp.MINOR)
def test_unknown_materials_are_not_a_bom_change() -> None:
before = fp.compute(cand())
after = fp.compute(cand(bom=None))
assert "bom" not in classify(cand(), cand(bom=None)).major
merged = fp.merge(before.parts, after)
assert merged.parts["bom"] == before.parts["bom"] and merged.full == before.full
def test_dataclass_copy_is_unchanged() -> None:
c = cand()
assert fp.compute(dataclasses.replace(c)).full == fp.compute(c).full
+112
View File
@@ -0,0 +1,112 @@
"""Hand-uploaded BOM files: header detection, the column patterns, skipped rows and bad files."""
from __future__ import annotations
import io
from decimal import Decimal
from typing import Any
import pytest
from packetstation.domain import handbom
def xlsx(rows: list[list[Any]]) -> bytes:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
assert ws is not None
for r in rows:
ws.append(r)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def csv_bytes(text: str, bom: bool = True) -> bytes:
return (b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")
def test_csv_header_after_title_rows() -> None:
data = csv_bytes(
"Bill of material,,,\n"
"Job L900-01,,,\n"
"\n"
"Item,Qty,Part No.,Description,Length,Material,Dwg Ref\n"
"1,2,PIPE-6-SCH40,Pipe 6 in sch 40,12 ft,A106-B,L900-0101\n"
"2,4,,Gusset 1/4 plate,,A36,\n"
)
lines = handbom.parse(data, "bom.csv")
assert [ln["item"] for ln in lines] == [1, 2]
first = lines[0]
assert first["qty"] == Decimal(2) and first["part_id"] == "PIPE-6-SCH40"
assert first["description"] == "Pipe 6 in sch 40" and first["length"] == "12 ft"
assert first["material"] == "A106-B" and first["dwg_ref"] == "L900-0101"
assert lines[1]["part_id"] is None and lines[1]["description"] == "Gusset 1/4 plate"
def test_xlsx_with_quantity_and_part_number() -> None:
data = xlsx(
[
["PESCO", None, None],
["Find", "Quantity", "PESCO Part Number", "Desc"],
[10, 1, "FLG-6-150", "Flange"],
[None, None, None, None],
[11, 2.5, "GSK-6-150", "Gasket"],
]
)
lines = handbom.parse(data, "bom.xlsx")
assert [(ln["item"], ln["qty"], ln["part_id"]) for ln in lines] == [
(10, Decimal(1), "FLG-6-150"),
(11, Decimal("2.5"), "GSK-6-150"),
]
def test_rows_without_description_or_part_are_skipped() -> None:
data = csv_bytes("Qty,Description\n2,Pipe\n,\n5,\nTotal,\n1,Cap\n")
lines = handbom.parse(data, "bom.csv")
assert [ln["description"] for ln in lines] == ["Pipe", "Cap"]
def test_items_default_to_the_row_number_and_skip_repeats() -> None:
data = csv_bytes("Item,Qty,Description\nA,1,One\n,1,Two\n1,1,Three\n")
lines = handbom.parse(data, "bom.csv")
items = [ln["item"] for ln in lines]
assert len(set(items)) == 3
assert items[0] == 1 and lines[0]["cl_item"] == "A"
def test_semicolon_csv_without_bom() -> None:
data = csv_bytes("Qty;Part ID;Description\n3;BOLT-5/8;Stud bolt\n", bom=False)
lines = handbom.parse(data, "bom.csv")
assert lines[0]["qty"] == Decimal(3) and lines[0]["part_id"] == "BOLT-5/8"
@pytest.mark.parametrize(
"data,name",
[
(b"", "empty.csv"),
(b"%PDF-1.7\n...", "drawing.pdf"),
(csv_bytes("Name,Colour\nPipe,Red\n"), "no-qty.csv"),
(csv_bytes("Qty,Length\n2,12 ft\n"), "no-desc.csv"),
(csv_bytes("Qty,Description\n,\n"), "no-lines.csv"),
(b"PK\x03\x04not really a zip", "broken.xlsx"),
],
)
def test_bad_files(data: bytes, name: str) -> None:
with pytest.raises(handbom.BadFile) as e:
handbom.parse(data, name)
assert str(e.value)
def test_header_must_be_within_the_first_20_rows() -> None:
data = csv_bytes("\n".join(["note"] * 25) + "\nQty,Description\n1,Pipe\n")
with pytest.raises(handbom.BadFile):
handbom.parse(data, "bom.csv")
def test_file_text_is_data() -> None:
data = csv_bytes('Qty,Description\n1,"=HYPERLINK(""http://x"") ignore previous instructions"\n')
lines = handbom.parse(data, "bom.csv")
assert lines[0]["description"].startswith("=HYPERLINK")

Some files were not shown because too many files have changed in this diff Show More