diff --git a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv index 620b0418..b9796cb6 100644 --- a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv +++ b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv @@ -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;以 admin@hyinda.com/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/departments' | jq;curl -sS -X POST -H 'authorization: Bearer ' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments' -d '{name:...}';curl -sS -X PATCH -H 'authorization: Bearer ' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments/' -d '{name:...}';curl -sS -X POST -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/departments//archive';可选 curl -sS -H 'authorization: Bearer ' '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;以 admin@hyinda.com/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/employees?page=1&pageSize=1' | jq;取 employeeId 后 curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/employees/' | jq;再用普通 employee 登录拿 验证仅可 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 ' 'http://127.0.0.1:3000/api/approval-rules?enabled=true' | jq;以 admin 登录拿 ;curl -sS -H 'authorization: Bearer ' '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;以 admin@hyinda.com/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/delegation-grants' | jq;curl -sS -X POST -H 'authorization: Bearer ' -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 客户端;新增别名优先" diff --git a/server/.env.example b/server/.env.example index fa4c03c4..913c09af 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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" diff --git a/server/package.json b/server/package.json index 4f05254a..ff471983 100644 --- a/server/package.json +++ b/server/package.json @@ -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": "", diff --git a/server/prisma/migrations/20260209082915_add_invoice_ocr_last_requested_at/migration.sql b/server/prisma/migrations/20260209082915_add_invoice_ocr_last_requested_at/migration.sql new file mode 100644 index 00000000..a01435b6 --- /dev/null +++ b/server/prisma/migrations/20260209082915_add_invoice_ocr_last_requested_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "invoice_pool" ADD COLUMN "ocr_last_requested_at" TIMESTAMP(3); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 9e8b6c7b..af3a7d83 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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? diff --git a/server/scripts/regression-audit.sh b/server/scripts/regression-audit.sh index a3a1fd61..717dba8a 100755 --- a/server/scripts/regression-audit.sh +++ b/server/scripts/regression-audit.sh @@ -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" diff --git a/server/scripts/regression-dedup.sh b/server/scripts/regression-dedup.sh index c29cd4b2..c77c5afc 100755 --- a/server/scripts/regression-dedup.sh +++ b/server/scripts/regression-dedup.sh @@ -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" < 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" < 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" < "$TMP_DIR/block-2.pdf" < "$TMP_DIR/non-dedup-1.pdf" < { + const marker = process.env.MARKER || 'AVAILABLE'; + const runSuffix = process.env.RUN_SUFFIX || 'default'; + const email = process.env.EMPLOYEE_IDENTIFIER || 'employee@hyinda.com'; + 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" diff --git a/server/scripts/regression-workflow-actions.sh b/server/scripts/regression-workflow-actions.sh index b1f3f198..cd76e8ac 100755 --- a/server/scripts/regression-workflow-actions.sh +++ b/server/scripts/regression-workflow-actions.sh @@ -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 diff --git a/server/src/app.js b/server/src/app.js index a3312b16..fd6c553f 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -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; } diff --git a/server/src/config.js b/server/src/config.js index 4ffa189b..2e3bcd33 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -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", diff --git a/server/src/jobs/invoiceArchiveJob.js b/server/src/jobs/invoiceArchiveJob.js new file mode 100644 index 00000000..962358ac --- /dev/null +++ b/server/src/jobs/invoiceArchiveJob.js @@ -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, +}; + diff --git a/server/src/routes/invoicePool.js b/server/src/routes/invoicePool.js index 212b15e7..57783c11 100644 --- a/server/src/routes/invoicePool.js +++ b/server/src/routes/invoicePool.js @@ -27,6 +27,32 @@ const bindBodySchema = z.object({ linkedItemId: z.string().uuid().optional(), }); +const invoiceEditBodySchema = z + .object({ + status: z.enum(["available"]).optional(), + remarks: z.string().max(500).nullable().optional(), + invoiceCode: z.string().trim().min(1).max(64).nullable().optional(), + invoiceNumber: z.string().trim().min(1).max(64).nullable().optional(), + invoiceDate: z + .string() + .regex(/^\\d{4}-\\d{2}-\\d{2}$/) + .nullable() + .optional(), + sellerName: z.string().trim().min(1).max(256).nullable().optional(), + buyerName: z.string().trim().min(1).max(256).nullable().optional(), + amount: z.string().trim().min(1).max(32).nullable().optional(), + taxAmount: z.string().trim().min(1).max(32).nullable().optional(), + totalAmount: z.string().trim().min(1).max(32).nullable().optional(), + }) + .strict(); + +const overrideDuplicateBodySchema = z + .object({ + refFormNumber: z.string().trim().min(1).max(64), + reason: z.string().trim().min(1).max(200), + }) + .strict(); + const invoiceListQuerySchema = z.object({ page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), @@ -153,6 +179,46 @@ function ensureInvoiceReadableByUser(request, invoice) { return false; } +function normalizeMoneyInput(value) { + if (value == null) { + return null; + } + + const input = String(value).trim(); + if (!input) { + return null; + } + + if (!/^\\d+(?:\\.\\d{1,2})?$/.test(input)) { + return undefined; + } + + const [intPart, fracPartRaw = ""] = input.split("."); + const fracPart = `${fracPartRaw}00`.slice(0, 2); + return `${intPart}.${fracPart}`; +} + +function parseDateOnlyString(value) { + if (value == null) { + return null; + } + + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(trimmed)) { + return undefined; + } + + return new Date(`${trimmed}T00:00:00.000Z`); +} + function sanitizeFilename(fileName) { if (!fileName) return "file"; return fileName.replace(/[\r\n"]/g, "_"); @@ -220,21 +286,35 @@ function normalizeInvoiceResponse(invoice) { return { id: invoice.id, status: invoice.status, + archivedReason: invoice.archived_reason ?? null, + archivedAt: invoice.archived_at?.toISOString?.() ?? null, ownerEmployeeId: invoice.owner_employee_id, uploadedByEmployeeId: invoice.uploaded_by_employee_id, + originalName: invoice.original_name ?? null, + mimeType: invoice.mime_type ?? null, + fileSize: invoice.file_size ?? null, + fileSha256: invoice.file_sha256 ?? null, + remarks: invoice.remarks ?? null, ocrType: invoice.ocr_type, ocrProvider: invoice.ocr_provider, ocrAttempts: invoice.ocr_attempts, + ocrLastRequestedAt: invoice.ocr_last_requested_at?.toISOString?.() ?? null, ocrLastError: invoice.ocr_last_error, ocrCompletedAt: invoice.ocr_completed_at?.toISOString?.() ?? null, invoiceCode: invoice.invoice_code, invoiceNumber: invoice.invoice_number, invoiceDate: invoice.invoice_date ? invoice.invoice_date.toISOString().slice(0, 10) : null, + sellerName: invoice.seller_name ?? null, + buyerName: invoice.buyer_name ?? null, amount: toMoneyString(invoice.amount), taxAmount: toMoneyString(invoice.tax_amount), totalAmount: toMoneyString(invoice.total_amount), linkedReimbursementId: invoice.linked_reimbursement_id, linkedItemId: invoice.linked_item_id, + isDuplicateOverride: invoice.is_duplicate_override ?? false, + overrideRefFormNumber: invoice.override_ref_form_number ?? null, + overrideReason: invoice.override_reason ?? null, + overriddenBy: invoice.overridden_by ?? null, createdAt: invoice.created_at?.toISOString?.() ?? null, updatedAt: invoice.updated_at?.toISOString?.() ?? null, }; @@ -494,6 +574,7 @@ async function invoicePoolRoutes(fastify) { const status = isOcrEnabledForType(fastify.config, ocrType) ? "pending_ocr" : "available"; const invoice = await fastify.prisma.$transaction(async (tx) => { + const now = new Date(); const created = await tx.invoicePool.create({ data: { owner_employee_id: ownerEmployeeId, @@ -505,6 +586,7 @@ async function invoicePoolRoutes(fastify) { file_sha256: fileSha256, ocr_type: ocrType, status, + ocr_last_requested_at: status === "pending_ocr" ? now : null, created_by: request.employee.id, updated_by: request.employee.id, }, @@ -564,21 +646,35 @@ async function invoicePoolRoutes(fastify) { select: { id: true, status: true, + archived_reason: true, + archived_at: true, owner_employee_id: true, uploaded_by_employee_id: true, + original_name: true, + mime_type: true, + file_size: true, + file_sha256: true, + remarks: true, ocr_type: true, ocr_provider: true, ocr_attempts: true, + ocr_last_requested_at: true, ocr_last_error: true, ocr_completed_at: true, invoice_code: true, invoice_number: true, invoice_date: true, + seller_name: true, + buyer_name: true, amount: true, tax_amount: true, total_amount: true, linked_reimbursement_id: true, linked_item_id: true, + is_duplicate_override: true, + override_ref_form_number: true, + override_reason: true, + overridden_by: true, created_at: true, updated_at: true, linked_reimbursement: { @@ -631,6 +727,1038 @@ async function invoicePoolRoutes(fastify) { }, ); + fastify.patch( + "/invoice-pool/:id", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); + if (!parsedInvoiceId.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { id: "invalid_uuid" }, + }); + } + + const parsedBody = invoiceEditBodySchema.safeParse(request.body ?? {}); + if (!parsedBody.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsedBody.error.flatten(), + }); + } + + if (Object.keys(parsedBody.data).length === 0) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { body: "empty_patch" }, + }); + } + + const invoiceId = parsedInvoiceId.data; + const canManage = fastify.hasPermission(request.employee, "invoice.manage"); + + const invoice = await fastify.prisma.$transaction(async (tx) => { + const locked = await tx.$queryRaw` + SELECT id + FROM "invoice_pool" + WHERE id = ${invoiceId}::uuid + FOR UPDATE + `; + if (!Array.isArray(locked) || locked.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const current = await tx.invoicePool.findUnique({ + where: { id: invoiceId }, + select: { + id: true, + status: true, + archived_reason: true, + archived_at: true, + owner_employee_id: true, + uploaded_by_employee_id: true, + original_name: true, + mime_type: true, + file_size: true, + file_sha256: true, + remarks: true, + ocr_type: true, + ocr_provider: true, + ocr_attempts: true, + ocr_last_requested_at: true, + ocr_last_error: true, + ocr_completed_at: true, + invoice_code: true, + invoice_number: true, + invoice_date: true, + seller_name: true, + buyer_name: true, + amount: true, + tax_amount: true, + total_amount: true, + linked_reimbursement_id: true, + linked_item_id: true, + is_duplicate_override: true, + override_ref_form_number: true, + override_reason: true, + overridden_by: true, + created_at: true, + updated_at: true, + }, + }); + + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const canEdit = + canManage || + current.owner_employee_id === request.employee.id || + current.uploaded_by_employee_id === request.employee.id; + if (!canEdit) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + if (!["available", "ocr_failed"].includes(current.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_status_for_edit" }, + }); + } + + const input = parsedBody.data; + const updateData = {}; + + if ("remarks" in input) { + updateData.remarks = input.remarks; + } + + if ("invoiceCode" in input) { + updateData.invoice_code = input.invoiceCode; + } + + if ("invoiceNumber" in input) { + updateData.invoice_number = input.invoiceNumber; + } + + if ("invoiceDate" in input) { + const parsedDate = parseDateOnlyString(input.invoiceDate); + if (parsedDate === undefined) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { invoiceDate: "invalid_date" }, + }); + } + updateData.invoice_date = parsedDate; + } + + if ("sellerName" in input) { + updateData.seller_name = input.sellerName; + } + + if ("buyerName" in input) { + updateData.buyer_name = input.buyerName; + } + + if ("amount" in input) { + const parsedAmount = normalizeMoneyInput(input.amount); + if (parsedAmount === undefined) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { amount: "invalid_money" }, + }); + } + updateData.amount = parsedAmount; + } + + if ("taxAmount" in input) { + const parsedAmount = normalizeMoneyInput(input.taxAmount); + if (parsedAmount === undefined) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { taxAmount: "invalid_money" }, + }); + } + updateData.tax_amount = parsedAmount; + } + + if ("totalAmount" in input) { + const parsedAmount = normalizeMoneyInput(input.totalAmount); + if (parsedAmount === undefined) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { totalAmount: "invalid_money" }, + }); + } + updateData.total_amount = parsedAmount; + } + + if (input.status) { + if (input.status === "available" && current.status === "ocr_failed") { + updateData.status = "available"; + updateData.ocr_last_error = null; + updateData.ocr_completed_at = new Date(); + updateData.ocr_locked_at = null; + updateData.ocr_lock_owner = null; + } else { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_status_transition" }, + }); + } + } + + if (Object.keys(updateData).length === 0) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { body: "empty_patch" }, + }); + } + + updateData.updated_by = request.employee.id; + + const updated = await tx.invoicePool.update({ + where: { id: invoiceId }, + data: updateData, + select: { + id: true, + status: true, + archived_reason: true, + archived_at: true, + owner_employee_id: true, + uploaded_by_employee_id: true, + original_name: true, + mime_type: true, + file_size: true, + file_sha256: true, + remarks: true, + ocr_type: true, + ocr_provider: true, + ocr_attempts: true, + ocr_last_requested_at: true, + ocr_last_error: true, + ocr_completed_at: true, + invoice_code: true, + invoice_number: true, + invoice_date: true, + seller_name: true, + buyer_name: true, + amount: true, + tax_amount: true, + total_amount: true, + linked_reimbursement_id: true, + linked_item_id: true, + is_duplicate_override: true, + override_ref_form_number: true, + override_reason: true, + overridden_by: true, + created_at: true, + updated_at: true, + }, + }); + + const changes = {}; + const trackedFields = [ + "status", + "remarks", + "invoice_code", + "invoice_number", + "invoice_date", + "seller_name", + "buyer_name", + "amount", + "tax_amount", + "total_amount", + ]; + + for (const field of trackedFields) { + const before = current[field]; + const after = updated[field]; + + const beforeValue = + before instanceof Date + ? before.toISOString().slice(0, 10) + : typeof before === "object" && before?.toString + ? before.toString() + : before ?? null; + const afterValue = + after instanceof Date + ? after.toISOString().slice(0, 10) + : typeof after === "object" && after?.toString + ? after.toString() + : after ?? null; + + if (beforeValue !== afterValue) { + changes[field] = { + before: beforeValue, + after: afterValue, + }; + } + } + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "INVOICE_POOL_EDITED", + targetType: "invoice_pool", + targetId: invoiceId, + extra: { + changes, + }, + }), + }); + + return updated; + }); + + return sendData(reply, request, normalizeInvoiceResponse(invoice)); + }, + ); + + fastify.post( + "/invoice-pool/:id/archive", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); + if (!parsedInvoiceId.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { id: "invalid_uuid" }, + }); + } + + const invoiceId = parsedInvoiceId.data; + const canManage = fastify.hasPermission(request.employee, "invoice.manage"); + + const invoice = await fastify.prisma.$transaction(async (tx) => { + const locked = await tx.$queryRaw` + SELECT id + FROM "invoice_pool" + WHERE id = ${invoiceId}::uuid + FOR UPDATE + `; + if (!Array.isArray(locked) || locked.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const current = await tx.invoicePool.findUnique({ + where: { id: invoiceId }, + select: { + id: true, + status: true, + linked_reimbursement_id: true, + owner_employee_id: true, + uploaded_by_employee_id: true, + archived_reason: true, + }, + }); + + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const canEdit = + canManage || + current.owner_employee_id === request.employee.id || + current.uploaded_by_employee_id === request.employee.id; + if (!canEdit) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + if (current.linked_reimbursement_id) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { invoice: "cannot_archive_bound_invoice" }, + }); + } + + if (current.status === "archived" && current.archived_reason === "manual") { + return tx.invoicePool.findUnique({ + where: { id: invoiceId }, + select: { + id: true, + status: true, + archived_reason: true, + archived_at: true, + owner_employee_id: true, + uploaded_by_employee_id: true, + original_name: true, + mime_type: true, + file_size: true, + file_sha256: true, + remarks: true, + ocr_type: true, + ocr_provider: true, + ocr_attempts: true, + ocr_last_requested_at: true, + ocr_last_error: true, + ocr_completed_at: true, + invoice_code: true, + invoice_number: true, + invoice_date: true, + seller_name: true, + buyer_name: true, + amount: true, + tax_amount: true, + total_amount: true, + linked_reimbursement_id: true, + linked_item_id: true, + is_duplicate_override: true, + override_ref_form_number: true, + override_reason: true, + overridden_by: true, + created_at: true, + updated_at: true, + }, + }); + } + + if (!["available", "ocr_failed"].includes(current.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_status_for_archive" }, + }); + } + + const now = new Date(); + const updated = await tx.invoicePool.update({ + where: { id: invoiceId }, + data: { + status: "archived", + archived_reason: "manual", + archived_at: now, + updated_by: request.employee.id, + }, + select: { + id: true, + status: true, + archived_reason: true, + archived_at: true, + owner_employee_id: true, + uploaded_by_employee_id: true, + original_name: true, + mime_type: true, + file_size: true, + file_sha256: true, + remarks: true, + ocr_type: true, + ocr_provider: true, + ocr_attempts: true, + ocr_last_requested_at: true, + ocr_last_error: true, + ocr_completed_at: true, + invoice_code: true, + invoice_number: true, + invoice_date: true, + seller_name: true, + buyer_name: true, + amount: true, + tax_amount: true, + total_amount: true, + linked_reimbursement_id: true, + linked_item_id: true, + is_duplicate_override: true, + override_ref_form_number: true, + override_reason: true, + overridden_by: true, + created_at: true, + updated_at: true, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "INVOICE_POOL_ARCHIVED", + targetType: "invoice_pool", + targetId: invoiceId, + extra: { + reason: "manual", + }, + }), + }); + + return updated; + }); + + return sendData(reply, request, normalizeInvoiceResponse(invoice)); + }, + ); + + fastify.post( + "/invoice-pool/:id/unbind", + { + preHandler: fastify.requirePermission("invoice.upload"), + }, + async (request, reply) => { + const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); + if (!parsedInvoiceId.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { id: "invalid_uuid" }, + }); + } + + const invoiceId = parsedInvoiceId.data; + let responseInvoice = null; + + await fastify.prisma.$transaction(async (tx) => { + const invoiceRows = await tx.$queryRaw` + SELECT id, status, linked_reimbursement_id + FROM "invoice_pool" + WHERE id = ${invoiceId}::uuid + FOR UPDATE + `; + + if (!Array.isArray(invoiceRows) || invoiceRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const invoice = invoiceRows[0]; + if (invoice.status !== "bound" || !invoice.linked_reimbursement_id) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { invoice: "not_bound" }, + }); + } + + const reimbursementRows = await tx.$queryRaw` + SELECT id, status, owner_id, submitter_id + FROM "reimbursement_forms" + WHERE id = ${invoice.linked_reimbursement_id}::uuid + FOR UPDATE + `; + + if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const reimbursement = reimbursementRows[0]; + const isOwn = + reimbursement.owner_id === request.employee.id || + reimbursement.submitter_id === request.employee.id; + const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); + if (!isOwn && !canViewAll) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + if (!["draft", "rejected"].includes(reimbursement.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { reimbursement: "invalid_status_for_unbind" }, + }); + } + + responseInvoice = await tx.invoicePool.update({ + where: { id: invoiceId }, + data: { + status: "available", + linked_reimbursement_id: null, + linked_item_id: null, + updated_by: request.employee.id, + }, + select: { + id: true, + status: true, + archived_reason: true, + archived_at: true, + owner_employee_id: true, + uploaded_by_employee_id: true, + original_name: true, + mime_type: true, + file_size: true, + file_sha256: true, + remarks: true, + ocr_type: true, + ocr_provider: true, + ocr_attempts: true, + ocr_last_requested_at: true, + ocr_last_error: true, + ocr_completed_at: true, + invoice_code: true, + invoice_number: true, + invoice_date: true, + seller_name: true, + buyer_name: true, + amount: true, + tax_amount: true, + total_amount: true, + linked_reimbursement_id: true, + linked_item_id: true, + is_duplicate_override: true, + override_ref_form_number: true, + override_reason: true, + overridden_by: true, + created_at: true, + updated_at: true, + }, + }); + }); + + return sendData(reply, request, normalizeInvoiceResponse(responseInvoice)); + }, + ); + + fastify.post( + "/invoice-pool/:id/retry-ocr", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); + if (!parsedInvoiceId.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { id: "invalid_uuid" }, + }); + } + + const invoiceId = parsedInvoiceId.data; + const canManage = fastify.hasPermission(request.employee, "invoice.manage"); + + const invoice = await fastify.prisma.$transaction(async (tx) => { + const locked = await tx.$queryRaw` + SELECT id + FROM "invoice_pool" + WHERE id = ${invoiceId}::uuid + FOR UPDATE + `; + if (!Array.isArray(locked) || locked.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const current = await tx.invoicePool.findUnique({ + where: { id: invoiceId }, + select: { + id: true, + status: true, + ocr_type: true, + ocr_attempts: true, + ocr_last_requested_at: true, + owner_employee_id: true, + uploaded_by_employee_id: true, + }, + }); + + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const canEdit = + canManage || + current.owner_employee_id === request.employee.id || + current.uploaded_by_employee_id === request.employee.id; + if (!canEdit) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + if (current.status !== "ocr_failed") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_status_for_retry" }, + }); + } + + if (!isOcrEnabledForType(fastify.config, current.ocr_type)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { ocr: "disabled" }, + }); + } + + const maxAttempts = Number(fastify.config.ocrMaxAttempts ?? 3); + if ((current.ocr_attempts ?? 0) >= maxAttempts) { + throw new AppError({ + code: "VALID_006", + message: "OCR 重试次数已达上限", + statusCode: 409, + details: { ocr: "attempts_exceeded" }, + }); + } + + const now = new Date(); + if (current.ocr_last_requested_at) { + const diffMs = now.getTime() - new Date(current.ocr_last_requested_at).getTime(); + if (diffMs < 60_000) { + throw new AppError({ + code: "VALID_007", + message: "OCR 重试过于频繁,请稍后再试", + statusCode: 409, + details: { ocr: "too_frequent" }, + }); + } + } + + const updated = await tx.invoicePool.update({ + where: { id: invoiceId }, + data: { + status: "pending_ocr", + ocr_last_requested_at: now, + ocr_last_error: null, + ocr_completed_at: null, + ocr_locked_at: null, + ocr_lock_owner: null, + updated_by: request.employee.id, + }, + select: { + id: true, + status: true, + archived_reason: true, + archived_at: true, + owner_employee_id: true, + uploaded_by_employee_id: true, + original_name: true, + mime_type: true, + file_size: true, + file_sha256: true, + remarks: true, + ocr_type: true, + ocr_provider: true, + ocr_attempts: true, + ocr_last_requested_at: true, + ocr_last_error: true, + ocr_completed_at: true, + invoice_code: true, + invoice_number: true, + invoice_date: true, + seller_name: true, + buyer_name: true, + amount: true, + tax_amount: true, + total_amount: true, + linked_reimbursement_id: true, + linked_item_id: true, + is_duplicate_override: true, + override_ref_form_number: true, + override_reason: true, + overridden_by: true, + created_at: true, + updated_at: true, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "INVOICE_OCR_RETRIED", + targetType: "invoice_pool", + targetId: invoiceId, + extra: { + attempts: current.ocr_attempts ?? 0, + nextStatus: "pending_ocr", + }, + }), + }); + + return updated; + }); + + return sendData(reply, request, normalizeInvoiceResponse(invoice)); + }, + ); + + fastify.post( + "/invoice-pool/:id/override-duplicate", + { + preHandler: fastify.requirePermission("expense.finance.override_duplicate"), + }, + async (request, reply) => { + const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); + if (!parsedInvoiceId.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { id: "invalid_uuid" }, + }); + } + + const parsedBody = overrideDuplicateBodySchema.safeParse(request.body ?? {}); + if (!parsedBody.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsedBody.error.flatten(), + }); + } + + const invoiceId = parsedInvoiceId.data; + const { refFormNumber, reason } = parsedBody.data; + + const invoice = await fastify.prisma.$transaction(async (tx) => { + const locked = await tx.$queryRaw` + SELECT id + FROM "invoice_pool" + WHERE id = ${invoiceId}::uuid + FOR UPDATE + `; + if (!Array.isArray(locked) || locked.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const current = await tx.invoicePool.findUnique({ + where: { id: invoiceId }, + select: { + id: true, + status: true, + linked_reimbursement_id: true, + file_sha256: true, + invoice_code: true, + invoice_number: true, + is_duplicate_override: true, + }, + }); + + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + if (current.linked_reimbursement_id) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { invoice: "already_bound" }, + }); + } + + if (!["available", "ocr_failed"].includes(current.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_status_for_override" }, + }); + } + + if (!current.file_sha256 && !(current.invoice_code && current.invoice_number)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { invoice: "missing_dedup_key" }, + }); + } + + const dedupFilters = []; + if (current.file_sha256) { + dedupFilters.push({ file_sha256: current.file_sha256 }); + } + if (current.invoice_code && current.invoice_number) { + dedupFilters.push({ + invoice_code: current.invoice_code, + invoice_number: current.invoice_number, + }); + } + + const conflicts = await tx.invoicePool.findMany({ + where: { + id: { not: invoiceId }, + is_duplicate_override: false, + AND: [ + { OR: dedupFilters }, + { + OR: [ + { status: "bound" }, + { status: "archived", archived_reason: "used_complete" }, + ], + }, + ], + }, + select: { + id: true, + status: true, + linked_reimbursement_id: true, + linked_reimbursement: { + select: { + status: true, + form_number: true, + }, + }, + }, + }); + + const blocked = conflicts.find((item) => + ["approved", "paid"].includes(item.linked_reimbursement?.status), + ); + if (blocked) { + throw new AppError({ + code: "VALID_004", + message: "原单已审批/已付款,禁止判重例外", + statusCode: 409, + details: { + conflictInvoiceId: blocked.id, + conflictFormNumber: blocked.linked_reimbursement?.form_number ?? null, + }, + }); + } + + const updated = await tx.invoicePool.update({ + where: { id: invoiceId }, + data: { + is_duplicate_override: true, + override_ref_form_number: refFormNumber, + override_reason: reason, + overridden_by: request.employee.id, + updated_by: request.employee.id, + }, + select: { + id: true, + status: true, + archived_reason: true, + archived_at: true, + owner_employee_id: true, + uploaded_by_employee_id: true, + original_name: true, + mime_type: true, + file_size: true, + file_sha256: true, + remarks: true, + ocr_type: true, + ocr_provider: true, + ocr_attempts: true, + ocr_last_requested_at: true, + ocr_last_error: true, + ocr_completed_at: true, + invoice_code: true, + invoice_number: true, + invoice_date: true, + seller_name: true, + buyer_name: true, + amount: true, + tax_amount: true, + total_amount: true, + linked_reimbursement_id: true, + linked_item_id: true, + is_duplicate_override: true, + override_ref_form_number: true, + override_reason: true, + overridden_by: true, + created_at: true, + updated_at: true, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "INVOICE_DUPLICATE_OVERRIDDEN", + targetType: "invoice_pool", + targetId: invoiceId, + extra: { + refFormNumber, + reason, + conflictInvoiceIds: conflicts.map((item) => item.id), + }, + }), + }); + + return updated; + }); + + return sendData(reply, request, normalizeInvoiceResponse(invoice)); + }, + ); + fastify.post( "/invoice-pool/:id/signed-url", { @@ -705,6 +1833,7 @@ async function invoicePoolRoutes(fastify) { const token = signPayload( { invoiceId: invoice.id, + actorId: request.employee.id, exp: expiresAt, }, fastify.config.jwtSecret, @@ -715,6 +1844,19 @@ async function invoicePoolRoutes(fastify) { `${request.protocol}://${request.headers.host ?? "localhost:3000"}`; const signedUrl = `${origin}/api/invoice-pool/${invoice.id}/file?token=${encodeURIComponent(token)}`; + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "INVOICE_SIGNED_URL_ISSUED", + targetType: "invoice_pool", + targetId: invoice.id, + extra: { + invoiceId: invoice.id, + expiresAt, + }, + }), + }); + return sendData(reply, request, { signedUrl, expiresAt: new Date(expiresAt).toISOString(), @@ -743,10 +1885,12 @@ async function invoicePoolRoutes(fastify) { } const payload = verified.payload; + const parsedActorId = z.string().uuid().safeParse(payload?.actorId); if ( payload?.invoiceId !== parsedInvoiceId.data || typeof payload?.exp !== "number" || - payload.exp <= Date.now() + payload.exp <= Date.now() || + !parsedActorId.success ) { throw new AppError({ code: "PERM_001", @@ -755,6 +1899,8 @@ async function invoicePoolRoutes(fastify) { }); } + const actorId = parsedActorId.data; + const invoice = await fastify.prisma.invoicePool.findUnique({ where: { id: parsedInvoiceId.data }, select: { @@ -792,6 +1938,23 @@ async function invoicePoolRoutes(fastify) { }); } + try { + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId, + action: "INVOICE_FILE_DOWNLOADED", + targetType: "invoice_pool", + targetId: parsedInvoiceId.data, + extra: { + invoiceId: parsedInvoiceId.data, + exp: payload.exp, + }, + }), + }); + } catch (error) { + fastify.log.error({ err: error, invoiceId: parsedInvoiceId.data }, "Audit log write failed"); + } + reply.type(invoice.mime_type || "application/octet-stream"); reply.header( "content-disposition", diff --git a/server/src/scripts/run-invoice-archive-once.js b/server/src/scripts/run-invoice-archive-once.js new file mode 100644 index 00000000..28a8a26b --- /dev/null +++ b/server/src/scripts/run-invoice-archive-once.js @@ -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); +}); + diff --git a/server/src/services/ocr/index.js b/server/src/services/ocr/index.js index eadeb8ad..90e16938 100644 --- a/server/src/services/ocr/index.js +++ b/server/src/services/ocr/index.js @@ -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";