完善借款、编号、付款与预算并发及验收(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 行删除
@@ -1,9 +1,12 @@
import { mkdirSync } from "node:fs";
import { mkdirSync, mkdtempSync } from "node:fs";
import path from "node:path";
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r125-invoice-reupload-after-delete");
const SCREENSHOT_ROOT = process.env.QA_VISUAL_OUTPUT_DIR || path.join(ROOT_DIR, "output/playwright/r125-invoice-reupload-after-delete");
mkdirSync(SCREENSHOT_ROOT, { recursive: true });
const SCREENSHOT_DIR = mkdtempSync(path.join(SCREENSHOT_ROOT, "r125-"));
test.use({ serviceWorkers: "block" });
const WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
@@ -107,6 +110,8 @@ function buildState() {
invoices: [] as ReturnType<typeof buildBoundInvoice>[],
availableInvoices: [buildAvailableInvoice()],
uploadCount: 0,
archived: false,
bindRequests: [] as Array<{ id: string; reimbursementId?: string; linkedItemId?: string }>,
};
}
@@ -297,7 +302,8 @@ async function mockApis(page: Page, state: ReturnType<typeof buildState>) {
if (pathName === "/api/invoice-pool/upload") {
state.uploadCount += 1;
const invoicePoolId = state.uploadCount === 1 ? reusableInvoiceId : newInvoiceId;
const invoicePoolId = state.archived ? newInvoiceId : reusableInvoiceId;
state.availableInvoices = [...state.availableInvoices.filter((item) => item.id !== invoicePoolId), { ...buildAvailableInvoice(), id: invoicePoolId }];
await fulfillJson(route, {
invoicePoolId,
status: "available",
@@ -311,7 +317,10 @@ async function mockApis(page: Page, state: ReturnType<typeof buildState>) {
const bindMatch = pathName.match(/^\/api\/invoice-pool\/([^/]+)\/bind$/);
if (bindMatch) {
const payload = request.postDataJSON() as { reimbursementId?: string; linkedItemId?: string };
state.bindRequests.push({ id: bindMatch[1], ...payload });
state.invoices = [buildBoundInvoice(bindMatch[1])];
state.availableInvoices = state.availableInvoices.filter((item) => item.id !== bindMatch[1]);
await fulfillJson(route, {
id: bindMatch[1],
status: "bound",
@@ -339,6 +348,7 @@ async function mockApis(page: Page, state: ReturnType<typeof buildState>) {
if (pathName === `/api/invoice-pool/${archivedInvoiceId}/archive`) {
state.availableInvoices = [];
state.archived = true;
await fulfillJson(route, {
id: archivedInvoiceId,
status: "archived",
@@ -385,29 +395,36 @@ test.describe("R125 发票删除后重传", () => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-invoice-add").click();
await page.getByLabel("新发票添加位置").selectOption("item-r125");
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
name: "北京悦海堂发票.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF r125 invoice"),
});
await page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 });
await page.keyboard.press("Escape");
await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
await expect(page.getByText("发票上传完成:1 张已入我的发票,请选择后添加")).toBeVisible({ timeout: 10_000 });
expect(state.bindRequests).toHaveLength(0);
await page.getByRole("button", { name: "添加到报销单", exact: true }).click();
await expect(page.locator(`#reimbursement-invoice-${reusableInvoiceId}`)).toBeVisible();
expect(state.bindRequests).toEqual([{ id: reusableInvoiceId, reimbursementId, linkedItemId: "item-r125" }]);
await page.getByRole("button", { name: "移除" }).first().click();
await page.locator(`#reimbursement-invoice-${reusableInvoiceId}`).getByRole("button", { name: "移除对应关系", exact: true }).click();
await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toHaveCount(0);
await page.getByTestId("reimbursement-invoice-add").click();
await page.getByLabel("新发票添加位置").selectOption("item-r125");
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
name: "北京悦海堂发票.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF r125 invoice"),
});
await page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 });
await page.keyboard.press("Escape");
await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
await expect(page.getByText("发票上传完成:1 张已入我的发票,请选择后添加")).toBeVisible({ timeout: 10_000 });
expect(state.bindRequests).toHaveLength(1);
await page.getByRole("button", { name: "添加到报销单", exact: true }).click();
await expect(page.locator(`#reimbursement-invoice-${reusableInvoiceId}`)).toBeVisible();
expect(state.bindRequests).toHaveLength(2);
expect(state.bindRequests[1]).toEqual({ id: reusableInvoiceId, reimbursementId, linkedItemId: "item-r125" });
await expectNoRawReimbursementCodes(page);
await page.screenshot({ path: screenshotPath("pc-reupload-after-unbind.png"), fullPage: true });
});
@@ -431,16 +448,20 @@ test.describe("R125 发票删除后重传", () => {
await expect(availableInvoiceCard).toHaveCount(0);
await invoiceDialog.getByRole("button", { name: "上传" }).click();
await mobile.page.getByLabel("新发票添加位置").selectOption("item-r125");
await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
name: "北京悦海堂发票.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF r125 invoice"),
});
await mobile.page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect(
mobile.page.getByTestId("reimbursement-invoice-dialog").getByText(/1 张已添加|已添加 1 张发票/),
).toBeVisible({ timeout: 10_000 });
await mobile.page.keyboard.press("Escape");
await expect(invoiceDialog.getByRole("button", { name: "添加到报销单 1", exact: true })).toBeEnabled();
expect(state.bindRequests).toHaveLength(0);
await invoiceDialog.getByRole("button", { name: "添加到报销单 1", exact: true }).click();
await expect(invoiceDialog.getByText("已添加 1 张发票", { exact: true })).toBeVisible();
expect(state.archived).toBe(true);
expect(state.bindRequests).toEqual([{ id: newInvoiceId, reimbursementId, linkedItemId: "item-r125" }]);
await invoiceDialog.getByRole("button", { name: "返回报销单", exact: true }).click();
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
await mobile.page.screenshot({ path: screenshotPath("mobile-reupload-after-archive.png"), fullPage: true });
} finally {