完善借款、编号、付款与预算并发及验收(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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -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()]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户