修复报销票据门禁提示与 OCR 降级(08-30-b2-submit-error-ocr-fallback)
这个提交包含在:
@@ -10,6 +10,35 @@ function readStringList(value: unknown): string[] {
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function countGuardAmountUnavailableItems(value: unknown): number {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item) => (
|
||||
isRecord(item)
|
||||
&& typeof item.itemId === "string"
|
||||
&& item.itemId.length > 0
|
||||
&& readStringList(item.invoiceIds).length > 0
|
||||
)).length
|
||||
: 0;
|
||||
}
|
||||
|
||||
function countGuardAmountExceededItems(value: unknown): number {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item) => (
|
||||
isRecord(item)
|
||||
&& typeof item.itemId === "string"
|
||||
&& item.itemId.length > 0
|
||||
&& typeof item.declaredAmount === "string"
|
||||
&& item.declaredAmount.length > 0
|
||||
&& typeof item.invoiceTotal === "string"
|
||||
&& item.invoiceTotal.length > 0
|
||||
)).length
|
||||
: 0;
|
||||
}
|
||||
|
||||
const FEATURE_FLAG_DISABLED_MESSAGES: Record<string, string> = {
|
||||
portal_home_v1_disabled: "工作台首页当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
announcements_v1_disabled: "公告模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
@@ -131,6 +160,38 @@ export function mapApiErrorMessage(
|
||||
return "所有报销明细金额都必须大于 0,请修改后再提交。";
|
||||
}
|
||||
|
||||
if (details?.items === "invoice_item_guard_failed") {
|
||||
const messages: string[] = [];
|
||||
const rawUnlinkedCount = details.unlinkedInvoiceCount;
|
||||
const unlinkedCount = typeof rawUnlinkedCount === "number"
|
||||
&& Number.isInteger(rawUnlinkedCount)
|
||||
&& rawUnlinkedCount >= 0
|
||||
? rawUnlinkedCount
|
||||
: 0;
|
||||
const missingCount = readStringList(details.missingInvoiceItemIds).length;
|
||||
const unavailableCount = countGuardAmountUnavailableItems(details.amountUnavailableItems);
|
||||
const exceededCount = countGuardAmountExceededItems(details.amountExceededItems);
|
||||
if (unlinkedCount > 0) messages.push(`${unlinkedCount} 张发票未选择报销明细`);
|
||||
if (missingCount > 0) messages.push(`${missingCount} 条需票明细尚未关联发票`);
|
||||
if (unavailableCount > 0) messages.push(`${unavailableCount} 条明细的发票金额未识别`);
|
||||
if (exceededCount > 0) messages.push(`${exceededCount} 条明细的申报金额高于发票合计`);
|
||||
return messages.length
|
||||
? `票据检查未通过:${messages.join(";")}。请按下方提示处理。`
|
||||
: "票据检查未通过,请核对每条明细与发票的关联和金额后重试。";
|
||||
}
|
||||
|
||||
if (details?.invoice === "invalid_unlinked_item_state") {
|
||||
return "这张发票的关联状态已变化,请刷新后重试。";
|
||||
}
|
||||
|
||||
if (details?.reimbursement === "invalid_status_for_invoice_item_link") {
|
||||
return "当前报销单状态已变化,不能再关联发票,请刷新页面。";
|
||||
}
|
||||
|
||||
if (details?.linkedItemId === "item_not_in_reimbursement") {
|
||||
return "所选明细已不存在或不属于本单,请刷新后重新选择。";
|
||||
}
|
||||
|
||||
const passwordErrors = readStringList(details?.password);
|
||||
if (passwordErrors.length) {
|
||||
return `新密码不符合要求:${passwordErrors.join(";")}。`;
|
||||
|
||||
@@ -194,6 +194,7 @@ export type ReimbursementDetail = {
|
||||
mimeType: string | null;
|
||||
fileSize: number | null;
|
||||
linkedItemId: string | null;
|
||||
amountEvidenceStatus: "verified" | "manual_review" | "ocr_failed";
|
||||
}>;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
@@ -225,6 +226,7 @@ export type ReimbursementDetail = {
|
||||
approvalHistory: ReimbursementApprovalHistoryItem[];
|
||||
riskFlags: RiskFlag[];
|
||||
invoiceGuardEnabled?: boolean;
|
||||
invoiceAmountManualReviewRequired?: boolean;
|
||||
items: ReimbursementItem[];
|
||||
};
|
||||
|
||||
@@ -391,6 +393,8 @@ export type ReimbursementSubmitResponse = {
|
||||
precheckDecision: "pass" | "warn" | "manual_review";
|
||||
standardWarnings: ExpenseStandardCheck[];
|
||||
openedRiskFlagCount: number;
|
||||
invoiceAmountManualReviewRequired: boolean;
|
||||
manualReviewItemCount: number;
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | number | undefined>) {
|
||||
@@ -472,6 +476,21 @@ export async function submitReimbursement(reimbursementId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function linkReimbursementInvoiceItem(
|
||||
reimbursementId: string,
|
||||
invoiceId: string,
|
||||
linkedItemId: string,
|
||||
) {
|
||||
return apiRequestData<{
|
||||
reimbursementId: string;
|
||||
invoiceId: string;
|
||||
linkedItemId: string;
|
||||
}>(`/api/reimbursements/${reimbursementId}/invoices/${invoiceId}/item-link`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ linkedItemId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function withdrawReimbursement(reimbursementId: string, comment?: string) {
|
||||
return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/withdraw`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { isApiError } from "../api/types";
|
||||
|
||||
export type InvoiceGuardAmountUnavailableItem = {
|
||||
itemId: string;
|
||||
invoiceIds: string[];
|
||||
};
|
||||
|
||||
export type InvoiceGuardAmountExceededItem = {
|
||||
itemId: string;
|
||||
declaredAmount: string;
|
||||
invoiceTotal: string;
|
||||
};
|
||||
|
||||
export type ReimbursementInvoiceGuardIssues = {
|
||||
unlinkedInvoiceCount: number;
|
||||
unlinkedInvoiceIds: string[];
|
||||
missingInvoiceItemIds: string[];
|
||||
amountUnavailableItems: InvoiceGuardAmountUnavailableItem[];
|
||||
amountExceededItems: InvoiceGuardAmountExceededItem[];
|
||||
};
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string" && item.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
function readNonNegativeInteger(value: unknown): number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
export function parseReimbursementInvoiceGuardIssues(
|
||||
error: unknown,
|
||||
): ReimbursementInvoiceGuardIssues | null {
|
||||
if (!isApiError(error) || error.details?.items !== "invoice_item_guard_failed") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const amountUnavailableItems = Array.isArray(error.details.amountUnavailableItems)
|
||||
? error.details.amountUnavailableItems.flatMap((value) => {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const itemId = "itemId" in value && typeof value.itemId === "string" ? value.itemId : "";
|
||||
if (!itemId) return [];
|
||||
return [{
|
||||
itemId,
|
||||
invoiceIds: readStringArray("invoiceIds" in value ? value.invoiceIds : undefined),
|
||||
}];
|
||||
})
|
||||
: [];
|
||||
|
||||
const amountExceededItems = Array.isArray(error.details.amountExceededItems)
|
||||
? error.details.amountExceededItems.flatMap((value) => {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const itemId = "itemId" in value && typeof value.itemId === "string" ? value.itemId : "";
|
||||
const declaredAmount = "declaredAmount" in value && typeof value.declaredAmount === "string"
|
||||
? value.declaredAmount
|
||||
: "";
|
||||
const invoiceTotal = "invoiceTotal" in value && typeof value.invoiceTotal === "string"
|
||||
? value.invoiceTotal
|
||||
: "";
|
||||
if (!itemId || !declaredAmount || !invoiceTotal) return [];
|
||||
return [{ itemId, declaredAmount, invoiceTotal }];
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
unlinkedInvoiceCount: readNonNegativeInteger(error.details.unlinkedInvoiceCount),
|
||||
unlinkedInvoiceIds: readStringArray(error.details.unlinkedInvoiceIds),
|
||||
missingInvoiceItemIds: readStringArray(error.details.missingInvoiceItemIds),
|
||||
amountUnavailableItems,
|
||||
amountExceededItems,
|
||||
};
|
||||
}
|
||||
|
||||
export function invoiceGuardIssueCount(issues: ReimbursementInvoiceGuardIssues): number {
|
||||
return issues.unlinkedInvoiceCount
|
||||
+ issues.missingInvoiceItemIds.length
|
||||
+ issues.amountUnavailableItems.length
|
||||
+ issues.amountExceededItems.length;
|
||||
}
|
||||
|
||||
export function resolveInvoiceGuardIssuesAfterLink(
|
||||
issues: ReimbursementInvoiceGuardIssues | null,
|
||||
invoiceId: string,
|
||||
linkedItemId: string,
|
||||
): ReimbursementInvoiceGuardIssues | null {
|
||||
if (!issues) return null;
|
||||
const locatedInvoice = issues.unlinkedInvoiceIds.includes(invoiceId);
|
||||
const unlinkedInvoiceIds = issues.unlinkedInvoiceIds.filter((id) => id !== invoiceId);
|
||||
const shouldDecreaseCount = locatedInvoice || issues.unlinkedInvoiceIds.length === 0;
|
||||
const next = {
|
||||
...issues,
|
||||
unlinkedInvoiceCount: shouldDecreaseCount
|
||||
? Math.max(0, issues.unlinkedInvoiceCount - 1)
|
||||
: issues.unlinkedInvoiceCount,
|
||||
unlinkedInvoiceIds,
|
||||
missingInvoiceItemIds: issues.missingInvoiceItemIds.filter((id) => id !== linkedItemId),
|
||||
};
|
||||
return invoiceGuardIssueCount(next) > 0 ? next : null;
|
||||
}
|
||||
|
||||
export function invoiceGuardItemMessages(
|
||||
issues: ReimbursementInvoiceGuardIssues | null,
|
||||
itemId: string,
|
||||
): string[] {
|
||||
if (!issues) return [];
|
||||
const messages: string[] = [];
|
||||
if (issues.missingInvoiceItemIds.includes(itemId)) {
|
||||
messages.push("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。");
|
||||
}
|
||||
if (issues.amountUnavailableItems.some((item) => item.itemId === itemId)) {
|
||||
messages.push("关联发票的票面金额未识别,请重试识别或联系财务处理。");
|
||||
}
|
||||
const exceeded = issues.amountExceededItems.find((item) => item.itemId === itemId);
|
||||
if (exceeded) {
|
||||
messages.push(
|
||||
`申报金额 ¥${exceeded.declaredAmount} 高于关联发票合计 ¥${exceeded.invoiceTotal},请补充发票或调整申报金额。`,
|
||||
);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function invoiceGuardInvoiceMessages(
|
||||
issues: ReimbursementInvoiceGuardIssues | null,
|
||||
invoiceId: string,
|
||||
linkedItemId: string | null,
|
||||
): string[] {
|
||||
if (!issues) return [];
|
||||
const messages: string[] = [];
|
||||
const isUnlinked = issues.unlinkedInvoiceIds.includes(invoiceId)
|
||||
|| (issues.unlinkedInvoiceIds.length === 0 && issues.unlinkedInvoiceCount > 0 && !linkedItemId);
|
||||
if (isUnlinked) {
|
||||
messages.push("这张发票还没有选择报销明细,请在下方完成关联。");
|
||||
}
|
||||
if (issues.amountUnavailableItems.some((item) => item.invoiceIds.includes(invoiceId))) {
|
||||
messages.push("这张发票未识别出票面金额,请重试识别或联系财务处理。");
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function focusFirstInvoiceGuardIssue() {
|
||||
window.requestAnimationFrame(() => {
|
||||
const target = document.querySelector<HTMLElement>('[data-reimbursement-guard-issue="true"]');
|
||||
if (!target) return;
|
||||
target.focus({ preventScroll: true });
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
submitReimbursement,
|
||||
updateReimbursementItem,
|
||||
uploadReimbursementAttachments,
|
||||
linkReimbursementInvoiceItem,
|
||||
type ReimbursementItem,
|
||||
type ReimbursementStatus,
|
||||
} from "../api/reimbursements";
|
||||
@@ -81,6 +82,15 @@ import { cn } from "../lib/utils";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor";
|
||||
import {
|
||||
focusFirstInvoiceGuardIssue,
|
||||
invoiceGuardInvoiceMessages,
|
||||
invoiceGuardIssueCount,
|
||||
invoiceGuardItemMessages,
|
||||
parseReimbursementInvoiceGuardIssues,
|
||||
resolveInvoiceGuardIssuesAfterLink,
|
||||
type ReimbursementInvoiceGuardIssues,
|
||||
} from "../lib/reimbursementInvoiceGuard";
|
||||
|
||||
type ItemFormState = {
|
||||
mode: "create" | "edit";
|
||||
@@ -167,6 +177,9 @@ export function MobileReimbursementDetailPage() {
|
||||
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
|
||||
const [itemError, setItemError] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
|
||||
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
|
||||
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
|
||||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||
const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
|
||||
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
|
||||
@@ -369,6 +382,7 @@ export function MobileReimbursementDetailPage() {
|
||||
await invalidateDetail();
|
||||
setItemForm(null);
|
||||
setItemError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
toast.success("明细已保存");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -408,15 +422,23 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: () => submitReimbursement(reimbursementId),
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (data) => {
|
||||
await invalidateDetail();
|
||||
setSubmitConfirmPrecheck(null);
|
||||
setActionError("");
|
||||
toast.success("已提交审批");
|
||||
setInvoiceGuardIssues(null);
|
||||
toast.success(
|
||||
data.invoiceAmountManualReviewRequired
|
||||
? "已提交,票面金额将由财务核对"
|
||||
: "已提交审批",
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveErrorMessage(error);
|
||||
const issues = parseReimbursementInvoiceGuardIssues(error);
|
||||
setActionError(message);
|
||||
setInvoiceGuardIssues(issues);
|
||||
if (issues) focusFirstInvoiceGuardIssue();
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
@@ -464,6 +486,7 @@ export function MobileReimbursementDetailPage() {
|
||||
setPendingOcrLinkedItemIds((previous) =>
|
||||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
|
||||
);
|
||||
if (succeededIds.length) setInvoiceGuardIssues(null);
|
||||
|
||||
if (!failed.length) {
|
||||
setInvoiceActionError("");
|
||||
@@ -531,6 +554,7 @@ export function MobileReimbursementDetailPage() {
|
||||
onSuccess: async () => {
|
||||
await invalidateDetail();
|
||||
setInvoiceActionError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
toast.success("发票已移除");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -540,6 +564,35 @@ export function MobileReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const linkInvoiceItemMutation = useMutation({
|
||||
mutationFn: ({ invoiceId, linkedItemId }: { invoiceId: string; linkedItemId: string }) =>
|
||||
linkReimbursementInvoiceItem(reimbursementId, invoiceId, linkedItemId),
|
||||
onSuccess: async (_result, variables) => {
|
||||
await invalidateDetail();
|
||||
setInvoiceGuardIssues((previous) => resolveInvoiceGuardIssuesAfterLink(
|
||||
previous,
|
||||
variables.invoiceId,
|
||||
variables.linkedItemId,
|
||||
));
|
||||
setInvoiceItemSelections((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[variables.invoiceId];
|
||||
return next;
|
||||
});
|
||||
setInvoiceItemLinkErrors((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[variables.invoiceId];
|
||||
return next;
|
||||
});
|
||||
toast.success("发票已关联到报销明细");
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
const message = resolveErrorMessage(error, "关联明细失败,请刷新后重试");
|
||||
setInvoiceItemLinkErrors((previous) => ({ ...previous, [variables.invoiceId]: message }));
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAvailableInvoiceMutation = useMutation({
|
||||
mutationFn: (invoiceId: string) => archiveInvoice(invoiceId),
|
||||
onSuccess: async (_result, invoiceId) => {
|
||||
@@ -805,6 +858,7 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
try {
|
||||
setActionError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
const precheck = await precheckReimbursement(reimbursementId);
|
||||
const message = buildPrecheckDialogMessage(precheck);
|
||||
if (precheck.decision === "block") {
|
||||
@@ -1023,6 +1077,20 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{invoiceGuardIssues ? (
|
||||
<section
|
||||
className="mobile-card border-red-300 bg-red-50"
|
||||
data-testid="mobile-reimbursement-invoice-guard-summary"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<h2 className="text-base font-semibold text-red-800">票据检查未通过</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-red-700">
|
||||
共 {invoiceGuardIssueCount(invoiceGuardIssues)} 项需要处理,请按下方明细和发票提示修改。
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-items">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
|
||||
@@ -1114,7 +1182,15 @@ export function MobileReimbursementDetailPage() {
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.items.length ? (
|
||||
detail.items.map((item) => (
|
||||
<article key={item.id} className="mobile-list-card">
|
||||
<article
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"mobile-list-card",
|
||||
invoiceGuardItemMessages(invoiceGuardIssues, item.id).length && "border-red-300 bg-red-50/60",
|
||||
)}
|
||||
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
|
||||
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{item.expenseType?.name ?? "费用"}</div>
|
||||
@@ -1126,6 +1202,15 @@ export function MobileReimbursementDetailPage() {
|
||||
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
|
||||
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
|
||||
) : null}
|
||||
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
|
||||
<p
|
||||
key={message}
|
||||
className="mt-2 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
{canMaintainReimbursement ? (
|
||||
<div className="mobile-two-action-grid mt-3">
|
||||
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
|
||||
@@ -1172,6 +1257,12 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail.invoiceAmountManualReviewRequired ? (
|
||||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
|
||||
本单包含未执行 OCR 的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-xs leading-5 text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</p>
|
||||
@@ -1273,8 +1364,21 @@ export function MobileReimbursementDetailPage() {
|
||||
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
|
||||
? "已关联"
|
||||
: resolveInvoiceNextStepText(invoice);
|
||||
const guardMessages = invoiceGuardInvoiceMessages(
|
||||
invoiceGuardIssues,
|
||||
invoice.id,
|
||||
invoice.linkedItemId,
|
||||
);
|
||||
return (
|
||||
<article key={invoice.id} className="mobile-list-card">
|
||||
<article
|
||||
key={invoice.id}
|
||||
className={cn(
|
||||
"mobile-list-card",
|
||||
guardMessages.length && "border-red-300 bg-red-50/60",
|
||||
)}
|
||||
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
|
||||
tabIndex={guardMessages.length ? -1 : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 w-full items-start gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
|
||||
@@ -1298,6 +1402,64 @@ export function MobileReimbursementDetailPage() {
|
||||
<ChevronRight aria-hidden="true" className="ml-auto mt-2 size-4 text-zinc-400" />
|
||||
</span>
|
||||
</button>
|
||||
{guardMessages.map((message) => (
|
||||
<p
|
||||
key={message}
|
||||
className="mt-3 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
{invoice.amountEvidenceStatus === "manual_review" ? (
|
||||
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-5 text-amber-800">
|
||||
票面金额未自动识别,需由财务查看原件核对。
|
||||
</p>
|
||||
) : null}
|
||||
{invoice.amountEvidenceStatus === "ocr_failed" ? (
|
||||
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm leading-5 text-red-700">
|
||||
OCR 已处理但未取得票面金额,提交前需要重试识别或联系财务。
|
||||
</p>
|
||||
) : null}
|
||||
{canMaintainReimbursement && !invoice.linkedItemId ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<label className="block text-sm font-medium text-zinc-700" htmlFor={`mobile-invoice-item-link-${invoice.id}`}>
|
||||
选择报销明细
|
||||
</label>
|
||||
<select
|
||||
id={`mobile-invoice-item-link-${invoice.id}`}
|
||||
className="select-std min-h-11 w-full"
|
||||
value={invoiceItemSelections[invoice.id] ?? ""}
|
||||
onChange={(event) => setInvoiceItemSelections((previous) => ({
|
||||
...previous,
|
||||
[invoice.id]: event.target.value,
|
||||
}))}
|
||||
>
|
||||
<option value="">请选择明细</option>
|
||||
{detail.items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
className="min-h-11 w-full"
|
||||
type="button"
|
||||
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
|
||||
onClick={() => linkInvoiceItemMutation.mutate({
|
||||
invoiceId: invoice.id,
|
||||
linkedItemId: invoiceItemSelections[invoice.id],
|
||||
})}
|
||||
>
|
||||
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
|
||||
</Button>
|
||||
{invoiceItemLinkErrors[invoice.id] ? (
|
||||
<p className="text-sm leading-5 text-red-700" role="alert">
|
||||
{invoiceItemLinkErrors[invoice.id]}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
@@ -1362,7 +1524,11 @@ export function MobileReimbursementDetailPage() {
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
|
||||
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
|
||||
{actionError ? (
|
||||
<div className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
<div
|
||||
className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
uploadReimbursementAttachments,
|
||||
withdrawReimbursement,
|
||||
linkReimbursementProject,
|
||||
linkReimbursementInvoiceItem,
|
||||
type ReimbursementItem,
|
||||
} from "../api/reimbursements";
|
||||
import { remindWorkflowTask } from "../api/workflow";
|
||||
@@ -95,6 +96,14 @@ import {
|
||||
type ItemEditorSaveData,
|
||||
} from "./reimbursement-detail/ReimbursementItemEditor";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
import {
|
||||
focusFirstInvoiceGuardIssue,
|
||||
invoiceGuardInvoiceMessages,
|
||||
invoiceGuardItemMessages,
|
||||
parseReimbursementInvoiceGuardIssues,
|
||||
resolveInvoiceGuardIssuesAfterLink,
|
||||
type ReimbursementInvoiceGuardIssues,
|
||||
} from "../lib/reimbursementInvoiceGuard";
|
||||
|
||||
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
|
||||
type SelectedInvoiceMap = Record<string, string>;
|
||||
@@ -111,6 +120,9 @@ export function ReimbursementDetailPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
|
||||
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
|
||||
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
|
||||
const [itemError, setItemError] = useState("");
|
||||
@@ -239,22 +251,34 @@ export function ReimbursementDetailPage() {
|
||||
return withdrawReimbursement(reimbursementId, params.comment);
|
||||
}
|
||||
},
|
||||
onSuccess: async (_data, params) => {
|
||||
onSuccess: async (data, params) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
|
||||
]);
|
||||
setActionError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
if (params.action === "submit") {
|
||||
setSubmitConfirmPrecheck(null);
|
||||
}
|
||||
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回" };
|
||||
const requiresManualReview = params.action === "submit"
|
||||
&& data != null
|
||||
&& typeof data === "object"
|
||||
&& "invoiceAmountManualReviewRequired" in data
|
||||
&& data.invoiceAmountManualReviewRequired === true;
|
||||
const actionMessages: Record<string, string> = {
|
||||
submit: requiresManualReview ? "已提交,票面金额将由财务核对" : "已提交",
|
||||
withdraw: "已撤回",
|
||||
};
|
||||
toast.success(actionMessages[params.action] ?? "操作成功");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = buildActionErrorMessage(error, "操作失败,请稍后重试");
|
||||
const issues = parseReimbursementInvoiceGuardIssues(error);
|
||||
setActionError(message);
|
||||
setInvoiceGuardIssues(issues);
|
||||
if (issues) focusFirstInvoiceGuardIssue();
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
@@ -306,6 +330,7 @@ export function ReimbursementDetailPage() {
|
||||
]);
|
||||
setItemEditor(null);
|
||||
setItemError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
toast.success("明细已保存");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -417,6 +442,7 @@ export function ReimbursementDetailPage() {
|
||||
setPendingOcrLinkedItemIds((previous) =>
|
||||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
|
||||
);
|
||||
if (succeededIds.length) setInvoiceGuardIssues(null);
|
||||
|
||||
if (!failedResults.length) {
|
||||
setInvoiceActionError("");
|
||||
@@ -497,6 +523,7 @@ export function ReimbursementDetailPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }),
|
||||
]);
|
||||
setInvoiceActionError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
toast.success("发票已移除");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -506,6 +533,35 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const linkInvoiceItemMutation = useMutation({
|
||||
mutationFn: ({ invoiceId, linkedItemId }: { invoiceId: string; linkedItemId: string }) =>
|
||||
linkReimbursementInvoiceItem(reimbursementId, invoiceId, linkedItemId),
|
||||
onSuccess: async (_result, variables) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
|
||||
setInvoiceGuardIssues((previous) => resolveInvoiceGuardIssuesAfterLink(
|
||||
previous,
|
||||
variables.invoiceId,
|
||||
variables.linkedItemId,
|
||||
));
|
||||
setInvoiceItemSelections((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[variables.invoiceId];
|
||||
return next;
|
||||
});
|
||||
setInvoiceItemLinkErrors((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[variables.invoiceId];
|
||||
return next;
|
||||
});
|
||||
toast.success("发票已关联到报销明细");
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
const message = buildActionErrorMessage(error, "关联明细失败,请刷新后重试");
|
||||
setInvoiceItemLinkErrors((previous) => ({ ...previous, [variables.invoiceId]: message }));
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadInvoiceMutation = useMutation({
|
||||
mutationFn: async (files: File[]) => {
|
||||
const ownerEmployeeId = detailQuery.data?.ownerId;
|
||||
@@ -849,6 +905,7 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
try {
|
||||
setActionError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
if (!hasCompleteTravelSummary(detail)) {
|
||||
const message = "请先补全出差事由、地点和起止日期";
|
||||
setActionError(message);
|
||||
@@ -1159,6 +1216,8 @@ export function ReimbursementDetailPage() {
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
data-testid="reimbursement-action-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{actionError}
|
||||
</p>
|
||||
@@ -1243,9 +1302,13 @@ export function ReimbursementDetailPage() {
|
||||
detail.items.map((item) => (
|
||||
<TableRow
|
||||
data-testid="reimbursement-item-row"
|
||||
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
|
||||
key={item.id}
|
||||
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
|
||||
className={
|
||||
blockStandardChecks.some((check) => check.itemId === item.id)
|
||||
invoiceGuardItemMessages(invoiceGuardIssues, item.id).length
|
||||
? "bg-red-50/70"
|
||||
: blockStandardChecks.some((check) => check.itemId === item.id)
|
||||
? "bg-red-50/70"
|
||||
: warnStandardChecks.some((check) => check.itemId === item.id)
|
||||
? "bg-amber-50/70"
|
||||
@@ -1271,6 +1334,15 @@ export function ReimbursementDetailPage() {
|
||||
{check.exceeded ? `,超出 ${formatMoney(check.exceededAmount)}` : ""}
|
||||
</div>
|
||||
))}
|
||||
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
|
||||
<div
|
||||
key={message}
|
||||
className="mt-2 rounded-md border border-red-200 bg-red-50 px-2 py-1.5 text-xs leading-5 text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>{item.description ?? "-"}</div>
|
||||
@@ -1406,13 +1478,29 @@ export function ReimbursementDetailPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{detail.invoiceAmountManualReviewRequired ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
|
||||
本单包含未执行 OCR 的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
|
||||
{detail.invoices.map((invoice) => {
|
||||
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
|
||||
? "已关联"
|
||||
: resolveInvoiceNextStepText(invoice);
|
||||
const guardMessages = invoiceGuardInvoiceMessages(
|
||||
invoiceGuardIssues,
|
||||
invoice.id,
|
||||
invoice.linkedItemId,
|
||||
);
|
||||
return (
|
||||
<div className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center" key={invoice.id}>
|
||||
<div
|
||||
className={`detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center ${guardMessages.length ? "border-red-300 bg-red-50/60" : ""}`}
|
||||
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
|
||||
key={invoice.id}
|
||||
tabIndex={guardMessages.length ? -1 : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
|
||||
@@ -1432,16 +1520,83 @@ export function ReimbursementDetailPage() {
|
||||
<span className="badge-neutral shrink-0">{resolveInvoiceStatusLabel(invoice.status)}</span>
|
||||
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
|
||||
</button>
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={unbindInvoiceMutation.isPending}
|
||||
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
{(guardMessages.length
|
||||
|| invoice.amountEvidenceStatus === "manual_review"
|
||||
|| invoice.amountEvidenceStatus === "ocr_failed"
|
||||
|| canUnbindInvoices) ? (
|
||||
<div className="w-full space-y-2 md:w-72">
|
||||
{guardMessages.map((message) => (
|
||||
<p
|
||||
key={message}
|
||||
className="rounded-md border border-red-200 bg-white px-3 py-2 text-xs leading-5 text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
{invoice.amountEvidenceStatus === "manual_review" ? (
|
||||
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
|
||||
票面金额未自动识别,需由财务查看原件核对。
|
||||
</p>
|
||||
) : null}
|
||||
{invoice.amountEvidenceStatus === "ocr_failed" ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs leading-5 text-red-700">
|
||||
OCR 已处理但未取得票面金额,提交前需要重试识别或联系财务。
|
||||
</p>
|
||||
) : null}
|
||||
{canMaintainReimbursement && !invoice.linkedItemId ? (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-zinc-700" htmlFor={`invoice-item-link-${invoice.id}`}>
|
||||
选择报销明细
|
||||
</label>
|
||||
<select
|
||||
id={`invoice-item-link-${invoice.id}`}
|
||||
className="select-std w-full"
|
||||
value={invoiceItemSelections[invoice.id] ?? ""}
|
||||
onChange={(event) => setInvoiceItemSelections((previous) => ({
|
||||
...previous,
|
||||
[invoice.id]: event.target.value,
|
||||
}))}
|
||||
>
|
||||
<option value="">请选择明细</option>
|
||||
{detail.items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
|
||||
onClick={() => linkInvoiceItemMutation.mutate({
|
||||
invoiceId: invoice.id,
|
||||
linkedItemId: invoiceItemSelections[invoice.id],
|
||||
})}
|
||||
>
|
||||
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
|
||||
</Button>
|
||||
{invoiceItemLinkErrors[invoice.id] ? (
|
||||
<p className="text-xs leading-5 text-red-700" role="alert">
|
||||
{invoiceItemLinkErrors[invoice.id]}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={unbindInvoiceMutation.isPending}
|
||||
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -107,6 +107,7 @@ function buildInvoice() {
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 4096,
|
||||
linkedItemId: "item-r111" as string | null,
|
||||
amountEvidenceStatus: "verified" as "verified" | "manual_review" | "ocr_failed",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,7 +189,11 @@ function buildReimbursementState() {
|
||||
bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>,
|
||||
itemSaveRequests: [] as Array<Record<string, unknown>>,
|
||||
submitRequestCount: 0,
|
||||
submitGuardDetails: null as null | Record<string, unknown>,
|
||||
invoiceItemLinkRequests: [] as Array<{ invoiceId: string; linkedItemId?: string }>,
|
||||
invoiceItemLinkError: null as null | Record<string, unknown>,
|
||||
invoiceGuardEnabled: false,
|
||||
invoiceAmountManualReviewRequired: false,
|
||||
detailItems: null as null | Array<Record<string, unknown>>,
|
||||
};
|
||||
}
|
||||
@@ -237,6 +242,7 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
|
||||
approvalHistory: [],
|
||||
riskFlags: [],
|
||||
invoiceGuardEnabled: state.invoiceGuardEnabled,
|
||||
invoiceAmountManualReviewRequired: state.invoiceAmountManualReviewRequired,
|
||||
items: state.detailItems ?? [
|
||||
{
|
||||
id: "item-r111",
|
||||
@@ -421,7 +427,61 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
|
||||
if (pathName === `/api/reimbursements/${reimbursementId}/submit` && request.method() === "POST") {
|
||||
state.submitRequestCount += 1;
|
||||
await fulfillJson(route, { id: reimbursementId, status: "reviewing" });
|
||||
if (state.submitGuardDetails) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
requestId: "mock-r111-submit-guard",
|
||||
error: {
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
details: state.submitGuardDetails,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
reimbursementId,
|
||||
status: "reviewing",
|
||||
invoiceAmountManualReviewRequired: state.invoiceAmountManualReviewRequired,
|
||||
manualReviewItemCount: state.invoiceAmountManualReviewRequired ? 1 : 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
pathName.startsWith(`/api/reimbursements/${reimbursementId}/invoices/`)
|
||||
&& pathName.endsWith("/item-link")
|
||||
&& request.method() === "PATCH"
|
||||
) {
|
||||
const invoiceId = pathName.split("/").at(-2) ?? "";
|
||||
const payload = request.postDataJSON() as { linkedItemId?: string };
|
||||
state.invoiceItemLinkRequests.push({ invoiceId, linkedItemId: payload.linkedItemId });
|
||||
if (state.invoiceItemLinkError) {
|
||||
await route.fulfill({
|
||||
status: 409,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
requestId: "mock-r111-item-link",
|
||||
error: {
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
details: state.invoiceItemLinkError,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.invoices = state.invoices.map((invoice) => (
|
||||
invoice.id === invoiceId ? { ...invoice, linkedItemId: payload.linkedItemId ?? null } : invoice
|
||||
));
|
||||
await fulfillJson(route, {
|
||||
reimbursementId,
|
||||
invoiceId,
|
||||
linkedItemId: payload.linkedItemId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -855,6 +915,153 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await expect.poll(() => state.submitRequestCount).toBe(1);
|
||||
});
|
||||
|
||||
test("B2 提交错误在 PC/移动就近呈现,未归属发票可原子选择明细", async ({ page }) => {
|
||||
const state = buildReimbursementState();
|
||||
state.invoiceGuardEnabled = true;
|
||||
state.detailItems = [
|
||||
{
|
||||
id: "item-missing-r111",
|
||||
expenseTypeId: "expense-type-r111",
|
||||
expenseDate: "2026-05-08",
|
||||
amount: "200.00",
|
||||
description: "缺少发票的明细",
|
||||
expenseType: { ...buildExpenseTypes().items[0] },
|
||||
},
|
||||
{
|
||||
id: "item-ocr-r111",
|
||||
expenseTypeId: "expense-type-r111",
|
||||
expenseDate: "2026-05-08",
|
||||
amount: "300.00",
|
||||
description: "金额识别失败的明细",
|
||||
expenseType: { ...buildExpenseTypes().items[0] },
|
||||
},
|
||||
{
|
||||
id: "item-over-r111",
|
||||
expenseTypeId: "expense-type-r111",
|
||||
expenseDate: "2026-05-08",
|
||||
amount: "1000.00",
|
||||
description: "金额超过发票的明细",
|
||||
expenseType: { ...buildExpenseTypes().items[0] },
|
||||
},
|
||||
{
|
||||
id: "item-manual-r111",
|
||||
expenseTypeId: "expense-type-r111",
|
||||
expenseDate: "2026-05-08",
|
||||
amount: "400.00",
|
||||
description: "人工核对票面金额的明细",
|
||||
expenseType: { ...buildExpenseTypes().items[0] },
|
||||
},
|
||||
];
|
||||
const ocrFailedInvoice = {
|
||||
...buildInvoice(),
|
||||
id: "77777777-7777-4777-8777-777777777777",
|
||||
invoiceNumber: "OCR-FAILED",
|
||||
originalName: "金额未识别.pdf",
|
||||
totalAmount: null,
|
||||
linkedItemId: "item-ocr-r111",
|
||||
amountEvidenceStatus: "ocr_failed" as const,
|
||||
};
|
||||
const amountOverInvoice = {
|
||||
...buildInvoice(),
|
||||
id: "88888888-8888-4888-8888-888888888888",
|
||||
invoiceNumber: "AMOUNT-OVER",
|
||||
originalName: "金额不足.pdf",
|
||||
totalAmount: "600.00",
|
||||
linkedItemId: "item-over-r111",
|
||||
};
|
||||
const manualReviewInvoice = {
|
||||
...buildInvoice(),
|
||||
id: "99999999-9999-4999-8999-999999999999",
|
||||
invoiceNumber: "MANUAL-REVIEW",
|
||||
originalName: "待人工核对.pdf",
|
||||
totalAmount: null,
|
||||
linkedItemId: "item-manual-r111",
|
||||
amountEvidenceStatus: "manual_review" as const,
|
||||
};
|
||||
state.invoices = [buildUnlinkedInvoice(), ocrFailedInvoice, amountOverInvoice, manualReviewInvoice];
|
||||
state.invoiceAmountManualReviewRequired = true;
|
||||
state.submitGuardDetails = {
|
||||
items: "invoice_item_guard_failed",
|
||||
unlinkedInvoiceCount: 1,
|
||||
unlinkedInvoiceIds: [unlinkedInvoiceId],
|
||||
missingInvoiceItemIds: ["item-missing-r111"],
|
||||
amountUnavailableItems: [{
|
||||
itemId: "item-ocr-r111",
|
||||
invoiceIds: [ocrFailedInvoice.id],
|
||||
}],
|
||||
amountExceededItems: [{
|
||||
itemId: "item-over-r111",
|
||||
declaredAmount: "1000.00",
|
||||
invoiceTotal: "600.00",
|
||||
}],
|
||||
};
|
||||
|
||||
await mockApis(page, state);
|
||||
await login(page);
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
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("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible();
|
||||
await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible();
|
||||
await expect(page.getByText(unlinkedInvoiceId, { exact: false })).toHaveCount(0);
|
||||
await expect(page.getByText("item-missing-r111", { exact: false })).toHaveCount(0);
|
||||
await page.getByLabel("选择报销明细").selectOption("item-missing-r111");
|
||||
state.invoiceItemLinkError = { invoice: "invalid_unlinked_item_state" };
|
||||
await page.getByRole("button", { name: "确认关联" }).click();
|
||||
await expect(
|
||||
page.getByTestId("reimbursement-invoices").getByText("这张发票的关联状态已变化,请刷新后重试。"),
|
||||
).toBeVisible();
|
||||
expect(state.invoices.find((invoice) => invoice.id === unlinkedInvoiceId)?.linkedItemId).toBeNull();
|
||||
state.invoiceItemLinkError = null;
|
||||
await page.getByRole("button", { name: "确认关联" }).click();
|
||||
await expect.poll(() => state.invoiceItemLinkRequests).toEqual([{
|
||||
invoiceId: unlinkedInvoiceId,
|
||||
linkedItemId: "item-missing-r111",
|
||||
}, {
|
||||
invoiceId: unlinkedInvoiceId,
|
||||
linkedItemId: "item-missing-r111",
|
||||
}]);
|
||||
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"),
|
||||
).toBeVisible();
|
||||
|
||||
const structuredGuardDetails = state.submitGuardDetails;
|
||||
state.submitGuardDetails = {
|
||||
items: "invoice_item_guard_failed",
|
||||
unlinkedInvoiceCount: "invalid-count",
|
||||
unlinkedInvoiceIds: [123],
|
||||
missingInvoiceItemIds: [{ id: "internal-only" }],
|
||||
amountUnavailableItems: [{ itemId: null, invoiceIds: [unlinkedInvoiceId] }],
|
||||
amountExceededItems: [{ itemId: "broken", declaredAmount: null, invoiceTotal: "600.00" }],
|
||||
};
|
||||
await page.getByTestId("reimbursement-submit").click();
|
||||
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText(
|
||||
"票据检查未通过,请核对每条明细与发票的关联和金额后重试。",
|
||||
);
|
||||
await expect(page.getByText(unlinkedInvoiceId, { exact: false })).toHaveCount(0);
|
||||
state.submitGuardDetails = structuredGuardDetails;
|
||||
|
||||
state.invoices = state.invoices.map((invoice) => (
|
||||
invoice.id === unlinkedInvoiceId ? { ...invoice, linkedItemId: null } : invoice
|
||||
));
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
||||
await page.getByTestId("reimbursement-submit").click();
|
||||
await expect(page.getByTestId("mobile-reimbursement-invoice-guard-summary")).toContainText("共 4 项需要处理");
|
||||
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toBeVisible();
|
||||
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible();
|
||||
await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible();
|
||||
await expect(page.getByLabel("选择报销明细")).toBeVisible();
|
||||
});
|
||||
|
||||
test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => {
|
||||
const state = buildReimbursementState();
|
||||
const mobile = await newWecomPage(browser);
|
||||
|
||||
在新工单中引用
屏蔽一个用户