feat(server): P5 基础能力补齐(组织/报销/代办/认证安全)
- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40) - 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5) - 行政代提交/代上传后端校验与审计(F7/F8) - 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
这个提交包含在:
@@ -17,6 +17,13 @@ MAX_UPLOAD_SIZE_BYTES="10485760"
|
||||
# RATE_LIMIT_LOGIN_MAX="10"
|
||||
# RATE_LIMIT_LOGIN_WINDOW="1 minute"
|
||||
|
||||
# ===== 登录安全(失败锁定/弱密码拦截)=====
|
||||
# AUTH_LOGIN_FAIL_WINDOW_SECONDS="900" # 15min
|
||||
# AUTH_LOGIN_FAIL_MAX_ATTEMPTS="5"
|
||||
# AUTH_LOGIN_LOCK_SECONDS="1800" # 30min
|
||||
# WEAK_PASSWORD_CHECK_ENABLED="true"
|
||||
# WEAK_PASSWORD_MIN_SCORE="2" # zxcvbn score < 2 -> reject
|
||||
|
||||
DELEGATION_REVOKE_CRON="0 * * * *"
|
||||
|
||||
OCR_PROVIDER="off"
|
||||
|
||||
+37
-3
@@ -56,6 +56,17 @@ npm run seed:trialrun
|
||||
- `TRIALRUN_ADMIN_PASSWORD` 为空时会生成并打印一次性口令(请立刻保存)。
|
||||
- 若管理员已存在,脚本只确保其为 active 且具备 `admin+employee` 角色,不会改动密码。
|
||||
|
||||
### 2.3.1 认证安全说明(P5)
|
||||
- `POST /api/auth/login` 成功响应包含 `mustChangePassword`:
|
||||
- `true`:表示该账号处于“首次登录/管理员重置密码后”状态;前端应强制引导改密后再进入业务页。
|
||||
- 改密接口:`POST /api/auth/change-password`(需要 `authorization: Bearer <token>` + `x-device-fingerprint`)。
|
||||
- 登录失败锁定(默认):
|
||||
- 15 分钟内失败 5 次会锁定 30 分钟,返回 `423 AUTH_002`(含 `lockedUntil`)。
|
||||
- 解锁:`POST /api/users/:id/unlock`(权限:`system.users`)。
|
||||
- 弱密码拦截:
|
||||
- 采用 `zxcvbn` 评分,默认 `score < 2` 拒绝(返回 `VALID_001.details.password`)。
|
||||
- 可通过 `WEAK_PASSWORD_CHECK_ENABLED=false` 关闭(不建议生产关闭)。
|
||||
|
||||
### 2.4 启动服务
|
||||
```bash
|
||||
cd /home/shunli/Projects/hyb-oa/server
|
||||
@@ -160,11 +171,25 @@ cd /home/shunli/Projects/hyb-oa/server
|
||||
RUN_AUDIT_REGRESSION=0 RUN_OCR_REGRESSION=0 ./scripts/regression-all.sh
|
||||
```
|
||||
|
||||
可选打开组织/配置回归(org + settings):
|
||||
```bash
|
||||
cd /home/shunli/Projects/hyb-oa/server
|
||||
RUN_ORG_SETTINGS_REGRESSION=1 ./scripts/regression-all.sh
|
||||
```
|
||||
|
||||
可选打开认证安全回归(auth security):
|
||||
```bash
|
||||
cd /home/shunli/Projects/hyb-oa/server
|
||||
RUN_AUTH_SECURITY_REGRESSION=1 ./scripts/regression-all.sh
|
||||
```
|
||||
|
||||
独立执行:
|
||||
```bash
|
||||
cd /home/shunli/Projects/hyb-oa/server
|
||||
./scripts/regression-audit.sh
|
||||
OCR_PROVIDER=mock ./scripts/regression-ocr.sh
|
||||
./scripts/regression-org-settings.sh
|
||||
./scripts/regression-auth-security.sh
|
||||
```
|
||||
|
||||
### 4.5 Postman 回归
|
||||
@@ -258,18 +283,27 @@ cd /home/shunli/Projects/hyb-oa
|
||||
- `GET /api/workflow/tasks/:taskId`:任务详情(实例、报销摘要、可执行动作、处理历史)。
|
||||
- `GET /api/workflow/approvers`:转审候选人列表(active + 具备 `expense.approve`)。
|
||||
|
||||
### 5.6 列表查询接口(P3)
|
||||
### 5.6 部门管理(P5 蓝图 F26)
|
||||
- `GET /api/departments`:部门列表 + 树(登录即可;默认不含归档部门)
|
||||
- 可选:`GET /api/departments?includeArchived=1`(包含归档部门)
|
||||
- `POST /api/departments`:创建部门(`system.users`)
|
||||
- `PATCH /api/departments/:id`:更新部门(`system.users`;已归档禁止修改;禁止形成父子环)
|
||||
- `POST /api/departments/:id/archive`:归档部门(`system.users`)
|
||||
- 归档约束:有子部门/有在职员工则禁止归档
|
||||
- 幂等:已归档重复调用返回 `outcome=already_archived`
|
||||
|
||||
### 5.7 列表查询接口(P3)
|
||||
- `GET /api/reimbursements`:支持 `page/pageSize/status/scope`。
|
||||
- `GET /api/invoice-pool`:支持 `page/pageSize/status/ownerScope`。
|
||||
|
||||
### 5.7 定时任务(可手动执行)
|
||||
### 5.8 定时任务(可手动执行)
|
||||
```bash
|
||||
cd /home/shunli/Projects/hyb-oa/server
|
||||
npm run job:revoke-expired-delegations
|
||||
npm run job:ocr-once
|
||||
```
|
||||
|
||||
### 5.8 账号与角色管理(E17,试运行运营)
|
||||
### 5.9 账号与角色管理(E17,试运行运营)
|
||||
- `GET /api/users`:员工列表(分页/搜索)
|
||||
- `POST /api/users`:创建员工(含初始密码)
|
||||
- `POST /api/users/:id/disable`:禁用员工(tokenVersion+1 + 撤销 refresh)
|
||||
|
||||
+9
-1
@@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
@@ -25,7 +26,8 @@
|
||||
"pg": "^8.18.0",
|
||||
"prisma": "^7.3.0",
|
||||
"tencentcloud-sdk-nodejs": "^4.1.14",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.3.6",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^16.4.5"
|
||||
@@ -2762,6 +2764,12 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zxcvbn": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz",
|
||||
"integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -40,7 +40,8 @@
|
||||
"pg": "^8.18.0",
|
||||
"prisma": "^7.3.0",
|
||||
"tencentcloud-sdk-nodejs": "^4.1.14",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.3.6",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^16.4.5"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "employees" ADD COLUMN "login_failed_count" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "login_failed_first_at" TIMESTAMP(3),
|
||||
ADD COLUMN "login_locked_until" TIMESTAMP(3),
|
||||
ADD COLUMN "must_change_password" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -150,6 +150,10 @@ model Employee {
|
||||
|
||||
password_hash String
|
||||
token_version Int @default(0)
|
||||
must_change_password Boolean @default(false)
|
||||
login_failed_count Int @default(0)
|
||||
login_failed_first_at DateTime?
|
||||
login_locked_until DateTime?
|
||||
archived_at DateTime?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
|
||||
@@ -4,34 +4,54 @@ 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}"
|
||||
RUN_ORG_SETTINGS_REGRESSION="${RUN_ORG_SETTINGS_REGRESSION:-0}"
|
||||
RUN_AUTH_SECURITY_REGRESSION="${RUN_AUTH_SECURITY_REGRESSION:-0}"
|
||||
|
||||
echo "=== [1/5] Smoke Test ==="
|
||||
echo "=== [1/7] Smoke Test ==="
|
||||
"$SCRIPT_DIR/smoke-test.sh"
|
||||
echo ""
|
||||
|
||||
echo "=== [2/5] Dedup Regression ==="
|
||||
echo "=== [2/7] Dedup Regression ==="
|
||||
"$SCRIPT_DIR/regression-dedup.sh"
|
||||
echo ""
|
||||
|
||||
echo "=== [3/5] Workflow Actions Regression ==="
|
||||
echo "=== [3/7] Workflow Actions Regression ==="
|
||||
"$SCRIPT_DIR/regression-workflow-actions.sh"
|
||||
echo ""
|
||||
|
||||
if [[ "$RUN_AUDIT_REGRESSION" == "1" ]]; then
|
||||
echo "=== [4/5] Audit Regression ==="
|
||||
echo "=== [4/7] Audit Regression ==="
|
||||
"$SCRIPT_DIR/regression-audit.sh"
|
||||
echo ""
|
||||
else
|
||||
echo "=== [4/5] Audit Regression (skipped, RUN_AUDIT_REGRESSION=$RUN_AUDIT_REGRESSION) ==="
|
||||
echo "=== [4/7] Audit Regression (skipped, RUN_AUDIT_REGRESSION=$RUN_AUDIT_REGRESSION) ==="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$RUN_OCR_REGRESSION" == "1" ]]; then
|
||||
echo "=== [5/5] OCR Regression ==="
|
||||
echo "=== [5/7] OCR Regression ==="
|
||||
"$SCRIPT_DIR/regression-ocr.sh"
|
||||
echo ""
|
||||
else
|
||||
echo "=== [5/5] OCR Regression (skipped, RUN_OCR_REGRESSION=$RUN_OCR_REGRESSION) ==="
|
||||
echo "=== [5/7] OCR Regression (skipped, RUN_OCR_REGRESSION=$RUN_OCR_REGRESSION) ==="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$RUN_ORG_SETTINGS_REGRESSION" == "1" ]]; then
|
||||
echo "=== [6/7] Org + Settings Regression ==="
|
||||
"$SCRIPT_DIR/regression-org-settings.sh"
|
||||
echo ""
|
||||
else
|
||||
echo "=== [6/7] Org + Settings Regression (skipped, RUN_ORG_SETTINGS_REGRESSION=$RUN_ORG_SETTINGS_REGRESSION) ==="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$RUN_AUTH_SECURITY_REGRESSION" == "1" ]]; then
|
||||
echo "=== [7/7] Auth Security Regression ==="
|
||||
"$SCRIPT_DIR/regression-auth-security.sh"
|
||||
echo ""
|
||||
else
|
||||
echo "=== [7/7] Auth Security Regression (skipped, RUN_AUTH_SECURITY_REGRESSION=$RUN_AUTH_SECURITY_REGRESSION) ==="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
||||
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-auth-security}"
|
||||
ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-admin@hyinda.com}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-AdminPassword1234}"
|
||||
|
||||
LOGIN_FAIL_MAX_ATTEMPTS="${LOGIN_FAIL_MAX_ATTEMPTS:-${AUTH_LOGIN_FAIL_MAX_ATTEMPTS:-5}}"
|
||||
|
||||
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)"
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
extract_json_field() {
|
||||
local file_path="$1"
|
||||
local jq_expr="$2"
|
||||
jq -er "$jq_expr" "$file_path"
|
||||
}
|
||||
|
||||
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_code="$2"
|
||||
local name="$3"
|
||||
local actual_code
|
||||
actual_code="$(jq -r '.error.code // empty' "$file_path")"
|
||||
if [[ "$actual_code" != "$expected_code" ]]; then
|
||||
echo "[ERROR] $name expected error code $expected_code but got $actual_code" >&2
|
||||
cat "$file_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
login() {
|
||||
local identifier="$1"
|
||||
local password="$2"
|
||||
local fingerprint="$3"
|
||||
local out_body="$4"
|
||||
|
||||
local status
|
||||
status="$(curl -sS -o "$out_body" -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\"}")"
|
||||
echo "$status"
|
||||
}
|
||||
|
||||
echo "[1/8] Login admin"
|
||||
admin_login_body="$TMP_DIR/admin-login.json"
|
||||
admin_login_status="$(login "$ADMIN_IDENTIFIER" "$ADMIN_PASSWORD" "${DEVICE_FINGERPRINT}-admin" "$admin_login_body")"
|
||||
assert_status "$admin_login_status" "200" "admin login" "$admin_login_body"
|
||||
ADMIN_TOKEN="$(extract_json_field "$admin_login_body" '.data.accessToken')"
|
||||
|
||||
echo "[2/8] Weak password should be rejected"
|
||||
weak_employee_no="W$(date +%s)$RANDOM"
|
||||
weak_employee_email="weak_${weak_employee_no}@hyinda.com"
|
||||
weak_employee_phone="139${RANDOM}${RANDOM}"
|
||||
weak_create_body="$TMP_DIR/weak-create.json"
|
||||
weak_create_status="$(curl -sS -o "$weak_create_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/users" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"employeeNo\":\"$weak_employee_no\",\"name\":\"Weak User\",\"phone\":\"$weak_employee_phone\",\"email\":\"$weak_employee_email\",\"title\":\"测试\",\"directManagerId\":null,\"hireDate\":\"2020-01-01\",\"roles\":[\"employee\"],\"password\":\"Password123456\"}")"
|
||||
assert_status "$weak_create_status" "400" "weak password rejected" "$weak_create_body"
|
||||
assert_error_code "$weak_create_body" "VALID_001" "weak password rejected"
|
||||
if ! jq -e '.error.details.password | type == "array" and (map(tostring) | join(" ") | contains("常见"))' "$weak_create_body" >/dev/null; then
|
||||
echo "[ERROR] weak password error message missing" >&2
|
||||
cat "$weak_create_body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[3/8] Create employee (initial password => mustChangePassword)"
|
||||
employee_no="A$(date +%s)$RANDOM"
|
||||
employee_email="auth_${employee_no}@hyinda.com"
|
||||
employee_phone="138${RANDOM}${RANDOM}"
|
||||
INITIAL_PASSWORD="InitA1$(date +%s%N)Xy"
|
||||
create_body="$TMP_DIR/create.json"
|
||||
create_status="$(curl -sS -o "$create_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/users" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"employeeNo\":\"$employee_no\",\"name\":\"Auth User\",\"phone\":\"$employee_phone\",\"email\":\"$employee_email\",\"title\":\"测试\",\"directManagerId\":null,\"hireDate\":\"2020-01-01\",\"roles\":[\"employee\"],\"password\":\"$INITIAL_PASSWORD\"}")"
|
||||
assert_status "$create_status" "200" "create employee" "$create_body"
|
||||
EMPLOYEE_ID="$(extract_json_field "$create_body" '.data.id')"
|
||||
|
||||
login_body_1="$TMP_DIR/login1.json"
|
||||
login_status_1="$(login "$employee_email" "$INITIAL_PASSWORD" "${DEVICE_FINGERPRINT}-employee" "$login_body_1")"
|
||||
assert_status "$login_status_1" "200" "initial login" "$login_body_1"
|
||||
if [[ "$(jq -r '.data.mustChangePassword' "$login_body_1")" != "true" ]]; then
|
||||
echo "[ERROR] expected mustChangePassword=true on initial login" >&2
|
||||
cat "$login_body_1" >&2
|
||||
exit 1
|
||||
fi
|
||||
ACCESS_TOKEN_1="$(extract_json_field "$login_body_1" '.data.accessToken')"
|
||||
|
||||
echo "[4/8] Change password should revoke old token"
|
||||
NEW_PASSWORD="NewA1$(date +%s%N)Zz"
|
||||
change_body="$TMP_DIR/change.json"
|
||||
change_status="$(curl -sS -o "$change_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/auth/change-password" \
|
||||
-H "authorization: Bearer $ACCESS_TOKEN_1" \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-device-fingerprint: ${DEVICE_FINGERPRINT}-employee" \
|
||||
-d "{\"oldPassword\":\"$INITIAL_PASSWORD\",\"newPassword\":\"$NEW_PASSWORD\"}")"
|
||||
assert_status "$change_status" "200" "change password" "$change_body"
|
||||
ACCESS_TOKEN_2="$(extract_json_field "$change_body" '.data.accessToken')"
|
||||
if [[ "$(jq -r '.data.mustChangePassword' "$change_body")" != "false" ]]; then
|
||||
echo "[ERROR] expected mustChangePassword=false after change" >&2
|
||||
cat "$change_body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
old_me_body="$TMP_DIR/old-me.json"
|
||||
old_me_status="$(curl -sS -o "$old_me_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/me" \
|
||||
-H "authorization: Bearer $ACCESS_TOKEN_1")"
|
||||
assert_status "$old_me_status" "401" "old token revoked" "$old_me_body"
|
||||
assert_error_code "$old_me_body" "AUTH_004" "old token revoked"
|
||||
|
||||
new_me_body="$TMP_DIR/new-me.json"
|
||||
new_me_status="$(curl -sS -o "$new_me_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/me" \
|
||||
-H "authorization: Bearer $ACCESS_TOKEN_2")"
|
||||
assert_status "$new_me_status" "200" "new token works" "$new_me_body"
|
||||
|
||||
echo "[5/8] Admin reset password => mustChangePassword=true"
|
||||
reset_body="$TMP_DIR/reset.json"
|
||||
reset_status="$(curl -sS -o "$reset_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/users/$EMPLOYEE_ID/reset-password" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d '{}')"
|
||||
assert_status "$reset_status" "200" "admin reset password" "$reset_body"
|
||||
ONE_TIME_PASSWORD="$(extract_json_field "$reset_body" '.data.oneTimePassword')"
|
||||
|
||||
login_body_2="$TMP_DIR/login2.json"
|
||||
login_status_2="$(login "$employee_email" "$ONE_TIME_PASSWORD" "${DEVICE_FINGERPRINT}-employee-reset" "$login_body_2")"
|
||||
assert_status "$login_status_2" "200" "login with reset password" "$login_body_2"
|
||||
if [[ "$(jq -r '.data.mustChangePassword' "$login_body_2")" != "true" ]]; then
|
||||
echo "[ERROR] expected mustChangePassword=true after admin reset" >&2
|
||||
cat "$login_body_2" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[6/8] Login failure lockout (AUTH_002)"
|
||||
wrong_password="WrongPassword1234"
|
||||
locked=0
|
||||
for i in $(seq 1 "$LOGIN_FAIL_MAX_ATTEMPTS"); do
|
||||
attempt_body="$TMP_DIR/lock-attempt-$i.json"
|
||||
attempt_status="$(login "$employee_email" "$wrong_password" "${DEVICE_FINGERPRINT}-lock-$i" "$attempt_body")"
|
||||
if [[ "$i" -lt "$LOGIN_FAIL_MAX_ATTEMPTS" ]]; then
|
||||
assert_status "$attempt_status" "401" "lock attempt $i" "$attempt_body"
|
||||
assert_error_code "$attempt_body" "AUTH_001" "lock attempt $i"
|
||||
else
|
||||
assert_status "$attempt_status" "423" "lock attempt $i triggers lock" "$attempt_body"
|
||||
assert_error_code "$attempt_body" "AUTH_002" "lock attempt $i triggers lock"
|
||||
if [[ "$(jq -r '.error.details.lockedUntil // empty' "$attempt_body")" == "" ]]; then
|
||||
echo "[ERROR] expected lockedUntil in AUTH_002 details" >&2
|
||||
cat "$attempt_body" >&2
|
||||
exit 1
|
||||
fi
|
||||
locked=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$locked" != "1" ]]; then
|
||||
echo "[ERROR] expected account to be locked" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[7/8] Locked account cannot login, unlock restores"
|
||||
locked_login_body="$TMP_DIR/locked-login.json"
|
||||
locked_login_status="$(login "$employee_email" "$ONE_TIME_PASSWORD" "${DEVICE_FINGERPRINT}-locked-correct" "$locked_login_body")"
|
||||
assert_status "$locked_login_status" "423" "locked account login blocked" "$locked_login_body"
|
||||
assert_error_code "$locked_login_body" "AUTH_002" "locked account login blocked"
|
||||
|
||||
unlock_body="$TMP_DIR/unlock.json"
|
||||
unlock_status="$(curl -sS -o "$unlock_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/users/$EMPLOYEE_ID/unlock" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$unlock_status" "200" "unlock user" "$unlock_body"
|
||||
|
||||
post_unlock_login_body="$TMP_DIR/post-unlock-login.json"
|
||||
post_unlock_login_status="$(login "$employee_email" "$ONE_TIME_PASSWORD" "${DEVICE_FINGERPRINT}-post-unlock" "$post_unlock_login_body")"
|
||||
assert_status "$post_unlock_login_status" "200" "login after unlock" "$post_unlock_login_body"
|
||||
|
||||
echo "[8/8] Done"
|
||||
echo "[OK] Auth security regression passed"
|
||||
@@ -136,15 +136,22 @@ NODE
|
||||
}
|
||||
|
||||
create_tmp_non_dedup_index() {
|
||||
DATABASE_URL="$DATABASE_URL" node <<'NODE'
|
||||
local reimbursement_id="$1"
|
||||
DATABASE_URL="$DATABASE_URL" REIMBURSEMENT_ID="$reimbursement_id" node <<'NODE'
|
||||
const { createPrismaClient } = require('./src/prismaClient');
|
||||
|
||||
(async () => {
|
||||
const reimbursementId = process.env.REIMBURSEMENT_ID;
|
||||
if (!reimbursementId) {
|
||||
throw new Error('REIMBURSEMENT_ID is required');
|
||||
}
|
||||
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
await prisma.$executeRawUnsafe('DROP INDEX IF EXISTS "tmp_unique_linked_reim"');
|
||||
await prisma.$executeRawUnsafe(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS tmp_unique_linked_reim ON "invoice_pool" ("linked_reimbursement_id") WHERE "linked_reimbursement_id" IS NOT NULL',
|
||||
`CREATE UNIQUE INDEX "tmp_unique_linked_reim" ON "invoice_pool" ("linked_reimbursement_id") WHERE "linked_reimbursement_id" = '${reimbursementId}'`,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
@@ -247,9 +254,6 @@ assert_status "$status" "409" "code+number dedup bind" "$TMP_DIR/bD.json"
|
||||
assert_error_code "$TMP_DIR/bD.json" "VALID_003"
|
||||
|
||||
echo "[4/7] Non-dedup P2002 should not map to VALID_003"
|
||||
create_tmp_non_dedup_index
|
||||
TMP_INDEX_CREATED=1
|
||||
|
||||
cat > "$TMP_DIR/non-dedup-1.pdf" <<PDF
|
||||
%PDF-1.4
|
||||
non-dedup-1-$RUN_SUFFIX
|
||||
@@ -260,6 +264,8 @@ non-dedup-2-$RUN_SUFFIX
|
||||
PDF
|
||||
|
||||
RID_E="$(create_reimbursement "$TMP_DIR/rE.json")"
|
||||
create_tmp_non_dedup_index "$RID_E"
|
||||
TMP_INDEX_CREATED=1
|
||||
status="$(upload_file "$TMP_DIR/non-dedup-1.pdf" "$TMP_DIR/uE1.json")"
|
||||
assert_status "$status" "200" "upload non-dedup-1" "$TMP_DIR/uE1.json"
|
||||
IID_E1="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uE1.json")"
|
||||
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
||||
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-org-settings}"
|
||||
|
||||
ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-admin@hyinda.com}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-AdminPassword1234}"
|
||||
EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}"
|
||||
EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}"
|
||||
ADMINISTRATION_IDENTIFIER="${ADMINISTRATION_IDENTIFIER:-administration@hyinda.com}"
|
||||
ADMINISTRATION_PASSWORD="${ADMINISTRATION_PASSWORD:-AdministrationPassword1234}"
|
||||
|
||||
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)"
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
extract_json_field() {
|
||||
local file_path="$1"
|
||||
local jq_expr="$2"
|
||||
jq -er "$jq_expr" "$file_path"
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
local name="$3"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "[ERROR] $name expected HTTP $expected but got $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_error_code() {
|
||||
local file_path="$1"
|
||||
local expected_code="$2"
|
||||
local name="$3"
|
||||
local actual_code
|
||||
actual_code="$(jq -r '.error.code // empty' "$file_path")"
|
||||
if [[ "$actual_code" != "$expected_code" ]]; then
|
||||
echo "[ERROR] $name expected error code $expected_code but got $actual_code" >&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 jq assertion failed: $jq_expr" >&2
|
||||
cat "$file_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
login() {
|
||||
local identifier="$1"
|
||||
local password="$2"
|
||||
local fingerprint="$3"
|
||||
local out_body="$4"
|
||||
|
||||
local status
|
||||
status="$(curl -sS -o "$out_body" -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"
|
||||
extract_json_field "$out_body" '.data.accessToken'
|
||||
}
|
||||
|
||||
echo "[1/12] Login admin/employee/administration"
|
||||
admin_login_body="$TMP_DIR/admin-login.json"
|
||||
ADMIN_TOKEN="$(login "$ADMIN_IDENTIFIER" "$ADMIN_PASSWORD" "${DEVICE_FINGERPRINT}-admin" "$admin_login_body")"
|
||||
admin_me_body="$TMP_DIR/admin-me.json"
|
||||
admin_me_status="$(curl -sS -o "$admin_me_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/me" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$admin_me_status" "200" "admin me"
|
||||
ADMIN_ID="$(extract_json_field "$admin_me_body" '.data.id')"
|
||||
|
||||
employee_login_body="$TMP_DIR/employee-login.json"
|
||||
EMPLOYEE_TOKEN="$(login "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "${DEVICE_FINGERPRINT}-employee" "$employee_login_body")"
|
||||
employee_me_body="$TMP_DIR/employee-me.json"
|
||||
employee_me_status="$(curl -sS -o "$employee_me_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/me" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$employee_me_status" "200" "employee me"
|
||||
EMPLOYEE_ID="$(extract_json_field "$employee_me_body" '.data.id')"
|
||||
|
||||
administration_login_body="$TMP_DIR/administration-login.json"
|
||||
ADMINISTRATION_TOKEN="$(login "$ADMINISTRATION_IDENTIFIER" "$ADMINISTRATION_PASSWORD" "${DEVICE_FINGERPRINT}-administration" "$administration_login_body")"
|
||||
administration_me_body="$TMP_DIR/administration-me.json"
|
||||
administration_me_status="$(curl -sS -o "$administration_me_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/me" \
|
||||
-H "authorization: Bearer $ADMINISTRATION_TOKEN")"
|
||||
assert_status "$administration_me_status" "200" "administration me"
|
||||
ADMINISTRATION_ID="$(extract_json_field "$administration_me_body" '.data.id')"
|
||||
|
||||
echo "[2/12] Expense types (read-only)"
|
||||
expense_list_body="$TMP_DIR/expense-types.json"
|
||||
expense_list_status="$(curl -sS -o "$expense_list_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/expense-types?enabled=true" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$expense_list_status" "200" "expense types list"
|
||||
assert_jq "$expense_list_body" '.data.items | length > 0' "expense types non-empty"
|
||||
assert_jq "$expense_list_body" '([.data.items[].sortOrder] as $a | ($a|length) as $n | if $n <= 1 then true else [range(1; $n) | $a[.-1] <= $a[.]] | all end)' "expense types sorted"
|
||||
|
||||
echo "[3/12] Expense types (management + audit)"
|
||||
expense_code="TEST_$(date +%s)_$RANDOM"
|
||||
expense_create_body="$TMP_DIR/expense-create.json"
|
||||
expense_create_status="$(curl -sS -o "$expense_create_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/expense-types" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"code\":\"$expense_code\",\"name\":\"$expense_code\",\"category\":\"other\",\"sortOrder\":9999,\"enabled\":true}")"
|
||||
assert_status "$expense_create_status" "200" "expense type create"
|
||||
EXPENSE_TYPE_ID="$(extract_json_field "$expense_create_body" '.data.id')"
|
||||
|
||||
expense_patch_body="$TMP_DIR/expense-patch.json"
|
||||
expense_patch_status="$(curl -sS -o "$expense_patch_body" -w "%{http_code}" \
|
||||
-X PATCH "$BASE_URL/api/expense-types/$EXPENSE_TYPE_ID" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d '{"enabled":false}')"
|
||||
assert_status "$expense_patch_status" "200" "expense type disable"
|
||||
|
||||
expense_enabled_body="$TMP_DIR/expense-enabled.json"
|
||||
expense_enabled_status="$(curl -sS -o "$expense_enabled_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/expense-types?enabled=true" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$expense_enabled_status" "200" "expense types enabled list"
|
||||
assert_jq "$expense_enabled_body" "([.data.items[].id] | index(\"$EXPENSE_TYPE_ID\") == null)" "disabled expense type filtered"
|
||||
|
||||
audit_expense_body="$TMP_DIR/audit-expense.json"
|
||||
audit_expense_status="$(curl -sS -o "$audit_expense_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/audit-logs?action=EXPENSE_TYPE_CREATED&targetType=expense_type&pageSize=50" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$audit_expense_status" "200" "audit expense type created"
|
||||
assert_jq "$audit_expense_body" "([.data.items[].targetId] | index(\"$EXPENSE_TYPE_ID\") != null)" "audit contains created expense type"
|
||||
|
||||
echo "[4/12] Config read: approval rules (login required)"
|
||||
approval_rules_body="$TMP_DIR/approval-rules.json"
|
||||
approval_rules_status="$(curl -sS -o "$approval_rules_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/approval-rules?enabled=true" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$approval_rules_status" "200" "approval rules list"
|
||||
assert_jq "$approval_rules_body" '.data.items | length > 0' "approval rules non-empty"
|
||||
|
||||
echo "[5/12] Config read: document sequence (system.settings)"
|
||||
sequence_body="$TMP_DIR/document-sequence.json"
|
||||
sequence_status="$(curl -sS -o "$sequence_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/document-sequences/reimbursement" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$sequence_status" "200" "document sequence read"
|
||||
assert_jq "$sequence_body" '.data.documentType == "reimbursement"' "document sequence type"
|
||||
|
||||
echo "[6/12] Departments (list)"
|
||||
departments_list_body="$TMP_DIR/departments-list.json"
|
||||
departments_list_status="$(curl -sS -o "$departments_list_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/departments" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$departments_list_status" "200" "departments list"
|
||||
assert_jq "$departments_list_body" '(.data.items | type) == "array"' "departments items array"
|
||||
assert_jq "$departments_list_body" '(.data.tree | type) == "array"' "departments tree array"
|
||||
|
||||
echo "[7/12] Departments (archive constraints)"
|
||||
dept_parent_name="TEST_DEPT_PARENT_$(date +%s)_$RANDOM"
|
||||
dept_child_name="TEST_DEPT_CHILD_$(date +%s)_$RANDOM"
|
||||
dept_leaf_name="TEST_DEPT_LEAF_$(date +%s)_$RANDOM"
|
||||
dept_parent_body="$TMP_DIR/dept-parent.json"
|
||||
dept_parent_status="$(curl -sS -o "$dept_parent_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"name\":\"$dept_parent_name\"}")"
|
||||
assert_status "$dept_parent_status" "200" "department create parent"
|
||||
DEPT_PARENT_ID="$(extract_json_field "$dept_parent_body" '.data.department.id')"
|
||||
|
||||
dept_child_body="$TMP_DIR/dept-child.json"
|
||||
dept_child_status="$(curl -sS -o "$dept_child_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"name\":\"$dept_child_name\",\"parentId\":\"$DEPT_PARENT_ID\"}")"
|
||||
assert_status "$dept_child_status" "200" "department create child"
|
||||
DEPT_CHILD_ID="$(extract_json_field "$dept_child_body" '.data.department.id')"
|
||||
|
||||
dept_archive_parent_body="$TMP_DIR/dept-archive-parent.json"
|
||||
dept_archive_parent_status="$(curl -sS -o "$dept_archive_parent_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments/$DEPT_PARENT_ID/archive" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$dept_archive_parent_status" "409" "department archive parent blocked"
|
||||
assert_error_code "$dept_archive_parent_body" "VALID_001" "department archive parent blocked"
|
||||
assert_jq "$dept_archive_parent_body" '.error.details.department == "has_children"' "department archive parent has_children"
|
||||
|
||||
dept_archive_child_body="$TMP_DIR/dept-archive-child.json"
|
||||
dept_archive_child_status="$(curl -sS -o "$dept_archive_child_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments/$DEPT_CHILD_ID/archive" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$dept_archive_child_status" "200" "department archive child"
|
||||
|
||||
dept_leaf_body="$TMP_DIR/dept-leaf.json"
|
||||
dept_leaf_status="$(curl -sS -o "$dept_leaf_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"name\":\"$dept_leaf_name\"}")"
|
||||
assert_status "$dept_leaf_status" "200" "department create leaf"
|
||||
DEPT_LEAF_ID="$(extract_json_field "$dept_leaf_body" '.data.department.id')"
|
||||
|
||||
dept_archive_leaf_body="$TMP_DIR/dept-archive-leaf.json"
|
||||
dept_archive_leaf_status="$(curl -sS -o "$dept_archive_leaf_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments/$DEPT_LEAF_ID/archive" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$dept_archive_leaf_status" "200" "department archive leaf"
|
||||
|
||||
echo "[8/12] Employees alias (/users vs /employees)"
|
||||
users_list_body="$TMP_DIR/users-list.json"
|
||||
users_list_status="$(curl -sS -o "$users_list_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/users?page=1&pageSize=5" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$users_list_status" "200" "users list"
|
||||
|
||||
employees_list_body="$TMP_DIR/employees-list.json"
|
||||
employees_list_status="$(curl -sS -o "$employees_list_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/employees?page=1&pageSize=5" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$employees_list_status" "200" "employees list"
|
||||
assert_jq "$employees_list_body" '(.data.total | type) == "number"' "employees list total"
|
||||
assert_jq "$employees_list_body" "(.data.total == $(jq -r '.data.total' "$users_list_body"))" "employees list total matches users"
|
||||
|
||||
echo "[9/12] Employee can read self but not others"
|
||||
employee_self_body="$TMP_DIR/employee-self.json"
|
||||
employee_self_status="$(curl -sS -o "$employee_self_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/employees/$EMPLOYEE_ID" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$employee_self_status" "200" "employee get self"
|
||||
|
||||
employee_other_body="$TMP_DIR/employee-other.json"
|
||||
employee_other_status="$(curl -sS -o "$employee_other_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/employees/$ADMIN_ID" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$employee_other_status" "403" "employee get other forbidden"
|
||||
assert_error_code "$employee_other_body" "PERM_001" "employee get other forbidden"
|
||||
|
||||
echo "[10/12] Department archive blocks active employees"
|
||||
dept_emp_name="TEST_DEPT_EMP_$(date +%s)_$RANDOM"
|
||||
dept_emp_body="$TMP_DIR/dept-emp.json"
|
||||
dept_emp_status="$(curl -sS -o "$dept_emp_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"name\":\"$dept_emp_name\"}")"
|
||||
assert_status "$dept_emp_status" "200" "department create for employee"
|
||||
DEPT_EMP_ID="$(extract_json_field "$dept_emp_body" '.data.department.id')"
|
||||
|
||||
new_employee_no="T$(date +%s)$RANDOM"
|
||||
new_employee_email="test_${new_employee_no}@hyinda.com"
|
||||
new_employee_phone="139${RANDOM}${RANDOM}"
|
||||
employee_create_body="$TMP_DIR/employee-create.json"
|
||||
employee_create_status="$(curl -sS -o "$employee_create_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/users" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"employeeNo\":\"$new_employee_no\",\"name\":\"Test User\",\"phone\":\"$new_employee_phone\",\"email\":\"$new_employee_email\",\"departmentId\":\"$DEPT_EMP_ID\",\"title\":\"测试\",\"directManagerId\":null,\"hireDate\":\"2020-01-01\",\"roles\":[\"employee\"],\"password\":\"TestPassword1234\"}")"
|
||||
assert_status "$employee_create_status" "200" "employee create"
|
||||
NEW_EMPLOYEE_ID="$(extract_json_field "$employee_create_body" '.data.id')"
|
||||
|
||||
dept_archive_blocked_body="$TMP_DIR/dept-archive-blocked.json"
|
||||
dept_archive_blocked_status="$(curl -sS -o "$dept_archive_blocked_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments/$DEPT_EMP_ID/archive" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$dept_archive_blocked_status" "409" "department archive blocked by active employees"
|
||||
assert_error_code "$dept_archive_blocked_body" "VALID_001" "department archive blocked by active employees"
|
||||
assert_jq "$dept_archive_blocked_body" '.error.details.department == "has_active_employees"' "department archive has_active_employees"
|
||||
|
||||
user_disable_body="$TMP_DIR/user-disable.json"
|
||||
user_disable_status="$(curl -sS -o "$user_disable_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/users/$NEW_EMPLOYEE_ID/disable" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$user_disable_status" "200" "user disable"
|
||||
|
||||
dept_archive_after_disable_body="$TMP_DIR/dept-archive-after-disable.json"
|
||||
dept_archive_after_disable_status="$(curl -sS -o "$dept_archive_after_disable_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/departments/$DEPT_EMP_ID/archive" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN")"
|
||||
assert_status "$dept_archive_after_disable_status" "200" "department archive after disable"
|
||||
|
||||
echo "[11/12] Delegation grants: admin maintain principal + alias consistency"
|
||||
delegation_create_body="$TMP_DIR/delegation-create.json"
|
||||
delegation_create_status="$(curl -sS -o "$delegation_create_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/delegation-grants" \
|
||||
-H "authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"principalEmployeeId\":\"$EMPLOYEE_ID\",\"delegateEmployeeId\":\"$ADMINISTRATION_ID\",\"scope\":\"reimbursement_submit\"}")"
|
||||
assert_status "$delegation_create_status" "200" "delegation grant create"
|
||||
DELEGATION_ID="$(extract_json_field "$delegation_create_body" '.data.delegation.id')"
|
||||
assert_jq "$delegation_create_body" ".data.delegation.principal.id == \"$EMPLOYEE_ID\"" "delegation principal embedded"
|
||||
assert_jq "$delegation_create_body" ".data.delegation.delegate.id == \"$ADMINISTRATION_ID\"" "delegation delegate embedded"
|
||||
|
||||
employee_delegations_body="$TMP_DIR/delegations-mine.json"
|
||||
employee_delegations_status="$(curl -sS -o "$employee_delegations_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/delegations/mine?status=active" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$employee_delegations_status" "200" "delegations mine"
|
||||
assert_jq "$employee_delegations_body" "([.data.initiated[].id] | index(\"$DELEGATION_ID\") != null)" "delegations mine contains created"
|
||||
|
||||
delegation_list_body="$TMP_DIR/delegation-grants-list.json"
|
||||
delegation_list_status="$(curl -sS -o "$delegation_list_body" -w "%{http_code}" \
|
||||
-X GET "$BASE_URL/api/delegation-grants?includeInactive=1" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$delegation_list_status" "200" "delegation grants list (employee view)"
|
||||
assert_jq "$delegation_list_body" "([.data.items[].id] | index(\"$DELEGATION_ID\") != null)" "delegation grants list contains created"
|
||||
|
||||
delegation_forbidden_body="$TMP_DIR/delegation-forbidden.json"
|
||||
delegation_forbidden_status="$(curl -sS -o "$delegation_forbidden_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/delegation-grants" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"principalEmployeeId\":\"$ADMIN_ID\",\"delegateEmployeeId\":\"$ADMINISTRATION_ID\",\"scope\":\"reimbursement_submit\"}")"
|
||||
assert_status "$delegation_forbidden_status" "403" "delegation grant forbidden"
|
||||
assert_error_code "$delegation_forbidden_body" "PERM_001" "delegation grant forbidden"
|
||||
|
||||
echo "[12/12] Revoke delegation (cleanup)"
|
||||
delegation_revoke_body="$TMP_DIR/delegation-revoke.json"
|
||||
delegation_revoke_status="$(curl -sS -o "$delegation_revoke_body" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/delegation-grants/$DELEGATION_ID/revoke" \
|
||||
-H "authorization: Bearer $EMPLOYEE_TOKEN")"
|
||||
assert_status "$delegation_revoke_status" "200" "delegation revoke"
|
||||
|
||||
echo "[OK] Org/Settings regression passed"
|
||||
@@ -22,6 +22,10 @@ const workflowRoutes = require("./routes/workflow");
|
||||
const delegationRoutes = require("./routes/delegations");
|
||||
const auditRoutes = require("./routes/audit");
|
||||
const usersRoutes = require("./routes/users");
|
||||
const departmentsRoutes = require("./routes/departments");
|
||||
const employeesRoutes = require("./routes/employees");
|
||||
const expenseTypesRoutes = require("./routes/expenseTypes");
|
||||
const configReadRoutes = require("./routes/configRead");
|
||||
const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob");
|
||||
const { startOcrJob } = require("./jobs/ocrJob");
|
||||
const { AppError } = require("./utils/AppError");
|
||||
@@ -191,6 +195,10 @@ async function buildApp() {
|
||||
await app.register(delegationRoutes, { prefix: "/api" });
|
||||
await app.register(auditRoutes, { prefix: "/api" });
|
||||
await app.register(usersRoutes, { prefix: "/api" });
|
||||
await app.register(employeesRoutes, { prefix: "/api" });
|
||||
await app.register(departmentsRoutes, { prefix: "/api" });
|
||||
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
||||
await app.register(configReadRoutes, { prefix: "/api" });
|
||||
|
||||
startDelegationRevokeJob(app);
|
||||
startOcrJob(app);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const path = require("node:path");
|
||||
|
||||
const ONE_DAY_SECONDS = 24 * 60 * 60;
|
||||
const ONE_MINUTE_SECONDS = 60;
|
||||
|
||||
function requiredEnv(name) {
|
||||
const value = process.env[name];
|
||||
@@ -39,6 +40,12 @@ function getConfig() {
|
||||
|
||||
devShowPasswordResetCode: process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true",
|
||||
|
||||
loginFailWindowSeconds: Number(
|
||||
process.env.AUTH_LOGIN_FAIL_WINDOW_SECONDS ?? 15 * ONE_MINUTE_SECONDS,
|
||||
),
|
||||
loginFailMaxAttempts: Number(process.env.AUTH_LOGIN_FAIL_MAX_ATTEMPTS ?? 5),
|
||||
loginLockSeconds: Number(process.env.AUTH_LOGIN_LOCK_SECONDS ?? 30 * ONE_MINUTE_SECONDS),
|
||||
|
||||
appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai",
|
||||
storageDir: path.resolve(serverRoot, storageDir),
|
||||
serveClientDist: process.env.SERVE_CLIENT_DIST === "true",
|
||||
|
||||
@@ -31,7 +31,17 @@ const ROLE_PERMISSIONS = {
|
||||
"system.settings",
|
||||
"system.audit.view",
|
||||
],
|
||||
administration: ["expense.proxy_submit", "expense.view_own"],
|
||||
administration: [
|
||||
"expense.proxy_submit",
|
||||
"expense.create",
|
||||
"expense.view_own",
|
||||
"expense.edit_own",
|
||||
"expense.delete_own",
|
||||
"expense.submit",
|
||||
"expense.withdraw",
|
||||
"invoice.upload",
|
||||
"invoice.view_own",
|
||||
],
|
||||
hr: ["system.users", "expense.view_all"],
|
||||
admin: ["*"],
|
||||
};
|
||||
|
||||
+239
-2
@@ -76,6 +76,10 @@ async function authRoutes(fastify) {
|
||||
status: true,
|
||||
password_hash: true,
|
||||
token_version: true,
|
||||
must_change_password: true,
|
||||
login_failed_count: true,
|
||||
login_failed_first_at: true,
|
||||
login_locked_until: true,
|
||||
},
|
||||
});
|
||||
if (!employee) {
|
||||
@@ -93,8 +97,96 @@ async function authRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (employee.login_locked_until && employee.login_locked_until > now) {
|
||||
throw new AppError({
|
||||
code: "AUTH_002",
|
||||
message: "账号已锁定,请稍后重试",
|
||||
statusCode: 423,
|
||||
details: {
|
||||
lockedUntil: employee.login_locked_until.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const ok = await verifyPassword(password, employee.password_hash);
|
||||
if (!ok) {
|
||||
const windowMs = fastify.config.loginFailWindowSeconds * 1000;
|
||||
const lockMs = fastify.config.loginLockSeconds * 1000;
|
||||
const maxAttempts = fastify.config.loginFailMaxAttempts;
|
||||
|
||||
const outcome = await fastify.prisma.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw`
|
||||
SELECT id, login_failed_count, login_failed_first_at
|
||||
FROM "employees"
|
||||
WHERE id = ${employee.id}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
throw new AppError({
|
||||
code: "AUTH_001",
|
||||
message: "用户名或密码错误",
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const current = rows[0];
|
||||
const firstAt = current.login_failed_first_at;
|
||||
let nextFirstAt = firstAt;
|
||||
let nextCount = Number(current.login_failed_count ?? 0);
|
||||
|
||||
if (!nextFirstAt || now.getTime() - nextFirstAt.getTime() > windowMs) {
|
||||
nextFirstAt = now;
|
||||
nextCount = 0;
|
||||
}
|
||||
|
||||
nextCount += 1;
|
||||
|
||||
let lockedUntil = null;
|
||||
let locked = false;
|
||||
if (nextCount >= maxAttempts) {
|
||||
locked = true;
|
||||
lockedUntil = new Date(now.getTime() + lockMs);
|
||||
}
|
||||
|
||||
await tx.employee.update({
|
||||
where: { id: employee.id },
|
||||
data: {
|
||||
login_failed_count: nextCount,
|
||||
login_failed_first_at: nextFirstAt,
|
||||
login_locked_until: lockedUntil,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: employee.id,
|
||||
action: locked ? "AUTH_LOGIN_LOCKED" : "AUTH_LOGIN_FAILED",
|
||||
targetType: "employee",
|
||||
targetId: employee.id,
|
||||
extra: {
|
||||
failedCount: nextCount,
|
||||
windowStart: nextFirstAt.toISOString(),
|
||||
lockedUntil: lockedUntil ? lockedUntil.toISOString() : null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return { locked, lockedUntil };
|
||||
});
|
||||
|
||||
if (outcome.locked) {
|
||||
throw new AppError({
|
||||
code: "AUTH_002",
|
||||
message: "账号已锁定,请稍后重试",
|
||||
statusCode: 423,
|
||||
details: {
|
||||
lockedUntil: outcome.lockedUntil ? outcome.lockedUntil.toISOString() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw new AppError({
|
||||
code: "AUTH_001",
|
||||
message: "用户名或密码错误",
|
||||
@@ -104,10 +196,18 @@ async function authRoutes(fastify) {
|
||||
|
||||
const refreshToken = randomRefreshToken();
|
||||
const refreshTokenHash = sha256Hex(refreshToken);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
await tx.employee.update({
|
||||
where: { id: employee.id },
|
||||
data: {
|
||||
login_failed_count: 0,
|
||||
login_failed_first_at: null,
|
||||
login_locked_until: null,
|
||||
},
|
||||
});
|
||||
|
||||
const activeSessions = await tx.authSession.findMany({
|
||||
where: {
|
||||
employee_id: employee.id,
|
||||
@@ -158,7 +258,10 @@ async function authRoutes(fastify) {
|
||||
);
|
||||
|
||||
const accessToken = fastify.issueAccessToken(employee);
|
||||
return sendData(reply, request, { accessToken });
|
||||
return sendData(reply, request, {
|
||||
accessToken,
|
||||
mustChangePassword: Boolean(employee.must_change_password),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/refresh", async (request, reply) => {
|
||||
@@ -378,6 +481,136 @@ async function authRoutes(fastify) {
|
||||
return sendData(reply, request, {});
|
||||
});
|
||||
|
||||
const changePasswordBodySchema = z.object({
|
||||
oldPassword: z.string().min(1),
|
||||
newPassword: z.string().min(1),
|
||||
});
|
||||
|
||||
fastify.post("/change-password", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
const parsed = changePasswordBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
|
||||
if (!deviceFingerprint) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { "x-device-fingerprint": "missing" },
|
||||
});
|
||||
}
|
||||
|
||||
const { oldPassword, newPassword } = parsed.data;
|
||||
|
||||
const policyErrors = validatePassword(newPassword);
|
||||
if (policyErrors.length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { password: policyErrors },
|
||||
});
|
||||
}
|
||||
|
||||
const employee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: request.employee.id },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
password_hash: true,
|
||||
token_version: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!employee || employee.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "账号已离职/禁用",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const ok = await verifyPassword(oldPassword, employee.password_hash);
|
||||
if (!ok) {
|
||||
throw new AppError({
|
||||
code: "AUTH_001",
|
||||
message: "用户名或密码错误",
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const newPasswordHash = await hashPassword(newPassword);
|
||||
const refreshToken = randomRefreshToken();
|
||||
const refreshTokenHash = sha256Hex(refreshToken);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
|
||||
|
||||
const updatedEmployee = await fastify.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.employee.update({
|
||||
where: { id: employee.id },
|
||||
data: {
|
||||
password_hash: newPasswordHash,
|
||||
must_change_password: false,
|
||||
login_failed_count: 0,
|
||||
login_failed_first_at: null,
|
||||
login_locked_until: null,
|
||||
token_version: { increment: 1 },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
token_version: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.authSession.updateMany({
|
||||
where: { employee_id: employee.id, revoked_at: null },
|
||||
data: { revoked_at: now, last_seen_at: now },
|
||||
});
|
||||
|
||||
await tx.authSession.create({
|
||||
data: {
|
||||
employee_id: employee.id,
|
||||
refresh_token_hash: refreshTokenHash,
|
||||
device_fingerprint: deviceFingerprint,
|
||||
ip_address: request.ip,
|
||||
user_agent: getHeaderString(request, "user-agent") ?? "",
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: employee.id,
|
||||
action: "AUTH_PASSWORD_CHANGED",
|
||||
targetType: "employee",
|
||||
targetId: employee.id,
|
||||
extra: {},
|
||||
}),
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
reply.setCookie(
|
||||
fastify.config.refreshCookieName,
|
||||
refreshToken,
|
||||
refreshCookieOptions(fastify.config),
|
||||
);
|
||||
|
||||
const accessToken = fastify.issueAccessToken(updatedEmployee);
|
||||
return sendData(reply, request, {
|
||||
accessToken,
|
||||
mustChangePassword: false,
|
||||
});
|
||||
});
|
||||
|
||||
const forgotPasswordBodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
@@ -508,6 +741,10 @@ async function authRoutes(fastify) {
|
||||
where: { id: employee.id },
|
||||
data: {
|
||||
password_hash: newPasswordHash,
|
||||
must_change_password: false,
|
||||
login_failed_count: 0,
|
||||
login_failed_first_at: null,
|
||||
login_locked_until: null,
|
||||
token_version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
|
||||
const ENABLED_QUERY_SCHEMA = z.object({
|
||||
enabled: z.enum(["true", "false", "1", "0"]).optional(),
|
||||
});
|
||||
|
||||
function parseEnabledQuery(query) {
|
||||
const parsed = ENABLED_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const enabledRaw = parsed.data.enabled;
|
||||
const enabled =
|
||||
enabledRaw == null
|
||||
? null
|
||||
: ["true", "1"].includes(enabledRaw)
|
||||
? true
|
||||
: ["false", "0"].includes(enabledRaw)
|
||||
? false
|
||||
: null;
|
||||
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
function mapApprovalRule(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
minAmount: item.min_amount?.toString?.() ?? String(item.min_amount ?? "0"),
|
||||
maxAmount: item.max_amount == null ? null : item.max_amount?.toString?.() ?? String(item.max_amount),
|
||||
level1Role: item.level1_role,
|
||||
level2Role: item.level2_role ?? null,
|
||||
level3Role: item.level3_role ?? null,
|
||||
enabled: item.enabled,
|
||||
priority: item.priority,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDocumentSequence(item, exists) {
|
||||
const fallback = {
|
||||
documentType: "reimbursement",
|
||||
prefix: "BX",
|
||||
dateFormat: "YYYYMMDD",
|
||||
separator: "-",
|
||||
numberLength: 4,
|
||||
resetPeriod: "none",
|
||||
currentNumber: 0,
|
||||
lastResetDate: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
};
|
||||
|
||||
if (!item) {
|
||||
return { exists, ...fallback };
|
||||
}
|
||||
|
||||
return {
|
||||
exists,
|
||||
documentType: item.document_type,
|
||||
prefix: item.prefix,
|
||||
dateFormat: item.date_format,
|
||||
separator: item.separator,
|
||||
numberLength: item.number_length,
|
||||
resetPeriod: item.reset_period,
|
||||
currentNumber: item.current_number,
|
||||
lastResetDate: item.last_reset_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
updatedBy: item.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function configReadRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/approval-rules",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseEnabledQuery(request.query);
|
||||
|
||||
const items = await fastify.prisma.approvalRule.findMany({
|
||||
where: {
|
||||
...(query.enabled == null ? {} : { enabled: query.enabled }),
|
||||
},
|
||||
orderBy: [{ priority: "asc" }, { min_amount: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
min_amount: true,
|
||||
max_amount: true,
|
||||
level1_role: true,
|
||||
level2_role: true,
|
||||
level3_role: true,
|
||||
enabled: true,
|
||||
priority: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
enabled: query.enabled,
|
||||
items: items.map(mapApprovalRule),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/document-sequences/reimbursement",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const sequence = await fastify.prisma.documentSequence.findUnique({
|
||||
where: { document_type: "reimbursement" },
|
||||
select: {
|
||||
document_type: true,
|
||||
prefix: true,
|
||||
date_format: true,
|
||||
separator: true,
|
||||
number_length: true,
|
||||
reset_period: true,
|
||||
current_number: true,
|
||||
last_reset_date: true,
|
||||
updated_at: true,
|
||||
updated_by: true,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, mapDocumentSequence(sequence, Boolean(sequence)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = configReadRoutes;
|
||||
|
||||
+439
-20
@@ -3,8 +3,10 @@ const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { computeDelegationStatus } = require("../utils/delegation");
|
||||
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
|
||||
const CREATE_DELEGATION_SCHEMA = z.object({
|
||||
delegateEmployeeId: z.string().uuid(),
|
||||
scope: z.enum(["reimbursement_submit", "all"]).optional(),
|
||||
@@ -12,10 +14,20 @@ const CREATE_DELEGATION_SCHEMA = z.object({
|
||||
validTo: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const CREATE_DELEGATION_GRANT_SCHEMA = CREATE_DELEGATION_SCHEMA.extend({
|
||||
principalEmployeeId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const QUERY_STATUS_SCHEMA = z.object({
|
||||
status: z.enum(["active", "scheduled", "expired", "revoked", "all"]).optional(),
|
||||
});
|
||||
|
||||
const LIST_DELEGATION_GRANTS_QUERY_SCHEMA = z.object({
|
||||
includeInactive: z.enum(["true", "false", "1", "0"]).optional(),
|
||||
principalEmployeeId: z.string().uuid().optional(),
|
||||
delegateEmployeeId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
function parseDelegationId(id) {
|
||||
const parsed = UUID_SCHEMA.safeParse(id);
|
||||
if (!parsed.success) {
|
||||
@@ -79,6 +91,56 @@ function parseCreateBody(body) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseCreateGrantBody(body) {
|
||||
const parsed = CREATE_DELEGATION_GRANT_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const validFrom = parsed.data.validFrom ? new Date(parsed.data.validFrom) : new Date();
|
||||
const validTo = parsed.data.validTo ? new Date(parsed.data.validTo) : null;
|
||||
|
||||
if (Number.isNaN(validFrom.getTime())) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { validFrom: "invalid_datetime" },
|
||||
});
|
||||
}
|
||||
|
||||
if (validTo && Number.isNaN(validTo.getTime())) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { validTo: "invalid_datetime" },
|
||||
});
|
||||
}
|
||||
|
||||
if (validTo && validTo <= validFrom) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { validTo: "must_be_after_validFrom" },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
principalEmployeeId: parsed.data.principalEmployeeId,
|
||||
delegateEmployeeId: parsed.data.delegateEmployeeId,
|
||||
scope: parsed.data.scope ?? "all",
|
||||
validFrom,
|
||||
validTo,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStatusQuery(query) {
|
||||
const parsed = QUERY_STATUS_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
@@ -93,6 +155,26 @@ function parseStatusQuery(query) {
|
||||
return parsed.data.status ?? "active";
|
||||
}
|
||||
|
||||
function parseListDelegationGrantsQuery(query) {
|
||||
const parsed = LIST_DELEGATION_GRANTS_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const includeInactive = ["true", "1"].includes(parsed.data.includeInactive ?? "false");
|
||||
|
||||
return {
|
||||
includeInactive,
|
||||
principalEmployeeId: parsed.data.principalEmployeeId,
|
||||
delegateEmployeeId: parsed.data.delegateEmployeeId,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesStatusFilter(record, status, now) {
|
||||
if (status === "all") {
|
||||
return true;
|
||||
@@ -101,6 +183,23 @@ function matchesStatusFilter(record, status, now) {
|
||||
return computeDelegationStatus(record, now) === status;
|
||||
}
|
||||
|
||||
function mapEmployeeEmbedded(employee) {
|
||||
if (!employee) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: employee.id,
|
||||
employeeNo: employee.employee_no,
|
||||
name: employee.name,
|
||||
departmentId: employee.department_id,
|
||||
status: employee.status,
|
||||
roleCodes: Array.isArray(employee.roles)
|
||||
? employee.roles.map((item) => item.role_code)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function toResponseItem(record, now) {
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -112,10 +211,299 @@ function toResponseItem(record, now) {
|
||||
revokedAt: record.revoked_at ? record.revoked_at.toISOString() : null,
|
||||
createdAt: record.created_at.toISOString(),
|
||||
status: computeDelegationStatus(record, now),
|
||||
principal: record.principal_employee ? mapEmployeeEmbedded(record.principal_employee) : null,
|
||||
delegate: record.delegate_employee ? mapEmployeeEmbedded(record.delegate_employee) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function delegationRoutes(fastify) {
|
||||
async function ensureDelegateEligible(tx, delegateEmployeeId, scope) {
|
||||
const delegateEmployee = await tx.employee.findUnique({
|
||||
where: { id: delegateEmployeeId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!delegateEmployee || delegateEmployee.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { delegateEmployeeId: "invalid_delegate" },
|
||||
});
|
||||
}
|
||||
|
||||
const roleCodes = delegateEmployee.roles.map((item) => item.role_code);
|
||||
const permissionCodes = resolvePermissionsForRoles(roleCodes);
|
||||
|
||||
if (scope === "reimbursement_submit") {
|
||||
if (!hasPermission(permissionCodes, "expense.proxy_submit")) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { delegateEmployeeId: "delegate_must_be_administration" },
|
||||
});
|
||||
}
|
||||
|
||||
return delegateEmployee;
|
||||
}
|
||||
|
||||
if (!hasPermission(permissionCodes, "expense.approve")) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { delegateEmployeeId: "delegate_must_be_approver" },
|
||||
});
|
||||
}
|
||||
|
||||
return delegateEmployee;
|
||||
}
|
||||
|
||||
async function loadGrantById(tx, delegationId) {
|
||||
return tx.delegationGrant.findUnique({
|
||||
where: { id: delegationId },
|
||||
include: {
|
||||
principal_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
delegate_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fastify.get(
|
||||
"/delegation-grants",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseListDelegationGrantsQuery(request.query);
|
||||
const now = new Date();
|
||||
|
||||
const canManageAll = fastify.hasPermission(request.employee, "system.users");
|
||||
|
||||
const baseWhere = canManageAll
|
||||
? {
|
||||
...(query.principalEmployeeId ? { principal_employee_id: query.principalEmployeeId } : {}),
|
||||
...(query.delegateEmployeeId ? { delegate_employee_id: query.delegateEmployeeId } : {}),
|
||||
}
|
||||
: {
|
||||
OR: [
|
||||
{ principal_employee_id: request.employee.id },
|
||||
{ delegate_employee_id: request.employee.id },
|
||||
],
|
||||
};
|
||||
|
||||
const where = {
|
||||
...baseWhere,
|
||||
...(query.includeInactive
|
||||
? {}
|
||||
: {
|
||||
revoked_at: null,
|
||||
valid_from: { lte: now },
|
||||
OR: [{ valid_to: null }, { valid_to: { gt: now } }],
|
||||
}),
|
||||
};
|
||||
|
||||
const items = await fastify.prisma.delegationGrant.findMany({
|
||||
where,
|
||||
orderBy: { created_at: "desc" },
|
||||
include: {
|
||||
principal_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
delegate_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
includeInactive: query.includeInactive,
|
||||
items: items.map((item) => toResponseItem(item, now)),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/delegation-grants",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const input = parseCreateGrantBody(request.body);
|
||||
const canManageAll = fastify.hasPermission(request.employee, "system.users");
|
||||
|
||||
const principalEmployeeId = input.principalEmployeeId ?? request.employee.id;
|
||||
if (principalEmployeeId !== request.employee.id && !canManageAll) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.delegateEmployeeId === principalEmployeeId) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { delegateEmployeeId: "self_not_allowed" },
|
||||
});
|
||||
}
|
||||
|
||||
const delegation = await fastify.prisma.$transaction(async (tx) => {
|
||||
const principal = await tx.employee.findUnique({
|
||||
where: { id: principalEmployeeId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!principal || principal.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { principalEmployeeId: "invalid_principal" },
|
||||
});
|
||||
}
|
||||
|
||||
await ensureDelegateEligible(tx, input.delegateEmployeeId, input.scope);
|
||||
|
||||
const created = await tx.delegationGrant.create({
|
||||
data: {
|
||||
principal_employee_id: principalEmployeeId,
|
||||
delegate_employee_id: input.delegateEmployeeId,
|
||||
scope: input.scope,
|
||||
valid_from: input.validFrom,
|
||||
valid_to: input.validTo,
|
||||
created_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "DELEGATION_CREATE",
|
||||
targetType: "delegation_grant",
|
||||
targetId: created.id,
|
||||
extra: {
|
||||
principalEmployeeId,
|
||||
delegateEmployeeId: input.delegateEmployeeId,
|
||||
scope: input.scope,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return loadGrantById(tx, created.id);
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
delegation: toResponseItem(delegation, new Date()),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/delegation-grants/:id/revoke",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const delegationId = parseDelegationId(request.params?.id);
|
||||
const now = new Date();
|
||||
const canManageAll = fastify.hasPermission(request.employee, "system.users");
|
||||
|
||||
const delegation = await loadGrantById(fastify.prisma, delegationId);
|
||||
if (!delegation) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const canRevoke =
|
||||
delegation.principal_employee_id === request.employee.id || canManageAll;
|
||||
if (!canRevoke) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (delegation.revoked_at) {
|
||||
return sendData(reply, request, {
|
||||
delegation: toResponseItem(delegation, now),
|
||||
outcome: "already_revoked",
|
||||
});
|
||||
}
|
||||
|
||||
const revoked = await fastify.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.delegationGrant.update({
|
||||
where: { id: delegationId },
|
||||
data: {
|
||||
revoked_at: now,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "DELEGATION_REVOKE",
|
||||
targetType: "delegation_grant",
|
||||
targetId: delegationId,
|
||||
extra: {
|
||||
principalEmployeeId: delegation.principal_employee_id,
|
||||
delegateEmployeeId: delegation.delegate_employee_id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return loadGrantById(tx, updated.id);
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
delegation: toResponseItem(revoked, now),
|
||||
outcome: "revoked",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/delegations",
|
||||
{
|
||||
@@ -133,24 +521,9 @@ async function delegationRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const delegateEmployee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: input.delegateEmployeeId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!delegateEmployee || delegateEmployee.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { delegateEmployeeId: "invalid_delegate" },
|
||||
});
|
||||
}
|
||||
|
||||
const delegation = await fastify.prisma.$transaction(async (tx) => {
|
||||
await ensureDelegateEligible(tx, input.delegateEmployeeId, input.scope);
|
||||
|
||||
const created = await tx.delegationGrant.create({
|
||||
data: {
|
||||
principal_employee_id: request.employee.id,
|
||||
@@ -176,7 +549,7 @@ async function delegationRoutes(fastify) {
|
||||
}),
|
||||
});
|
||||
|
||||
return created;
|
||||
return loadGrantById(tx, created.id);
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
@@ -200,12 +573,56 @@ async function delegationRoutes(fastify) {
|
||||
principal_employee_id: request.employee.id,
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
include: {
|
||||
principal_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
delegate_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
fastify.prisma.delegationGrant.findMany({
|
||||
where: {
|
||||
delegate_employee_id: request.employee.id,
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
include: {
|
||||
principal_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
delegate_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -246,6 +663,7 @@ async function delegationRoutes(fastify) {
|
||||
|
||||
const canRevoke =
|
||||
delegation.principal_employee_id === request.employee.id ||
|
||||
fastify.hasPermission(request.employee, "system.users") ||
|
||||
fastify.hasPermission(request.employee, "system.settings");
|
||||
if (!canRevoke) {
|
||||
throw new AppError({
|
||||
@@ -256,8 +674,9 @@ async function delegationRoutes(fastify) {
|
||||
}
|
||||
|
||||
if (delegation.revoked_at) {
|
||||
const embedded = await loadGrantById(fastify.prisma, delegationId);
|
||||
return sendData(reply, request, {
|
||||
delegation: toResponseItem(delegation, now),
|
||||
delegation: toResponseItem(embedded ?? delegation, now),
|
||||
outcome: "already_revoked",
|
||||
});
|
||||
}
|
||||
@@ -283,7 +702,7 @@ async function delegationRoutes(fastify) {
|
||||
}),
|
||||
});
|
||||
|
||||
return updated;
|
||||
return loadGrantById(tx, updated.id);
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
|
||||
const LIST_QUERY_SCHEMA = z.object({
|
||||
includeArchived: z.enum(["0", "1", "true", "false"]).optional(),
|
||||
});
|
||||
|
||||
const CREATE_BODY_SCHEMA = z.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
parentId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
const UPDATE_BODY_SCHEMA = z.object({
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
parentId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
function parseDepartmentId(value) {
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { id: "invalid_uuid" },
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseListQuery(query) {
|
||||
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const raw = parsed.data.includeArchived ?? "0";
|
||||
const includeArchived = raw === "1" || raw === "true";
|
||||
|
||||
return { includeArchived };
|
||||
}
|
||||
|
||||
function parseCreateBody(body) {
|
||||
const parsed = CREATE_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: parsed.data.name.trim(),
|
||||
parentId: parsed.data.parentId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseUpdateBody(body) {
|
||||
const parsed = UPDATE_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const hasUpdate = typeof parsed.data.name === "string" || "parentId" in parsed.data;
|
||||
if (!hasUpdate) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { body: "empty" },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...(typeof parsed.data.name === "string" ? { name: parsed.data.name.trim() } : {}),
|
||||
...(Object.prototype.hasOwnProperty.call(parsed.data, "parentId")
|
||||
? { parentId: parsed.data.parentId ?? null }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mapDepartmentItem(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
parentId: record.parent_id,
|
||||
archivedAt: record.archived_at ? record.archived_at.toISOString() : null,
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDepartmentTree(items) {
|
||||
const nodesById = new Map();
|
||||
for (const record of items) {
|
||||
nodesById.set(record.id, { ...mapDepartmentItem(record), children: [] });
|
||||
}
|
||||
|
||||
const roots = [];
|
||||
for (const record of items) {
|
||||
const node = nodesById.get(record.id);
|
||||
if (!node) continue;
|
||||
|
||||
const parentId = record.parent_id;
|
||||
if (parentId && nodesById.has(parentId)) {
|
||||
nodesById.get(parentId).children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
function detectParentCycle(departments, departmentId, parentId) {
|
||||
const parentMap = new Map(departments.map((item) => [item.id, item.parent_id]));
|
||||
let cursor = parentId;
|
||||
const visited = new Set();
|
||||
|
||||
while (cursor) {
|
||||
if (visited.has(cursor)) {
|
||||
break;
|
||||
}
|
||||
visited.add(cursor);
|
||||
|
||||
if (cursor === departmentId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
cursor = parentMap.get(cursor) ?? null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function departmentsRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/departments",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseListQuery(request.query);
|
||||
const where = query.includeArchived ? {} : { archived_at: null };
|
||||
|
||||
const items = await fastify.prisma.department.findMany({
|
||||
where,
|
||||
orderBy: [{ name: "asc" }, { created_at: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
parent_id: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
includeArchived: query.includeArchived,
|
||||
items: items.map(mapDepartmentItem),
|
||||
tree: buildDepartmentTree(items),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/departments",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const input = parseCreateBody(request.body);
|
||||
const now = new Date();
|
||||
|
||||
if (input.parentId) {
|
||||
const parent = await fastify.prisma.department.findUnique({
|
||||
where: { id: input.parentId },
|
||||
select: { id: true, archived_at: true },
|
||||
});
|
||||
if (!parent) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { parentId: "not_found" },
|
||||
});
|
||||
}
|
||||
if (parent.archived_at) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { parentId: "archived" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const created = await fastify.prisma.$transaction(async (tx) => {
|
||||
const department = await tx.department.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
parent_id: input.parentId,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
created_at: now,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
parent_id: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "DEPARTMENT_CREATE",
|
||||
targetType: "department",
|
||||
targetId: department.id,
|
||||
extra: {
|
||||
name: department.name,
|
||||
parentId: department.parent_id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return department;
|
||||
});
|
||||
|
||||
return sendData(reply, request, { department: mapDepartmentItem(created) });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
"/departments/:id",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const departmentId = parseDepartmentId(request.params?.id);
|
||||
const input = parseUpdateBody(request.body);
|
||||
const now = new Date();
|
||||
|
||||
const updated = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.department.findUnique({
|
||||
where: { id: departmentId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
parent_id: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (current.archived_at) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "archived" },
|
||||
});
|
||||
}
|
||||
|
||||
const parentId =
|
||||
Object.prototype.hasOwnProperty.call(input, "parentId") ? input.parentId : current.parent_id;
|
||||
|
||||
if (parentId === departmentId) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { parentId: "self_not_allowed" },
|
||||
});
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
const parent = await tx.department.findUnique({
|
||||
where: { id: parentId },
|
||||
select: { id: true, archived_at: true },
|
||||
});
|
||||
if (!parent) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { parentId: "not_found" },
|
||||
});
|
||||
}
|
||||
if (parent.archived_at) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { parentId: "archived" },
|
||||
});
|
||||
}
|
||||
|
||||
const allDepartments = await tx.department.findMany({
|
||||
select: { id: true, parent_id: true },
|
||||
});
|
||||
|
||||
if (detectParentCycle(allDepartments, departmentId, parentId)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { parentId: "cycle_not_allowed" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const changes = {};
|
||||
if (typeof input.name === "string" && input.name !== current.name) {
|
||||
changes.name = { before: current.name, after: input.name };
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(input, "parentId")) {
|
||||
changes.parentId = { before: current.parent_id, after: parentId };
|
||||
}
|
||||
|
||||
const department = await tx.department.update({
|
||||
where: { id: departmentId },
|
||||
data: {
|
||||
...(typeof input.name === "string" ? { name: input.name } : {}),
|
||||
...(Object.prototype.hasOwnProperty.call(input, "parentId") ? { parent_id: parentId } : {}),
|
||||
updated_by: request.employee.id,
|
||||
updated_at: now,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
parent_id: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (Object.keys(changes).length) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "DEPARTMENT_UPDATE",
|
||||
targetType: "department",
|
||||
targetId: department.id,
|
||||
extra: { changes },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return department;
|
||||
});
|
||||
|
||||
return sendData(reply, request, { department: mapDepartmentItem(updated) });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/departments/:id/archive",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const departmentId = parseDepartmentId(request.params?.id);
|
||||
const now = new Date();
|
||||
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.department.findUnique({
|
||||
where: { id: departmentId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
parent_id: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (current.archived_at) {
|
||||
return { department: current, outcome: "already_archived" };
|
||||
}
|
||||
|
||||
const [childCount, activeEmployeeCount] = await Promise.all([
|
||||
tx.department.count({ where: { parent_id: departmentId } }),
|
||||
tx.employee.count({ where: { department_id: departmentId, status: "active" } }),
|
||||
]);
|
||||
|
||||
if (childCount > 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { department: "has_children" },
|
||||
});
|
||||
}
|
||||
|
||||
if (activeEmployeeCount > 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { department: "has_active_employees" },
|
||||
});
|
||||
}
|
||||
|
||||
const department = await tx.department.update({
|
||||
where: { id: departmentId },
|
||||
data: {
|
||||
archived_at: now,
|
||||
archived_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
updated_at: now,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
parent_id: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "DEPARTMENT_ARCHIVE",
|
||||
targetType: "department",
|
||||
targetId: department.id,
|
||||
extra: { name: department.name },
|
||||
}),
|
||||
});
|
||||
|
||||
return { department, outcome: "archived" };
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
department: mapDepartmentItem(result.department),
|
||||
outcome: result.outcome,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = departmentsRoutes;
|
||||
@@ -0,0 +1,8 @@
|
||||
const usersRoutes = require("./users");
|
||||
|
||||
async function employeesRoutes(fastify) {
|
||||
await usersRoutes(fastify, { basePath: "/employees" });
|
||||
}
|
||||
|
||||
module.exports = employeesRoutes;
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
|
||||
const LIST_QUERY_SCHEMA = z.object({
|
||||
enabled: z.enum(["true", "false", "1", "0"]).optional(),
|
||||
});
|
||||
|
||||
const CREATE_BODY_SCHEMA = z.object({
|
||||
code: z.string().trim().min(1).max(50),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
description: z.string().trim().max(500).optional(),
|
||||
category: z.enum(["travel", "rd", "production", "office", "business", "assets", "other"]),
|
||||
sortOrder: z.number().int().min(0).max(10000),
|
||||
accountCodeDefault: z.string().trim().max(50).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const UPDATE_BODY_SCHEMA = CREATE_BODY_SCHEMA.partial();
|
||||
|
||||
function parseListQuery(query) {
|
||||
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const enabledRaw = parsed.data.enabled;
|
||||
const enabled =
|
||||
enabledRaw == null
|
||||
? null
|
||||
: ["true", "1"].includes(enabledRaw)
|
||||
? true
|
||||
: ["false", "0"].includes(enabledRaw)
|
||||
? false
|
||||
: null;
|
||||
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseExpenseTypeId(params) {
|
||||
const parsed = z.string().uuid().safeParse(params?.id);
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { id: "invalid_uuid" },
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function mapExpenseType(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
category: item.category,
|
||||
sortOrder: item.sort_order,
|
||||
accountCodeDefault: item.account_code_default,
|
||||
isSystemDefault: item.is_system_default,
|
||||
enabled: item.enabled,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function expenseTypesRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/expense-types",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseListQuery(request.query);
|
||||
|
||||
const items = await fastify.prisma.expenseType.findMany({
|
||||
where: {
|
||||
...(query.enabled == null ? {} : { enabled: query.enabled }),
|
||||
},
|
||||
orderBy: [{ sort_order: "asc" }, { code: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
description: true,
|
||||
category: true,
|
||||
sort_order: true,
|
||||
account_code_default: true,
|
||||
is_system_default: true,
|
||||
enabled: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
enabled: query.enabled,
|
||||
items: items.map(mapExpenseType),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/expense-types",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = parseBody(CREATE_BODY_SCHEMA, request.body);
|
||||
const now = new Date();
|
||||
|
||||
let created = null;
|
||||
try {
|
||||
created = await fastify.prisma.$transaction(async (tx) => {
|
||||
const expenseType = await tx.expenseType.create({
|
||||
data: {
|
||||
code: body.code,
|
||||
name: body.name,
|
||||
description: body.description ?? null,
|
||||
category: body.category,
|
||||
sort_order: body.sortOrder,
|
||||
account_code_default: body.accountCodeDefault ?? null,
|
||||
enabled: body.enabled ?? true,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "EXPENSE_TYPE_CREATED",
|
||||
targetType: "expense_type",
|
||||
targetId: expenseType.id,
|
||||
extra: {
|
||||
code: expenseType.code,
|
||||
name: expenseType.name,
|
||||
enabled: expenseType.enabled,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return expenseType;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === "P2002") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { code: "duplicate" },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sendData(reply, request, mapExpenseType(created));
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
"/expense-types/:id",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const expenseTypeId = parseExpenseTypeId(request.params);
|
||||
const body = parseBody(UPDATE_BODY_SCHEMA, request.body);
|
||||
|
||||
if (!Object.keys(body).length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { body: "empty" },
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
let updated = null;
|
||||
try {
|
||||
updated = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.expenseType.findUnique({
|
||||
where: { id: expenseTypeId },
|
||||
});
|
||||
if (!current) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const changes = {};
|
||||
if (typeof body.name === "string" && body.name !== current.name) {
|
||||
changes.name = { before: current.name, after: body.name };
|
||||
}
|
||||
if (typeof body.description === "string" && body.description !== (current.description ?? "")) {
|
||||
changes.description = { before: current.description, after: body.description };
|
||||
}
|
||||
if (typeof body.category === "string" && body.category !== current.category) {
|
||||
changes.category = { before: current.category, after: body.category };
|
||||
}
|
||||
if (typeof body.sortOrder === "number" && body.sortOrder !== current.sort_order) {
|
||||
changes.sortOrder = { before: current.sort_order, after: body.sortOrder };
|
||||
}
|
||||
if (
|
||||
typeof body.accountCodeDefault === "string" &&
|
||||
body.accountCodeDefault !== (current.account_code_default ?? "")
|
||||
) {
|
||||
changes.accountCodeDefault = {
|
||||
before: current.account_code_default,
|
||||
after: body.accountCodeDefault,
|
||||
};
|
||||
}
|
||||
if (typeof body.enabled === "boolean" && body.enabled !== current.enabled) {
|
||||
changes.enabled = { before: current.enabled, after: body.enabled };
|
||||
}
|
||||
|
||||
const expenseType = await tx.expenseType.update({
|
||||
where: { id: expenseTypeId },
|
||||
data: {
|
||||
...(typeof body.name === "string" ? { name: body.name } : {}),
|
||||
...(typeof body.description === "string" ? { description: body.description } : {}),
|
||||
...(typeof body.category === "string" ? { category: body.category } : {}),
|
||||
...(typeof body.sortOrder === "number" ? { sort_order: body.sortOrder } : {}),
|
||||
...(typeof body.accountCodeDefault === "string"
|
||||
? { account_code_default: body.accountCodeDefault }
|
||||
: {}),
|
||||
...(typeof body.enabled === "boolean" ? { enabled: body.enabled } : {}),
|
||||
updated_at: now,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (Object.keys(changes).length) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "EXPENSE_TYPE_UPDATED",
|
||||
targetType: "expense_type",
|
||||
targetId: expenseTypeId,
|
||||
extra: {
|
||||
code: expenseType.code,
|
||||
changes,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return expenseType;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === "P2002") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { code: "duplicate" },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sendData(reply, request, mapExpenseType(updated));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = expenseTypesRoutes;
|
||||
|
||||
+92
-26
@@ -6,10 +6,12 @@ const path = require("node:path");
|
||||
const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { compactDateString } = require("../utils/date");
|
||||
const { sha256Hex } = require("../utils/crypto");
|
||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { toMoneyString } = require("../utils/workflow");
|
||||
const { findActiveDelegationForDelegate } = require("../utils/delegation");
|
||||
const { isOcrEnabledForType } = require("../services/ocr");
|
||||
|
||||
const MIME_ALLOWLIST = new Set([
|
||||
@@ -392,14 +394,57 @@ async function invoicePoolRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const ownerEmployeeId = fields.owner_employee_id ?? request.employee.id;
|
||||
if (ownerEmployeeId !== request.employee.id) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { owner_employee_id: "cross_owner_upload_not_allowed_in_mvp" },
|
||||
const ownerEmployeeId =
|
||||
typeof fields.owner_employee_id === "string" && fields.owner_employee_id.trim()
|
||||
? fields.owner_employee_id.trim()
|
||||
: request.employee.id;
|
||||
const isCrossOwner = ownerEmployeeId !== request.employee.id;
|
||||
let delegationGrantId = null;
|
||||
|
||||
if (isCrossOwner) {
|
||||
const canManageUsers = fastify.hasPermission(request.employee, "system.users");
|
||||
if (!canManageUsers) {
|
||||
const isAdministration = request.employee.roleCodes?.includes("administration");
|
||||
const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit");
|
||||
if (!isAdministration || !canProxySubmit) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { owner_employee_id: "cross_owner_upload_not_allowed" },
|
||||
});
|
||||
}
|
||||
|
||||
const delegation = await findActiveDelegationForDelegate(fastify.prisma, {
|
||||
principalEmployeeId: ownerEmployeeId,
|
||||
delegateEmployeeId: request.employee.id,
|
||||
allowedScopes: ["reimbursement_submit", "all"],
|
||||
now: new Date(),
|
||||
});
|
||||
if (!delegation) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { delegation: "missing_active_grant" },
|
||||
});
|
||||
}
|
||||
|
||||
delegationGrantId = delegation.id;
|
||||
}
|
||||
|
||||
const ownerEmployee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: ownerEmployeeId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!ownerEmployee || ownerEmployee.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { owner_employee_id: "invalid_owner" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ocrType = fields.ocr_type ?? "general";
|
||||
@@ -448,25 +493,46 @@ async function invoicePoolRoutes(fastify) {
|
||||
|
||||
const status = isOcrEnabledForType(fastify.config, ocrType) ? "pending_ocr" : "available";
|
||||
|
||||
const invoice = await fastify.prisma.invoicePool.create({
|
||||
data: {
|
||||
owner_employee_id: ownerEmployeeId,
|
||||
uploaded_by_employee_id: request.employee.id,
|
||||
storage_path: storagePath,
|
||||
original_name: filePart.filename ?? "upload",
|
||||
mime_type: filePart.mimetype,
|
||||
file_size: fileSize,
|
||||
file_sha256: fileSha256,
|
||||
ocr_type: ocrType,
|
||||
status,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
file_sha256: true,
|
||||
},
|
||||
const invoice = await fastify.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.invoicePool.create({
|
||||
data: {
|
||||
owner_employee_id: ownerEmployeeId,
|
||||
uploaded_by_employee_id: request.employee.id,
|
||||
storage_path: storagePath,
|
||||
original_name: filePart.filename ?? "upload",
|
||||
mime_type: filePart.mimetype,
|
||||
file_size: fileSize,
|
||||
file_sha256: fileSha256,
|
||||
ocr_type: ocrType,
|
||||
status,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
file_sha256: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "INVOICE_POOL_UPLOADED",
|
||||
targetType: "invoice_pool",
|
||||
targetId: created.id,
|
||||
extra: {
|
||||
ownerEmployeeId,
|
||||
uploadedByEmployeeId: request.employee.id,
|
||||
crossOwner: isCrossOwner,
|
||||
delegationGrantId,
|
||||
ocrType,
|
||||
fileSha256,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
|
||||
+688
-19
@@ -1,7 +1,9 @@
|
||||
const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const { findActiveDelegationForDelegate } = require("../utils/delegation");
|
||||
const {
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
resolveActionableStep,
|
||||
@@ -24,6 +26,18 @@ const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
|
||||
scope: z.enum(["own", "all"]).optional(),
|
||||
});
|
||||
|
||||
const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
|
||||
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
description: z.string().trim().max(500).nullable().optional(),
|
||||
});
|
||||
|
||||
const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
|
||||
expenseTypeId: z.string().uuid(),
|
||||
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
|
||||
description: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||
if (canViewAll) {
|
||||
@@ -50,6 +64,24 @@ function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureApplicantOrAdmin(request, reimbursement) {
|
||||
if (request.employee?.roleCodes?.includes("admin")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isApplicant =
|
||||
reimbursement.owner_id === request.employee.id ||
|
||||
reimbursement.submitter_id === request.employee.id;
|
||||
|
||||
if (!isApplicant) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatFormNumber(sequence, timezone) {
|
||||
const datePart = compactDateString(timezone);
|
||||
const serial = String(sequence.current_number).padStart(sequence.number_length, "0");
|
||||
@@ -88,6 +120,53 @@ function parseReimbursementId(id) {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseReimbursementUpdateBody(body) {
|
||||
const parsed = REIMBURSEMENT_UPDATE_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!Object.keys(parsed.data).length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { body: "empty" },
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseItemBody(body) {
|
||||
const parsed = REIMBURSEMENT_ITEM_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const amountNumber = Number(parsed.data.amount);
|
||||
if (!Number.isFinite(amountNumber) || amountNumber < 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { amount: "invalid" },
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parsePayBody(body) {
|
||||
const parsed = PAY_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
@@ -214,6 +293,24 @@ function ensureNonNegativeAmount(totalAmount) {
|
||||
}
|
||||
}
|
||||
|
||||
async function recomputeTotalAmount(tx, reimbursementId, actorId) {
|
||||
const aggregate = await tx.reimbursementItem.aggregate({
|
||||
where: { reimbursement_id: reimbursementId },
|
||||
_sum: { amount: true },
|
||||
});
|
||||
|
||||
const nextTotal = aggregate._sum.amount ?? "0";
|
||||
await tx.reimbursementForm.update({
|
||||
where: { id: reimbursementId },
|
||||
data: {
|
||||
total_amount: nextTotal,
|
||||
updated_by: actorId,
|
||||
},
|
||||
});
|
||||
|
||||
return nextTotal;
|
||||
}
|
||||
|
||||
async function loadApprovalRuleForAmount(tx, totalAmount) {
|
||||
const amountString = toMoneyString(totalAmount);
|
||||
const approvalRule = await tx.approvalRule.findFirst({
|
||||
@@ -246,6 +343,7 @@ async function loadApprovalRuleForAmount(tx, totalAmount) {
|
||||
|
||||
async function reimbursementRoutes(fastify) {
|
||||
const createBodySchema = z.object({
|
||||
ownerEmployeeId: z.string().uuid().optional(),
|
||||
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
});
|
||||
@@ -269,29 +367,102 @@ async function reimbursementRoutes(fastify) {
|
||||
const reimbursementDate =
|
||||
parsed.data.reimbursementDate ?? currentDateString(fastify.config.appTimezone);
|
||||
|
||||
const reimbursement = await fastify.prisma.reimbursementForm.create({
|
||||
data: {
|
||||
submitter_id: request.employee.id,
|
||||
owner_id: request.employee.id,
|
||||
department_id: request.employee.department_id,
|
||||
reimbursement_date: parseDateOnlyString(reimbursementDate),
|
||||
description: parsed.data.description ?? null,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
reimbursement_date: true,
|
||||
description: true,
|
||||
total_amount: true,
|
||||
form_number: true,
|
||||
},
|
||||
const ownerEmployeeId = parsed.data.ownerEmployeeId ?? request.employee.id;
|
||||
const isProxyCreate = ownerEmployeeId !== request.employee.id;
|
||||
|
||||
const reimbursement = await fastify.prisma.$transaction(async (tx) => {
|
||||
let departmentId = request.employee.department_id;
|
||||
let delegationGrantId = null;
|
||||
|
||||
if (isProxyCreate) {
|
||||
const isAdministration = request.employee.roleCodes?.includes("administration");
|
||||
const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit");
|
||||
if (!isAdministration || !canProxySubmit) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const ownerEmployee = await tx.employee.findUnique({
|
||||
where: { id: ownerEmployeeId },
|
||||
select: { id: true, status: true, department_id: true },
|
||||
});
|
||||
if (!ownerEmployee || ownerEmployee.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { ownerEmployeeId: "invalid_owner" },
|
||||
});
|
||||
}
|
||||
|
||||
const delegation = await findActiveDelegationForDelegate(tx, {
|
||||
principalEmployeeId: ownerEmployeeId,
|
||||
delegateEmployeeId: request.employee.id,
|
||||
allowedScopes: ["reimbursement_submit", "all"],
|
||||
now: new Date(),
|
||||
});
|
||||
if (!delegation) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { delegation: "missing_active_grant" },
|
||||
});
|
||||
}
|
||||
|
||||
delegationGrantId = delegation.id;
|
||||
departmentId = ownerEmployee.department_id;
|
||||
}
|
||||
|
||||
const created = await tx.reimbursementForm.create({
|
||||
data: {
|
||||
submitter_id: request.employee.id,
|
||||
owner_id: ownerEmployeeId,
|
||||
department_id: departmentId,
|
||||
reimbursement_date: parseDateOnlyString(reimbursementDate),
|
||||
description: parsed.data.description ?? null,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
reimbursement_date: true,
|
||||
description: true,
|
||||
total_amount: true,
|
||||
form_number: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (isProxyCreate) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_PROXY_CREATED",
|
||||
targetType: "reimbursement_form",
|
||||
targetId: created.id,
|
||||
extra: {
|
||||
ownerEmployeeId,
|
||||
submitterEmployeeId: request.employee.id,
|
||||
delegationGrantId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
id: reimbursement.id,
|
||||
formNumber: reimbursement.form_number,
|
||||
submitterId: reimbursement.submitter_id,
|
||||
ownerId: reimbursement.owner_id,
|
||||
reimbursementDate,
|
||||
status: reimbursement.status,
|
||||
description: reimbursement.description,
|
||||
@@ -402,6 +573,25 @@ async function reimbursementRoutes(fastify) {
|
||||
submitter: {
|
||||
select: { id: true, employee_no: true, name: true },
|
||||
},
|
||||
items: {
|
||||
orderBy: { expense_date: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
expense_type_id: true,
|
||||
expense_date: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
expense_type: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
category: true,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invoice_pool_items: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -458,6 +648,22 @@ async function reimbursementRoutes(fastify) {
|
||||
description: reimbursement.description,
|
||||
owner: reimbursement.owner,
|
||||
submitter: reimbursement.submitter,
|
||||
items: reimbursement.items.map((item) => ({
|
||||
id: item.id,
|
||||
expenseTypeId: item.expense_type_id,
|
||||
expenseDate: item.expense_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
amount: toMoneyString(item.amount),
|
||||
description: item.description,
|
||||
expenseType: item.expense_type
|
||||
? {
|
||||
id: item.expense_type.id,
|
||||
code: item.expense_type.code,
|
||||
name: item.expense_type.name,
|
||||
category: item.expense_type.category,
|
||||
enabled: item.expense_type.enabled,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
invoices: reimbursement.invoice_pool_items.map((item) => ({
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
@@ -476,6 +682,453 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
"/reimbursements/:id",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const body = parseReimbursementUpdateBody(request.body);
|
||||
|
||||
const updated = await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, status
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
|
||||
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_edit_status" },
|
||||
});
|
||||
}
|
||||
|
||||
const nextDescription =
|
||||
body.description === undefined ? undefined : body.description?.trim() || null;
|
||||
|
||||
return tx.reimbursementForm.update({
|
||||
where: { id: reimbursementId },
|
||||
data: {
|
||||
...(body.reimbursementDate
|
||||
? { reimbursement_date: parseDateOnlyString(body.reimbursementDate) }
|
||||
: {}),
|
||||
...(body.description !== undefined ? { description: nextDescription } : {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
reimbursement_date: true,
|
||||
description: true,
|
||||
total_amount: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
reimbursementDate: updated.reimbursement_date.toISOString().slice(0, 10),
|
||||
description: updated.description,
|
||||
totalAmount: toMoneyString(updated.total_amount),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete(
|
||||
"/reimbursements/:id",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.delete_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, status
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
|
||||
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
|
||||
if (reimbursement.status !== "draft") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_delete_status" },
|
||||
});
|
||||
}
|
||||
|
||||
const unbound = await tx.invoicePool.updateMany({
|
||||
where: {
|
||||
linked_reimbursement_id: reimbursementId,
|
||||
status: "bound",
|
||||
},
|
||||
data: {
|
||||
status: "available",
|
||||
linked_reimbursement_id: null,
|
||||
linked_item_id: null,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.reimbursementForm.delete({
|
||||
where: { id: reimbursementId },
|
||||
});
|
||||
|
||||
return {
|
||||
reimbursementId,
|
||||
unboundInvoiceCount: unbound.count,
|
||||
};
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
reimbursementId: result.reimbursementId,
|
||||
outcome: "deleted",
|
||||
unboundInvoiceCount: result.unboundInvoiceCount,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/items",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const body = parseItemBody(request.body);
|
||||
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, status
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_item_edit_status" },
|
||||
});
|
||||
}
|
||||
|
||||
const expenseType = await tx.expenseType.findUnique({
|
||||
where: { id: body.expenseTypeId },
|
||||
select: { id: true, enabled: true },
|
||||
});
|
||||
if (!expenseType || !expenseType.enabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { expenseTypeId: "invalid_or_disabled" },
|
||||
});
|
||||
}
|
||||
|
||||
const created = await tx.reimbursementItem.create({
|
||||
data: {
|
||||
reimbursement_id: reimbursementId,
|
||||
expense_type_id: body.expenseTypeId,
|
||||
expense_date: parseDateOnlyString(body.expenseDate),
|
||||
amount: body.amount,
|
||||
description: body.description,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
expense_type_id: true,
|
||||
expense_date: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
},
|
||||
});
|
||||
|
||||
const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id);
|
||||
|
||||
return {
|
||||
item: created,
|
||||
totalAmount: nextTotal,
|
||||
};
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
item: {
|
||||
id: result.item.id,
|
||||
expenseTypeId: result.item.expense_type_id,
|
||||
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
|
||||
amount: toMoneyString(result.item.amount),
|
||||
description: result.item.description,
|
||||
},
|
||||
totalAmount: toMoneyString(result.totalAmount),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
"/reimbursements/:id/items/:itemId",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedItemId = UUID_SCHEMA.safeParse(request.params?.itemId);
|
||||
if (!parsedItemId.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { itemId: "invalid_uuid" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsedBody = REIMBURSEMENT_ITEM_BODY_SCHEMA.partial().safeParse(request.body ?? {});
|
||||
if (!parsedBody.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsedBody.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!Object.keys(parsedBody.data).length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { body: "empty" },
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof parsedBody.data.amount === "string") {
|
||||
const amountNumber = Number(parsedBody.data.amount);
|
||||
if (!Number.isFinite(amountNumber) || amountNumber < 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { amount: "invalid" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, status
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_item_edit_status" },
|
||||
});
|
||||
}
|
||||
|
||||
const item = await tx.reimbursementItem.findFirst({
|
||||
where: {
|
||||
id: parsedItemId.data,
|
||||
reimbursement_id: reimbursementId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!item) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof parsedBody.data.expenseTypeId === "string") {
|
||||
const expenseType = await tx.expenseType.findUnique({
|
||||
where: { id: parsedBody.data.expenseTypeId },
|
||||
select: { id: true, enabled: true },
|
||||
});
|
||||
if (!expenseType || !expenseType.enabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { expenseTypeId: "invalid_or_disabled" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await tx.reimbursementItem.update({
|
||||
where: { id: parsedItemId.data },
|
||||
data: {
|
||||
...(typeof parsedBody.data.expenseTypeId === "string"
|
||||
? { expense_type_id: parsedBody.data.expenseTypeId }
|
||||
: {}),
|
||||
...(typeof parsedBody.data.expenseDate === "string"
|
||||
? { expense_date: parseDateOnlyString(parsedBody.data.expenseDate) }
|
||||
: {}),
|
||||
...(typeof parsedBody.data.amount === "string"
|
||||
? { amount: parsedBody.data.amount }
|
||||
: {}),
|
||||
...(typeof parsedBody.data.description === "string"
|
||||
? { description: parsedBody.data.description.trim() }
|
||||
: {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
expense_type_id: true,
|
||||
expense_date: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
},
|
||||
});
|
||||
|
||||
const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id);
|
||||
|
||||
return {
|
||||
item: updated,
|
||||
totalAmount: nextTotal,
|
||||
};
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
item: {
|
||||
id: result.item.id,
|
||||
expenseTypeId: result.item.expense_type_id,
|
||||
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
|
||||
amount: toMoneyString(result.item.amount),
|
||||
description: result.item.description,
|
||||
},
|
||||
totalAmount: toMoneyString(result.totalAmount),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete(
|
||||
"/reimbursements/:id/items/:itemId",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedItemId = UUID_SCHEMA.safeParse(request.params?.itemId);
|
||||
if (!parsedItemId.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { itemId: "invalid_uuid" },
|
||||
});
|
||||
}
|
||||
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, status
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_item_edit_status" },
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await tx.reimbursementItem.deleteMany({
|
||||
where: {
|
||||
id: parsedItemId.data,
|
||||
reimbursement_id: reimbursementId,
|
||||
},
|
||||
});
|
||||
if (deleted.count === 0) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id);
|
||||
|
||||
return { totalAmount: nextTotal };
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
outcome: "deleted",
|
||||
totalAmount: toMoneyString(result.totalAmount),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/submit",
|
||||
{
|
||||
@@ -502,7 +1155,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureOwnOrCanViewAll(fastify, request, reimbursement);
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
@@ -694,6 +1347,22 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
});
|
||||
|
||||
if (reimbursement.submitter_id !== reimbursement.owner_id) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_PROXY_SUBMITTED",
|
||||
targetType: "reimbursement_form",
|
||||
targetId: reimbursementId,
|
||||
extra: {
|
||||
ownerEmployeeId: reimbursement.owner_id,
|
||||
submitterEmployeeId: reimbursement.submitter_id,
|
||||
formNumber,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
submitResult = {
|
||||
reimbursementId,
|
||||
formNumber,
|
||||
|
||||
+472
-10
@@ -1,3 +1,5 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
@@ -32,11 +34,22 @@ const createBodySchema = z.object({
|
||||
departmentId: z.string().uuid().optional(),
|
||||
title: z.string().min(1).max(100).optional(),
|
||||
directManagerId: z.string().uuid().nullable().optional(),
|
||||
hireDate: z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/).optional(),
|
||||
hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
roles: z.array(roleCodeSchema).min(1).optional(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const updateBodySchema = z.object({
|
||||
employeeNo: z.string().trim().min(1).max(50).optional(),
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
phone: z.string().trim().min(6).max(30).optional(),
|
||||
email: z.string().trim().email().max(200).optional(),
|
||||
departmentId: z.string().uuid().optional(),
|
||||
title: z.string().trim().min(1).max(100).optional(),
|
||||
directManagerId: z.string().uuid().nullable().optional(),
|
||||
hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
});
|
||||
|
||||
const setRolesBodySchema = z.object({
|
||||
roles: z.array(roleCodeSchema).min(1),
|
||||
});
|
||||
@@ -104,6 +117,29 @@ function parseCreateBody(body) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseUpdateBody(body) {
|
||||
const parsed = updateBodySchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!Object.keys(parsed.data).length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { body: "empty" },
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function mapEmployeeSummary(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -120,6 +156,15 @@ function mapEmployeeSummary(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapEmployeeDetail(row) {
|
||||
return {
|
||||
...mapEmployeeSummary(row),
|
||||
directManagerId: row.direct_manager_id ?? null,
|
||||
hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
archivedAt: row.archived_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUniqueErrorDetails(error) {
|
||||
const meta = error?.meta ?? {};
|
||||
const target = meta.target ?? meta?.cause?.constraint?.fields ?? null;
|
||||
@@ -135,9 +180,14 @@ function normalizeUniqueErrorDetails(error) {
|
||||
return Object.keys(details).length ? details : { unique: "duplicate" };
|
||||
}
|
||||
|
||||
async function usersRoutes(fastify) {
|
||||
async function usersRoutes(fastify, opts = {}) {
|
||||
const basePathRaw =
|
||||
typeof opts.basePath === "string" && opts.basePath.trim() ? opts.basePath.trim() : "/users";
|
||||
const basePath = basePathRaw.startsWith("/") ? basePathRaw : `/${basePathRaw}`;
|
||||
const routeBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
|
||||
|
||||
fastify.get(
|
||||
"/users",
|
||||
routeBasePath,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
@@ -192,8 +242,61 @@ async function usersRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
`${routeBasePath}/:id`,
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
const canManage = fastify.hasPermission(request.employee, "system.users");
|
||||
|
||||
if (!canManage) {
|
||||
const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own");
|
||||
const isSelf = request.employee.id === userId;
|
||||
if (!canViewOwn || !isSelf) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const employee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, mapEmployeeDetail(employee));
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/users",
|
||||
routeBasePath,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
@@ -204,7 +307,7 @@ async function usersRoutes(fastify) {
|
||||
|
||||
const department = await fastify.prisma.department.findUnique({
|
||||
where: { id: departmentId },
|
||||
select: { id: true },
|
||||
select: { id: true, archived_at: true },
|
||||
});
|
||||
if (!department) {
|
||||
throw new AppError({
|
||||
@@ -215,6 +318,38 @@ async function usersRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
if (department.archived_at) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { departmentId: "archived" },
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof body.directManagerId === "string") {
|
||||
const manager = await fastify.prisma.employee.findUnique({
|
||||
where: { id: body.directManagerId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!manager) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { directManagerId: "not_found" },
|
||||
});
|
||||
}
|
||||
if (manager.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { directManagerId: "inactive" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(body.password);
|
||||
const now = new Date();
|
||||
|
||||
@@ -236,6 +371,7 @@ async function usersRoutes(fastify) {
|
||||
bank_account_encrypted: "ENC_TBD",
|
||||
bank_name: "TBD",
|
||||
password_hash: passwordHash,
|
||||
must_change_password: true,
|
||||
created_at: now,
|
||||
created_by: request.employee.id,
|
||||
updated_at: now,
|
||||
@@ -305,8 +441,209 @@ async function usersRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
`${routeBasePath}/:id`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
const body = parseUpdateBody(request.body);
|
||||
const now = new Date();
|
||||
|
||||
const hireDate =
|
||||
typeof body.hireDate === "string" ? new Date(`${body.hireDate}T00:00:00.000Z`) : null;
|
||||
if (hireDate && Number.isNaN(hireDate.getTime())) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { hireDate: "invalid_date" },
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof body.departmentId === "string") {
|
||||
const department = await fastify.prisma.department.findUnique({
|
||||
where: { id: body.departmentId },
|
||||
select: { id: true, archived_at: true },
|
||||
});
|
||||
if (!department) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { departmentId: "not_found" },
|
||||
});
|
||||
}
|
||||
if (department.archived_at) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { departmentId: "archived" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "directManagerId")) {
|
||||
if (body.directManagerId === userId) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { directManagerId: "self_not_allowed" },
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof body.directManagerId === "string") {
|
||||
const manager = await fastify.prisma.employee.findUnique({
|
||||
where: { id: body.directManagerId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!manager) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { directManagerId: "not_found" },
|
||||
});
|
||||
}
|
||||
if (manager.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { directManagerId: "inactive" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let updated = null;
|
||||
try {
|
||||
updated = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.employee.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
});
|
||||
if (!current) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const changes = {};
|
||||
if (typeof body.employeeNo === "string" && body.employeeNo !== current.employee_no) {
|
||||
changes.employeeNo = { before: current.employee_no, after: body.employeeNo };
|
||||
}
|
||||
if (typeof body.name === "string" && body.name !== current.name) {
|
||||
changes.name = { before: current.name, after: body.name };
|
||||
}
|
||||
if (typeof body.phone === "string" && body.phone !== current.phone) {
|
||||
changes.phone = { before: current.phone, after: body.phone };
|
||||
}
|
||||
if (typeof body.email === "string" && body.email !== current.email) {
|
||||
changes.email = { before: current.email, after: body.email };
|
||||
}
|
||||
if (typeof body.departmentId === "string" && body.departmentId !== current.department_id) {
|
||||
changes.departmentId = { before: current.department_id, after: body.departmentId };
|
||||
}
|
||||
if (typeof body.title === "string" && body.title !== current.title) {
|
||||
changes.title = { before: current.title, after: body.title };
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(body, "directManagerId")) {
|
||||
changes.directManagerId = { before: current.direct_manager_id, after: body.directManagerId ?? null };
|
||||
}
|
||||
if (typeof body.hireDate === "string") {
|
||||
const currentHireDate = current.hire_date?.toISOString?.()?.slice(0, 10) ?? null;
|
||||
if (body.hireDate !== currentHireDate) {
|
||||
changes.hireDate = { before: currentHireDate, after: body.hireDate };
|
||||
}
|
||||
}
|
||||
|
||||
const employee = await tx.employee.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(typeof body.employeeNo === "string" ? { employee_no: body.employeeNo } : {}),
|
||||
...(typeof body.name === "string" ? { name: body.name } : {}),
|
||||
...(typeof body.phone === "string" ? { phone: body.phone } : {}),
|
||||
...(typeof body.email === "string" ? { email: body.email } : {}),
|
||||
...(typeof body.departmentId === "string" ? { department_id: body.departmentId } : {}),
|
||||
...(typeof body.title === "string" ? { title: body.title } : {}),
|
||||
...(Object.prototype.hasOwnProperty.call(body, "directManagerId")
|
||||
? { direct_manager_id: body.directManagerId ?? null }
|
||||
: {}),
|
||||
...(typeof body.hireDate === "string" ? { hire_date: hireDate } : {}),
|
||||
updated_at: now,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (Object.keys(changes).length) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "SYSTEM_USER_UPDATED",
|
||||
targetType: "employee",
|
||||
targetId: userId,
|
||||
extra: { changes },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return employee;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === "P2002") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: normalizeUniqueErrorDetails(error),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sendData(reply, request, mapEmployeeDetail(updated));
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/users/:id/disable",
|
||||
`${routeBasePath}/:id/archive`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
@@ -364,7 +701,65 @@ async function usersRoutes(fastify) {
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/users/:id/enable",
|
||||
`${routeBasePath}/:id/disable`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
if (userId === request.employee.id) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { user: "self_disable_not_allowed" },
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const employee = await fastify.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.employee.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
status: "archived",
|
||||
archived_at: now,
|
||||
token_version: { increment: 1 },
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
token_version: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.authSession.updateMany({
|
||||
where: { employee_id: userId, revoked_at: null },
|
||||
data: { revoked_at: now },
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "SYSTEM_USER_DISABLED",
|
||||
targetType: "employee",
|
||||
targetId: userId,
|
||||
extra: {},
|
||||
}),
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
id: employee.id,
|
||||
status: employee.status,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
`${routeBasePath}/:id/enable`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
@@ -414,7 +809,7 @@ async function usersRoutes(fastify) {
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/users/:id/reset-password",
|
||||
`${routeBasePath}/:id/reset-password`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
@@ -454,6 +849,10 @@ async function usersRoutes(fastify) {
|
||||
where: { id: userId },
|
||||
data: {
|
||||
password_hash: passwordHash,
|
||||
must_change_password: true,
|
||||
login_failed_count: 0,
|
||||
login_failed_first_at: null,
|
||||
login_locked_until: null,
|
||||
token_version: { increment: 1 },
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
@@ -483,7 +882,71 @@ async function usersRoutes(fastify) {
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/users/:id/roles",
|
||||
`${routeBasePath}/:id/unlock`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
const now = new Date();
|
||||
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.employee.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
login_locked_until: true,
|
||||
login_failed_count: true,
|
||||
},
|
||||
});
|
||||
if (!current) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const wasLocked = Boolean(current.login_locked_until && current.login_locked_until > now);
|
||||
|
||||
await tx.employee.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
login_failed_count: 0,
|
||||
login_failed_first_at: null,
|
||||
login_locked_until: null,
|
||||
updated_by: request.employee.id,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "SYSTEM_USER_UNLOCKED",
|
||||
targetType: "employee",
|
||||
targetId: userId,
|
||||
extra: {
|
||||
previousLockedUntil: current.login_locked_until
|
||||
? current.login_locked_until.toISOString()
|
||||
: null,
|
||||
previousFailedCount: current.login_failed_count ?? 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return { wasLocked };
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
id: userId,
|
||||
outcome: result.wasLocked ? "unlocked" : "already_unlocked",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
`${routeBasePath}/:id/roles`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
@@ -559,4 +1022,3 @@ async function usersRoutes(fastify) {
|
||||
}
|
||||
|
||||
module.exports = usersRoutes;
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ async function ensureEmployee(prisma, payload) {
|
||||
bank_account_encrypted: "ENC_DEV_ONLY",
|
||||
bank_name: "DEV_BANK",
|
||||
password_hash: passwordHash,
|
||||
must_change_password: false,
|
||||
updated_at: now,
|
||||
},
|
||||
select: { id: true, email: true },
|
||||
@@ -92,6 +93,7 @@ async function ensureEmployee(prisma, payload) {
|
||||
bank_account_encrypted: "ENC_DEV_ONLY",
|
||||
bank_name: "DEV_BANK",
|
||||
password_hash: passwordHash,
|
||||
must_change_password: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
@@ -108,6 +108,7 @@ async function ensureAdminEmployee(prisma, params) {
|
||||
bank_account_encrypted: "ENC_TRIALRUN",
|
||||
bank_name: "TRIALRUN_BANK",
|
||||
password_hash: passwordHash,
|
||||
must_change_password: true,
|
||||
created_at: now,
|
||||
created_by: null,
|
||||
updated_at: now,
|
||||
@@ -178,4 +179,3 @@ main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const zxcvbn = require("zxcvbn");
|
||||
|
||||
function validatePassword(password) {
|
||||
const errors = [];
|
||||
|
||||
@@ -14,8 +16,17 @@ function validatePassword(password) {
|
||||
errors.push("密码必须包含数字");
|
||||
}
|
||||
|
||||
const weakCheckEnabled = process.env.WEAK_PASSWORD_CHECK_ENABLED !== "false";
|
||||
const weakMinScore = Number(process.env.WEAK_PASSWORD_MIN_SCORE ?? 2);
|
||||
|
||||
if (weakCheckEnabled && typeof password === "string" && password.length >= 1) {
|
||||
const score = zxcvbn(password).score ?? 0;
|
||||
if (score < weakMinScore) {
|
||||
errors.push("密码过于常见/弱");
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
module.exports = { validatePassword };
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户