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]>
This commit is contained in:
2026-10-19 13:39:14 -06:00
co-authored by Claude Opus 5.5
parent 4c0cba4da5
commit bb8eb17748
21 changed files with 547 additions and 55 deletions
+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()),
+80 -12
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">
<b>Rev {String(v.rev_ok.rev)} approved by hand</b> in place of VISUAL rev {v.rev_ok.vis_rev || "-"}
<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 ? (
<>
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>
<div>
Effective in M-Files: <span className="mono">{staged.name ?? `rev ${staged.rev}`}</span>
{staged.asset_id ? (
<>
{" "}
<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>
)}