Compare commits

...
3 Commits
Author SHA1 Message Date
spencerm c5bf48ac50 Merge pull request 'drawings: on a rev mismatch, Clerical can keep the retired rev VISUAL calls (#41)' (#46) from issue-41-retired-rev into main
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 10m50s
ci / image (push) Successful in 47s
ci / e2e (push) Successful in 3m46s
Reviewed-on: #46
2026-10-19 21:05:14 +00:00
spencermandClaude Opus 5.5 f2f66db6ed deps: pin mfileskit 0.1.3 in requirements-pesco.txt too
ci / frontend (pull_request) Successful in 42s
ci / server (pull_request) Successful in 11m31s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 4m8s
CI and the image install from server/requirements-pesco.txt, which still
pinned 0.1.2 while pyproject asked for 0.1.3, so pip check failed. Its
runtime dependencies are unchanged from 0.1.2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:54:24 -06:00
spencermandClaude Opus 5.5 bb8eb17748 drawings: on a rev mismatch, Clerical can keep the retired rev VISUAL calls (#41)
ci / server (pull_request) Failing after 17s
ci / frontend (pull_request) Successful in 42s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Skipped
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 <[email protected]>
2026-10-19 13:39:14 -06:00
23 changed files with 549 additions and 57 deletions
+2
View File
@@ -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.
+3 -2
View File
@@ -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 <base>) - drafting status: rev N <where>". |
| No released rev and nothing in drafting | `not_found` | `not_found` | Note "Drawing not in M-Files - check the drawing number in VISUAL". |
| VISUAL's rev is not a whole number | `bad_rev` | `bad_rev` | Not attached. |
| Revs differ, and no approved rev matches M-Files | `rev_mismatch` | `rev_mismatch` | Note "Rev mismatch: VISUAL calls rev V, M-Files has rev N", plus the drafting status. The M-Files copy is fetched and staged for Clerical Supervisor approval. No change order: VISUAL did not change. |
| Revs 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 <where>" | Wait for drafting to release it, or upload the drawing by hand with a reason and a signature. |
| `not_found` | Drawing not in M-Files - check the drawing number in VISUAL | Check the drawing number in VISUAL and have it fixed there. Or upload by hand. |
| `lookup_failed` | M-Files lookup failed: (the reason) | Nothing at first: the job retries with backoff. If it persists, tell IT. |
+2
View File
@@ -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.
+1
View File
@@ -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:
+2 -1
View File
@@ -68,7 +68,8 @@ export const packetsApi = {
databook: (id: number) => api.post<PacketRead>(`${base(id)}/databook`, {}),
/* Phase 3: VISUAL packets. */
revApprove: (id: number, body: { reason: string } & Guarded) => api.post<PacketRead>(`${base(id)}/rev-approve`, body),
revApprove: (id: number, body: { reason: string; use?: "latest" | "called" } & Guarded) =>
api.post<PacketRead>(`${base(id)}/rev-approve`, body),
handDrawing: (id: number, body: { asset_id: string; reason: string } & Guarded) =>
api.post<PacketRead>(`${base(id)}/hand/drawing`, body),
/** Multipart cannot carry JSON, so the signature rides as sign_factor and sign_secret. */
+25 -2
View File
@@ -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;
}
+25
View File
@@ -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()),
+78 -10
View File
@@ -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 (
<section className="stack" aria-label="From VISUAL">
{v.rev_ok && hasDwg ? (
<div className="callout flush">
<div className={v.rev_ok.use === "called" ? "callout warn flush" : "callout flush"}>
{v.rev_ok.use === "called" ? (
<>
<b>
Rev {String(v.rev_ok.rev)} kept as VISUAL calls it
</b>{" "}
- {(v.rev_ok.controlled || "not Effective").toLowerCase()} in M-Files
{v.rev_ok.latest ? `, which has rev ${v.rev_ok.latest}` : ""}
</>
) : (
<>
<b>Rev {String(v.rev_ok.rev)} approved by hand</b> in place of VISUAL rev {v.rev_ok.vis_rev || "-"}
</>
)}
{v.rev_ok.by ? ` - ${whoName(v.rev_ok.by)}` : ""} {v.rev_ok.at ? fmtTime(v.rev_ok.at) : ""}
{v.rev_ok.reason ? `: ${v.rev_ok.reason}` : ""}.
</div>
@@ -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 ? <div className="small">M-Files: {appr.note}{appr.at ? ` (looked ${fmtTime(appr.at)})` : ""}</div> : null}
{mismatch ? (
<div className="small">
<div className="small stack">
{staged ? (
<div>
Effective in M-Files: <span className="mono">{staged.name ?? `rev ${staged.rev}`}</span>
{staged.asset_id ? (
<>
In M-Files: <span className="mono">{staged.name ?? `rev ${staged.rev}`}</span>.{" "}
{canApproveRev ? (
<button className="btn sm pri" type="button" disabled={working} onClick={() => void approveRev()}>
Approve rev {String(staged.rev)} for this packet
</button>
) : draft ? (
<span className="muted">A Clerical supervisor can approve rev {String(staged.rev)} for this packet.</span>
) : null}
{" "}
<a className="lnk" href={blobUrl(staged.asset_id)} target="_blank" rel="noopener noreferrer">
View
</a>
</>
) : null}
.
</div>
) : null}
{called ? (
<div>
VISUAL calls rev {called.rev}: <span className="mono">{called.name ?? `rev ${called.rev}`}</span>, {(called.controlled || "not Effective").toLowerCase()}
{called.state ? ` (${called.state})` : ""} in M-Files{" "}
<a className="lnk" href={blobUrl(called.asset_id as string)} target="_blank" rel="noopener noreferrer">
View
</a>
.
</div>
) : v.called?.missing ? (
<div className="muted">VISUAL calls rev {v.called.rev}, which is not in M-Files.</div>
) : null}
{staged || called ? (
canApproveRev ? (
<div className="acts">
{staged ? (
<button className="btn sm pri" type="button" disabled={working} onClick={() => void approveRev()}>
{called ? `Use latest rev ${String(staged.rev)}` : `Approve rev ${String(staged.rev)} for this packet`}
</button>
) : null}
{called ? (
<button className="btn sm" type="button" disabled={working} onClick={() => void keepCalled()}>
Use rev {called.rev} as VISUAL calls it
</button>
) : null}
</div>
) : draft ? (
<span className="muted">
{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.`}
</span>
) : null
) : (
<span className="muted">The M-Files drawing is being copied. Check back after the next drawing run.</span>
)}
+1 -1
View File
@@ -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]
+36 -6
View File
@@ -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)
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,7 +271,11 @@ 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.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)
+14 -1
View File
@@ -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:
+22 -3
View File
@@ -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 <base>" 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 <where>", 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:
+40 -7
View File
@@ -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/<file>`.
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/<file>`.
"""
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():
+15 -8
View File
@@ -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/<uuid>; the PDF magic and the size limit are checked, then `assets.store_file` hashes it in chunks,
fsyncs and renames it into place, and reuses an existing asset row with the same sha256 and size.
@@ -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(
+51 -1
View File
@@ -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,9 +145,26 @@ 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: 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")
+1 -1
View File
@@ -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]
@@ -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)
);
@@ -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,9 +397,12 @@ 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:
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(
@@ -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"]
+1 -1
View File
@@ -1,4 +1,4 @@
# PESCO packages, served only by the Gitea index:
# pip install --index-url https://gt.pescoinc.biz/api/packages/spencerm/pypi/simple/ -r server/requirements-pesco.txt
visualkit==0.3.0
mfileskit==0.1.2
mfileskit==0.1.3
+1 -1
View File
@@ -22,7 +22,7 @@ openpyxl==3.1.5
reportlab==5.0.1
pillow==13.0.0
# needed by visualkit 0.3.0 and mfileskit 0.1.2
# needed by visualkit 0.3.0 and mfileskit 0.1.3
pyodbc==5.3.0
keyring==25.7.0
+13 -1
View File
@@ -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)
+37 -1
View File
@@ -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"
+43
View File
@@ -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