RI6 完善报销票据体验与状态文案
这个提交包含在:
@@ -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()}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
|
@@ -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) {
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户