Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
The Q.C. screen (KPIs, boards, project folders), the Q.C. tab on the packet page (check items from the qc_check_items settings row, visual exam per weld, NDE, hardness with the NACE label, notes), accept with a route and a signature gated by qc_missing_empty, reject with a reason or an NCR number closing the inspection into qc_history, Q.C. folder documents per project and unit with superseding revisions, packet documents as a closed set with take-out reasons, the digital ITP per unit from the frozen template (line initials with CAS, unsign, N/A, customer codes, rejects with NCR, block signatures, sign-off with the PDF hook, admin reopen, the ITP log), hold-point warnings by department page group, the scoped policy check for unit and project writes, migration 0007, tests, Playwright scenarios and docs. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,541 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { PIN } from "./helpers";
|
||||
import {
|
||||
L as SL,
|
||||
actor,
|
||||
api,
|
||||
closeAll,
|
||||
packet,
|
||||
packetTab,
|
||||
pullAndRelease,
|
||||
setPin,
|
||||
sign,
|
||||
signSheet,
|
||||
signWithPassword,
|
||||
signWithPin,
|
||||
statusOf,
|
||||
stepApi,
|
||||
userId,
|
||||
type Actor,
|
||||
} from "./shophelpers";
|
||||
import {
|
||||
L,
|
||||
NCR,
|
||||
TEMPLATE_ITEMS,
|
||||
atQcApi,
|
||||
blockSign,
|
||||
cardOf,
|
||||
column,
|
||||
enabledApprove,
|
||||
itpBlock,
|
||||
itpRow,
|
||||
itpSection,
|
||||
logActions,
|
||||
makeQcDraft,
|
||||
makeQcProject,
|
||||
part,
|
||||
pdfFile,
|
||||
qcBar,
|
||||
qcChecks,
|
||||
textFile,
|
||||
type Crew,
|
||||
type QcPacket,
|
||||
type QcProject,
|
||||
} from "./qchelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
|
||||
// Phase 6 Q.C. scenarios on the CI stack, modelled on the old tests/ps titp, tqcf and tpdocs, plus the accept and
|
||||
// reject flows of the old qcTab. clerk.clara makes a project with two units; the crew (wh.wade, sup.wes,
|
||||
// weld.andy) takes two packets of unit 1 to Q.C. through the API. Accept: qc.quinn inspects on the Q.C. tab and
|
||||
// accepts to the data book with her PIN. Reject: one weld rejected, an NCR number only, her password; sup.wes sees
|
||||
// the callout and sends it to Q.C. again. tqcf: project and unit folders, a new revision, a refused file, wh.wade
|
||||
// has no Q.C. screen. tpdocs: a packet document added and taken out. titp: the digital ITP on unit 2 with the hold
|
||||
// callout on a 03 and an 11 packet, every line and block signed, sign-off, and an admin reopen.
|
||||
// Every UI label used here is in L (qchelpers.ts).
|
||||
|
||||
const RUN = Date.now().toString(36).toUpperCase().slice(-5);
|
||||
const PROJECT = `Q6${RUN}`;
|
||||
const T = {
|
||||
accept: `QC accept spool ${RUN}`,
|
||||
reject: `QC reject spool ${RUN}`,
|
||||
wp: `ITP weld spool ${RUN}`,
|
||||
fa: `ITP final spool ${RUN}`,
|
||||
};
|
||||
|
||||
let proj: QcProject;
|
||||
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 open(page: Page, id: number, tabName?: RegExp): Promise<void> {
|
||||
await page.goto(`/packets/${id}`);
|
||||
await expect(page.getByRole("heading").first()).toBeVisible();
|
||||
if (tabName) await packetTab(page, tabName).click();
|
||||
}
|
||||
|
||||
async function openQcHome(page: Page): Promise<void> {
|
||||
await page.goto("/qc");
|
||||
await expect(page.getByRole("heading", { name: L.homeHead })).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe.serial("qc", () => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
test("setup: a project with two units, two packets of unit 1 at Q.C., qc.quinn'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 makeQcProject(clara, PROJECT);
|
||||
P.accept = await atQcApi(crew, proj.units[0], T.accept, "Stall 2", "Bay 2");
|
||||
P.reject = await atQcApi(crew, proj.units[0], T.reject, "Stall 3", "Bay 2");
|
||||
// unit 2: a 03 packet released to the shop, and a draft released straight to 11 Final Assembly - North
|
||||
const wp = await makeQcDraft(clara, proj.units[1], T.wp);
|
||||
await pullAndRelease(wade, wp.id);
|
||||
P.wp = wp;
|
||||
const fa = await makeQcDraft(clara, proj.units[1], T.fa, { dept: "11", stay: true });
|
||||
const rel = await stepApi(clara, fa.id, "release_to_shop", { dept: "11" });
|
||||
expect(rel.status).toBe("ready");
|
||||
P.fa = fa;
|
||||
await setPin(quinn, PIN);
|
||||
await closeAll(clara, wade, wes, andy, quinn);
|
||||
});
|
||||
|
||||
test("accept: inspect on the Q.C. tab and accept to the data book with the PIN", async ({ browser }) => {
|
||||
const id = pk("accept").id;
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const page = quinn.page;
|
||||
|
||||
// the Q.C. screen lists it as waiting, with the spool location
|
||||
await openQcHome(page);
|
||||
await expect(page.getByText(L.homeEyebrow).first()).toBeVisible();
|
||||
for (const k of [L.kpiWaiting, L.kpiAccepted, L.kpiRejected])
|
||||
await expect(page.getByText(k).first()).toBeVisible();
|
||||
const ready = column(page, L.colReady);
|
||||
const card = cardOf(ready, id);
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText(L.spoolAt("Bay 2"));
|
||||
await card.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/packets/${id}`));
|
||||
|
||||
// the Q.C. bar lists what is missing and Accept is disabled
|
||||
const bar = qcBar(page);
|
||||
await expect(bar).toBeVisible();
|
||||
const missing = bar.getByTestId(L.qcMissing);
|
||||
await expect(missing).toContainText(L.stillNeeded);
|
||||
await expect(missing).toContainText("10 inspection items");
|
||||
await expect(missing).toContainText("2 welds without a visual result");
|
||||
await expect(missing).toContainText("inspector name");
|
||||
const acceptBtn = bar.getByRole("button", { name: L.accept, exact: true });
|
||||
await expect(acceptBtn).toBeDisabled();
|
||||
|
||||
// the Q.C. tab: inspector and stamp first, so the ticks carry the stamp
|
||||
await packetTab(page, L.tabQc).click();
|
||||
await page.getByLabel(L.inspector, { exact: true }).fill("Quinn QC");
|
||||
await page.getByLabel(L.stamp, { exact: true }).fill("Q1");
|
||||
await page.getByLabel(L.stamp, { exact: true }).blur();
|
||||
await expect.poll(async () => (await packet(quinn, id)).qc.stamp).toBe("Q1");
|
||||
const boxes = qcChecks(page);
|
||||
await expect(boxes).toHaveCount(10);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await boxes.nth(i).check();
|
||||
await expect(boxes.nth(i)).toBeChecked();
|
||||
}
|
||||
await expect(page.getByText(L.stampTick).first()).toBeVisible();
|
||||
// both welds accepted
|
||||
const vt = part(page, L.vtSection);
|
||||
for (const no of [1, 2]) {
|
||||
const btn = vt.getByRole("row", { name: L.weldRow(no) }).getByRole("button", { name: L.vtAccept, exact: true });
|
||||
await btn.click();
|
||||
await expect(btn).toHaveAttribute("aria-pressed", "true");
|
||||
}
|
||||
// NDE: RT, Acceptable
|
||||
await page.getByLabel(L.ndeMethod, { exact: true }).selectOption("RT");
|
||||
await page.getByLabel(L.ndeResult, { exact: true }).selectOption("Acceptable");
|
||||
await expect.poll(async () => (await packet(quinn, id)).qc.nde_result).toBe("Acceptable");
|
||||
|
||||
// Accept is enabled; route to the data book; sign with the PIN
|
||||
await expect(missing).toBeHidden();
|
||||
await expect(acceptBtn).toBeEnabled();
|
||||
await bar.getByLabel(L.route, { exact: true }).selectOption({ label: L.routeDataBook });
|
||||
await acceptBtn.click();
|
||||
await signWithPin(page, PIN);
|
||||
await expect(signSheet(page)).toBeHidden();
|
||||
await expect.poll(() => statusOf(quinn, id)).toBe("complete");
|
||||
await expect(page.getByTestId(L.statusPill)).toContainText(L.statusRouted);
|
||||
const after = await packet(quinn, id);
|
||||
expect(after.routed_to).toBe("databook");
|
||||
|
||||
// History has the accept text
|
||||
await packetTab(page, L.tabHistory).click();
|
||||
await expect(page.getByText(`Q.C. accepted → ${L.routeDataBook}`).first()).toBeVisible();
|
||||
expect(await logActions(quinn, id)).toContain(`Q.C. accepted → ${L.routeDataBook}`);
|
||||
|
||||
// the Q.C. screen's Accepted column has it
|
||||
await openQcHome(page);
|
||||
await expect(cardOf(column(page, L.colAccepted), id)).toBeVisible();
|
||||
await closeAll(quinn);
|
||||
});
|
||||
|
||||
test("reject: one weld rejected, an NCR number only, the password; the supervisor sends it again", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const id = pk("reject").id;
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const page = quinn.page;
|
||||
await open(page, id, L.tabQc);
|
||||
|
||||
// weld 1 rejected with a note
|
||||
const w1 = part(page, L.vtSection).getByRole("row", { name: L.weldRow(1) });
|
||||
await w1.getByRole("button", { name: L.vtReject, exact: true }).click();
|
||||
const defect = w1.getByLabel(L.weldNote(1));
|
||||
await expect(defect).toHaveAttribute("placeholder", L.defectNote);
|
||||
await defect.fill("undercut");
|
||||
await defect.blur();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const exam = (await packet(quinn, id)).qc.weld_exam as Json;
|
||||
return Object.values(exam).map((e) => `${(e as Json).result}:${(e as Json).note ?? ""}`);
|
||||
})
|
||||
.toContain("reject:undercut");
|
||||
|
||||
// the reject sheet: enabled on an NCR number alone
|
||||
const bar = qcBar(page);
|
||||
await bar.getByRole("button", { name: L.reject, exact: true }).click();
|
||||
const sheet = page.getByRole("dialog", { name: L.rejectSheet, exact: true });
|
||||
await expect(sheet).toBeVisible();
|
||||
const submitReject = sheet.getByRole("button", { name: L.rejectSubmit });
|
||||
await expect(submitReject).toBeDisabled();
|
||||
await sheet.getByLabel(L.ncrNumber, { exact: true }).fill(NCR);
|
||||
await expect(submitReject).toBeEnabled();
|
||||
await submitReject.click();
|
||||
await signWithPassword(page, "qc.quinn");
|
||||
await expect.poll(() => statusOf(quinn, id)).toBe("review");
|
||||
await expect(page.getByTestId(L.statusPill)).toContainText(L.statusReview);
|
||||
const after = await packet(quinn, id);
|
||||
expect(after.qc).toBeNull();
|
||||
expect(after.qc_last.result).toBe("rejected");
|
||||
expect(after.qc_last.ncr).toBe(NCR);
|
||||
|
||||
// the Q.C. screen's rejected column has it
|
||||
await openQcHome(page);
|
||||
await expect(cardOf(column(page, L.colRejected), id)).toBeVisible();
|
||||
|
||||
// sup.wes sees the callout with the NCR link and the rejected weld
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
await open(wes.page, id);
|
||||
const callout = wes.page.getByRole("note").filter({ hasText: L.qcRejected }).first();
|
||||
await expect(callout).toBeVisible();
|
||||
const link = callout.getByRole("link", { name: new RegExp(NCR) });
|
||||
await expect(link).toHaveAttribute("href", new RegExp(`/ncrs/${NCR}$`));
|
||||
await expect(link).toHaveAttribute("rel", /noopener/);
|
||||
await expect(callout.getByText(L.rejectedWelds("W1"), { exact: false })).toBeVisible();
|
||||
|
||||
// the review box sends it to Q.C. again (password: sup.wes has no PIN)
|
||||
await wes.page.getByLabel(SL.bookReady).check();
|
||||
await wes.page.getByLabel(SL.spoolReady).check();
|
||||
await wes.page.getByLabel(SL.spoolLocation).fill("Bay 4");
|
||||
await wes.page.getByRole("button", { name: SL.sendToQc, exact: true }).click();
|
||||
await signWithPassword(wes.page, "sup.wes");
|
||||
await expect.poll(() => statusOf(wes, id)).toBe("qc");
|
||||
|
||||
// a fresh empty inspection; the first one is under Earlier inspections
|
||||
await open(page, id, L.tabQc);
|
||||
const boxes = qcChecks(page);
|
||||
await expect(boxes).toHaveCount(10);
|
||||
for (let i = 0; i < 10; i++) await expect(boxes.nth(i)).not.toBeChecked();
|
||||
const earlier = part(page, L.earlier);
|
||||
await expect(earlier).toBeVisible();
|
||||
await expect(earlier).toContainText(L.rejectedPill);
|
||||
await expect(earlier).toContainText(NCR);
|
||||
await closeAll(quinn, wes);
|
||||
});
|
||||
|
||||
test("tqcf: project and unit folders, a new revision, a refused file, no Q.C. screen for wh.wade", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const page = quinn.page;
|
||||
const unit1 = proj.units[0];
|
||||
|
||||
// the home's folder card
|
||||
await openQcHome(page);
|
||||
const folderCard = part(page, L.folders).locator(L.pcard).filter({ hasText: PROJECT }).first();
|
||||
await expect(folderCard).toBeVisible();
|
||||
await expect(folderCard).toContainText(L.folderUnits(2));
|
||||
await folderCard.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/qc/projects/${proj.id}`));
|
||||
await expect(page.getByText(L.allFolders).first()).toBeVisible();
|
||||
|
||||
// a project WPS
|
||||
await page.getByRole("button", { name: L.addQcDoc }).first().click();
|
||||
let sheet = page.getByRole("dialog", { name: L.qcDocSheetAdd });
|
||||
await expect(sheet).toBeVisible();
|
||||
await sheet.getByLabel(L.qdType, { exact: true }).selectOption("WPS / PQR");
|
||||
await sheet.getByLabel(L.qdTitle, { exact: true }).fill("WPS-101 GTAW/SMAW");
|
||||
await sheet.getByLabel(L.qdRev, { exact: true }).fill("2");
|
||||
await sheet.getByLabel(L.qdFile).setInputFiles(pdfFile("wps.pdf"));
|
||||
await sheet.getByRole("button", { name: L.addToFolder }).click();
|
||||
await expect(sheet).toBeHidden();
|
||||
const projectDocs = part(page, L.projectDocs);
|
||||
await expect(projectDocs.getByText("WPS-101 GTAW/SMAW").first()).toBeVisible();
|
||||
await expect(projectDocs.getByText("Rev 2").first()).toBeVisible();
|
||||
|
||||
// unit 1: no ITP yet; add an ITP document
|
||||
await page.locator(L.qcUnit).filter({ hasText: String(unit1.number) }).first().click();
|
||||
await expect(page).toHaveURL(new RegExp(`/qc/units/${unit1.id}`));
|
||||
await expect(page.getByText(L.noItpCallout)).toBeVisible();
|
||||
await page.getByRole("button", { name: L.addQcDoc }).first().click();
|
||||
sheet = page.getByRole("dialog", { name: L.qcDocSheetAdd });
|
||||
await expect(sheet.getByLabel(L.qdType, { exact: true })).toHaveValue("ITP");
|
||||
await sheet.getByLabel(L.qdTitle, { exact: true }).fill(`ITP ${unit1.number}`);
|
||||
await sheet.getByLabel(L.qdFile).setInputFiles(pdfFile("itp.pdf"));
|
||||
await sheet.getByRole("button", { name: L.addToFolder }).click();
|
||||
await expect(sheet).toBeHidden();
|
||||
const unitDocs = part(page, L.unitDocs);
|
||||
await expect(unitDocs.getByText(`ITP ${unit1.number}`).first()).toBeVisible();
|
||||
await expect(page.getByText(L.noItpCallout)).toBeHidden();
|
||||
// the project's WPS is listed as inherited
|
||||
await expect(part(page, L.fromProject(PROJECT)).getByText("WPS-101 GTAW/SMAW").first()).toBeVisible();
|
||||
|
||||
// a text file is refused with the server's message
|
||||
await page.getByRole("button", { name: L.addQcDoc }).first().click();
|
||||
sheet = page.getByRole("dialog", { name: L.qcDocSheetAdd });
|
||||
await sheet.getByLabel(L.qdFile).setInputFiles(textFile());
|
||||
await sheet.getByRole("button", { name: L.addToFolder }).click();
|
||||
await expect(sheet.getByRole("alert")).toContainText(L.badFile);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(sheet).toBeHidden();
|
||||
|
||||
// a new revision of the ITP document
|
||||
await unitDocs.getByRole("button", { name: L.newRevision }).first().click();
|
||||
sheet = page.getByRole("dialog", { name: L.qcDocSheetRev });
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet).toContainText("The old one stays on file under Superseded.");
|
||||
await sheet.getByLabel(L.qdRev, { exact: true }).fill("1");
|
||||
await sheet.getByLabel(L.qdFile).setInputFiles(pdfFile("itp-r1.pdf"));
|
||||
await sheet.getByRole("button", { name: L.fileNewRev }).click();
|
||||
await expect(sheet).toBeHidden();
|
||||
await expect(unitDocs.locator(L.qcDocRow)).toHaveCount(1);
|
||||
await expect(unitDocs.getByText("Rev 1").first()).toBeVisible();
|
||||
const old = part(page, L.superseded);
|
||||
await old.getByRole("button", { name: L.show, exact: true }).click();
|
||||
await expect(old.getByText(`ITP ${unit1.number}`).first()).toBeVisible();
|
||||
await expect(old.getByText(/superseded .* by /i).first()).toBeVisible();
|
||||
|
||||
// back to the project, back to home
|
||||
await page.getByRole("button", { name: L.back }).or(page.getByRole("link", { name: L.back })).first().click();
|
||||
await expect(page).toHaveURL(new RegExp(`/qc/projects/${proj.id}`));
|
||||
await expect(page.locator(L.qcUnit)).toHaveCount(2);
|
||||
await page.getByRole("button", { name: L.back }).or(page.getByRole("link", { name: L.back })).first().click();
|
||||
await expect(page).toHaveURL(/\/qc\/?$/);
|
||||
|
||||
// a packet of unit 1 lists both documents in its Documents tab
|
||||
await open(page, pk("accept").id, L.tabDocs);
|
||||
const fromFolder = part(page, L.alsoInDataBook);
|
||||
await expect(fromFolder.getByText("WPS-101 GTAW/SMAW").first()).toBeVisible();
|
||||
await expect(fromFolder.getByText(`ITP ${unit1.number}`).first()).toBeVisible();
|
||||
|
||||
// wh.wade has no Q.C. screen
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
await wade.page.goto("/qc");
|
||||
await expect(wade.page).not.toHaveURL(/\/qc(\/|$)/);
|
||||
await closeAll(quinn, wade);
|
||||
});
|
||||
|
||||
test("tpdocs: a packet document added, refused, seen read-only, taken out", async ({ browser }) => {
|
||||
const id = pk("reject").id;
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const page = quinn.page;
|
||||
await open(page, id, L.tabDocs);
|
||||
await expect(part(page, L.docsSection)).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: L.addDocument }).click();
|
||||
let sheet = page.getByRole("dialog", { name: L.addDocSheet });
|
||||
await expect(sheet).toBeVisible();
|
||||
await sheet.getByLabel(L.docType, { exact: true }).selectOption({ label: L.ndeKind });
|
||||
await sheet.getByLabel(L.docTitle, { exact: true }).fill("RT report W1-W4");
|
||||
await sheet.getByLabel(L.docNote).fill("RPT-2210, accept");
|
||||
await sheet.getByLabel(L.docFile).setInputFiles(pdfFile("rt.pdf"));
|
||||
await sheet.getByRole("button", { name: L.addToPacket }).click();
|
||||
await expect(sheet).toBeHidden();
|
||||
const docs = part(page, L.docsSection);
|
||||
await expect(docs.getByText("RT report W1-W4").first()).toBeVisible();
|
||||
expect(await logActions(quinn, id)).toContain("Document added");
|
||||
await packetTab(page, L.tabHistory).click();
|
||||
await expect(page.getByText("Document added").first()).toBeVisible();
|
||||
|
||||
// a text file is refused
|
||||
await packetTab(page, L.tabDocs).click();
|
||||
await page.getByRole("button", { name: L.addDocument }).click();
|
||||
sheet = page.getByRole("dialog", { name: L.addDocSheet });
|
||||
await sheet.getByLabel(L.docFile).setInputFiles(textFile());
|
||||
await sheet.getByRole("button", { name: L.addToPacket }).click();
|
||||
await expect(sheet.getByRole("alert")).toContainText(L.badFile);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(sheet).toBeHidden();
|
||||
|
||||
// wh.wade sees the row and no Add document
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
await open(wade.page, id, L.tabDocs);
|
||||
await expect(part(wade.page, L.docsSection).getByText("RT report W1-W4").first()).toBeVisible();
|
||||
await expect(wade.page.getByRole("button", { name: L.addDocument })).toHaveCount(0);
|
||||
await expect(wade.page.getByRole("button", { name: L.takeOut })).toHaveCount(0);
|
||||
|
||||
// qc.quinn takes it out with a reason
|
||||
await docs.getByRole("button", { name: L.takeOut }).first().click();
|
||||
const out = page.getByRole("dialog", { name: L.takeOutSheet });
|
||||
await expect(out).toBeVisible();
|
||||
await out.getByLabel(L.takeOutReason, { exact: true }).fill("wrong packet");
|
||||
await out.getByRole("button", { name: L.takeOutSubmit, exact: true }).click();
|
||||
await expect(out).toBeHidden();
|
||||
const toggle = page.getByRole("button", { name: L.showTakenOut(1) });
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
await expect(page.getByText(L.takenOutBy).first()).toContainText("wrong packet");
|
||||
const p = await packet(quinn, id);
|
||||
const row = (p.docs as Json[]).find((d) => d.title === "RT report W1-W4") as Json;
|
||||
expect(row.removed.reason).toBe("wrong packet");
|
||||
await packetTab(page, L.tabHistory).click();
|
||||
await expect(page.getByText("Document taken out of the data book").first()).toBeVisible();
|
||||
await closeAll(quinn, wade);
|
||||
});
|
||||
|
||||
test("titp: the digital ITP, the hold callout, every block signed, sign-off, reopen", async ({ browser }) => {
|
||||
const unit2 = proj.units[1];
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const page = quinn.page;
|
||||
|
||||
// start from the unit folder
|
||||
await page.goto(`/qc/units/${unit2.id}`);
|
||||
await expect(page.getByText(L.noItp)).toBeVisible();
|
||||
await page.getByRole("button", { name: L.startItp }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/qc/units/${unit2.id}/itp`));
|
||||
const no = `${unit2.number}-ITP`;
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText(no);
|
||||
await expect(page.locator(L.itpRow)).toHaveCount(TEMPLATE_ITEMS);
|
||||
const banners = page.locator(L.itpPage);
|
||||
await expect(banners).toHaveCount(3);
|
||||
for (let i = 0; i < 3; i++) await expect(banners.nth(i)).toContainText(L.pageBanners[i]);
|
||||
await expect(page.getByLabel(L.hdr.no, { exact: true })).toHaveValue(no);
|
||||
await expect(page.getByLabel(L.hdr.client, { exact: true })).toHaveValue("ACME");
|
||||
await expect(page.getByLabel(L.hdr.pname, { exact: true })).toHaveValue("Separator skids");
|
||||
await expect(page.getByLabel(L.hdr.pno, { exact: true })).toHaveValue(PROJECT);
|
||||
await expect(page.getByLabel(L.hdr.po, { exact: true })).toHaveValue("5541");
|
||||
await expect(page.getByLabel(L.hdr.unit, { exact: true })).toHaveValue(String(unit2.number));
|
||||
|
||||
// approve 1.1: the initials badge shows
|
||||
await itpRow(page, "1.1").getByRole("button", { name: L.approve, exact: true }).click();
|
||||
await expect(itpRow(page, "1.1").locator(L.initials)).toHaveText("QQ");
|
||||
|
||||
// section 5 N/A: its rows get the na class
|
||||
await itpSection(page, "5").getByRole("button", { name: L.sectionNa }).click();
|
||||
await expect(itpSection(page, "5").locator(`${L.itpRow}.na`)).toHaveCount(5);
|
||||
|
||||
// line 1.2 details: a customer witness and a note
|
||||
await page.getByRole("button", { name: L.lineDetails("1.2") }).click();
|
||||
const lineSheet = page.getByRole("dialog", { name: L.lineSheet });
|
||||
await expect(lineSheet).toBeVisible();
|
||||
await lineSheet.getByLabel(L.custWitness).fill("Rick Inspector");
|
||||
await lineSheet.getByLabel(L.lineNote, { exact: true }).fill("Reviewed WPS-101 R2");
|
||||
await lineSheet.getByRole("button", { name: L.save, exact: true }).click();
|
||||
await expect(lineSheet).toBeHidden();
|
||||
await expect(itpRow(page, "1.2")).toContainText("Rick Inspector");
|
||||
|
||||
// block 5 (all N/A) can be signed; block 3 cannot yet
|
||||
await expect(itpBlock(page, "5")).toContainText(L.blockScope("Section 5"));
|
||||
await expect(itpBlock(page, "3")).toContainText(L.blockScope("Sections 1–3"));
|
||||
await expect(blockSign(page, "5")).toBeEnabled();
|
||||
await expect(blockSign(page, "5")).toHaveText(L.signAllNa);
|
||||
await expect(blockSign(page, "3")).toBeDisabled();
|
||||
await blockSign(page, "5").click();
|
||||
await signWithPin(page, PIN);
|
||||
await expect(signSheet(page)).toBeHidden();
|
||||
await expect(itpBlock(page, "5")).toContainText("Quinn QC");
|
||||
|
||||
// sup.wes on the 03 packet of unit 2: WP points only; the 11 packet: FA points
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
await open(wes.page, pk("wp").id);
|
||||
const hold = wes.page.locator(L.holdCallout);
|
||||
await expect(hold).toBeVisible();
|
||||
await expect(hold).toContainText(`ITP ${no}`);
|
||||
await expect(hold).toContainText("3.4");
|
||||
await expect(hold).not.toContainText("4.2");
|
||||
await expect(hold).toContainText(L.holdAdvice);
|
||||
await open(wes.page, pk("fa").id);
|
||||
await expect(wes.page.locator(L.holdCallout)).toContainText("9.6");
|
||||
await expect(wes.page.locator(L.holdCallout)).not.toContainText("3.4");
|
||||
|
||||
// qc.quinn approves every remaining line
|
||||
const approve = enabledApprove(page);
|
||||
for (let k = 0; k < TEMPLATE_ITEMS + 5; k++) {
|
||||
const n = await approve.count();
|
||||
if (!n) break;
|
||||
await approve.first().click();
|
||||
await expect(approve).toHaveCount(n - 1);
|
||||
}
|
||||
await expect(approve).toHaveCount(0);
|
||||
// the customer on block 3, then blocks 3, 4, 6, 7, 8, 9
|
||||
const b3 = itpBlock(page, "3");
|
||||
await b3.getByLabel(L.custRepName).fill("Rick Inspector");
|
||||
await b3.getByRole("button", { name: L.record, exact: true }).click();
|
||||
await expect(b3).toContainText("recorded by");
|
||||
for (const key of ["3", "4", "6", "7", "8", "9"]) {
|
||||
await expect(blockSign(page, key)).toBeEnabled();
|
||||
await blockSign(page, key).click();
|
||||
await signWithPin(page, PIN);
|
||||
await expect(signSheet(page)).toBeHidden();
|
||||
await expect(blockSign(page, key)).toHaveCount(0);
|
||||
}
|
||||
|
||||
// sign off with the PIN
|
||||
const signOffBtn = page.getByRole("button", { name: L.signOff });
|
||||
await expect(signOffBtn).toBeVisible();
|
||||
await signOffBtn.click();
|
||||
await signWithPin(page, PIN);
|
||||
await expect(signSheet(page)).toBeHidden();
|
||||
await expect(page.getByRole("note").filter({ hasText: L.signedOffCallout }).first()).toBeVisible();
|
||||
await expect(page.locator(L.itpRow).getByRole("button", { name: L.approve, exact: true })).toHaveCount(0);
|
||||
await expect(page.getByLabel(L.hdr.client, { exact: true })).toBeDisabled();
|
||||
const itp = await api(quinn, "get", `/api/units/${unit2.id}/itp`);
|
||||
expect(itp.status).toBe("complete");
|
||||
|
||||
// the hold callout on the 03 packet is gone
|
||||
await open(wes.page, pk("wp").id);
|
||||
await expect(wes.page.locator(L.holdCallout)).toHaveCount(0);
|
||||
|
||||
// the project folder's unit tile shows ITP ✓
|
||||
await page.goto(`/qc/projects/${proj.id}`);
|
||||
await expect(page.locator(L.qcUnit).filter({ hasText: String(unit2.number) })).toContainText(L.itpTick);
|
||||
|
||||
// admin.ann reopens with a reason; the page is editable again
|
||||
const ann = await actor(browser, "admin.ann");
|
||||
await ann.page.goto(`/qc/units/${unit2.id}/itp`);
|
||||
await ann.page.getByRole("button", { name: L.reopen, exact: true }).click();
|
||||
const reopen = ann.page.getByRole("dialog", { name: L.reopenSheet });
|
||||
await expect(reopen).toBeVisible();
|
||||
await reopen.getByLabel(L.reopenReason, { exact: true }).fill("Customer asked to re-inspect 4.2");
|
||||
await reopen.getByRole("button", { name: new RegExp(`^${L.reopen}`) }).click();
|
||||
await expect(reopen).toBeHidden();
|
||||
await expect.poll(async () => (await api(ann, "get", `/api/units/${unit2.id}/itp`)).status).toBe("open");
|
||||
await page.goto(`/qc/units/${unit2.id}/itp`); // quinn's page was on the project folder
|
||||
await expect(page.getByLabel(L.hdr.client, { exact: true })).toBeEnabled();
|
||||
await closeAll(quinn, wes, ann);
|
||||
});
|
||||
|
||||
test("api: the signature and the step body used here still answer as the contract says", async ({ browser }) => {
|
||||
// A guard for the helpers: a reject without a reason or an NCR is refused before any signature is asked.
|
||||
const quinn: Actor = await actor(browser, "qc.quinn");
|
||||
const p = await packet(quinn, pk("reject").id);
|
||||
expect(p.status).toBe("qc");
|
||||
await stepApi(quinn, p.id, "qc_reject", { sign: sign("qc.quinn") }, 400);
|
||||
await closeAll(quinn);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { section } from "./helpers";
|
||||
import {
|
||||
api,
|
||||
assignApi,
|
||||
finishStageApi,
|
||||
pullAndRelease,
|
||||
sign,
|
||||
stepApi,
|
||||
type Actor,
|
||||
} from "./shophelpers";
|
||||
import { onePagePdf, writeHeaders, type Json } from "./whhelpers";
|
||||
|
||||
// Helpers for the phase 6 Q.C. scenarios: a project with two units, packets taken to status qc through the API,
|
||||
// and the UI labels the Q.C. spec depends on (L). The labels are the texts of the phase 6 design contract
|
||||
// (Frontend B and C). The main session adjusts them here in one place when the components differ.
|
||||
|
||||
export const L = {
|
||||
// Q.C. home (screens/qc/QcHome.tsx)
|
||||
homeEyebrow: "Q.C. · Inspection",
|
||||
homeHead: "Inspect the spool and the data book",
|
||||
kpiWaiting: "Waiting for inspection",
|
||||
kpiAccepted: "Accepted last 7 days",
|
||||
kpiRejected: "Rejected last 7 days",
|
||||
colReady: "Ready for inspection",
|
||||
colAccepted: "Accepted",
|
||||
colRejected: "Rejected — back with supervisor", // the old heading text, em dash kept
|
||||
spoolAt: (loc: string) => `Spool at ${loc}`,
|
||||
findJob: "Find a job",
|
||||
folders: "Project folders",
|
||||
chipActive: "Active",
|
||||
chipAll: "All",
|
||||
folderUnits: (n: number) => new RegExp(`${n} units?\\b`),
|
||||
// packet page: tabs, status, callouts
|
||||
tabQc: /^Q\.C\./,
|
||||
tabDocs: /^Documents/,
|
||||
tabHistory: /^History/,
|
||||
statusPill: "status-pill", // testid (phase 5)
|
||||
statusRouted: /Routed|Complete/, // the contract says "Routed"; the status label of complete is "Complete"
|
||||
statusReview: "Supervisor review",
|
||||
qcRejected: /^Q\.C\. rejected/,
|
||||
rejectedWelds: (w: string) => `Rejected welds: ${w}`,
|
||||
holdCallout: ".itphold", // .callout.warn.itphold, role note
|
||||
holdAdvice: "Check with Q.C. before moving past these points.",
|
||||
openItp: "Open ITP",
|
||||
// the Q.C. bar (packet/QcActions.tsx)
|
||||
qcBar: "Q.C. inspection", // region name
|
||||
qcMissing: "qc-missing", // testid
|
||||
stillNeeded: /^Still needed/,
|
||||
route: "Route",
|
||||
routeDataBook: "Clerical – Data Book", // the en dash is the old text
|
||||
routeShipping: "Shipping",
|
||||
accept: "Accept",
|
||||
reject: "Reject",
|
||||
rejectSheet: "Reject",
|
||||
rejectReason: "Reason / what failed",
|
||||
ncrNumber: "NCR number",
|
||||
rejectSubmit: "Reject to supervisor",
|
||||
// the Q.C. tab (packet/QcTab.tsx)
|
||||
inspector: "Inspector",
|
||||
stamp: "Stamp / ID",
|
||||
checkItem: ".ck-item",
|
||||
// the ten check boxes are named by their item text (B: aria-label is the text)
|
||||
checkBoxes:
|
||||
/^(Heat number recorded|MTRs on file|Weld map complete|Welds made to|Stall checklist|Dimensions verified|Heat numbers on material|Welds stamped|Clean: no slag|Quantity matches)/,
|
||||
stampTick: /^Q1 · /,
|
||||
vtSection: "Visual weld inspection", // region
|
||||
weldRow: (no: number) => `Weld W${no}`, // role row
|
||||
weldNote: (no: number) => `W${no} note`,
|
||||
vtAccept: "Accept",
|
||||
vtReject: "Reject",
|
||||
defectNote: "Defect (undercut, porosity…)", // placeholder while rejected
|
||||
ndeSection: /^NDE & testing/,
|
||||
ndeMethod: "NDE method",
|
||||
ndeResult: "NDE result",
|
||||
reportNo: "Report no.",
|
||||
inspectionNotes: "Inspection notes",
|
||||
earlier: "Earlier inspections", // region
|
||||
rejectedPill: "REJECTED",
|
||||
// the Documents tab (packet/tabs.tsx)
|
||||
docsSection: "Documents for the data book", // region
|
||||
addDocument: /Add document$/, // an aria-hidden + comes first
|
||||
addDocSheet: "Add a document",
|
||||
docType: "Type",
|
||||
docTitle: "Title",
|
||||
docNote: "Note (optional)",
|
||||
docFile: "File (PDF, PNG or JPG, up to 25 MB)",
|
||||
addToPacket: "Add to packet",
|
||||
view: "View",
|
||||
takeOut: "Take out of data book",
|
||||
takeOutSheet: "Take out of the data book",
|
||||
takeOutReason: "Reason",
|
||||
takeOutSubmit: "Take out",
|
||||
showTakenOut: (n: number) => `Show ${n} taken out`,
|
||||
takenOutBy: /^Taken out of the data book by/,
|
||||
alsoInDataBook: "Also in the data book from the Q.C. folder", // region
|
||||
ndeKind: "NDE report (RT / UT / MT / PT)",
|
||||
badFile: "Only PDF, PNG and JPEG files can be stored.", // the upload's 415 text
|
||||
// Q.C. folders (screens/qc/QcProjectFolder.tsx, QcUnitFolder.tsx, QcDocSheet.tsx)
|
||||
back: /Back$/, // "← Back"
|
||||
allFolders: "All project folders",
|
||||
addQcDoc: /Add document$/, // "+ Add document"
|
||||
qcDocSheetAdd: "Add document",
|
||||
qcDocSheetRev: "New revision",
|
||||
qdType: "Type",
|
||||
qdTitle: "Title",
|
||||
qdRev: "Rev",
|
||||
qdFile: "File (PDF, PNG or JPG, up to 25 MB)",
|
||||
qdNote: "Note",
|
||||
addToFolder: "Add to folder",
|
||||
fileNewRev: "File new revision",
|
||||
newRevision: "New revision",
|
||||
projectDocs: "Project documents", // region and list
|
||||
unitFolders: /^Unit folders/,
|
||||
unitDocs: "Unit documents", // region and list
|
||||
fromProject: (n: string) => `From project ${n}`, // region
|
||||
superseded: /^Superseded/, // region (the count follows)
|
||||
show: "Show",
|
||||
hide: "Hide",
|
||||
noItpCallout: "No ITP on file for this unit yet.",
|
||||
qcDocRow: ".qcdoc", // one document row (listitem)
|
||||
qcUnit: ".qcunit",
|
||||
pcard: ".pcard", // inside the region Project folders
|
||||
itpTick: "ITP ✓",
|
||||
// the ITP (screens/qc/ItpPage.tsx)
|
||||
noItp: "No digital ITP yet.",
|
||||
startItp: "Start ITP from template",
|
||||
itpRow: ".itprow:not(.hd)", // the item rows; each section also has one .itprow.hd header row
|
||||
itpPage: ".itppage",
|
||||
itpSec: ".itpsec",
|
||||
itpBlock: ".itpblock",
|
||||
lineGroup: (n: string) => `Line ${n}`, // role group, exact
|
||||
sectionRegion: (n: string) => new RegExp(`^${n}\\. `), // region "5. INTERNAL COATING"
|
||||
blockGroup: (key: string) => `ITP block ${key}`, // role group
|
||||
approve: "Approve",
|
||||
sectionNa: "Section N/A",
|
||||
sectionApplies: "Section applies",
|
||||
lineDetails: (n: string) => `Line ${n} details`,
|
||||
lineSheet: "ITP line",
|
||||
custWitness: "Customer witness / reviewer",
|
||||
lineNote: "Note",
|
||||
save: "Save",
|
||||
signSection: /^Sign section/,
|
||||
signAllNa: /^Sign \(all N\/A\)/,
|
||||
custRepName: "Customer rep name",
|
||||
record: "Record",
|
||||
signOff: "Sign off ITP",
|
||||
reopen: "Reopen",
|
||||
reopenSheet: "Reopen ITP",
|
||||
reopenReason: "Reason",
|
||||
signedOffCallout: /^Signed off .* by /,
|
||||
initials: ".ini",
|
||||
hdr: {
|
||||
no: "Inspection plan no.",
|
||||
client: "Client",
|
||||
pname: "Project name",
|
||||
pno: "Project no.",
|
||||
po: "Purchase order",
|
||||
unit: "Unit no.",
|
||||
},
|
||||
pageBanners: [/1 of 3 · WP Weld Parts/, /2 of 3 · C Code/, /3 of 3 · FA Final Assembly/],
|
||||
blockScope: (scope: string) => `PESCO Q.C. signature · ${scope}`,
|
||||
// shared (phase 5 review box, signature sheet)
|
||||
bookReady: "Data book package ready",
|
||||
spoolReady: "Spool ready for inspection",
|
||||
spoolLocation: "Spool location",
|
||||
sendToQc: "Send to Q.C.",
|
||||
} as const;
|
||||
|
||||
export const NCR = "NCR-2026-0021";
|
||||
export const TEMPLATE_ITEMS = 70; // counted from src/js/itp.js ITP_TMPL
|
||||
|
||||
export interface QcProject {
|
||||
id: number;
|
||||
number: string;
|
||||
units: Json[]; // two units: [0] for the inspections and documents, [1] for the ITP
|
||||
}
|
||||
|
||||
export interface QcPacket {
|
||||
id: number;
|
||||
title: string;
|
||||
unitNumber: string;
|
||||
}
|
||||
|
||||
/** As clerk.clara: a project with a customer and a PO, and two units. */
|
||||
export async function makeQcProject(
|
||||
clara: Actor,
|
||||
number: string,
|
||||
name = "Separator skids",
|
||||
): Promise<QcProject> {
|
||||
const project = await api(
|
||||
clara,
|
||||
"post",
|
||||
"/api/projects",
|
||||
{ number, name, customer: "ACME", po: "5541" },
|
||||
{ status: 201 },
|
||||
);
|
||||
const units = (await api(
|
||||
clara,
|
||||
"post",
|
||||
`/api/projects/${project.id}/units`,
|
||||
{ count: 2 },
|
||||
{ status: 201 },
|
||||
)) as unknown as Json[];
|
||||
expect(units.length).toBe(2);
|
||||
return { id: Number(project.id), number: String(project.number), units };
|
||||
}
|
||||
|
||||
/**
|
||||
* As clerk.clara: a hand draft on `unit` in `dept` with two work orders and three BOM lines, a one-page PDF
|
||||
* drawing attached with her password, the heat map answered. Sent to the warehouse unless `stay` is set.
|
||||
*/
|
||||
export async function makeQcDraft(
|
||||
clara: Actor,
|
||||
unit: Json,
|
||||
title: string,
|
||||
opts: { dept?: string; stay?: boolean } = {},
|
||||
): Promise<QcPacket> {
|
||||
const tag = `${unit.number ?? unit.id}`.slice(-8);
|
||||
const draft = await api(
|
||||
clara,
|
||||
"post",
|
||||
"/api/packets",
|
||||
{
|
||||
unit_id: unit.id,
|
||||
dept_code: opts.dept ?? "03",
|
||||
op: "WELD-PART",
|
||||
title,
|
||||
customer: "ACME",
|
||||
drawing: `D-${tag}`,
|
||||
rev: "A",
|
||||
wps: "WPS-11",
|
||||
work_orders: [
|
||||
{ wo_base: `W${tag}`.slice(0, 20), lot: "001", split: "0", sub: "0", qty: 2, target_hrs: 2 },
|
||||
{ wo_base: `X${tag}`.slice(0, 20), lot: "001", split: "0", sub: "0", qty: 1, target_hrs: 2.5 },
|
||||
],
|
||||
bom: [
|
||||
{ item: 1, qty: 1, part_id: "PIPE-4", description: "4in pipe SCH40" },
|
||||
{ item: 2, qty: 2, part_id: "FLG-4", description: "4in flange" },
|
||||
{ item: 3, qty: 1, part_id: "STUD-58", description: "5/8 stud bolt" },
|
||||
],
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
const asset = await uploadPdf(clara, `${tag}.pdf`, "drawing");
|
||||
await api(clara, "post", `/api/packets/${draft.id}/pages`, {
|
||||
asset_id: asset.id,
|
||||
type: "drawing",
|
||||
sign: sign("clerk.clara"),
|
||||
});
|
||||
await api(clara, "patch", `/api/packets/${draft.id}`, { set: { heat_map: true }, base: {} });
|
||||
if (!opts.stay) {
|
||||
const sent = await stepApi(clara, Number(draft.id), "send_to_warehouse");
|
||||
expect(sent.status).toBe("warehouse");
|
||||
}
|
||||
return { id: Number(draft.id), title, unitNumber: String(unit.number ?? "") };
|
||||
}
|
||||
|
||||
/** Upload a one-page PDF with a purpose (document by default). */
|
||||
export async function uploadPdf(a: Actor, name: string, purpose = "document"): Promise<Json> {
|
||||
const up = await a.page.request.post("/api/assets", {
|
||||
headers: writeHeaders(a.page),
|
||||
multipart: {
|
||||
file: { name, mimeType: "application/pdf", buffer: onePagePdf() },
|
||||
purpose,
|
||||
},
|
||||
});
|
||||
expect(up.status(), await up.text()).toBe(201);
|
||||
return (await up.json()) as Json;
|
||||
}
|
||||
|
||||
export interface Crew {
|
||||
clara: Actor;
|
||||
wade: Actor;
|
||||
wes: Actor;
|
||||
andy: Actor;
|
||||
andyId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a packet to status qc through the API: a draft on `unit` in 03, pulled and released by wh.wade, assigned by
|
||||
* sup.wes to `stall` and weld.andy, started, two welds and the checklist, submitted, sent to Q.C. at `location`.
|
||||
*/
|
||||
export async function atQcApi(
|
||||
crew: Crew,
|
||||
unit: Json,
|
||||
title: string,
|
||||
stall = "Stall 2",
|
||||
location = "Bay 2",
|
||||
): Promise<QcPacket> {
|
||||
const p = await makeQcDraft(crew.clara, unit, title);
|
||||
await pullAndRelease(crew.wade, p.id);
|
||||
await assignApi(crew.wes, p.id, stall, crew.andyId);
|
||||
await stepApi(crew.andy, p.id, "start");
|
||||
await finishStageApi(crew.andy, p.id, 2);
|
||||
const sub = await stepApi(crew.andy, p.id, "submit", { sign: sign("weld.andy") });
|
||||
expect(sub.status).toBe("review");
|
||||
const qc = await stepApi(crew.wes, p.id, "send_to_qc", {
|
||||
book: true,
|
||||
spool: true,
|
||||
location,
|
||||
sign: sign("sup.wes"),
|
||||
});
|
||||
expect(qc.status).toBe("qc");
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A board column of the Q.C. home by its heading. */
|
||||
export function column(page: Page, heading: string): Locator {
|
||||
return page
|
||||
.getByRole("region", { name: new RegExp(`^${esc(heading)}`) })
|
||||
.or(section(page, new RegExp(`^${esc(heading)}`)))
|
||||
.first();
|
||||
}
|
||||
|
||||
/** The Q.C. bar on a packet at Q.C. */
|
||||
export function qcBar(page: Page): Locator {
|
||||
return page.getByRole("region", { name: L.qcBar });
|
||||
}
|
||||
|
||||
/** A region by its accessible name (the Q.C. tab and Documents tab parts, the folder sections). */
|
||||
export function part(page: Page, name: string | RegExp): Locator {
|
||||
return page.getByRole("region", { name }).first();
|
||||
}
|
||||
|
||||
/** A packet card by its link. */
|
||||
export function cardOf(scope: Locator, id: number): Locator {
|
||||
return scope.locator(`a[href="/packets/${id}"]`).first();
|
||||
}
|
||||
|
||||
/** The ten Q.C. check boxes. */
|
||||
export function qcChecks(page: Page): Locator {
|
||||
return page.getByRole("checkbox", { name: L.checkBoxes });
|
||||
}
|
||||
|
||||
/** One ITP row by its line number (role group `Line 1.1`, exact: `Line 1.1` is inside `Line 1.10`). */
|
||||
export function itpRow(page: Page, n: string): Locator {
|
||||
return page.getByRole("group", { name: L.lineGroup(n), exact: true });
|
||||
}
|
||||
|
||||
/** One ITP section by its number (region `5. INTERNAL COATING`). */
|
||||
export function itpSection(page: Page, n: string): Locator {
|
||||
return page.getByRole("region", { name: L.sectionRegion(n) }).first();
|
||||
}
|
||||
|
||||
/** One ITP signature block by its key (group `ITP block 3`). */
|
||||
export function itpBlock(page: Page, key: string): Locator {
|
||||
return page.getByRole("group", { name: L.blockGroup(key), exact: true });
|
||||
}
|
||||
|
||||
/** The block's sign button (`Sign section` or `Sign (all N/A)`). */
|
||||
export function blockSign(page: Page, key: string): Locator {
|
||||
const b = itpBlock(page, key);
|
||||
return b
|
||||
.getByRole("button", { name: L.signSection })
|
||||
.or(b.getByRole("button", { name: L.signAllNa }))
|
||||
.first();
|
||||
}
|
||||
|
||||
/** Enabled Approve buttons on the ITP page. */
|
||||
export function enabledApprove(page: Page): Locator {
|
||||
return page.locator(`${L.itpRow} button:not([disabled])`, { hasText: /^Approve$/ });
|
||||
}
|
||||
|
||||
export function esc(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Text file bytes, for the refused-file checks. */
|
||||
export function textFile(name = "notes.txt"): { name: string; mimeType: string; buffer: Buffer } {
|
||||
return { name, mimeType: "text/plain", buffer: Buffer.from("hello from the CI\n") };
|
||||
}
|
||||
|
||||
/** A PDF file for an input[type=file]. */
|
||||
export function pdfFile(name: string): { name: string; mimeType: string; buffer: Buffer } {
|
||||
return { name, mimeType: "application/pdf", buffer: onePagePdf() };
|
||||
}
|
||||
|
||||
/** The packet log actions, newest first. */
|
||||
export async function logActions(a: Actor, id: number): Promise<string[]> {
|
||||
const rows = (await api(a, "get", `/api/packets/${id}/log`)) as unknown as Json[];
|
||||
return rows.map((r) => String(r.action));
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
"""Helpers for the phase 6 Q.C. tests: a packet brought to status qc with two welds, the Q.C. inspection endpoints,
|
||||
accept and reject, the digital ITP, and Q.C. folder documents.
|
||||
|
||||
Built on shophelpers (imported, never edited). The packet is the shop packet: a hand draft in 03 (drawing D-5001
|
||||
rev B, WPS-11, three BOM lines with heats, two work orders), assigned to weld.andy, two welds, submitted, and sent
|
||||
to Q.C. by sup.wes with the book and the spool confirmed at `Bay 2`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory, FakeUser
|
||||
from fastapi.testclient import TestClient
|
||||
from pkthelpers import step
|
||||
from shophelpers import (
|
||||
SIGN,
|
||||
USERS,
|
||||
Shop,
|
||||
finish_stage,
|
||||
in_progress,
|
||||
ok,
|
||||
open_shop,
|
||||
submit,
|
||||
)
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
NCR = "NCR-2026-0021"
|
||||
NCR2 = "NCR-2026-0022"
|
||||
NCR_URL = "https://ncr.pescoinc.biz/ncrs/"
|
||||
MSG_NCR = "Enter an NCR number like NCR-2026-0021."
|
||||
MSG_ITP_COMPLETE = "The ITP is signed off and locked. An admin can reopen it."
|
||||
MSG_NCR_REQUIRED = "Enter the NCR # before approving this line."
|
||||
MSG_UNSIGN = "Only the inspector who initialed this line, or an admin, can remove it."
|
||||
MSG_BLOCK_SIGNED = "Undo the section signature first."
|
||||
MSG_BLOCK_NOT_READY = "Every line must be initialed or N/A first."
|
||||
MSG_BLOCKS_UNSIGNED = "Every section must be signed first."
|
||||
MSG_DOC_KIND = "Pick a document type from the list."
|
||||
MSG_TAKE_OUT = "Say why it comes out of the data book."
|
||||
MSG_NDE = "Pick an NDE method from the list."
|
||||
ROUTE_DB = "Clerical – Data Book" # the en dash is the old text
|
||||
TEMPLATE_VER = "ITP_Generation_Revised_2"
|
||||
|
||||
QC_KEYS = (
|
||||
"db_heats",
|
||||
"db_mtr",
|
||||
"db_weldmap",
|
||||
"db_wps",
|
||||
"db_ck",
|
||||
"ph_dim",
|
||||
"ph_heat",
|
||||
"ph_stamp",
|
||||
"ph_clean",
|
||||
"ph_qty",
|
||||
)
|
||||
DOC_KINDS = {
|
||||
"nde": "NDE report (RT / UT / MT / PT)",
|
||||
"hydro": "Hydrotest report",
|
||||
"dimensional": "Dimensional report",
|
||||
"pmi": "PMI report",
|
||||
"coating": "Coating / paint report",
|
||||
"coc": "Certificate of conformance",
|
||||
"customer": "Customer inspection report",
|
||||
"ncr": "NCR",
|
||||
"other": "Other",
|
||||
}
|
||||
QC_TYPES = (
|
||||
"ITP",
|
||||
"WPS / PQR",
|
||||
"NDE procedure",
|
||||
"Hydrotest procedure",
|
||||
"PMI / material",
|
||||
"Calibration certificate",
|
||||
"Customer spec",
|
||||
"Other",
|
||||
)
|
||||
|
||||
# The template, counted from src/js/itp.js line 4 (ITP_TMPL): 70 items in 9 sections.
|
||||
TEMPLATE_ITEMS = 70
|
||||
SECTION_SIZES = {"1": 14, "2": 3, "3": 4, "4": 6, "5": 5, "6": 6, "7": 11, "8": 14, "9": 7}
|
||||
SECTION_PAGE = {"1": "WP", "2": "WP", "3": "WP", "4": "C", "5": "C", "6": "C", "7": "FA", "8": "FA", "9": "FA"}
|
||||
SECTION_TITLE_5 = "INTERNAL COATING"
|
||||
# Hold (H) and witness (W) lines on the PESCO or the template customer code, by page.
|
||||
HOLDS = {
|
||||
"WP": ("3.4",),
|
||||
"C": ("4.2", "4.4", "4.5", "5.1", "5.2", "5.3", "5.5", "6.1", "6.2", "6.3", "6.6"),
|
||||
"FA": ("7.5", "7.9", "7.11", "9.6", "9.7"),
|
||||
}
|
||||
ALL_HOLDS = HOLDS["WP"] + HOLDS["C"] + HOLDS["FA"]
|
||||
BLOCKS = {
|
||||
"3": ["1", "2", "3"],
|
||||
"4": ["4"],
|
||||
"5": ["5"],
|
||||
"6": ["6"],
|
||||
"7": ["7"],
|
||||
"8": ["8"],
|
||||
"9": ["9"],
|
||||
}
|
||||
DEPT_PAGES = {
|
||||
"01": ["WP"],
|
||||
"02": ["WP"],
|
||||
"03": ["WP"],
|
||||
"04": ["WP"],
|
||||
"05": ["WP"],
|
||||
"10": ["WP"],
|
||||
"06": ["C"],
|
||||
"07": ["C"],
|
||||
"13": ["C"],
|
||||
"09": ["WP", "C"],
|
||||
"08": ["FA"],
|
||||
"11": ["FA"],
|
||||
"12": ["FA"],
|
||||
"14": ["FA"],
|
||||
}
|
||||
|
||||
# Two extra people for the "another Q.C. user" and Q.C. supervisor cases. FakeDirectory takes a user list, so a
|
||||
# test module adds them through its own fake_dir fixture; fakedir.py stays as it is.
|
||||
QC_SUE = FakeUser("qc.sue", "Sue Second", "5002", ["PS-QC"])
|
||||
QC_SAM = FakeUser("qcsup.sam", "Sam Qcsup", "5003", ["PS-QC-Supervisor"])
|
||||
QC_USERS = (*USERS, "qc.sue", "qcsup.sam")
|
||||
|
||||
|
||||
def qc_directory() -> FakeDirectory:
|
||||
d = FakeDirectory()
|
||||
d.users.extend(
|
||||
[
|
||||
FakeUser(QC_SUE.sam, QC_SUE.display, QC_SUE.emp_no, list(QC_SUE.groups)),
|
||||
FakeUser(QC_SAM.sam, QC_SAM.display, QC_SAM.emp_no, list(QC_SAM.groups)),
|
||||
]
|
||||
)
|
||||
return d
|
||||
|
||||
|
||||
def open_qc_shop(settings: Settings, fake_dir: FakeDirectory) -> Iterator[Shop]:
|
||||
"""open_shop, plus the two extra Q.C. people signed in once when the directory has them."""
|
||||
for shop in open_shop(settings, fake_dir):
|
||||
for sam in ("qc.sue", "qcsup.sam"):
|
||||
if any(u.sam == sam for u in fake_dir.users):
|
||||
shop(sam)
|
||||
yield shop
|
||||
|
||||
|
||||
def today_us() -> str:
|
||||
"""M/D/YYYY in America/Denver, the date form of the ITP notes."""
|
||||
d = datetime.now(ZoneInfo("America/Denver"))
|
||||
return f"{d.month}/{d.day}/{d.year}"
|
||||
|
||||
|
||||
def today_iso() -> str:
|
||||
return datetime.now(ZoneInfo("America/Denver")).date().isoformat()
|
||||
|
||||
|
||||
def ok_any(r: Any) -> dict[str, Any]:
|
||||
"""200 or 201 (the contract does not fix the create code)."""
|
||||
assert r.status_code in (200, 201), r.text
|
||||
out: dict[str, Any] = r.json()
|
||||
return out
|
||||
|
||||
|
||||
def g(obj: Any, key: str) -> Any:
|
||||
"""A field of a dict or an object (the pure helpers may return either)."""
|
||||
return obj[key] if isinstance(obj, dict) else getattr(obj, key)
|
||||
|
||||
|
||||
def delete_json(c: TestClient, url: str, body: dict[str, Any] | None = None, **kw: Any) -> Any:
|
||||
return c.request("DELETE", url, json=body, **kw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the packet
|
||||
|
||||
|
||||
def to_qc(shop: Shop, pkt: dict[str, Any], location: str = "Bay 2", by: str = "sup.wes", **kw: Any) -> dict[str, Any]:
|
||||
out = ok(step(shop(by), pkt, "send_to_qc", book=True, spool=True, location=location, sign=SIGN, **kw))
|
||||
assert out["status"] == "qc", out["status"]
|
||||
return out
|
||||
|
||||
|
||||
def at_qc(shop: Shop, unit_id: int | None = None, welds: int = 2, **kw: Any) -> dict[str, Any]:
|
||||
"""A shop packet with `welds` welds, submitted, and sent to Q.C. at Bay 2 by sup.wes."""
|
||||
pkt = in_progress(shop, unit_id, **kw)
|
||||
pkt = finish_stage(shop, pkt, welds=welds)
|
||||
pkt = submit(shop, pkt)
|
||||
return to_qc(shop, pkt)
|
||||
|
||||
|
||||
def live_welds(pkt: dict[str, Any]) -> list[int]:
|
||||
return sorted(int(w["weld_no"]) for w in pkt["welds"] if not w.get("removed_at") and not w.get("removed"))
|
||||
|
||||
|
||||
def qc_check(c: TestClient, pid: int, key: str, done: bool = True) -> Any:
|
||||
return c.put(f"/api/packets/{pid}/qc/checks/{key}", json={"done": done})
|
||||
|
||||
|
||||
def qc_weld(c: TestClient, pid: int, no: int, **body: Any) -> Any:
|
||||
return c.put(f"/api/packets/{pid}/qc/welds/{no}", json=body)
|
||||
|
||||
|
||||
def qc_patch(c: TestClient, pid: int, set_: dict[str, Any], base: dict[str, Any] | None = None, **kw: Any) -> Any:
|
||||
return c.patch(f"/api/packets/{pid}/qc", json={"set": set_, "base": base or {}, **kw})
|
||||
|
||||
|
||||
def finish_inspection(
|
||||
shop: Shop,
|
||||
pkt: dict[str, Any],
|
||||
by: str = "qc.quinn",
|
||||
inspector: str = "Quinn QC",
|
||||
stamp: str = "Q1",
|
||||
nde: tuple[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Every check ticked, every live weld accepted, the inspector and stamp set (and the NDE when given)."""
|
||||
c = shop(by)
|
||||
pid = pkt["id"]
|
||||
fields: dict[str, Any] = {"inspector": inspector, "stamp": stamp}
|
||||
pkt = ok(qc_patch(c, pid, fields))
|
||||
for k in QC_KEYS:
|
||||
pkt = ok(qc_check(c, pid, k))
|
||||
for no in live_welds(pkt):
|
||||
pkt = ok(qc_weld(c, pid, no, result="accept"))
|
||||
if nde is not None:
|
||||
pkt = ok(qc_patch(c, pid, {"nde_method": nde[0]}))
|
||||
pkt = ok(qc_patch(c, pid, {"nde_result": nde[1]}))
|
||||
assert pkt["qc"]["missing"] == [], pkt["qc"]["missing"]
|
||||
return pkt
|
||||
|
||||
|
||||
def accept(shop: Shop, pkt: dict[str, Any], routed_to: str = "databook", by: str = "qc.quinn") -> Any:
|
||||
return step(shop(by), pkt, "qc_accept", routed_to=routed_to, sign=SIGN)
|
||||
|
||||
|
||||
def accept_route(shop: Shop, pkt: dict[str, Any], dept: str = "09", by: str = "qc.quinn") -> Any:
|
||||
return step(shop(by), pkt, "qc_accept_and_route", dept=dept, sign=SIGN)
|
||||
|
||||
|
||||
def reject(
|
||||
shop: Shop, pkt: dict[str, Any], reason: str | None = None, ncr: str | None = None, by: str = "qc.quinn"
|
||||
) -> Any:
|
||||
body: dict[str, Any] = {"sign": SIGN}
|
||||
if reason is not None:
|
||||
body["reason"] = reason
|
||||
if ncr is not None:
|
||||
body["ncr"] = ncr
|
||||
return step(shop(by), pkt, "qc_reject", **body)
|
||||
|
||||
|
||||
def accepted_packet(shop: Shop, unit_id: int | None = None, routed_to: str = "databook") -> dict[str, Any]:
|
||||
pkt = finish_inspection(shop, at_qc(shop, unit_id))
|
||||
out = ok(accept(shop, pkt, routed_to))
|
||||
assert out["status"] == "complete"
|
||||
return out
|
||||
|
||||
|
||||
def filed_packet(shop: Shop, unit_id: int | None = None) -> dict[str, Any]:
|
||||
"""Accepted to the data book, closed in VISUAL and filed by clerk.clara."""
|
||||
pkt = accepted_packet(shop, unit_id)
|
||||
c = shop("clerk.clara")
|
||||
pkt = ok(c.post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True}))
|
||||
out = ok(step(c, pkt, "file", sign=SIGN))
|
||||
assert out["status"] == "filed"
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- assets and documents
|
||||
|
||||
|
||||
def unique_pdf() -> bytes:
|
||||
"""A one-page PDF with its own bytes. The asset store returns the existing row for the same bytes (and its
|
||||
original name), so each upload that names a file needs bytes of its own."""
|
||||
import io
|
||||
import secrets
|
||||
|
||||
from pypdf import PdfWriter
|
||||
|
||||
w = PdfWriter()
|
||||
w.add_blank_page(width=612, height=792)
|
||||
w.add_metadata({"/Title": secrets.token_hex(8)})
|
||||
buf = io.BytesIO()
|
||||
w.write(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def upload_doc(
|
||||
c: TestClient,
|
||||
name: str = "doc.pdf",
|
||||
data: bytes | None = None,
|
||||
mime: str = "application/pdf",
|
||||
purpose: str = "document",
|
||||
) -> dict[str, Any]:
|
||||
r = c.post(
|
||||
"/api/assets", files={"file": (name, unique_pdf() if data is None else data, mime)}, data={"purpose": purpose}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
out: dict[str, Any] = r.json()
|
||||
return out
|
||||
|
||||
|
||||
def csv_asset(c: TestClient) -> dict[str, Any]:
|
||||
"""A stored CSV (through the BOM purpose), to try as a document."""
|
||||
return upload_doc(c, "parts.csv", b"item,qty,part\n1,2,PIPE-4\n2,1,FLG-4\n", "text/csv", purpose="bom")
|
||||
|
||||
|
||||
def add_packet_doc(
|
||||
c: TestClient,
|
||||
pid: int,
|
||||
kind: str = "nde",
|
||||
title: str | None = "RT report W1-W4",
|
||||
note: str | None = None,
|
||||
file_name: str = "rt.pdf",
|
||||
asset_id: str | None = None,
|
||||
**kw: Any,
|
||||
) -> Any:
|
||||
if asset_id is None:
|
||||
asset_id = upload_doc(c, file_name)["id"]
|
||||
body: dict[str, Any] = {"asset_id": asset_id, "kind": kind, **kw}
|
||||
if title is not None:
|
||||
body["title"] = title
|
||||
if note is not None:
|
||||
body["note"] = note
|
||||
return c.post(f"/api/packets/{pid}/docs", json=body)
|
||||
|
||||
|
||||
def take_out(c: TestClient, pid: int, doc_id: int, reason: str | None = "wrong packet") -> Any:
|
||||
return delete_json(c, f"/api/packets/{pid}/docs/{doc_id}", {} if reason is None else {"reason": reason})
|
||||
|
||||
|
||||
def make_qc_doc(
|
||||
shop: Shop,
|
||||
*,
|
||||
unit_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
type: str = "ITP",
|
||||
title: str | None = None,
|
||||
rev: str | None = None,
|
||||
note: str | None = None,
|
||||
replaces: int | None = None,
|
||||
by: str = "qc.quinn",
|
||||
file_name: str = "doc.pdf",
|
||||
asset_id: str | None = None,
|
||||
**kw: Any,
|
||||
) -> Any:
|
||||
"""POST /api/qc/docs with a fresh tiny PDF (purpose document). Returns the response."""
|
||||
c = shop(by)
|
||||
if asset_id is None:
|
||||
asset_id = upload_doc(c, file_name)["id"]
|
||||
body: dict[str, Any] = {"type": type, "asset_id": asset_id, **kw}
|
||||
for k, v in (
|
||||
("unit_id", unit_id),
|
||||
("project_id", project_id),
|
||||
("title", title),
|
||||
("rev", rev),
|
||||
("note", note),
|
||||
("replaces", replaces),
|
||||
):
|
||||
if v is not None:
|
||||
body[k] = v
|
||||
return c.post("/api/qc/docs", json=body)
|
||||
|
||||
|
||||
def folder_unit(c: TestClient, unit_id: int) -> dict[str, Any]:
|
||||
return ok(c.get(f"/api/qc/folders/units/{unit_id}"))
|
||||
|
||||
|
||||
def folder_project(c: TestClient, project_id: int) -> dict[str, Any]:
|
||||
return ok(c.get(f"/api/qc/folders/projects/{project_id}"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the ITP
|
||||
|
||||
|
||||
def get_itp(c: TestClient, unit_id: int) -> dict[str, Any]:
|
||||
return ok(c.get(f"/api/units/{unit_id}/itp"))
|
||||
|
||||
|
||||
def start_itp(shop: Shop, unit_id: int, by: str = "qc.quinn") -> dict[str, Any]:
|
||||
itp = ok_any(shop(by).post(f"/api/units/{unit_id}/itp", json={}))
|
||||
assert itp["status"] == "open", itp
|
||||
return itp
|
||||
|
||||
|
||||
def item_keys(itp: dict[str, Any]) -> list[str]:
|
||||
return [str(i["n"]) for s in itp["template"]["sections"] for i in s["items"]]
|
||||
|
||||
|
||||
def section_keys(itp: dict[str, Any], n: str) -> list[str]:
|
||||
sec = next(s for s in itp["template"]["sections"] if str(s["n"]) == n)
|
||||
return [str(i["n"]) for i in sec["items"]]
|
||||
|
||||
|
||||
def line(itp: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
out: dict[str, Any] = itp["lines"][key]
|
||||
return out
|
||||
|
||||
|
||||
def block(itp: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
return next(b for b in itp["blocks"] if str(b["key"]) == key)
|
||||
|
||||
|
||||
def is_done(ln: dict[str, Any]) -> bool:
|
||||
return bool(ln.get("na")) or ln.get("result") in ("approve", "na")
|
||||
|
||||
|
||||
def sign_line(c: TestClient, itp: dict[str, Any], key: str, if_match: int | None = None, **body: Any) -> Any:
|
||||
headers = {} if if_match is None else {"If-Match": str(if_match)}
|
||||
return c.post(f"/api/itps/{itp['id']}/lines/{key}/sign", json=body, headers=headers)
|
||||
|
||||
|
||||
def unsign_line(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
|
||||
return c.post(f"/api/itps/{itp['id']}/lines/{key}/unsign", json=body)
|
||||
|
||||
|
||||
def patch_line(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
|
||||
return c.patch(f"/api/itps/{itp['id']}/lines/{key}", json=body)
|
||||
|
||||
|
||||
def section_na(c: TestClient, itp: dict[str, Any], n: str, na: bool = True) -> Any:
|
||||
return c.post(f"/api/itps/{itp['id']}/sections/{n}/na", json={"na": na})
|
||||
|
||||
|
||||
def reject_line(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
|
||||
return c.post(f"/api/itps/{itp['id']}/lines/{key}/reject", json=body)
|
||||
|
||||
|
||||
def enter_ncr(c: TestClient, itp: dict[str, Any], key: str, ncr: str) -> Any:
|
||||
return c.post(f"/api/itps/{itp['id']}/lines/{key}/ncr", json={"ncr": ncr})
|
||||
|
||||
|
||||
def patch_block(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
|
||||
return c.patch(f"/api/itps/{itp['id']}/blocks/{key}", json=body)
|
||||
|
||||
|
||||
def sign_block(c: TestClient, itp: dict[str, Any], key: str, sign: dict[str, Any] | None = SIGN, **body: Any) -> Any:
|
||||
if sign is not None:
|
||||
body["sign"] = sign
|
||||
return c.post(f"/api/itps/{itp['id']}/blocks/{key}/sign", json=body)
|
||||
|
||||
|
||||
def unsign_block(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
|
||||
return delete_json(c, f"/api/itps/{itp['id']}/blocks/{key}/sign", body)
|
||||
|
||||
|
||||
def sign_off(c: TestClient, itp: dict[str, Any], sign: dict[str, Any] | None = SIGN, **body: Any) -> Any:
|
||||
if sign is not None:
|
||||
body["sign"] = sign
|
||||
return c.post(f"/api/itps/{itp['id']}/signoff", json=body)
|
||||
|
||||
|
||||
def reopen_itp(c: TestClient, itp: dict[str, Any], reason: str, **body: Any) -> Any:
|
||||
return c.post(f"/api/itps/{itp['id']}/reopen", json={"reason": reason, **body})
|
||||
|
||||
|
||||
def patch_itp(c: TestClient, itp: dict[str, Any], set_: dict[str, Any], base: dict[str, Any] | None = None) -> Any:
|
||||
return c.patch(f"/api/itps/{itp['id']}", json={"set": set_, "base": base or {}})
|
||||
|
||||
|
||||
def approve_all(shop: Shop, itp: dict[str, Any], by: str = "qc.quinn", keys: list[str] | None = None) -> dict[str, Any]:
|
||||
"""Approve every line that is still open (not N/A, not approved). A rejected line needs its NCR first."""
|
||||
c = shop(by)
|
||||
for key in keys if keys is not None else item_keys(itp):
|
||||
ln = line(itp, key)
|
||||
if is_done(ln):
|
||||
continue
|
||||
itp = ok(sign_line(c, itp, key))
|
||||
return itp
|
||||
|
||||
|
||||
def na_lines(shop: Shop, itp: dict[str, Any], keys: list[str], by: str = "qc.quinn") -> dict[str, Any]:
|
||||
c = shop(by)
|
||||
for key in keys:
|
||||
itp = ok(patch_line(c, itp, key, na=True))
|
||||
return itp
|
||||
|
||||
|
||||
def sign_all_blocks(shop: Shop, itp: dict[str, Any], by: str = "qc.quinn") -> dict[str, Any]:
|
||||
c = shop(by)
|
||||
for b in list(itp["blocks"]):
|
||||
if not block(itp, str(b["key"]))["signed"]:
|
||||
itp = ok(sign_block(c, itp, str(b["key"])))
|
||||
return itp
|
||||
|
||||
|
||||
def itp_log(itp: dict[str, Any]) -> list[tuple[str, str | None]]:
|
||||
"""(action, note) newest first."""
|
||||
return [(r["action"], r["note"]) for r in itp["log"]]
|
||||
|
||||
|
||||
def patch_hook(monkeypatch: pytest.MonkeyPatch, fn: Any) -> None:
|
||||
"""Replace pdf.itp.file_signed_itp_pdf, also where a module imported it by name."""
|
||||
for name in ("packetstation.pdf.itp", "packetstation.api.itp", "packetstation.domain.itp"):
|
||||
try:
|
||||
mod = importlib.import_module(name)
|
||||
except ImportError:
|
||||
continue
|
||||
if hasattr(mod, "file_signed_itp_pdf"):
|
||||
monkeypatch.setattr(mod, "file_signed_itp_pdf", fn)
|
||||
|
||||
|
||||
def as_uuid(value: Any) -> uuid.UUID:
|
||||
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
|
||||
@@ -0,0 +1,550 @@
|
||||
"""Phase 6 digital ITP through the API: start from the frozen template, line initials with their compare-and-set,
|
||||
unsign by the same inspector or an admin, N/A on a line and a section, customer codes and witnesses, reject and the
|
||||
NCR, block fields and signatures, sign-off with the PDF hook, the lock after sign-off, reopen, and the log texts.
|
||||
|
||||
The directory adds two people to the fakedir roster through this module's fixture: qc.sue (a second Q.C. user) and
|
||||
qcsup.sam (a Q.C. supervisor). fakedir.py itself has neither."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pshelpers import needs_pg
|
||||
from qchelpers import (
|
||||
MSG_BLOCK_NOT_READY,
|
||||
MSG_BLOCK_SIGNED,
|
||||
MSG_BLOCKS_UNSIGNED,
|
||||
MSG_ITP_COMPLETE,
|
||||
MSG_NCR,
|
||||
MSG_NCR_REQUIRED,
|
||||
MSG_UNSIGN,
|
||||
NCR,
|
||||
NCR2,
|
||||
SECTION_SIZES,
|
||||
SECTION_TITLE_5,
|
||||
TEMPLATE_ITEMS,
|
||||
TEMPLATE_VER,
|
||||
approve_all,
|
||||
block,
|
||||
enter_ncr,
|
||||
get_itp,
|
||||
item_keys,
|
||||
itp_log,
|
||||
line,
|
||||
na_lines,
|
||||
open_qc_shop,
|
||||
patch_block,
|
||||
patch_hook,
|
||||
patch_itp,
|
||||
patch_line,
|
||||
qc_directory,
|
||||
reject_line,
|
||||
reopen_itp,
|
||||
section_keys,
|
||||
section_na,
|
||||
sign_all_blocks,
|
||||
sign_block,
|
||||
sign_line,
|
||||
sign_off,
|
||||
start_itp,
|
||||
today_iso,
|
||||
today_us,
|
||||
unsign_block,
|
||||
unsign_line,
|
||||
upload_doc,
|
||||
)
|
||||
from shophelpers import IT, Shop, all_rows, make_unit, ok, one, refused
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qc_shop(pg_settings: Settings, dev_visual: None) -> Iterator[Shop]:
|
||||
"""A Shop on a directory that also has qc.sue (Q.C.) and qcsup.sam (Q.C. supervisor)."""
|
||||
yield from open_qc_shop(pg_settings, qc_directory())
|
||||
|
||||
|
||||
def numbers(shop: Shop, unit_id: int) -> tuple[str, str]:
|
||||
row = one(
|
||||
shop.settings,
|
||||
"SELECT p.number, p.number || u.lot FROM units u JOIN projects p ON p.id = u.project_id WHERE u.id = %s",
|
||||
(unit_id,),
|
||||
)
|
||||
return str(row[0]), str(row[1])
|
||||
|
||||
|
||||
def new_itp(shop: Shop) -> tuple[int, dict[str, Any]]:
|
||||
_, unit_id = make_unit(shop.settings)
|
||||
return unit_id, start_itp(shop, unit_id)
|
||||
|
||||
|
||||
def done_section(shop: Shop, itp: dict[str, Any], n: str) -> dict[str, Any]:
|
||||
"""The first line of section n approved, the rest N/A: its block is ready."""
|
||||
keys = section_keys(itp, n)
|
||||
itp = ok(sign_line(shop("qc.quinn"), itp, keys[0]))
|
||||
return na_lines(shop, itp, keys[1:])
|
||||
|
||||
|
||||
# ================================================================ start
|
||||
|
||||
|
||||
def test_start(qc_shop: Shop) -> None:
|
||||
shop = qc_shop
|
||||
from packetstation.domain.itp_template import ITP_TEMPLATE
|
||||
|
||||
project_id, unit_id = make_unit(shop.settings)
|
||||
pno, uno = numbers(shop, unit_id)
|
||||
empty = get_itp(shop("qc.quinn"), unit_id)
|
||||
assert empty["itp"] is None and empty["unit"]["id"] == unit_id and empty["unit"]["number"] == uno
|
||||
assert shop("qc.quinn").get("/api/units/999999/itp").status_code == 404
|
||||
refused(shop("wh.wade").post(f"/api/units/{unit_id}/itp", json={}), 403, "forbidden")
|
||||
refused(shop("clerk.clara").post(f"/api/units/{unit_id}/itp", json={}), 403, "forbidden")
|
||||
|
||||
itp = start_itp(shop, unit_id)
|
||||
assert itp["unit_id"] == unit_id and itp["no"] == f"{uno}-ITP" and itp["status"] == "open"
|
||||
assert itp["hdr"] == {"client": "Acme Refining", "pname": "Shop project", "pno": pno, "po": "PO-55", "unit": uno}
|
||||
assert itp["template"] == ITP_TEMPLATE and itp["template"]["ver"] == TEMPLATE_VER
|
||||
assert len(item_keys(itp)) == TEMPLATE_ITEMS
|
||||
assert {str(s["n"]): len(s["items"]) for s in itp["template"]["sections"]} == SECTION_SIZES
|
||||
assert itp["pages"] == [
|
||||
{"code": "WP", "name": "Weld Parts"},
|
||||
{"code": "C", "name": "Code"},
|
||||
{"code": "FA", "name": "Final Assembly"},
|
||||
]
|
||||
assert set(itp["lines"]) == set(item_keys(itp))
|
||||
assert all(ln["result"] is None and ln["na"] is False for ln in itp["lines"].values())
|
||||
assert itp["progress"] == {"done": 0, "total": TEMPLATE_ITEMS, "holds": 17}
|
||||
assert len(itp["holds"]) == 17
|
||||
assert [b["key"] for b in itp["blocks"]] == ["3", "4", "5", "6", "7", "8", "9"]
|
||||
assert block(itp, "3")["sections"] == ["1", "2", "3"] and block(itp, "3")["scope"] == "Sections 1–3"
|
||||
assert block(itp, "4")["scope"] == "Section 4" and block(itp, "9")["final"] is True
|
||||
assert not any(b["signed"] or b["ready"] for b in itp["blocks"])
|
||||
assert itp["signoff"] is None and itp["reopen"] is None
|
||||
assert itp["created_by"]["display_name"] == "Quinn QC" and itp["created_at"]
|
||||
assert itp["can"] == {"edit": True, "sign_off": False, "reopen": False}
|
||||
assert itp_log(itp) == [("ITP started", f"{uno}-ITP · template {TEMPLATE_VER}")]
|
||||
# the template is frozen in the row
|
||||
row = one(shop.settings, "SELECT template, no FROM itps WHERE id = %s", (itp["id"],))
|
||||
assert row[0] == ITP_TEMPLATE and row[1] == f"{uno}-ITP"
|
||||
# a second start
|
||||
refused(shop("qc.quinn").post(f"/api/units/{unit_id}/itp", json={}), 409, "itp_exists")
|
||||
# other people read it, without edit rights
|
||||
other = get_itp(shop("wh.wade"), unit_id)
|
||||
assert other["id"] == itp["id"] and other["can"]["edit"] is False
|
||||
# the audit row
|
||||
au = one(shop.settings, "SELECT subject_type, subject_id, detail FROM audit_events WHERE action = 'itp.itp_start'")
|
||||
assert au is not None and au[0] == "itp" and str(au[1]) == str(itp["id"])
|
||||
assert au[2]["text"] == "ITP started" and au[2]["unit_id"] == unit_id
|
||||
del project_id
|
||||
|
||||
|
||||
def test_header_fields(qc_shop: Shop) -> None:
|
||||
shop = qc_shop
|
||||
_, itp = new_itp(shop)
|
||||
q = shop("qc.quinn")
|
||||
v = itp["version"]
|
||||
itp = ok(patch_itp(q, itp, {"client": "ACME"}, {"client": "Acme Refining"}))
|
||||
assert itp["hdr"]["client"] == "ACME" and itp["version"] == v + 1
|
||||
assert itp_log(itp)[0] == ("ITP header changed", "client: ACME")
|
||||
itp = ok(patch_itp(q, itp, {"no": "L9-ITP-B"}))
|
||||
assert itp["no"] == "L9-ITP-B" and itp_log(itp)[0][0] == "ITP plan no. changed"
|
||||
r = refused(patch_itp(q, itp, {"client": "Other"}, {"client": "Acme Refining"}), 409, "field_conflict")
|
||||
assert r["current"] == {"client": "ACME"}
|
||||
refused(patch_itp(shop("clerk.clara"), itp, {"client": "X"}), 403, "forbidden")
|
||||
|
||||
|
||||
# ================================================================ lines
|
||||
|
||||
|
||||
def test_line_sign_and_cas(qc_shop: Shop) -> None:
|
||||
shop = qc_shop
|
||||
_, itp = new_itp(shop)
|
||||
q = shop("qc.quinn")
|
||||
itp = ok(sign_line(q, itp, "1.1"))
|
||||
ln = line(itp, "1.1")
|
||||
assert ln["result"] == "approve" and ln["by"]["display_name"] == "Quinn QC" and ln["initials"] == "QQ"
|
||||
assert ln["at"] and itp["progress"]["done"] == 1
|
||||
assert itp_log(itp)[0][0] == "ITP line 1.1 approved" and not itp_log(itp)[0][1]
|
||||
refused(sign_line(q, itp, "1.1"), 409, "already_signed")
|
||||
# a stale If-Match
|
||||
rv = line(itp, "1.2")["row_version"]
|
||||
refused(sign_line(q, itp, "1.2", if_match=rv + 5), 409, "row_conflict")
|
||||
itp = ok(sign_line(q, itp, "1.2", if_match=rv))
|
||||
assert line(itp, "1.2")["row_version"] > rv
|
||||
# another Q.C. user signs with their own initials
|
||||
itp = ok(sign_line(shop("qc.sue"), itp, "1.3"))
|
||||
assert line(itp, "1.3")["initials"] == "SS" and line(itp, "1.3")["by"]["display_name"] == "Sue Second"
|
||||
refused(sign_line(shop("sup.wes"), itp, "1.4"), 403, "forbidden")
|
||||
# a key that is not in the template
|
||||
assert sign_line(q, itp, "9.99").status_code in (404, 422)
|
||||
|
||||
|
||||
def test_line_unsign(qc_shop: Shop) -> None:
|
||||
shop = qc_shop
|
||||
_, itp = new_itp(shop)
|
||||
q = shop("qc.quinn")
|
||||
itp = ok(sign_line(q, itp, "1.1"))
|
||||
# another Q.C. user may not remove it
|
||||
refused(unsign_line(shop("qc.sue"), itp, "1.1"), 403, "forbidden", MSG_UNSIGN)
|
||||
itp = ok(unsign_line(q, itp, "1.1"))
|
||||
assert line(itp, "1.1")["result"] is None and line(itp, "1.1")["by"] is None
|
||||
assert itp_log(itp)[0] == ("ITP line 1.1 initial removed", f"was Quinn QC {today_us()}")
|
||||
# an admin, with an IT reason
|
||||
itp = ok(sign_line(q, itp, "1.1"))
|
||||
refused(unsign_line(shop("admin.ann"), itp, "1.1"), 400, "it_reason_required")
|
||||
itp = ok(unsign_line(shop("admin.ann"), itp, "1.1", it_reason=IT))
|
||||
assert line(itp, "1.1")["result"] is None
|
||||
log_row = itp["log"][0]
|
||||
assert log_row["by"]["display_name"] == "Ann Admin"
|
||||
# refused once the block is signed
|
||||
itp = done_section(shop, itp, "4")
|
||||
first = section_keys(itp, "4")[0]
|
||||
itp = ok(sign_block(q, itp, "4"))
|
||||
refused(unsign_line(q, itp, first), 409, "block_signed", MSG_BLOCK_SIGNED)
|
||||
# an open line has nothing to remove
|
||||
r = unsign_line(q, itp, "1.5")
|
||||
assert r.status_code in (404, 409, 422), r.text
|
||||
|
||||
|
||||
def test_line_na_and_section_na(qc_shop: Shop) -> None:
|
||||
shop = qc_shop
|
||||
_, itp = new_itp(shop)
|
||||
q = shop("qc.quinn")
|
||||
itp = ok(patch_line(q, itp, "1.2", na=True))
|
||||
assert line(itp, "1.2")["na"] is True and itp["progress"]["done"] == 1
|
||||
assert itp_log(itp)[0] == ("ITP line 1.2 updated", "marked N/A")
|
||||
refused(sign_line(q, itp, "1.2"), 409, "line_na")
|
||||
itp = ok(patch_line(q, itp, "1.2", na=False))
|
||||
assert line(itp, "1.2")["na"] is False and itp_log(itp)[0] == ("ITP line 1.2 updated", "applies again")
|
||||
itp = ok(sign_line(q, itp, "1.1"))
|
||||
refused(patch_line(q, itp, "1.1", na=True), 409, "already_signed")
|
||||
# a whole section
|
||||
v = itp["version"]
|
||||
itp = ok(section_na(q, itp, "5"))
|
||||
assert all(line(itp, k)["na"] for k in section_keys(itp, "5"))
|
||||
assert itp_log(itp)[0] == ("ITP section 5 marked N/A", SECTION_TITLE_5) and itp["version"] > v
|
||||
assert block(itp, "5")["ready"] is True and block(itp, "5")["all_na"] is True
|
||||
itp = ok(section_na(q, itp, "5", na=False))
|
||||
assert not any(line(itp, k)["na"] for k in section_keys(itp, "5"))
|
||||
assert itp_log(itp)[0] == ("ITP section 5 applies again", SECTION_TITLE_5)
|
||||
# refused once a line of the section is approved
|
||||
refused(section_na(q, itp, "1"), 409, "already_signed")
|
||||
refused(section_na(shop("clerk.clara"), itp, "5"), 403, "forbidden")
|
||||
|
||||
|
||||
def test_line_customer_and_note(qc_shop: Shop) -> None:
|
||||
shop = qc_shop
|
||||
_, itp = new_itp(shop)
|
||||
q = shop("qc.quinn")
|
||||
refused(patch_line(q, itp, "1.3", cust_code="X"), 422, "invalid_field")
|
||||
itp = ok(patch_line(q, itp, "1.3", cust_code="W"))
|
||||
assert line(itp, "1.3")["cust_code"] == "W"
|
||||
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer code W")
|
||||
itp = ok(patch_line(q, itp, "1.3", cust_code=""))
|
||||
assert not line(itp, "1.3")["cust_code"]
|
||||
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer code as template")
|
||||
itp = ok(patch_line(q, itp, "1.3", cust_by="Rick Inspector"))
|
||||
ln = line(itp, "1.3")
|
||||
assert ln["cust_by"] == "Rick Inspector" and ln["cust_at"] == today_iso()
|
||||
assert ln["cust_rec"]["display_name"] == "Quinn QC"
|
||||
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer Rick Inspector")
|
||||
itp = ok(patch_line(q, itp, "1.3", cust_by="Rick Inspector", cust_at="2026-10-01"))
|
||||
assert line(itp, "1.3")["cust_at"] == "2026-10-01"
|
||||
itp = ok(patch_line(q, itp, "1.3", cust_by=""))
|
||||
assert not line(itp, "1.3")["cust_by"] and line(itp, "1.3")["cust_at"] is None
|
||||
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer cleared")
|
||||
itp = ok(patch_line(q, itp, "1.3", cust_code="H", note="Reviewed WPS-101 R2"))
|
||||
assert line(itp, "1.3")["note"] == "Reviewed WPS-101 R2"
|
||||
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer code H, note")
|
||||
for code in ("R", "R/AU", "AU", "W", "H", "NA"):
|
||||
assert ok(patch_line(q, itp, "1.4", cust_code=code))["lines"]["1.4"]["cust_code"] == code
|
||||
# nothing changed: no log, no version bump
|
||||
itp = ok(patch_line(q, itp, "1.4", cust_code="NA"))
|
||||
before = (len(itp["log"]), itp["version"])
|
||||
itp = ok(patch_line(q, itp, "1.4", cust_code="NA"))
|
||||
assert (len(itp["log"]), itp["version"]) == before
|
||||
# limits
|
||||
r = patch_line(q, itp, "1.5", note="x" * 501)
|
||||
assert r.status_code == 422 or len(r.json()["lines"]["1.5"]["note"]) <= 500, r.text
|
||||
|
||||
|
||||
def test_reject_and_ncr(qc_shop: Shop) -> None:
|
||||
shop = qc_shop
|
||||
_, itp = new_itp(shop)
|
||||
q = shop("qc.quinn")
|
||||
# reject without an NCR
|
||||
itp = ok(reject_line(q, itp, "1.4", note="Manual is rev C"))
|
||||
ln = line(itp, "1.4")
|
||||
assert ln["result"] == "reject" and ln["ncr"] is None and ln["ncr_missing"] is True
|
||||
assert [r["kind"] for r in ln["rejects"]] == ["reject"] and ln["rejects"][0]["note"] == "Manual is rev C"
|
||||
assert itp_log(itp)[0] == ("ITP line 1.4 rejected", "NCR # pending · Manual is rev C")
|
||||
assert any(h["n"] == "1.4" and h["rejected"] for h in itp["holds"])
|
||||
refused(sign_line(q, itp, "1.4"), 409, "ncr_required", MSG_NCR_REQUIRED)
|
||||
refused(patch_line(q, itp, "1.4", na=True), 409, "line_rejected")
|
||||
r = reject_line(q, itp, "1.4", note="again")
|
||||
assert r.status_code == 409, r.text
|
||||
# the NCR later
|
||||
refused(enter_ncr(q, itp, "1.4", "NCR-26-1"), 422, "invalid_ncr", MSG_NCR)
|
||||
itp = ok(enter_ncr(q, itp, "1.4", " ncr-2026-0021"))
|
||||
ln = line(itp, "1.4")
|
||||
assert ln["ncr"] == NCR and ln["ncr_missing"] is False
|
||||
assert [r["kind"] for r in ln["rejects"]] == ["reject", "ncr"]
|
||||
assert itp_log(itp)[0] == ("ITP line 1.4 NCR number entered", f"NCR {NCR}")
|
||||
refused(enter_ncr(q, itp, "1.4", NCR2), 409, "no_open_reject")
|
||||
refused(enter_ncr(q, itp, "1.5", NCR2), 409, "no_open_reject")
|
||||
itp = ok(sign_line(q, itp, "1.4"))
|
||||
assert line(itp, "1.4")["result"] == "approve"
|
||||
assert itp_log(itp)[0] == ("ITP line 1.4 approved", f"after NCR {NCR}")
|
||||
assert not any(h["n"] == "1.4" for h in itp["holds"])
|
||||
# the reject history survives the approve
|
||||
assert len(line(itp, "1.4")["rejects"]) == 2
|
||||
rows = all_rows(shop.settings, "SELECT kind, ncr FROM itp_rejects WHERE itp_id = %s ORDER BY id", (itp["id"],))
|
||||
assert [tuple(r) for r in rows] == [("reject", None), ("ncr", NCR)]
|
||||
# reject with the NCR at once
|
||||
itp = ok(reject_line(q, itp, "1.5", ncr="ncr-2026-0022", note="Map missing W4"))
|
||||
assert line(itp, "1.5")["ncr"] == NCR2 and line(itp, "1.5")["ncr_missing"] is False
|
||||
assert itp_log(itp)[0] == ("ITP line 1.5 rejected", f"NCR {NCR2} · Map missing W4")
|
||||
itp = ok(sign_line(q, itp, "1.5"))
|
||||
assert itp_log(itp)[0] == ("ITP line 1.5 approved", f"after NCR {NCR2}")
|
||||
# refused on an approved line and on an N/A line
|
||||
assert reject_line(q, itp, "1.5", note="x").status_code == 409
|
||||
itp = ok(patch_line(q, itp, "1.6", na=True))
|
||||
assert reject_line(q, itp, "1.6", note="x").status_code == 409
|
||||
refused(reject_line(q, itp, "1.7", ncr="bad one"), 422, "invalid_ncr")
|
||||
|
||||
|
||||
# ================================================================ blocks
|
||||
|
||||
|
||||
def test_block_fields(qc_shop: Shop) -> None:
|
||||
shop = qc_shop
|
||||
_, itp = new_itp(shop)
|
||||
q = shop("qc.quinn")
|
||||
itp = ok(patch_block(q, itp, "3", qn="QN-17"))
|
||||
assert block(itp, "3")["qn"] == "QN-17" and itp_log(itp)[0] == ("ITP block 3 QN # changed", "QN-17")
|
||||
itp = ok(patch_block(q, itp, "3", remarks="Fit-up per drawing"))
|
||||
assert itp_log(itp)[0] == ("ITP block 3 remarks changed", "Fit-up per drawing")
|
||||
itp = ok(patch_block(q, itp, "3", cust_name="Rick Inspector"))
|
||||
b = block(itp, "3")
|
||||
assert b["cust_name"] == "Rick Inspector" and b["cust_at"] == today_iso()
|
||||
assert b["cust_by"]["display_name"] == "Quinn QC" and b["signed"] is False
|
||||
assert itp_log(itp)[0] == ("ITP block 3 customer complete recorded", "Rick Inspector")
|
||||
itp = ok(patch_block(q, itp, "3", cust_clear=True))
|
||||
b = block(itp, "3")
|
||||
assert not b["cust_name"] and b["cust_at"] is None and b["cust_by"] is None
|
||||
assert itp_log(itp)[0][0] == "ITP block 3 customer complete cleared"
|
||||
assert b["qn"] == "QN-17"
|
||||
refused(patch_block(shop("sup.wes"), itp, "3", qn="QN-1"), 403, "forbidden")
|
||||
|
||||
|
||||
def test_block_sign_gate(qc_shop: Shop) -> None:
|
||||
shop = qc_shop
|
||||
_, itp = new_itp(shop)
|
||||
q = shop("qc.quinn")
|
||||
refused(sign_block(q, itp, "4"), 409, "block_not_ready", MSG_BLOCK_NOT_READY)
|
||||
itp = done_section(shop, itp, "4")
|
||||
assert block(itp, "4")["ready"] is True and block(itp, "4")["all_na"] is False
|
||||
r = refused(sign_block(q, itp, "4", sign=None), 400, "signature_required")
|
||||
assert r.get("meaning") == "Sign an ITP block"
|
||||
r = sign_block(q, itp, "4", sign={"factor": "password", "secret": "wrong-one"})
|
||||
assert 400 <= r.status_code < 500, r.text
|
||||
itp = ok(sign_block(q, itp, "4"))
|
||||
b = block(itp, "4")
|
||||
assert b["signed"] is True and b["signed_by"]["display_name"] == "Quinn QC" and b["signed_at"]
|
||||
assert itp["log"][0]["action"] == "ITP block 4 signed" and itp["log"][0]["note"] == "Section 4"
|
||||
assert itp["log"][0]["signed"] is True
|
||||
refused(sign_block(q, itp, "4"), 409, "already_signed")
|
||||
sig = one(
|
||||
shop.settings, "SELECT subject_type, subject_id, packet_id, meaning FROM signatures ORDER BY id DESC LIMIT 1"
|
||||
)
|
||||
assert (sig[0], str(sig[1]), sig[2], sig[3]) == ("itp", str(itp["id"]), None, "Sign an ITP block")
|
||||
# a block of all N/A lines signs
|
||||
itp = ok(section_na(q, itp, "5"))
|
||||
itp = ok(sign_block(q, itp, "5"))
|
||||
assert block(itp, "5")["signed"] is True and block(itp, "5")["all_na"] is True
|
||||
# block 3 covers sections 1 to 3
|
||||
itp = ok(section_na(q, itp, "2"))
|
||||
itp = ok(section_na(q, itp, "3"))
|
||||
assert block(itp, "3")["ready"] is False
|
||||
itp = ok(section_na(q, itp, "1"))
|
||||
assert block(itp, "3")["ready"] is True
|
||||
itp = ok(sign_block(q, itp, "3"))
|
||||
assert itp["log"][0]["note"] == "Sections 1–3"
|
||||
refused(sign_block(shop("sup.wes"), itp, "6"), 403, "forbidden")
|
||||
|
||||
|
||||
def test_block_unsign(qc_shop: Shop) -> None:
|
||||
shop = qc_shop
|
||||
_, itp = new_itp(shop)
|
||||
q = shop("qc.quinn")
|
||||
itp = done_section(shop, itp, "4")
|
||||
itp = ok(patch_block(q, itp, "4", qn="QN-4"))
|
||||
itp = ok(sign_block(q, itp, "4"))
|
||||
refused(unsign_block(shop("qc.sue"), itp, "4"), 403, "forbidden")
|
||||
itp = ok(unsign_block(q, itp, "4"))
|
||||
b = block(itp, "4")
|
||||
assert b["signed"] is False and b["signed_by"] is None and b["signed_at"] is None and b["qn"] == "QN-4"
|
||||
assert itp_log(itp)[0][0] == "ITP block 4 signature removed"
|
||||
# an admin may, with an IT reason
|
||||
itp = ok(sign_block(q, itp, "4"))
|
||||
itp = ok(unsign_block(shop("admin.ann"), itp, "4", it_reason=IT))
|
||||
assert block(itp, "4")["signed"] is False
|
||||
# the line can be unsigned again now
|
||||
itp = ok(unsign_line(q, itp, section_keys(itp, "4")[0]))
|
||||
|
||||
|
||||
# ================================================================ sign-off, lock, reopen
|
||||
|
||||
|
||||
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:
|
||||
shop = qc_shop
|
||||
unit_id, itp = new_itp(shop)
|
||||
q = shop("qc.quinn")
|
||||
refused(sign_off(q, itp), 409, "blocks_unsigned", MSG_BLOCKS_UNSIGNED)
|
||||
itp = approve_all(shop, itp)
|
||||
assert itp["progress"] == {"done": TEMPLATE_ITEMS, "total": TEMPLATE_ITEMS, "holds": 0}
|
||||
assert itp["holds"] == [] and itp["can"]["sign_off"] is False
|
||||
blocks = [str(b["key"]) for b in itp["blocks"]]
|
||||
for key in blocks[:-1]:
|
||||
itp = ok(sign_block(q, itp, key))
|
||||
refused(sign_off(q, itp), 409, "blocks_unsigned")
|
||||
itp = ok(sign_block(q, itp, blocks[-1]))
|
||||
assert itp["can"]["sign_off"] is True
|
||||
r = refused(sign_off(q, itp, sign=None), 400, "signature_required")
|
||||
assert r.get("meaning") == "Sign off the ITP"
|
||||
refused(sign_off(shop("clerk.clara"), itp), 403, "forbidden")
|
||||
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"]
|
||||
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
|
||||
# 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
|
||||
|
||||
|
||||
def test_locked_after_signoff_and_reopen(qc_shop: Shop) -> None:
|
||||
shop = qc_shop
|
||||
_, itp = new_itp(shop)
|
||||
q = shop("qc.quinn")
|
||||
itp = ok(sign_off(q, finish_all(shop, itp)))
|
||||
first = section_keys(itp, "1")[0]
|
||||
for r in (
|
||||
patch_itp(q, itp, {"client": "X"}),
|
||||
sign_line(q, itp, first),
|
||||
unsign_line(q, itp, first),
|
||||
patch_line(q, itp, first, note="late"),
|
||||
section_na(q, itp, "5"),
|
||||
reject_line(q, itp, first, ncr=NCR),
|
||||
enter_ncr(q, itp, first, NCR),
|
||||
patch_block(q, itp, "3", qn="QN-9"),
|
||||
sign_block(q, itp, "3"),
|
||||
unsign_block(q, itp, "3"),
|
||||
sign_off(q, itp),
|
||||
):
|
||||
refused(r, 409, "itp_complete", MSG_ITP_COMPLETE)
|
||||
# reopen: Q.C. users may not; the Q.C. supervisor and the admin may
|
||||
refused(reopen_itp(q, itp, "Customer asked for 4.2 again"), 403, "forbidden")
|
||||
refused(reopen_itp(shop("qc.sue"), itp, "Customer asked for 4.2 again"), 403, "forbidden")
|
||||
r = reopen_itp(shop("admin.ann"), itp, "short")
|
||||
assert r.status_code == 400 and r.json()["code"] in ("reason_required", "it_reason_required"), r.text
|
||||
itp = ok(reopen_itp(shop("admin.ann"), itp, "Customer asked for 4.2 again"))
|
||||
assert itp["status"] == "open" and itp["reopen"]["reason"] == "Customer asked for 4.2 again"
|
||||
assert itp["reopen"]["by"]["display_name"] == "Ann Admin" and itp["reopen"]["at"]
|
||||
assert itp_log(itp)[0] == ("ITP reopened", "Customer asked for 4.2 again")
|
||||
au = one(shop.settings, "SELECT it_reason FROM audit_events WHERE action = 'itp.itp_reopen'")
|
||||
assert au is not None and au[0] == "Customer asked for 4.2 again"
|
||||
# the signatures stay; the inspector undoes what must change
|
||||
assert all(b["signed"] for b in itp["blocks"]) and itp["can"]["sign_off"] is True
|
||||
refused(reopen_itp(shop("admin.ann"), itp, "Customer asked again"), 409, "itp_open")
|
||||
itp = ok(patch_itp(q, itp, {"client": "ACME"}))
|
||||
# a Q.C. supervisor reopens too
|
||||
itp = ok(sign_off(q, itp))
|
||||
itp = ok(reopen_itp(shop("qcsup.sam"), itp, "Re-inspect the nozzle welds"))
|
||||
assert itp["status"] == "open" and itp["reopen"]["by"]["display_name"] == "Sam Qcsup"
|
||||
|
||||
|
||||
def test_signoff_with_pdf_hook(qc_shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
shop = qc_shop
|
||||
unit_id, itp = new_itp(shop)
|
||||
q = shop("qc.quinn")
|
||||
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:
|
||||
calls.append(itp_row)
|
||||
return uuid.UUID(asset_ids[len(calls) - 1])
|
||||
|
||||
patch_hook(monkeypatch, fake_hook)
|
||||
itp = ok(sign_off(q, finish_all(shop, itp)))
|
||||
assert len(calls) == 1
|
||||
assert itp["signoff"]["asset_id"] == asset_ids[0]
|
||||
assert itp_log(itp)[0] == ("ITP signed off", "PDF filed")
|
||||
row = one(shop.settings, "SELECT signoff_asset_id FROM itps WHERE id = %s", (itp["id"],))
|
||||
assert str(row[0]) == asset_ids[0]
|
||||
docs = all_rows(
|
||||
shop.settings,
|
||||
"SELECT id, type, title, coalesce(rev, ''), note, auto, superseded_by, asset_id"
|
||||
" FROM qc_docs WHERE unit_id = %s",
|
||||
(unit_id,),
|
||||
)
|
||||
assert len(docs) == 1
|
||||
d = docs[0]
|
||||
assert (d[1], d[2], d[3], d[5], d[6]) == ("ITP", f"{itp['no']} — signed off", "", True, None)
|
||||
assert d[4] == "Filed automatically when the digital ITP was signed off." and str(d[7]) == asset_ids[0]
|
||||
# a second sign-off (after a reopen) supersedes the earlier auto document
|
||||
itp = ok(reopen_itp(shop("admin.ann"), itp, "Customer asked for 4.2 again"))
|
||||
itp = ok(sign_off(q, itp))
|
||||
assert itp["signoff"]["asset_id"] == asset_ids[1]
|
||||
docs = all_rows(
|
||||
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
|
||||
|
||||
|
||||
def test_log_order_and_filed_packet(qc_shop: Shop) -> None:
|
||||
"""The ITP belongs to the unit: a filed packet in the unit does not stop any ITP write. The log is newest
|
||||
first, at most 600 rows, and every write wrote one row."""
|
||||
shop = qc_shop
|
||||
from qchelpers import filed_packet
|
||||
|
||||
_, unit_id = make_unit(shop.settings)
|
||||
filed_packet(shop, unit_id)
|
||||
itp = start_itp(shop, unit_id)
|
||||
q = shop("qc.quinn")
|
||||
itp = ok(sign_line(q, itp, "1.1"))
|
||||
itp = ok(patch_line(q, itp, "1.2", na=True))
|
||||
itp = ok(reject_line(q, itp, "1.3", ncr=NCR))
|
||||
itp = ok(patch_block(q, itp, "3", qn="QN-1"))
|
||||
itp = ok(patch_itp(q, itp, {"po": "PO-99"}))
|
||||
assert [a for a, _ in itp_log(itp)] == [
|
||||
"ITP header changed",
|
||||
"ITP block 3 QN # changed",
|
||||
"ITP line 1.3 rejected",
|
||||
"ITP line 1.2 updated",
|
||||
"ITP line 1.1 approved",
|
||||
"ITP started",
|
||||
]
|
||||
ids = [r["id"] for r in itp["log"]]
|
||||
assert ids == sorted(ids, reverse=True)
|
||||
assert all(r["by"]["display_name"] == "Quinn QC" and r["by_name"] == "Quinn QC" for r in itp["log"])
|
||||
count = all_rows(shop.settings, "SELECT count(*) FROM itp_log WHERE itp_id = %s", (itp["id"],))[0][0]
|
||||
assert count == 6
|
||||
# the rest of the ITP still works next to the filed packet: unsign, sign a block, sign off
|
||||
itp = ok(unsign_line(q, itp, "1.1"))
|
||||
itp = ok(sign_off(q, finish_all(shop, itp)))
|
||||
assert itp["status"] == "complete"
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Phase 6 ITP hold points: the pure functions in domain/itp.py (hold codes, blocks, department pages, open holds),
|
||||
and the packet read model's `itp_hold` filtered by the packet's department page group. The hold is a warning only:
|
||||
no precondition reads it, so `blocked` never mentions it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from pshelpers import needs_pg
|
||||
from qchelpers import (
|
||||
ALL_HOLDS,
|
||||
BLOCKS,
|
||||
DEPT_PAGES,
|
||||
HOLDS,
|
||||
NCR,
|
||||
g,
|
||||
patch_line,
|
||||
reject_line,
|
||||
sign_line,
|
||||
start_itp,
|
||||
)
|
||||
from shophelpers import Shop, draft, fresh, make_unit, ok, open_shop, ready_packet, ready_skipped, run
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
SHOP_CODES = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14"]
|
||||
|
||||
|
||||
def itpd() -> Any:
|
||||
from packetstation.domain import itp
|
||||
|
||||
return itp
|
||||
|
||||
|
||||
def template() -> dict[str, Any]:
|
||||
from packetstation.domain.itp_template import ITP_TEMPLATE
|
||||
|
||||
out: dict[str, Any] = ITP_TEMPLATE
|
||||
return out
|
||||
|
||||
|
||||
def hold_keys(holds: Any) -> list[str]:
|
||||
return [str(g(h, "n")) for h in holds]
|
||||
|
||||
|
||||
# ================================================================ pure
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "hold"),
|
||||
[
|
||||
("H", True),
|
||||
("W", True),
|
||||
("R/W", True),
|
||||
("R/H", True),
|
||||
("AU/W", True),
|
||||
("R/AU", False),
|
||||
("NA", False),
|
||||
("R", False),
|
||||
("A", False),
|
||||
("AU", False),
|
||||
("O", False),
|
||||
("I", False),
|
||||
("WH", False),
|
||||
("H/R", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_is_hold_code(code: str, hold: bool) -> None:
|
||||
assert bool(itpd().is_hold_code(code)) is hold
|
||||
|
||||
|
||||
def test_template_shape() -> None:
|
||||
t = template()
|
||||
assert t["ver"] == "ITP_Generation_Revised_2"
|
||||
assert "O = Observe" in t["legend"] and "NA = Not Applicable" in t["legend"]
|
||||
assert [str(s["n"]) for s in t["sections"]] == [str(n) for n in range(1, 10)]
|
||||
assert [s["page"] for s in t["sections"]] == ["WP"] * 3 + ["C"] * 3 + ["FA"] * 3
|
||||
assert [str(s["n"]) for s in t["sections"] if s.get("sig")] == ["3", "4", "5", "6", "7", "8", "9"]
|
||||
assert [str(s["n"]) for s in t["sections"] if s.get("final")] == ["9"]
|
||||
items = list(itpd().items(t))
|
||||
assert len(items) == 70
|
||||
codes = {str(i["n"]): (i["code"], i["cust"]) for s in t["sections"] for i in s["items"]}
|
||||
assert codes["3.4"] == ("A", "W") and codes["4.2"] == ("O", "H") and codes["9.7"] == ("R", "H")
|
||||
|
||||
|
||||
def test_blocks() -> None:
|
||||
out = list(itpd().blocks(template()))
|
||||
assert [str(g(b, "key")) for b in out] == list(BLOCKS)
|
||||
|
||||
# sections may be numbers or the section dicts themselves (the server gives the dicts)
|
||||
def sec_no(s: Any) -> str:
|
||||
return str(s["n"]) if isinstance(s, dict) else str(s)
|
||||
|
||||
assert {str(g(b, "key")): [sec_no(s) for s in g(b, "sections")] for b in out} == BLOCKS
|
||||
assert [str(g(b, "key")) for b in out if g(b, "final")] == ["9"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", SHOP_CODES)
|
||||
def test_dept_pages_shop(code: str) -> None:
|
||||
assert list(itpd().dept_pages(code)) == DEPT_PAGES[code]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", ["CL", "WH", "QC", "PM", "ZZ", ""])
|
||||
def test_dept_pages_support_or_unknown(code: str) -> None:
|
||||
assert itpd().dept_pages(code) is None
|
||||
|
||||
|
||||
def test_open_holds_from_the_template() -> None:
|
||||
t = template()
|
||||
holds = itpd().open_holds(t, {})
|
||||
assert sorted(hold_keys(holds), key=_key) == sorted(ALL_HOLDS, key=_key)
|
||||
assert len(holds) == 17
|
||||
for page, keys in HOLDS.items():
|
||||
assert sorted(hold_keys(itpd().open_holds(t, {}, pages=[page])), key=_key) == sorted(keys, key=_key), page
|
||||
wp_c = itpd().open_holds(t, {}, pages=["WP", "C"])
|
||||
assert len(wp_c) == 12
|
||||
|
||||
|
||||
def test_open_holds_drop_done_lines_and_add_rejected() -> None:
|
||||
t = template()
|
||||
lines = {
|
||||
"3.4": {"key": "3.4", "result": "approve", "na": False},
|
||||
"4.2": {"key": "4.2", "result": "na", "na": True},
|
||||
"1.1": {"key": "1.1", "result": "reject", "ncr": None, "na": False},
|
||||
"1.2": {"key": "1.2", "result": "reject", "ncr": NCR, "na": False},
|
||||
"1.3": {"key": "1.3", "result": None, "cust_code": "H", "na": False},
|
||||
"4.4": {"key": "4.4", "result": None, "cust_code": "R", "na": False}, # a line code R: 4.4's PESCO code is O
|
||||
}
|
||||
keys = hold_keys(itpd().open_holds(t, lines))
|
||||
assert "3.4" not in keys and "4.2" not in keys
|
||||
assert {"1.1", "1.2", "1.3"} <= set(keys)
|
||||
# 4.4: the template customer code is H; the line's own code R replaces it, and the PESCO code is O
|
||||
assert "4.4" not in keys
|
||||
assert len(keys) == 17 - 3 + 3
|
||||
wp = itpd().open_holds(t, lines, pages=["WP"])
|
||||
assert sorted(hold_keys(wp), key=_key) == ["1.1", "1.2", "1.3"]
|
||||
rej = {str(g(h, "n")): h for h in wp}
|
||||
assert g(rej["1.1"], "rejected") is True and g(rej["1.1"], "ncr") is None
|
||||
assert g(rej["1.2"], "ncr") == NCR and g(rej["1.3"], "rejected") is False
|
||||
|
||||
|
||||
def _key(n: str) -> tuple[int, ...]:
|
||||
return tuple(int(x) for x in n.split("."))
|
||||
|
||||
|
||||
# ================================================================ through the API
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def _hold(shop: Shop, pid: int, user: str = "sup.wes") -> Any:
|
||||
return fresh(shop(user), pid)["itp_hold"]
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_itp_hold_by_department(shop: Shop) -> None:
|
||||
_, unit_id = make_unit(shop.settings)
|
||||
in_03 = ready_packet(shop, unit_id)
|
||||
assert _hold(shop, in_03["id"]) is None # no ITP yet
|
||||
itp = start_itp(shop, unit_id)
|
||||
in_11 = ready_skipped(shop, unit_id, dept="11")
|
||||
in_09 = ready_skipped(shop, unit_id, dept="09")
|
||||
support = draft(shop, unit_id)
|
||||
run(shop.settings, "UPDATE packets SET dept_code = 'QC' WHERE id = %s", (support["id"],))
|
||||
|
||||
h = _hold(shop, in_03["id"])
|
||||
assert h["no"] == itp["no"] and h["pages"] == ["WP"] and h["count"] == 1 and h["rejected"] is False
|
||||
assert hold_keys(h["items"]) == ["3.4"]
|
||||
item = h["items"][0]
|
||||
assert "W" in item["codes"] and item["t"] and item["rejected"] is False and item["ncr"] is None
|
||||
|
||||
h = _hold(shop, in_11["id"])
|
||||
assert h["pages"] == ["FA"] and sorted(hold_keys(h["items"]), key=_key) == list(HOLDS["FA"])
|
||||
assert "9.6" in hold_keys(h["items"]) and "4.2" not in hold_keys(h["items"])
|
||||
h = _hold(shop, in_09["id"])
|
||||
assert h["pages"] == ["WP", "C"] and h["count"] == 12
|
||||
h = _hold(shop, support["id"], "clerk.clara")
|
||||
assert h["pages"] is None and h["count"] == 17 and len(h["items"]) == 17
|
||||
|
||||
# the summary on the packet
|
||||
s = fresh(shop("sup.wes"), in_03["id"])["itp"]
|
||||
assert s["id"] == itp["id"] and s["no"] == itp["no"] and s["status"] == "open"
|
||||
assert s["done"] == 0 and s["total"] == 70 and s["holds"] == 17
|
||||
|
||||
# a warning only: nothing in blocked names the ITP
|
||||
for pid in (in_03["id"], in_11["id"]):
|
||||
for user in ("sup.wes", "lead.lou", "qc.quinn", "clerk.clara", "wh.wade"):
|
||||
blocked = fresh(shop(user), pid)["blocked"]
|
||||
assert not any("ITP" in why for reasons in blocked.values() for why in reasons), (user, blocked)
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_itp_hold_follows_the_lines(shop: Shop) -> None:
|
||||
_, unit_id = make_unit(shop.settings)
|
||||
in_03 = ready_packet(shop, unit_id)
|
||||
itp = start_itp(shop, unit_id)
|
||||
q = shop("qc.quinn")
|
||||
# approving the only WP hold clears the 03 packet's hold
|
||||
itp = ok(sign_line(q, itp, "3.4"))
|
||||
assert _hold(shop, in_03["id"]) is None
|
||||
assert fresh(shop("sup.wes"), in_03["id"])["itp"]["holds"] == 16
|
||||
# a rejected WP line comes back as a rejected point, with its NCR when given
|
||||
itp = ok(reject_line(q, itp, "1.1", note="MTR heat does not match"))
|
||||
h = _hold(shop, in_03["id"])
|
||||
assert h["count"] == 1 and h["rejected"] is True
|
||||
assert h["items"][0]["n"] == "1.1" and h["items"][0]["rejected"] is True and h["items"][0]["ncr"] is None
|
||||
itp = ok(reject_line(q, itp, "1.2", ncr=NCR, note="WPS rev"))
|
||||
h = _hold(shop, in_03["id"])
|
||||
assert h["count"] == 2 and next(i for i in h["items"] if i["n"] == "1.2")["ncr"] == NCR
|
||||
# a customer hold code on a WP line adds a point; N/A takes a point away
|
||||
itp = ok(patch_line(q, itp, "2.1", cust_code="H"))
|
||||
assert _hold(shop, in_03["id"])["count"] == 3
|
||||
itp = ok(patch_line(q, itp, "2.1", na=True))
|
||||
assert _hold(shop, in_03["id"])["count"] == 2
|
||||
# the ITP read lists every open point, unfiltered
|
||||
assert itp["progress"]["holds"] == len(itp["holds"])
|
||||
assert {"1.1", "1.2", "4.2", "9.7"} <= set(hold_keys(itp["holds"]))
|
||||
# once the ITP is complete the hold is gone
|
||||
run(shop.settings, "UPDATE itps SET status = 'complete' WHERE id = %s", (itp["id"],))
|
||||
assert _hold(shop, in_03["id"]) is None
|
||||
run(shop.settings, "UPDATE itps SET status = 'open' WHERE id = %s", (itp["id"],))
|
||||
assert _hold(shop, in_03["id"])["count"] == 2
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_itp_hold_count_on_the_qc_home(shop: Shop) -> None:
|
||||
from qchelpers import at_qc
|
||||
|
||||
_, unit_id = make_unit(shop.settings)
|
||||
start_itp(shop, unit_id)
|
||||
pkt = at_qc(shop, unit_id)
|
||||
home = ok(shop("qc.quinn").get("/api/qc/home"))
|
||||
card = next(c for c in home["columns"]["ready"] if c["id"] == pkt["id"])
|
||||
# dept 03 sees the WP page: 3.4
|
||||
assert card["itp_hold_count"] == 1
|
||||
@@ -464,7 +464,7 @@ def test_work_orders_put_and_docs(pg_client2: TestClient, unit: tuple[int, int])
|
||||
wo = r.json()["work_orders"]
|
||||
assert [(w["wo_base"], w["lot"], w["sub"]) for w in wo] == [("0070001", "7A", "01")]
|
||||
a = upload(pg_client2, tiny_pdf(1)).json()
|
||||
r = pg_client2.post(f"/api/packets/{pkt['id']}/docs", json={"asset_id": a["id"], "kind": "mtr", "title": "MTR 1"})
|
||||
r = pg_client2.post(f"/api/packets/{pkt['id']}/docs", json={"asset_id": a["id"], "kind": "other", "title": "MTR 1"})
|
||||
assert r.status_code == 200 and r.json()["docs"][0]["title"] == "MTR 1"
|
||||
doc_id = r.json()["docs"][0]["id"]
|
||||
as_user(pg_client2, "wh.wade")
|
||||
@@ -472,7 +472,7 @@ def test_work_orders_put_and_docs(pg_client2: TestClient, unit: tuple[int, int])
|
||||
assert r.status_code == 403
|
||||
as_user(pg_client2, "qc.quinn")
|
||||
r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}/docs/{doc_id}", json={"reason": "wrong file"})
|
||||
assert r.status_code == 200 and r.json()["docs"] == []
|
||||
assert r.status_code == 200 and r.json()["docs"][0]["removed"]["reason"] == "wrong file"
|
||||
|
||||
|
||||
def test_seal_detects_tampering(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
"""Phase 6 Q.C. policy: the packet Q.C. actions (QC_ACCEPT, QC_ACCEPT_AND_ROUTE, QC_REJECT, EDIT_QC, DOC_ADD,
|
||||
DOC_REMOVE) as every test user from every status the helpers reach, the scoped actions (QC_DOC_ADD and the ITP
|
||||
actions) through `check_scoped`, and the qc_missing_empty gate one reason at a time.
|
||||
|
||||
The expectations come from an oracle written from the phase 6 contract (who, from-status, CO hold, filed lock,
|
||||
preconditions), not from the engine. Part one runs the pure policy; part two runs the API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import itertools
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from pkthelpers import step
|
||||
from pshelpers import needs_pg
|
||||
from qchelpers import (
|
||||
QC_KEYS,
|
||||
accepted_packet,
|
||||
add_packet_doc,
|
||||
at_qc,
|
||||
filed_packet,
|
||||
finish_inspection,
|
||||
qc_check,
|
||||
qc_patch,
|
||||
qc_weld,
|
||||
take_out,
|
||||
upload_doc,
|
||||
)
|
||||
from shophelpers import (
|
||||
IT,
|
||||
SIGN,
|
||||
USERS,
|
||||
Shop,
|
||||
draft,
|
||||
fresh,
|
||||
in_progress,
|
||||
in_review,
|
||||
issue_co,
|
||||
ok,
|
||||
open_shop,
|
||||
ready_packet,
|
||||
refused,
|
||||
to_station,
|
||||
to_warehouse,
|
||||
)
|
||||
|
||||
from packetstation.policy import SYSTEM_FEED, Action, PacketState, Principal, can, check, principal_from_roles
|
||||
from packetstation.settings import Settings
|
||||
|
||||
ANDY = "00000000-0000-0000-0000-00000000a001"
|
||||
REASON = "Weld 2 undercut too deep"
|
||||
NCR = "NCR-2026-0021"
|
||||
|
||||
PACKET_ACTIONS = ("qc_accept", "qc_accept_and_route", "qc_reject", "edit_qc", "doc_add", "doc_remove")
|
||||
TRANSITIONS = ("qc_accept", "qc_accept_and_route", "qc_reject")
|
||||
FROM: dict[str, frozenset[str]] = {
|
||||
"qc_accept": frozenset({"qc"}),
|
||||
"qc_accept_and_route": frozenset({"qc"}),
|
||||
"qc_reject": frozenset({"qc"}),
|
||||
"edit_qc": frozenset({"qc"}),
|
||||
"doc_add": frozenset(), # any status; the filed lock applies
|
||||
"doc_remove": frozenset(),
|
||||
}
|
||||
WHO = {
|
||||
"qc_accept": "QC",
|
||||
"qc_accept_and_route": "QC",
|
||||
"qc_reject": "QC",
|
||||
"edit_qc": "QC",
|
||||
"doc_add": "CL_OR_QC",
|
||||
"doc_remove": "CL_OR_QC",
|
||||
}
|
||||
CO_HELD = frozenset({"qc_accept", "qc_accept_and_route"})
|
||||
GATED = frozenset({"qc_accept", "qc_accept_and_route"}) # qc_missing_empty
|
||||
SIGNED = {
|
||||
"qc_accept": "Q.C. accept",
|
||||
"qc_accept_and_route": "Q.C. accept and route",
|
||||
"qc_reject": "Q.C. reject",
|
||||
}
|
||||
|
||||
ROLES = {
|
||||
"admin.ann": ["admin"],
|
||||
"clerk.clara": ["clerical"],
|
||||
"clerk.leo": ["clerical_supervisor"],
|
||||
"wh.wade": ["warehouse"],
|
||||
"qc.quinn": ["qc"],
|
||||
"pm.paula": ["pm"],
|
||||
"weld.andy": ["shop:03:worker"],
|
||||
"lead.lou": ["shop:03:lead"],
|
||||
"sup.wes": ["shop:03:supervisor"],
|
||||
"multi.mia": ["clerical", "shop:06:supervisor"],
|
||||
"both.bo": ["shop:03:worker", "shop:03:supervisor"],
|
||||
"dup.dave": ["shop:03:worker"],
|
||||
}
|
||||
QC_PEOPLE = frozenset({"qc.quinn"})
|
||||
CL_OR_QC_PEOPLE = frozenset({"clerk.clara", "clerk.leo", "multi.mia", "qc.quinn"})
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Case:
|
||||
name: str
|
||||
status: str
|
||||
qc_done: bool = False # the open inspection has nothing missing
|
||||
|
||||
|
||||
CASES = {
|
||||
"draft": Case("draft", "draft"),
|
||||
"warehouse": Case("warehouse", "warehouse"),
|
||||
"ready": Case("ready", "ready"),
|
||||
"assigned": Case("assigned", "assigned"),
|
||||
"in_progress": Case("in_progress", "in_progress"),
|
||||
"review": Case("review", "review"),
|
||||
"qc": Case("qc", "qc"),
|
||||
"qc_done": Case("qc_done", "qc", qc_done=True),
|
||||
"complete": Case("complete", "complete"),
|
||||
"filed": Case("filed", "filed"),
|
||||
}
|
||||
|
||||
|
||||
def who_ok(user: str, action: str) -> bool:
|
||||
if WHO[action] == "QC":
|
||||
return user in QC_PEOPLE
|
||||
return user in CL_OR_QC_PEOPLE
|
||||
|
||||
|
||||
def expected(user: str, case: Case, action: str, *, it_reason: bool = False, co_hold: bool = False) -> tuple[int, str]:
|
||||
"""The answer with every request input given (route, dept, reason, signature). The order is the contract's:
|
||||
status, CO hold, admin bypass, filed lock, who, preconditions."""
|
||||
if FROM[action] and case.status not in FROM[action]:
|
||||
return 403, "wrong_status"
|
||||
if co_hold and action in CO_HELD:
|
||||
return 403, "co_hold"
|
||||
allowed = who_ok(user, action)
|
||||
if not allowed and user == "admin.ann":
|
||||
if not it_reason:
|
||||
return 400, "it_reason_required"
|
||||
allowed = True
|
||||
if case.status == "filed":
|
||||
return 403, "filed"
|
||||
if not allowed:
|
||||
return 403, "forbidden"
|
||||
if action in GATED and not case.qc_done:
|
||||
return 403, "precondition"
|
||||
return 200, ""
|
||||
|
||||
|
||||
def in_can(user: str, case: Case, action: str, **kw: Any) -> bool:
|
||||
return expected(user, case, action, it_reason=True, **kw)[0] == 200
|
||||
|
||||
|
||||
def in_blocked(user: str, case: Case, action: str, **kw: Any) -> bool:
|
||||
"""`blocked`: status, who and the filed lock pass, but qc_missing_empty or the CO hold fails."""
|
||||
code = expected(user, case, action, it_reason=True, **kw)[1]
|
||||
if code == "co_hold":
|
||||
return who_ok(user, action) or user == "admin.ann"
|
||||
return code == "precondition"
|
||||
|
||||
|
||||
# ================================================================ part one: the pure policy
|
||||
|
||||
MISSING = ("10 inspection items", "2 welds without a visual result", "inspector name")
|
||||
|
||||
|
||||
def P(user: str) -> Principal:
|
||||
uid = ANDY if user == "weld.andy" else f"00000000-0000-0000-0000-{abs(hash(user)) % 10**12:012d}"
|
||||
return principal_from_roles(uid, ROLES[user])
|
||||
|
||||
|
||||
def state(case: Case, **kw: Any) -> PacketState:
|
||||
active = case.status in ("assigned", "in_progress", "review")
|
||||
base = PacketState(
|
||||
id=1,
|
||||
status=case.status,
|
||||
dept="03",
|
||||
station_id=101 if active else None,
|
||||
welder_id=ANDY if active else None,
|
||||
wh_skipped=False,
|
||||
routed_to="databook" if case.status in ("complete", "filed") else None,
|
||||
vclosed=case.status == "filed",
|
||||
removed=False,
|
||||
source_kind="hand",
|
||||
weld_dept=True,
|
||||
co_hold=False,
|
||||
drawing_attached=True,
|
||||
heat_map=True,
|
||||
bom_total=3,
|
||||
bom_ready=3,
|
||||
welds=2,
|
||||
checklist_complete=True,
|
||||
filler_set=True,
|
||||
qc_missing=() if case.qc_done or case.status != "qc" else MISSING,
|
||||
has_qc_sent=case.status == "qc",
|
||||
)
|
||||
return dataclasses.replace(base, **kw)
|
||||
|
||||
|
||||
EXTRA: dict[str, dict[str, Any]] = {
|
||||
"qc_accept": {"routed_to": "databook"},
|
||||
"qc_accept_and_route": {"dept": "09"},
|
||||
"qc_reject": {"reason": REASON},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("user", "case", "action"), list(itertools.product(ROLES, CASES, PACKET_ACTIONS)))
|
||||
def test_policy_matrix(user: str, case: str, action: str) -> None:
|
||||
c = CASES[case]
|
||||
s = state(c)
|
||||
d = check(P(user), s, Action(action), extra=EXTRA.get(action, {}))
|
||||
want = expected(user, c, action)
|
||||
assert ("" if d.ok else d.code) == want[1], (user, case, action, d)
|
||||
if d.ok and action in SIGNED:
|
||||
assert d.signature == SIGNED[action]
|
||||
allowed, blocked = can(P(user), s)
|
||||
assert (Action(action) in allowed) == in_can(user, c, action), (user, case, action, allowed)
|
||||
assert (Action(action) in blocked) == in_blocked(user, c, action), (user, case, action, blocked)
|
||||
if Action(action) in blocked and action in GATED:
|
||||
assert blocked[Action(action)] == ["Q.C. still needs: " + ", ".join(MISSING) + "."]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user", list(ROLES))
|
||||
@pytest.mark.parametrize("case", ["qc", "qc_done"])
|
||||
def test_policy_co_hold_on_accept_not_reject(user: str, case: str) -> None:
|
||||
c = CASES[case]
|
||||
why = "Change order CO-1 must be verified first by: 03 Weld Parts."
|
||||
s = state(c, co_hold=True, co_hold_why=why)
|
||||
allowed, blocked = can(P(user), s)
|
||||
for action in PACKET_ACTIONS:
|
||||
d = check(P(user), s, Action(action), extra=EXTRA.get(action, {}))
|
||||
want = expected(user, c, action, co_hold=True)
|
||||
assert ("" if d.ok else d.code) == want[1], (user, case, action, d)
|
||||
if want[1] == "co_hold":
|
||||
assert d.why == why
|
||||
assert (Action(action) in allowed) == in_can(user, c, action, co_hold=True), (user, case, action)
|
||||
assert (Action(action) in blocked) == in_blocked(user, c, action, co_hold=True), (user, case, action)
|
||||
if Action(action) in blocked and action in CO_HELD:
|
||||
assert blocked[Action(action)][0] == why
|
||||
if case == "qc":
|
||||
assert blocked[Action(action)][1] == "Q.C. still needs: " + ", ".join(MISSING) + "."
|
||||
|
||||
|
||||
def test_policy_reject_needs_a_reason() -> None:
|
||||
s = state(CASES["qc"])
|
||||
for extra in ({}, {"reason": ""}, {"reason": "bad"}):
|
||||
d = check(P("qc.quinn"), s, Action.QC_REJECT, extra=extra)
|
||||
assert d.code == "reason_required", (extra, d)
|
||||
assert check(P("qc.quinn"), s, Action.QC_REJECT, extra={"reason": NCR}).ok
|
||||
# the accept routes are inputs
|
||||
assert check(P("qc.quinn"), state(CASES["qc_done"]), Action.QC_ACCEPT, extra={}).code == "precondition"
|
||||
d = check(P("qc.quinn"), state(CASES["qc_done"]), Action.QC_ACCEPT_AND_ROUTE, extra={"dept": "03"})
|
||||
assert d.code == "precondition"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- scoped actions (no packet)
|
||||
|
||||
SCOPED: dict[str, str] = {
|
||||
"qc_doc_add": "CL_OR_QC",
|
||||
"itp_start": "QC",
|
||||
"itp_line_sign": "QC",
|
||||
"itp_line_unsign": "QC",
|
||||
"itp_edit_line": "QC",
|
||||
"itp_block_sign": "QC",
|
||||
"itp_block_unsign": "QC",
|
||||
"itp_sign_off": "QC",
|
||||
"itp_reopen": "QC_SUP",
|
||||
}
|
||||
SCOPED_SIGNED = {"itp_block_sign": "Sign an ITP block", "itp_sign_off": "Sign off the ITP"}
|
||||
SCOPED_ROLES = {**ROLES, "qc.sup": ["qc_supervisor"]}
|
||||
|
||||
|
||||
def scoped_who(user: str, who: str) -> bool:
|
||||
if who == "QC":
|
||||
return user in ("qc.quinn", "qc.sup")
|
||||
if who == "QC_SUP":
|
||||
return user == "qc.sup"
|
||||
return user in CL_OR_QC_PEOPLE or user == "qc.sup"
|
||||
|
||||
|
||||
def scoped_expected(user: str, action: str, *, it_reason: bool, reason: bool = True) -> tuple[bool, str, bool]:
|
||||
"""(ok, code, admin_bypass) for check_scoped."""
|
||||
allowed = scoped_who(user, SCOPED[action])
|
||||
bypass = False
|
||||
if not allowed and user == "admin.ann":
|
||||
if not it_reason:
|
||||
return False, "it_reason_required", False
|
||||
allowed, bypass = True, True
|
||||
if not allowed:
|
||||
return False, "forbidden", False
|
||||
if action == "itp_reopen" and not reason:
|
||||
return False, "reason_required", bypass
|
||||
return True, "", bypass
|
||||
|
||||
|
||||
def _check_scoped() -> Any:
|
||||
from packetstation.policy.engine import check_scoped
|
||||
|
||||
return check_scoped
|
||||
|
||||
|
||||
def SP(user: str) -> Principal:
|
||||
return principal_from_roles(f"00000000-0000-0000-0000-{abs(hash(user)) % 10**12:012d}", SCOPED_ROLES[user])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("user", "action"), list(itertools.product(SCOPED_ROLES, SCOPED)))
|
||||
@pytest.mark.parametrize("it", [False, True])
|
||||
def test_check_scoped(user: str, action: str, it: bool) -> None:
|
||||
check_scoped = _check_scoped()
|
||||
extra = {"reason": "Customer asked to re-inspect 4.2"} if action == "itp_reopen" else {}
|
||||
d = check_scoped(SP(user), Action(action), extra=extra, it_reason=IT if it else None)
|
||||
want = scoped_expected(user, action, it_reason=it)
|
||||
assert (d.ok, "" if d.ok else d.code) == want[:2], (user, action, it, d)
|
||||
if d.ok:
|
||||
assert d.admin_bypass == want[2], (user, action, d)
|
||||
if action in SCOPED_SIGNED:
|
||||
assert d.signature == SCOPED_SIGNED[action]
|
||||
|
||||
|
||||
def test_check_scoped_special_cases() -> None:
|
||||
check_scoped = _check_scoped()
|
||||
# the system principal never passes a people action
|
||||
for action in SCOPED:
|
||||
d = check_scoped(SYSTEM_FEED, Action(action), extra={"reason": "Customer asked again"})
|
||||
assert d.code == "forbidden", (action, d)
|
||||
# admin: an IT reason shorter than 8 characters is no IT reason
|
||||
d = check_scoped(SP("admin.ann"), Action("itp_start"), it_reason="short")
|
||||
assert d.code == "it_reason_required"
|
||||
d = check_scoped(SP("admin.ann"), Action("itp_start"), it_reason=IT)
|
||||
assert d.ok and d.admin_bypass
|
||||
# reopen: the reason is required for the Q.C. supervisor and for the admin
|
||||
d = check_scoped(SP("qc.sup"), Action("itp_reopen"), extra={})
|
||||
assert d.code == "reason_required"
|
||||
d = check_scoped(SP("admin.ann"), Action("itp_reopen"), extra={"reason": "no"}, it_reason=IT)
|
||||
assert d.code == "reason_required"
|
||||
d = check_scoped(SP("qc.quinn"), Action("itp_reopen"), extra={"reason": "Customer asked again"})
|
||||
assert d.code == "forbidden"
|
||||
# PM reads only
|
||||
for action in SCOPED:
|
||||
assert check_scoped(SP("pm.paula"), Action(action), extra={"reason": "Customer asked"}).code == "forbidden"
|
||||
|
||||
|
||||
def test_scoped_actions_exist_with_labels() -> None:
|
||||
for name in ("qc_doc_add", "itp_start", "itp_line_sign", "itp_line_unsign", "itp_block_unsign"):
|
||||
a = Action(name)
|
||||
assert a.value == name
|
||||
from packetstation.policy.actions import EDIT_WHAT, RULES
|
||||
|
||||
for name in SCOPED:
|
||||
rule = RULES[Action(name)]
|
||||
assert rule.to_status is None and not rule.system_only, name
|
||||
assert name in {str(a) for a in EDIT_WHAT}, name
|
||||
assert EDIT_WHAT[Action("qc_doc_add")] == "the Q.C. folder"
|
||||
assert EDIT_WHAT[Action("itp_start")] == "the ITP"
|
||||
|
||||
|
||||
# ================================================================ part two: the API
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def body_for(action: str) -> dict[str, Any]:
|
||||
return {
|
||||
"qc_accept": {"routed_to": "databook", "sign": SIGN},
|
||||
"qc_accept_and_route": {"dept": "09", "sign": SIGN},
|
||||
"qc_reject": {"reason": REASON, "sign": SIGN},
|
||||
}[action]
|
||||
|
||||
|
||||
def build(shop: Shop, case: str) -> dict[str, Any]:
|
||||
"""A packet in the case's status with one packet document (by clerk.clara), made through the API."""
|
||||
if case == "filed":
|
||||
pkt = accepted_packet(shop)
|
||||
ok(add_packet_doc(shop("clerk.clara"), pkt["id"]))
|
||||
c = shop("clerk.clara")
|
||||
pkt = ok(c.post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True}))
|
||||
return ok(step(c, pkt, "file", sign=SIGN))
|
||||
if case == "draft":
|
||||
pkt = draft(shop)
|
||||
elif case == "warehouse":
|
||||
pkt = to_warehouse(shop, draft(shop))
|
||||
elif case == "ready":
|
||||
pkt = ready_packet(shop)
|
||||
elif case == "assigned":
|
||||
pkt = to_station(shop, ready_packet(shop))
|
||||
elif case == "in_progress":
|
||||
pkt = in_progress(shop)
|
||||
elif case == "review":
|
||||
pkt = in_review(shop)
|
||||
elif case == "qc":
|
||||
pkt = at_qc(shop)
|
||||
elif case == "qc_done":
|
||||
pkt = finish_inspection(shop, at_qc(shop))
|
||||
elif case == "complete":
|
||||
pkt = accepted_packet(shop)
|
||||
else:
|
||||
raise AssertionError(case)
|
||||
return ok(add_packet_doc(shop("clerk.clara"), pkt["id"]))
|
||||
|
||||
|
||||
def live_doc(pkt: dict[str, Any]) -> int:
|
||||
return int(next(d for d in pkt["docs"] if not d.get("removed"))["id"])
|
||||
|
||||
|
||||
@needs_pg
|
||||
@pytest.mark.parametrize("case", list(CASES))
|
||||
def test_api_matrix(shop: Shop, case: str) -> None:
|
||||
"""Every refused Q.C. step answers the oracle's code and changes nothing; `can` and `blocked` agree for every
|
||||
user. Allowed steps are not sent here (test_qc_api and test_qc_docs send them)."""
|
||||
c = CASES[case]
|
||||
pkt = build(shop, case)
|
||||
assert pkt["status"] == c.status
|
||||
# Building a filed packet takes minutes through the tunnel; sessions idle that long lock. Sign in afresh.
|
||||
shop.clients.clear()
|
||||
doc_id = live_doc(pkt)
|
||||
asset = upload_doc(shop("clerk.clara"))["id"]
|
||||
for user in USERS:
|
||||
mine = fresh(shop(user), pkt["id"])
|
||||
for action in PACKET_ACTIONS:
|
||||
assert (action in mine["can"]) == in_can(user, c, action), (user, case, action, mine["can"])
|
||||
assert (action in mine["blocked"]) == in_blocked(user, c, action), (user, case, action, mine["blocked"])
|
||||
want = expected(user, c, action)
|
||||
if c.status == "filed":
|
||||
# The write pipeline answers `filed` before it runs the policy (domain/pipeline.packet_write), so
|
||||
# through the API every write on a filed packet is 403 filed, whatever the engine order says.
|
||||
want = (403, "filed")
|
||||
if want[0] == 200:
|
||||
continue
|
||||
if action in TRANSITIONS:
|
||||
r = step(shop(user), pkt, action, **body_for(action))
|
||||
elif action == "doc_add":
|
||||
r = add_packet_doc(shop(user), pkt["id"], asset_id=asset)
|
||||
elif action == "doc_remove":
|
||||
r = take_out(shop(user), pkt["id"], doc_id)
|
||||
else:
|
||||
r = qc_patch(shop(user), pkt["id"], {"notes": "Looks fine"})
|
||||
if c.status != "qc" and r.status_code == 409:
|
||||
# no open inspection: the contract does not fix whether this or wrong_status answers first
|
||||
assert r.json()["code"] == "no_inspection", r.text
|
||||
continue
|
||||
assert (r.status_code, r.json().get("code")) == want, (user, case, action, r.text)
|
||||
if want[1] == "precondition" and action in GATED:
|
||||
assert r.json()["detail"].startswith("Q.C. still needs: ")
|
||||
assert mine["blocked"][action] == [r.json()["detail"]]
|
||||
after = fresh(shop("admin.ann"), pkt["id"])
|
||||
assert (after["status"], after["version"]) == (pkt["status"], pkt["version"])
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_api_admin_with_it_reason(shop: Shop) -> None:
|
||||
"""The admin passes who with an IT reason but never the Q.C. gate or the filed lock."""
|
||||
pkt = at_qc(shop)
|
||||
ann = shop("admin.ann")
|
||||
r = refused(step(ann, pkt, "qc_accept", routed_to="databook", sign=SIGN, it_reason=IT), 403, "precondition")
|
||||
assert r["detail"].startswith("Q.C. still needs: ")
|
||||
out = ok(qc_patch(ann, pkt["id"], {"notes": "Fixed by IT"}, it_reason=IT))
|
||||
assert out["qc"]["notes"] == "Fixed by IT"
|
||||
out = ok(add_packet_doc(ann, pkt["id"], it_reason=IT))
|
||||
refused(add_packet_doc(ann, pkt["id"]), 400, "it_reason_required")
|
||||
out = ok(step(ann, out, "qc_reject", reason=REASON, sign=SIGN, it_reason=IT))
|
||||
assert out["status"] == "review"
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_api_signature_required(shop: Shop) -> None:
|
||||
pkt = finish_inspection(shop, at_qc(shop))
|
||||
q = shop("qc.quinn")
|
||||
for action, body in (
|
||||
("qc_accept", {"routed_to": "databook"}),
|
||||
("qc_accept_and_route", {"dept": "09"}),
|
||||
("qc_reject", {"reason": REASON}),
|
||||
):
|
||||
r = refused(step(q, pkt, action, **body), 400, "signature_required")
|
||||
assert r.get("meaning") == SIGNED[action], r
|
||||
assert fresh(q, pkt["id"])["status"] == "qc"
|
||||
|
||||
|
||||
def _missing(pkt: dict[str, Any]) -> list[str]:
|
||||
out: list[str] = pkt["qc"]["missing"]
|
||||
return out
|
||||
|
||||
|
||||
def _gate(shop: Shop, pkt: dict[str, Any], parts: list[str]) -> None:
|
||||
"""The read model, the blocked list and a refused accept all say the same thing."""
|
||||
msg = "Q.C. still needs: " + ", ".join(parts) + "."
|
||||
mine = fresh(shop("qc.quinn"), pkt["id"])
|
||||
assert _missing(mine) == parts
|
||||
assert "qc_accept" not in mine["can"]
|
||||
assert mine["blocked"]["qc_accept"] == [msg]
|
||||
assert mine["blocked"]["qc_accept_and_route"] == [msg]
|
||||
r = refused(step(shop("qc.quinn"), mine, "qc_accept", routed_to="databook", sign=SIGN), 403, "precondition")
|
||||
assert r["detail"] == msg
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_api_qc_missing_one_by_one(shop: Shop) -> None:
|
||||
pkt = at_qc(shop)
|
||||
q = shop("qc.quinn")
|
||||
pid = pkt["id"]
|
||||
welds = sorted(int(w["weld_no"]) for w in pkt["welds"])
|
||||
assert len(welds) == 2
|
||||
_gate(shop, pkt, ["10 inspection items", "2 welds without a visual result", "inspector name"])
|
||||
for k in QC_KEYS[:7]:
|
||||
ok(qc_check(q, pid, k))
|
||||
_gate(shop, pkt, ["3 inspection items", "2 welds without a visual result", "inspector name"])
|
||||
for k in QC_KEYS[7:9]:
|
||||
ok(qc_check(q, pid, k))
|
||||
_gate(shop, pkt, ["1 inspection item", "2 welds without a visual result", "inspector name"])
|
||||
ok(qc_check(q, pid, QC_KEYS[9]))
|
||||
_gate(shop, pkt, ["2 welds without a visual result", "inspector name"])
|
||||
ok(qc_weld(q, pid, welds[0], result="accept"))
|
||||
_gate(shop, pkt, ["1 weld without a visual result", "inspector name"])
|
||||
ok(qc_weld(q, pid, welds[1], result="reject", note="undercut"))
|
||||
_gate(shop, pkt, ["a rejected weld (reject the packet instead)", "inspector name"])
|
||||
ok(qc_weld(q, pid, welds[1], result="accept"))
|
||||
_gate(shop, pkt, ["inspector name"])
|
||||
ok(qc_patch(q, pid, {"nde_method": "RT"}))
|
||||
_gate(shop, pkt, ["NDE result", "inspector name"])
|
||||
ok(qc_patch(q, pid, {"nde_result": "Rejectable"}))
|
||||
_gate(shop, pkt, ["NDE result", "inspector name"])
|
||||
ok(qc_patch(q, pid, {"nde_result": "Acceptable"}))
|
||||
_gate(shop, pkt, ["inspector name"])
|
||||
out = ok(qc_patch(q, pid, {"inspector": "Quinn QC"}))
|
||||
assert _missing(out) == []
|
||||
assert "qc_accept" in out["can"] and "qc_accept_and_route" in out["can"]
|
||||
assert "qc_accept" not in out["blocked"]
|
||||
# ticking one key off again brings the gate back
|
||||
ok(qc_check(q, pid, "ph_qty", done=False))
|
||||
_gate(shop, pkt, ["1 inspection item"])
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_api_co_hold_blocks_accept_not_reject(shop: Shop) -> None:
|
||||
pkt = finish_inspection(shop, at_qc(shop))
|
||||
pkt = issue_co(shop, pkt, ["03"])
|
||||
mine = fresh(shop("qc.quinn"), pkt["id"])
|
||||
held = "Change order CO-1 must be verified first by: 03 Weld Parts."
|
||||
assert mine["blocked"]["qc_accept"] == [held]
|
||||
assert "qc_reject" in mine["can"]
|
||||
refused(step(shop("qc.quinn"), mine, "qc_accept", routed_to="databook", sign=SIGN), 403, "co_hold", held)
|
||||
refused(step(shop("qc.quinn"), mine, "qc_accept_and_route", dept="09", sign=SIGN), 403, "co_hold", held)
|
||||
out = ok(step(shop("qc.quinn"), mine, "qc_reject", ncr=NCR, sign=SIGN))
|
||||
assert out["status"] == "review"
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_api_filed_lock_on_documents(shop: Shop) -> None:
|
||||
pkt = filed_packet(shop)
|
||||
for user in ("qc.quinn", "clerk.clara"):
|
||||
refused(add_packet_doc(shop(user), pkt["id"]), 403, "filed")
|
||||
refused(add_packet_doc(shop("admin.ann"), pkt["id"], it_reason=IT), 403, "filed")
|
||||
@@ -0,0 +1,386 @@
|
||||
"""Phase 6 Q.C. inspection API: the check and weld PUTs, the field CAS PATCH, the qc_items hints, nace_label, accept
|
||||
to the data book, to shipping and to a department, reject with a reason, an NCR or both, the second inspection
|
||||
after a reject, and the Q.C. home board. Every log text is the contract's, exactly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from pkthelpers import step
|
||||
from pshelpers import needs_pg
|
||||
from qchelpers import (
|
||||
NCR,
|
||||
NCR_URL,
|
||||
QC_KEYS,
|
||||
ROUTE_DB,
|
||||
accept,
|
||||
accept_route,
|
||||
at_qc,
|
||||
finish_inspection,
|
||||
qc_check,
|
||||
qc_patch,
|
||||
qc_weld,
|
||||
reject,
|
||||
to_qc,
|
||||
)
|
||||
from shophelpers import (
|
||||
SIGN,
|
||||
Shop,
|
||||
all_rows,
|
||||
fresh,
|
||||
issue_co,
|
||||
last_log,
|
||||
log_rows,
|
||||
make_unit,
|
||||
ok,
|
||||
one,
|
||||
open_shop,
|
||||
refused,
|
||||
run,
|
||||
set_stamp,
|
||||
)
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
REASON = "Weld 2 undercut too deep"
|
||||
ITEM_TEXT = {
|
||||
"db_heats": "Heat number recorded for every BOM item",
|
||||
"db_mtr": "MTRs on file and match the heat numbers",
|
||||
"db_weldmap": "Weld map complete: every weld identified",
|
||||
"db_wps": "Welds made to WPS-11; welder [75] qualified",
|
||||
"db_ck": "Stall checklist and weld record complete",
|
||||
"ph_dim": "Dimensions verified against D-5001 Rev B",
|
||||
"ph_heat": "Heat numbers on material match the heat map",
|
||||
"ph_stamp": "Welds stamped with welder ID",
|
||||
"ph_clean": "Clean: no slag, spatter or arc strikes",
|
||||
"ph_qty": "Quantity matches the work orders",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def actions(shop: Shop, pid: int) -> list[str]:
|
||||
return [r["action"] for r in log_rows(shop("qc.quinn"), pid)]
|
||||
|
||||
|
||||
# ================================================================ checks, welds, fields
|
||||
|
||||
|
||||
def test_check_put_per_key(shop: Shop) -> None:
|
||||
set_stamp(shop.settings, "weld.andy", "75")
|
||||
pkt = at_qc(shop)
|
||||
q = shop("qc.quinn")
|
||||
pid = pkt["id"]
|
||||
ok(qc_patch(q, pid, {"stamp": "Q1"}))
|
||||
# the item texts carry the drawing, the rev, the WPS and the welder stamp
|
||||
texts = {i["key"]: i["text"] for i in fresh(q, pid)["qc_items"]}
|
||||
assert texts == ITEM_TEXT
|
||||
for key in QC_KEYS:
|
||||
before = len(log_rows(q, pid))
|
||||
out = ok(qc_check(q, pid, key))
|
||||
entry = out["qc"]["checks"][key]
|
||||
assert entry["done"] is True and entry["at"] and entry["by"], entry
|
||||
assert entry["stamp"] == "Q1" and entry["by_name"] == "Quinn QC", entry
|
||||
log = last_log(q, pid)
|
||||
assert log["action"] == f"Q.C. checked: {ITEM_TEXT[key]}" and not log["note"], log
|
||||
assert len(log_rows(q, pid)) == before + 1
|
||||
# a tick that changes nothing writes nothing
|
||||
before = len(log_rows(q, pid))
|
||||
ok(qc_check(q, pid, "ph_dim"))
|
||||
assert len(log_rows(q, pid)) == before
|
||||
# untick
|
||||
out = ok(qc_check(q, pid, "ph_dim", done=False))
|
||||
assert out["qc"]["checks"]["ph_dim"].get("done") is False
|
||||
assert last_log(q, pid)["action"] == "Q.C. unchecked: Dimensions verified against D-5001 Rev B"
|
||||
# an unknown key
|
||||
refused(qc_check(q, pid, "ph_paint"), 422, "invalid_key")
|
||||
# Q.C. only
|
||||
refused(qc_check(shop("sup.wes"), pid, "ph_dim"), 403, "forbidden")
|
||||
|
||||
|
||||
def test_check_put_without_inspection(shop: Shop) -> None:
|
||||
pkt = at_qc(shop)
|
||||
out = ok(reject(shop, pkt, REASON))
|
||||
assert out["qc"] is None
|
||||
# back with the supervisor: no inspection is open
|
||||
r = qc_check(shop("qc.quinn"), pkt["id"], "ph_dim")
|
||||
assert r.status_code in (403, 409), r.text
|
||||
assert r.json()["code"] in ("wrong_status", "no_inspection"), r.text
|
||||
|
||||
|
||||
def test_weld_put(shop: Shop) -> None:
|
||||
pkt = at_qc(shop)
|
||||
q = shop("qc.quinn")
|
||||
pid = pkt["id"]
|
||||
w1, w2 = sorted(int(w["weld_no"]) for w in pkt["welds"])
|
||||
out = ok(qc_weld(q, pid, w1, result="accept"))
|
||||
e = out["qc"]["weld_exam"][str(w1)]
|
||||
assert e["result"] == "accept" and e["at"] and e["by"] and e["by_name"] == "Quinn QC", e
|
||||
assert last_log(q, pid)["action"] == f"VT accept · W{w1}" and not last_log(q, pid)["note"]
|
||||
out = ok(qc_weld(q, pid, w2, result="reject", note="undercut"))
|
||||
assert out["qc"]["weld_exam"][str(w2)]["note"] == "undercut"
|
||||
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == (f"VT reject · W{w2}", "undercut")
|
||||
# a note-only change
|
||||
ok(qc_weld(q, pid, w2, note="undercut 1/32"))
|
||||
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == ("Q.C. record changed", f"W{w2} note")
|
||||
# cleared
|
||||
out = ok(qc_weld(q, pid, w2, result=""))
|
||||
assert not out["qc"]["weld_exam"].get(str(w2), {}).get("result")
|
||||
assert last_log(q, pid)["action"] == f"VT cleared · W{w2}"
|
||||
# a weld that is not on the packet
|
||||
refused(qc_weld(q, pid, 99, result="accept"), 422, "invalid_weld")
|
||||
refused(qc_weld(shop("clerk.clara"), pid, w1, result="reject"), 403, "forbidden")
|
||||
|
||||
|
||||
def test_field_cas_patch(shop: Shop) -> None:
|
||||
pkt = at_qc(shop)
|
||||
q = shop("qc.quinn")
|
||||
pid = pkt["id"]
|
||||
out = ok(qc_patch(q, pid, {"inspector": "Quinn QC", "stamp": "Q1"}, {"inspector": None, "stamp": None}))
|
||||
assert out["qc"]["inspector"] == "Quinn QC" and out["qc"]["stamp"] == "Q1"
|
||||
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == (
|
||||
"Q.C. record changed",
|
||||
"inspector Quinn QC · stamp Q1",
|
||||
)
|
||||
# a stale base
|
||||
r = refused(qc_patch(q, pid, {"inspector": "Q. Quinn"}, {"inspector": "Somebody"}), 409, "field_conflict")
|
||||
assert r["current"] == {"inspector": "Quinn QC"}
|
||||
# NDE method and result validation
|
||||
r = refused(qc_patch(q, pid, {"nde_method": "XR"}), 422, "invalid_field")
|
||||
assert r["detail"] == "Pick an NDE method from the list."
|
||||
refused(qc_patch(q, pid, {"nde_result": "Maybe"}), 422, "invalid_field")
|
||||
ok(qc_patch(q, pid, {"nde_method": "RT"}))
|
||||
assert last_log(q, pid)["action"] == "NDE method: RT"
|
||||
out = ok(qc_patch(q, pid, {"nde_result": "Acceptable"}))
|
||||
assert out["qc"]["nde_result"] == "Acceptable"
|
||||
assert last_log(q, pid)["action"] == "NDE result: Acceptable"
|
||||
out = ok(qc_patch(q, pid, {"hardness": "198 HB", "nde_report": "RPT-1"}))
|
||||
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == (
|
||||
"Q.C. record changed",
|
||||
"hardness 198 HB · report RPT-1",
|
||||
)
|
||||
ok(qc_patch(q, pid, {"notes": "Fit-up good"}))
|
||||
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == ("Q.C. record changed", "notes changed")
|
||||
ok(qc_patch(q, pid, {"nde_result": ""}))
|
||||
assert last_log(q, pid)["action"] == "NDE result: -"
|
||||
ok(qc_patch(q, pid, {"nde_result": "Acceptable"}))
|
||||
# None required clears the result and the report
|
||||
out = ok(qc_patch(q, pid, {"nde_method": "None required"}))
|
||||
assert out["qc"]["nde_method"] == "None required"
|
||||
assert not out["qc"]["nde_result"] and not out["qc"]["nde_report"]
|
||||
assert "NDE method: None required" in actions(shop, pid)
|
||||
for method in ("UT", "MT", "PT"):
|
||||
assert ok(qc_patch(q, pid, {"nde_method": method}))["qc"]["nde_method"] == method
|
||||
# the old flat body with If-Match still merges checks (phase 2), or the new endpoints replaced it
|
||||
cur = fresh(q, pid)["qc"]
|
||||
r = q.patch(
|
||||
f"/api/packets/{pid}/qc",
|
||||
json={"checks": {"ph_qty": {"done": True}}},
|
||||
headers={"If-Match": str(cur["row_version"])},
|
||||
)
|
||||
assert r.status_code < 500, r.text
|
||||
# the nace field is computed, not written
|
||||
r = qc_patch(q, pid, {"nace": True})
|
||||
assert r.status_code < 500, r.text
|
||||
assert fresh(q, pid)["qc"]["nace_label"] is False
|
||||
|
||||
|
||||
def test_qc_items_hints_and_nace_label(shop: Shop) -> None:
|
||||
pkt = at_qc(shop)
|
||||
q = shop("qc.quinn")
|
||||
items = fresh(q, pkt["id"])["qc_items"]
|
||||
assert [i["key"] for i in items] == list(QC_KEYS)
|
||||
assert [i["group"] for i in items] == ["Data book package"] * 5 + ["Physical spool"] * 5
|
||||
hints = {i["key"]: i["hint"] for i in items}
|
||||
assert hints["db_heats"] == "3/3 recorded"
|
||||
assert hints["db_mtr"] == "0/3 MTRs attached"
|
||||
assert hints["db_weldmap"] == "2 welds on map"
|
||||
assert hints["db_ck"] == "checklist 9/9 · filler ER70S-6"
|
||||
assert all(hints[k] is None for k in QC_KEYS[5:]), hints
|
||||
assert fresh(q, pkt["id"])["qc"]["nace_label"] is False
|
||||
run(shop.settings, "UPDATE packets SET wps = 'WPS-12 nace sour' WHERE id = %s", (pkt["id"],))
|
||||
assert fresh(q, pkt["id"])["qc"]["nace_label"] is True
|
||||
|
||||
|
||||
# ================================================================ accept
|
||||
|
||||
|
||||
def _snapshot(shop: Shop, pid: int) -> Any:
|
||||
row = one(
|
||||
shop.settings,
|
||||
"SELECT qc_snapshot FROM packet_stages WHERE packet_id = %s AND closed_at IS NOT NULL ORDER BY id DESC LIMIT 1",
|
||||
(pid,),
|
||||
)
|
||||
assert row is not None
|
||||
return row[0]
|
||||
|
||||
|
||||
def test_accept_to_data_book(shop: Shop) -> None:
|
||||
pkt = finish_inspection(shop, at_qc(shop), nde=("RT", "Acceptable"))
|
||||
out = ok(accept(shop, pkt, "databook"))
|
||||
assert out["status"] == "complete" and out["routed_to"] == "databook" and out["qc"] is None
|
||||
assert out["stage"] is None
|
||||
h = out["qc_history"][-1]
|
||||
assert h["result"] == "accepted" and h["inspector"] == "Quinn QC"
|
||||
assert h["by"]["display_name"] == "Quinn QC" and h["location"] == "Bay 2" and h["sent_at"]
|
||||
assert h["rejected_welds"] == []
|
||||
assert out["qc_last"]["id"] == h["id"] and out["qc_last"]["result"] == "accepted"
|
||||
snap = _snapshot(shop, pkt["id"])
|
||||
assert snap and snap.get("inspector") == "Quinn QC" and snap.get("result") == "accepted", snap
|
||||
log = last_log(shop("qc.quinn"), pkt["id"])
|
||||
assert log["action"] == f"Q.C. accepted → {ROUTE_DB}"
|
||||
assert log["note"] == "Quinn QC [Q1] · 2 welds VT accepted · RT Acceptable"
|
||||
assert log["signed"] is True
|
||||
|
||||
|
||||
def test_accept_to_shipping(shop: Shop) -> None:
|
||||
pkt = finish_inspection(shop, at_qc(shop))
|
||||
out = ok(accept(shop, pkt, "shipping"))
|
||||
assert out["status"] == "complete" and out["routed_to"] == "shipping"
|
||||
log = last_log(shop("qc.quinn"), pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Q.C. accepted → Shipping", "Quinn QC [Q1] · 2 welds VT accepted")
|
||||
|
||||
|
||||
def test_accept_note_without_stamp(shop: Shop) -> None:
|
||||
pkt = finish_inspection(shop, at_qc(shop, welds=1), stamp="")
|
||||
ok(accept(shop, pkt, "shipping"))
|
||||
log = last_log(shop("qc.quinn"), pkt["id"])
|
||||
assert log["note"] == "Quinn QC · 1 weld VT accepted" or log["note"] == "Quinn QC · 1 welds VT accepted", log
|
||||
|
||||
|
||||
def test_accept_and_route_to_hydro(shop: Shop) -> None:
|
||||
pkt = finish_inspection(shop, at_qc(shop))
|
||||
refused(accept_route(shop, pkt, "03"), 403, "precondition")
|
||||
out = ok(accept_route(shop, pkt, "09"))
|
||||
assert out["status"] == "ready" and out["dept"]["code"] == "09"
|
||||
assert out["stage"] is not None and out["stage"]["dept_code"] == "09"
|
||||
assert out["qc"] is None and out["qc_last"]["result"] == "accepted"
|
||||
log = last_log(shop("qc.quinn"), pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Q.C. accepted → 09 Hydro", "Quinn QC [Q1] · from 03 Weld Parts")
|
||||
|
||||
|
||||
# ================================================================ reject
|
||||
|
||||
|
||||
def _rejected(shop: Shop, pkt: dict[str, Any], out: dict[str, Any], note: str, return_note: str) -> None:
|
||||
assert out["status"] == "review" and out["qc"] is None
|
||||
last = out["qc_last"]
|
||||
assert last["result"] == "rejected"
|
||||
assert out["return_note"] == return_note
|
||||
log = last_log(shop("sup.wes"), pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Q.C. rejected → supervisor", note)
|
||||
assert log["signed"] is True
|
||||
|
||||
|
||||
def test_reject_with_reason_only(shop: Shop) -> None:
|
||||
pkt = at_qc(shop)
|
||||
w2 = max(int(w["weld_no"]) for w in pkt["welds"])
|
||||
ok(qc_weld(shop("qc.quinn"), pkt["id"], w2, result="reject", note="undercut"))
|
||||
out = ok(reject(shop, pkt, REASON))
|
||||
_rejected(shop, pkt, out, f"{REASON} · welds W{w2}", REASON)
|
||||
assert out["qc_last"]["ncr"] is None and out["qc_last"]["rejected_welds"] == [f"W{w2}"]
|
||||
|
||||
|
||||
def test_reject_with_ncr_only(shop: Shop) -> None:
|
||||
pkt = at_qc(shop)
|
||||
q = shop("qc.quinn")
|
||||
refused(reject(shop, pkt, ncr="NCR-26-1"), 422, "invalid_ncr", "Enter an NCR number like NCR-2026-0021.")
|
||||
refused(reject(shop, pkt), 400, "reason_required")
|
||||
refused(reject(shop, pkt, "bad"), 400, "reason_required")
|
||||
assert fresh(q, pkt["id"])["status"] == "qc"
|
||||
out = ok(reject(shop, pkt, ncr=" ncr-2026-0021 "))
|
||||
_rejected(shop, pkt, out, NCR, NCR)
|
||||
assert out["qc_last"]["ncr"] == NCR and out["qc_last"]["rejected_welds"] == []
|
||||
row = one(
|
||||
shop.settings, "SELECT ncr FROM qc_inspections WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pkt["id"],)
|
||||
)
|
||||
assert row[0] == NCR
|
||||
assert NCR_URL.endswith("/ncrs/")
|
||||
|
||||
|
||||
def test_reject_with_both_then_send_again(shop: Shop) -> None:
|
||||
pkt = at_qc(shop)
|
||||
q = shop("qc.quinn")
|
||||
w1, w2 = sorted(int(w["weld_no"]) for w in pkt["welds"])
|
||||
ok(qc_check(q, pkt["id"], "ph_dim"))
|
||||
ok(qc_weld(q, pkt["id"], w2, result="reject", note="porosity"))
|
||||
ok(qc_weld(q, pkt["id"], w1, result="reject", note="undercut"))
|
||||
out = ok(reject(shop, pkt, "Weld 2 undercut", ncr=NCR))
|
||||
_rejected(shop, pkt, out, f"{NCR} · Weld 2 undercut · welds W{w1}, W{w2}", f"{NCR} · Weld 2 undercut")
|
||||
first = out["qc_last"]
|
||||
assert first["ncr"] == NCR and first["rejected_welds"] == [f"W{w1}", f"W{w2}"]
|
||||
assert first["by"]["display_name"] == "Quinn QC" and first["location"] == "Bay 2"
|
||||
assert len(out["qc_history"]) == 1
|
||||
# the supervisor sees it and sends it to Q.C. again: a fresh empty inspection
|
||||
wes = fresh(shop("sup.wes"), pkt["id"])
|
||||
assert wes["qc_last"]["result"] == "rejected"
|
||||
again = to_qc(shop, wes, location="Bay 4")
|
||||
assert again["qc"]["checks"] == {} and again["qc"]["weld_exam"] == {}
|
||||
assert again["qc"]["location"] == "Bay 4" and again["qc"]["ncr"] is None
|
||||
assert len(again["qc_history"]) == 1 and again["qc_history"][0]["ncr"] == NCR
|
||||
assert again["qc_last"]["id"] == first["id"]
|
||||
rows = all_rows(shop.settings, "SELECT count(*) FROM qc_inspections WHERE packet_id = %s", (pkt["id"],))
|
||||
assert rows[0][0] == 2
|
||||
|
||||
|
||||
def test_co_hold_blocks_accept_but_not_reject(shop: Shop) -> None:
|
||||
pkt = finish_inspection(shop, at_qc(shop))
|
||||
pkt = issue_co(shop, pkt, ["03"])
|
||||
refused(accept(shop, pkt), 403, "co_hold")
|
||||
out = ok(reject(shop, fresh(shop("qc.quinn"), pkt["id"]), REASON))
|
||||
assert out["status"] == "review"
|
||||
|
||||
|
||||
# ================================================================ the Q.C. home
|
||||
|
||||
|
||||
def _ids(cards: list[dict[str, Any]]) -> list[int]:
|
||||
return [int(c["id"]) for c in cards]
|
||||
|
||||
|
||||
def test_qc_home(shop: Shop) -> None:
|
||||
q = shop("qc.quinn")
|
||||
_, unit_a = make_unit(shop.settings)
|
||||
_, unit_b = make_unit(shop.settings)
|
||||
waiting = at_qc(shop, unit_a)
|
||||
to_reject = at_qc(shop, unit_b)
|
||||
home = ok(q.get("/api/qc/home"))
|
||||
assert set(home) >= {"kpis", "columns", "folders"}
|
||||
assert home["kpis"] == {"waiting": 2, "accepted_7d": 0, "rejected_7d": 0}
|
||||
assert _ids(home["columns"]["ready"]) == [waiting["id"], to_reject["id"]] # oldest status_at first
|
||||
card = home["columns"]["ready"][0]
|
||||
assert card["qc"]["location"] == "Bay 2" and card["qc"]["sent_at"]
|
||||
assert "itp_hold_count" in card
|
||||
assert home["columns"]["accepted"] == [] and home["columns"]["rejected"] == []
|
||||
|
||||
ok(reject(shop, to_reject, ncr=NCR))
|
||||
accepted = ok(accept(shop, finish_inspection(shop, waiting), "databook"))
|
||||
home = ok(q.get("/api/qc/home"))
|
||||
assert home["kpis"] == {"waiting": 0, "accepted_7d": 1, "rejected_7d": 1}
|
||||
assert _ids(home["columns"]["accepted"]) == [accepted["id"]]
|
||||
assert _ids(home["columns"]["rejected"]) == [to_reject["id"]]
|
||||
assert home["columns"]["rejected"][0]["qc"]["result"] == "rejected"
|
||||
assert home["columns"]["accepted"][0]["qc"]["result"] == "accepted"
|
||||
|
||||
# the unit filter narrows the request
|
||||
narrowed = ok(q.get(f"/api/qc/home?unit_id={unit_b}"))
|
||||
assert _ids(narrowed["columns"]["rejected"]) == [to_reject["id"]] and narrowed["columns"]["accepted"] == []
|
||||
|
||||
# pm.paula reads it; a session is needed
|
||||
assert ok(shop("pm.paula").get("/api/qc/home"))["kpis"]["accepted_7d"] == 1
|
||||
assert ok(shop("wh.wade").get("/api/qc/home"))["kpis"]["rejected_7d"] == 1
|
||||
|
||||
|
||||
def test_transition_stale_expect(shop: Shop) -> None:
|
||||
pkt = finish_inspection(shop, at_qc(shop))
|
||||
ok(reject(shop, pkt, REASON))
|
||||
r = refused(step(shop("qc.quinn"), pkt, "qc_accept", routed_to="databook", sign=SIGN), 409, "stale_step")
|
||||
assert r["current"]["status"] == "review"
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Phase 6 documents: packet documents for the data book (add, the closed kind set, the file type, the filed lock,
|
||||
take out with a reason and the row kept) and Q.C. folder documents on a unit or a project (add, the closed type
|
||||
set, who may add, new revisions that supersede and never delete, the folder reads and counts, the audit texts)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from pshelpers import needs_pg
|
||||
from qchelpers import (
|
||||
DOC_KINDS,
|
||||
MSG_DOC_KIND,
|
||||
MSG_TAKE_OUT,
|
||||
QC_TYPES,
|
||||
add_packet_doc,
|
||||
csv_asset,
|
||||
filed_packet,
|
||||
folder_project,
|
||||
folder_unit,
|
||||
make_qc_doc,
|
||||
start_itp,
|
||||
take_out,
|
||||
)
|
||||
from shophelpers import (
|
||||
IT,
|
||||
Shop,
|
||||
all_rows,
|
||||
draft,
|
||||
fresh,
|
||||
last_log,
|
||||
make_unit,
|
||||
ok,
|
||||
one,
|
||||
open_shop,
|
||||
refused,
|
||||
run,
|
||||
)
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def numbers(shop: Shop, unit_id: int) -> tuple[str, str]:
|
||||
"""(project number, unit number) of a unit."""
|
||||
row = one(
|
||||
shop.settings,
|
||||
"SELECT p.number, p.number || u.lot FROM units u JOIN projects p ON p.id = u.project_id WHERE u.id = %s",
|
||||
(unit_id,),
|
||||
)
|
||||
return str(row[0]), str(row[1])
|
||||
|
||||
|
||||
def add_unit(shop: Shop, project_id: int, lot: str) -> int:
|
||||
row = one(
|
||||
shop.settings,
|
||||
"INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, 'Unit two', 'open') RETURNING id",
|
||||
(project_id, lot),
|
||||
)
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def doc_of(pkt: dict[str, Any], doc_id: int) -> dict[str, Any]:
|
||||
return next(d for d in pkt["docs"] if int(d["id"]) == doc_id)
|
||||
|
||||
|
||||
# ================================================================ packet documents
|
||||
|
||||
|
||||
def test_packet_doc_add(shop: Shop) -> None:
|
||||
pkt = draft(shop)
|
||||
q = shop("qc.quinn")
|
||||
out = ok(add_packet_doc(q, pkt["id"], "nde", "RT report W1-W4", "RPT-2210, accept", "rt.pdf"))
|
||||
d = out["docs"][-1]
|
||||
assert d["kind"] == "nde" and d["kind_label"] == DOC_KINDS["nde"]
|
||||
assert d["title"] == "RT report W1-W4" and d["note"] == "RPT-2210, accept"
|
||||
assert d["file_name"] == "rt.pdf" and d["size"] > 0 and d["removed"] is None
|
||||
log = last_log(q, pkt["id"])
|
||||
assert (log["action"], log["note"]) == (
|
||||
"Document added",
|
||||
"NDE report (RT / UT / MT / PT) · RT report W1-W4 · rt.pdf",
|
||||
)
|
||||
# clerk.clara adds one with no title: the title is the kind label
|
||||
out = ok(add_packet_doc(shop("clerk.clara"), pkt["id"], "hydro", None, None, "hydro.pdf"))
|
||||
d = out["docs"][-1]
|
||||
assert d["title"] == "Hydrotest report" and d["kind_label"] == "Hydrotest report"
|
||||
assert last_log(q, pkt["id"])["note"] == "Hydrotest report · Hydrotest report · hydro.pdf"
|
||||
# every kind of the closed set is accepted
|
||||
for kind, label in DOC_KINDS.items():
|
||||
out = ok(add_packet_doc(q, pkt["id"], kind, f"Doc {kind}"))
|
||||
assert out["docs"][-1]["kind_label"] == label
|
||||
assert [d["kind"] for d in out["docs"]][:2] == ["nde", "hydro"] # ordered added_at, id
|
||||
|
||||
|
||||
def test_packet_doc_refusals(shop: Shop) -> None:
|
||||
pkt = draft(shop)
|
||||
q = shop("qc.quinn")
|
||||
refused(add_packet_doc(q, pkt["id"], "xray"), 422, "invalid_kind", MSG_DOC_KIND)
|
||||
refused(add_packet_doc(q, pkt["id"], asset_id=csv_asset(q)["id"]), 422, "invalid_asset")
|
||||
refused(add_packet_doc(shop("wh.wade"), pkt["id"]), 403, "forbidden")
|
||||
refused(add_packet_doc(shop("sup.wes"), pkt["id"]), 403, "forbidden")
|
||||
assert fresh(q, pkt["id"])["docs"] == []
|
||||
# over the note limit: refused, or cut to the limit (the contract gives the limit, not the answer)
|
||||
r = add_packet_doc(q, pkt["id"], note="x" * 301)
|
||||
assert r.status_code == 422 or len(r.json()["docs"][-1]["note"]) <= 300, r.text
|
||||
|
||||
|
||||
def test_packet_doc_note_limit(shop: Shop) -> None:
|
||||
pkt = draft(shop)
|
||||
r = add_packet_doc(shop("qc.quinn"), pkt["id"], note="x" * 300)
|
||||
if r.status_code == 200:
|
||||
assert len(r.json()["docs"][-1]["note"]) == 300
|
||||
else:
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
def test_packet_doc_filed(shop: Shop) -> None:
|
||||
pkt = filed_packet(shop)
|
||||
refused(add_packet_doc(shop("qc.quinn"), pkt["id"]), 403, "filed")
|
||||
refused(add_packet_doc(shop("clerk.clara"), pkt["id"]), 403, "filed")
|
||||
|
||||
|
||||
def test_packet_doc_take_out(shop: Shop) -> None:
|
||||
pkt = draft(shop)
|
||||
q = shop("qc.quinn")
|
||||
out = ok(add_packet_doc(q, pkt["id"], "nde", "RT report W1-W4", "RPT-2210, accept"))
|
||||
doc_id = int(out["docs"][-1]["id"])
|
||||
for reason in (None, "", "ab"):
|
||||
refused(take_out(q, pkt["id"], doc_id, reason), 400, "reason_required", MSG_TAKE_OUT)
|
||||
refused(take_out(shop("wh.wade"), pkt["id"], doc_id), 403, "forbidden")
|
||||
out = ok(take_out(shop("clerk.clara"), pkt["id"], doc_id, "wrong packet"))
|
||||
d = doc_of(out, doc_id)
|
||||
assert d["removed"]["reason"] == "wrong packet" and d["removed"]["at"]
|
||||
assert d["removed"]["by"]["display_name"] == "Clara Clerk"
|
||||
log = last_log(q, pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Document taken out of the data book", "RT report W1-W4 · wrong packet")
|
||||
refused(take_out(q, pkt["id"], doc_id, "again please"), 409, "already_removed")
|
||||
row = one(shop.settings, "SELECT removed_reason, removed_by IS NOT NULL FROM packet_docs WHERE id = %s", (doc_id,))
|
||||
assert tuple(row) == ("wrong packet", True)
|
||||
# nothing is deleted
|
||||
assert all_rows(shop.settings, "SELECT count(*) FROM packet_docs WHERE packet_id = %s", (pkt["id"],))[0][0] == 1
|
||||
|
||||
|
||||
def test_qc_folder_docs_on_the_packet(shop: Shop) -> None:
|
||||
project_id, unit_id = make_unit(shop.settings)
|
||||
pkt = draft(shop, unit_id)
|
||||
assert fresh(shop("qc.quinn"), pkt["id"])["qc_folder_docs"] == []
|
||||
ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP unit one"))
|
||||
ok(make_qc_doc(shop, project_id=project_id, type="WPS / PQR", title="WPS-101 GTAW/SMAW", rev="2"))
|
||||
docs = fresh(shop("wh.wade"), pkt["id"])["qc_folder_docs"]
|
||||
by_scope = {d["scope"]: d for d in docs}
|
||||
assert set(by_scope) == {"unit", "project"}
|
||||
assert by_scope["unit"]["type"] == "ITP" and by_scope["unit"]["title"] == "ITP unit one"
|
||||
assert by_scope["project"]["rev"] == "2" and by_scope["project"]["url"].startswith("/_blob/")
|
||||
|
||||
|
||||
# ================================================================ Q.C. folder documents
|
||||
|
||||
|
||||
def test_qc_doc_add_unit_and_project(shop: Shop) -> None:
|
||||
project_id, unit_id = make_unit(shop.settings)
|
||||
pno, uno = numbers(shop, unit_id)
|
||||
out = ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP unit one", file_name="itp.pdf"))
|
||||
assert out["unit"]["id"] == unit_id and out["project"]["id"] == project_id
|
||||
d = out["docs"][0]
|
||||
assert d["type"] == "ITP" and d["unit_id"] == unit_id and d["project_id"] is None
|
||||
assert d["file_name"] == "itp.pdf" and d["url"] == f"/_blob/{d['asset_id']}" and d["auto"] is False
|
||||
assert d["added_by"]["display_name"] == "Quinn QC" and d["superseded_by"] is None
|
||||
out = ok(
|
||||
make_qc_doc(
|
||||
shop, project_id=project_id, type="WPS / PQR", title="WPS-101 GTAW/SMAW", rev="2", file_name="wps.pdf"
|
||||
)
|
||||
)
|
||||
assert out["project"]["id"] == project_id and "units" in out
|
||||
assert [x["title"] for x in out["docs"]] == ["WPS-101 GTAW/SMAW"]
|
||||
# the audit rows and their texts
|
||||
rows = all_rows(
|
||||
shop.settings, "SELECT action, subject_type, detail FROM audit_events WHERE action LIKE 'qc_doc.%%' ORDER BY id"
|
||||
)
|
||||
assert [r[0] for r in rows] == ["qc_doc.add", "qc_doc.add"]
|
||||
assert rows[0][2]["text"] == "Q.C. document added"
|
||||
assert rows[0][2]["note"] == f"{uno} · ITP · ITP unit one · itp.pdf"
|
||||
assert rows[1][2]["note"] == f"{pno} · WPS / PQR · WPS-101 GTAW/SMAW Rev 2 · wps.pdf"
|
||||
# the title defaults to the type
|
||||
out = ok(make_qc_doc(shop, unit_id=unit_id, type="NDE procedure"))
|
||||
assert out["docs"][-1]["title"] == "NDE procedure"
|
||||
for t in QC_TYPES:
|
||||
assert ok(make_qc_doc(shop, unit_id=unit_id, type=t))["docs"][-1]["type"] == t
|
||||
|
||||
|
||||
def test_qc_doc_refusals(shop: Shop) -> None:
|
||||
project_id, unit_id = make_unit(shop.settings)
|
||||
refused(make_qc_doc(shop, unit_id=unit_id, project_id=project_id), 422, "invalid_request")
|
||||
refused(make_qc_doc(shop), 422, "invalid_request")
|
||||
refused(
|
||||
make_qc_doc(shop, unit_id=unit_id, type="Drawing"), 422, "invalid_type", "Pick a document type from the list."
|
||||
)
|
||||
refused(make_qc_doc(shop, unit_id=unit_id, asset_id=csv_asset(shop("qc.quinn"))["id"]), 422, "invalid_asset")
|
||||
refused(make_qc_doc(shop, unit_id=unit_id, by="wh.wade"), 403, "forbidden")
|
||||
refused(make_qc_doc(shop, unit_id=unit_id, by="weld.andy"), 403, "forbidden")
|
||||
refused(make_qc_doc(shop, unit_id=unit_id, by="pm.paula"), 403, "forbidden")
|
||||
refused(make_qc_doc(shop, unit_id=unit_id, by="admin.ann"), 400, "it_reason_required")
|
||||
assert folder_unit(shop("qc.quinn"), unit_id)["docs"] == []
|
||||
# who may add: Clerical, Q.C., and an admin with an IT reason
|
||||
ok(make_qc_doc(shop, unit_id=unit_id, by="clerk.clara"))
|
||||
ok(make_qc_doc(shop, unit_id=unit_id, by="multi.mia"))
|
||||
out = ok(make_qc_doc(shop, unit_id=unit_id, by="admin.ann", it_reason=IT))
|
||||
assert len(out["docs"]) == 3
|
||||
row = one(shop.settings, "SELECT it_reason FROM audit_events WHERE action = 'qc_doc.add' ORDER BY id DESC LIMIT 1")
|
||||
assert row[0] == IT
|
||||
# over the limits (title 140, rev 12): refused, or cut to the limit
|
||||
for field, n in (("title", 140), ("rev", 12)):
|
||||
r = make_qc_doc(shop, unit_id=unit_id, **{field: "x" * (n + 1)})
|
||||
assert r.status_code == 422 or len(r.json()["docs"][-1][field]) <= n, (field, r.text)
|
||||
|
||||
|
||||
def test_qc_doc_new_revision(shop: Shop) -> None:
|
||||
project_id, unit_id = make_unit(shop.settings)
|
||||
other_unit = add_unit(shop, project_id, "002")
|
||||
uno = numbers(shop, unit_id)[1]
|
||||
first = ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP L1", rev="0"))["docs"][0]
|
||||
# a revision from another folder or of a missing row is refused
|
||||
refused(make_qc_doc(shop, unit_id=other_unit, replaces=first["id"]), 422, "invalid_replace")
|
||||
refused(make_qc_doc(shop, project_id=project_id, replaces=first["id"]), 422, "invalid_replace")
|
||||
refused(make_qc_doc(shop, unit_id=unit_id, replaces=999999), 422, "invalid_replace")
|
||||
out = ok(
|
||||
make_qc_doc(
|
||||
shop, unit_id=unit_id, type="ITP", title="ITP L1", rev="1", replaces=first["id"], file_name="itp1.pdf"
|
||||
)
|
||||
)
|
||||
assert [d["rev"] for d in out["docs"]] == ["1"]
|
||||
assert [d["id"] for d in out["superseded"]] == [first["id"]]
|
||||
old = out["superseded"][0]
|
||||
new_id = out["docs"][0]["id"]
|
||||
assert old["superseded_by"] == new_id and old["superseded_at"]
|
||||
assert old["superseded_by_user"]["display_name"] == "Quinn QC"
|
||||
# a second revision of the same old row
|
||||
refused(make_qc_doc(shop, unit_id=unit_id, rev="1b", replaces=first["id"]), 409, "already_superseded")
|
||||
# the new one can be revised again; superseded is newest first
|
||||
out = ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP L1", rev="2", replaces=new_id, by="clerk.clara"))
|
||||
assert [d["rev"] for d in out["docs"]] == ["2"]
|
||||
assert [d["rev"] for d in out["superseded"]] == ["1", "0"]
|
||||
# nothing is ever deleted
|
||||
assert all_rows(shop.settings, "SELECT count(*) FROM qc_docs WHERE unit_id = %s", (unit_id,))[0][0] == 3
|
||||
rows = all_rows(shop.settings, "SELECT action, detail FROM audit_events WHERE action LIKE 'qc_doc.%%' ORDER BY id")
|
||||
assert [r[0] for r in rows] == ["qc_doc.add", "qc_doc.revise", "qc_doc.revise"]
|
||||
assert rows[1][1]["text"] == "Q.C. document revised"
|
||||
assert rows[1][1]["note"] == f"{uno} · ITP · ITP L1 Rev 1 · itp1.pdf"
|
||||
|
||||
|
||||
def test_qc_folders_and_counts(shop: Shop) -> None:
|
||||
q = shop("qc.quinn")
|
||||
project_id, unit_1 = make_unit(shop.settings)
|
||||
unit_2 = add_unit(shop, project_id, "002")
|
||||
closed_id, _ = make_unit(shop.settings)
|
||||
run(shop.settings, "UPDATE projects SET status = 'closed' WHERE id = %s", (closed_id,))
|
||||
|
||||
def row(show: str, pid: int) -> dict[str, Any] | None:
|
||||
rows = ok(q.get(f"/api/qc/folders?show={show}"))
|
||||
return next((r for r in rows if r["id"] == pid), None)
|
||||
|
||||
r = row("active", project_id)
|
||||
assert r is not None and r["units"] == 2 and r["docs"] == 0 and r["units_without_itp"] == 2
|
||||
assert {"number", "name", "customer", "po", "status"} <= set(r)
|
||||
assert row("active", closed_id) is None
|
||||
closed = row("all", closed_id)
|
||||
assert closed is not None and closed["units_without_itp"] == 0 and closed["status"] == "closed"
|
||||
# an ITP document on unit 1, then a digital ITP on unit 2
|
||||
ok(make_qc_doc(shop, unit_id=unit_1, type="ITP"))
|
||||
r = row("active", project_id)
|
||||
assert r is not None and r["units_without_itp"] == 1 and r["docs"] == 1
|
||||
start_itp(shop, unit_2)
|
||||
r = row("active", project_id)
|
||||
assert r is not None and r["units_without_itp"] == 0
|
||||
# a project document counts too; a WPS is not an ITP
|
||||
ok(make_qc_doc(shop, project_id=project_id, type="WPS / PQR"))
|
||||
r = row("active", project_id)
|
||||
assert r is not None and r["docs"] == 2 and r["units_without_itp"] == 0
|
||||
# the home lists the active folders
|
||||
home = ok(q.get("/api/qc/home"))
|
||||
assert any(f["id"] == project_id for f in home["folders"])
|
||||
assert not any(f["id"] == closed_id for f in home["folders"])
|
||||
numbers_in_order = [f["number"] for f in ok(q.get("/api/qc/folders?show=all"))]
|
||||
assert numbers_in_order == sorted(numbers_in_order)
|
||||
|
||||
|
||||
def test_project_itp_document_covers_every_unit(shop: Shop) -> None:
|
||||
project_id, unit_1 = make_unit(shop.settings)
|
||||
add_unit(shop, project_id, "002")
|
||||
ok(make_qc_doc(shop, project_id=project_id, type="ITP", title="Project ITP"))
|
||||
rows = ok(shop("qc.quinn").get("/api/qc/folders?show=active"))
|
||||
r = next(x for x in rows if x["id"] == project_id)
|
||||
assert r["units_without_itp"] == 0
|
||||
assert folder_unit(shop("qc.quinn"), unit_1)["no_itp"] is False
|
||||
|
||||
|
||||
def test_project_and_unit_folder_reads(shop: Shop) -> None:
|
||||
q = shop("qc.quinn")
|
||||
project_id, unit_1 = make_unit(shop.settings)
|
||||
unit_2 = add_unit(shop, project_id, "002")
|
||||
pno, uno = numbers(shop, unit_1)
|
||||
ok(make_qc_doc(shop, project_id=project_id, type="WPS / PQR", title="WPS-101", rev="2"))
|
||||
ok(make_qc_doc(shop, unit_id=unit_1, type="Customer spec", title="Spec A"))
|
||||
itp = start_itp(shop, unit_2)
|
||||
|
||||
pf = folder_project(q, project_id)
|
||||
assert pf["project"]["number"] == pno and pf["project"]["customer"] == "Acme Refining"
|
||||
assert [d["title"] for d in pf["docs"]] == ["WPS-101"] and pf["superseded"] == []
|
||||
units = {u["id"]: u for u in pf["units"]}
|
||||
assert set(units) == {unit_1, unit_2}
|
||||
assert units[unit_1]["number"] == uno and units[unit_1]["docs"] == 1 and units[unit_1]["itp"] is None
|
||||
assert units[unit_1]["has_itp_doc"] is False
|
||||
assert units[unit_2]["itp"]["id"] == itp["id"] and units[unit_2]["itp"]["no"] == itp["no"]
|
||||
assert units[unit_2]["itp"]["total"] == 70 and units[unit_2]["itp"]["done"] == 0
|
||||
|
||||
uf = folder_unit(q, unit_1)
|
||||
assert uf["unit"]["number"] == uno and uf["project"]["id"] == project_id
|
||||
assert [d["title"] for d in uf["docs"]] == ["Spec A"]
|
||||
assert [d["title"] for d in uf["inherited"]] == ["WPS-101"]
|
||||
assert uf["itp"] is None and uf["no_itp"] is True
|
||||
uf2 = folder_unit(q, unit_2)
|
||||
assert uf2["itp"]["status"] == "open" and uf2["no_itp"] is False
|
||||
# any session reads; unknown ids are 404
|
||||
assert shop("wh.wade").get(f"/api/qc/folders/units/{unit_1}").status_code == 200
|
||||
assert q.get("/api/qc/folders/units/999999").status_code == 404
|
||||
assert q.get("/api/qc/folders/projects/999999").status_code == 404
|
||||
@@ -43,7 +43,7 @@ ENUMS = {
|
||||
"lookup_failed",
|
||||
"hand",
|
||||
],
|
||||
"itp_status": ["open", "signed", "reopened"],
|
||||
"itp_status": ["open", "signed", "reopened", "complete"],
|
||||
}
|
||||
|
||||
# One minimal INSERT per append-only table, with ids from seed().
|
||||
@@ -63,6 +63,7 @@ APPEND_INSERTS = {
|
||||
"INSERT INTO packet_log (packet_id, action, by) VALUES (%(packet)s, 'Packet created by hand', %(user)s)"
|
||||
),
|
||||
"itp_rejects": "INSERT INTO itp_rejects (itp_id, line_key, ncr) VALUES (%(itp)s, 'k1', 'NCR-1')",
|
||||
"itp_log": "INSERT INTO itp_log (itp_id, action, by) VALUES (%(itp)s, 'ITP started', %(user)s)",
|
||||
"co_verifications": (
|
||||
"INSERT INTO co_verifications (co_id, dept_code, by, signature_id) VALUES (%(co)s, 'WH', %(user)s, %(sig)s)"
|
||||
),
|
||||
@@ -347,9 +348,8 @@ def test_app_can_write_every_table_and_read_every_view(pg_settings: Settings) ->
|
||||
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by) "
|
||||
"VALUES (%(packet)s, '\\x01', 'abcd1234', %(user)s)",
|
||||
"INSERT INTO packet_visual_state (packet_id) VALUES (%(packet)s)",
|
||||
"INSERT INTO packet_docs (packet_id, asset_id, kind, title) VALUES (%(packet)s, %(asset)s, 'mtr', 'MTR')",
|
||||
"INSERT INTO qc_docs (project_id, unit_id, type, asset_id) "
|
||||
"VALUES (%(project)s, %(unit)s, 'wps', %(asset)s)",
|
||||
"INSERT INTO packet_docs (packet_id, asset_id, kind, title) VALUES (%(packet)s, %(asset)s, 'nde', 'RT')",
|
||||
"INSERT INTO qc_docs (unit_id, type, asset_id) VALUES (%(unit)s, 'WPS / PQR', %(asset)s)",
|
||||
"INSERT INTO itp_lines (itp_id, key, result, by) VALUES (%(itp)s, 'k1', 'approve', %(user)s)",
|
||||
"INSERT INTO itp_blocks (itp_id, block_key, signed_by, signature_id) "
|
||||
"VALUES (%(itp)s, 'b1', %(user)s, %(sig)s)",
|
||||
@@ -527,3 +527,85 @@ def test_shop_columns_and_checklist_templates(pg_settings: Settings) -> None:
|
||||
assert [i["key"] for i in value["weld"]] == ["dwg", "heat", "wps", "fit", "pre", "vt", "stamp", "clean", "qty"]
|
||||
assert [i["key"] for i in value["plain"]] == ["dwg", "prev", "mat", "work", "insp", "qty"]
|
||||
assert value["weld"][0]["text"] == "Drawing {drawing} Rev {rev} matches the packet"
|
||||
|
||||
|
||||
def test_qc_columns_checks_and_items(pg_settings: Settings) -> None:
|
||||
"""0007: the inspection NCR, document notes and kinds, Q.C. folder scope and types, the ITP details, the ITP log
|
||||
and the Q.C. check items row."""
|
||||
with db.connect_as("migrate", pg_settings, autocommit=True) as conn:
|
||||
names = [r[0] for r in conn.execute("SELECT name FROM schema_migrations ORDER BY name").fetchall()]
|
||||
assert "0007_qc.sql" in names
|
||||
with app_conn(pg_settings) as conn:
|
||||
for t, c, kind in (
|
||||
("qc_inspections", "ncr", "text"),
|
||||
("packet_docs", "note", "text"),
|
||||
("qc_docs", "title", "text"),
|
||||
("qc_docs", "note", "text"),
|
||||
("qc_docs", "superseded_at", "timestamp with time zone"),
|
||||
("qc_docs", "superseded_by_user", "uuid"),
|
||||
("itps", "no", "text"),
|
||||
("itps", "hdr", "jsonb"),
|
||||
("itps", "created_at", "timestamp with time zone"),
|
||||
("itps", "created_by", "uuid"),
|
||||
("itp_lines", "initials", "text"),
|
||||
("itp_lines", "cust_code", "text"),
|
||||
("itp_lines", "cust_by", "text"),
|
||||
("itp_lines", "cust_at", "date"),
|
||||
("itp_lines", "cust_rec", "uuid"),
|
||||
("itp_lines", "cust_rec_at", "timestamp with time zone"),
|
||||
("itp_rejects", "kind", "text"),
|
||||
("itp_blocks", "qn", "text"),
|
||||
("itp_blocks", "remarks", "text"),
|
||||
("itp_blocks", "cust_name", "text"),
|
||||
("itp_blocks", "cust_at", "date"),
|
||||
("itp_blocks", "cust_by", "uuid"),
|
||||
("itp_blocks", "cust_rec_at", "timestamp with time zone"),
|
||||
("itp_blocks", "row_version", "integer"),
|
||||
("itp_log", "it_reason", "text"),
|
||||
):
|
||||
got = one(
|
||||
conn,
|
||||
"SELECT data_type FROM information_schema.columns WHERE table_name = %s AND column_name = %s",
|
||||
(t, c),
|
||||
)
|
||||
assert got == kind, (t, c)
|
||||
ids = seed(conn)
|
||||
ids["asset"] = one(
|
||||
conn,
|
||||
"INSERT INTO assets (sha256, size, mime, storage_key, pages) VALUES ('\\x00', 10, 'application/pdf',"
|
||||
" 'ab/q7', 1) RETURNING id",
|
||||
)
|
||||
with pytest.raises(psycopg.errors.CheckViolation), conn.transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO packet_docs (packet_id, asset_id, kind) VALUES (%(packet)s, %(asset)s, 'mtr')", ids
|
||||
)
|
||||
with pytest.raises(psycopg.errors.CheckViolation), conn.transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO qc_docs (project_id, unit_id, type, asset_id) VALUES (%(project)s, %(unit)s, 'ITP',"
|
||||
" %(asset)s)",
|
||||
ids,
|
||||
)
|
||||
with pytest.raises(psycopg.errors.CheckViolation), conn.transaction():
|
||||
conn.execute("INSERT INTO qc_docs (unit_id, type, asset_id) VALUES (%(unit)s, 'wps', %(asset)s)", ids)
|
||||
conn.execute("INSERT INTO qc_docs (project_id, type, asset_id) VALUES (%(project)s, 'ITP', %(asset)s)", ids)
|
||||
# A reject may come before its NCR number; a block row exists before it is signed.
|
||||
conn.execute("INSERT INTO itp_rejects (itp_id, line_key) VALUES (%(itp)s, '1.1')", ids)
|
||||
assert one(conn, "SELECT kind FROM itp_rejects WHERE itp_id = %(itp)s", ids) == "reject"
|
||||
conn.execute("INSERT INTO itp_blocks (itp_id, block_key) VALUES (%(itp)s, '3')", ids)
|
||||
assert one(conn, "SELECT signed_at FROM itp_blocks WHERE itp_id = %(itp)s", ids) is None
|
||||
conn.execute("UPDATE itps SET status = 'complete' WHERE id = %(itp)s", ids)
|
||||
value = one(conn, "SELECT value FROM settings WHERE key = 'qc_check_items'")
|
||||
assert [g["group"] for g in value["groups"]] == ["Data book package", "Physical spool"]
|
||||
keys = [i["key"] for g in value["groups"] for i in g["items"]]
|
||||
assert keys == [
|
||||
"db_heats",
|
||||
"db_mtr",
|
||||
"db_weldmap",
|
||||
"db_wps",
|
||||
"db_ck",
|
||||
"ph_dim",
|
||||
"ph_heat",
|
||||
"ph_stamp",
|
||||
"ph_clean",
|
||||
"ph_qty",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user