feat(P11-P12): 落地治理开关与移动端MVP(G0-G6/H1/M1-M4)
- 新增 Feature Flag 模型、评估服务与管理 API(erp_sync/invoice_verification)\n- 完成 ERP 与验真灰度收口(路由/服务/作业统一判定)\n- 补齐 pay 与手动 sync 的 department_id 灰度上下文\n- 增加管理后台开关页、PWA 基础设施与移动拍照/审批页面\n- 增加回归脚本、安全基线与压测脚本,更新文档与执行合同\n\nIssue: G0,G1,G2,G3,G4,G5,G6,H1,M1,M2,M3,M4
这个提交包含在:
@@ -57,6 +57,7 @@ OCR_TIMEOUT_MS="5000"
|
||||
|
||||
# ===== 发票验真(P6)=====
|
||||
INVOICE_VERIFICATION_ENABLED="false"
|
||||
# INVOICE_VERIFICATION_ENABLED 作为Env硬闸;在线灰度由 /api/system/feature-flags 管理
|
||||
VERIFICATION_PROVIDER="mock" # mock|tax
|
||||
VERIFICATION_POLL_CRON="* * * * *"
|
||||
VERIFICATION_MAX_ATTEMPTS="3"
|
||||
@@ -73,6 +74,7 @@ TENCENT_OCR_REGION="ap-beijing"
|
||||
# ===== 预算与集成(P7-P8)=====
|
||||
BUDGET_CONTROL_MODE="warn" # warn|block
|
||||
ERP_SYNC_ENABLED="false"
|
||||
# ERP_SYNC_ENABLED 作为Env硬闸;在线灰度由 /api/system/feature-flags 管理
|
||||
ERP_SYNC_PROVIDER="mock" # mock|http
|
||||
ERP_SYNC_ENDPOINT=""
|
||||
ERP_SYNC_TIMEOUT_MS="5000"
|
||||
|
||||
+10
@@ -459,6 +459,14 @@ npm run job:ocr-once
|
||||
- `TRAVEL_SYNC_ENABLED=false`
|
||||
- `IM_NOTIFIER_ENABLED=false`
|
||||
|
||||
### 9.1.1 Feature Flag 治理(P11)
|
||||
- 新增统一管理 API:
|
||||
- `GET /api/system/feature-flags`
|
||||
- `PUT /api/system/feature-flags/:key`
|
||||
- 首期纳管键:`erp_sync`、`invoice_verification`。
|
||||
- 优先级规则:`Env硬闸` > `DB在线灰度`。即使 DB 放开,若 Env 为 false 仍会关闭。
|
||||
- 部门灰度模式:`rolloutType=department_allowlist` 时,仅白名单部门生效。
|
||||
|
||||
建议:先在测试组织灰度开启,再逐步扩大范围。
|
||||
|
||||
### 9.2 新增 API 摘要
|
||||
@@ -489,6 +497,7 @@ cd /home/shunli/Projects/hyb-oa/server
|
||||
./scripts/regression-dashboard.sh
|
||||
./scripts/regression-budget.sh
|
||||
./scripts/regression-integrations.sh
|
||||
./scripts/regression-feature-flags.sh
|
||||
```
|
||||
|
||||
可通过总回归开关启用:
|
||||
@@ -498,6 +507,7 @@ RUN_VERIFICATION_REGRESSION=1 \
|
||||
RUN_DASHBOARD_REGRESSION=1 \
|
||||
RUN_BUDGET_REGRESSION=1 \
|
||||
RUN_INTEGRATIONS_REGRESSION=1 \
|
||||
RUN_FEATURE_FLAGS_REGRESSION=1 \
|
||||
./scripts/regression-all.sh
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
CREATE TABLE "feature_flags" (
|
||||
"id" UUID NOT NULL,
|
||||
"flag_key" VARCHAR(64) NOT NULL,
|
||||
"override_mode" VARCHAR(16) NOT NULL DEFAULT 'force_off',
|
||||
"rollout_type" VARCHAR(32) NOT NULL DEFAULT 'global',
|
||||
"department_ids" JSONB,
|
||||
"note" TEXT,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"updated_by" UUID,
|
||||
CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "feature_flags_flag_key_key" ON "feature_flags"("flag_key");
|
||||
CREATE INDEX "idx_feature_flags_key" ON "feature_flags"("flag_key");
|
||||
|
||||
INSERT INTO "feature_flags" (
|
||||
"id",
|
||||
"flag_key",
|
||||
"override_mode",
|
||||
"rollout_type",
|
||||
"department_ids",
|
||||
"note",
|
||||
"version",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
) VALUES
|
||||
('6eb0ff4b-912a-4d19-8e4f-c2365604a200'::uuid, 'erp_sync', 'force_off', 'global', '[]'::jsonb, 'bootstrap default: force_off', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('6eb0ff4b-912a-4d19-8e4f-c2365604a201'::uuid, 'invoice_verification', 'force_off', 'global', '[]'::jsonb, 'bootstrap default: force_off', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
@@ -608,6 +608,24 @@ model BudgetLedgerEntry {
|
||||
@@map("budget_ledger_entries")
|
||||
}
|
||||
|
||||
model FeatureFlag {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
flag_key String @unique @db.VarChar(64)
|
||||
override_mode String @default("force_off") @db.VarChar(16)
|
||||
rollout_type String @default("global") @db.VarChar(32)
|
||||
department_ids Json?
|
||||
note String?
|
||||
version Int @default(1)
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
@@index([flag_key], map: "idx_feature_flags_key")
|
||||
@@map("feature_flags")
|
||||
}
|
||||
|
||||
model IntegrationOutbox {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
event_type String @db.VarChar(64)
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
||||
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-benchmark-dashboard}"
|
||||
ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-finance_mgr@hyinda.com}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-FinanceMgrPassword1234}"
|
||||
ITERATIONS="${ITERATIONS:-10}"
|
||||
|
||||
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
|
||||
|
||||
login() {
|
||||
local out_file="$1"
|
||||
local status
|
||||
status="$(curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/auth/login" \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-device-fingerprint: $DEVICE_FINGERPRINT" \
|
||||
-d "{\"identifier\":\"$ADMIN_IDENTIFIER\",\"password\":\"$ADMIN_PASSWORD\"}")"
|
||||
|
||||
if [[ "$status" != "200" ]]; then
|
||||
echo "[ERROR] login failed: HTTP $status" >&2
|
||||
cat "$out_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
jq -er '.data.accessToken' "$out_file"
|
||||
}
|
||||
|
||||
benchmark_endpoint() {
|
||||
local endpoint="$1"
|
||||
local token="$2"
|
||||
|
||||
local total_ms=0
|
||||
local max_ms=0
|
||||
local min_ms=999999
|
||||
|
||||
for _ in $(seq 1 "$ITERATIONS"); do
|
||||
local body_file="$TMP_DIR/$(basename "$endpoint").json"
|
||||
local elapsed
|
||||
elapsed="$(curl -sS -o "$body_file" -w "%{time_total}" \
|
||||
-H "authorization: Bearer $token" \
|
||||
"$BASE_URL$endpoint")"
|
||||
|
||||
local elapsed_ms
|
||||
elapsed_ms="$(python3 - <<PY
|
||||
value = float("$elapsed")
|
||||
print(int(round(value * 1000)))
|
||||
PY
|
||||
)"
|
||||
|
||||
total_ms=$((total_ms + elapsed_ms))
|
||||
if (( elapsed_ms > max_ms )); then
|
||||
max_ms=$elapsed_ms
|
||||
fi
|
||||
if (( elapsed_ms < min_ms )); then
|
||||
min_ms=$elapsed_ms
|
||||
fi
|
||||
done
|
||||
|
||||
local avg_ms=$((total_ms / ITERATIONS))
|
||||
echo "$endpoint avg=${avg_ms}ms min=${min_ms}ms max=${max_ms}ms iterations=$ITERATIONS"
|
||||
}
|
||||
|
||||
echo "[1/2] Login"
|
||||
TOKEN="$(login "$TMP_DIR/login.json")"
|
||||
|
||||
echo "[2/2] Dashboard endpoint benchmarks"
|
||||
benchmark_endpoint "/api/dashboard/expense-overview" "$TOKEN"
|
||||
benchmark_endpoint "/api/dashboard/trends" "$TOKEN"
|
||||
benchmark_endpoint "/api/dashboard/top-categories" "$TOKEN"
|
||||
benchmark_endpoint "/api/dashboard/approval-efficiency" "$TOKEN"
|
||||
|
||||
echo "[OK] Dashboard benchmark completed"
|
||||
@@ -10,6 +10,7 @@ RUN_VERIFICATION_REGRESSION="${RUN_VERIFICATION_REGRESSION:-0}"
|
||||
RUN_DASHBOARD_REGRESSION="${RUN_DASHBOARD_REGRESSION:-0}"
|
||||
RUN_BUDGET_REGRESSION="${RUN_BUDGET_REGRESSION:-0}"
|
||||
RUN_INTEGRATIONS_REGRESSION="${RUN_INTEGRATIONS_REGRESSION:-0}"
|
||||
RUN_FEATURE_FLAGS_REGRESSION="${RUN_FEATURE_FLAGS_REGRESSION:-0}"
|
||||
|
||||
echo "=== [1/7] Smoke Test ==="
|
||||
"$SCRIPT_DIR/smoke-test.sh"
|
||||
@@ -95,4 +96,13 @@ else
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$RUN_FEATURE_FLAGS_REGRESSION" == "1" ]]; then
|
||||
echo "=== [12/12] Feature Flags Regression ==="
|
||||
"$SCRIPT_DIR/regression-feature-flags.sh"
|
||||
echo ""
|
||||
else
|
||||
echo "=== [12/12] Feature Flags Regression (skipped, RUN_FEATURE_FLAGS_REGRESSION=$RUN_FEATURE_FLAGS_REGRESSION) ==="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "[OK] All regressions passed"
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
||||
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-feature-flags}"
|
||||
FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}"
|
||||
FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "[ERROR] curl not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "[ERROR] jq not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
assert_status() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
local name="$3"
|
||||
local body_file="${4:-}"
|
||||
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "[ERROR] $name expected HTTP $expected but got $actual" >&2
|
||||
if [[ -n "$body_file" && -f "$body_file" ]]; then
|
||||
cat "$body_file" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
login() {
|
||||
local identifier="$1"
|
||||
local password="$2"
|
||||
local out_file="$3"
|
||||
|
||||
local status
|
||||
status="$(curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/auth/login" \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-device-fingerprint: $DEVICE_FINGERPRINT" \
|
||||
-d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")"
|
||||
assert_status "$status" "200" "login" "$out_file"
|
||||
jq -er '.data.accessToken' "$out_file"
|
||||
}
|
||||
|
||||
api_json() {
|
||||
local method="$1"
|
||||
local url="$2"
|
||||
local token="$3"
|
||||
local out_file="$4"
|
||||
local body="${5:-}"
|
||||
|
||||
if [[ -n "$body" ]]; then
|
||||
curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X "$method" "$url" \
|
||||
-H "authorization: Bearer $token" \
|
||||
-H "content-type: application/json" \
|
||||
-d "$body"
|
||||
else
|
||||
curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X "$method" "$url" \
|
||||
-H "authorization: Bearer $token"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "[1/4] Login finance_mgr"
|
||||
TOKEN="$(login "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/login.json")"
|
||||
|
||||
echo "[2/4] List feature flags"
|
||||
status="$(api_json GET "$BASE_URL/api/system/feature-flags" "$TOKEN" "$TMP_DIR/list.json")"
|
||||
assert_status "$status" "200" "feature-flags list" "$TMP_DIR/list.json"
|
||||
jq -e '.data.items | length >= 2' "$TMP_DIR/list.json" >/dev/null
|
||||
|
||||
ERP_VERSION="$(jq -er '.data.items[] | select(.flagKey=="erp_sync") | .version' "$TMP_DIR/list.json")"
|
||||
|
||||
echo "[3/4] Update erp_sync to force_off/global"
|
||||
status="$(api_json PUT "$BASE_URL/api/system/feature-flags/erp_sync" "$TOKEN" "$TMP_DIR/update.json" "{\"overrideMode\":\"force_off\",\"rolloutType\":\"global\",\"departmentIds\":[],\"note\":\"regression-force-off\",\"expectedVersion\":$ERP_VERSION}")"
|
||||
assert_status "$status" "200" "feature-flags update" "$TMP_DIR/update.json"
|
||||
jq -e '.data.item.flagKey == "erp_sync"' "$TMP_DIR/update.json" >/dev/null
|
||||
jq -e '.data.item.overrideMode == "force_off"' "$TMP_DIR/update.json" >/dev/null
|
||||
|
||||
echo "[4/4] Verify stale version conflict"
|
||||
status="$(api_json PUT "$BASE_URL/api/system/feature-flags/erp_sync" "$TOKEN" "$TMP_DIR/conflict.json" "{\"overrideMode\":\"force_off\",\"rolloutType\":\"global\",\"departmentIds\":[],\"note\":\"stale-version\",\"expectedVersion\":$ERP_VERSION}")"
|
||||
if [[ "$status" != "409" ]]; then
|
||||
echo "[ERROR] expected version conflict 409 but got $status" >&2
|
||||
cat "$TMP_DIR/conflict.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[OK] Feature flags regression passed"
|
||||
@@ -292,6 +292,50 @@ run_outbox_once() {
|
||||
npm run --silent job:integration-outbox-once >"$TMP_DIR/outbox-once.log"
|
||||
}
|
||||
|
||||
set_erp_flag() {
|
||||
local mode="$1"
|
||||
DATABASE_URL="$DATABASE_URL" MODE="$mode" node <<'NODE'
|
||||
require("dotenv/config");
|
||||
|
||||
const { createPrismaClient } = require("./src/prismaClient");
|
||||
|
||||
(async () => {
|
||||
const mode = process.env.MODE;
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
|
||||
try {
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { flag_key: "erp_sync" },
|
||||
create: {
|
||||
flag_key: "erp_sync",
|
||||
override_mode: mode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "regression-integrations",
|
||||
},
|
||||
update: {
|
||||
override_mode: mode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "regression-integrations",
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
echo "[0/6] Ensure ERP feature flag force_on"
|
||||
set_erp_flag "force_on"
|
||||
|
||||
assert_mode_semantics() {
|
||||
local mode="$1"
|
||||
local state_json="$2"
|
||||
@@ -373,3 +417,6 @@ run_mode_case "timeout"
|
||||
|
||||
echo "[6/6] Done"
|
||||
echo "[OK] Integrations regression passed"
|
||||
|
||||
echo "[post] Reset ERP feature flag force_off"
|
||||
set_erp_flag "force_off"
|
||||
|
||||
@@ -153,6 +153,50 @@ const { getConfig } = require('./src/config');
|
||||
NODE
|
||||
}
|
||||
|
||||
set_verification_flag() {
|
||||
local mode="$1"
|
||||
DATABASE_URL="$DATABASE_URL" MODE="$mode" node <<'NODE'
|
||||
require("dotenv/config");
|
||||
|
||||
const { createPrismaClient } = require("./src/prismaClient");
|
||||
|
||||
(async () => {
|
||||
const mode = process.env.MODE;
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
|
||||
try {
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { flag_key: "invoice_verification" },
|
||||
create: {
|
||||
flag_key: "invoice_verification",
|
||||
override_mode: mode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "regression-verification",
|
||||
},
|
||||
update: {
|
||||
override_mode: mode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "regression-verification",
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
echo "[0/6] Ensure verification feature flag force_on"
|
||||
set_verification_flag "force_on"
|
||||
|
||||
echo "[1/6] Login finance_mgr"
|
||||
TOKEN="$(login "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/login.json")"
|
||||
|
||||
@@ -184,3 +228,6 @@ if [[ "$status" != "409" ]]; then
|
||||
fi
|
||||
|
||||
echo "[OK] Verification regression passed"
|
||||
|
||||
echo "[post] Reset verification feature flag force_off"
|
||||
set_verification_flag "force_off"
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "[1/3] Check critical env defaults"
|
||||
if [[ "${JWT_SECRET:-}" == "change_me" ]]; then
|
||||
echo "[WARN] JWT_SECRET should not be change_me in non-dev environments"
|
||||
fi
|
||||
|
||||
if [[ "${ERP_SYNC_ENABLED:-false}" != "false" ]]; then
|
||||
echo "[WARN] ERP_SYNC_ENABLED is not false; ensure feature-flag governance is enabled"
|
||||
fi
|
||||
|
||||
if [[ "${INVOICE_VERIFICATION_ENABLED:-false}" != "false" ]]; then
|
||||
echo "[WARN] INVOICE_VERIFICATION_ENABLED is not false; ensure rollout is controlled"
|
||||
fi
|
||||
|
||||
echo "[2/3] Basic static hardening checks"
|
||||
node --check src/routes/auth.js
|
||||
node --check src/routes/integrations.js
|
||||
node --check src/routes/invoicePool/verification.js
|
||||
node --check src/services/featureFlags/index.js
|
||||
|
||||
echo "[3/3] Dependency audit (non-blocking)"
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
npm audit --omit=dev --audit-level=high || true
|
||||
fi
|
||||
|
||||
echo "[OK] Security baseline script completed"
|
||||
@@ -28,6 +28,7 @@ const employeesRoutes = require("./routes/employees");
|
||||
const expenseTypesRoutes = require("./routes/expenseTypes");
|
||||
const configReadRoutes = require("./routes/configRead");
|
||||
const approvalRulesRoutes = require("./routes/approvalRules");
|
||||
const featureFlagsRoutes = require("./routes/featureFlags");
|
||||
const notificationsRoutes = require("./routes/notifications");
|
||||
const exportsRoutes = require("./routes/exports");
|
||||
const dashboardRoutes = require("./routes/dashboard");
|
||||
@@ -229,6 +230,7 @@ async function buildApp() {
|
||||
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
||||
await app.register(configReadRoutes, { prefix: "/api" });
|
||||
await app.register(approvalRulesRoutes, { prefix: "/api" });
|
||||
await app.register(featureFlagsRoutes, { prefix: "/api" });
|
||||
await app.register(notificationsRoutes, { prefix: "/api" });
|
||||
await app.register(dashboardRoutes, { prefix: "/api" });
|
||||
await app.register(budgetsRoutes, { prefix: "/api" });
|
||||
|
||||
@@ -91,6 +91,7 @@ function getConfig() {
|
||||
ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS ?? 5000),
|
||||
|
||||
invoiceVerificationEnabled: process.env.INVOICE_VERIFICATION_ENABLED === "true",
|
||||
invoiceVerificationFlagEnv: process.env.INVOICE_VERIFICATION_ENABLED ?? "false",
|
||||
verificationProvider: process.env.VERIFICATION_PROVIDER ?? "mock",
|
||||
verificationPollCron: process.env.VERIFICATION_POLL_CRON ?? "* * * * *",
|
||||
verificationMaxAttempts: Number(process.env.VERIFICATION_MAX_ATTEMPTS ?? 3),
|
||||
@@ -102,6 +103,7 @@ function getConfig() {
|
||||
budgetControlMode: process.env.BUDGET_CONTROL_MODE ?? "warn",
|
||||
|
||||
erpSyncEnabled: process.env.ERP_SYNC_ENABLED === "true",
|
||||
erpSyncFlagEnv: process.env.ERP_SYNC_ENABLED ?? "false",
|
||||
erpSyncProvider: process.env.ERP_SYNC_PROVIDER ?? "mock",
|
||||
erpSyncEndpoint: process.env.ERP_SYNC_ENDPOINT ?? "",
|
||||
erpSyncTimeoutMs: Number(process.env.ERP_SYNC_TIMEOUT_MS ?? 5000),
|
||||
|
||||
@@ -5,6 +5,10 @@ const { archiveUnusedInvoices } = require("./invoiceArchiveJob");
|
||||
const { runOcrOnce } = require("./ocrJob");
|
||||
const { runVerificationForRecord } = require("./verificationJob");
|
||||
const { runIntegrationOutboxOnce } = require("./integrationOutboxJob");
|
||||
const {
|
||||
FEATURE_FLAG_KEYS,
|
||||
evaluateFeatureFlag,
|
||||
} = require("../services/featureFlags");
|
||||
|
||||
const JOB_OCR_TICK = "ocr.tick";
|
||||
const JOB_DELEGATION_REVOKE = "delegation.revoke-expired";
|
||||
@@ -267,7 +271,14 @@ async function startPgBossJobs(fastify) {
|
||||
await boss.unschedule(JOB_OCR_TICK).catch(() => {});
|
||||
}
|
||||
|
||||
if (fastify.config.invoiceVerificationEnabled) {
|
||||
const verificationScheduling = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
strictDepartment: false,
|
||||
});
|
||||
|
||||
if (verificationScheduling.enabled) {
|
||||
await boss.schedule(JOB_INVOICE_VERIFY, fastify.config.verificationPollCron, null, {
|
||||
tz: fastify.config.appTimezone,
|
||||
singletonKey: JOB_INVOICE_VERIFY,
|
||||
@@ -288,7 +299,8 @@ async function startPgBossJobs(fastify) {
|
||||
schema: fastify.config.pgBossSchema,
|
||||
ocrProvider: fastify.config.ocrProvider,
|
||||
ocrCron: fastify.config.ocrPollCron,
|
||||
verificationEnabled: fastify.config.invoiceVerificationEnabled,
|
||||
verificationEnabled: verificationScheduling.enabled,
|
||||
verificationFlagReason: verificationScheduling.reason,
|
||||
verificationCron: fastify.config.verificationPollCron,
|
||||
delegationCron: fastify.config.delegationRevokeCron,
|
||||
invoiceArchiveCron: fastify.config.invoiceArchiveCron,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
const { verifyInvoiceByProvider } = require("../services/verification");
|
||||
const {
|
||||
evaluateVerificationAccess,
|
||||
verifyInvoiceByProvider,
|
||||
} = require("../services/verification");
|
||||
|
||||
function toResultStatus(status) {
|
||||
if (["passed", "failed", "error"].includes(status)) {
|
||||
@@ -32,6 +35,16 @@ async function runVerificationForRecord(prisma, config, recordId) {
|
||||
total_amount: true,
|
||||
remarks: true,
|
||||
status: true,
|
||||
owner_employee: {
|
||||
select: {
|
||||
department_id: true,
|
||||
},
|
||||
},
|
||||
linked_reimbursement: {
|
||||
select: {
|
||||
department_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -54,6 +67,37 @@ async function runVerificationForRecord(prisma, config, recordId) {
|
||||
return { skipped: true, reason: "status_not_pending" };
|
||||
}
|
||||
|
||||
const departmentId =
|
||||
record.invoice.linked_reimbursement?.department_id ??
|
||||
record.invoice.owner_employee?.department_id ??
|
||||
null;
|
||||
|
||||
const access = await evaluateVerificationAccess({
|
||||
prisma: tx,
|
||||
config,
|
||||
departmentId,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (!access.enabled) {
|
||||
await tx.invoiceVerificationRecord.update({
|
||||
where: { id: recordId },
|
||||
data: {
|
||||
status: "error",
|
||||
error_code: "verification_disabled_by_flag",
|
||||
error_message: access.reason,
|
||||
completed_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
skipped: false,
|
||||
status: "error",
|
||||
durationMs: 0,
|
||||
resultCode: "verification_disabled_by_flag",
|
||||
};
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const verification = await verifyInvoiceByProvider({
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const {
|
||||
FEATURE_FLAG_KEY_LIST,
|
||||
OVERRIDE_MODE_LIST,
|
||||
ROLLOUT_TYPE_LIST,
|
||||
evaluateFeatureFlag,
|
||||
listFeatureFlags,
|
||||
updateFeatureFlag,
|
||||
} = require("../services/featureFlags");
|
||||
|
||||
const KEY_PARAM_SCHEMA = z.object({
|
||||
key: z.enum(FEATURE_FLAG_KEY_LIST),
|
||||
});
|
||||
|
||||
const UPDATE_BODY_SCHEMA = z.object({
|
||||
overrideMode: z.enum(OVERRIDE_MODE_LIST),
|
||||
rolloutType: z.enum(ROLLOUT_TYPE_LIST),
|
||||
departmentIds: z.array(z.string().uuid()).default([]),
|
||||
note: z.string().trim().max(500).optional().default(""),
|
||||
expectedVersion: z.coerce.number().int().min(1),
|
||||
});
|
||||
|
||||
function parseKey(params) {
|
||||
const parsed = KEY_PARAM_SCHEMA.safeParse(params ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data.key;
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.rolloutType !== "department_allowlist" && parsed.data.departmentIds.length > 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { departmentIds: "rollout_type_global_disallows_department_ids" },
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function featureFlagsRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/system/feature-flags",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const items = await listFeatureFlags(fastify.prisma, fastify.config);
|
||||
return sendData(reply, request, { items });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.put(
|
||||
"/system/feature-flags/:key",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const key = parseKey(request.params);
|
||||
const body = parseUpdateBody(request.body);
|
||||
|
||||
const result = await updateFeatureFlag(fastify.prisma, {
|
||||
flagKey: key,
|
||||
overrideMode: body.overrideMode,
|
||||
rolloutType: body.rolloutType,
|
||||
departmentIds: body.departmentIds,
|
||||
note: body.note,
|
||||
expectedVersion: body.expectedVersion,
|
||||
actorId: request.employee.id,
|
||||
});
|
||||
|
||||
if (result.status === "not_found" || !result.current) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === "version_conflict") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
featureFlag: "version_conflict",
|
||||
expectedVersion: body.expectedVersion,
|
||||
currentVersion: result.current.version,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status !== "updated" || !result.updated) {
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "服务器内部错误",
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "FEATURE_FLAG_UPDATED",
|
||||
targetType: "feature_flag",
|
||||
targetId: result.updated.id,
|
||||
extra: {
|
||||
flagKey: key,
|
||||
from: {
|
||||
overrideMode: result.current.overrideMode,
|
||||
rolloutType: result.current.rolloutType,
|
||||
departmentIds: result.current.departmentIds,
|
||||
note: result.current.note,
|
||||
version: result.current.version,
|
||||
},
|
||||
to: {
|
||||
overrideMode: result.updated.overrideMode,
|
||||
rolloutType: result.updated.rolloutType,
|
||||
departmentIds: result.updated.departmentIds,
|
||||
note: result.updated.note,
|
||||
version: result.updated.version,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const effective = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: key,
|
||||
strictDepartment: false,
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
item: {
|
||||
...result.updated,
|
||||
envEnabled: effective.envEnabled,
|
||||
effectiveEnabled: effective.enabled,
|
||||
effectiveReason: effective.reason,
|
||||
matchedDepartmentId: effective.matchedDepartmentId,
|
||||
requiresDepartment: effective.requiresDepartment,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = featureFlagsRoutes;
|
||||
@@ -5,6 +5,7 @@ const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
|
||||
const { buildErpPayload } = require("../services/integrations/erp");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { runIntegrationOutboxOnce } = require("../jobs/integrationOutboxJob");
|
||||
|
||||
async function integrationsRoutes(fastify) {
|
||||
@@ -14,15 +15,6 @@ async function integrationsRoutes(fastify) {
|
||||
preHandler: fastify.requirePermission("integration.manage"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!fastify.config.erpSyncEnabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { integration: "erp_sync_disabled" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsedId = z.string().uuid().safeParse(request.params?.reimbursementId);
|
||||
if (!parsedId.success) {
|
||||
throw new AppError({
|
||||
@@ -42,6 +34,7 @@ async function integrationsRoutes(fastify) {
|
||||
form_number: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
total_amount: true,
|
||||
payment_voucher_no: true,
|
||||
@@ -67,6 +60,27 @@ async function integrationsRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const erpGate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (!erpGate.enabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
integration: "feature_flag_blocked",
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
reason: erpGate.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const payload = buildErpPayload({ reimbursement });
|
||||
const enqueued = await enqueueIntegrationEvent(fastify.prisma, {
|
||||
eventType: "erp.reimbursement.paid",
|
||||
@@ -85,6 +99,9 @@ async function integrationsRoutes(fastify) {
|
||||
extra: {
|
||||
outboxId: enqueued.id,
|
||||
duplicated: enqueued.duplicated,
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
featureFlagReason: erpGate.reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -3,12 +3,87 @@ const { z } = require("zod");
|
||||
const { AppError } = require("../../utils/AppError");
|
||||
const { sendData } = require("../../utils/reply");
|
||||
const { buildAuditLogData } = require("../../utils/audit");
|
||||
const { getProvider, isVerificationEnabled } = require("../../services/verification");
|
||||
const { getProvider, evaluateVerificationAccess } = require("../../services/verification");
|
||||
|
||||
const { mapVerificationRecord } = require("./shared/mappers");
|
||||
const { parseVerificationListQuery } = require("./shared/schemas");
|
||||
const { triggerVerificationJob } = require("./shared/helpers");
|
||||
|
||||
function throwVerificationDisabled(reason) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "disabled", featureFlag: reason },
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveVerificationDepartmentContext(tx, request, invoiceId) {
|
||||
const invoice = await tx.invoicePool.findUnique({
|
||||
where: { id: invoiceId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
invoice_code: true,
|
||||
invoice_number: true,
|
||||
total_amount: true,
|
||||
remarks: true,
|
||||
owner_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
department_id: true,
|
||||
},
|
||||
},
|
||||
linked_reimbursement: {
|
||||
select: {
|
||||
id: true,
|
||||
department_id: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const departmentId =
|
||||
invoice.linked_reimbursement?.department_id ??
|
||||
invoice.owner_employee?.department_id ??
|
||||
request.employee.department_id ??
|
||||
null;
|
||||
|
||||
return {
|
||||
invoice,
|
||||
departmentId,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureVerificationEnabledWithContext({ tx, fastify, request, invoiceId }) {
|
||||
const context = await resolveVerificationDepartmentContext(tx, request, invoiceId);
|
||||
const evaluation = await evaluateVerificationAccess({
|
||||
prisma: tx,
|
||||
config: fastify.config,
|
||||
departmentId: context.departmentId,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (!evaluation.enabled) {
|
||||
throwVerificationDisabled(evaluation.reason);
|
||||
}
|
||||
|
||||
return {
|
||||
...context,
|
||||
evaluation,
|
||||
};
|
||||
}
|
||||
|
||||
function registerInvoiceVerificationRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/invoice-pool/:id/verify",
|
||||
@@ -16,15 +91,6 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
preHandler: fastify.requirePermission("invoice.verify"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!isVerificationEnabled(fastify.config)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "disabled" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
|
||||
if (!parsedInvoiceId.success) {
|
||||
throw new AppError({
|
||||
@@ -54,24 +120,12 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const invoice = await tx.invoicePool.findUnique({
|
||||
where: { id: invoiceId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
invoice_code: true,
|
||||
invoice_number: true,
|
||||
total_amount: true,
|
||||
remarks: true,
|
||||
},
|
||||
const { invoice, departmentId, evaluation } = await ensureVerificationEnabledWithContext({
|
||||
tx,
|
||||
fastify,
|
||||
request,
|
||||
invoiceId,
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const latest = await tx.invoiceVerificationRecord.findFirst({
|
||||
where: { invoice_id: invoiceId },
|
||||
@@ -116,6 +170,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
invoiceCode: invoice.invoice_code ?? null,
|
||||
invoiceNumber: invoice.invoice_number ?? null,
|
||||
totalAmount: invoice.total_amount?.toString?.() ?? null,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
},
|
||||
requested_by: request.employee.id,
|
||||
requested_at: new Date(now),
|
||||
@@ -135,6 +191,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
extra: {
|
||||
verificationId: created.id,
|
||||
provider,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -142,6 +200,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
return {
|
||||
verificationId: created.id,
|
||||
provider,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -157,6 +217,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
extra: {
|
||||
verificationId: response.verificationId,
|
||||
status: trigger.result?.status ?? null,
|
||||
departmentId: response.departmentId,
|
||||
featureFlagReason: response.featureFlagReason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -259,15 +321,6 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
preHandler: fastify.requirePermission("invoice.verify"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!isVerificationEnabled(fastify.config)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "disabled" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
|
||||
if (!parsedInvoiceId.success) {
|
||||
throw new AppError({
|
||||
@@ -282,6 +335,13 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
const provider = getProvider(fastify.config);
|
||||
|
||||
const created = await fastify.prisma.$transaction(async (tx) => {
|
||||
const { departmentId, evaluation } = await ensureVerificationEnabledWithContext({
|
||||
tx,
|
||||
fastify,
|
||||
request,
|
||||
invoiceId,
|
||||
});
|
||||
|
||||
const latest = await tx.invoiceVerificationRecord.findFirst({
|
||||
where: { invoice_id: invoiceId },
|
||||
orderBy: { created_at: "desc" },
|
||||
@@ -344,6 +404,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
requested_at: new Date(),
|
||||
request_payload: {
|
||||
retryOfId: latest.id,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
@@ -361,6 +423,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
verificationId: createdRecord.id,
|
||||
retryOfId: latest.id,
|
||||
attempts,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
const { sendNotificationEmailSafe } = require("../services/email");
|
||||
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
|
||||
const { buildErpPayload } = require("../services/integrations/erp");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const {
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
resolveActionableStep,
|
||||
@@ -1691,7 +1692,7 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, status, form_number, owner_id, submitter_id,
|
||||
SELECT id, status, form_number, owner_id, submitter_id, department_id,
|
||||
payment_voucher_no, payment_date, payment_channel, paid_at, paid_by
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
@@ -1772,6 +1773,7 @@ async function reimbursementRoutes(fastify) {
|
||||
form_number: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
department_id: true,
|
||||
total_amount: true,
|
||||
payment_voucher_no: true,
|
||||
payment_date: true,
|
||||
@@ -1779,14 +1781,40 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
});
|
||||
|
||||
if (refreshed && fastify.config.erpSyncEnabled) {
|
||||
await enqueueIntegrationEvent(tx, {
|
||||
eventType: "erp.reimbursement.paid",
|
||||
dedupeKey: refreshed.id,
|
||||
payload: buildErpPayload({ reimbursement: refreshed }),
|
||||
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
|
||||
actorId: request.employee.id,
|
||||
if (refreshed) {
|
||||
const departmentId =
|
||||
refreshed.department_id ?? reimbursement.department_id ?? request.employee.department_id ?? null;
|
||||
const erpGate = await evaluateFeatureFlag({
|
||||
prisma: tx,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (erpGate.enabled) {
|
||||
await enqueueIntegrationEvent(tx, {
|
||||
eventType: "erp.reimbursement.paid",
|
||||
dedupeKey: refreshed.id,
|
||||
payload: buildErpPayload({ reimbursement: refreshed }),
|
||||
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
|
||||
actorId: request.employee.id,
|
||||
});
|
||||
} else {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG",
|
||||
targetType: "reimbursement_form",
|
||||
targetId: refreshed.id,
|
||||
extra: {
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId,
|
||||
reason: erpGate.reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
const FEATURE_FLAG_KEYS = {
|
||||
erpSync: "erp_sync",
|
||||
invoiceVerification: "invoice_verification",
|
||||
};
|
||||
|
||||
const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS));
|
||||
const OVERRIDE_MODE_LIST = Object.freeze(["inherit", "force_on", "force_off"]);
|
||||
const ROLLOUT_TYPE_LIST = Object.freeze(["global", "department_allowlist"]);
|
||||
|
||||
function isKnownFeatureFlagKey(flagKey) {
|
||||
return FEATURE_FLAG_KEY_LIST.includes(flagKey);
|
||||
}
|
||||
|
||||
function normalizeOverrideMode(value) {
|
||||
const mode = String(value ?? "force_off").trim().toLowerCase();
|
||||
if (OVERRIDE_MODE_LIST.includes(mode)) {
|
||||
return mode;
|
||||
}
|
||||
return "force_off";
|
||||
}
|
||||
|
||||
function normalizeRolloutType(value) {
|
||||
const type = String(value ?? "global").trim().toLowerCase();
|
||||
if (ROLLOUT_TYPE_LIST.includes(type)) {
|
||||
return type;
|
||||
}
|
||||
return "global";
|
||||
}
|
||||
|
||||
function sanitizeDepartmentIds(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const items = [];
|
||||
for (const id of value) {
|
||||
if (typeof id !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = id.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.has(trimmed)) {
|
||||
seen.add(trimmed);
|
||||
items.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function resolveEnvGate(config, flagKey) {
|
||||
if (flagKey === FEATURE_FLAG_KEYS.erpSync) {
|
||||
return Boolean(config.erpSyncEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.invoiceVerification) {
|
||||
return Boolean(config.invoiceVerificationEnabled);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function mapFlagRecord(record) {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const departmentIds = sanitizeDepartmentIds(record.department_ids);
|
||||
const overrideMode = normalizeOverrideMode(record.override_mode);
|
||||
const rolloutType = normalizeRolloutType(record.rollout_type);
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
flagKey: record.flag_key,
|
||||
overrideMode,
|
||||
rolloutType,
|
||||
departmentIds,
|
||||
note: record.note ?? "",
|
||||
version: Number(record.version ?? 1),
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
createdBy: record.created_by ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
updatedBy: record.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultRecord(flagKey) {
|
||||
return {
|
||||
id: null,
|
||||
flagKey,
|
||||
overrideMode: "force_off",
|
||||
rolloutType: "global",
|
||||
departmentIds: [],
|
||||
note: "",
|
||||
version: 1,
|
||||
createdAt: null,
|
||||
createdBy: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
};
|
||||
}
|
||||
|
||||
function withEvaluation(base, evaluation) {
|
||||
return {
|
||||
...base,
|
||||
envEnabled: evaluation.envEnabled,
|
||||
effectiveEnabled: evaluation.enabled,
|
||||
effectiveReason: evaluation.reason,
|
||||
matchedDepartmentId: evaluation.matchedDepartmentId,
|
||||
requiresDepartment: evaluation.requiresDepartment,
|
||||
};
|
||||
}
|
||||
|
||||
async function getFlagRecord(prisma, flagKey) {
|
||||
const row = await prisma.featureFlag.findUnique({
|
||||
where: { flag_key: flagKey },
|
||||
});
|
||||
|
||||
return mapFlagRecord(row);
|
||||
}
|
||||
|
||||
async function evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey,
|
||||
departmentId = null,
|
||||
strictDepartment = true,
|
||||
}) {
|
||||
const envEnabled = resolveEnvGate(config, flagKey);
|
||||
const normalizedDepartmentId =
|
||||
typeof departmentId === "string" && departmentId.trim() ? departmentId.trim() : null;
|
||||
|
||||
if (!envEnabled) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "env_hard_off",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
const record = await getFlagRecord(prisma, flagKey);
|
||||
if (!record) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "missing_flag_record",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!OVERRIDE_MODE_LIST.includes(record.overrideMode)) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "invalid_override_mode",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!ROLLOUT_TYPE_LIST.includes(record.rolloutType)) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "invalid_rollout_type",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (record.overrideMode === "force_off") {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "flag_force_off",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
const modeEnabled = record.overrideMode === "force_on" || record.overrideMode === "inherit";
|
||||
if (!modeEnabled) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "mode_not_enabled",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (record.rolloutType === "global") {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: true,
|
||||
reason: record.overrideMode === "inherit" ? "env_inherit_global_on" : "flag_force_on_global",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
const allowlist = record.departmentIds;
|
||||
if (!allowlist.length) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "department_allowlist_empty",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!normalizedDepartmentId) {
|
||||
if (strictDepartment) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "department_missing",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
flagKey,
|
||||
enabled: true,
|
||||
reason: "department_deferred",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
const matched = allowlist.includes(normalizedDepartmentId);
|
||||
if (!matched) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "department_not_allowed",
|
||||
envEnabled,
|
||||
matchedDepartmentId: normalizedDepartmentId,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
flagKey,
|
||||
enabled: true,
|
||||
reason:
|
||||
record.overrideMode === "inherit"
|
||||
? "env_inherit_department_allowed"
|
||||
: "flag_force_on_department_allowed",
|
||||
envEnabled,
|
||||
matchedDepartmentId: normalizedDepartmentId,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function listFeatureFlags(prisma, config) {
|
||||
const rows = await prisma.featureFlag.findMany({
|
||||
where: {
|
||||
flag_key: { in: FEATURE_FLAG_KEY_LIST },
|
||||
},
|
||||
orderBy: { flag_key: "asc" },
|
||||
});
|
||||
|
||||
const recordMap = new Map(rows.map((row) => [row.flag_key, mapFlagRecord(row)]));
|
||||
|
||||
const items = [];
|
||||
for (const flagKey of FEATURE_FLAG_KEY_LIST) {
|
||||
const base = recordMap.get(flagKey) ?? buildDefaultRecord(flagKey);
|
||||
const evaluation = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey,
|
||||
departmentId: null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
items.push(withEvaluation(base, evaluation));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function updateFeatureFlag(prisma, input) {
|
||||
const flagKey = String(input.flagKey ?? "").trim();
|
||||
if (!isKnownFeatureFlagKey(flagKey)) {
|
||||
return {
|
||||
status: "not_found",
|
||||
current: null,
|
||||
updated: null,
|
||||
};
|
||||
}
|
||||
|
||||
const overrideMode = normalizeOverrideMode(input.overrideMode);
|
||||
const rolloutType = normalizeRolloutType(input.rolloutType);
|
||||
const departmentIds =
|
||||
rolloutType === "department_allowlist" ? sanitizeDepartmentIds(input.departmentIds) : [];
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const currentRow = await tx.featureFlag.findUnique({
|
||||
where: { flag_key: flagKey },
|
||||
});
|
||||
|
||||
if (!currentRow) {
|
||||
return {
|
||||
status: "not_found",
|
||||
current: null,
|
||||
updated: null,
|
||||
};
|
||||
}
|
||||
|
||||
const current = mapFlagRecord(currentRow);
|
||||
|
||||
if (Number(input.expectedVersion) !== current.version) {
|
||||
return {
|
||||
status: "version_conflict",
|
||||
current,
|
||||
updated: null,
|
||||
};
|
||||
}
|
||||
|
||||
const updateResult = await tx.featureFlag.updateMany({
|
||||
where: {
|
||||
id: current.id,
|
||||
version: current.version,
|
||||
},
|
||||
data: {
|
||||
override_mode: overrideMode,
|
||||
rollout_type: rolloutType,
|
||||
department_ids: departmentIds,
|
||||
note: input.note ?? "",
|
||||
version: {
|
||||
increment: 1,
|
||||
},
|
||||
updated_by: input.actorId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (updateResult.count !== 1) {
|
||||
const latest = await tx.featureFlag.findUnique({
|
||||
where: { flag_key: flagKey },
|
||||
});
|
||||
return {
|
||||
status: "version_conflict",
|
||||
current: mapFlagRecord(latest),
|
||||
updated: null,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedRow = await tx.featureFlag.findUnique({
|
||||
where: { flag_key: flagKey },
|
||||
});
|
||||
|
||||
return {
|
||||
status: "updated",
|
||||
current,
|
||||
updated: mapFlagRecord(updatedRow),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FEATURE_FLAG_KEYS,
|
||||
FEATURE_FLAG_KEY_LIST,
|
||||
OVERRIDE_MODE_LIST,
|
||||
ROLLOUT_TYPE_LIST,
|
||||
isKnownFeatureFlagKey,
|
||||
sanitizeDepartmentIds,
|
||||
evaluateFeatureFlag,
|
||||
listFeatureFlags,
|
||||
updateFeatureFlag,
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
const { runMockVerification } = require("./mock");
|
||||
const { runTaxVerification } = require("./tax");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../featureFlags");
|
||||
|
||||
function getProvider(config) {
|
||||
return String(config.verificationProvider ?? "mock").trim().toLowerCase();
|
||||
@@ -9,6 +10,28 @@ function isVerificationEnabled(config) {
|
||||
return Boolean(config.invoiceVerificationEnabled);
|
||||
}
|
||||
|
||||
async function evaluateVerificationAccess({
|
||||
prisma,
|
||||
config,
|
||||
departmentId = null,
|
||||
strictDepartment = true,
|
||||
}) {
|
||||
const evaluation = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
departmentId,
|
||||
strictDepartment,
|
||||
});
|
||||
|
||||
return {
|
||||
enabled: evaluation.enabled,
|
||||
reason: evaluation.reason,
|
||||
matchedDepartmentId: evaluation.matchedDepartmentId,
|
||||
envEnabled: evaluation.envEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyInvoiceByProvider({ config, invoice }) {
|
||||
const provider = getProvider(config);
|
||||
|
||||
@@ -33,5 +56,6 @@ async function verifyInvoiceByProvider({ config, invoice }) {
|
||||
module.exports = {
|
||||
getProvider,
|
||||
isVerificationEnabled,
|
||||
evaluateVerificationAccess,
|
||||
verifyInvoiceByProvider,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
async function runTaxVerification({ config, invoice }) {
|
||||
if (!config.invoiceVerificationEnabled) {
|
||||
return {
|
||||
status: "error",
|
||||
resultCode: "verification_disabled",
|
||||
resultMessage: "invoice verification disabled",
|
||||
payload: {
|
||||
provider: "tax",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
resultCode: "tax_provider_not_configured",
|
||||
|
||||
在新工单中引用
屏蔽一个用户