完善借款、编号、付款与预算并发及验收(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,4 +1,5 @@
import { type APIResponse } from "@playwright/test";
import { createRequire } from "node:module";
import { expect, test } from "./helpers/fixtures";
import {
@@ -55,6 +56,7 @@ type EmployeeSummary = {
type ExpenseTypeListResponse = {
items: Array<{
id: string;
code: string;
enabled: boolean;
name: string;
}>;
@@ -79,11 +81,7 @@ type SubmittedReimbursementSeed = {
type AuthedApi = Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"];
function formatDate(offsetDays: number) {
const value = new Date();
value.setUTCDate(value.getUTCDate() + offsetDays);
return value.toISOString().slice(0, 10);
}
const requireServer = createRequire(new URL("../../../server/package.json", import.meta.url));
async function readApiData<T>(response: APIResponse) {
const payload = (await response.json()) as { data?: T; error?: { code?: string; message?: string } };
@@ -118,7 +116,7 @@ async function ensureFeatureFlags(api: Awaited<ReturnType<typeof createAuthentic
}
async function createSubmittedReimbursement(api: AuthedApi, label: string): Promise<SubmittedReimbursementSeed> {
const reimbursementDate = formatDate(30 + Math.floor(Math.random() * 45));
const reimbursementDate = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date());
const description = `Playwright 通知补盲 ${label}`;
const created = await apiPostData<{ id: string }>(api, "/api/reimbursements", {
reimbursementDate,
@@ -126,25 +124,27 @@ async function createSubmittedReimbursement(api: AuthedApi, label: string): Prom
});
const expenseTypes = await apiGetData<ExpenseTypeListResponse>(api, "/api/expense-types?enabled=true");
const expenseTypeId = expenseTypes.items[0]?.id;
const expenseTypeId = expenseTypes.items.find((item) => item.enabled && item.code === "office_postal")?.id;
if (!expenseTypeId) {
throw new Error("未找到可用报销费用类型");
throw new Error("未找到合成通知用例必需的邮寄快递费用类型");
}
await apiPostData(api, `/api/reimbursements/${created.id}/items`, {
const item = await apiPostData<{ id: string }>(api, `/api/reimbursements/${created.id}/items`, {
expenseTypeId,
expenseDate: reimbursementDate,
amount: "1683.00",
amount: "10.00",
description,
});
const document = await requireServer("pdf-lib").PDFDocument.create();
document.addPage().drawText(`Synthetic R75 notification ${label} ${Date.now()}`, { x: 40, y: 700 });
const upload = await readApiData<InvoiceUploadResponse>(
await api.post("/api/invoice-pool/upload", {
multipart: {
file: {
name: `notify-${label}.pdf`,
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\nPW_R75_NOTIFY_${label}_${Date.now()}_${Math.random().toString(16).slice(2)}\n%%EOF\n`, "utf8"),
buffer: Buffer.from(await document.save()),
},
ocr_type: "general",
},
@@ -166,9 +166,14 @@ async function createSubmittedReimbursement(api: AuthedApi, label: string): Prom
await apiPostData(api, `/api/invoice-pool/${upload.invoicePoolId}/bind`, {
reimbursementId: created.id,
linkedItemId: item.id,
});
const submitted = await apiPostData<SubmittedReimbursementSeed>(api, `/api/reimbursements/${created.id}/submit`, {});
const response = await api.post(`/api/reimbursements/${created.id}/submit`, { data: {} });
const payload = await response.json() as { data?: SubmittedReimbursementSeed; error?: { code?: string; details?: { reasonCode?: string } } };
expect(response.ok(), JSON.stringify({ status: response.status(), code: payload.error?.code, reasonCode: payload.error?.details?.reasonCode, fields: Object.keys(payload.error?.details ?? {}) })).toBe(true);
expect(payload.data).toBeDefined();
const submitted = payload.data!;
return {
reimbursementId: submitted.reimbursementId,
formNumber: submitted.formNumber,
@@ -198,7 +203,7 @@ async function fetchEmployeeByKeyword(
const encoded = encodeURIComponent(keyword);
const payload = await apiGetData<{ items: EmployeeSummary[] }>(
api,
`/api/employees?page=1&pageSize=20&q=${encoded}`,
`/api/employees?page=1&pageSize=20&scope=management&q=${encoded}`,
);
return payload.items.find((item) => item.employeeNo === keyword || item.email === keyword) ?? null;
}