feat: 补齐报销付款待办交接(09-11-reimbursement-payment-handoffs)
这个提交包含在:
@@ -23,6 +23,40 @@ QA script patterns over new abstractions.
|
|||||||
- `cd server && ./scripts/regression-workflow-actions.sh`
|
- `cd server && ./scripts/regression-workflow-actions.sh`
|
||||||
- `cd server && ./scripts/regression-all.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
|
## Scenario: Business Error Reasons And Payment Creation Readiness
|
||||||
|
|
||||||
### 1. Scope / Trigger
|
### 1. Scope / Trigger
|
||||||
|
|||||||
@@ -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。
|
||||||
@@ -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/角色修改,本任务代码回退不修改业务记录;整站既有留痕删除回退语义仍有效。获批提交推送后冻结真实候选,再刷新生产和恢复前置、另批部署与公网合成验收,不让真人代测。
|
||||||
@@ -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。
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -79,6 +79,19 @@ export type PortalHomePayload = {
|
|||||||
canViewTeam: boolean;
|
canViewTeam: boolean;
|
||||||
isAdminOnly: 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?: {
|
financeSummary?: {
|
||||||
pendingBatchCount: number;
|
pendingBatchCount: number;
|
||||||
failedItemCount: number;
|
failedItemCount: number;
|
||||||
|
|||||||
@@ -131,6 +131,9 @@ export function MobilePortalPage() {
|
|||||||
enabled: canUseFinance,
|
enabled: canUseFinance,
|
||||||
queryKey: ["mobile", "portal", "home"],
|
queryKey: ["mobile", "portal", "home"],
|
||||||
queryFn: () => fetchPortalHome(),
|
queryFn: () => fetchPortalHome(),
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -167,13 +170,14 @@ export function MobilePortalPage() {
|
|||||||
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
|
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
const financeSummary = portalHomeQuery.data?.financeSummary ?? null;
|
const financeSummary = portalHomeQuery.data?.financeSummary ?? null;
|
||||||
|
const businessHandoffs = portalHomeQuery.data?.businessHandoffs;
|
||||||
const paymentBatchEnabled = Boolean(financeSummary?.paymentBatchEnabled);
|
const paymentBatchEnabled = Boolean(financeSummary?.paymentBatchEnabled);
|
||||||
const financePendingCount = financeSummary?.pendingBatchCount ?? 0;
|
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 &&
|
const shouldShowFinanceCard = canUseFinance &&
|
||||||
!portalHomeQuery.isLoading &&
|
!portalHomeQuery.isLoading &&
|
||||||
!portalHomeQuery.isError &&
|
!portalHomeQuery.isError &&
|
||||||
(paymentBatchEnabled || financePendingCount > 0 || financeExceptionCount > 0);
|
(businessHandoffs ? financeExceptionCount > 0 : paymentBatchEnabled || financePendingCount > 0 || financeExceptionCount > 0);
|
||||||
const financeCardBadge = paymentBatchEnabled ? "复核与付款" : financePendingCount > 0 ? "待付款" : "异常与复核";
|
const financeCardBadge = paymentBatchEnabled ? "复核与付款" : financePendingCount > 0 ? "待付款" : "异常与复核";
|
||||||
|
|
||||||
async function openHomeNotification(item: NotificationItem) {
|
async function openHomeNotification(item: NotificationItem) {
|
||||||
@@ -298,6 +302,21 @@ export function MobilePortalPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{portalHomeQuery.isError ? <section className="mobile-card space-y-3" role="alert"><p>业务待办加载失败,请重试。</p><Button onClick={() => void portalHomeQuery.refetch()} type="button" variant="secondary">重试加载待办</Button></section> : null}
|
||||||
|
|
||||||
|
{!portalHomeQuery.isError && businessHandoffs && businessHandoffs.total > 0 ? (
|
||||||
|
<section className="mobile-card mobile-page-stack" data-testid="mobile-business-handoffs">
|
||||||
|
<div className="mobile-section-header"><h2 className="mobile-section-title">业务待办</h2><span>{businessHandoffs.total} 项</span></div>
|
||||||
|
{businessHandoffs.items.map((item) => (
|
||||||
|
<Link className="mobile-list-card block" key={item.key} to={item.mobileHref}>
|
||||||
|
<div className="mobile-list-card__title">{item.title}{item.reference ? ` · ${item.reference}` : ""}</div>
|
||||||
|
<p className="mobile-list-card__subtitle mt-1">{item.nextStep}</p>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">{item.ownerLabel} · 查看处理</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{shouldShowFinanceCard ? (
|
{shouldShowFinanceCard ? (
|
||||||
<section id="finance" tabIndex={-1} className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
|
<section id="finance" tabIndex={-1} className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
|
||||||
<div className="mobile-section-header">
|
<div className="mobile-section-header">
|
||||||
@@ -305,10 +324,10 @@ export function MobilePortalPage() {
|
|||||||
<span className="badge-neutral">{financeCardBadge}</span>
|
<span className="badge-neutral">{financeCardBadge}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mobile-metric-grid text-sm">
|
<div className="mobile-metric-grid text-sm">
|
||||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
{!businessHandoffs ? <div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||||
<div className="text-xs text-zinc-500">待付款</div>
|
<div className="text-xs text-zinc-500">待付款</div>
|
||||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{financePendingCount}</div>
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{financePendingCount}</div>
|
||||||
</div>
|
</div> : null}
|
||||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||||
<div className="text-xs text-zinc-500">异常</div>
|
<div className="text-xs text-zinc-500">异常</div>
|
||||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{financeExceptionCount}</div>
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{financeExceptionCount}</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type WorkItem = {
|
|||||||
icon: string;
|
icon: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
count: number;
|
count: number;
|
||||||
|
showReason?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EmptyAction = {
|
type EmptyAction = {
|
||||||
@@ -107,6 +108,11 @@ function uniqueWorkItems(items: WorkItem[]) {
|
|||||||
|
|
||||||
function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: RoleCapabilities) {
|
function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: RoleCapabilities) {
|
||||||
const items: WorkItem[] = [];
|
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) {
|
if (payload.pendingTasks.count > 0) {
|
||||||
items.push({
|
items.push({
|
||||||
@@ -158,7 +164,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (roleCapabilities.canUseFinance && payload.financeSummary) {
|
if (roleCapabilities.canUseFinance && payload.financeSummary) {
|
||||||
if (payload.financeSummary.failedItemCount > 0) {
|
if (!payload.businessHandoffs && payload.financeSummary.failedItemCount > 0) {
|
||||||
items.push({
|
items.push({
|
||||||
key: "finance-failed-payment",
|
key: "finance-failed-payment",
|
||||||
title: `异常付款 ${payload.financeSummary.failedItemCount} 项`,
|
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({
|
items.push({
|
||||||
key: "finance-pending-batch",
|
key: "finance-pending-batch",
|
||||||
title: `待付款批次 ${payload.financeSummary.pendingBatchCount} 个`,
|
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) {
|
function buildProgressItems(payload: PortalHomePayload) {
|
||||||
@@ -579,6 +585,7 @@ function WorkItemRow({ item }: { item: WorkItem }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="portal-task-row__title">{item.title}</span>
|
<span className="portal-task-row__title">{item.title}</span>
|
||||||
|
{item.showReason ? <span className="mt-1 block text-sm text-zinc-600">{item.reason}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="portal-task-row__meta">{item.meta}</span>
|
<span className="portal-task-row__meta">{item.meta}</span>
|
||||||
<span className="portal-task-row__action">{item.actionLabel}</span>
|
<span className="portal-task-row__action">{item.actionLabel}</span>
|
||||||
@@ -680,7 +687,7 @@ function ConditionalWorkPanel({
|
|||||||
export function PortalHomePage() {
|
export function PortalHomePage() {
|
||||||
const { me, permissions } = useAuth();
|
const { me, permissions } = useAuth();
|
||||||
const [showWelcome, setShowWelcome] = useState(false);
|
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"]);
|
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import path from "node:path";
|
|||||||
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
||||||
|
|
||||||
import { EMPLOYEE, FINANCE, login, type E2EUser } from "./helpers/auth";
|
import { EMPLOYEE, FINANCE, login, type E2EUser } from "./helpers/auth";
|
||||||
|
import type { PortalHomePayload } from "../../src/api/portal";
|
||||||
|
|
||||||
|
test.use({ serviceWorkers: "block" });
|
||||||
|
|
||||||
const ADMIN: E2EUser = {
|
const ADMIN: E2EUser = {
|
||||||
identifier: "[email protected]",
|
identifier: "[email protected]",
|
||||||
@@ -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(() => {
|
await page.addInitScript(() => {
|
||||||
window.localStorage.removeItem("hyb_oa_show_welcome");
|
window.localStorage.removeItem("hyb_oa_show_welcome");
|
||||||
window.localStorage.removeItem("hyb_oa_onboarding_active");
|
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: [] })) });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (pathName === "/api/auth/login") {
|
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
|
||||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: "visual-token", expiresIn: 900 })) });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (pathName === "/api/me") {
|
if (pathName === "/api/me") {
|
||||||
@@ -178,7 +181,12 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pathName === "/api/portal/home") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +195,56 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("visual portal", () => {
|
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<PortalHomePayload["businessHandoffs"]>["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<PortalHomePayload["businessHandoffs"]>["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) => {
|
test("员工 Portal 视觉基线", async ({ page }, testInfo) => {
|
||||||
await mockVisualPortal(page, "employee");
|
await mockVisualPortal(page, "employee");
|
||||||
await page.setViewportSize({ width: 1440, height: 1200 });
|
await page.setViewportSize({ width: 1440, height: 1200 });
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ async function main() {
|
|||||||
if (process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1") {
|
if (process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1") {
|
||||||
await app.register(serverRequire("./src/routes/paymentBatches"), { prefix: "/api" });
|
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") {
|
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 *" });
|
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);
|
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) });
|
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() };
|
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 bind = (invoice, form, actor) => post(`/invoice-pool/${invoice.id}/bind`, { reimbursementId: form.id, createItem: true }, actor);
|
||||||
const objects = [
|
const objects = [
|
||||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
"<< /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}/approve`, {})).status, 403);
|
||||||
assert.equal((await post(`/workflow/tasks/${firstTask.id}/return`, { comment: "synthetic correction required" }, manager.id)).status, 200);
|
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");
|
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 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);
|
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`, {});
|
const returnedResubmit = await post(`/reimbursements/${form.id}/submit`, {});
|
||||||
assert.equal(returnedResubmit.status, 200);
|
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" } });
|
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 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");
|
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");
|
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");
|
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") {
|
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()]);
|
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 } } });
|
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);
|
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");
|
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 {
|
} finally {
|
||||||
if (browser) await browser.close().catch(() => { process.exitCode = 1; console.error("Browser cleanup failed"); });
|
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"); });
|
if (preview) await new Promise((resolve, reject) => preview.httpServer.close((error) => error ? reject(error) : resolve())).catch(() => { process.exitCode = 1; console.error("Preview cleanup failed"); });
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const { createRequire } = require("node:module");
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
|
||||||
module.exports = async function verifyPaymentExperience({
|
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.fieldEncryptionKey = randomBytes(32).toString("base64");
|
||||||
app.config.paymentBatchEnabled = true;
|
app.config.paymentBatchEnabled = true;
|
||||||
@@ -23,6 +23,8 @@ module.exports = async function verifyPaymentExperience({
|
|||||||
account_number_encrypted: encryptStringOrThrow(app.config, `6217${"0".repeat(8)}1234`),
|
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 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 readProgress = async (actor) => {
|
||||||
const response = await fetch(`${origin}/api/reimbursements/${form.id}`, {
|
const response = await fetch(`${origin}/api/reimbursements/${form.id}`, {
|
||||||
headers: { authorization: `Bearer ${tokens.get(actor.id)}` }, signal: AbortSignal.timeout(15000),
|
headers: { authorization: `Bearer ${tokens.get(actor.id)}` }, signal: AbortSignal.timeout(15000),
|
||||||
@@ -36,6 +38,9 @@ module.exports = async function verifyPaymentExperience({
|
|||||||
}, approvers.finance.id);
|
}, approvers.finance.id);
|
||||||
assert.equal(created.status, 201, `create payment batch: ${created.body.error?.details?.reasonCode ?? created.status}`);
|
assert.equal(created.status, 201, `create payment batch: ${created.body.error?.details?.reasonCode ?? created.status}`);
|
||||||
const batch = created.body.data.item;
|
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");
|
const reviewStage = (await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_batch_review");
|
||||||
assert.equal(reviewStage.state, "current");
|
assert.equal(reviewStage.state, "current");
|
||||||
assert.equal(reviewStage.detail, "待非制单人复核付款");
|
assert.equal(reviewStage.detail, "待非制单人复核付款");
|
||||||
@@ -46,6 +51,9 @@ module.exports = async function verifyPaymentExperience({
|
|||||||
assert.equal((await download(approvers.finance)).status, 409);
|
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.finance.id)).status, 403);
|
||||||
assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.ceo.id)).status, 200);
|
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 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(approvers.ceo)).status, 403);
|
||||||
assert.equal((await download(employee)).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 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");
|
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");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { sendData } = require("../utils/reply");
|
|||||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
const { getWorkspaceSetting } = require("../services/workspaceSettings");
|
const { getWorkspaceSetting } = require("../services/workspaceSettings");
|
||||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||||
|
const { loadReimbursementPaymentHandoffs } = require("../services/reimbursementPaymentHandoffs");
|
||||||
|
|
||||||
const PORTAL_RECENT_ACTIVITY_LIMIT = 15;
|
const PORTAL_RECENT_ACTIVITY_LIMIT = 15;
|
||||||
const PORTAL_RECENT_ACTIVITY_SOURCE_TAKE = 24;
|
const PORTAL_RECENT_ACTIVITY_SOURCE_TAKE = 24;
|
||||||
@@ -455,6 +456,7 @@ async function portalRoutes(fastify) {
|
|||||||
await ensureFeatureEnabled(fastify, request);
|
await ensureFeatureEnabled(fastify, request);
|
||||||
|
|
||||||
const workspace = await getWorkspaceSetting(fastify.prisma);
|
const workspace = await getWorkspaceSetting(fastify.prisma);
|
||||||
|
const businessHandoffs = await loadReimbursementPaymentHandoffs(fastify, request.employee);
|
||||||
const today = currentDateString(fastify.config.appTimezone);
|
const today = currentDateString(fastify.config.appTimezone);
|
||||||
const currentWeekStart = resolveWeekStart(today);
|
const currentWeekStart = resolveWeekStart(today);
|
||||||
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
|
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
|
||||||
@@ -761,6 +763,7 @@ async function portalRoutes(fastify) {
|
|||||||
{ domain: "reimbursement", count: reimbursementsCount, href: "/reimbursements" },
|
{ domain: "reimbursement", count: reimbursementsCount, href: "/reimbursements" },
|
||||||
{ domain: "leave", count: leaveCount, href: "/leave-requests" },
|
{ domain: "leave", count: leaveCount, href: "/leave-requests" },
|
||||||
],
|
],
|
||||||
|
businessHandoffs,
|
||||||
riskSummary: riskCount > 0 ? { openCount: riskCount, href: "/ap-operations" } : null,
|
riskSummary: riskCount > 0 ? { openCount: riskCount, href: "/ap-operations" } : null,
|
||||||
announcements: announcementRows.map((item) => ({
|
announcements: announcementRows.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -177,6 +177,7 @@ async function buildApp(options = {}) {
|
|||||||
},
|
},
|
||||||
reimbursementForm: {
|
reimbursementForm: {
|
||||||
count: vi.fn(async () => 2),
|
count: vi.fn(async () => 2),
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
},
|
},
|
||||||
leaveRequest: {
|
leaveRequest: {
|
||||||
count: vi.fn(async () => 1),
|
count: vi.fn(async () => 1),
|
||||||
@@ -192,6 +193,7 @@ async function buildApp(options = {}) {
|
|||||||
},
|
},
|
||||||
paymentBatch: {
|
paymentBatch: {
|
||||||
count: vi.fn(async () => options.pendingFinanceBatchCount ?? 0),
|
count: vi.fn(async () => options.pendingFinanceBatchCount ?? 0),
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
},
|
},
|
||||||
paymentBatchItem: {
|
paymentBatchItem: {
|
||||||
count: vi.fn(async () => options.failedFinanceItemCount ?? 0),
|
count: vi.fn(async () => options.failedFinanceItemCount ?? 0),
|
||||||
@@ -293,6 +295,8 @@ async function buildApp(options = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
|
prisma.$executeRawUnsafe = vi.fn(async () => 0);
|
||||||
|
prisma.$transaction = vi.fn(async (work) => work(prisma));
|
||||||
app.decorate("prisma", prisma);
|
app.decorate("prisma", prisma);
|
||||||
app.decorate("config", {
|
app.decorate("config", {
|
||||||
appTimezone: "Asia/Shanghai",
|
appTimezone: "Asia/Shanghai",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户