feat(server): 完成 P3 后端API与回归(D2~D9)
- 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)
这个提交包含在:
@@ -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
|
||||||
@@ -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:[email protected]: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
|
||||||
@@ -16,8 +16,14 @@ echo "=== [2/3] Server Static Checks ==="
|
|||||||
node --check src/routes/reimbursements.js
|
node --check src/routes/reimbursements.js
|
||||||
node --check src/routes/workflow.js
|
node --check src/routes/workflow.js
|
||||||
node --check src/routes/delegations.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/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-workflow-actions.sh
|
||||||
|
bash -n scripts/regression-audit.sh
|
||||||
|
bash -n scripts/regression-ocr.sh
|
||||||
bash -n scripts/regression-all.sh
|
bash -n scripts/regression-all.sh
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+26
-4
@@ -117,7 +117,20 @@ cd /home/shunli/Projects/hyb-oa/server
|
|||||||
./scripts/regression-all.sh
|
./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 回归
|
### 4.5 Postman 回归
|
||||||
- Collection: `server/postman/hyb-oa.postman_collection.json`
|
- Collection: `server/postman/hyb-oa.postman_collection.json`
|
||||||
@@ -173,7 +186,16 @@ npm run dev
|
|||||||
- 权限:`system.audit.view`(finance_mgr/admin)
|
- 权限:`system.audit.view`(finance_mgr/admin)
|
||||||
- 支持分页与筛选(actor/action/targetType/date range)。
|
- 支持分页与筛选(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
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd /home/shunli/Projects/hyb-oa/server
|
||||||
npm run job:revoke-expired-delegations
|
npm run job:revoke-expired-delegations
|
||||||
@@ -190,7 +212,7 @@ npm run job:ocr-once
|
|||||||
- `SIGNED_URL_TTL_SECONDS`: 默认 `600`
|
- `SIGNED_URL_TTL_SECONDS`: 默认 `600`
|
||||||
- `MAX_UPLOAD_SIZE_BYTES`: 默认 `10485760`(10MB)
|
- `MAX_UPLOAD_SIZE_BYTES`: 默认 `10485760`(10MB)
|
||||||
- `DELEGATION_REVOKE_CRON`: 委派自动回收定时表达式(默认 `0 * * * *`)
|
- `DELEGATION_REVOKE_CRON`: 委派自动回收定时表达式(默认 `0 * * * *`)
|
||||||
- `OCR_PROVIDER`: `off` 或 `tencent`(默认 `off`)
|
- `OCR_PROVIDER`: `off` / `mock` / `tencent`(默认 `off`)
|
||||||
- `OCR_BATCH_SIZE`: 每轮 OCR 认领数量(默认 `10`)
|
- `OCR_BATCH_SIZE`: 每轮 OCR 认领数量(默认 `10`)
|
||||||
- `OCR_LOCK_TTL_SECONDS`: OCR 锁超时秒数(默认 `300`)
|
- `OCR_LOCK_TTL_SECONDS`: OCR 锁超时秒数(默认 `300`)
|
||||||
- `OCR_POLL_CRON`: OCR 轮询 cron(默认 `* * * * *`)
|
- `OCR_POLL_CRON`: OCR 轮询 cron(默认 `* * * * *`)
|
||||||
@@ -211,7 +233,7 @@ npm run job:ocr-once
|
|||||||
| signed-url 接口返回 403 | `JWT_SECRET` 不一致、token 过期、访问人无权限 | 统一服务密钥、确认 URL 在 TTL 内、检查账号角色 |
|
| signed-url 接口返回 403 | `JWT_SECRET` 不一致、token 过期、访问人无权限 | 统一服务密钥、确认 URL 在 TTL 内、检查账号角色 |
|
||||||
| 上传返回 `file_too_large` | 文件超 `MAX_UPLOAD_SIZE_BYTES` | 调整环境变量或使用更小文件 |
|
| 上传返回 `file_too_large` | 文件超 `MAX_UPLOAD_SIZE_BYTES` | 调整环境变量或使用更小文件 |
|
||||||
| 上传失败且报目录/权限错误 | `STORAGE_DIR` 不存在或不可写 | 创建目录并修正权限;Docker 需确认 volume 挂载 |
|
| 上传失败且报目录/权限错误 | `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` |
|
| 委派过期后仍可操作 | 自动回收任务未运行 | 检查 `DELEGATION_REVOKE_CRON`,手动执行 `npm run job:revoke-expired-delegations` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,17 +2,37 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
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"
|
"$SCRIPT_DIR/smoke-test.sh"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "=== [2/3] Dedup Regression ==="
|
echo "=== [2/5] Dedup Regression ==="
|
||||||
"$SCRIPT_DIR/regression-dedup.sh"
|
"$SCRIPT_DIR/regression-dedup.sh"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "=== [3/3] Workflow Actions Regression ==="
|
echo "=== [3/5] Workflow Actions Regression ==="
|
||||||
"$SCRIPT_DIR/regression-workflow-actions.sh"
|
"$SCRIPT_DIR/regression-workflow-actions.sh"
|
||||||
echo ""
|
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"
|
echo "[OK] All regressions passed"
|
||||||
|
|||||||
+193
@@ -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"
|
||||||
+477
@@ -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 || '[email protected]';
|
||||||
|
const config = getConfig();
|
||||||
|
const { prisma, pool } = createPrismaClient();
|
||||||
|
await prisma.$connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const owner = await prisma.employee.findUnique({
|
||||||
|
where: { email },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!owner) {
|
||||||
|
throw new Error(`employee not found: ${email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relPath = path.join('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"
|
||||||
@@ -274,7 +274,7 @@ async function runOcrOnce(prisma, config, logger, workerId = `ocr:${process.pid}
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startOcrJob(fastify) {
|
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");
|
fastify.log.info({ provider: fastify.config.ocrProvider }, "OCR job disabled");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ const bindBodySchema = z.object({
|
|||||||
linkedItemId: z.string().uuid().optional(),
|
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([
|
const DEDUP_CONSTRAINT_NAMES = new Set([
|
||||||
"invoice_pool_file_sha256_dedup_ux",
|
"invoice_pool_file_sha256_dedup_ux",
|
||||||
"invoice_pool_invoice_code_number_dedup_ux",
|
"invoice_pool_invoice_code_number_dedup_ux",
|
||||||
@@ -149,7 +156,187 @@ function sanitizeFilename(fileName) {
|
|||||||
return fileName.replace(/[\r\n"]/g, "_");
|
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) {
|
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(
|
fastify.post(
|
||||||
"/invoice-pool/upload",
|
"/invoice-pool/upload",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ const PAY_BODY_SCHEMA = z.object({
|
|||||||
paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(),
|
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) {
|
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||||
if (canViewAll) {
|
if (canViewAll) {
|
||||||
@@ -93,6 +102,85 @@ function parsePayBody(body) {
|
|||||||
return parsed.data;
|
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) {
|
function toDateOnlyString(dateValue) {
|
||||||
if (!dateValue) {
|
if (!dateValue) {
|
||||||
return null;
|
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(
|
fastify.get(
|
||||||
"/reimbursements/:id",
|
"/reimbursements/:id",
|
||||||
{
|
{
|
||||||
|
|||||||
+402
-32
@@ -1,8 +1,8 @@
|
|||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
const { AppError } = require("../utils/AppError");
|
const { AppError } = require("../utils/AppError");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
|
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS } = require("../constants/permissions");
|
||||||
const { getWorkflowStepsSnapshot, resolveActionableStep } = require("../utils/workflow");
|
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
||||||
const {
|
const {
|
||||||
findActiveDelegationForDelegate,
|
findActiveDelegationForDelegate,
|
||||||
listActiveDelegationPrincipals,
|
listActiveDelegationPrincipals,
|
||||||
@@ -17,6 +17,22 @@ const TRANSFER_BODY_SCHEMA = z.object({
|
|||||||
reason: z.string().max(500),
|
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) {
|
function parseRequiredComment(body) {
|
||||||
const parsed = REQUIRED_COMMENT_SCHEMA.safeParse(body ?? {});
|
const parsed = REQUIRED_COMMENT_SCHEMA.safeParse(body ?? {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -72,6 +88,123 @@ function parseTaskId(taskId) {
|
|||||||
return parsed.data;
|
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) {
|
async function lockWorkflowTask(tx, taskId) {
|
||||||
const taskRows = await tx.$queryRaw`
|
const taskRows = await tx.$queryRaw`
|
||||||
SELECT id, instance_id, step_id, step_name, assignee_id, assignee_role, status
|
SELECT id, instance_id, step_id, step_name, assignee_id, assignee_role, status
|
||||||
@@ -274,38 +407,68 @@ async function workflowRoutes(fastify) {
|
|||||||
preHandler: fastify.authenticate,
|
preHandler: fastify.authenticate,
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const querySchema = z.object({
|
const query = parseTaskListQuery(request.query);
|
||||||
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
|
const skip = (query.page - 1) * query.pageSize;
|
||||||
});
|
|
||||||
const parsedQuery = querySchema.safeParse(request.query ?? {});
|
|
||||||
if (!parsedQuery.success) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: parsedQuery.error.flatten(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = parsedQuery.data.status ?? "pending";
|
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
|
||||||
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 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: {
|
where: {
|
||||||
status,
|
id: taskId,
|
||||||
OR: assigneeFilters,
|
OR: assigneeFilters,
|
||||||
},
|
},
|
||||||
orderBy: { created_at: "desc" },
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
instance_id: true,
|
instance_id: true,
|
||||||
@@ -315,20 +478,227 @@ async function workflowRoutes(fastify) {
|
|||||||
assignee_role: true,
|
assignee_role: true,
|
||||||
status: true,
|
status: true,
|
||||||
outcome: true,
|
outcome: true,
|
||||||
|
comment: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
completed_at: true,
|
completed_at: true,
|
||||||
workflow_instance: {
|
workflow_instance: {
|
||||||
select: {
|
select: {
|
||||||
entity_type: true,
|
id: true,
|
||||||
entity_id: true,
|
|
||||||
current_status: 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, {
|
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),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const { runOcrOnce } = require("../jobs/ocrJob");
|
|||||||
const { prisma, pool } = createPrismaClient();
|
const { prisma, pool } = createPrismaClient();
|
||||||
await prisma.$connect();
|
await prisma.$connect();
|
||||||
try {
|
try {
|
||||||
if (config.ocrProvider !== "tencent") {
|
if (!["tencent", "mock"].includes(config.ocrProvider)) {
|
||||||
process.stdout.write("ocr_provider_disabled\n");
|
process.stdout.write("ocr_provider_disabled\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
const { runTencentVatOcr } = require("./tencent");
|
const { runTencentVatOcr } = require("./tencent");
|
||||||
|
const { runMockOcr } = require("./mock");
|
||||||
|
|
||||||
function isOcrEnabledForType(config, ocrType) {
|
function isOcrEnabledForType(config, ocrType) {
|
||||||
|
if (config.ocrProvider === "mock") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return config.ocrProvider === "tencent" && ocrType === "vat";
|
return config.ocrProvider === "tencent" && ocrType === "vat";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runOcrForInvoice({ config, ocrType, imageBase64 }) {
|
async function runOcrForInvoice({ config, ocrType, imageBase64 }) {
|
||||||
|
if (config.ocrProvider === "mock") {
|
||||||
|
return runMockOcr({ ocrType, imageBase64 });
|
||||||
|
}
|
||||||
|
|
||||||
if (config.ocrProvider !== "tencent") {
|
if (config.ocrProvider !== "tencent") {
|
||||||
throw new Error(`Unsupported OCR provider: ${config.ocrProvider}`);
|
throw new Error(`Unsupported OCR provider: ${config.ocrProvider}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
在新工单中引用
屏蔽一个用户