diff --git a/.trellis/spec/hyb-oa/approval-workflow.md b/.trellis/spec/hyb-oa/approval-workflow.md index 44d18df0..ead17e38 100644 --- a/.trellis/spec/hyb-oa/approval-workflow.md +++ b/.trellis/spec/hyb-oa/approval-workflow.md @@ -17,6 +17,96 @@ task before modifying approval behavior. - Reviewer review is required after changes touching workflow, status machine, approval rules, permissions or migrations. +## Verification: Direct Manager And CEO Are The Same Employee + +`scripts/qa/test-invoice-first-http.cjs` supports opt-in +`RUN_MANAGER_IS_CEO_TEST=1` with the existing disposable-database guard. +It creates employee, ceo+employee and finance+employee synthetic identities; +employee and finance report to the CEO, with no manager-role holder. +The default separate-manager fixture remains supported. + +For the fixture's manager → ceo → finance rule, the direct-manager task is +assigned by employee relationship without requiring a manager role. The same +CEO must approve two separate steps; they are not automatically collapsed by +employee identity. Verify pending task identity/role, HTTP approval and replay +rejection at each step, then approved status and zero pending tasks. +Payment creation by finance and review by CEO remain separate responsibilities. + +This is a contract test, not permission to deduplicate workflow steps or to add +manager roles to production. It does not cover finance submitting its own +reimbursement, CEO self-fallback, or every amount/workflow definition. Keep +those scenarios separate from the employee-owned fixture and do not infer +production account readiness from synthetic login results. + +## Scenario: Finance-Owned Reimbursement Uses One CEO Approval + +### 1. Scope / Trigger + +New reimbursement submissions and resubmissions whose owner holds explicit +`finance` use one CEO approval. A finance submitter acting for an ordinary +employee does not trigger this rule. This is not global same-person step +deduplication and does not rewrite existing instances. + +### 2. Signatures + +`buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId, submitterId, entityType, logger })` +applies the specialization only to `entityType: "reimbursement"`. +`buildFinanceOwnerApprovalSnapshot({ tx, ownerId, submitterId })` is shared +with reimbursement operational precheck and returns null for non-finance owners. + +### 3. Contracts + +The new step is `approval_1_ceo_finance_owner`, titled 总经理审批, with existing +`{ type: "role", value: "ceo" }` assignment semantics. Require at least one +active, unarchived CEO outside both owner and submitter before submission. +Another finance employee does not change this rule; finance+CEO applicants +still cannot self-approve. Execution-time actor/principal self-approval and +business-role guards remain authoritative. No role or payment permission changes. +Finance creates the payment batch; the separate eligible non-maker reviews it. +For this new step, transfer targets and both execution actor and delegation +principal must still hold active, unarchived CEO membership. A transferred +specific-user assignment is not a substitute for that role. This restriction +is enforced in shared action handling, not only frontend choices. +Reimbursement list pending-workflow actions and workflow task actions also +exclude non-CEO actors for this step, including transferred assignees whose +role was revoked and non-CEO delegates. The server guard remains authoritative. + +### 4. Validation / Error Matrix + +No non-applicant CEO: snapshot raises 409 `no_actionable_approver` with +`finance_owner_ceo_unavailable`; operational precheck exposes a blocking reason +with that code and administrator guidance. This operational check runs with +budget precheck both enabled and disabled. Submission must leave no pending +instance on rejection; role deactivation after submission is not prevented by +an earlier precheck. +Ineligible transfer/execution returns 403 with reason code +`reimbursement.finance_owner_ceo_required`. Old executors can parse the new +step but lack this guard: rolling back only the image after new snapshots exist +is not a safe way to continue processing them. + +### 5. Good / Base / Bad Cases + +Good: finance submits → CEO approves once → finance makes batch → CEO reviews. +Base: employee-owned manager/CEO/finance steps and CEO-owned finance fallback +retain their earlier semantics. Bad: grant finance to CEO, allow self-approval, +or treat reimbursement approval as payment authorization. + +### 6. Tests Required + +Workflow unit and operational-precheck tests cover identity exclusions and +unchanged non-reimbursement paths. The disposable HTTP fixture with +`RUN_MANAGER_IS_CEO_TEST=1 RUN_OWNER_APPROVAL_TEST=1` covers the new chain, +return/resubmit, second finance, multi-role self-denial, unavailable CEO with +budget gates on/off, and separate payment review. Browser-enabled runs cover +desktop/mobile submission/approval and payment detail handoff. +This is synthetic validation, not production login, live OCR or bank acceptance. + +### 7. Wrong vs Correct + +Wrong: keep three identical CEO approvals or wait for a finance self-task to fail. +Correct: explicitly construct one CEO step for finance-owned new snapshots; +retain a separate payment review and immutable historical workflow facts. + ## Scenario: System Operator Is Not A Business Approver ### 1. Scope / Trigger diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md index 5880ed79..1fb1c919 100644 --- a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md @@ -1,5 +1,19 @@ # 报销状态空隙与财务处理视角:设计 +## 2026-09-12 财务本人报销CEO一次审批方案 + +状态:已批准本地实施,取代上一版无人可批才替代且保留三步的草案。边界仅报销;不全局改变请假、采购等流程。 + +1. 在报销快照构建前形成完整申请人集合(owner_id、submitter_id),与审批执行端的申请人认定一致。候选人须active且未归档,并排除所有申请人;委托的实际操作者与principal仍执行既有自审守卫,不以委托创造绕过路径。 +2. 显式传递报销上下文给共享快照逻辑,或使用报销专用包装层,避免其他调用方隐式获得新语义。同步reimbursements提交与feeControl预检两个调用点,不能只修提交。非本次触发组合保留既有CEO本人finance兜底行为。 +3. 仅报销归属人持finance时,检查非归属人且非提交人的有效CEO候选,将新快照构造成一个CEO角色审批步骤,即使存在第二位财务。财务代填普通员工单据不触发简化;普通员工/仅CEO归属人保留原路径。仍先校验有效审批规则,不绕过缺规则门禁。 +4. 步骤id为approval_1_ceo_finance_owner,名称“总经理审批”,沿用role/ceo快照结构,无新增schema。这是局部明确的一次审批,不使用全局按人去重。付款创建及非制单人复核逻辑完全不改。 +5. 无合法替代人返回明确的no_actionable_approver及稳定原因码,预检、提交口径一致。本人候选校验进入不受budget_precheck开关控制的operationalChecks,避免预算关闭时提示可提交后才失败;未知数据库异常不转为业务警告。审批执行端ensureNoSelfApproval及业务资格校验不得削弱。 +6. 在途流程不自动迁移。若发现真实在途阻断,先只读确认支持的合法退回/撤回入口,再另批修复方案;不得直接改approved或删任务救单。新快照可被旧执行器读取不代表安全兼容:旧执行器允许转交非CEO,首次生成新快照后不能只退旧镜像并继续处理它。发布方案必须包含保留新资格守卫的回退补丁或暂停这类流程的受控方案,不能宣称退镜像即可恢复原状。 +7. 独立只读复核发现既有转交可把CEO任务转给只有expense.approve的非CEO。局部修复仅绑定新步骤常量:转交目标、实际操作者、委托principal均须为active且未归档的CEO,同时保留自审排除。守卫进入共同执行入口,涵盖单条/批量及转交后的审批;旧流程不附加此新约束。资格撤销后不能依赖之前的指定人分配继续批准。 + +验证采用现有隔离三人组织脚本,将BLOCKED_OWNER_APPROVAL的已知死结场景改为真实成功断言;保留原失败证据。增加第二财务、owner/submitter不同、申请人同时持ceo+finance、CEO不存在/停用/归档及委托负向。覆盖新提交、退回重提、费用预检一致性、逐步审批、财务组批自审拒绝、CEO独立付款复核;桌面和手机验证真实入口与待办交接。不得用mock业务路由证明审批闭环,也不将合成paid称作真实付款。 + ## 2026-09-12 修正整体宽度遗漏 此前仅统一侧栏,保留报销正文max-w-7xl而标题与付款正文未限宽;1920px时出现正文内缩。本次移除共享报销桌面壳的mx-auto/w-full/max-w-7xl,完全复用付款同款grid与外层PageScaffold。共享壳覆盖报销详情及审批工作台,不改全站容器或手机断点。测试除侧栏宽度外须比较两页grid左右边界及报销hero左右边界,不能只测1280/1440时尚未触发的最大宽度。 diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md index f76210a4..2a329c24 100644 --- a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md @@ -1,7 +1,83 @@ # 报销状态空隙与财务处理视角:实施计划 +## 2026-09-12 本人报销修复执行清单(已授权提交/推送及只读预检,部署另批) + +用户在本地验收后明确批准提交、推送与只读发布预检。本轮不部署、不补写生产备份/镜像、不清理服务器、不改真实数据;执行回执保存于既有artifacts/qa-layout/finance-owner-release,提交/推送实际状态以Git及该回执为准。下文“未提交”等表述保留为本地验收时点记录,不代表后续发布准备尚未执行。 + +- [x] 按最新批准更新PRD/design:财务归属人由CEO一次审批,财务经办付款、CEO独立付款复核;第二财务不改变此规则,代填不算本人报销,在途不自动改写。 +- [x] 已加载受影响规约,核实快照两调用点及全局自审合同,接着补失败回归。 +- [x] 修复报销预检/提交共享快照路径,保留全局自审守卫及普通员工/CEO本人原路径;未新增生产配置或迁移。 +- [x] 单元及真实Postgres/HTTP验证身份矩阵,缺候选没有残留pending流程;同一CEO和独立经理回归通过。 +- [x] 桌面/手机验证财务本人提交、CEO一次审批、付款交接及独立复核;原BLOCKED及中间失败证据均保留。 +- [x] 更新审批业务规约、spec和任务进度;语法、受影响测试、typecheck/build、Prisma及治理/diff检查通过,独立只读复核的两项发现均闭合。 +- [ ] 完成后准备diff及发布影响说明;提交、推送、部署与真实在途处理分别按明确授权执行,重新核验候选/生产/备份/回退兼容,不沿用旧发布例外。 + +本地验收时点:代码已实现;本地验收完成;当时未提交/推送/部署,未连接生产或改真人数据;上轮已确认生产及本地基线为31ab9fbb。后续提交/推送与只读发布预检已获授权,实际部署另批。不是整个09-04任务或员工公网准入已完成。 + +验证与证据均在既有artifacts/qa-layout下: + +- `cd server && npm test -- tests/unit/routes/reimbursements tests/unit/routes/workflow tests/unit/services/reimbursementOperationalChecks.test.js tests/unit/services/expenseStandards.check.test.js tests/unit/utils/workflow.test.js`:249/249,finance-owner-regression-guarded;最后列表动作修订后额外运行detail/stats两文件43/43(属于249中的回归,不重复计数),finance-owner-projection-tests。 +- `cd server && npm test -- tests/unit/scripts/error-reason-client.test.js`:155/155,finance-owner-payment-error-tests。该轮命令同时带了两个不存在的付款测试路径,实际只执行这一文件,未将其声称为付款测试通过。 +- `cd server && PAYMENT_ORDER_PYTHON_BIN=/tmp/hyb-oa-biff8-test-venv/bin/python npm test -- tests/unit/services/paymentOrderXls.test.js tests/unit/services/paymentBatchReadiness.test.js tests/unit/routes/paymentBatches.reimbursement.test.js`:115/115,finance-owner-payment-tests-configured。三组共519项不同单测,不累加中间重复运行。 +- `RUN_ISOLATED_DB_TEST=1 RUN_MANAGER_IS_CEO_TEST=1 RUN_OWNER_APPROVAL_TEST=1 RUN_BROWSER_TEST=1 RUN_RETAINED_DELETE_TEST=1 RUN_JOB_QUEUE=1 RUN_PAYMENT_EXPERIENCE_TEST=1 RUN_HANDOFF_PROJECTION_TEST=1 PAYMENT_ORDER_PYTHON_BIN=/tmp/hyb-oa-biff8-test-venv/bin/python node scripts/qa/test-invoice-first-http.cjs`:最终finance-owner-accepted退出0,41组场景;含预算开关两态、CEO停用/归档/作为提交人/角色缺失、第二财务、双角色自审、退回重提、转交及委托资格、两类非CEO列表动作隐藏、桌面/手机真实提交与审批,以及财务本人付款经办/独立复核/制单/合成回单。临时数据库和附件清理成功。 +- 独立经理模式(上述命令移除RUN_MANAGER_IS_CEO_TEST、RUN_OWNER_APPROVAL_TEST、RUN_BROWSER_TEST):finance-owner-distinct-manager退出0,17组HTTP场景;不声称这轮含浏览器。已有同一CEO HTTP验证finance-owner-http-guarded为27组,最后完整验收以41组为准。 +- `cd client && npm run build`(包含typecheck)、`cd server && npm run prisma:validate`、受影响JS的node --check、错误原因strict合同、Trellis治理、process baseline及git diff --check通过。audit-docs --check-registry无错误,仍报告22个既有未注册文档警告,未扩大范围清理。 + +独立只读复核:主会话实施/测试,单独只读review先发现F1转交可绕过CEO要求,补实际actor/principal和目标角色守卫;复核再发现F2报销列表仍给非CEO动作提示,补新步骤的列表投影。最终复核0阻断、0未关闭非阻断;reviewer只审源码,未冒充独立跑测试。旧执行器能解析新快照但没有这些守卫,不允许首次新流程后仅回退旧镜像继续处理;发布影响见design。 + +中间失败如实保留:初始4个红色单测复现缺口;首轮HTTP因夹具使用不存在的inactive枚举失败,改用status=archived及独立archived_at两种有效数据;首轮本人浏览器误以为无警告也要二次确认,按实际precheck决策校正测试,不给用户增加确认负担;首轮付款单测未指定已有BIFF8解释器导致5项失败,配置正确解释器后115/115。没有通过增加超时、删除断言或修改付款产品代码掩盖失败。 + +本轮仍非完整生产应用启动:认证/HTTP/Prisma和业务路由真实,/api/me、导航和通知列表为辅助夹具;OCR为真实worker配mock提供方,未访问腾讯或银行。合成paid不等于真实付款,真实银行导入及完整恢复等其他主线缺口不由本轮代替。 + +## 2026-09-12 财务/CEO本人报销验证(已批准) + +沿本地三人组织组合新增RUN_OWNER_APPROVAL_TEST=1,必须同时显式启用同一经理/CEO模式。仅在一次性数据库把合成manager→ceo→finance规则明确配置owner_self_fallback_role=finance;不执行生产回填脚本、不声称所有生产规则已复核。创建owner/submitter均为对应合成账号且票据同属该人的完整草稿,验证CEO无经理时走财务兜底、自审拒绝和财务审批;仅一位财务时其本人报销检查提交门是否因无非本人财务审批人而阻断。此阶段先确认事实,不自动增加角色、绕自审或修改产品规则。 + +发现业务阻断:CEO本人单据在显式finance兜底下生成恰好一条finance任务,CEO尝试审批403、财务200,最终approved。唯一财务本人单据提交200,CEO分别完成manager及ceo两步均200,随后finance角色任务pending;财务尝试审批409/self_approval_not_allowed,CEO尝试403,单据仍reviewing。说明此合成规则下提交前置没有阻止后续只剩申请人可匹配角色的死结;不据此断言所有生产金额档或已存在单据都受影响。 + +首轮admission-owner-approval预期提交409但实际200而失败;第二轮diagnostic预期自审403但实际409,按现有ensureNoSelfApproval合同纠正并增加具体原因断言。observed轮完整复现,原PASS仅代表观察断言通过、不是业务准入通过;随即改为独立businessBlockers,阻断时输出BLOCKED_OWNER_APPROVAL并退出1,避免误放行。原日志保留,最终以gate日志/退出码为准。所有运行均使用随机本地DB/合成资料,finally清理,不连生产。本轮新增本人场景是HTTP/真实Postgres验证,未新增本人浏览器场景,不复用前轮普通员工浏览器结论冒充覆盖。 + +建议业务方向待用户确认:财务本人报销由非本人负责人审批,不向本人再派财务审批;CEO本人继续财务兜底,付款制单人与复核人分离。不得简单允许自审、给CEO补finance角色、创建虚假审批人或直接把存量单据改成approved。涉及审批规则/快照及在途单据兼容,确认后另补本任务设计与反例,再实施只读review;当前只完成诊断测试,未修改产品或生产。 + +## 2026-09-12 同一经理/CEO组织组合验证(已批准) + +复用既有隔离脚本,新增显式RUN_MANAGER_IS_CEO_TEST=1,仅创建合成employee、ceo+employee、finance+employee三人,员工和财务的直接经理均为CEO,不创建manager角色持有人。默认独立manager模式保留。验证现有三阶段任务实际分派及同一人是否需要两次操作,退回/重提、桌面手机、付款自审拒绝及合成回单闭环;不改产品审批规则、不修改生产账号,不推断财务本人报销自审兜底也已通过。单独保存新模式日志,失败不降低旧断言,不把查询关系当成真实审批通过。 + +admission-same-ceo.log及.exit:完整隔离运行退出0,29组场景PASS_REAL_DB_HTTP,数据库与合成附件清理成功。直接经理任务明确指派CEO账号,即使无人持manager角色也能提交;随后ceo角色任务仍需同一人另批一次,再到finance,重复审批409,最终无pending任务且approved。两次审批不是两个人把关,未自动去重,不因本次验证改变业务规则。财务创建批次自审403,CEO复核通过,桌面/手机制单返回、合成回单和待办1→0均通过。此结论只针对合成manager→ceo→finance规则和普通员工报销;财务本人报销与CEO自报兜底仍未验证。 + +实际命令沿既有RUN_ISOLATED_DB_TEST/RUN_BROWSER_TEST/RUN_JOB_QUEUE/RUN_PAYMENT_EXPERIENCE_TEST/RUN_RETAINED_DELETE_TEST/RUN_HANDOFF_PROJECTION_TEST全设1,加RUN_MANAGER_IS_CEO_TEST=1及固定BIFF8解释器运行node scripts/qa/test-invoice-first-http.cjs。运行时server/client未改变;前端沿用上轮同源构建,不重复部署。语法、Trellis治理、process-contract baseline及diff检查通过。默认模式同输入回归另记admission-distinct-manager-regression,不拿旧脚本通过抵扣此次夹具改动。 + +默认独立manager模式回归退出0,27组场景完整通过,清理成功。两模式回执汇总admission-organization-receipt.json,绑定三脚本摘要与各自日志;不是29+27个独立单测,也不等于完整生产启动合同。仅主会话检查,未独立review、未提交/推送、未连生产。下一步优先补财务本人报销与CEO本人报销的所有者自审/兜底场景,再汇总首批准入缺口;不为了验收去改真人角色或重放真人单据。 + +## 2026-09-12 首批准入认证与跨角色验证(已批准接续) + +生产只读盘点(2026-09-12T09:29Z):31ab9fbb运行版本匹配。远端事务RepeatableRead/READ ONLY、10秒statement_timeout,仅取角色/经理关联、状态、通知及资料存在布尔值;不读密码哈希、银行密文值或明文,不登录、不写业务。脱敏回执admission-production-readonly-confirmed.json;初轮仅smoke-前缀不足,依据09-07历史身份清单补oa_smoke_前缀重读,初轮原件保留。E标签只属该查询顺序,身份以历史用户已确认邮箱前缀匹配,不推断姓名。 + +- CEO/财务/普通员工均active;后两者经理指向CEO。三人主体/开户行关联/加密账号存在,但不等于解密成功、建行校验通过或可付款。CEO与普通员工must_change_password=true,财务false;没有本轮登录验证,不把该标志直接判成不能登录或擅自清除。管理员主体/开户行关联缺失仍不自动回填,个人报销非本轮默认准入。 +- 测试前缀候选4个:财务已archived;两位manager和一位employee active,没有专用ceo候选。4个主体/开户行/加密账号均缺,通知开关均true,其中一位manager缺直接经理。不得凭“已有测试账号”启动生产审批/回单链;创建或恢复角色可能影响真实审批路由,当前不授权此类变更。 +- 既有check-oa-only-smoke-readiness脚本会POST登录并发起多模块读取,不是零写入元数据探针,且旧矩阵假定独立manager角色,不直接运行或注入真人密码。本轮没有搜索/加载凭据文件。 +- 下一步先在本地合成环境补“员工/财务直接经理就是同一CEO、无独立manager角色”的组织组合,核审批任务去重及付款复核分离,避免为了测生产而加角色污染路由。公网合成闭环需单独方案解决专用身份、凭据受控交付、测试主体/银行卡资料、通知与支付隔离及清理;当前只报告缺口,不要求普通员工代测。真实银行导入仍由授权财务办理,不等于让财务代做软件回归。 + +先在已发布31ab9fbb同源代码上运行既有test-invoice-first-http.cjs:一次性本地Postgres/tmpfs、随机合成账号密码和密钥、独立临时附件目录,真实HTTP/JWT/浏览器,启用RUN_ISOLATED_DB_TEST、RUN_BROWSER_TEST、RUN_JOB_QUEUE、RUN_PAYMENT_EXPERIENCE_TEST、RUN_RETAINED_DELETE_TEST、RUN_HANDOFF_PROJECTION_TEST。当前源码无新运行时改动;前端重新构建后验证。 + +此阶段不读取生产凭据、不连接生产数据库、不处理真实单据;OCR强制mock提供方,支付无银行连接,通知只用现有脚本的本地合成路径。脚本finally仅清理本轮随机容器与临时目录。成功必须包含认证正反例、上传选票、退回重提、三步审批、独立付款复核/自审拒绝、制单、回单登记与待办消失;不把本地PASS当作公网真人角色验收、腾讯OCR验证或完整发布门禁通过。 + +执行顺序:构建 → 既有隔离脚本 → 核实际通过项及清理 → 明确剩余公网/业务主数据准入缺口。失败只归因本阶段,不解冻09-06的R1或批量修旧测试,不要求员工代测。 + +首次admission-flow退出1,付款浏览器超时,finally清理完成。现有异常正则漏了test-payment-experience,只显示外层420行;加入该模块名后诊断回跑仍失败,准确定位test-payment-experience.cjs:110(admission-diagnostic)。原因是旧桌面分支在actions卡片内部寻找已Portal到body的菜单项,而当前组件和既有前端规约均要求page级菜单角色。沿本阶段最小修复测试脚本:桌面/手机统一按page菜单项精确名称定位,并断言唯一、可见再点击;保留下载提示、无原因框、真实BIFF8、回到原单、进度和付款状态断言。不改运行时、不增加超时、不删除失败证据、不重建未变化前端。 + +admission-final继续到退回删除后退出1,定位test-reimbursement-retained-delete.cjs:163;同类遗漏:桌面DropdownMenuItem不是button,取消确认后菜单已关闭须重开。检查本轮三个关联脚本的菜单入口后,分别使用桌面menuitem/手机button、唯一性断言,桌面取消后重新打开菜单;删除后若有更多入口则展开,显式断言菜单项及按钮均不存在,避免关闭菜单导致负向假通过。旧日志名final不等于通过,最终以退出码及完整回执为准。 + +最终admission-complete退出0,PASS_REAL_DB_HTTP,27组场景完整完成,真实Postgres/密码登录/JWT/HTTP及桌面手机浏览器,覆盖上传去重/worker选票分类编辑、提交与退回重提、三步审批、自审拒绝、组批独立复核、首次/重复中文BIFF8下载及返回原单、合成回单登记/paid和待办消失、发票并发/故障回滚、留痕删除并发及历史只读、token撤销。容器和合成附件清理成功;前端admission-build退出0(含typecheck/build/PWA),无运行时代码变更,不要求重新部署。前三次失败回执保留;只有本次完整结果计为通过,不把27组场景写成27个Vitest测试或全项目门禁。 + +覆盖限制:此脚本的/api/me、导航、通知列表是辅助夹具,认证插件/登录与业务路由是真实代码;账号是合成employee/manager/ceo/finance,manager与ceo不同账号,不等于已验证公司实际“直接经理与CEO同一人”的全部路径。OCR为真实pg-boss/worker+mock提供方,本轮无腾讯调用;无银行连接、无公网真实账号认证或生产业务写入,不将合成paid算实际付款。已有frontend规约明确Portal菜单用page级角色,本次是脚本漏遵循,不重复新增同义经验条目。 + +下一步:先准备生产只读准入清单,核对现有专用测试身份、角色/经理映射及资料存在性,不复用员工密码、不读取银行明文或回填;在确认专用身份和窄范围授权后再执行公网合成闭环。真实组织角色组合、专用账号的生产写入/清理范围和禁用通知/支付外联需写清,不能把本次“继续”扩成生产账号创建授权。此次仅3份QA脚本及本任务记录未提交,提交另批;不自动重启服务或再次发布31ab9fbb。 + ## 2026-09-12 F1 付款创建纵深防御(接续已批准后续规划) +发布后回执:用户随后明确批准五文件提交推送,并授权当前752ec413本地备份/独立镜像补齐、31ab9fbb发布及自动验收。31ab9fbb已部署,existing退出0、备份合同刷新通过,前后pending=none;公网ready/106资源扫描通过,数据库未重启、环境文件未变。运行镜像付款路由/readiness及四份制单相关文件摘要与候选一致,纯合成内存BIFF8生成读回通过;未操作真实业务。证据artifacts/qa-layout/f1-release。下列未提交/未部署和待授权描述是历史阶段;本段不属于31ab9fbb候选树。123项按输入不变复用,不追加计数;本轮未独立review、未做生产删除/创建API或真实数据库并发验证。下一主线为首批准入认证与跨角色验收,不重复开发已发布防护。 + 复核七处来源读取后,本轮先封住新付款创建入口:候选列表和readiness/锁后创建共用的loadEntitySnapshots显式要求deleted_at为空。不得向所有查询机械添加过滤:既有批次详情、历史收款人兜底和费用明细属于凭证读取,保留现有授权读取。删除仍只允许draft/rejected且无活动批次,不扩展approved删除,不改审批/复核/付款执行状态机。 - [x] 新回归先失败:构造approved但带删除标记的防御性fixture,候选排除,readiness与锁后创建拒绝且无成功写入/通知;未删除正例和历史批次详情保持。 diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md index 7341437a..3c327d3a 100644 --- a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md @@ -1,5 +1,18 @@ # 报销状态空隙与财务处理视角 +## 2026-09-12 财务本人报销闭环(已批准本地实施) + +用户已批准本地实施和验证:财务本人报销由CEO一次审批,财务创建付款批次、CEO独立复核;提交/推送/部署及生产写入另批。复用当前任务。隔离验证已复现本人报销死结,不据此宣称真人单据已阻断。 + +以报销归属人持finance为触发条件,新快照统一为一个CEO审批步骤,即使存在第二位财务。财务代填普通员工单据不算财务本人报销。普通员工及仅CEO归属人保留原规则;归属人同时持finance与ceo仍必须由另一个合法CEO审批。 + +- 申请人范围同时包含归属人与提交人,代提不能成为自审旁路;多角色、委托不豁免自审限制。 +- 不摘角色、不加finance角色、不改付款复核权限,不把报销审批当作付款复核。 +- 财务本人报销不再重复经理/CEO/finance三步,这不是全局按审批人去重。付款复核仍是另外一次独立动作。 +- 无合法替代CEO时在提交前明确阻断,不生成明知无人可处理的新流程;不得返回成功后再等待管理员救单。 +- 仅适用于新提交及合法重提生成的新快照;不自动改写在途、已审批及历史单据。 +- 验收须覆盖普通员工、CEO本人、唯一财务本人、存在第二位财务、代提与多角色、停用/归档候选及无人可批;由执行端在合成环境验证,不要求员工代测。 + ## Goal ### 2026-09-12 最新备注方案(已批准修改、测试后部署) diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json index 730f3311..c8d84156 100644 --- a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json @@ -2,7 +2,7 @@ "id": "reimbursement-state-gap-finance-view", "name": "reimbursement-state-gap-finance-view", "title": "报销状态空隙与财务处理视角", - "description": "修复报销状态空隙、草稿读取和退回留痕删除;追加付款下一步与详情侧栏一致性,保持原审批和付款写入边界。", + "description": "修复报销状态空隙、草稿读取、退回留痕删除及付款详情一致性;追加已批准的财务本人报销CEO一次审批,保留付款独立复核与生产写入边界。", "status": "in_progress", "dev_type": null, "scope": "server,client,tests,spec", @@ -21,6 +21,6 @@ "children": [], "parent": null, "relatedFiles": [], - "notes": "09-12当前生产752ec413:备注自解释、此前布局与门户隔离均已发布,公网和运行镜像合成BIFF8验收通过,详见implement顶部及purpose-remark-release回执;旧未发布表述仅属历史阶段。接续已批准后续规划完成F1付款创建来源显式删除过滤,本地4文件123/123通过、Prisma/语法通过;无真实业务写入,未提交推送或部署本增量,独立只读review待外部执行。现有付款批次历史读取保留,不开放approved删除。后续为本增量复核与授权发布、首批准入认证及跨角色验收;银行导入、完整恢复仍未闭合,不自动归档。", + "notes": "09-12上轮确认生产及本地基线31ab9fbb,付款创建删除过滤已发布。财务本人报销CEO一次审批、付款独立复核及转交/委托资格约束本地完成,519项不同单测、最终41组隔离HTTP/桌面手机场景通过,独立只读复核两项发现闭合。用户已授权本候选提交/推送及只读发布预检,实际执行状态见Git及artifacts/qa-layout/finance-owner-release回执,部署/生产退路补写/清理另批。旧镜像缺新CEO守卫,已有新快照后不能直接退镜像继续处理。命令、覆盖限制和中间失败见implement;银行导入、完整恢复等其他准入缺口仍未闭合,不自动归档。", "meta": {} } diff --git a/client/src/api/errorReasons.ts b/client/src/api/errorReasons.ts index 1f3f5d87..1f223ba9 100644 --- a/client/src/api/errorReasons.ts +++ b/client/src/api/errorReasons.ts @@ -1,4 +1,6 @@ export const ERROR_REASON_MESSAGES: Record = { + "reimbursement.finance_owner_ceo_unavailable": "财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。", + "reimbursement.finance_owner_ceo_required": "财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。", "reimbursement.invoice_prefill_incomplete": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。", "reimbursement.items_description_required": "请补充明细的支出说明后再提交。", "reimbursement.material_preview_limit": "报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。", diff --git a/docs/需求-统一审批中心规约.md b/docs/需求-统一审批中心规约.md index 5205729b..2cc7718d 100644 --- a/docs/需求-统一审批中心规约.md +++ b/docs/需求-统一审批中心规约.md @@ -127,6 +127,16 @@ OAFLOW1 后,OA 主办的报销审批规则增加 `ownerSelfFallbackRole`。当 --- +### 2.5 财务本人报销与独立付款复核(2026-09-12 已批准,待发布) + +财务本人报销的新提交及重提,由非申请人的在职总经理一次审批,不再重复经理、总经理、财务三个审批步骤。以报销归属人持有明确的 `finance` 角色识别,不硬编码姓名;财务代填其他员工的单据不属于此分支。存在第二位财务也不改变本规则。 + +- 排除归属人与提交人自审;归属人同时持有 `finance+ceo` 时仍须由另一位合法总经理审批,不能走本人审批或本人财务兜底。 +- 没有合法总经理时,预检和提交明确阻断,预算预检开关关闭也不能绕过。 +- 该单步任务只能转交给另一位合法总经理。审批时实际操作者及委托方仍须具备有效总经理资格,不因指定人分配、转交或委托免检。 +- 报销通过后仍由财务创建付款批次,包括财务自己的报销;非制单人的合格上级另行复核付款,财务再下载制单、通过网银付款并登记回单。报销审批不等于付款复核,下载成功不等于付款完成。 +- 普通员工原审批链及仅持CEO角色的负责人财务兜底不变;在途与历史快照不自动改写。此规则属于当前09-04任务的新候选,不宣称已部署。 + ## 3. 入口与任务视图 ### 3.1 入口 diff --git a/scripts/qa/test-invoice-first-http.cjs b/scripts/qa/test-invoice-first-http.cjs index 709be358..1471545d 100644 --- a/scripts/qa/test-invoice-first-http.cjs +++ b/scripts/qa/test-invoice-first-http.cjs @@ -13,6 +13,9 @@ const docker = (args, input) => execFileSync("docker", args, { input, encoding: async function main() { assert.equal(process.env.RUN_ISOLATED_DB_TEST, "1", "Explicit disposable database opt-in required"); + const managerIsCeo = process.env.RUN_MANAGER_IS_CEO_TEST === "1"; + const ownerApprovalTest = process.env.RUN_OWNER_APPROVAL_TEST === "1"; + assert.ok(!ownerApprovalTest || managerIsCeo, "Owner approval checks require the small-team fixture"); const password = randomUUID(); let container; let prisma; @@ -22,6 +25,7 @@ async function main() { let browser; let preview; const passed = []; + const businessBlockers = []; try { container = docker(["run", "-d", "--rm", "--pull=never", "--tmpfs", "/var/lib/postgresql/data", "-p", "127.0.0.1::5432", "-e", `POSTGRES_PASSWORD=${password}`, "postgres:16-alpine"]); let ready = false; @@ -45,13 +49,12 @@ async function main() { bank_account_encrypted: "synthetic-not-account", bank_name: "synthetic", password_hash: "not-a-login-hash", } }); const expenseType = await prisma.expenseType.create({ data: { code: "synthetic_http", name: "synthetic", category: "office", sort_order: 1 } }); - const manager = await prisma.employee.create({ data: { + let manager = managerIsCeo ? null : await prisma.employee.create({ data: { name: "synthetic-manager", email: "invoice-manager@example.invalid", employee_no: "synthetic-manager", department_id: department.id, title: "synthetic", hire_date: new Date("2026-09-09"), bank_account_encrypted: "synthetic-not-account", bank_name: "synthetic", password_hash: "not-a-login-hash", } }); - await prisma.employee.update({ where: { id: employee.id }, data: { direct_manager_id: manager.id } }); - const approvers = { manager }; + const approvers = manager ? { manager } : {}; for (const role of ["ceo", "finance"]) { approvers[role] = await prisma.employee.create({ data: { name: `synthetic-${role}`, email: `invoice-${role}@example.invalid`, employee_no: `synthetic-${role}`, @@ -62,12 +65,24 @@ async function main() { for (const [role, actor] of Object.entries(approvers)) { await prisma.employeeRole.create({ data: { employee_id: actor.id, role_code: role } }); } + if (managerIsCeo) { + manager = approvers.ceo; + approvers.manager = manager; + for (const actor of [approvers.ceo, approvers.finance]) { + await prisma.employeeRole.create({ data: { employee_id: actor.id, role_code: "employee" } }); + } + await prisma.employee.update({ where: { id: approvers.finance.id }, data: { direct_manager_id: manager.id } }); + assert.equal(await prisma.employeeRole.count({ where: { role_code: "manager" } }), 0); + assert.equal(await prisma.employee.count(), 3); + passed.push("small team: employee and finance report to the same CEO; no manager role or separate manager account"); + } + await prisma.employee.update({ where: { id: employee.id }, data: { direct_manager_id: manager.id } }); await prisma.employeeRole.create({ data: { employee_id: employee.id, role_code: "employee" } }); const loginPassword = `Synthetic!${randomUUID()}`; const passwordHash = await serverRequire("./src/utils/crypto").hashPassword(loginPassword); await prisma.employee.updateMany({ data: { password_hash: passwordHash, must_change_password: false } }); await prisma.workflowDefinition.create({ data: { name: "synthetic-reimbursement", entity_type: "reimbursement", definition_json: { steps: [] }, version: 1 } }); - await prisma.approvalRule.create({ data: { name: "synthetic-three-step", min_amount: 0, level1_role: "manager", level2_role: "ceo", level3_role: "finance", priority: 1 } }); + const approvalRule = await prisma.approvalRule.create({ data: { name: "synthetic-three-step", min_amount: 0, level1_role: "manager", level2_role: "ceo", level3_role: "finance", priority: 1 } }); await prisma.featureFlag.create({ data: { flag_key: "reimbursement_invoice_guard_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] } }); const makeForm = () => prisma.reimbursementForm.create({ data: { form_number: `SYN-${randomUUID()}`, owner_id: employee.id, submitter_id: employee.id, department_id: department.id, reimbursement_date: new Date("2026-09-09") } }); const makeInvoice = (overrides = {}) => prisma.invoicePool.create({ data: { @@ -110,7 +125,7 @@ async function main() { } const origin = await app.listen({ host: "127.0.0.1", port: 0 }); const tokens = new Map(); - for (const actor of [employee, ...Object.values(approvers)]) { + for (const actor of new Map([employee, ...Object.values(approvers)].map((actor) => [actor.id, actor])).values()) { const login = await fetch(`${origin}/api/auth/login`, { method: "POST", headers: { "content-type": "application/json", "x-device-fingerprint": `synthetic-${actor.id}` }, body: JSON.stringify({ identifier: actor.email, password: loginPassword }) }); assert.equal(login.status, 200, "password login"); tokens.set(actor.id, (await login.json()).data.accessToken); @@ -371,6 +386,7 @@ async function main() { assert.equal(resubmit.status, 200); const newWorkflowId = resubmit.body.data.workflowInstanceId; assert.notEqual(newWorkflowId, workflow.id); + const approvalActors = []; for (const role of ["manager", "ceo", "finance"]) { const task = await prisma.workflowTask.findFirst({ where: { instance_id: newWorkflowId, status: "pending" } }); assert.ok(task, `pending task for ${role}`); @@ -410,12 +426,17 @@ async function main() { } const approval = await post(`/workflow/tasks/${task.id}/approve`, {}, approvers[role].id); assert.equal(approval.status, 200, `${role} approval: ${approval.status} ${approval.body.error?.code ?? ""}`); + approvalActors.push(approvers[role].id); assert.equal((await post(`/workflow/tasks/${task.id}/approve`, {}, approvers[role].id)).status, 409); } assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "approved"); assert.equal(await prisma.workflowTask.count({ where: { instance_id: newWorkflowId, status: "pending" } }), 0); 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 (managerIsCeo) { + assert.deepEqual(approvalActors, [approvers.ceo.id, approvers.ceo.id, approvers.finance.id]); + passed.push("small team: same CEO completes separate manager and CEO steps; no automatic person-level deduplication"); + } 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, handoffCheck }); } @@ -496,10 +517,207 @@ async function main() { root, browser, preview, loginPassword, }); } + if (ownerApprovalTest) { + await prisma.approvalRule.update({ where: { id: approvalRule.id }, data: { owner_self_fallback_role: "finance" } }); + const prepareOwnerForm = async (owner) => { + const ownedForm = await makeForm(); + const ownedInvoice = await makeInvoice(); + assert.equal((await bind(ownedInvoice, ownedForm)).status, 200); + const item = await prisma.reimbursementItem.findFirst({ where: { reimbursement_id: ownedForm.id } }); + await prisma.reimbursementItem.update({ where: { id: item.id }, data: { expense_type_id: expenseType.id, description: "synthetic owner approval" } }); + await prisma.invoicePool.update({ where: { id: ownedInvoice.id }, data: { owner_employee_id: owner.id, uploaded_by_employee_id: owner.id } }); + await prisma.reimbursementForm.update({ where: { id: ownedForm.id }, data: { owner_id: owner.id, submitter_id: owner.id } }); + return ownedForm; + }; + const ceoForm = await prepareOwnerForm(approvers.ceo); + const ceoSubmitted = await post(`/reimbursements/${ceoForm.id}/submit`, {}, approvers.ceo.id); + assert.equal(ceoSubmitted.status, 200, "CEO owner submits with explicit finance fallback"); + const instanceId = ceoSubmitted.body.data.workflowInstanceId; + const tasks = await prisma.workflowTask.findMany({ where: { instance_id: instanceId } }); + assert.equal(tasks.length, 1, "CEO own chain collapses to one finance step"); + assert.equal(tasks[0].assignee_role, "finance"); + assert.equal((await post(`/workflow/tasks/${tasks[0].id}/approve`, {}, approvers.ceo.id)).status, 403); + assert.equal((await post(`/workflow/tasks/${tasks[0].id}/approve`, {}, approvers.finance.id)).status, 200); + assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: ceoForm.id } })).status, "approved"); + passed.push("CEO owner with finance fallback: exactly one finance task, owner rejected, finance approves"); + const financeForm = await prepareOwnerForm(approvers.finance); + const financeSubmitted = await post(`/reimbursements/${financeForm.id}/submit`, {}, approvers.finance.id); + assert.equal(financeSubmitted.status, 200, "finance owner submits for one CEO approval"); + const originalFinanceInstanceId = financeSubmitted.body.data.workflowInstanceId; + const returnedTask = await prisma.workflowTask.findFirst({ where: { instance_id: originalFinanceInstanceId, status: "pending" } }); + assert.equal(returnedTask.assignee_role, "ceo"); + assert.equal((await post(`/workflow/tasks/${returnedTask.id}/return`, { comment: "synthetic finance correction" }, approvers.ceo.id)).status, 200); + const financeResubmitted = await post(`/reimbursements/${financeForm.id}/submit`, {}, approvers.finance.id); + assert.equal(financeResubmitted.status, 200); + const financeInstanceId = financeResubmitted.body.data.workflowInstanceId; + assert.notEqual(financeInstanceId, originalFinanceInstanceId); + assert.equal(await prisma.workflowTask.count({ where: { instance_id: originalFinanceInstanceId } }), 1); + const selfTask = await prisma.workflowTask.findFirst({ where: { instance_id: financeInstanceId, status: "pending" } }); + assert.ok(selfTask); + assert.equal(selfTask.assignee_role, "ceo"); + assert.equal(await prisma.workflowTask.count({ where: { instance_id: financeInstanceId } }), 1); + const selfApproval = await post(`/workflow/tasks/${selfTask.id}/approve`, {}, approvers.finance.id); + assert.equal(selfApproval.status, 403); + assert.equal((await post(`/workflow/tasks/${selfTask.id}/approve`, {}, approvers.ceo.id)).status, 200); + assert.equal((await post(`/workflow/tasks/${selfTask.id}/approve`, {}, approvers.ceo.id)).status, 409); + assert.equal(await prisma.workflowTask.count({ where: { instance_id: financeInstanceId, status: "pending" } }), 0); + assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: financeForm.id } })).status, "approved"); + passed.push("finance owner: return/resubmit preserves history; one CEO approval completes reimbursement, owner denied, replay denied, no finance task"); + if (process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1") { + await require("./test-payment-experience.cjs")({ + app, prisma, employee, approvers, form: financeForm, payee: approvers.finance, + fixtureCode: "SYN-PAY-FINANCE", post, origin, tokens, serverRequire, passed, + browser, preview, loginPassword, handoffCheck, + }); + passed.push("finance owner payment: finance creates own batch, cannot review, CEO separately reviews, synthetic export/receipt only"); + } + for (const unavailable of ["inactive", "archived", "submitter", "missing_role"]) { + const blockedForm = await prepareOwnerForm(approvers.finance); + try { + if (unavailable === "inactive") await prisma.employee.update({ where: { id: approvers.ceo.id }, data: { status: "archived" } }); + if (unavailable === "archived") await prisma.employee.update({ where: { id: approvers.ceo.id }, data: { archived_at: new Date() } }); + if (unavailable === "submitter") await prisma.reimbursementForm.update({ where: { id: blockedForm.id }, data: { submitter_id: approvers.ceo.id } }); + if (unavailable === "missing_role") await prisma.employeeRole.deleteMany({ where: { employee_id: approvers.ceo.id, role_code: "ceo" } }); + for (const budgetEnabled of [false, true]) { + await prisma.featureFlag.upsert({ + where: { flag_key: "budget_precheck_v1" }, + create: { flag_key: "budget_precheck_v1", override_mode: budgetEnabled ? "force_on" : "force_off", rollout_type: "global", department_ids: [] }, + update: { override_mode: budgetEnabled ? "force_on" : "force_off" }, + }); + const precheck = await post(`/reimbursements/${blockedForm.id}/precheck`, {}, approvers.finance.id); + assert.equal(precheck.status, 200); + assert.equal(precheck.body.data.decision, "block"); + assert.ok(precheck.body.data.blockingReasons.some((reason) => reason.code === "finance_owner_ceo_unavailable")); + assert.equal((await post(`/reimbursements/${blockedForm.id}/submit`, {}, approvers.finance.id)).status, 409); + assert.equal(await prisma.workflowInstance.count({ where: { reimbursement_id: blockedForm.id } }), 0); + assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: blockedForm.id } })).status, "draft"); + } + } finally { + await prisma.employee.update({ where: { id: approvers.ceo.id }, data: { status: "active", archived_at: null } }); + if (unavailable === "missing_role") await prisma.employeeRole.create({ data: { employee_id: approvers.ceo.id, role_code: "ceo" } }); + await prisma.featureFlag.update({ where: { flag_key: "budget_precheck_v1" }, data: { override_mode: "force_off" } }); + } + } + passed.push("finance owner: inactive/archived/submitter/missing-role CEO blocks precheck and submission without pending workflow"); + for (const additionalRole of ["second_finance", "owner_ceo"]) { + const actorId = additionalRole === "second_finance" ? employee.id : approvers.finance.id; + const roleCode = additionalRole === "second_finance" ? "finance" : "ceo"; + await prisma.employeeRole.create({ data: { employee_id: actorId, role_code: roleCode } }); + try { + const extraForm = await prepareOwnerForm(approvers.finance); + const submitted = await post(`/reimbursements/${extraForm.id}/submit`, {}, approvers.finance.id); + assert.equal(submitted.status, 200); + const extraTasks = await prisma.workflowTask.findMany({ where: { instance_id: submitted.body.data.workflowInstanceId } }); + assert.equal(extraTasks.length, 1); + assert.equal(extraTasks[0].assignee_role, "ceo"); + if (additionalRole === "owner_ceo") { + const denied = await post(`/workflow/tasks/${extraTasks[0].id}/approve`, {}, approvers.finance.id); + assert.equal(denied.status, 409); + assert.equal(denied.body.error.details.workflow, "self_approval_not_allowed"); + } else { + assert.equal((await post(`/workflow/tasks/${extraTasks[0].id}/approve`, {}, employee.id)).status, 403); + } + assert.equal((await post(`/workflow/tasks/${extraTasks[0].id}/approve`, {}, approvers.ceo.id)).status, 200); + assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: extraForm.id } })).status, "approved"); + } finally { + await prisma.employeeRole.deleteMany({ where: { employee_id: actorId, role_code: roleCode } }); + } + } + passed.push("finance owner: second finance does not change CEO-only rule; finance+CEO applicant still denied self-approval"); + if (browser && preview) { + const { expect } = createRequire(path.join(root, "client/package.json"))("@playwright/test"); + const uiOrigin = `http://127.0.0.1:${preview.httpServer.address().port}`; + for (const mobile of [false, true]) { + const browserForm = await prepareOwnerForm(approvers.finance); + for (const actor of [approvers.finance, approvers.ceo]) { + const context = await browser.newContext({ serviceWorkers: "block", viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 } }); + try { + const fingerprint = `synthetic-owner-${mobile}-${actor.id}`; + await context.addCookies([{ name: "hyb_oa_device_fingerprint", value: fingerprint, url: uiOrigin }]); + const login = await context.request.post(`${uiOrigin}/api/auth/login`, { + headers: { "x-device-fingerprint": fingerprint }, + data: { identifier: actor.email, password: loginPassword }, + }); + assert.equal(login.status(), 200); + const page = await context.newPage(); + if (actor.id === approvers.finance.id) { + await page.goto(`${uiOrigin}${mobile ? "/mobile" : ""}/reimbursements/${browserForm.id}`); + const response = page.waitForResponse((result) => result.url().endsWith(`/reimbursements/${browserForm.id}/submit`) && result.request().method() === "POST"); + const precheckResponse = page.waitForResponse((result) => result.url().endsWith(`/reimbursements/${browserForm.id}/precheck`) && result.request().method() === "POST"); + await page.getByTestId("reimbursement-submit").click(); + const precheck = await (await precheckResponse).json(); + assert.notEqual(precheck.data.decision, "block"); + if (precheck.data.decision !== "pass") { + const confirm = page.getByRole("button", { name: "确认提交", exact: true }); + await expect(confirm).toBeVisible(); + await confirm.click(); + } + assert.equal((await response).status(), 200); + } else { + const workflow = await prisma.workflowInstance.findFirst({ where: { reimbursement_id: browserForm.id, current_status: "pending" } }); + const task = await prisma.workflowTask.findFirst({ where: { instance_id: workflow.id, status: "pending" } }); + assert.equal(task.assignee_role, "ceo"); + await page.goto(`${uiOrigin}${mobile ? "/mobile/approvals" : "/workflow/tasks"}/${task.id}`); + await page.getByTestId(mobile ? "mobile-approvals-approve" : "workflow-task-approve").click(); + await expect(page.getByTestId("workflow-action-confirm-dialog")).toBeVisible(); + const response = page.waitForResponse((result) => result.url().endsWith(`/workflow/tasks/${task.id}/approve`) && result.request().method() === "POST"); + await page.getByTestId("workflow-action-confirm-submit").click(); + assert.equal((await response).status(), 200); + assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: browserForm.id } })).status, "approved"); + assert.equal(await prisma.workflowTask.count({ where: { instance_id: workflow.id } }), 1); + } + } finally { await context.close(); } + } + } + passed.push("finance owner: real desktop/mobile finance submits after precheck, CEO approves once via UI and no further task is created"); + } + const transferForm = await prepareOwnerForm(approvers.finance); + const transferSubmitted = await post(`/reimbursements/${transferForm.id}/submit`, {}, approvers.finance.id); + assert.equal(transferSubmitted.status, 200); + const transferTask = await prisma.workflowTask.findFirst({ where: { instance_id: transferSubmitted.body.data.workflowInstanceId, status: "pending" } }); + const assertNoPendingActions = async (actor) => { + const response = await fetch(`${origin}/api/reimbursements?scope=all&pageSize=100`, { + headers: { authorization: `Bearer ${tokens.get(actor.id)}` }, signal: AbortSignal.timeout(15000), + }); + assert.equal(response.status, 200); + const source = (await response.json()).data.items.find((item) => item.id === transferForm.id); + assert.ok(source); + assert.equal(source.pendingWorkflow, null, "non-CEO has no reimbursement list approval prompt"); + }; + await prisma.employeeRole.create({ data: { employee_id: employee.id, role_code: "finance" } }); + const transferBody = { toApproverId: employee.id, reason: "synthetic transfer safety validation" }; + const deniedTransfer = await post(`/workflow/tasks/${transferTask.id}/transfer`, transferBody, approvers.ceo.id); + assert.equal(deniedTransfer.status, 403); + assert.equal(deniedTransfer.body.error.details.reasonCode, "reimbursement.finance_owner_ceo_required"); + assert.equal((await prisma.workflowTask.findUnique({ where: { id: transferTask.id } })).assignee_role, "ceo"); + await prisma.employeeRole.create({ data: { employee_id: employee.id, role_code: "ceo" } }); + assert.equal((await post(`/workflow/tasks/${transferTask.id}/transfer`, transferBody, approvers.ceo.id)).status, 200); + await prisma.employeeRole.deleteMany({ where: { employee_id: employee.id, role_code: "ceo" } }); + await assertNoPendingActions(employee); + assert.equal((await post(`/workflow/tasks/${transferTask.id}/approve`, {}, employee.id)).status, 403); + const delegatedGrant = await prisma.delegationGrant.create({ data: { + principal_employee_id: employee.id, delegate_employee_id: approvers.ceo.id, scope: "all", + valid_from: new Date(Date.now() - 60000), + } }); + assert.equal((await post(`/workflow/tasks/${transferTask.id}/approve`, {}, approvers.ceo.id)).status, 403); + await prisma.employeeRole.create({ data: { employee_id: employee.id, role_code: "ceo" } }); + await prisma.employeeRole.create({ data: { employee_id: approvers.ceo.id, role_code: "finance" } }); + await prisma.employeeRole.deleteMany({ where: { employee_id: approvers.ceo.id, role_code: "ceo" } }); + await assertNoPendingActions(approvers.ceo); + assert.equal((await post(`/workflow/tasks/${transferTask.id}/approve`, {}, approvers.ceo.id)).status, 403); + await prisma.employeeRole.create({ data: { employee_id: approvers.ceo.id, role_code: "ceo" } }); + await prisma.employeeRole.deleteMany({ where: { employee_id: approvers.ceo.id, role_code: "finance" } }); + assert.equal((await post(`/workflow/tasks/${transferTask.id}/approve`, {}, approvers.ceo.id)).status, 200); + assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: transferForm.id } })).status, "approved"); + await prisma.delegationGrant.delete({ where: { id: delegatedGrant.id } }); + await prisma.employeeRole.deleteMany({ where: { employee_id: employee.id, role_code: { in: ["finance", "ceo"] } } }); + passed.push("finance owner: transfer only to CEO, stale assignee or principal and non-CEO delegate rejected; eligible CEO delegation approves"); + } 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_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)); + console.log(JSON.stringify({ status: businessBlockers.length ? "BLOCKED_OWNER_APPROVAL" : "PASS_REAL_DB_HTTP", passed, businessBlockers, 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)); + if (businessBlockers.length) process.exitCode = 1; } 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"); }); @@ -517,4 +735,4 @@ async function main() { } } -main().catch((error) => { console.error("Isolated HTTP verification failed:", error.code ?? error.name, error.code === "ERR_ASSERTION" ? error.message : "", error.stack?.match(/test-(?:invoice-first-http|reimbursement-retained-delete)\.cjs:\d+:\d+/)?.[0] ?? ""); process.exitCode = 1; }); +main().catch((error) => { console.error("Isolated HTTP verification failed:", error.code ?? error.name, error.code === "ERR_ASSERTION" ? error.message : "", error.stack?.match(/test-(?:invoice-first-http|reimbursement-retained-delete|payment-experience)\.cjs:\d+:\d+/)?.[0] ?? ""); process.exitCode = 1; }); diff --git a/scripts/qa/test-payment-experience.cjs b/scripts/qa/test-payment-experience.cjs index 89dd967d..77bd620b 100644 --- a/scripts/qa/test-payment-experience.cjs +++ b/scripts/qa/test-payment-experience.cjs @@ -6,15 +6,16 @@ const path = require("node:path"); module.exports = async function verifyPaymentExperience({ app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword, handoffCheck, + payee = employee, fixtureCode = "SYN-PAY", }) { - app.config.fieldEncryptionKey = randomBytes(32).toString("base64"); + app.config.fieldEncryptionKey ??= randomBytes(32).toString("base64"); app.config.paymentBatchEnabled = true; const { encryptStringOrThrow } = serverRequire("./src/services/crypto"); - const company = await prisma.legalEntity.create({ data: { code: "SYN-PAY", name: "合成测试主体" } }); + const company = await prisma.legalEntity.create({ data: { code: fixtureCode, name: "合成测试主体" } }); const bank = await prisma.bankInstitution.upsert({ where: { code: "CCB" }, create: { code: "CCB", name: "中国建设银行" }, update: {}, }); - await prisma.employee.update({ where: { id: employee.id }, data: { + await prisma.employee.update({ where: { id: payee.id }, data: { legal_entity_id: company.id, bank_institution_id: bank.id, bank_name: bank.name, bank_account_encrypted: encryptStringOrThrow(app.config, `6222${"0".repeat(8)}5678`), } }); @@ -22,7 +23,11 @@ module.exports = async function verifyPaymentExperience({ legal_entity_id: company.id, bank_institution_id: bank.id, account_name: "合成测试主体", 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.upsert({ + where: { flag_key: "payment_batch_v1" }, + create: { flag_key: "payment_batch_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] }, + update: {}, + }); await handoffCheck?.(approvers.finance, "reimbursement_batch", form.id, 1); await handoffCheck?.(approvers.ceo, "reimbursement_batch", form.id, 0); const readProgress = async (actor) => { @@ -73,7 +78,15 @@ module.exports = async function verifyPaymentExperience({ assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_and_receipt").detail, "已生成银行制单,等待付款完成并上传回单,非审批环节"); } - assert.equal((await readProgress(employee)).stages.some((stage) => stage.targetHref), false); + if (payee.id === employee.id) { + assert.equal((await readProgress(employee)).stages.some((stage) => stage.targetHref), false); + } else { + const unrelatedRead = await fetch(`${origin}/api/reimbursements/${form.id}`, { + headers: { authorization: `Bearer ${tokens.get(employee.id)}` }, + signal: AbortSignal.timeout(15000), + }); + assert.equal(unrelatedRead.status, 403); + } const audits = await prisma.auditLog.findMany({ where: { target_id: batch.id, action: { in: ["PAYMENT_ORDER_EXPORTED", "PAYMENT_ORDER_REEXPORTED"] } } }); assert.equal(audits.length, 2); for (const audit of audits) { @@ -106,8 +119,10 @@ module.exports = async function verifyPaymentExperience({ const actions = page.getByTestId(mobile ? "payment-batch-mobile-actions" : "payment-batch-action-panel"); await expect(actions.getByRole("button", { name: "上传银行回单" })).toBeVisible(); await actions.getByText("更多", { exact: true }).click(); - if (mobile) await page.getByRole("menuitem", { name: "重新下载制单" }).click(); - else await actions.getByTestId("payment-batch-action-redownloadOrder").click(); + const redownload = page.getByRole("menuitem", { name: "重新下载制单", exact: true }); + await expect(redownload).toHaveCount(1); + await expect(redownload).toBeVisible(); + await redownload.click(); const dialog = page.getByRole("dialog"); await expect(dialog).toContainText("请勿重复提交"); await expect(dialog.getByRole("textbox")).toHaveCount(0); diff --git a/scripts/qa/test-reimbursement-retained-delete.cjs b/scripts/qa/test-reimbursement-retained-delete.cjs index 146b01d6..4be0e845 100644 --- a/scripts/qa/test-reimbursement-retained-delete.cjs +++ b/scripts/qa/test-reimbursement-retained-delete.cjs @@ -160,17 +160,22 @@ module.exports = async function verifyRetainedDeletion(context) { await page.goto(target); if (!mobile) await page.getByTestId("reimbursement-more-toggle").click(); else await page.getByTestId("mobile-reimbursement-more").click(); - await page.getByRole("button", { name: "删除报销单", exact: true }).click(); + const deleteAction = page.getByRole(mobile ? "button" : "menuitem", { name: "删除报销单", exact: true }); + await expect(deleteAction).toHaveCount(1); + await deleteAction.click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText("审批记录仍保留"); await page.getByTestId("action-confirm-cancel").click(); assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: fixture.form.id } })).deleted_at, null); - await page.getByRole("button", { name: "删除报销单", exact: true }).click(); + if (!mobile) await page.getByTestId("reimbursement-more-toggle").click(); + await deleteAction.click(); await page.getByTestId("action-confirm-submit").click(); await expect.poll(async () => Boolean((await prisma.reimbursementForm.findUnique({ where: { id: fixture.form.id } })).deleted_at)).toBe(true); await page.goto(target); await expect(page.getByText("已删除", { exact: true }).first()).toBeVisible(); await expect(page.getByTestId("reimbursement-submit")).toHaveCount(0); await expect(page.getByTestId("reimbursement-invoice-add")).toHaveCount(0); + if (!mobile && await page.getByTestId("reimbursement-more-toggle").count()) await page.getByTestId("reimbursement-more-toggle").click(); + await expect(page.getByRole("menuitem", { name: "删除报销单", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "删除报销单", exact: true })).toHaveCount(0); await verify(fixture, "reject"); } finally { await browserContext.close(); } diff --git a/server/src/constants/errorReasons.js b/server/src/constants/errorReasons.js index 70b1a655..2ab2c17e 100644 --- a/server/src/constants/errorReasons.js +++ b/server/src/constants/errorReasons.js @@ -1,4 +1,6 @@ const ERROR_REASONS = [ + {"code":"reimbursement.finance_owner_ceo_unavailable","domain":"reimbursement","audience":"user","defaultMessage":"财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。","recovery":"follow_guidance"}, + {"code":"reimbursement.finance_owner_ceo_required","domain":"reimbursement","audience":"user","defaultMessage":"财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。","recovery":"follow_guidance"}, {"code":"reimbursement.invoice_prefill_incomplete","domain":"reimbursement","audience":"user","defaultMessage":"票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。","recovery":"follow_guidance"}, {"code":"reimbursement.items_description_required","domain":"reimbursement","audience":"user","defaultMessage":"请补充明细的支出说明后再提交。","recovery":"follow_guidance"}, {"code":"reimbursement.material_preview_limit","domain":"reimbursement","audience":"user","defaultMessage":"报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。","recovery":"view_individually"}, diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 8d0df72b..4f5275a7 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -66,6 +66,7 @@ const { } = require("../services/businessAttachmentGovernance"); const { buildApprovalWorkflowSnapshot, + FINANCE_OWNER_APPROVAL_STEP_ID, getWorkflowStepsSnapshot, resolveActionableStep, resolveWorkflowStepDisplayName, @@ -955,6 +956,10 @@ function buildPendingWorkflowSummary(item, request, delegatedPrincipalIds) { if (!pendingInstance || !pendingTask) { return null; } + if (pendingInstance.current_step_id === FINANCE_OWNER_APPROVAL_STEP_ID + && !(request.employee.roleCodes ?? []).includes("ceo")) { + return null; + } if (!isPendingWorkflowTaskActionable(request, pendingTask, delegatedPrincipalIds)) { return null; @@ -4349,6 +4354,8 @@ async function reimbursementRoutes(fastify) { tx, approvalRule, ownerId: reimbursement.owner_id, + submitterId: reimbursement.submitter_id, + entityType: "reimbursement", logger: fastify.log, }); const { actionable, skipped } = await resolveActionableStep({ diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index 3690d269..91fbbd09 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -1,10 +1,12 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const { hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions"); const { BUSINESS_APPROVAL_ROLE_CODES, + FINANCE_OWNER_APPROVAL_STEP_ID, getWorkflowStepsSnapshot, isBusinessApprovalRoleCode, resolveActionableStep, @@ -685,6 +687,11 @@ function listAllowedActionsForTask(request, task, actionable) { if (task.status !== "pending" || !actionable) { return []; } + if (task.workflow_instance?.entity_type === "reimbursement" + && task.step_id === FINANCE_OWNER_APPROVAL_STEP_ID + && !request.employee.roleCodes.includes("ceo")) { + return []; + } const actions = []; const approvePermission = resolveEntityPermission(task.workflow_instance?.entity_type, "approve"); @@ -1066,10 +1073,32 @@ function ensureNoSelfApproval(actorIds, applicantIds) { }); } +async function ensureFinanceOwnerCeoActors(tx, workflowInstance, actorIds) { + if (workflowInstance.entity_type !== "reimbursement" + || workflowInstance.current_step_id !== FINANCE_OWNER_APPROVAL_STEP_ID) { + return; + } + const eligible = await tx.employeeRole.findMany({ + where: { + employee_id: { in: actorIds }, + role_code: "ceo", + employee: { status: "active", archived_at: null }, + }, + select: { employee_id: true }, + }); + if (actorIds.some((actorId) => !eligible.some((holder) => holder.employee_id === actorId))) { + throw buildBusinessValidationError("reimbursement.finance_owner_ceo_required", { + workflow: "finance_owner_ceo_required", + }, 403); + } +} + async function ensureWorkflowApprovalAllowed(tx, workflowInstance, request, assignment) { ensureBusinessApproveRole(request.employee, workflowInstance.entity_type); const applicantIds = await loadWorkflowApplicantIds(tx, workflowInstance); - ensureNoSelfApproval(uniq([request.employee.id, assignment?.principalId]), applicantIds); + const actorIds = uniq([request.employee.id, assignment?.principalId]); + ensureNoSelfApproval(actorIds, applicantIds); + await ensureFinanceOwnerCeoActors(tx, workflowInstance, actorIds); return applicantIds; } @@ -3197,6 +3226,7 @@ async function workflowRoutes(fastify) { const transferCount = await ensureTransferChainNotExceeded(tx, taskId); await ensureTransferTargetEligible(tx, toApproverId, approvePermission); ensureNoSelfApproval([toApproverId], applicantIds); + await ensureFinanceOwnerCeoActors(tx, workflowInstance, [toApproverId]); if (toApproverId === request.employee.id) { throw new AppError({ diff --git a/server/src/services/feeControl.js b/server/src/services/feeControl.js index 069edfdc..a04fdb57 100644 --- a/server/src/services/feeControl.js +++ b/server/src/services/feeControl.js @@ -4,6 +4,7 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags"); const { currentDateString, parseDateOnlyString } = require("../utils/date"); const { buildApprovalWorkflowSnapshot, + buildFinanceOwnerApprovalSnapshot, parseWorkflowSteps, resolveActionableStep, } = require("../utils/workflow"); @@ -92,6 +93,8 @@ async function buildReimbursementOperationalChecks(tx, { reimbursementId, config where: { id: reimbursementId }, select: { id: true, + owner_id: true, + submitter_id: true, items: { orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { @@ -106,6 +109,22 @@ async function buildReimbursementOperationalChecks(tx, { reimbursementId, config throw buildNotFoundError(); } + const blockingReasons = []; + try { + await buildFinanceOwnerApprovalSnapshot({ + tx, ownerId: reimbursement.owner_id, submitterId: reimbursement.submitter_id, + }); + } catch (error) { + if (!(error instanceof AppError) || error.details?.reason !== "finance_owner_ceo_unavailable") { + throw error; + } + blockingReasons.push(buildIssue( + "finance_owner_ceo_unavailable", + "缺少非申请人的总经理审批人", + "财务本人报销需由非归属人且非提交人的在职总经理审批,请联系管理员核实审批配置。", + )); + } + const [invoices, attachmentCount] = await Promise.all([ tx.invoicePool.findMany({ where: { linked_reimbursement_id: reimbursementId, status: "bound" }, @@ -191,6 +210,7 @@ async function buildReimbursementOperationalChecks(tx, { reimbursementId, config timelinessChecks, assetHint: { required: assetItemCount > 0, itemCount: assetItemCount }, warnings, + blockingReasons, }; } @@ -200,6 +220,7 @@ function mergeReimbursementOperationalChecks(precheck, operationalChecks) { evidenceSummary: operationalChecks.evidenceSummary, timelinessChecks: operationalChecks.timelinessChecks, assetHint: operationalChecks.assetHint, + blockingReasons: [...precheck.blockingReasons, ...operationalChecks.blockingReasons], warnings: [...precheck.warnings, ...operationalChecks.warnings], }; } @@ -659,6 +680,7 @@ async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger total_amount: true, status: true, owner_id: true, + submitter_id: true, items: { select: { id: true, @@ -729,6 +751,8 @@ async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger tx, approvalRule, ownerId: reimbursement.owner_id, + submitterId: reimbursement.submitter_id, + entityType: "reimbursement", logger, }); const resolved = await resolveActionableStep({ diff --git a/server/src/utils/workflow.js b/server/src/utils/workflow.js index f2ba4d1a..620735b3 100644 --- a/server/src/utils/workflow.js +++ b/server/src/utils/workflow.js @@ -1,4 +1,7 @@ const { AppError } = require("./AppError"); +const { buildBusinessValidationError } = require("./businessValidationError"); + +const FINANCE_OWNER_APPROVAL_STEP_ID = "approval_1_ceo_finance_owner"; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const MAX_MANAGER_DEPTH = 10; @@ -199,8 +202,39 @@ function assertOwnerSelfFallbackRole(roleCode, ownerRoleCodes) { return roleCode; } -async function buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId, logger }) { +async function buildFinanceOwnerApprovalSnapshot({ tx, ownerId, submitterId }) { + const ownerRoleCodes = await loadOwnerRoleCodes(tx, ownerId); + if (ownerRoleCodes?.has("finance")) { + const applicantIds = [...new Set([ownerId, submitterId].filter(Boolean))]; + const ceo = await tx.employeeRole.findFirst({ + where: { + role_code: "ceo", + employee_id: { notIn: applicantIds }, + employee: { status: "active", archived_at: null }, + }, + select: { employee_id: true }, + }); + if (!ceo) { + throw buildBusinessValidationError("reimbursement.finance_owner_ceo_unavailable", { + workflow: "no_actionable_approver", reason: "finance_owner_ceo_unavailable", + }, 409); + } + return [{ + id: FINANCE_OWNER_APPROVAL_STEP_ID, + name: "总经理审批", + type: "approval", + assignee_rule: { type: "role", value: "ceo" }, + }]; + } + return null; +} + +async function buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId, submitterId, entityType, logger }) { const rawSteps = buildWorkflowStepsFromApprovalRule(approvalRule); + if (entityType === "reimbursement") { + const financeOwnerSteps = await buildFinanceOwnerApprovalSnapshot({ tx, ownerId, submitterId }); + if (financeOwnerSteps) return financeOwnerSteps; + } const hasManagerStep = rawSteps.some((step) => isDepartmentManagerRule(step.assignee_rule)); if (!hasManagerStep || !approvalRule.owner_self_fallback_role) { @@ -567,9 +601,11 @@ function toMoneyString(value) { } module.exports = { + FINANCE_OWNER_APPROVAL_STEP_ID, BUSINESS_APPROVAL_ROLE_CODES, OWNER_SELF_FALLBACK_ROLE_CODES, buildApprovalWorkflowSnapshot, + buildFinanceOwnerApprovalSnapshot, buildWorkflowStepsFromApprovalRule, getWorkflowStepsSnapshot, isBusinessApprovalRoleCode, diff --git a/server/tests/unit/fixtures/error-reason-messages.json b/server/tests/unit/fixtures/error-reason-messages.json index b95cff35..d2a334c1 100644 --- a/server/tests/unit/fixtures/error-reason-messages.json +++ b/server/tests/unit/fixtures/error-reason-messages.json @@ -1,4 +1,6 @@ { + "reimbursement.finance_owner_ceo_unavailable": "财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。", + "reimbursement.finance_owner_ceo_required": "财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。", "reimbursement.invoice_prefill_incomplete": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。", "reimbursement.items_description_required": "请补充明细的支出说明后再提交。", "reimbursement.material_preview_limit": "报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。", diff --git a/server/tests/unit/services/reimbursementOperationalChecks.test.js b/server/tests/unit/services/reimbursementOperationalChecks.test.js index 2a9a2396..97b49b46 100644 --- a/server/tests/unit/services/reimbursementOperationalChecks.test.js +++ b/server/tests/unit/services/reimbursementOperationalChecks.test.js @@ -6,9 +6,15 @@ const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; function buildTx() { return { + employee: { + findUnique: vi.fn(async () => ({ roles: [{ role_code: "employee" }] })), + }, + employeeRole: { findFirst: vi.fn(async () => null) }, reimbursementForm: { findUnique: vi.fn(async () => ({ id: REIMBURSEMENT_ID, + owner_id: REIMBURSEMENT_ID, + submitter_id: REIMBURSEMENT_ID, items: [ { id: "item-office", @@ -37,6 +43,21 @@ function buildTx() { } describe("buildReimbursementOperationalChecks", () => { + it("reports missing CEO as an operational blocker independently of the budget feature", async () => { + const tx = buildTx(); + tx.employee.findUnique.mockResolvedValue({ roles: [{ role_code: "finance" }] }); + const result = await buildReimbursementOperationalChecks(tx, { + reimbursementId: REIMBURSEMENT_ID, config: { appTimezone: "Asia/Shanghai" }, + }); + expect(result.blockingReasons).toEqual([expect.objectContaining({ code: "finance_owner_ceo_unavailable" })]); + expect(tx.employeeRole.findFirst).toHaveBeenCalledWith(expect.objectContaining({ + where: { + role_code: "ceo", employee_id: { notIn: [REIMBURSEMENT_ID] }, + employee: { status: "active", archived_at: null }, + }, + })); + }); + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-08-04T04:00:00.000Z")); diff --git a/server/tests/unit/utils/workflow.test.js b/server/tests/unit/utils/workflow.test.js index 705ac1c3..54a9d52d 100644 --- a/server/tests/unit/utils/workflow.test.js +++ b/server/tests/unit/utils/workflow.test.js @@ -131,6 +131,50 @@ describe("workflow assignee resolution", () => { }); describe("buildApprovalWorkflowSnapshot", () => { + it.each([ + ["small", "finance", null], + ["large", "ceo", "finance"], + ])("uses exactly one CEO step for finance-owned reimbursement %s", async (_name, level2, level3) => { + const tx = buildTx({ ownerRoles: ["finance", "employee"], managerId: MANAGER_ID }); + const steps = await workflowUtils.buildApprovalWorkflowSnapshot({ + tx, entityType: "reimbursement", ownerId: OWNER_ID, submitterId: MANAGER_ID, + approvalRule: { level1_role: "manager", level2_role: level2, level3_role: level3 }, + }); + expect(steps).toEqual([{ + id: "approval_1_ceo_finance_owner", name: "总经理审批", type: "approval", + assignee_rule: { type: "role", value: "ceo" }, + }]); + expect(tx.employeeRole.findFirst).toHaveBeenCalledWith({ + where: { + role_code: "ceo", + employee_id: { notIn: [OWNER_ID, MANAGER_ID] }, + employee: { status: "active", archived_at: null }, + }, + select: { employee_id: true }, + }); + }); + + it.each([["finance"], ["finance", "ceo"]])("blocks finance owner roles %j without non-applicant CEO", async (...ownerRoles) => { + const tx = buildTx({ ownerRoles, fallbackHasHolder: false }); + await expect(workflowUtils.buildApprovalWorkflowSnapshot({ + tx, entityType: "reimbursement", ownerId: OWNER_ID, submitterId: OWNER_ID, + approvalRule: { level1_role: "finance" }, + })).rejects.toMatchObject({ + statusCode: 409, + details: { workflow: "no_actionable_approver", reason: "finance_owner_ceo_unavailable" }, + }); + }); + + it("does not simplify other entities or a finance submitter's employee-owned form", async () => { + for (const [entityType, ownerRoles] of [["purchase_request", ["finance"]], ["reimbursement", ["employee"]]]) { + const steps = await workflowUtils.buildApprovalWorkflowSnapshot({ + tx: buildTx({ ownerRoles }), entityType, ownerId: OWNER_ID, submitterId: FINANCE_MGR_ID, + approvalRule: { level1_role: "manager", level2_role: "ceo", level3_role: "finance" }, + }); + expect(steps).toHaveLength(3); + } + }); + it("keeps ordinary employee approval snapshots unchanged", async () => { const tx = buildTx({ ownerRoles: ["employee"], managerId: MANAGER_ID });