diff --git a/client/src/lib/invoiceStatus.ts b/client/src/lib/invoiceStatus.ts new file mode 100644 index 00000000..4fe68a02 --- /dev/null +++ b/client/src/lib/invoiceStatus.ts @@ -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 = { + 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); +} diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx index ba437e9f..5ee14429 100644 --- a/client/src/pages/MobileReimbursementDetailPage.tsx +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -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 = { cancelled: "bg-zinc-100 text-zinc-500", }; -const INVOICE_STATUS_LABEL: Record = { - 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 ? (
- {detail.invoices.map((invoice) => ( -
-
-
-
{formatInvoiceTitle(invoice)}
-
- {invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} + {detail.invoices.map((invoice) => { + const invoiceHint = resolveInvoiceNextStepText(invoice); + return ( +
+
+
+
{formatInvoiceDisplayTitle(invoice)}
+
+ {invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} +
+
明细:{resolveLinkedItemLabel(invoice.linkedItemId)}
+ {invoiceHint ?
{invoiceHint}
: null} +
+
+
{formatCurrency(invoice.totalAmount)}
+ + {resolveInvoiceStatusLabel(invoice.status)} +
-
明细:{resolveLinkedItemLabel(invoice.linkedItemId)}
-
-
{formatCurrency(invoice.totalAmount)}
- - {INVOICE_STATUS_LABEL[invoice.status] ?? "未知状态"} - -
-
- {canUnbindInvoices ? ( - - ) : null} -
- ))} + {canUnbindInvoices ? ( + + ) : null} + + ); + })}
) : (
@@ -1356,11 +1351,18 @@ export function MobileReimbursementDetailPage() { /> ) : ( - 识别失败 + {resolveInvoiceStatusLabel(invoice.status)} )}
-
{formatInvoiceTitle(invoice)}
+
+
{formatInvoiceDisplayTitle(invoice)}
+ {isAvailable ? ( + + {resolveInvoiceStatusLabel(invoice.status)} + + ) : null} +
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} · {formatCurrency(invoice.totalAmount)}
@@ -1368,7 +1370,7 @@ export function MobileReimbursementDetailPage() {
{isFailed ? (
- {invoice.unavailableMessage ?? "识别失败,可删除后重新上传。"} + {resolveInvoiceUserMessage(invoice)}
) : null} {isAvailable && checked ? ( @@ -1474,7 +1476,7 @@ export function MobileReimbursementDetailPage() { open={confirmDeleteReimbursement} onOpenChange={setConfirmDeleteReimbursement} title="删除草稿?" - description="删除后无法恢复,已添加发票会回到可用状态。" + description="删除后无法恢复,已添加发票会回到可添加状态。" variant="destructive" confirmLabel="删除草稿" onConfirm={() => deleteReimbursementMutation.mutate()} diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index d8b170bf..1d80cc6a 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -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 = { cancelled: "已取消", }; -const INVOICE_STATUS_LABEL: Record = { - 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 = { 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 ? (
- {detail.invoices.map((item) => ( -
-
-
-
- 发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"} + {detail.invoices.map((item) => { + const invoiceHint = resolveInvoiceNextStepText(item); + return ( +
+
+
+
+ {formatInvoiceDisplayTitle(item)} +
+
+ {item.sellerName || "未识别销售方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"} +
+
+ 原始文件:{item.originalName || "未记录原始文件"} +
+
+ 明细:{resolveLinkedItemLabel(item.linkedItemId)} +
-
- {item.sellerName || "未识别卖方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"} +
+
+ + {resolveInvoiceStatusLabel(item.status)} + + {canUnbindInvoices ? ( + + ) : null} +
+ {invoiceHint ? ( +
{invoiceHint}
+ ) : null}
-
- 原始文件:{item.originalName || item.id} -
-
- 明细:{resolveLinkedItemLabel(item.linkedItemId)} -
-
-
- - {invoiceStatusLabel(item.status)} - - {canUnbindInvoices ? ( - - ) : null}
-
- ))} + ); + })}
) : (

暂无发票

@@ -1633,7 +1637,7 @@ export function ReimbursementDetailPage() { > 添加发票 - 选择可用发票,或上传新发票。 + 选择可添加发票,或上传新发票。
@@ -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 (
- 添加后自动生成或匹配报销明细 + {invoiceHint} 添加后自动生成或匹配报销明细。
@@ -1881,7 +1884,7 @@ export function ReimbursementDetailPage() { open={confirmDeleteReimbursement} onOpenChange={setConfirmDeleteReimbursement} title={`确认删除 ${displayCode}?`} - description="删除后无法恢复,已添加发票会回到可用状态。" + description="删除后无法恢复,已添加发票会回到可添加状态。" variant="destructive" confirmLabel="删除草稿" onConfirm={() => { deleteReimbursementMutation.mutate(); }} diff --git a/client/tests/e2e/r109-mobile-reimbursement-feedback.spec.ts b/client/tests/e2e/r109-mobile-reimbursement-feedback.spec.ts index c1c58058..c8d58f27 100644 --- a/client/tests/e2e/r109-mobile-reimbursement-feedback.spec.ts +++ b/client/tests/e2e/r109-mobile-reimbursement-feedback.spec.ts @@ -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); diff --git a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts index 144db52d..a587b4a8 100644 --- a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts +++ b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts @@ -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(); diff --git a/docs/trialrun/manual-trialrun-report.md b/docs/trialrun/manual-trialrun-report.md index dfb29bd8..73d0524b 100644 --- a/docs/trialrun/manual-trialrun-report.md +++ b/docs/trialrun/manual-trialrun-report.md @@ -14,8 +14,8 @@ | 项目 | 记录 | | --- | --- | | 公网地址 | `https://oa.hyinda.com` | -| 当前门禁 | R5 已 `DONE/DONE/DONE`;R6 首轮小范围试运行已完成;R6-ISSUE-003 采购提交阻断已热修、部署并复跑通过;PC6/PA6/FL6/AP6 后续优化已完成;下一批进入 RI6 报销票据体验完善 | -| 当前代码基线 | `2d87c36`;公网 `https://oa.hyinda.com` 已部署 AP6 审批催办、超时提醒和效率摘要,并完成 live smoke readiness 复检 | +| 当前门禁 | R5 已 `DONE/DONE/DONE`;R6 首轮小范围试运行已完成;R6-ISSUE-003 采购提交阻断已热修、部署并复跑通过;PC6/PA6/FL6/AP6/RI6 后续优化已完成;下一批进入 LP6 请假采购体验完善 | +| 当前代码基线 | 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` | | 回滚联系人 | `wangshunli` | | 问题登记负责人 | `wangshunli` | @@ -88,7 +88,9 @@ R6-3 试运行前自动检查(2026-05-12):公网 `/ready` 正常;public 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 至少完成一轮后另建合同。 ## 结论 diff --git a/issues/2026-05-12_01-44-00-r6-reimbursement-invoice-polish-v1.csv b/issues/2026-05-12_01-44-00-r6-reimbursement-invoice-polish-v1.csv index 92baf0f0..627b0b80 100644 --- a/issues/2026-05-12_01-44-00-r6-reimbursement-invoice-polish-v1.csv +++ b/issues/2026-05-12_01-44-00-r6-reimbursement-invoice-polish-v1.csv @@ -1,6 +1,6 @@ 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-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-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-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-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-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,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,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,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。 diff --git a/scripts/qa/check-ui-design-rules.mjs b/scripts/qa/check-ui-design-rules.mjs index 7d2231f1..3211c820 100644 --- a/scripts/qa/check-ui-design-rules.mjs +++ b/scripts/qa/check-ui-design-rules.mjs @@ -240,6 +240,10 @@ function scanReimbursementRawCodeDisplay(file, source) { regex: /return\s+linkedItemId;/g, message: "普通报销详情的发票关联明细不应回退展示内部明细 ID。", }, + { + regex: /invoiceId\.slice\(0,\s*8\)/g, + message: "普通报销详情的发票失败提示不应展示内部发票 ID 片段,应显示发票标题或用户可理解原因。", + }, ]; for (const pattern of patterns) {