完善借款、编号、付款与预算并发及验收(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,15 +1,17 @@
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { expect, test, type Browser, type Page } from "@playwright/test";
import {
EMPLOYEE,
MANAGER,
createAuthenticatedApiClient,
apiGetData,
apiPostData,
type AuthenticatedApiClient,
type E2EUser,
} from "./helpers/auth";
import { createSubmittedTravelRequestViaApi } from "./helpers/travel";
import { waitForWorkflowTask } from "./helpers/workflow";
type Finding = {
@@ -44,8 +46,12 @@ type MobileMetrics = {
};
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r108");
const ARTIFACT_DIR = path.join(ROOT_DIR, "artifacts/r108-mobile-wecom-visual");
const RUN_DIR = process.env.QA_VISUAL_OUTPUT_DIR
? path.resolve(process.env.QA_VISUAL_OUTPUT_DIR, "r108")
: path.join(ROOT_DIR, "artifacts/qa-layout/r108-runs", `${Date.now()}-${process.pid}`);
const SCREENSHOT_DIR = path.join(RUN_DIR, "screenshots");
const ARTIFACT_DIR = path.join(RUN_DIR, "reports");
const requireServer = createRequire(new URL("../../../server/package.json", import.meta.url));
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";
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
@@ -64,7 +70,7 @@ function artifactPath(fileName: string) {
}
function redact(value: string) {
return value.replace(UUID_RE, "[uuid]");
return value.replaceAll(ROOT_DIR, "<worktree>").replace(UUID_RE, "[uuid]");
}
function addFinding(finding: Finding) {
@@ -96,43 +102,36 @@ async function newWecomPage(browser: Browser, viewport: { width: number; height:
return { context, page: await context.newPage() };
}
async function firstEnabledExpenseTypeId(client: AuthenticatedApiClient) {
const response = await client.api.get("/api/expense-types?enabled=true");
const payload = await response.json() as {
data?: { items?: Array<{ id: string; enabled?: boolean }> };
};
const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0];
if (!response.ok() || !item?.id) {
throw new Error(`无法读取可用费用类型:HTTP ${response.status()}`);
}
return item.id;
async function syntheticPdf(label: string) {
const document = await requireServer("pdf-lib").PDFDocument.create();
document.addPage().drawText(`Synthetic R108 ${label} ${Date.now()}`, { x: 40, y: 700 });
return Buffer.from(await document.save());
}
async function seedEmployeeMobileData() {
const client = await createAuthenticatedApiClient(EMPLOYEE);
try {
const expenseTypeId = await firstEnabledExpenseTypeId(client);
const reimbursementResponse = await client.api.post("/api/reimbursements", {
data: { description: "R108 移动端巡检报销草稿" },
const expenseDate = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date());
const types = await apiGetData<{ items: Array<{ id: string; code: string; enabled: boolean }> }>(client.api, "/api/expense-types?enabled=true");
const expenseType = types.items.find(item => item.enabled && item.code === "office_postal");
expect(expenseType, "R108 requires the active synthetic postal expense type").toBeDefined();
const reimbursement = await apiPostData<{ id: string }>(client.api, "/api/reimbursements", {
description: "R108 移动审批合成报销", reimbursementDate: expenseDate,
});
const reimbursementPayload = await reimbursementResponse.json() as { data?: { id?: string } };
if (!reimbursementResponse.ok() || !reimbursementPayload.data?.id) {
throw new Error(`创建移动端报销草稿失败:HTTP ${reimbursementResponse.status()}`);
}
const itemResponse = await client.api.post(`/api/reimbursements/${reimbursementPayload.data.id}/items`, {
data: {
expenseTypeId,
expenseDate: "2026-05-07",
amount: "88.80",
description: "R108 移动巡检明细",
},
const item = await apiPostData<{ id: string }>(client.api, `/api/reimbursements/${reimbursement.id}/items`, {
expenseTypeId: expenseType!.id, expenseDate, amount: "10.00", description: "R108 合成邮寄费",
});
if (!itemResponse.ok()) {
throw new Error(`创建移动端报销明细失败:HTTP ${itemResponse.status()}`);
}
return { reimbursementId: reimbursementPayload.data.id };
const uploaded = await client.api.post("/api/invoice-pool/upload", {
multipart: { file: { name: "r108-approval.pdf", mimeType: "application/pdf", buffer: await syntheticPdf("approval") }, ocr_type: "general" },
});
expect(uploaded.ok(), `R108 upload HTTP ${uploaded.status()}`).toBe(true);
const invoice = await uploaded.json() as { data: { invoicePoolId: string } };
await expect.poll(async () => (await apiGetData<{ status: string }>(client.api, `/api/invoice-pool/${invoice.data.invoicePoolId}`)).status, { timeout: 90_000 }).toBe("available");
await apiPostData(client.api, `/api/invoice-pool/${invoice.data.invoicePoolId}/bind`, { reimbursementId: reimbursement.id, linkedItemId: item.id });
const submitted = await apiPostData<{ reimbursementId: string; formNumber: string; currentTaskId: string }>(client.api, `/api/reimbursements/${reimbursement.id}/submit`, {});
const denied = await client.api.post(`/api/workflow/tasks/${submitted.currentTaskId}/approve`, { data: {} });
expect(denied.status(), "员工不可审批分配给经理的任务").toBe(403);
return submitted;
} finally {
await client.dispose();
}
@@ -370,9 +369,9 @@ async function auditMobilePage(page: Page, options: MobileAuditOptions): Promise
return metrics;
}
function writeReport() {
function writeReport(completed: boolean) {
const generatedAt = new Date().toISOString();
const report = { generatedAt, checks, findings };
const report = { generatedAt, status: completed && findings.length === 0 ? "PASS" : "INCOMPLETE_OR_FAILED", checks, findings };
writeFileSync(artifactPath("report.json"), redact(JSON.stringify(report, null, 2)));
const lines = [
@@ -382,9 +381,10 @@ function writeReport() {
"",
"## 结论",
"",
`- 完整执行:${completed ? "是" : "否(以浏览器失败为准)"}`,
`- 巡检记录数:${checks.length}`,
`- 发现问题数:${findings.length}`,
`- 截图目录:${SCREENSHOT_DIR}`,
`- 截图目录:${path.relative(ROOT_DIR, SCREENSHOT_DIR)}`,
"",
"## 发现的问题",
"",
@@ -412,33 +412,21 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => {
test.skip(process.env.E2E_MODE === "live", "R108 本地移动专项只在临时本地环境运行");
test("员工移动页面、票据上传队列与经理移动审批可巡检", async ({ browser }) => {
let travel: Awaited<ReturnType<typeof createSubmittedTravelRequestViaApi>> | null = null;
let completed = false;
const contexts: Array<Awaited<ReturnType<typeof newWecomPage>>["context"]> = [];
let managerClient: AuthenticatedApiClient | null = null;
try {
try {
await seedEmployeeMobileData();
travel = await createSubmittedTravelRequestViaApi({
user: EMPLOYEE,
purposePrefix: "R108移动审批视觉",
amount: "1098.00",
});
managerClient = await createAuthenticatedApiClient(MANAGER);
} catch (error) {
test.skip(true, `R108 初始化 API 不可用,跳过本地视觉巡检:${error instanceof Error ? error.message : String(error)}`);
return;
}
if (!travel || !managerClient) {
test.skip(true, "R108 初始化未产生必要测试数据,跳过本地视觉巡检。");
return;
}
const reimbursement = await seedEmployeeMobileData();
managerClient = await createAuthenticatedApiClient(MANAGER);
const task = await waitForWorkflowTask(managerClient.api, {
entityId: travel.travelRequest.requestId,
entityType: "travel_request",
entityId: reimbursement.reimbursementId,
entityType: "reimbursement",
});
expect(task.id).toBe(reimbursement.currentTaskId);
const employee390 = await newWecomPage(browser, { width: 390, height: 844 });
contexts.push(employee390.context);
await loginForMobile(employee390.page, EMPLOYEE);
await auditMobilePage(employee390.page, { label: "企微 390-首页", route: "/mobile", screenshotName: "mobile-390-home" });
await auditMobilePage(employee390.page, { label: "企微 390-报销", route: "/mobile/reimbursements", screenshotName: "mobile-390-reimbursements" });
@@ -451,8 +439,8 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => {
await employee390.page.evaluate(() => window.dispatchEvent(new Event("offline")));
await expect(employee390.page.getByText("离线", { exact: true })).toBeVisible();
await employee390.page.getByTestId("mobile-capture-pdf-file").setInputFiles([
{ name: "r108-mobile-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF\n") },
{ name: "r108-mobile-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF\n") },
{ name: "r108-mobile-a.pdf", mimeType: "application/pdf", buffer: await syntheticPdf("queue-a") },
{ name: "r108-mobile-b.pdf", mimeType: "application/pdf", buffer: await syntheticPdf("queue-b") },
]);
await expect(employee390.page.getByText("已选择 2 个")).toBeVisible();
await employee390.page.getByTestId("mobile-capture-upload").click();
@@ -466,33 +454,43 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => {
await employee390.context.close();
const employee414 = await newWecomPage(browser, { width: 414, height: 896 });
contexts.push(employee414.context);
await loginForMobile(employee414.page, EMPLOYEE);
await auditMobilePage(employee414.page, { label: "企微 414-首页", route: "/mobile", screenshotName: "mobile-414-home" });
await auditMobilePage(employee414.page, { label: "企微 414-票据上传", route: "/mobile/capture", screenshotName: "mobile-414-capture" });
await employee414.context.close();
const manager390 = await newWecomPage(browser, { width: 390, height: 844 });
contexts.push(manager390.context);
await loginForMobile(manager390.page, MANAGER);
await manager390.page.goto(`/mobile/approvals?taskId=${encodeURIComponent(task.id)}`);
await expect(manager390.page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
await expect(manager390.page.getByTestId("mobile-approvals-detail")).toContainText(travel.travelRequest.requestNo);
await expect(manager390.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible({ timeout: 30_000 });
await expect(manager390.page.getByTestId("reimbursement-mobile-workspace")).toContainText(reimbursement.formNumber);
await expect(manager390.page.getByTestId("mobile-approvals-approve")).toBeEnabled();
await expect(manager390.page.getByTestId("mobile-approvals-reject")).toBeEnabled();
await expect(manager390.page.getByTestId("mobile-approvals-reject")).toHaveCount(0);
await expect(manager390.page.getByTestId("mobile-approvals-return")).toBeEnabled();
await manager390.page.getByTestId("mobile-approvals-return").click();
await manager390.page.getByTestId("mobile-approvals-comment").fill("R108 移动审批操作区验证");
await auditMobilePage(manager390.page, { label: "企微 390-审批详情", route: `/mobile/approvals?taskId=${encodeURIComponent(task.id)}`, screenshotName: "mobile-390-approvals-detail" });
await auditMobilePage(manager390.page, { label: "企微 390-审批详情", route: `/mobile/approvals?taskId=${encodeURIComponent(task.id)}`, screenshotName: "mobile-390-approvals-detail", skipNavigation: true });
await manager390.page.getByTestId("mobile-approvals-approve").click();
await expect(manager390.page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
const approval = manager390.page.waitForResponse(response => new URL(response.url()).pathname === `/api/workflow/tasks/${task.id}/approve` && response.request().method() === "POST");
await manager390.page.getByTestId("workflow-action-confirm-submit").click();
await expect(manager390.page.getByText("操作成功")).toBeVisible({ timeout: 30_000 });
expect((await approval).ok()).toBe(true);
const processed = await apiGetData<{ task: { status: string; outcome: string } }>(managerClient.api, `/api/workflow/tasks/${task.id}`);
expect(processed.task.status).toBe("completed");
expect(processed.task.outcome).toBe("approved");
await expect(manager390.page.getByText("已通过", { exact: true })).toBeVisible({ timeout: 30_000 });
await manager390.context.close();
writeReport();
if (process.env.R108_MOBILE_ALLOW_FINDINGS !== "1") {
{
const summary = findings.map((finding) => `${finding.severity} ${finding.page} / ${finding.category}: ${finding.title}`).join("\n");
expect(findings, summary || "R108 mobile visual findings should be empty").toEqual([]);
}
completed = true;
} finally {
await Promise.all([travel?.client.dispose(), managerClient?.dispose()]);
writeReport(completed);
await Promise.all([...contexts.map(context => context.close()), managerClient?.dispose()]);
}
});
});