比较提交

...
2 次代码提交
作者 SHA1 备注 提交日期
wangsli 98eabdde7f fix: 统一报销付款详情布局与票据展示(09-04-reimbursement-state-gap-finance-view)
ci-minimal / check-minimal (push) Canceled after 0s
ci-e2e / e2e (push) Canceled after 0s
2026-09-11 22:28:06 +08:00
wangsli 2b4e3faa0e fix: 完善报销制单备注(09-08-reimbursement-payment-closeout) 2026-09-11 22:27:40 +08:00
共修改 16 个文件,包含 279 行新增和 36 行删除
@@ -14,6 +14,11 @@ and verification contracts. Do not copy broad aesthetic guidance from
## Convention: Reimbursement Payment Handoff Consistency
- Reimbursement desktop shells and payment detail opt into `detail-workspace-grid--payment`: a 320px aside at lg and above, single column below lg. A shared proportional grid does not guarantee equal asides when parent max-width differs. Verify actual widths on both pages at 1280/1440, not only matching class names; leave unrelated detail grids unchanged.
- `BusinessDetailActionPanel({ inlineActions: true })` puts the primary action followed by the More trigger in a wrapping, content-width row. The default layout stays unchanged. Both detail pages use the existing DropdownMenu; menu items are portalled outside the card, so test via page-level menu roles. Keep disabled states, permission conditions, confirmations and mobile payment actions intact. Do not pass known-empty action components as children: a React element that renders null is still a nonempty slot and otherwise creates a phantom divider.
- Linked reimbursement invoices use a desktop table with number preview, seller, date and right-aligned amount; narrow rows stack without duplicating IDs or preview controls. Keep OCR warnings, unlinked correction cards, attachment delete confirmation and evidence anchors. Test five synthetic invoices, long names/numbers, empty attachments, preview calls, narrow layout and the existing approval/detail consumers.
- A compact payee-account reveal control stays beside the masked value as a 44px Eye button with title and aria-label `查看完整账号`. Preserve role/permission/status/account gates and the reason/audit dialog; icon clicks must not immediately request plaintext. Layout measurements wait for fonts and compare aligned centers; round subpixel box heights to hundredths before checking the 44px minimum (Chromium can return 43.99994 for a CSS 44px target).
- `resolveReimbursementPaymentHandoff({ status, deletedAt, progress })` consumes the server's `payment_batch_created`, `payment_batch_review` and `payment_and_receipt` stages. It is a display projection, not a second payment eligibility or write state machine.
- Only an explicit current creation stage followed by pending review/payment can advertise creation, and the caller still requires `payment.batch.manage`. Missing stages, unknown status or contradictory facts never produce a guessed creation action. Cancelled and failed batches use the existing stage explanation, not automatic recreation.
- Read-only detail states (including reviewing, approved and paid) override global focus caching locally (`staleTime: 0`, refetch on mount and visibility return). Do not enable whole-app focus refetch or overwrite draft/rejected editors for this fix. A query error must not keep a stale actionable payment panel.
@@ -1,5 +1,11 @@
# 报销状态空隙与财务处理视角:设计
## 2026-09-11 等宽侧栏与票据表格
动作卡通过可选inlineActions布局主动作和更多,默认合同不变。两页更多复用现有Radix DropdownMenu,Portal渲染后测试按page.getByRole("menuitem")定位,而不是动作卡内找按钮。业务回调和确认对话框不变;报销调用方仅在有撤回或动作错误时传ReimbursementActions,避免React组件返回null但外层误判为有内容而生成空分隔线。
根因:共享detail-workspace-grid使用比例列,而报销壳另有max-w-7xl,付款壳无同一上限。新增局部共享detail-workspace-grid--payment类供两处使用,lg起固定320px侧栏,主列minmax(0,1fr),保持各自正文宽度和手机规则。frontend-design不可用,仅复用现有表格、边框和按钮样式,不创建新视觉体系。已关联票据采用响应式表格,窄屏按行紧凑排列;未关联票据保留原纠错控件与唯一锚点。姓名和号码允许换行,预览、移除及OCR告警按原capabilities保留。
## 2026-09-11 下一步与侧栏一致性方案(已批准本地实施)
本轮只读核对已发布候选,未查询生产。ReimbursementDetailPage 的 approved 分支无条件显示“等待财务组建付款批次”,其主动作只再检查 payment.batch.manage;同一页面 ProcessingProgress 已消费服务端按真实批次状态构造的阶段。修复必须连接这两处投影,不能只把错误文字换成另一句固定文案。
@@ -1,5 +1,29 @@
# 报销状态空隙与财务处理视角:实施计划
## 本轮复核输入与提交准备(未提交)
用户批准整理两笔独立改动的复核/提交方案。基线均为5d8cdf12;01为制单备注5文件,02为UI布局11文件,路径无重叠。仅生成补丁、摘要清单并用临时索引验证顺序合并,不改变实际HEAD/index,不创建commit,不推送或部署。复核入口位于layout副本的artifacts/qa-layout/review-preparation/README.md;临时合并树不是发布候选commit。既有验证按输入不变复用,本轮不新增测试通过计数;UI补跑外层143、网银导入未验证、未独立复核等边界保留。提交前须核对冻结摘要,发布前还需验证合并后的实际候选,不能把单包测试冒充集成验收。
## 2026-09-11 侧栏与票据展示增量
独立layout副本基于5d8cdf12,官方current核对本task;不混制单备注及其他未提交工作。本轮已授权本地实施,未授权提交/推送/部署。
- [x] 统一两处侧栏,复用表格展示已关联发票,保留异常/手机/附件能力;同轮更多下拉同行、账号旁查看图标已实施。
- [x] 添加同屏宽度和票据展示回归,运行typecheck/build与相关浏览器测试。
- [x] 主会话复核、更新规约和验证记录,明确未发布边界。
本轮本地实施已完成:图标仅替换展示,原employee.bank.read/finance角色、reviewed/failed及paymentAccount条件未改,点击仍需填写原因后才能请求完整账号。布局/菜单不改API、权限规则和状态机;主会话核源码及合成截图,不冒充独立复核或生产验收。
验证回执:
- client下npm run build(含typecheck)重跑退出0,Vite/PWA完成,116项预缓存;最终源文件再次npm run typecheck退出0。首轮build外层143保留,不计为通过。
- E2E_BASE_URL指向本地4317,npx playwright test tests/e2e/system-action-confirmation.spec.ts tests/e2e/reimbursement-content-consistency.spec.ts --workers=1 --reporter=list:50项中47通过、3失败。手机失败是boundingBox将44px回传为43.99993896484375;改为百分之一像素精度判断,仍要求44px。另两项为未指定BIFF8运行环境,生成器不可用,不是制单内容失败。
- 使用既有BIFF8虚拟环境的PAYMENT_ORDER_PYTHON_BIN,仅重跑手机交接和双端制单3项,Playwright报告3 passed。外层会话返回143,不能把整条补跑命令称为退出0;进程检查已无该补跑进程。保留日志和结果,不为刷绿再次重复已通过输入。50项用例已有通过结果,但不是一次50/50退出0的运行。
- 首轮侧栏4项中3通过,同排几何测量出现8px差值,失败截图未见持续错排;等待字体并按中心线比较后,1280/1440均通过且两页侧栏都实测320px。不将字体竞态猜测冒充已独立证明的原因。
- 图标键盘打开/取消零完整账号请求;5张长名称/长编号在390/1280/1440均完整且不造成页面横向溢出,预览请求命中正确票据;桌面更多、删除/撤回/打印、手机底栏、上传回单、双端真实BIFF8下载与重复下载边界均有回归通过结果。
- Trellis治理、process-contract baseline、git diff --check通过。本地证据存artifacts/qa-layout(ignored),含首次失败和补跑记录;不提交合成截图或原始运行输出到业务文档。
未提交、未推送、未部署、未做生产写入;制单备注仍在另一独立副本。本轮不归档整个task。下一步按路径汇总本UI增量与独立备注增量的候选复核输入,经明确授权后再分别提交/推送及准备发布,不覆盖原integration任务历史。
## 2026-09-11 迟到 401 复核接续(最新,门户取消修正本地通过)
主会话只读核对 dcde0d44 的 33 源文件、3 补丁重放及历史证据后,双端合成浏览器补查甲→乙→甲:甲旧请求迟到 401 后按乙会话重试,结果进入甲缓存,甲再次登录等待新响应时显示乙待办。该包 NO_GO;独立复核仍 PENDING_INLINE,原 6/62/真实隔离通过记录不改写。回执在 postpush-identity/readonly-review.md。
@@ -2,6 +2,14 @@
## Goal
### 2026-09-11 追加:侧栏等宽与票据紧凑展示(已批准)
用户同轮要求“查看完整账号”改为账号旁Eye图标,保留44px触控、title/aria-label及键盘入口;账号仍默认脱敏,权限/角色/状态条件和填写原因、审计流程不变,不改为悬浮或点击即解密。
用户同轮追加“更多”紧随主按钮:报销和付款桌面动作行左对齐,主按钮按内容宽度,更多为相同下拉菜单;不足则换行,不改变手机付款底栏。保留菜单内删除/打印/下载/上传/取消的资格、禁用与确认语义。去掉空动作组件导致的重复分隔线,不隐藏实际撤回或错误信息。
报销详情/审批共用壳与付款详情桌面侧栏统一320px,lg以下仍单列,操作在上、进度在下;不改其他业务布局。发票桌面按号码、开票方、日期、金额、操作展示,号码沿用预览入口,金额右对齐;窄屏紧凑列表。补充附件保留独立文件列表及已有类型/大小、预览和有权删除入口,空态一行。不新增关联步骤,不隐藏未关联/OCR失败等异常处理。使用合成数据验收等宽、多票据、长名称、预览、附件删除及390px无页面溢出。无权限/API/生产变更,不混入制单备注补丁。
### 2026-09-11 追加需求:付款下一步与详情侧栏一致性(已批准本地实施)
用户反馈财务在报销详情同时看到“去组建付款批次”和已创建、已复核的付款进度。只记录脱敏状态矛盾,不复制真实截图、票据或金额。已发布 fe025e65 源码中右侧下一步及主动作只按报销 status=approved 判断,忽略已由服务端提供的付款阶段;报销进度插在主列材料之后,付款详情则在右栏操作下方。报销审批通过并不等于尚未组批,也不等于银行已经付款。
@@ -1,5 +1,11 @@
# 业务闭环方案(规划,未实施)
## 2026-09-11 已批准备注增量
仅修改buildPaymentOrderRows的备注列,使用已验证且已trim的payeeName和entityCode拼成“姓名报销款 单号”。payeeName来自组批冻结的snapshot_json.payee.name;路由将归属人的收款信息冻结为payee,生成器不另查现职员工/提交人。每个batch item保持一行,即使姓名/收款账号相同也不合并。姓名或单号为空沿现有blockers整批拒绝。
不改模板列宽/格式、不缩写或截断、不引入未经银行证明的长度阈值。测试使用合成姓名,逐列xlrd读回用途、备注及文字类型,并证明同名两单独立对应、旧序号不重排。BX/PB编号规则本轮不改;编号说明以PRD最新节为准,不将日期前缀误当每日重置的证据。
## 1. 复用而非重建
现有入口是付款页面的待组批、付款批次、复核及“下载银行制单”。后端 POST /api/payment-batches/:id/payment-order 要求财务角色与 payment.order.export,批次 reviewed,完整且可用的同源收付款快照。生成成功会更新导出计数和审计;不能把这个 POST 当作生产只读预检。
@@ -18,7 +24,7 @@
| 9 | 客户方流水号 | 批次号+行号,稳定、唯一并满足长度限制 |
| 10 | 金额 | 已批准批次项金额,数值且两位小数 |
| 11 | 用途 | 报销款,不复制工资/公积金用途 |
| 12 | 备注 | 报销单号 |
| 12 | 备注 | 冻结收款人姓名+“报销款 ”+完整报销单号(09-11已批准增量) |
| 13–15 | 短信标志/手机/附加信息 | 0、不通知,后两列空;不读样例手机号 |
用户三个 input 文档的表头完全一致,空白模板与内置模板相同,账号列为文本。检查原件只提取结构与摘要,原件不改、不重新保存、不复制真实行到测试夹具。工资及公积金样例仅用于确定文件格式,不暗中扩为新业务。
@@ -1,5 +1,22 @@
# 执行顺序(本地技术审查/修复已批准,真实业务动作另批)
## 本轮复核输入与提交准备(未提交)
用户批准整理两笔独立改动的复核/提交方案。基线均为5d8cdf12;01为制单备注5文件,02为UI布局11文件,路径无重叠。仅生成补丁、摘要清单并用临时索引验证顺序合并,不改变实际HEAD/index,不创建commit,不推送或部署。复核入口位于layout副本的artifacts/qa-layout/review-preparation/README.md;临时合并树不是发布候选commit。既有验证按输入不变复用,本轮不新增测试通过计数;UI补跑外层143、网银导入未验证、未独立复核等边界保留。提交前须核对冻结摘要,发布前还需验证合并后的实际候选,不能把单包测试冒充集成验收。
## 2026-09-11 制单备注小改(已批准,独立执行副本)
基线5d8cdf12;独立remarks工作副本执行,官方current已切本task。原integration副本的既有制单准备/恢复/账号隔离记录不覆盖、不混入本补丁。不新建task,不提交、推送或部署。
- [x] 核制单一单一行、冻结payee来源、模板没有备注长度说明,BX配置驱动/PB按日三位流水;编号实现不改。
- [x] 先补备注、多人/同人多单及缺失输入回归,跑红后改备注拼接。
- [x] 跑制单单测与真实BIFF8读回、编号/付款路由相关回归、语法与流程检查。
- [x] 主会话复核最小差异,记录银行导入未验证和候选未发布;不要求真人代测。
本轮验证:旧实现15项中3项按预期失败(备注缺姓名与用途),修改一行运行时后4个测试文件121/121通过:制单15、编号8、付款路由82、付款就绪16。追加用途/备注BIFF8文本类型断言后,制单15/15再通过;不将重复执行累加为新测试数。真实固定模板生成并用xlrd读回,验证15列、3个sheet、模板表头/列宽、中文备注、金额、账号前导零、稳定客户流水号。node --check、git diff --check、Trellis治理和process-contract baseline检查通过。
主会话检查确认运行时只改备注拼接,不动权限、状态、审批、编号或数据迁移;本轮不是独立复核。现有银行模板没有长度上限说明,网银实际导入及长备注接受性未验证。仅本地完成,未连接生产、未导出真实制单、未提交/推送/部署。下一步将本增量纳入候选复核与提交准备;提交/推送和部署须明确授权,不能直接覆盖原integration任务记录或将原有冻结候选视为包含本改动。
## 2026-09-11 业务就绪只读检查准备(未执行生产查询)
复用已确认身份和经理/主体回填回执,不再猜邮箱或索取工号。排除 smoke 账号,分别核对能登录、能提交、能审批/复核、能组批/登记,不能合并成一个笼统 ready。
@@ -2,6 +2,15 @@
## Goal
### 2026-09-11 制单备注与编号解释(已批准规划实施,未部署)
- 用途继续为“报销款”;每行备注为冻结收款快照姓名+“报销款 ”+该行完整报销编号,例如“合成收款人报销款 BX-20260828-0014”。不从提交人、当前员工资料或固定真人姓名取值。
- 现有一单一行保持,不按收款人合并转账;同一批次同一收款人的多张单仍逐行对应完整编号。缺少姓名或单号继续整批拒绝,不猜测、不截断。
- 保留BIFF8模板、15列、用途、金额、账号文本、客户流水号、排序、权限/复核/审计与下载行为;不修改历史文件、历史单号、计数器或生产配置,不新增迁移。
- 编号只标识创建顺序,不表示此前单据已经报销/付款。BX当前生成器优先既有序列配置,回退resetPeriod=none;PB按日三位流水,从001起。不能把讨论中的“若按日则0001起”直接改成重置生产BX或将PB扩位;周期变化另行确认。本轮仅解释并保留现状,生产具体BX序列配置未查询。
- 固定空模板未提供备注长度上限;本轮验证完整文字写入/读回,不自造银行限制,不做字符截断。实际网银导入及长备注限制仍未验证。
- 验收:合成收款人与提交人不同、同收款人多单、一单一行、长姓名/编号不丢失、缺失输入拒绝及BIFF8逐列读回;编号单测回归。生产测试账号治理暂停,不并入本轮。
### 2026-09-10 本轮批准范围
本轮报销/付款体验全部完成并合并验证后再部署,不等待项目所有存量技术债。付款三项为中文文件名、下载免手填理由、操作卡下方处理进度;退回留痕删除由 09-04 task 独立交付,共同验收。已发布的发票优先录入保留回归,不重复实施。本段覆盖下方历史 R8 的“制单下载必须原因”;不覆盖回单下载、完整账号查看或取消原因策略。真实付款仍由业务人员执行。
@@ -87,6 +87,7 @@ export function BusinessDetailActionPanel({
primaryAction,
secondaryActions,
moreActions,
inlineActions = false,
summary,
children,
className,
@@ -98,6 +99,7 @@ export function BusinessDetailActionPanel({
primaryAction?: ReactNode;
secondaryActions?: ReactNode;
moreActions?: ReactNode;
inlineActions?: boolean;
summary?: ReactNode;
children?: ReactNode;
className?: string;
@@ -121,9 +123,10 @@ export function BusinessDetailActionPanel({
<SectionPanel title={title} actions={status} className={cn("detail-action-panel lg:sticky lg:top-24", className)}>
<div className="detail-action-stack" data-testid={testId}>
{hasBeforeActions ? <div>{beforeActions}</div> : null}
{hasPrimaryAction ? <div className="grid gap-2">{primaryAction}</div> : null}
{inlineActions && (hasPrimaryAction || hasMoreActions) ? <div className="flex flex-wrap items-center gap-2" data-testid="detail-inline-actions">{primaryAction}{moreActions}</div> : null}
{!inlineActions && hasPrimaryAction ? <div className="grid gap-2">{primaryAction}</div> : null}
{hasSecondaryActions ? <div className="workflow-action-bar">{secondaryActions}</div> : null}
{hasMoreActions ? <div className="border-t border-zinc-200 pt-3">{moreActions}</div> : null}
{!inlineActions && hasMoreActions ? <div className="border-t border-zinc-200 pt-3">{moreActions}</div> : null}
{hasChildren ? (
<div className={cn(hasActionContent && "border-t border-zinc-200 pt-3")}>{children}</div>
) : null}
@@ -30,7 +30,7 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots
)}
/>
{slots.transientFeedback}
<BusinessDetailWorkspace className="mx-auto w-full max-w-7xl">
<BusinessDetailWorkspace className="detail-workspace-grid--payment mx-auto w-full max-w-7xl">
<BusinessDetailMain>
{slots.headerDetails}
{slots.beforeEvidence}
@@ -167,6 +167,43 @@ function InvoiceMaterialRow({ invoice, capabilities, linkTargets }: { invoice: R
);
}
function InvoiceMaterialsTable({ invoices, capabilities }: { invoices: ReimbursementInvoiceEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
return (
<div className="overflow-hidden rounded-lg border border-zinc-200" data-testid="reimbursement-invoices-table">
<table aria-label="报销发票" className="block w-full table-fixed text-left text-sm lg:table">
<thead className="hidden bg-zinc-50 text-xs text-zinc-600 lg:table-header-group">
<tr>
<th className="w-[30%] px-3 py-2.5 font-medium" scope="col">发票号码</th>
<th className="px-3 py-2.5 font-medium" scope="col">开票方</th>
<th className="w-24 px-3 py-2.5 font-medium" scope="col">日期</th>
<th className="w-28 px-3 py-2.5 text-right font-medium" scope="col">金额</th>
{capabilities.onUnlinkInvoice ? <th className="w-28 px-3 py-2.5 font-medium" scope="col">操作</th> : null}
</tr>
</thead>
<tbody className="block divide-y divide-zinc-200 bg-white lg:table-row-group">
{invoices.map((invoice) => {
const title = invoice.invoiceNumber || invoice.originalName || "未命名发票";
const statusCopy = evidenceStatusCopy(invoice.amountEvidenceStatus);
const hasBlockingIssue = invoice.amountEvidenceStatus === "ocr_failed";
return (
<tr className="block p-3 lg:table-row lg:p-0" data-testid="approval-invoice-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} id={`reimbursement-invoice-${invoice.id}`} key={invoice.id} tabIndex={hasBlockingIssue ? -1 : undefined}>
<td className="block break-all align-top lg:table-cell lg:px-3 lg:py-2">
{capabilities.onOpenInvoice ? <Button aria-label={`查看发票:${title}`} className="h-auto min-h-11 max-w-full justify-start whitespace-normal break-all px-0 text-left" onClick={() => capabilities.onOpenInvoice?.(invoice)} type="button" variant="link">{title}</Button> : <span className="font-medium text-zinc-900">{title}</span>}
{statusCopy ? <p className={hasBlockingIssue ? "text-xs text-red-700" : "text-xs text-zinc-600"}>{statusCopy}</p> : null}
</td>
<td className="block break-words text-zinc-600 [overflow-wrap:anywhere] lg:table-cell lg:px-3 lg:py-3">{invoice.sellerName || invoice.documentSubtype || invoice.documentCategory || "票据"}</td>
<td className="block text-zinc-600 lg:table-cell lg:px-3 lg:py-3">{formatDisplayDate(invoice.invoiceDate)}</td>
<td className="block text-right font-medium tabular-nums lg:table-cell lg:px-3 lg:py-3">{formatMoney(invoice.totalAmount)}</td>
{capabilities.onUnlinkInvoice ? <td className="block lg:table-cell lg:px-3 lg:py-2"><Button className="min-h-11 whitespace-normal" onClick={() => capabilities.onUnlinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">移除对应关系</Button></td> : null}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function AttachmentMaterialRow({ attachment, capabilities }: { attachment: ReimbursementAttachmentEvidence; capabilities: ReimbursementEvidenceCapabilities }) {
return <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-zinc-200 px-3 py-2">
<div className="min-w-0 flex-1">
@@ -197,7 +234,7 @@ export function ReimbursementItemEvidenceList({ amountSummary, evidence, capabil
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="font-medium text-zinc-900">票据与附件</h3>{capabilities.onOpenMaterialBundle ? <Button className="min-h-11" onClick={capabilities.onOpenMaterialBundle} size="sm" type="button" variant="secondary">查看完整材料</Button> : null}</div>
<div className="space-y-2" data-testid="approval-invoices">
<h4 className="text-sm font-medium text-zinc-800">发票({linkedInvoices.length + evidence.unlinkedInvoices.length})</h4>
{linkedInvoices.map((invoice) => <InvoiceMaterialRow capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />)}
{linkedInvoices.length ? (mobile ? linkedInvoices.map((invoice) => <InvoiceMaterialRow capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />) : <InvoiceMaterialsTable capabilities={capabilities} invoices={linkedInvoices} />) : null}
{evidence.unlinkedInvoices.length ? <div className="space-y-2 rounded-lg border border-red-200 bg-red-50 p-3" data-testid="approval-unlinked-invoices" id="reimbursement-workspace-unlinked"><p className="text-sm font-medium text-red-900">尚未对应明细({evidence.unlinkedInvoices.length})</p><p className="text-sm text-red-800">这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。</p>{evidence.unlinkedInvoices.map((invoice) => <InvoiceMaterialRow capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />)}</div> : null}
{!linkedInvoices.length && !evidence.unlinkedInvoices.length ? <p className="text-sm text-zinc-500">暂无发票</p> : null}
</div>
+4
查看文件
@@ -588,6 +588,10 @@
@apply grid gap-5 lg:grid-cols-[minmax(0,1.45fr),minmax(280px,0.72fr)];
}
.detail-workspace-grid--payment {
@apply lg:grid-cols-[minmax(0,1fr),320px];
}
.detail-command-panel {
border-color: rgba(203, 213, 225, 0.92);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.9));
+14 -10
查看文件
@@ -3,6 +3,7 @@ import { resolveErrorReasonMessage, UNKNOWN_VALIDATION_MESSAGE } from "../api/er
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { Eye } from "lucide-react";
import {
cancelPaymentBatch,
createPaymentBatch,
@@ -827,7 +828,7 @@ export function PaymentBatchesPage() {
<div
className={paymentActionModel.primary || paymentActionModel.more.length ? "pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)] lg:pb-0" : ""}
>
<BusinessDetailWorkspace testId="payment-batch-detail-workspace">
<BusinessDetailWorkspace className="detail-workspace-grid--payment" testId="payment-batch-detail-workspace">
<BusinessDetailMain>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -914,7 +915,7 @@ export function PaymentBatchesPage() {
<div className="mt-2 detail-fact-grid text-sm md:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">收款人</div><div className="detail-fact-card__value">{item.payeeName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">开户银行</div><div className="detail-fact-card__value">{item.bankName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div>{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="secondary" className="mt-2" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}>查看完整账号</Button> : null}</div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value flex items-center gap-1"><span className="min-w-0 break-all">{item.bankAccountMasked || "-"}</span>{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="ghost" className="h-11 w-11 shrink-0 p-0" aria-label="查看完整账号" title="查看完整账号" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}><Eye aria-hidden="true" className="size-4" /></Button> : null}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel)}</div></div>
</div>
{["reviewed", "failed"].includes(selectedBatch.status) ? (
@@ -1073,18 +1074,21 @@ export function PaymentBatchesPage() {
<BusinessDetailAside>
<BusinessDetailActionPanel
inlineActions
className="hidden lg:block lg:static"
status={<StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />}
primaryAction={paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : undefined}
moreActions={paymentActionModel.more.length ? (
<details className="detail-disclosure">
<summary>更多</summary>
<div className="mt-3 grid gap-2">
{paymentActionModel.more.map((kind) =>
renderPaymentActionButton(kind, kind === "cancel" ? "destructive" : "secondary"),
)}
</div>
</details>
<DropdownMenu>
<DropdownMenuTrigger asChild><Button type="button" variant="secondary">更多</Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
{paymentActionModel.more.map((kind) => (
<DropdownMenuItem key={kind} data-testid={`payment-batch-action-${kind}`} disabled={paymentActionDisabled(kind)} className={kind === "cancel" ? "min-h-11 text-red-600 focus:text-red-600" : "min-h-11"} onSelect={() => activatePaymentAction(kind)}>
{paymentActionLabel(kind)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : undefined}
summary={(
<>
+4 -13
查看文件
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { fetchExpenseTypes } from "../api/expenseTypes";
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
import {
@@ -114,7 +115,6 @@ export function ReimbursementDetailPage() {
const [itemError, setItemError] = useState("");
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
const [showSecondaryDetail, setShowSecondaryDetail] = useState(false);
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
const [invoiceSearchInput, setInvoiceSearchInput] = useState("");
@@ -1041,17 +1041,6 @@ export function ReimbursementDetailPage() {
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : null,
hasMoreActions ? (
<Button
key="more"
type="button"
variant="secondary"
data-testid="reimbursement-more-toggle"
onClick={() => setShowSecondaryDetail((value) => !value)}
>
{showSecondaryDetail ? "收起更多" : "更多"}
</Button>
) : null,
];
const workspaceEvidence = buildReimbursementApprovalEvidence(detail, {
@@ -1112,8 +1101,10 @@ export function ReimbursementDetailPage() {
</>
);
const workspaceActionPanel = (
<BusinessDetailActionPanel className="lg:static" status={<StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ? <div className="grid gap-2" data-testid="reimbursement-secondary-detail">{detail.status !== "paid" ? <Button asChild variant="secondary"><Link to={"/reimbursements/" + detail.id + "/print"}>打印 / PDF</Link></Button> : null}{canDeleteReimbursement ? <Button data-testid="reimbursement-delete-draft" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除报销单</Button> : null}</div> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
<BusinessDetailActionPanel inlineActions className="lg:static" status={<StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={hasMoreActions ? <DropdownMenu><DropdownMenuTrigger asChild><Button data-testid="reimbursement-more-toggle" type="button" variant="secondary">更多</Button></DropdownMenuTrigger><DropdownMenuContent align="end" data-testid="reimbursement-secondary-detail">{detail.status !== "paid" ? <DropdownMenuItem asChild className="min-h-11"><Link to={"/reimbursements/" + detail.id + "/print"}>打印 / PDF</Link></DropdownMenuItem> : null}{canDeleteReimbursement ? <DropdownMenuItem className="min-h-11 text-red-600 focus:text-red-600" data-testid="reimbursement-delete-draft" disabled={deleteReimbursementMutation.isPending} onSelect={() => setConfirmDeleteReimbursement(true)}>删除报销单</DropdownMenuItem> : null}</DropdownMenuContent></DropdownMenu> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
{(detail.status === "reviewing" && isReimbursementParticipant) || (["reviewing", "approved"].includes(detail.status) && actionError) ? (
<ReimbursementActions status={detail.status} showSubmitAction={false} showWithdrawAction={isReimbursementParticipant} actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""} submitDisabled={blockStandardChecks.length > 0} submitHint="" submitNotice="" isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} />
) : null}
</BusinessDetailActionPanel>
);
@@ -680,7 +680,7 @@ test.describe("报销付款交接一致性", () => {
const link = page.getByRole("link", { name: "查看所在付款批次", exact: true });
await expect(link).toHaveCount(1);
const linkBounds = await link.boundingBox();
expect(linkBounds!.height).toBeGreaterThanOrEqual(44);
expect(Math.round(linkBounds!.height * 100) / 100).toBeGreaterThanOrEqual(44);
if (!mobile) expect(linkBounds!.width).toBeLessThan(180);
await link.click();
await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible();
@@ -698,7 +698,99 @@ test.describe("报销付款交接一致性", () => {
});
}
test("账号查看图标紧随脱敏值且确认前不请求完整账号", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await mockSystemActionApis(page, {
canExportPaymentOrder: true,
meOverride: { roleCodes: ["finance"], permissions: [...me.permissions, "employee.bank.read"] },
});
let reveals = 0;
page.on("request", (request) => { if (new URL(request.url()).pathname.endsWith("/payee-bank/reveal")) reveals += 1; });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const reveal = page.getByRole("button", { name: "查看完整账号", exact: true });
await expect(reveal).toBeVisible();
await expect(reveal).toHaveText("");
await expect(reveal).toHaveAttribute("title", "查看完整账号");
const icon = await reveal.boundingBox();
const account = await reveal.locator("..").locator("span").boundingBox();
expect(icon!.width).toBeGreaterThanOrEqual(44);
expect(icon!.height).toBeGreaterThanOrEqual(44);
expect(icon!.x).toBeGreaterThan(account!.x);
await reveal.focus();
await page.keyboard.press("Enter");
const dialog = page.getByRole("dialog");
await expect(dialog).toContainText("本次查看会记录");
await expect(dialog.getByRole("textbox", { name: "查看收款账号原因" })).toBeVisible();
expect(reveals).toBe(0);
await dialog.getByRole("button", { name: "返回", exact: true }).click();
expect(reveals).toBe(0);
});
for (const width of [390, 1280, 1440]) {
test(`票据紧凑展示${width}多张长名称及预览不丢失`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 960 });
await mockSystemActionApis(page, { reimbursementStatus: "approved" });
const detail = reimbursementDetail("approved");
const sellerName = "合成开票方长名称".repeat(8);
detail.invoices = Array.from({ length: 5 }, (_, index) => ({
...detail.invoices[0],
id: `invoice-layout-${index}`,
invoiceNumber: `SYNTHETIC-LAYOUT-000000000000000000000${index}`,
sellerName,
}));
detail.attachments = [];
await page.route(`**/api/reimbursements/${reimbursementId}`, (route) => fulfillJson(route, detail));
let previews = 0;
await page.route("**/api/invoice-pool/invoice-layout-0/signed-url", async (route) => {
previews += 1;
await fulfillJson(route, { signedUrl: "about:blank", originalName: "合成预览.pdf", fileSize: 1024 });
});
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const table = page.getByTestId("reimbursement-invoices-table");
await expect(table).toBeVisible();
await expect(table.getByTestId("approval-invoice-evidence")).toHaveCount(5);
await expect(table.getByText(sellerName, { exact: true })).toHaveCount(5);
if (width >= 1024) await expect(table.getByRole("columnheader", { name: "金额" })).toBeVisible();
else await expect(table.locator("thead")).toBeHidden();
await expect(page.getByText("未上传补充附件", { exact: true })).toBeVisible();
await table.getByRole("button", { name: `查看发票:${detail.invoices[0].invoiceNumber}`, exact: true }).click();
await expect.poll(() => previews).toBe(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true);
await table.screenshot({ path: testInfo.outputPath("invoice-table.png") });
});
}
for (const width of [1280, 1440]) {
test(`桌面${width}付款与报销侧栏等宽且更多紧随主按钮`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 960 });
await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress("reviewed") });
await login(page, user);
const widths: number[] = [];
for (const [url, panelId] of [
[`/reimbursements/${reimbursementId}`, "reimbursement-action-panel"],
[`/payment-batches?id=${paymentBatchId}`, "payment-batch-action-panel"],
]) {
await page.goto(url);
const panel = page.getByTestId(panelId);
await expect(panel).toBeVisible();
await page.evaluate(() => document.fonts.ready);
widths.push((await panel.locator("../..").boundingBox())!.width);
const row = panel.getByTestId("detail-inline-actions");
const primary = await row.locator("a, button").first().boundingBox();
const more = await row.getByRole("button", { name: "更多", exact: true }).boundingBox();
expect(Math.abs(primary!.y + primary!.height / 2 - more!.y - more!.height / 2)).toBeLessThan(2);
expect(more!.x).toBeGreaterThan(primary!.x + primary!.width);
await row.getByRole("button", { name: "更多", exact: true }).click();
await expect(page.getByRole("menu")).toBeVisible();
await page.keyboard.press("Escape");
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true);
await page.screenshot({ path: testInfo.outputPath(`${panelId}.png`), fullPage: true });
}
expect(widths).toEqual([320, 320]);
});
test(`桌面${width}创建批次按钮短文案按内容宽度且只导航不组批`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 960 });
await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress("none") });
@@ -913,13 +1005,13 @@ test.describe("R7E 全站高风险操作二次确认", () => {
const actionPanel = page.getByTestId("payment-batch-action-panel");
await expect(actionPanel.getByTestId("payment-batch-action-downloadOrder")).toBeVisible();
await actionPanel.getByText("更多", { exact: true }).click();
await expect(actionPanel.getByRole("button", { name: "上传银行回单" })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "上传银行回单" })).toBeVisible();
await expect(page.getByTestId("payment-batch-mobile-actions")).toBeHidden();
await expect(page.getByTestId("payment-batch-receipt-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
const desktopFileChooser = page.waitForEvent("filechooser");
await actionPanel.getByRole("button", { name: "上传银行回单" }).click();
await page.getByRole("menuitem", { name: "上传银行回单" }).click();
await (await desktopFileChooser).setFiles({
name: "合成银行回单.pdf",
mimeType: "application/pdf",
@@ -1008,7 +1100,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.screenshot({ path: testInfo.outputPath("after-payment-order.png"), fullPage: true });
await actions.getByText("更多", { exact: true }).click();
if (mobile) await page.getByRole("menuitem", { name: "重新下载制单" }).click();
else await actions.getByTestId("payment-batch-action-redownloadOrder").click();
else await page.getByRole("menuitem", { name: "重新下载制单" }).click();
await expect(dialog).toContainText("请勿重复提交");
await expect(dialog.getByRole("textbox")).toHaveCount(0);
const secondDownload = page.waitForEvent("download");
+1 -1
查看文件
@@ -156,7 +156,7 @@ function buildPaymentOrderRows(config, batch) {
serial,
Number(amount.toFixed(2)),
"报销款",
entityCode,
`${payeeName}报销款 ${entityCode}`,
"0",
"",
"",
@@ -7,6 +7,7 @@ const { encryptStringOrThrow } = require("../../../src/services/crypto");
const {
PaymentOrderXlsError,
buildCustomerSerial,
buildPaymentOrderRows,
generatePaymentOrderXls,
} = require("../../../src/services/paymentOrderXls");
@@ -42,6 +43,41 @@ function buildBatch(overrides = {}) {
}
describe("payment order BIFF8 generator", () => {
it("uses frozen payee names and keeps separate rows for the same payee", () => {
const batch = buildBatch();
for (const item of batch.items) {
item.snapshot_json.payee.name = " 合成收款人 ";
item.snapshot_json.submitter = { name: "合成提交人" };
item.owner = { name: "已改名归属人" };
}
const result = buildPaymentOrderRows(config, batch);
expect(result.rows.map((row) => row.slice(10, 12))).toEqual([
["报销款", "合成收款人报销款 BX-20260802-0001"],
["报销款", "合成收款人报销款 BX-20260802-0002"],
]);
expect(result.serials).toEqual(["PB-20260802-002-R001", "PB-20260802-002-R002"]);
expect(result.totalAmount).toBe("3.20");
});
it("preserves complete names and document codes without truncation", () => {
const batch = buildBatch();
const name = "合成姓名".repeat(20);
const code = "BX-20260802-000123456789";
batch.items = [batch.items[0]];
batch.items[0].snapshot_json.payee.name = ` ${name} `;
batch.items[0].entity_code = ` ${code} `;
expect(buildPaymentOrderRows(config, batch).rows[0][11]).toBe(`${name}报销款 ${code}`);
});
it.each([
["payee_name_missing", (item) => { item.snapshot_json.payee.name = " "; }],
["entity_code_missing", (item) => { item.entity_code = " "; }],
])("rejects the entire export for %s", (code, invalidate) => {
const batch = buildBatch();
invalidate(batch.items[0]);
expect(() => buildPaymentOrderRows(config, batch)).toThrow(expect.objectContaining({ code }));
});
it("generates a BIFF8 workbook with stable rows, totals and customer serials", () => {
const first = generatePaymentOrderXls(config, buildBatch());
const second = generatePaymentOrderXls(config, buildBatch());
@@ -67,7 +103,7 @@ describe("payment order BIFF8 generator", () => {
"sheet=book.sheet_by_index(0)",
"source=template.sheet_by_index(0)",
"rows=[row for row in range(1,sheet.nrows) if sheet.cell_value(row,0) != '']",
"print(json.dumps({'biff':book.biff_version,'sheets':book.sheet_names(),'columns':sheet.ncols,'headerMatches':sheet.row_values(0)==source.row_values(0),'widthsMatch':all(sheet.computed_column_width(col)==source.computed_column_width(col) for col in range(15)),'rows':[sheet.row_values(row) for row in rows],'textTypes':[[sheet.cell_type(row,col) for col in [1,3,4,8]] for row in rows],'amountFormats':[book.format_map[book.xf_list[sheet.cell_xf_index(row,9)].format_key].format_str for row in rows]}))",
"print(json.dumps({'biff':book.biff_version,'sheets':book.sheet_names(),'columns':sheet.ncols,'headerMatches':sheet.row_values(0)==source.row_values(0),'widthsMatch':all(sheet.computed_column_width(col)==source.computed_column_width(col) for col in range(15)),'rows':[sheet.row_values(row) for row in rows],'textTypes':[[sheet.cell_type(row,col) for col in [1,3,4,8,10,11]] for row in rows],'amountFormats':[book.format_map[book.xf_list[sheet.cell_xf_index(row,9)].format_key].format_str for row in rows]}))",
].join("\n"), templatePath], { input: generated.buffer, maxBuffer: 1024 * 1024 });
expect(readback.stderr.toString("utf8")).toBe("");
expect(readback.status).toBe(0);
@@ -78,15 +114,16 @@ describe("payment order BIFF8 generator", () => {
columns: 15,
headerMatches: true,
widthsMatch: true,
textTypes: [[1, 1, 1, 1], [1, 1, 1, 1]],
textTypes: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
amountFormats: ["0.00", "0.00"],
});
expect(parsed.rows).toHaveLength(2);
expect(parsed.rows[0]).toEqual([
"1", payerAccount, "合成测试公司", "01", payeeAccount, "甲", "中国建设银行", "",
"PB-20260802-002-R001", 1.2, "报销款", "BX-20260802-0001", "0", "", "",
"PB-20260802-002-R001", 1.2, "报销款", "甲报销款 BX-20260802-0001", "0", "", "",
]);
expect(parsed.rows[1][9]).toBe(2);
expect(parsed.rows[1].slice(10, 12)).toEqual(["报销款", "乙报销款 BX-20260802-0002"]);
});
it("fails the whole export and reports every blocking payee issue", () => {