RI6 完善报销票据体验与状态文案

这个提交包含在:
2026-05-12 12:02:50 +08:00
父节点 9e37528293
当前提交 21887ce28a
共修改 8 个文件,包含 278 行新增和 129 行删除
+136
查看文件
@@ -0,0 +1,136 @@
import type { InvoicePoolStatus } from "../api/invoicePool";
import { formatInvoiceOcrErrorMessage } from "./invoiceOcr";
type InvoiceDisplayLike = {
status?: string | null;
invoiceCode?: string | null;
invoiceNumber?: string | null;
invoiceDate?: string | null;
sellerName?: string | null;
originalName?: string | null;
unavailableReason?: string | null;
unavailableMessage?: string | null;
availabilityReason?: string | null;
availabilityMessage?: string | null;
archivedReason?: string | null;
reason?: string | null;
message?: string | null;
ocrLastError?: string | null;
};
const INVOICE_STATUS_LABELS: Record<InvoicePoolStatus, string> = {
pending_ocr: "识别中",
ocr_failed: "识别失败",
available: "可添加",
bound: "已添加",
archived: "已归档/已报销",
};
export function resolveInvoiceStatusLabel(status: string | null | undefined) {
return status && status in INVOICE_STATUS_LABELS
? INVOICE_STATUS_LABELS[status as InvoicePoolStatus]
: "未知状态";
}
export function formatInvoiceDisplayTitle(invoice: InvoiceDisplayLike | null | undefined) {
if (!invoice) {
return "未识别发票";
}
if (invoice.invoiceCode || invoice.invoiceNumber) {
return `${invoice.invoiceCode || "无代码"}-${invoice.invoiceNumber || "无号码"}`;
}
return invoice.sellerName || invoice.originalName || "未识别发票";
}
function looksLikeMachineMessage(message: string) {
return /^[a-z0-9_:. -]+$/i.test(message) && /[a-z]/i.test(message);
}
function resolveReasonMessage(reason: string | null | undefined) {
if (!reason) {
return "";
}
if (/duplicate/i.test(reason)) {
return "已识别出相同发票,不能重复添加。";
}
if (/used_complete|used|archived/i.test(reason)) {
return "该发票已报销或归档,不能再次添加。";
}
if (/bound|linked/i.test(reason)) {
return "该发票已添加到其他单据,不能重复添加。";
}
if (/pending/i.test(reason)) {
return "发票正在识别,完成后会自动刷新。";
}
if (/failed|missing_required_fields/i.test(reason)) {
return formatInvoiceOcrErrorMessage(reason);
}
return "";
}
export function resolveInvoiceUserMessage(invoice: InvoiceDisplayLike | null | undefined) {
if (!invoice) {
return "该发票暂不可添加,请稍后刷新。";
}
const explicitMessage = invoice.unavailableMessage || invoice.availabilityMessage || invoice.message || "";
if (explicitMessage && !looksLikeMachineMessage(explicitMessage)) {
return explicitMessage;
}
const reasonMessage =
resolveReasonMessage(explicitMessage) ||
resolveReasonMessage(invoice.unavailableReason) ||
resolveReasonMessage(invoice.availabilityReason) ||
resolveReasonMessage(invoice.reason) ||
resolveReasonMessage(invoice.archivedReason);
if (reasonMessage) {
return reasonMessage;
}
if (invoice.ocrLastError) {
const errorMessage = formatInvoiceOcrErrorMessage(invoice.ocrLastError);
return /重试|重新上传|人工处理/.test(errorMessage)
? errorMessage
: `${errorMessage} 可删除后重新上传。`;
}
switch (invoice.status) {
case "pending_ocr":
return "发票正在识别,完成后会自动刷新。";
case "available":
return "可添加到当前报销单。";
case "bound":
return "该发票已添加到报销单,草稿或驳回状态可移除。";
case "archived":
return "该发票已归档或已报销,不能再次添加。";
case "ocr_failed":
return "识别失败,可删除后重新上传。";
default:
return "该发票暂不可添加,请稍后刷新。";
}
}
export function resolveInvoiceNextStepText(invoice: InvoiceDisplayLike | null | undefined) {
if (!invoice?.status) {
return "";
}
if (invoice.status === "available") {
return "可添加到当前报销单。";
}
if (invoice.status === "bound") {
return "已添加到报销单,草稿或驳回状态可移除。";
}
return resolveInvoiceUserMessage(invoice);
}
@@ -45,6 +45,12 @@ import {
import { Input } from "../components/ui/input";
import { Textarea } from "../components/ui/textarea";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
formatInvoiceDisplayTitle,
resolveInvoiceNextStepText,
resolveInvoiceStatusLabel,
resolveInvoiceUserMessage,
} from "../lib/invoiceStatus";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { TEST_IDS } from "../lib/testIds";
@@ -86,14 +92,6 @@ const STATUS_TONE: Record<ReimbursementStatus, string> = {
cancelled: "bg-zinc-100 text-zinc-500",
};
const INVOICE_STATUS_LABEL: Record<string, string> = {
pending_ocr: "识别中",
ocr_failed: "识别失败",
available: "可用",
bound: "已添加",
archived: "已归档",
};
function todayDate() {
return new Date().toISOString().slice(0, 10);
}
@@ -134,18 +132,6 @@ function workflowActionLabel(action: string) {
return labels[action] ?? action;
}
function formatInvoiceTitle(invoice: {
invoiceCode?: string | null;
invoiceNumber?: string | null;
sellerName?: string | null;
originalName?: string | null;
}) {
if (invoice.invoiceCode || invoice.invoiceNumber) {
return `${invoice.invoiceCode || "-"}-${invoice.invoiceNumber || "-"}`;
}
return invoice.sellerName || invoice.originalName || "未识别票据";
}
function matchInvoiceSearch(
invoice: {
invoiceCode?: string | null;
@@ -282,7 +268,7 @@ export function MobileReimbursementDetailPage() {
originalName: item.originalName,
createdAt: item.createdAt,
status: "ocr_failed",
unavailableMessage: "识别失败,可删除后重新上传。",
unavailableMessage: resolveInvoiceUserMessage(item),
})),
[failedInvoicesQuery.data?.items, invoiceSearchLower],
);
@@ -463,7 +449,12 @@ export function MobileReimbursementDetailPage() {
return;
}
const message = failed.map((item) => `${item.invoiceId.slice(0, 8)}:${item.message ?? "添加失败"}`).join(";");
const message = failed
.map((item) => {
const invoice = invoiceLibraryItems.find((candidate) => candidate.id === item.invoiceId);
return `${formatInvoiceDisplayTitle(invoice)}:${item.message ?? "添加失败"}`;
})
.join(";");
setInvoiceActionError(message);
toast.error("部分发票添加失败");
},
@@ -486,7 +477,7 @@ export function MobileReimbursementDetailPage() {
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => {
const message = items
.map((item) => `${item.originalName ?? "未命名发票"}:${item.availabilityMessage ?? item.message}`)
.map((item) => `${formatInvoiceDisplayTitle(item)}:${resolveInvoiceUserMessage(item)}`)
.join(";");
setInvoiceActionError(message);
setInvoiceActionMessage("");
@@ -565,7 +556,7 @@ export function MobileReimbursementDetailPage() {
if (uploadResult.uploadAction === "blocked" || !uploadResult.invoicePoolId) {
failures.push({
file,
message: uploadResult.unavailableMessage ?? "该发票不可添加",
message: resolveInvoiceUserMessage(uploadResult),
});
} else if (uploadResult.status === "available") {
try {
@@ -577,9 +568,9 @@ export function MobileReimbursementDetailPage() {
} else if (uploadResult.status === "pending_ocr") {
pendingIds.push(uploadResult.invoicePoolId);
} else if (uploadResult.status === "ocr_failed") {
failures.push({ file, message: uploadResult.unavailableMessage ?? "发票识别失败,请重新识别或手动处理" });
failures.push({ file, message: resolveInvoiceUserMessage(uploadResult) });
} else {
failures.push({ file, message: uploadResult.unavailableMessage ?? "该发票不可添加" });
failures.push({ file, message: resolveInvoiceUserMessage(uploadResult) });
}
} catch (error) {
failures.push({ file, message: resolveErrorMessage(error) });
@@ -1013,37 +1004,41 @@ export function MobileReimbursementDetailPage() {
{detail.invoices.length ? (
<div className="mt-3 space-y-2">
{detail.invoices.map((invoice) => (
<article key={invoice.id} className="mobile-list-card">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceTitle(invoice)}</div>
<div className="mt-1 text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)}
{detail.invoices.map((invoice) => {
const invoiceHint = resolveInvoiceNextStepText(invoice);
return (
<article key={invoice.id} className="mobile-list-card">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</div>
<div className="mt-1 text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)}
</div>
<div className="mt-1 text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</div>
{invoiceHint ? <div className="mt-1 text-xs text-zinc-500">{invoiceHint}</div> : null}
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div>
<span className="mt-1 inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{resolveInvoiceStatusLabel(invoice.status)}
</span>
</div>
<div className="mt-1 text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</div>
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div>
<span className="mt-1 inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{INVOICE_STATUS_LABEL[invoice.status] ?? "未知状态"}
</span>
</div>
</div>
{canUnbindInvoices ? (
<Button
className="mt-3 w-full"
size="sm"
variant="secondary"
type="button"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
移除
</Button>
) : null}
</article>
))}
{canUnbindInvoices ? (
<Button
className="mt-3 w-full"
size="sm"
variant="secondary"
type="button"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
移除
</Button>
) : null}
</article>
);
})}
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
@@ -1356,11 +1351,18 @@ export function MobileReimbursementDetailPage() {
/>
) : (
<span className="mt-0.5 inline-flex shrink-0 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-700">
识别失败
{resolveInvoiceStatusLabel(invoice.status)}
</span>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceTitle(invoice)}</div>
<div className="flex items-start justify-between gap-2">
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</div>
{isAvailable ? (
<span className="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
{resolveInvoiceStatusLabel(invoice.status)}
</span>
) : null}
</div>
<div className="mt-1 text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} · {formatCurrency(invoice.totalAmount)}
</div>
@@ -1368,7 +1370,7 @@ export function MobileReimbursementDetailPage() {
</div>
{isFailed ? (
<div className="mt-3 rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">
{invoice.unavailableMessage ?? "识别失败,可删除后重新上传。"}
{resolveInvoiceUserMessage(invoice)}
</div>
) : null}
{isAvailable && checked ? (
@@ -1474,7 +1476,7 @@ export function MobileReimbursementDetailPage() {
open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement}
title="删除草稿?"
description="删除后无法恢复,已添加发票会回到可用状态。"
description="删除后无法恢复,已添加发票会回到可添加状态。"
variant="destructive"
confirmLabel="删除草稿"
onConfirm={() => deleteReimbursementMutation.mutate()}
+64 -61
查看文件
@@ -53,7 +53,12 @@ import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
import {
formatInvoiceDisplayTitle,
resolveInvoiceNextStepText,
resolveInvoiceStatusLabel,
resolveInvoiceUserMessage,
} from "../lib/invoiceStatus";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { TEST_IDS } from "../lib/testIds";
@@ -77,14 +82,6 @@ const REIMBURSEMENT_STATUS_LABEL: Record<ReimbursementStatus, string> = {
cancelled: "已取消",
};
const INVOICE_STATUS_LABEL: Record<string, string> = {
pending_ocr: "识别中",
ocr_failed: "识别失败",
available: "可添加",
bound: "已添加",
archived: "已归档",
};
function formatDateTime(value: string | null) {
if (!value) return "-";
return value.slice(0, 19).replace("T", " ");
@@ -106,10 +103,6 @@ function reimbursementStatusLabel(status: ReimbursementStatus) {
return REIMBURSEMENT_STATUS_LABEL[status] ?? "未知状态";
}
function invoiceStatusLabel(status: string) {
return INVOICE_STATUS_LABEL[status] ?? "未知状态";
}
function workflowActionLabel(action: string) {
const labels: Record<string, string> = {
start: "提交",
@@ -547,7 +540,10 @@ export function ReimbursementDetailPage() {
}
const summary = failedResults
.map((item) => `${item.invoiceId.slice(0, 8)}: ${item.message ?? "添加失败"}`)
.map((item) => {
const invoice = (bindableInvoicesQuery.data?.items ?? []).find((candidate) => candidate.id === item.invoiceId);
return `${formatInvoiceDisplayTitle(invoice)}:${item.message ?? "添加失败"}`;
})
.join(";");
setInvoiceActionError(summary);
if (succeededIds.length) {
@@ -575,7 +571,7 @@ export function ReimbursementDetailPage() {
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
const message = items
.map((item) => `${item.originalName ?? "未命名发票"}: ${item.availabilityMessage ?? item.message ?? formatInvoiceOcrErrorMessage(item.ocrLastError)}`)
.map((item) => `${formatInvoiceDisplayTitle(item)}:${resolveInvoiceUserMessage(item)}`)
.join(";");
setInvoiceActionError(message);
toast.error("部分发票需要处理");
@@ -633,7 +629,7 @@ export function ReimbursementDetailPage() {
if (result.uploadAction === "blocked" || !result.invoicePoolId) {
uploadFailures.push({
file,
message: result.unavailableMessage ?? "该发票不可添加",
message: resolveInvoiceUserMessage(result),
});
} else if (result.status === "available") {
availableIds.push(result.invoicePoolId);
@@ -642,7 +638,7 @@ export function ReimbursementDetailPage() {
} else {
uploadFailures.push({
file,
message: result.unavailableMessage ?? "该发票不可添加",
message: resolveInvoiceUserMessage(result),
});
}
} catch (error) {
@@ -660,7 +656,7 @@ export function ReimbursementDetailPage() {
await bindInvoice(invoiceId, { reimbursementId });
boundCount += 1;
} catch (error) {
bindFailures.push(`${invoiceId.slice(0, 8)}: ${buildActionErrorMessage(error, "添加发票失败")}`);
bindFailures.push(`新上传发票:${buildActionErrorMessage(error, "添加发票失败")}`);
}
}
@@ -1404,45 +1400,53 @@ export function ReimbursementDetailPage() {
{(detail.invoices ?? []).length ? (
<div className="space-y-3">
{detail.invoices.map((item) => (
<div
className="rounded-lg border border-zinc-200 px-4 py-3"
key={item.id}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="font-medium text-zinc-900">
发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
{detail.invoices.map((item) => {
const invoiceHint = resolveInvoiceNextStepText(item);
return (
<div
className="rounded-lg border border-zinc-200 px-4 py-3"
key={item.id}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="font-medium text-zinc-900">
{formatInvoiceDisplayTitle(item)}
</div>
<div className="text-zinc-600">
{item.sellerName || "未识别销售方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
</div>
<div className="text-xs text-zinc-500">
原始文件:{item.originalName || "未记录原始文件"}
</div>
<div className="text-xs text-zinc-500">
明细:{resolveLinkedItemLabel(item.linkedItemId)}
</div>
</div>
<div className="text-zinc-600">
{item.sellerName || "未识别卖方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
<div className="flex flex-col items-start gap-2 md:items-end">
<div className="flex items-center gap-2">
<span className="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{resolveInvoiceStatusLabel(item.status)}
</span>
{canUnbindInvoices ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(item.id)}
>
移除
</Button>
) : null}
</div>
{invoiceHint ? (
<div className="max-w-[260px] text-xs text-zinc-500 md:text-right">{invoiceHint}</div>
) : null}
</div>
<div className="text-xs text-zinc-500">
原始文件:{item.originalName || item.id}
</div>
<div className="text-xs text-zinc-500">
明细:{resolveLinkedItemLabel(item.linkedItemId)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{invoiceStatusLabel(item.status)}
</span>
{canUnbindInvoices ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(item.id)}
>
移除
</Button>
) : null}
</div>
</div>
</div>
))}
);
})}
</div>
) : (
<p className="text-sm text-zinc-500">暂无发票</p>
@@ -1633,7 +1637,7 @@ export function ReimbursementDetailPage() {
>
<DialogHeader>
<DialogTitle>添加发票</DialogTitle>
<DialogDescription>选择可用发票,或上传新发票。</DialogDescription>
<DialogDescription>选择可添加发票,或上传新发票。</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@@ -1669,6 +1673,7 @@ export function ReimbursementDetailPage() {
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
bindableInvoicesQuery.data?.items.map((item) => {
const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id);
const invoiceHint = resolveInvoiceNextStepText({ ...item, status: "available" });
return (
<label
key={item.id}
@@ -1683,20 +1688,18 @@ export function ReimbursementDetailPage() {
onChange={(event) => toggleInvoiceSelection(item.id, event.target.checked)}
/>
<div className="space-y-1">
<div className="font-medium text-zinc-900">
发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
</div>
<div className="font-medium text-zinc-900">{formatInvoiceDisplayTitle(item)}</div>
<div className="text-zinc-600">
{item.sellerName || "未识别卖方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
{item.sellerName || "未识别销售方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
</div>
<div className="text-xs text-zinc-500">
原始文件:{item.originalName || item.id} · 上传时间 {formatDateTime(item.createdAt)}
原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDateTime(item.createdAt)}
</div>
</div>
</div>
<div className="min-w-[220px] rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700 md:max-w-[240px]">
添加后自动生成或匹配报销明细
{invoiceHint} 添加后自动生成或匹配报销明细。
</div>
</div>
</label>
@@ -1881,7 +1884,7 @@ export function ReimbursementDetailPage() {
open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement}
title={`确认删除 ${displayCode}?`}
description="删除后无法恢复,已添加发票会回到可用状态。"
description="删除后无法恢复,已添加发票会回到可添加状态。"
variant="destructive"
confirmLabel="删除草稿"
onConfirm={() => { deleteReimbursementMutation.mutate(); }}
@@ -188,7 +188,8 @@ test.describe.serial("R109 企业微信移动报销反馈专项", () => {
const meMetrics = await collectMobileMetrics(employee.page);
expect(meMetrics.moreMenuRatio ?? 0).toBeLessThanOrEqual(0.5);
expect(meMetrics.navHeight).toBeLessThanOrEqual(68);
expect(meMetrics.activeNavAlpha).toBeGreaterThan(0.9);
expect(meMetrics.activeNavAlpha).toBeGreaterThanOrEqual(0.08);
expect(meMetrics.activeNavAlpha).toBeLessThanOrEqual(0.3);
expect(meMetrics.activeNavAverage, meMetrics.activeNavBackground).toBeLessThan(245);
} finally {
await seeded.client.api.delete(`/api/reimbursements/${seeded.id}`).catch(() => undefined);
@@ -590,8 +590,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
const availableInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "044002600111-87654321" });
const failedInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "识别失败发票.pdf" });
await expect(availableInvoiceCard).toBeVisible();
await expect(availableInvoiceCard.getByText("可添加", { exact: true })).toBeVisible();
await expect(failedInvoiceCard.getByText("识别失败", { exact: true })).toBeVisible();
await expect(failedInvoiceCard.getByText("识别失败,可删除后重新上传。")).toBeVisible();
await expect(failedInvoiceCard).toContainText("可删除后重新上传");
await availableInvoiceCard.getByRole("button", { name: "删除" }).click();
await expect(mobile.page.getByRole("heading", { name: "删除发票?" })).toBeVisible();