完善借款、编号、付款与预算并发及验收(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 行删除
@@ -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}`);
}
}
}
});