完善借款、编号、付款与预算并发及验收(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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -325,6 +325,11 @@ async function mockSyntheticApis(page: Page, actor: SyntheticActor, scenario: Mo
|
||||
await fulfillJson(route, { days: 30, total: 0, items: [] });
|
||||
return;
|
||||
}
|
||||
if (pathName === `/api/contracts-registry/${CONTRACT_ITEM.id}` && request.method() === "GET") {
|
||||
await fulfillJson(route, { item: { ...CONTRACT_ITEM, terminatedAt: null, terminatedReason: null,
|
||||
terminatedBy: null, terminator: null, documentRefs: [], history: [] } });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/contracts-registry") {
|
||||
await fulfillJson(route, { items: scenario.contracts ?? [] });
|
||||
return;
|
||||
@@ -400,6 +405,22 @@ test("只有付款复核职责的经理看不到新建批次", async ({ page })
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
|
||||
async function expectNoContractWriteActions(page: Page) {
|
||||
for (const name of ["新增合同", "编辑", "续约", "归档"]) {
|
||||
const controls = page.getByRole("button", { name, exact: true })
|
||||
.or(page.getByRole("link", { name, exact: true }))
|
||||
.or(page.getByRole("menuitem", { name, exact: true }));
|
||||
expect(await controls.count(), `readonly contract leaked action: ${name}`).toBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
test("合同只读动作断言能发现新增编辑续约归档泄露", async ({ page }) => {
|
||||
for (const name of ["新增合同", "编辑", "续约", "归档"]) {
|
||||
await page.setContent(`<main><button>${name}</button></main>`);
|
||||
await expect(expectNoContractWriteActions(page)).rejects.toThrow(/readonly contract leaked action/);
|
||||
}
|
||||
});
|
||||
|
||||
test("合同只读人员看不到新增编辑续约和归档动作", async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
|
||||
const pageErrors = capturePageErrors(page);
|
||||
@@ -411,20 +432,68 @@ test("合同只读人员看不到新增编辑续约和归档动作", async ({ pa
|
||||
await expect(page.getByRole("heading", { level: 1, name: "合同", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "新增合同", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0);
|
||||
await expectNoContractWriteActions(page);
|
||||
|
||||
const contractRow = page
|
||||
.getByTestId("contracts-registry-table")
|
||||
.getByRole("row")
|
||||
.filter({ hasText: CONTRACT_ITEM.contractNo });
|
||||
await expect(contractRow).toBeVisible();
|
||||
await contractRow.getByRole("button", { name: "更多", exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "查看项目", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "续约", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "归档", exact: true })).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/contracts-registry$/);
|
||||
await contractRow.getByRole("link", { name: "查看详情", exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/contracts-registry/${CONTRACT_ITEM.id}$`));
|
||||
await expect(page.getByTestId("contract-detail-workspace")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { level: 1, name: CONTRACT_ITEM.contractNo, exact: true })).toBeVisible();
|
||||
await expectNoContractWriteActions(page);
|
||||
await expect(page.getByRole("link", { name: "返回合同列表", exact: true })).toBeVisible();
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
|
||||
test("合同管理身份在同一生效合同上可见新增编辑续约归档", async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
|
||||
const pageErrors = capturePageErrors(page);
|
||||
const actor = { ...CONTRACT_READER_ACTOR, roleCodes: ["admin"] };
|
||||
await mockSyntheticApis(page, actor, { contracts: [CONTRACT_ITEM] });
|
||||
await login(page, actor);
|
||||
await page.goto("/contracts-registry");
|
||||
await expect(page.getByRole("link", { name: "新增合同", exact: true })).toBeVisible();
|
||||
const row = page.getByTestId("contracts-registry-table").getByRole("row").filter({ hasText: CONTRACT_ITEM.contractNo });
|
||||
await row.getByRole("link", { name: "查看详情", exact: true }).click();
|
||||
await expect(page.getByTestId("contract-detail-workspace")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { level: 1, name: CONTRACT_ITEM.contractNo, exact: true })).toBeVisible();
|
||||
for (const name of ["编辑", "续约", "归档"]) {
|
||||
await expect(page.getByRole("button", { name, exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name, exact: true }).first()).toBeEnabled();
|
||||
}
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
|
||||
for (const action of ["新增合同", "编辑", "续约", "归档"]) {
|
||||
test(`合同真实只读页面反例能拦截泄露动作:${action}`, async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
|
||||
await mockSyntheticApis(page, CONTRACT_READER_ACTOR, { contracts: [CONTRACT_ITEM] });
|
||||
await login(page, CONTRACT_READER_ACTOR);
|
||||
await page.goto("/contracts-registry");
|
||||
await expect(page.getByTestId("contracts-registry-table")).toContainText(CONTRACT_ITEM.contractNo);
|
||||
if (action !== "新增合同") {
|
||||
const row = page.getByTestId("contracts-registry-table").getByRole("row").filter({ hasText: CONTRACT_ITEM.contractNo });
|
||||
await row.getByRole("link", { name: "查看详情", exact: true }).click();
|
||||
await expect(page.getByTestId("contract-detail-workspace")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { level: 1, name: CONTRACT_ITEM.contractNo, exact: true })).toBeVisible();
|
||||
}
|
||||
await expectNoContractWriteActions(page);
|
||||
await page.evaluate(name => {
|
||||
const control = document.createElement(name === "新增合同" ? "a" : "button");
|
||||
if (control instanceof HTMLAnchorElement) control.href = "/contracts-registry/new";
|
||||
control.textContent = name;
|
||||
control.dataset.qaCounterexample = "contract-action";
|
||||
document.querySelector("main")!.append(control);
|
||||
}, action);
|
||||
await expect(expectNoContractWriteActions(page)).rejects.toThrow(`readonly contract leaked action: ${action}`);
|
||||
await page.locator('[data-qa-counterexample="contract-action"]').evaluate(node => node.remove());
|
||||
await expectNoContractWriteActions(page);
|
||||
});
|
||||
}
|
||||
|
||||
test("无票据上传权限的兼容角色直达票据页时失败关闭", async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
|
||||
const pageErrors = capturePageErrors(page);
|
||||
@@ -624,3 +693,76 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次",
|
||||
});
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
|
||||
test("编号兼容:付款候选完整搜索、新旧批次和混合报销明细保持编号与实体对应", async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "仅本地合成编号,不访问线上");
|
||||
const codes = ["BX-202609-0002", "BX-202609-0010", "BX-202609-10000", "BX-20260917-0198"];
|
||||
const candidates = codes.map((businessNo, index) => ({
|
||||
...PAYMENT_CANDIDATE, entityId: `11111111-1111-4111-8111-${String(index + 1).padStart(12, "0")}`, businessNo,
|
||||
}));
|
||||
const queries: string[] = [];
|
||||
let created: unknown;
|
||||
await mockSyntheticApis(page, FINANCE_ACTOR, {
|
||||
paymentCandidates: candidates, onCandidateQuery: query => queries.push(query.get("q") ?? ""),
|
||||
onCreatePaymentBatch: payload => { created = payload; },
|
||||
});
|
||||
const base = {
|
||||
name: "合成编号兼容批次", status: "draft", entityType: "reimbursement", totalAmount: "5122.00", highRisk: false,
|
||||
defaultPaymentDate: null, defaultPaymentChannel: "cash", createdBy: FINANCE_ACTOR.id,
|
||||
paymentAccount: null, paymentOrderExportCount: 0, canExportPaymentOrder: false,
|
||||
paymentOrderExportBlockers: [], processingProgress: { stages: [] },
|
||||
reviewRequirement: { completedReviewCount: 0, requiredReviewCount: 1, nextStep: "first_review" },
|
||||
};
|
||||
const batches = [
|
||||
{ ...base, id: "33333333-3333-4333-8333-333333333333", batchNo: "PB-20260917-001", sources: candidates.slice(-1) },
|
||||
{ ...base, id: CREATED_PAYMENT_BATCH_ID, batchNo: "PB-2026-0001", sources: candidates },
|
||||
];
|
||||
await page.route(url => url.pathname === "/api/payment-batches", async route => {
|
||||
if (route.request().method() !== "GET") return route.fallback();
|
||||
await fulfillJson(route, { items: batches });
|
||||
});
|
||||
for (const batch of batches) {
|
||||
await page.route(`**/api/payment-batches/${batch.id}`, route => fulfillJson(route, { item: {
|
||||
...batch, items: batch.sources.map((source, index) => ({
|
||||
id: `${batch.id}-item-${index}`, entityId: source.entityId, entityCode: source.businessNo,
|
||||
entityTitle: "合成报销", amount: source.amount, currencyCode: "CNY", status: "pending",
|
||||
paymentChannelOverride: null, paymentDateOverride: null, paymentVoucherNoOverride: null,
|
||||
failureCode: null, failureMessage: null, expenseItems: [], expenseItemsTotal: "0.00", openRiskFlags: [],
|
||||
})),
|
||||
} }));
|
||||
await page.route(`**/api/payment-batches/${batch.id}/receipt-attachments`, route => fulfillJson(route, { items: [] }));
|
||||
}
|
||||
await login(page, FINANCE_ACTOR);
|
||||
await page.goto("/payment-batches");
|
||||
const search = page.getByTestId("payment-batch-candidate-search");
|
||||
for (const source of [candidates[0], candidates[3]]) {
|
||||
await search.fill(source.businessNo);
|
||||
await expect.poll(() => queries.at(-1)).toBe(source.businessNo);
|
||||
await expect(page.getByTestId("payment-batch-candidate-row").filter({ hasText: source.businessNo })).toBeVisible();
|
||||
}
|
||||
await search.fill("");
|
||||
for (const source of candidates) {
|
||||
await page.getByTestId("payment-batch-candidate-row").filter({ hasText: source.businessNo }).getByRole("checkbox").check();
|
||||
}
|
||||
await page.getByTestId("payment-batch-default-channel").selectOption("cash");
|
||||
await page.getByRole("button", { name: "创建并提交复核", exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`id=${CREATED_PAYMENT_BATCH_ID}`));
|
||||
expect(created).toEqual({ entityType: "reimbursement", entityIds: candidates.map(source => source.entityId), defaultPaymentChannel: "cash" });
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
for (const batch of batches) {
|
||||
await page.goto("/payment-batches?view=batches");
|
||||
const list = page.getByTestId(width === 390 ? "payment-batches-mobile-list" : "payment-batches-table");
|
||||
for (const item of batches) await expect(list).toContainText(item.batchNo);
|
||||
await page.goto(`/payment-batches?view=batches&id=${batch.id}`);
|
||||
await expect(page.getByText(batch.batchNo, { exact: true }).first()).toBeVisible();
|
||||
const cards = page.getByTestId("payment-batch-items-panel").locator(".detail-evidence-card");
|
||||
await expect(cards).toHaveCount(batch.sources.length);
|
||||
// The API owns natural sorting; the page preserves its order and ID links.
|
||||
for (const [index, source] of batch.sources.entries()) {
|
||||
await expect(cards.nth(index)).toContainText(source.businessNo);
|
||||
await expect(cards.nth(index).getByRole("link", { name: "查看报销单" })).toHaveAttribute("href", `/reimbursements/${source.entityId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户