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 <[email protected]>
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user