完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)

汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
2026-09-19 13:11:04 +08:00
父节点 729d90576f
当前提交 1f8d5b658a
共修改 367 个文件,包含 21079 行新增和 12287 行删除
+5 -2
查看文件
@@ -93,8 +93,11 @@ async function readJson<T>(response: { ok(): boolean; status(): number; text():
return parsed.data;
}
export async function login(page: Page, user: E2EUser) {
await page.goto("/login");
export async function login(page: Page, user: E2EUser, { reuseLoginPage = false } = {}) {
// Opt-in for a sequential UI flow whose preceding logout already reached
// the canonical page without a redirect. Form and portal checks stay unchanged.
const current = new URL(page.url());
if (!reuseLoginPage || current.pathname !== "/login" || current.search || current.hash) await page.goto("/login");
await page.getByTestId(TEST_IDS.login.identifier).fill(user.identifier);
await page.getByTestId(TEST_IDS.login.password).fill(user.password);
await page.getByTestId(TEST_IDS.login.submit).click();
@@ -10,15 +10,15 @@ export class ReimbursementDetailPageObject {
}
get overview() {
return this.page.getByTestId(TEST_IDS.reimbursement.overview);
return this.page.getByTestId("reimbursement-desktop-workspace");
}
get summary() {
return this.page.getByTestId(TEST_IDS.reimbursement.summary);
return this.overview.locator("section.workbench-hero");
}
get itemsTable() {
return this.page.getByTestId(TEST_IDS.reimbursement.items);
return this.page.getByTestId("reimbursement-expense-items");
}
get addItemBtn() {
@@ -34,7 +34,7 @@ export class ReimbursementDetailPageObject {
}
get invoices() {
return this.page.getByTestId(TEST_IDS.reimbursement.invoices);
return this.page.getByTestId("approval-invoices");
}
get invoiceAddBtn() {
@@ -53,10 +53,6 @@ export class ReimbursementDetailPageObject {
return this.page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit);
}
get erpExport() {
return this.page.getByTestId(TEST_IDS.reimbursement.erpExport);
}
get submitBtn() {
return this.page.getByTestId(TEST_IDS.reimbursement.submit);
}
@@ -79,7 +75,7 @@ export class ReimbursementDetailPageObject {
}
async uploadInvoicePdf(fileName: string, buffer: Buffer) {
const invoiceCards = this.invoices.locator(":scope > div");
const invoiceCards = this.invoices.getByTestId("approval-invoice-evidence");
const invoiceCountBeforeUpload = await invoiceCards.count();
await this.invoiceAddBtn.click();
await expect(this.invoiceDialog).toBeVisible();
@@ -103,20 +99,50 @@ export class ReimbursementDetailPageObject {
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
}
await expect(invoiceCards).toHaveCount(invoiceCountBeforeUpload + 1, { timeout: 90_000 });
await this.page.keyboard.press("Escape");
// OCR selects the invoice; binding remains an explicit user action.
const invoiceRow = this.invoiceDialog.locator("label").filter({ hasText: fileName });
await expect(invoiceRow.getByRole("checkbox")).toBeChecked({ timeout: 90_000 });
const target = invoiceRow.getByRole("combobox", { name: "添加到哪条明细" });
const existingItems = target.locator('option:not([value=""])');
await expect(existingItems).toHaveCount(1);
await target.selectOption((await existingItems.getAttribute("value"))!);
await this.invoiceDialog.getByRole("button", { name: "添加到报销单", exact: true }).click();
await expect(invoiceCards).toHaveCount(invoiceCountBeforeUpload + 1);
await expect(this.itemsTable.getByTestId("approval-item-evidence")).toHaveCount(1);
await expect(this.invoiceDialog).toHaveCount(0);
return invoiceId;
}
// 当前 seed 保证 precheck 不触发 warn/manual_review;若 seed 变更,此处需相应调整。
async submitReimbursement(acceptConfirm = true) {
const dialogPromise = acceptConfirm
? this.page.waitForEvent("dialog", { timeout: 2_000 }).then((dialog) => dialog.accept()).catch(() => undefined)
: Promise.resolve();
async addItem(amount = "10.00") {
await this.addItemBtn.click();
const expenseType = this.page.getByTestId("reimbursement-item-expense-type");
await expect(expenseType.locator("option")).not.toHaveCount(1);
const value = await expenseType.locator("option").evaluateAll(options =>
(options as HTMLOptionElement[]).find(option => option.textContent === "邮寄快递")?.value);
expect(value, "synthetic fixture requires the seeded courier expense type").toBeTruthy();
await expenseType.selectOption(value!);
await this.page.getByTestId("reimbursement-item-amount").fill(amount);
await this.page.getByTestId("reimbursement-item-description").fill("合成快递费用,低于票面金额部分为非报销支出");
await this.page.getByTestId("reimbursement-item-save").click();
await expect(this.page.getByTestId("reimbursement-item-editor")).toHaveCount(0);
await expect(this.itemsTable.getByTestId("approval-item-evidence")).toHaveCount(1);
}
async submitReimbursement() {
const precheck = this.page.waitForResponse(response =>
new URL(response.url()).pathname.endsWith("/precheck") && response.request().method() === "POST");
const submitted = this.page.waitForResponse(response =>
new URL(response.url()).pathname.endsWith("/submit") && response.request().method() === "POST");
await this.submitBtn.click();
await dialogPromise;
const check = await precheck;
expect(check.ok()).toBe(true);
const data = (await check.json()).data;
if (data?.decision === "warn" || data?.decision === "manual_review" || data?.standardChecks?.some((item: {exceeded: boolean; controlMode: string}) => item.exceeded && item.controlMode === "warn")) {
await this.page.getByRole("dialog", { name: "确认提交报销单?" }).getByRole("button", { name: "确认提交", exact: true }).click();
}
const response = await submitted;
expect(response.ok(), `synthetic submit HTTP ${response.status()}`).toBe(true);
return (await response.json()).data as {currentTaskId: string};
}
}
@@ -0,0 +1,254 @@
import { createRequire } from "node:module";
import { expect, type APIRequestContext, type Page } from "@playwright/test";
import { ADMIN, EMPLOYEE, FINANCE, FINANCE_MGR, MANAGER, E2E_BASE_URL, apiGetData, apiPostData, createAuthenticatedApiClient, login, logout } from "./auth";
import { ReimbursementDetailPageObject } from "./pageObjects/ReimbursementDetailPage";
const HR = { identifier: "[email protected]", password: "HrPassword1234", name: "HR" };
// Public fictitious accounts; exclusively written to the run-owned local database.
const payer = "000011112222";
const payee = "000033334444";
const requireServer = createRequire(new URL("../../../../server/package.json", import.meta.url));
async function syntheticPdf(label: string) {
const pdf = await requireServer("pdf-lib").PDFDocument.create();
pdf.addPage().drawText(label);
return Buffer.from(await pdf.save());
}
async function prepareBankFacts(api: APIRequestContext, employeeId: string) {
expect(new URL(E2E_BASE_URL).hostname).toBe("127.0.0.1");
expect(process.env.QA_ENVIRONMENT_CONTEXT, "financial fixture needs the managed disposable environment").toBeTruthy();
const banks = await apiGetData<{items: Array<{id: string; code: string}>}>(api, "/api/bank-institutions");
const bank = banks.items.find(item => item.code === "CCB");
expect(bank).toBeTruthy();
const entities = await apiGetData<{items: Array<{id: string}>}>(api, "/api/master-data/legal-entities?enabled=true");
expect(entities.items).toHaveLength(1);
const legalEntityId = entities.items[0].id;
const created = await api.post("/api/payment-accounts", { data: { legalEntityId, bankInstitutionId: bank!.id, accountName: "合成企业付款账户", accountNumber: payer } });
expect([201, 409]).toContain(created.status());
const accounts = await apiGetData<{items: Array<{legalEntity: {id: string}; enabled: boolean; accountName: string; accountNumberMasked: string}>}>(api, "/api/payment-accounts");
const active = accounts.items.filter(item => item.enabled && item.legalEntity.id === legalEntityId);
expect(active).toHaveLength(1);
expect(active[0].accountName).toBe("合成企业付款账户");
expect(active[0].accountNumberMasked).toContain("2222");
const hr = await createAuthenticatedApiClient(HR);
try {
const response = await hr.api.patch(`/api/users/${employeeId}/sensitive`, {data: {bankInstitutionId: bank!.id, bankAccount: payee}});
expect(response.ok(), `synthetic bank setup HTTP ${response.status()}`).toBe(true);
} finally { await hr.dispose(); }
}
async function manualReceipt(api: APIRequestContext, reviewer: APIRequestContext, batchId: string, itemId: string, attachmentId: string) {
const base = `/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/manual-verification`;
type Detail = { item: {version: number; state: string; pagesExpected: number} };
await expect.poll(async () => (await apiGetData<Detail>(api, base)).item.state, {timeout: 60_000}).toBe("ocr_failed");
const { item: detail } = await apiGetData<Detail>(api, base);
expect(detail.pagesExpected).toBe(1);
const { item: proposal } = await apiPostData<{item: {id: string; version: number; status: string}}>(api, base, {
expectedVersion: detail.version, reason: "合成回单逐项核对,不代表真实银行付款", pages: [{pageNumber: 1, itemId,
fields: Object.entries({小写金额: "10.00", 币别: "CNY", 付款人账号: payer, 收款人账号: payee, 凭证号: `SYNTHETIC-${batchId}`, 单据状态: "交易成功"}).map(([Name, Value]) => ({Name, Value}))}],
});
expect(proposal.status).toBe("pending_review");
const reviewPath = `${base}/${proposal.id}/review`;
const body = {expectedVersion: proposal.version, decision: "approve", reason: "合成独立复核"};
expect((await api.post(reviewPath, {data: body})).status()).toBe(403);
await apiPostData(reviewer, reviewPath, body);
}
export async function reimbursementPaymentFlow(page: Page) {
const started = Date.now();
const checkpoint = (stage: string) => console.info("[reimbursement-payment-flow]", JSON.stringify({ stage, elapsedMs: Date.now() - started }));
checkpoint("api-auth:start");
const detail = new ReimbursementDetailPageObject(page);
const clients = await Promise.all([ADMIN, EMPLOYEE, FINANCE, FINANCE_MGR, MANAGER].map(user => createAuthenticatedApiClient(user)));
const [admin, employee, finance, financeMgr, manager] = clients;
checkpoint("api-auth:complete");
try {
checkpoint("bank-fixture:start");
await prepareBankFacts(financeMgr.api, employee.me.id);
checkpoint("employee-ui:start");
await login(page, EMPLOYEE, { reuseLoginPage: true });
await page.goto("/reimbursements");
const creating = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/reimbursements");
await page.getByTestId("reimbursements-create").click();
await page.getByRole("menuitem", {name: "空白报销"}).click();
const created = await creating;
expect(created.ok()).toBe(true);
const reimbursementId = (await created.json()).data.id as string;
await detail.assertLoaded();
await detail.addItem();
checkpoint("invoice-upload:start");
const invoicePdf = await syntheticPdf(`Synthetic invoice ${reimbursementId}`);
const invoiceId = await detail.uploadInvoicePdf(`synthetic-${reimbursementId}.pdf`, invoicePdf);
await expect(page.locator(`#reimbursement-invoice-${invoiceId}`)).toBeVisible();
{
checkpoint("invoice-view:start");
const signedResponse = page.waitForResponse(response =>
response.request().method() === "POST" && new URL(response.url()).pathname === `/api/invoice-pool/${invoiceId}/signed-url`);
const popupReady = page.waitForEvent("popup");
await page.locator(`#reimbursement-invoice-${invoiceId}`).getByRole("button", {name: /^查看发票/}).click();
const popup = await popupReady;
checkpoint("invoice-view:popup");
// Headless PDF navigation can remain about:blank. Verify the UI-created
// signed URL serves the exact valid PDF; native viewer rendering is separate.
const signed = await signedResponse;
expect(signed.ok()).toBe(true);
const url = (await signed.json()).data.signedUrl as string;
expect(new URL(url).origin).toBe(new URL(E2E_BASE_URL).origin);
expect(new URL(url).pathname).toBe(`/api/invoice-pool/${invoiceId}/file`);
const file = await page.request.get(url);
expect(file.ok()).toBe(true);
expect(file.headers()["content-type"]).toContain("application/pdf");
expect(file.headers()["content-disposition"]).toContain("inline");
expect(await file.body()).toEqual(invoicePdf);
await popup.close();
}
checkpoint("reimbursement-submit:start");
const submitted = await detail.submitReimbursement();
const reimbursement = await apiGetData<{formNumber: string; status: string}>(employee.api, `/api/reimbursements/${reimbursementId}`);
expect(reimbursement.status).toBe("reviewing");
await expect(detail.workflowProgress).toContainText(MANAGER.name);
await expect(detail.workflowProgress).toContainText("财务复核");
await logout(page, EMPLOYEE.name);
let taskId: string | null = submitted.currentTaskId;
for (const [user, client, role] of [[MANAGER, manager, "manager"], [FINANCE, finance, "finance"]] as const) {
checkpoint(`${role}-approval:start`);
expect(taskId).toBeTruthy();
const task = await apiGetData<{task: {stepId: string; assigneeId: string | null; assigneeRole: string | null}}>(client.api, `/api/workflow/tasks/${taskId}`);
expect(task.task.stepId).toBe(role === "manager" ? "approval_1_manager" : "approval_2_finance");
if (role === "manager") expect(task.task.assigneeId).toBe(manager.me.id);
else expect(task.task.assigneeRole).toBe("finance");
await login(page, user, { reuseLoginPage: true });
await page.goto(`/workflow/tasks/${taskId}`);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
const approving = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/workflow/tasks/${taskId}/approve`);
await page.getByTestId("workflow-task-approve").click();
await expect(page.getByTestId("workflow-action-confirm-dialog")).toBeVisible();
await page.getByTestId("workflow-action-confirm-submit").click();
const approved = await approving;
expect(approved.ok()).toBe(true);
const result = (await approved.json()).data;
expect(result.reimbursementStatus).toBe(role === "manager" ? "reviewing" : "approved");
taskId = result.nextTaskId;
if (role === "finance") {
expect(taskId).toBeNull();
// No reload: covers the reported finance progress refresh regression.
const financeStage = page.getByTestId("reimbursement-approval-processing-progress").locator("li").filter({hasText: "财务复核"});
await Promise.all([
expect(financeStage).toContainText("通过"),
expect(financeStage).toContainText(FINANCE.name),
expect(financeStage).not.toContainText("处理中"),
expect(financeStage).toHaveAttribute("data-state", "completed"),
]);
}
// The same finance actor next creates the batch. Keep that real session
// until the next role change instead of immediately logging it in again.
if (role !== "finance") await logout(page, user.name);
checkpoint(`${role}-approval:complete`);
}
await expect.poll(async () => {
const response = await employee.api.get(`/api/reimbursements/${reimbursementId}`);
const payload = await response.json();
if (!response.ok()) {
const code = /^[A-Z][A-Z0-9_]{0,40}$/.test(payload.error?.code ?? "") ? payload.error.code : "UNKNOWN";
const audit = await admin.api.get("/api/audit-logs?pageSize=100");
const history = audit.ok() ? (await audit.json()).data?.items ?? [] : [];
const allowed = new Set(["REFRESH_REUSE_DETECTED", "AUTH_PASSWORD_CHANGED", "AUTH_PASSWORD_RESET", "SYSTEM_USER_ROLES_SET", "SYSTEM_USER_DISABLED"]);
console.info("[reimbursement-payment-flow]", JSON.stringify({
stage: "employee-after-approval:error", status: response.status(), code,
auditStatus: audit.status(),
authEvents: history.filter((item: { targetId: string; action: string; createdAt: string }) =>
item.targetId === employee.me.id && allowed.has(item.action) && Date.parse(item.createdAt) >= started)
.map((item: { action: string; createdAt: string }) => ({ action: item.action, elapsedMs: Date.parse(item.createdAt) - started })),
}));
throw new Error(`Employee approval read HTTP ${response.status()} ${code}`);
}
return payload.data.status;
}).toBe("approved");
checkpoint("payment-batch-create:start");
await page.goto("/payment-batches");
const createPanel = page.getByTestId("payment-batch-create-panel");
await createPanel.getByTestId("payment-batch-candidate-search").fill(reimbursement.formNumber);
const candidate = createPanel.getByTestId("payment-batch-candidate-row").filter({hasText: reimbursement.formNumber});
await expect(candidate).toBeVisible();
await candidate.getByRole("checkbox").check();
await createPanel.getByTestId("payment-batch-default-channel").selectOption("bank");
const creatingBatch = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/payment-batches");
await createPanel.getByRole("button", {name: "创建并提交复核"}).click();
const createdBatch = await creatingBatch;
expect(createdBatch.ok()).toBe(true);
expect(createdBatch.request().postDataJSON()).toMatchObject({entityType: "reimbursement", entityIds: [reimbursementId], defaultPaymentChannel: "bank"});
const batch = (await createdBatch.json()).data.item as {id: string; name: string; items: Array<{id: string}>};
await page.goto(`/payment-batches?id=${batch.id}`);
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("0/1");
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", {name: "复核通过"})).toHaveCount(0);
expect((await finance.api.post(`/api/payment-batches/${batch.id}/review`, {data: {}})).status()).toBe(403);
await logout(page, FINANCE.name);
checkpoint("payment-batch-review:start");
await login(page, MANAGER, { reuseLoginPage: true });
await page.goto(`/payment-batches?id=${batch.id}`);
await page.getByTestId("payment-batch-action-panel").getByRole("button", {name: "复核通过"}).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("独立复核");
await page.getByTestId("action-confirm-submit").click();
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("1/1");
await logout(page, MANAGER.name);
await login(page, FINANCE, { reuseLoginPage: true });
await page.goto(`/payment-batches?id=${batch.id}`);
expect((await finance.api.post(`/api/payment-batches/${batch.id}/execute`, {data: {}})).status()).toBe(409);
checkpoint("receipt-upload:start");
const receiptPdf = await syntheticPdf(`Synthetic receipt ${batch.id}`);
const uploading = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/payment-batches/${batch.id}/receipt-attachments`);
await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({name: "synthetic-receipt.pdf", mimeType: "application/pdf", buffer: receiptPdf});
const upload = await uploading;
expect(upload.ok()).toBe(true);
const attachmentId = (await upload.json()).data.items[0].id as string;
const batchDetail = await apiGetData<{item: {items: Array<{id: string}>}}>(finance.api, `/api/payment-batches/${batch.id}`);
checkpoint("receipt-manual-review:start");
await manualReceipt(finance.api, manager.api, batch.id, batchDetail.item.items[0].id, attachmentId);
checkpoint("payment-execution:start");
await page.reload();
const executing = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/payment-batches/${batch.id}/execute`);
await page.getByTestId("payment-batch-action-panel").getByRole("button", {name: "登记付款完成"}).click();
await page.getByTestId("action-confirm-submit").click();
expect((await executing).ok()).toBe(true);
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("已付款");
const receipts = page.getByTestId("payment-batch-receipt-panel");
await Promise.all([
expect(page.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0),
expect(page.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0),
expect(page.getByTestId("payment-batch-item-payment-readonly")).toBeVisible(),
expect(receipts.getByRole("button", {name: "上传银行回单", exact: true})).toHaveCount(0),
expect(receipts.getByRole("button", {name: "作废", exact: true})).toHaveCount(0),
expect(receipts.getByRole("button", {name: "下载", exact: true})).toBeVisible(),
]);
checkpoint("downstream-audit:start");
await Promise.all([["REIMBURSEMENT_PAYMENT_MARKED", reimbursementId], ["PAYMENT_BATCH_RECEIPT_UPLOADED", batch.id], ["PAYMENT_RECEIPT_MANUAL_REVIEWED", batch.id]].map(async ([action, targetId]) => {
const audit = await apiGetData<{items: Array<{targetId: string; extra: {idempotent?: boolean; attachmentIds?: string[]}}>}>(admin.api, `/api/audit-logs?action=${action}&pageSize=50`);
const event = audit.items.find(item => item.targetId === targetId);
expect(event, `${action} audit required`).toBeTruthy();
if (action === "REIMBURSEMENT_PAYMENT_MARKED") expect(event!.extra.idempotent).toBe(false);
if (action === "PAYMENT_BATCH_RECEIPT_UPLOADED") expect(event!.extra.attachmentIds).toContain(attachmentId);
}));
await page.goto("/ap-operations");
await page.getByRole("tab", {name: /财务台账/}).click();
await expect(page.getByTestId("finance-ledger-table")).toContainText(reimbursement.formNumber);
await expect(page.getByTestId("finance-ledger-table")).toContainText("已付款");
await page.goto(`/reimbursements/${reimbursementId}`);
await detail.assertLoaded();
expect((await apiGetData<{status: string}>(employee.api, `/api/reimbursements/${reimbursementId}`)).status).toBe("paid");
await expect(detail.summary).toContainText("已支付");
await expect(detail.workflowProgress).toContainText("已付款");
await expect(page.getByTestId("reimbursement-pay")).toHaveCount(0);
const invoice = await apiGetData<{status: string; archivedAt: string; linkedReimbursementId: string}>(employee.api, `/api/invoice-pool/${invoiceId}`);
expect(invoice).toMatchObject({status: "archived", linkedReimbursementId: reimbursementId});
expect(invoice.archivedAt).toBeTruthy();
expect((await financeMgr.api.get(`/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`)).status()).toBe(404);
await logout(page, FINANCE.name);
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login/);
} finally {
checkpoint("api-dispose:start");
await Promise.all(clients.map(client => client.dispose()));
checkpoint("api-dispose:complete");
}
}
@@ -0,0 +1,28 @@
export type VisualPageIdentity = { route: string; role: string; viewport: string };
export type VisualPageCompletion = VisualPageIdentity & { actualRoute: string };
export type VisualGroupResult = { id: string; status: string };
const pageKey = (page: VisualPageIdentity) => JSON.stringify([page.viewport, page.role, page.route]);
// Only fully audited pages belong in completed; arrival alone is insufficient.
export function visualCoverageProblems(
planned: VisualPageIdentity[],
completed: VisualPageCompletion[],
expectedGroups: string[],
groups: VisualGroupResult[],
) {
const problems: string[] = [];
const plannedKeys = planned.map(pageKey);
const completedKeys = completed.map(pageKey);
if (!plannedKeys.length) problems.push("empty-plan");
if (new Set(plannedKeys).size !== plannedKeys.length) problems.push("duplicate-plan");
if (new Set(completedKeys).size !== completedKeys.length) problems.push("duplicate-completion");
if (plannedKeys.some(key => !completedKeys.includes(key))) problems.push("missing-page");
if (completedKeys.some(key => !plannedKeys.includes(key))) problems.push("unexpected-page");
if (completed.some(page => page.actualRoute !== page.route)) problems.push("wrong-arrival");
if (!expectedGroups.length || new Set(expectedGroups).size !== expectedGroups.length) problems.push("invalid-group-plan");
if (new Set(groups.map(group => group.id)).size !== groups.length) problems.push("duplicate-group");
if (expectedGroups.some(id => !groups.some(group => group.id === id && group.status === "passed"))) problems.push("incomplete-group");
if (groups.some(group => !expectedGroups.includes(group.id))) problems.push("unexpected-group");
return problems;
}