diff --git a/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md b/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md
index 45154447..c6cc6e1f 100644
--- a/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md
+++ b/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md
@@ -576,6 +576,98 @@ reply.header(
);
```
+## Scenario: Reimbursement Material Presentation And Item Linking
+
+### 1. Scope / Trigger
+
+- Trigger: changing PC or mobile reimbursement-detail presentation for invoice
+ originals, supporting attachments or their relationship to expense items.
+- Purpose: let finance reconcile each item with its invoice without exposing a
+ separate technical “open file” button for every material.
+
+### 2. Signatures
+
+- Existing detail payload: `detail.items[]`, `detail.invoices[]` with
+ `invoice.linkedItemId: string | null`, and `detail.attachments[]`.
+- Existing preview actions: `openInvoiceMutation` and
+ `handleOpenAttachment`; WeCom continues through `previewFile` and PC through
+ its existing browser-safe path.
+- Stable UI selectors: `reimbursement-materials` and
+ `mobile-reimbursement-materials`; existing invoice/attachment child test IDs
+ remain available for focused regressions.
+
+### 3. Contracts
+
+- Present invoice originals and supporting attachments under one user-facing
+ `报销材料` section with separate type labels and counts. Do not merge their
+ database types, API fields, authorization, audit or lifecycle rules.
+- Within each expense item, render only invoices whose
+ `linkedItemId === item.id`. The linked invoice summary is itself a semantic
+ preview button and shows an invoice identifier, seller and amount.
+- Keep every invoice, including unlinked or stale-linked invoices, in the
+ material section. Never infer a link from amount, order or list position.
+- The material information row is the preview target; do not append a repeated
+ visible `查看原件` button to each file. Draft-only add/remove/upload/delete
+ actions remain separate buttons so preview cannot mutate data.
+- Do not introduce a file-browser route, carousel, bulk download or aggregate
+ backend endpoint for the small-team detail page unless later real use proves
+ that the inline material list is insufficient.
+- Clickable rows have a descriptive `aria-label`, visible focus, disabled state
+ during open, a chevron affordance and a minimum 44px mobile touch target.
+
+### 4. Validation & Error Matrix
+
+- Matching `linkedItemId` -> show the invoice inside that item and in the
+ material section.
+- `linkedItemId=null` or target item absent -> show only in the material
+ section; do not attach it to the first or amount-matching item.
+- No invoices and no attachments -> one `暂无报销材料` empty state.
+- Preview pending -> disable repeated opens; existing preview errors and
+ download-reason behavior remain authoritative.
+- Read-only approver -> preview rows visible, mutation actions absent.
+
+### 5. Good/Base/Bad Cases
+
+- Good: a hotel item shows its linked hotel invoice inline, while an unlinked
+ payment screenshot remains under `报销材料` and both originals are previewable.
+- Base: one item, one linked invoice and no attachment renders one inline
+ invoice summary plus one typed material row.
+- Bad: flatten invoices away from items, guess links by equal amount, merge all
+ file records into one backend type, or add a `查看原件` button beside every
+ row.
+
+### 6. Tests Required
+
+- Focused PC and WeCom Playwright fixtures contain at least one linked and one
+ unlinked invoice plus one supporting attachment.
+- Assert the linked invoice appears only in the matching item; the unlinked
+ invoice appears in materials but not in any item.
+- Assert material counts, absence of exact visible `查看原件` buttons, PC open
+ URL behavior, WeCom `previewFile` name/size/URL, 44px touch target and mobile
+ portrait/landscape overflow.
+- Preserve editable add/remove/upload/delete regression coverage, then run
+ frontend typecheck/build and Trellis governance gates.
+
+### 7. Wrong vs Correct
+
+#### Wrong
+
+```tsx
+detail.invoices.map((invoice) => );
+```
+
+#### Correct
+
+```tsx
+detail.invoices
+ .filter((invoice) => invoice.linkedItemId === item.id)
+ .map((invoice) => (
+
+ ));
+```
+
## Scenario: Payment Batch Receipt Attention Count
### 1. Scope / Trigger
diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md
index 3e5ff55e..7454aae5 100644
--- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md
+++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md
@@ -202,3 +202,12 @@
- `name` 使用数据库原始显示名但必须确保与实际 MIME 匹配并带安全扩展名;`size` 来自持久化的准确字节数。
- 短时 URL 不写入 Trellis、聊天、前端持久化存储、错误详情或普通日志;页面关闭/刷新后重新签发。
- 生产首次验收先用无业务数据 PDF 证明 `config + previewFile`,再查看真实材料。部署、企微真机调用和真实审批继续单独获批。
+
+## 12. 报销明细与材料的 KISS 对齐
+
+- 交互采用“展示统一、语义保留”:用户界面把发票原件和补充附件统一归入“报销材料”,但 API、数据库类型、授权、审计和生命周期保持不变;付款后材料不在本轮提前接入。
+- 财务核对以费用明细为主。两端页面按 `invoice.linkedItemId === item.id` 将发票摘要渲染在对应明细内,摘要至少包含发票标识、销售方和金额;摘要整行是明确的键盘/触控预览入口,并复用现有 `openInvoiceMutation`。
+- 未关联或关联目标已不存在的发票只出现在“报销材料”区,不猜测关联关系。材料区显示“发票 N · 补充附件 M”,并继续列出全部材料,方便主管快速通览以及处理未关联材料。
+- 材料列表以带类型标签的文件信息行作为预览入口,不再给每份材料追加“查看原件”按钮。草稿编辑态的添加发票、移除发票、上传附件和删除附件仍作为独立显式动作,避免把预览与数据变更混在同一点击目标。
+- 不新增材料浏览页、底部抽屉、上一份/下一份状态、批量下载、缩略图瀑布流或新的后端聚合接口。PC 与移动端保持相同信息结构,但沿用各自已有桌面表格和移动卡片组件。
+- 可点击信息行必须有可见焦点、自然中文 `aria-label`,移动端触控目标不小于 44px;加载中禁用重复打开。企微仍走 `previewFile`,PC 仍走当前安全预览路径。
diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md
index adbe928e..c554bebf 100644
--- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md
+++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md
@@ -142,7 +142,15 @@
- [x] 公网 `/ready` 为 `8b376f413585`,活动 server 中唯一移动报销 chunk 包含 `mobile-reimbursements-scope-all` 标识。
- [x] 复用既有 unit 合同参数与运行时间刷新 automatic backup 三项和 WARCH3;4 个 service 均匹配 `8b376f4`,4 个 timer 均 enabled/active。
- [x] 手工触发 automatic backup daily 与源端 watchdog,二者均为 `success/0`;未执行真实审批、财务审核、付款批次或付款写入。
-- [ ] 请用户用自己的企微管理员账号进入“报销 → 审批中 → 全部”,打开杨俊的报销单并分别验证发票、附件“查看原件”;成功前仍不允许主管审批。
+- [x] 用户已用自己的企微管理员账号进入“报销 → 审批中 → 全部”,打开当前真实报销并确认能够查看材料;本项只记录查看验收,不代表主管审批或财务链路已完成。
+
+## 6.9 报销明细与材料 KISS 对齐
+
+- [x] PC 与移动端按 `linkedItemId` 在每条费用明细内展示关联发票摘要,并复用当前安全预览动作;未关联发票不归入明细。
+- [x] 将原发票区和附件区收敛为单一“报销材料”区,显示分类计数,全部材料信息行可点击预览且不再重复显示“查看原件”按钮。
+- [x] 保留草稿编辑态的添加/移除发票与上传/删除附件,不改后端合同、对象授权、审计、签名 URL、企微 JS-SDK 或审批状态机。
+- [x] 更新聚焦 Playwright,覆盖关联/未关联发票、统一计数、PC/企微预览、编辑态动作、44px 触控目标和移动横屏无溢出;前端 typecheck/build 与 Trellis 质量门禁通过。
+- [ ] 本地实现提交后,推送、公网部署与真人业务验收另行获得明确批准。
## 7. 完成条件
@@ -199,3 +207,9 @@
- 外部验收边界:生产已部署但企微真机中的无业务 PDF 原生预览、马建勇对真实发票与附件的可见性仍待人工验收;在材料真实可见前不得完成主管审批。
- 移动范围对齐:管理员移动报销页新增“我的 / 全部”,默认 `own`,选择“全部”后请求现有 `scope=all` 并把 status/scope 带入详情回跳来源;普通员工即使 URL 带 `scope=all` 仍请求 `own` 且看不到切换。首轮 Playwright 因未显式指定本地 Vite 地址而在登录前连接失败;指定 `E2E_BASE_URL` 后功能执行,首次严格 44px 断言测得浏览器子像素值略低于 44,控件提升为 48px 后最终聚焦 2/2、同文件完整回归 5/5 通过。375px 竖屏和 667×375 横屏无横向溢出,typecheck/build 通过。
- 移动范围生产发布:`20b20b1` 与 `8b376f4` 已推送,`8b376f413585` 以 `SYNC_ENV=0` 部署;无待执行 migration,公网 ready、external/runtime smoke、公网扫描和活动移动 chunk 标识通过。第一次本地命令在进程启动前被安全护栏拒绝,第二次因 portfolio review 令来源校验在同步前停止;临时 stash 后第三次成功,目录已原样恢复。4 个 service 构建身份匹配、4 个 timer enabled/active,新 daily 与 watchdog 均为 `success/0`。企微管理员真机的“全部”列表及发票/附件原生预览仍待用户验收。
+- 真实材料查看验收:用户随后确认已能从自己的企微管理员账号找到当前真实报销并查看材料;该确认只关闭查看阻断,不代表主管审批、财务审核、付款批次或最终复核已经完成。
+- 明细/材料 KISS 实现:PC 与移动端按 `linkedItemId` 在费用明细内显示可预览发票,未关联发票只留在统一“报销材料”区;材料区显示类型计数并以整行作为预览入口,后台类型、权限、审计、短时 URL 和企微 `previewFile` 均未改变。
+- 聚焦 Playwright 首轮因本地前端服务未启动,PC/移动 2 项均在 `/login` 前 `ERR_CONNECTION_REFUSED`,未进入功能断言。启动 Vite 并指定 `E2E_BASE_URL` 后,两端首次都因旧测试仍假设销售方文本唯一而触发 strict-mode failure;这是新界面同时在关联明细和材料区展示同一发票摘要后的断言漂移,改为按区域断言。
+- 下一轮 PC 通过,移动在“添加附件”后因测试仍从旧附件子区查找已移到统一材料区的隐藏 file input,等待至用例超时;页面快照确认实现已渲染且未发生业务错误。测试改为从材料区定位上传控件后,移动单项通过。
+- 最终本地验证:生产 build(含 typecheck)通过;R111 PC/企微双端与 Wave3 只读时区/KISS 聚焦回归 3/3 通过,企微 `previewFile` 参数、PC 打开 URL、关联/未关联发票、材料计数、无逐文件“查看原件”按钮、44px 触控目标和 667×375 横屏无溢出均有合成断言。
+- 最终治理:task validate、Trellis governance、process contract、敏感扫描与 `git diff --check` 通过;文档审计为 0 error、1 条既有汇总 warning(22 个历史未登记文件)。稳定展示合同已写入 `reimbursement-invoice-ocr` spec;未部署、未读取或写入真实业务数据。
diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md
index 175c528d..4ebc0d73 100644
--- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md
+++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md
@@ -43,6 +43,7 @@
- 真实审批继续暴露材料查看阻断:PC 能打开普通附件,但 PC 与移动报销详情均没有发票原件入口,企微 WebView 中异步 `window.open(blob:)` 只下载附件而不能预览。企业微信自建应用内必须优先使用官方 `ww.previewFile` / `wx.previewFile` 原生能力查看 PDF;普通 PC 浏览器保留浏览器预览,两端保持“查看原件”的业务能力一致。
- 生产验收发现移动报销列表固定请求 `scope=own`,导致已有 `expense.view_all` 的管理员无法在企微工作台找到杨俊的报销单并代为验证原生预览。移动端必须与 PC 的范围能力对齐:仅对 `expense.view_all` 用户显示“我的 / 全部”切换,默认仍为“我的”,选择“全部”时复用现有 `scope=all` API;普通员工不显示范围切换,不修改后端权限模型。
- 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 URL。原生层不会携带 OA Bearer token 或可靠复用 WebView Cookie,因此附件可以在当前任务内增加仅用于 `previewFile` 的短时 bearer URL 例外;该 URL 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。
+- 生产可见性验收通过后发现报销详情仍把每张发票、每个补充附件都暴露为独立“查看原件”按钮,且费用明细与其 `linkedItemId` 发票分区展示,不利于财务逐条核对。PC 与移动端应统一采用“业务关联优先、材料入口收敛”的展示:每条明细直接显示并可预览其关联发票;全部发票和补充附件仍在单一“报销材料”区按类型汇总,文件信息行本身作为预览入口,不新增独立材料浏览路由、轮播器或后端模型。
## Scope
@@ -77,7 +78,9 @@
- [x] PC 报销详情中的发票和附件提供对应的“查看原件”,使用短时签名 URL 或现有认证下载能力;两端审批人均只能查看当前报销绑定材料,不能编辑、解绑、删除或获得票据池管理权限。
- [x] 移动报销列表仅对具备 `expense.view_all` 的用户显示“我的 / 全部”切换,默认“我的”;切换“全部”后能通过现有列表进入他人报销详情,普通员工始终只能查看本人列表。
- [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。
-- [ ] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,真实材料可见前不允许完成主管审批。
+- [x] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,用户已确认能在企微查看当前真实报销材料。
+- [x] PC 与移动端每条费用明细展示通过 `linkedItemId` 关联的发票摘要,点击该摘要可沿用现有安全预览能力打开原件;未关联发票不错误归入任何明细。
+- [x] PC 与移动端将原“发票 / 附件材料”收敛为单一“报销材料”区,显示发票与补充附件计数;材料信息行可点击预览,不再为每份材料重复渲染“查看原件”按钮,同时保留草稿编辑态的添加、移除、上传和删除能力。
## Out of Scope
@@ -101,3 +104,4 @@
- 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。
- 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。
- 用合成管理员与普通员工权限验证移动报销范围:管理员默认请求 `scope=own`,切换后请求 `scope=all` 并显示他人报销;普通员工不显示“全部”且始终请求 `scope=own`。
+- 用合成多明细、已关联发票、未关联发票和补充附件验证 PC/移动一致性:关联发票出现在正确明细内;统一材料区计数正确且所有材料仍可预览;页面不再出现逐文件“查看原件”按钮;编辑态管理动作不回归。
diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx
index 8456663f..347a516f 100644
--- a/client/src/pages/MobileReimbursementDetailPage.tsx
+++ b/client/src/pages/MobileReimbursementDetailPage.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { fetchExpenseTypes } from "../api/expenseTypes";
import {
@@ -983,6 +984,28 @@ export function MobileReimbursementDetailPage() {
{formatCurrency(item.amount)}
{item.description ? {item.description}
: null}
+ {detail.invoices
+ .filter((invoice) => invoice.linkedItemId === item.id)
+ .map((invoice) => (
+
+ ))}
{canEdit ? (
-
-
-
发票
- {canOpenInvoiceDialog ? (
-
- ) : null}
-
-
- {detail.invoices.length ? (
-
- {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)}
-
-
-
- {canUnbindInvoices ? (
-
- ) : null}
-
-
- );
- })}
-
- ) : (
-
- 暂无发票
-
- )}
-
-
-
+
-
附件材料
- {canManageAttachments ?
PDF/JPG/PNG/WebP,单次最多 5 个
: null}
+
报销材料
+
+ 发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
+
- {canManageAttachments ? (
-
- ) : null}
+ {canOpenInvoiceDialog || canManageAttachments ? (
+
+ {canOpenInvoiceDialog ? (
+
+ ) : null}
+ {canManageAttachments ? (
+
+ ) : null}
+
+ ) : null}
+
+ {invoiceActionError ? (
+
+ {invoiceActionError}
+
+ ) : null}
+
{canManageAttachments ? (
) : null}
- {(detail.attachments ?? []).length ? (
+ {detail.invoices.length || (detail.attachments ?? []).length ? (
- {(detail.attachments ?? []).map((attachment) => (
-
-
-
{attachment.originalName}
-
- {Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
-
-
-
-
-
+ {canUnbindInvoices ? (
setDeleteAttachmentTarget({ id: attachment.id, name: attachment.originalName })}
+ disabled={unbindInvoiceMutation.isPending}
+ onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
- 删除
+ 移除
) : null}
-
+
+ );
+ })}
+
+
+
+ {(detail.attachments ?? []).map((attachment) => (
+
+ void handleOpenAttachment(attachment)}
+ >
+
+ 补充附件
+ {attachment.originalName}
+
+ {Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
+
+
+
+
+
+ {canManageAttachments ? (
+ setDeleteAttachmentTarget({ id: attachment.id, name: attachment.originalName })}
+ >
+ 删除
+
+ ) : null}
))}
+
) : (
- 暂无附件材料
+ 暂无报销材料
)}
diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx
index 72dbcb1d..1b9655cc 100644
--- a/client/src/pages/ReimbursementDetailPage.tsx
+++ b/client/src/pages/ReimbursementDetailPage.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ChevronRight } from "lucide-react";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
@@ -1251,24 +1252,22 @@ export function ReimbursementDetailPage() {
) : null}
-
+
-
-
报销明细
-
- {canEditItems ? (
-
- 新增明细
-
- ) : null}
-
+ {canEditItems ? (
+
+
+ 新增明细
+
+
+ ) : null}
{itemError ? (
))}
+ {detail.invoices
+ .filter((invoice) => invoice.linkedItemId === item.id)
+ .map((invoice) => (
+ openInvoiceMutation.mutate(invoice.id)}
+ >
+
+
+ 发票 · {formatInvoiceDisplayTitle(invoice)}
+
+
+ {invoice.sellerName || "未识别销售方"} · ¥{invoice.totalAmount ?? "-"}
+
+
+
+
+ ))}
{canEditItems ? (
@@ -1396,12 +1417,15 @@ export function ReimbursementDetailPage() {
) : null}
-
+
+
+
+
+
-
-
发票
- {canEditItems ?
提交前至少添加 1 张发票。
: null}
-
+
+ 发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
+
{canOpenInvoiceBindDialog ? (
添加发票
- ) : canEditItems ? (
-
当前账号不可在此页添加发票
) : null}
@@ -1425,74 +1447,47 @@ export function ReimbursementDetailPage() {
) : null}
- {(detail.invoices ?? []).length ? (
-
- {detail.invoices.map((item) => {
- const invoiceHint = resolveInvoiceNextStepText(item);
- return (
-
+ {detail.invoices.map((invoice) => {
+ const invoiceHint = resolveInvoiceNextStepText(invoice);
+ return (
+
+
openInvoiceMutation.mutate(invoice.id)}
>
-
-
-
- {formatInvoiceDisplayTitle(item)}
-
-
- {item.sellerName || "未识别销售方"} · {item.invoiceDate ? formatDisplayDate(item.invoiceDate) : "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
-
-
- 原始文件:{item.originalName || "未记录原始文件"}
-
-
- 明细:{resolveLinkedItemLabel(item.linkedItemId)}
-
-
-
-
-
- {resolveInvoiceStatusLabel(item.status)}
-
- openInvoiceMutation.mutate(item.id)}
- >
- 查看原件
-
- {canUnbindInvoices ? (
- unbindInvoiceMutation.mutate(item.id)}
- >
- 移除
-
- ) : null}
-
- {invoiceHint ? (
-
{invoiceHint}
- ) : null}
-
-
-
- );
- })}
-
- ) : (
-
暂无发票
- )}
-
-
-
+
发票
+
+ {formatInvoiceDisplayTitle(invoice)}
+
+ {invoice.sellerName || "未识别销售方"} · {invoice.invoiceDate ? formatDisplayDate(invoice.invoiceDate) : "未识别日期"} · ¥{invoice.totalAmount ?? "-"}
+
+ 明细:{resolveLinkedItemLabel(invoice.linkedItemId)}
+ {invoiceHint ? {invoiceHint} : null}
+
+
{resolveInvoiceStatusLabel(invoice.status)}
+
+
+ {canUnbindInvoices ? (
+
unbindInvoiceMutation.mutate(invoice.id)}
+ >
+ 移除
+
+ ) : null}
+
+ );
+ })}
+
-
-
+
{canEditItems ? (
支持 PDF/JPG/PNG/WebP,单次最多 5 个。
@@ -1539,42 +1534,42 @@ export function ReimbursementDetailPage() {
{(detail.attachments ?? []).length ? (
{(detail.attachments ?? []).map((attachment) => (
-
-
-
{attachment.originalName}
-
- {Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
-
-
-
-
+
+
void handleOpenAttachment(attachment)}
+ >
+ 补充附件
+
+ {attachment.originalName}
+
+ {Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
+
+
+
+
+
+ {canEditItems ? (
void handleOpenAttachment(attachment)}
+ disabled={deleteAttachmentMutation.isPending}
+ onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
>
- 查看原件
+ 删除
- {canEditItems ? (
-
setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
- >
- 删除
-
- ) : null}
-
+ ) : null}
))}
- ) : (
-
暂无附件材料
+ ) : detail.invoices.length ? null : (
+
暂无报销材料
)}
+
diff --git a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts
index 68b62504..49d8f6e3 100644
--- a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts
+++ b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts
@@ -10,6 +10,7 @@ const WECOM_UA =
const reimbursementId = "11111111-1111-4111-8111-111111111111";
const ownerId = "employee-r111";
const pendingInvoiceId = "22222222-2222-4222-8222-222222222222";
+const unlinkedInvoiceId = "66666666-6666-4666-8666-666666666666";
const attachmentId = "33333333-3333-4333-8333-333333333333";
const availableInvoiceId = "44444444-4444-4444-8444-444444444444";
const failedInvoiceId = "55555555-5555-4555-8555-555555555555";
@@ -80,7 +81,18 @@ function buildInvoice() {
originalName: "北京悦海堂发票.pdf",
mimeType: "application/pdf",
fileSize: 4096,
- linkedItemId: "item-r111",
+ linkedItemId: "item-r111" as string | null,
+ };
+}
+
+function buildUnlinkedInvoice() {
+ return {
+ ...buildInvoice(),
+ id: unlinkedInvoiceId,
+ invoiceNumber: "12345679",
+ sellerName: "未关联材料供应方",
+ originalName: "未关联发票.pdf",
+ linkedItemId: null,
};
}
@@ -432,7 +444,7 @@ async function mockApis(page: Page, state: ReturnType
{
expect(state.statusPollCount).toBeGreaterThanOrEqual(1);
await page.keyboard.press("Escape");
- await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
+ await expect(page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
await expect(page.getByText("明细:业务招待 · 客户招待")).toBeVisible();
- await page.getByRole("button", { name: "查看原件" }).first().click();
+ await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 0");
+ const itemRow = page.getByTestId("reimbursement-item-row");
+ await expect(itemRow.getByRole("button", { name: /查看关联发票/ })).toHaveCount(1);
+ await expect(itemRow).not.toContainText("未关联材料供应方");
+ await expect(page.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2);
+ await expect(page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
+ await itemRow.getByRole("button", { name: /查看关联发票/ }).click();
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(1);
const invoiceOpenUrl = await page.evaluate(
() => String((window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls[0]?.[0] ?? ""),
@@ -612,7 +630,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
});
await page.getByRole("button", { name: "上传附件" }).click();
await expect(page.getByText("付款截图.png")).toBeVisible();
- await page.getByTestId("reimbursement-attachments").getByRole("button", { name: "查看原件" }).click();
+ await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
+ await page.getByTestId("reimbursement-attachments").getByRole("button", { name: /查看补充附件/ }).click();
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(2);
const bodyText = await page.locator("body").innerText();
@@ -656,7 +675,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await login(mobile.page);
await mobile.page.goto(`/mobile/reimbursements/${reimbursementId}`);
await expect(mobile.page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
- await expect(mobile.page.getByRole("heading", { name: "附件材料" })).toBeVisible();
+ await expect(mobile.page.getByRole("heading", { name: "报销材料" })).toBeVisible();
const beforeDialogMetrics = await collectVisualMetrics(mobile.page);
expect(beforeDialogMetrics.scrollWidth).toBeLessThanOrEqual(beforeDialogMetrics.width + 2);
@@ -712,21 +731,29 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
mobile.page.getByTestId("reimbursement-invoice-dialog").getByText("已添加 1 张发票"),
).toBeVisible({ timeout: 15_000 });
await mobile.page.keyboard.press("Escape");
- await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
- await mobile.page.getByRole("button", { name: "查看原件" }).first().click();
+ await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
+ await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 0");
+ const mobileItem = mobile.page.locator("article.mobile-list-card").filter({ hasText: "客户招待" }).first();
+ await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(1);
+ await expect(mobileItem).not.toContainText("未关联材料供应方");
+ await expect(mobile.page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
+ const linkedInvoiceTarget = await mobileItem.getByRole("button", { name: /查看关联发票/ }).boundingBox();
+ expect(linkedInvoiceTarget?.height ?? 0).toBeGreaterThanOrEqual(44);
+ await mobile.page.getByRole("button", { name: /查看发票原件:044002600111-12345678/ }).click();
await expect.poll(() => mobile.page.evaluate(() => (
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(1);
- await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "选择" }).click();
- await mobile.page.locator("[data-testid='mobile-reimbursement-attachments'] input[type='file']").setInputFiles({
+ await mobile.page.getByRole("button", { name: "添加附件" }).click();
+ await mobile.page.getByTestId("mobile-reimbursement-materials").locator("input[type='file']").setInputFiles({
name: "付款截图.png",
mimeType: "image/png",
buffer: Buffer.from("r111 mobile attachment"),
});
- await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "上传附件" }).click();
+ await mobile.page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: "上传附件" }).click();
await expect(mobile.page.getByText("付款截图.png")).toBeVisible();
- await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "查看原件" }).click();
+ await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
+ await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: /查看补充附件/ }).click();
await expect.poll(() => mobile.page.evaluate(() => (
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(2);
@@ -753,6 +780,11 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
window as typeof window & { __r111OpenCalls: unknown[][] }
).__r111OpenCalls.length)).toBe(0);
+ await mobile.page.setViewportSize({ width: 667, height: 375 });
+ const landscapeMetrics = await collectVisualMetrics(mobile.page);
+ expect(landscapeMetrics.scrollWidth).toBeLessThanOrEqual(landscapeMetrics.width + 2);
+ await mobile.page.setViewportSize({ width: 390, height: 844 });
+
const bodyText = await mobile.page.locator("body").innerText();
expect(bodyText).not.toContain("绑定整单");
expect(bodyText).not.toContain("不绑定明细");