drawings: VISUAL's rev is the source of truth; attach it when M-Files holds it Effective or Retired
ci / frontend (pull_request) Successful in 43s
ci / server (pull_request) Successful in 11m3s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 3m52s

When M-Files' Effective rev differs from the rev VISUAL calls, the drawing
job looks that rev up (MFilesKit DrawingLookup.revision). If M-Files has
it as Effective or Retired, the job copies it into drawing_revisions and
attaches it to the draft with no flag and no Clerical approval, so the
draft can auto-release. Past draft, already attached or with a hand
drawing: unchanged, no flag and no change order.

Any other status, or no document at that rev, still flags rev_mismatch
with a note saying why. Packet Station only ever attaches Effective or
Retired drawings. rev_approve stays for that case only.

This replaces the "Use rev V as VISUAL calls it" choice from #46: the
use=called path in rev-approve and its button are removed. A packet that
already kept the rev that way keeps it, since that rev is now used anyway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 16:05:57 -06:00
co-authored by Claude Opus 5.5
parent c5bf48ac50
commit ca407a2db3
16 changed files with 292 additions and 250 deletions
+1 -2
View File
@@ -68,8 +68,7 @@ export const packetsApi = {
databook: (id: number) => api.post<PacketRead>(`${base(id)}/databook`, {}),
/* Phase 3: VISUAL packets. */
revApprove: (id: number, body: { reason: string; use?: "latest" | "called" } & Guarded) =>
api.post<PacketRead>(`${base(id)}/rev-approve`, body),
revApprove: (id: number, body: { reason: string } & 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. */
+7 -2
View File
@@ -905,11 +905,13 @@ 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. */
/** The rev VISUAL calls, as M-Files holds it when that is not the Effective rev. Set by the drawing job. A copy (Effective
* or Retired) is attached automatically; `missing` when M-Files has no document at that rev, `refused` when its status
* may not be attached. */
export interface CalledRev {
rev: string;
missing?: boolean;
refused?: boolean;
controlled?: string;
state?: string | null;
name?: string | null;
@@ -923,6 +925,9 @@ export interface VisualInfo {
/** Set by the read model from drawing_lookups when M-Files has a staged copy. */
staged_rev?: { rev: number | string; name: string | null; asset_id?: string };
called?: CalledRev | null;
/** The M-Files copy attached by the drawing job. `controlled` is set when it is the rev VISUAL calls at a status other
* than Effective's newest (Retired, say). */
vault?: { asset_id: string; name?: string | null; rev: number | string; note?: string | null; at?: string; controlled?: string } | null;
partspecs?: string[];
folded?: FoldedOp[];
manual_dwg?: HandUpload;
+20 -18
View File
@@ -116,29 +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,
it("says when the rev VISUAL calls is attached although M-Files has retired it", async () => {
const page = { n: 1, type: "drawing", asset_id: "as-r2", pn: 1, dwg: "M00543", rev: "2", void: null, url: "/_blob/as-r2" };
const attached = visualDraft({
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" } },
pages: [page] as unknown as PacketRead["pages"],
visual: {
appr: { ok: true, rev: 3, vrev: "2", name: "M00543 r3.pdf", note: "" },
vault: { asset_id: "as-r2", name: "M00543 r2.pdf", rev: 2, controlled: "Retired" },
},
});
const fetch = mockFetch({ ...base(draft, LEO), "POST /api/packets/1/rev-approve": { body: kept } });
mockFetch({ ...base(attached, LEO) });
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();
expect(await screen.findByText("Rev 2 attached as VISUAL calls it")).toBeInTheDocument();
expect(screen.getByText(/retired in M-Files, which has rev 3 as Effective/)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Approve rev/ })).not.toBeInTheDocument();
});
it("explains why the rev VISUAL calls is not attached when its status is not Effective or Retired", async () => {
const draft = visualDraft({ visual: { ...visualDraft().visual, called: { rev: "2", controlled: "Obsolete", refused: true } } });
mockFetch({ ...base(draft, LEO) });
renderApp("/packets/1");
expect(await screen.findByText(/VISUAL calls rev 2, which is obsolete in M-Files/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Approve rev 3 for this packet" })).toBeInTheDocument();
});
it("uploads a hand BOM as multipart with the signature fields", async () => {
+23 -46
View File
@@ -163,7 +163,9 @@ 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 called = v.called ?? null;
const vault = v.vault;
const retiredAttached = hasDwg && !drawingFlag && !!vault?.controlled && vault.controlled.toLowerCase() !== "effective";
const mismatch = flags.includes("rev_mismatch");
const md = v.manual_dwg;
const mb = v.manual_bom;
@@ -188,25 +190,6 @@ 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") {
@@ -259,6 +242,15 @@ export function VisualBox({
</div>
) : null}
{retiredAttached && vault ? (
<div className="callout flush">
<b>
Rev {String(vault.rev)} attached as VISUAL calls it
</b>{" "}
- {(vault.controlled || "").toLowerCase()} in M-Files{appr?.rev !== undefined && appr.rev !== null ? `, which has rev ${appr.rev} as Effective` : ""}.
</div>
) : null}
{!hasDwg || drawingFlag ? (
<div className="callout warn flush">
<b>{hasDwg ? "Made from VISUAL - drawing needs a look." : "Made from VISUAL - drawing not attached yet."}</b> {p.drawing || "No drawing number"} rev {p.rev || "-"}. The drawing
@@ -280,38 +272,23 @@ export function VisualBox({
.
</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>
.
{called?.missing ? (
<div className="muted">VISUAL calls rev {called.rev}, which is not in M-Files.</div>
) : called?.refused ? (
<div className="muted">
VISUAL calls rev {called.rev}, which is {(called.controlled || "").toLowerCase()} in M-Files. Only an Effective or Retired drawing is
attached.
</div>
) : v.called?.missing ? (
<div className="muted">VISUAL calls rev {v.called.rev}, which is not in M-Files.</div>
) : null}
{staged || called ? (
{staged ? (
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}
<button className="btn sm pri" type="button" disabled={working} onClick={() => void approveRev()}>
Approve rev {String(staged.rev)} for this packet
</button>
</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>
<span className="muted">A Clerical supervisor can approve rev {String(staged.rev)} for this packet.</span>
) : null
) : (
<span className="muted">The M-Files drawing is being copied. Check back after the next drawing run.</span>