From 65e7b37ebd51d24687b620c51ccf34312c4be005 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 8 Feb 2026 23:08:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=AE=8C=E6=88=90=20P3=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AFAPI=E4=B8=8E=E5=9B=9E=E5=BD=92=EF=BC=88D2~D9?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workflow: 任务详情/转审候选/列表分页字段(D5) - reimbursements: 列表API + pay凭证字段/幂等增强(D7) - invoice-pool: 列表/详情API + 上传pending_ocr(D9) - ocr/audit: mock provider + 回归脚本 + regression-all开关(D2/D3) - CI: 新增 ci-minimal / ci-regression 工作流(D4) --- .github/workflows/ci-minimal.yml | 37 +++ .github/workflows/ci-regression.yml | 95 ++++++ scripts/check-minimal.sh | 6 + server/README.md | 30 +- server/scripts/regression-all.sh | 26 +- server/scripts/regression-audit.sh | 193 +++++++++++ server/scripts/regression-ocr.sh | 477 ++++++++++++++++++++++++++++ server/src/jobs/ocrJob.js | 2 +- server/src/routes/invoicePool.js | 187 +++++++++++ server/src/routes/reimbursements.js | 164 ++++++++++ server/src/routes/workflow.js | 434 +++++++++++++++++++++++-- server/src/scripts/run-ocr-once.js | 2 +- server/src/services/ocr/index.js | 9 + server/src/services/ocr/mock.js | 60 ++++ 14 files changed, 1681 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/ci-minimal.yml create mode 100644 .github/workflows/ci-regression.yml create mode 100755 server/scripts/regression-audit.sh create mode 100755 server/scripts/regression-ocr.sh create mode 100644 server/src/services/ocr/mock.js diff --git a/.github/workflows/ci-minimal.yml b/.github/workflows/ci-minimal.yml new file mode 100644 index 00000000..dcb6678a --- /dev/null +++ b/.github/workflows/ci-minimal.yml @@ -0,0 +1,37 @@ +name: ci-minimal + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + check-minimal: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: | + server/package-lock.json + client/package-lock.json + + - name: Install server dependencies + working-directory: server + run: npm ci + + - name: Install client dependencies + working-directory: client + run: npm ci + + - name: Run minimal checks + run: ./scripts/check-minimal.sh diff --git a/.github/workflows/ci-regression.yml b/.github/workflows/ci-regression.yml new file mode 100644 index 00000000..2d5773d7 --- /dev/null +++ b/.github/workflows/ci-regression.yml @@ -0,0 +1,95 @@ +name: ci-regression + +on: + workflow_dispatch: + schedule: + - cron: "30 18 * * *" + +jobs: + server-regression: + runs-on: ubuntu-latest + timeout-minutes: 45 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: hyb_oa + POSTGRES_USER: hyboa_user + POSTGRES_PASSWORD: hyboa_password + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U hyboa_user -d hyb_oa" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + NODE_ENV: test + PORT: 3000 + HOST: 127.0.0.1 + APP_ORIGIN: http://127.0.0.1:5173 + DATABASE_URL: postgresql://hyboa_user:hyboa_password@127.0.0.1:5432/hyb_oa?schema=public + JWT_SECRET: ci_regression_secret_key + OCR_PROVIDER: mock + OCR_MAX_ATTEMPTS: 3 + DELEGATION_REVOKE_CRON: "0 * * * *" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: server/package-lock.json + + - name: Install server dependencies + working-directory: server + run: npm ci + + - name: Migrate database + working-directory: server + run: npm run prisma:migrate:deploy + + - name: Seed database + working-directory: server + run: npm run seed:dev + + - name: Start server + working-directory: server + run: | + nohup npm run dev > /tmp/hyb-oa-server.log 2>&1 & + echo $! > /tmp/hyb-oa-server.pid + + - name: Wait for health + run: | + for i in {1..30}; do + if curl -fsS "http://127.0.0.1:3000/health" >/dev/null; then + exit 0 + fi + sleep 2 + done + echo "Server health check timed out" >&2 + exit 1 + + - name: Run full regression suite + working-directory: server + run: | + RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 ./scripts/regression-all.sh + + - name: Stop server + if: always() + run: | + if [[ -f /tmp/hyb-oa-server.pid ]]; then + kill "$(cat /tmp/hyb-oa-server.pid)" || true + fi + + - name: Dump server log on failure + if: failure() + run: | + echo "==== /tmp/hyb-oa-server.log ====" + cat /tmp/hyb-oa-server.log || true diff --git a/scripts/check-minimal.sh b/scripts/check-minimal.sh index eb2b4862..3071a044 100755 --- a/scripts/check-minimal.sh +++ b/scripts/check-minimal.sh @@ -16,8 +16,14 @@ echo "=== [2/3] Server Static Checks ===" node --check src/routes/reimbursements.js node --check src/routes/workflow.js node --check src/routes/delegations.js + node --check src/routes/invoicePool.js + node --check src/routes/audit.js node --check src/jobs/delegationRevokeJob.js + node --check src/jobs/ocrJob.js + node --check src/services/ocr/index.js bash -n scripts/regression-workflow-actions.sh + bash -n scripts/regression-audit.sh + bash -n scripts/regression-ocr.sh bash -n scripts/regression-all.sh ) diff --git a/server/README.md b/server/README.md index 471e5436..989d73cf 100644 --- a/server/README.md +++ b/server/README.md @@ -117,7 +117,20 @@ cd /home/shunli/Projects/hyb-oa/server ./scripts/regression-all.sh ``` -串联 `smoke-test.sh + regression-dedup.sh + regression-workflow-actions.sh`,适用于 CI 或发版前检查。 +串联 `smoke-test.sh + regression-dedup.sh + regression-workflow-actions.sh + regression-audit.sh + regression-ocr.sh`,适用于 CI 或发版前检查。 + +可选关闭审计/OCR: +```bash +cd /home/shunli/Projects/hyb-oa/server +RUN_AUDIT_REGRESSION=0 RUN_OCR_REGRESSION=0 ./scripts/regression-all.sh +``` + +独立执行: +```bash +cd /home/shunli/Projects/hyb-oa/server +./scripts/regression-audit.sh +OCR_PROVIDER=mock ./scripts/regression-ocr.sh +``` ### 4.5 Postman 回归 - Collection: `server/postman/hyb-oa.postman_collection.json` @@ -173,7 +186,16 @@ npm run dev - 权限:`system.audit.view`(finance_mgr/admin) - 支持分页与筛选(actor/action/targetType/date range)。 -### 5.5 定时任务(可手动执行) +### 5.5 工作流查询增强(P3) +- `GET /api/workflow/tasks`:支持 `status/page/pageSize`,返回 `page/pageSize/total/items`。 +- `GET /api/workflow/tasks/:taskId`:任务详情(实例、报销摘要、可执行动作、处理历史)。 +- `GET /api/workflow/approvers`:转审候选人列表(active + 具备 `expense.approve`)。 + +### 5.6 列表查询接口(P3) +- `GET /api/reimbursements`:支持 `page/pageSize/status/scope`。 +- `GET /api/invoice-pool`:支持 `page/pageSize/status/ownerScope`。 + +### 5.7 定时任务(可手动执行) ```bash cd /home/shunli/Projects/hyb-oa/server npm run job:revoke-expired-delegations @@ -190,7 +212,7 @@ npm run job:ocr-once - `SIGNED_URL_TTL_SECONDS`: 默认 `600` - `MAX_UPLOAD_SIZE_BYTES`: 默认 `10485760`(10MB) - `DELEGATION_REVOKE_CRON`: 委派自动回收定时表达式(默认 `0 * * * *`) -- `OCR_PROVIDER`: `off` 或 `tencent`(默认 `off`) +- `OCR_PROVIDER`: `off` / `mock` / `tencent`(默认 `off`) - `OCR_BATCH_SIZE`: 每轮 OCR 认领数量(默认 `10`) - `OCR_LOCK_TTL_SECONDS`: OCR 锁超时秒数(默认 `300`) - `OCR_POLL_CRON`: OCR 轮询 cron(默认 `* * * * *`) @@ -211,7 +233,7 @@ npm run job:ocr-once | signed-url 接口返回 403 | `JWT_SECRET` 不一致、token 过期、访问人无权限 | 统一服务密钥、确认 URL 在 TTL 内、检查账号角色 | | 上传返回 `file_too_large` | 文件超 `MAX_UPLOAD_SIZE_BYTES` | 调整环境变量或使用更小文件 | | 上传失败且报目录/权限错误 | `STORAGE_DIR` 不存在或不可写 | 创建目录并修正权限;Docker 需确认 volume 挂载 | -| OCR 长期停留 `pending_ocr` | `OCR_PROVIDER=off` 或密钥无效 | 检查 OCR 环境变量,手动执行 `npm run job:ocr-once` 验证 | +| OCR 长期停留 `pending_ocr` | `OCR_PROVIDER=off`、mock未开启或腾讯密钥无效 | 检查 OCR 环境变量,优先用 `OCR_PROVIDER=mock npm run job:ocr-once` 验证链路 | | 委派过期后仍可操作 | 自动回收任务未运行 | 检查 `DELEGATION_REVOKE_CRON`,手动执行 `npm run job:revoke-expired-delegations` | --- diff --git a/server/scripts/regression-all.sh b/server/scripts/regression-all.sh index 2fb95b59..2d9ceaf8 100755 --- a/server/scripts/regression-all.sh +++ b/server/scripts/regression-all.sh @@ -2,17 +2,37 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RUN_AUDIT_REGRESSION="${RUN_AUDIT_REGRESSION:-1}" +RUN_OCR_REGRESSION="${RUN_OCR_REGRESSION:-1}" -echo "=== [1/3] Smoke Test ===" +echo "=== [1/5] Smoke Test ===" "$SCRIPT_DIR/smoke-test.sh" echo "" -echo "=== [2/3] Dedup Regression ===" +echo "=== [2/5] Dedup Regression ===" "$SCRIPT_DIR/regression-dedup.sh" echo "" -echo "=== [3/3] Workflow Actions Regression ===" +echo "=== [3/5] Workflow Actions Regression ===" "$SCRIPT_DIR/regression-workflow-actions.sh" echo "" +if [[ "$RUN_AUDIT_REGRESSION" == "1" ]]; then + echo "=== [4/5] Audit Regression ===" + "$SCRIPT_DIR/regression-audit.sh" + echo "" +else + echo "=== [4/5] Audit Regression (skipped, RUN_AUDIT_REGRESSION=$RUN_AUDIT_REGRESSION) ===" + echo "" +fi + +if [[ "$RUN_OCR_REGRESSION" == "1" ]]; then + echo "=== [5/5] OCR Regression ===" + "$SCRIPT_DIR/regression-ocr.sh" + echo "" +else + echo "=== [5/5] OCR Regression (skipped, RUN_OCR_REGRESSION=$RUN_OCR_REGRESSION) ===" + echo "" +fi + echo "[OK] All regressions passed" diff --git a/server/scripts/regression-audit.sh b/server/scripts/regression-audit.sh new file mode 100755 index 00000000..a3a1fd61 --- /dev/null +++ b/server/scripts/regression-audit.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://127.0.0.1:3000}" +DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-audit}" +EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}" +EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}" +MANAGER_IDENTIFIER="${MANAGER_IDENTIFIER:-manager@hyinda.com}" +MANAGER_PASSWORD="${MANAGER_PASSWORD:-ManagerPassword1234}" +FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}" +FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}" +CEO_IDENTIFIER="${CEO_IDENTIFIER:-ceo@hyinda.com}" + +if ! command -v curl >/dev/null 2>&1; then + echo "[ERROR] curl not found" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "[ERROR] jq not found" >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +RUN_SUFFIX="$(date +%s)-$RANDOM" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +assert_status() { + local actual="$1" + local expected="$2" + local name="$3" + local body_file="${4:-}" + + if [[ "$actual" != "$expected" ]]; then + echo "[ERROR] $name expected HTTP $expected but got $actual" >&2 + if [[ -n "$body_file" && -f "$body_file" ]]; then + cat "$body_file" >&2 + fi + exit 1 + fi +} + +assert_error_code() { + local file_path="$1" + local expected="$2" + local name="$3" + local actual + actual="$(jq -r '.error.code // empty' "$file_path")" + if [[ "$actual" != "$expected" ]]; then + echo "[ERROR] $name expected error code $expected but got $actual" >&2 + cat "$file_path" >&2 + exit 1 + fi +} + +assert_jq() { + local file_path="$1" + local jq_expr="$2" + local name="$3" + + if ! jq -e "$jq_expr" "$file_path" >/dev/null; then + echo "[ERROR] $name assertion failed: $jq_expr" >&2 + cat "$file_path" >&2 + exit 1 + fi +} + +api_json_auth() { + 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 +} + +login_user() { + local identifier="$1" + local password="$2" + local fingerprint="$3" + local out_file="$4" + + local status + status="$(curl -sS -o "$out_file" -w "%{http_code}" \ + -X POST "$BASE_URL/api/auth/login" \ + -H "content-type: application/json" \ + -H "x-device-fingerprint: $fingerprint" \ + -d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")" + + assert_status "$status" "200" "login $identifier" "$out_file" + jq -er '.data.accessToken' "$out_file" +} + +auth_get() { + local url="$1" + local token="$2" + local out_file="$3" + + curl -sS -o "$out_file" -w "%{http_code}" \ + -X GET "$url" \ + -H "authorization: Bearer $token" +} + +get_me_id() { + local token="$1" + local out_file="$2" + + local status + status="$(auth_get "$BASE_URL/api/me" "$token" "$out_file")" + assert_status "$status" "200" "get me" "$out_file" + jq -er '.data.id' "$out_file" +} + +create_delegation() { + local token="$1" + local delegate_id="$2" + local valid_from="$3" + local valid_to="$4" + local out_file="$5" + + local status + status="$(api_json_auth POST "$BASE_URL/api/delegations" "$token" "$out_file" "{\"delegateEmployeeId\":\"$delegate_id\",\"scope\":\"all\",\"validFrom\":\"$valid_from\",\"validTo\":\"$valid_to\"}")" + assert_status "$status" "200" "create delegation" "$out_file" + jq -er '.data.delegation.id' "$out_file" +} + +revoke_delegation() { + local token="$1" + local delegation_id="$2" + local out_file="$3" + + local status + status="$(api_json_auth POST "$BASE_URL/api/delegations/$delegation_id/revoke" "$token" "$out_file" '{}')" + assert_status "$status" "200" "revoke delegation" "$out_file" +} + +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")" +FINANCE_MGR_TOKEN="$(login_user "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$DEVICE_FINGERPRINT-finance-mgr-$RUN_SUFFIX" "$TMP_DIR/login-finance-mgr.json")" +CEO_TOKEN="$(login_user "$CEO_IDENTIFIER" "${CEO_PASSWORD:-CeoPassword1234}" "$DEVICE_FINGERPRINT-ceo-$RUN_SUFFIX" "$TMP_DIR/login-ceo.json")" + +echo "[2/6] permission check: non-audit role forbidden" +perm_status="$(auth_get "$BASE_URL/api/audit-logs?page=1&pageSize=5" "$EMPLOYEE_TOKEN" "$TMP_DIR/audit-perm.json")" +assert_status "$perm_status" "403" "audit permission" "$TMP_DIR/audit-perm.json" +assert_error_code "$TMP_DIR/audit-perm.json" "PERM_001" "audit permission" + +echo "[3/6] create deterministic audit records" +CEO_ID="$(get_me_id "$CEO_TOKEN" "$TMP_DIR/me-ceo.json")" +VALID_FROM="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" +VALID_TO="$(date -u -d "+1 day" +"%Y-%m-%dT%H:%M:%S.000Z")" +DELEGATION_ID="$(create_delegation "$MANAGER_TOKEN" "$CEO_ID" "$VALID_FROM" "$VALID_TO" "$TMP_DIR/delegation-create.json")" +revoke_delegation "$MANAGER_TOKEN" "$DELEGATION_ID" "$TMP_DIR/delegation-revoke.json" + +echo "[4/6] pagination and base response" +page_status="$(auth_get "$BASE_URL/api/audit-logs?page=1&pageSize=3" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-page.json")" +assert_status "$page_status" "200" "audit list page" "$TMP_DIR/audit-page.json" +assert_jq "$TMP_DIR/audit-page.json" '.data.page == 1 and .data.pageSize == 3 and (.data.total|type=="number") and (.data.items|type=="array")' "audit list page" + +echo "[5/6] action/targetType/date filters" +filter_status="$(auth_get "$BASE_URL/api/audit-logs?action=DELEGATION_CREATE&targetType=delegation_grant&page=1&pageSize=20" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-filter-action.json")" +assert_status "$filter_status" "200" "audit filter action" "$TMP_DIR/audit-filter-action.json" +assert_jq "$TMP_DIR/audit-filter-action.json" '.data.items | any(.action == "DELEGATION_CREATE" and .targetType == "delegation_grant")' "audit filter action" + +range_status="$(auth_get "$BASE_URL/api/audit-logs?dateFrom=2026-01-01T00:00:00.000Z&dateTo=2026-12-31T23:59:59.000Z&page=1&pageSize=10" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-filter-date.json")" +assert_status "$range_status" "200" "audit filter date" "$TMP_DIR/audit-filter-date.json" +assert_jq "$TMP_DIR/audit-filter-date.json" '.data.items|type=="array"' "audit filter date" + +echo "[6/6] invalid query check" +invalid_status="$(auth_get "$BASE_URL/api/audit-logs?page=0&pageSize=20" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-invalid-page.json")" +assert_status "$invalid_status" "400" "audit invalid page" "$TMP_DIR/audit-invalid-page.json" +assert_error_code "$TMP_DIR/audit-invalid-page.json" "VALID_001" "audit invalid page" + +date_invalid_status="$(auth_get "$BASE_URL/api/audit-logs?dateFrom=2026-02-10T00:00:00.000Z&dateTo=2026-02-01T00:00:00.000Z" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-invalid-date.json")" +assert_status "$date_invalid_status" "400" "audit invalid date" "$TMP_DIR/audit-invalid-date.json" +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 "[OK] Audit regression passed" diff --git a/server/scripts/regression-ocr.sh b/server/scripts/regression-ocr.sh new file mode 100755 index 00000000..0eb3a932 --- /dev/null +++ b/server/scripts/regression-ocr.sh @@ -0,0 +1,477 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if ! command -v node >/dev/null 2>&1; then + echo "[ERROR] node not found" >&2 + exit 1 +fi + +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 + +EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}" +RUN_SUFFIX="$(date +%s)-$RANDOM" +TMP_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +run_ocr_once() { + OCR_PROVIDER=mock OCR_MAX_ATTEMPTS=3 npm run --silent job:ocr-once +} + +create_pending_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 || 'MOCK_OK'; + 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('ocr-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: `ocr-${runSuffix}.txt`, + mime_type: 'text/plain', + file_size: Buffer.byteLength(marker), + file_sha256: fileSha256, + status: 'pending_ocr', + ocr_type: 'vat', + 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_status_bound() { + local invoice_id="$1" + DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" node <<'NODE' +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const invoiceId = process.env.INVOICE_ID; + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + + try { + const invoice = await prisma.invoicePool.findUnique({ + where: { id: invoiceId }, + select: { + owner_employee_id: true, + }, + }); + + if (!invoice) { + throw new Error(`invoice not found: ${invoiceId}`); + } + + const owner = await prisma.employee.findUnique({ + where: { id: invoice.owner_employee_id }, + select: { + id: true, + department_id: true, + }, + }); + + if (!owner) { + throw new Error(`invoice owner not found: ${invoice.owner_employee_id}`); + } + + const reimbursement = await prisma.reimbursementForm.create({ + data: { + submitter_id: owner.id, + owner_id: owner.id, + department_id: owner.department_id, + reimbursement_date: new Date('2026-02-08T00:00:00.000Z'), + description: 'regression-ocr synthetic reimbursement', + status: 'reviewing', + created_by: owner.id, + updated_by: owner.id, + }, + select: { + id: true, + }, + }); + + await prisma.invoicePool.update({ + where: { id: invoiceId }, + data: { + status: 'bound', + linked_reimbursement_id: reimbursement.id, + updated_by: owner.id, + }, + }); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_available_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, + invoice_code: true, + invoice_number: true, + ocr_attempts: true, + ocr_last_error: true, + ocr_locked_at: true, + ocr_lock_owner: true, + }, + }); + + if (!invoice || invoice.status !== 'available') { + console.error(`[ERROR] ${scenario} expected status=available but got ${invoice?.status ?? 'N/A'}`); + process.exit(1); + } + + if (!invoice.invoice_code || !invoice.invoice_number) { + console.error(`[ERROR] ${scenario} expected invoice_code and invoice_number to be filled`); + process.exit(1); + } + + if (invoice.ocr_locked_at || invoice.ocr_lock_owner) { + console.error(`[ERROR] ${scenario} expected lock released`); + process.exit(1); + } + + if ((invoice.ocr_attempts ?? 0) < 1) { + console.error(`[ERROR] ${scenario} expected ocr_attempts >= 1 but got ${invoice.ocr_attempts}`); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_duplicate_failed_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, + ocr_last_error: true, + ocr_attempts: true, + ocr_locked_at: true, + ocr_lock_owner: true, + }, + }); + + if (!invoice || invoice.status !== 'ocr_failed') { + console.error(`[ERROR] ${scenario} expected status=ocr_failed but got ${invoice?.status ?? 'N/A'}`); + process.exit(1); + } + + if (invoice.ocr_last_error !== 'duplicate_invoice_code_number') { + console.error(`[ERROR] ${scenario} expected ocr_last_error=duplicate_invoice_code_number but got ${invoice.ocr_last_error}`); + process.exit(1); + } + + if ((invoice.ocr_attempts ?? 0) < 1) { + console.error(`[ERROR] ${scenario} expected ocr_attempts >= 1 but got ${invoice.ocr_attempts}`); + process.exit(1); + } + + if (invoice.ocr_locked_at || invoice.ocr_lock_owner) { + console.error(`[ERROR] ${scenario} expected lock released`); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_pending_attempt() { + local invoice_id="$1" + local expected_attempts="$2" + local scenario="$3" + DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" EXPECTED_ATTEMPTS="$expected_attempts" SCENARIO="$scenario" node <<'NODE' +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const invoiceId = process.env.INVOICE_ID; + const expectedAttempts = Number(process.env.EXPECTED_ATTEMPTS); + 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, + ocr_attempts: true, + ocr_last_error: true, + ocr_locked_at: true, + ocr_lock_owner: true, + }, + }); + + if (!invoice || invoice.status !== 'pending_ocr') { + console.error(`[ERROR] ${scenario} expected status=pending_ocr but got ${invoice?.status ?? 'N/A'}`); + process.exit(1); + } + + if (invoice.ocr_attempts !== expectedAttempts) { + console.error(`[ERROR] ${scenario} expected attempts=${expectedAttempts} but got ${invoice.ocr_attempts}`); + process.exit(1); + } + + if (invoice.ocr_last_error !== 'mock_ocr_failed') { + console.error(`[ERROR] ${scenario} expected ocr_last_error=mock_ocr_failed but got ${invoice.ocr_last_error}`); + process.exit(1); + } + + if (invoice.ocr_locked_at || invoice.ocr_lock_owner) { + console.error(`[ERROR] ${scenario} expected lock released`); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_terminal_failed_attempt() { + local invoice_id="$1" + local expected_attempts="$2" + local scenario="$3" + DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" EXPECTED_ATTEMPTS="$expected_attempts" SCENARIO="$scenario" node <<'NODE' +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const invoiceId = process.env.INVOICE_ID; + const expectedAttempts = Number(process.env.EXPECTED_ATTEMPTS); + 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, + ocr_attempts: true, + ocr_last_error: true, + ocr_completed_at: true, + ocr_locked_at: true, + ocr_lock_owner: true, + }, + }); + + if (!invoice || invoice.status !== 'ocr_failed') { + console.error(`[ERROR] ${scenario} expected status=ocr_failed but got ${invoice?.status ?? 'N/A'}`); + process.exit(1); + } + + if (invoice.ocr_attempts !== expectedAttempts) { + console.error(`[ERROR] ${scenario} expected attempts=${expectedAttempts} but got ${invoice.ocr_attempts}`); + process.exit(1); + } + + if (invoice.ocr_last_error !== 'mock_ocr_failed') { + console.error(`[ERROR] ${scenario} expected ocr_last_error=mock_ocr_failed but got ${invoice.ocr_last_error}`); + process.exit(1); + } + + if (!invoice.ocr_completed_at) { + console.error(`[ERROR] ${scenario} expected ocr_completed_at not null`); + process.exit(1); + } + + if (invoice.ocr_locked_at || invoice.ocr_lock_owner) { + console.error(`[ERROR] ${scenario} expected lock released`); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_attempt_count() { + local invoice_id="$1" + local expected_attempts="$2" + local scenario="$3" + DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" EXPECTED_ATTEMPTS="$expected_attempts" SCENARIO="$scenario" node <<'NODE' +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const invoiceId = process.env.INVOICE_ID; + const expectedAttempts = Number(process.env.EXPECTED_ATTEMPTS); + 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, + ocr_attempts: true, + }, + }); + + if (!invoice || invoice.status !== 'available') { + console.error(`[ERROR] ${scenario} expected status=available but got ${invoice?.status ?? 'N/A'}`); + process.exit(1); + } + + if (invoice.ocr_attempts !== expectedAttempts) { + console.error(`[ERROR] ${scenario} expected attempts=${expectedAttempts} but got ${invoice.ocr_attempts}`); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +echo "[1/4] 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" +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" +set_invoice_status_bound "$INVOICE_BASE" +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" +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" +run_ocr_once >"$TMP_DIR/ocr-fail-2.log" +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" +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" +) & +PID_A=$! +( + OCR_PROVIDER=mock OCR_BATCH_SIZE=1 OCR_MAX_ATTEMPTS=3 npm run --silent job:ocr-once >"$TMP_DIR/ocr-concurrent-b.log" +) & +PID_B=$! +wait "$PID_A" +wait "$PID_B" +assert_available_invoice "$INVOICE_CONCURRENT" "ocr concurrency available" +assert_attempt_count "$INVOICE_CONCURRENT" "1" "ocr concurrency attempts" + +echo "[OK] OCR regression passed" diff --git a/server/src/jobs/ocrJob.js b/server/src/jobs/ocrJob.js index a2135d56..b30cead4 100644 --- a/server/src/jobs/ocrJob.js +++ b/server/src/jobs/ocrJob.js @@ -274,7 +274,7 @@ async function runOcrOnce(prisma, config, logger, workerId = `ocr:${process.pid} } function startOcrJob(fastify) { - if (fastify.config.ocrProvider !== "tencent") { + if (!["tencent", "mock"].includes(fastify.config.ocrProvider)) { fastify.log.info({ provider: fastify.config.ocrProvider }, "OCR job disabled"); return null; } diff --git a/server/src/routes/invoicePool.js b/server/src/routes/invoicePool.js index 60d5f231..65eeb384 100644 --- a/server/src/routes/invoicePool.js +++ b/server/src/routes/invoicePool.js @@ -25,6 +25,13 @@ const bindBodySchema = z.object({ linkedItemId: z.string().uuid().optional(), }); +const invoiceListQuerySchema = z.object({ + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), + status: z.enum(["pending_ocr", "ocr_failed", "available", "bound", "archived"]).optional(), + ownerScope: z.enum(["own", "all"]).optional(), +}); + const DEDUP_CONSTRAINT_NAMES = new Set([ "invoice_pool_file_sha256_dedup_ux", "invoice_pool_invoice_code_number_dedup_ux", @@ -149,7 +156,187 @@ function sanitizeFilename(fileName) { return fileName.replace(/[\r\n"]/g, "_"); } +function parseInvoiceListQuery(query) { + const parsed = invoiceListQuerySchema.safeParse(query ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + return { + page: parsed.data.page ?? 1, + pageSize: parsed.data.pageSize ?? 20, + status: parsed.data.status, + ownerScope: parsed.data.ownerScope, + }; +} + +function mapInvoiceSummary(item) { + return { + id: item.id, + status: item.status, + ocrType: item.ocr_type, + ocrProvider: item.ocr_provider, + ocrAttempts: item.ocr_attempts, + ocrLastError: item.ocr_last_error, + ocrCompletedAt: item.ocr_completed_at?.toISOString?.() ?? null, + invoiceCode: item.invoice_code, + invoiceNumber: item.invoice_number, + invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, + amount: toMoneyString(item.amount), + taxAmount: toMoneyString(item.tax_amount), + totalAmount: toMoneyString(item.total_amount), + linkedReimbursementId: item.linked_reimbursement_id, + linkedItemId: item.linked_item_id, + originalName: item.original_name, + mimeType: item.mime_type, + fileSize: item.file_size, + owner: item.owner_employee + ? { + id: item.owner_employee.id, + employeeNo: item.owner_employee.employee_no, + name: item.owner_employee.name, + } + : null, + uploadedBy: item.uploaded_by_employee + ? { + id: item.uploaded_by_employee.id, + employeeNo: item.uploaded_by_employee.employee_no, + name: item.uploaded_by_employee.name, + } + : null, + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + }; +} + +function normalizeInvoiceResponse(invoice) { + return { + id: invoice.id, + status: invoice.status, + ownerEmployeeId: invoice.owner_employee_id, + uploadedByEmployeeId: invoice.uploaded_by_employee_id, + ocrType: invoice.ocr_type, + ocrProvider: invoice.ocr_provider, + ocrAttempts: invoice.ocr_attempts, + 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, + amount: toMoneyString(invoice.amount), + taxAmount: toMoneyString(invoice.tax_amount), + totalAmount: toMoneyString(invoice.total_amount), + linkedReimbursementId: invoice.linked_reimbursement_id, + linkedItemId: invoice.linked_item_id, + createdAt: invoice.created_at?.toISOString?.() ?? null, + updatedAt: invoice.updated_at?.toISOString?.() ?? null, + }; +} + async function invoicePoolRoutes(fastify) { + + fastify.get( + "/invoice-pool", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const query = parseInvoiceListQuery(request.query); + + const canViewOwn = fastify.hasPermission(request.employee, "invoice.view_own"); + const canManage = fastify.hasPermission(request.employee, "invoice.manage"); + + if (!canViewOwn && !canManage) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + const ownerScope = query.ownerScope ?? (canManage ? "all" : "own"); + if (ownerScope === "all" && !canManage) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + const skip = (query.page - 1) * query.pageSize; + const where = { + ...(query.status ? { status: query.status } : {}), + ...(ownerScope === "all" + ? {} + : { + OR: [ + { owner_employee_id: request.employee.id }, + { uploaded_by_employee_id: request.employee.id }, + { + linked_reimbursement: { + OR: [ + { owner_id: request.employee.id }, + { submitter_id: request.employee.id }, + ], + }, + }, + ], + }), + }; + + const [items, total] = await Promise.all([ + fastify.prisma.invoicePool.findMany({ + where, + orderBy: { created_at: "desc" }, + skip, + take: query.pageSize, + select: { + id: true, + status: true, + ocr_type: true, + ocr_provider: true, + ocr_attempts: true, + ocr_last_error: true, + ocr_completed_at: true, + invoice_code: true, + invoice_number: true, + invoice_date: true, + amount: true, + tax_amount: true, + total_amount: true, + linked_reimbursement_id: true, + linked_item_id: true, + original_name: true, + mime_type: true, + file_size: true, + created_at: true, + updated_at: true, + owner_employee: { + select: { id: true, employee_no: true, name: true }, + }, + uploaded_by_employee: { + select: { id: true, employee_no: true, name: true }, + }, + }, + }), + fastify.prisma.invoicePool.count({ where }), + ]); + + return sendData(reply, request, { + page: query.page, + pageSize: query.pageSize, + total, + ownerScope, + items: items.map(mapInvoiceSummary), + }); + }, + ); + fastify.post( "/invoice-pool/upload", { diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index bcb12d48..db800c45 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -15,6 +15,15 @@ const PAY_BODY_SCHEMA = z.object({ paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(), }); +const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({ + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), + status: z + .enum(["draft", "reviewing", "approved", "paid", "rejected", "cancelled"]) + .optional(), + scope: z.enum(["own", "all"]).optional(), +}); + function ensureOwnOrCanViewAll(fastify, request, reimbursement) { const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); if (canViewAll) { @@ -93,6 +102,85 @@ function parsePayBody(body) { return parsed.data; } +function parseReimbursementListQuery(query) { + const parsed = REIMBURSEMENT_LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + return { + page: parsed.data.page ?? 1, + pageSize: parsed.data.pageSize ?? 20, + status: parsed.data.status, + scope: parsed.data.scope, + }; +} + +function buildOwnScopeWhere(employeeId) { + return { + OR: [{ owner_id: employeeId }, { submitter_id: employeeId }], + }; +} + +function isGlobalViewRole(roleCodes) { + return roleCodes.some((code) => + ["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code), + ); +} + +function buildViewAllScopeWhere(request) { + if (isGlobalViewRole(request.employee.roleCodes ?? [])) { + return {}; + } + + return { + OR: [ + { owner_id: request.employee.id }, + { submitter_id: request.employee.id }, + { owner: { direct_manager_id: request.employee.id } }, + ], + }; +} + +function mapReimbursementSummary(item) { + return { + id: item.id, + formNumber: item.form_number, + status: item.status, + reimbursementDate: item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? null, + totalAmount: toMoneyString(item.total_amount), + description: item.description, + payment: { + paymentVoucherNo: item.payment_voucher_no, + paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null, + paymentChannel: item.payment_channel, + paidAt: item.paid_at?.toISOString?.() ?? null, + paidBy: item.paid_by, + }, + owner: item.owner + ? { + id: item.owner.id, + employeeNo: item.owner.employee_no, + name: item.owner.name, + } + : null, + submitter: item.submitter + ? { + id: item.submitter.id, + employeeNo: item.submitter.employee_no, + name: item.submitter.name, + } + : null, + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + }; +} + function toDateOnlyString(dateValue) { if (!dateValue) { return null; @@ -212,6 +300,82 @@ async function reimbursementRoutes(fastify) { }, ); + fastify.get( + "/reimbursements", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const query = parseReimbursementListQuery(request.query); + const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own"); + const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); + + if (!canViewOwn && !canViewAll) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + const scope = query.scope ?? (canViewAll ? "all" : "own"); + if (scope === "all" && !canViewAll) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + const skip = (query.page - 1) * query.pageSize; + const where = { + ...(query.status ? { status: query.status } : {}), + ...(scope === "all" + ? buildViewAllScopeWhere(request) + : buildOwnScopeWhere(request.employee.id)), + }; + + const [items, total] = await Promise.all([ + fastify.prisma.reimbursementForm.findMany({ + where, + orderBy: { created_at: "desc" }, + skip, + take: query.pageSize, + select: { + id: true, + form_number: true, + status: true, + reimbursement_date: true, + total_amount: true, + description: true, + payment_voucher_no: true, + payment_date: true, + payment_channel: true, + paid_at: true, + paid_by: true, + created_at: true, + updated_at: true, + owner: { + select: { id: true, employee_no: true, name: true }, + }, + submitter: { + select: { id: true, employee_no: true, name: true }, + }, + }, + }), + fastify.prisma.reimbursementForm.count({ where }), + ]); + + return sendData(reply, request, { + page: query.page, + pageSize: query.pageSize, + total, + scope, + items: items.map(mapReimbursementSummary), + }); + }, + ); + fastify.get( "/reimbursements/:id", { diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index deaa9b65..f53ddd73 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -1,8 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); -const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions"); -const { getWorkflowStepsSnapshot, resolveActionableStep } = require("../utils/workflow"); +const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS } = require("../constants/permissions"); +const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow"); const { findActiveDelegationForDelegate, listActiveDelegationPrincipals, @@ -17,6 +17,22 @@ const TRANSFER_BODY_SCHEMA = z.object({ reason: z.string().max(500), }); +const TASK_LIST_QUERY_SCHEMA = z.object({ + status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(), + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), +}); + +const APPROVER_LIST_QUERY_SCHEMA = z.object({ + keyword: z.string().trim().max(100).optional(), + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), +}); + +const APPROVER_ROLE_CODES = Object.keys(ROLE_PERMISSIONS).filter((roleCode) => + hasPermission(ROLE_PERMISSIONS[roleCode], "expense.approve"), +); + function parseRequiredComment(body) { const parsed = REQUIRED_COMMENT_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { @@ -72,6 +88,123 @@ function parseTaskId(taskId) { return parsed.data; } +function parseTaskListQuery(query) { + const parsed = TASK_LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + return { + status: parsed.data.status ?? "pending", + page: parsed.data.page ?? 1, + pageSize: parsed.data.pageSize ?? 20, + }; +} + +function parseApproverListQuery(query) { + const parsed = APPROVER_LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + return { + keyword: parsed.data.keyword ?? "", + page: parsed.data.page ?? 1, + pageSize: parsed.data.pageSize ?? 20, + }; +} + +function isoOrNull(value) { + if (!value) { + return null; + } + + return value.toISOString(); +} + +function mapTaskSummary(task) { + const workflowInstance = task.workflow_instance + ? { + entity_type: task.workflow_instance.entity_type, + entity_id: task.workflow_instance.entity_id, + current_status: task.workflow_instance.current_status, + } + : null; + + return { + id: task.id, + instance_id: task.instance_id, + step_id: task.step_id, + step_name: task.step_name, + assignee_id: task.assignee_id, + assignee_role: task.assignee_role, + status: task.status, + outcome: task.outcome, + created_at: isoOrNull(task.created_at), + completed_at: isoOrNull(task.completed_at), + workflow_instance: workflowInstance, + + instanceId: task.instance_id, + stepId: task.step_id, + stepName: task.step_name, + assigneeId: task.assignee_id, + assigneeRole: task.assignee_role, + createdAt: isoOrNull(task.created_at), + completedAt: isoOrNull(task.completed_at), + workflowInstance: workflowInstance + ? { + entityType: workflowInstance.entity_type, + entityId: workflowInstance.entity_id, + currentStatus: workflowInstance.current_status, + } + : null, + }; +} + +async function buildTaskVisibilityFilters(tx, request) { + const roleFilters = request.employee.roleCodes.length + ? [{ assignee_role: { in: request.employee.roleCodes } }] + : []; + const delegatedPrincipalIds = await listActiveDelegationPrincipals(tx, { + delegateEmployeeId: request.employee.id, + allowedScopes: ["all"], + }); + + const assigneeFilters = [{ assignee_id: request.employee.id }, ...roleFilters]; + if (delegatedPrincipalIds.length) { + assigneeFilters.push({ assignee_id: { in: delegatedPrincipalIds } }); + } + + return assigneeFilters; +} + +function listAllowedActionsForTask(request, task, actionable) { + if (task.status !== "pending" || !actionable) { + return []; + } + + const actions = []; + if (hasPermission(request.employee.permissionCodes, "expense.approve")) { + actions.push("approve", "reject", "return"); + } + + if (hasPermission(request.employee.permissionCodes, "expense.transfer")) { + actions.push("transfer"); + } + + return actions; +} + async function lockWorkflowTask(tx, taskId) { const taskRows = await tx.$queryRaw` SELECT id, instance_id, step_id, step_name, assignee_id, assignee_role, status @@ -274,38 +407,68 @@ async function workflowRoutes(fastify) { preHandler: fastify.authenticate, }, async (request, reply) => { - const querySchema = z.object({ - status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(), - }); - const parsedQuery = querySchema.safeParse(request.query ?? {}); - if (!parsedQuery.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsedQuery.error.flatten(), - }); - } + const query = parseTaskListQuery(request.query); + const skip = (query.page - 1) * query.pageSize; - const status = parsedQuery.data.status ?? "pending"; - const roleFilters = request.employee.roleCodes.length - ? [{ assignee_role: { in: request.employee.roleCodes } }] - : []; - const delegatedPrincipalIds = await listActiveDelegationPrincipals(fastify.prisma, { - delegateEmployeeId: request.employee.id, - allowedScopes: ["all"], - }); - const assigneeFilters = [{ assignee_id: request.employee.id }, ...roleFilters]; - if (delegatedPrincipalIds.length) { - assigneeFilters.push({ assignee_id: { in: delegatedPrincipalIds } }); - } + const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request); - const tasks = await fastify.prisma.workflowTask.findMany({ + const where = { + status: query.status, + OR: assigneeFilters, + }; + + const [tasks, total] = await Promise.all([ + fastify.prisma.workflowTask.findMany({ + where, + orderBy: { created_at: "desc" }, + skip, + take: query.pageSize, + select: { + id: true, + instance_id: true, + step_id: true, + step_name: true, + assignee_id: true, + assignee_role: true, + status: true, + outcome: true, + created_at: true, + completed_at: true, + workflow_instance: { + select: { + entity_type: true, + entity_id: true, + current_status: true, + }, + }, + }, + }), + fastify.prisma.workflowTask.count({ where }), + ]); + + return sendData(reply, request, { + page: query.page, + pageSize: query.pageSize, + total, + items: tasks.map(mapTaskSummary), + }); + }, + ); + + fastify.get( + "/workflow/tasks/:taskId", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const taskId = parseTaskId(request.params?.taskId); + const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request); + + const task = await fastify.prisma.workflowTask.findFirst({ where: { - status, + id: taskId, OR: assigneeFilters, }, - orderBy: { created_at: "desc" }, select: { id: true, instance_id: true, @@ -315,20 +478,227 @@ async function workflowRoutes(fastify) { assignee_role: true, status: true, outcome: true, + comment: true, created_at: true, completed_at: true, workflow_instance: { select: { - entity_type: true, - entity_id: true, + id: true, current_status: true, + current_step_id: true, + started_at: true, + completed_at: true, + reimbursement_form: { + select: { + id: true, + form_number: true, + status: true, + total_amount: true, + reimbursement_date: true, + description: true, + owner: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, + submitter: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!task) { + const existing = await fastify.prisma.workflowTask.findUnique({ + where: { id: taskId }, + select: { id: true }, + }); + + if (existing) { + throw new AppError({ + code: "PERM_002", + message: "不在审批范围/非当前处理人", + statusCode: 403, + }); + } + + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + let actionable = false; + if (task.status === "pending") { + try { + await ensureTaskAssignableToCurrentEmployee(fastify.prisma, task, request); + actionable = true; + } catch (error) { + if (error instanceof AppError && error.code === "PERM_002") { + actionable = false; + } else { + throw error; + } + } + } + + const historyItems = await fastify.prisma.workflowHistory.findMany({ + where: { instance_id: task.instance_id }, + orderBy: { created_at: "asc" }, + select: { + id: true, + task_id: true, + actor_id: true, + action: true, + comment: true, + payload: true, + created_at: true, + actor: { + select: { + id: true, + name: true, + employee_no: true, }, }, }, }); return sendData(reply, request, { - items: tasks, + task: { + id: task.id, + instanceId: task.instance_id, + stepId: task.step_id, + stepName: task.step_name, + assigneeId: task.assignee_id, + assigneeRole: task.assignee_role, + status: task.status, + outcome: task.outcome, + comment: task.comment, + createdAt: isoOrNull(task.created_at), + completedAt: isoOrNull(task.completed_at), + }, + workflowInstance: task.workflow_instance + ? { + id: task.workflow_instance.id, + currentStatus: task.workflow_instance.current_status, + currentStepId: task.workflow_instance.current_step_id, + startedAt: isoOrNull(task.workflow_instance.started_at), + completedAt: isoOrNull(task.workflow_instance.completed_at), + } + : null, + reimbursement: task.workflow_instance?.reimbursement_form + ? { + id: task.workflow_instance.reimbursement_form.id, + formNumber: task.workflow_instance.reimbursement_form.form_number, + status: task.workflow_instance.reimbursement_form.status, + reimbursementDate: + task.workflow_instance.reimbursement_form.reimbursement_date + ?.toISOString?.() + ?.slice(0, 10) ?? null, + totalAmount: toMoneyString(task.workflow_instance.reimbursement_form.total_amount), + description: task.workflow_instance.reimbursement_form.description, + owner: task.workflow_instance.reimbursement_form.owner + ? { + id: task.workflow_instance.reimbursement_form.owner.id, + employeeNo: task.workflow_instance.reimbursement_form.owner.employee_no, + name: task.workflow_instance.reimbursement_form.owner.name, + } + : null, + submitter: task.workflow_instance.reimbursement_form.submitter + ? { + id: task.workflow_instance.reimbursement_form.submitter.id, + employeeNo: task.workflow_instance.reimbursement_form.submitter.employee_no, + name: task.workflow_instance.reimbursement_form.submitter.name, + } + : null, + } + : null, + allowedActions: listAllowedActionsForTask(request, task, actionable), + history: historyItems.map((item) => ({ + id: item.id, + taskId: item.task_id, + actorId: item.actor_id, + actorName: item.actor?.name ?? "", + actorEmployeeNo: item.actor?.employee_no ?? "", + action: item.action, + comment: item.comment, + payload: item.payload, + createdAt: isoOrNull(item.created_at), + })), + }); + }, + ); + + fastify.get( + "/workflow/approvers", + { + preHandler: fastify.requirePermission("expense.transfer"), + }, + async (request, reply) => { + const query = parseApproverListQuery(request.query); + const skip = (query.page - 1) * query.pageSize; + + const where = { + status: "active", + id: { not: request.employee.id }, + roles: { + some: { + role_code: { in: APPROVER_ROLE_CODES }, + }, + }, + ...(query.keyword + ? { + OR: [ + { name: { contains: query.keyword, mode: "insensitive" } }, + { email: { contains: query.keyword, mode: "insensitive" } }, + { employee_no: { contains: query.keyword, mode: "insensitive" } }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + fastify.prisma.employee.findMany({ + where, + orderBy: { name: "asc" }, + skip, + take: query.pageSize, + select: { + id: true, + employee_no: true, + name: true, + email: true, + roles: { + select: { + role_code: true, + }, + }, + }, + }), + fastify.prisma.employee.count({ where }), + ]); + + return sendData(reply, request, { + page: query.page, + pageSize: query.pageSize, + total, + items: items.map((item) => ({ + id: item.id, + employeeNo: item.employee_no, + name: item.name, + email: item.email, + roleCodes: item.roles.map((role) => role.role_code), + })), }); }, ); diff --git a/server/src/scripts/run-ocr-once.js b/server/src/scripts/run-ocr-once.js index c4736527..f3a3fdf3 100644 --- a/server/src/scripts/run-ocr-once.js +++ b/server/src/scripts/run-ocr-once.js @@ -9,7 +9,7 @@ const { runOcrOnce } = require("../jobs/ocrJob"); const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { - if (config.ocrProvider !== "tencent") { + if (!["tencent", "mock"].includes(config.ocrProvider)) { process.stdout.write("ocr_provider_disabled\n"); return; } diff --git a/server/src/services/ocr/index.js b/server/src/services/ocr/index.js index eff46885..eadeb8ad 100644 --- a/server/src/services/ocr/index.js +++ b/server/src/services/ocr/index.js @@ -1,10 +1,19 @@ const { runTencentVatOcr } = require("./tencent"); +const { runMockOcr } = require("./mock"); function isOcrEnabledForType(config, ocrType) { + if (config.ocrProvider === "mock") { + return true; + } + return config.ocrProvider === "tencent" && ocrType === "vat"; } async function runOcrForInvoice({ config, ocrType, imageBase64 }) { + if (config.ocrProvider === "mock") { + return runMockOcr({ ocrType, imageBase64 }); + } + if (config.ocrProvider !== "tencent") { throw new Error(`Unsupported OCR provider: ${config.ocrProvider}`); } diff --git a/server/src/services/ocr/mock.js b/server/src/services/ocr/mock.js new file mode 100644 index 00000000..8e082f1b --- /dev/null +++ b/server/src/services/ocr/mock.js @@ -0,0 +1,60 @@ +const crypto = require("node:crypto"); + +function decodeMarker(imageBase64) { + try { + return Buffer.from(imageBase64, "base64").toString("utf8"); + } catch { + return ""; + } +} + +function buildInvoiceKey(markerText) { + const duplicateMatch = markerText.match(/MOCK_DUP:([A-Za-z0-9_-]+)/); + if (duplicateMatch?.[1]) { + return `dup:${duplicateMatch[1]}`; + } + + const hash = crypto.createHash("sha256").update(markerText || "mock-empty").digest("hex"); + return `hash:${hash.slice(0, 20)}`; +} + +function toAmount(seedHex, fallback) { + const value = Number.parseInt(seedHex.slice(0, 8), 16); + if (!Number.isFinite(value)) { + return fallback; + } + + return ((value % 50000) / 100 + fallback).toFixed(2); +} + +async function runMockOcr({ ocrType, imageBase64 }) { + const markerText = decodeMarker(imageBase64); + if (markerText.includes("MOCK_OCR_FAIL")) { + throw new Error("mock_ocr_failed"); + } + + const invoiceKey = buildInvoiceKey(markerText); + const hash = crypto.createHash("sha256").update(invoiceKey).digest("hex"); + const invoiceCode = `MOCK${hash.slice(0, 8).toUpperCase()}`; + const invoiceNumber = hash.slice(8, 16).toUpperCase(); + + return { + invoiceCode, + invoiceNumber, + invoiceDate: "2026-02-08", + amount: toAmount(hash.slice(16, 24), 100), + taxAmount: toAmount(hash.slice(24, 32), 10), + totalAmount: toAmount(hash.slice(32, 40), 110), + sellerName: `Mock Seller (${ocrType})`, + buyerName: "Mock Buyer", + raw: { + provider: "mock", + invoiceKey, + marker: markerText.slice(0, 128), + }, + }; +} + +module.exports = { + runMockOcr, +};