diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index 0df6aefc..b54e77ae 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -23,6 +23,40 @@ QA script patterns over new abstractions. - `cd server && ./scripts/regression-workflow-actions.sh` - `cd server && ./scripts/regression-all.sh` +## Scenario: Read-only Reimbursement/Payment Handoffs + +### 1. Scope / Trigger + +The portal must expose business work between reimbursement return, approval, batch creation, independent review and payment recording, without a new todo store or notification dependency. + +### 2. Signatures + +`loadReimbursementPaymentHandoffs(fastify, employee)` supplies `businessHandoffs: { total, items }` on the authenticated `GET /portal/home` route. Each item has `key, kind, reference, title, nextStep, ownerLabel, href, mobileHref`; kind is one of `reimbursement_edit | reimbursement_batch | payment_review | payment_execute`. + +### 3. Contracts + +Use a RepeatableRead, READ ONLY transaction (5s acquisition, 10s execution limit). Select necessary identity/reference/stage/department facts, never bank snapshots or amounts. Stable stage/object keys are unique; total equals all returned items, with no silent eight-row cutoff. Mutations retain their own authoritative gates. No schema, new environment setting, persistent todo, decryption or notification send is introduced. + +### 4. Validation & Error Matrix + +- Returned work requires participant + submit/read rights and the latest workflow's final return/reject fact; new, withdrawn, paid and deleted drafts are excluded. +- Unbatched approved sources require manage permission, exclude deleted/paid and non-cancelled reservations. Missing bank data remains work, not proof of readiness. +- Review requires manager/ceo + review permission, a different maker, draft/unreviewed status and pending items. Reviewed/failed processing requires manage. +- Payment work requires both global and source-department payment gates. Missing/deleted batch sources do not yield actionable handoffs. +- Query failure follows the existing API error boundary and frontend retry state; never convert it into a successful zero-items response. + +### 5. Good / Base / Bad Cases + +Good: review moves work from the non-maker reviewer to finance. Base: genuinely no qualifying work returns zero items. Bad: a historical return followed by withdrawal, self-review, or a hidden source must not produce an action. + +### 6. Tests Required + +Service/route tests cover permissions, department gates, latest-history selection, deleted sources, no read side effects and stable counts. Real isolated return/resubmit/create/review/receipt/paid transitions must prove work appears and disappears. PC/mobile tests cover refresh, errors, more than eight items and no double-counting of legacy finance aggregates; source-only or mock-only checks are insufficient for final acceptance. + +### 7. Wrong vs Correct + +Wrong: treat a notice count or pending-batch aggregate as the complete finance worklist. Correct: derive four business handoffs from current facts; when the new DTO is present, omit the old pending/failed aggregates from action counts while preserving independent risk reminders. Notification outages do not erase business work. + ## Scenario: Business Error Reasons And Payment Creation Readiness ### 1. Scope / Trigger diff --git a/.trellis/tasks/09-11-reimbursement-payment-handoffs/design.md b/.trellis/tasks/09-11-reimbursement-payment-handoffs/design.md new file mode 100644 index 00000000..ce2452da --- /dev/null +++ b/.trellis/tasks/09-11-reimbursement-payment-handoffs/design.md @@ -0,0 +1,9 @@ +# 最小待办交接投影 + +服务端服务 `loadReimbursementPaymentHandoffs` 从业务记录只读构造 `{ total, items }`,挂在现有 portal home DTO。项目为小团队,四类活动对象按稳定 key 去重,total 从返回 items 计算;不新建队列数据库或通用任务系统。资料未齐不是忽略该报销的理由,点击进入原业务页由权威 readiness/写事务决定能否组批。 + +只选择必要字段。退回检查最新流程最后的 return/reject 事实,禁止把历史曾退回但当前主动撤回的草稿重新标成待修改。付款查询限 reimbursement,exclude deleted/paid,排除非取消批次占用;部门 gate 在本次加载内缓存,不解密银行资料。批次复核使用原 manager/ceo+review 资格及非制单人条件;付款处理仅 manage。 + +门户行动行复用现有组件,旧 finance pending/failed 聚合行只作为旧服务端 DTO 兼容,收到新投影时不重复列入行动计数;独立风险提醒不冒充付款复核。移动端复用同一 items,报销使用 mobile 深链,付款沿既有移动边界。只读查询错误使用页面已有错误反馈,不回落成零待办。 + +无 schema/迁移/seed/业务写入/消息发送;已有通知机制不变,通知丢失不丢业务事实。每次渲染不是批准执行,写接口仍独立授权。代码回退恢复旧门户,不改业务记录。独立 reviewer 受 inline 限制时明确 pending。 diff --git a/.trellis/tasks/09-11-reimbursement-payment-handoffs/implement.md b/.trellis/tasks/09-11-reimbursement-payment-handoffs/implement.md new file mode 100644 index 00000000..30856a4d --- /dev/null +++ b/.trellis/tasks/09-11-reimbursement-payment-handoffs/implement.md @@ -0,0 +1,29 @@ +# 执行清单 + +已获 2026-09-11 综合规划实施批准;独立工作树基线 fe025e65。复用已读 backend/frontend/权限规约;未授权生产操作、提交、推送或归档。 + +- [x] 只读事实加载与四类投影,最小 DTO 和正反例。 +- [x] 接入 PC/手机既有门户,避免新旧聚合重复,聚焦/重入刷新。 +- [x] Vitest 服务/路由、客户端 typecheck/build、合成 Playwright、真实隔离业务状态演进验证。 +- [x] 主会话只读检查和规约更新;与详情修复按补丁形成联合工作树,不整文件覆盖。 +- [ ] 独立只读 review,inline 主会话自检不抵扣。 +- [ ] 明确授权后分别提交推送、冻结新候选。 +- [ ] 最新生产/备份只读预检、就绪/恢复、获批发布及公网受控验收,分别登记,不由本地 PASS 推导。 + +## 2026-09-11 实施与联合验收回执 + +代码完成但未提交。原独立副本为 artifacts/isolated-worktrees/20260911-payment-handoffs;联合副本为 artifacts/isolated-worktrees/20260910-reimbursement-payment-integration,基线同为 fe025e65。联合副本包含 09-04 详情修复与本任务,两个共享 QA 文件按补丁合并;根 checkout 既有改动、旧已审包与干净发布 clone 未动。正式 current 分别与各自任务一致;没有将旧 HEAD 冒充包含本轮改动的候选 SHA。 + +- 服务端只读事务派生四类事项,不存 todo、不发通知、不解密账户;来源/部门 gate、角色/权限、非本人制单及删除/撤回排除均有正反例。缺银行资料仍是待组批事项,不声称能成功创建批次。 +- PC/手机使用同一 DTO,total 与全部明细一致;收到新投影不重复叠加旧财务聚合。移除 PC 行动前八项截断,增加超过八条回归。加载失败与空态分开,局部重入/聚焦重新查询。 +- 联合单测:6 文件 135/135,unit.log;联合页面:39/39、退出 0,ui.log;build.log 包含 typecheck/build/PWA。这些是联合数量,不与分项 16/16、3/3 重复相加。 +- real-db-browser.log:PASS_REAL_DB_HTTP、退出 0。真实一次性 PostgreSQL、密码登录/JWT、HTTP、桌面/手机,按退回→重提→审批→组批→独立复核→导出→合成回单→登记推进;门户事项随状态出现/消失,重复读取稳定且不写审计。并行保留原退回留痕删除、授权、历史材料与发票重用回归。 +- 最终证据在联合副本 artifacts/qa-flow/20260911-combined/;独立分项记录保留在原副本 artifacts/qa-flow/20260911-handoffs/。已查看联合 PC/手机门户合成截图,复用既有组件,不宣称新视觉体系已审。 +- 测试修正仅为真实 CJS require/feature flag fixture、has_session cookie 及合成回单字段对齐;没有放宽认证、资料门禁或产品付款约束。初次失败日志留存,以最终联合退出 0 为准。 +- 主会话已只读复核投影最小字段、只读事务、来源排除、目标入口、maker-checker、无新增写入及新旧聚合去重。独立 reviewer 未执行,旧候选审查不自动覆盖本轮。 + +测试不用真实员工或银行数据;OCR 为真实 worker + mock provider,长审批历史为合成 fixture。回单登记只发生在隔离合成库,没有银行连接。数据库和合成文件已清理;未调用真实腾讯 OCR、未恢复正式备份、未查询/修改生产、未发送消息。正式就绪与配对恢复准备仍归原付款/备份任务,不假报首批员工启用完成。 + +## 下一步与提交边界 + +先以联合 changes.diff/manifest 固定未提交输入供只读复核。建议与 09-04 拆为两笔,各引用 task;共享 QA 的先后补丁必须保留,不能整文件覆盖。无 schema/migration/seed/角色修改,本任务代码回退不修改业务记录;整站既有留痕删除回退语义仍有效。获批提交推送后冻结真实候选,再刷新生产和恢复前置、另批部署与公网合成验收,不让真人代测。 diff --git a/.trellis/tasks/09-11-reimbursement-payment-handoffs/prd.md b/.trellis/tasks/09-11-reimbursement-payment-handoffs/prd.md new file mode 100644 index 00000000..466318fb --- /dev/null +++ b/.trellis/tasks/09-11-reimbursement-payment-handoffs/prd.md @@ -0,0 +1,38 @@ +# 报销付款四类待办交接投影 + +## Goal + +以现有业务状态只读投影退回修改、财务待组批、独立付款复核、财务付款登记四类待办,统一门户入口和计数;不新增审批状态机或生产写入。 + +## Requirements + +- 用户已批准综合规划连续本地实施。本任务只承接四次业务交接,不改通用 todo 手工事项、审批状态机、角色、银行渠道或资料。 +- 退回/驳回报销向有维护资格的 owner/submitter 投影待修改;从未提交的私人草稿、已撤回和留痕删除不算退回待办。 +- 财务看到未付款且未被有效批次占用的已审批报销;缺资料仍是待处理,不把待组批投影说成已通过创建门禁。部门功能开关与真实批次入口权限保持一致。 +- 经理/CEO 的付款复核待办排除本人制单;财务看到 reviewed/failed 批次的付款/回单登记待办,不把 draft 的复核动作交给制单人。 +- 门户 PC/手机消费同一服务端投影;每个对象/阶段稳定唯一,数量与列表一致,处理后重新查询即消失,不依赖通知送达或复制出持久化待办。 +- 不输出金额、事由、姓名、银行卡或原始错误。只展示业务编号、阶段、责任职责和有权限的目标入口。手机不新增批量付款能力。 + +## Scope + +报销/付款四类交接的服务端只读投影、现有 PC/手机门户展示、计数与刷新及定向验证。 + +## Out of Scope + +新审批引擎、手工 todo 改造、schema/migration、角色变更、自动付款、真实业务资料读取/回填、企业微信发送、生产部署、批量归档。 + +## Test Plan + +Vitest 四类服务与 portal route;真实隔离 PostgreSQL/认证/HTTP 退回、重提、审批、组批、复核、合成回单与付款登记;Playwright 合成 PC/手机门户(含 refresh、空态、重复聚合排除);client typecheck/build、UI strict、Trellis governance、git diff --check。真实腾讯 OCR 与正式恢复未覆盖。 + +## Acceptance Criteria + +- [x] 四类状态正反例、真实角色/权限、maker-checker、部门开关、删除/撤回/新草稿排除有回归。 +- [x] 同一响应的 total 与实际 items 一致,无计数截断伪装;旧财务汇总不重复算作行动。 +- [x] 真实隔离库推进退回→重提→审批→组批→复核→登记,重复读取不写库,不依赖通知发送。 +- [x] PC/手机入口、错误与空态、查询刷新及最终联合工作树回归通过。 +- [ ] 独立只读 review、授权提交和部署后的受控验收,不以主会话本地测试抵扣。 + +## Notes + +- 复用 09-04/09-08 真实交接证据。本独立跨模块交付与详情一致性分工作树,不为每个页面/按钮新建 task。 diff --git a/.trellis/tasks/09-11-reimbursement-payment-handoffs/task.json b/.trellis/tasks/09-11-reimbursement-payment-handoffs/task.json new file mode 100644 index 00000000..02d18ce3 --- /dev/null +++ b/.trellis/tasks/09-11-reimbursement-payment-handoffs/task.json @@ -0,0 +1,26 @@ +{ + "id": "reimbursement-payment-handoffs", + "name": "reimbursement-payment-handoffs", + "title": "报销付款四类待办交接投影", + "description": "以现有业务状态只读投影退回修改、财务待组批、独立付款复核、财务付款登记四类待办,统一门户入口和计数;不新增审批状态机或生产写入。", + "status": "in_progress", + "dev_type": "fullstack", + "scope": "portal-reimbursement-payment", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-11", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "09-11本地实施完成,与09-04详情修复按补丁联合;135单测、39页面及真实隔离HTTP/双端状态演进通过,未调用真实银行/OCR或生产。基线fe025e65不含本次未提交改动;独立复核、授权提交推送、新鲜预检/配对恢复与部署验收待执行。证据与副本位置见implement,不归档。", + "meta": {} +} diff --git a/client/src/api/portal.ts b/client/src/api/portal.ts index 30ec9d73..4c6854f3 100644 --- a/client/src/api/portal.ts +++ b/client/src/api/portal.ts @@ -79,6 +79,19 @@ export type PortalHomePayload = { canViewTeam: boolean; isAdminOnly: boolean; }; + businessHandoffs?: { + total: number; + items: Array<{ + key: string; + kind: "reimbursement_edit" | "reimbursement_batch" | "payment_review" | "payment_execute"; + reference: string | null; + title: string; + nextStep: string; + ownerLabel: string; + href: string; + mobileHref: string; + }>; + }; financeSummary?: { pendingBatchCount: number; failedItemCount: number; diff --git a/client/src/pages/MobilePortalPage.tsx b/client/src/pages/MobilePortalPage.tsx index 8c7a1f18..53f58b09 100644 --- a/client/src/pages/MobilePortalPage.tsx +++ b/client/src/pages/MobilePortalPage.tsx @@ -131,6 +131,9 @@ export function MobilePortalPage() { enabled: canUseFinance, queryKey: ["mobile", "portal", "home"], queryFn: () => fetchPortalHome(), + staleTime: 0, + refetchOnMount: true, + refetchOnWindowFocus: true, }); useEffect(() => { @@ -167,13 +170,14 @@ export function MobilePortalPage() { .filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_"))) .slice(0, 3); const financeSummary = portalHomeQuery.data?.financeSummary ?? null; + const businessHandoffs = portalHomeQuery.data?.businessHandoffs; const paymentBatchEnabled = Boolean(financeSummary?.paymentBatchEnabled); const financePendingCount = financeSummary?.pendingBatchCount ?? 0; - const financeExceptionCount = (financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0); + const financeExceptionCount = (businessHandoffs ? 0 : financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0); const shouldShowFinanceCard = canUseFinance && !portalHomeQuery.isLoading && !portalHomeQuery.isError && - (paymentBatchEnabled || financePendingCount > 0 || financeExceptionCount > 0); + (businessHandoffs ? financeExceptionCount > 0 : paymentBatchEnabled || financePendingCount > 0 || financeExceptionCount > 0); const financeCardBadge = paymentBatchEnabled ? "复核与付款" : financePendingCount > 0 ? "待付款" : "异常与复核"; async function openHomeNotification(item: NotificationItem) { @@ -298,6 +302,21 @@ export function MobilePortalPage() { ) : null} + {portalHomeQuery.isError ?

业务待办加载失败,请重试。

: null} + + {!portalHomeQuery.isError && businessHandoffs && businessHandoffs.total > 0 ? ( +
+

业务待办

{businessHandoffs.total} 项
+ {businessHandoffs.items.map((item) => ( + +
{item.title}{item.reference ? ` · ${item.reference}` : ""}
+

{item.nextStep}

+

{item.ownerLabel} · 查看处理

+ + ))} +
+ ) : null} + {shouldShowFinanceCard ? (
@@ -305,10 +324,10 @@ export function MobilePortalPage() { {financeCardBadge}
-
+ {!businessHandoffs ?
待付款
{financePendingCount}
-
+
: null}
异常
{financeExceptionCount}
diff --git a/client/src/pages/PortalHomePage.tsx b/client/src/pages/PortalHomePage.tsx index fa44092d..cfd02772 100644 --- a/client/src/pages/PortalHomePage.tsx +++ b/client/src/pages/PortalHomePage.tsx @@ -28,6 +28,7 @@ type WorkItem = { icon: string; priority: number; count: number; + showReason?: boolean; }; type EmptyAction = { @@ -107,6 +108,11 @@ function uniqueWorkItems(items: WorkItem[]) { function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: RoleCapabilities) { const items: WorkItem[] = []; + for (const handoff of payload.businessHandoffs?.items ?? []) { + items.push({ key: handoff.key, title: [handoff.title, handoff.reference].filter(Boolean).join(" · "), + reason: handoff.nextStep, meta: handoff.ownerLabel, href: handoff.href, actionLabel: "查看处理", + severity: "warning", icon: "assignment", priority: 98, count: 1, showReason: true }); + } if (payload.pendingTasks.count > 0) { items.push({ @@ -158,7 +164,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol } if (roleCapabilities.canUseFinance && payload.financeSummary) { - if (payload.financeSummary.failedItemCount > 0) { + if (!payload.businessHandoffs && payload.financeSummary.failedItemCount > 0) { items.push({ key: "finance-failed-payment", title: `异常付款 ${payload.financeSummary.failedItemCount} 项`, @@ -188,7 +194,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol }); } - if (payload.financeSummary.pendingBatchCount > 0) { + if (!payload.businessHandoffs && payload.financeSummary.pendingBatchCount > 0) { items.push({ key: "finance-pending-batch", title: `待付款批次 ${payload.financeSummary.pendingBatchCount} 个`, @@ -234,7 +240,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol }); } - return uniqueWorkItems(items).slice(0, 8); + return uniqueWorkItems(items); } function buildProgressItems(payload: PortalHomePayload) { @@ -579,6 +585,7 @@ function WorkItemRow({ item }: { item: WorkItem }) { {item.title} + {item.showReason ? {item.reason} : null} {item.meta} {item.actionLabel} @@ -680,7 +687,7 @@ function ConditionalWorkPanel({ export function PortalHomePage() { const { me, permissions } = useAuth(); const [showWelcome, setShowWelcome] = useState(false); - const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() }); + const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome(), staleTime: 0, refetchOnMount: true, refetchOnWindowFocus: true }); const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]); useEffect(() => { diff --git a/client/tests/e2e/visual-portal.spec.ts b/client/tests/e2e/visual-portal.spec.ts index f3b54546..3620659a 100644 --- a/client/tests/e2e/visual-portal.spec.ts +++ b/client/tests/e2e/visual-portal.spec.ts @@ -3,6 +3,9 @@ import path from "node:path"; import { expect, test, type Page, type TestInfo } from "@playwright/test"; import { EMPLOYEE, FINANCE, login, type E2EUser } from "./helpers/auth"; +import type { PortalHomePayload } from "../../src/api/portal"; + +test.use({ serviceWorkers: "block" }); const ADMIN: E2EUser = { identifier: "admin@hyinda.com", @@ -109,7 +112,7 @@ function portalPayload(mode: "employee" | "admin" | "finance") { }; } -async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "finance") { +async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "finance", handoffs?: () => PortalHomePayload["businessHandoffs"]) { await page.addInitScript(() => { window.localStorage.removeItem("hyb_oa_show_welcome"); window.localStorage.removeItem("hyb_oa_onboarding_active"); @@ -131,8 +134,8 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ enabled: false, entryEnabled: false, oauthEnabled: false, corpId: "", agentId: "", loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [] })) }); return; } - if (pathName === "/api/auth/login") { - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: "visual-token", expiresIn: 900 })) }); + if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") { + await route.fulfill({ status: 200, contentType: "application/json", headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" }, body: JSON.stringify(envelope({ accessToken: "visual-token", expiresIn: 900 })) }); return; } if (pathName === "/api/me") { @@ -178,7 +181,12 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ return; } if (pathName === "/api/portal/home") { - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(portalPayload(mode))) }); + const payload = portalPayload(mode); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(handoffs ? { + ...payload, businessHandoffs: handoffs(), pendingTasks: { count: 0, href: "/workflow/tasks" }, + todoSummary: null, weeklyReport: null, projectSummary: null, + financeSummary: mode === "finance" ? { ...payload.financeSummary, pendingBatchCount: 9, failedItemCount: 1, riskCount: 0 } : null, + } : payload)) }); return; } @@ -187,6 +195,56 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ } test.describe("visual portal", () => { + for (const mobile of [false, true]) { + test(`业务交接${mobile ? "手机" : "PC"}显示真实事项并在处理后刷新消失`, async ({ page }, testInfo) => { + test.skip(process.env.E2E_MODE === "live", "仅本地合成事项"); + let items: NonNullable["items"] = [ + { key: "reimbursement-batch:synthetic", kind: "reimbursement_batch", reference: "BX-SYN", title: "报销待组建付款批次", nextStep: "核对付款资料后组批", ownerLabel: "财务", href: "/reimbursements/synthetic", mobileHref: "/mobile/reimbursements/synthetic" }, + { key: "payment-execute:synthetic", kind: "payment_execute", reference: "PB-SYN", title: "付款与回单待处理", nextStep: "已生成银行制单,等待付款完成并上传回单", ownerLabel: "财务", href: "/payment-batches?id=synthetic", mobileHref: "/mobile/payment-batches?id=synthetic" }, + ]; + await mockVisualPortal(page, "finance", () => ({ total: items.length, items })); + await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }); + await login(page, FINANCE); + await page.goto(mobile ? "/mobile" : "/portal"); + await expect(page.getByText("报销待组建付款批次 · BX-SYN", { exact: true })).toBeVisible(); + await expect(page.getByText("付款与回单待处理 · PB-SYN", { exact: true })).toBeVisible(); + await expect(page.getByText("待付款批次 9 个", { exact: true })).toHaveCount(0); + await expect(page.getByText("异常付款 1 项", { exact: true })).toHaveCount(0); + if (mobile) await expect(page.getByTestId("mobile-business-handoffs")).toContainText("2 项"); + else await expect(page.getByTestId("portal-work-summary")).toContainText("2"); + await page.screenshot({ path: testInfo.outputPath("handoffs.png"), fullPage: true }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true); + items = []; + await page.reload(); + await expect(page.getByText("付款与回单待处理 · PB-SYN", { exact: true })).toHaveCount(0); + await expect(page.getByText("待付款批次 9 个", { exact: true })).toHaveCount(0); + }); + } + + test("员工退回待修改直接进入本人的报销,不依赖通知未读数", async ({ page }) => { + test.skip(process.env.E2E_MODE === "live", "仅本地合成事项"); + await mockVisualPortal(page, "employee", () => ({ total: 1, items: [{ key: "reimbursement-edit:synthetic", kind: "reimbursement_edit", reference: "BX-SYN", title: "报销退回待修改", nextStep: "修改后重新提交", ownerLabel: "报销人", href: "/reimbursements/synthetic", mobileHref: "/mobile/reimbursements/synthetic" }] })); + await login(page, EMPLOYEE); + await page.goto("/portal"); + const row = page.getByRole("link").filter({ hasText: "报销退回待修改" }); + await expect(row).toHaveCount(1); + await expect(row).toHaveAttribute("href", "/reimbursements/synthetic"); + }); + + test("超过八条业务事项时计数与展示不截断", async ({ page }) => { + test.skip(process.env.E2E_MODE === "live", "仅本地合成事项"); + const items: NonNullable["items"] = Array.from({ length: 9 }, (_, index) => ({ + key: `reimbursement-batch:synthetic-${index}`, kind: "reimbursement_batch", reference: `BX-SYN-${index}`, + title: "报销待组建付款批次", nextStep: "核对付款资料后组批", ownerLabel: "财务", + href: `/reimbursements/synthetic-${index}`, mobileHref: `/mobile/reimbursements/synthetic-${index}`, + })); + await mockVisualPortal(page, "finance", () => ({ total: items.length, items })); + await login(page, FINANCE); + await page.goto("/portal"); + await expect(page.getByRole("link").filter({ hasText: "报销待组建付款批次" })).toHaveCount(9); + await expect(page.getByTestId("portal-work-summary")).toContainText("9"); + }); + test("员工 Portal 视觉基线", async ({ page }, testInfo) => { await mockVisualPortal(page, "employee"); await page.setViewportSize({ width: 1440, height: 1200 }); diff --git a/scripts/qa/test-invoice-first-http.cjs b/scripts/qa/test-invoice-first-http.cjs index a898d9b2..a8796bf2 100644 --- a/scripts/qa/test-invoice-first-http.cjs +++ b/scripts/qa/test-invoice-first-http.cjs @@ -96,6 +96,11 @@ async function main() { if (process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1") { await app.register(serverRequire("./src/routes/paymentBatches"), { prefix: "/api" }); } + if (process.env.RUN_HANDOFF_PROJECTION_TEST === "1") { + app.config.portalHomeEnabled = true; + await prisma.featureFlag.create({ data: { flag_key: "portal_home_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] } }); + await app.register(serverRequire("./src/routes/portal"), { prefix: "/api" }); + } if (process.env.RUN_JOB_QUEUE === "1") { Object.assign(app.config, { jobDriver: "pgboss", pgBossSchema: "synthetic_jobs", ocrMaxAttempts: 3, ocrPollCron: "0 0 1 1 *", delegationRevokeCron: "0 0 1 1 *", invoiceArchiveCron: "0 0 1 1 *", integrationOutboxCron: "0 0 1 1 *" }); await serverRequire("./src/jobs/pgBossJobs").startPgBossJobs(app); @@ -115,6 +120,21 @@ async function main() { const response = await fetch(`${origin}/api${url}`, { method, headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(15000) }); return { status: response.status, body: await response.json() }; }; + const handoffCheck = async (actor, kind, entityId, expectedCount) => { + if (process.env.RUN_HANDOFF_PROJECTION_TEST !== "1") return; + const auditCount = await prisma.auditLog.count(); + const getHandoffs = async () => { + const response = await fetch(`${origin}/api/portal/home`, { headers: { authorization: `Bearer ${tokens.get(actor.id)}` }, signal: AbortSignal.timeout(15000) }); + assert.equal(response.status, 200, "authorized portal handoff projection"); + const payload = (await response.json()).data.businessHandoffs; + assert.equal(payload.total, payload.items.length); + assert.equal(new Set(payload.items.map((item) => item.key)).size, payload.total); + assert.equal(payload.items.filter((item) => item.kind === kind && item.key.endsWith(`:${entityId}`)).length, expectedCount); + return payload; + }; + assert.deepEqual(await getHandoffs(), await getHandoffs()); + assert.equal(await prisma.auditLog.count(), auditCount, "handoff reads do not create audit or workflow writes"); + }; const bind = (invoice, form, actor) => post(`/invoice-pool/${invoice.id}/bind`, { reimbursementId: form.id, createItem: true }, actor); const objects = [ "<< /Type /Catalog /Pages 2 0 R >>", @@ -261,10 +281,13 @@ async function main() { assert.equal((await post(`/workflow/tasks/${firstTask.id}/approve`, {})).status, 403); assert.equal((await post(`/workflow/tasks/${firstTask.id}/return`, { comment: "synthetic correction required" }, manager.id)).status, 200); assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "draft"); + await handoffCheck(employee, "reimbursement_edit", form.id, 1); + await handoffCheck(approvers.finance, "reimbursement_edit", form.id, 0); assert.equal((await prisma.invoicePool.findUnique({ where: { id: invoice.id } })).linked_reimbursement_id, form.id); assert.equal((await post(`/reimbursements/${form.id}/items/${items[0].id}`, { description: "synthetic corrected purpose" }, null, "PATCH")).status, 200); const returnedResubmit = await post(`/reimbursements/${form.id}/submit`, {}); assert.equal(returnedResubmit.status, 200); + await handoffCheck(employee, "reimbursement_edit", form.id, 0); const rejectTask = await prisma.workflowTask.findFirst({ where: { instance_id: returnedResubmit.body.data.workflowInstanceId, status: "pending" } }); assert.equal((await post(`/workflow/tasks/${rejectTask.id}/reject`, { comment: "synthetic rejected correction" }, manager.id)).status, 200); assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "rejected"); @@ -319,7 +342,7 @@ async function main() { assert.equal((await prisma.workflowInstance.findUnique({ where: { id: newWorkflowId } })).current_status, "approved"); passed.push("owner cannot self-approve; return/edit/resubmit and manager/ceo/finance approval complete without payment"); if (process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1") { - await require("./test-payment-experience.cjs")({ app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword }); + await require("./test-payment-experience.cjs")({ app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword, handoffCheck }); } const competingForms = await Promise.all([makeForm(), makeForm()]); @@ -401,7 +424,7 @@ async function main() { await prisma.employee.update({ where: { id: employee.id }, data: { token_version: { increment: 1 } } }); assert.equal((await fetch(`${origin}/api/me`, { headers: { authorization: `Bearer ${tokens.get(employee.id)}` } })).status, 401); passed.push("revoked token version rejected by real auth plugin"); - console.log(JSON.stringify({ status: "PASS_REAL_DB_HTTP", passed, authentication: "REAL_PASSWORD_LOGIN_JWT_SYNTHETIC_ACCOUNTS", liveOcr: "NOT_TESTED", ocrWorker: process.env.RUN_JOB_QUEUE === "1" ? "REAL_PGBOSS_MOCK_PROVIDER" : process.env.RUN_BROWSER_TEST === "1" ? "REAL_WORKER_MOCK_PROVIDER_MANUAL_DISPATCH" : "NOT_TESTED", approvalFlow: "RETURN_RESUBMIT_THREE_STEP_APPROVAL", payment: process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1" ? "CREATE_REVIEW_DOWNLOAD_STILL_UNPAID" : "NOT_TESTED" }, null, 2)); + console.log(JSON.stringify({ status: "PASS_REAL_DB_HTTP", passed, authentication: "REAL_PASSWORD_LOGIN_JWT_SYNTHETIC_ACCOUNTS", liveOcr: "NOT_TESTED", ocrWorker: process.env.RUN_JOB_QUEUE === "1" ? "REAL_PGBOSS_MOCK_PROVIDER" : process.env.RUN_BROWSER_TEST === "1" ? "REAL_WORKER_MOCK_PROVIDER_MANUAL_DISPATCH" : "NOT_TESTED", approvalFlow: "RETURN_RESUBMIT_THREE_STEP_APPROVAL", payment: process.env.RUN_HANDOFF_PROJECTION_TEST === "1" ? "SYNTHETIC_RECEIPT_RECORDED_PAID_NO_BANK_CONNECTION" : process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1" ? "CREATE_REVIEW_DOWNLOAD_STILL_UNPAID" : "NOT_TESTED" }, null, 2)); } finally { if (browser) await browser.close().catch(() => { process.exitCode = 1; console.error("Browser cleanup failed"); }); if (preview) await new Promise((resolve, reject) => preview.httpServer.close((error) => error ? reject(error) : resolve())).catch(() => { process.exitCode = 1; console.error("Preview cleanup failed"); }); diff --git a/scripts/qa/test-payment-experience.cjs b/scripts/qa/test-payment-experience.cjs index add01888..3d6be482 100644 --- a/scripts/qa/test-payment-experience.cjs +++ b/scripts/qa/test-payment-experience.cjs @@ -5,7 +5,7 @@ const { createRequire } = require("node:module"); const path = require("node:path"); module.exports = async function verifyPaymentExperience({ - app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword, + app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword, handoffCheck, }) { app.config.fieldEncryptionKey = randomBytes(32).toString("base64"); app.config.paymentBatchEnabled = true; @@ -23,6 +23,8 @@ module.exports = async function verifyPaymentExperience({ account_number_encrypted: encryptStringOrThrow(app.config, `6217${"0".repeat(8)}1234`), } }); await prisma.featureFlag.create({ data: { flag_key: "payment_batch_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] } }); + await handoffCheck?.(approvers.finance, "reimbursement_batch", form.id, 1); + await handoffCheck?.(approvers.ceo, "reimbursement_batch", form.id, 0); const readProgress = async (actor) => { const response = await fetch(`${origin}/api/reimbursements/${form.id}`, { headers: { authorization: `Bearer ${tokens.get(actor.id)}` }, signal: AbortSignal.timeout(15000), @@ -36,6 +38,9 @@ module.exports = async function verifyPaymentExperience({ }, approvers.finance.id); assert.equal(created.status, 201, `create payment batch: ${created.body.error?.details?.reasonCode ?? created.status}`); const batch = created.body.data.item; + await handoffCheck?.(approvers.finance, "reimbursement_batch", form.id, 0); + await handoffCheck?.(approvers.ceo, "payment_review", batch.id, 1); + await handoffCheck?.(approvers.finance, "payment_review", batch.id, 0); const reviewStage = (await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_batch_review"); assert.equal(reviewStage.state, "current"); assert.equal(reviewStage.detail, "待非制单人复核付款"); @@ -46,6 +51,9 @@ module.exports = async function verifyPaymentExperience({ assert.equal((await download(approvers.finance)).status, 409); assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.finance.id)).status, 403); assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.ceo.id)).status, 200); + await handoffCheck?.(approvers.ceo, "payment_review", batch.id, 0); + await handoffCheck?.(approvers.finance, "payment_execute", batch.id, 1); + await handoffCheck?.(approvers.ceo, "payment_execute", batch.id, 0); assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_and_receipt").state, "current"); assert.equal((await download(approvers.ceo)).status, 403); assert.equal((await download(employee)).status, 403); @@ -125,4 +133,21 @@ module.exports = async function verifyPaymentExperience({ passed.push("payment: real authenticated desktop/mobile reimbursement-to-batch download and return agree on stage, unique link, action-before-progress; no reason input; repeat warning retained"); } passed.push("payment: real finance create, CEO review, unauthorized export refused, Chinese BIFF8 first/re-download without reason, audited and still unpaid"); + if (process.env.RUN_HANDOFF_PROJECTION_TEST === "1") { + await handoffCheck(approvers.finance, "payment_execute", batch.id, 1); + assert.equal((await post(`/payment-batches/${batch.id}/execute`, {}, approvers.finance.id)).status, 409); + const receipt = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jD1sAAAAASUVORK5CYII=", "base64"); + const upload = new FormData(); + upload.append("files", new Blob([receipt], { type: "image/png" }), "synthetic-receipt.png"); + const uploaded = await fetch(`${origin}/api/payment-batches/${batch.id}/receipt-attachments`, { + method: "POST", headers: { authorization: `Bearer ${tokens.get(approvers.finance.id)}` }, body: upload, signal: AbortSignal.timeout(15000), + }); + assert.equal(uploaded.status, 200, "synthetic receipt upload"); + const executed = await post(`/payment-batches/${batch.id}/execute`, {}, approvers.finance.id); + assert.equal(executed.status, 200); + assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "paid"); + await handoffCheck(approvers.finance, "payment_execute", batch.id, 0); + await handoffCheck(approvers.finance, "reimbursement_batch", form.id, 0); + passed.push("handoffs: real read-only portal return/resubmit/create/review/export/synthetic receipt/paid lifecycle; unique stable counts, no self-review, no bank connection"); + } }; diff --git a/server/src/routes/portal.js b/server/src/routes/portal.js index bc630aeb..f9c762bd 100644 --- a/server/src/routes/portal.js +++ b/server/src/routes/portal.js @@ -7,6 +7,7 @@ const { sendData } = require("../utils/reply"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { getWorkspaceSetting } = require("../services/workspaceSettings"); const { currentDateString, parseDateOnlyString } = require("../utils/date"); +const { loadReimbursementPaymentHandoffs } = require("../services/reimbursementPaymentHandoffs"); const PORTAL_RECENT_ACTIVITY_LIMIT = 15; const PORTAL_RECENT_ACTIVITY_SOURCE_TAKE = 24; @@ -455,6 +456,7 @@ async function portalRoutes(fastify) { await ensureFeatureEnabled(fastify, request); const workspace = await getWorkspaceSetting(fastify.prisma); + const businessHandoffs = await loadReimbursementPaymentHandoffs(fastify, request.employee); const today = currentDateString(fastify.config.appTimezone); const currentWeekStart = resolveWeekStart(today); const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request); @@ -761,6 +763,7 @@ async function portalRoutes(fastify) { { domain: "reimbursement", count: reimbursementsCount, href: "/reimbursements" }, { domain: "leave", count: leaveCount, href: "/leave-requests" }, ], + businessHandoffs, riskSummary: riskCount > 0 ? { openCount: riskCount, href: "/ap-operations" } : null, announcements: announcementRows.map((item) => ({ id: item.id, diff --git a/server/src/services/reimbursementPaymentHandoffs.js b/server/src/services/reimbursementPaymentHandoffs.js new file mode 100644 index 00000000..4a875809 --- /dev/null +++ b/server/src/services/reimbursementPaymentHandoffs.js @@ -0,0 +1,92 @@ +const { evaluateFeatureFlag, FEATURE_FLAG_KEYS } = require("./featureFlags"); +const { buildPaymentProcessingSemantics } = require("./paymentProcessingProgress"); + +async function loadReimbursementPaymentHandoffs(fastify, employee) { + return fastify.prisma.$transaction(async (tx) => { + await tx.$executeRawUnsafe("SET TRANSACTION READ ONLY"); + const items = []; + const canManage = fastify.hasPermission(employee, "payment.batch.manage"); + const canReview = fastify.hasPermission(employee, "payment.batch.review") + && (employee.roleCodes ?? []).some((role) => ["manager", "ceo"].includes(role)); + if (fastify.hasPermission(employee, "expense.submit") && fastify.hasPermission(employee, "expense.view_own")) { + const returned = await tx.reimbursementForm.findMany({ + where: { deleted_at: null, paid_at: null, status: { in: ["draft", "rejected"] }, OR: [{ owner_id: employee.id }, { submitter_id: employee.id }] }, + select: { id: true, form_number: true, workflow_instances: { + orderBy: [{ created_at: "desc" }, { id: "desc" }], take: 1, + select: { workflow_history: { orderBy: [{ created_at: "desc" }, { id: "desc" }], take: 1, select: { action: true } } }, + } }, + orderBy: [{ updated_at: "asc" }, { id: "asc" }], + }); + for (const form of returned) { + if (!["return", "reject"].includes(form.workflow_instances[0]?.workflow_history[0]?.action)) continue; + items.push({ key: `reimbursement-edit:${form.id}`, kind: "reimbursement_edit", reference: form.form_number, + title: "报销退回待修改", nextStep: "修改后重新提交,也可删除后重新填报", ownerLabel: "报销人或代填人", + href: `/reimbursements/${form.id}`, mobileHref: `/mobile/reimbursements/${form.id}` }); + } + } + if (!canManage && !canReview) return { total: items.length, items }; + const gate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.paymentBatch, strictDepartment: false }); + if (!gate.enabled) return { total: items.length, items }; + const departmentGates = new Map(); + const departmentEnabled = async (departmentId) => { + const key = departmentId ?? null; + if (!departmentGates.has(key)) departmentGates.set(key, evaluateFeatureFlag({ + prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.paymentBatch, departmentId: key, strictDepartment: true, + }).then((result) => result.enabled)); + return departmentGates.get(key); + }; + if (canManage) { + const approved = await tx.reimbursementForm.findMany({ + where: { status: "approved", paid_at: null, deleted_at: null }, + select: { id: true, form_number: true, department_id: true }, + orderBy: [{ updated_at: "asc" }, { id: "asc" }], + }); + const reservations = approved.length ? await tx.paymentBatchItem.findMany({ + where: { entity_id: { in: approved.map((form) => form.id) }, payment_batch: { entity_type: "reimbursement", status: { not: "cancelled" } } }, + select: { entity_id: true }, + }) : []; + const reserved = new Set(reservations.map((item) => item.entity_id)); + for (const form of approved) { + if (reserved.has(form.id) || !await departmentEnabled(form.department_id)) continue; + items.push({ key: `reimbursement-batch:${form.id}`, kind: "reimbursement_batch", reference: form.form_number, + title: "报销待组建付款批次", nextStep: "核对付款资料后组批,资料缺项由付款页说明", ownerLabel: "财务", + href: `/reimbursements/${form.id}`, mobileHref: `/mobile/reimbursements/${form.id}` }); + } + } + const batches = await tx.paymentBatch.findMany({ + where: { entity_type: "reimbursement", OR: [ + ...(canReview ? [{ status: "draft", reviewed_at: null, created_by: { not: employee.id } }] : []), + ...(canManage ? [{ status: { in: ["reviewed", "failed"] } }] : []), + ] }, + select: { id: true, batch_no: true, status: true, created_by: true, payment_order_export_count: true, default_payment_channel: true, + items: { select: { entity_id: true, status: true, payment_channel_override: true } }, + _count: { select: { receipt_attachments: { where: { archived_at: null } } } }, + }, + orderBy: [{ created_at: "asc" }, { id: "asc" }], + }); + const sourceIds = [...new Set(batches.flatMap((batch) => batch.items.map((item) => item.entity_id)))]; + const sources = sourceIds.length ? await tx.reimbursementForm.findMany({ + where: { id: { in: sourceIds }, deleted_at: null }, select: { id: true, department_id: true }, + }) : []; + const sourceDepartments = new Map(sources.map((source) => [source.id, source.department_id])); + for (const batch of batches) { + if (!batch.items.length || !batch.created_by) continue; + if (batch.status === "draft" && batch.items.some((item) => item.status !== "pending")) continue; + const readable = await Promise.all(batch.items.map((item) => sourceDepartments.has(item.entity_id) && departmentEnabled(sourceDepartments.get(item.entity_id)))); + if (readable.some((enabled) => !enabled)) continue; + const review = batch.status === "draft"; + const semantics = buildPaymentProcessingSemantics({ status: batch.status, + requiresBankReceipt: batch.items.some((item) => ["pending", "failed"].includes(item.status) + && (item.payment_channel_override ?? batch.default_payment_channel ?? "bank") === "bank"), + paymentOrderExportCount: batch.payment_order_export_count, receiptCount: batch._count.receipt_attachments, + }); + items.push({ key: `payment-${review ? "review" : "execute"}:${batch.id}`, kind: review ? "payment_review" : "payment_execute", reference: batch.batch_no, + title: review ? "付款批次待复核" : "付款与回单待处理", nextStep: (review ? semantics.review : semantics.payment).detail, + ownerLabel: review ? "非制单复核人" : "财务", href: `/payment-batches?id=${batch.id}`, mobileHref: `/mobile/payment-batches?id=${batch.id}` }); + } + const unique = [...new Map(items.map((item) => [item.key, item])).values()]; + return { total: unique.length, items: unique }; + }, { isolationLevel: "RepeatableRead", maxWait: 5000, timeout: 10000 }); +} + +module.exports = { loadReimbursementPaymentHandoffs }; diff --git a/server/tests/unit/routes/portal.test.js b/server/tests/unit/routes/portal.test.js index 72b2ee5e..e6077274 100644 --- a/server/tests/unit/routes/portal.test.js +++ b/server/tests/unit/routes/portal.test.js @@ -177,6 +177,7 @@ async function buildApp(options = {}) { }, reimbursementForm: { count: vi.fn(async () => 2), + findMany: vi.fn(async () => []), }, leaveRequest: { count: vi.fn(async () => 1), @@ -192,6 +193,7 @@ async function buildApp(options = {}) { }, paymentBatch: { count: vi.fn(async () => options.pendingFinanceBatchCount ?? 0), + findMany: vi.fn(async () => []), }, paymentBatchItem: { count: vi.fn(async () => options.failedFinanceItemCount ?? 0), @@ -293,6 +295,8 @@ async function buildApp(options = {}) { }; const app = Fastify(); + prisma.$executeRawUnsafe = vi.fn(async () => 0); + prisma.$transaction = vi.fn(async (work) => work(prisma)); app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", diff --git a/server/tests/unit/services/reimbursementPaymentHandoffs.test.js b/server/tests/unit/services/reimbursementPaymentHandoffs.test.js new file mode 100644 index 00000000..6b93eb96 --- /dev/null +++ b/server/tests/unit/services/reimbursementPaymentHandoffs.test.js @@ -0,0 +1,91 @@ +const { loadReimbursementPaymentHandoffs } = require("../../../src/services/reimbursementPaymentHandoffs"); + +const gates = { global: true, disabled: [] }; + +function setup({ permissions = [], roles = [], returned = [], approved = [], reservations = [], batches = [], sources = [] } = {}) { + const tx = { + $executeRawUnsafe: vi.fn(async () => 0), + featureFlag: { findUnique: vi.fn(async () => ({ override_mode: gates.global ? "force_on" : "force_off", + rollout_type: gates.disabled.length ? "department_allowlist" : "global", department_ids: gates.disabled.length ? ["free"] : [], + })) }, + reimbursementForm: { findMany: vi.fn(async ({ where }) => where.OR ? returned : where.status === "approved" ? approved : sources) }, + paymentBatchItem: { findMany: vi.fn(async () => reservations) }, + paymentBatch: { findMany: vi.fn(async ({ where }) => batches.filter((batch) => where.OR.some((filter) => + filter.status === "draft" ? batch.status === "draft" && batch.created_by !== "actor" : filter.status.in.includes(batch.status)))) }, + }; + const fastify = { config: { paymentBatchEnabled: true }, prisma: { $transaction: vi.fn(async (work) => work(tx)) }, hasPermission: (_, permission) => permissions.includes(permission) }; + const employee = { id: "actor", roleCodes: roles }; + return { tx, fastify, run: () => loadReimbursementPaymentHandoffs(fastify, employee) }; +} + +function batch(status = "draft", creator = "maker") { + return { id: "batch", batch_no: "PB-SYN", status, created_by: creator, default_payment_channel: "bank", payment_order_export_count: 0, + items: [{ entity_id: "form", status: "pending" }], _count: { receipt_attachments: 0 } }; +} + +describe("报销付款四类只读交接", () => { + beforeEach(() => { gates.global = true; gates.disabled = []; }); + it("无权限不读取业务来源,事务始终只读", async () => { + const { run, tx, fastify } = setup(); + expect(await run()).toEqual({ total: 0, items: [] }); + expect(tx.reimbursementForm.findMany).not.toHaveBeenCalled(); + expect(tx.paymentBatch.findMany).not.toHaveBeenCalled(); + expect(tx.$executeRawUnsafe).toHaveBeenCalledWith("SET TRANSACTION READ ONLY"); + expect(fastify.prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: "RepeatableRead", maxWait: 5000, timeout: 10000 }); + }); + + it("只认最新流程退回/驳回,不把新草稿、已撤回或旧退回历史当待修改", async () => { + const returned = ["return", "reject", "withdraw", null].map((action, index) => ({ id: `form-${index}`, form_number: `BX-${index}`, + workflow_instances: action ? [{ workflow_history: [{ action }] }] : [] })); + const { run, tx } = setup({ permissions: ["expense.submit", "expense.view_own"], returned }); + const result = await run(); + expect(result.total).toBe(2); + expect(result.items.map((item) => item.kind)).toEqual(["reimbursement_edit", "reimbursement_edit"]); + expect(tx.reimbursementForm.findMany.mock.calls[0][0].where).toEqual({ deleted_at: null, paid_at: null, status: { in: ["draft", "rejected"] }, OR: [{ owner_id: "actor" }, { submitter_id: "actor" }] }); + }); + + it("待组批排除活动占用和部门停用,但不读取或解密银行资料", async () => { + gates.disabled = ["disabled"]; + const approved = ["free", "reserved", "disabled"].map((id) => ({ id, form_number: id, department_id: id })); + const { run, tx } = setup({ permissions: ["payment.batch.manage"], approved, reservations: [{ entity_id: "reserved" }] }); + const result = await run(); + expect(result.items.map((item) => item.reference)).toEqual(["free"]); + expect(tx.reimbursementForm.findMany.mock.calls[0][0].where).toEqual({ status: "approved", paid_at: null, deleted_at: null }); + expect(tx.paymentBatchItem.findMany.mock.calls[0][0].where.payment_batch.status).toEqual({ not: "cancelled" }); + expect(JSON.stringify(tx.reimbursementForm.findMany.mock.calls)).not.toMatch(/bank|total_amount|description/); + }); + + it.each([[[], false], [["admin"], false], [["manager"], true], [["ceo"], true]])("复核需要真实业务角色 %j,不用通配冒充", async (roles, expected) => { + const { run } = setup({ roles, permissions: ["payment.batch.review"], batches: [batch()], sources: [{ id: "form", department_id: "department" }] }); + expect((await run()).total).toBe(expected ? 1 : 0); + }); + + it("多角色制单人也不能收到自审任务", async () => { + const { run } = setup({ roles: ["ceo", "finance"], permissions: ["payment.batch.review", "payment.batch.manage"], batches: [batch("draft", "actor")] }); + expect((await run()).total).toBe(0); + }); + + it.each(["reviewed", "failed"])("%s 交给财务,不交给单独 CEO;导出不意味着付款", async (status) => { + const data = { batches: [{ ...batch(status), payment_order_export_count: 1 }], sources: [{ id: "form", department_id: "department" }] }; + const result = await setup({ ...data, permissions: ["payment.batch.manage"] }).run(); + expect(result.total).toBe(1); + expect(result.items[0].kind).toBe("payment_execute"); + expect(result.items[0].nextStep).toContain(status === "failed" ? "付款登记失败" : "已生成银行制单"); + expect((await setup({ ...data, permissions: ["payment.batch.review"], roles: ["ceo"] }).run()).total).toBe(0); + }); + + it("批次已取消/完成、来源已删除/缺失、部门停用不继续投影", async () => { + const base = { permissions: ["payment.batch.manage"], batches: [batch("executed"), batch("cancelled")] }; + expect((await setup(base).run()).total).toBe(0); + expect((await setup({ ...base, batches: [batch("reviewed")] }).run()).total).toBe(0); + gates.disabled = ["department"]; + expect((await setup({ ...base, batches: [batch("reviewed")], sources: [{ id: "form", department_id: "department" }] }).run()).total).toBe(0); + }); + + it("反复加载不重复,通知设施不可用也不丢待办;全局停用不暴露批次", async () => { + const { run } = setup({ permissions: ["payment.batch.manage"], approved: [{ id: "free", department_id: "department" }] }); + expect(await run()).toEqual(await run()); + gates.global = false; + expect((await run()).total).toBe(0); + }); +});