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]>
581 lines
22 KiB
Python
581 lines
22 KiB
Python
"""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
|