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
+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,
}