feat(server): 票据增强闭环(F9/F10/F11/F12/F13/F28)

- /invoice-pool 新增 unbind、PATCH 修正、manual archive、OCR retry、判重例外 override(含 VALID_004/006/007)

- signed-url 签发/下载写入审计日志(脱敏 extra)

- 新增未绑定票据自动归档作业 + 一次性执行脚本

- 回归脚本增强:workflow-actions/dedup/audit/ocr 覆盖上述场景;v3 CSV 状态推进至 DONE
这个提交包含在:
2026-02-09 17:09:34 +08:00
父节点 3ae9e08692
当前提交 8bd068f2a3
共修改 15 个文件,包含 1882 行新增和 14 行删除
@@ -7,11 +7,11 @@ F5,报销明细CRUD与总金额重算,"新增报销明细 items 的增改删;
F6,前端报销编辑器(明细+费用类型),"在报销详情页补齐明细编辑能力(费用类型下拉+日期+金额+说明),并保持现有提交/撤回/支付闭环","employee 可完整填写明细并提交;列表/详情金额一致;typecheck/build/e2e 通过","cd client && npm run typecheck && npm run build && npm run test:e2e",manual,TODO,TODO,TODO,"client/src/pages/ReimbursementDetailPage.tsx | client/src/api/reimbursements.ts | client/src/api/expenseTypes.ts","[""F3"",""F5""]","UI继续复用现有 shadcn 风格组件;加 data-testid"
F7,代提交(expense.proxy_submit),"允许 administration 为员工 owner 创建/提交报销(submitter=行政,owner=员工):POST /api/reimbursements 支持 ownerEmployeeId;强制 delegation_grants 有效期校验;workflow 解析以 owner 为基准;必要时补齐 administration 角色最小权限(expense.create/submit/withdraw/invoice.upload)","无授权时 403;有授权可 create/submit 且审批人按 owner.direct_manager 解析;own 口径包含 submitter_id=me;audit_logs 记录 proxy 行为","cd server && ./scripts/regression-workflow-actions.sh",manual,DONE,DONE,DONE,"server/src/routes/reimbursements.js | server/src/utils/delegation.js | server/src/routes/workflow.js | server/src/constants/permissions.js | server/scripts/regression-workflow-actions.sh","[""F5""]","前端可后置:先保证后端权限链/审计链正确"
F8,行政代上传(owner≠uploaded_by),"放开 invoice-pool/upload 跨 owner 上传:system.users 允许;administration 需具备 expense.proxy_submit 且存在有效 delegation_grants(scope=reimbursement_submit/all);并写 audit_logs","行政可为已授权员工上传;未授权拒绝;owner/uploaded_by 记录正确;发票归属与可见性规则不被破坏;审计可追踪","cd server && ./scripts/regression-ocr.sh && ./scripts/regression-audit.sh",manual,DONE,DONE,DONE,"server/src/routes/invoicePool.js | server/src/utils/delegation.js | server/src/routes/audit.js | server/src/constants/permissions.js","[""F7""]","保持上传不阻塞 OCR;ownerScope 查询语义不变"
F9,票据解绑/重绑(bound->available),"新增 unbind 接口:仅当报销单处于 draft/rejected 时允许解绑;解绑后票据回到 available 并清理 linked_*","解绑权限与状态校验正确;解绑后可重新绑定其他报销草稿;不破坏判重约束","cd server && ./scripts/regression-workflow-actions.sh",manual,TODO,TODO,TODO,"server/src/routes/invoicePool.js | server/scripts/regression-workflow-actions.sh","[""F1""]","与 invoice_pool_status_invariants_chk 保持一致"
F10,判重例外(override duplicate),"新增 finance_mgr 判重例外接口:标记 invoice_pool.is_duplicate_override=true 并记录引用原单号+原因;写 audit_logs","命中判重时普通用户仍 VALID_003;finance_mgr 可 override 后成功 bind/submit;若原单 status=approved/paid 则 VALID_004","cd server && ./scripts/regression-dedup.sh && ./scripts/regression-audit.sh",manual,TODO,TODO,TODO,"server/src/routes/invoicePool.js | server/src/routes/reimbursements.js | server/scripts/regression-dedup.sh | server/src/routes/audit.js","[""F9""]","严格遵守规格书:例外必须可审计可追溯"
F11,OCR失败重试API(次数/频控),"新增 POST /api/invoice-pool/:id/retry-ocr:attempt<=3 且 60 秒频控;超限返回 VALID_006/VALID_007;写 audit_logs","ocr_failed 可重试回到 pending_ocr 并由 job 处理;频控与次数校验正确;mock 模式可回归","cd server && ./scripts/regression-ocr.sh && ./scripts/regression-audit.sh",manual,TODO,TODO,TODO,"server/src/routes/invoicePool.js | server/src/jobs/ocrJob.js | server/prisma/schema.prisma | server/prisma/migrations/* | server/scripts/regression-ocr.sh","[""F1""]","错误码新增需同步 client 错误文案映射"
F12,90天未绑定自动归档(unused_timeout),"新增作业:available/ocr_failed 超过 90 天未绑定自动归档为 archived_reason=unused_timeout;并保证不参与判重","作业可重复运行幂等;归档后仍可查询/预览;不影响全局判重历史口径","cd server && ./scripts/regression-ocr.sh && manual: 调整 created_at 模拟超期后跑 job 断言",manual,TODO,TODO,TODO,"server/src/jobs/invoiceArchiveJob.js | server/src/app.js | server/README.md","[""F11""]","后续迁移到 pg-boss;当前可先 node-cron 实现"
F13,signed-url审计与下载行为记录,"对 signed-url 签发与下载两类事件写 audit_logs(不记录敏感 token 明文)","无权限仍 403;有权限获取 signed-url 与下载时均可在 audit_logs 检索到对应记录","cd server && ./scripts/regression-audit.sh",manual,TODO,TODO,TODO,"server/src/routes/invoicePool.js | server/src/routes/audit.js","[""F1""]","extra 仅保存 invoiceId/actorId/expireAt 等脱敏信息"
F9,票据解绑/重绑(bound->available),"新增 unbind 接口:仅当报销单处于 draft/rejected 时允许解绑;解绑后票据回到 available 并清理 linked_*","解绑权限与状态校验正确;解绑后可重新绑定其他报销草稿;不破坏判重约束","cd server && ./scripts/regression-workflow-actions.sh",manual,DONE,DONE,DONE,"server/src/routes/invoicePool.js | server/scripts/regression-workflow-actions.sh","[""F1""]","与 invoice_pool_status_invariants_chk 保持一致"
F10,判重例外(override duplicate),"新增 finance_mgr 判重例外接口:标记 invoice_pool.is_duplicate_override=true 并记录引用原单号+原因;写 audit_logs","命中判重时普通用户仍 VALID_003;finance_mgr 可 override 后成功 bind/submit;若原单 status=approved/paid 则 VALID_004","cd server && ./scripts/regression-dedup.sh && ./scripts/regression-audit.sh",manual,DONE,DONE,DONE,"server/src/routes/invoicePool.js | server/src/routes/reimbursements.js | server/scripts/regression-dedup.sh | server/src/routes/audit.js","[""F9""]","严格遵守规格书:例外必须可审计可追溯"
F11,OCR失败重试API(次数/频控),"新增 POST /api/invoice-pool/:id/retry-ocr:attempt<=3 且 60 秒频控;超限返回 VALID_006/VALID_007;写 audit_logs","ocr_failed 可重试回到 pending_ocr 并由 job 处理;频控与次数校验正确;mock 模式可回归","cd server && ./scripts/regression-ocr.sh && ./scripts/regression-audit.sh",manual,DONE,DONE,DONE,"server/src/routes/invoicePool.js | server/src/jobs/ocrJob.js | server/prisma/schema.prisma | server/prisma/migrations/* | server/scripts/regression-ocr.sh","[""F1""]","错误码新增需同步 client 错误文案映射"
F12,90天未绑定自动归档(unused_timeout),"新增作业:available/ocr_failed 超过 90 天未绑定自动归档为 archived_reason=unused_timeout;并保证不参与判重","作业可重复运行幂等;归档后仍可查询/预览;不影响全局判重历史口径","cd server && ./scripts/regression-ocr.sh",manual,DONE,DONE,DONE,"server/src/jobs/invoiceArchiveJob.js | server/src/app.js | server/README.md","[""F11""]","后续迁移到 pg-boss;当前可先 node-cron 实现"
F13,signed-url审计与下载行为记录,"对 signed-url 签发与下载两类事件写 audit_logs(不记录敏感 token 明文)","无权限仍 403;有权限获取 signed-url 与下载时均可在 audit_logs 检索到对应记录","cd server && ./scripts/regression-audit.sh",manual,DONE,DONE,DONE,"server/src/routes/invoicePool.js | server/src/routes/audit.js","[""F1""]","extra 仅保存 invoiceId/actorId/expireAt 等脱敏信息"
F14,站内通知基础设施(DB+API),"新增 notifications 表与 API(list/markRead),用于工作流与系统事件通知","可分页读取;可标记已读;权限隔离正确;最小索引满足试运行","cd server && npm run prisma:migrate:dev && ./scripts/regression-all.sh",manual,TODO,TODO,TODO,"server/prisma/schema.prisma | server/src/routes/notifications.js | server/src/app.js","[""F1""]","只做最小字段:title/body/type/targetId/readAt"
F15,邮件通知(SMTP/Nodemailer),"引入 nodemailer 发送邮件:新待办/审批结果/付款完成/密码重置等;失败降级不阻塞主流程;可配置开关","EMAIL_ENABLED=false 时不发送;开启后可发出测试邮件;失败仅记录 audit/日志不影响业务","manual: 配置 SMTP 并触发一次待办通知/重置密码邮件",manual,TODO,TODO,TODO,"server/src/services/email/* | server/src/config.js | server/package.json","[""F14""]","优先复用成熟 nodemailer;写回滚说明"
F16,前端通知入口(Bell),"新增通知列表页/弹层与未读角标;支持标记已读","四角色均可见自己的通知;读写后角标正确;e2e 覆盖核心路径","cd client && npm run test:e2e",manual,TODO,TODO,TODO,"client/src/components/navigation/Topbar.tsx | client/src/pages/NotificationsPage.tsx | client/src/api/notifications.ts","[""F14""]","保持 UI 轻量,不做复杂订阅推送"
@@ -29,7 +29,7 @@ F24,工作流扩展到其他实体(P7+),"抽象 entity_type 扩展到请假/
F25,敏感字段加密与脱敏(P6+),"落实员工银行卡/身份证等敏感字段的可逆加密与脱敏输出,并对查看/变更写审计","对外响应仅脱敏;解密仅限特权权限并写 audit;不在日志/错误中泄露明文","manual: 创建员工并查看返回脱敏;用特权接口取明文并在 audit 检索记录",manual,TODO,TODO,TODO,"server/src/routes/users.js | server/src/services/crypto/* | server/src/routes/audit.js","[""F1""]","优先用成熟库(libsodium/crypto),并明确密钥轮换策略"
F26,部门API(departments),"补齐部门树/列表与管理接口:GET /api/departments(默认隐藏归档,可 query 开启);POST/PATCH;POST /:id/archive(无子部门/无在职员工约束);写 audit_logs","部门树可用且过滤正确;归档约束正确;system.users 才可写;审计可追踪","manual: 启动 server;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/departments' | jq;curl -sS -X POST -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments' -d '{name:...}';curl -sS -X PATCH -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments/<ID>' -d '{name:...}';curl -sS -X POST -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/departments/<ID>/archive';可选 curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/audit-logs?action=DEPARTMENT_*' | jq",manual,DONE,DONE,DONE,"server/src/routes/departments.js | server/src/app.js | server/README.md","[""F1""]","单机试运行优先;树构造按 parent_id 组装"
F27,员工API对齐(新增别名 /api/employees),"对齐规格书:在保留 /api/users 的同时新增 /api/employees 别名路由;补齐 GET详情/PATCH更新/POST archive;并保证本人可读自己的详情","/api/employees 与 /api/users 行为一致;system.users 可增改归档;本人可 GET 自己;审计完整","manual: 启动 server;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/employees?page=1&pageSize=1' | jq;取 employeeId 后 curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/employees/<ID>' | jq;再用普通 employee 登录拿 <EMP_TOKEN> 验证仅可 GET 自己且 GET 他人返回 403 PERM_001",manual,DONE,DONE,DONE,"server/src/routes/users.js | server/src/routes/employees.js | server/src/app.js | server/README.md","[""F26""]","优先复用既有 usersRoutes 实现,避免重复逻辑"
F28,票据人工修正与手工归档(invoice-pool patch/archive),"补齐 PATCH /api/invoice-pool/:id(remarks/人工修正关键字段,允许 ocr_failed -> available);补齐 POST /api/invoice-pool/:id/archive(manual,未绑定才允许);写 audit_logs(INVOICE_POOL_EDITED/INVOICE_POOL_ARCHIVED)","服务端鉴权正确(owner/uploader/invoice.manage);人工修正后可参与 bind/判重;手工归档后不影响历史判重口径;审计可检索","cd server && ./scripts/regression-audit.sh",manual,TODO,TODO,TODO,"server/src/routes/invoicePool.js | server/scripts/regression-audit.sh | server/README.md","[""F1""]","extra.changes 仅记录必要字段的前后快照(避免敏感/大字段)"
F28,票据人工修正与手工归档(invoice-pool patch/archive),"补齐 PATCH /api/invoice-pool/:id(remarks/人工修正关键字段,允许 ocr_failed -> available);补齐 POST /api/invoice-pool/:id/archive(manual,未绑定才允许);写 audit_logs(INVOICE_POOL_EDITED/INVOICE_POOL_ARCHIVED)","服务端鉴权正确(owner/uploader/invoice.manage);人工修正后可参与 bind/判重;手工归档后不影响历史判重口径;审计可检索","cd server && ./scripts/regression-audit.sh",manual,DONE,DONE,DONE,"server/src/routes/invoicePool.js | server/scripts/regression-audit.sh | server/README.md","[""F1""]","extra.changes 仅记录必要字段的前后快照(避免敏感/大字段)"
F29,配置只读API(approval-rules/document-sequences),"补齐只读配置接口:GET /api/approval-rules?enabled=true(登录即可);GET /api/document-sequences/reimbursement(system.settings 可选)","两接口返回结构稳定且不暴露敏感;不影响现有 submit 生成单号逻辑","manual: 启动 server;任意登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/approval-rules?enabled=true' | jq;以 admin 登录拿 <ADMIN_TOKEN>;curl -sS -H 'authorization: Bearer <ADMIN_TOKEN>' 'http://127.0.0.1:3000/api/document-sequences/reimbursement' | jq",manual,DONE,DONE,DONE,"server/src/routes/configRead.js | server/src/app.js | server/README.md","[""F1""]","仅只读;写接口与更复杂可视化后置"
F30,通知触发集成(workflow/pay/auth),"把关键事件写入 notifications 并可选触发邮件:新待办/审批结果/付款完成/密码重置等;失败降级不阻塞主流程","提交后审批人收到通知;审批后 owner/submitter 收到结果;付款后收到通知;EMAIL_ENABLED=false 时仅站内通知;审计/日志可追踪","manual: 走一条 submit->approve->pay 链路并验证 notifications 与(可选)邮件",manual,TODO,TODO,TODO,"server/src/routes/workflow.js | server/src/routes/reimbursements.js | server/src/services/notifications/* | server/src/services/email/*","[""F14""]","先做站内通知,邮件作为可选增强(复用 nodemailer)"
F31,Delegation Grants API对齐(新增别名/增强响应),"新增 /api/delegation-grants 别名路由并增强现有 delegation API:响应内嵌 principal/delegate 员工摘要;system.users 可代维护 principal;delegate 资格校验:scope=reimbursement_submit 需 expense.proxy_submit(administration);scope=all 需 expense.approve;支持 includeInactive","/api/delegation-grants 与 /api/delegations 兼容;返回包含员工摘要;无权限阻断;代维护审计完整","manual: 启动 server;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/delegation-grants' | jq;curl -sS -X POST -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/delegation-grants' -d '{delegateEmployeeId:... scope:... validTo:...}';验证 /api/delegations/mine 与 /api/delegation-grants 返回一致且包含 principal/delegate 摘要",manual,DONE,DONE,DONE,"server/src/routes/delegations.js | server/src/app.js | server/README.md","[""F27""]","不破坏既有 /api/delegations 客户端;新增别名优先"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
7 F6 前端报销编辑器(明细+费用类型) 在报销详情页补齐明细编辑能力(费用类型下拉+日期+金额+说明),并保持现有提交/撤回/支付闭环 employee 可完整填写明细并提交;列表/详情金额一致;typecheck/build/e2e 通过 cd client && npm run typecheck && npm run build && npm run test:e2e manual TODO TODO TODO client/src/pages/ReimbursementDetailPage.tsx | client/src/api/reimbursements.ts | client/src/api/expenseTypes.ts ["F3","F5"] UI继续复用现有 shadcn 风格组件;加 data-testid
8 F7 代提交(expense.proxy_submit) 允许 administration 为员工 owner 创建/提交报销(submitter=行政,owner=员工):POST /api/reimbursements 支持 ownerEmployeeId;强制 delegation_grants 有效期校验;workflow 解析以 owner 为基准;必要时补齐 administration 角色最小权限(expense.create/submit/withdraw/invoice.upload) 无授权时 403;有授权可 create/submit 且审批人按 owner.direct_manager 解析;own 口径包含 submitter_id=me;audit_logs 记录 proxy 行为 cd server && ./scripts/regression-workflow-actions.sh manual DONE DONE DONE server/src/routes/reimbursements.js | server/src/utils/delegation.js | server/src/routes/workflow.js | server/src/constants/permissions.js | server/scripts/regression-workflow-actions.sh ["F5"] 前端可后置:先保证后端权限链/审计链正确
9 F8 行政代上传(owner≠uploaded_by) 放开 invoice-pool/upload 跨 owner 上传:system.users 允许;administration 需具备 expense.proxy_submit 且存在有效 delegation_grants(scope=reimbursement_submit/all);并写 audit_logs 行政可为已授权员工上传;未授权拒绝;owner/uploaded_by 记录正确;发票归属与可见性规则不被破坏;审计可追踪 cd server && ./scripts/regression-ocr.sh && ./scripts/regression-audit.sh manual DONE DONE DONE server/src/routes/invoicePool.js | server/src/utils/delegation.js | server/src/routes/audit.js | server/src/constants/permissions.js ["F7"] 保持上传不阻塞 OCR;ownerScope 查询语义不变
10 F9 票据解绑/重绑(bound->available) 新增 unbind 接口:仅当报销单处于 draft/rejected 时允许解绑;解绑后票据回到 available 并清理 linked_* 解绑权限与状态校验正确;解绑后可重新绑定其他报销草稿;不破坏判重约束 cd server && ./scripts/regression-workflow-actions.sh manual TODO DONE TODO DONE TODO DONE server/src/routes/invoicePool.js | server/scripts/regression-workflow-actions.sh ["F1"] 与 invoice_pool_status_invariants_chk 保持一致
11 F10 判重例外(override duplicate) 新增 finance_mgr 判重例外接口:标记 invoice_pool.is_duplicate_override=true 并记录引用原单号+原因;写 audit_logs 命中判重时普通用户仍 VALID_003;finance_mgr 可 override 后成功 bind/submit;若原单 status=approved/paid 则 VALID_004 cd server && ./scripts/regression-dedup.sh && ./scripts/regression-audit.sh manual TODO DONE TODO DONE TODO DONE server/src/routes/invoicePool.js | server/src/routes/reimbursements.js | server/scripts/regression-dedup.sh | server/src/routes/audit.js ["F9"] 严格遵守规格书:例外必须可审计可追溯
12 F11 OCR失败重试API(次数/频控) 新增 POST /api/invoice-pool/:id/retry-ocr:attempt<=3 且 60 秒频控;超限返回 VALID_006/VALID_007;写 audit_logs ocr_failed 可重试回到 pending_ocr 并由 job 处理;频控与次数校验正确;mock 模式可回归 cd server && ./scripts/regression-ocr.sh && ./scripts/regression-audit.sh manual TODO DONE TODO DONE TODO DONE server/src/routes/invoicePool.js | server/src/jobs/ocrJob.js | server/prisma/schema.prisma | server/prisma/migrations/* | server/scripts/regression-ocr.sh ["F1"] 错误码新增需同步 client 错误文案映射
13 F12 90天未绑定自动归档(unused_timeout) 新增作业:available/ocr_failed 超过 90 天未绑定自动归档为 archived_reason=unused_timeout;并保证不参与判重 作业可重复运行幂等;归档后仍可查询/预览;不影响全局判重历史口径 cd server && ./scripts/regression-ocr.sh && manual: 调整 created_at 模拟超期后跑 job 断言 cd server && ./scripts/regression-ocr.sh manual TODO DONE TODO DONE TODO DONE server/src/jobs/invoiceArchiveJob.js | server/src/app.js | server/README.md ["F11"] 后续迁移到 pg-boss;当前可先 node-cron 实现
14 F13 signed-url审计与下载行为记录 对 signed-url 签发与下载两类事件写 audit_logs(不记录敏感 token 明文) 无权限仍 403;有权限获取 signed-url 与下载时均可在 audit_logs 检索到对应记录 cd server && ./scripts/regression-audit.sh manual TODO DONE TODO DONE TODO DONE server/src/routes/invoicePool.js | server/src/routes/audit.js ["F1"] extra 仅保存 invoiceId/actorId/expireAt 等脱敏信息
15 F14 站内通知基础设施(DB+API) 新增 notifications 表与 API(list/markRead),用于工作流与系统事件通知 可分页读取;可标记已读;权限隔离正确;最小索引满足试运行 cd server && npm run prisma:migrate:dev && ./scripts/regression-all.sh manual TODO TODO TODO server/prisma/schema.prisma | server/src/routes/notifications.js | server/src/app.js ["F1"] 只做最小字段:title/body/type/targetId/readAt
16 F15 邮件通知(SMTP/Nodemailer) 引入 nodemailer 发送邮件:新待办/审批结果/付款完成/密码重置等;失败降级不阻塞主流程;可配置开关 EMAIL_ENABLED=false 时不发送;开启后可发出测试邮件;失败仅记录 audit/日志不影响业务 manual: 配置 SMTP 并触发一次待办通知/重置密码邮件 manual TODO TODO TODO server/src/services/email/* | server/src/config.js | server/package.json ["F14"] 优先复用成熟 nodemailer;写回滚说明
17 F16 前端通知入口(Bell) 新增通知列表页/弹层与未读角标;支持标记已读 四角色均可见自己的通知;读写后角标正确;e2e 覆盖核心路径 cd client && npm run test:e2e manual TODO TODO TODO client/src/components/navigation/Topbar.tsx | client/src/pages/NotificationsPage.tsx | client/src/api/notifications.ts ["F14"] 保持 UI 轻量,不做复杂订阅推送
29 F25 敏感字段加密与脱敏(P6+) 落实员工银行卡/身份证等敏感字段的可逆加密与脱敏输出,并对查看/变更写审计 对外响应仅脱敏;解密仅限特权权限并写 audit;不在日志/错误中泄露明文 manual: 创建员工并查看返回脱敏;用特权接口取明文并在 audit 检索记录 manual TODO TODO TODO server/src/routes/users.js | server/src/services/crypto/* | server/src/routes/audit.js ["F1"] 优先用成熟库(libsodium/crypto),并明确密钥轮换策略
30 F26 部门API(departments) 补齐部门树/列表与管理接口:GET /api/departments(默认隐藏归档,可 query 开启);POST/PATCH;POST /:id/archive(无子部门/无在职员工约束);写 audit_logs 部门树可用且过滤正确;归档约束正确;system.users 才可写;审计可追踪 manual: 启动 server;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/departments' | jq;curl -sS -X POST -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments' -d '{name:...}';curl -sS -X PATCH -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments/<ID>' -d '{name:...}';curl -sS -X POST -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/departments/<ID>/archive';可选 curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/audit-logs?action=DEPARTMENT_*' | jq manual DONE DONE DONE server/src/routes/departments.js | server/src/app.js | server/README.md ["F1"] 单机试运行优先;树构造按 parent_id 组装
31 F27 员工API对齐(新增别名 /api/employees) 对齐规格书:在保留 /api/users 的同时新增 /api/employees 别名路由;补齐 GET详情/PATCH更新/POST archive;并保证本人可读自己的详情 /api/employees 与 /api/users 行为一致;system.users 可增改归档;本人可 GET 自己;审计完整 manual: 启动 server;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/employees?page=1&pageSize=1' | jq;取 employeeId 后 curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/employees/<ID>' | jq;再用普通 employee 登录拿 <EMP_TOKEN> 验证仅可 GET 自己且 GET 他人返回 403 PERM_001 manual DONE DONE DONE server/src/routes/users.js | server/src/routes/employees.js | server/src/app.js | server/README.md ["F26"] 优先复用既有 usersRoutes 实现,避免重复逻辑
32 F28 票据人工修正与手工归档(invoice-pool patch/archive) 补齐 PATCH /api/invoice-pool/:id(remarks/人工修正关键字段,允许 ocr_failed -> available);补齐 POST /api/invoice-pool/:id/archive(manual,未绑定才允许);写 audit_logs(INVOICE_POOL_EDITED/INVOICE_POOL_ARCHIVED) 服务端鉴权正确(owner/uploader/invoice.manage);人工修正后可参与 bind/判重;手工归档后不影响历史判重口径;审计可检索 cd server && ./scripts/regression-audit.sh manual TODO DONE TODO DONE TODO DONE server/src/routes/invoicePool.js | server/scripts/regression-audit.sh | server/README.md ["F1"] extra.changes 仅记录必要字段的前后快照(避免敏感/大字段)
33 F29 配置只读API(approval-rules/document-sequences) 补齐只读配置接口:GET /api/approval-rules?enabled=true(登录即可);GET /api/document-sequences/reimbursement(system.settings 可选) 两接口返回结构稳定且不暴露敏感;不影响现有 submit 生成单号逻辑 manual: 启动 server;任意登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/approval-rules?enabled=true' | jq;以 admin 登录拿 <ADMIN_TOKEN>;curl -sS -H 'authorization: Bearer <ADMIN_TOKEN>' 'http://127.0.0.1:3000/api/document-sequences/reimbursement' | jq manual DONE DONE DONE server/src/routes/configRead.js | server/src/app.js | server/README.md ["F1"] 仅只读;写接口与更复杂可视化后置
34 F30 通知触发集成(workflow/pay/auth) 把关键事件写入 notifications 并可选触发邮件:新待办/审批结果/付款完成/密码重置等;失败降级不阻塞主流程 提交后审批人收到通知;审批后 owner/submitter 收到结果;付款后收到通知;EMAIL_ENABLED=false 时仅站内通知;审计/日志可追踪 manual: 走一条 submit->approve->pay 链路并验证 notifications 与(可选)邮件 manual TODO TODO TODO server/src/routes/workflow.js | server/src/routes/reimbursements.js | server/src/services/notifications/* | server/src/services/email/* ["F14"] 先做站内通知,邮件作为可选增强(复用 nodemailer)
35 F31 Delegation Grants API对齐(新增别名/增强响应) 新增 /api/delegation-grants 别名路由并增强现有 delegation API:响应内嵌 principal/delegate 员工摘要;system.users 可代维护 principal;delegate 资格校验:scope=reimbursement_submit 需 expense.proxy_submit(administration);scope=all 需 expense.approve;支持 includeInactive /api/delegation-grants 与 /api/delegations 兼容;返回包含员工摘要;无权限阻断;代维护审计完整 manual: 启动 server;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/delegation-grants' | jq;curl -sS -X POST -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/delegation-grants' -d '{delegateEmployeeId:... scope:... validTo:...}';验证 /api/delegations/mine 与 /api/delegation-grants 返回一致且包含 principal/delegate 摘要 manual DONE DONE DONE server/src/routes/delegations.js | server/src/app.js | server/README.md ["F27"] 不破坏既有 /api/delegations 客户端;新增别名优先
+4
查看文件
@@ -32,6 +32,10 @@ OCR_LOCK_TTL_SECONDS="300"
OCR_POLL_CRON="* * * * *"
OCR_MAX_ATTEMPTS="3"
OCR_TIMEOUT_MS="5000"
INVOICE_ARCHIVE_CRON="30 2 * * *"
INVOICE_ARCHIVE_UNUSED_DAYS="90"
INVOICE_ARCHIVE_BATCH_SIZE="200"
TENCENT_SECRET_ID=""
TENCENT_SECRET_KEY=""
TENCENT_OCR_REGION="ap-beijing"
+2 -1
查看文件
@@ -18,7 +18,8 @@
"seed:dev": "npm run seed:config && npm run seed:dev-users",
"seed:trialrun": "node src/scripts/seed-trialrun.js",
"job:revoke-expired-delegations": "node src/scripts/revoke-expired-delegations.js",
"job:ocr-once": "node src/scripts/run-ocr-once.js"
"job:ocr-once": "node src/scripts/run-ocr-once.js",
"job:archive-unused-invoices": "node src/scripts/run-invoice-archive-once.js"
},
"keywords": [],
"author": "",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "invoice_pool" ADD COLUMN "ocr_last_requested_at" TIMESTAMP(3);
+1
查看文件
@@ -427,6 +427,7 @@ model InvoicePool {
ocr_type OcrType @default(general)
ocr_result_id String? @db.Uuid
ocr_attempts Int @default(0)
ocr_last_requested_at DateTime?
ocr_last_error String?
ocr_completed_at DateTime?
ocr_locked_at DateTime?
+284
查看文件
@@ -21,6 +21,26 @@ if ! command -v jq >/dev/null 2>&1; then
exit 1
fi
if ! command -v node >/dev/null 2>&1; then
echo "[ERROR] node not found" >&2
exit 1
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
exit 1
fi
TMP_DIR="$(mktemp -d)"
RUN_SUFFIX="$(date +%s)-$RANDOM"
cleanup() {
@@ -148,6 +168,183 @@ revoke_delegation() {
assert_status "$status" "200" "revoke delegation" "$out_file"
}
upload_invoice() {
local token="$1"
local file_path="$2"
local out_file="$3"
local ocr_type="${4:-}"
if [[ -n "$ocr_type" ]]; then
curl -sS -o "$out_file" -w "%{http_code}" \
-X POST "$BASE_URL/api/invoice-pool/upload" \
-H "authorization: Bearer $token" \
-F "file=@$file_path" \
-F "ocr_type=$ocr_type"
else
curl -sS -o "$out_file" -w "%{http_code}" \
-X POST "$BASE_URL/api/invoice-pool/upload" \
-H "authorization: Bearer $token" \
-F "file=@$file_path"
fi
}
create_reimbursement() {
local token="$1"
local out_file="$2"
local status
status="$(api_json_auth POST "$BASE_URL/api/reimbursements" "$token" "$out_file" '{}')"
assert_status "$status" "200" "create reimbursement" "$out_file"
jq -er '.data.id' "$out_file"
}
bind_invoice() {
local token="$1"
local invoice_id="$2"
local reimbursement_id="$3"
local out_file="$4"
local status
status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$invoice_id/bind" "$token" "$out_file" "{\"reimbursementId\":\"$reimbursement_id\"}")"
assert_status "$status" "200" "bind invoice" "$out_file"
}
patch_invoice() {
local token="$1"
local invoice_id="$2"
local body="$3"
local out_file="$4"
local status
status="$(api_json_auth PATCH "$BASE_URL/api/invoice-pool/$invoice_id" "$token" "$out_file" "$body")"
assert_status "$status" "200" "patch invoice" "$out_file"
}
archive_invoice_manual() {
local token="$1"
local invoice_id="$2"
local out_file="$3"
local status
status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$invoice_id/archive" "$token" "$out_file" '{}')"
assert_status "$status" "200" "archive invoice" "$out_file"
}
issue_signed_url() {
local token="$1"
local invoice_id="$2"
local out_file="$3"
local status
status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$invoice_id/signed-url" "$token" "$out_file" '{}')"
assert_status "$status" "200" "issue signed url" "$out_file"
jq -er '.data.signedUrl' "$out_file"
}
override_duplicate() {
local token="$1"
local invoice_id="$2"
local ref_form_number="$3"
local reason="$4"
local out_file="$5"
local status
status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$invoice_id/override-duplicate" "$token" "$out_file" "{\"refFormNumber\":\"$ref_form_number\",\"reason\":\"$reason\"}")"
echo "$status"
}
retry_ocr() {
local token="$1"
local invoice_id="$2"
local out_file="$3"
api_json_auth POST "$BASE_URL/api/invoice-pool/$invoice_id/retry-ocr" "$token" "$out_file" '{}'
}
assert_audit_has_target() {
local file_path="$1"
local target_id="$2"
local scenario="$3"
if ! jq -e --arg tid "$target_id" '.data.items | any(.targetId == $tid)' "$file_path" >/dev/null; then
echo "[ERROR] $scenario expected audit log targetId=$target_id" >&2
cat "$file_path" >&2
exit 1
fi
}
create_invoice_record() {
local owner_email="$1"
local marker="$2"
local status="$3"
local attempts="$4"
local last_requested_at="$5"
DATABASE_URL="$DATABASE_URL" OWNER_EMAIL="$owner_email" MARKER="$marker" STATUS="$status" ATTEMPTS="$attempts" LAST_REQUESTED_AT="$last_requested_at" node <<'NODE'
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const { createPrismaClient } = require('./src/prismaClient');
const { getConfig } = require('./src/config');
(async () => {
const email = process.env.OWNER_EMAIL;
const marker = process.env.MARKER || 'MOCK_OK';
const status = process.env.STATUS || 'ocr_failed';
const attempts = Number(process.env.ATTEMPTS || 0);
const lastRequestedAtRaw = process.env.LAST_REQUESTED_AT || '';
const config = getConfig();
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
const owner = await prisma.employee.findUnique({
where: { email },
select: { id: true },
});
if (!owner) {
throw new Error(`employee not found: ${email}`);
}
const relPath = path.join('audit-regression', `${crypto.randomUUID()}.txt`);
const absPath = path.resolve(config.storageDir, relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, marker, 'utf8');
const fileSha256 = crypto.createHash('sha256').update(marker).digest('hex');
const created = await prisma.invoicePool.create({
data: {
owner_employee_id: owner.id,
uploaded_by_employee_id: owner.id,
storage_path: relPath,
original_name: `audit-${Date.now()}.txt`,
mime_type: 'text/plain',
file_size: Buffer.byteLength(marker),
file_sha256: fileSha256,
status,
ocr_type: 'vat',
ocr_attempts: attempts,
ocr_last_requested_at: lastRequestedAtRaw ? new Date(lastRequestedAtRaw) : null,
created_by: owner.id,
updated_by: owner.id,
},
select: { id: true },
});
process.stdout.write(`${created.id}\n`);
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
echo "[1/6] login users"
EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$DEVICE_FINGERPRINT-employee-$RUN_SUFFIX" "$TMP_DIR/login-employee.json")"
MANAGER_TOKEN="$(login_user "$MANAGER_IDENTIFIER" "$MANAGER_PASSWORD" "$DEVICE_FINGERPRINT-manager-$RUN_SUFFIX" "$TMP_DIR/login-manager.json")"
@@ -190,4 +387,91 @@ assert_status "$date_invalid_status" "400" "audit invalid date" "$TMP_DIR/audit-
assert_error_code "$TMP_DIR/audit-invalid-date.json" "VALID_001" "audit invalid date"
assert_jq "$TMP_DIR/audit-invalid-date.json" '.error.details.dateTo == "must_be_after_dateFrom"' "audit invalid date"
echo "[7/10] invoice edit/archive + signed-url/download audit"
EMPLOYEE_ID="$(get_me_id "$EMPLOYEE_TOKEN" "$TMP_DIR/me-employee.json")"
FINANCE_MGR_ID="$(get_me_id "$FINANCE_MGR_TOKEN" "$TMP_DIR/me-fm.json")"
INVOICE_FILE="$TMP_DIR/invoice-audit-$RUN_SUFFIX.pdf"
printf '%%PDF-1.4\nINVOICE-AUDIT-%s\n' "$RUN_SUFFIX" > "$INVOICE_FILE"
upload_status="$(upload_invoice "$EMPLOYEE_TOKEN" "$INVOICE_FILE" "$TMP_DIR/invoice-upload.json")"
assert_status "$upload_status" "200" "upload invoice audit" "$TMP_DIR/invoice-upload.json"
INVOICE_ID="$(jq -er '.data.invoicePoolId' "$TMP_DIR/invoice-upload.json")"
patch_invoice "$EMPLOYEE_TOKEN" "$INVOICE_ID" "{\"remarks\":\"audit-$RUN_SUFFIX\",\"invoiceCode\":\"AUD$RUN_SUFFIX\",\"invoiceNumber\":\"$RUN_SUFFIX\"}" "$TMP_DIR/invoice-patch.json"
archive_invoice_manual "$EMPLOYEE_TOKEN" "$INVOICE_ID" "$TMP_DIR/invoice-archive.json"
SIGNED_URL="$(issue_signed_url "$EMPLOYEE_TOKEN" "$INVOICE_ID" "$TMP_DIR/invoice-signed.json")"
download_status="$(curl -sS -L -o "$TMP_DIR/invoice-download.bin" -w "%{http_code}" "$SIGNED_URL")"
assert_status "$download_status" "200" "invoice signed url download"
if [[ ! -s "$TMP_DIR/invoice-download.bin" ]]; then
echo "[ERROR] invoice signed url download is empty" >&2
exit 1
fi
for action in INVOICE_POOL_EDITED INVOICE_POOL_ARCHIVED INVOICE_SIGNED_URL_ISSUED INVOICE_FILE_DOWNLOADED; do
audit_file="$TMP_DIR/audit-$action.json"
audit_status="$(auth_get "$BASE_URL/api/audit-logs?action=$action&targetType=invoice_pool&page=1&pageSize=50" "$FINANCE_MGR_TOKEN" "$audit_file")"
assert_status "$audit_status" "200" "audit query $action" "$audit_file"
assert_audit_has_target "$audit_file" "$INVOICE_ID" "audit $action"
done
echo "[8/10] duplicate override audit"
RID_OVERRIDE_A="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/override-reim-a.json")"
RID_OVERRIDE_B="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/override-reim-b.json")"
OV_FILE="$TMP_DIR/override-$RUN_SUFFIX.pdf"
printf '%%PDF-1.4\nMOCK_DUP:%s\n' "$RUN_SUFFIX" > "$OV_FILE"
upload_status="$(upload_invoice "$EMPLOYEE_TOKEN" "$OV_FILE" "$TMP_DIR/override-upload-a.json")"
assert_status "$upload_status" "200" "upload override invoice A" "$TMP_DIR/override-upload-a.json"
IID_OVERRIDE_A="$(jq -er '.data.invoicePoolId' "$TMP_DIR/override-upload-a.json")"
bind_invoice "$EMPLOYEE_TOKEN" "$IID_OVERRIDE_A" "$RID_OVERRIDE_A" "$TMP_DIR/override-bind-a.json"
upload_status="$(upload_invoice "$EMPLOYEE_TOKEN" "$OV_FILE" "$TMP_DIR/override-upload-b.json")"
assert_status "$upload_status" "200" "upload override invoice B" "$TMP_DIR/override-upload-b.json"
IID_OVERRIDE_B="$(jq -er '.data.invoicePoolId' "$TMP_DIR/override-upload-b.json")"
bind_status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$IID_OVERRIDE_B/bind" "$EMPLOYEE_TOKEN" "$TMP_DIR/override-bind-b.json" "{\"reimbursementId\":\"$RID_OVERRIDE_B\"}")"
assert_status "$bind_status" "409" "bind duplicate before override" "$TMP_DIR/override-bind-b.json"
assert_error_code "$TMP_DIR/override-bind-b.json" "VALID_003" "bind duplicate before override"
override_status="$(override_duplicate "$FINANCE_MGR_TOKEN" "$IID_OVERRIDE_B" "REF-$RUN_SUFFIX" "regression override" "$TMP_DIR/override.json")"
assert_status "$override_status" "200" "override duplicate" "$TMP_DIR/override.json"
bind_invoice "$EMPLOYEE_TOKEN" "$IID_OVERRIDE_B" "$RID_OVERRIDE_B" "$TMP_DIR/override-bind-b2.json"
override_audit_status="$(auth_get "$BASE_URL/api/audit-logs?action=INVOICE_DUPLICATE_OVERRIDDEN&targetType=invoice_pool&actorId=$FINANCE_MGR_ID&page=1&pageSize=50" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-override.json")"
assert_status "$override_audit_status" "200" "audit override" "$TMP_DIR/audit-override.json"
assert_audit_has_target "$TMP_DIR/audit-override.json" "$IID_OVERRIDE_B" "audit override"
echo "[9/10] OCR retry audit + VALID_006/VALID_007"
NOW_ISO="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
OLD_ISO="$(date -u -d "-2 minutes" +"%Y-%m-%dT%H:%M:%S.000Z")"
INVOICE_FREQ="$(create_invoice_record "$EMPLOYEE_IDENTIFIER" "MOCK_OK:$RUN_SUFFIX" "ocr_failed" "0" "$NOW_ISO")"
retry_freq_status="$(retry_ocr "$EMPLOYEE_TOKEN" "$INVOICE_FREQ" "$TMP_DIR/retry-freq.json")"
assert_status "$retry_freq_status" "409" "retry too frequent" "$TMP_DIR/retry-freq.json"
assert_error_code "$TMP_DIR/retry-freq.json" "VALID_007" "retry too frequent"
INVOICE_EXCEEDED="$(create_invoice_record "$EMPLOYEE_IDENTIFIER" "MOCK_OK:$RUN_SUFFIX" "ocr_failed" "3" "$OLD_ISO")"
retry_exceeded_status="$(retry_ocr "$EMPLOYEE_TOKEN" "$INVOICE_EXCEEDED" "$TMP_DIR/retry-exceeded.json")"
assert_status "$retry_exceeded_status" "409" "retry attempts exceeded" "$TMP_DIR/retry-exceeded.json"
assert_error_code "$TMP_DIR/retry-exceeded.json" "VALID_006" "retry attempts exceeded"
INVOICE_RETRY_OK="$(create_invoice_record "$EMPLOYEE_IDENTIFIER" "MOCK_OK:$RUN_SUFFIX" "ocr_failed" "1" "$OLD_ISO")"
retry_ok_status="$(retry_ocr "$EMPLOYEE_TOKEN" "$INVOICE_RETRY_OK" "$TMP_DIR/retry-ok.json")"
assert_status "$retry_ok_status" "200" "retry ok" "$TMP_DIR/retry-ok.json"
assert_jq "$TMP_DIR/retry-ok.json" '.data.status == "pending_ocr"' "retry ok status"
OCR_PROVIDER=mock OCR_MAX_ATTEMPTS=3 npm run --silent job:ocr-once >"$TMP_DIR/ocr-retry-run.log"
invoice_after_ocr_status="$(auth_get "$BASE_URL/api/invoice-pool/$INVOICE_RETRY_OK" "$EMPLOYEE_TOKEN" "$TMP_DIR/invoice-after-ocr.json")"
assert_status "$invoice_after_ocr_status" "200" "invoice after ocr" "$TMP_DIR/invoice-after-ocr.json"
assert_jq "$TMP_DIR/invoice-after-ocr.json" '.data.status == "available"' "invoice after ocr"
retry_audit_status="$(auth_get "$BASE_URL/api/audit-logs?action=INVOICE_OCR_RETRIED&targetType=invoice_pool&actorId=$EMPLOYEE_ID&page=1&pageSize=50" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-retry.json")"
assert_status "$retry_audit_status" "200" "audit retry" "$TMP_DIR/audit-retry.json"
assert_audit_has_target "$TMP_DIR/audit-retry.json" "$INVOICE_RETRY_OK" "audit retry"
echo "[OK] Audit regression passed"
+107 -1
查看文件
@@ -5,6 +5,8 @@ BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-dedup}"
EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}"
EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}"
FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}"
FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}"
if ! command -v curl >/dev/null 2>&1; then
echo "[ERROR] curl not found" >&2
@@ -38,7 +40,7 @@ fi
TMP_DIR="$(mktemp -d)"
TMP_INDEX_CREATED=0
RUN_SUFFIX="$(date +%s)-$RANDOM"
RUN_SUFFIX="$(date +%s%N)-$RANDOM"
DEDUP_CODE="RCODE-$RUN_SUFFIX"
DEDUP_NUMBER="RNO-$RUN_SUFFIX"
@@ -84,6 +86,26 @@ api_json() {
fi
}
api_json_with_token() {
local method="$1"
local url="$2"
local token="$3"
local out_file="$4"
local body="${5:-}"
if [[ -n "$body" ]]; then
curl -sS -o "$out_file" -w "%{http_code}" \
-X "$method" "$url" \
-H "authorization: Bearer $token" \
-H "content-type: application/json" \
-d "$body"
else
curl -sS -o "$out_file" -w "%{http_code}" \
-X "$method" "$url" \
-H "authorization: Bearer $token"
fi
}
upload_file() {
local file_path="$1"
local out_file="$2"
@@ -135,6 +157,34 @@ const { createPrismaClient } = require('./src/prismaClient');
NODE
}
set_reimbursement_status_db() {
local reimbursement_id="$1"
local status="$2"
DATABASE_URL="$DATABASE_URL" REIMBURSEMENT_ID="$reimbursement_id" STATUS="$status" node <<'NODE'
const { createPrismaClient } = require('./src/prismaClient');
(async () => {
const reimbursementId = process.env.REIMBURSEMENT_ID;
const status = process.env.STATUS;
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
await prisma.reimbursementForm.update({
where: { id: reimbursementId },
data: { status },
});
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
create_tmp_non_dedup_index() {
local reimbursement_id="$1"
DATABASE_URL="$DATABASE_URL" REIMBURSEMENT_ID="$reimbursement_id" node <<'NODE'
@@ -205,6 +255,15 @@ login_code="$(curl -sS -o "$TMP_DIR/login.json" -w "%{http_code}" \
assert_status "$login_code" "200" "login" "$TMP_DIR/login.json"
TOKEN="$(jq -er '.data.accessToken' "$TMP_DIR/login.json")"
echo "[1.1/7] Login finance_mgr"
login_fm_code="$(curl -sS -o "$TMP_DIR/login-fm.json" -w "%{http_code}" \
-X POST "$BASE_URL/api/auth/login" \
-H "content-type: application/json" \
-H "x-device-fingerprint: $DEVICE_FINGERPRINT-finance-mgr" \
-d "{\"identifier\":\"$FINANCE_MGR_IDENTIFIER\",\"password\":\"$FINANCE_MGR_PASSWORD\"}")"
assert_status "$login_fm_code" "200" "login finance_mgr" "$TMP_DIR/login-fm.json"
TOKEN_FM="$(jq -er '.data.accessToken' "$TMP_DIR/login-fm.json")"
echo "[2/7] Dedup by file_sha256 => VALID_003"
cat > "$TMP_DIR/same-file.pdf" <<PDF
%PDF-1.4
@@ -226,6 +285,12 @@ status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_B/bind" "$TMP_DIR/bB.js
assert_status "$status" "409" "file_sha256 dedup bind" "$TMP_DIR/bB.json"
assert_error_code "$TMP_DIR/bB.json" "VALID_003"
echo "[2.1/7] finance_mgr override duplicate (file_sha256) -> bind ok"
override_status="$(api_json_with_token POST "$BASE_URL/api/invoice-pool/$IID_B/override-duplicate" "$TOKEN_FM" "$TMP_DIR/override-file.json" "{\"refFormNumber\":\"REF-$RUN_SUFFIX\",\"reason\":\"regression override file_sha256\"}")"
assert_status "$override_status" "200" "override duplicate (file_sha256)" "$TMP_DIR/override-file.json"
rebind_status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_B/bind" "$TMP_DIR/bB2.json" "{\"reimbursementId\":\"$RID_B\"}")"
assert_status "$rebind_status" "200" "bind after override (file_sha256)" "$TMP_DIR/bB2.json"
echo "[3/7] Dedup by invoice_code+invoice_number => VALID_003"
cat > "$TMP_DIR/code-1.pdf" <<PDF
%PDF-1.4
@@ -253,6 +318,47 @@ status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_D/bind" "$TMP_DIR/bD.js
assert_status "$status" "409" "code+number dedup bind" "$TMP_DIR/bD.json"
assert_error_code "$TMP_DIR/bD.json" "VALID_003"
echo "[3.1/7] finance_mgr override duplicate (code+number) -> bind ok"
override_status="$(api_json_with_token POST "$BASE_URL/api/invoice-pool/$IID_D/override-duplicate" "$TOKEN_FM" "$TMP_DIR/override-code.json" "{\"refFormNumber\":\"REF-$RUN_SUFFIX\",\"reason\":\"regression override code+number\"}")"
assert_status "$override_status" "200" "override duplicate (code+number)" "$TMP_DIR/override-code.json"
rebind_status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_D/bind" "$TMP_DIR/bD2.json" "{\"reimbursementId\":\"$RID_D\"}")"
assert_status "$rebind_status" "200" "bind after override (code+number)" "$TMP_DIR/bD2.json"
echo "[3.2/7] override blocked when original reimbursement approved => VALID_004"
BLOCK_CODE="BCODE-$RUN_SUFFIX"
BLOCK_NUMBER="BNO-$RUN_SUFFIX"
cat > "$TMP_DIR/block-1.pdf" <<PDF
%PDF-1.4
block-one-$RUN_SUFFIX
PDF
cat > "$TMP_DIR/block-2.pdf" <<PDF
%PDF-1.4
block-two-$RUN_SUFFIX
PDF
RID_BLOCK_A="$(create_reimbursement "$TMP_DIR/r-block-a.json")"
status="$(upload_file "$TMP_DIR/block-1.pdf" "$TMP_DIR/u-block-a.json")"
assert_status "$status" "200" "upload block-1" "$TMP_DIR/u-block-a.json"
IID_BLOCK_A="$(jq -er '.data.invoicePoolId' "$TMP_DIR/u-block-a.json")"
set_invoice_code_number "$IID_BLOCK_A" "$BLOCK_CODE" "$BLOCK_NUMBER"
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_BLOCK_A/bind" "$TMP_DIR/b-block-a.json" "{\"reimbursementId\":\"$RID_BLOCK_A\"}")"
assert_status "$status" "200" "bind block-1" "$TMP_DIR/b-block-a.json"
set_reimbursement_status_db "$RID_BLOCK_A" "approved"
RID_BLOCK_B="$(create_reimbursement "$TMP_DIR/r-block-b.json")"
status="$(upload_file "$TMP_DIR/block-2.pdf" "$TMP_DIR/u-block-b.json")"
assert_status "$status" "200" "upload block-2" "$TMP_DIR/u-block-b.json"
IID_BLOCK_B="$(jq -er '.data.invoicePoolId' "$TMP_DIR/u-block-b.json")"
set_invoice_code_number "$IID_BLOCK_B" "$BLOCK_CODE" "$BLOCK_NUMBER"
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_BLOCK_B/bind" "$TMP_DIR/b-block-b.json" "{\"reimbursementId\":\"$RID_BLOCK_B\"}")"
assert_status "$status" "409" "bind block-2 dedup" "$TMP_DIR/b-block-b.json"
assert_error_code "$TMP_DIR/b-block-b.json" "VALID_003"
override_block_status="$(api_json_with_token POST "$BASE_URL/api/invoice-pool/$IID_BLOCK_B/override-duplicate" "$TOKEN_FM" "$TMP_DIR/override-block.json" "{\"refFormNumber\":\"REF-$RUN_SUFFIX\",\"reason\":\"regression override blocked\"}")"
assert_status "$override_block_status" "409" "override blocked" "$TMP_DIR/override-block.json"
assert_error_code "$TMP_DIR/override-block.json" "VALID_004"
echo "[4/7] Non-dedup P2002 should not map to VALID_003"
cat > "$TMP_DIR/non-dedup-1.pdf" <<PDF
%PDF-1.4
+159 -4
查看文件
@@ -34,6 +34,10 @@ run_ocr_once() {
OCR_PROVIDER=mock OCR_MAX_ATTEMPTS=3 npm run --silent job:ocr-once
}
run_archive_once() {
INVOICE_ARCHIVE_UNUSED_DAYS=90 npm run --silent job:archive-unused-invoices
}
create_pending_invoice() {
local marker="$1"
DATABASE_URL="$DATABASE_URL" EMPLOYEE_IDENTIFIER="$EMPLOYEE_IDENTIFIER" RUN_SUFFIX="$RUN_SUFFIX" MARKER="$marker" node <<'NODE'
@@ -96,6 +100,97 @@ const { getConfig } = require('./src/config');
NODE
}
create_available_invoice() {
local marker="$1"
DATABASE_URL="$DATABASE_URL" EMPLOYEE_IDENTIFIER="$EMPLOYEE_IDENTIFIER" RUN_SUFFIX="$RUN_SUFFIX" MARKER="$marker" node <<'NODE'
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const { createPrismaClient } = require('./src/prismaClient');
const { getConfig } = require('./src/config');
(async () => {
const marker = process.env.MARKER || 'AVAILABLE';
const runSuffix = process.env.RUN_SUFFIX || 'default';
const email = process.env.EMPLOYEE_IDENTIFIER || '[email protected]';
const config = getConfig();
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
const owner = await prisma.employee.findUnique({
where: { email },
select: { id: true },
});
if (!owner) {
throw new Error(`employee not found: ${email}`);
}
const relPath = path.join('archive-regression', runSuffix, `${crypto.randomUUID()}.txt`);
const absPath = path.resolve(config.storageDir, relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, marker, 'utf8');
const fileSha256 = crypto.createHash('sha256').update(marker).digest('hex');
const created = await prisma.invoicePool.create({
data: {
owner_employee_id: owner.id,
uploaded_by_employee_id: owner.id,
storage_path: relPath,
original_name: `archive-${runSuffix}.txt`,
mime_type: 'text/plain',
file_size: Buffer.byteLength(marker),
file_sha256: fileSha256,
status: 'available',
ocr_type: 'general',
created_by: owner.id,
updated_by: owner.id,
},
select: { id: true },
});
process.stdout.write(`${created.id}\n`);
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
set_invoice_created_at() {
local invoice_id="$1"
local created_at="$2"
DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" CREATED_AT="$created_at" node <<'NODE'
const { createPrismaClient } = require('./src/prismaClient');
(async () => {
const invoiceId = process.env.INVOICE_ID;
const createdAt = process.env.CREATED_AT;
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
await prisma.$executeRaw`
UPDATE "invoice_pool"
SET created_at = ${new Date(createdAt)}
WHERE id = ${invoiceId}::uuid
`;
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
set_invoice_status_bound() {
local invoice_id="$1"
DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" node <<'NODE'
@@ -436,12 +531,65 @@ const { createPrismaClient } = require('./src/prismaClient');
NODE
}
echo "[1/4] pending_ocr -> available"
assert_unused_archived_invoice() {
local invoice_id="$1"
local scenario="$2"
DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" SCENARIO="$scenario" node <<'NODE'
const { createPrismaClient } = require('./src/prismaClient');
(async () => {
const invoiceId = process.env.INVOICE_ID;
const scenario = process.env.SCENARIO;
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
const invoice = await prisma.invoicePool.findUnique({
where: { id: invoiceId },
select: {
status: true,
archived_reason: true,
archived_at: true,
linked_reimbursement_id: true,
},
});
if (!invoice) {
console.error(`[ERROR] ${scenario} invoice not found: ${invoiceId}`);
process.exit(1);
}
if (invoice.status !== 'archived' || invoice.archived_reason !== 'unused_timeout') {
console.error(`[ERROR] ${scenario} expected archived unused_timeout but got status=${invoice.status} reason=${invoice.archived_reason}`);
process.exit(1);
}
if (!invoice.archived_at) {
console.error(`[ERROR] ${scenario} expected archived_at not null`);
process.exit(1);
}
if (invoice.linked_reimbursement_id) {
console.error(`[ERROR] ${scenario} expected linked_reimbursement_id null`);
process.exit(1);
}
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
echo "[1/5] pending_ocr -> available"
INVOICE_OK="$(create_pending_invoice "MOCK_OK:${RUN_SUFFIX}")"
run_ocr_once >"$TMP_DIR/ocr-ok.log"
assert_available_invoice "$INVOICE_OK" "ocr pending->available"
echo "[2/4] duplicate invoice -> ocr_failed"
echo "[2/5] duplicate invoice -> ocr_failed"
INVOICE_BASE="$(create_pending_invoice "MOCK_DUP:${RUN_SUFFIX}")"
run_ocr_once >"$TMP_DIR/ocr-dup-base.log"
assert_available_invoice "$INVOICE_BASE" "ocr duplicate base"
@@ -450,7 +598,7 @@ INVOICE_DUP="$(create_pending_invoice "MOCK_DUP:${RUN_SUFFIX}")"
run_ocr_once >"$TMP_DIR/ocr-dup-second.log"
assert_duplicate_failed_invoice "$INVOICE_DUP" "ocr duplicate second"
echo "[3/4] failed retry until terminal"
echo "[3/5] failed retry until terminal"
INVOICE_FAIL="$(create_pending_invoice "MOCK_OCR_FAIL:${RUN_SUFFIX}")"
run_ocr_once >"$TMP_DIR/ocr-fail-1.log"
assert_pending_attempt "$INVOICE_FAIL" "1" "ocr fail attempt 1"
@@ -459,7 +607,7 @@ assert_pending_attempt "$INVOICE_FAIL" "2" "ocr fail attempt 2"
run_ocr_once >"$TMP_DIR/ocr-fail-3.log"
assert_terminal_failed_attempt "$INVOICE_FAIL" "3" "ocr fail terminal"
echo "[4/4] concurrency lock semantics"
echo "[4/5] concurrency lock semantics"
INVOICE_CONCURRENT="$(create_pending_invoice "MOCK_CONCURRENT:${RUN_SUFFIX}")"
(
OCR_PROVIDER=mock OCR_BATCH_SIZE=1 OCR_MAX_ATTEMPTS=3 npm run --silent job:ocr-once >"$TMP_DIR/ocr-concurrent-a.log"
@@ -474,4 +622,11 @@ wait "$PID_B"
assert_available_invoice "$INVOICE_CONCURRENT" "ocr concurrency available"
assert_attempt_count "$INVOICE_CONCURRENT" "1" "ocr concurrency attempts"
echo "[5/5] unused invoice auto archive (90d)"
INVOICE_UNUSED="$(create_available_invoice "UNUSED:${RUN_SUFFIX}")"
OLD_DATE="$(date -u -d "-120 days" +"%Y-%m-%dT%H:%M:%S.000Z")"
set_invoice_created_at "$INVOICE_UNUSED" "$OLD_DATE"
run_archive_once >"$TMP_DIR/archive-unused.log"
assert_unused_archived_invoice "$INVOICE_UNUSED" "invoice unused archive"
echo "[OK] OCR regression passed"
@@ -1317,6 +1317,17 @@ if [[ "$RUN_PAY_CONCURRENCY_CHECK" == "1" ]]; then
assert_invoices_archived_used_complete "$RID_PAY_CONCURRENCY" "pay concurrency"
fi
echo "[22.2/23] invoice unbind/rebind"
RID_UNBIND_A="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/unbind-a-create.json")"
INVOICE_UNBIND="$(create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_UNBIND_A" "unbind")"
unbind_status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$INVOICE_UNBIND/unbind" "$EMPLOYEE_TOKEN" "$TMP_DIR/unbind.json" '{}')"
assert_status "$unbind_status" "200" "invoice unbind" "$TMP_DIR/unbind.json"
assert_jq "$TMP_DIR/unbind.json" '.data.status == "available" and .data.linkedReimbursementId == null and .data.linkedItemId == null' "invoice unbind response"
RID_UNBIND_B="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/unbind-b-create.json")"
rebind_status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$INVOICE_UNBIND/bind" "$EMPLOYEE_TOKEN" "$TMP_DIR/unbind-rebind.json" "{\"reimbursementId\":\"$RID_UNBIND_B\"}")"
assert_status "$rebind_status" "200" "invoice rebind after unbind" "$TMP_DIR/unbind-rebind.json"
echo "[23/23] Final manager relation restore"
set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID"
EMPLOYEE_MANAGER_PATCHED=0
+2
查看文件
@@ -28,6 +28,7 @@ const expenseTypesRoutes = require("./routes/expenseTypes");
const configReadRoutes = require("./routes/configRead");
const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob");
const { startOcrJob } = require("./jobs/ocrJob");
const { startInvoiceArchiveJob } = require("./jobs/invoiceArchiveJob");
const { AppError } = require("./utils/AppError");
async function buildApp() {
@@ -202,6 +203,7 @@ async function buildApp() {
startDelegationRevokeJob(app);
startOcrJob(app);
startInvoiceArchiveJob(app);
return app;
}
+4
查看文件
@@ -69,6 +69,10 @@ function getConfig() {
ocrMaxAttempts: Number(process.env.OCR_MAX_ATTEMPTS ?? 3),
ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS ?? 5000),
invoiceArchiveCron: process.env.INVOICE_ARCHIVE_CRON ?? "30 2 * * *",
invoiceArchiveUnusedDays: Number(process.env.INVOICE_ARCHIVE_UNUSED_DAYS ?? 90),
invoiceArchiveBatchSize: Number(process.env.INVOICE_ARCHIVE_BATCH_SIZE ?? 200),
tencentSecretId: process.env.TENCENT_SECRET_ID ?? "",
tencentSecretKey: process.env.TENCENT_SECRET_KEY ?? "",
tencentOcrRegion: process.env.TENCENT_OCR_REGION ?? "ap-beijing",
+110
查看文件
@@ -0,0 +1,110 @@
const cron = require("node-cron");
async function claimAndArchiveUnusedInvoices(tx, batchSize, unusedDays) {
return tx.$queryRaw`
WITH picked AS (
SELECT id
FROM "invoice_pool"
WHERE linked_reimbursement_id IS NULL
AND status IN ('available', 'ocr_failed')
AND created_at < NOW() - make_interval(days => ${unusedDays})
ORDER BY created_at ASC
LIMIT ${batchSize}
FOR UPDATE SKIP LOCKED
)
UPDATE "invoice_pool" AS i
SET status = 'archived',
archived_reason = 'unused_timeout',
archived_at = NOW(),
linked_item_id = NULL,
ocr_locked_at = NULL,
ocr_lock_owner = NULL,
updated_by = NULL
FROM picked
WHERE i.id = picked.id
AND i.linked_reimbursement_id IS NULL
AND i.status IN ('available', 'ocr_failed')
RETURNING i.id, i.owner_employee_id, i.status, i.archived_at
`;
}
async function archiveUnusedInvoices(prisma, logger, options = {}) {
const batchSize = Math.max(1, Number(options.batchSize ?? 200));
const unusedDays = Math.max(1, Number(options.unusedDays ?? 90));
let total = 0;
let round = 0;
while (true) {
round += 1;
const archivedRows = await prisma.$transaction(async (tx) => {
return claimAndArchiveUnusedInvoices(tx, batchSize, unusedDays);
});
if (!archivedRows.length) {
break;
}
total += archivedRows.length;
logger.info(
{
round,
claimed: archivedRows.length,
updated: archivedRows.length,
archived: archivedRows.length,
unusedDays,
},
"Unused invoices archived batch processed",
);
}
if (total > 0) {
logger.info({ archived: total, unusedDays }, "Unused invoices auto-archived");
}
return total;
}
function startInvoiceArchiveJob(fastify) {
let running = false;
const task = cron.schedule(fastify.config.invoiceArchiveCron, async () => {
if (running) {
return;
}
running = true;
try {
await archiveUnusedInvoices(fastify.prisma, fastify.log, {
batchSize: fastify.config.invoiceArchiveBatchSize,
unusedDays: fastify.config.invoiceArchiveUnusedDays,
});
} catch (error) {
fastify.log.error({ err: error }, "Invoice archive job failed");
} finally {
running = false;
}
});
fastify.addHook("onClose", async () => {
task.stop();
task.destroy();
});
fastify.log.info(
{
cron: fastify.config.invoiceArchiveCron,
unusedDays: fastify.config.invoiceArchiveUnusedDays,
},
"Invoice archive job scheduled",
);
return task;
}
module.exports = {
archiveUnusedInvoices,
startInvoiceArchiveJob,
};
文件差异内容过多而无法显示 加载差异
@@ -0,0 +1,25 @@
require("dotenv/config");
const { createPrismaClient } = require("../prismaClient");
const { getConfig } = require("../config");
const { archiveUnusedInvoices } = require("../jobs/invoiceArchiveJob");
(async () => {
const config = getConfig();
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
const archived = await archiveUnusedInvoices(prisma, console, {
batchSize: config.invoiceArchiveBatchSize,
unusedDays: config.invoiceArchiveUnusedDays,
});
process.stdout.write(`archived=${archived}\n`);
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
+1 -1
查看文件
@@ -3,7 +3,7 @@ const { runMockOcr } = require("./mock");
function isOcrEnabledForType(config, ocrType) {
if (config.ocrProvider === "mock") {
return true;
return ocrType === "vat";
}
return config.ocrProvider === "tencent" && ocrType === "vat";