From bb8eb1774869404a02ba4f0fa25fd0cacbae3e32 Mon Sep 17 00:00:00 2001 From: Spencer McGuire Date: Mon, 19 Oct 2026 13:36:42 -0600 Subject: [PATCH] drawings: on a rev mismatch, Clerical can keep the retired rev VISUAL calls (#41) When VISUAL calls a rev that M-Files has retired, the draft is still flagged rev_mismatch, but the drawing job now also copies the rev VISUAL calls (MFilesKit 0.1.3 DrawingLookup.revision) into drawing_revisions and visual.called. A Clerical Supervisor picks either rev on the packet: "Use latest rev N" as before, or "Use rev V as VISUAL calls it" (rev-approve with use=called), with a reason and a signature. The kept rev holds while M-Files' Effective rev stays the one chosen against: no flag, no change order. A newer Effective rev flags the packet again; a new VISUAL rev clears the choice. Migration 0009 adds drawing_revisions. Needs mfileskit 0.1.3. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + docs/FEED.md | 5 +- docs/POLICY.md | 2 + docs/SCHEMA.md | 1 + frontend/src/api/packets.ts | 3 +- frontend/src/api/types.ts | 27 +++- frontend/src/packet/visual.test.tsx | 25 ++++ frontend/src/packet/visual.tsx | 92 ++++++++++++-- pyproject.toml | 2 +- server/packetstation/api/packet_visual.py | 46 +++++-- server/packetstation/domain/packets.py | 15 ++- server/packetstation/drawings/decide.py | 25 +++- server/packetstation/drawings/fake.py | 47 +++++-- server/packetstation/drawings/fetch.py | 23 ++-- server/packetstation/drawings/lookup.py | 54 +++++++- server/packetstation/feed/apply.py | 2 +- .../migrations/0009_drawing_revisions.sql | 19 +++ .../worker/jobs/drawing_lookup.py | 117 +++++++++++++++++- tests/fixtures/generator.py | 14 ++- tests/server/test_drawing_decide.py | 38 +++++- tests/server/test_feed_api.py | 43 +++++++ 21 files changed, 547 insertions(+), 55 deletions(-) create mode 100644 server/packetstation/migrations/0009_drawing_revisions.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 51caba1..2ed2474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ All notable changes to this project are recorded here. The format follows [Keep ### Added +- Rev mismatch with a retired rev (Gitea issue 41): when VISUAL calls a rev that M-Files still holds but has retired, the packet is still flagged, and a Clerical Supervisor can choose either rev from the packet: "Use latest rev N" (M-Files' Effective rev, as before) or "Use rev V as VISUAL calls it". Both PDFs open from the packet first. Keeping VISUAL's rev needs a reason and a signature, is logged, and holds until M-Files releases a newer rev or VISUAL's rev changes. Needs MFilesKit 0.1.3 (`DrawingLookup.revision`) and migration 0009. + - Phase 8: Project Management, the find bar, presence and motion. The Project Management screen (`/pm`, view only) with the tabs Overview (the headline numbers, a plain-words summary, the pipeline by step with the slowest step in pink, the units late or at risk), Projects & units (every project with its units, due date, days left, packets by step, % done and health: On track, At risk, Late, Complete, No packets yet; a project is as healthy as its worst unit; tap a project for its units and a unit for its jobs), Bottlenecks & aging (time in the current step, every open packet longest waiting first with over 3 days flagged, average time per step for completed moves), Hours vs target (station timer hours and VISUAL ticket hours side by side against the work-order target by department, worker and project, plus every packet over target), Quality & issues (Q.C. rejects, open shorts, open change orders, warehouse holds, sent back) and Find jobs. The period picker (30 days, 90 days, 12 months, all) sets the window for history figures. Every table has Excel (CSV); "Excel (CSV) — all" saves every report; "PDF report" builds the PESCO production status report (cover with the headline numbers, summary and pipeline, projects, flow, labor, issues) on the server with the controlled-copy stamp. Every export is logged as "report exported" in the audit and the print log. - The find bar on every home screen: search over project number or name, lot, operation, drawing and rev, WPS, customer, PO, old project numbers, work orders, heat numbers, part IDs, welder name or stamp, change order and ECN numbers, with several words narrowing and Esc clearing; the project then unit picker; Filters with Department, Status (including At a station and Everything not filed), Station, Worker and, for Clerical and Project Management, the flag kind; the quick flags Change order open, Shorts open, Project overdue, Due within 7 days and Mine (what that screen is responsible for; hidden on the station and Project Management screens); Board or List with the list sortable by last updated, project / lot, due date, status or department. Filters and the view are saved per browser (`ps.flt`); the search text is not. Tapping a number tile shows only those jobs; tapping again clears. The numbers follow the project pick but not the other filters. - Every filter is applied by the server: `GET /api/packets` and the shop, stall, warehouse and Q.C. boards take `q`, `status`, `dept`, `station_id`, `welder_id`, `flags`, `flag`, `sort`, `mine_screen` and `mine_station_id`, and answer `facets` (the stations and workers in the project scope) for the dropdowns. A board never fetches more than it shows. diff --git a/docs/FEED.md b/docs/FEED.md index a6f4b07..45ad808 100644 --- a/docs/FEED.md +++ b/docs/FEED.md @@ -291,7 +291,8 @@ The file is downloaded only when the object id and version or the file id and ve | No released rev, but a drafting rev or a drafting assignment | `not_released` | `not_released` | Note "Not released in M-Files yet (sheet of ) - drafting status: rev N ". | | No released rev and nothing in drafting | `not_found` | `not_found` | Note "Drawing not in M-Files - check the drawing number in VISUAL". | | VISUAL's rev is not a whole number | `bad_rev` | `bad_rev` | Not attached. | -| Revs differ, and no approved rev matches M-Files | `rev_mismatch` | `rev_mismatch` | Note "Rev mismatch: VISUAL calls rev V, M-Files has rev N", plus the drafting status. The M-Files copy is fetched and staged for Clerical Supervisor approval. No change order: VISUAL did not change. | +| Revs differ, and no approved rev matches M-Files | `rev_mismatch` | `rev_mismatch` | Note "Rev mismatch: VISUAL calls rev V, M-Files has rev N", plus the drafting status. The M-Files copy is fetched and staged for Clerical Supervisor approval. On a draft the rev VISUAL calls is looked up too: when M-Files still holds it (Retired, say), that copy is fetched into `drawing_revisions` and kept on the packet as `visual.called`, and the log note says "rev V is Retired in M-Files". When M-Files has no document at that rev, the note says "rev V is not in M-Files". No change order: VISUAL did not change. | +| Revs differ, but Clerical kept the rev VISUAL calls while M-Files' Effective rev is still the one they chose against | `unchanged` | none | The kept rev stays attached and confirmed. No flag and no change order, also past draft. A newer Effective rev in M-Files flags the packet again. | | Revs match (or the approved rev matches), draft | `attach` | none | Drawing pages attached, `drawing_state` `attached`, drawing flags cleared, rev confirmed. Log "Drawing attached from M-Files". | | Revs match, draft with a hand drawing at the same rev | `unchanged` | none | The hand drawing stays. | | Revs match, past draft, same file (same sha256) | `unchanged` | none | Nothing. | @@ -322,7 +323,7 @@ Flags live in `packets.flags`. A draft with any flag shows in Clerical's Flagged | Flag | Text shown | What Clerical does | |---|---|---| | `drawing_needed` | Made from VISUAL - drawing not attached yet | Usually nothing: the drawing job attaches it. Run now to hurry it. Upload by hand only if it stays stuck. | -| `rev_mismatch` | The M-Files note, for example "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3" | Get the rev corrected in VISUAL, or a Clerical Supervisor approves the staged M-Files rev with a reason and a signature. | +| `rev_mismatch` | The M-Files note, for example "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3" | Get the rev corrected in VISUAL. Or a Clerical Supervisor chooses, with a reason and a signature: the latest (Effective) rev in M-Files, or the rev VISUAL calls when M-Files still holds it (Retired). Both PDFs can be opened from the packet first. | | `not_released` | The note: "Not released in M-Files yet ... drafting status: rev N " | Wait for drafting to release it, or upload the drawing by hand with a reason and a signature. | | `not_found` | Drawing not in M-Files - check the drawing number in VISUAL | Check the drawing number in VISUAL and have it fixed there. Or upload by hand. | | `lookup_failed` | M-Files lookup failed: (the reason) | Nothing at first: the job retries with backoff. If it persists, tell IT. | diff --git a/docs/POLICY.md b/docs/POLICY.md index 436ba3b..0de7301 100644 --- a/docs/POLICY.md +++ b/docs/POLICY.md @@ -867,6 +867,8 @@ What the feed may never write: When M-Files has a different released rev from VISUAL, the drawing job stages the M-Files copy. A Clerical Supervisor (PS-Clerical-Supervisor) can approve it on a draft with rev_approve: a reason of at least 8 characters and a signature ("Approve the M-Files revision"). The pages are attached and the approval is kept on the packet (`visual.rev_ok`). With nothing staged the answer is 409 `nothing_staged`. +The same action, with `use: "called"`, keeps the rev VISUAL calls instead, when M-Files still holds that rev at another status (Retired). The drawing job copies it on a draft flagged `rev_mismatch`. The same role, reason and signature apply, and the signed content carries `use`. `visual.rev_ok` records `use: "called"`, the M-Files status of the kept rev and the Effective rev at the time (`latest`). The log says "Rev VISUAL calls kept by hand". The choice holds while M-Files' Effective rev stays `latest`: no flag and no change order. A newer Effective rev flags the packet again, and a new VISUAL rev clears the choice. With no copy of that rev the answer is 409 `nothing_staged`. + ### Hand uploads on flagged drafts On a draft made by the feed that is flagged (or already has a hand upload), Clerical can upload a drawing (edit_document_set) or a BOM (edit_bom) by hand. Both need a reason and a signature. A hand drawing sets `drawing_state` `hand` and flag `hand_drawing`; a hand BOM sets flag `hand_bom` and the feed stops replacing the BOM. An unflagged draft refuses with 409 `not_flagged`. Auto-release never takes a hand-flagged draft, so a person sends it on. "Use VISUAL's BOM again" removes the hand BOM. diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 29d43e3..a1e16e7 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -377,6 +377,7 @@ Migration 0004. Every ID part is text (`wo_base`, `lot`, `split`, `sub`, part ID - `visual_ignored_wos`: work orders kept out by the go-live date, unique on (`wo_base`, `lot`, `split`, `sub`). `rel`, `project`, `unit_number`, `res`, `dwg`, `first_seen_batch`, `last_seen_at`, and `pulled_in_at`, `pulled_in_by`, `pulled_in_reason` when Clerical pulls one in. - `visual_resource_map`: `resource` (PK), `target` (`fold`, `ignore` or a two-digit department code, by CHECK), `confirmed`, `updated_at`, `updated_by`. Seeded from the old build's `visual_map.json`, every entry unconfirmed. "Electical" is spelled that way in VISUAL. - `drawing_lookups`: one row per drawing number as VISUAL writes it (`dwg` PK, trimmed). The last answer: `checked_at`, `ok`, `rev` (released), `name`, `obj_id`, `obj_version`, `file_id`, `file_version`, `file_size`, `base` (H sheet), `draft_rev`, `draft_where`, `in_drafting`, `note`. Retry state: `fail_count`, `failing_since`, `next_check_at`. The fetched file: `asset_id`, `sha256`. `visual_rev_seen` is the VISUAL rev at the last lookup, so a rev change moves the drawing to the front of the queue. A rev mismatch keeps the staged M-Files copy here for approval. +- `drawing_revisions` (0009): other revs of a drawing that M-Files still holds, one row per (`dwg`, `rev`). The drawing job writes it when a draft's VISUAL rev differs from the Effective rev and M-Files has a document at the VISUAL rev. `controlled` (M-Files' Controlled Status, for example Retired), `state` (workflow state, for example Superseded), `checked_at`, `name`, the M-Files identity (`obj_id`, `obj_version`, `file_id`, `file_version`, `file_size`), `base` (H sheet), and the fetched copy (`asset_id`, `sha256`). rev_approve with `use: "called"` attaches from here. Column additions in 0004: diff --git a/frontend/src/api/packets.ts b/frontend/src/api/packets.ts index a42ba62..1c29f39 100644 --- a/frontend/src/api/packets.ts +++ b/frontend/src/api/packets.ts @@ -68,7 +68,8 @@ export const packetsApi = { databook: (id: number) => api.post(`${base(id)}/databook`, {}), /* Phase 3: VISUAL packets. */ - revApprove: (id: number, body: { reason: string } & Guarded) => api.post(`${base(id)}/rev-approve`, body), + revApprove: (id: number, body: { reason: string; use?: "latest" | "called" } & Guarded) => + api.post(`${base(id)}/rev-approve`, body), handDrawing: (id: number, body: { asset_id: string; reason: string } & Guarded) => api.post(`${base(id)}/hand/drawing`, body), /** Multipart cannot carry JSON, so the signature rides as sign_factor and sign_secret. */ diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4bc79bc..46b37f5 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -905,16 +905,39 @@ export interface FoldedOp { tgt?: number | string; } +/** The rev VISUAL calls, as M-Files holds it at another status (Retired, say). Set by the drawing job on a draft flagged + * rev_mismatch. `missing` when M-Files has no document at that rev. */ +export interface CalledRev { + rev: string; + missing?: boolean; + controlled?: string; + state?: string | null; + name?: string | null; + asset_id?: string; + pages?: number; +} + /** The packet's `visual` jsonb. Every key is optional; hand drafts have {}. */ export interface VisualInfo { appr?: VisualAppr; /** Set by the read model from drawing_lookups when M-Files has a staged copy. */ - staged_rev?: { rev: number | string; name: string | null }; + staged_rev?: { rev: number | string; name: string | null; asset_id?: string }; + called?: CalledRev | null; partspecs?: string[]; folded?: FoldedOp[]; manual_dwg?: HandUpload; manual_bom?: HandUpload; - rev_ok?: { rev: string | number; vis_rev?: string | null; by?: WhoRef | string | null; at?: string | null; reason?: string | null }; + rev_ok?: { + rev: string | number; + vis_rev?: string | null; + by?: WhoRef | string | null; + at?: string | null; + reason?: string | null; + /** "called": Clerical kept the rev VISUAL calls over M-Files' Effective rev (`latest`). */ + use?: "latest" | "called"; + latest?: string | null; + controlled?: string | null; + }; rev_confirmed?: boolean; [key: string]: unknown; } diff --git a/frontend/src/packet/visual.test.tsx b/frontend/src/packet/visual.test.tsx index 092a15b..24f4e8a 100644 --- a/frontend/src/packet/visual.test.tsx +++ b/frontend/src/packet/visual.test.tsx @@ -116,6 +116,31 @@ describe("Packet page, VISUAL box", () => { expect(await screen.findByText("Rev 3 approved and attached.")).toBeInTheDocument(); }); + it("offers the retired rev VISUAL calls next to the latest, and keeps it with use=called", async () => { + const called = { rev: "2", controlled: "Retired", state: "Superseded", name: "M00543 r2.pdf", asset_id: "as-r2", pages: 1 }; + const draft = visualDraft({ visual: { ...visualDraft().visual, staged_rev: { rev: 3, name: "M00543 r3.pdf", asset_id: "as-r3" }, called } }); + const kept = visualDraft({ + version: 4, + flags: [], + flag: null, + drawing_state: "attached", + visual: { rev_ok: { rev: "2", vis_rev: "2", use: "called", latest: "3", controlled: "Retired", reason: "Lot 1 builds to rev 2" } }, + }); + const fetch = mockFetch({ ...base(draft, LEO), "POST /api/packets/1/rev-approve": { body: kept } }); + renderApp("/packets/1"); + expect(await screen.findByRole("button", { name: "Use latest rev 3" })).toBeInTheDocument(); + expect(screen.getByText(/retired \(Superseded\) in M-Files/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Use rev 2 as VISUAL calls it" })); + const ask = await screen.findByRole("dialog", { name: "Use rev 2 for M00543" }); + fireEvent.change(within(ask).getByLabelText("Reason"), { target: { value: "Lot 1 builds to rev 2" } }); + fireEvent.click(within(ask).getByRole("button", { name: "Use rev 2" })); + await sign(); + expect(callsTo(fetch, "POST", "/api/packets/1/rev-approve")).toEqual([ + { reason: "Lot 1 builds to rev 2", use: "called", sign: { factor: "password", secret: "pw-1" } }, + ]); + expect(await screen.findByText("Rev 2 attached as VISUAL calls it.")).toBeInTheDocument(); + }); + it("uploads a hand BOM as multipart with the signature fields", async () => { const fetch = mockFetch({ ...base(visualDraft()), diff --git a/frontend/src/packet/visual.tsx b/frontend/src/packet/visual.tsx index df98c3c..f3d42e6 100644 --- a/frontend/src/packet/visual.tsx +++ b/frontend/src/packet/visual.tsx @@ -163,6 +163,7 @@ export function VisualBox({ const drawingFlag = flags.some((f) => (DRAWING_FLAGS as readonly string[]).includes(f)); const appr = v.appr; const staged = v.staged_rev; + const called = v.called && !v.called.missing && v.called.asset_id ? v.called : null; const mismatch = flags.includes("rev_mismatch"); const md = v.manual_dwg; const mb = v.manual_bom; @@ -187,6 +188,25 @@ export function VisualBox({ await mutate((g) => packetsApi.revApprove(p.id, { reason, ...g }), { sign: SIGN_REV_APPROVE, ok: `Rev ${staged.rev} approved and attached.` }); }; + const keepCalled = async () => { + if (!called) return; + const status = (called.controlled || "not Effective").toLowerCase(); + const reason = await askReason({ + title: `Use rev ${called.rev} for ${p.drawing ?? "this drawing"}`, + sub, + note: `VISUAL calls rev ${called.rev}, which is ${status} in M-Files${staged ? `. The Effective rev is ${staged.rev}` : ""}. Rev ${called.rev}${called.name ? ` (${called.name})` : ""} is attached to this packet and the shop builds to it. It is recorded with your name, signature and the reason. If M-Files releases a newer rev or VISUAL's rev changes, the packet is flagged again.`, + placeholder: `e.g. Engineering says build this lot to rev ${called.rev}`, + min: 3, + max: 400, + submitLabel: `Use rev ${called.rev}`, + }); + if (reason === null) return; + await mutate((g) => packetsApi.revApprove(p.id, { reason, use: "called", ...g }), { + sign: SIGN_REV_APPROVE, + ok: `Rev ${called.rev} attached as VISUAL calls it.`, + }); + }; + const upload = async (kind: "drawing" | "bom", file: File, reason: string) => { setHand(null); if (kind === "bom") { @@ -220,8 +240,20 @@ export function VisualBox({ return (
{v.rev_ok && hasDwg ? ( -
- Rev {String(v.rev_ok.rev)} approved by hand in place of VISUAL rev {v.rev_ok.vis_rev || "-"} +
+ {v.rev_ok.use === "called" ? ( + <> + + Rev {String(v.rev_ok.rev)} kept as VISUAL calls it + {" "} + - {(v.rev_ok.controlled || "not Effective").toLowerCase()} in M-Files + {v.rev_ok.latest ? `, which has rev ${v.rev_ok.latest}` : ""} + + ) : ( + <> + Rev {String(v.rev_ok.rev)} approved by hand in place of VISUAL rev {v.rev_ok.vis_rev || "-"} + + )} {v.rev_ok.by ? ` - ${whoName(v.rev_ok.by)}` : ""} {v.rev_ok.at ? fmtTime(v.rev_ok.at) : ""} {v.rev_ok.reason ? `: ${v.rev_ok.reason}` : ""}.
@@ -233,18 +265,54 @@ export function VisualBox({ is attached from M-Files automatically when its rev matches the rev VISUAL calls. Only Clerical can upload one by hand, and only while the packet is flagged. {appr?.note ?
M-Files: {appr.note}{appr.at ? ` (looked ${fmtTime(appr.at)})` : ""}
: null} {mismatch ? ( -
+
{staged ? ( - <> - In M-Files: {staged.name ?? `rev ${staged.rev}`}.{" "} - {canApproveRev ? ( - - ) : draft ? ( - A Clerical supervisor can approve rev {String(staged.rev)} for this packet. +
+ Effective in M-Files: {staged.name ?? `rev ${staged.rev}`} + {staged.asset_id ? ( + <> + {" "} + + View + + ) : null} - + . +
+ ) : null} + {called ? ( +
+ VISUAL calls rev {called.rev}: {called.name ?? `rev ${called.rev}`}, {(called.controlled || "not Effective").toLowerCase()} + {called.state ? ` (${called.state})` : ""} in M-Files{" "} + + View + + . +
+ ) : v.called?.missing ? ( +
VISUAL calls rev {v.called.rev}, which is not in M-Files.
+ ) : null} + {staged || called ? ( + canApproveRev ? ( +
+ {staged ? ( + + ) : null} + {called ? ( + + ) : null} +
+ ) : draft ? ( + + {called && staged + ? `A Clerical supervisor can choose rev ${staged.rev} or rev ${called.rev} for this packet.` + : `A Clerical supervisor can approve rev ${String((staged ?? called)?.rev)} for this packet.`} + + ) : null ) : ( The M-Files drawing is being copied. Check back after the next drawing run. )} diff --git a/pyproject.toml b/pyproject.toml index d838318..9a0f8aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "reportlab==5.0.1", "pillow==13.0.0", "visualkit==0.3.0", - "mfileskit==0.1.2", + "mfileskit==0.1.3", ] [project.optional-dependencies] diff --git a/server/packetstation/api/packet_visual.py b/server/packetstation/api/packet_visual.py index 84e56d1..8be1245 100644 --- a/server/packetstation/api/packet_visual.py +++ b/server/packetstation/api/packet_visual.py @@ -12,7 +12,7 @@ import hashlib import uuid from collections.abc import Mapping from datetime import UTC, datetime -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Depends, Request from psycopg.types.json import Jsonb @@ -49,6 +49,9 @@ MSG_DRAFT_ONLY = "Only a draft takes a hand upload. Once it leaves Clerical, cha class RevApproveBody(BaseModel): reason: str | None = Field(default=None, max_length=2000) + # "latest": M-Files' Effective rev in place of the rev VISUAL calls. "called": the rev VISUAL calls, which M-Files + # holds at another status (Retired, say). + use: Literal["latest", "called"] = "latest" sign: SignBody | None = None it_reason: str | None = Field(default=None, max_length=1000) @@ -186,17 +189,22 @@ def rev_approve( 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.""" + """A Clerical Supervisor settles a rev mismatch on a draft: M-Files' Effective rev in place of the rev VISUAL calls + (`use = latest`), or the rev VISUAL calls although M-Files has retired it (`use = called`). 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 + if body.use == "called": + staged = packets.called_lookup(conn, row["drawing"], row["rev"]) if row else None + else: + 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, + "use": body.use, "rev": staged["rev"] if staged else None, "sha256": bytes(staged["sha256"]).hex() if staged and staged["sha256"] else None, "reason": reason, @@ -215,7 +223,17 @@ def rev_approve( ) as w: if w.packet["source_kind"] != "visual": raise ApiError(409, "not_visual", MSG_NOT_VISUAL) - lk = packets.staged_lookup(conn, w.packet["drawing"]) + latest = packets.staged_lookup(conn, w.packet["drawing"]) + if body.use == "called": + lk = packets.called_lookup(conn, w.packet["drawing"], w.packet["rev"]) + if lk is None: + raise ApiError( + 409, + "nothing_staged", + "M-Files has no copy of the rev VISUAL calls ready. Try after the next run.", + ) + else: + lk = latest if lk is None: raise ApiError( 409, "nothing_staged", "No M-Files copy of this drawing is ready yet. Try after the next run." @@ -229,13 +247,21 @@ def rev_approve( 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} + ok = {"rev": rev, "vis_rev": vis_rev, **who_of(w), "at": now_iso(), "reason": reason} + if body.use == "called": + # The decision holds while M-Files' Effective rev stays the one Clerical chose against. + ok |= { + "use": "called", + "latest": None if latest is None else str(latest["rev"]), + "controlled": lk["controlled"], + } + visual["rev_ok"] = ok visual["rev_confirmed"] = True visual["vault"] = { "asset_id": str(asset["id"]), "name": lk["name"], "rev": rev, - "note": lk["note"], + "note": f"{lk['controlled']} in M-Files" if body.use == "called" else lk["note"], "at": now_iso(), } visual.pop("manual_dwg", None) @@ -245,8 +271,12 @@ def rev_approve( (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}") + w.detail.update(asset_id=str(asset["id"]), rev=rev, vis_rev=vis_rev, use=body.use) + if body.use == "called": + effective = f"M-Files has rev {ok['latest']}" if ok["latest"] else "no Effective rev in M-Files" + w.log("Rev VISUAL calls kept by hand", f"rev {rev} ({lk['controlled']} in M-Files), {effective} · {reason}") + else: + 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) diff --git a/server/packetstation/domain/packets.py b/server/packetstation/domain/packets.py index 3bb3fea..1b16425 100644 --- a/server/packetstation/domain/packets.py +++ b/server/packetstation/domain/packets.py @@ -643,7 +643,20 @@ def staged_rev(conn: Any, drawing: Any) -> dict[str, Any] | None: lk = staged_lookup(conn, drawing) if lk is None: return None - return {"rev": lk["rev"], "name": lk["name"]} + return {"rev": lk["rev"], "name": lk["name"], "asset_id": str(lk["asset_id"])} + + +def called_lookup(conn: Any, drawing: Any, rev: Any) -> dict[str, Any] | None: + """The drawing_revisions row for the rev VISUAL calls, when the drawing job copied it (asset set).""" + dwg = str(drawing or "").strip() + r = str(rev or "").strip() + if not dwg or not r.isdigit(): + return None + return one( + conn, + "SELECT * FROM drawing_revisions WHERE dwg = %s AND rev = %s AND asset_id IS NOT NULL", + (dwg, int(r)), + ) def staged_lookup(conn: Any, drawing: Any) -> dict[str, Any] | None: diff --git a/server/packetstation/drawings/decide.py b/server/packetstation/drawings/decide.py index 51ab327..ebe9a73 100644 --- a/server/packetstation/drawings/decide.py +++ b/server/packetstation/drawings/decide.py @@ -13,7 +13,10 @@ Rows, in the order they are tried: 4. no released rev otherwise -> flag not_found. 5. VISUAL rev not a number (and no approved rev equal to M-Files') -> flag bad_rev, not attached. 6. revs differ (and no approved rev equal to M-Files') -> flag rev_mismatch. The job stages the M-Files copy - for Clerical Supervisor approval on drafts only. + for Clerical Supervisor approval on drafts only, and on a draft also copies the rev VISUAL calls when M-Files + still holds it (Retired, say), so Clerical can choose either. + Revs differ, but Clerical chose to keep the rev VISUAL calls while M-Files' Effective rev was this one -> + unchanged (no flag, no change order). A newer Effective rev flags the packet again. 7. revs match, draft with a hand drawing -> unchanged (the feed already took the hand drawing off on a rev move). 8. revs match, draft with this very file attached at this rev -> unchanged. 9. revs match, draft -> attach ("sheet of drawing " for an H sheet). @@ -69,6 +72,8 @@ class Target: co_pending: bool = False co_staged_sha: bytes | None = None # sha256 of the file the pending CO already stages rejected_sha: bytes | None = None # sha256 staged on the last drawing-origin CO Clerical rejected + # Clerical chose the rev VISUAL calls (a Retired rev) over M-Files' Effective rev; this is that Effective rev. + kept_latest: str | None = None @dataclass(frozen=True) @@ -79,11 +84,15 @@ class Decision: note: str -def _int(v: object) -> int | None: +def as_int(v: object) -> int | None: + """A rev as a number: "3" -> 3; "A", "" and None -> None.""" s = "" if v is None else str(v).strip() return int(s) if s.isdigit() else None +_int = as_int + + def draft_line(f: Found) -> str: """The old draftLine: "rev N ", or "in drafting, no released PDF yet", or "".""" if f.draft_rev is not None and f.draft_where: @@ -102,13 +111,21 @@ def failed_note(f: Found) -> str: return f"{_FAILED_PREFIX}{detail or 'no detail'}" +def kept_called(t: Target, f: Found) -> bool: + """Clerical kept the rev VISUAL calls, and M-Files' Effective rev is still the one they chose against.""" + return f.ok and f.rev is not None and t.kept_latest is not None and str(t.kept_latest).strip() == str(f.rev) + + def rev_confirmed(t: Target, f: Found) -> bool: - """The old revConfirmed: M-Files' released rev is the rev VISUAL calls, or Clerical approved that rev.""" + """The old revConfirmed: M-Files' released rev is the rev VISUAL calls, or Clerical approved that rev, or Clerical + kept the rev VISUAL calls over that rev.""" if not f.ok or f.rev is None: return False v = _int(t.visual_rev) if v is not None and v == f.rev: return True + if kept_called(t, f): + return True return t.rev_ok is not None and str(t.rev_ok).strip() == str(f.rev) @@ -156,6 +173,8 @@ def decide(t: Target, f: Found, *, same_file: bool, fetched_sha: bytes | None = approved = t.rev_ok is not None and str(t.rev_ok).strip() == str(f.rev) vrev = (t.visual_rev or "").strip() v = _int(vrev) + if not approved and kept_called(t, f): + return keep(UNCHANGED, f"Rev {vrev} kept by Clerical; M-Files has rev {f.rev}") if not approved: # 5 if v is None: diff --git a/server/packetstation/drawings/fake.py b/server/packetstation/drawings/fake.py index f99e589..6a3a135 100644 --- a/server/packetstation/drawings/fake.py +++ b/server/packetstation/drawings/fake.py @@ -4,7 +4,8 @@ `{"rev": 3, "file": "L073762X r3.pdf", "draft_rev": null, "draft_where": "", "in_drafting": false, "ok": true, "note": "", "obj_id": 1, "obj_version": 1, "file_id": 1, "file_version": 1}`; every key is optional. A drawing not in the file is not found, after the same H-sheet fallback MFilesKit uses (K978-01-6H13 -> K978-01-6). -`download` copies `dir/`. +An entry may also carry `"revisions": {"2": {"file": "L073762X r2.pdf", "status": "Retired", "state": "Superseded", +"obj_id": 7, "file_id": 107}}` for `revision` (other revs of the drawing, any status). `download` copies `dir/`. """ from __future__ import annotations @@ -60,16 +61,12 @@ class FixtureLookup: def status(self, dwg: str) -> Found: dwg = (dwg or "").strip() - data = self._load() + self._load() if self._error: return failed(dwg, self._error) if not dwg: return Found(dwg="", note="no drawing number") - entry, base = data.get(dwg), None - if entry is None: - b = base_drawing(dwg) - if b and b in data: - entry, base = data[b], b + entry, base = self._entry(dwg) if entry is None: return Found(dwg=dwg, note=NOT_FOUND_NOTE) if entry.get("ok") is False: @@ -98,6 +95,40 @@ class FixtureLookup: note=str(entry.get("note") or ""), ) + def _entry(self, dwg: str) -> tuple[dict[str, Any] | None, str | None]: + data = self._load() + entry, base = data.get(dwg), None + if entry is None: + b = base_drawing(dwg) + if b and b in data: + entry, base = data[b], b + return entry, base + + def revision(self, dwg: str, rev: int) -> Found | None: + dwg = (dwg or "").strip() + entry, base = self._entry(dwg) + if self._error: + raise DrawingFetchError(self._error) + revs = (entry or {}).get("revisions") or {} + r = revs.get(str(int(rev))) if isinstance(revs, dict) else None + if not isinstance(r, dict) or not r.get("file"): + return None + p = self._file(str(r["file"])) + return Found( + dwg=dwg, + ok=True, + rev=int(rev), + name=str(r.get("name") or r["file"]), + obj_id=_int(r.get("obj_id", 1)), + obj_version=_int(r.get("obj_version", 1)), + file_id=_int(r.get("file_id", 1)), + file_version=_int(r.get("file_version", 1)), + file_size=p.stat().st_size if p is not None and p.is_file() else None, + base=base, + controlled=str(r.get("status") or "Retired"), + state=str(r.get("state") or ""), + ) + def _file(self, name: str) -> Path | None: root = self.dir.resolve() p = (root / name).resolve() @@ -105,6 +136,8 @@ class FixtureLookup: def download(self, found: Found, dest: Path) -> int: entry = self._load().get(found.base or found.dwg) or self._load().get(found.dwg) or {} + if found.controlled and found.rev is not None: + entry = ((entry.get("revisions") or {}).get(str(found.rev))) or {} name = entry.get("file") src = self._file(str(name)) if name else None if src is None or not src.is_file(): diff --git a/server/packetstation/drawings/fetch.py b/server/packetstation/drawings/fetch.py index bf797ec..31d12f7 100644 --- a/server/packetstation/drawings/fetch.py +++ b/server/packetstation/drawings/fetch.py @@ -1,7 +1,8 @@ """Copy a released drawing PDF from M-Files into assets. Downloads only when the M-Files identity (object id and version, file id and version) differs from what the -`drawing_lookups` row last stored, or that row has no asset (or the asset's file is gone). The download goes to +`drawing_lookups` row last stored (the `drawing_revisions` row for a `Lookup.revision` result), or that row has no +asset (or the asset's file is gone). The download goes to ASSETS_DIR/tmp/; the PDF magic and the size limit are checked, then `assets.store_file` hashes it in chunks, fsyncs and renames it into place, and reuses an existing asset row with the same sha256 and size. @@ -39,14 +40,19 @@ class FetchResult: reused: bool = False # downloaded, but the same bytes were already stored +_CURRENT_SQL = ( + "SELECT l.obj_id, l.obj_version, l.file_id, l.file_version, l.asset_id, a.sha256, a.pages," + " a.storage_key, a.original_name FROM {table} l LEFT JOIN assets a ON a.id = l.asset_id" +) + + def _current(conn: Any, settings: Any, found: Found, dwg: str) -> FetchResult | None: with conn.cursor(row_factory=dict_row) as cur: - row = cur.execute( - "SELECT l.obj_id, l.obj_version, l.file_id, l.file_version, l.asset_id, a.sha256, a.pages," - " a.storage_key, a.original_name FROM drawing_lookups l LEFT JOIN assets a ON a.id = l.asset_id" - " WHERE l.dwg = %s", - (dwg,), - ).fetchone() + if found.controlled: + sql = _CURRENT_SQL.format(table="drawing_revisions") + " WHERE l.dwg = %s AND l.rev = %s" + row = cur.execute(sql, (dwg, found.rev)).fetchone() + else: + row = cur.execute(_CURRENT_SQL.format(table="drawing_lookups") + " WHERE l.dwg = %s", (dwg,)).fetchone() if row is None or row["asset_id"] is None or row["sha256"] is None: return None if (row["obj_id"], row["obj_version"], row["file_id"], row["file_version"]) != found.identity: @@ -68,7 +74,7 @@ def _current(conn: Any, settings: Any, found: Found, dwg: str) -> FetchResult | def fetch(conn: Any, settings: Any, lookup: Lookup, found: Found, *, dwg: str) -> FetchResult: - """The asset for M-Files' released file of `dwg`. Raises FetchError.""" + """The asset for M-Files' released file of `dwg` (or the rev a `Lookup.revision` found). Raises FetchError.""" if not found.ok or found.rev is None: raise FetchError(f"{dwg}: no released file in M-Files") cur = _current(conn, settings, found, dwg) @@ -105,6 +111,7 @@ def fetch(conn: Any, settings: Any, lookup: Lookup, found: Found, *, dwg: str) - "file_id": found.file_id, "file_version": found.file_version, "rev": found.rev, + **({"controlled": found.controlled} if found.controlled else {}), } try: row = assets.store_file( diff --git a/server/packetstation/drawings/lookup.py b/server/packetstation/drawings/lookup.py index dde8f4f..cd680a2 100644 --- a/server/packetstation/drawings/lookup.py +++ b/server/packetstation/drawings/lookup.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol if TYPE_CHECKING: - from mfileskit.drawings import DrawingStatus + from mfileskit.drawings import DrawingStatus, RevisionFile from mfileskit.vault import Vault from ..settings import Settings @@ -50,6 +50,14 @@ class Found: draft_where: str = "" in_drafting: bool = False note: str = "" + # Set only on a `Lookup.revision` result: M-Files' Controlled Status for that rev ("Effective", "Retired") and + # its workflow state ("Superseded"). + controlled: str = "" + state: str = "" + + @property + def effective(self) -> bool: + return self.controlled.casefold() == "effective" @property def identity(self) -> tuple[int | None, int | None, int | None, int | None]: @@ -63,6 +71,11 @@ def failed(dwg: str, note: str) -> Found: class Lookup(Protocol): def status(self, dwg: str) -> Found: ... + def revision(self, dwg: str, rev: int) -> Found | None: + """One rev's file whatever its status (a Retired rev VISUAL still calls), with `controlled` set; None when + M-Files has no document at that rev. Raises LookupError when M-Files can't be read.""" + ... + def download(self, found: Found, dest: Path) -> int: """Write the released PDF to `dest`; bytes written. Raises DrawingFetchError (a LookupError).""" ... @@ -91,6 +104,25 @@ def from_status(st: DrawingStatus) -> Found: ) +def from_revision(rf: RevisionFile) -> Found: + """MFilesKit's RevisionFile -> Found, with `controlled` and `state` set.""" + ov, fi = rf.obj_ver, rf.file + return Found( + dwg=rf.drawing, + ok=True, + rev=rf.rev, + name=rf.name, + obj_id=ov.id if ov is not None else None, + obj_version=ov.version if ov is not None else None, + file_id=fi.id if fi is not None else None, + file_version=fi.version if fi is not None else None, + file_size=fi.size if fi is not None else None, + base=rf.base, + controlled=rf.status or "", + state=rf.state or "", + ) + + class MFilesLookup: """M-Files through `mfileskit.drawings.DrawingLookup`. Read-only.""" @@ -100,6 +132,7 @@ class MFilesLookup: self.vault = vault self._lookup = DrawingLookup(vault) # reads the vault schema; raises MFilesError when the vault is down self._seen: dict[str, DrawingStatus] = {} + self._seen_rev: dict[tuple[str, int], RevisionFile] = {} def status(self, dwg: str) -> Found: from mfileskit.errors import MFilesError @@ -112,10 +145,27 @@ class MFilesLookup: self._seen[dwg] = st return from_status(st) + def revision(self, dwg: str, rev: int) -> Found | None: + from mfileskit.errors import MFilesError + + dwg = (dwg or "").strip() + try: + rf = self._lookup.revision(dwg, int(rev)) + except MFilesError as e: + raise DrawingFetchError(f"{dwg}: M-Files lookup of rev {rev} failed: {e}"[:NOTE_MAX]) from None + if rf is None: + return None + self._seen_rev[(dwg, int(rev))] = rf + return from_revision(rf) + def download(self, found: Found, dest: Path) -> int: from mfileskit.errors import MFilesError - st = self._seen.get(found.dwg) + st: DrawingStatus | RevisionFile | None + if found.controlled and found.rev is not None: + st = self._seen_rev.get((found.dwg, found.rev)) + else: + st = self._seen.get(found.dwg) if st is None or st.obj_ver is None or st.file is None: raise DrawingFetchError(f"{found.dwg}: no released file to download") try: diff --git a/server/packetstation/feed/apply.py b/server/packetstation/feed/apply.py index bd31c71..d12e128 100644 --- a/server/packetstation/feed/apply.py +++ b/server/packetstation/feed/apply.py @@ -501,7 +501,7 @@ def _update_draft(run: _Run, packet: Mapping[str, Any], c: Candidate) -> None: state = str(row.get("drawing_state")) if moved: conn.execute("DELETE FROM packet_pages WHERE packet_id = %s AND type = 'drawing'", (sw.id,)) - for k in ("rev_ok", "manual_dwg", "vault", "appr"): + for k in ("rev_ok", "manual_dwg", "vault", "appr", "called"): visual.pop(k, None) visual["rev_confirmed"] = False flags = [f for f in flags if f not in DRAWING_FLAGS] diff --git a/server/packetstation/migrations/0009_drawing_revisions.sql b/server/packetstation/migrations/0009_drawing_revisions.sql new file mode 100644 index 0000000..e379736 --- /dev/null +++ b/server/packetstation/migrations/0009_drawing_revisions.sql @@ -0,0 +1,19 @@ +-- Other revs of a drawing that M-Files still holds (a Retired rev VISUAL calls), so Clerical can choose between +-- the rev VISUAL calls and the Effective one. One row per drawing number and rev. asset_id is the fetched copy. +CREATE TABLE drawing_revisions ( + dwg text NOT NULL, + rev int NOT NULL, + checked_at timestamptz NOT NULL, + controlled text NOT NULL, + state text, + name text, + obj_id int, + obj_version int, + file_id int, + file_version int, + file_size bigint, + base text, + asset_id uuid REFERENCES assets (id), + sha256 bytea, + PRIMARY KEY (dwg, rev) +); diff --git a/server/packetstation/worker/jobs/drawing_lookup.py b/server/packetstation/worker/jobs/drawing_lookup.py index 759b35f..e4362be 100644 --- a/server/packetstation/worker/jobs/drawing_lookup.py +++ b/server/packetstation/worker/jobs/drawing_lookup.py @@ -4,7 +4,9 @@ flag, stage or open a change order on the packets that use them. Per run: the queue (drawings/queue.py) cut to `drawing_budget`; per drawing one `Lookup.status`, a fetch when a released file exists (drawings/fetch.py downloads only when its M-Files identity changed), the `drawing_lookups` row, then `decide` for every live packet that uses the drawing (its own drawing, or the target of its pending feed -change order), applied through `feed.writes.system_write`. A busy packet is left for the next run. +change order), applied through `feed.writes.system_write`. A busy packet is left for the next run. On a draft +flagged rev_mismatch, the rev VISUAL calls is looked up too (`Lookup.revision`) and copied into assets when M-Files +still holds it (Retired, say), so Clerical can choose it or the Effective rev. Never writes to M-Files. """ @@ -56,6 +58,7 @@ def new_counts() -> Counts: "busy": 0, "refused": 0, "no_file": 0, + "called_fetched": 0, } @@ -124,8 +127,9 @@ def _drawing(ctx: JobContext, lk: Lookup, item: queue.QueueItem, counts: Counts, vrev_seen = item.visual_rev if item.co_id is None else own_rev(conn, item.dwg) save_lookup(conn, item, found, fetched, now, fetch_failed=fetch_failed, visual_rev_seen=vrev_seen) scope = [str(d) for d in (getattr(ctx.feed_settings, "departments", None) or [])] + called: dict[int, dict[str, Any] | None] = {} # VISUAL rev -> what M-Files holds at that rev, this drawing for pid, relations in sharing(conn, item.dwg, scope): - _packet(ctx, item.dwg, found, fetched, pid, relations, counts) + _packet(ctx, lk, item.dwg, found, fetched, pid, relations, counts, called) # ---------------------------------------------------------------- drawing_lookups @@ -206,6 +210,92 @@ def save_lookup( ) +def save_revision(conn: Any, dwg: str, rf: Found, fetched: FetchResult, now: datetime) -> None: + """Upsert the drawing_revisions row for a rev `Lookup.revision` found and the copy of its file.""" + conn.execute( + "INSERT INTO drawing_revisions (dwg, rev, checked_at, controlled, state, name, obj_id, obj_version, file_id," + " file_version, file_size, base, asset_id, sha256) VALUES (%(dwg)s, %(rev)s, %(now)s, %(controlled)s," + " %(state)s, %(name)s, %(obj_id)s, %(obj_version)s, %(file_id)s, %(file_version)s, %(file_size)s, %(base)s," + " %(asset_id)s, %(sha)s) ON CONFLICT (dwg, rev) DO UPDATE SET checked_at = EXCLUDED.checked_at," + " controlled = EXCLUDED.controlled, state = EXCLUDED.state, name = EXCLUDED.name, obj_id = EXCLUDED.obj_id," + " obj_version = EXCLUDED.obj_version, file_id = EXCLUDED.file_id, file_version = EXCLUDED.file_version," + " file_size = EXCLUDED.file_size, base = EXCLUDED.base, asset_id = EXCLUDED.asset_id, sha256 = EXCLUDED.sha256", + { + "dwg": dwg, + "rev": rf.rev, + "now": now, + "controlled": rf.controlled, + "state": rf.state or None, + "name": fetched.name or rf.name, + "obj_id": rf.obj_id, + "obj_version": rf.obj_version, + "file_id": rf.file_id, + "file_version": rf.file_version, + "file_size": rf.file_size, + "base": rf.base, + "asset_id": fetched.asset_id, + "sha": fetched.sha256, + }, + ) + + +def called_rev( + ctx: JobContext, lk: Lookup, dwg: str, visual_rev: str, memo: dict[int, dict[str, Any] | None], counts: Counts +) -> dict[str, Any] | None: + """The rev VISUAL calls, as M-Files holds it: {rev, controlled, state, name, asset_id, sha256, pages}, or + {rev, missing: true} when M-Files has no document at that rev. None when it could not be read this run (the + packet keeps what it had) or the VISUAL rev is not a number.""" + v = D.as_int(visual_rev) + if v is None: + return None + if v in memo: + return memo[v] + memo[v] = None + conn = ctx.conn + try: + rf = lk.revision(dwg, v) + except LookupError as e: + ctx.error(f"drawing {dwg}: rev {v}: {e}") + return None + if rf is None: + memo[v] = {"rev": str(v), "missing": True} + return memo[v] + try: + fetched = fetch(conn, ctx.settings, lk, rf, dwg=dwg) + except FetchError as e: + counts["fetch_failed"] += 1 + ctx.error(str(e)) + return None + with conn.transaction(): + save_revision(conn, dwg, rf, fetched, datetime.now(UTC)) + if fetched.downloaded: + counts["called_fetched"] += 1 + audit.append( + conn, + action="drawing.fetched", + role_snapshot="system", + subject_type="asset", + subject_id=str(fetched.asset_id), + detail={ + "dwg": dwg, + "rev": v, + "controlled": rf.controlled, + "sha256": fetched.sha256.hex(), + "reused": fetched.reused, + }, + ) + memo[v] = { + "rev": str(v), + "controlled": rf.controlled, + "state": rf.state or None, + "name": fetched.name or rf.name, + "asset_id": str(fetched.asset_id), + "sha256": fetched.sha256.hex(), + "pages": fetched.pages, + } + return memo[v] + + # ---------------------------------------------------------------- packets @@ -307,10 +397,13 @@ def target_for(dwg: str, pf: PacketFacts, *, own: bool) -> D.Target: co_waiting = _dwg_rev(waiting) co_staged = _dwg_rev(staged) packet_rev = str(row["rev"] or "").strip() - rev_ok = None + rev_ok = kept_latest = None ro = visual.get("rev_ok") if isinstance(ro, dict) and ro.get("rev") is not None and str(ro.get("vis_rev") or "").strip() == packet_rev: - rev_ok = str(ro["rev"]).strip() + if ro.get("use") == "called": + kept_latest = None if ro.get("latest") is None else str(ro["latest"]).strip() + else: + rev_ok = str(ro["rev"]).strip() visual_rev = packet_rev if own or co_waiting is None else co_waiting[1] return D.Target( dwg=dwg, @@ -326,6 +419,7 @@ def target_for(dwg: str, pf: PacketFacts, *, own: bool) -> D.Target: co_pending=co is not None, co_staged_sha=_sha(staged.get("sha256")) if isinstance(staged, dict) else None, rejected_sha=pf.rejected_sha, + kept_latest=kept_latest, ) @@ -360,12 +454,14 @@ def new_flags(flags: list[str], d: D.Decision) -> list[str]: def _packet( ctx: JobContext, + lk: Lookup, dwg: str, found: Found, fetched: FetchResult | None, pid: int, relations: set[str], counts: Counts, + called_memo: dict[int, dict[str, Any] | None], ) -> None: conn = ctx.conn pf = facts(conn, pid) @@ -393,6 +489,12 @@ def _packet( confirmed = D.rev_confirmed(t, found) if bool(visual.get("rev_confirmed")) != confirmed: patch["rev_confirmed"] = confirmed + if d.kind == D.REV_MISMATCH and row["status"] == "draft": + called = called_rev(ctx, lk, dwg, t.visual_rev, called_memo, counts) + if called is not None and visual.get("called") != called: + patch["called"] = called + elif visual.get("called") is not None: + patch["called"] = None state_after = d.drawing_state if own else str(row["drawing_state"]) material = ( d.kind in (D.ATTACH, D.OPEN_CO, D.STAGE_FOR_CO) @@ -405,6 +507,11 @@ def _packet( return action = A.VIS_UPDATE_DRAFT if row["status"] == "draft" else A.VIS_OPEN_CO label, note = "Drawing status from M-Files", d.note or None + called_now = patch.get("called") or {} + if note and called_now.get("controlled"): + note = f"{note}; rev {called_now['rev']} is {called_now['controlled']} in M-Files" + elif note and called_now.get("missing"): + note = f"{note}; rev {called_now['rev']} is not in M-Files" if d.kind == D.ATTACH and fetched is not None: label = "Drawing attached from M-Files" note = "; ".join(x for x in (fetched.name or found.name or "", d.note) if x) or None @@ -533,4 +640,4 @@ def _cadence(fs: Any, now: datetime) -> int | None: SPEC = JobSpec(name="drawing-lookup", run=run, cadence=_cadence, pausable=True) -__all__ = ["SPEC", "new_flags", "run", "save_lookup", "sharing", "target_for"] +__all__ = ["SPEC", "called_rev", "new_flags", "run", "save_lookup", "save_revision", "sharing", "target_for"] diff --git a/tests/fixtures/generator.py b/tests/fixtures/generator.py index b2dc1ab..66e98a8 100644 --- a/tests/fixtures/generator.py +++ b/tests/fixtures/generator.py @@ -596,7 +596,18 @@ def drawings_state(n: int) -> dict[str, Any]: 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-0201": { # VISUAL calls rev 2: rev mismatch. Rev 2 is Retired in M-Files. + **_dwg(3, "L900-0201 r3.pdf", n=2), + "revisions": { + "2": { + "file": "L900-0201 r2.pdf", + "status": "Retired", + "state": "Superseded", + "obj_id": 32, + "file_id": 132, + } + }, + }, "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" @@ -643,6 +654,7 @@ def generate(out_dir: Path | str, today: date | None = None) -> Path: 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")) + files.update(r["file"] for v in dwgs.values() for r in (v.get("revisions") or {}).values() if r.get("file")) for name in sorted(files): (out / name).write_bytes(pdf_bytes(name)) write_state(out, 1) diff --git a/tests/server/test_drawing_decide.py b/tests/server/test_drawing_decide.py index cd79390..29c9abc 100644 --- a/tests/server/test_drawing_decide.py +++ b/tests/server/test_drawing_decide.py @@ -19,7 +19,7 @@ from feedhelpers import ( ) from pshelpers import app_conn, needs_pg -from packetstation.drawings.decide import Decision, Target, decide +from packetstation.drawings.decide import Decision, Target, decide, rev_confirmed from packetstation.drawings.fake import FixtureLookup from packetstation.drawings.lookup import Found from packetstation.settings import Settings @@ -129,6 +129,26 @@ def test_approved_other_rev_still_mismatches() -> None: assert d(target(visual_rev="2", rev_ok="4"), found(rev=3)).kind == "rev_mismatch" +def test_kept_called_rev_stays_while_the_effective_rev_is_the_same() -> None: + t = target(visual_rev="2", drawing_state="attached", attached_rev="2", kept_latest="3") + r = d(t, found(rev=3)) + assert (r.kind, r.flag, r.drawing_state) == ("unchanged", None, "attached") + assert r.note == "Rev 2 kept by Clerical; M-Files has rev 3" + assert rev_confirmed(t, found(rev=3)) + + +def test_kept_called_rev_opens_no_change_order_past_draft() -> None: + t = target(visual_rev="2", status="ready", drawing_state="attached", attached_rev="2", kept_latest="3") + assert d(t, found(rev=3)).kind == "unchanged" + + +def test_kept_called_rev_flags_again_on_a_newer_effective_rev() -> None: + t = target(visual_rev="2", drawing_state="attached", attached_rev="2", kept_latest="3") + r = d(t, found(rev=4)) + assert (r.kind, r.flag, r.drawing_state) == ("rev_mismatch", "rev_mismatch", "attached") + assert not rev_confirmed(t, found(rev=4)) + + def test_match_on_a_draft_attaches() -> None: r = d(target(), found()) assert (r.kind, r.flag, r.drawing_state) == ("attach", None, "attached") @@ -193,6 +213,11 @@ def test_fixture_lookup_states(tmp_path: Path) -> None: assert one.status("L900-0701").rev is None and one.status("L900-0701").ok dest = tmp_path / "out" / "x.pdf" assert one.download(one.status("L900-0101"), dest) > 0 and dest.read_bytes().startswith(b"%PDF-") + r2 = one.revision("L900-0201", 2) + assert r2 is not None and (r2.rev, r2.controlled, r2.effective) == (2, "Retired", False) + assert one.revision("L900-0201", 9) is None and one.revision("L900-0101", 1) is None + dest2 = tmp_path / "out" / "r2.pdf" + assert one.download(r2, dest2) > 0 and dest2.read_bytes() != dest.read_bytes() (tmp_path / "state").write_text("2") two = FixtureLookup(tmp_path) f2 = two.status("L900-0101") @@ -249,6 +274,17 @@ def test_job_attaches_and_flags(fx: Settings) -> None: ).fetchone() assert staged == (True, 3) # staged for Clerical Supervisor approval assert _drawing_pages(conn, mm["id"]) == [] + called = mm["visual"]["called"] # VISUAL's rev 2, Retired in M-Files, copied for Clerical's choice + assert (called["rev"], called["controlled"], called["state"]) == ("2", "Retired", "Superseded") + assert called["asset_id"] and called["pages"] >= 1 + rv = conn.execute( + "SELECT controlled, asset_id IS NOT NULL FROM drawing_revisions WHERE dwg = 'L900-0201' AND rev = 2" + ).fetchone() + assert rv == ("Retired", True) + assert conn.execute( + "SELECT count(*) FROM packet_log WHERE packet_id = %s AND note LIKE %s", + (mm["id"], "%rev 2 is Retired in M-Files%"), + ).fetchone()[0] nr = packet_by_key(conn, gen.KEY_DRAFTING) assert nr is not None and "not_released" in nr["flags"] and nr["drawing_state"] == "not_released" diff --git a/tests/server/test_feed_api.py b/tests/server/test_feed_api.py index 8027969..36bdbe0 100644 --- a/tests/server/test_feed_api.py +++ b/tests/server/test_feed_api.py @@ -221,6 +221,49 @@ def test_rev_approve(client: TestClient, fx: Settings) -> None: assert p["drawing_state"] == "attached" and "rev_mismatch" not in p["flags"] +def test_rev_approve_keeps_the_rev_visual_calls(client: TestClient, fx: Settings) -> None: + run_feed(fx, 1) + run_drawings(fx) + with app_conn(fx) as conn: + pid = packet_id(conn, gen.KEY_TWO_OPS) # VISUAL rev 2 (Retired in M-Files), M-Files rev 3 + nf = packet_id(conn, gen.KEY_NOT_FOUND) + body = {"reason": "Engineering says build lot 1 to rev 2", "sign": SIGN, "use": "called"} + as_user(client, "clerk.leo") + r = client.post(f"/api/packets/{nf}/rev-approve", json=body) + assert r.status_code == 409 and r.json()["code"] == "nothing_staged", r.text + r = client.post(f"/api/packets/{pid}/rev-approve", json={**body, "use": "oldest"}) + assert r.status_code == 422, r.text + r = client.post(f"/api/packets/{pid}/rev-approve", json=body) + assert r.status_code == 200, r.text + with app_conn(fx) as conn: + p = packet_row(conn, pid) + assert p["drawing_state"] == "attached" and "rev_mismatch" not in p["flags"] + ok = p["visual"]["rev_ok"] + assert (ok["rev"], ok["vis_rev"], ok["use"], ok["latest"], ok["controlled"]) == ( + "2", + "2", + "called", + "3", + "Retired", + ) + assert p["visual"]["rev_confirmed"] is True + pages = drawing_pages(conn, pid) + assert pages and all(pg[1] == "2" for pg in pages) + log = conn.execute( + "SELECT note FROM packet_log WHERE packet_id = %s AND action = 'Rev VISUAL calls kept by hand'", (pid,) + ).fetchall() + assert len(log) == 1 and "Retired in M-Files" in log[0][0] and "M-Files has rev 3" in log[0][0] + # the drawing job leaves the kept rev alone while M-Files' Effective rev is still 3 + with app_conn(fx) as conn: + conn.execute("UPDATE drawing_lookups SET checked_at = now() - interval '2 days', next_check_at = now()") + run_drawings(fx) + with app_conn(fx) as conn: + p = packet_row(conn, pid) + assert p["drawing_state"] == "attached" and "rev_mismatch" not in p["flags"] + assert p["visual"]["rev_confirmed"] is True and p["visual"].get("called") is None + assert all(pg[1] == "2" for pg in drawing_pages(conn, pid)) + + # ---------------------------------------------------------------- hand uploads