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
+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())