文件
hyb-oa/client/tests/e2e/reimbursement-content-consistency.spec.ts
T

219 行
11 KiB
TypeScript

import { readFileSync } from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
import { buildReimbursementApprovalEvidence } from "../../src/lib/reimbursementApprovalProjection";
import { resolveReimbursementApprovalBlockReason } from "../../src/lib/reimbursementApprovalReadiness";
import {
buildReimbursementAmountSummary,
parseMoneyToMinorUnits,
} from "../../src/lib/reimbursementWorkspace";
function source(relativePath: string) {
return readFileSync(path.resolve(process.cwd(), relativePath), "utf8");
}
test("报销金额使用整数分判断完整性且不产生展示用第二总额", () => {
expect(parseMoneyToMinorUnits("0")).toBe(0n);
expect(parseMoneyToMinorUnits("0.1")).toBe(10n);
expect(parseMoneyToMinorUnits("1234.56")).toBe(123456n);
expect(parseMoneyToMinorUnits("1,234.56")).toBeNull();
expect(parseMoneyToMinorUnits("0.001")).toBeNull();
expect(parseMoneyToMinorUnits("NaN")).toBeNull();
expect(parseMoneyToMinorUnits(null)).toBeNull();
expect(buildReimbursementAmountSummary("0.30", [{ amount: "0.1" }, { amount: "0.20" }])).toEqual({
authoritativeTotalAmount: "0.30",
integrity: "ready",
});
expect(buildReimbursementAmountSummary("1000.00", [{ amount: "400" }, { amount: "600.00" }])).toEqual({
authoritativeTotalAmount: "1000.00",
integrity: "ready",
});
expect(buildReimbursementAmountSummary("100.00", [{ amount: "99.99" }])).toEqual({
authoritativeTotalAmount: "100.00",
integrity: "mismatch",
});
expect(buildReimbursementAmountSummary("100.00", [{ amount: "invalid" }])).toEqual({
authoritativeTotalAmount: "100.00",
integrity: "unavailable",
});
expect(buildReimbursementAmountSummary("0.00", [])).toEqual({
authoritativeTotalAmount: "0.00",
integrity: "ready",
});
});
test("报销审批五类阻断原因由同一投影给出且正常态保持安静", () => {
const base = {
amountIntegrity: "ready" as const,
detailState: "ready" as const,
online: true,
snapshotParseStatus: "ready",
};
expect(resolveReimbursementApprovalBlockReason(base)).toBeNull();
expect(resolveReimbursementApprovalBlockReason({ ...base, online: false })).toBe("报销审批需联网核对完整报销内容;当前不能通过、退回修改或转审。");
expect(resolveReimbursementApprovalBlockReason({ ...base, detailState: "loading" })).toBe("正在加载完整报销内容和票据;加载完成前不能作出审批决定。");
expect(resolveReimbursementApprovalBlockReason({ ...base, detailState: "error" })).toBe("报销内容未加载完整,暂不能审批,请刷新后重试");
expect(resolveReimbursementApprovalBlockReason({ ...base, snapshotParseStatus: "invalid" })).toBe("提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。");
expect(resolveReimbursementApprovalBlockReason({ ...base, snapshotParseStatus: undefined })).toBe("提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。");
expect(resolveReimbursementApprovalBlockReason({ ...base, amountIntegrity: "mismatch" })).toBe("报销总额与明细汇总不一致,请联系管理员");
expect(resolveReimbursementApprovalBlockReason({ ...base, amountIntegrity: "unavailable" })).toBe("明细金额无法完整核对,请联系管理员");
});
test("动作面板复用唯一阻断投影且预算三态判断保持原合同", () => {
const adapter = source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx");
expect(adapter.match(/resolveReimbursementApprovalBlockReason\(/g)).toHaveLength(1);
expect(adapter).toContain("throw new Error(decisionBlockReason");
expect(adapter).toContain("setActionError(decisionBlockReason");
expect(adapter).toContain("{decisionBlockReason}");
expect(adapter.match(/snapshot\?\.budgetCheckStatus === "missing"/g)).toHaveLength(3);
expect(adapter).not.toContain("完整内容已就绪");
expect(adapter).not.toContain("内容未就绪,审批动作已停用");
expect(adapter).not.toContain("statusSummary");
expect(adapter).toContain("actionError || activeSecondaryAction ?");
expect(adapter).toContain('data-testid="reimbursement-approval-primary-actions"');
expect(adapter).toContain('className="grid grid-cols-2 gap-2"');
expect(adapter).toContain("退回修改");
expect(adapter).not.toContain("rejectWorkflowTask");
expect(adapter).not.toContain('allowedActions.has("reject")');
expect(adapter).not.toContain("workflow-task-reject-toggle");
expect(adapter).not.toContain("mobile-approvals-reject");
});
test("旧审批层叠 class 与更多菜单样式无定义或消费者", () => {
const styles = source("src/index.css");
const productSources = [
source("src/components/patterns/BusinessDetailTemplate.tsx"),
source("src/pages/WorkflowTaskDetailPage.tsx"),
source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx"),
].join("\n");
for (const legacyClass of ["workflow-action-panel", "workflow-action-more"]) {
expect(styles).not.toContain(legacyClass);
expect(productSources).not.toContain(legacyClass);
}
expect(productSources).toContain("detail-action-panel lg:sticky lg:top-24");
});
test("PC 与移动明细只展示共享模型中的服务端权威合计", () => {
const evidenceList = source("src/components/reimbursements/ReimbursementItemEvidenceList.tsx");
const desktopDetail = source("src/pages/ReimbursementDetailPage.tsx");
const mobileDetail = source("src/pages/MobileReimbursementDetailPage.tsx");
const approvalAdapter = source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx");
const desktopShell = source("src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx");
const mobileShell = source("src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx");
expect(evidenceList).toContain("<tfoot");
expect(evidenceList).toContain("amountSummary.authoritativeTotalAmount");
expect(evidenceList).not.toContain("items.reduce(");
expect(desktopShell).toContain("model.amountSummary.authoritativeTotalAmount");
expect(mobileShell).toContain("model.amountSummary.authoritativeTotalAmount");
expect(desktopShell).not.toContain("model.header.totalAmount");
expect(mobileShell).not.toContain("model.header.totalAmount");
for (const consumer of [desktopDetail, mobileDetail, approvalAdapter]) {
expect(consumer).toContain("buildReimbursementWorkspaceModel(");
expect(consumer).not.toContain("buildReimbursementAmountSummary(");
}
});
test("PC 审批历史只进入右栏且移动历史保持正文末尾", () => {
const desktopShell = source("src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx");
const mobileShell = source("src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx");
const approvalAdapter = source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx");
expect(desktopShell).not.toContain("slots.timeline");
expect(desktopShell.indexOf("slots.actionPanel")).toBeLessThan(desktopShell.indexOf("slots.asideContext"));
expect(mobileShell.indexOf("slots.afterEvidence")).toBeLessThan(mobileShell.indexOf("slots.timeline"));
expect(approvalAdapter).toContain("asideContext: timeline");
expect(approvalAdapter.match(/^\s+timeline,$/gm)).toHaveLength(1);
});
test("申请人进度与审批历史保持有意分工", () => {
const desktopDetail = source("src/pages/ReimbursementDetailPage.tsx");
const mobileDetail = source("src/pages/MobileReimbursementDetailPage.tsx");
const approvalAdapter = source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx");
expect(desktopDetail).toContain("<ProcessingProgress");
expect(mobileDetail).toContain("<ProcessingProgress");
expect(desktopDetail.match(/targetLinkLabel="查看所在付款批次"/g)).toHaveLength(1);
expect(mobileDetail.match(/targetLinkLabel="查看所在付款批次"/g)).toHaveLength(1);
expect(approvalAdapter).toContain('data-testid="reimbursement-workflow-history"');
expect(approvalAdapter).not.toContain("<ProcessingProgress");
});
test("待组批工作台保持单一候选源且报销详情使用可刷新深链", () => {
const paymentPage = source("src/pages/PaymentBatchesPage.tsx");
const reimbursementDetail = source("src/pages/ReimbursementDetailPage.tsx");
expect(paymentPage.match(/data-testid="payment-batch-candidates"/g)).toHaveLength(1);
expect(paymentPage.match(/fetchPaymentBatchCandidates\(/g)).toHaveLength(1);
expect(paymentPage).toContain('next.set("view", "pending")');
expect(paymentPage).toContain('next.set("page", String(page))');
expect(paymentPage).toContain("candidatesQuery.data?.total");
expect(reimbursementDetail).toContain('hasPermission(permissions, "payment.batch.manage")');
expect(reimbursementDetail).toContain("/payment-batches?view=pending&q=");
expect(reimbursementDetail).toContain("等待财务组建付款批次");
});
test("票据申报事实与系统金额证据状态分属正文和处理上下文", () => {
const baseDetail = {
items: [{
id: "item-1",
expenseTypeId: "type-1",
expenseDate: "2026-09-01",
amount: "100.00",
description: "合成明细",
assetPriorApprovalBasis: null,
expenseType: { id: "type-1", code: "office_supplies", name: "办公用品", category: "office", enabled: true, requiresInvoice: true },
}],
attachments: [],
};
const buildDetail = (amountEvidenceStatus: "verified" | "manual_review") => ({
...baseDetail,
invoices: [{
id: "invoice-1",
status: "bound",
invoiceCode: "CODE-1",
invoiceNumber: "NUMBER-1",
invoiceDate: "2026-09-01",
sellerName: "合成供应商",
buyerName: null,
documentCategory: "invoice",
documentSubtype: "vat",
totalAmount: "100.00",
originalName: "invoice.pdf",
mimeType: "application/pdf",
fileSize: 100,
linkedItemId: "item-1",
amountEvidenceStatus,
}],
});
const snapshot = { standardViolations: [], budgetCheck: null, budgetCheckStatus: "not_applicable" as const, parseStatus: "ready" as const };
const verified = buildReimbursementApprovalEvidence(buildDetail("verified"), snapshot).items[0].invoices[0];
const manualReview = buildReimbursementApprovalEvidence(buildDetail("manual_review"), snapshot).items[0].invoices[0];
const declaredFact = ({ amountEvidenceStatus: _context, ...fact }: typeof verified) => fact;
expect(declaredFact(verified)).toEqual(declaredFact(manualReview));
expect(verified.amountEvidenceStatus).toBe("verified");
expect(manualReview.amountEvidenceStatus).toBe("manual_review");
});
test("打印页保持组合归档视图且归档元数据不进入交互工作台", () => {
const printPage = source("src/pages/ReimbursementPrintPage.tsx");
const desktopShell = source("src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx");
const mobileShell = source("src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx");
expect(printPage).toContain("const totalAmount = detail.totalAmount");
expect(printPage).toContain("<QRCodeSVG");
expect(printPage).toContain("打印时间:");
expect(printPage).toContain("打印人:");
expect(printPage).toContain("审批记录");
for (const shell of [desktopShell, mobileShell]) {
expect(shell).not.toContain("QRCodeSVG");
expect(shell).not.toContain("打印时间:");
expect(shell).not.toContain("打印人:");
}
});