优化报销审批证据布局(reimbursement-form-shared-component-migration)

这个提交包含在:
2026-09-02 12:55:24 +08:00
父节点 9fc3b76438
当前提交 4b974fd87c
共修改 17 个文件,包含 291 行新增和 243 行删除
@@ -34,7 +34,7 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots
<BusinessDetailMain>
{slots.headerDetails}
{slots.beforeEvidence}
<SectionPanel title="明细与票据" className="scroll-mt-24">
<SectionPanel title="报销内容" className="scroll-mt-24">
<div id="reimbursement-workspace-evidence">
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} />
</div>
@@ -13,10 +13,7 @@ export type ReimbursementEvidenceCapabilities = {
onOpenAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
onOpenMaterialBundle?: () => void;
onDeleteAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
onReferenceItem?: (item: ReimbursementItemEvidence) => void;
onReferenceInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
onEditItem?: (item: ReimbursementItemEvidence) => void;
onDeleteItem?: (item: ReimbursementItemEvidence) => void;
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence, linkedItemId: string) => void;
linkInvoicePending?: boolean;
linkInvoiceErrors?: Record<string, string>;
@@ -35,198 +32,147 @@ function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidence
return null;
}
function InvoiceCard({
invoice,
capabilities,
linkTargets,
}: {
invoice: ReimbursementInvoiceEvidence;
capabilities: ReimbursementEvidenceCapabilities;
linkTargets: ReimbursementItemEvidence[];
}) {
function itemHasBlockingIssue(item: ReimbursementItemEvidence) {
return item.amountExceeded
|| item.invoices.some((invoice) => invoice.amountEvidenceStatus === "ocr_failed")
|| (item.expenseType?.requiresInvoice !== false && item.invoices.length === 0);
}
function itemInvoiceStatus(item: ReimbursementItemEvidence) {
if (item.expenseType?.requiresInvoice === false && item.invoices.length === 0) return "免票";
if (!item.invoices.length) return "未关联";
if (item.amountExceeded || item.invoices.some((invoice) => invoice.amountEvidenceStatus !== "verified")) return "需核对";
return `${item.invoices.length} 张`;
}
function ItemIssues({ item }: { item: ReimbursementItemEvidence }) {
return (
<>
{item.assetPriorApprovalBasis ? <p className="mt-2 text-sm text-blue-800">事前批准依据:{item.assetPriorApprovalBasis}</p> : null}
{item.standardViolations.map((violation) => (
<p className={violation.controlMode === "block" ? "mt-2 text-sm text-red-700" : "mt-2 text-sm text-amber-800"} key={`${violation.itemId}-${violation.standardId}`}>
提交时费用标准提示:申报 {formatMoney(violation.amount)},标准 {formatMoney(violation.maxAmount)},超出 {formatMoney(violation.exceededAmount)}。审批时请核对原因。
</p>
))}
{item.showOfficeElectronicsHint ? <p className="mt-2 text-sm text-amber-800">该金额通常应选择办公电子设备,请核对费用类型和事前批准依据。</p> : null}
{item.amountExceeded ? <p className="mt-2 text-sm text-red-700">当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。</p> : null}
{item.expenseType?.requiresInvoice !== false && !item.invoices.length ? <p className="mt-2 text-sm text-red-700">这条明细尚无对应票据,暂时无法提交;请在票据区添加票据并选择这条明细。</p> : null}
</>
);
}
function ExpenseItemsTable({ items, capabilities }: { 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">
<table className="w-full min-w-[700px] table-fixed text-left text-sm">
<thead className="bg-zinc-50 text-xs text-zinc-600">
<tr>
<th className="w-12 px-3 py-2.5 font-medium" scope="col">序号</th>
<th className="w-32 px-3 py-2.5 font-medium" scope="col">费用类型</th>
<th className="w-28 px-3 py-2.5 font-medium" scope="col">发生日期</th>
<th className="px-3 py-2.5 font-medium" scope="col">费用说明</th>
<th className="w-28 px-3 py-2.5 text-right font-medium" scope="col">金额</th>
<th className="w-24 px-3 py-2.5 font-medium" scope="col">票据</th>
{editable ? <th className="w-20 px-3 py-2.5 text-right font-medium" scope="col">操作</th> : null}
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 bg-white">
{items.map((item, index) => {
const hasBlockingIssue = itemHasBlockingIssue(item);
return (
<tr data-testid="approval-item-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} id={`reimbursement-item-${item.id}`} key={item.id} tabIndex={hasBlockingIssue ? -1 : undefined}>
<td className="px-3 py-3 align-top text-zinc-500">{index + 1}</td>
<td className="px-3 py-3 align-top font-medium text-zinc-900">{item.expenseType?.name ?? "未分类费用"}</td>
<td className="px-3 py-3 align-top text-zinc-600">{formatDisplayDate(item.expenseDate)}</td>
<td className="px-3 py-3 align-top text-zinc-700"><p>{item.description || "未填写明细说明"}</p><ItemIssues item={item} /></td>
<td className="px-3 py-3 text-right align-top font-medium tabular-nums text-zinc-900">{formatMoney(item.amount)}</td>
<td className={hasBlockingIssue ? "px-3 py-3 align-top font-medium text-red-700" : "px-3 py-3 align-top text-zinc-600"}>{itemInvoiceStatus(item)}</td>
{editable ? <td className="px-3 py-2 text-right align-top"><Button className="min-h-11" onClick={() => capabilities.onEditItem?.(item)} size="sm" type="button" variant="secondary">编辑</Button></td> : null}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function ExpenseItemCards({ items, capabilities }: { items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
return <div className="space-y-3">{items.map((item, index) => {
const hasBlockingIssue = itemHasBlockingIssue(item);
return (
<article className="mobile-list-card" data-testid="approval-item-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} id={`reimbursement-item-${item.id}`} key={item.id} tabIndex={hasBlockingIssue ? -1 : undefined}>
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><p className="text-xs text-zinc-500">明细 {index + 1} · {formatDisplayDate(item.expenseDate)}</p><h3 className="mt-1 font-medium text-zinc-950">{item.expenseType?.name ?? "未分类费用"}</h3></div><p className="shrink-0 font-semibold tabular-nums text-zinc-950">{formatMoney(item.amount)}</p></div>
<p className="mt-2 text-sm leading-6 text-zinc-700">{item.description || "未填写明细说明"}</p>
<p className={hasBlockingIssue ? "mt-2 text-sm font-medium text-red-700" : "mt-2 text-sm text-zinc-600"}>票据:{itemInvoiceStatus(item)}</p>
<ItemIssues item={item} />
{capabilities.onEditItem ? <Button className="mt-3 min-h-11 w-full" onClick={() => capabilities.onEditItem?.(item)} type="button" variant="secondary">编辑</Button> : null}
</article>
);
})}</div>;
}
function InvoiceMaterialRow({ invoice, capabilities, linkTargets }: { invoice: ReimbursementInvoiceEvidence; capabilities: ReimbursementEvidenceCapabilities; linkTargets: ReimbursementItemEvidence[] }) {
const [selectedItemId, setSelectedItemId] = useState("");
const title = invoice.invoiceNumber || invoice.originalName || "未命名发票";
const hasBlockingIssue = !invoice.linkedItemId || invoice.amountEvidenceStatus === "ocr_failed";
return (
<article
className="rounded-lg border border-zinc-200 bg-white p-3"
data-testid="approval-invoice-evidence"
data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined}
tabIndex={hasBlockingIssue ? -1 : undefined}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h4 className="truncate text-sm font-medium text-zinc-900">{title}</h4>
<p className="mt-1 text-xs leading-5 text-zinc-600">
{invoice.sellerName || invoice.documentSubtype || invoice.documentCategory || "票据"}
{" · "}{formatDisplayDate(invoice.invoiceDate)}
{" · "}{formatMoney(invoice.totalAmount)}
</p>
{evidenceStatusCopy(invoice.amountEvidenceStatus) ? (
<p className={invoice.amountEvidenceStatus === "ocr_failed" ? "mt-1 text-xs text-red-700" : "mt-1 text-xs text-zinc-600"}>
{evidenceStatusCopy(invoice.amountEvidenceStatus)}
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{capabilities.onOpenInvoice ? (
<Button aria-label={`查看发票原件:${title}`} className="min-h-11" onClick={() => capabilities.onOpenInvoice?.(invoice)} size="sm" type="button" variant="secondary">
查看原件
</Button>
) : null}
{capabilities.onReferenceInvoice ? (
<Button className="min-h-11" data-testid="approval-reference-invoice" onClick={() => capabilities.onReferenceInvoice?.(invoice)} size="sm" type="button" variant="secondary">
引用到退回意见
</Button>
) : null}
{capabilities.onLinkInvoice && !invoice.linkedItemId ? (
<div className="grid min-w-[220px] gap-2">
<label className="text-xs font-medium text-zinc-700" htmlFor={`reimbursement-link-item-${invoice.id}`}>选择报销明细</label>
<select
className="select-std min-h-11"
disabled={capabilities.linkInvoicePending}
id={`reimbursement-link-item-${invoice.id}`}
onChange={(event) => setSelectedItemId(event.target.value)}
value={selectedItemId}
>
<option value="">请选择报销明细</option>
{linkTargets.map((item, index) => (
<option key={item.id} value={item.id}>明细 {index + 1} · {item.expenseType?.name ?? "未分类费用"} · {formatMoney(item.amount)}</option>
))}
</select>
<Button
className="min-h-11"
disabled={!selectedItemId || capabilities.linkInvoicePending}
onClick={() => capabilities.onLinkInvoice?.(invoice, selectedItemId)}
size="sm"
type="button"
variant="secondary"
>
{capabilities.linkInvoicePending ? "关联中..." : "确认关联"}
</Button>
{capabilities.linkInvoiceErrors?.[invoice.id] ? (
<p className="text-xs text-red-700" role="alert">{capabilities.linkInvoiceErrors[invoice.id]}</p>
) : null}
</div>
) : null}
{capabilities.onUnlinkInvoice && invoice.linkedItemId ? (
<Button className="min-h-11" onClick={() => capabilities.onUnlinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">移除对应关系</Button>
) : null}
<article className="rounded-lg border border-zinc-200 bg-white p-3" data-testid="approval-invoice-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} id={`reimbursement-invoice-${invoice.id}`} tabIndex={hasBlockingIssue ? -1 : undefined}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{capabilities.onOpenInvoice ? <Button aria-label={`查看发票:${title}`} className="h-auto min-h-11 max-w-full justify-start whitespace-normal px-0 text-left" onClick={() => capabilities.onOpenInvoice?.(invoice)} type="button" variant="link">{title}</Button> : <h4 className="text-sm font-medium text-zinc-900">{title}</h4>}
<p className="text-xs leading-5 text-zinc-600">{invoice.sellerName || invoice.documentSubtype || invoice.documentCategory || "票据"} · {formatDisplayDate(invoice.invoiceDate)} · {formatMoney(invoice.totalAmount)}</p>
{evidenceStatusCopy(invoice.amountEvidenceStatus) ? <p className={invoice.amountEvidenceStatus === "ocr_failed" ? "mt-1 text-xs text-red-700" : "mt-1 text-xs text-zinc-600"}>{evidenceStatusCopy(invoice.amountEvidenceStatus)}</p> : null}
</div>
{capabilities.onUnlinkInvoice && invoice.linkedItemId ? <Button className="min-h-11" onClick={() => capabilities.onUnlinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">移除对应关系</Button> : null}
</div>
{capabilities.onLinkInvoice && !invoice.linkedItemId ? (
<div className="mt-3 grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end">
<label className="grid gap-1 text-xs font-medium text-zinc-700" htmlFor={`reimbursement-link-item-${invoice.id}`}>选择报销明细
<select className="select-std min-h-11" disabled={capabilities.linkInvoicePending} id={`reimbursement-link-item-${invoice.id}`} onChange={(event) => setSelectedItemId(event.target.value)} value={selectedItemId}>
<option value="">请选择报销明细</option>
{linkTargets.map((item, index) => <option key={item.id} value={item.id}>明细 {index + 1} · {item.expenseType?.name ?? "未分类费用"} · {formatMoney(item.amount)}</option>)}
</select>
</label>
<Button className="min-h-11" disabled={!selectedItemId || capabilities.linkInvoicePending} onClick={() => capabilities.onLinkInvoice?.(invoice, selectedItemId)} size="sm" type="button" variant="secondary">{capabilities.linkInvoicePending ? "关联中..." : "确认关联"}</Button>
{capabilities.linkInvoiceErrors?.[invoice.id] ? <p className="text-xs text-red-700 sm:col-span-2" role="alert">{capabilities.linkInvoiceErrors[invoice.id]}</p> : null}
</div>
) : null}
</article>
);
}
function AttachmentMaterialRow({ attachment, capabilities }: { attachment: ReimbursementAttachmentEvidence; capabilities: ReimbursementEvidenceCapabilities }) {
return <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-zinc-200 px-3 py-2">
<div className="min-w-0 flex-1">
{capabilities.onOpenAttachment ? <Button aria-label={`查看补充附件:${attachment.originalName}`} className="h-auto min-h-11 max-w-full justify-start whitespace-normal px-0 text-left" onClick={() => capabilities.onOpenAttachment?.(attachment)} type="button" variant="link">{attachment.originalName}</Button> : <p className="truncate text-sm font-medium text-zinc-900">{attachment.originalName}</p>}
<p className="text-xs text-zinc-500">{attachment.mimeType} · {(attachment.fileSize / 1024).toFixed(1)} KB</p>
</div>
{capabilities.onDeleteAttachment ? <Button className="min-h-11" onClick={() => capabilities.onDeleteAttachment?.(attachment)} size="sm" type="button" variant="secondary">删除附件</Button> : null}
</div>;
}
export function ReimbursementItemEvidenceList({ evidence, capabilities = {}, mobile = false }: Props) {
const linkedInvoices = evidence.items.flatMap((item) => item.invoices);
return (
<div className="space-y-5" data-testid="reimbursement-approval-evidence">
<div className="space-y-3">
{evidence.items.length ? evidence.items.map((item, index) => {
const hasBlockingIssue = item.amountExceeded || item.invoices.some((invoice) => invoice.amountEvidenceStatus === "ocr_failed") || (item.expenseType?.requiresInvoice !== false && item.invoices.length === 0);
return (
<article className={mobile ? "mobile-list-card" : "rounded-xl border border-zinc-200 bg-zinc-50/60 p-4"} data-testid="approval-item-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} tabIndex={hasBlockingIssue ? -1 : undefined} key={item.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-medium text-zinc-500">明细 {index + 1}</p>
<h3 className="mt-1 font-medium text-zinc-950">{item.expenseType?.name ?? "未分类费用"}</h3>
<p className="mt-1 text-sm text-zinc-600">
{formatDisplayDate(item.expenseDate)} · {formatMoney(item.amount)}
</p>
</div>
{capabilities.onReferenceItem ? (
<Button className="min-h-11" data-testid="approval-reference-item" onClick={() => capabilities.onReferenceItem?.(item)} size="sm" type="button" variant="secondary">
引用到退回意见
</Button>
) : null}
{capabilities.onEditItem ? (
<Button className="min-h-11" onClick={() => capabilities.onEditItem?.(item)} size="sm" type="button" variant="secondary">编辑明细</Button>
) : null}
{capabilities.onDeleteItem ? (
<Button className="min-h-11" onClick={() => capabilities.onDeleteItem?.(item)} size="sm" type="button" variant="secondary">删除明细</Button>
) : null}
</div>
<p className="mt-3 text-sm leading-6 text-zinc-700">{item.description || "未填写明细说明"}</p>
{item.assetPriorApprovalBasis ? (
<p className="mt-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">
事前批准依据:{item.assetPriorApprovalBasis}
</p>
) : null}
{item.standardViolations.map((violation) => (
<p
className={violation.controlMode === "block"
? "mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
: "mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"}
key={`${violation.itemId}-${violation.standardId}`}
>
提交时费用标准提示:申报 {formatMoney(violation.amount)},标准 {formatMoney(violation.maxAmount)},超出 {formatMoney(violation.exceededAmount)}。审批时请核对原因。
</p>
))}
{item.showOfficeElectronicsHint ? (
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
该金额通常应选择办公电子设备,请核对费用类型和事前批准依据。
</p>
) : null}
{item.amountExceeded ? (
<p className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。
</p>
) : null}
{item.invoices.length || item.expenseType?.requiresInvoice !== false ? <div className="mt-4 space-y-2">
<p className="text-sm font-medium text-zinc-800">所属票据({item.invoices.length})</p>
{item.invoices.length ? item.invoices.map((invoice) => (
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />
)) : (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。
</p>
)}
</div> : null}
</article>
); }) : (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>
)}
</div>
{evidence.unlinkedInvoices.length ? (
<section className="space-y-2 rounded-xl border border-red-200 bg-red-50 p-4" data-testid="approval-unlinked-invoices" id="reimbursement-workspace-unlinked">
<div>
<h3 className="font-medium text-red-900">尚未对应明细的票据({evidence.unlinkedInvoices.length})</h3>
<p className="mt-1 text-sm text-red-800">这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。</p>
</div>
{evidence.unlinkedInvoices.map((invoice) => (
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />
))}
</section>
) : null}
<section className="space-y-2" data-testid="approval-attachments">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-medium text-zinc-900">补充附件({evidence.attachments.length})</h3>
{capabilities.onOpenMaterialBundle ? (
<Button className="min-h-11" onClick={capabilities.onOpenMaterialBundle} size="sm" type="button" variant="secondary">
查看完整材料
</Button>
) : null}
<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>}
</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>
<div className="space-y-2" data-testid="approval-invoices">
<h4 className="text-sm font-medium text-zinc-800">发票({linkedInvoices.length + evidence.unlinkedInvoices.length})</h4>
{linkedInvoices.map((invoice) => <InvoiceMaterialRow capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />)}
{evidence.unlinkedInvoices.length ? <div className="space-y-2 rounded-lg border border-red-200 bg-red-50 p-3" data-testid="approval-unlinked-invoices" id="reimbursement-workspace-unlinked"><p className="text-sm font-medium text-red-900">尚未对应明细({evidence.unlinkedInvoices.length})</p><p className="text-sm text-red-800">这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。</p>{evidence.unlinkedInvoices.map((invoice) => <InvoiceMaterialRow capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />)}</div> : null}
{!linkedInvoices.length && !evidence.unlinkedInvoices.length ? <p className="text-sm text-zinc-500">暂无发票</p> : null}
</div>
<div className="space-y-2" data-testid="approval-attachments">
<h4 className="text-sm font-medium text-zinc-800">补充附件({evidence.attachments.length})</h4>
{evidence.attachments.length ? evidence.attachments.map((attachment) => <AttachmentMaterialRow attachment={attachment} capabilities={capabilities} key={attachment.id} />) : <p className="text-sm text-zinc-500">未上传补充附件</p>}
</div>
{evidence.attachments.length ? evidence.attachments.map((attachment) => (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-zinc-200 px-3 py-2" key={attachment.id}>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-zinc-900">{attachment.originalName}</p>
<p className="text-xs text-zinc-500">{attachment.mimeType} · {(attachment.fileSize / 1024).toFixed(1)} KB</p>
</div>
{capabilities.onOpenAttachment ? (
<Button aria-label={`查看补充附件:${attachment.originalName}`} className="min-h-11" onClick={() => capabilities.onOpenAttachment?.(attachment)} size="sm" type="button" variant="secondary">
查看附件
</Button>
) : null}
{capabilities.onDeleteAttachment ? (
<Button className="min-h-11" onClick={() => capabilities.onDeleteAttachment?.(attachment)} size="sm" type="button" variant="secondary">
删除附件
</Button>
) : null}
</div>
)) : <p className="text-sm text-zinc-500">未上传补充附件</p>}
</section>
</div>
);
@@ -47,7 +47,7 @@ export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots,
</section>
{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>
<h2 className="mb-3 text-base font-semibold text-zinc-900">报销内容</h2>
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} mobile />
</section>
{slots.afterEvidence}
@@ -0,0 +1,34 @@
import { useMemo, useState } from "react";
import type { ReimbursementApprovalEvidenceProjection } from "../../lib/reimbursementApprovalProjection";
import { buildReimbursementReturnReference } from "../../lib/reimbursementApprovalProjection";
import { formatDisplayDate } from "../../lib/displayFormat";
import { Button } from "../ui/button";
type Props = { evidence: ReimbursementApprovalEvidenceProjection; onReference: (reference: string) => void };
export function ReimbursementReturnReferencePicker({ evidence, onReference }: Props) {
const [showAll, setShowAll] = useState(false);
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const candidates = useMemo(() => {
const items = evidence.items.map((item) => ({
id: `item:${item.id}`,
kind: "item" as const,
label: `明细:${item.expenseType?.name ?? "未分类费用"} · ${formatDisplayDate(item.expenseDate)}`,
abnormal: item.amountExceeded || item.standardViolations.length > 0 || item.showOfficeElectronicsHint || item.invoices.some((invoice) => invoice.amountEvidenceStatus !== "verified") || (item.expenseType?.requiresInvoice !== false && item.invoices.length === 0),
reference: buildReimbursementReturnReference({ item }),
}));
const invoices = [...evidence.items.flatMap((item) => item.invoices), ...evidence.unlinkedInvoices].map((invoice) => ({
id: `invoice:${invoice.id}`,
kind: "invoice" as const,
label: `发票:${invoice.invoiceNumber || invoice.originalName || "未命名发票"}`,
abnormal: !invoice.linkedItemId || invoice.amountEvidenceStatus !== "verified",
reference: buildReimbursementReturnReference({ invoice }),
}));
return [...items, ...invoices].sort((left, right) => Number(right.abnormal) - Number(left.abnormal));
}, [evidence]);
const visible = showAll ? candidates : candidates.filter((candidate) => candidate.abnormal);
return <div className="space-y-2 rounded-lg border border-zinc-200 bg-zinc-50 p-3" data-testid="reimbursement-return-reference-picker">
<div className="flex items-center justify-between gap-2"><p className="text-xs font-medium text-zinc-700">引用报销内容</p>{candidates.some((candidate) => !candidate.abnormal) ? <Button className="min-h-11" onClick={() => setShowAll((value) => !value)} size="sm" type="button" variant="ghost">{showAll ? "只看需关注" : "展开全部"}</Button> : null}</div>
{visible.length ? visible.map((candidate) => <label className="flex min-h-11 cursor-pointer items-center gap-2 rounded-md px-2 text-sm text-zinc-700 hover:bg-white" key={candidate.id}><input checked={selected.has(candidate.id)} data-testid={candidate.kind === "item" ? "approval-reference-item" : "approval-reference-invoice"} onChange={(event) => { if (!event.target.checked) return; setSelected((current) => new Set(current).add(candidate.id)); onReference(candidate.reference); }} type="checkbox" /><span>{candidate.label}{candidate.abnormal ? " · 需关注" : ""}</span></label>) : <p className="text-xs text-zinc-500">当前没有需优先引用的问题;可展开全部选择。</p>}
</div>;
}
@@ -372,6 +372,7 @@ export function MobileReimbursementDetailPage() {
mutationFn: (itemId: string) => deleteReimbursementItem(reimbursementId, itemId),
onSuccess: async () => {
await invalidateDetail();
setItemForm(null);
setConfirmDeleteItemId("");
toast.success("明细已删除");
},
@@ -924,7 +925,6 @@ export function MobileReimbursementDetailPage() {
const sourceItem = detail.items.find((candidate) => candidate.id === item.id);
if (sourceItem) startEditItem(sourceItem);
} : undefined,
onDeleteItem: canMaintainReimbursement ? (item: { id: string }) => setConfirmDeleteItemId(item.id) : undefined,
onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined,
linkInvoicePending: linkInvoiceItemMutation.isPending,
linkInvoiceErrors: invoiceItemLinkErrors,
@@ -961,7 +961,7 @@ export function MobileReimbursementDetailPage() {
<label className="block space-y-1 text-sm text-zinc-700"><span>金额</span><Input disabled={itemSaveMutation.isPending} inputMode="decimal" value={itemForm.amount} onChange={(event) => setItemForm((current) => current ? { ...current, amount: event.target.value } : current)} /></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>说明</span><Textarea disabled={itemSaveMutation.isPending} rows={2} value={itemForm.description} onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)} /></label>
{shouldShowOfficeElectronicsHint(expenseTypes.find((type) => type.id === itemForm.expenseTypeId), itemForm.amount) ? <p className="text-xs text-amber-700">该金额通常应选择办公电子设备。</p> : null}
<div className="grid grid-cols-2 gap-2"><Button disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending} onClick={() => itemSaveMutation.mutate(itemForm)} type="button">保存</Button><Button disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)} type="button" variant="secondary">取消</Button></div>
<div className="grid grid-cols-2 gap-2"><Button disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending} onClick={() => itemSaveMutation.mutate(itemForm)} type="button">保存</Button><Button disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)} type="button" variant="secondary">取消</Button>{itemForm.mode === "edit" && itemForm.itemId ? <Button className="col-span-2" data-testid="reimbursement-item-delete" disabled={itemSaveMutation.isPending} onClick={() => setConfirmDeleteItemId(itemForm.itemId ?? "")} type="button" variant="destructive">删除明细</Button> : null}</div>
</div>
) : <p className="text-xs text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
</section>
+3 -2
查看文件
@@ -327,6 +327,8 @@ export function ReimbursementDetailPage() {
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
]);
setItemEditor(null);
setConfirmDeleteItemId("");
setItemError("");
toast.success("明细已删除");
},
@@ -1056,7 +1058,6 @@ export function ReimbursementDetailPage() {
const sourceItem = detail.items.find((candidate) => candidate.id === item.id);
if (sourceItem) startEditItem(sourceItem);
} : undefined,
onDeleteItem: canMaintainReimbursement ? (item: { id: string }) => setConfirmDeleteItemId(item.id) : undefined,
onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined,
linkInvoicePending: linkInvoiceItemMutation.isPending,
linkInvoiceErrors: invoiceItemLinkErrors,
@@ -1081,7 +1082,7 @@ export function ReimbursementDetailPage() {
{itemError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{itemError}</p> : null}
{canMaintainReimbursement ? (
<SectionPanel title="维护报销内容" actions={<div className="flex flex-wrap gap-2"><Button data-testid="reimbursement-item-add" disabled={itemSaveMutation.isPending} onClick={startCreateItem} type="button" variant="secondary">新增明细</Button><Button data-testid={TEST_IDS.reimbursement.invoiceAdd} disabled={!canOpenInvoiceBindDialog} onClick={() => handleInvoiceDialogOpenChange(true)} type="button" variant="secondary">添加发票</Button><label className="inline-flex min-h-11 cursor-pointer items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-700" htmlFor="reimbursement-attachment-upload">选择附件</label><input className="hidden" id="reimbursement-attachment-upload" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files = Array.from(event.target.files ?? []); if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) { setSelectedAttachmentFiles([]); event.target.value = ""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return; } setSelectedAttachmentFiles(files); }} /><Button disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending} onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)} type="button" variant="secondary">{uploadAttachmentsMutation.isPending ? "上传中..." : selectedAttachmentFiles.length ? `上传 ${selectedAttachmentFiles.length} 个附件` : "上传附件"}</Button></div>}>
{itemEditor ? <ReimbursementItemEditor mode={itemEditor.mode} expenseTypes={expenseTypes} expenseTypesLoading={expenseTypesQuery.isLoading} expenseTypesError={expenseTypesQuery.isError} departmentId={detail.departmentId} initialValues={editorInitialValues} onSave={handleItemSave} onCancel={handleItemCancel} isPending={itemSaveMutation.isPending} /> : <p className="text-sm text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
{itemEditor ? <ReimbursementItemEditor mode={itemEditor.mode} expenseTypes={expenseTypes} expenseTypesLoading={expenseTypesQuery.isLoading} expenseTypesError={expenseTypesQuery.isError} departmentId={detail.departmentId} initialValues={editorInitialValues} onSave={handleItemSave} onCancel={handleItemCancel} onDelete={itemEditor.mode === "edit" ? () => setConfirmDeleteItemId(itemEditor.itemId) : undefined} isPending={itemSaveMutation.isPending} /> : <p className="text-sm text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
</SectionPanel>
) : null}
</>
@@ -32,6 +32,7 @@ export interface ReimbursementItemEditorProps {
initialValues: ItemEditorInitialValues;
onSave: (data: ItemEditorSaveData) => void;
onCancel: () => void;
onDelete?: () => void;
isPending: boolean;
}
@@ -44,6 +45,7 @@ export function ReimbursementItemEditor({
initialValues,
onSave,
onCancel,
onDelete,
isPending,
}: ReimbursementItemEditorProps) {
const [expenseTypeId, setExpenseTypeId] = useState(initialValues.expenseTypeId);
@@ -216,6 +218,11 @@ export function ReimbursementItemEditor({
>
取消
</Button>
{mode === "edit" && onDelete ? (
<Button data-testid="reimbursement-item-delete" disabled={isPending} onClick={onDelete} type="button" variant="destructive">
删除明细
</Button>
) : null}
</div>
</div>
);
@@ -25,6 +25,7 @@ import { StatusBadge } from "../../components/common/StatusBadge";
import { useAttachmentDownloadReasonDialog } from "../../components/common/useAttachmentDownloadReasonDialog";
import { ReimbursementDesktopWorkspaceShell } from "../../components/reimbursements/ReimbursementDesktopWorkspaceShell";
import { ReimbursementMobileWorkspaceShell } from "../../components/reimbursements/ReimbursementMobileWorkspaceShell";
import { ReimbursementReturnReferencePicker } from "../../components/reimbursements/ReimbursementReturnReferencePicker";
import { BusinessDetailActionPanel } from "../../components/patterns/BusinessDetailTemplate";
import { MobileStickyActionBar } from "../../components/patterns/MobileStickyActionBar";
import { SectionPanel } from "../../components/patterns/SectionPanel";
@@ -34,10 +35,7 @@ import { Textarea } from "../../components/ui/textarea";
import { approvalRoleLabel, workflowActionLabel } from "../../lib/approvalDisplay";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../../lib/attachmentGovernance";
import { formatDisplayDateTime } from "../../lib/displayFormat";
import {
buildReimbursementApprovalEvidence,
buildReimbursementReturnReference,
} from "../../lib/reimbursementApprovalProjection";
import { buildReimbursementApprovalEvidence } from "../../lib/reimbursementApprovalProjection";
import {
buildReimbursementWorkspaceHeader,
buildReimbursementWorkspaceIssueAnchors,
@@ -295,6 +293,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
<Label htmlFor={`${layout}-reimbursement-return-comment`}>退回意见</Label>
<p className="text-xs text-zinc-500">退回后原单可修改并重新提交,单号和审批历史会保留。</p>
<Textarea data-testid={layout === "desktop" ? "workflow-task-return-comment" : "mobile-approvals-comment"} id={`${layout}-reimbursement-return-comment`} value={returnComment} onChange={(event) => setReturnComment(event.target.value)} />
<ReimbursementReturnReferencePicker evidence={model.evidence} onReference={appendReturnReference} />
<Button className="min-h-11 w-full" data-testid={layout === "desktop" ? "workflow-task-return" : "mobile-approvals-return-submit"} disabled={actionDisabled || !returnComment.trim()} onClick={() => requestAction("return")} type="button" variant="secondary">确认退回</Button>
</div>
) : null}
@@ -341,8 +340,6 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => void openAttachment(attachment.id),
onOpenMaterialBundle: hasMaterials ? () => void openAllMaterials() : undefined,
onReferenceItem: (item: Parameters<typeof buildReimbursementReturnReference>[0]["item"]) => appendReturnReference(buildReimbursementReturnReference({ item })),
onReferenceInvoice: (invoice: Parameters<typeof buildReimbursementReturnReference>[0]["invoice"]) => appendReturnReference(buildReimbursementReturnReference({ invoice })),
};
const dialogs = (
@@ -1257,6 +1257,8 @@ test("已完成审批只保留中文只读摘要", async ({ page }) => {
await page.goto("/workflow/tasks/task-wave3-completed");
await expect(page.getByTestId("workflow-task-action-panel")).toBeVisible();
await expect(page.getByTestId("reimbursement-expense-items-table")).toBeVisible();
await expect(page.getByTestId("approval-item-evidence").getByRole("button")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByTestId("workflow-task-action-panel")).toContainText("审批角色:财务");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月27日 18:10");
@@ -1308,18 +1310,19 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
await expect(page.getByTestId("reimbursement-back")).toContainText("返回报销列表");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("¥1,860.00");
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("¥860.00");
await expect(page.getByTestId("approval-item-evidence").getByRole("button")).toHaveCount(0);
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票:/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const pcInvoicePreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ });
const pcInvoicePreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票:/ });
const pcMaterialPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: "查看完整材料" });
const pcAttachmentPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ });
await pcInvoicePreview.focus();
await expect(pcInvoicePreview).toBeFocused();
await page.keyboard.press("Tab");
await pcMaterialPreview.focus();
await expect(pcMaterialPreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(pcInvoicePreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(pcAttachmentPreview).toBeFocused();
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("负责人复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("付款复核");
@@ -1332,24 +1335,25 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile/reimbursements/reim-wave3");
await expect(page.getByTestId("mobile-reimbursement-detail-header")).toBeVisible();
await expect(page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
await expect(page.getByRole("heading", { name: "报销内容", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText(visualMe.name);
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText("¥1,860.00");
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("¥860.00");
await expect(page.getByTestId("approval-item-evidence").getByRole("button")).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("负责人复核");
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票:/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const mobileInvoicePreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ });
const mobileInvoicePreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票:/ });
const mobileMaterialPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: "查看完整材料" });
const mobileAttachmentPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ });
await mobileInvoicePreview.focus();
await expect(mobileInvoicePreview).toBeFocused();
await page.keyboard.press("Tab");
await mobileMaterialPreview.focus();
await expect(mobileMaterialPreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(mobileInvoicePreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(mobileAttachmentPreview).toBeFocused();
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("付款完成");
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
@@ -1364,7 +1368,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.goto("/reimbursements/reim-wave3");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByRole("heading", { name: `报销单 ${reimbursementDetail.formNumber}` })).toBeVisible();
await expect(page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
await expect(page.getByRole("heading", { name: "报销内容" })).toBeVisible();
await expect(page.getByRole("heading", { name: "当前状态与下一步" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "业务摘要" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "预算与审核提示" })).toHaveCount(0);
@@ -1551,7 +1555,7 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
await page.goto("/mobile/reimbursements/reim-wave3");
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
await expect(page.getByRole("heading", { name: "报销内容", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText(visualMe.name);
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
@@ -415,11 +415,11 @@ 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("approval-item-evidence")).toContainText("明细 1");
await expect(page.getByTestId("approval-item-evidence")).toContainText("所属票据(0)");
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("这条明细尚无对应票据");
await page.getByRole("button", { name: "编辑", exact: true }).click();
await page.getByRole("button", { name: "编辑", exact: true }).first().click();
const editor = page.getByTestId("reimbursement-header-editor");
await editor.getByLabel("报销日期").fill("2026-05-07");
await editor.getByLabel("报销事由").fill("办公网络费用");
@@ -533,7 +533,7 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.getByTestId("reimbursement-item-add")).toHaveCount(0);
await expect(page.getByRole("combobox", { name: "关联项目" })).toHaveCount(0);
await expect(page.getByTestId("approval-unlinked-invoices")).toContainText("R102-OTHER");
await expect(page.getByTestId("approval-item-evidence")).toContainText("所属票据(0)");
await expect(page.getByTestId("approval-item-evidence")).toContainText("未关联");
await expect(page.getByText("草稿或驳回状态可移除")).toHaveCount(0);
await page.setViewportSize({ width: 375, height: 812 });
@@ -566,7 +566,7 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.getByText("只读查看")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /新增|编辑|删除/ })).toHaveCount(0);
await expect(page.getByTestId("approval-unlinked-invoices")).toBeVisible();
await expect(page.getByTestId("approval-item-evidence")).toContainText("所属票据(0)");
await expect(page.getByTestId("approval-item-evidence")).toContainText("票据:未关联");
await expect(page.getByRole("button", { name: "移除" })).toHaveCount(0);
const routeState = await page.evaluate(() => window.history.state?.usr ?? null);
expect(routeState?.from).toContain("status=reviewing");
@@ -584,7 +584,7 @@ test.describe("R102 reimbursement main flow", () => {
test("报销明细记录切换时支持取消保持与确认放弃", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("approval-item-evidence").getByRole("button", { name: "编辑明细" }).click();
await page.getByTestId("approval-item-evidence").getByRole("button", { name: "编辑", exact: true }).click();
const addItem = page.getByTestId("reimbursement-item-add");
await addItem.click();
@@ -819,7 +819,7 @@ async function exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(
const itemSelect = page.getByLabel("选择报销明细");
await itemSelect.selectOption(targetItemId);
await itemSelect.locator("..").getByRole("button", { name: "确认关联", exact: true }).click();
await page.getByTestId("approval-unlinked-invoices").getByRole("button", { name: "确认关联", exact: true }).click();
await expect.poll(() => state.invoiceItemLinkRequests).toEqual([{
invoiceId: invoice.id,
linkedItemId: targetItemId,
@@ -877,7 +877,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("12345679");
await expect(page.getByTestId("reimbursement-approval-evidence")).not.toContainText("无代码");
await expect(page.getByTestId("reimbursement-approval-evidence")).not.toContainText("未识别销售方");
await expect(page.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2);
await expect(page.getByRole("button", { name: /查看发票:/ })).toHaveCount(2);
await expect(page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "查看附件", exact: true })).toHaveCount(0);
await page.locator("#reimbursement-attachment-upload").setInputFiles({
name: "付款截图.png",
@@ -1103,7 +1105,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.getByTestId("reimbursement-submit").click();
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细");
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。")).toBeVisible();
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请在票据区添加票据并选择这条明细。")).toBeVisible();
await expect(page.getByText("当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。")).toBeVisible();
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toBeVisible();
await expect(page.getByText("票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。")).toBeVisible();
@@ -1129,7 +1131,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
invoiceId: unlinkedInvoiceId,
linkedItemId: "item-missing-r111",
}]);
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。")).toHaveCount(0);
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请在票据区添加票据并选择这条明细。")).toHaveCount(0);
await expect(page.getByText("当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。")).toBeVisible();
const structuredGuardDetails = state.submitGuardDetails;
@@ -1155,7 +1157,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-submit").click();
await expect(page.getByTestId("reimbursement-action-error")).toContainText("票据检查未通过");
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。")).toBeVisible();
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请在票据区添加票据并选择这条明细。")).toBeVisible();
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toBeVisible();
await expect(page.getByText("票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。")).toBeVisible();
await expect(page.getByText(
@@ -1196,7 +1198,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await login(mobile.page);
await mobile.page.goto(`/mobile/reimbursements/${reimbursementId}`);
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(mobile.page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
await expect(mobile.page.getByRole("heading", { name: "报销内容", exact: true })).toBeVisible();
const beforeDialogMetrics = await collectVisualMetrics(mobile.page);
expect(beforeDialogMetrics.scrollWidth).toBeLessThanOrEqual(beforeDialogMetrics.width + 2);
@@ -1260,11 +1262,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await expect(mobileEvidence).not.toContainText("无代码");
await expect(mobileEvidence).not.toContainText("未识别销售方");
const mobileItem = mobile.page.getByTestId("approval-item-evidence").filter({ hasText: "客户招待" }).first();
await expect(mobileItem).toContainText("12345678");
await expect(mobileItem).not.toContainText("12345678");
await expect(mobileItem).not.toContainText("12345679");
await expect(mobile.page.getByTestId("approval-unlinked-invoices")).toContainText("12345679");
await expect(mobileEvidence.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2);
await expect(mobileEvidence.getByRole("button", { name: /查看发票:/ })).toHaveCount(2);
await expect(mobile.page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
await expect(mobile.page.getByRole("button", { name: "查看附件", exact: true })).toHaveCount(0);
const maintenanceSection = mobile.page.locator("section").filter({ has: mobile.page.getByRole("heading", { name: "维护报销内容" }) });
await expect(maintenanceSection.getByRole("button", { name: "选择附件" })).toBeVisible();
@@ -432,10 +432,14 @@ test.describe("R7E 审批动作二次确认", () => {
await expect(page.getByTestId("workflow-task-summary")).toHaveCount(0);
await expect(page.getByText("打开完整原始表单")).toHaveCount(0);
await expect(page.getByText(/0 条/)).toHaveCount(0);
await expect(page.getByRole("button", { name: "编辑明细" })).toHaveCount(0);
await expect(page.getByTestId("approval-item-evidence").getByRole("button")).toHaveCount(0);
await expect(page.getByRole("button", { name: "删除明细" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "选择明细" })).toHaveCount(0);
await expect(page.getByText("引用到退回意见")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-return-reference-picker")).toHaveCount(0);
await page.getByTestId("workflow-task-return-toggle").click();
await expect(page.getByTestId("reimbursement-return-reference-picker")).toBeVisible();
await page.getByTestId("approval-reference-item").click();
await expect(page.getByTestId("workflow-task-return-comment")).toHaveValue(/【明细:交通费 · 2026-09-01 · ¥180\.00】/);
});