feat(报销): 收敛只读内容与审批上下文 [09-02-reimbursement-readonly-content-consistency]

这个提交包含在:
2026-09-02 21:13:08 +08:00
父节点 dda625b15f
当前提交 e48514d542
共修改 20 个文件,包含 740 行新增和 47 行删除
@@ -25,7 +25,7 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots
{slots.status}
<span className="info-chip">报销人:{model.header.ownerName}</span>
<span className="info-chip">日期:{formatDisplayDate(model.header.reimbursementDate)}</span>
<span className="info-chip">合计:{formatMoney(model.header.totalAmount)}</span>
<span className="info-chip">合计:{formatMoney(model.amountSummary.authoritativeTotalAmount)}</span>
</>
)}
/>
@@ -36,13 +36,15 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots
{slots.beforeEvidence}
<SectionPanel title="报销内容" className="scroll-mt-24">
<div id="reimbursement-workspace-evidence">
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} />
<ReimbursementItemEvidenceList amountSummary={model.amountSummary} evidence={model.evidence} capabilities={capabilities} />
</div>
</SectionPanel>
{slots.afterEvidence}
{slots.timeline}
</BusinessDetailMain>
<BusinessDetailAside>{slots.actionPanel}</BusinessDetailAside>
<BusinessDetailAside>
{slots.actionPanel}
{slots.asideContext}
</BusinessDetailAside>
</BusinessDetailWorkspace>
</PageScaffold>
);
@@ -7,6 +7,7 @@ import type {
ReimbursementInvoiceEvidence,
ReimbursementItemEvidence,
} from "../../lib/reimbursementApprovalProjection";
import { reimbursementAmountIntegrityMessage, type ReimbursementAmountSummary } from "../../lib/reimbursementWorkspace";
export type ReimbursementEvidenceCapabilities = {
onOpenInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
@@ -21,11 +22,17 @@ export type ReimbursementEvidenceCapabilities = {
};
type Props = {
amountSummary: ReimbursementAmountSummary;
evidence: ReimbursementApprovalEvidenceProjection;
capabilities?: ReimbursementEvidenceCapabilities;
mobile?: boolean;
};
function AmountIntegrityMessage({ integrity }: Pick<ReimbursementAmountSummary, "integrity">) {
const message = reimbursementAmountIntegrityMessage(integrity);
return message ? <p className="mt-1 text-sm font-medium text-red-700" role="alert">{message}</p> : null;
}
function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidenceStatus"]) {
if (status === "ocr_failed") return "该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。";
if (status === "manual_review") return "票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。";
@@ -61,7 +68,7 @@ function ItemIssues({ item }: { item: ReimbursementItemEvidence }) {
);
}
function ExpenseItemsTable({ items, capabilities }: { items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
function ExpenseItemsTable({ amountSummary, items, capabilities }: { amountSummary: ReimbursementAmountSummary; items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
const editable = Boolean(capabilities.onEditItem);
return (
<div className="overflow-x-auto rounded-lg border border-zinc-200" data-testid="reimbursement-expense-items-table">
@@ -93,12 +100,22 @@ function ExpenseItemsTable({ items, capabilities }: { items: ReimbursementItemEv
);
})}
</tbody>
<tfoot className="border-t border-zinc-300 bg-zinc-50" data-testid="reimbursement-amount-summary" id="reimbursement-workspace-amount-summary">
<tr>
<th className="px-3 py-3 text-right font-medium text-zinc-700" colSpan={4} scope="row">明细合计</th>
<td aria-label="服务端权威明细合计" className="px-3 py-3 text-right font-semibold tabular-nums text-zinc-950">
{formatMoney(amountSummary.authoritativeTotalAmount)}
</td>
<td className="px-3 py-3" colSpan={editable ? 2 : 1} />
</tr>
{amountSummary.integrity !== "ready" ? <tr><td className="px-3 pb-3" colSpan={editable ? 7 : 6}><AmountIntegrityMessage integrity={amountSummary.integrity} /></td></tr> : null}
</tfoot>
</table>
</div>
);
}
function ExpenseItemCards({ items, capabilities }: { items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
function ExpenseItemCards({ amountSummary, items, capabilities }: { amountSummary: ReimbursementAmountSummary; items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
return <div className="space-y-3">{items.map((item, index) => {
const hasBlockingIssue = itemHasBlockingIssue(item);
return (
@@ -110,7 +127,12 @@ function ExpenseItemCards({ items, capabilities }: { items: ReimbursementItemEvi
{capabilities.onEditItem ? <Button className="mt-3 min-h-11 w-full" onClick={() => capabilities.onEditItem?.(item)} type="button" variant="secondary">编辑</Button> : null}
</article>
);
})}</div>;
})}
<div className="flex items-start justify-between gap-3 border-t border-zinc-200 pt-3" data-testid="reimbursement-amount-summary" id="reimbursement-workspace-amount-summary">
<div className="min-w-0"><p className="text-sm font-medium text-zinc-700">明细合计</p><AmountIntegrityMessage integrity={amountSummary.integrity} /></div>
<p aria-label="服务端权威明细合计" className="shrink-0 font-semibold tabular-nums text-zinc-950">{formatMoney(amountSummary.authoritativeTotalAmount)}</p>
</div>
</div>;
}
function InvoiceMaterialRow({ invoice, capabilities, linkTargets }: { invoice: ReimbursementInvoiceEvidence; capabilities: ReimbursementEvidenceCapabilities; linkTargets: ReimbursementItemEvidence[] }) {
@@ -153,13 +175,21 @@ function AttachmentMaterialRow({ attachment, capabilities }: { attachment: Reimb
</div>;
}
export function ReimbursementItemEvidenceList({ evidence, capabilities = {}, mobile = false }: Props) {
export function ReimbursementItemEvidenceList({ amountSummary, evidence, capabilities = {}, mobile = false }: Props) {
const linkedInvoices = evidence.items.flatMap((item) => item.invoices);
return (
<div className="space-y-6" data-testid="reimbursement-approval-evidence">
<section className="space-y-3" data-testid="reimbursement-expense-items">
<h3 className="font-medium text-zinc-900">费用明细</h3>
{evidence.items.length ? (mobile ? <ExpenseItemCards capabilities={capabilities} items={evidence.items} /> : <ExpenseItemsTable capabilities={capabilities} items={evidence.items} />) : <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>}
{evidence.items.length ? (mobile ? <ExpenseItemCards amountSummary={amountSummary} capabilities={capabilities} items={evidence.items} /> : <ExpenseItemsTable amountSummary={amountSummary} capabilities={capabilities} items={evidence.items} />) : (
<div className="space-y-3">
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>
<div className="flex items-start justify-between gap-3 border-t border-zinc-200 pt-3" data-testid="reimbursement-amount-summary" id="reimbursement-workspace-amount-summary">
<div><p className="text-sm font-medium text-zinc-700">明细合计</p><AmountIntegrityMessage integrity={amountSummary.integrity} /></div>
<p aria-label="服务端权威明细合计" className="font-semibold tabular-nums text-zinc-950">{formatMoney(amountSummary.authoritativeTotalAmount)}</p>
</div>
</div>
)}
</section>
<section className="space-y-4" data-testid="approval-materials">
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="font-medium text-zinc-900">票据与附件</h3>{capabilities.onOpenMaterialBundle ? <Button className="min-h-11" onClick={capabilities.onOpenMaterialBundle} size="sm" type="button" variant="secondary">查看完整材料</Button> : null}</div>
@@ -27,7 +27,7 @@ export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots,
<section className="mobile-card mobile-focus-card mobile-page-stack">
<div>
<p className="text-xs text-zinc-500">报销合计</p>
<p className="mt-1 text-3xl font-semibold text-zinc-950">{formatMoney(model.header.totalAmount)}</p>
<p className="mt-1 text-3xl font-semibold text-zinc-950">{formatMoney(model.amountSummary.authoritativeTotalAmount)}</p>
</div>
<div className="mobile-info-grid">
<div className="mobile-list-card">
@@ -48,7 +48,7 @@ export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots,
{slots.beforeEvidence}
<section className="mobile-card scroll-mt-20" data-testid="reimbursement-workspace-evidence" id="reimbursement-workspace-evidence">
<h2 className="mb-3 text-base font-semibold text-zinc-900">报销内容</h2>
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} mobile />
<ReimbursementItemEvidenceList amountSummary={model.amountSummary} evidence={model.evidence} capabilities={capabilities} mobile />
</section>
{slots.afterEvidence}
{slots.timeline}
+71 -4
查看文件
@@ -7,7 +7,6 @@ export type ReimbursementWorkspaceHeaderModel = {
displayCode: string;
ownerName: string;
reimbursementDate: string | null;
totalAmount: string | null;
description: string | null;
};
@@ -17,8 +16,14 @@ export type ReimbursementWorkspaceIssueAnchor = {
severity: "blocking" | "review" | "info";
};
export type ReimbursementAmountSummary = {
authoritativeTotalAmount: string | null;
integrity: "ready" | "mismatch" | "unavailable";
};
export type ReimbursementWorkspaceModel = {
header: ReimbursementWorkspaceHeaderModel;
amountSummary: ReimbursementAmountSummary;
evidence: ReimbursementApprovalEvidenceProjection;
issueAnchors: ReimbursementWorkspaceIssueAnchor[];
};
@@ -31,27 +36,69 @@ export type ReimbursementWorkspaceSlots = {
afterEvidence?: ReactNode;
timeline?: ReactNode;
actionPanel?: ReactNode;
asideContext?: ReactNode;
mobileActions?: ReactNode;
transientFeedback?: ReactNode;
};
export function buildReimbursementWorkspaceHeader(
detail: Pick<ReimbursementDetail, "formNumber" | "owner" | "reimbursementDate" | "totalAmount" | "description" | "id">,
detail: Pick<ReimbursementDetail, "formNumber" | "owner" | "reimbursementDate" | "description" | "id">,
): ReimbursementWorkspaceHeaderModel {
return {
displayCode: resolveReimbursementDisplayCode(detail),
ownerName: detail.owner?.name ?? "-",
reimbursementDate: detail.reimbursementDate,
totalAmount: detail.totalAmount,
description: detail.description,
};
}
export function parseMoneyToMinorUnits(value: string | null | undefined): bigint | null {
if (typeof value !== "string" || !/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/.test(value)) return null;
const [whole, fraction = ""] = value.split(".");
return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0"));
}
export function buildReimbursementAmountSummary(
authoritativeTotalAmount: string | null,
items: Array<{ amount: string }>,
): ReimbursementAmountSummary {
const authoritativeMinorUnits = parseMoneyToMinorUnits(authoritativeTotalAmount);
const itemMinorUnits = items.map((item) => parseMoneyToMinorUnits(item.amount));
if (authoritativeMinorUnits === null || itemMinorUnits.some((value) => value === null)) {
return { authoritativeTotalAmount, integrity: "unavailable" };
}
const derivedMinorUnits = itemMinorUnits.reduce<bigint>((sum, value) => sum + (value ?? 0n), 0n);
return {
authoritativeTotalAmount,
integrity: authoritativeMinorUnits === derivedMinorUnits ? "ready" : "mismatch",
};
}
export function reimbursementAmountIntegrityMessage(integrity: ReimbursementAmountSummary["integrity"]) {
if (integrity === "mismatch") return "报销总额与明细汇总不一致,请联系管理员";
if (integrity === "unavailable") return "明细金额无法完整核对,请联系管理员";
return null;
}
export function buildReimbursementWorkspaceIssueAnchors(
evidence: ReimbursementApprovalEvidenceProjection,
options: { snapshotMissing?: boolean; snapshotInvalid?: boolean } = {},
options: {
amountIntegrity?: ReimbursementAmountSummary["integrity"];
snapshotMissing?: boolean;
snapshotInvalid?: boolean;
} = {},
): ReimbursementWorkspaceIssueAnchor[] {
const anchors: ReimbursementWorkspaceIssueAnchor[] = [];
const amountIntegrityMessage = options.amountIntegrity
? reimbursementAmountIntegrityMessage(options.amountIntegrity)
: null;
if (amountIntegrityMessage) {
anchors.push({
id: "reimbursement-workspace-amount-summary",
label: amountIntegrityMessage,
severity: "blocking",
});
}
if (options.snapshotInvalid) {
anchors.push({ id: "reimbursement-workspace-snapshot", label: "提交记录无法读取", severity: "blocking" });
} else if (options.snapshotMissing) {
@@ -94,3 +141,23 @@ export function buildReimbursementWorkspaceIssueAnchors(
}
return anchors;
}
export function buildReimbursementWorkspaceModel(
detail: Pick<
ReimbursementDetail,
"formNumber" | "owner" | "reimbursementDate" | "totalAmount" | "description" | "id"
>,
evidence: ReimbursementApprovalEvidenceProjection,
options: { snapshotMissing?: boolean; snapshotInvalid?: boolean } = {},
): ReimbursementWorkspaceModel {
const amountSummary = buildReimbursementAmountSummary(detail.totalAmount, evidence.items);
return {
header: buildReimbursementWorkspaceHeader(detail),
amountSummary,
evidence,
issueAnchors: buildReimbursementWorkspaceIssueAnchors(evidence, {
...options,
amountIntegrity: amountSummary.integrity,
}),
};
}
@@ -60,7 +60,7 @@ import { Textarea } from "../components/ui/textarea";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection";
import { buildReimbursementWorkspaceHeader, buildReimbursementWorkspaceIssueAnchors } from "../lib/reimbursementWorkspace";
import { buildReimbursementWorkspaceModel } from "../lib/reimbursementWorkspace";
import {
formatInvoiceDisplayTitle,
resolveInvoicePartyText,
@@ -909,11 +909,7 @@ export function MobileReimbursementDetailPage() {
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
});
const workspaceModel = {
header: buildReimbursementWorkspaceHeader(detail),
evidence: workspaceEvidence,
issueAnchors: buildReimbursementWorkspaceIssueAnchors(workspaceEvidence),
};
const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence);
const workspaceCapabilities = {
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => {
+2 -6
查看文件
@@ -84,7 +84,7 @@ import {
} from "./reimbursement-detail/ReimbursementItemEditor";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection";
import { buildReimbursementWorkspaceHeader, buildReimbursementWorkspaceIssueAnchors } from "../lib/reimbursementWorkspace";
import { buildReimbursementWorkspaceModel } from "../lib/reimbursementWorkspace";
import {
focusFirstInvoiceGuardIssue,
parseReimbursementInvoiceGuardIssues,
@@ -1042,11 +1042,7 @@ export function ReimbursementDetailPage() {
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
});
const workspaceModel = {
header: buildReimbursementWorkspaceHeader(detail),
evidence: workspaceEvidence,
issueAnchors: buildReimbursementWorkspaceIssueAnchors(workspaceEvidence),
};
const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence);
const workspaceCapabilities = {
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => {
@@ -36,10 +36,7 @@ import { approvalRoleLabel, workflowActionLabel } from "../../lib/approvalDispla
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../../lib/attachmentGovernance";
import { formatDisplayDateTime } from "../../lib/displayFormat";
import { buildReimbursementApprovalEvidence } from "../../lib/reimbursementApprovalProjection";
import {
buildReimbursementWorkspaceHeader,
buildReimbursementWorkspaceIssueAnchors,
} from "../../lib/reimbursementWorkspace";
import { buildReimbursementWorkspaceModel, reimbursementAmountIntegrityMessage } from "../../lib/reimbursementWorkspace";
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { isWecomWebView } from "../../lib/runtimeContext";
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../../lib/workflowActionConfirm";
@@ -121,16 +118,17 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
}, [reimbursementDetailQuery.data, snapshot]);
const model = useMemo(() => {
if (!reimbursementDetailQuery.data || !evidence) return null;
return {
header: buildReimbursementWorkspaceHeader(reimbursementDetailQuery.data),
evidence,
issueAnchors: buildReimbursementWorkspaceIssueAnchors(evidence, {
snapshotInvalid: snapshot?.parseStatus === "invalid",
snapshotMissing: snapshot?.budgetCheckStatus === "missing",
}),
};
return buildReimbursementWorkspaceModel(reimbursementDetailQuery.data, evidence, {
snapshotInvalid: snapshot?.parseStatus === "invalid",
snapshotMissing: snapshot?.budgetCheckStatus === "missing",
});
}, [evidence, reimbursementDetailQuery.data, snapshot?.budgetCheckStatus, snapshot?.parseStatus]);
const decisionReady = Boolean(online && model && snapshot?.parseStatus === "ready");
const decisionReady = Boolean(
online
&& model
&& model.amountSummary.integrity === "ready"
&& snapshot?.parseStatus === "ready",
);
const allowedActions = useMemo(() => new Set(detail.allowedActions), [detail.allowedActions]);
const canTransfer = allowedActions.has("transfer") && hasPermission(permissions, "expense.transfer");
const approverQuery = useQuery({
@@ -176,7 +174,13 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
const actionMutation = useMutation({
mutationFn: async (action: "approve" | "reject" | "return" | "transfer") => {
if (!decisionReady) throw new Error("完整报销内容或提交时检查尚未就绪,暂时不能处理");
if (!decisionReady) {
throw new Error(
model
? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,暂时不能处理"
: "完整报销内容或提交时检查尚未就绪,暂时不能处理",
);
}
if (action === "approve") return approveWorkflowTask(taskId);
if (action === "reject") return rejectWorkflowTask(taskId, rejectComment.trim());
if (action === "return") return returnWorkflowTask(taskId, returnComment.trim());
@@ -205,7 +209,11 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
function requestAction(action: WorkflowConfirmAction) {
if (!decisionReady) {
setActionError(online ? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。" : "报销审批需联网核对完整报销内容后才能处理。");
setActionError(!online
? "报销审批需联网核对完整报销内容后才能处理。"
: model
? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。"
: "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。");
return;
}
const comment = action === "reject" ? rejectComment : action === "return" ? returnComment : "";
@@ -322,7 +330,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
);
const timeline = (
<SectionPanel title="处理历史">
<div className="detail-timeline">
<div className="detail-timeline" data-testid="reimbursement-workflow-history">
{detail.history.length ? detail.history.map((item) => (
<div className="detail-timeline-item" key={item.id}>
<div className="font-medium text-zinc-900">{workflowActionLabel(item.action)} · {item.actorName || item.actorEmployeeNo}</div>
@@ -433,8 +441,8 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
backAction: <Button asChild variant="ghost"><Link replace to={backTarget}>返回审批列表</Link></Button>,
beforeEvidence: detail.task.status !== "pending" ? <p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">当前显示报销单最新版本;审批意见与提交检查保留历史语义。</p> : null,
afterEvidence: snapshot?.budgetCheckStatus === "missing" ? <p className="text-sm text-zinc-500" id="reimbursement-workspace-snapshot">该次提交未留存预算快照。</p> : null,
timeline,
actionPanel,
asideContext: timeline,
}}
/>
{dialogs}
@@ -87,9 +87,12 @@ test("B-2b 报销工作台双端共享内容合同且 task detail 只由路由
expect(shell).toContain("<ReimbursementItemEvidenceList");
expect(shell.indexOf("slots.beforeEvidence")).toBeLessThan(shell.indexOf("<ReimbursementItemEvidenceList"));
expect(shell.indexOf("<ReimbursementItemEvidenceList")).toBeLessThan(shell.indexOf("slots.afterEvidence"));
expect(shell.indexOf("slots.afterEvidence")).toBeLessThan(shell.indexOf("slots.timeline"));
}
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(desktopRoute.match(/fetchWorkflowTaskDetail\(/g)).toHaveLength(1);
expect(mobileRoute.match(/fetchWorkflowTaskDetail\(/g)).toHaveLength(1);
expect(approvalAdapter).not.toContain("fetchWorkflowTaskDetail");
@@ -415,6 +415,8 @@ test.describe("R102 reimbursement main flow", () => {
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("¥168.30");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("明细合计");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥168.30");
await expect(page.getByTestId("reimbursement-expense-items-table").getByRole("columnheader")).toHaveText(["序号", "费用类型", "发生日期", "费用说明", "金额", "票据", "操作"]);
await expect(page.getByTestId("approval-item-evidence")).toContainText("未关联");
await expect(page.getByTestId("approval-item-evidence")).toContainText("这条明细尚无对应票据");
@@ -451,6 +453,8 @@ test.describe("R102 reimbursement main flow", () => {
await page.getByTestId("mobile-reimbursement-card").getByRole("link").click();
await expect(page.getByTestId("reimbursement-approval-evidence").getByTestId("approval-item-evidence")).toHaveCount(1);
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText("¥168.30");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("明细合计");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥168.30");
});
test("管理员查看他人报销时保持只读并可在移动端切换全部报销", async ({ page }) => {
@@ -0,0 +1,149 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
import { buildReimbursementApprovalEvidence } from "../../src/lib/reimbursementApprovalProjection";
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("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 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("打印人:");
}
});
@@ -387,6 +387,14 @@ test.describe("R7E 审批动作二次确认", () => {
submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
},
leaveRequest: null,
history: Array.from({ length: 6 }, (_, index) => ({
id: `history-${index + 1}`,
action: index === 0 ? "submit" : "approve",
actorName: index === 0 ? "申请人" : `审批人 ${index}`,
actorEmployeeNo: `E${index + 1}`,
comment: index === 5 ? "这是一条用于验证右栏长意见自然换行且不会把审批动作推到历史之后的合成长意见。".repeat(3) : `第 ${index + 1} 条处理意见`,
createdAt: pendingTaskCreatedAt,
})),
};
const reimbursementDetail = {
id: reimbursementId,
@@ -425,6 +433,22 @@ test.describe("R7E 审批动作二次确认", () => {
await expect(page.getByRole("heading", { name: "报销单 BX-APPROVAL-001" })).toHaveCount(1);
await expect(page.getByText("客户拜访交通费", { exact: true })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("客户拜访打车");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("明细合计");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥180.00");
await expect(page.getByText("报销总额与明细汇总不一致,请联系管理员")).toHaveCount(0);
await expect(page.getByText("明细金额无法完整核对,请联系管理员")).toHaveCount(0);
await expect(page.getByTestId("workflow-task-approve")).toBeEnabled();
await expect(page.getByTestId("workflow-task-return-toggle")).toBeEnabled();
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeEnabled();
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeEnabled();
await expect(page.getByTestId("reimbursement-workflow-history").locator(".detail-timeline-item")).toHaveCount(6);
await expect(page.getByRole("heading", { name: "处理历史" })).toHaveCount(1);
const actionBox = await page.getByTestId("workflow-task-action-panel").boundingBox();
const historyBox = await page.getByTestId("reimbursement-workflow-history").boundingBox();
expect(actionBox).not.toBeNull();
expect(historyBox).not.toBeNull();
expect(actionBox!.y + actionBox!.height).toBeLessThanOrEqual(historyBox!.y);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2)).toBe(true);
await expect(page.getByTestId("approval-unlinked-invoices")).toContainText("FP-002");
await expect(page.locator("#reimbursement-workspace-snapshot")).toContainText("该次提交未留存预算快照");
await expect(page.getByText("当前申报金额高于已读取的票面金额合计")).toBeVisible();
@@ -469,4 +493,146 @@ test.describe("R7E 审批动作二次确认", () => {
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeDisabled();
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeDisabled();
});
test("移动报销审批保留正文末尾历史且底部动作不遮挡", async ({ page }) => {
const reimbursementId = "reimbursement-mobile-history";
const taskDetail = {
...pendingTaskDetail,
workflowInstance: {
...pendingTaskDetail.workflowInstance,
entityType: "reimbursement",
contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "not_applicable", parseStatus: "ready" },
},
reimbursement: {
id: reimbursementId,
formNumber: "BX-MOBILE-HISTORY",
status: "reviewing",
reimbursementDate: "2026-09-01",
totalAmount: "100.00",
description: "移动端历史布局",
owner: { id: "owner", employeeNo: "E001", name: "申请人" },
submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
},
leaveRequest: null,
history: Array.from({ length: 6 }, (_, index) => ({
id: `mobile-history-${index + 1}`,
action: index === 0 ? "submit" : "approve",
actorName: index === 0 ? "申请人" : `审批人 ${index}`,
actorEmployeeNo: `M${index + 1}`,
comment: index === 5 ? "用于验证移动端长意见换行与底部安全区的合成长意见。".repeat(4) : `移动端第 ${index + 1} 条意见`,
createdAt: pendingTaskCreatedAt,
})),
};
const reimbursementDetail = {
id: reimbursementId,
formNumber: "BX-MOBILE-HISTORY",
status: "reviewing",
reimbursementDate: "2026-09-01",
totalAmount: "100.00",
description: "移动端历史布局",
owner: { id: "owner", employeeNo: "E001", name: "申请人" },
items: [{
id: "mobile-item",
expenseTypeId: "mobile-type",
expenseDate: "2026-09-01",
amount: "100.00",
description: "移动端合成明细",
assetPriorApprovalBasis: null,
expenseType: { id: "mobile-type", code: "travel_meal", name: "伙食补助费", category: "travel", enabled: true, requiresInvoice: false },
}],
invoices: [],
attachments: [],
};
await mockWorkflowConfirmApis(page, { taskDetail, reimbursementDetail });
await page.setViewportSize({ width: 375, height: 667 });
await login(page, MANAGER);
await page.goto(`/mobile/approvals/${pendingTask.id}`);
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥100.00");
await expect(page.getByTestId("reimbursement-workflow-history").locator(".detail-timeline-item")).toHaveCount(6);
const assertMobileGeometry = async () => {
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2)).toBe(true);
const lastHistory = page.getByTestId("reimbursement-workflow-history").locator(".detail-timeline-item").last();
await page.evaluate(() => {
const scroller = document.querySelector<HTMLElement>(".hyb-mobile-shell .overflow-y-auto");
scroller?.scrollTo(0, scroller.scrollHeight);
});
const historyBox = await lastHistory.boundingBox();
const stickyBox = await page.getByTestId("mobile-approvals-sticky-actions").boundingBox();
expect(historyBox).not.toBeNull();
expect(stickyBox).not.toBeNull();
expect(historyBox!.y + historyBox!.height).toBeLessThanOrEqual(stickyBox!.y);
};
await assertMobileGeometry();
await page.setViewportSize({ width: 667, height: 375 });
await assertMobileGeometry();
});
for (const scenario of [
{
name: "权威总额与明细汇总不一致",
itemAmount: "99.99",
expectedMessage: "报销总额与明细汇总不一致,请联系管理员",
},
{
name: "明细金额无法解析",
itemAmount: "invalid",
expectedMessage: "明细金额无法完整核对,请联系管理员",
},
]) {
test(`PC 报销${scenario.name}时四类审批入口 fail closed`, async ({ page }) => {
const reimbursementId = `reimbursement-integrity-${scenario.itemAmount === "invalid" ? "unavailable" : "mismatch"}`;
const taskDetail = {
...pendingTaskDetail,
workflowInstance: {
...pendingTaskDetail.workflowInstance,
entityType: "reimbursement",
contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "not_applicable", parseStatus: "ready" },
},
reimbursement: {
id: reimbursementId,
formNumber: "BX-INTEGRITY-001",
status: "reviewing",
reimbursementDate: "2026-09-01",
totalAmount: "100.00",
description: "合成完整性场景",
owner: { id: "owner", employeeNo: "E001", name: "申请人" },
submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
},
leaveRequest: null,
};
const reimbursementDetail = {
id: reimbursementId,
formNumber: "BX-INTEGRITY-001",
status: "reviewing",
reimbursementDate: "2026-09-01",
totalAmount: "100.00",
description: "合成完整性场景",
owner: { id: "owner", employeeNo: "E001", name: "申请人" },
items: [{
id: "item-integrity",
expenseTypeId: "type-integrity",
expenseDate: "2026-09-01",
amount: scenario.itemAmount,
description: "完整性核对明细",
assetPriorApprovalBasis: null,
expenseType: { id: "type-integrity", code: "office_supplies", name: "办公用品", category: "office", enabled: true, requiresInvoice: false },
}],
invoices: [],
attachments: [],
};
await mockWorkflowConfirmApis(page, { taskDetail, reimbursementDetail });
await login(page, MANAGER);
await page.goto(`/workflow/tasks/${pendingTask.id}`);
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥100.00");
await expect(page.getByRole("alert").filter({ hasText: scenario.expectedMessage })).toBeVisible();
await expect(page.getByText("差额", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("workflow-task-approve")).toBeDisabled();
await expect(page.getByTestId("workflow-task-return-toggle")).toBeDisabled();
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeDisabled();
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeDisabled();
});
}
});