Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
- C1 (issue 1): over target follows VISUAL ticket hours, not the timer. - C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one. - C3 (issue 25): an incomplete unit ITP blocks filing. - C4 (issue 27): prints open the print dialog on the page, no new tab. - C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets. - C6 (issue 8): the data book leaves out the history pages. - C7 (issue 18): the supervisor approves each received short; accept and filing wait for it. - C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START. - C9 (issue 32): Q.C. and Clerical may run the stamp check. - C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only. - C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall. Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -99,4 +99,36 @@ test.describe.serial("admin stamp check", () => {
|
||||
await expect(results).toContainText("Job traveler");
|
||||
await closeAll(ann);
|
||||
});
|
||||
|
||||
test("C9: Q.C. checks a stamp on the Stamp check screen; Warehouse is refused", async ({ browser }) => {
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const page = quinn.page;
|
||||
// the API outside /admin answers Q.C.
|
||||
const ok = await api(quinn, "post", "/api/stamp-verify", { text: LINE });
|
||||
expect(ok.valid).toBe(true);
|
||||
expect(ok.print.packet_id).toBe(P.id);
|
||||
|
||||
// the top-bar link opens the screen
|
||||
await page.goto("/qc");
|
||||
await page.getByRole("banner").getByRole("link", { name: "Stamp check", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/stamp-check$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Stamp check" })).toBeVisible();
|
||||
const form = page.locator("form").filter({ has: page.getByRole("heading", { name: "Check a stamp line" }) });
|
||||
await form.getByLabel("Stamp line or code", { exact: true }).fill(LINE);
|
||||
await form.getByRole("button", { name: "Check stamp", exact: true }).click();
|
||||
const result = form.getByRole("status", { name: "Stamp result" });
|
||||
await expect(result).toContainText("Genuine.");
|
||||
await expect(result).toContainText("Job traveler");
|
||||
|
||||
// Clerical may check too; Warehouse may not, and its screen says so
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
expect((await api(clara, "post", "/api/stamp-verify", { text: LINE })).valid).toBe(true);
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
const refused = await call(wade, "post", "/api/stamp-verify", { text: LINE });
|
||||
expect(refused.status).toBe(403);
|
||||
await expect(wade.page.getByRole("banner").getByRole("link", { name: "Stamp check", exact: true })).toHaveCount(0);
|
||||
await wade.page.goto("/stamp-check");
|
||||
await expect(wade.page.getByTestId("not-for-role")).toContainText("The Stamp check is not for your role.");
|
||||
await closeAll(quinn, clara, wade);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { actor, api, closeAll } from "./shophelpers";
|
||||
import { call } from "./adminhelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
|
||||
// Change C8 on the CI stack: checklists per department. multi.mia (Clerical and 06 Code Bldg 2 supervisor) opens
|
||||
// Checklists from the top bar, sees only her own department, adds an item and saves. sup.wes (03 supervisor) can
|
||||
// read 06 but not change it, and Q.C. and a lead cannot change any. At the end mia sets 06 back to the default list
|
||||
// so the other scenarios keep the lists they expect.
|
||||
|
||||
const RUN = Date.now().toString(36).toLowerCase().slice(-5);
|
||||
const KEY = `e2e_${RUN}`;
|
||||
const TEXT = `Nameplate checked ${RUN}`;
|
||||
|
||||
async function resetTo06Default(a: Parameters<typeof api>[0]): Promise<void> {
|
||||
const cur = await api(a, "get", "/api/checklists/06");
|
||||
if (cur.custom) await api(a, "put", "/api/checklists/06", { items: null, base: cur.version });
|
||||
}
|
||||
|
||||
test.describe.serial("department checklists", () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test("C8: a supervisor edits their own department's checklist; others cannot", async ({ browser }) => {
|
||||
const mia = await actor(browser, "multi.mia");
|
||||
await resetTo06Default(mia);
|
||||
const list = (await api(mia, "get", "/api/checklists")) as unknown as Json[];
|
||||
const editable = list.filter((d) => d.editable).map((d) => String(d.dept_code));
|
||||
expect(editable).toEqual(["06"]);
|
||||
|
||||
// the top-bar link; one department, so no picker
|
||||
const page = mia.page;
|
||||
await page.goto("/clerical");
|
||||
await page.getByRole("banner").getByRole("link", { name: "Checklists", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/checklists$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Checklists" })).toBeVisible();
|
||||
await expect(page.getByLabel("Department", { exact: true })).toHaveCount(0);
|
||||
const editor = page.locator("section").filter({ has: page.getByRole("heading", { name: /^06 / }) });
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(editor).toContainText(/Uses the default/);
|
||||
const keys = editor.getByLabel(/^Item \d+ key$/);
|
||||
const n = await keys.count();
|
||||
expect(n).toBeGreaterThan(0);
|
||||
|
||||
// add an item at the end and save
|
||||
await editor.getByRole("button", { name: "Add item", exact: true }).click();
|
||||
await expect(keys).toHaveCount(n + 1);
|
||||
await editor.getByLabel(`Item ${n + 1} group`, { exact: true }).fill("Final");
|
||||
await editor.getByLabel(`Item ${n + 1} key`, { exact: true }).fill(KEY);
|
||||
await editor.getByLabel(`Item ${n + 1} text`, { exact: true }).fill(TEXT);
|
||||
await editor.getByRole("button", { name: "Save checklist", exact: true }).click();
|
||||
await expect(editor.getByText(/^Checklist saved\./)).toBeVisible();
|
||||
await expect(editor).toContainText("Own list");
|
||||
const saved = await api(mia, "get", "/api/checklists/06");
|
||||
expect(saved.custom).toBe(true);
|
||||
expect(saved.editable).toBe(true);
|
||||
expect((saved.items as Json[]).map((i) => i.key)).toContain(KEY);
|
||||
expect((saved.items as Json[]).length).toBe(n + 1);
|
||||
|
||||
// sup.wes reads 06 but may not change it; his own list is 03
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const as03 = await api(wes, "get", "/api/checklists/06");
|
||||
expect(as03.editable).toBe(false);
|
||||
expect((as03.items as Json[]).map((i) => i.key)).toContain(KEY);
|
||||
const refused = await call(wes, "put", "/api/checklists/06", { items: [], base: saved.version });
|
||||
expect(refused.status).toBe(403);
|
||||
await wes.page.goto("/checklists");
|
||||
await expect(wes.page.locator("section").filter({ has: wes.page.getByRole("heading", { name: /^03 / }) })).toBeVisible();
|
||||
await expect(wes.page.getByRole("heading", { name: /^06 / })).toHaveCount(0);
|
||||
|
||||
// Q.C. and a lead: no edit, no screen
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
expect((await call(quinn, "put", "/api/checklists/03", { items: [], base: 0 })).status).toBe(403);
|
||||
const lou = await actor(browser, "lead.lou");
|
||||
expect((await api(lou, "get", "/api/checklists/03")).editable).toBe(false);
|
||||
await lou.page.goto("/checklists");
|
||||
await expect(lou.page.getByTestId("not-for-role")).toContainText("The Checklists screen is not for your role.");
|
||||
|
||||
// a stale save is refused with the current list
|
||||
const stale = await call(mia, "put", "/api/checklists/06", { items: saved.items, base: Number(saved.version) - 1 });
|
||||
expect(stale.status).toBe(409);
|
||||
|
||||
// back to the default so the other scenarios keep their lists
|
||||
await resetTo06Default(mia);
|
||||
expect((await api(mia, "get", "/api/checklists/06")).custom).toBe(false);
|
||||
await closeAll(mia, wes, quinn, lou);
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
atCompleteApi,
|
||||
cardOf,
|
||||
clickAndCatch,
|
||||
clickAndPrint,
|
||||
column,
|
||||
expectPdf,
|
||||
fetchOk,
|
||||
itpOf,
|
||||
makeCloseProject,
|
||||
openClericalHome,
|
||||
openPacket,
|
||||
@@ -206,18 +208,20 @@ test.describe.serial("close", () => {
|
||||
await sheet.getByRole("button", { name: L.cancel, exact: true }).click();
|
||||
await expect(sheet).toBeHidden();
|
||||
|
||||
// a row opens the print in a new tab and closes the sheet; the server logs it
|
||||
// a row closes the sheet and prints on the same page (C4): the stamped PDF is fetched and the print dialog
|
||||
// opens, no new tab; the server logs it
|
||||
const before = await printCount(clara, id, "traveler");
|
||||
await box.getByRole("button", { name: L.printOpen, exact: true }).click();
|
||||
sheet = page.getByRole("dialog", { name: L.printSheet, exact: true });
|
||||
await expect(sheet).toBeVisible();
|
||||
await clickAndCatch(
|
||||
clara.ctx,
|
||||
await clickAndPrint(
|
||||
page,
|
||||
sheet.getByRole("button", { name: L.printRows[0], exact: true }),
|
||||
`/api/packets/${id}/print/traveler`,
|
||||
);
|
||||
await expect(sheet).toBeHidden();
|
||||
await expect(page).toHaveURL(new RegExp(`/packets/${id}$`));
|
||||
expect(clara.ctx.pages().length).toBe(1);
|
||||
await expect.poll(() => printCount(clara, id, "traveler")).toBe(before + 1);
|
||||
|
||||
// the same print through the API: a PDF, and one more log row
|
||||
@@ -244,6 +248,33 @@ test.describe.serial("close", () => {
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("C3: an open ITP on the unit blocks filing until Q.C. signs it off", async ({ browser }) => {
|
||||
const id = pk("r1").id;
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const unitId = Number(proj.unit.id);
|
||||
const started = await api(quinn, "post", `/api/units/${unitId}/itp`, {});
|
||||
expect(started.status).not.toBe("complete");
|
||||
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await openPacket(page, id);
|
||||
const box = panel(page);
|
||||
await expect(box.getByText(L.readyForBook)).toBeVisible();
|
||||
const file = box.getByRole("button", { name: L.fileIt, exact: true });
|
||||
await expect(file).toBeDisabled();
|
||||
await expect(box.getByText(L.itpOpen)).toBeVisible();
|
||||
expect((await packet(clara, id)).blocked.file).toContain(L.itpOpen);
|
||||
await stepApi(clara, id, "file", { sign: sign("clerk.clara") }, 403);
|
||||
expect(await statusOf(clara, id)).toBe("complete");
|
||||
|
||||
// signed off: File is enabled again
|
||||
await signedOffItpApi(quinn, unitId);
|
||||
await page.reload();
|
||||
await expect(box.getByRole("button", { name: L.fileIt, exact: true })).toBeEnabled();
|
||||
await expect(box.getByText(L.itpOpen)).toHaveCount(0);
|
||||
await closeAll(quinn, clara);
|
||||
});
|
||||
|
||||
test("R1: filed in the data book with the PIN; the seal holds and the filed data book downloads", async ({
|
||||
browser,
|
||||
}) => {
|
||||
@@ -326,12 +357,14 @@ test.describe.serial("close", () => {
|
||||
await closeAll(ann, clara);
|
||||
});
|
||||
|
||||
test("ITP: signed off through the API, its PDF is filed in the unit folder and linked on the ITP page", async ({
|
||||
test("ITP: signed off, its PDF is filed in the unit folder and linked on the ITP page", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const unitId = Number(proj.unit.id);
|
||||
const itp = await signedOffItpApi(quinn, unitId);
|
||||
// signed off in the C3 scenario above
|
||||
const itp = (await itpOf(quinn, unitId)) as Json;
|
||||
expect(itp.status).toBe("complete");
|
||||
const no = String(itp.no);
|
||||
expect(itp.signoff.asset_id).toBeTruthy();
|
||||
|
||||
@@ -356,6 +389,13 @@ test.describe.serial("close", () => {
|
||||
.first();
|
||||
const req = await clickAndCatch(quinn.ctx, quinn.page, pdfBtn, `/api/itps/${itp.id}/pdf`);
|
||||
await expectPdf(quinn, req.url());
|
||||
// Print goes to the print dialog on the same page (C4)
|
||||
await clickAndPrint(
|
||||
quinn.page,
|
||||
quinn.page.getByRole("button", { name: "Print", exact: true }),
|
||||
`/api/itps/${itp.id}/print`,
|
||||
);
|
||||
await expect(quinn.page).toHaveURL(new RegExp(`/qc/units/${unitId}/itp$`));
|
||||
const signed = quinn.page.getByRole("link", { name: L.signedPdf, exact: true });
|
||||
await expect(signed).toBeVisible();
|
||||
await expect(signed).toHaveAttribute("href", new RegExp(`/_blob/${itp.signoff.asset_id}`));
|
||||
|
||||
@@ -27,6 +27,8 @@ export const L = {
|
||||
sealUnknown: "Seal unknown",
|
||||
reopenedLine: /^Reopened by .+: .+\. The earlier seal is kept as broken\./,
|
||||
blocked: "Close the job in VISUAL first.",
|
||||
itpOpen: "Complete the unit's ITP first. Q.C. signs it off on the ITP page.", // C3
|
||||
shortsOpen: "Every short must be received and approved by the supervisor first.", // C7
|
||||
visualLine: "visual-line", // testid of the VISUAL observed line
|
||||
sealState: "seal-state", // testid of the seal line
|
||||
reopened: "reopened", // testid of the reopen line
|
||||
@@ -34,7 +36,6 @@ export const L = {
|
||||
jobClosed: "Job closed in VISUAL",
|
||||
moveBack: "Not closed in VISUAL, move back",
|
||||
fileIt: "File in the data book",
|
||||
fileAnyway: "File anyway",
|
||||
dataBookPdf: "Data book (PDF)",
|
||||
download: "Download",
|
||||
csv: "Traceability (CSV)",
|
||||
@@ -42,7 +43,6 @@ export const L = {
|
||||
reopenIt: "Reopen (IT)",
|
||||
// sheets
|
||||
printSheet: "Print",
|
||||
openWosSheet: "File with open work orders?",
|
||||
reopenSheet: "Reopen a filed packet",
|
||||
// print rows (each a button whose name starts with the row title)
|
||||
printRows: ["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)", "Data book"],
|
||||
@@ -202,6 +202,40 @@ export async function clickAndCatch(ctx: BrowserContext, page: Page, target: Loc
|
||||
return { url: () => fixed, response };
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a print control and check it prints on the same page (change C4): the page fetches the stamped PDF (the URL
|
||||
* contains `part`), draws it and calls window.print, which is stubbed here. No tab is opened. Returns the PDF URL.
|
||||
*/
|
||||
export async function clickAndPrint(page: Page, target: Locator, part: string): Promise<string> {
|
||||
await expect(target).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__printed: number;
|
||||
__opened: string[];
|
||||
print: () => void;
|
||||
open: (u?: string | URL) => Window | null;
|
||||
};
|
||||
w.__printed = 0;
|
||||
w.__opened = [];
|
||||
w.print = () => {
|
||||
w.__printed += 1;
|
||||
};
|
||||
w.open = (u?: string | URL) => {
|
||||
w.__opened.push(String(u ?? ""));
|
||||
return null;
|
||||
};
|
||||
});
|
||||
const fetched = page.waitForResponse((r) => r.url().includes(part), { timeout: 30_000 });
|
||||
await target.click();
|
||||
const res = await fetched;
|
||||
expect(res.status(), `print fetch ${res.url()}`).toBe(200);
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as unknown as { __printed: number }).__printed), { timeout: 30_000 })
|
||||
.toBe(1);
|
||||
expect(await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened)).toEqual([]);
|
||||
return res.url();
|
||||
}
|
||||
|
||||
/** GET a URL through the actor's API context and check the status. */
|
||||
export async function fetchOk(a: Actor, url: string): Promise<APIResponse> {
|
||||
const r = await a.page.request.get(url);
|
||||
|
||||
@@ -180,4 +180,24 @@ test.describe.serial("project management", () => {
|
||||
await expect(pmTab(page, L.pm.tabs.hours)).toHaveAttribute("aria-selected", "true"); // ps.pmtab
|
||||
await closeAll(paula);
|
||||
});
|
||||
test("C10: the PM screen and /api/pm are for Project Management and Admin only", async ({ browser }) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
for (const a of [clara, wes]) {
|
||||
for (const url of ["/api/pm/overview", "/api/pm/hours", "/api/pm/export.csv", "/api/pm/report.pdf"]) {
|
||||
const r = await a.page.request.get(url);
|
||||
expect(r.status(), `${a.sam} ${url}`).toBe(403);
|
||||
}
|
||||
await a.page.goto(L.homes.pm);
|
||||
await expect(a.page.getByTestId("not-for-role")).toContainText(
|
||||
"The Project Management screen is not for your role.",
|
||||
);
|
||||
await expect(a.page.getByRole("heading", { level: 1, name: L.pm.head })).toHaveCount(0);
|
||||
}
|
||||
// Admin still reads it
|
||||
const ann = await actor(browser, "admin.ann");
|
||||
const r = await ann.page.request.get("/api/pm/overview");
|
||||
expect(r.status()).toBe(200);
|
||||
await closeAll(clara, wes, ann);
|
||||
});
|
||||
});
|
||||
|
||||
+122
-3
@@ -7,6 +7,8 @@ import {
|
||||
closeAll,
|
||||
packet,
|
||||
packetTab,
|
||||
assignApi,
|
||||
finishStageApi,
|
||||
pullAndRelease,
|
||||
setPin,
|
||||
sign,
|
||||
@@ -43,6 +45,8 @@ import {
|
||||
type QcProject,
|
||||
} from "./qchelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
import { L as CL, finishInspectionApi } from "./closehelpers";
|
||||
import { pullWithHeat } from "./pmhelpers";
|
||||
|
||||
// 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,
|
||||
@@ -60,6 +64,8 @@ const T = {
|
||||
reject: `QC reject spool ${RUN}`,
|
||||
wp: `ITP weld spool ${RUN}`,
|
||||
fa: `ITP final spool ${RUN}`,
|
||||
ncr: `QC ncr spool ${RUN}`,
|
||||
short: `QC short spool ${RUN}`,
|
||||
};
|
||||
|
||||
let proj: QcProject;
|
||||
@@ -104,6 +110,20 @@ test.describe.serial("qc", () => {
|
||||
const rel = await stepApi(clara, fa.id, "release_to_shop", { dept: "11" });
|
||||
expect(rel.status).toBe("ready");
|
||||
P.fa = fa;
|
||||
// C2: a packet to reject without an NCR number
|
||||
P.ncr = await atQcApi(crew, proj.units[0], T.ncr, "Stall 4", "Bay 2");
|
||||
// C7: a packet with item 2 short; wh.wade receives it before the shop works it
|
||||
const sh = await makeQcDraft(clara, proj.units[0], T.short);
|
||||
await pullWithHeat(wade, sh.id, `HS${RUN}-`, [2]);
|
||||
const rec = await api(wade, "post", `/api/packets/${sh.id}/bom/2/short/receipts`, { qty: 2, heat: `HS${RUN}-R2` });
|
||||
expect(rec.shorts_unapproved).toBe(1);
|
||||
await assignApi(wes, sh.id, "Stall 4", andyId);
|
||||
await stepApi(andy, sh.id, "start");
|
||||
await finishStageApi(andy, sh.id, 2);
|
||||
await stepApi(andy, sh.id, "submit", { sign: sign("weld.andy") });
|
||||
const shq = await stepApi(wes, sh.id, "send_to_qc", { book: true, spool: true, location: "Bay 2", sign: sign("sup.wes") });
|
||||
expect(shq.status).toBe("qc");
|
||||
P.short = sh;
|
||||
await setPin(quinn, PIN);
|
||||
await closeAll(clara, wade, wes, andy, quinn);
|
||||
});
|
||||
@@ -184,7 +204,7 @@ test.describe.serial("qc", () => {
|
||||
await closeAll(quinn);
|
||||
});
|
||||
|
||||
test("reject: one weld rejected, an NCR number only, the password; the supervisor sends it again", async ({
|
||||
test("reject: one weld rejected, a reason and an NCR number, the password; the supervisor sends it again", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const id = pk("reject").id;
|
||||
@@ -206,7 +226,7 @@ test.describe.serial("qc", () => {
|
||||
})
|
||||
.toContain("reject:undercut");
|
||||
|
||||
// the reject sheet: enabled on an NCR number alone
|
||||
// the reject sheet: an NCR number alone is not enough, the reason needs 8 characters (C2)
|
||||
const bar = qcBar(page);
|
||||
await bar.getByRole("button", { name: L.reject, exact: true }).click();
|
||||
const sheet = page.getByRole("dialog", { name: L.rejectSheet, exact: true });
|
||||
@@ -214,6 +234,12 @@ test.describe.serial("qc", () => {
|
||||
const submitReject = sheet.getByRole("button", { name: L.rejectSubmit });
|
||||
await expect(submitReject).toBeDisabled();
|
||||
await sheet.getByLabel(L.ncrNumber, { exact: true }).fill(NCR);
|
||||
await expect(submitReject).toBeDisabled();
|
||||
const reason = sheet.getByLabel(L.rejectReason, { exact: true });
|
||||
await reason.fill("Short");
|
||||
await expect(sheet.getByText(L.reasonShort)).toBeVisible();
|
||||
await expect(submitReject).toBeDisabled();
|
||||
await reason.fill("Undercut on W1, grind and reweld");
|
||||
await expect(submitReject).toBeEnabled();
|
||||
await submitReject.click();
|
||||
await signWithPassword(page, "qc.quinn");
|
||||
@@ -530,12 +556,105 @@ test.describe.serial("qc", () => {
|
||||
await closeAll(quinn, wes, ann);
|
||||
});
|
||||
|
||||
test("C2: a reject without an NCR number needs the number at the accept", async ({ browser }) => {
|
||||
const id = pk("ncr").id;
|
||||
const NCR2 = "NCR-2026-0042";
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
// rejected with a detailed reason and no NCR number; sup.wes sends it again; a fresh inspection is complete
|
||||
const rej = await stepApi(quinn, id, "qc_reject", { reason: "Porosity on W2, grind out and reweld", sign: sign("qc.quinn") });
|
||||
expect(rej.status).toBe("review");
|
||||
expect(rej.ncr_required).toBe(true);
|
||||
await stepApi(wes, id, "send_to_qc", { book: true, spool: true, location: "Bay 3", sign: sign("sup.wes") });
|
||||
await finishInspectionApi(quinn, id);
|
||||
expect((await packet(quinn, id)).ncr_required).toBe(true);
|
||||
// the API refuses the accept without the number (422)
|
||||
const r = await quinn.page.request.post(`/api/packets/${id}/transitions`, {
|
||||
headers: { Origin: new URL(quinn.page.url()).origin, "X-Requested-With": "PacketStation" },
|
||||
data: { action: "qc_accept", expect: { status: "qc", dept: "03" }, routed_to: "databook", sign: sign("qc.quinn") },
|
||||
});
|
||||
expect(r.status()).toBe(422);
|
||||
expect(((await r.json()) as Json).detail).toBe(L.ncrFirst);
|
||||
|
||||
// the Q.C. bar asks for it; Accept waits for a well-formed number
|
||||
const page = quinn.page;
|
||||
await open(page, id);
|
||||
const bar = qcBar(page);
|
||||
await expect(bar.getByTestId("ncr-required")).toBeVisible();
|
||||
const acceptBtn = bar.getByRole("button", { name: L.accept, exact: true });
|
||||
const field = bar.getByLabel(L.ncrOfReject, { exact: true });
|
||||
await expect(acceptBtn).toBeDisabled();
|
||||
await field.fill("NCR-12");
|
||||
await expect(acceptBtn).toBeDisabled();
|
||||
await field.fill(NCR2);
|
||||
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");
|
||||
const after = await packet(quinn, id);
|
||||
expect(after.qc_last.ncr).toBe(NCR2);
|
||||
expect(after.ncr_required).toBe(false);
|
||||
await closeAll(quinn, wes);
|
||||
});
|
||||
|
||||
test("C7: a received short needs the supervisor's approval before Q.C. accepts", async ({ browser }) => {
|
||||
const id = pk("short").id;
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
await finishInspectionApi(quinn, id);
|
||||
let p = await packet(quinn, id);
|
||||
expect(p.shorts_unapproved).toBe(1);
|
||||
expect(p.blocked.qc_accept).toContain(CL.shortsOpen);
|
||||
await stepApi(quinn, id, "qc_accept", { routed_to: "databook", sign: sign("qc.quinn") }, 403);
|
||||
const receipt = Number(((p.shorts as Json[])[0].receipts as Json[])[0].id);
|
||||
const approveUrl = `/api/packets/${id}/shorts/receipts/${receipt}/approve`;
|
||||
|
||||
// the Q.C. bar says why Accept is off
|
||||
await open(quinn.page, id);
|
||||
const bar = qcBar(quinn.page);
|
||||
await expect(bar.getByText(CL.shortsOpen)).toBeVisible();
|
||||
await expect(bar.getByRole("button", { name: L.accept, exact: true })).toBeDisabled();
|
||||
|
||||
// Q.C. and a lead may not approve; the department supervisor does
|
||||
await api(quinn, "post", approveUrl, { sign: sign("qc.quinn") }, { status: 403 });
|
||||
const lou = await actor(browser, "lead.lou");
|
||||
await api(lou, "post", approveUrl, { sign: sign("lead.lou") }, { status: 403 });
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
await open(wes.page, id, L.tabMaterials);
|
||||
const receipts = wes.page.getByRole("group", { name: "Receipts for item 2" });
|
||||
await expect(receipts).toContainText(`heat HS${RUN}-R2`);
|
||||
await expect(receipts).toContainText(L.waitingApproval);
|
||||
await receipts.getByRole("button", { name: "Approve", exact: true }).click();
|
||||
await signWithPassword(wes.page, "sup.wes");
|
||||
await expect(signSheet(wes.page)).toBeHidden();
|
||||
await expect(receipts).toContainText("Approved");
|
||||
await expect(receipts).not.toContainText(L.waitingApproval);
|
||||
p = await packet(wes, id);
|
||||
expect(p.shorts_unapproved).toBe(0);
|
||||
// a second approval of the same receipt is refused
|
||||
await api(wes, "post", approveUrl, { sign: sign("sup.wes") }, { status: 409 });
|
||||
|
||||
// now Q.C. accepts
|
||||
await quinn.page.reload();
|
||||
const acceptBtn = qcBar(quinn.page).getByRole("button", { name: L.accept, exact: true });
|
||||
await expect(acceptBtn).toBeEnabled();
|
||||
await acceptBtn.click();
|
||||
await signWithPin(quinn.page, PIN);
|
||||
await expect(signSheet(quinn.page)).toBeHidden();
|
||||
await expect.poll(() => statusOf(quinn, id)).toBe("complete");
|
||||
await closeAll(quinn, lou, wes);
|
||||
});
|
||||
|
||||
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.
|
||||
// A guard for the helpers: a reject without a reason is refused before any signature is asked, and an NCR
|
||||
// number does not stand in for the reason (C2).
|
||||
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 stepApi(quinn, p.id, "qc_reject", { ncr: NCR, sign: sign("qc.quinn") }, 400);
|
||||
await stepApi(quinn, p.id, "qc_reject", { reason: "Bad", ncr: NCR, sign: sign("qc.quinn") }, 400);
|
||||
await closeAll(quinn);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,11 @@ export const L = {
|
||||
rejectSheet: "Reject",
|
||||
rejectReason: "Reason / what failed",
|
||||
ncrNumber: "NCR number",
|
||||
reasonShort: "At least 8 characters.", // C2: under the reason while it is short
|
||||
ncrOfReject: "NCR number of the reject", // C2: the accept's field when ncr_required
|
||||
ncrFirst: "Enter the NCR number of the reject first.", // C2: the 422 text
|
||||
tabMaterials: /^Materials/,
|
||||
waitingApproval: "Waiting for the supervisor's approval.", // C7
|
||||
rejectSubmit: "Reject to supervisor",
|
||||
// the Q.C. tab (packet/QcTab.tsx)
|
||||
inspector: "Inspector",
|
||||
|
||||
@@ -121,12 +121,16 @@ test.describe.serial("shop", () => {
|
||||
expect(assigned.welder.id).toBe(andyId);
|
||||
expect(assigned.welder.stamp).toBe("A7");
|
||||
|
||||
// weld.andy on the Stall screen
|
||||
// weld.andy on the Stall screen: no station picker (C11); his packets come from GET /api/stall/mine, each
|
||||
// with the station sup.wes picked
|
||||
const andy = await actor(browser, "weld.andy");
|
||||
const mineApi = await api(andy, "get", "/api/stall/mine");
|
||||
const mineCard = ((mineApi.mine ?? []) as Json[]).find((c) => Number(c.id) === id);
|
||||
expect(mineCard?.station_name).toBe("Stall 1");
|
||||
const stallMine = andy.page.waitForResponse((r) => r.url().includes("/api/stall/mine"));
|
||||
await andy.page.goto("/stall");
|
||||
const picker = andy.page.getByRole("combobox", { name: L.stallPicker });
|
||||
if (await picker.count())
|
||||
await picker.first().selectOption({ label: L.stallOption("Stall 1") });
|
||||
expect((await stallMine).status()).toBe(200);
|
||||
await expect(andy.page.getByRole("combobox", { name: L.stallPicker })).toHaveCount(0);
|
||||
const myHead = andy.page.getByRole("heading", {
|
||||
name: new RegExp(`^${L.myPackets}`),
|
||||
});
|
||||
@@ -642,3 +646,16 @@ test.describe.serial("shop", () => {
|
||||
await closeAll(wes);
|
||||
});
|
||||
});
|
||||
|
||||
test("C11: a person with nothing assigned sees that the supervisor assigns packets", async ({ browser }) => {
|
||||
// both.bo (03 worker and supervisor) has no packet assigned to him in these scenarios
|
||||
const bo = await actor(browser, "both.bo");
|
||||
const mine = await api(bo, "get", "/api/stall/mine");
|
||||
expect(mine.mine).toEqual([]);
|
||||
expect(mine.submitted).toEqual([]);
|
||||
expect(String(mine.empty)).toContain("Your supervisor assigns packets to you.");
|
||||
await bo.page.goto("/stall");
|
||||
await expect(bo.page.getByText("Your supervisor assigns packets to you.").first()).toBeVisible();
|
||||
await expect(bo.page.getByRole("combobox", { name: L.stallPicker })).toHaveCount(0);
|
||||
await closeAll(bo);
|
||||
});
|
||||
|
||||
@@ -107,13 +107,15 @@ def set_feed_legs(
|
||||
observed: bool = True,
|
||||
) -> None:
|
||||
"""Simulate the feed: the packet becomes a VISUAL packet (source_kind visual, a visual_key), packet_visual_state
|
||||
holds the legs as the feed writes them ({key: "base/lot/split/sub", seq, st}) and visual_closed_at is set when
|
||||
`closed`. `legs` None writes no state row (the feed has not looked yet)."""
|
||||
holds the legs as the feed writes them ({key: "base/lot/split/sub", seq, st}), and when `closed` the feed's
|
||||
close is set: visual_closed_at, and vclosed_at with no vclosed_by (C5: the feed is the writer of vclosed on a
|
||||
VISUAL packet). `legs` None writes no state row (the feed has not looked yet)."""
|
||||
with app_conn(settings) as conn:
|
||||
conn.execute(
|
||||
"UPDATE packets SET source_kind = 'visual', visual_key = coalesce(visual_key, %s),"
|
||||
" visual_closed_at = CASE WHEN %s THEN now() END WHERE id = %s",
|
||||
(f"test-{packet_id}-{secrets.token_hex(3)}", closed, packet_id),
|
||||
" visual_closed_at = CASE WHEN %s THEN now() END, vclosed_at = CASE WHEN %s THEN now() END,"
|
||||
" vclosed_by = NULL WHERE id = %s",
|
||||
(f"test-{packet_id}-{secrets.token_hex(3)}", closed, closed, packet_id),
|
||||
)
|
||||
if legs is None:
|
||||
conn.execute("DELETE FROM packet_visual_state WHERE packet_id = %s", (packet_id,))
|
||||
@@ -250,7 +252,7 @@ def rich_complete(shop: Shop, *, itp_doc: bool = False, mtrs: bool = True) -> di
|
||||
pkt = submit(shop, finish_stage(shop, pkt, welds=2))
|
||||
pkt = ok(step(shop("sup.wes"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
|
||||
pkt = finish_inspection(shop, pkt)
|
||||
pkt = ok(accept(shop, pkt, "databook"))
|
||||
pkt = accept_with_open_short(shop, pkt, 3)
|
||||
add_doc(shop, pkt)
|
||||
r = make_qc_doc(shop, unit_id=unit_id, type="ITP" if itp_doc else "WPS / PQR", title="Unit folder doc")
|
||||
assert r.status_code in (200, 201), r.text
|
||||
@@ -261,6 +263,34 @@ def rich_complete(shop: Shop, *, itp_doc: bool = False, mtrs: bool = True) -> di
|
||||
return out
|
||||
|
||||
|
||||
def approve_receipts(shop: Shop, pkt: dict[str, Any], by: str = "sup.wes") -> dict[str, Any]:
|
||||
"""The department supervisor approves every received short not yet approved (C7). Returns the packet read."""
|
||||
for sh in pkt.get("shorts") or []:
|
||||
for r in sh["receipts"]:
|
||||
if r["approval"] is None:
|
||||
url = f"/api/packets/{pkt['id']}/shorts/receipts/{r['id']}/approve"
|
||||
pkt = ok(shop(by).post(url, json={"sign": SIGN}))
|
||||
return pkt
|
||||
|
||||
|
||||
def accept_with_open_short(shop: Shop, pkt: dict[str, Any], item: int) -> dict[str, Any]:
|
||||
"""Accept to the data book while item `item` keeps its open short: a packet completed before change C7, which
|
||||
the data book and the trace CSV still show. The receipts are approved through the API; the open short is closed
|
||||
by SQL for the accept only (C7 refuses an accept with an open short) and opened again after it."""
|
||||
pkt = approve_receipts(shop, pkt)
|
||||
sid = line_of(pkt, item)["short"]["id"]
|
||||
with app_conn(shop.settings) as conn:
|
||||
conn.execute("UPDATE bom_shorts SET closed_at = now() WHERE id = %s", (sid,))
|
||||
pkt = ok(accept(shop, fresh_read(shop, pkt["id"]), "databook"))
|
||||
with app_conn(shop.settings) as conn:
|
||||
conn.execute("UPDATE bom_shorts SET closed_at = NULL WHERE id = %s", (sid,))
|
||||
return fresh_read(shop, pkt["id"])
|
||||
|
||||
|
||||
def fresh_read(shop: Shop, pid: int) -> dict[str, Any]:
|
||||
return ok(shop("clerk.clara").get(f"/api/packets/{pid}"))
|
||||
|
||||
|
||||
def upload_bytes_pdf() -> bytes:
|
||||
"""A one-page PDF with its own bytes (the store dedupes equal bytes)."""
|
||||
from qchelpers import unique_pdf
|
||||
|
||||
@@ -7,7 +7,7 @@ The labor hours are rows written straight into `visual_labor`, as the feed tests
|
||||
The PM world (dates are calendar days in the site time zone, America/Denver):
|
||||
|
||||
project L670 "Pump skids" (Acme Gas, PO-9, due today + 20), alias OLD670
|
||||
unit 001 "Skid A" due yesterday -> late k_run (in progress, timer 6 h, ticket 3.2 h, target 4.5 h),
|
||||
unit 001 "Skid A" due yesterday -> late k_run (in progress, timer 6 h, ticket 5.2 h, target 4.5 h),
|
||||
k_short (warehouse pulling, an open short with a past ETA)
|
||||
unit 002 "Skid B" due today + 4 -> risk k_rev (review after a Q.C. reject), k_qc (at Q.C.)
|
||||
unit 003 "Skid C" due today + 30 -> risk k_hold (warehouse hold for 5 days), k_inc (warehouse waiting)
|
||||
@@ -101,13 +101,21 @@ def kpi_labels(period: str = "90") -> tuple[str, ...]:
|
||||
"Open change orders",
|
||||
"Open shorts",
|
||||
f"Q.C. rejects · {lower}",
|
||||
"Packets over target hours",
|
||||
"Packets over target hours (tickets)",
|
||||
f"Filed · {lower}",
|
||||
"Average lead time, created → filed",
|
||||
)
|
||||
|
||||
|
||||
HOURS_TAIL = ["Packets", "Timer h", "Ticket h", "Target h", "Variance h", "Timer / target", "Over target"]
|
||||
HOURS_TAIL = [
|
||||
"Packets",
|
||||
"Timer h",
|
||||
"Ticket h",
|
||||
"Target h",
|
||||
"Ticket variance h",
|
||||
"Ticket h / target",
|
||||
"Over target (tickets)",
|
||||
]
|
||||
HEADS: dict[str, list[str]] = {
|
||||
"projects": [
|
||||
"Project / unit",
|
||||
@@ -131,7 +139,7 @@ HEADS: dict[str, list[str]] = {
|
||||
"hours_dept": ["Department", *HOURS_TAIL],
|
||||
"hours_worker": ["Worker", *HOURS_TAIL],
|
||||
"hours_project": ["Project", *HOURS_TAIL],
|
||||
"hours_over": ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
|
||||
"hours_over": ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by (tickets)"],
|
||||
"quality_rejects": ["Job", "Date", "Inspector", "Reason / NCR", "Station / worker"],
|
||||
"quality_shorts": ["Job", "Item", "Short qty", "Reason", "PO / req", "ETA", "Age", "Packet is"],
|
||||
"quality_cos": ["Job", "Change order", "Change", "Waiting on", "Open for"],
|
||||
@@ -190,8 +198,8 @@ NOTES = {
|
||||
"aging_steps": "How long packets have been sitting where they are right now.",
|
||||
"aging_open": "Over 3 days in one step is flagged.",
|
||||
"aging_history": "From the step history recorded on every move.",
|
||||
"hours_over": "Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the"
|
||||
" clock of record.",
|
||||
"hours_over": "VISUAL ticket hours against the work-order target hours decide over target. The station timer is"
|
||||
" shown beside them. A packet with no ticket hours is not over target.",
|
||||
}
|
||||
|
||||
TAB_TABLES = {
|
||||
@@ -509,7 +517,7 @@ def pm_world(shop: Shop) -> PmWorld:
|
||||
u = w.units
|
||||
wade = shop("wh.wade")
|
||||
|
||||
# unit 001, late: a running packet over target with ticket hours, a short with a past ETA
|
||||
# unit 001, late: a running packet over target on its ticket hours, a short with a past ETA
|
||||
run_pkt = start(shop, to_station(shop, ready_skipped(shop, u[1], title="Running spool")))
|
||||
w.k["k_run"] = run_pkt["id"]
|
||||
run(
|
||||
@@ -518,7 +526,7 @@ def pm_world(shop: Shop) -> PmWorld:
|
||||
(4 * 3600 * 1000, run_pkt["id"]),
|
||||
)
|
||||
set_running_since(s, run_pkt["id"], 2 * 3600)
|
||||
labor_rows(s, run_pkt["id"], [("L670001", 10, "1.5"), ("L670002", 20, "1.7")])
|
||||
labor_rows(s, run_pkt["id"], [("L670001", 10, "3.0"), ("L670002", 20, "2.2")])
|
||||
|
||||
short = to_warehouse(shop, draft(shop, u[1], title="Short spool"))
|
||||
yesterday = (today - timedelta(days=1)).isoformat()
|
||||
|
||||
@@ -64,8 +64,6 @@ def endpoints(andy: str, ck_version: int) -> list[tuple[str, str, dict[str, Any]
|
||||
("GET", "/api/admin/access-log", {}, 200),
|
||||
("GET", "/api/admin/access-log.csv", {}, 200),
|
||||
("GET", "/api/admin/presence", {}, 200),
|
||||
("POST", "/api/admin/stamp-verify", {"json": {"text": "0123456789abcdef"}}, 200),
|
||||
("POST", "/api/admin/stamp-verify/pdf", {"files": {"file": ("a.pdf", tiny_pdf(), "application/pdf")}}, 200),
|
||||
("GET", "/api/admin/directory-flags", {}, 200),
|
||||
("POST", "/api/admin/ad/refresh", {}, 202),
|
||||
("GET", "/api/admin/ad/reconcile", {}, 200),
|
||||
@@ -123,3 +121,38 @@ def test_every_new_admin_endpoint_as_every_role(
|
||||
assert n == len(OTHER_ROLES) * len(eps)
|
||||
details = conn.execute("SELECT detail FROM audit_events WHERE action = 'policy.denied' LIMIT 1").fetchone()[0]
|
||||
assert set(details) == {"method", "path", "code"}
|
||||
|
||||
|
||||
def stamp_endpoints() -> list[tuple[str, str, dict[str, Any]]]:
|
||||
out = []
|
||||
for base in ("/api/stamp-verify", "/api/admin/stamp-verify"):
|
||||
out.append(("POST", base, {"json": {"text": "0123456789abcdef"}}))
|
||||
out.append(("POST", f"{base}/pdf", {"files": {"file": ("a.pdf", tiny_pdf(), "application/pdf")}}))
|
||||
return out
|
||||
|
||||
|
||||
def test_stamp_check_roles(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
|
||||
"""Change C9: the stamp check is open to Admin, Q.C. and every Clerical level, at both paths, on a password
|
||||
session. Every other role gets 403."""
|
||||
eps = stamp_endpoints()
|
||||
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as anon:
|
||||
for method, path, kw in eps:
|
||||
assert call(anon, method, path, kw).status_code == 401, path
|
||||
for sam in ("admin.ann", "qc.quinn", "clerk.clara", "clerk.leo"):
|
||||
assert sign_in(pg_client, sam).status_code == 200, sam
|
||||
for method, path, kw in eps:
|
||||
r = call(pg_client, method, path, kw)
|
||||
assert r.status_code == 200, (sam, path, r.status_code, r.text)
|
||||
pg_client.post("/api/auth/signout")
|
||||
for sam in ("wh.wade", "pm.paula", "weld.andy", "lead.lou", "sup.wes"):
|
||||
assert sign_in(pg_client, sam).status_code == 200, sam
|
||||
for method, path, kw in eps:
|
||||
r = call(pg_client, method, path, kw)
|
||||
assert r.status_code == 403 and r.json()["code"] == "forbidden", (sam, path, r.status_code)
|
||||
pg_client.post("/api/auth/signout")
|
||||
# A PIN session: the password session rule stays
|
||||
register_device(pg_client)
|
||||
set_pin(pg_client, "admin.ann")
|
||||
assert pin_signin(pg_client, "1001").status_code == 200
|
||||
r = call(pg_client, eps[0][0], eps[0][1], eps[0][2])
|
||||
assert r.status_code == 403 and r.json()["code"] == "password_session_required", r.text
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Change C1 (Gitea issue 1): "over target" is decided by the VISUAL ticket hours against the work-order target, not
|
||||
by the station timer. A packet with no ticket hours is not over target. No database: a stub report feeds the PM
|
||||
hours tables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from packetstation.domain import pm
|
||||
|
||||
|
||||
class StubReport:
|
||||
def __init__(self, hours: dict[int, dict[str, float]]) -> None:
|
||||
self._h = hours
|
||||
self.cards = [
|
||||
{"id": pid, "status": "station", "dept": {"code": "WP"}, "unit": {"number": f"L1-00{pid}"}, "op": "10"}
|
||||
for pid in hours
|
||||
]
|
||||
|
||||
def hours(self) -> dict[int, dict[str, float]]:
|
||||
return self._h
|
||||
|
||||
def dept_short(self, code: str) -> str:
|
||||
return code
|
||||
|
||||
|
||||
def h(timer: float, ticket: float, target: float) -> dict[str, float]:
|
||||
return {"timer_h": timer, "ticket_h": ticket, "target_h": target}
|
||||
|
||||
|
||||
WORLD = {
|
||||
1: h(9.0, 0.0, 4.0), # timer over, no tickets: not over
|
||||
2: h(1.0, 5.5, 4.0), # tickets over, timer under: over
|
||||
3: h(9.0, 3.0, 4.0), # timer over, tickets under: not over
|
||||
4: h(0.0, 2.0, 0.0), # no target: never over
|
||||
}
|
||||
|
||||
|
||||
def tables() -> dict[str, dict[str, Any]]:
|
||||
rep: Any = StubReport(WORLD)
|
||||
return {t["key"]: t for t in pm.hours_tables(rep)}
|
||||
|
||||
|
||||
def test_is_over_reads_the_tickets() -> None:
|
||||
assert [pid for pid, x in WORLD.items() if pm.is_over(x)] == [2]
|
||||
|
||||
|
||||
def test_over_table_lists_ticket_overs_only() -> None:
|
||||
over = tables()["hours_over"]
|
||||
assert over["head"][-1] == "Over by (tickets)"
|
||||
assert [r["pid"] for r in over["rows"]] == [2]
|
||||
assert over["rows"][0]["cells"][-1] == "+1.5 h"
|
||||
|
||||
|
||||
def test_group_counts_and_heads() -> None:
|
||||
dept = tables()["hours_dept"]
|
||||
assert dept["head"][-3:] == ["Ticket variance h", "Ticket h / target", "Over target (tickets)"]
|
||||
cells = dept["rows"][0]["cells"]
|
||||
assert cells[-1] == "1" # one packet over on its tickets
|
||||
# ticket 8.5 against target 12: under, so the group is not flagged and the ratio is the tickets'
|
||||
assert cells[-2] == "71%" and not dept["rows"][0]["flag"]
|
||||
|
||||
|
||||
def test_over_target_count() -> None:
|
||||
rep: Any = StubReport(WORLD)
|
||||
assert pm.over_target_count(rep) == 1
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Change C2 (Gitea issue 24): a Q.C. reject always needs a detailed reason, and a packet rejected without an NCR
|
||||
number cannot be accepted until the NCR number is given with the accept (QC_ACCEPT and QC_ACCEPT_AND_ROUTE).
|
||||
|
||||
The packet read says `ncr_required`; can() does not block on it (the NCR comes with the step); the server refuses
|
||||
the step without it, 422 ncr_required. The accept stores the NCR on its inspection and in the log."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from pkthelpers import step
|
||||
from pshelpers import needs_pg
|
||||
from qchelpers import NCR, accept, at_qc, finish_inspection, reject, to_qc
|
||||
from shophelpers import SIGN, Shop, fresh, last_log, ok, one, open_shop, refused
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
REASON = "Weld 2 undercut too deep"
|
||||
MSG = "Enter the NCR number of the reject first."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def test_accept_after_a_reject_without_ncr_needs_one(shop: Shop) -> None:
|
||||
pkt = at_qc(shop)
|
||||
assert pkt["ncr_required"] is False
|
||||
out = ok(reject(shop, pkt, REASON))
|
||||
assert out["ncr_required"] is True and out["qc_last"]["ncr"] is None
|
||||
pkt = finish_inspection(shop, to_qc(shop, fresh(shop("sup.wes"), pkt["id"])))
|
||||
assert pkt["ncr_required"] is True
|
||||
# can() does not block on it: the NCR is input with the step
|
||||
assert {"qc_accept", "qc_accept_and_route"} <= set(pkt["can"]) and "qc_accept" not in pkt["blocked"]
|
||||
refused(accept(shop, pkt), 422, "ncr_required", MSG)
|
||||
refused(step(shop("qc.quinn"), pkt, "qc_accept_and_route", dept="09", sign=SIGN), 422, "ncr_required", MSG)
|
||||
refused(step(shop("qc.quinn"), pkt, "qc_accept", routed_to="databook", ncr=" ", sign=SIGN), 422, "ncr_required")
|
||||
refused(
|
||||
step(shop("qc.quinn"), pkt, "qc_accept", routed_to="databook", ncr="NCR-26-1", sign=SIGN), 422, "invalid_ncr"
|
||||
)
|
||||
assert fresh(shop("qc.quinn"), pkt["id"])["status"] == "qc"
|
||||
out = ok(step(shop("qc.quinn"), pkt, "qc_accept", routed_to="databook", ncr=" ncr-2026-0021 ", sign=SIGN))
|
||||
assert out["status"] == "complete" and out["ncr_required"] is False
|
||||
assert out["qc_last"]["result"] == "accepted" and out["qc_last"]["ncr"] == NCR
|
||||
row = one(
|
||||
shop.settings,
|
||||
"SELECT ncr, result FROM qc_inspections WHERE packet_id = %s ORDER BY id DESC LIMIT 1",
|
||||
(pkt["id"],),
|
||||
)
|
||||
assert tuple(row) == (NCR, "accepted")
|
||||
log = last_log(shop("qc.quinn"), pkt["id"])
|
||||
assert log["action"] == "Q.C. accepted → Clerical – Data Book"
|
||||
assert log["note"] == f"Quinn QC [Q1] · 2 welds VT accepted · {NCR}", log
|
||||
|
||||
|
||||
def test_accept_and_route_carries_the_ncr(shop: Shop) -> None:
|
||||
pkt = at_qc(shop)
|
||||
ok(reject(shop, pkt, REASON))
|
||||
pkt = finish_inspection(shop, to_qc(shop, fresh(shop("sup.wes"), pkt["id"])))
|
||||
out = ok(step(shop("qc.quinn"), pkt, "qc_accept_and_route", dept="09", ncr=NCR, sign=SIGN))
|
||||
assert out["status"] == "ready" and out["ncr_required"] is False and out["qc_last"]["ncr"] == NCR
|
||||
log = last_log(shop("qc.quinn"), pkt["id"])
|
||||
assert log["note"] == f"Quinn QC [Q1] · from 03 Weld Parts · {NCR}", log
|
||||
|
||||
|
||||
def test_a_reject_with_its_ncr_needs_none_at_accept(shop: Shop) -> None:
|
||||
pkt = at_qc(shop)
|
||||
out = ok(reject(shop, pkt, REASON, ncr=NCR))
|
||||
assert out["ncr_required"] is False and out["return_note"] == f"{NCR} · {REASON}"
|
||||
pkt = finish_inspection(shop, to_qc(shop, fresh(shop("sup.wes"), pkt["id"])))
|
||||
out = ok(accept(shop, pkt))
|
||||
assert out["status"] == "complete" and out["qc_last"]["ncr"] is None
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Change C3 (Gitea issue 25): FILE is refused while the packet's unit has an ITP that is not complete. A unit with
|
||||
no ITP does not block. The reason shows in `blocked.file` so the close panel says why."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from closehelpers import at_complete, file_packet, vclose
|
||||
from fakedir import FakeDirectory
|
||||
from pshelpers import needs_pg
|
||||
from qchelpers import start_itp
|
||||
from shophelpers import Shop, fresh, make_unit, ok, open_shop, refused, run
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
MSG = "Complete the unit's ITP first. Q.C. signs it off on the ITP page."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def test_open_itp_blocks_the_file_until_signed_off(shop: Shop) -> None:
|
||||
_, unit_id = make_unit(shop.settings)
|
||||
pkt = vclose(shop, at_complete(shop, unit_id))
|
||||
pid = pkt["id"]
|
||||
assert "file" in pkt["can"] # no ITP on the unit: nothing blocks
|
||||
itp = start_itp(shop, unit_id)
|
||||
view = fresh(shop("clerk.clara"), pid)
|
||||
assert "file" not in view["can"] and view["blocked"]["file"] == [MSG]
|
||||
[card] = ok(shop("clerk.clara").get(f"/api/packets?unit_id={unit_id}"))["packets"]
|
||||
assert card["blocked"]["file"] == [MSG]
|
||||
refused(file_packet(shop, view), 403, "precondition", MSG)
|
||||
# the ITP sign-off itself is tested in test_itp.py; here its end state is enough
|
||||
run(shop.settings, "UPDATE itps SET status = 'complete', signoff_at = now() WHERE id = %s", (itp["id"],))
|
||||
view = fresh(shop("clerk.clara"), pid)
|
||||
assert "file" in view["can"] and "file" not in view["blocked"]
|
||||
assert ok(file_packet(shop, view))["status"] == "filed"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Change C5 (Gitea issue 31): filing is blocked, not warned, while VISUAL still shows a work order open, on both
|
||||
routes. On a VISUAL packet the feed is the one writer of `vclosed`: it sets it when it sees every work order closed
|
||||
(the Clerical board then shows the packet under "Ready for data book") and clears it when one shows open again.
|
||||
|
||||
The feed's observe step runs here on legs written by SQL. Nothing here reads VISUAL or M-Files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator, Mapping
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from closehelpers import at_complete, file_packet, leg, q1, set_feed_legs
|
||||
from fakedir import FakeDirectory
|
||||
from pshelpers import app_conn, needs_pg
|
||||
from shophelpers import Shop, find_log, fresh, ok, open_shop, refused
|
||||
|
||||
from packetstation.feed import apply
|
||||
from packetstation.feed.types import WoKey
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
W1 = WoKey("L670001", "001", "0", "0")
|
||||
W2 = WoKey("L670002", "001", "0", "0")
|
||||
OPEN_W2 = "VISUAL still shows work orders open on this packet: L670002/001. Filing waits until VISUAL closes them."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def observe(settings: Settings, seen: Mapping[WoKey, str]) -> dict[str, Any]:
|
||||
"""The feed's observe step for the given leg statuses. Returns the run's counts."""
|
||||
with app_conn(settings) as conn:
|
||||
run = apply._Run(conn, None, None, None, date.today())
|
||||
apply._observe(run, seen, None)
|
||||
return dict(run.counts)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["databook", "shipping"])
|
||||
def test_feed_closes_and_opens_a_visual_packet(shop: Shop, route: str) -> None:
|
||||
pkt = at_complete(shop, route=route)
|
||||
pid = pkt["id"]
|
||||
set_feed_legs(shop.settings, pid, [leg("L670001", st="R"), leg("L670002", st="R")])
|
||||
c = shop("clerk.clara")
|
||||
|
||||
# one leg closed: still open in VISUAL, nothing set
|
||||
counts = observe(shop.settings, {W1: "C", W2: "R"})
|
||||
assert counts["closed_observed"] == 0 and counts["errors"] == 0, counts
|
||||
view = fresh(c, pid)
|
||||
assert view["vclosed"] is None and view["blocked"]["file"] == [OPEN_W2]
|
||||
|
||||
# every leg closed: the feed sets vclosed (no person) and the file opens
|
||||
counts = observe(shop.settings, {W1: "C", W2: "C"})
|
||||
assert counts["closed_observed"] == 1 and counts["errors"] == 0, counts
|
||||
view = fresh(c, pid)
|
||||
assert view["vclosed"]["at"] is not None and view["vclosed"]["by"] is None
|
||||
assert view["vclose_check"]["closed_at"] is not None and view["vclose_check"]["agrees"] is True
|
||||
assert "file" in view["can"]
|
||||
assert find_log(c, pid, "Closed in VISUAL (observed)")
|
||||
assert observe(shop.settings, {W1: "C", W2: "C"})["closed_observed"] == 0 # nothing changed: no write
|
||||
|
||||
# a leg open again: the feed clears it and the file waits
|
||||
observe(shop.settings, {W1: "C", W2: "R"})
|
||||
view = fresh(c, pid)
|
||||
assert view["vclosed"] is None and view["vclose_check"]["closed_at"] is None
|
||||
assert view["blocked"]["file"] == [OPEN_W2]
|
||||
assert find_log(c, pid, "Open again in VISUAL (observed)")["note"] == "L670002/001"
|
||||
refused(file_packet(shop, view), 403, "precondition", OPEN_W2)
|
||||
|
||||
# closed and cancelled count as done
|
||||
assert observe(shop.settings, {W1: "C", W2: "X"})["closed_observed"] == 1
|
||||
out = ok(file_packet(shop, fresh(c, pid)))
|
||||
assert out["status"] == "filed" and out["vclosed"]["by"] is None
|
||||
# the feed leaves a filed packet alone
|
||||
observe(shop.settings, {W1: "R", W2: "R"})
|
||||
assert q1(shop.settings, "SELECT vclosed_at IS NOT NULL FROM packets WHERE id = %s", (pid,))[0] is True
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Change C7 (Gitea issue 18): every received short needs the department supervisor's approval, one receipt at a time.
|
||||
|
||||
SHORT_APPROVE through POST /api/packets/{id}/shorts/receipts/{receipt_id}/approve: who (the supervisor of the
|
||||
packet's department, Admin with an IT reason), the signature, one approval per receipt (409), the read model (who
|
||||
received and who approved each receipt, shorts_unapproved, can), and the shorts_approved precondition on QC_ACCEPT
|
||||
and FILE (not on QC_ACCEPT_AND_ROUTE). Nothing here reads VISUAL or M-Files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from closehelpers import at_complete, file_packet, q1, vclose
|
||||
from fakedir import FakeDirectory
|
||||
from pshelpers import app_conn, needs_pg
|
||||
from qchelpers import accept, accept_route, finish_inspection, to_qc
|
||||
from shophelpers import (
|
||||
IT,
|
||||
SIGN,
|
||||
Shop,
|
||||
draft,
|
||||
find_log,
|
||||
finish_stage,
|
||||
fresh,
|
||||
im,
|
||||
line_of,
|
||||
ok,
|
||||
open_shop,
|
||||
refused,
|
||||
release,
|
||||
start,
|
||||
submit,
|
||||
to_station,
|
||||
to_warehouse,
|
||||
)
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
MSG = "Every short must be received and approved by the supervisor first."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def approve(shop: Shop, pkt: dict[str, Any], receipt_id: int, by: str = "sup.wes", **body: Any) -> Any:
|
||||
body.setdefault("sign", SIGN)
|
||||
return shop(by).post(f"/api/packets/{pkt['id']}/shorts/receipts/{receipt_id}/approve", json=body)
|
||||
|
||||
|
||||
def receive(shop: Shop, pkt: dict[str, Any], item: int, qty: int, heat: str, by: str = "wh.wade") -> dict[str, Any]:
|
||||
return ok(shop(by).post(f"/api/packets/{pkt['id']}/bom/{item}/short/receipts", json={"qty": qty, "heat": heat}))
|
||||
|
||||
|
||||
def short_at_qc(shop: Shop) -> dict[str, Any]:
|
||||
"""Items 1 and 2 pulled; item 3 short 2 of 4, one piece received (HT-31), one still open; through the shop
|
||||
and at Q.C. with the inspection finished."""
|
||||
pkt = to_warehouse(shop, draft(shop))
|
||||
wade = shop("wh.wade")
|
||||
pid = pkt["id"]
|
||||
for item in (1, 2):
|
||||
cur = line_of(pkt, item)
|
||||
pkt = ok(wade.patch(f"/api/packets/{pid}/bom/{item}", json={"pulled": True}, headers=im(cur["row_version"])))
|
||||
cur = line_of(pkt, item)
|
||||
pkt = ok(wade.patch(f"/api/packets/{pid}/bom/{item}", json={"heat": "HT-1"}, headers=im(cur["row_version"])))
|
||||
pkt = ok(wade.post(f"/api/packets/{pid}/bom/3/short", json={"qty": 2, "reason": "On order", "expect_open": False}))
|
||||
pkt = receive(shop, pkt, 3, 1, "HT-31")
|
||||
pkt = start(shop, to_station(shop, release(shop, pkt)))
|
||||
pkt = submit(shop, finish_stage(shop, pkt, welds=1))
|
||||
return finish_inspection(shop, to_qc(shop, pkt))
|
||||
|
||||
|
||||
def test_approve_receipts_then_accept(shop: Shop) -> None:
|
||||
pkt = short_at_qc(shop)
|
||||
pid = pkt["id"]
|
||||
assert pkt["shorts_unapproved"] == 2 # one open short and one receipt without approval
|
||||
assert pkt["blocked"]["qc_accept"] == [MSG]
|
||||
assert "qc_accept_and_route" in pkt["can"] and "qc_accept_and_route" not in pkt["blocked"]
|
||||
refused(accept(shop, pkt), 403, "precondition", MSG)
|
||||
|
||||
[short] = pkt["shorts"]
|
||||
assert (short["item"], float(short["qty"]), float(short["qty_open"]), short["closed_at"]) == (3, 2, 1, None)
|
||||
[r1] = short["receipts"]
|
||||
assert float(r1["qty"]) == 1 and r1["heat"] == "HT-31" and r1["by"]["display_name"] and r1["approval"] is None
|
||||
assert line_of(pkt, 3)["short"]["receipts"][0]["approval"] is None
|
||||
|
||||
# who: the supervisor of the packet's department; a lead, Q.C., the warehouse and Clerical are refused
|
||||
wes = fresh(shop("sup.wes"), pid)
|
||||
assert "short_approve" in wes["can"]
|
||||
for sam in ("lead.lou", "weld.andy", "qc.quinn", "wh.wade", "clerk.leo"):
|
||||
assert "short_approve" not in fresh(shop(sam), pid)["can"], sam
|
||||
refused(approve(shop, pkt, r1["id"], by=sam), 403, "forbidden", "Your access doesn't allow this step.")
|
||||
refused(approve(shop, pkt, r1["id"], by="admin.ann"), 400, "it_reason_required")
|
||||
# a signature is required; an unknown receipt is 404
|
||||
refused(approve(shop, pkt, r1["id"], sign=None), 400, "signature_required")
|
||||
refused(approve(shop, pkt, r1["id"] + 100000), 404, "not_found")
|
||||
|
||||
out = ok(approve(shop, pkt, r1["id"], note="Heat matches the issue ticket"))
|
||||
a = out["shorts"][0]["receipts"][0]["approval"]
|
||||
assert a["by"]["id"] == str(shop.uid("sup.wes")) and a["by"]["display_name"], a
|
||||
assert a["note"] == "Heat matches the issue ticket" and a["at"] and a["dept_code"] == "03"
|
||||
assert line_of(out, 3)["short"]["receipts"][0]["approval"]["note"] == "Heat matches the issue ticket"
|
||||
assert out["shorts_unapproved"] == 1 # the short is still open
|
||||
log = find_log(shop("sup.wes"), pid, "Short approved · item 3")
|
||||
assert log["note"] == "1 pc · heat HT-31 · Heat matches the issue ticket" and log["signed"] is True
|
||||
row = q1(shop.settings, "SELECT dept_code, signature_id FROM short_approvals WHERE receipt_id = %s", (r1["id"],))
|
||||
assert row[0] == "03" and row[1] is not None
|
||||
# one approval per receipt
|
||||
refused(approve(shop, pkt, r1["id"]), 409, "already_approved", "This receipt is already approved.")
|
||||
|
||||
# the warehouse receives the last piece: the short closes, the new receipt waits for approval
|
||||
out = receive(shop, out, 3, 1, "HT-32")
|
||||
assert out["shorts"][0]["closed_at"] is not None and line_of(out, 3)["short"] is None
|
||||
assert out["shorts_unapproved"] == 1 and fresh(shop("qc.quinn"), pid)["blocked"]["qc_accept"] == [MSG]
|
||||
r2 = out["shorts"][0]["receipts"][1]
|
||||
assert r2["heat"] == "HT-32" and r2["approval"] is None
|
||||
# Admin approves through the bypass with an IT reason
|
||||
out = ok(approve(shop, out, r2["id"], by="admin.ann", it_reason=IT))
|
||||
assert out["shorts_unapproved"] == 0 and "qc_accept" not in fresh(shop("qc.quinn"), pid)["blocked"]
|
||||
done = ok(accept(shop, fresh(shop("qc.quinn"), pid)))
|
||||
assert done["status"] == "complete"
|
||||
# an approval needs no open step: still open to the supervisor at complete, but nothing is left to approve
|
||||
refused(approve(shop, done, r2["id"]), 409, "already_approved")
|
||||
|
||||
|
||||
def test_accept_and_route_does_not_wait_for_the_shorts(shop: Shop) -> None:
|
||||
pkt = short_at_qc(shop)
|
||||
out = ok(accept_route(shop, pkt, "09"))
|
||||
assert out["status"] == "ready" and out["dept"]["code"] == "09" and out["shorts_unapproved"] == 2
|
||||
# the packet's department is now 09: the 03 supervisor no longer approves, a 09 supervisor would
|
||||
r1 = out["shorts"][0]["receipts"][0]
|
||||
refused(approve(shop, out, r1["id"]), 403, "forbidden")
|
||||
|
||||
|
||||
def test_file_waits_for_the_approval_and_the_filed_lock(shop: Shop) -> None:
|
||||
"""A packet completed before C7 with a receipt nobody approved: FILE shows and refuses it until approved."""
|
||||
pkt = at_complete(shop)
|
||||
pid = pkt["id"]
|
||||
with app_conn(shop.settings) as conn:
|
||||
line = conn.execute("SELECT id FROM bom_lines WHERE packet_id = %s AND item = 3", (pid,)).fetchone()[0]
|
||||
sid = conn.execute(
|
||||
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open, reason, closed_at)"
|
||||
" VALUES (%s, %s, 1, 0, 'On order', now()) RETURNING id",
|
||||
(pid, line),
|
||||
).fetchone()[0]
|
||||
rid = conn.execute(
|
||||
"INSERT INTO short_receipts (short_id, packet_id, qty, heat) VALUES (%s, %s, 1, 'HT-77') RETURNING id",
|
||||
(sid, pid),
|
||||
).fetchone()[0]
|
||||
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
|
||||
assert pkt["shorts_unapproved"] == 1 and pkt["blocked"]["file"] == [MSG]
|
||||
refused(file_packet(shop, pkt), 403, "precondition", MSG)
|
||||
[card] = ok(shop("clerk.clara").get(f"/api/packets?unit_id={pkt['unit']['id']}"))["packets"]
|
||||
assert card["shorts_unapproved"] == 1 and card["blocked"]["file"] == [MSG]
|
||||
ok(approve(shop, pkt, rid))
|
||||
out = ok(file_packet(shop, fresh(shop("clerk.clara"), pid)))
|
||||
assert out["status"] == "filed"
|
||||
# filed: nobody approves any more
|
||||
refused(approve(shop, out, rid, by="admin.ann", it_reason=IT), 409, "already_approved")
|
||||
with app_conn(shop.settings) as conn:
|
||||
rid2 = conn.execute(
|
||||
"INSERT INTO short_receipts (short_id, packet_id, qty, heat) VALUES (%s, %s, 1, 'HT-78') RETURNING id",
|
||||
(sid, pid),
|
||||
).fetchone()[0]
|
||||
refused(approve(shop, out, rid2), 403, "filed")
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Change C8 (Gitea issue 23): checklists per department.
|
||||
|
||||
The department supervisor edits their own department's list, Admin edits any, a lead, a worker and Q.C. do not. A
|
||||
save carries the version (`base` or If-Match) and is audited. A stage freezes its list at START, so a change reaches
|
||||
only stages not yet started. A department without its own list uses the default for its kind. Nothing here reads
|
||||
VISUAL or M-Files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from shophelpers import (
|
||||
PLAIN_KEYS,
|
||||
WELD_KEYS,
|
||||
Shop,
|
||||
add_weld,
|
||||
fresh,
|
||||
ok,
|
||||
one,
|
||||
open_shop,
|
||||
ready_packet,
|
||||
refused,
|
||||
set_filler,
|
||||
start,
|
||||
tick_all,
|
||||
to_station,
|
||||
)
|
||||
|
||||
from packetstation.domain import checklists
|
||||
from packetstation.settings import Settings
|
||||
|
||||
URL = "/api/checklists"
|
||||
NEW_LIST = [
|
||||
{"group": "Before welding", "key": "dwg", "text": "Drawing {drawing} Rev {rev} on the bench"},
|
||||
{"group": "Before welding", "key": "purge", "text": "Back purge set"},
|
||||
{"group": "After welding", "key": "vt", "text": "Visual done"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def keys(pkt: dict[str, Any]) -> list[str]:
|
||||
return [i["key"] for i in pkt["checklist_items"]]
|
||||
|
||||
|
||||
def put(shop: Shop, sam: str, dept: str, items: Any, base: int | None) -> Any:
|
||||
body: dict[str, Any] = {"items": items}
|
||||
if base is not None:
|
||||
body["base"] = base
|
||||
return shop(sam).put(f"{URL}/{dept}", json=body)
|
||||
|
||||
|
||||
def test_list_and_get(shop: Shop) -> None:
|
||||
rows = {r["dept_code"]: r for r in ok(shop("sup.wes").get(URL))}
|
||||
assert "03" in rows and "CL" not in rows and "QC" not in rows
|
||||
assert rows["03"]["editable"] is True and rows["03"]["weld"] is True and rows["03"]["custom"] is False
|
||||
assert rows["03"]["version"] == 0 and rows["03"]["dept_name"] == "Weld Parts"
|
||||
assert not any(r["editable"] for code, r in rows.items() if code != "03")
|
||||
assert all(r["editable"] for r in ok(shop("admin.ann").get(URL)))
|
||||
for sam in ("lead.lou", "weld.andy", "qc.quinn", "clerk.clara"):
|
||||
assert not any(r["editable"] for r in ok(shop(sam).get(URL))), sam
|
||||
got = ok(shop("qc.quinn").get(f"{URL}/03"))
|
||||
assert [i["key"] for i in got["items"]] == list(WELD_KEYS) and got["defaults"] == got["items"]
|
||||
assert got["editable"] is False and got["custom"] is False
|
||||
assert "{drawing}" in got["items"][0]["text"] # unfilled
|
||||
plain = ok(shop("sup.wes").get(f"{URL}/09"))
|
||||
assert [i["key"] for i in plain["items"]] == list(PLAIN_KEYS) and plain["editable"] is False
|
||||
refused(shop("sup.wes").get(f"{URL}/QC"), 404, "not_found")
|
||||
|
||||
|
||||
def test_who_may_save(shop: Shop) -> None:
|
||||
for sam in ("lead.lou", "weld.andy", "qc.quinn", "clerk.clara", "pm.paula"):
|
||||
refused(put(shop, sam, "03", NEW_LIST, 0), 403, "forbidden")
|
||||
refused(put(shop, "sup.wes", "09", NEW_LIST, 0), 403, "forbidden") # not his department
|
||||
out = ok(put(shop, "sup.wes", "03", NEW_LIST, 0))
|
||||
assert out["custom"] is True and out["version"] == 1 and [i["key"] for i in out["items"]] == ["dwg", "purge", "vt"]
|
||||
# Admin edits any department
|
||||
out = ok(put(shop, "admin.ann", "09", NEW_LIST, 0))
|
||||
assert out["custom"] is True and out["dept_code"] == "09"
|
||||
# the other departments keep the default for their kind
|
||||
assert ok(shop("sup.wes").get(f"{URL}/04"))["custom"] is False
|
||||
row = one(
|
||||
shop.settings,
|
||||
"SELECT count(*) FROM audit_events WHERE action = 'checklists.dept_updated' AND subject_id = 'checklist:03'",
|
||||
)
|
||||
assert row is not None and row[0] == 1
|
||||
|
||||
|
||||
def test_version_cas_and_validation(shop: Shop) -> None:
|
||||
ok(put(shop, "sup.wes", "03", NEW_LIST, 0))
|
||||
stale = refused(put(shop, "sup.wes", "03", NEW_LIST, 0), 409, "version_conflict")
|
||||
assert stale["current"]["version"] == 1 and stale["current"]["custom"] is True
|
||||
refused(put(shop, "sup.wes", "03", NEW_LIST, None), 428, "if_match_required")
|
||||
r = shop("sup.wes").put(f"{URL}/03", json={"items": NEW_LIST}, headers={"If-Match": '"1"'})
|
||||
assert ok(r)["version"] == 2
|
||||
bad = refused(put(shop, "sup.wes", "03", [{"group": "G", "key": "Bad-Key", "text": "t"}], 2), 422, "bad_checklist")
|
||||
assert "key" in bad["detail"]
|
||||
refused(put(shop, "sup.wes", "03", [], 2), 422, "bad_checklist")
|
||||
refused(put(shop, "sup.wes", "03", [{"group": "G", "key": "x", "text": "{stamp}"}], 2), 422, "bad_checklist")
|
||||
# null sets the department back to the default for its kind
|
||||
back = ok(put(shop, "sup.wes", "03", None, 2))
|
||||
assert back["custom"] is False and back["version"] == 3 and [i["key"] for i in back["items"]] == list(WELD_KEYS)
|
||||
|
||||
|
||||
def test_a_change_reaches_only_stages_not_started(shop: Shop) -> None:
|
||||
andy = shop("weld.andy")
|
||||
started = start(shop, to_station(shop, ready_packet(shop, drawing="D-7007", rev="A")))
|
||||
assigned = to_station(shop, ready_packet(shop), station="Stall 2")
|
||||
waiting = ready_packet(shop)
|
||||
ok(put(shop, "sup.wes", "03", NEW_LIST, 0))
|
||||
|
||||
# the started stage keeps the default it started with
|
||||
assert keys(fresh(andy, started["id"])) == list(WELD_KEYS)
|
||||
refused(andy.put(f"/api/packets/{started['id']}/stage/checklist/purge", json={"done": True}), 422, "invalid_key")
|
||||
# the stages not yet started read the new list
|
||||
assert keys(fresh(andy, assigned["id"])) == ["dwg", "purge", "vt"]
|
||||
assert keys(fresh(shop("sup.wes"), waiting["id"])) == ["dwg", "purge", "vt"]
|
||||
|
||||
# START freezes: a later change does not reach it
|
||||
later = start(shop, assigned)
|
||||
ok(put(shop, "sup.wes", "03", [*NEW_LIST, {"group": "After welding", "key": "tag", "text": "Tagged"}], 1))
|
||||
assert keys(fresh(andy, later["id"])) == ["dwg", "purge", "vt"]
|
||||
assert keys(fresh(shop("sup.wes"), waiting["id"])) == ["dwg", "purge", "vt", "tag"]
|
||||
stage = one(
|
||||
shop.settings,
|
||||
"SELECT checklist_template FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL",
|
||||
(later["id"],),
|
||||
)
|
||||
assert stage is not None and [i["key"] for i in stage[0]] == ["dwg", "purge", "vt"]
|
||||
|
||||
# completeness reads the frozen list: three ticks, a weld and the filler let it submit
|
||||
out = tick_all(shop, later, keys=("dwg", "purge", "vt"))
|
||||
out = set_filler(shop, add_weld(shop, out))
|
||||
assert "submit" in fresh(andy, out["id"])["can"]
|
||||
# the started packet still needs the default keys
|
||||
assert "submit" not in fresh(andy, started["id"])["can"]
|
||||
|
||||
# the data book's checklist section reads the frozen list too
|
||||
with shop_conn(shop) as conn:
|
||||
t = checklists.stage_templates(conn, stage_id(shop, later["id"]), "03")
|
||||
assert [i["key"] for i in t["weld"]] == ["dwg", "purge", "vt"]
|
||||
live = checklists.stage_templates(conn, None, "03")
|
||||
assert [i["key"] for i in live["weld"]] == ["dwg", "purge", "vt", "tag"]
|
||||
|
||||
|
||||
def stage_id(shop: Shop, packet_id: int) -> int:
|
||||
row = one(shop.settings, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
|
||||
assert row is not None
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def shop_conn(shop: Shop) -> Any:
|
||||
from pshelpers import app_conn
|
||||
|
||||
return app_conn(shop.settings)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Phase 7 close and file through the API: the VISUAL close and the filing as every role, the feed's open work
|
||||
orders (the note suffix, the 409 and the confirm), the data book filed with the seal, the reopen that keeps the
|
||||
broken seal and its data book, the refile with a second seal, a failed data book build that files nothing, and a
|
||||
hand packet with no feed state.
|
||||
orders (a block on both routes since C5; the feed closes a VISUAL packet), the data book filed with the seal, the
|
||||
reopen that keeps the broken seal and its data book, the refile with a second seal, a failed data book build that
|
||||
files nothing, and a hand packet with no feed state.
|
||||
|
||||
The feed state is written by SQL in the shape feed/apply.py writes it. Nothing here reads VISUAL or M-Files."""
|
||||
|
||||
@@ -132,6 +132,7 @@ def test_vclosed_and_file_refused_outside_complete(shop: Shop) -> None:
|
||||
|
||||
|
||||
def test_shipping_route_files_without_the_visual_close(shop: Shop) -> None:
|
||||
"""A hand packet on the Shipping route: no feed state, no Clerical close."""
|
||||
pkt = at_complete(shop, route="shipping")
|
||||
r = shop("clerk.clara").post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True})
|
||||
assert r.status_code in (403, 409) and r.json()["code"] in ("wrong_status", "precondition"), r.text
|
||||
@@ -140,10 +141,18 @@ def test_shipping_route_files_without_the_visual_close(shop: Shop) -> None:
|
||||
assert out["seal"]["databook"]["pages"] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the feed's open work orders
|
||||
# ---------------------------------------------------------------- the feed's open work orders (C5)
|
||||
|
||||
MSG_FEED_CLOSES = "The VISUAL feed marks this job closed when it sees every work order closed."
|
||||
MSG_FEED_WAIT = "Filing waits until the VISUAL feed sees the job closed."
|
||||
|
||||
|
||||
def test_vclose_check_and_the_open_wo_note(shop: Shop) -> None:
|
||||
def open_msg(wos: str) -> str:
|
||||
return f"VISUAL still shows work orders open on this packet: {wos}. Filing waits until VISUAL closes them."
|
||||
|
||||
|
||||
def test_visual_packet_open_in_visual_cannot_be_closed_or_filed(shop: Shop) -> None:
|
||||
"""C5: Clerical cannot mark a VISUAL packet closed; the open work orders block the file, and say why."""
|
||||
pkt = at_complete(shop)
|
||||
pid = pkt["id"]
|
||||
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", st="R")])
|
||||
@@ -151,54 +160,58 @@ def test_vclose_check_and_the_open_wo_note(shop: Shop) -> None:
|
||||
vc = view["vclose_check"]
|
||||
assert vc["open_wos"] == ["L670002/001"] and vc["closed_at"] is None and vc["observed_at"] is not None
|
||||
assert vc["agrees"] is True # neither closed
|
||||
pkt = vclose(shop, view)
|
||||
assert pkt["vclose_check"]["agrees"] is False
|
||||
row = find_log(shop("clerk.clara"), pid, "Job closed in VISUAL")
|
||||
assert row["note"] == "L670001, L670002 · VISUAL still shows open: L670002/001", row
|
||||
assert "set_vclosed" not in view["can"] and "file" not in view["can"]
|
||||
assert view["blocked"]["file"] == [open_msg("L670002/001")]
|
||||
refused(shop("clerk.clara").post(f"/api/packets/{pid}/vclosed", json={"closed": True}), 403, "wrong_status")
|
||||
refused(file_packet(shop, view), 403, "precondition", open_msg("L670002/001"))
|
||||
|
||||
|
||||
def test_vclose_check_closed_by_the_feed(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
pid = pkt["id"]
|
||||
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", st="C")], closed=True)
|
||||
vc = fresh(shop("clerk.clara"), pid)["vclose_check"]
|
||||
assert vc["open_wos"] == [] and vc["closed_at"] is not None and vc["agrees"] is False
|
||||
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
|
||||
assert pkt["vclose_check"]["agrees"] is True
|
||||
assert find_log(shop("clerk.clara"), pid, "Job closed in VISUAL")["note"] == "L670001, L670002"
|
||||
out = ok(file_packet(shop, pkt))
|
||||
assert out["seal"]["open_wos"] is None
|
||||
view = fresh(shop("clerk.clara"), pid)
|
||||
vc = view["vclose_check"]
|
||||
assert vc["open_wos"] == [] and vc["closed_at"] is not None and vc["agrees"] is True
|
||||
assert view["vclosed"]["at"] is not None and view["vclosed"]["by"] is None # the feed, not a person
|
||||
assert "file" in view["can"] and "file" not in view["blocked"]
|
||||
out = ok(file_packet(shop, view))
|
||||
assert out["status"] == "filed" and out["seal"]["open_wos"] is None
|
||||
|
||||
|
||||
def test_vclose_check_before_the_feed_looked(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
set_feed_legs(shop.settings, pkt["id"], None)
|
||||
vc = fresh(shop("clerk.clara"), pkt["id"])["vclose_check"]
|
||||
view = fresh(shop("clerk.clara"), pkt["id"])
|
||||
vc = view["vclose_check"]
|
||||
assert vc is not None and vc["observed_at"] is None and vc["open_wos"] == []
|
||||
assert view["blocked"]["file"] == [MSG_FEED_WAIT]
|
||||
|
||||
|
||||
def test_file_with_open_work_orders_needs_the_confirm(shop: Shop) -> None:
|
||||
pkt = at_complete(shop)
|
||||
@pytest.mark.parametrize("route", ["databook", "shipping"])
|
||||
def test_file_blocked_while_visual_shows_work_orders_open(shop: Shop, route: str) -> None:
|
||||
"""C5: a block on both routes, not a warning. The old confirm_open_wos is ignored. A close set before C5
|
||||
(vclosed by hand on a VISUAL packet) does not pass while the feed shows work orders open."""
|
||||
pkt = at_complete(shop, route=route)
|
||||
pid = pkt["id"]
|
||||
set_feed_legs(shop.settings, pid, [leg("L670001", st="R"), leg("L670002", lot="7A", st="R")])
|
||||
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
|
||||
with_hand_close = route == "databook"
|
||||
if with_hand_close:
|
||||
q1(shop.settings, "UPDATE packets SET vclosed_at = now() WHERE id = %s RETURNING id", (pid,))
|
||||
pkt = fresh(shop("clerk.clara"), pid)
|
||||
sigs = q1(shop.settings, "SELECT count(*) FROM signatures")[0]
|
||||
body = refused(file_packet(shop, pkt), 409, "open_work_orders")
|
||||
assert body["work_orders"] == ["L670001/001", "L670002/7A"]
|
||||
assert body["detail"] == (
|
||||
"VISUAL still shows 2 work orders open on this packet: L670001/001, L670002/7A. File anyway?"
|
||||
)
|
||||
msg = open_msg("L670001/001, L670002/7A")
|
||||
refused(file_packet(shop, pkt), 403, "precondition", msg)
|
||||
refused(file_packet(shop, pkt, confirm_open_wos=True), 403, "precondition", msg)
|
||||
# nothing written, no signature taken
|
||||
assert q1(shop.settings, "SELECT count(*) FROM signatures")[0] == sigs
|
||||
assert q1(shop.settings, "SELECT count(*) FROM packet_seals WHERE packet_id = %s", (pid,))[0] == 0
|
||||
assert fresh(shop("clerk.clara"), pid)["status"] == "complete"
|
||||
refused(file_packet(shop, pkt, confirm_open_wos=False), 409, "open_work_orders")
|
||||
out = ok(file_packet(shop, pkt, confirm_open_wos=True, note="Customer pickup today"))
|
||||
assert out["status"] == "filed" and out["seal"]["open_wos"] == ["L670001/001", "L670002/7A"]
|
||||
row = q1(shop.settings, "SELECT open_wos FROM packet_seals WHERE packet_id = %s", (pid,))
|
||||
assert row[0] == ["L670001/001", "L670002/7A"]
|
||||
note = find_log(shop("clerk.clara"), pid, "Filed in the data book")["note"]
|
||||
assert note == "Customer pickup today · Filed with work orders open in VISUAL: L670001/001, L670002/7A", note
|
||||
# the feed sees VISUAL closed: the packet files, on either route
|
||||
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", lot="7A", st="X")], closed=True)
|
||||
out = ok(file_packet(shop, fresh(shop("clerk.clara"), pid), confirm_open_wos=True, note="Customer pickup today"))
|
||||
assert out["status"] == "filed" and out["seal"]["open_wos"] is None
|
||||
assert find_log(shop("clerk.clara"), pid, "Filed in the data book")["note"] == "Customer pickup today"
|
||||
|
||||
|
||||
def test_cancelled_legs_do_not_count_as_open(shop: Shop) -> None:
|
||||
|
||||
@@ -204,7 +204,12 @@ def test_mine_per_screen(world: FilterWorld) -> None:
|
||||
assert ids(world, "multi.mia", flags="mine") == clerical # Clerical comes first in the screen order
|
||||
stall1 = shop.stall("03", "Stall 1")
|
||||
assert ids(world, "weld.andy", flags="mine", mine_screen="stall", mine_station_id=stall1) == world.ids("a1")
|
||||
assert ids(world, "weld.andy", flags="mine", mine_screen="stall") == set() # no station picked
|
||||
# change C11: the stall's Mine is the packets assigned to me, at any station; a picked station is not needed
|
||||
assert ids(world, "weld.andy", flags="mine", mine_screen="stall") == world.ids("a1")
|
||||
stall2 = shop.stall("03", "Stall 2")
|
||||
assert ids(world, "weld.andy", flags="mine", mine_screen="stall", mine_station_id=stall2) == world.ids("a1")
|
||||
assert ids(world, "dup.dave", flags="mine", mine_screen="stall") == world.ids("a2")
|
||||
assert ids(world, "admin.ann", flags="mine", mine_screen="stall") == set() # nothing assigned to her
|
||||
assert ids(world, "qc.quinn", flags="mine", mine_screen="qc") == world.ids("q1")
|
||||
assert ids(world, "pm.paula", flags="mine") == world.every
|
||||
assert ids(world, "admin.ann", flags="mine", mine_screen="admin") == world.every
|
||||
@@ -367,6 +372,29 @@ def test_stall_board_filters(world: FilterWorld) -> None:
|
||||
assert get_ok(andy, "/api/shop/stall", station_id=stall1, status="review")["mine"] == []
|
||||
|
||||
|
||||
def test_stall_mine_across_stations(world: FilterWorld) -> None:
|
||||
"""Change C11: GET /api/stall/mine, the packets assigned to the signed-in person, each with its station."""
|
||||
shop = world.shop
|
||||
andy = get_ok(shop("weld.andy"), "/api/stall/mine")
|
||||
assert andy["station"] is None and andy["empty"] is None and "facets" in andy
|
||||
assert [c["id"] for c in andy["mine"]] == [world.k["a1"]] and andy["submitted"] == []
|
||||
card = andy["mine"][0]
|
||||
assert card["station_id"] == shop.stall("03", "Stall 1") and card["station_name"] == "Stall 1"
|
||||
dave = get_ok(shop("dup.dave"), "/api/stall/mine")
|
||||
got = [(c["id"], c["station_name"], c["status"]) for c in dave["mine"]]
|
||||
assert got == [(world.k["a2"], "Stall 2", "in_progress")]
|
||||
# the find bar narrows
|
||||
assert get_ok(shop("weld.andy"), "/api/stall/mine", q="nothing-matches-this")["mine"] == []
|
||||
# nobody assigned: the empty state names the supervisor
|
||||
for sam in ("lead.lou", "qc.quinn"):
|
||||
none = get_ok(shop(sam), "/api/stall/mine")
|
||||
assert none["mine"] == [] and none["submitted"] == []
|
||||
assert none["empty"] == "Nothing is assigned to you now. Your supervisor assigns packets to you."
|
||||
# a station in the request is a find bar narrowing, never a pick that widens
|
||||
other = get_ok(shop("weld.andy"), "/api/stall/mine", station_id=shop.stall("03", "Stall 2"))
|
||||
assert other["mine"] == []
|
||||
|
||||
|
||||
def test_warehouse_board_filters_keep_the_kpis(world: FilterWorld) -> None:
|
||||
wade = world.shop("wh.wade")
|
||||
base = get_ok(wade, "/api/warehouse/board")
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
copy of sheet 1, a heat table, two welds, an MTR PDF and an MTR PNG, a short with a receipt, a packet document, a
|
||||
Q.C. folder document, the closed stage and the Q.C. accept.
|
||||
|
||||
The index (seven sections in order, the expected rows), the page count, the cover, the index page, the void
|
||||
sheet, the traceability page, the warning rows, the metadata, the stamp on every page and its signature, and the
|
||||
stamp per mode. Nothing here reads VISUAL or M-Files."""
|
||||
The index (six sections in order, the expected rows; no history pages since change C6), the page count, the cover,
|
||||
the index page, the void sheet, the traceability page, the warning rows, the metadata, the stamp on every page and
|
||||
its signature, and the stamp per mode. Nothing here reads VISUAL or M-Files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,6 +21,7 @@ import pytest
|
||||
from closehelpers import (
|
||||
HEATS,
|
||||
RECEIPT_HEAT,
|
||||
add_doc,
|
||||
builder_conn,
|
||||
delete_asset_file,
|
||||
flat,
|
||||
@@ -43,7 +44,7 @@ pytestmark = needs_pg
|
||||
|
||||
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
|
||||
SEAL = {"hash": "ab12cd34ef56" + "0" * 52, "key_id": "k1234567", "sealed_at": NOW}
|
||||
SECTIONS = [1, 2, 3, 4, 5, 6, 7]
|
||||
SECTIONS = [1, 2, 3, 4, 5, 6]
|
||||
SIG_RE = re.compile(r"stamp ([0-9a-f]{16})")
|
||||
DUMP_DIR = os.environ.get("DATABOOK_DUMP_DIR") # off unless set
|
||||
|
||||
@@ -98,13 +99,14 @@ def test_data_book_preview(shop: Shop) -> None:
|
||||
assert len(reader.pages) == built.pages
|
||||
rows = index(built)
|
||||
|
||||
# the seven sections, in order, each present
|
||||
# the six sections, in order, each present
|
||||
seen = [r["section"] for r in rows]
|
||||
assert seen == sorted(seen) and sorted(set(seen)) == SECTIONS, seen
|
||||
|
||||
s1 = docs_in(rows, 1)
|
||||
assert sum("approved drawing" in d for d in s1) == 2, s1
|
||||
assert any(d.startswith("SUPERSEDED - VOID by CO-1") for d in s1), s1
|
||||
assert s1[-1] == "Change order record", s1
|
||||
drawing_rows = [r for r in rows if r["section"] == 1 and "approved drawing" in str(r["doc"])]
|
||||
assert all(r["ref"] == "D-5001" and r["rev"] == "RB" for r in drawing_rows), drawing_rows
|
||||
|
||||
@@ -131,7 +133,8 @@ def test_data_book_preview(shop: Shop) -> None:
|
||||
mtr_rows = [r for r in rows if r["section"] == 6]
|
||||
assert mtr_rows[0]["ref"] == HEATS[1]
|
||||
|
||||
assert docs_in(rows, 7) == ["Packet record history"]
|
||||
# change C6: no history pages; the book is the one copy and goes to the customer
|
||||
assert not any("history" in str(r["doc"]).lower() for r in rows), rows
|
||||
|
||||
# pages: the parts plus the front matter (cover and index); page numbers point into the book
|
||||
parts = sum(int(r["count"] or 0) for r in rows)
|
||||
@@ -167,8 +170,10 @@ def test_data_book_preview(shop: Shop) -> None:
|
||||
qc_page = next(r for r in rows if r["section"] == 4)["page"]
|
||||
qc_lines = texts[int(qc_page) - 1].splitlines()
|
||||
assert "AM" not in qc_lines and "PM" not in qc_lines, qc_lines # the date and time stay on one line
|
||||
hist_page = next(r for r in rows if r["section"] == 7)["page"]
|
||||
assert "CO-1 " in flat(texts[int(hist_page) - 1]) and "documents changed" in flat(texts[int(hist_page) - 1])
|
||||
co_page = next(r for r in rows if r["doc"] == "Change order record")["page"]
|
||||
assert "CO-1 " in flat(texts[int(co_page) - 1]) and "documents changed" in flat(texts[int(co_page) - 1])
|
||||
whole = flat(" ".join(texts))
|
||||
assert "RECORD HISTORY" not in whole.upper() and "Every action taken on this packet" not in whole
|
||||
|
||||
# metadata
|
||||
meta = reader.metadata
|
||||
@@ -230,6 +235,15 @@ def test_data_book_with_an_itp_document_has_no_itp_warning(shop: Shop) -> None:
|
||||
assert not any(r["doc"] == "No signed ITP filed for this unit" for r in s5)
|
||||
|
||||
|
||||
def test_data_book_carries_an_ncr_document(shop: Shop) -> None:
|
||||
"""Change C2 asks the NCR document Q.C. uploads to be in the data book: every packet document kind is in
|
||||
section 5, the NCR kind included."""
|
||||
pkt = rich_complete(shop, mtrs=False)
|
||||
add_doc(shop, pkt, title="NCR 2026-0017", kind="ncr")
|
||||
s5 = docs_in(index(build(shop, pkt["id"])), 5)
|
||||
assert any(d.startswith("NCR") and "NCR 2026-0017" in d for d in s5), s5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- phase 8: against the old build's book
|
||||
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ def test_find_bar_boards_and_pm_reports(shop: Shop) -> None:
|
||||
assert qc["kpis"]["waiting"] == 0 and "facets" in qc
|
||||
stall = ok(shop("weld.andy").get("/api/shop/stall", params={"station_id": shop.stall(), "flags": "mine"}))
|
||||
assert [c["id"] for c in stall["mine"]] == [p_st["id"]]
|
||||
mine = ok(shop("weld.andy").get("/api/stall/mine")) # change C11: no station picked
|
||||
assert [c["id"] for c in mine["mine"]] == [p_st["id"]] and mine["mine"][0]["station_id"] == shop.stall()
|
||||
|
||||
# Project Management
|
||||
pmc = shop("pm.paula")
|
||||
@@ -111,7 +113,8 @@ def test_find_bar_boards_and_pm_reports(shop: Shop) -> None:
|
||||
assert [t["key"] for t in ag["tables"]] == ["aging_steps", "aging_open", "aging_history"]
|
||||
hr = ok(pmc.get("/api/pm/hours"))
|
||||
over_rows = hr["tables"][3]["rows"]
|
||||
assert [r["pid"] for r in over_rows] == [p_st["id"]], hr
|
||||
# change C1: the timer is over its target but there are no ticket hours, so it is not over target
|
||||
assert over_rows == [], hr
|
||||
ql = ok(pmc.get("/api/pm/quality"))
|
||||
assert [len(t["rows"]) for t in ql["tables"]][:4] == [1, 1, 1, 1], ql
|
||||
fd = ok(pmc.get("/api/pm/find", params={"sort": "project", "project_id": project_id}))
|
||||
@@ -134,4 +137,4 @@ def test_find_bar_boards_and_pm_reports(shop: Shop) -> None:
|
||||
kinds = [x[0] for x in conn.execute("SELECT kind FROM print_log ORDER BY id")]
|
||||
acts = [x[0] for x in conn.execute("SELECT action FROM audit_events WHERE action = 'report exported'")]
|
||||
assert kinds == ["report-csv", "report-pdf"] and len(acts) == 2
|
||||
assert shop("clerk.clara").get("/api/pm/overview").status_code == 200
|
||||
assert shop("clerk.clara").get("/api/pm/overview").status_code == 403 # change C10: PM and Admin only
|
||||
|
||||
@@ -119,7 +119,7 @@ def test_overview_summary(world: PmWorld) -> None:
|
||||
slow = [x for x in lines if x.startswith("Slowest step right now: ")]
|
||||
assert slow and slow[0].startswith(f'Slowest step right now: "{STEP_LABEL["wh_hold"]}", averaging 5')
|
||||
assert "Needs attention: 1 warehouse hold, 1 open change order, 1 open short, 1 Q.C. reject." in lines
|
||||
assert "1 packet is over target hours." in lines
|
||||
assert "1 packet is over target hours on the VISUAL tickets." in lines
|
||||
|
||||
|
||||
def test_overview_attention(world: PmWorld) -> None:
|
||||
@@ -217,7 +217,7 @@ def test_hours(world: PmWorld) -> None:
|
||||
assert len(dept) == 1 and "Weld Parts" in dept[0]["cells"][0]
|
||||
# counted: the running packet, and every packet past review with target hours
|
||||
assert dept[0]["cells"][1] == "6"
|
||||
assert dept[0]["cells"][3] == "3.2" # ticket hours: the running packet's VISUAL rows only
|
||||
assert dept[0]["cells"][3] == "5.2" # ticket hours: the running packet's VISUAL rows only
|
||||
assert dept[0]["cells"][4] == "27.0" # 6 x 4.5
|
||||
assert dept[0]["cells"][7] == "1"
|
||||
over = table(out, "hours_over")["rows"]
|
||||
@@ -226,7 +226,8 @@ def test_hours(world: PmWorld) -> None:
|
||||
# 4 h stored plus the running span (2 h when the world was built, more by now)
|
||||
timer = float(cells[3])
|
||||
assert 6.0 <= timer < 8.0, cells
|
||||
assert cells[4] == "3.2" and cells[5] == "4.5" and cells[6] == f"+{timer - 4.5:.1f} h"
|
||||
# over by the ticket hours (C1), not the timer
|
||||
assert cells[4] == "5.2" and cells[5] == "4.5" and cells[6] == "+0.7 h"
|
||||
assert "Andy Welder" in cells[1] or "Stall 1" in cells[1]
|
||||
workers = {r["cells"][0]: r for r in table(out, "hours_worker")["rows"]}
|
||||
assert set(workers) == {"Andy Welder", "—"}
|
||||
@@ -343,13 +344,23 @@ def test_scope_follows_the_project(world: PmWorld) -> None:
|
||||
assert all(table(q, k)["rows"] == [] for k in TAB_TABLES["quality"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sam", ["pm.paula", "clerk.clara", "weld.andy"])
|
||||
def test_every_signed_in_role_reads(world: PmWorld, sam: str) -> None:
|
||||
@pytest.mark.parametrize("sam", ["pm.paula", "admin.ann"])
|
||||
def test_pm_and_admin_read(world: PmWorld, sam: str) -> None:
|
||||
for what in ENDPOINTS:
|
||||
r = world.shop(sam).get(f"/api/pm/{what}")
|
||||
assert r.status_code == 200, (sam, what, r.text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sam", ["clerk.clara", "weld.andy", "qc.quinn", "wh.wade", "sup.wes"])
|
||||
def test_other_roles_are_refused(world: PmWorld, sam: str) -> None:
|
||||
# change C10: PS-ProjectMgmt and Admin only
|
||||
for what in [*ENDPOINTS, "export.csv", "report.pdf"]:
|
||||
r = world.shop(sam).get(f"/api/pm/{what}")
|
||||
assert r.status_code == 403, (sam, what, r.text)
|
||||
me = get_ok(world.shop(sam), "/api/auth/me")
|
||||
assert "pm" not in me.get("screens", []), me
|
||||
|
||||
|
||||
def test_unsigned_is_refused_and_nothing_writes(world: PmWorld) -> None:
|
||||
c = anon(world.shop)
|
||||
for what in ENDPOINTS:
|
||||
|
||||
@@ -178,7 +178,8 @@ def test_export_needs_a_session(world: PmWorld) -> None:
|
||||
c = anon(world.shop)
|
||||
assert c.get("/api/pm/export.csv", params={"table": "all"}).status_code == 401
|
||||
assert c.get("/api/pm/report.pdf").status_code == 401
|
||||
assert world.shop("clerk.clara").get("/api/pm/export.csv", params={"table": "jobs"}).status_code == 200
|
||||
assert world.shop("clerk.clara").get("/api/pm/export.csv", params={"table": "jobs"}).status_code == 403
|
||||
assert world.shop("clerk.clara").get("/api/pm/report.pdf").status_code == 403
|
||||
|
||||
|
||||
# ================================================================ PDF
|
||||
|
||||
@@ -376,7 +376,12 @@ PRECONDITION_CASES: list[tuple[Action, str, str, dict[str, Any], dict[str, Any],
|
||||
(A.QC_ACCEPT, "qc", "qc", {"qc_missing": ("weld exam",)}, {}, "qc_missing_empty"),
|
||||
(A.QC_ACCEPT, "qc", "qc", {}, {"routed_to": "scrap"}, "route_given"),
|
||||
(A.QC_ACCEPT_AND_ROUTE, "qc", "qc", {}, {"dept": "WH"}, "target_dept_given"),
|
||||
(A.FILE, "complete", "clerical", {"vclosed": False}, {}, "vclosed_if_databook"),
|
||||
(A.FILE, "complete", "clerical", {"vclosed": False}, {}, "closed_in_visual"),
|
||||
(A.FILE, "complete", "clerical", {"itp_open": True}, {}, "itp_complete"),
|
||||
(A.FILE, "complete", "clerical", {"shorts_unapproved": 1}, {}, "shorts_approved"),
|
||||
(A.QC_ACCEPT, "qc", "qc", {"shorts_unapproved": 2}, {}, "shorts_approved"),
|
||||
(A.QC_ACCEPT, "qc", "qc", {"ncr_required": True}, {}, "ncr_given"),
|
||||
(A.QC_ACCEPT_AND_ROUTE, "qc", "qc", {"ncr_required": True}, {"ncr": "NCR-26-1"}, "ncr_given"),
|
||||
(A.WH_SEND_BACK, "warehouse", "warehouse", {}, {"reason": "short"}, "reason_given"),
|
||||
(A.QC_REJECT, "qc", "qc", {}, {"reason": None}, "reason_given"),
|
||||
(A.REMOVE, "draft", "clerical", {}, {"reason": " x "}, "reason_given"),
|
||||
@@ -396,7 +401,7 @@ def test_each_precondition_fails_alone(
|
||||
x = {**FULL_EXTRA, **extra}
|
||||
assert check(ROLES[role], state(status), action, extra=FULL_EXTRA).ok
|
||||
d = check(ROLES[role], s, action, extra=x)
|
||||
want = "reason_required" if name == "reason_given" else "precondition"
|
||||
want = {"reason_given": "reason_required", "ncr_given": "ncr_required"}.get(name, "precondition")
|
||||
assert (d.ok, d.code) == (False, want)
|
||||
if name == "qc_missing_empty":
|
||||
assert d.why == "Q.C. still needs: weld exam."
|
||||
@@ -423,6 +428,76 @@ def test_vclosed_only_needed_for_the_data_book_route() -> None:
|
||||
assert check(ROLES["clerical"], state("complete", routed_to="shipping", vclosed=False), A.FILE).ok
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["databook", "shipping"])
|
||||
def test_visual_packet_files_only_once_the_feed_saw_it_closed(route: str) -> None:
|
||||
"""C5: a VISUAL packet waits for the feed on both routes; open work orders are named."""
|
||||
clerk = ROLES["clerical"]
|
||||
vis = state("complete", source_kind="visual", routed_to=route)
|
||||
assert check(clerk, vis, A.FILE).ok
|
||||
d = check(clerk, dataclasses.replace(vis, vclosed=False), A.FILE)
|
||||
assert (d.code, d.why) == ("precondition", "Filing waits until the VISUAL feed sees the job closed.")
|
||||
opened = dataclasses.replace(vis, visual_open_wos=("L670001/001", "L670002/7A"))
|
||||
d = check(clerk, opened, A.FILE)
|
||||
assert d.code == "precondition"
|
||||
assert d.why == (
|
||||
"VISUAL still shows work orders open on this packet: L670001/001, L670002/7A."
|
||||
" Filing waits until VISUAL closes them."
|
||||
)
|
||||
assert can(clerk, opened)[1][A.FILE] == [d.why]
|
||||
# never bypassed
|
||||
assert check(ROLES["admin"], opened, A.FILE, it_reason=IT).code == "precondition"
|
||||
|
||||
|
||||
def test_file_blocked_shows_every_close_reason() -> None:
|
||||
s = state("complete", vclosed=False, itp_open=True, shorts_unapproved=1)
|
||||
allowed, blocked = can(ROLES["clerical"], s)
|
||||
assert A.FILE not in allowed
|
||||
assert blocked[A.FILE] == [MESSAGES["closed_in_visual"], MESSAGES["itp_complete"], MESSAGES["shorts_approved"]]
|
||||
assert MESSAGES["itp_complete"] == "Complete the unit's ITP first. Q.C. signs it off on the ITP page."
|
||||
assert MESSAGES["shorts_approved"] == "Every short must be received and approved by the supervisor first."
|
||||
|
||||
|
||||
def test_ncr_given_is_input_and_skipped_by_can() -> None:
|
||||
"""C2: after a reject with no NCR, the accept carries a well-formed NCR. can() does not block on it."""
|
||||
s = state("qc", ncr_required=True)
|
||||
allowed, blocked = can(ROLES["qc"], s)
|
||||
assert {A.QC_ACCEPT, A.QC_ACCEPT_AND_ROUTE} <= set(allowed) and A.QC_ACCEPT not in blocked
|
||||
d = check(ROLES["qc"], s, A.QC_ACCEPT, extra=FULL_EXTRA)
|
||||
assert (d.ok, d.code, d.why) == (False, "ncr_required", "Enter the NCR number of the reject first.")
|
||||
assert check(ROLES["qc"], s, A.QC_ACCEPT, extra={**FULL_EXTRA, "ncr": "NCR-2026-0021"}).ok
|
||||
assert check(ROLES["qc"], s, A.QC_ACCEPT_AND_ROUTE, extra={**FULL_EXTRA, "ncr": "ncr-2026-0021"}).ok
|
||||
# not needed when no reject is waiting for one
|
||||
assert check(ROLES["qc"], state("qc"), A.QC_ACCEPT, extra=FULL_EXTRA).ok
|
||||
|
||||
|
||||
def test_shorts_block_the_accept_but_not_the_accept_and_route() -> None:
|
||||
s = state("qc", shorts_unapproved=1)
|
||||
_, blocked = can(ROLES["qc"], s)
|
||||
assert blocked[A.QC_ACCEPT] == [MESSAGES["shorts_approved"]]
|
||||
assert A.QC_ACCEPT_AND_ROUTE not in blocked
|
||||
assert check(ROLES["qc"], s, A.QC_ACCEPT_AND_ROUTE, extra=FULL_EXTRA).ok
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", STATUSES)
|
||||
def test_short_approve_is_the_department_supervisor(status: str) -> None:
|
||||
"""C7: the supervisor of the packet's department (a lead is not enough), any status but filed; Admin through
|
||||
the bypass with an IT reason. A signature is required."""
|
||||
s = state(status)
|
||||
d = check(ROLES["sup03"], s, A.SHORT_APPROVE)
|
||||
if status == "filed":
|
||||
assert d.code == "filed"
|
||||
return
|
||||
assert d.ok and d.signature == "Approve a received short"
|
||||
assert A.SHORT_APPROVE in can(ROLES["sup03"], s)[0]
|
||||
for role in ("lead03", "worker03", "welder", "warehouse_sup", "qc_sup", "clerical_sup", "pm", "worker06"):
|
||||
d = check(ROLES[role], s, A.SHORT_APPROVE)
|
||||
assert (d.code, d.why) == ("forbidden", "Your access doesn't allow this step."), role
|
||||
assert check(P(["shop:06:supervisor"]), s, A.SHORT_APPROVE).code == "forbidden"
|
||||
assert check(ROLES["admin"], s, A.SHORT_APPROVE).code == "it_reason_required"
|
||||
a = check(ROLES["admin"], s, A.SHORT_APPROVE, it_reason=IT)
|
||||
assert a.ok and a.admin_bypass
|
||||
|
||||
|
||||
def test_co_staged_if_dwg_change() -> None:
|
||||
from packetstation.policy.preconditions import co_staged_if_dwg_change
|
||||
|
||||
@@ -468,6 +543,16 @@ def test_set_vclosed_needs_the_data_book_route() -> None:
|
||||
assert check(ROLES["qc"], state("complete"), A.SET_VCLOSED).code == "forbidden"
|
||||
|
||||
|
||||
def test_set_vclosed_is_for_a_hand_packet_only() -> None:
|
||||
"""C5: the feed closes a VISUAL packet; Clerical confirms a hand packet only."""
|
||||
for route in ("databook", "shipping"):
|
||||
d = check(ROLES["clerical"], state("complete", source_kind="visual", routed_to=route), A.SET_VCLOSED)
|
||||
assert (d.code, d.why) == (
|
||||
"wrong_status",
|
||||
"The VISUAL feed marks this job closed when it sees every work order closed.",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", STATUSES)
|
||||
def test_set_databook_at_is_clerical_or_qc(status: str) -> None:
|
||||
s = state(status)
|
||||
|
||||
@@ -541,7 +541,7 @@ def test_api_co_hold_blocks_accept_not_reject(shop: Shop) -> None:
|
||||
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))
|
||||
out = ok(step(shop("qc.quinn"), mine, "qc_reject", reason="Weld 2 undercut too deep", ncr=NCR, sign=SIGN))
|
||||
assert out["status"] == "review"
|
||||
|
||||
|
||||
|
||||
@@ -289,16 +289,20 @@ def test_reject_with_reason_only(shop: Shop) -> None:
|
||||
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:
|
||||
def test_reject_needs_a_reason_even_with_an_ncr(shop: Shop) -> None:
|
||||
"""C2: an NCR number no longer stands in for the reason."""
|
||||
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, REASON, 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")
|
||||
refused(reject(shop, pkt, ncr=NCR), 400, "reason_required", "Give a reason of at least 8 characters.")
|
||||
refused(reject(shop, pkt, "bad", ncr=NCR), 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)
|
||||
out = ok(reject(shop, pkt, REASON, ncr=" ncr-2026-0021 "))
|
||||
_rejected(shop, pkt, out, f"{NCR} · {REASON}", f"{NCR} · {REASON}")
|
||||
assert out["qc_last"]["ncr"] == NCR and out["qc_last"]["rejected_welds"] == []
|
||||
assert out["ncr_required"] is False
|
||||
row = one(
|
||||
shop.settings, "SELECT ncr FROM qc_inspections WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pkt["id"],)
|
||||
)
|
||||
@@ -361,7 +365,7 @@ def test_qc_home(shop: Shop) -> None:
|
||||
assert "itp_hold_count" in card
|
||||
assert home["columns"]["accepted"] == [] and home["columns"]["rejected"] == []
|
||||
|
||||
ok(reject(shop, to_reject, ncr=NCR))
|
||||
ok(reject(shop, to_reject, REASON, 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}
|
||||
|
||||
@@ -55,6 +55,11 @@ APPEND_INSERTS = {
|
||||
"short_receipts": (
|
||||
"INSERT INTO short_receipts (short_id, packet_id, qty, by) VALUES (%(short)s, %(packet)s, 1, %(user)s)"
|
||||
),
|
||||
# C7: after short_receipts, whose row it approves
|
||||
"short_approvals": (
|
||||
"INSERT INTO short_approvals (receipt_id, short_id, packet_id, dept_code, by)"
|
||||
" SELECT id, short_id, packet_id, '03', %(user)s FROM short_receipts WHERE short_id = %(short)s LIMIT 1"
|
||||
),
|
||||
"packet_transitions": (
|
||||
"INSERT INTO packet_transitions (packet_id, action, by) VALUES (%(packet)s, 'send_to_warehouse', %(user)s)"
|
||||
),
|
||||
|
||||
@@ -420,12 +420,16 @@ def test_checklist_templates_from_settings(shop: Shop) -> None:
|
||||
out = set_filler(shop, add_weld(shop, out))
|
||||
assert "submit" in fresh(andy, out["id"])["can"]
|
||||
|
||||
# a malformed row falls back to the defaults in code
|
||||
# change C8: the started stage keeps the list it started with, whatever the settings say now
|
||||
custom_keys = ["dwg", "wps", "vt"]
|
||||
set_templates(s, {"weld": "nonsense"})
|
||||
pkt = fresh(andy, pkt["id"])
|
||||
assert [i["key"] for i in pkt["checklist_items"]] == list(WELD_KEYS)
|
||||
assert [i["key"] for i in fresh(andy, pkt["id"])["checklist_items"]] == custom_keys
|
||||
# a malformed row falls back to the defaults in code for a stage not yet started
|
||||
later = to_station(shop, ready_packet(shop))
|
||||
assert [i["key"] for i in fresh(andy, later["id"])["checklist_items"]] == list(WELD_KEYS)
|
||||
run(s, "DELETE FROM settings WHERE key = 'checklist_templates'")
|
||||
assert [i["key"] for i in fresh(andy, pkt["id"])["checklist_items"]] == list(WELD_KEYS)
|
||||
assert [i["key"] for i in fresh(andy, later["id"])["checklist_items"]] == list(WELD_KEYS)
|
||||
assert [i["key"] for i in fresh(andy, pkt["id"])["checklist_items"]] == custom_keys
|
||||
|
||||
|
||||
# ================================================================ stage fields
|
||||
|
||||
Reference in New Issue
Block a user