Phase 7: close, file, PDFs and prints
Clerical close and file: the VISUAL observed state beside the close confirm, a 409 with a second confirm when the feed still shows work orders open, filing with a signature and the HMAC seal, the data book built on the server and stored on the seal in the filing transaction, admin reopen keeping the broken seal with its data book. Server PDFs with reportlab, pypdf and pypdfium2: the manufacturing data book page for page after the old build (cover, index, drawings with VOID stamps, traceability and heat map rasters, weld maps and welding records, Q.C. inspections, Q.C. and test documents, MTRs, record history), the job traveler with work order QR codes, the ITP PDF filed automatically at sign-off, every print kind with the PRINTED COPY stamp, the controlled-copy stamp signed with the seal key, PDF metadata, the traceability CSV with the formula guard, and the append-only print_log with an audit row for every print and export (migration 0008). Frontend: the close and file panel, the print menu and its entry points, the printed copies list on the History tab, the ITP PDF buttons. Tests: server (close, file, reopen, seal, the builders, prints, CSV, stamps), frontend, and the Playwright close and print scenarios. Docs updated; SECURITY closes M2, S6, S7 and S8. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { PIN } from "./helpers";
|
||||
import {
|
||||
actor,
|
||||
api,
|
||||
closeAll,
|
||||
packet,
|
||||
packetTab,
|
||||
setPin,
|
||||
sign,
|
||||
signSheet,
|
||||
signWithPassword,
|
||||
signWithPin,
|
||||
statusOf,
|
||||
stepApi,
|
||||
userId,
|
||||
} from "./shophelpers";
|
||||
import type { Crew, QcPacket } from "./qchelpers";
|
||||
import {
|
||||
L,
|
||||
atCompleteApi,
|
||||
cardOf,
|
||||
clickAndCatch,
|
||||
column,
|
||||
expectPdf,
|
||||
fetchOk,
|
||||
makeCloseProject,
|
||||
openClericalHome,
|
||||
openPacket,
|
||||
panel,
|
||||
printCount,
|
||||
prints,
|
||||
signedOffItpApi,
|
||||
type CloseProject,
|
||||
} from "./closehelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
|
||||
// Phase 7 close and file scenarios on the CI stack, modelled on the old tests/ps tclose and tpr. clerk.clara makes
|
||||
// a project with one unit; the crew (wh.wade, sup.wes, weld.andy) and qc.quinn take two packets, R1 and R2, to
|
||||
// status complete routed to the data book through the API. Then the Clerical home lists both as ready to close,
|
||||
// clara confirms R1 closed in VISUAL, R2 cannot be filed yet, the data book, the CSV and the prints answer as
|
||||
// files, R1 is filed with her PIN and the seal holds, admin.ann reopens it with a reason and her password, and
|
||||
// clara files it again on a second seal. Last, the unit's ITP is signed off through the API and its PDF is filed in
|
||||
// the unit folder. Every UI label used here is in L (closehelpers.ts).
|
||||
|
||||
const RUN = Date.now().toString(36).toUpperCase().slice(-5);
|
||||
const PROJECT = `C7${RUN}`;
|
||||
const T = {
|
||||
r1: `Close R1 spool ${RUN}`,
|
||||
r2: `Close R2 spool ${RUN}`,
|
||||
};
|
||||
|
||||
let proj: CloseProject;
|
||||
const P: Partial<Record<keyof typeof T, QcPacket>> = {};
|
||||
|
||||
function pk(k: keyof typeof T): QcPacket {
|
||||
const p = P[k];
|
||||
if (!p) throw new Error(`packet ${k} was not made`);
|
||||
return p;
|
||||
}
|
||||
|
||||
async function homeShows(page: Page, heading: string, id: number): Promise<void> {
|
||||
await openClericalHome(page, PROJECT);
|
||||
await expect(cardOf(column(page, heading), id)).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe.serial("close", () => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
test("setup: a project with one unit, two packets complete routed to the data book, clara's PIN", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const andy = await actor(browser, "weld.andy");
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const andyId = await userId(andy, "weld.andy");
|
||||
const crew: Crew = { clara, wade, wes, andy, andyId };
|
||||
proj = await makeCloseProject(clara, PROJECT);
|
||||
P.r1 = await atCompleteApi(crew, quinn, proj.unit, T.r1, "Stall 2");
|
||||
P.r2 = await atCompleteApi(crew, quinn, proj.unit, T.r2, "Stall 3");
|
||||
for (const id of [P.r1.id, P.r2.id]) {
|
||||
const p = await packet(clara, id);
|
||||
expect(p.vclosed).toBeFalsy();
|
||||
expect(p.seal).toBeFalsy();
|
||||
}
|
||||
await setPin(clara, PIN);
|
||||
await closeAll(clara, wade, wes, andy, quinn);
|
||||
});
|
||||
|
||||
test("clerical home: both packets are ready to close", async ({ browser }) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openClericalHome(page, PROJECT);
|
||||
const toClose = column(page, L.colToClose);
|
||||
await expect(cardOf(toClose, pk("r1").id)).toBeVisible();
|
||||
await expect(cardOf(toClose, pk("r2").id)).toBeVisible();
|
||||
await expect(page.locator(`[aria-label="${L.kpiToClose(2)}"]`)).toBeVisible();
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("R1: confirmed closed in VISUAL, it moves to ready for the data book", async ({ browser }) => {
|
||||
const id = pk("r1").id;
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
await expect(box).toBeVisible();
|
||||
await expect(box.getByText(L.readyToClose)).toBeVisible();
|
||||
// a hand packet has no feed state, so no VISUAL line
|
||||
expect((await packet(clara, id)).vclose_check ?? null).toBeNull();
|
||||
await expect(box.getByTestId(L.visualLine)).toHaveCount(0);
|
||||
const close = box.getByRole("button", { name: L.jobClosed, exact: true });
|
||||
await expect(close).toBeEnabled();
|
||||
await close.click();
|
||||
await expect(box.getByText(L.readyForBook)).toBeVisible();
|
||||
await expect.poll(async () => Boolean((await packet(clara, id)).vclosed)).toBe(true);
|
||||
await expect(box.getByRole("button", { name: L.fileIt, exact: true })).toBeEnabled();
|
||||
await expect(box.getByRole("button", { name: L.moveBack, exact: true })).toBeVisible();
|
||||
|
||||
await homeShows(page, L.colToBook, id);
|
||||
await expect(cardOf(column(page, L.colToClose), id)).toHaveCount(0);
|
||||
await expect(page.locator(`[aria-label="${L.kpiToClose(1)}"]`)).toBeVisible();
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("R2: filing is blocked until the job is closed in VISUAL; move back undoes the close", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const id = pk("r2").id;
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
await expect(box.getByText(L.readyToClose)).toBeVisible();
|
||||
const file = box.getByRole("button", { name: L.fileIt, exact: true });
|
||||
await expect(file).toBeVisible();
|
||||
await expect(file).toBeDisabled();
|
||||
await expect(box.getByText(L.blocked)).toBeVisible();
|
||||
// the API refuses it the same way (403 precondition)
|
||||
await stepApi(clara, id, "file", { sign: sign("clerk.clara") }, 403);
|
||||
expect(await statusOf(clara, id)).toBe("complete");
|
||||
|
||||
// close, then move back: it is ready to close again (the old vReopen)
|
||||
await box.getByRole("button", { name: L.jobClosed, exact: true }).click();
|
||||
await expect(box.getByText(L.readyForBook)).toBeVisible();
|
||||
await box.getByRole("button", { name: L.moveBack, exact: true }).click();
|
||||
await expect(box.getByText(L.readyToClose)).toBeVisible();
|
||||
await expect.poll(async () => Boolean((await packet(clara, id)).vclosed)).toBe(false);
|
||||
await homeShows(page, L.colToClose, id);
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("R1: the data book and the traceability CSV answer as files", async ({ browser }) => {
|
||||
const id = pk("r1").id;
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
await expect(box.getByText(L.readyForBook)).toBeVisible();
|
||||
await expect(box.getByRole("button", { name: L.download, exact: true }).or(
|
||||
box.getByRole("link", { name: L.download, exact: true }),
|
||||
).first()).toBeVisible();
|
||||
|
||||
// Data book (PDF): the browser asks for the preview data book; the same URL answers a PDF
|
||||
const bookBtn = box
|
||||
.getByRole("button", { name: L.dataBookPdf, exact: true })
|
||||
.or(box.getByRole("link", { name: L.dataBookPdf, exact: true }))
|
||||
.first();
|
||||
const bookReq = await clickAndCatch(clara.ctx, page, bookBtn, `/api/packets/${id}/pdf/databook`);
|
||||
await expectPdf(clara, bookReq.url());
|
||||
await expect(page).toHaveURL(new RegExp(`/packets/${id}`));
|
||||
|
||||
// Traceability (CSV): a link to the CSV; text/csv with the record's first line
|
||||
const csvLink = box
|
||||
.getByRole("link", { name: L.csv, exact: true })
|
||||
.or(box.getByRole("button", { name: L.csv, exact: true }))
|
||||
.first();
|
||||
const csvReq = await clickAndCatch(clara.ctx, page, csvLink, `/api/packets/${id}/export/traceability.csv`);
|
||||
const r = await fetchOk(clara, csvReq.url());
|
||||
expect(r.headers()["content-type"]).toContain("text/csv");
|
||||
const text = await r.text();
|
||||
expect(text.split("\r\n")[0]).toBe("PESCO weld & heat traceability record");
|
||||
expect(text).toContain("BOM item");
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("R1: the print menu lists the kinds; a traveler print is logged and shows on the History tab", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const id = pk("r1").id;
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
await expect(box.getByText(L.readyForBook)).toBeVisible();
|
||||
|
||||
await box.getByRole("button", { name: L.printOpen, exact: true }).click();
|
||||
let sheet = page.getByRole("dialog", { name: L.printSheet, exact: true });
|
||||
await expect(sheet).toBeVisible();
|
||||
for (const row of L.printRows) await expect(sheet.getByRole("button", { name: row, exact: true })).toBeVisible();
|
||||
// a hand packet with no change orders has no void pages
|
||||
await expect(sheet.getByRole("button", { name: L.voidRow, exact: true })).toHaveCount(0);
|
||||
await expect(sheet.getByText(L.printFooter)).toBeVisible();
|
||||
await sheet.getByRole("button", { name: L.cancel, exact: true }).click();
|
||||
await expect(sheet).toBeHidden();
|
||||
|
||||
// a row opens the print in a new tab and closes the sheet; the server logs it
|
||||
const before = await printCount(clara, id, "traveler");
|
||||
await box.getByRole("button", { name: L.printOpen, exact: true }).click();
|
||||
sheet = page.getByRole("dialog", { name: L.printSheet, exact: true });
|
||||
await expect(sheet).toBeVisible();
|
||||
await clickAndCatch(
|
||||
clara.ctx,
|
||||
page,
|
||||
sheet.getByRole("button", { name: L.printRows[0], exact: true }),
|
||||
`/api/packets/${id}/print/traveler`,
|
||||
);
|
||||
await expect(sheet).toBeHidden();
|
||||
await expect.poll(() => printCount(clara, id, "traveler")).toBe(before + 1);
|
||||
|
||||
// the same print through the API: a PDF, and one more log row
|
||||
await expectPdf(clara, `/api/packets/${id}/print/traveler`);
|
||||
await expect.poll(() => printCount(clara, id, "traveler")).toBe(before + 2);
|
||||
const rows = await prints(clara, id);
|
||||
expect(rows[0].kind).toBe("traveler");
|
||||
expect(rows[0].what).toBe("Job traveler");
|
||||
expect(Number(rows[0].pages)).toBeGreaterThan(0);
|
||||
|
||||
// the Work orders tab has the traveler print and the traveler PDF
|
||||
await packetTab(page, L.tabWorkOrders).click();
|
||||
await expect(page.getByRole("button", { name: L.printTraveler, exact: true })).toBeVisible();
|
||||
const trav = page.getByRole("link", { name: L.travelerPdf, exact: true });
|
||||
await expect(trav).toBeVisible();
|
||||
await expect(trav).toHaveAttribute("href", new RegExp(`/api/packets/${id}/pdf/traveler$`));
|
||||
|
||||
// the History tab lists the printed copies
|
||||
await packetTab(page, L.tabHistory).click();
|
||||
const hist = page.getByRole("region", { name: L.printsSection });
|
||||
await expect(hist).toBeVisible();
|
||||
await expect(hist.getByText(/Job traveler/).first()).toBeVisible();
|
||||
await expect(hist.getByText(L.nothingPrinted)).toHaveCount(0);
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("R1: filed in the data book with the PIN; the seal holds and the filed data book downloads", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const id = pk("r1").id;
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
const file = box.getByRole("button", { name: L.fileIt, exact: true });
|
||||
await expect(file).toBeEnabled();
|
||||
await file.click();
|
||||
await signWithPin(page, PIN);
|
||||
await expect(signSheet(page)).toBeHidden();
|
||||
await expect.poll(() => statusOf(clara, id)).toBe("filed");
|
||||
await expect(box.getByText(L.filed).first()).toBeVisible();
|
||||
await expect(box.getByText(L.sealIntact).first()).toBeVisible();
|
||||
await expect(box.getByText(L.sealBroken)).toHaveCount(0);
|
||||
|
||||
const p = await packet(clara, id);
|
||||
expect(p.seals).toBe(1);
|
||||
expect(p.databook_at).toBeTruthy();
|
||||
expect(Number(p.seal.databook.pages)).toBeGreaterThan(0);
|
||||
expect(p.seal.broken_at ?? null).toBeNull();
|
||||
const seal = await api(clara, "get", `/api/packets/${id}/seal`);
|
||||
expect(seal.valid).toBe(true);
|
||||
const dl = await fetchOk(clara, String(p.seal.databook.download_url));
|
||||
expect(dl.headers()["content-type"]).toContain("application/pdf");
|
||||
expect(dl.headers()["content-disposition"] ?? "").toMatch(/^attachment/);
|
||||
expect((await dl.body()).subarray(0, 4).toString("latin1")).toBe("%PDF");
|
||||
// the export endpoint hands back the stored copy once filed
|
||||
await expectPdf(clara, `/api/packets/${id}/pdf/databook`);
|
||||
|
||||
// the home lists it as filed
|
||||
await openClericalHome(page, PROJECT);
|
||||
const card = cardOf(column(page, L.colFiled), id);
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText(L.filedPill);
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("R1: admin.ann reopens with a reason and her password; clara files it again on a second seal", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const id = pk("r1").id;
|
||||
const ann = await actor(browser, "admin.ann");
|
||||
const page = ann.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
await expect(box.getByText(L.filed).first()).toBeVisible();
|
||||
await box.getByRole("button", { name: L.reopenIt, exact: true }).click();
|
||||
const reason = page.getByRole("dialog", { name: L.reopenSheet, exact: true });
|
||||
await expect(reason).toBeVisible();
|
||||
const field = reason.getByRole("textbox").first();
|
||||
await field.fill("Wrong heat number filed on item 2");
|
||||
await field.press("Enter");
|
||||
await signWithPassword(page, "admin.ann");
|
||||
await expect(signSheet(page)).toBeHidden();
|
||||
await expect.poll(() => statusOf(ann, id)).toBe("complete");
|
||||
await expect(box.getByText(L.reopenedLine).first()).toBeVisible();
|
||||
await expect(box.getByText(L.reopenedLine).first()).toContainText("Wrong heat number filed on item 2");
|
||||
|
||||
let seal = await api(ann, "get", `/api/packets/${id}/seal`);
|
||||
expect((seal.seals as Json[]).length).toBe(1);
|
||||
expect((seal.seals as Json[])[0].broken_reason).toBe("Wrong heat number filed on item 2");
|
||||
const reopened = await packet(ann, id);
|
||||
expect(reopened.seal.broken_at).toBeTruthy();
|
||||
expect(reopened.seal.databook).toBeTruthy();
|
||||
|
||||
// clara files it again through the API (the UI path is covered above)
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
if (!(await packet(clara, id)).vclosed) await api(clara, "post", `/api/packets/${id}/vclosed`, { closed: true });
|
||||
const out = await stepApi(clara, id, "file", { sign: sign("clerk.clara") });
|
||||
expect(out.status).toBe("filed");
|
||||
expect(out.seals).toBe(2);
|
||||
seal = await api(clara, "get", `/api/packets/${id}/seal`);
|
||||
expect((seal.seals as Json[]).length).toBe(2);
|
||||
expect(seal.valid).toBe(true);
|
||||
await openPacket(clara.page, id);
|
||||
await expect(panel(clara.page).getByText(L.sealIntact).first()).toBeVisible();
|
||||
await closeAll(ann, clara);
|
||||
});
|
||||
|
||||
test("ITP: signed off through the API, its PDF is filed in the unit folder and linked on the ITP page", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const unitId = Number(proj.unit.id);
|
||||
const itp = await signedOffItpApi(quinn, unitId);
|
||||
const no = String(itp.no);
|
||||
expect(itp.signoff.asset_id).toBeTruthy();
|
||||
|
||||
// the ITP PDF, built now
|
||||
await expectPdf(quinn, `/api/itps/${itp.id}/pdf`);
|
||||
// the signed copy filed at sign-off
|
||||
await expectPdf(quinn, `/_blob/${itp.signoff.asset_id}`);
|
||||
|
||||
// the unit folder lists the automatic ITP document
|
||||
const folder = await api(quinn, "get", `/api/qc/folders/units/${unitId}`);
|
||||
const titles = ((folder.docs ?? []) as Json[]).map((d) => String(d.title));
|
||||
expect(titles).toContain(L.signedOffDoc(no));
|
||||
await quinn.page.goto(`/qc/units/${unitId}`);
|
||||
await expect(quinn.page.getByText(L.signedOffDoc(no)).first()).toBeVisible();
|
||||
|
||||
// the ITP page has the PDF button and the signed PDF link
|
||||
await quinn.page.goto(`/qc/units/${unitId}/itp`);
|
||||
await expect(quinn.page.getByRole("heading", { level: 1 })).toHaveText(no);
|
||||
const pdfBtn = quinn.page
|
||||
.getByRole("button", { name: L.itpPdf, exact: true })
|
||||
.or(quinn.page.getByRole("link", { name: L.itpPdf, exact: true }))
|
||||
.first();
|
||||
const req = await clickAndCatch(quinn.ctx, quinn.page, pdfBtn, `/api/itps/${itp.id}/pdf`);
|
||||
await expectPdf(quinn, req.url());
|
||||
const signed = quinn.page.getByRole("link", { name: L.signedPdf, exact: true });
|
||||
await expect(signed).toBeVisible();
|
||||
await expect(signed).toHaveAttribute("href", new RegExp(`/_blob/${itp.signoff.asset_id}`));
|
||||
await closeAll(quinn);
|
||||
});
|
||||
|
||||
test("api: print guards and a signed-out read", async ({ browser }) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const id = pk("r2").id;
|
||||
// R2 has no void pages
|
||||
const v = await clara.page.request.get(`/api/packets/${id}/print/void`);
|
||||
expect(v.status()).toBe(404);
|
||||
expect(((await v.json()) as Json).code).toBe("nothing_to_print");
|
||||
// a document that is not one of the packet's is refused
|
||||
const d = await clara.page.request.get(`/api/packets/${id}/print/doc?asset=00000000-0000-0000-0000-000000000000`);
|
||||
expect(d.status()).toBe(404);
|
||||
// nobody signed in: 401
|
||||
const anon = await browser.newContext();
|
||||
const r = await anon.request.get(new URL(`/api/packets/${id}/pdf/traveler`, clara.page.url()).toString());
|
||||
expect(r.status()).toBe(401);
|
||||
await anon.close();
|
||||
await closeAll(clara);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { expect, type APIResponse, type BrowserContext, type Locator, type Page } from "@playwright/test";
|
||||
import { api, packet, sign, stepApi, type Actor } from "./shophelpers";
|
||||
import { atQcApi, type Crew, type QcPacket } from "./qchelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
|
||||
// Helpers for the phase 7 close and file scenarios: a project with one unit, packets taken to complete routed to
|
||||
// the data book through the API, the ITP signed off through the API, and the UI labels the close spec depends on
|
||||
// (L). The labels are the "Labels" section of the phase 7 design contract and the components as agent B built
|
||||
// them. The main session adjusts them here in one place when the components differ.
|
||||
|
||||
export const L = {
|
||||
// the Clerical home (screens/clerical/ClericalHome.tsx, boards.ts)
|
||||
clericalHead: "Clerical",
|
||||
findJob: "Find a job",
|
||||
colToClose: "Ready to close",
|
||||
colToBook: "Ready for data book",
|
||||
colFiled: "Filed",
|
||||
kpiToClose: (n: number) => `Ready to close: ${n}`, // the kpi tile's aria-label
|
||||
filedPill: "Filed in data book", // the status label of filed; the card's aria-label ends with it
|
||||
// the close and file panel
|
||||
panel: "Close and file", // section aria-label (role region)
|
||||
readyToClose: "Back from the shop, ready to close.",
|
||||
readyForBook: "Ready for the data book.",
|
||||
filed: "Filed in the data book",
|
||||
sealIntact: "Seal intact",
|
||||
sealBroken: "Seal does not match the record",
|
||||
sealUnknown: "Seal unknown",
|
||||
reopenedLine: /^Reopened by .+: .+\. The earlier seal is kept as broken\./,
|
||||
blocked: "Close the job in VISUAL first.",
|
||||
visualLine: "visual-line", // testid of the VISUAL observed line
|
||||
sealState: "seal-state", // testid of the seal line
|
||||
reopened: "reopened", // testid of the reopen line
|
||||
// panel buttons
|
||||
jobClosed: "Job closed in VISUAL",
|
||||
moveBack: "Not closed in VISUAL, move back",
|
||||
fileIt: "File in the data book",
|
||||
fileAnyway: "File anyway",
|
||||
dataBookPdf: "Data book (PDF)",
|
||||
download: "Download",
|
||||
csv: "Traceability (CSV)",
|
||||
printOpen: "Print…", // U+2026, as the contract writes it
|
||||
reopenIt: "Reopen (IT)",
|
||||
// sheets
|
||||
printSheet: "Print",
|
||||
openWosSheet: "File with open work orders?",
|
||||
reopenSheet: "Reopen a filed packet",
|
||||
// print rows (each a button whose name starts with the row title)
|
||||
printRows: ["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)", "Data book"],
|
||||
voidRow: "Superseded drawings (VOID)",
|
||||
printFooter: /Every printed page is stamped with who printed it and when/,
|
||||
// other print entry points
|
||||
printTraveler: "Print traveler",
|
||||
travelerPdf: "Traveler (PDF)",
|
||||
printSheetBtn: "Print this sheet",
|
||||
// the packet tabs
|
||||
tabWorkOrders: /^Work orders/,
|
||||
tabHistory: /^History/,
|
||||
// the History tab
|
||||
printsSection: "Printed copies and exports", // region
|
||||
nothingPrinted: "Nothing printed yet.",
|
||||
// the ITP page
|
||||
itpPdf: "ITP (PDF)",
|
||||
signedPdf: "Signed PDF",
|
||||
signedOffDoc: (no: string) => `${no} — signed off`, // the automatic Q.C. folder document, em dash is the server's text
|
||||
// shared
|
||||
cancel: "Cancel",
|
||||
statusPill: "status-pill", // testid (phase 5)
|
||||
} as const;
|
||||
|
||||
/** A regex that matches a text literally from its start. */
|
||||
export function starts(s: string): RegExp {
|
||||
return new RegExp(`^${s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
|
||||
}
|
||||
|
||||
export interface CloseProject {
|
||||
id: number;
|
||||
number: string;
|
||||
unit: Json;
|
||||
}
|
||||
|
||||
/** As clerk.clara: a project with a customer and a PO, and one unit. */
|
||||
export async function makeCloseProject(clara: Actor, number: string, name = "Close and file skid"): Promise<CloseProject> {
|
||||
const project = await api(
|
||||
clara,
|
||||
"post",
|
||||
"/api/projects",
|
||||
{ number, name, customer: "ACME", po: "7731" },
|
||||
{ status: 201 },
|
||||
);
|
||||
const units = (await api(clara, "post", `/api/projects/${project.id}/units`, {}, { status: 201 })) as unknown as Json[];
|
||||
expect(units.length).toBe(1);
|
||||
return { id: Number(project.id), number: String(project.number), unit: units[0] as Json };
|
||||
}
|
||||
|
||||
/** The ten Q.C. check keys (tests/server/qchelpers.py QC_KEYS). */
|
||||
export const QC_KEYS = [
|
||||
"db_heats",
|
||||
"db_mtr",
|
||||
"db_weldmap",
|
||||
"db_wps",
|
||||
"db_ck",
|
||||
"ph_dim",
|
||||
"ph_heat",
|
||||
"ph_stamp",
|
||||
"ph_clean",
|
||||
"ph_qty",
|
||||
] as const;
|
||||
|
||||
/** As qc.quinn through the API: inspector and stamp, every check, every live weld accepted. */
|
||||
export async function finishInspectionApi(quinn: Actor, id: number): Promise<Json> {
|
||||
let p = await api(quinn, "patch", `/api/packets/${id}/qc`, { set: { inspector: "Quinn QC", stamp: "Q1" }, base: {} });
|
||||
for (const k of QC_KEYS) p = await api(quinn, "put", `/api/packets/${id}/qc/checks/${k}`, { done: true });
|
||||
const welds = (p.welds as Json[]).filter((w) => !w.removed_at && !w.removed).map((w) => Number(w.weld_no));
|
||||
for (const no of welds) p = await api(quinn, "put", `/api/packets/${id}/qc/welds/${no}`, { result: "accept" });
|
||||
expect(p.qc.missing, JSON.stringify(p.qc.missing)).toEqual([]);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A packet taken through the shop and Q.C. to status complete, routed to the data book, all through the API. */
|
||||
export async function atCompleteApi(crew: Crew, quinn: Actor, unit: Json, title: string, stall: string): Promise<QcPacket> {
|
||||
const p = await atQcApi(crew, unit, title, stall, "Bay 7");
|
||||
await finishInspectionApi(quinn, p.id);
|
||||
const out = await stepApi(quinn, p.id, "qc_accept", { routed_to: "databook", sign: sign("qc.quinn") });
|
||||
expect(out.status).toBe("complete");
|
||||
expect(out.routed_to).toBe("databook");
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A board column of the Clerical home by its heading (the heading ends with the card count). */
|
||||
export function column(page: Page, heading: string): Locator {
|
||||
return page.getByRole("region", { name: new RegExp(`^${heading}\\s*\\d*$`) }).first();
|
||||
}
|
||||
|
||||
/** A packet card by its link. */
|
||||
export function cardOf(scope: Locator, id: number): Locator {
|
||||
return scope.locator(`a[href="/packets/${id}"]`).first();
|
||||
}
|
||||
|
||||
/** The Clerical home, narrowed to one project through the find box so the numbers count only this run. */
|
||||
export async function openClericalHome(page: Page, project: string): Promise<void> {
|
||||
await page.goto("/clerical");
|
||||
await expect(page.getByRole("heading", { level: 1, name: L.clericalHead })).toBeVisible();
|
||||
await page.getByRole("searchbox", { name: L.findJob }).fill(project);
|
||||
}
|
||||
|
||||
/** The close and file panel. */
|
||||
export function panel(page: Page): Locator {
|
||||
return page.getByRole("region", { name: L.panel, exact: true });
|
||||
}
|
||||
|
||||
/** Open a packet page and wait for its heading. */
|
||||
export async function openPacket(page: Page, id: number): Promise<void> {
|
||||
await page.goto(`/packets/${id}`);
|
||||
await expect(page.getByRole("heading").first()).toBeVisible();
|
||||
}
|
||||
|
||||
/** A packet's current state on the server, for expect.poll. */
|
||||
export async function field(a: Actor, id: number, pick: (p: Json) => unknown): Promise<unknown> {
|
||||
return pick(await packet(a, id));
|
||||
}
|
||||
|
||||
/** What a click asked the browser to open: the URL, plus the server's answer fetched through the page's session. */
|
||||
export interface Opened {
|
||||
url: () => string;
|
||||
response: APIResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click `target` and catch the URL it opens, then fetch it through the page's own session so the server sees the
|
||||
* request and logs it. A link is read from its href (a download link would start a download, not a navigation).
|
||||
* A button that calls window.open is caught through a stub, because a noopener tab in headless Chromium does not
|
||||
* report its request on the context. The URL must contain `part`.
|
||||
*/
|
||||
export async function clickAndCatch(ctx: BrowserContext, page: Page, target: Locator, part: string): Promise<Opened> {
|
||||
await expect(target).toBeVisible();
|
||||
let url: string | null = null;
|
||||
const href = await target.getAttribute("href");
|
||||
if (href) {
|
||||
url = new URL(href, page.url()).toString();
|
||||
} else {
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __opened: string[]; open: (u?: string | URL) => Window | null };
|
||||
w.__opened = [];
|
||||
w.open = (u?: string | URL) => {
|
||||
w.__opened.push(String(u ?? ""));
|
||||
return null;
|
||||
};
|
||||
});
|
||||
await target.click();
|
||||
await expect
|
||||
.poll(async () => (await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened)).length, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
const opened = await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened);
|
||||
url = new URL(opened[0] ?? "", page.url()).toString();
|
||||
}
|
||||
expect(url, `opened ${url}`).toContain(part);
|
||||
const response = await page.request.get(url);
|
||||
for (const other of ctx.pages()) if (other !== page) await other.close();
|
||||
const fixed = url;
|
||||
return { url: () => fixed, response };
|
||||
}
|
||||
|
||||
/** GET a URL through the actor's API context and check the status. */
|
||||
export async function fetchOk(a: Actor, url: string): Promise<APIResponse> {
|
||||
const r = await a.page.request.get(url);
|
||||
expect(r.status(), `GET ${url}: ${r.status()}`).toBe(200);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** GET a PDF through the actor's API context: 200, application/pdf, the body starts with %PDF. */
|
||||
export async function expectPdf(a: Actor, url: string): Promise<Buffer> {
|
||||
const r = await fetchOk(a, url);
|
||||
expect(r.headers()["content-type"]).toContain("application/pdf");
|
||||
const body = await r.body();
|
||||
expect(body.subarray(0, 4).toString("latin1")).toBe("%PDF");
|
||||
return body;
|
||||
}
|
||||
|
||||
/** The packet's print log rows, newest first (GET /api/packets/{id}/prints). */
|
||||
export async function prints(a: Actor, id: number): Promise<Json[]> {
|
||||
const out = await api(a, "get", `/api/packets/${id}/prints`);
|
||||
return (out.prints ?? []) as Json[];
|
||||
}
|
||||
|
||||
/** How many print log rows of `kind` the packet has. */
|
||||
export async function printCount(a: Actor, id: number, kind: string): Promise<number> {
|
||||
return (await prints(a, id)).filter((r) => r.kind === kind).length;
|
||||
}
|
||||
|
||||
/** The ITP of a unit, or null. */
|
||||
export async function itpOf(a: Actor, unitId: number): Promise<Json | null> {
|
||||
const out = await api(a, "get", `/api/units/${unitId}/itp`);
|
||||
return out.itp === null ? null : out;
|
||||
}
|
||||
|
||||
/**
|
||||
* As qc.quinn through the API: start the unit's ITP, approve every open line, sign every block with the
|
||||
* password, sign off. Returns the signed-off ITP.
|
||||
*/
|
||||
export async function signedOffItpApi(quinn: Actor, unitId: number): Promise<Json> {
|
||||
let itp = (await itpOf(quinn, unitId)) ?? (await api(quinn, "post", `/api/units/${unitId}/itp`, {}));
|
||||
for (const line of Object.values(itp.lines as Record<string, Json>)) {
|
||||
if (line.result === null || line.result === undefined)
|
||||
itp = await api(quinn, "post", `/api/itps/${itp.id}/lines/${line.key}/sign`, {});
|
||||
}
|
||||
for (const b of itp.blocks as Json[]) {
|
||||
if (b.signed) continue;
|
||||
itp = await api(quinn, "post", `/api/itps/${itp.id}/blocks/${b.key}/sign`, { sign: sign("qc.quinn") });
|
||||
}
|
||||
itp = await api(quinn, "post", `/api/itps/${itp.id}/signoff`, { sign: sign("qc.quinn") });
|
||||
expect(itp.status).toBe("complete");
|
||||
return itp;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Helpers for the phase 7 tests: a packet at complete on either route, the feed state written by SQL, a rich
|
||||
packet for the data book (two drawing sheets, a void sheet, a heat table, two welds, MTRs, a short with a receipt,
|
||||
a packet document and a Q.C. folder document), and PDF text helpers.
|
||||
|
||||
Built on shophelpers and qchelpers (imported, never edited). Nothing here reads VISUAL or M-Files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import secrets
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from fakedir import FakeDirectory
|
||||
from pkthelpers import step
|
||||
from pshelpers import app_conn
|
||||
from psycopg.types.json import Jsonb
|
||||
from qchelpers import accept, add_packet_doc, at_qc, finish_inspection, make_qc_doc, upload_doc
|
||||
from shophelpers import (
|
||||
SIGN,
|
||||
Shop,
|
||||
draft,
|
||||
finish_stage,
|
||||
im,
|
||||
line_of,
|
||||
make_unit,
|
||||
ok,
|
||||
open_shop,
|
||||
release,
|
||||
start,
|
||||
submit,
|
||||
to_station,
|
||||
to_warehouse,
|
||||
)
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
PDF = "application/pdf"
|
||||
HEATS = {1: "HT-10", 2: "HT-20", 3: "HT-30"}
|
||||
RECEIPT_HEAT = "HT-31"
|
||||
|
||||
|
||||
def open_close_shop(settings: Settings, fake_dir: FakeDirectory) -> Iterator[Shop]:
|
||||
yield from open_shop(settings, fake_dir)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- PDFs and files
|
||||
|
||||
|
||||
def pdf_reader(data: bytes) -> Any:
|
||||
from pypdf import PdfReader
|
||||
|
||||
return PdfReader(io.BytesIO(data))
|
||||
|
||||
|
||||
def pdf_pages(data: bytes) -> int:
|
||||
return len(pdf_reader(data).pages)
|
||||
|
||||
|
||||
def page_texts(data: bytes) -> list[str]:
|
||||
return [p.extract_text() or "" for p in pdf_reader(data).pages]
|
||||
|
||||
|
||||
def pdf_text(data: bytes) -> str:
|
||||
"""The text of every page, joined with newlines."""
|
||||
return "\n".join(page_texts(data))
|
||||
|
||||
|
||||
def flat(text: str) -> str:
|
||||
"""Whitespace folded to single spaces (text extraction breaks lines where the layout wraps)."""
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def tiny_png(color: tuple[int, int, int] | None = None) -> bytes:
|
||||
from PIL import Image
|
||||
|
||||
buf = io.BytesIO()
|
||||
c = color or (secrets.randbelow(256), secrets.randbelow(256), secrets.randbelow(256))
|
||||
Image.new("RGB", (4, 4), c).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def is_pdf(data: bytes) -> bool:
|
||||
return data[:5] == b"%PDF-"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- SQL
|
||||
|
||||
|
||||
def q1(settings: Settings, sql: str, params: Any = ()) -> Any:
|
||||
with app_conn(settings) as conn:
|
||||
return conn.execute(sql, params).fetchone()
|
||||
|
||||
|
||||
def qall(settings: Settings, sql: str, params: Any = ()) -> list[Any]:
|
||||
with app_conn(settings) as conn:
|
||||
return list(conn.execute(sql, params).fetchall())
|
||||
|
||||
|
||||
def set_feed_legs(
|
||||
settings: Settings,
|
||||
packet_id: int,
|
||||
legs: list[dict[str, Any]] | None,
|
||||
*,
|
||||
closed: bool = False,
|
||||
observed: bool = True,
|
||||
) -> None:
|
||||
"""Simulate the feed: the packet becomes a VISUAL packet (source_kind visual, a visual_key), packet_visual_state
|
||||
holds the legs as the feed writes them ({key: "base/lot/split/sub", seq, st}) and visual_closed_at is set when
|
||||
`closed`. `legs` None writes no state row (the feed has not looked yet)."""
|
||||
with app_conn(settings) as conn:
|
||||
conn.execute(
|
||||
"UPDATE packets SET source_kind = 'visual', visual_key = coalesce(visual_key, %s),"
|
||||
" visual_closed_at = CASE WHEN %s THEN now() END WHERE id = %s",
|
||||
(f"test-{packet_id}-{secrets.token_hex(3)}", closed, packet_id),
|
||||
)
|
||||
if legs is None:
|
||||
conn.execute("DELETE FROM packet_visual_state WHERE packet_id = %s", (packet_id,))
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT INTO packet_visual_state (packet_id, legs, observed_at)"
|
||||
" VALUES (%s, %s, CASE WHEN %s THEN now() END)"
|
||||
" ON CONFLICT (packet_id) DO UPDATE SET legs = EXCLUDED.legs, observed_at = EXCLUDED.observed_at",
|
||||
(packet_id, Jsonb(legs), observed),
|
||||
)
|
||||
|
||||
|
||||
def leg(base: str, lot: str = "001", st: str = "R", seq: int = 10, split: str = "0", sub: str = "0") -> dict[str, Any]:
|
||||
"""One leg as the feed stores it, with `status` beside `st` (the contract names the code `status`)."""
|
||||
return {"key": f"{base}/{lot}/{split}/{sub}", "seq": seq, "st": st, "status": st}
|
||||
|
||||
|
||||
def void_page_by_sql(settings: Settings, packet_id: int, of_n: int = 1, co_no: int = 1) -> int:
|
||||
"""A change order (approved, minor, no hold) and a void copy of sheet `of_n` as a new page row, the shape a
|
||||
change order approval leaves (void_co_id, void_at, void_by, void_new_n). Returns the void page's n."""
|
||||
with app_conn(settings) as conn:
|
||||
by = conn.execute("SELECT id FROM users WHERE sam_account = 'clerk.clara'").fetchone()[0]
|
||||
co = conn.execute(
|
||||
"INSERT INTO change_orders (packet_id, co_no, origin, review, minor, hold, reason, issued_by,"
|
||||
" approved_at, approved_by) VALUES (%s, %s, 'manual', 'approved', true, false, 'Nozzle moved', %s,"
|
||||
" now(), %s) RETURNING id",
|
||||
(packet_id, co_no, by, by),
|
||||
).fetchone()[0]
|
||||
src = conn.execute(
|
||||
"SELECT asset_id, pn, dwg, rev FROM packet_pages WHERE packet_id = %s AND n = %s", (packet_id, of_n)
|
||||
).fetchone()
|
||||
n = conn.execute("SELECT max(n) + 1 FROM packet_pages WHERE packet_id = %s", (packet_id,)).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO packet_pages (packet_id, n, type, asset_id, pn, dwg, rev, void_co_id, void_at, void_by,"
|
||||
" void_new_n) VALUES (%s, %s, 'drawing', %s, %s, %s, %s, %s, now(), %s, %s)",
|
||||
(packet_id, n, src[0], src[1], src[2], "A", co, by, of_n),
|
||||
)
|
||||
return int(n)
|
||||
|
||||
|
||||
def remove_by_sql(settings: Settings, packet_id: int) -> None:
|
||||
with app_conn(settings) as conn:
|
||||
conn.execute(
|
||||
"UPDATE packets SET removed_at = now(), removed_reason = 'test' WHERE id = %s",
|
||||
(packet_id,),
|
||||
)
|
||||
|
||||
|
||||
def delete_asset_file(settings: Settings, asset_id: str) -> None:
|
||||
"""Remove a stored file from disk (the asset row stays): a part that cannot be read."""
|
||||
from packetstation.domain import assets
|
||||
|
||||
row = q1(settings, "SELECT storage_key FROM assets WHERE id = %s", (asset_id,))
|
||||
assets.path_of(settings, str(row[0])).unlink()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- packets
|
||||
|
||||
|
||||
def at_complete(shop: Shop, unit_id: int | None = None, route: str = "databook", welds: int = 2) -> dict[str, Any]:
|
||||
"""A shop packet taken through Q.C. and accepted with `route` (databook or shipping): status complete."""
|
||||
pkt = finish_inspection(shop, at_qc(shop, unit_id, welds=welds))
|
||||
out = ok(accept(shop, pkt, route))
|
||||
assert out["status"] == "complete" and out["routed_to"] == route, out["status"]
|
||||
return out
|
||||
|
||||
|
||||
def vclose(shop: Shop, pkt: dict[str, Any], by: str = "clerk.clara") -> dict[str, Any]:
|
||||
return ok(shop(by).post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True}))
|
||||
|
||||
|
||||
def file_packet(shop: Shop, pkt: dict[str, Any], by: str = "clerk.clara", **body: Any) -> Any:
|
||||
return step(shop(by), pkt, "file", sign=SIGN, **body)
|
||||
|
||||
|
||||
def filed(shop: Shop, unit_id: int | None = None) -> dict[str, Any]:
|
||||
pkt = vclose(shop, at_complete(shop, unit_id))
|
||||
out = ok(file_packet(shop, pkt))
|
||||
assert out["status"] == "filed"
|
||||
return out
|
||||
|
||||
|
||||
def attach_mtr(shop: Shop, pkt: dict[str, Any], item: int, data: bytes, name: str, mime: str, by: str) -> Any:
|
||||
"""Upload an MTR (purpose mtr) and set it on a BOM line. Returns the packet answer."""
|
||||
a = upload_doc(shop(by), name, data, mime, purpose="mtr")
|
||||
cur = line_of(pkt, item)
|
||||
return ok(
|
||||
shop(by).patch(
|
||||
f"/api/packets/{pkt['id']}/bom/{item}", json={"mtr_asset_id": a["id"]}, headers=im(cur["row_version"])
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def add_doc(shop: Shop, pkt: dict[str, Any], title: str = "RT report W1-W2", by: str = "qc.quinn", **kw: Any) -> Any:
|
||||
return ok(add_packet_doc(shop(by), pkt["id"], title=title, **kw))
|
||||
|
||||
|
||||
def rich_complete(shop: Shop, *, itp_doc: bool = False, mtrs: bool = True) -> dict[str, Any]:
|
||||
"""The data book packet, complete on the data book route:
|
||||
|
||||
- drawing D-5001 rev B, two sheets, BOM items 1 to 3, two work orders;
|
||||
- in the warehouse: items 1 and 2 pulled with heats HT-10 and HT-20; item 3 short 2 of 4 (On order) with heat
|
||||
HT-30, then 1 pc received with heat HT-31 (one still open); an MTR PDF on item 1 and an MTR PNG on item 2
|
||||
(when `mtrs`); the heat table on sheet 1;
|
||||
- the shop: two welds, the checklist and the filler, submitted, sent to Q.C., inspected and accepted;
|
||||
- a packet document (RT report) and a Q.C. folder document on the unit (WPS / PQR, or ITP when `itp_doc`);
|
||||
- sheet 1 voided by change order 1 as a new sheet 3.
|
||||
|
||||
Returns the fresh packet read. `pkt["_unit_id"]` and `pkt["_project_id"]` carry the ids."""
|
||||
project_id, unit_id = make_unit(shop.settings)
|
||||
pkt = to_warehouse(shop, draft(shop, unit_id, pages=2))
|
||||
wade = shop("wh.wade")
|
||||
pid = pkt["id"]
|
||||
for item in (1, 2):
|
||||
cur = line_of(pkt, item)
|
||||
pkt = ok(wade.patch(f"/api/packets/{pid}/bom/{item}", json={"pulled": True}, headers=im(cur["row_version"])))
|
||||
cur = line_of(pkt, item)
|
||||
pkt = ok(
|
||||
wade.patch(f"/api/packets/{pid}/bom/{item}", json={"heat": HEATS[item]}, headers=im(cur["row_version"]))
|
||||
)
|
||||
pkt = ok(
|
||||
wade.post(
|
||||
f"/api/packets/{pid}/bom/3/short",
|
||||
json={"qty": 2, "reason": "On order", "heat": HEATS[3], "expect_open": False},
|
||||
)
|
||||
)
|
||||
pkt = ok(wade.post(f"/api/packets/{pid}/bom/3/short/receipts", json={"qty": 1, "heat": RECEIPT_HEAT}))
|
||||
if mtrs:
|
||||
pkt = attach_mtr(shop, pkt, 1, upload_bytes_pdf(), "mtr-1.pdf", PDF, "wh.wade")
|
||||
pkt = attach_mtr(shop, pkt, 2, tiny_png(), "mtr-2.png", "image/png", "wh.wade")
|
||||
pkt = ok(wade.put(f"/api/packets/{pid}/heat-table/1", json={"x": 0.6, "y": 0.6, "s": 1.0}))
|
||||
pkt = release(shop, pkt)
|
||||
pkt = start(shop, to_station(shop, pkt))
|
||||
pkt = submit(shop, finish_stage(shop, pkt, welds=2))
|
||||
pkt = ok(step(shop("sup.wes"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
|
||||
pkt = finish_inspection(shop, pkt)
|
||||
pkt = ok(accept(shop, pkt, "databook"))
|
||||
add_doc(shop, pkt)
|
||||
r = make_qc_doc(shop, unit_id=unit_id, type="ITP" if itp_doc else "WPS / PQR", title="Unit folder doc")
|
||||
assert r.status_code in (200, 201), r.text
|
||||
void_page_by_sql(shop.settings, pid, of_n=1)
|
||||
out = ok(shop("clerk.clara").get(f"/api/packets/{pid}"))
|
||||
out["_unit_id"] = unit_id
|
||||
out["_project_id"] = project_id
|
||||
return out
|
||||
|
||||
|
||||
def upload_bytes_pdf() -> bytes:
|
||||
"""A one-page PDF with its own bytes (the store dedupes equal bytes)."""
|
||||
from qchelpers import unique_pdf
|
||||
|
||||
return unique_pdf()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the builders' inputs
|
||||
|
||||
|
||||
def who(shop: Shop, sam: str = "clerk.clara") -> dict[str, Any]:
|
||||
"""The WhoRef of a test user: {id, display_name, emp_no}."""
|
||||
row = q1(shop.settings, "SELECT id, display_name, emp_no FROM users WHERE sam_account = %s", (sam,))
|
||||
return {"id": str(row[0]), "display_name": row[1], "emp_no": row[2]}
|
||||
|
||||
|
||||
def builder_conn(settings: Settings) -> Any:
|
||||
"""A connection for calling a builder directly (autocommit, app role)."""
|
||||
return app_conn(settings)
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Phase 7 close and file through the API: the VISUAL close and the filing as every role, the feed's open work
|
||||
orders (the note suffix, the 409 and the confirm), the data book filed with the seal, the reopen that keeps the
|
||||
broken seal and its data book, the refile with a second seal, a failed data book build that files nothing, and a
|
||||
hand packet with no feed state.
|
||||
|
||||
The feed state is written by SQL in the shape feed/apply.py writes it. Nothing here reads VISUAL or M-Files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from closehelpers import (
|
||||
at_complete,
|
||||
file_packet,
|
||||
filed,
|
||||
flat,
|
||||
leg,
|
||||
pdf_text,
|
||||
q1,
|
||||
qall,
|
||||
set_feed_legs,
|
||||
vclose,
|
||||
)
|
||||
from fakedir import FakeDirectory
|
||||
from pshelpers import needs_pg
|
||||
from qchelpers import at_qc
|
||||
from shophelpers import SIGN, Shop, find_log, fresh, log_rows, ok, open_shop, refused
|
||||
|
||||
from packetstation.policy import Action, check, principal_from_roles
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
MSG_VCLOSED = "Close the job in VISUAL first."
|
||||
CLERICAL = ("clerk.clara", "clerk.leo")
|
||||
NOT_CLERICAL = ("admin.ann", "wh.wade", "qc.quinn", "pm.paula", "weld.andy", "lead.lou", "sup.wes")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def oracle(sam: str, status: str, action: Action, routed_to: str = "databook", vclosed: bool = False) -> str:
|
||||
"""The expected code for SET_VCLOSED and FILE, from the phase 2 rule table: Clerical only, status complete;
|
||||
FILE on the data book route needs the VISUAL close; an admin outside the role needs an IT reason."""
|
||||
if status != "complete":
|
||||
return "wrong_status"
|
||||
if action is Action.SET_VCLOSED and routed_to != "databook":
|
||||
return "wrong_status"
|
||||
if sam not in CLERICAL:
|
||||
return "it_reason_required" if sam == "admin.ann" else "forbidden"
|
||||
if action is Action.FILE and routed_to == "databook" and not vclosed:
|
||||
return "precondition"
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the pure rule, every role and status
|
||||
|
||||
|
||||
ROLES = {
|
||||
"clerk.clara": ["clerical"],
|
||||
"clerk.leo": ["clerical_supervisor"],
|
||||
"admin.ann": ["admin"],
|
||||
"wh.wade": ["warehouse"],
|
||||
"qc.quinn": ["qc"],
|
||||
"pm.paula": ["pm"],
|
||||
"weld.andy": ["shop:03:worker"],
|
||||
"lead.lou": ["shop:03:lead"],
|
||||
"sup.wes": ["shop:03:supervisor"],
|
||||
}
|
||||
|
||||
|
||||
def test_rule_table_matches_the_oracle_for_every_role_and_status() -> None:
|
||||
import dataclasses
|
||||
|
||||
from test_policy_matrix import state
|
||||
|
||||
from packetstation.policy import STATUSES
|
||||
|
||||
for sam, roles in ROLES.items():
|
||||
p = principal_from_roles("00000000-0000-0000-0000-0000000000b0", roles)
|
||||
for status in STATUSES:
|
||||
for action in (Action.SET_VCLOSED, Action.FILE):
|
||||
for route in ("databook", "shipping"):
|
||||
for vclosed in (False, True):
|
||||
s = dataclasses.replace(state(status), routed_to=route, vclosed=vclosed)
|
||||
d = check(p, s, action)
|
||||
want = oracle(sam, status, action, route, vclosed)
|
||||
if want == "precondition":
|
||||
assert not d.ok and d.why == MSG_VCLOSED, (sam, status, action, route, d)
|
||||
elif want == "wrong_status" and action is Action.SET_VCLOSED and status == "complete":
|
||||
# SET_VCLOSED on the Shipping route: refused, whatever code the engine picks
|
||||
assert not d.ok, (sam, status, action, route)
|
||||
else:
|
||||
assert d.ok is (want == ""), (sam, status, action, route, vclosed, d.code)
|
||||
assert d.code == want, (sam, status, action, route, vclosed, d.code)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the API, every role
|
||||
|
||||
|
||||
def test_vclosed_and_file_as_every_role_at_complete(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
pid = pkt["id"]
|
||||
for sam in NOT_CLERICAL:
|
||||
r = shop(sam).post(f"/api/packets/{pid}/vclosed", json={"closed": True})
|
||||
want = oracle(sam, "complete", Action.SET_VCLOSED)
|
||||
assert r.status_code in (400, 403) and r.json()["code"] == want, (sam, r.text)
|
||||
r = file_packet(shop, fresh(shop("clerk.clara"), pid), by=sam)
|
||||
assert r.json()["code"] in (oracle(sam, "complete", Action.FILE), "signature_required"), (sam, r.text)
|
||||
assert r.status_code in (400, 403), (sam, r.text)
|
||||
# Clerical: the file waits for the VISUAL close on the data book route
|
||||
refused(file_packet(shop, pkt), 403, "precondition", MSG_VCLOSED)
|
||||
pkt = vclose(shop, pkt, by="clerk.leo")
|
||||
assert pkt["vclosed"]["by"]["display_name"] == "Leo Lead"
|
||||
out = ok(file_packet(shop, pkt, by="clerk.clara"))
|
||||
assert out["status"] == "filed"
|
||||
|
||||
|
||||
def test_vclosed_and_file_refused_outside_complete(shop: Shop) -> None:
|
||||
pkt = at_qc(shop)
|
||||
c = shop("clerk.clara")
|
||||
refused(c.post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True}), 403, "wrong_status")
|
||||
refused(file_packet(shop, pkt), 403, "wrong_status")
|
||||
done = filed(shop)
|
||||
refused(c.post(f"/api/packets/{done['id']}/vclosed", json={"closed": False}), 403, "filed")
|
||||
r = file_packet(shop, done)
|
||||
assert r.status_code == 403 and r.json()["code"] in ("filed", "wrong_status"), r.text
|
||||
|
||||
|
||||
def test_shipping_route_files_without_the_visual_close(shop: Shop) -> None:
|
||||
pkt = at_complete(shop, route="shipping")
|
||||
r = shop("clerk.clara").post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True})
|
||||
assert r.status_code in (403, 409) and r.json()["code"] in ("wrong_status", "precondition"), r.text
|
||||
out = ok(file_packet(shop, pkt))
|
||||
assert out["status"] == "filed" and out["vclosed"] is None
|
||||
assert out["seal"]["databook"]["pages"] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the feed's open work orders
|
||||
|
||||
|
||||
def test_vclose_check_and_the_open_wo_note(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
pid = pkt["id"]
|
||||
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", st="R")])
|
||||
view = fresh(shop("clerk.clara"), pid)
|
||||
vc = view["vclose_check"]
|
||||
assert vc["open_wos"] == ["L670002/001"] and vc["closed_at"] is None and vc["observed_at"] is not None
|
||||
assert vc["agrees"] is True # neither closed
|
||||
pkt = vclose(shop, view)
|
||||
assert pkt["vclose_check"]["agrees"] is False
|
||||
row = find_log(shop("clerk.clara"), pid, "Job closed in VISUAL")
|
||||
assert row["note"] == "L670001, L670002 · VISUAL still shows open: L670002/001", row
|
||||
|
||||
|
||||
def test_vclose_check_closed_by_the_feed(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
pid = pkt["id"]
|
||||
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", st="C")], closed=True)
|
||||
vc = fresh(shop("clerk.clara"), pid)["vclose_check"]
|
||||
assert vc["open_wos"] == [] and vc["closed_at"] is not None and vc["agrees"] is False
|
||||
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
|
||||
assert pkt["vclose_check"]["agrees"] is True
|
||||
assert find_log(shop("clerk.clara"), pid, "Job closed in VISUAL")["note"] == "L670001, L670002"
|
||||
out = ok(file_packet(shop, pkt))
|
||||
assert out["seal"]["open_wos"] is None
|
||||
|
||||
|
||||
def test_vclose_check_before_the_feed_looked(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
set_feed_legs(shop.settings, pkt["id"], None)
|
||||
vc = fresh(shop("clerk.clara"), pkt["id"])["vclose_check"]
|
||||
assert vc is not None and vc["observed_at"] is None and vc["open_wos"] == []
|
||||
|
||||
|
||||
def test_file_with_open_work_orders_needs_the_confirm(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
pid = pkt["id"]
|
||||
set_feed_legs(shop.settings, pid, [leg("L670001", st="R"), leg("L670002", lot="7A", st="R")])
|
||||
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
|
||||
sigs = q1(shop.settings, "SELECT count(*) FROM signatures")[0]
|
||||
body = refused(file_packet(shop, pkt), 409, "open_work_orders")
|
||||
assert body["work_orders"] == ["L670001/001", "L670002/7A"]
|
||||
assert body["detail"] == (
|
||||
"VISUAL still shows 2 work orders open on this packet: L670001/001, L670002/7A. File anyway?"
|
||||
)
|
||||
# nothing written, no signature taken
|
||||
assert q1(shop.settings, "SELECT count(*) FROM signatures")[0] == sigs
|
||||
assert q1(shop.settings, "SELECT count(*) FROM packet_seals WHERE packet_id = %s", (pid,))[0] == 0
|
||||
assert fresh(shop("clerk.clara"), pid)["status"] == "complete"
|
||||
refused(file_packet(shop, pkt, confirm_open_wos=False), 409, "open_work_orders")
|
||||
out = ok(file_packet(shop, pkt, confirm_open_wos=True, note="Customer pickup today"))
|
||||
assert out["status"] == "filed" and out["seal"]["open_wos"] == ["L670001/001", "L670002/7A"]
|
||||
row = q1(shop.settings, "SELECT open_wos FROM packet_seals WHERE packet_id = %s", (pid,))
|
||||
assert row[0] == ["L670001/001", "L670002/7A"]
|
||||
note = find_log(shop("clerk.clara"), pid, "Filed in the data book")["note"]
|
||||
assert note == "Customer pickup today · Filed with work orders open in VISUAL: L670001/001, L670002/7A", note
|
||||
|
||||
|
||||
def test_cancelled_legs_do_not_count_as_open(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
set_feed_legs(shop.settings, pkt["id"], [leg("L670001", st="C"), leg("L670002", st="X")])
|
||||
assert fresh(shop("clerk.clara"), pkt["id"])["vclose_check"]["open_wos"] == []
|
||||
|
||||
|
||||
def test_hand_packet_has_no_feed_check(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
assert pkt["vclose_check"] is None
|
||||
pkt = vclose(shop, pkt)
|
||||
assert find_log(shop("clerk.clara"), pkt["id"], "Job closed in VISUAL")["note"] == "L670001, L670002"
|
||||
out = ok(file_packet(shop, pkt))
|
||||
assert out["status"] == "filed" and out["seal"]["open_wos"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the filing, the seal, the data book
|
||||
|
||||
|
||||
def test_filing_builds_and_stores_the_data_book(shop: Shop) -> None:
|
||||
pkt = vclose(shop, at_complete(shop))
|
||||
pid = pkt["id"]
|
||||
out = ok(file_packet(shop, pkt))
|
||||
assert out["status"] == "filed" and out["databook_at"] is not None
|
||||
seal = out["seal"]
|
||||
assert seal["broken_at"] is None and seal["signed"] is True and out["seals"] == 1
|
||||
db = seal["databook"]
|
||||
assert db["pages"] > 0 and db["url"] == f"/_blob/{db['asset_id']}"
|
||||
assert db["download_url"] == f"/_blob/{db['asset_id']}?download=1"
|
||||
assert db["file_name"].endswith("_Manufacturing_Data_Book.pdf") and db["size"] > 0
|
||||
rows = log_rows(shop("clerk.clara"), pid)
|
||||
actions = [r["action"] for r in rows]
|
||||
i = actions.index("Filed in the data book")
|
||||
j = actions.index("Data book filed")
|
||||
assert j < i, actions # newest first: the data book row comes after the filing row
|
||||
assert rows[j]["note"] == f"{db['pages']} pages"
|
||||
# the seal checks out and the stored copy is the filed data book
|
||||
view = ok(shop("clerk.clara").get(f"/api/packets/{pid}/seal"))
|
||||
assert view["valid"] is True
|
||||
row = q1(shop.settings, "SELECT databook_asset_id, databook_pages FROM packet_seals WHERE packet_id = %s", (pid,))
|
||||
assert str(row[0]) == db["asset_id"] and row[1] == db["pages"]
|
||||
r = shop("clerk.clara").get(db["url"])
|
||||
assert r.status_code == 200 and r.headers["content-type"].startswith("application/pdf")
|
||||
text = flat(pdf_text(r.content))
|
||||
assert "MANUFACTURING" in text
|
||||
project = out["unit"]["project"]["number"]
|
||||
assert project in text
|
||||
assert f"seal {view['seal']['hash'][:12]}" in text
|
||||
src = q1(shop.settings, "SELECT source, source_ref, packet_id FROM assets WHERE id = %s", (db["asset_id"],))
|
||||
assert src[0] == "databook" and src[1]["packet_id"] == pid and src[2] == pid
|
||||
# the export answers with the stored copy, unchanged
|
||||
r2 = shop("pm.paula").get(f"/api/packets/{pid}/pdf/databook")
|
||||
assert r2.status_code == 200 and r2.content == r.content
|
||||
|
||||
|
||||
def test_reopen_keeps_the_broken_seal_and_refile_adds_a_second(shop: Shop) -> None:
|
||||
pkt = filed(shop)
|
||||
pid = pkt["id"]
|
||||
first = pkt["seal"]["databook"]
|
||||
r = shop("admin.ann").post(f"/api/packets/{pid}/reopen", json={"reason": "Wrong heat number filed", "sign": SIGN})
|
||||
assert r.status_code == 200, r.text
|
||||
out = r.json()
|
||||
assert out["status"] == "complete" and out["seal"]["broken_at"] is not None
|
||||
assert out["seal"]["broken_reason"] == "Wrong heat number filed"
|
||||
assert out["seal"]["databook"]["asset_id"] == first["asset_id"] and out["seals"] == 1
|
||||
assert shop("clerk.clara").get(first["url"]).status_code == 200
|
||||
# complete again: the export builds a preview, not the broken seal's copy
|
||||
r = shop("clerk.clara").get(f"/api/packets/{pid}/pdf/databook")
|
||||
assert r.status_code == 200 and "not yet filed" in flat(pdf_text(r.content))
|
||||
# refile: a second seal, a second data book, the first kept
|
||||
again = ok(file_packet(shop, fresh(shop("clerk.clara"), pid)))
|
||||
assert again["status"] == "filed" and again["seals"] == 2
|
||||
assert again["seal"]["broken_at"] is None and again["seal"]["databook"]["asset_id"] != first["asset_id"]
|
||||
view = ok(shop("clerk.clara").get(f"/api/packets/{pid}/seal"))
|
||||
assert view["valid"] is True and len(view["seals"]) == 2
|
||||
assert view["seals"][1]["broken_reason"] == "Wrong heat number filed"
|
||||
rows = qall(
|
||||
shop.settings,
|
||||
"SELECT databook_asset_id, broken_at IS NOT NULL FROM packet_seals WHERE packet_id = %s ORDER BY id",
|
||||
(pid,),
|
||||
)
|
||||
assert [r[1] for r in rows] == [True, False] and all(r[0] is not None for r in rows)
|
||||
|
||||
|
||||
def test_failed_data_book_files_nothing(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from packetstation.pdf import databook
|
||||
from packetstation.pdf.style import PdfError
|
||||
|
||||
def boom(*a: Any, **k: Any) -> Any:
|
||||
raise PdfError("the drawing could not be read")
|
||||
|
||||
monkeypatch.setattr(databook, "build", boom)
|
||||
pkt = vclose(shop, at_complete(shop))
|
||||
pid = pkt["id"]
|
||||
body = refused(file_packet(shop, pkt), 500, "databook_failed")
|
||||
assert body["detail"].startswith("The data book could not be built:") and "Nothing was filed." in body["detail"]
|
||||
view = fresh(shop("clerk.clara"), pid)
|
||||
assert view["status"] == "complete" and view["seal"] is None and view["seals"] == 0
|
||||
assert q1(shop.settings, "SELECT count(*) FROM packet_seals WHERE packet_id = %s", (pid,))[0] == 0
|
||||
assert "Filed in the data book" not in [r["action"] for r in log_rows(shop("clerk.clara"), pid)]
|
||||
@@ -405,7 +405,8 @@ def finish_all(shop: Shop, itp: dict[str, Any]) -> dict[str, Any]:
|
||||
return sign_all_blocks(shop, approve_all(shop, itp))
|
||||
|
||||
|
||||
def test_signoff_without_pdf(qc_shop: Shop) -> None:
|
||||
def test_signoff_files_pdf(qc_shop: Shop) -> None:
|
||||
"""Phase 7: the sign-off builds the signed ITP PDF and files it in the unit's Q.C. folder."""
|
||||
shop = qc_shop
|
||||
unit_id, itp = new_itp(shop)
|
||||
q = shop("qc.quinn")
|
||||
@@ -425,13 +426,17 @@ def test_signoff_without_pdf(qc_shop: Shop) -> None:
|
||||
itp = ok(sign_off(q, itp))
|
||||
assert itp["status"] == "complete" and itp["can"] == {"edit": False, "sign_off": False, "reopen": False}
|
||||
assert itp["signoff"]["by"]["display_name"] == "Quinn QC" and itp["signoff"]["at"]
|
||||
assert itp["signoff"]["asset_id"] is None
|
||||
assert itp_log(itp)[0] == ("ITP signed off", "PDF filing reserved for phase 7") and itp["log"][0]["signed"]
|
||||
asset_id = itp["signoff"]["asset_id"]
|
||||
assert asset_id is not None
|
||||
assert itp_log(itp)[0] == ("ITP signed off", "PDF filed") and itp["log"][0]["signed"]
|
||||
row = one(
|
||||
shop.settings, "SELECT signoff_asset_id, signoff_signature_id IS NOT NULL FROM itps WHERE id = %s", (itp["id"],)
|
||||
)
|
||||
assert tuple(row) == (None, True)
|
||||
assert all_rows(shop.settings, "SELECT count(*) FROM qc_docs WHERE unit_id = %s", (unit_id,))[0][0] == 0
|
||||
assert (str(row[0]), row[1]) == (asset_id, True)
|
||||
asset = one(shop.settings, "SELECT mime, source, original_name FROM assets WHERE id = %s", (asset_id,))
|
||||
assert tuple(asset) == ("application/pdf", "itp_signoff", f"{itp['no']}_signed.pdf")
|
||||
docs = all_rows(shop.settings, "SELECT type, auto, asset_id FROM qc_docs WHERE unit_id = %s", (unit_id,))
|
||||
assert [(d[0], d[1], str(d[2])) for d in docs] == [("ITP", True, asset_id)]
|
||||
# the admin may reopen; qc.quinn and qc.sue may not
|
||||
assert get_itp(shop("admin.ann"), unit_id)["can"]["reopen"] is True
|
||||
assert get_itp(q, unit_id)["can"]["reopen"] is False
|
||||
@@ -485,7 +490,8 @@ def test_signoff_with_pdf_hook(qc_shop: Shop, monkeypatch: pytest.MonkeyPatch) -
|
||||
calls: list[Any] = []
|
||||
asset_ids = [upload_doc(q, "itp-1.pdf")["id"], upload_doc(q, "itp-2.pdf")["id"]]
|
||||
|
||||
def fake_hook(conn: Any, itp_row: Any, actor: Any) -> uuid.UUID:
|
||||
def fake_hook(conn: Any, itp_row: Any, actor: Any, settings: Any = None) -> uuid.UUID:
|
||||
assert settings is not None
|
||||
calls.append(itp_row)
|
||||
return uuid.UUID(asset_ids[len(calls) - 1])
|
||||
|
||||
|
||||
@@ -264,7 +264,8 @@ def test_happy_path_file_and_reopen(pg_client2: TestClient, pg_settings: Setting
|
||||
"qc_accept",
|
||||
"file",
|
||||
]
|
||||
assert pg_client2.get(f"/api/packets/{pid}/log").json()[0]["action"] == "Filed in the data book"
|
||||
actions = [e["action"] for e in pg_client2.get(f"/api/packets/{pid}/log").json()[:2]]
|
||||
assert actions == ["Data book filed", "Filed in the data book"]
|
||||
# filed: every write is refused, Admin included
|
||||
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"drawing": "D-2"}})
|
||||
assert r.status_code == 403 and r.json()["code"] == "filed"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Phase 7 data book builder, called directly on a packet built through the API: two drawing sheets and a void
|
||||
copy of sheet 1, a heat table, two welds, an MTR PDF and an MTR PNG, a short with a receipt, a packet document, a
|
||||
Q.C. folder document, the closed stage and the Q.C. accept.
|
||||
|
||||
The index (seven sections in order, the expected rows), the page count, the cover, the index page, the void
|
||||
sheet, the traceability page, the warning rows, the metadata, the stamp on every page and its signature, and the
|
||||
stamp per mode. Nothing here reads VISUAL or M-Files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from closehelpers import (
|
||||
HEATS,
|
||||
RECEIPT_HEAT,
|
||||
builder_conn,
|
||||
delete_asset_file,
|
||||
flat,
|
||||
page_texts,
|
||||
pdf_reader,
|
||||
rich_complete,
|
||||
who,
|
||||
)
|
||||
from fakedir import FakeDirectory
|
||||
from pshelpers import TEST_SEAL_KEY, needs_pg
|
||||
from qchelpers import g
|
||||
from shophelpers import Shop, open_shop, run
|
||||
|
||||
from packetstation.pdf import databook, stamps
|
||||
from packetstation.pdf.style import clean
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
|
||||
SEAL = {"hash": "ab12cd34ef56" + "0" * 52, "key_id": "k1234567", "sealed_at": NOW}
|
||||
SECTIONS = [1, 2, 3, 4, 5, 6, 7]
|
||||
SIG_RE = re.compile(r"stamp ([0-9a-f]{16})")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def build(shop: Shop, pid: int, mode: str = "preview", **kw: Any) -> Any:
|
||||
with builder_conn(shop.settings) as conn:
|
||||
return databook.build(
|
||||
conn,
|
||||
shop.settings,
|
||||
pid,
|
||||
who=who(shop),
|
||||
now=NOW,
|
||||
seal=SEAL if mode == "filed" else None,
|
||||
mode=mode,
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def index(built: Any) -> list[dict[str, Any]]:
|
||||
keys = ("section", "doc", "ref", "rev", "page", "warn", "count")
|
||||
return [{k: g(r, k) for k in keys} for r in built.index]
|
||||
|
||||
|
||||
def docs_in(rows: list[dict[str, Any]], section: int) -> list[str]:
|
||||
return [str(r["doc"]) for r in rows if r["section"] == section]
|
||||
|
||||
|
||||
def test_data_book_preview(shop: Shop) -> None:
|
||||
pkt = rich_complete(shop)
|
||||
pid = pkt["id"]
|
||||
built = build(shop, pid)
|
||||
data: bytes = built.bytes
|
||||
assert data[:5] == b"%PDF-"
|
||||
reader = pdf_reader(data)
|
||||
assert len(reader.pages) == built.pages
|
||||
rows = index(built)
|
||||
|
||||
# the seven sections, in order, each present
|
||||
seen = [r["section"] for r in rows]
|
||||
assert seen == sorted(seen) and sorted(set(seen)) == SECTIONS, seen
|
||||
|
||||
s1 = docs_in(rows, 1)
|
||||
assert sum("approved drawing" in d for d in s1) == 2, s1
|
||||
assert any(d.startswith("SUPERSEDED - VOID by CO-1") for d in s1), s1
|
||||
drawing_rows = [r for r in rows if r["section"] == 1 and "approved drawing" in str(r["doc"])]
|
||||
assert all(r["ref"] == "D-5001" and r["rev"] == "RB" for r in drawing_rows), drawing_rows
|
||||
|
||||
s2 = docs_in(rows, 2)
|
||||
assert s2[0].startswith("Material traceability record"), s2
|
||||
assert any(d.startswith("Heat map") and "heat number table" in d for d in s2), s2
|
||||
|
||||
s3 = docs_in(rows, 3)
|
||||
assert any(d.startswith("Weld map") and "W1, W2" in d for d in s3), s3
|
||||
assert any(d.startswith("Weld log, welding record and checklist") for d in s3), s3
|
||||
|
||||
s4 = docs_in(rows, 4)
|
||||
assert len(s4) == 1 and s4[0].startswith("Q.C. inspection") and "ACCEPTED" in s4[0], s4
|
||||
|
||||
s5 = docs_in(rows, 5)
|
||||
assert any("WPS / PQR" in d and "Unit folder doc" in d for d in s5), s5
|
||||
assert any("RT report W1-W2" in d for d in s5), s5
|
||||
itp_warn = [r for r in rows if r["section"] == 5 and r["doc"] == "No signed ITP filed for this unit"]
|
||||
assert len(itp_warn) == 1 and itp_warn[0]["warn"] is True
|
||||
|
||||
s6 = docs_in(rows, 6)
|
||||
assert len(s6) == 2 and all(d.startswith("MTR") for d in s6), s6
|
||||
assert "item 1" in s6[0] and "item 2" in s6[1]
|
||||
mtr_rows = [r for r in rows if r["section"] == 6]
|
||||
assert mtr_rows[0]["ref"] == HEATS[1]
|
||||
|
||||
assert docs_in(rows, 7) == ["Packet record history"]
|
||||
|
||||
# pages: the parts plus the front matter (cover and index); page numbers point into the book
|
||||
parts = sum(int(r["count"] or 0) for r in rows)
|
||||
front = built.pages - parts
|
||||
assert front == 2, (built.pages, parts) # the cover and one index page
|
||||
numbered = [r for r in rows if r["page"] is not None]
|
||||
assert all(front < int(r["page"]) <= built.pages for r in numbered), numbered
|
||||
assert all(r["page"] is None and not r["count"] for r in rows if r["warn"] and "could not" in str(r["doc"]))
|
||||
|
||||
texts = page_texts(data)
|
||||
assert "MANUFACTURING" in texts[0] and "DATA BOOK" in flat(texts[0])
|
||||
assert pkt["unit"]["project"]["number"] in flat(texts[0])
|
||||
index_text = flat(" ".join(texts[1:front]))
|
||||
assert "INDEX" in index_text and "TABLE OF CONTENTS" in index_text
|
||||
for r in rows:
|
||||
words = flat(clean(str(r["doc"])))[:30]
|
||||
assert words in index_text, (words, index_text[:2000])
|
||||
|
||||
# the void sheet carries the void text, the traceability page the heats
|
||||
void_page = next(r for r in rows if str(r["doc"]).startswith("SUPERSEDED"))["page"]
|
||||
assert "VOID - superseded by CO-1" in flat(texts[int(void_page) - 1])
|
||||
trace_page = next(r for r in rows if str(r["doc"]).startswith("Material traceability"))["page"]
|
||||
trace = flat(" ".join(texts[int(trace_page) - 1 : int(trace_page) + 1]))
|
||||
for heat in (HEATS[1], HEATS[2], RECEIPT_HEAT):
|
||||
assert heat in trace, heat
|
||||
assert "open items" in trace.lower() and "item 3" in trace
|
||||
|
||||
# metadata
|
||||
meta = reader.metadata
|
||||
assert meta.title == f"{pkt['unit']['project']['number']} Lot 001 Manufacturing Data Book - Spool 7 outlet"
|
||||
assert meta.producer == "Packet Station"
|
||||
assert meta.get("/Author") == "PESCO - Process Equipment & Service Company, Inc."
|
||||
|
||||
# the stamp on every page, preview mode, and its signature verifies
|
||||
sigs = set()
|
||||
for i, t in enumerate(texts):
|
||||
ft = flat(t)
|
||||
assert "Controlled copy - exported by Clara Clerk (4001)" in ft, (i, ft[-300:])
|
||||
assert "not yet filed" in ft, i
|
||||
m = SIG_RE.search(ft)
|
||||
assert m, (i, ft[-300:])
|
||||
sigs.add(m.group(1))
|
||||
assert len(sigs) == 1
|
||||
sig = sigs.pop()
|
||||
user = who(shop)["id"]
|
||||
key = TEST_SEAL_KEY.encode()
|
||||
assert stamps.verify_stamp(key, pid, "databook", "", user, NOW.isoformat(), sig)
|
||||
assert not stamps.verify_stamp(key, pid, "traveler", "", user, NOW.isoformat(), sig)
|
||||
|
||||
|
||||
def test_data_book_filed_mode_and_warnings(shop: Shop) -> None:
|
||||
pkt = rich_complete(shop)
|
||||
pid = pkt["id"]
|
||||
filed = build(shop, pid, mode="filed")
|
||||
texts = page_texts(filed.bytes)
|
||||
assert all("seal ab12cd34ef56" in flat(t) for t in texts)
|
||||
assert not any("not yet filed" in flat(t) for t in texts)
|
||||
m = SIG_RE.search(flat(texts[0]))
|
||||
assert m and filed.stamp_sig == m.group(1)
|
||||
assert stamps.verify_stamp(TEST_SEAL_KEY.encode(), pid, "databook", SEAL["hash"], who(shop)["id"], NOW, m.group(1))
|
||||
meta = pdf_reader(filed.bytes).metadata
|
||||
assert "seal" in (meta.creator or "") and "4001" in (meta.creator or "")
|
||||
|
||||
# no MTRs: the MTR warning row; a deleted packet document file: could not be read
|
||||
run(shop.settings, "UPDATE bom_lines SET mtr_asset_id = NULL WHERE packet_id = %s", (pid,))
|
||||
doc = next(d for d in pkt["docs"] if d.get("removed") is None)
|
||||
asset_id = doc.get("asset_id") or doc["url"].rsplit("/", 1)[-1]
|
||||
delete_asset_file(shop.settings, asset_id)
|
||||
built = build(shop, pid)
|
||||
rows = index(built)
|
||||
mtr = [r for r in rows if r["section"] == 6]
|
||||
assert [r["doc"] for r in mtr] == ["No MTRs attached in Packet Station — add from the warehouse files"]
|
||||
assert mtr[0]["warn"] is True and mtr[0]["page"] is None
|
||||
bad = [r for r in rows if r["section"] == 5 and str(r["doc"]).endswith("could not be read")]
|
||||
assert len(bad) == 1 and "RT report W1-W2" in bad[0]["doc"] and bad[0]["warn"] is True
|
||||
# the index still fits on one page: the cover, the index and the parts
|
||||
assert built.pages == 2 + sum(int(r["count"] or 0) for r in rows)
|
||||
|
||||
|
||||
def test_data_book_with_an_itp_document_has_no_itp_warning(shop: Shop) -> None:
|
||||
pkt = rich_complete(shop, itp_doc=True, mtrs=False)
|
||||
rows = index(build(shop, pkt["id"]))
|
||||
s5 = [r for r in rows if r["section"] == 5]
|
||||
assert s5[0]["doc"].startswith("ITP"), s5
|
||||
assert not any(r["doc"] == "No signed ITP filed for this unit" for r in s5)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Phase 7 ITP PDF: the builder on an open and a complete ITP (the number, a section title, the approvals, the
|
||||
stamp, three page groups), and the sign-off through the API that now files the signed PDF (the asset on the ITP,
|
||||
the automatic Q.C. folder document, the log note), superseded by a second sign-off after a reopen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from closehelpers import builder_conn, flat, page_texts, pdf_reader, q1, qall, who
|
||||
from pshelpers import needs_pg
|
||||
from psycopg.rows import dict_row
|
||||
from qchelpers import (
|
||||
SECTION_TITLE_5,
|
||||
approve_all,
|
||||
itp_log,
|
||||
open_qc_shop,
|
||||
qc_directory,
|
||||
reopen_itp,
|
||||
sign_all_blocks,
|
||||
sign_line,
|
||||
sign_off,
|
||||
start_itp,
|
||||
)
|
||||
from shophelpers import Shop, make_unit, ok
|
||||
|
||||
from packetstation.pdf import itp as itp_pdf
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
|
||||
PAGE_HEADS = ("1 OF 3: WP", "2 OF 3: C", "3 OF 3: FA") # the header sub is drawn in capitals
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qc_shop(pg_settings: Settings, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_qc_shop(pg_settings, qc_directory())
|
||||
|
||||
|
||||
def build(shop: Shop, itp_id: int) -> Any:
|
||||
with builder_conn(shop.settings) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
row = cur.execute("SELECT * FROM itps WHERE id = %s", (itp_id,)).fetchone()
|
||||
return itp_pdf.build(conn, shop.settings, row, who=who(shop, "qc.quinn"), now=NOW)
|
||||
|
||||
|
||||
def test_open_itp_pdf(qc_shop: Shop) -> None:
|
||||
shop = qc_shop
|
||||
_, unit_id = make_unit(shop.settings)
|
||||
itp = start_itp(shop, unit_id)
|
||||
itp = ok(sign_line(shop("qc.quinn"), itp, "1.1"))
|
||||
built = build(shop, itp["id"])
|
||||
assert built.bytes[:5] == b"%PDF-"
|
||||
assert len(pdf_reader(built.bytes).pages) == built.pages >= 3
|
||||
texts = [flat(t) for t in page_texts(built.bytes)]
|
||||
text = " ".join(texts)
|
||||
assert itp["no"] in text and SECTION_TITLE_5 in text
|
||||
assert "Approved" in text and "INSPECTION & TEST PLAN" in text
|
||||
# three page groups, in order WP, C, FA
|
||||
firsts = [next(i for i, t in enumerate(texts) if head in t) for head in PAGE_HEADS]
|
||||
assert firsts == sorted(firsts) and firsts[0] == 0, firsts
|
||||
for t in texts:
|
||||
assert "OPEN, not signed off" in t
|
||||
assert "Controlled copy - by Quinn QC (5001) 2026-10-17 21:04 UTC - OPEN, not signed off - stamp " in t
|
||||
assert pdf_reader(built.bytes).metadata.title == f"ITP {itp['no']}"
|
||||
|
||||
|
||||
def test_signoff_files_the_signed_pdf(qc_shop: Shop) -> None:
|
||||
shop = qc_shop
|
||||
_, unit_id = make_unit(shop.settings)
|
||||
q = shop("qc.quinn")
|
||||
itp = sign_all_blocks(shop, approve_all(shop, start_itp(shop, unit_id)))
|
||||
itp = ok(sign_off(q, itp))
|
||||
assert itp["status"] == "complete"
|
||||
asset_id = itp["signoff"]["asset_id"]
|
||||
assert asset_id is not None
|
||||
assert itp_log(itp)[0] == ("ITP signed off", "PDF filed")
|
||||
row = q1(shop.settings, "SELECT signoff_asset_id FROM itps WHERE id = %s", (itp["id"],))
|
||||
assert str(row[0]) == asset_id
|
||||
a = q1(shop.settings, "SELECT original_name, mime, source FROM assets WHERE id = %s", (asset_id,))
|
||||
assert a[0] == f"{itp['no']}_signed.pdf" and a[1] == "application/pdf" and a[2] == "itp_signoff"
|
||||
docs = qall(
|
||||
shop.settings,
|
||||
"SELECT id, type, title, auto, superseded_by, asset_id FROM qc_docs WHERE unit_id = %s ORDER BY id",
|
||||
(unit_id,),
|
||||
)
|
||||
assert len(docs) == 1
|
||||
assert tuple(docs[0][1:5]) == ("ITP", f"{itp['no']} — signed off", True, None)
|
||||
assert str(docs[0][5]) == asset_id
|
||||
r = q.get(f"/_blob/{asset_id}")
|
||||
assert r.status_code == 200 and r.content[:5] == b"%PDF-"
|
||||
text = flat(" ".join(page_texts(r.content)))
|
||||
assert itp["no"] in text and "signed off" in text and "OPEN, not signed off" not in text
|
||||
assert "ITP signed off" in text and "Quinn QC (5001)" in text
|
||||
|
||||
# the builder on the complete ITP says signed off on every page
|
||||
for t in page_texts(build(shop, itp["id"]).bytes):
|
||||
assert "signed off" in flat(t) and "OPEN, not signed off" not in flat(t)
|
||||
|
||||
# reopen and sign off again: a second PDF supersedes the first automatic document
|
||||
itp = ok(reopen_itp(shop("admin.ann"), itp, "Customer asked for 4.2 again"))
|
||||
itp = ok(sign_off(q, itp))
|
||||
second = itp["signoff"]["asset_id"]
|
||||
assert second is not None and second != asset_id
|
||||
docs = qall(
|
||||
shop.settings, "SELECT id, superseded_by, asset_id FROM qc_docs WHERE unit_id = %s ORDER BY id", (unit_id,)
|
||||
)
|
||||
assert len(docs) == 2 and docs[0][1] == docs[1][0] and docs[1][1] is None and str(docs[1][2]) == second
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Phase 7 job traveler builder: the page count, the work order boxes (the QR text is a barcode, never text; the
|
||||
work order, lot and seq are text), the blanks for a packet with no heats, the sign-off rows after signed steps, the
|
||||
note for a packet with no work orders, the stamp and the metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from closehelpers import at_complete, builder_conn, flat, page_texts, pdf_reader, who
|
||||
from fakedir import FakeDirectory
|
||||
from pkthelpers import make_draft
|
||||
from pshelpers import needs_pg
|
||||
from shophelpers import BOM3, WOS, Shop, draft, make_unit, open_shop
|
||||
|
||||
from packetstation.pdf import traveler
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def build(shop: Shop, pid: int) -> Any:
|
||||
with builder_conn(shop.settings) as conn:
|
||||
return traveler.build(conn, shop.settings, pid, who=who(shop), now=NOW)
|
||||
|
||||
|
||||
def test_traveler_of_a_draft(shop: Shop) -> None:
|
||||
"""A draft with no signed step yet (no drawing attached, so no signed attach row): the blank sign-off rows."""
|
||||
_, unit_id = make_unit(shop.settings)
|
||||
pkt = make_draft(shop("clerk.clara"), unit_id, bom=BOM3, work_orders=WOS)
|
||||
built = build(shop, pkt["id"])
|
||||
assert built.bytes[:5] == b"%PDF-"
|
||||
reader = pdf_reader(built.bytes)
|
||||
assert len(reader.pages) == built.pages >= 1
|
||||
texts = page_texts(built.bytes)
|
||||
text = flat(" ".join(texts))
|
||||
assert "JOB TRAVELER" in text.upper()
|
||||
for wo in ("L670001", "L670002"):
|
||||
assert wo in text
|
||||
assert "Lot 001" in text and "Seq" in text
|
||||
# the QR payload is drawn as a barcode, never as text
|
||||
assert "*%2" not in text and "$001$0$0$" not in text
|
||||
# no heats yet: blanks
|
||||
assert "________" in text
|
||||
assert "Work complete (stall)" in text and "Supervisor review" in text and "Q.C. inspection" in text
|
||||
project = pkt["unit"]["project"]["number"]
|
||||
for i, t in enumerate(texts):
|
||||
ft = flat(t)
|
||||
assert "Controlled copy - exported by Clara Clerk (4001)" in ft, i
|
||||
assert f"Page {i + 1} of {len(texts)}" in ft, i
|
||||
assert reader.metadata.title == f"{project} Lot 001 Job Traveler", reader.metadata.title
|
||||
assert reader.metadata.producer == "Packet Station"
|
||||
|
||||
|
||||
def test_traveler_after_signed_steps(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
text = flat(" ".join(page_texts(build(shop, pkt["id"]).bytes)))
|
||||
assert "Andy Welder (3003)" in text # signed the submit
|
||||
assert "Wes Super (2003)" in text # signed the send to Q.C.
|
||||
assert "Quinn QC (5001)" in text # signed the accept
|
||||
assert "Work complete (stall)" not in text
|
||||
assert "HT-10" in text # the heats pulled in the warehouse
|
||||
assert "Q.C. ACCEPTED" in text
|
||||
|
||||
|
||||
def test_traveler_without_work_orders(shop: Shop) -> None:
|
||||
pkt = draft(shop, wos=[])
|
||||
text = flat(" ".join(page_texts(build(shop, pkt["id"]).bytes)))
|
||||
assert "No work orders were read from this packet." in text
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Phase 7 printed copies and exports through the API: every print kind with its stamp on every page, the print log
|
||||
and the audit, one sheet, a document print and a foreign asset, nothing to print, the data book before complete,
|
||||
the prints list, the ITP print and PDF, the exports, the signed-out call, the PM role, a removed packet, and the
|
||||
append-only print log."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from closehelpers import (
|
||||
at_complete,
|
||||
flat,
|
||||
is_pdf,
|
||||
page_texts,
|
||||
pdf_pages,
|
||||
q1,
|
||||
qall,
|
||||
remove_by_sql,
|
||||
rich_complete,
|
||||
)
|
||||
from fakedir import FakeDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg
|
||||
from qchelpers import approve_all, at_qc, sign_all_blocks, sign_off, start_itp, upload_doc
|
||||
from shophelpers import Shop, draft, ok, open_shop, refused
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
WHAT = {
|
||||
"traveler": "Job traveler",
|
||||
"map-h": "Drawing with heat map",
|
||||
"map-w": "Drawing with weld map",
|
||||
"clean": "Drawing (no markup)",
|
||||
"void": "Superseded drawings (VOID)",
|
||||
"databook": "Data book",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def get_pdf(c: TestClient, url: str) -> bytes:
|
||||
r = c.get(url)
|
||||
assert r.status_code == 200, (url, r.text[:500])
|
||||
assert r.headers["content-type"].startswith("application/pdf"), url
|
||||
assert r.headers.get("x-content-type-options") == "nosniff"
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
assert r.headers["content-disposition"].startswith("inline;"), r.headers["content-disposition"]
|
||||
assert is_pdf(r.content)
|
||||
return r.content
|
||||
|
||||
|
||||
def assert_print_stamp(data: bytes, what: str, by: str = "Clara Clerk (4001)") -> None:
|
||||
for i, t in enumerate(page_texts(data)):
|
||||
ft = flat(t)
|
||||
assert f"PRINTED COPY - {what} - printed by {by}" in ft, (i, what, ft[-400:])
|
||||
assert "verify revision before use" in ft, (i, what)
|
||||
|
||||
|
||||
def last_print(settings: Settings, pid: int) -> Any:
|
||||
return q1(
|
||||
settings,
|
||||
"SELECT kind, what, pages, by_name, stamp_sig, stamp, asset_id FROM print_log WHERE packet_id = %s"
|
||||
" ORDER BY id DESC LIMIT 1",
|
||||
(pid,),
|
||||
)
|
||||
|
||||
|
||||
def last_audit(settings: Settings, pid: int, action: str) -> Any:
|
||||
row = q1(
|
||||
settings,
|
||||
"SELECT detail, user_id FROM audit_events WHERE action = %s AND packet_id = %s ORDER BY id DESC LIMIT 1",
|
||||
(action, pid),
|
||||
)
|
||||
assert row is not None, (action, pid)
|
||||
return row
|
||||
|
||||
|
||||
def test_every_print_kind(shop: Shop) -> None:
|
||||
pkt = rich_complete(shop)
|
||||
pid = pkt["id"]
|
||||
c = shop("clerk.clara")
|
||||
for kind, what in WHAT.items():
|
||||
data = get_pdf(c, f"/api/packets/{pid}/print/{kind}")
|
||||
assert_print_stamp(data, what)
|
||||
row = last_print(shop.settings, pid)
|
||||
assert (row[0], row[1], row[2], row[3]) == (kind, what, pdf_pages(data), "Clara Clerk"), row
|
||||
assert row[4] and len(row[4]) == 16 and row[4] in row[5]
|
||||
au = last_audit(shop.settings, pid, "print")
|
||||
assert au[0]["kind"] == kind and au[0]["what"] == what and au[0]["pages"] == pdf_pages(data)
|
||||
assert au[0]["stamp_sig"] == row[4]
|
||||
# page counts: two live sheets, one void sheet
|
||||
assert pdf_pages(get_pdf(c, f"/api/packets/{pid}/print/clean")) == 3
|
||||
assert pdf_pages(get_pdf(c, f"/api/packets/{pid}/print/void")) == 1
|
||||
# one sheet
|
||||
for kind in ("map-h", "map-w", "clean"):
|
||||
data = get_pdf(c, f"/api/packets/{pid}/print/{kind}?sheet=2")
|
||||
assert pdf_pages(data) == 1
|
||||
assert_print_stamp(data, f"{WHAT[kind]} (sheet 2)")
|
||||
assert last_print(shop.settings, pid)[1] == f"{WHAT[kind]} (sheet 2)"
|
||||
# the download form
|
||||
r = c.get(f"/api/packets/{pid}/print/traveler?download=1")
|
||||
assert r.status_code == 200 and r.headers["content-disposition"].startswith("attachment;")
|
||||
assert '-Lot001 Job traveler.pdf"' in r.headers["content-disposition"], r.headers["content-disposition"]
|
||||
|
||||
|
||||
def test_document_prints(shop: Shop) -> None:
|
||||
pkt = rich_complete(shop)
|
||||
pid = pkt["id"]
|
||||
c = shop("clerk.clara")
|
||||
mtr = next(b for b in pkt["bom"] if b["item"] == 1)
|
||||
mtr_id = (mtr.get("mtr") or {}).get("asset_id") or mtr.get("mtr_asset_id")
|
||||
png = next(b for b in pkt["bom"] if b["item"] == 2)
|
||||
png_id = (png.get("mtr") or {}).get("asset_id") or png.get("mtr_asset_id")
|
||||
doc = next(d for d in pkt["docs"] if d.get("removed") is None)
|
||||
doc_id = doc.get("asset_id") or doc["url"].rsplit("/", 1)[-1]
|
||||
folder = pkt["qc_folder_docs"][0]
|
||||
folder_id = folder.get("asset_id") or folder["url"].rsplit("/", 1)[-1]
|
||||
for asset, what_starts in (
|
||||
(mtr_id, "MTR - item 1"),
|
||||
(png_id, "MTR - item 2"),
|
||||
(doc_id, "RT report W1-W2"),
|
||||
(folder_id, "WPS / PQR - Unit folder doc"),
|
||||
):
|
||||
data = get_pdf(c, f"/api/packets/{pid}/print/doc?asset={asset}")
|
||||
row = last_print(shop.settings, pid)
|
||||
assert row[0] == "doc" and row[1].startswith(what_starts) and str(row[6]) == str(asset), row
|
||||
assert_print_stamp(data, row[1])
|
||||
assert last_audit(shop.settings, pid, "print")[0].get("asset_id") in (str(asset), None)
|
||||
# a PNG MTR prints on one letter page
|
||||
assert pdf_pages(get_pdf(c, f"/api/packets/{pid}/print/doc?asset={png_id}")) == 1
|
||||
# an asset that is not the packet's
|
||||
foreign = upload_doc(c, "other.pdf")["id"]
|
||||
refused(c.get(f"/api/packets/{pid}/print/doc?asset={foreign}"), 404, "not_found")
|
||||
r = c.get(f"/api/packets/{pid}/print/doc")
|
||||
assert r.status_code in (404, 422), r.text
|
||||
r = c.get(f"/api/packets/{pid}/print/nonsense")
|
||||
assert r.status_code in (404, 422), r.text
|
||||
|
||||
|
||||
def test_nothing_to_print_and_wrong_status(shop: Shop) -> None:
|
||||
pkt = at_qc(shop)
|
||||
c = shop("clerk.clara")
|
||||
refused(c.get(f"/api/packets/{pkt['id']}/print/void"), 404, "nothing_to_print", "Nothing to print.")
|
||||
refused(c.get(f"/api/packets/{pkt['id']}/print/databook"), 409, "wrong_status")
|
||||
refused(
|
||||
c.get(f"/api/packets/{pkt['id']}/pdf/databook"),
|
||||
409,
|
||||
"wrong_status",
|
||||
"The data book is built once the packet is back from Q.C.",
|
||||
)
|
||||
assert q1(shop.settings, "SELECT count(*) FROM print_log WHERE packet_id = %s", (pkt["id"],))[0] == 0
|
||||
# the traveler prints in any status
|
||||
get_pdf(c, f"/api/packets/{pkt['id']}/print/traveler")
|
||||
|
||||
|
||||
def test_prints_list_newest_first_and_exports(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
pid = pkt["id"]
|
||||
c = shop("clerk.clara")
|
||||
assert ok(c.get(f"/api/packets/{pid}/prints")) == {"prints": []}
|
||||
get_pdf(c, f"/api/packets/{pid}/print/traveler")
|
||||
get_pdf(shop("pm.paula"), f"/api/packets/{pid}/pdf/traveler")
|
||||
data = get_pdf(shop("pm.paula"), f"/api/packets/{pid}/pdf/databook")
|
||||
assert "not yet filed" in flat(page_texts(data)[0])
|
||||
assert shop("pm.paula").get(f"/api/packets/{pid}/export/traceability.csv").status_code == 200
|
||||
out = ok(shop("qc.quinn").get(f"/api/packets/{pid}/prints"))["prints"]
|
||||
assert [p["kind"] for p in out] == ["csv", "databook-export", "traveler", "traveler"]
|
||||
assert out[0]["by"]["display_name"] == "Paula PM" and out[-1]["by"]["display_name"] == "Clara Clerk"
|
||||
assert out[1]["pages"] == pdf_pages(data) and out[1]["what"]
|
||||
assert set(out[0]) >= {"id", "at", "by", "kind", "what", "pages"}
|
||||
ids = [p["id"] for p in out]
|
||||
assert ids == sorted(ids, reverse=True)
|
||||
kinds = [
|
||||
r[0]["kind"]
|
||||
for r in qall(
|
||||
shop.settings,
|
||||
"SELECT detail FROM audit_events WHERE action = 'export' AND packet_id = %s ORDER BY id",
|
||||
(pid,),
|
||||
)
|
||||
]
|
||||
assert kinds == ["traveler", "databook", "csv"]
|
||||
assert (
|
||||
q1(shop.settings, "SELECT count(*) FROM audit_events WHERE action = 'print' AND packet_id = %s", (pid,))[0] == 1
|
||||
)
|
||||
# prints and exports write no packet log rows
|
||||
actions = [r[0] for r in qall(shop.settings, "SELECT action FROM packet_log WHERE packet_id = %s", (pid,))]
|
||||
assert not any("print" in a.lower() or "export" in a.lower() for a in actions)
|
||||
|
||||
|
||||
def test_itp_print_and_pdf(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
unit_id = pkt["unit"]["id"]
|
||||
itp = start_itp(shop, unit_id)
|
||||
q = shop("qc.quinn")
|
||||
data = get_pdf(q, f"/api/itps/{itp['id']}/pdf")
|
||||
assert "OPEN, not signed off" in flat(page_texts(data)[0])
|
||||
row = q1(shop.settings, "SELECT kind, unit_id, itp_id, packet_id FROM print_log ORDER BY id DESC LIMIT 1")
|
||||
assert tuple(row) == ("itp-pdf", unit_id, itp["id"], None)
|
||||
r = q.get(f"/api/itps/{itp['id']}/pdf?download=1")
|
||||
assert r.status_code == 200 and r.headers["content-disposition"].startswith("attachment;")
|
||||
assert f"{itp['no']}.pdf" in r.headers["content-disposition"]
|
||||
data = get_pdf(shop("pm.paula"), f"/api/itps/{itp['id']}/print")
|
||||
for t in page_texts(data):
|
||||
assert "PRINTED COPY" in flat(t) and "verify revision before use" in flat(t)
|
||||
assert q1(shop.settings, "SELECT kind FROM print_log ORDER BY id DESC LIMIT 1")[0] == "itp"
|
||||
itp = ok(sign_off(q, sign_all_blocks(shop, approve_all(shop, itp))))
|
||||
data = get_pdf(q, f"/api/itps/{itp['id']}/pdf")
|
||||
assert "signed off" in flat(page_texts(data)[0]) and "OPEN, not signed off" not in flat(page_texts(data)[0])
|
||||
refused(q.get("/api/itps/999999/pdf"), 404, "not_found")
|
||||
|
||||
|
||||
def test_signed_out_pm_removed_and_append_only(shop: Shop) -> None:
|
||||
gone = draft(shop)
|
||||
remove_by_sql(shop.settings, gone["id"])
|
||||
pkt = at_complete(shop)
|
||||
pid = pkt["id"]
|
||||
anon = TestClient(shop.main.app, headers=ORIGIN_HEADERS)
|
||||
for url in (
|
||||
f"/api/packets/{pid}/pdf/databook",
|
||||
f"/api/packets/{pid}/pdf/traveler",
|
||||
f"/api/packets/{pid}/export/traceability.csv",
|
||||
f"/api/packets/{pid}/print/traveler",
|
||||
f"/api/packets/{pid}/prints",
|
||||
):
|
||||
assert anon.get(url).status_code == 401, url
|
||||
# the PM role reads every one
|
||||
pm = shop("pm.paula")
|
||||
for kind in ("traveler", "map-h", "map-w", "clean", "databook"):
|
||||
get_pdf(pm, f"/api/packets/{pid}/print/{kind}")
|
||||
# a missing packet and a removed packet are 404
|
||||
refused(pm.get("/api/packets/999999/print/traveler"), 404, "not_found")
|
||||
# a fresh session: the PDFs above can take longer than the idle lock (15 minutes over the tunnel)
|
||||
clara = shop.fresh("clerk.clara")
|
||||
for url in (
|
||||
f"/api/packets/{gone['id']}/print/traveler",
|
||||
f"/api/packets/{gone['id']}/pdf/traveler",
|
||||
f"/api/packets/{gone['id']}/export/traceability.csv",
|
||||
):
|
||||
refused(clara.get(url), 404, "not_found")
|
||||
# the print log is append-only
|
||||
with app_conn(shop.settings) as conn:
|
||||
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
||||
conn.execute("UPDATE print_log SET what = 'x' WHERE packet_id = %s", (pid,))
|
||||
with app_conn(shop.settings) as conn:
|
||||
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
||||
conn.execute("DELETE FROM print_log WHERE packet_id = %s", (pid,))
|
||||
assert q1(shop.settings, "SELECT count(*) FROM print_log WHERE packet_id = %s", (pid,))[0] == 5
|
||||
@@ -67,6 +67,9 @@ APPEND_INSERTS = {
|
||||
"co_verifications": (
|
||||
"INSERT INTO co_verifications (co_id, dept_code, by, signature_id) VALUES (%(co)s, 'WH', %(user)s, %(sig)s)"
|
||||
),
|
||||
"print_log": (
|
||||
"INSERT INTO print_log (packet_id, kind, what, by) VALUES (%(packet)s, 'traveler', 'Job traveler', %(user)s)"
|
||||
),
|
||||
}
|
||||
APPEND_ONLY = list(APPEND_INSERTS)
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Phase 7 stamps: the controlled-copy and print stamp texts, the short stamp signature made with the seal key, and
|
||||
its check. Pure functions; no database."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pshelpers import TEST_SEAL_KEY
|
||||
|
||||
from packetstation.pdf import stamps
|
||||
|
||||
KEY = TEST_SEAL_KEY.encode()
|
||||
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
|
||||
WHO = {"id": "00000000-0000-0000-0000-000000000c1a", "display_name": "Clara Clerk", "emp_no": "4001"}
|
||||
SEAL = "0123456789abcdef" * 4
|
||||
USER = WHO["id"]
|
||||
AT = NOW.isoformat()
|
||||
|
||||
|
||||
def sig(packet_id: int = 7, kind: str = "databook", seal: str = SEAL, user: str = USER, at: str = AT) -> str:
|
||||
return stamps.stamp_sig(KEY, packet_id, kind, seal, user, at)
|
||||
|
||||
|
||||
def test_stamp_sig_is_16_hex_and_stable() -> None:
|
||||
s = sig()
|
||||
assert re.fullmatch(r"[0-9a-f]{16}", s), s
|
||||
assert sig() == s
|
||||
|
||||
|
||||
def test_stamp_sig_differs_per_kind_packet_user_time_seal_and_key() -> None:
|
||||
base = sig()
|
||||
assert sig(kind="traveler") != base
|
||||
assert sig(packet_id=8) != base
|
||||
assert sig(user="00000000-0000-0000-0000-000000000bbb") != base
|
||||
assert sig(at="2026-10-17T21:05:00+00:00") != base
|
||||
assert sig(seal="f" * 64) != base
|
||||
assert stamps.stamp_sig(b"another-key-0123456789abcdef", 7, "databook", SEAL, USER, AT) != base
|
||||
|
||||
|
||||
def test_verify_stamp_accepts_and_rejects() -> None:
|
||||
good = sig()
|
||||
assert stamps.verify_stamp(KEY, 7, "databook", SEAL, USER, AT, good) is True
|
||||
assert stamps.verify_stamp(KEY, 7, "databook", SEAL, USER, AT, "0" * 16) is False
|
||||
assert stamps.verify_stamp(KEY, 8, "databook", SEAL, USER, AT, good) is False
|
||||
assert stamps.verify_stamp(KEY, 7, "traveler", SEAL, USER, AT, good) is False
|
||||
# the time is signed to the minute, in any ISO form
|
||||
assert stamps.verify_stamp(KEY, 7, "databook", SEAL, USER, "2026-10-17T21:04:59+00:00", good) is True
|
||||
assert stamps.verify_stamp(KEY, 7, "databook", SEAL, USER, "2026-10-17T21:05:00+00:00", good) is False
|
||||
|
||||
|
||||
def test_controlled_stamp_texts() -> None:
|
||||
filed = stamps.controlled_stamp(WHO, NOW, mode="filed", seal_hash_hex=SEAL)
|
||||
assert filed.startswith("Controlled copy - exported by Clara Clerk (4001) 2026-10-17 21:04 UTC")
|
||||
assert "seal 0123456789ab" in filed and "seal 0123456789abc" not in filed
|
||||
preview = stamps.controlled_stamp(WHO, NOW, mode="preview", seal_hash_hex=None)
|
||||
assert preview.startswith("Controlled copy - exported by Clara Clerk (4001) 2026-10-17 21:04 UTC")
|
||||
assert "not yet filed" in preview
|
||||
printed = stamps.controlled_stamp(WHO, NOW, mode="print", seal_hash_hex=None)
|
||||
assert "printed by Clara Clerk (4001)" in printed
|
||||
trav = stamps.controlled_stamp(WHO, NOW, mode="traveler")
|
||||
assert trav == "Controlled copy - exported by Clara Clerk (4001) 2026-10-17 21:04 UTC"
|
||||
for text in (filed, preview, printed, trav):
|
||||
assert text.isascii(), text
|
||||
assert "—" not in text and "–" not in text
|
||||
|
||||
|
||||
def test_missing_emp_number_reads_question_mark() -> None:
|
||||
text = stamps.controlled_stamp({**WHO, "emp_no": None}, NOW, mode="preview", seal_hash_hex=None)
|
||||
assert "Clara Clerk (?)" in text
|
||||
|
||||
|
||||
def packet_like() -> Any:
|
||||
"""The fields print_stamp reads, as a dict (the read model) and as an object, so either shape works."""
|
||||
return {
|
||||
"id": 7,
|
||||
"drawing": "D-5001",
|
||||
"rev": "B",
|
||||
"unit": {
|
||||
"id": 3,
|
||||
"number": "L5A1B2C001",
|
||||
"lot": "001",
|
||||
"project": {"id": 2, "number": "L5A1B2C", "name": "Shop project"},
|
||||
},
|
||||
"project": {"number": "L5A1B2C"},
|
||||
}
|
||||
|
||||
|
||||
def test_print_stamp_text_is_ascii_and_carries_the_parts() -> None:
|
||||
text = stamps.print_stamp(WHO, NOW, "Drawing with weld map — sheet 2", packet_like())
|
||||
assert text.startswith("PRINTED COPY - Drawing with weld map")
|
||||
assert "printed by Clara Clerk (4001) 2026-10-17 21:04 UTC" in text
|
||||
assert "L5A1B2C Lot 001 dwg D-5001 RB" in text, text
|
||||
assert text.endswith(" - verify revision before use"), text
|
||||
assert text.isascii(), text
|
||||
|
||||
|
||||
def test_print_stamp_replaces_non_ascii() -> None:
|
||||
who = {**WHO, "display_name": "Zoë Łukasz"}
|
||||
text = stamps.print_stamp(who, NOW, "MTR → item 3", packet_like())
|
||||
assert text.isascii(), text
|
||||
|
||||
|
||||
def test_apply_stamp_and_metadata() -> None:
|
||||
from closehelpers import flat, page_texts, pdf_reader
|
||||
from pkthelpers import tiny_pdf
|
||||
|
||||
line = "Controlled copy - exported by Clara Clerk (4001) 2026-10-17 21:04 UTC - stamp 0123456789abcdef"
|
||||
for box in (False, True):
|
||||
out = stamps.apply_stamp(tiny_pdf(2), line, ours={0}, box=box)
|
||||
texts = page_texts(out)
|
||||
assert len(texts) == 2 and all(line in flat(t) for t in texts), (box, texts)
|
||||
out = stamps.set_metadata(tiny_pdf(1), "Title A", "Subject B", "k1, k2", "Packet Station - test")
|
||||
meta = pdf_reader(out).metadata
|
||||
assert meta.title == "Title A" and meta.subject == "Subject B" and meta.creator == "Packet Station - test"
|
||||
assert meta.producer == "Packet Station"
|
||||
assert meta.get("/Author") == "PESCO - Process Equipment & Service Company, Inc."
|
||||
assert meta.get("/CreationDate")
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Phase 7 traceability CSV: the formula guard of csv_cell, and the export endpoint (content type, file name, CRLF
|
||||
line ends, no BOM, the header rows, a BOM row with a short and its receipts, the weld rows, the Q.C. rows, the
|
||||
audit row)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from closehelpers import HEATS, RECEIPT_HEAT, at_complete, q1, qall, rich_complete
|
||||
from fakedir import FakeDirectory
|
||||
from pshelpers import app_conn, needs_pg
|
||||
from shophelpers import Shop, open_shop
|
||||
|
||||
from packetstation.pdf.tracecsv import csv_cell
|
||||
from packetstation.settings import Settings
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "want"),
|
||||
[
|
||||
("=1+1", "'=1+1"),
|
||||
("+31", "'+31"),
|
||||
("-2", "'-2"),
|
||||
("@SUM(A1)", "'@SUM(A1)"),
|
||||
("\tTAB", "'\tTAB"),
|
||||
("\rCR", '"\'\rCR"'),
|
||||
('say "hi"', '"say ""hi"""'),
|
||||
("a,b", '"a,b"'),
|
||||
("two\nlines", '"two\nlines"'),
|
||||
('=HYPERLINK("x")', '"\'=HYPERLINK(""x"")"'),
|
||||
("HT-10", "HT-10"),
|
||||
("", ""),
|
||||
(None, ""),
|
||||
(3, "3"),
|
||||
],
|
||||
)
|
||||
def test_csv_cell(value: object, want: str) -> None:
|
||||
assert csv_cell(value) == want
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the endpoint
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def rows_of(text: str) -> list[list[str]]:
|
||||
return list(csv.reader(io.StringIO(text, newline="")))
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_traceability_csv(shop: Shop) -> None:
|
||||
pkt = rich_complete(shop)
|
||||
pid = pkt["id"]
|
||||
project = pkt["unit"]["project"]["number"]
|
||||
r = shop("pm.paula").get(f"/api/packets/{pid}/export/traceability.csv")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-type"].startswith("text/csv")
|
||||
cd = r.headers["content-disposition"]
|
||||
assert "_D-5001_traceability.csv" in cd and project in cd, cd
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
raw = r.content
|
||||
assert not raw.startswith(b"\xef\xbb\xbf")
|
||||
assert b"\r\n" in raw and b"\n" not in raw.replace(b"\r\n", b"")
|
||||
rows = rows_of(raw.decode("utf-8"))
|
||||
assert rows[0] == ["PESCO weld & heat traceability record"]
|
||||
assert rows[1] == ["Project", project, "Lot", "001", "Operation", "WELD-PART"], rows[1]
|
||||
flat_rows = [",".join(x) for x in rows]
|
||||
assert any(x.startswith("Drawing,D-5001,Rev,B") for x in flat_rows), flat_rows[:6]
|
||||
assert any(x.startswith("WPS,WPS-11,Station,Stall 1") for x in flat_rows), flat_rows[:6]
|
||||
assert any(x.startswith("Filler,ER70S-6") for x in flat_rows), flat_rows[:6]
|
||||
head = rows.index(
|
||||
[
|
||||
"BOM item",
|
||||
"Qty",
|
||||
"Issued",
|
||||
"Location",
|
||||
"Part ID",
|
||||
"Description",
|
||||
"Length",
|
||||
"Material",
|
||||
"Heat no.",
|
||||
"MTR on file",
|
||||
"Open short qty",
|
||||
"Short reason",
|
||||
"Short received",
|
||||
]
|
||||
)
|
||||
bom = {r[0]: r for r in rows[head + 1 : head + 4]}
|
||||
assert bom["1"][4] == "PIPE-4" and bom["1"][8] == HEATS[1] and bom["1"][9] == "Yes"
|
||||
assert bom["2"][9] == "Yes"
|
||||
short = bom["3"]
|
||||
assert HEATS[3] in short[8] and RECEIPT_HEAT in short[8]
|
||||
assert short[10] == "1" and short[11] == "On order"
|
||||
assert short[12].startswith(f"1 pc {RECEIPT_HEAT} "), short
|
||||
weld_head = next(i for i, x in enumerate(rows) if x[:2] == ["Weld", "Work order"])
|
||||
assert rows[weld_head] == [
|
||||
"Weld",
|
||||
"Work order",
|
||||
"Piece",
|
||||
"Joins items",
|
||||
"Heats joined",
|
||||
"Type",
|
||||
"Size",
|
||||
"Process",
|
||||
"WPS",
|
||||
"Stamp",
|
||||
"Logged",
|
||||
]
|
||||
welds = rows[weld_head + 1 : weld_head + 3]
|
||||
assert [w[0] for w in welds] == ["W1", "W2"]
|
||||
assert welds[0][3] == "1 + 2" and welds[0][4] == f"{HEATS[1]} + {HEATS[2]}" and welds[0][5] == "Fillet"
|
||||
assert any(x[:2] == ["Checklist item", "Done"] for x in rows)
|
||||
qc = next(x for x in rows if x and x[0] == "Q.C. inspection")
|
||||
assert qc[:4] == ["Q.C. inspection", "accepted", "Inspector", "Quinn QC"], qc
|
||||
assert "Quinn QC" in qc and "Q1" in qc
|
||||
assert any(x and x[0] == "NDE" for x in rows)
|
||||
assert any(x and x[0].startswith("Q.C.: ") for x in rows)
|
||||
vt = [x for x in rows if x and x[0].startswith("VT W")]
|
||||
assert [x[0] for x in vt] == ["VT W1", "VT W2"] and all(x[1] == "accept" for x in vt)
|
||||
au = q1(
|
||||
shop.settings,
|
||||
"SELECT detail FROM audit_events WHERE action = 'export' AND packet_id = %s ORDER BY id DESC",
|
||||
(pid,),
|
||||
)
|
||||
assert au is not None and au[0]["kind"] == "csv"
|
||||
log = qall(shop.settings, "SELECT kind, by_name FROM print_log WHERE packet_id = %s", (pid,))
|
||||
assert ("csv", "Paula PM") in [tuple(x) for x in log]
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_formula_values_are_guarded_in_the_export(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
with app_conn(shop.settings) as conn:
|
||||
conn.execute("UPDATE bom_lines SET description = '=cmd|calc' WHERE packet_id = %s AND item = 1", (pkt["id"],))
|
||||
r = shop("clerk.clara").get(f"/api/packets/{pkt['id']}/export/traceability.csv")
|
||||
assert r.status_code == 200
|
||||
assert "'=cmd|calc" in r.text and ",=cmd" not in r.text
|
||||
Reference in New Issue
Block a user