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 { Input } from "../components/ui/input";
import { Textarea } from "../components/ui/textarea"; import { Textarea } from "../components/ui/textarea";
import { buildPrecheckDialogMessage } from "../lib/precheck"; import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
formatInvoiceDisplayTitle,
resolveInvoiceNextStepText,
resolveInvoiceStatusLabel,
resolveInvoiceUserMessage,
} from "../lib/invoiceStatus";
import { resolveErrorMessage } from "../lib/resolveErrorMessage"; import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { TEST_IDS } from "../lib/testIds"; import { TEST_IDS } from "../lib/testIds";
@@ -86,14 +92,6 @@ const STATUS_TONE: Record<ReimbursementStatus, string> = {
cancelled: "bg-zinc-100 text-zinc-500", cancelled: "bg-zinc-100 text-zinc-500",
}; };
const INVOICE_STATUS_LABEL: Record<string, string> = {
pending_ocr: "识别中",
ocr_failed: "识别失败",
available: "可用",
bound: "已添加",
archived: "已归档",
};
function todayDate() { function todayDate() {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
@@ -134,18 +132,6 @@ function workflowActionLabel(action: string) {
return labels[action] ?? action; 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( function matchInvoiceSearch(
invoice: { invoice: {
invoiceCode?: string | null; invoiceCode?: string | null;
@@ -282,7 +268,7 @@ export function MobileReimbursementDetailPage() {
originalName: item.originalName, originalName: item.originalName,
createdAt: item.createdAt, createdAt: item.createdAt,
status: "ocr_failed", status: "ocr_failed",
unavailableMessage: "识别失败,可删除后重新上传。", unavailableMessage: resolveInvoiceUserMessage(item),
})), })),
[failedInvoicesQuery.data?.items, invoiceSearchLower], [failedInvoicesQuery.data?.items, invoiceSearchLower],
); );
@@ -463,7 +449,12 @@ export function MobileReimbursementDetailPage() {
return; 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); setInvoiceActionError(message);
toast.error("部分发票添加失败"); 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 handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => {
const message = items const message = items
.map((item) => `${item.originalName ?? "未命名发票"}:${item.availabilityMessage ?? item.message}`) .map((item) => `${formatInvoiceDisplayTitle(item)}:${resolveInvoiceUserMessage(item)}`)
.join(";"); .join(";");
setInvoiceActionError(message); setInvoiceActionError(message);
setInvoiceActionMessage(""); setInvoiceActionMessage("");
@@ -565,7 +556,7 @@ export function MobileReimbursementDetailPage() {
if (uploadResult.uploadAction === "blocked" || !uploadResult.invoicePoolId) { if (uploadResult.uploadAction === "blocked" || !uploadResult.invoicePoolId) {
failures.push({ failures.push({
file, file,
message: uploadResult.unavailableMessage ?? "该发票不可添加", message: resolveInvoiceUserMessage(uploadResult),
}); });
} else if (uploadResult.status === "available") { } else if (uploadResult.status === "available") {
try { try {
@@ -577,9 +568,9 @@ export function MobileReimbursementDetailPage() {
} else if (uploadResult.status === "pending_ocr") { } else if (uploadResult.status === "pending_ocr") {
pendingIds.push(uploadResult.invoicePoolId); pendingIds.push(uploadResult.invoicePoolId);
} else if (uploadResult.status === "ocr_failed") { } else if (uploadResult.status === "ocr_failed") {
failures.push({ file, message: uploadResult.unavailableMessage ?? "发票识别失败,请重新识别或手动处理" }); failures.push({ file, message: resolveInvoiceUserMessage(uploadResult) });
} else { } else {
failures.push({ file, message: uploadResult.unavailableMessage ?? "该发票不可添加" }); failures.push({ file, message: resolveInvoiceUserMessage(uploadResult) });
} }
} catch (error) { } catch (error) {
failures.push({ file, message: resolveErrorMessage(error) }); failures.push({ file, message: resolveErrorMessage(error) });
@@ -1013,37 +1004,41 @@ export function MobileReimbursementDetailPage() {
{detail.invoices.length ? ( {detail.invoices.length ? (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{detail.invoices.map((invoice) => ( {detail.invoices.map((invoice) => {
<article key={invoice.id} className="mobile-list-card"> const invoiceHint = resolveInvoiceNextStepText(invoice);
<div className="flex items-start justify-between gap-3"> return (
<div className="min-w-0"> <article key={invoice.id} className="mobile-list-card">
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceTitle(invoice)}</div> <div className="flex items-start justify-between gap-3">
<div className="mt-1 text-xs text-zinc-500"> <div className="min-w-0">
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} <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>
<div className="mt-1 text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</div>
</div> </div>
<div className="shrink-0 text-right"> {canUnbindInvoices ? (
<div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div> <Button
<span className="mt-1 inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600"> className="mt-3 w-full"
{INVOICE_STATUS_LABEL[invoice.status] ?? "未知状态"} size="sm"
</span> variant="secondary"
</div> type="button"
</div> disabled={unbindInvoiceMutation.isPending}
{canUnbindInvoices ? ( onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
<Button >
className="mt-3 w-full" 移除
size="sm" </Button>
variant="secondary" ) : null}
type="button" </article>
disabled={unbindInvoiceMutation.isPending} );
onClick={() => unbindInvoiceMutation.mutate(invoice.id)} })}
>
移除
</Button>
) : null}
</article>
))}
</div> </div>
) : ( ) : (
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500"> <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"> <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> </span>
)} )}
<div className="min-w-0 flex-1"> <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"> <div className="mt-1 text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} · {formatCurrency(invoice.totalAmount)} {invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} · {formatCurrency(invoice.totalAmount)}
</div> </div>
@@ -1368,7 +1370,7 @@ export function MobileReimbursementDetailPage() {
</div> </div>
{isFailed ? ( {isFailed ? (
<div className="mt-3 rounded-md bg-red-50 px-3 py-2 text-xs text-red-700"> <div className="mt-3 rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">
{invoice.unavailableMessage ?? "识别失败,可删除后重新上传。"} {resolveInvoiceUserMessage(invoice)}
</div> </div>
) : null} ) : null}
{isAvailable && checked ? ( {isAvailable && checked ? (
@@ -1474,7 +1476,7 @@ export function MobileReimbursementDetailPage() {
open={confirmDeleteReimbursement} open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement} onOpenChange={setConfirmDeleteReimbursement}
title="删除草稿?" title="删除草稿?"
description="删除后无法恢复,已添加发票会回到可用状态。" description="删除后无法恢复,已添加发票会回到可添加状态。"
variant="destructive" variant="destructive"
confirmLabel="删除草稿" confirmLabel="删除草稿"
onConfirm={() => deleteReimbursementMutation.mutate()} onConfirm={() => deleteReimbursementMutation.mutate()}
+64 -61
查看文件
@@ -53,7 +53,12 @@ import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero"; import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { PageScaffold } from "../components/patterns/PageScaffold"; import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel"; 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 { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { TEST_IDS } from "../lib/testIds"; import { TEST_IDS } from "../lib/testIds";
@@ -77,14 +82,6 @@ const REIMBURSEMENT_STATUS_LABEL: Record<ReimbursementStatus, string> = {
cancelled: "已取消", cancelled: "已取消",
}; };
const INVOICE_STATUS_LABEL: Record<string, string> = {
pending_ocr: "识别中",
ocr_failed: "识别失败",
available: "可添加",
bound: "已添加",
archived: "已归档",
};
function formatDateTime(value: string | null) { function formatDateTime(value: string | null) {
if (!value) return "-"; if (!value) return "-";
return value.slice(0, 19).replace("T", " "); return value.slice(0, 19).replace("T", " ");
@@ -106,10 +103,6 @@ function reimbursementStatusLabel(status: ReimbursementStatus) {
return REIMBURSEMENT_STATUS_LABEL[status] ?? "未知状态"; return REIMBURSEMENT_STATUS_LABEL[status] ?? "未知状态";
} }
function invoiceStatusLabel(status: string) {
return INVOICE_STATUS_LABEL[status] ?? "未知状态";
}
function workflowActionLabel(action: string) { function workflowActionLabel(action: string) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
start: "提交", start: "提交",
@@ -547,7 +540,10 @@ export function ReimbursementDetailPage() {
} }
const summary = failedResults 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(";"); .join(";");
setInvoiceActionError(summary); setInvoiceActionError(summary);
if (succeededIds.length) { 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 handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
const message = items const message = items
.map((item) => `${item.originalName ?? "未命名发票"}: ${item.availabilityMessage ?? item.message ?? formatInvoiceOcrErrorMessage(item.ocrLastError)}`) .map((item) => `${formatInvoiceDisplayTitle(item)}:${resolveInvoiceUserMessage(item)}`)
.join(";"); .join(";");
setInvoiceActionError(message); setInvoiceActionError(message);
toast.error("部分发票需要处理"); toast.error("部分发票需要处理");
@@ -633,7 +629,7 @@ export function ReimbursementDetailPage() {
if (result.uploadAction === "blocked" || !result.invoicePoolId) { if (result.uploadAction === "blocked" || !result.invoicePoolId) {
uploadFailures.push({ uploadFailures.push({
file, file,
message: result.unavailableMessage ?? "该发票不可添加", message: resolveInvoiceUserMessage(result),
}); });
} else if (result.status === "available") { } else if (result.status === "available") {
availableIds.push(result.invoicePoolId); availableIds.push(result.invoicePoolId);
@@ -642,7 +638,7 @@ export function ReimbursementDetailPage() {
} else { } else {
uploadFailures.push({ uploadFailures.push({
file, file,
message: result.unavailableMessage ?? "该发票不可添加", message: resolveInvoiceUserMessage(result),
}); });
} }
} catch (error) { } catch (error) {
@@ -660,7 +656,7 @@ export function ReimbursementDetailPage() {
await bindInvoice(invoiceId, { reimbursementId }); await bindInvoice(invoiceId, { reimbursementId });
boundCount += 1; boundCount += 1;
} catch (error) { } catch (error) {
bindFailures.push(`${invoiceId.slice(0, 8)}: ${buildActionErrorMessage(error, "添加发票失败")}`); bindFailures.push(`新上传发票:${buildActionErrorMessage(error, "添加发票失败")}`);
} }
} }
@@ -1404,45 +1400,53 @@ export function ReimbursementDetailPage() {
{(detail.invoices ?? []).length ? ( {(detail.invoices ?? []).length ? (
<div className="space-y-3"> <div className="space-y-3">
{detail.invoices.map((item) => ( {detail.invoices.map((item) => {
<div const invoiceHint = resolveInvoiceNextStepText(item);
className="rounded-lg border border-zinc-200 px-4 py-3" return (
key={item.id} <div
> className="rounded-lg border border-zinc-200 px-4 py-3"
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> key={item.id}
<div className="space-y-1"> >
<div className="font-medium text-zinc-900"> <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"} <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>
<div className="text-zinc-600"> <div className="flex flex-col items-start gap-2 md:items-end">
{item.sellerName || "未识别卖方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"} <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>
<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>
</div> );
))} })}
</div> </div>
) : ( ) : (
<p className="text-sm text-zinc-500">暂无发票</p> <p className="text-sm text-zinc-500">暂无发票</p>
@@ -1633,7 +1637,7 @@ export function ReimbursementDetailPage() {
> >
<DialogHeader> <DialogHeader>
<DialogTitle>添加发票</DialogTitle> <DialogTitle>添加发票</DialogTitle>
<DialogDescription>选择可用发票,或上传新发票。</DialogDescription> <DialogDescription>选择可添加发票,或上传新发票。</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
@@ -1669,6 +1673,7 @@ export function ReimbursementDetailPage() {
) : (bindableInvoicesQuery.data?.items ?? []).length ? ( ) : (bindableInvoicesQuery.data?.items ?? []).length ? (
bindableInvoicesQuery.data?.items.map((item) => { bindableInvoicesQuery.data?.items.map((item) => {
const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id); const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id);
const invoiceHint = resolveInvoiceNextStepText({ ...item, status: "available" });
return ( return (
<label <label
key={item.id} key={item.id}
@@ -1683,20 +1688,18 @@ export function ReimbursementDetailPage() {
onChange={(event) => toggleInvoiceSelection(item.id, event.target.checked)} onChange={(event) => toggleInvoiceSelection(item.id, event.target.checked)}
/> />
<div className="space-y-1"> <div className="space-y-1">
<div className="font-medium text-zinc-900"> <div className="font-medium text-zinc-900">{formatInvoiceDisplayTitle(item)}</div>
发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
</div>
<div className="text-zinc-600"> <div className="text-zinc-600">
{item.sellerName || "未识别卖方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"} {item.sellerName || "未识别销售方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
</div> </div>
<div className="text-xs text-zinc-500"> <div className="text-xs text-zinc-500">
原始文件:{item.originalName || item.id} · 上传时间 {formatDateTime(item.createdAt)} 原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDateTime(item.createdAt)}
</div> </div>
</div> </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]"> <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>
</div> </div>
</label> </label>
@@ -1881,7 +1884,7 @@ export function ReimbursementDetailPage() {
open={confirmDeleteReimbursement} open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement} onOpenChange={setConfirmDeleteReimbursement}
title={`确认删除 ${displayCode}?`} title={`确认删除 ${displayCode}?`}
description="删除后无法恢复,已添加发票会回到可用状态。" description="删除后无法恢复,已添加发票会回到可添加状态。"
variant="destructive" variant="destructive"
confirmLabel="删除草稿" confirmLabel="删除草稿"
onConfirm={() => { deleteReimbursementMutation.mutate(); }} onConfirm={() => { deleteReimbursementMutation.mutate(); }}
@@ -188,7 +188,8 @@ test.describe.serial("R109 企业微信移动报销反馈专项", () => {
const meMetrics = await collectMobileMetrics(employee.page); const meMetrics = await collectMobileMetrics(employee.page);
expect(meMetrics.moreMenuRatio ?? 0).toBeLessThanOrEqual(0.5); expect(meMetrics.moreMenuRatio ?? 0).toBeLessThanOrEqual(0.5);
expect(meMetrics.navHeight).toBeLessThanOrEqual(68); 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); expect(meMetrics.activeNavAverage, meMetrics.activeNavBackground).toBeLessThan(245);
} finally { } finally {
await seeded.client.api.delete(`/api/reimbursements/${seeded.id}`).catch(() => undefined); 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 availableInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "044002600111-87654321" });
const failedInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "识别失败发票.pdf" }); const failedInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "识别失败发票.pdf" });
await expect(availableInvoiceCard).toBeVisible(); await expect(availableInvoiceCard).toBeVisible();
await expect(availableInvoiceCard.getByText("可添加", { exact: true })).toBeVisible();
await expect(failedInvoiceCard.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 availableInvoiceCard.getByRole("button", { name: "删除" }).click();
await expect(mobile.page.getByRole("heading", { name: "删除发票?" })).toBeVisible(); await expect(mobile.page.getByRole("heading", { name: "删除发票?" })).toBeVisible();
+5 -3
查看文件
@@ -14,8 +14,8 @@
| 项目 | 记录 | | 项目 | 记录 |
| --- | --- | | --- | --- |
| 公网地址 | `https://oa.hyinda.com` | | 公网地址 | `https://oa.hyinda.com` |
| 当前门禁 | R5 已 `DONE/DONE/DONE`;R6 首轮小范围试运行已完成;R6-ISSUE-003 采购提交阻断已热修、部署并复跑通过;PC6/PA6/FL6/AP6 后续优化已完成;下一批进入 RI6 报销票据体验完善 | | 当前门禁 | R5 已 `DONE/DONE/DONE`;R6 首轮小范围试运行已完成;R6-ISSUE-003 采购提交阻断已热修、部署并复跑通过;PC6/PA6/FL6/AP6/RI6 后续优化已完成;下一批进入 LP6 请假采购体验完善 |
| 当前代码基线 | `2d87c36`;公网 `https://oa.hyinda.com` 已部署 AP6 审批催办、超时提醒和效率摘要,并完成 live smoke readiness 复检 | | 当前代码基线 | RI6 收口提交;公网 `https://oa.hyinda.com` 已部署报销票据体验完善,并完成 `/ready`、public bundle 和 live smoke readiness 复检 |
| 试运行窗口 | `2026-05-12 14:00-17:00 Asia/Shanghai`;若现场无法执行,顺延到 `2026-05-13 14:00-17:00 Asia/Shanghai` | | 试运行窗口 | `2026-05-12 14:00-17:00 Asia/Shanghai`;若现场无法执行,顺延到 `2026-05-13 14:00-17:00 Asia/Shanghai` |
| 回滚联系人 | `wangshunli` | | 回滚联系人 | `wangshunli` |
| 问题登记负责人 | `wangshunli` | | 问题登记负责人 | `wangshunli` |
@@ -88,7 +88,9 @@ R6-3 试运行前自动检查(2026-05-12):公网 `/ready` 正常;public
R6 首轮小范围试运行通过。已验证报销、请假、采购、审批、企业微信个人应用消息、移动入口既有门禁和财务付款主链路;采购提交阻断已通过 PS6 热修关闭。R6 通过不等于全面正式上线,下一批按独立 Issue 推进 PC 信息架构、移动二轮体验、审批催办/超时、审批效率摘要和财务台账增强。 R6 首轮小范围试运行通过。已验证报销、请假、采购、审批、企业微信个人应用消息、移动入口既有门禁和财务付款主链路;采购提交阻断已通过 PS6 热修关闭。R6 通过不等于全面正式上线,下一批按独立 Issue 推进 PC 信息架构、移动二轮体验、审批催办/超时、审批效率摘要和财务台账增强。
AP6 收口(2026-05-12):审批效率 v1 已完成并部署。代码提交 `72e38db` 增加审批催办、超时提醒 job 和待办效率摘要,状态回填提交 `2d87c36` 记录部署验收;公网 `/ready` 正常,public bundle `findings=0`,live smoke readiness 通过,报告 `artifacts/qa-flow/live-smoke-readiness-20260512-104535`。AP6 v1 固定为:申请人/提交人可通过 `POST /api/workflow/tasks/:taskId/remind` 催办本人 pending 审批,24 小时同一任务最多 1 次;`GET /api/workflow/tasks/summary` 返回待办和超时聚合;`job:workflow-overdue-reminders` 只提醒 pending 待办,不改变审批状态。下一批默认进入 RI6 报销票据体验完善;R7 尚未启动,需在 RI6/LP6 至少完成一轮后另建合同。 AP6 收口(2026-05-12):审批效率 v1 已完成并部署。代码提交 `72e38db` 增加审批催办、超时提醒 job 和待办效率摘要,状态回填提交 `2d87c36` 记录部署验收;公网 `/ready` 正常,public bundle `findings=0`,live smoke readiness 通过,报告 `artifacts/qa-flow/live-smoke-readiness-20260512-104535`。AP6 v1 固定为:申请人/提交人可通过 `POST /api/workflow/tasks/:taskId/remind` 催办本人 pending 审批,24 小时同一任务最多 1 次;`GET /api/workflow/tasks/summary` 返回待办和超时聚合;`job:workflow-overdue-reminders` 只提醒 pending 待办,不改变审批状态。AP6 后已按计划进入 RI6。
RI6 收口(2026-05-12):报销票据体验完善已完成并部署。PC/移动报销详情统一复用发票状态文案和下一步提示,普通员工继续从报销详情添加 PDF 发票,不恢复“票据中心/发票池”主入口;移动可添加发票显示明确“可添加”状态,识别失败、重复、已归档或已报销进入可解释终态。本地 `client typecheck/build`、`check-ui-design-rules --strict`、R111/R125 专项 4 passed、R109 移动专项 1 passed、`BUSINESS_FLOW_GROUPS=reimbursement` 10 passed / 1 skipped;已部署到 `https://oa.hyinda.com`,公网 `/ready` 正常,public bundle `findings=0`,live smoke readiness 通过,报告 `artifacts/qa-flow/live-smoke-readiness-20260512-120138`。下一批默认进入 LP6 请假采购体验完善;R7 尚未启动,需在 RI6/LP6 至少完成一轮后另建合同。
## 结论 ## 结论
@@ -1,6 +1,6 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
RI6-1,报销票据体验合同建档,规划 R6 后报销和票据体验第一批完善。,"1) 本 CSV 校验通过;2) 员工仍不进入票据中心;3) 不展示 OCR、发票池等内部术语;4) PDF-only 口径保持不变。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-44-00-r6-reimbursement-invoice-polish-v1.csv;人工核对 docs/reimbursement-invoice-rules.md。",python3|manual,DONE,DONE,DONE,issues/2026-05-12_01-44-00-r6-reimbursement-invoice-polish-v1.csv,[],普通员工发票入口仍为报销详情添加发票。 RI6-1,报销票据体验合同建档,规划 R6 后报销和票据体验第一批完善。,"1) 本 CSV 校验通过;2) 员工仍不进入票据中心;3) 不展示 OCR、发票池等内部术语;4) PDF-only 口径保持不变。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-44-00-r6-reimbursement-invoice-polish-v1.csv;人工核对 docs/reimbursement-invoice-rules.md。",python3|manual,DONE,DONE,DONE,issues/2026-05-12_01-44-00-r6-reimbursement-invoice-polish-v1.csv,[],普通员工发票入口仍为报销详情添加发票。
RI6-2,PC 移动报销能力对齐,对齐 PC 和移动的草稿、编辑、删除、附件、PDF 发票和提交审批能力。,"1) PC 和移动都能完成报销草稿主流程;2) 员工不见票据中心入口;3) 移动不使用桌面表格;4) data-testid 稳定。","流程级验证:PC 和移动分别新建草稿 -> 编辑明细 -> 添加附件/PDF 发票 -> 提交审批 -> 删除草稿或验证非草稿不可删。",playwright|npm,TODO,TODO,TODO,client/src/pages/ReimbursementsPage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx,"[""RI6-1""]",只做体验和一致性,不改 OCR provider。 RI6-2,PC 移动报销能力对齐,对齐 PC 和移动的草稿、编辑、删除、附件、PDF 发票和提交审批能力。,"1) PC 和移动都能完成报销草稿主流程;2) 员工不见票据中心入口;3) 移动不使用桌面表格;4) data-testid 稳定。","流程级验证:PC 和移动分别新建草稿 -> 编辑明细 -> 添加附件/PDF 发票 -> 提交审批 -> 删除草稿或验证非草稿不可删。",playwright|npm,DONE,DONE,DONE,client/src/pages/ReimbursementsPage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx,"[""RI6-1""]",已统一 PC/移动报销详情发票入口、附件和草稿操作;R111/R125/R109 与报销业务流通过。
RI6-3,发票状态解释优化,统一可用、识别中、识别失败、已绑定、重复票据、已归档等状态文案和下一步提示。,"1) 重复票据给出可理解原因;2) 识别失败有下一步;3) 已绑定和已归档不可误操作;4) 内部术语扫描为 0。","流程级验证:上传 PDF 样票 -> 模拟或真实 OCR 终态 -> 检查各状态文案;node scripts/qa/check-ui-design-rules.mjs --strict。",node|playwright,TODO,TODO,TODO,client/src/pages/*Reimbursement*|scripts/qa/check-ui-design-rules.mjs,"[""RI6-2""]",真实 OCR 重复票据可解释终态继续视为通过。 RI6-3,发票状态解释优化,统一可用、识别中、识别失败、已绑定、重复票据、已归档等状态文案和下一步提示。,"1) 重复票据给出可理解原因;2) 识别失败有下一步;3) 已绑定和已归档不可误操作;4) 内部术语扫描为 0。","流程级验证:上传 PDF 样票 -> 模拟或真实 OCR 终态 -> 检查各状态文案;node scripts/qa/check-ui-design-rules.mjs --strict。",node|playwright,DONE,DONE,DONE,client/src/pages/*Reimbursement*|scripts/qa/check-ui-design-rules.mjs,"[""RI6-2""]",共享发票状态文案已接入;check-ui-design-rules strict 0 findings;真实 OCR 重复票据可解释终态继续视为通过。
RI6-4,报销详情信息架构收口,PC 详情高密度展示,移动详情轻任务展示,突出发票、附件、审批流和付款状态。,"1) PC 列表和详情适合筛选和查看;2) 移动详情无横向滚动和遮挡;3) 付款状态与财务动作一致。","流程级验证:PC 1440x960 和移动多视口打开报销详情;审批和付款后状态同步;视觉截图输出。",playwright,TODO,TODO,TODO,client/src/pages/ReimbursementsPage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx,"[""RI6-3""]",遵循 docs/product-design-rules.md。 RI6-4,报销详情信息架构收口,PC 详情高密度展示,移动详情轻任务展示,突出发票、附件、审批流和付款状态。,"1) PC 列表和详情适合筛选和查看;2) 移动详情无横向滚动和遮挡;3) 付款状态与财务动作一致。","流程级验证:PC 1440x960 和移动多视口打开报销详情;审批和付款后状态同步;视觉截图输出。",playwright,DONE,DONE,DONE,client/src/pages/ReimbursementsPage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx,"[""RI6-3""]",R111/R109 截图输出到 output/playwright;移动深层页无底部 Tab,吸底动作保持可点。
RI6-5,报销票据回归部署,完成后执行报销业务流、视觉、部署和公网复检。,"1) 报销业务流 gate 通过;2) R109/R111/R125 相关回归通过;3) 部署后 /ready、bundle 扫描和 live smoke 通过。","流程级验证:BUSINESS_FLOW_GROUPS=reimbursement bash scripts/qa/run-business-flow-gates.sh local;client typecheck/build;deploy;live smoke readiness。",bash|npm|playwright,TODO,TODO,TODO,client|server|scripts/qa|deploy/trialrun,"[""RI6-4""]",敏感票据和截图不提交仓库。 RI6-5,报销票据回归部署,完成后执行报销业务流、视觉、部署和公网复检。,"1) 报销业务流 gate 通过;2) R109/R111/R125 相关回归通过;3) 部署后 /ready、bundle 扫描和 live smoke 通过。","流程级验证:BUSINESS_FLOW_GROUPS=reimbursement bash scripts/qa/run-business-flow-gates.sh local;client typecheck/build;deploy;live smoke readiness。",bash|npm|playwright,DONE,DONE,DONE,client|server|scripts/qa|deploy/trialrun,"[""RI6-4""]",已部署到 https://oa.hyinda.com;/ready ok;public bundle findings=0;live readiness PASS,报告 artifacts/qa-flow/live-smoke-readiness-20260512-120138。
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 RI6-1 报销票据体验合同建档 规划 R6 后报销和票据体验第一批完善。 1) 本 CSV 校验通过;2) 员工仍不进入票据中心;3) 不展示 OCR、发票池等内部术语;4) PDF-only 口径保持不变。 流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-44-00-r6-reimbursement-invoice-polish-v1.csv;人工核对 docs/reimbursement-invoice-rules.md。 python3|manual DONE DONE DONE issues/2026-05-12_01-44-00-r6-reimbursement-invoice-polish-v1.csv [] 普通员工发票入口仍为报销详情添加发票。
3 RI6-2 PC 移动报销能力对齐 对齐 PC 和移动的草稿、编辑、删除、附件、PDF 发票和提交审批能力。 1) PC 和移动都能完成报销草稿主流程;2) 员工不见票据中心入口;3) 移动不使用桌面表格;4) data-testid 稳定。 流程级验证:PC 和移动分别新建草稿 -> 编辑明细 -> 添加附件/PDF 发票 -> 提交审批 -> 删除草稿或验证非草稿不可删。 playwright|npm TODO DONE TODO DONE TODO DONE client/src/pages/ReimbursementsPage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx ["RI6-1"] 只做体验和一致性,不改 OCR provider。 已统一 PC/移动报销详情发票入口、附件和草稿操作;R111/R125/R109 与报销业务流通过。
4 RI6-3 发票状态解释优化 统一可用、识别中、识别失败、已绑定、重复票据、已归档等状态文案和下一步提示。 1) 重复票据给出可理解原因;2) 识别失败有下一步;3) 已绑定和已归档不可误操作;4) 内部术语扫描为 0。 流程级验证:上传 PDF 样票 -> 模拟或真实 OCR 终态 -> 检查各状态文案;node scripts/qa/check-ui-design-rules.mjs --strict。 node|playwright TODO DONE TODO DONE TODO DONE client/src/pages/*Reimbursement*|scripts/qa/check-ui-design-rules.mjs ["RI6-2"] 真实 OCR 重复票据可解释终态继续视为通过。 共享发票状态文案已接入;check-ui-design-rules strict 0 findings;真实 OCR 重复票据可解释终态继续视为通过。
5 RI6-4 报销详情信息架构收口 PC 详情高密度展示,移动详情轻任务展示,突出发票、附件、审批流和付款状态。 1) PC 列表和详情适合筛选和查看;2) 移动详情无横向滚动和遮挡;3) 付款状态与财务动作一致。 流程级验证:PC 1440x960 和移动多视口打开报销详情;审批和付款后状态同步;视觉截图输出。 playwright TODO DONE TODO DONE TODO DONE client/src/pages/ReimbursementsPage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx ["RI6-3"] 遵循 docs/product-design-rules.md。 R111/R109 截图输出到 output/playwright;移动深层页无底部 Tab,吸底动作保持可点。
6 RI6-5 报销票据回归部署 完成后执行报销业务流、视觉、部署和公网复检。 1) 报销业务流 gate 通过;2) R109/R111/R125 相关回归通过;3) 部署后 /ready、bundle 扫描和 live smoke 通过。 流程级验证:BUSINESS_FLOW_GROUPS=reimbursement bash scripts/qa/run-business-flow-gates.sh local;client typecheck/build;deploy;live smoke readiness。 bash|npm|playwright TODO DONE TODO DONE TODO DONE client|server|scripts/qa|deploy/trialrun ["RI6-4"] 敏感票据和截图不提交仓库。 已部署到 https://oa.hyinda.com;/ready ok;public bundle findings=0;live readiness PASS,报告 artifacts/qa-flow/live-smoke-readiness-20260512-120138。
+4
查看文件
@@ -240,6 +240,10 @@ function scanReimbursementRawCodeDisplay(file, source) {
regex: /return\s+linkedItemId;/g, regex: /return\s+linkedItemId;/g,
message: "普通报销详情的发票关联明细不应回退展示内部明细 ID。", message: "普通报销详情的发票关联明细不应回退展示内部明细 ID。",
}, },
{
regex: /invoiceId\.slice\(0,\s*8\)/g,
message: "普通报销详情的发票失败提示不应展示内部发票 ID 片段,应显示发票标题或用户可理解原因。",
},
]; ];
for (const pattern of patterns) { for (const pattern of patterns) {