feat: 完成P6-P8后端能力与P9路由重构(V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3)

这个提交包含在:
2026-02-10 11:28:28 +08:00
父节点 bec21e1b38
当前提交 6bc09f557b
共修改 33 个文件,包含 3467 行新增和 13 行删除
+3 -1
查看文件
@@ -20,7 +20,9 @@
"seed:trialrun": "node src/scripts/seed-trialrun.js",
"job:revoke-expired-delegations": "node src/scripts/revoke-expired-delegations.js",
"job:ocr-once": "node src/scripts/run-ocr-once.js",
"job:archive-unused-invoices": "node src/scripts/run-invoice-archive-once.js"
"job:archive-unused-invoices": "node src/scripts/run-invoice-archive-once.js",
"job:integration-outbox-once": "node src/scripts/run-integration-outbox-once.js",
"job:verification-once": "node src/scripts/run-verification-once.js"
},
"keywords": [],
"author": "",
@@ -0,0 +1,163 @@
-- P6-P8 remaining work baseline tables
CREATE TABLE "invoice_verification_records" (
"id" UUID NOT NULL,
"invoice_id" UUID NOT NULL,
"provider" VARCHAR(32) NOT NULL,
"status" VARCHAR(16) NOT NULL,
"request_payload" JSONB,
"result_payload" JSONB,
"result_code" VARCHAR(64),
"result_message" TEXT,
"error_code" VARCHAR(64),
"error_message" TEXT,
"duration_ms" INTEGER,
"requested_by" UUID,
"requested_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP(3),
"retry_of_id" UUID,
"attempts" INTEGER NOT NULL DEFAULT 1,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "invoice_verification_records_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "budget_pools" (
"id" UUID NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"department_id" UUID,
"status" VARCHAR(16) NOT NULL DEFAULT 'active',
"period_start" DATE NOT NULL,
"period_end" DATE NOT NULL,
"allocated_amount" DECIMAL(14,2) NOT NULL,
"used_amount" DECIMAL(14,2) NOT NULL DEFAULT 0,
"warning_threshold_percent" INTEGER NOT NULL DEFAULT 80,
"archived_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "budget_pools_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "budget_ledger_entries" (
"id" UUID NOT NULL,
"budget_pool_id" UUID NOT NULL,
"reimbursement_id" UUID,
"kind" VARCHAR(32) NOT NULL,
"amount" DECIMAL(14,2) NOT NULL,
"note" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
CONSTRAINT "budget_ledger_entries_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "integration_outbox" (
"id" UUID NOT NULL,
"event_type" VARCHAR(64) NOT NULL,
"status" VARCHAR(16) NOT NULL DEFAULT 'pending',
"payload" JSONB NOT NULL,
"dedupe_key" VARCHAR(128),
"attempts" INTEGER NOT NULL DEFAULT 0,
"max_attempts" INTEGER NOT NULL DEFAULT 5,
"last_error" TEXT,
"next_retry_at" TIMESTAMP(3),
"processed_at" TIMESTAMP(3),
"dead_letter_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "integration_outbox_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "travel_orders" (
"id" UUID NOT NULL,
"source" VARCHAR(32) NOT NULL DEFAULT 'manual',
"external_order_no" TEXT NOT NULL,
"employee_id" UUID NOT NULL,
"department_id" UUID,
"linked_reimbursement_id" UUID,
"occurred_at" DATE,
"amount" DECIMAL(14,2),
"currency" VARCHAR(8) NOT NULL DEFAULT 'CNY',
"status" VARCHAR(20) NOT NULL DEFAULT 'imported',
"raw_payload" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "travel_orders_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "notification_channels" (
"id" UUID NOT NULL,
"channel_type" VARCHAR(32) NOT NULL,
"name" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"config_json" JSONB NOT NULL,
"template_json" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "notification_channels_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "budget_pools_code_key" ON "budget_pools"("code");
CREATE UNIQUE INDEX "integration_outbox_event_dedupe_ux" ON "integration_outbox"("event_type", "dedupe_key");
CREATE UNIQUE INDEX "travel_orders_source_order_ux" ON "travel_orders"("source", "external_order_no");
CREATE UNIQUE INDEX "notification_channels_type_name_ux" ON "notification_channels"("channel_type", "name");
CREATE INDEX "idx_invoice_verification_invoice_created_at" ON "invoice_verification_records"("invoice_id", "created_at");
CREATE INDEX "idx_invoice_verification_status_requested_at" ON "invoice_verification_records"("status", "requested_at");
CREATE INDEX "invoice_verification_records_requested_by_idx" ON "invoice_verification_records"("requested_by");
CREATE INDEX "idx_budget_pools_department_status" ON "budget_pools"("department_id", "status");
CREATE INDEX "idx_budget_pools_period" ON "budget_pools"("status", "period_start", "period_end");
CREATE INDEX "idx_budget_ledger_pool_created_at" ON "budget_ledger_entries"("budget_pool_id", "created_at");
CREATE INDEX "budget_ledger_entries_reimbursement_id_idx" ON "budget_ledger_entries"("reimbursement_id");
CREATE INDEX "idx_integration_outbox_queue" ON "integration_outbox"("status", "next_retry_at", "created_at");
CREATE INDEX "idx_travel_orders_employee_occurred" ON "travel_orders"("employee_id", "occurred_at");
CREATE INDEX "travel_orders_linked_reimbursement_id_idx" ON "travel_orders"("linked_reimbursement_id");
CREATE INDEX "idx_notification_channels_enabled_type" ON "notification_channels"("enabled", "channel_type");
ALTER TABLE "invoice_verification_records"
ADD CONSTRAINT "invoice_verification_records_invoice_id_fkey"
FOREIGN KEY ("invoice_id") REFERENCES "invoice_pool"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "invoice_verification_records"
ADD CONSTRAINT "invoice_verification_records_requested_by_fkey"
FOREIGN KEY ("requested_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "invoice_verification_records"
ADD CONSTRAINT "invoice_verification_records_retry_of_id_fkey"
FOREIGN KEY ("retry_of_id") REFERENCES "invoice_verification_records"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "budget_pools"
ADD CONSTRAINT "budget_pools_department_id_fkey"
FOREIGN KEY ("department_id") REFERENCES "departments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "budget_ledger_entries"
ADD CONSTRAINT "budget_ledger_entries_budget_pool_id_fkey"
FOREIGN KEY ("budget_pool_id") REFERENCES "budget_pools"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "budget_ledger_entries"
ADD CONSTRAINT "budget_ledger_entries_reimbursement_id_fkey"
FOREIGN KEY ("reimbursement_id") REFERENCES "reimbursement_forms"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "travel_orders"
ADD CONSTRAINT "travel_orders_employee_id_fkey"
FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "travel_orders"
ADD CONSTRAINT "travel_orders_department_id_fkey"
FOREIGN KEY ("department_id") REFERENCES "departments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "travel_orders"
ADD CONSTRAINT "travel_orders_linked_reimbursement_id_fkey"
FOREIGN KEY ("linked_reimbursement_id") REFERENCES "reimbursement_forms"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+151
查看文件
@@ -136,6 +136,8 @@ model Department {
reimbursement_forms ReimbursementForm[]
leave_requests LeaveRequest[]
workflow_instances WorkflowInstance[]
budget_pools BudgetPool[]
travel_orders TravelOrder[]
@@index([parent_id])
@@map("departments")
@@ -191,6 +193,8 @@ model Employee {
owned_invoices InvoicePool[] @relation("InvoiceOwner")
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
overridden_invoices InvoicePool[] @relation("InvoiceOverrideBy")
requested_invoice_verifications InvoiceVerificationRecord[] @relation("InvoiceVerificationRequester")
travel_orders TravelOrder[]
workflow_instances_started WorkflowInstance[] @relation("WorkflowInstanceSubmitter")
workflow_history_actions WorkflowHistory[] @relation("WorkflowHistoryActor")
@@ -383,6 +387,8 @@ model ReimbursementForm {
invoice_pool_items InvoicePool[]
workflow_instances WorkflowInstance[]
approval_transfers ApprovalTransfer[]
budget_ledger_entries BudgetLedgerEntry[]
travel_orders TravelOrder[]
@@index([owner_id])
@@index([submitter_id])
@@ -514,6 +520,7 @@ model InvoicePool {
ocr_result OcrResult? @relation(fields: [ocr_result_id], references: [id], onDelete: SetNull)
linked_reimbursement ReimbursementForm? @relation(fields: [linked_reimbursement_id], references: [id], onDelete: SetNull)
linked_item ReimbursementItem? @relation(fields: [linked_item_id], references: [id], onDelete: SetNull)
verification_records InvoiceVerificationRecord[]
@@index([owner_employee_id])
@@index([uploaded_by_employee_id])
@@ -526,6 +533,150 @@ model InvoicePool {
@@map("invoice_pool")
}
model InvoiceVerificationRecord {
id String @id @default(uuid()) @db.Uuid
invoice_id String @db.Uuid
provider String @db.VarChar(32)
status String @db.VarChar(16)
request_payload Json?
result_payload Json?
result_code String? @db.VarChar(64)
result_message String?
error_code String? @db.VarChar(64)
error_message String?
duration_ms Int?
requested_by String? @db.Uuid
requested_at DateTime @default(now())
completed_at DateTime?
retry_of_id String? @db.Uuid
attempts Int @default(1)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
invoice InvoicePool @relation(fields: [invoice_id], references: [id], onDelete: Cascade)
requester Employee? @relation("InvoiceVerificationRequester", fields: [requested_by], references: [id], onDelete: SetNull)
retry_of InvoiceVerificationRecord? @relation("InvoiceVerificationRetry", fields: [retry_of_id], references: [id], onDelete: SetNull)
retries InvoiceVerificationRecord[] @relation("InvoiceVerificationRetry")
@@index([invoice_id, created_at], map: "idx_invoice_verification_invoice_created_at")
@@index([status, requested_at], map: "idx_invoice_verification_status_requested_at")
@@index([requested_by])
@@map("invoice_verification_records")
}
model BudgetPool {
id String @id @default(uuid()) @db.Uuid
code String @unique
name String
department_id String? @db.Uuid
status String @default("active") @db.VarChar(16)
period_start DateTime @db.Date
period_end DateTime @db.Date
allocated_amount Decimal @db.Decimal(14, 2)
used_amount Decimal @default(0) @db.Decimal(14, 2)
warning_threshold_percent Int @default(80)
archived_at DateTime?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull)
ledger_entries BudgetLedgerEntry[]
@@index([department_id, status], map: "idx_budget_pools_department_status")
@@index([status, period_start, period_end], map: "idx_budget_pools_period")
@@map("budget_pools")
}
model BudgetLedgerEntry {
id String @id @default(uuid()) @db.Uuid
budget_pool_id String @db.Uuid
reimbursement_id String? @db.Uuid
kind String @db.VarChar(32)
amount Decimal @db.Decimal(14, 2)
note String?
created_at DateTime @default(now())
created_by String? @db.Uuid
budget_pool BudgetPool @relation(fields: [budget_pool_id], references: [id], onDelete: Cascade)
reimbursement_form ReimbursementForm? @relation(fields: [reimbursement_id], references: [id], onDelete: SetNull)
@@index([budget_pool_id, created_at], map: "idx_budget_ledger_pool_created_at")
@@index([reimbursement_id])
@@map("budget_ledger_entries")
}
model IntegrationOutbox {
id String @id @default(uuid()) @db.Uuid
event_type String @db.VarChar(64)
status String @default("pending") @db.VarChar(16)
payload Json
dedupe_key String? @db.VarChar(128)
attempts Int @default(0)
max_attempts Int @default(5)
last_error String?
next_retry_at DateTime?
processed_at DateTime?
dead_letter_at DateTime?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
@@unique([event_type, dedupe_key], map: "integration_outbox_event_dedupe_ux")
@@index([status, next_retry_at, created_at], map: "idx_integration_outbox_queue")
@@map("integration_outbox")
}
model TravelOrder {
id String @id @default(uuid()) @db.Uuid
source String @default("manual") @db.VarChar(32)
external_order_no String
employee_id String @db.Uuid
department_id String? @db.Uuid
linked_reimbursement_id String? @db.Uuid
occurred_at DateTime? @db.Date
amount Decimal? @db.Decimal(14, 2)
currency String @default("CNY") @db.VarChar(8)
status String @default("imported") @db.VarChar(20)
raw_payload Json?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict)
department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull)
linked_reimbursement ReimbursementForm? @relation(fields: [linked_reimbursement_id], references: [id], onDelete: SetNull)
@@unique([source, external_order_no], map: "travel_orders_source_order_ux")
@@index([employee_id, occurred_at], map: "idx_travel_orders_employee_occurred")
@@index([linked_reimbursement_id])
@@map("travel_orders")
}
model NotificationChannel {
id String @id @default(uuid()) @db.Uuid
channel_type String @db.VarChar(32)
name String
enabled Boolean @default(true)
config_json Json
template_json Json?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
@@unique([channel_type, name], map: "notification_channels_type_name_ux")
@@index([enabled, channel_type], map: "idx_notification_channels_enabled_type")
@@map("notification_channels")
}
model WorkflowDefinition {
id String @id @default(uuid()) @db.Uuid
name String
+41 -1
查看文件
@@ -6,6 +6,10 @@ 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}"
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}"
echo "=== [1/7] Smoke Test ==="
"$SCRIPT_DIR/smoke-test.sh"
@@ -47,7 +51,7 @@ else
fi
if [[ "$RUN_AUTH_SECURITY_REGRESSION" == "1" ]]; then
echo "=== [7/7] Auth Security Regression ==="
echo "=== [7/7] Auth Security Regression ==="
"$SCRIPT_DIR/regression-auth-security.sh"
echo ""
else
@@ -55,4 +59,40 @@ else
echo ""
fi
if [[ "$RUN_VERIFICATION_REGRESSION" == "1" ]]; then
echo "=== [8/11] Verification Regression ==="
"$SCRIPT_DIR/regression-verification.sh"
echo ""
else
echo "=== [8/11] Verification Regression (skipped, RUN_VERIFICATION_REGRESSION=$RUN_VERIFICATION_REGRESSION) ==="
echo ""
fi
if [[ "$RUN_DASHBOARD_REGRESSION" == "1" ]]; then
echo "=== [9/11] Dashboard Regression ==="
"$SCRIPT_DIR/regression-dashboard.sh"
echo ""
else
echo "=== [9/11] Dashboard Regression (skipped, RUN_DASHBOARD_REGRESSION=$RUN_DASHBOARD_REGRESSION) ==="
echo ""
fi
if [[ "$RUN_BUDGET_REGRESSION" == "1" ]]; then
echo "=== [10/11] Budget Regression ==="
"$SCRIPT_DIR/regression-budget.sh"
echo ""
else
echo "=== [10/11] Budget Regression (skipped, RUN_BUDGET_REGRESSION=$RUN_BUDGET_REGRESSION) ==="
echo ""
fi
if [[ "$RUN_INTEGRATIONS_REGRESSION" == "1" ]]; then
echo "=== [11/11] Integrations Regression ==="
"$SCRIPT_DIR/regression-integrations.sh"
echo ""
else
echo "=== [11/11] Integrations Regression (skipped, RUN_INTEGRATIONS_REGRESSION=$RUN_INTEGRATIONS_REGRESSION) ==="
echo ""
fi
echo "[OK] All regressions passed"
+168
查看文件
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-budget}"
FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}"
FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}"
EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}"
EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}"
if ! command -v curl >/dev/null 2>&1; then
echo "[ERROR] curl not found" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "[ERROR] jq not found" >&2
exit 1
fi
TMP_DIR="$(mktemp -d)"
RUN_SUFFIX="$(date +%s)-$RANDOM"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
assert_status() {
local actual="$1"
local expected="$2"
local name="$3"
local body_file="${4:-}"
if [[ "$actual" != "$expected" ]]; then
echo "[ERROR] $name expected HTTP $expected but got $actual" >&2
if [[ -n "$body_file" && -f "$body_file" ]]; then
cat "$body_file" >&2
fi
exit 1
fi
}
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 $identifier" "$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/7] Login users"
FINANCE_TOKEN="$(login "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/login-finance.json")"
EMPLOYEE_TOKEN="$(login "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$TMP_DIR/login-employee.json")"
echo "[2/7] Create budget pool"
BUDGET_CODE="BGT-$RUN_SUFFIX"
status="$(api_json POST "$BASE_URL/api/budgets" "$FINANCE_TOKEN" "$TMP_DIR/budget-create.json" "{\"code\":\"$BUDGET_CODE\",\"name\":\"Regression Budget\",\"periodStart\":\"2026-01-01\",\"periodEnd\":\"2026-12-31\",\"allocatedAmount\":\"5000.00\",\"warningThresholdPercent\":80}")"
assert_status "$status" "200" "create budget" "$TMP_DIR/budget-create.json"
BUDGET_ID="$(jq -er '.data.id' "$TMP_DIR/budget-create.json")"
echo "[3/7] Query budgets"
status="$(api_json GET "$BASE_URL/api/budgets?page=1&pageSize=20" "$FINANCE_TOKEN" "$TMP_DIR/budget-list.json")"
assert_status "$status" "200" "list budgets" "$TMP_DIR/budget-list.json"
jq -e '.data.items | length >= 1' "$TMP_DIR/budget-list.json" >/dev/null
echo "[4/7] Build reimbursement with item + invoice"
status="$(api_json POST "$BASE_URL/api/reimbursements" "$EMPLOYEE_TOKEN" "$TMP_DIR/reimbursement-create.json" '{}')"
assert_status "$status" "200" "create reimbursement" "$TMP_DIR/reimbursement-create.json"
REIMBURSEMENT_ID="$(jq -er '.data.id' "$TMP_DIR/reimbursement-create.json")"
EXPENSE_TYPE_ID="$(api_json GET "$BASE_URL/api/expense-types?enabled=1" "$EMPLOYEE_TOKEN" "$TMP_DIR/expense-types.json" >/dev/null; jq -er '.data.items[0].id' "$TMP_DIR/expense-types.json")"
status="$(api_json POST "$BASE_URL/api/reimbursements/$REIMBURSEMENT_ID/items" "$EMPLOYEE_TOKEN" "$TMP_DIR/reimbursement-item.json" "{\"expenseTypeId\":\"$EXPENSE_TYPE_ID\",\"expenseDate\":\"2026-02-09\",\"amount\":\"6000.00\",\"description\":\"budget regression\"}")"
assert_status "$status" "200" "create reimbursement item" "$TMP_DIR/reimbursement-item.json"
DUMMY_FILE="$TMP_DIR/invoice.png"
printf 'budget-regression-%s-%s' "$RUN_SUFFIX" "$RANDOM" >"$DUMMY_FILE"
status="$(curl -sS -o "$TMP_DIR/invoice-upload.json" -w "%{http_code}" \
-X POST "$BASE_URL/api/invoice-pool/upload" \
-H "authorization: Bearer $EMPLOYEE_TOKEN" \
-F "file=@$DUMMY_FILE;type=image/png")"
assert_status "$status" "200" "upload invoice" "$TMP_DIR/invoice-upload.json"
INVOICE_ID="$(jq -er '.data.invoicePoolId' "$TMP_DIR/invoice-upload.json")"
status="$(api_json POST "$BASE_URL/api/invoice-pool/$INVOICE_ID/bind" "$EMPLOYEE_TOKEN" "$TMP_DIR/invoice-bind.json" "{\"reimbursementId\":\"$REIMBURSEMENT_ID\"}")"
assert_status "$status" "200" "bind invoice" "$TMP_DIR/invoice-bind.json"
echo "[5/7] Submit reimbursement (warn mode should pass)"
status="$(BUDGET_CONTROL_MODE=warn api_json POST "$BASE_URL/api/reimbursements/$REIMBURSEMENT_ID/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/reimbursement-submit-warn.json" '{}')"
assert_status "$status" "200" "submit reimbursement warn" "$TMP_DIR/reimbursement-submit-warn.json"
echo "[6/7] Archive budget"
status="$(api_json POST "$BASE_URL/api/budgets/$BUDGET_ID/archive" "$FINANCE_TOKEN" "$TMP_DIR/budget-archive.json" '{}')"
assert_status "$status" "200" "archive budget" "$TMP_DIR/budget-archive.json"
status="$(api_json GET "$BASE_URL/api/budgets?status=archived" "$FINANCE_TOKEN" "$TMP_DIR/budget-archived-list.json")"
assert_status "$status" "200" "list archived budgets" "$TMP_DIR/budget-archived-list.json"
echo "[7/7] Done"
echo "[OK] Budget regression passed"
if [[ -n "${BLOCK_BASE_URL:-}" ]]; then
echo "[extra] Block mode scenario on BLOCK_BASE_URL=$BLOCK_BASE_URL"
BLOCK_FINANCE_TOKEN="$(BASE_URL="$BLOCK_BASE_URL" login "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/block-login-finance.json")"
BLOCK_EMPLOYEE_TOKEN="$(BASE_URL="$BLOCK_BASE_URL" login "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$TMP_DIR/block-login-employee.json")"
BLOCK_BUDGET_CODE="BGT-BLOCK-$RUN_SUFFIX"
status="$(BASE_URL="$BLOCK_BASE_URL" api_json POST "$BLOCK_BASE_URL/api/budgets" "$BLOCK_FINANCE_TOKEN" "$TMP_DIR/block-budget-create.json" "{\"code\":\"$BLOCK_BUDGET_CODE\",\"name\":\"Regression Block Budget\",\"periodStart\":\"2026-01-01\",\"periodEnd\":\"2026-12-31\",\"allocatedAmount\":\"5000.00\",\"warningThresholdPercent\":80}")"
assert_status "$status" "200" "create block budget" "$TMP_DIR/block-budget-create.json"
status="$(BASE_URL="$BLOCK_BASE_URL" api_json POST "$BLOCK_BASE_URL/api/reimbursements" "$BLOCK_EMPLOYEE_TOKEN" "$TMP_DIR/block-reimbursement-create.json" '{}')"
assert_status "$status" "200" "create block reimbursement" "$TMP_DIR/block-reimbursement-create.json"
BLOCK_REIMBURSEMENT_ID="$(jq -er '.data.id' "$TMP_DIR/block-reimbursement-create.json")"
EXPENSE_TYPE_ID="$(BASE_URL="$BLOCK_BASE_URL" api_json GET "$BLOCK_BASE_URL/api/expense-types?enabled=1" "$BLOCK_EMPLOYEE_TOKEN" "$TMP_DIR/block-expense-types.json" >/dev/null; jq -er '.data.items[0].id' "$TMP_DIR/block-expense-types.json")"
status="$(BASE_URL="$BLOCK_BASE_URL" api_json POST "$BLOCK_BASE_URL/api/reimbursements/$BLOCK_REIMBURSEMENT_ID/items" "$BLOCK_EMPLOYEE_TOKEN" "$TMP_DIR/block-reimbursement-item.json" "{\"expenseTypeId\":\"$EXPENSE_TYPE_ID\",\"expenseDate\":\"2026-02-09\",\"amount\":\"6000.00\",\"description\":\"budget block regression\"}")"
assert_status "$status" "200" "create block reimbursement item" "$TMP_DIR/block-reimbursement-item.json"
BLOCK_DUMMY_FILE="$TMP_DIR/block-invoice.png"
printf "budget-block-regression-%s-%s" "$RUN_SUFFIX" "$RANDOM" >"$BLOCK_DUMMY_FILE"
status="$(curl -sS -o "$TMP_DIR/block-invoice-upload.json" -w "%{http_code}" \
-X POST "$BLOCK_BASE_URL/api/invoice-pool/upload" \
-H "authorization: Bearer $BLOCK_EMPLOYEE_TOKEN" \
-F "file=@$BLOCK_DUMMY_FILE;type=image/png")"
assert_status "$status" "200" "upload block invoice" "$TMP_DIR/block-invoice-upload.json"
BLOCK_INVOICE_ID="$(jq -er '.data.invoicePoolId' "$TMP_DIR/block-invoice-upload.json")"
status="$(BASE_URL="$BLOCK_BASE_URL" api_json POST "$BLOCK_BASE_URL/api/invoice-pool/$BLOCK_INVOICE_ID/bind" "$BLOCK_EMPLOYEE_TOKEN" "$TMP_DIR/block-invoice-bind.json" "{\"reimbursementId\":\"$BLOCK_REIMBURSEMENT_ID\"}")"
assert_status "$status" "200" "bind block invoice" "$TMP_DIR/block-invoice-bind.json"
status="$(BASE_URL="$BLOCK_BASE_URL" api_json POST "$BLOCK_BASE_URL/api/reimbursements/$BLOCK_REIMBURSEMENT_ID/submit" "$BLOCK_EMPLOYEE_TOKEN" "$TMP_DIR/block-reimbursement-submit.json" '{}')"
if [[ "$status" != "409" ]]; then
echo "[ERROR] block mode expected 409 but got $status" >&2
cat "$TMP_DIR/block-reimbursement-submit.json" >&2
exit 1
fi
jq -e '.error.details.budget == "exceeded_block_mode"' "$TMP_DIR/block-reimbursement-submit.json" >/dev/null
echo "[OK] Budget block-mode regression passed"
fi
+146
查看文件
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-dashboard}"
FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}"
FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}"
DASHBOARD_PERF_CHECK="${DASHBOARD_PERF_CHECK:-0}"
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
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 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\":\"$FINANCE_MGR_IDENTIFIER\",\"password\":\"$FINANCE_MGR_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"
curl -sS -o "$out_file" -w "%{http_code}" \
-X "$method" "$url" \
-H "authorization: Bearer $token"
}
assert_json_shape() {
local file="$1"
local expr="$2"
local name="$3"
if ! jq -e "$expr" "$file" >/dev/null; then
echo "[ERROR] $name json assertion failed: $expr" >&2
cat "$file" >&2
exit 1
fi
}
echo "[1/5] Login"
TOKEN="$(login "$TMP_DIR/login.json")"
FILTER_QS="dateFrom=2026-01-01&dateTo=2026-12-31"
echo "[2/5] Query dashboard endpoints"
status="$(api_json GET "$BASE_URL/api/dashboard/expense-overview?$FILTER_QS" "$TOKEN" "$TMP_DIR/overview.json")"
assert_status "$status" "200" "dashboard overview" "$TMP_DIR/overview.json"
assert_json_shape "$TMP_DIR/overview.json" '.data.totalCount >= 0 and (.data.totalAmount | type == "string")' "overview"
status="$(api_json GET "$BASE_URL/api/dashboard/trends?$FILTER_QS" "$TOKEN" "$TMP_DIR/trends-1.json")"
assert_status "$status" "200" "dashboard trends(1)" "$TMP_DIR/trends-1.json"
assert_json_shape "$TMP_DIR/trends-1.json" '.data.points | type == "array"' "trends-array"
assert_json_shape "$TMP_DIR/trends-1.json" '.data.points | all(.[]; (.date | type == "string") and (.amount | type == "string") and (.count | type == "number"))' "trends-shape"
assert_json_shape "$TMP_DIR/trends-1.json" '
.data.points
| reduce .[] as $p ({prev:null, ok:true};
if .ok == false then .
elif .prev == null or $p.date >= .prev then {prev: $p.date, ok: true}
else {prev: $p.date, ok: false}
end
)
| .ok
' "trends-order"
status="$(api_json GET "$BASE_URL/api/dashboard/trends?$FILTER_QS" "$TOKEN" "$TMP_DIR/trends-2.json")"
assert_status "$status" "200" "dashboard trends(2)" "$TMP_DIR/trends-2.json"
if ! diff -u <(jq -S '.data.points' "$TMP_DIR/trends-1.json") <(jq -S '.data.points' "$TMP_DIR/trends-2.json") >/dev/null; then
echo "[ERROR] trends response not stable across repeated calls" >&2
exit 1
fi
status="$(api_json GET "$BASE_URL/api/dashboard/top-categories?$FILTER_QS&limit=5" "$TOKEN" "$TMP_DIR/top-categories.json")"
assert_status "$status" "200" "dashboard top-categories" "$TMP_DIR/top-categories.json"
assert_json_shape "$TMP_DIR/top-categories.json" '.data.items | type == "array" and (length <= 5)' "top-categories-array"
assert_json_shape "$TMP_DIR/top-categories.json" '.data.items | all(.[]; (.code | type == "string") and (.name | type == "string") and (.amount | type == "string") and (.count | type == "number"))' "top-categories-shape"
status="$(api_json GET "$BASE_URL/api/dashboard/approval-efficiency?$FILTER_QS" "$TOKEN" "$TMP_DIR/efficiency.json")"
assert_status "$status" "200" "dashboard approval-efficiency" "$TMP_DIR/efficiency.json"
assert_json_shape "$TMP_DIR/efficiency.json" '.data.averageHours >= 0 and .data.approvedCount >= 0' "efficiency"
echo "[3/5] Export dashboard data"
status="$(curl -sS -o "$TMP_DIR/export.csv" -w "%{http_code}" \
-X GET "$BASE_URL/api/exports/dashboard/reimbursements?format=csv&$FILTER_QS" \
-H "authorization: Bearer $TOKEN")"
assert_status "$status" "200" "export dashboard csv" "$TMP_DIR/export.csv"
if [[ ! -s "$TMP_DIR/export.csv" ]]; then
echo "[ERROR] dashboard csv export is empty" >&2
exit 1
fi
status="$(curl -sS -o "$TMP_DIR/export.xlsx" -w "%{http_code}" \
-X GET "$BASE_URL/api/exports/dashboard/reimbursements?format=xlsx&$FILTER_QS" \
-H "authorization: Bearer $TOKEN")"
assert_status "$status" "200" "export dashboard xlsx" "$TMP_DIR/export.xlsx"
if [[ ! -s "$TMP_DIR/export.xlsx" ]]; then
echo "[ERROR] dashboard xlsx export is empty" >&2
exit 1
fi
if [[ "$DASHBOARD_PERF_CHECK" == "1" ]]; then
echo "[4/5] Optional performance probe"
PERF_TIME="$(curl -sS -o /dev/null -w "%{time_total}" \
-X GET "$BASE_URL/api/dashboard/trends?$FILTER_QS" \
-H "authorization: Bearer $TOKEN")"
echo "[INFO] trends endpoint time_total=${PERF_TIME}s"
else
echo "[4/5] Optional performance probe (skipped, DASHBOARD_PERF_CHECK=$DASHBOARD_PERF_CHECK)"
fi
echo "[5/5] Done"
echo "[OK] Dashboard regression passed"
+186
查看文件
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-verification}"
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"
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
exit 1
fi
TMP_DIR="$(mktemp -d)"
RUN_SUFFIX="$(date +%s)-$RANDOM"
cleanup() {
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
}
create_available_invoice() {
local marker="$1"
DATABASE_URL="$DATABASE_URL" RUN_SUFFIX="$RUN_SUFFIX" MARKER="$marker" node <<'NODE'
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const { createPrismaClient } = require('./src/prismaClient');
const { getConfig } = require('./src/config');
(async () => {
const marker = process.env.MARKER || 'VERIFY_OK';
const runSuffix = process.env.RUN_SUFFIX || 'default';
const config = getConfig();
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
const owner = await prisma.employee.findUnique({
where: { email: '[email protected]' },
select: { id: true },
});
if (!owner) {
throw new Error('finance_mgr not found');
}
const relPath = path.join('verification-regression', runSuffix, `${crypto.randomUUID()}.txt`);
const absPath = path.resolve(config.storageDir, relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, marker, 'utf8');
const fileSha256 = crypto.createHash('sha256').update(marker).digest('hex');
const created = await prisma.invoicePool.create({
data: {
owner_employee_id: owner.id,
uploaded_by_employee_id: owner.id,
storage_path: relPath,
original_name: `verify-${runSuffix}.txt`,
mime_type: 'text/plain',
file_size: Buffer.byteLength(marker),
file_sha256: fileSha256,
status: 'available',
ocr_type: 'general',
invoice_code: 'MOCKPASS',
invoice_number: 'PASS',
remarks: marker.includes('FAIL') ? 'verify-fail' : null,
created_by: owner.id,
updated_by: owner.id,
},
select: { id: true },
});
process.stdout.write(`${created.id}\n`);
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
echo "[1/6] Login finance_mgr"
TOKEN="$(login "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/login.json")"
echo "[2/6] Create synthetic available invoice"
INVOICE_ID="$(create_available_invoice "VERIFY_OK")"
echo "[3/6] Request verification"
status="$(api_json POST "$BASE_URL/api/invoice-pool/$INVOICE_ID/verify" "$TOKEN" "$TMP_DIR/verify.json" '{}')"
assert_status "$status" "200" "invoice verify" "$TMP_DIR/verify.json"
VERIFICATION_ID="$(jq -er '.data.verificationId' "$TMP_DIR/verify.json")"
echo "[4/6] Run verification worker inline"
VERIFICATION_RECORD_ID="$VERIFICATION_ID" INVOICE_VERIFICATION_ENABLED=true VERIFICATION_PROVIDER=mock npm run --silent job:verification-once >"$TMP_DIR/verify-once.log"
sleep 0.2
echo "[5/6] Query verification records"
status="$(api_json GET "$BASE_URL/api/invoice-pool/$INVOICE_ID/verification?limit=5" "$TOKEN" "$TMP_DIR/records.json")"
assert_status "$status" "200" "list verification" "$TMP_DIR/records.json"
jq -e '.data.items | length >= 1' "$TMP_DIR/records.json" >/dev/null
jq -e '.data.items[0].status == "passed"' "$TMP_DIR/records.json" >/dev/null
echo "[6/6] Retry should be throttled immediately"
status="$(api_json POST "$BASE_URL/api/invoice-pool/$INVOICE_ID/verification/retry" "$TOKEN" "$TMP_DIR/retry.json" '{}')"
if [[ "$status" != "409" ]]; then
echo "[ERROR] expected retry throttled 409 but got $status" >&2
cat "$TMP_DIR/retry.json" >&2
exit 1
fi
echo "[OK] Verification regression passed"
+8
查看文件
@@ -30,6 +30,10 @@ const configReadRoutes = require("./routes/configRead");
const approvalRulesRoutes = require("./routes/approvalRules");
const notificationsRoutes = require("./routes/notifications");
const exportsRoutes = require("./routes/exports");
const dashboardRoutes = require("./routes/dashboard");
const budgetsRoutes = require("./routes/budgets");
const integrationsRoutes = require("./routes/integrations");
const travelOrdersRoutes = require("./routes/travelOrders");
const { startJobs } = require("./jobs/jobSystem");
const { initMetrics } = require("./observability");
const { AppError } = require("./utils/AppError");
@@ -226,6 +230,10 @@ async function buildApp() {
await app.register(configReadRoutes, { prefix: "/api" });
await app.register(approvalRulesRoutes, { prefix: "/api" });
await app.register(notificationsRoutes, { prefix: "/api" });
await app.register(dashboardRoutes, { prefix: "/api" });
await app.register(budgetsRoutes, { prefix: "/api" });
await app.register(integrationsRoutes, { prefix: "/api" });
await app.register(travelOrdersRoutes, { prefix: "/api" });
await app.register(exportsRoutes, { prefix: "/api" });
await startJobs(app);
+27 -3
查看文件
@@ -12,11 +12,29 @@ const EMPLOYEE_BASE_PERMISSIONS = [
const ROLE_PERMISSIONS = {
employee: EMPLOYEE_BASE_PERMISSIONS,
manager: [...EMPLOYEE_BASE_PERMISSIONS, "expense.approve", "expense.transfer", "expense.view_all"],
ceo: [...EMPLOYEE_BASE_PERMISSIONS, "expense.approve", "expense.transfer", "expense.view_all"],
manager: [
...EMPLOYEE_BASE_PERMISSIONS,
"expense.approve",
"expense.transfer",
"expense.view_all",
"report.dashboard.view",
"budget.view",
"budget.manage",
],
ceo: [
...EMPLOYEE_BASE_PERMISSIONS,
"expense.approve",
"expense.transfer",
"expense.view_all",
"report.dashboard.view",
"budget.view",
],
finance: [
...EMPLOYEE_BASE_PERMISSIONS,
"invoice.manage",
"invoice.verify",
"report.dashboard.view",
"budget.view",
"expense.approve",
"expense.finance.pay",
"expense.view_all",
@@ -24,6 +42,12 @@ const ROLE_PERMISSIONS = {
finance_mgr: [
...EMPLOYEE_BASE_PERMISSIONS,
"invoice.manage",
"invoice.verify",
"report.dashboard.view",
"budget.view",
"budget.manage",
"integration.manage",
"notification.channel.manage",
"expense.approve",
"expense.finance.pay",
"expense.view_all",
@@ -42,7 +66,7 @@ const ROLE_PERMISSIONS = {
"invoice.upload",
"invoice.view_own",
],
hr: ["system.users", "system.users_sensitive", "expense.view_all"],
hr: ["system.users", "system.users_sensitive", "expense.view_all", "report.dashboard.view"],
admin: ["*"],
};
@@ -0,0 +1,41 @@
const cron = require("node-cron");
const { runIntegrationOutboxOnce } = require("./integrationOutboxJob");
function startIntegrationOutboxWorker(fastify) {
let running = false;
const task = cron.schedule(fastify.config.integrationOutboxCron, async () => {
if (running) {
return;
}
running = true;
try {
await runIntegrationOutboxOnce(fastify.prisma, fastify.config, fastify.log);
} catch (error) {
fastify.log.error({ err: error }, "Integration outbox worker tick failed");
} finally {
running = false;
}
});
fastify.addHook("onClose", async () => {
task.stop();
task.destroy();
});
fastify.log.info(
{
cron: fastify.config.integrationOutboxCron,
batchSize: fastify.config.integrationOutboxBatchSize,
},
"Integration outbox worker scheduled",
);
return task;
}
module.exports = {
startIntegrationOutboxWorker,
};
+2 -1
查看文件
@@ -1,6 +1,7 @@
const { startDelegationRevokeJob } = require("./delegationRevokeJob");
const { startInvoiceArchiveJob } = require("./invoiceArchiveJob");
const { startOcrJob } = require("./ocrJob");
const { startIntegrationOutboxWorker } = require("./integrationOutboxWorker");
const { startPgBossJobs } = require("./pgBossJobs");
async function startJobs(fastify) {
@@ -17,10 +18,10 @@ async function startJobs(fastify) {
startDelegationRevokeJob(fastify);
startOcrJob(fastify);
startInvoiceArchiveJob(fastify);
startIntegrationOutboxWorker(fastify);
return { driver: "cron" };
}
module.exports = {
startJobs,
};
+117
查看文件
@@ -3,10 +3,14 @@ const PgBoss = require("pg-boss");
const { revokeExpiredDelegations } = require("./delegationRevokeJob");
const { archiveUnusedInvoices } = require("./invoiceArchiveJob");
const { runOcrOnce } = require("./ocrJob");
const { runVerificationForRecord } = require("./verificationJob");
const { runIntegrationOutboxOnce } = require("./integrationOutboxJob");
const JOB_OCR_TICK = "ocr.tick";
const JOB_DELEGATION_REVOKE = "delegation.revoke-expired";
const JOB_INVOICE_ARCHIVE = "invoice.archive-unused";
const JOB_INVOICE_VERIFY = "invoice.verify";
const JOB_INTEGRATION_OUTBOX = "integration.outbox.dispatch";
function resolveDatabaseConnectionString() {
const connectionString = process.env.DATABASE_URL;
@@ -35,6 +39,8 @@ async function startPgBossJobs(fastify) {
await boss.createQueue(JOB_OCR_TICK);
await boss.createQueue(JOB_DELEGATION_REVOKE);
await boss.createQueue(JOB_INVOICE_ARCHIVE);
await boss.createQueue(JOB_INVOICE_VERIFY);
await boss.createQueue(JOB_INTEGRATION_OUTBOX);
await boss.work(JOB_DELEGATION_REVOKE, { batchSize: 1 }, async (jobs) => {
const job = jobs?.[0];
@@ -149,6 +155,96 @@ async function startPgBossJobs(fastify) {
}
});
await boss.work(JOB_INVOICE_VERIFY, { batchSize: 1 }, async (jobs) => {
const job = jobs?.[0];
const recordId = job?.data?.recordId;
if (!recordId) {
return;
}
try {
const result = await runVerificationForRecord(
fastify.prisma,
fastify.config,
recordId,
);
fastify.metrics?.jobRunsTotal?.inc?.({
job: JOB_INVOICE_VERIFY,
driver: "pgboss",
outcome: "success",
});
if (!result?.skipped) {
fastify.metrics?.jobProcessedTotal?.inc?.(
{
job: JOB_INVOICE_VERIFY,
driver: "pgboss",
kind: result.status ?? "unknown",
},
1,
);
}
} catch (error) {
fastify.metrics?.jobRunsTotal?.inc?.({
job: JOB_INVOICE_VERIFY,
driver: "pgboss",
outcome: "failure",
});
fastify.log.error({ err: error, jobId: job?.id }, "pg-boss invoice verify failed");
throw error;
}
});
await boss.work(JOB_INTEGRATION_OUTBOX, { batchSize: 1 }, async (jobs) => {
const job = jobs?.[0];
try {
const result = await runIntegrationOutboxOnce(
fastify.prisma,
fastify.config,
fastify.log,
);
fastify.metrics?.jobRunsTotal?.inc?.({
job: JOB_INTEGRATION_OUTBOX,
driver: "pgboss",
outcome: "success",
});
if (result.succeeded > 0) {
fastify.metrics?.jobProcessedTotal?.inc?.(
{
job: JOB_INTEGRATION_OUTBOX,
driver: "pgboss",
kind: "succeeded",
},
result.succeeded,
);
}
if (result.failed > 0) {
fastify.metrics?.jobProcessedTotal?.inc?.(
{
job: JOB_INTEGRATION_OUTBOX,
driver: "pgboss",
kind: "failed",
},
result.failed,
);
}
if (result.total > 0) {
fastify.log.info(
{ jobId: job?.id, ...result },
"pg-boss integration outbox tick processed",
);
}
} catch (error) {
fastify.metrics?.jobRunsTotal?.inc?.({
job: JOB_INTEGRATION_OUTBOX,
driver: "pgboss",
outcome: "failure",
});
fastify.log.error({ err: error, jobId: job?.id }, "pg-boss integration outbox failed");
throw error;
}
});
await boss.schedule(JOB_DELEGATION_REVOKE, fastify.config.delegationRevokeCron, null, {
tz: fastify.config.appTimezone,
singletonKey: JOB_DELEGATION_REVOKE,
@@ -171,13 +267,32 @@ async function startPgBossJobs(fastify) {
await boss.unschedule(JOB_OCR_TICK).catch(() => {});
}
if (fastify.config.invoiceVerificationEnabled) {
await boss.schedule(JOB_INVOICE_VERIFY, fastify.config.verificationPollCron, null, {
tz: fastify.config.appTimezone,
singletonKey: JOB_INVOICE_VERIFY,
singletonSeconds: 55,
});
} else {
await boss.unschedule(JOB_INVOICE_VERIFY).catch(() => {});
}
await boss.schedule(JOB_INTEGRATION_OUTBOX, fastify.config.integrationOutboxCron, null, {
tz: fastify.config.appTimezone,
singletonKey: JOB_INTEGRATION_OUTBOX,
singletonSeconds: 55,
});
fastify.log.info(
{
schema: fastify.config.pgBossSchema,
ocrProvider: fastify.config.ocrProvider,
ocrCron: fastify.config.ocrPollCron,
verificationEnabled: fastify.config.invoiceVerificationEnabled,
verificationCron: fastify.config.verificationPollCron,
delegationCron: fastify.config.delegationRevokeCron,
invoiceArchiveCron: fastify.config.invoiceArchiveCron,
integrationOutboxCron: fastify.config.integrationOutboxCron,
},
"pg-boss job system started",
);
@@ -189,5 +304,7 @@ module.exports = {
JOB_OCR_TICK,
JOB_DELEGATION_REVOKE,
JOB_INVOICE_ARCHIVE,
JOB_INVOICE_VERIFY,
JOB_INTEGRATION_OUTBOX,
startPgBossJobs,
};
+96
查看文件
@@ -0,0 +1,96 @@
const { verifyInvoiceByProvider } = require("../services/verification");
function toResultStatus(status) {
if (["passed", "failed", "error"].includes(status)) {
return status;
}
return "error";
}
async function runVerificationForRecord(prisma, config, recordId) {
const result = await prisma.$transaction(async (tx) => {
const lockedRows = await tx.$queryRaw`
SELECT id
FROM "invoice_verification_records"
WHERE id = ${recordId}::uuid
FOR UPDATE
`;
if (!Array.isArray(lockedRows) || lockedRows.length === 0) {
return { skipped: true, reason: "record_not_found" };
}
const record = await tx.invoiceVerificationRecord.findUnique({
where: { id: recordId },
include: {
invoice: {
select: {
id: true,
invoice_code: true,
invoice_number: true,
total_amount: true,
remarks: true,
status: true,
},
},
},
});
if (!record || !record.invoice) {
await tx.invoiceVerificationRecord.update({
where: { id: recordId },
data: {
status: "error",
error_code: "invoice_not_found",
error_message: "invoice not found",
completed_at: new Date(),
},
});
return { skipped: true, reason: "invoice_not_found" };
}
if (record.status !== "pending") {
return { skipped: true, reason: "status_not_pending" };
}
const startedAt = Date.now();
const verification = await verifyInvoiceByProvider({
config,
invoice: record.invoice,
});
const durationMs = Date.now() - startedAt;
const status = toResultStatus(verification.status);
await tx.invoiceVerificationRecord.update({
where: { id: recordId },
data: {
status,
result_code: verification.resultCode ?? null,
result_message: verification.resultMessage ?? null,
result_payload: verification.payload ?? null,
error_code: status === "error" ? verification.resultCode ?? "verification_error" : null,
error_message:
status === "error"
? verification.resultMessage ?? "verification_error"
: null,
duration_ms: durationMs,
completed_at: new Date(),
},
});
return {
skipped: false,
status,
durationMs,
resultCode: verification.resultCode ?? null,
};
});
return result;
}
module.exports = {
runVerificationForRecord,
};
+280
查看文件
@@ -0,0 +1,280 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const BUDGET_CREATE_SCHEMA = z.object({
code: z.string().trim().min(2).max(32),
name: z.string().trim().min(1).max(120),
departmentId: z.string().uuid().nullable().optional(),
periodStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
periodEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
allocatedAmount: z.string().regex(/^\d+(?:\.\d{1,2})?$/),
warningThresholdPercent: z.coerce.number().int().min(1).max(100).optional(),
});
const BUDGET_PATCH_SCHEMA = z
.object({
name: z.string().trim().min(1).max(120).optional(),
allocatedAmount: z.string().regex(/^\d+(?:\.\d{1,2})?$/).optional(),
warningThresholdPercent: z.coerce.number().int().min(1).max(100).optional(),
status: z.enum(["active", "archived"]).optional(),
})
.strict();
const BUDGET_LIST_QUERY_SCHEMA = z.object({
status: z.enum(["active", "archived"]).optional(),
departmentId: z.string().uuid().optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
function toDate(value) {
return new Date(`${value}T00:00:00.000Z`);
}
function toMoneyString(value) {
if (value == null) {
return "0.00";
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric.toFixed(2) : "0.00";
}
function mapBudget(item) {
const allocated = Number(item.allocated_amount ?? 0);
const used = Number(item.used_amount ?? 0);
const remaining = allocated - used;
return {
id: item.id,
code: item.code,
name: item.name,
departmentId: item.department_id ?? null,
status: item.status,
periodStart: item.period_start?.toISOString?.()?.slice(0, 10) ?? null,
periodEnd: item.period_end?.toISOString?.()?.slice(0, 10) ?? null,
allocatedAmount: toMoneyString(item.allocated_amount),
usedAmount: toMoneyString(item.used_amount),
remainingAmount: toMoneyString(remaining),
warningThresholdPercent: item.warning_threshold_percent,
archivedAt: item.archived_at?.toISOString?.() ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
function parseBudgetId(value) {
const parsed = z.string().uuid().safeParse(value);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
return parsed.data;
}
async function budgetsRoutes(fastify) {
fastify.get(
"/budgets",
{
preHandler: fastify.requirePermission("budget.view"),
},
async (request, reply) => {
const parsed = BUDGET_LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const query = parsed.data;
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where = {
...(query.status ? { status: query.status } : {}),
...(query.departmentId ? { department_id: query.departmentId } : {}),
};
const [items, total] = await Promise.all([
fastify.prisma.budgetPool.findMany({
where,
orderBy: [{ period_start: "desc" }, { created_at: "desc" }],
skip,
take: pageSize,
}),
fastify.prisma.budgetPool.count({ where }),
]);
return sendData(reply, request, {
page,
pageSize,
total,
items: items.map(mapBudget),
});
},
);
fastify.post(
"/budgets",
{
preHandler: fastify.requirePermission("budget.manage"),
},
async (request, reply) => {
const parsed = BUDGET_CREATE_SCHEMA.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const body = parsed.data;
const start = toDate(body.periodStart);
const end = toDate(body.periodEnd);
if (start > end) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { periodEnd: "must_be_after_periodStart" },
});
}
const created = await fastify.prisma.budgetPool.create({
data: {
code: body.code.toUpperCase(),
name: body.name,
department_id: body.departmentId ?? null,
status: "active",
period_start: start,
period_end: end,
allocated_amount: body.allocatedAmount,
used_amount: "0.00",
warning_threshold_percent: body.warningThresholdPercent ?? 80,
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_CREATED",
targetType: "budget_pool",
targetId: created.id,
extra: {
code: created.code,
allocatedAmount: created.allocated_amount,
},
}),
});
return sendData(reply, request, mapBudget(created));
},
);
fastify.patch(
"/budgets/:id",
{
preHandler: fastify.requirePermission("budget.manage"),
},
async (request, reply) => {
const budgetId = parseBudgetId(request.params?.id);
const parsed = BUDGET_PATCH_SCHEMA.safeParse(request.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_patch" },
});
}
const patch = parsed.data;
const updated = await fastify.prisma.budgetPool.update({
where: { id: budgetId },
data: {
...(patch.name != null ? { name: patch.name } : {}),
...(patch.allocatedAmount != null ? { allocated_amount: patch.allocatedAmount } : {}),
...(patch.warningThresholdPercent != null
? { warning_threshold_percent: patch.warningThresholdPercent }
: {}),
...(patch.status ? { status: patch.status } : {}),
...(patch.status === "archived" ? { archived_at: new Date() } : {}),
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_UPDATED",
targetType: "budget_pool",
targetId: budgetId,
extra: {
patch,
},
}),
});
return sendData(reply, request, mapBudget(updated));
},
);
fastify.post(
"/budgets/:id/archive",
{
preHandler: fastify.requirePermission("budget.manage"),
},
async (request, reply) => {
const budgetId = parseBudgetId(request.params?.id);
const updated = await fastify.prisma.budgetPool.update({
where: { id: budgetId },
data: {
status: "archived",
archived_at: new Date(),
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_ARCHIVED",
targetType: "budget_pool",
targetId: budgetId,
extra: {
code: updated.code,
},
}),
});
return sendData(reply, request, mapBudget(updated));
},
);
}
module.exports = budgetsRoutes;
+278
查看文件
@@ -0,0 +1,278 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const QUERY_SCHEMA = z.object({
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
departmentId: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(30).optional(),
});
function parseQuery(query) {
const parsed = QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const dateFrom = parsed.data.dateFrom ? new Date(`${parsed.data.dateFrom}T00:00:00.000Z`) : null;
const dateTo = parsed.data.dateTo ? new Date(`${parsed.data.dateTo}T23:59:59.999Z`) : null;
if (dateFrom && dateTo && dateFrom > dateTo) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { dateTo: "must_be_after_dateFrom" },
});
}
return {
dateFrom,
dateTo,
departmentId: parsed.data.departmentId ?? null,
limit: parsed.data.limit ?? 8,
};
}
function canViewAll(request) {
return request.employee.roleCodes.some((code) =>
["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code),
);
}
function buildReimbursementWhere(request, query) {
const where = {
...(query.dateFrom || query.dateTo
? {
reimbursement_date: {
...(query.dateFrom ? { gte: query.dateFrom } : {}),
...(query.dateTo ? { lte: query.dateTo } : {}),
},
}
: {}),
...(query.departmentId ? { department_id: query.departmentId } : {}),
};
if (canViewAll(request)) {
return where;
}
return {
...where,
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
{ owner: { direct_manager_id: request.employee.id } },
],
};
}
function moneyString(value) {
if (value == null) {
return "0.00";
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return "0.00";
}
return numeric.toFixed(2);
}
async function dashboardRoutes(fastify) {
fastify.get(
"/dashboard/expense-overview",
{
preHandler: fastify.requirePermission("report.dashboard.view"),
},
async (request, reply) => {
const query = parseQuery(request.query);
const where = buildReimbursementWhere(request, query);
const [totalCount, totalAmountAgg, paidCount, paidAmountAgg, reviewingCount] = await Promise.all([
fastify.prisma.reimbursementForm.count({ where }),
fastify.prisma.reimbursementForm.aggregate({
where,
_sum: { total_amount: true },
}),
fastify.prisma.reimbursementForm.count({
where: { ...where, status: "paid" },
}),
fastify.prisma.reimbursementForm.aggregate({
where: { ...where, status: "paid" },
_sum: { total_amount: true },
}),
fastify.prisma.reimbursementForm.count({
where: { ...where, status: "reviewing" },
}),
]);
return sendData(reply, request, {
totalCount,
totalAmount: moneyString(totalAmountAgg._sum.total_amount),
paidCount,
paidAmount: moneyString(paidAmountAgg._sum.total_amount),
reviewingCount,
});
},
);
fastify.get(
"/dashboard/trends",
{
preHandler: fastify.requirePermission("report.dashboard.view"),
},
async (request, reply) => {
const query = parseQuery(request.query);
const where = buildReimbursementWhere(request, query);
const groups = await fastify.prisma.reimbursementForm.groupBy({
where,
by: ["reimbursement_date"],
_sum: {
total_amount: true,
},
_count: {
_all: true,
},
orderBy: {
reimbursement_date: "asc",
},
});
const points = groups.map((item) => ({
date: item.reimbursement_date?.toISOString?.().slice(0, 10) ?? "unknown",
amount: moneyString(item._sum.total_amount),
count: item._count._all,
}));
return sendData(reply, request, {
points,
});
},
);
fastify.get(
"/dashboard/top-categories",
{
preHandler: fastify.requirePermission("report.dashboard.view"),
},
async (request, reply) => {
const query = parseQuery(request.query);
const where = buildReimbursementWhere(request, query);
const grouped = await fastify.prisma.reimbursementItem.groupBy({
where: {
reimbursement_form: where,
},
by: ["expense_type_id"],
_sum: {
amount: true,
},
_count: {
_all: true,
},
orderBy: {
_sum: {
amount: "desc",
},
},
take: query.limit,
});
const typeIds = grouped.map((item) => item.expense_type_id);
const expenseTypes = typeIds.length
? await fastify.prisma.expenseType.findMany({
where: { id: { in: typeIds } },
select: { id: true, code: true, name: true },
})
: [];
const typeMap = new Map(expenseTypes.map((item) => [item.id, item]));
const items = grouped.map((item) => {
const expenseType = typeMap.get(item.expense_type_id);
return {
code: expenseType?.code ?? "unknown",
name: expenseType?.name ?? "未知",
amount: moneyString(item._sum.amount),
count: item._count._all,
};
});
return sendData(reply, request, {
items,
});
},
);
fastify.get(
"/dashboard/approval-efficiency",
{
preHandler: fastify.requirePermission("report.dashboard.view"),
},
async (request, reply) => {
const query = parseQuery(request.query);
const where = buildReimbursementWhere(request, query);
const approvedForms = await fastify.prisma.reimbursementForm.findMany({
where: {
...where,
status: {
in: ["approved", "paid"],
},
},
select: {
id: true,
created_at: true,
},
});
const formIdSet = approvedForms.map((item) => item.id);
const instances = formIdSet.length
? await fastify.prisma.workflowInstance.findMany({
where: {
entity_type: "reimbursement",
reimbursement_id: { in: formIdSet },
completed_at: { not: null },
current_status: "approved",
},
select: {
reimbursement_id: true,
completed_at: true,
},
})
: [];
const completedMap = new Map(
instances.map((item) => [item.reimbursement_id, item.completed_at]),
);
const durations = approvedForms
.map((form) => {
const completedAt = completedMap.get(form.id);
if (!completedAt) {
return null;
}
return Math.max(0, completedAt.getTime() - form.created_at.getTime());
})
.filter((item) => item != null);
const totalMs = durations.reduce((sum, value) => sum + value, 0);
const avgMs = durations.length ? Math.round(totalMs / durations.length) : 0;
return sendData(reply, request, {
approvedCount: durations.length,
averageHours: Number((avgMs / 3_600_000).toFixed(2)),
});
},
);
}
module.exports = dashboardRoutes;
+171 -1
查看文件
@@ -41,6 +41,14 @@ const AUDIT_EXPORT_QUERY_SCHEMA = z.object({
limit: z.coerce.number().int().min(1).max(50000).optional(),
});
const DASHBOARD_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
departmentId: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(20000).optional(),
});
function csvCell(value) {
if (value == null) return "";
@@ -113,6 +121,127 @@ function buildViewAllScopeWhere(request) {
};
}
function toDateRange(query) {
const dateFrom = query.dateFrom ? new Date(`${query.dateFrom}T00:00:00.000Z`) : null;
const dateTo = query.dateTo ? new Date(`${query.dateTo}T23:59:59.999Z`) : null;
if (dateFrom && dateTo && dateFrom > dateTo) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { dateTo: "must_be_after_dateFrom" },
});
}
return { dateFrom, dateTo };
}
function buildDashboardWhere(request, query) {
const { dateFrom, dateTo } = toDateRange(query);
const dateWhere =
dateFrom || dateTo
? {
reimbursement_date: {
...(dateFrom ? { gte: dateFrom } : {}),
...(dateTo ? { lte: dateTo } : {}),
},
}
: {};
const departmentWhere = query.departmentId ? { department_id: query.departmentId } : {};
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
return {
...dateWhere,
...departmentWhere,
};
}
return {
...dateWhere,
...departmentWhere,
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
{ owner: { direct_manager_id: request.employee.id } },
],
};
}
async function* streamDashboardReimbursementsCsv(prisma, where, limit) {
yield "\ufeff";
yield csvRow([
"formNumber",
"status",
"departmentId",
"reimbursementDate",
"totalAmount",
"ownerName",
"submitterName",
"createdAt",
"id",
]);
const batchSize = 500;
let remaining = limit;
let cursorCreatedAt = null;
let cursorId = null;
while (remaining > 0) {
const take = Math.min(batchSize, remaining);
const rows = await prisma.reimbursementForm.findMany({
where: {
...where,
...(cursorCreatedAt
? {
OR: [
{ created_at: { lt: cursorCreatedAt } },
{ created_at: cursorCreatedAt, id: { lt: cursorId } },
],
}
: {}),
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take,
select: {
id: true,
form_number: true,
status: true,
department_id: true,
reimbursement_date: true,
total_amount: true,
created_at: true,
owner: { select: { name: true } },
submitter: { select: { name: true } },
},
});
if (!rows.length) {
break;
}
for (const item of rows) {
yield csvRow([
item.form_number ?? "",
item.status,
item.department_id,
item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? "",
item.total_amount?.toString?.() ?? String(item.total_amount ?? ""),
item.owner?.name ?? "",
item.submitter?.name ?? "",
item.created_at?.toISOString?.() ?? "",
item.id,
]);
remaining -= 1;
if (remaining <= 0) break;
}
const last = rows[rows.length - 1];
cursorCreatedAt = last.created_at;
cursorId = last.id;
}
}
function exportFilename(prefix, format) {
const now = new Date();
const stamp = now.toISOString().slice(0, 10);
@@ -731,7 +860,48 @@ async function exportsRoutes(fastify) {
return reply.send(stream);
},
);
fastify.get(
"/exports/dashboard/reimbursements",
{
preHandler: fastify.requirePermission("report.dashboard.view"),
},
async (request, reply) => {
const query = parseQuery(DASHBOARD_EXPORT_QUERY_SCHEMA, request.query);
const where = buildDashboardWhere(request, query);
const format = resolveExportFormat(query);
const limit = query.limit ?? 5000;
const total = await fastify.prisma.reimbursementForm.count({ where });
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPORT_DASHBOARD",
targetType: "export",
extra: {
format,
limit,
matchedTotal: total,
dateFrom: query.dateFrom ?? null,
dateTo: query.dateTo ?? null,
departmentId: query.departmentId ?? null,
},
}),
});
const filename = exportFilename("dashboard-reimbursements", format);
setExportHeaders(reply, format, filename);
if (format === "xlsx") {
const buffer = await buildReimbursementsXlsx(fastify.prisma, where, limit);
return reply.send(Buffer.from(buffer));
}
const stream = Readable.from(streamDashboardReimbursementsCsv(fastify.prisma, where, limit));
return reply.send(stream);
},
);
}
module.exports = exportsRoutes;
+106
查看文件
@@ -0,0 +1,106 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
const { buildErpPayload } = require("../services/integrations/erp");
const { runIntegrationOutboxOnce } = require("../jobs/integrationOutboxJob");
async function integrationsRoutes(fastify) {
fastify.post(
"/integrations/erp/sync/:reimbursementId",
{
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({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { reimbursementId: "invalid_uuid" },
});
}
const reimbursementId = parsedId.data;
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
form_number: true,
owner_id: true,
submitter_id: true,
status: true,
total_amount: true,
payment_voucher_no: true,
payment_date: true,
paid_at: true,
},
});
if (!reimbursement) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (reimbursement.status !== "paid") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "erp_sync_requires_paid" },
});
}
const payload = buildErpPayload({ reimbursement });
const enqueued = await enqueueIntegrationEvent(fastify.prisma, {
eventType: "erp.reimbursement.paid",
payload,
dedupeKey: reimbursement.id,
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
actorId: request.employee.id,
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ERP_SYNC_ENQUEUED",
targetType: "reimbursement_form",
targetId: reimbursementId,
extra: {
outboxId: enqueued.id,
duplicated: enqueued.duplicated,
},
}),
});
const runNow = request.query?.runNow === "1" || request.query?.runNow === 1 || request.query?.runNow === true || request.query?.runNow === "true";
if (runNow) {
await runIntegrationOutboxOnce(fastify.prisma, fastify.config, fastify.log);
}
return sendData(reply, request, {
outboxId: enqueued.id,
status: enqueued.status,
duplicated: enqueued.duplicated,
});
},
);
}
module.exports = integrationsRoutes;
@@ -0,0 +1,20 @@
const { JOB_INVOICE_VERIFY } = require("../../../jobs/pgBossJobs");
const { runVerificationForRecord } = require("../../../jobs/verificationJob");
async function triggerVerificationJob(fastify, recordId) {
if (fastify.config.jobDriver === "pgboss" && fastify.boss) {
await fastify.boss.send(JOB_INVOICE_VERIFY, { recordId });
return { mode: "queued" };
}
const result = await runVerificationForRecord(
fastify.prisma,
fastify.config,
recordId,
);
return { mode: "inline", result };
}
module.exports = {
triggerVerificationJob,
};
@@ -0,0 +1,116 @@
const { toMoneyString } = require("../../../utils/workflow");
function mapVerificationRecord(item) {
if (!item) {
return null;
}
return {
id: item.id,
provider: item.provider,
status: item.status,
resultCode: item.result_code ?? null,
resultMessage: item.result_message ?? null,
errorCode: item.error_code ?? null,
errorMessage: item.error_message ?? null,
durationMs: item.duration_ms ?? null,
requestedBy: item.requested_by ?? null,
requestedAt: item.requested_at?.toISOString?.() ?? null,
completedAt: item.completed_at?.toISOString?.() ?? null,
retryOfId: item.retry_of_id ?? null,
attempts: item.attempts ?? 1,
};
}
function mapInvoiceSummary(item) {
const latestVerification = Array.isArray(item.verification_records)
? item.verification_records[0] ?? null
: null;
return {
id: item.id,
status: item.status,
ocrType: item.ocr_type,
ocrProvider: item.ocr_provider,
ocrAttempts: item.ocr_attempts,
ocrLastError: item.ocr_last_error,
ocrCompletedAt: item.ocr_completed_at?.toISOString?.() ?? null,
invoiceCode: item.invoice_code,
invoiceNumber: item.invoice_number,
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
amount: toMoneyString(item.amount),
taxAmount: toMoneyString(item.tax_amount),
totalAmount: toMoneyString(item.total_amount),
linkedReimbursementId: item.linked_reimbursement_id,
linkedItemId: item.linked_item_id,
originalName: item.original_name,
mimeType: item.mime_type,
fileSize: item.file_size,
verification: mapVerificationRecord(latestVerification),
owner: item.owner_employee
? {
id: item.owner_employee.id,
employeeNo: item.owner_employee.employee_no,
name: item.owner_employee.name,
}
: null,
uploadedBy: item.uploaded_by_employee
? {
id: item.uploaded_by_employee.id,
employeeNo: item.uploaded_by_employee.employee_no,
name: item.uploaded_by_employee.name,
}
: null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
function normalizeInvoiceResponse(invoice) {
const latestVerification = Array.isArray(invoice.verification_records)
? invoice.verification_records[0] ?? null
: null;
return {
id: invoice.id,
status: invoice.status,
archivedReason: invoice.archived_reason ?? null,
archivedAt: invoice.archived_at?.toISOString?.() ?? null,
ownerEmployeeId: invoice.owner_employee_id,
uploadedByEmployeeId: invoice.uploaded_by_employee_id,
originalName: invoice.original_name ?? null,
mimeType: invoice.mime_type ?? null,
fileSize: invoice.file_size ?? null,
fileSha256: invoice.file_sha256 ?? null,
remarks: invoice.remarks ?? null,
ocrType: invoice.ocr_type,
ocrProvider: invoice.ocr_provider,
ocrAttempts: invoice.ocr_attempts,
ocrLastRequestedAt: invoice.ocr_last_requested_at?.toISOString?.() ?? null,
ocrLastError: invoice.ocr_last_error,
ocrCompletedAt: invoice.ocr_completed_at?.toISOString?.() ?? null,
invoiceCode: invoice.invoice_code,
invoiceNumber: invoice.invoice_number,
invoiceDate: invoice.invoice_date ? invoice.invoice_date.toISOString().slice(0, 10) : null,
sellerName: invoice.seller_name ?? null,
buyerName: invoice.buyer_name ?? null,
amount: toMoneyString(invoice.amount),
taxAmount: toMoneyString(invoice.tax_amount),
totalAmount: toMoneyString(invoice.total_amount),
linkedReimbursementId: invoice.linked_reimbursement_id,
linkedItemId: invoice.linked_item_id,
verification: mapVerificationRecord(latestVerification),
isDuplicateOverride: invoice.is_duplicate_override ?? false,
overrideRefFormNumber: invoice.override_ref_form_number ?? null,
overrideReason: invoice.override_reason ?? null,
overriddenBy: invoice.overridden_by ?? null,
createdAt: invoice.created_at?.toISOString?.() ?? null,
updatedAt: invoice.updated_at?.toISOString?.() ?? null,
};
}
module.exports = {
mapVerificationRecord,
mapInvoiceSummary,
normalizeInvoiceResponse,
};
@@ -0,0 +1,27 @@
const { z } = require("zod");
const { AppError } = require("../../../utils/AppError");
const verificationListQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(50).optional(),
});
function parseVerificationListQuery(query) {
const parsed = verificationListQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
limit: parsed.data.limit ?? 20,
};
}
module.exports = {
parseVerificationListQuery,
};
@@ -0,0 +1,114 @@
function buildVerificationRecordSelect() {
return {
id: true,
provider: true,
status: true,
result_code: true,
result_message: true,
error_code: true,
error_message: true,
duration_ms: true,
requested_by: true,
requested_at: true,
completed_at: true,
retry_of_id: true,
attempts: true,
};
}
function buildLatestVerificationSelect() {
return {
orderBy: { created_at: "desc" },
take: 1,
select: buildVerificationRecordSelect(),
};
}
function buildInvoiceSummarySelect() {
return {
id: true,
status: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
original_name: true,
mime_type: true,
file_size: true,
created_at: true,
updated_at: true,
verification_records: buildLatestVerificationSelect(),
owner_employee: {
select: { id: true, employee_no: true, name: true },
},
uploaded_by_employee: {
select: { id: true, employee_no: true, name: true },
},
};
}
function buildInvoiceDetailSelect(options = {}) {
const select = {
id: true,
status: true,
archived_reason: true,
archived_at: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
remarks: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
seller_name: true,
buyer_name: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
is_duplicate_override: true,
override_ref_form_number: true,
override_reason: true,
overridden_by: true,
created_at: true,
updated_at: true,
verification_records: buildLatestVerificationSelect(),
};
if (options.includeLinkedReimbursement) {
select.linked_reimbursement = {
select: {
owner_id: true,
submitter_id: true,
},
};
}
return select;
}
module.exports = {
buildInvoiceSummarySelect,
buildInvoiceDetailSelect,
buildVerificationRecordSelect,
buildLatestVerificationSelect,
};
@@ -0,0 +1,384 @@
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 { mapVerificationRecord } = require("./shared/mappers");
const { parseVerificationListQuery } = require("./shared/schemas");
const { triggerVerificationJob } = require("./shared/helpers");
function registerInvoiceVerificationRoutes(fastify) {
fastify.post(
"/invoice-pool/:id/verify",
{
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({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
const invoiceId = parsedInvoiceId.data;
const provider = getProvider(fastify.config);
const now = Date.now();
const response = await fastify.prisma.$transaction(async (tx) => {
const lockedRows = await tx.$queryRaw`
SELECT id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
`;
if (!Array.isArray(lockedRows) || lockedRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
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,
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const latest = await tx.invoiceVerificationRecord.findFirst({
where: { invoice_id: invoiceId },
orderBy: { created_at: "desc" },
select: {
id: true,
status: true,
requested_at: true,
attempts: true,
},
});
if (latest?.status === "pending") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "already_pending" },
});
}
if (latest?.requested_at) {
const diffMs = now - new Date(latest.requested_at).getTime();
const cooldownMs =
Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000;
if (diffMs < cooldownMs) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "too_frequent" },
});
}
}
const created = await tx.invoiceVerificationRecord.create({
data: {
invoice_id: invoiceId,
provider,
status: "pending",
request_payload: {
invoiceCode: invoice.invoice_code ?? null,
invoiceNumber: invoice.invoice_number ?? null,
totalAmount: invoice.total_amount?.toString?.() ?? null,
},
requested_by: request.employee.id,
requested_at: new Date(now),
attempts: 1,
},
select: {
id: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_VERIFICATION_REQUESTED",
targetType: "invoice_pool",
targetId: invoiceId,
extra: {
verificationId: created.id,
provider,
},
}),
});
return {
verificationId: created.id,
provider,
};
});
const trigger = await triggerVerificationJob(fastify, response.verificationId);
if (trigger.mode === "inline") {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_VERIFICATION_INLINE_EXECUTED",
targetType: "invoice_pool",
targetId: invoiceId,
extra: {
verificationId: response.verificationId,
status: trigger.result?.status ?? null,
},
}),
});
}
return sendData(reply, request, {
invoiceId,
verificationId: response.verificationId,
provider: response.provider,
triggerMode: trigger.mode,
});
},
);
fastify.get(
"/invoice-pool/:id/verification",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
const query = parseVerificationListQuery(request.query);
const invoiceId = parsedInvoiceId.data;
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
const canVerify = fastify.hasPermission(request.employee, "invoice.verify");
if (!canManage && !canVerify) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
linked_reimbursement: {
select: {
owner_id: true,
submitter_id: true,
},
},
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const readable =
canManage ||
canVerify ||
invoice.owner_employee_id === request.employee.id ||
invoice.uploaded_by_employee_id === request.employee.id ||
invoice.linked_reimbursement?.owner_id === request.employee.id ||
invoice.linked_reimbursement?.submitter_id === request.employee.id;
if (!readable) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const items = await fastify.prisma.invoiceVerificationRecord.findMany({
where: { invoice_id: invoiceId },
orderBy: { created_at: "desc" },
take: query.limit,
});
return sendData(reply, request, {
invoiceId,
items: items.map(mapVerificationRecord),
});
},
);
fastify.post(
"/invoice-pool/:id/verification/retry",
{
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({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
const invoiceId = parsedInvoiceId.data;
const provider = getProvider(fastify.config);
const created = await fastify.prisma.$transaction(async (tx) => {
const latest = await tx.invoiceVerificationRecord.findFirst({
where: { invoice_id: invoiceId },
orderBy: { created_at: "desc" },
select: {
id: true,
status: true,
attempts: true,
requested_at: true,
},
});
if (!latest) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "not_found" },
});
}
if (latest.status === "pending") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "already_pending" },
});
}
const cooldownMs =
Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000;
const diffMs = Date.now() - new Date(latest.requested_at).getTime();
if (diffMs < cooldownMs) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "too_frequent" },
});
}
const attempts = Number(latest.attempts ?? 1) + 1;
if (attempts > Number(fastify.config.verificationMaxAttempts ?? 3)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "attempts_exceeded" },
});
}
const createdRecord = await tx.invoiceVerificationRecord.create({
data: {
invoice_id: invoiceId,
provider,
status: "pending",
retry_of_id: latest.id,
attempts,
requested_by: request.employee.id,
requested_at: new Date(),
request_payload: {
retryOfId: latest.id,
},
},
select: {
id: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_VERIFICATION_RETRIED",
targetType: "invoice_pool",
targetId: invoiceId,
extra: {
verificationId: createdRecord.id,
retryOfId: latest.id,
attempts,
},
}),
});
return createdRecord;
});
await triggerVerificationJob(fastify, created.id);
return sendData(reply, request, {
invoiceId,
verificationId: created.id,
status: "pending",
});
},
);
}
module.exports = {
registerInvoiceVerificationRoutes,
};
+114 -1
查看文件
@@ -2,6 +2,8 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { sendInstantMessageByChannel } = require("../services/notifications/channels");
const LIST_QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
@@ -58,6 +60,19 @@ function mapNotificationItem(record) {
};
}
function mapNotificationChannel(record) {
return {
id: record.id,
channelType: record.channel_type,
name: record.name,
enabled: record.enabled,
configJson: record.config_json,
templateJson: record.template_json,
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
async function notificationsRoutes(fastify) {
fastify.get(
"/notifications",
@@ -189,7 +204,105 @@ async function notificationsRoutes(fastify) {
return sendData(reply, request, mapNotificationItem(notification));
},
);
fastify.get(
"/notifications/channels",
{
preHandler: fastify.requirePermission("notification.channel.manage"),
},
async (request, reply) => {
const items = await fastify.prisma.notificationChannel.findMany({
orderBy: [{ channel_type: "asc" }, { name: "asc" }],
});
return sendData(reply, request, {
items: items.map(mapNotificationChannel),
});
},
);
fastify.post(
"/notifications/channels/test",
{
preHandler: fastify.requirePermission("notification.channel.manage"),
},
async (request, reply) => {
const schema = z
.object({
channelType: z.string().trim().min(1).max(32),
name: z.string().trim().min(1).max(100),
})
.strict();
const parsed = schema.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const input = parsed.data;
const channel = await fastify.prisma.notificationChannel.upsert({
where: {
channel_type_name: {
channel_type: input.channelType,
name: input.name,
},
},
update: {
enabled: true,
config_json: {
mode: "mock",
},
updated_by: request.employee.id,
},
create: {
channel_type: input.channelType,
name: input.name,
enabled: true,
config_json: {
mode: "mock",
},
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
const result = await sendInstantMessageByChannel({
config: fastify.config,
channel,
payload: {
title: "通知渠道测试",
body: `测试人:${request.employee.id}`,
targetType: "notification_channel",
targetId: channel.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "NOTIFICATION_CHANNEL_TESTED",
targetType: "notification_channel",
targetId: channel.id,
extra: {
channelType: channel.channel_type,
name: channel.name,
resultCode: result.code,
},
}),
});
return sendData(reply, request, {
channel: mapNotificationChannel(channel),
testResult: result,
});
},
);
}
module.exports = notificationsRoutes;
+144
查看文件
@@ -9,6 +9,8 @@ const {
resolveRecipientsForAssignee,
} = require("../services/notifications");
const { sendNotificationEmailSafe } = require("../services/email");
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
const { buildErpPayload } = require("../services/integrations/erp");
const {
buildWorkflowStepsFromApprovalRule,
resolveActionableStep,
@@ -346,6 +348,50 @@ async function loadApprovalRuleForAmount(tx, totalAmount) {
return approvalRule;
}
async function resolveApplicableBudgetPool(tx, reimbursement) {
return tx.budgetPool.findFirst({
where: {
status: "active",
OR: [{ department_id: reimbursement.department_id }, { department_id: null }],
period_start: { lte: reimbursement.reimbursement_date },
period_end: { gte: reimbursement.reimbursement_date },
},
orderBy: [{ department_id: "desc" }, { created_at: "asc" }],
});
}
function normalizeBudgetControlMode(config) {
const mode = String(config.budgetControlMode ?? "warn").toLowerCase();
if (mode === "block") {
return "block";
}
return "warn";
}
function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) {
const allocatedAmount = Number(budgetPool.allocated_amount ?? 0);
const usedAmount = Number(budgetPool.used_amount ?? 0);
const currentAmount = Number(reimbursementAmount ?? 0);
const nextUsed = usedAmount + currentAmount;
const nextUsagePercent = allocatedAmount > 0 ? (nextUsed / allocatedAmount) * 100 : 0;
return {
budgetId: budgetPool.id,
budgetCode: budgetPool.code,
budgetName: budgetPool.name,
controlMode,
warningThresholdPercent: budgetPool.warning_threshold_percent,
allocatedAmount: allocatedAmount.toFixed(2),
usedAmount: usedAmount.toFixed(2),
remainingAmount: Math.max(0, allocatedAmount - usedAmount).toFixed(2),
currentAmount: currentAmount.toFixed(2),
nextUsedAmount: nextUsed.toFixed(2),
nextUsagePercent: Number(nextUsagePercent.toFixed(2)),
exceedsThreshold: nextUsagePercent >= budgetPool.warning_threshold_percent,
exceedsBudget: nextUsed > allocatedAmount,
};
}
async function reimbursementRoutes(fastify) {
const createBodySchema = z.object({
ownerEmployeeId: z.string().uuid().optional(),
@@ -638,6 +684,12 @@ async function reimbursementRoutes(fastify) {
ensureOwnOrCanViewAll(fastify, request, reimbursement);
const controlMode = normalizeBudgetControlMode(fastify.config);
const budgetPool = await resolveApplicableBudgetPool(fastify.prisma, reimbursement);
const budgetHint = budgetPool
? buildBudgetHint(budgetPool, reimbursement.total_amount, controlMode)
: null;
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
@@ -669,6 +721,7 @@ async function reimbursementRoutes(fastify) {
}
: null,
})),
budgetHint,
invoices: reimbursement.invoice_pool_items.map((item) => ({
id: item.id,
status: item.status,
@@ -1142,6 +1195,7 @@ async function reimbursementRoutes(fastify) {
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
let submitResult = null;
let budgetCheck = null;
const emailTasks = [];
await fastify.prisma.$transaction(async (tx) => {
@@ -1206,6 +1260,71 @@ async function reimbursementRoutes(fastify) {
});
}
const budgetPool = await resolveApplicableBudgetPool(tx, reimbursement);
if (budgetPool) {
const allocatedAmount = Number(budgetPool.allocated_amount ?? 0);
const usedAmount = Number(budgetPool.used_amount ?? 0);
const currentAmount = Number(reimbursement.total_amount ?? 0);
const nextUsed = usedAmount + currentAmount;
const usagePercent = allocatedAmount > 0 ? (nextUsed / allocatedAmount) * 100 : 0;
const controlMode = normalizeBudgetControlMode(fastify.config);
budgetCheck = {
budgetId: budgetPool.id,
budgetCode: budgetPool.code,
controlMode,
usagePercent: Number(usagePercent.toFixed(2)),
exceededThreshold: usagePercent >= budgetPool.warning_threshold_percent,
exceededBudget: nextUsed > allocatedAmount,
};
if (usagePercent >= budgetPool.warning_threshold_percent) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_THRESHOLD_HIT",
targetType: "budget_pool",
targetId: budgetPool.id,
extra: {
reimbursementId,
formNumber: reimbursement.form_number ?? null,
usagePercent: Number(usagePercent.toFixed(2)),
threshold: budgetPool.warning_threshold_percent,
controlMode,
},
}),
});
}
if (controlMode === "block" && nextUsed > allocatedAmount) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { budget: "exceeded_block_mode" },
});
}
await tx.budgetLedgerEntry.create({
data: {
budget_pool_id: budgetPool.id,
reimbursement_id: reimbursementId,
kind: controlMode === "block" && nextUsed > allocatedAmount ? "blocked" : "submit",
amount: reimbursement.total_amount,
note: `submit:${reimbursement.form_number ?? reimbursementId}`,
created_by: request.employee.id,
},
});
await tx.budgetPool.update({
where: { id: budgetPool.id },
data: {
used_amount: nextUsed.toFixed(2),
updated_by: request.employee.id,
},
});
}
const workflowDefinition = await tx.workflowDefinition.findFirst({
where: {
entity_type: "reimbursement",
@@ -1405,6 +1524,7 @@ async function reimbursementRoutes(fastify) {
workflowInstanceId: workflowInstance.id,
currentTaskId: workflowTask.id,
status: "reviewing",
budget: budgetCheck,
};
});
@@ -1645,6 +1765,30 @@ async function reimbursementRoutes(fastify) {
},
});
const refreshed = await tx.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
form_number: true,
owner_id: true,
submitter_id: true,
total_amount: true,
payment_voucher_no: true,
payment_date: true,
paid_at: true,
},
});
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,
});
}
try {
const recipients = Array.from(
new Set([reimbursement.owner_id, reimbursement.submitter_id].filter(Boolean)),
+152
查看文件
@@ -0,0 +1,152 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { normalizeTravelImportRows } = require("../services/integrations/travel");
const IMPORT_SCHEMA = z.object({
orders: z.array(z.record(z.string(), z.unknown())).min(1).max(100),
});
function mapTravelOrder(item) {
return {
id: item.id,
source: item.source,
externalOrderNo: item.external_order_no,
employeeId: item.employee_id,
departmentId: item.department_id ?? null,
linkedReimbursementId: item.linked_reimbursement_id ?? null,
occurredAt: item.occurred_at?.toISOString?.()?.slice(0, 10) ?? null,
amount: item.amount?.toString?.() ?? null,
currency: item.currency,
status: item.status,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
async function travelOrdersRoutes(fastify) {
fastify.post(
"/travel/orders/import",
{
preHandler: fastify.requirePermission("integration.manage"),
},
async (request, reply) => {
if (!fastify.config.travelSyncEnabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { integration: "travel_sync_disabled" },
});
}
const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const rows = normalizeTravelImportRows(parsed.data.orders);
if (!rows.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { orders: "empty_after_normalization" },
});
}
const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId)));
const employees = await fastify.prisma.employee.findMany({
where: {
id: { in: employeeIds },
status: "active",
},
select: {
id: true,
department_id: true,
},
});
const employeeMap = new Map(employees.map((item) => [item.id, item]));
const created = [];
const skipped = [];
for (const row of rows) {
const employee = employeeMap.get(row.employeeId);
if (!employee) {
skipped.push({
source: row.source,
externalOrderNo: row.externalOrderNo,
reason: "employee_not_found",
});
continue;
}
const upserted = await fastify.prisma.travelOrder.upsert({
where: {
source_external_order_no: {
source: row.source,
external_order_no: row.externalOrderNo,
},
},
update: {
employee_id: row.employeeId,
department_id: employee.department_id,
linked_reimbursement_id: row.linkedReimbursementId,
occurred_at: row.occurredAt,
amount: row.amount,
currency: row.currency,
status: "imported",
raw_payload: row.rawPayload,
updated_by: request.employee.id,
},
create: {
source: row.source,
external_order_no: row.externalOrderNo,
employee_id: row.employeeId,
department_id: employee.department_id,
linked_reimbursement_id: row.linkedReimbursementId,
occurred_at: row.occurredAt,
amount: row.amount,
currency: row.currency,
status: "imported",
raw_payload: row.rawPayload,
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
created.push(upserted);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_ORDERS_IMPORTED",
targetType: "travel_order",
extra: {
requested: rows.length,
createdOrUpdated: created.length,
skipped,
},
}),
});
return sendData(reply, request, {
requested: rows.length,
imported: created.length,
skipped,
items: created.map(mapTravelOrder),
});
},
);
}
module.exports = travelOrdersRoutes;
@@ -0,0 +1,23 @@
require("dotenv/config");
const { createPrismaClient } = require("../prismaClient");
const { getConfig } = require("../config");
const { runIntegrationOutboxOnce } = require("../jobs/integrationOutboxJob");
(async () => {
const config = getConfig();
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
const result = await runIntegrationOutboxOnce(prisma, config, console);
process.stdout.write(
`total=${result.total} succeeded=${result.succeeded} failed=${result.failed}\n`,
);
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,26 @@
require("dotenv/config");
const { createPrismaClient } = require("../prismaClient");
const { getConfig } = require("../config");
const { runVerificationForRecord } = require("../jobs/verificationJob");
(async () => {
const recordId = process.env.VERIFICATION_RECORD_ID;
if (!recordId) {
throw new Error("VERIFICATION_RECORD_ID is required");
}
const config = getConfig();
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
const result = await runVerificationForRecord(prisma, config, recordId);
process.stdout.write(`${JSON.stringify(result)}\n`);
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
+125 -5
查看文件
@@ -1,5 +1,7 @@
const nodemailer = require("nodemailer");
const { sendInstantMessageByChannel } = require("../notifications/channels");
function resolveEmailBaseUrl(config) {
const explicit = String(config.emailBaseUrl ?? "").trim();
if (explicit) {
@@ -51,9 +53,21 @@ function getTransporter(config) {
return cachedTransporter;
}
function uniq(values) {
return Array.from(new Set((values ?? []).filter(Boolean)));
}
async function sendNotificationEmailSafe(params) {
try {
return await sendNotificationEmail(params);
const [emailResult, imResult] = await Promise.all([
sendNotificationEmail(params),
sendNotificationIm(params),
]);
return {
email: emailResult,
im: imResult,
};
} catch (error) {
params.logger?.error?.(
{
@@ -62,9 +76,12 @@ async function sendNotificationEmailSafe(params) {
notificationType: params.type,
recipients: params.recipientEmployeeIds?.length ?? 0,
},
"Failed to send notification email",
"Failed to send notification",
);
return { skipped: false, sent: 0, failed: params.recipientEmployeeIds?.length ?? 0 };
return {
email: { skipped: false, sent: 0, failed: params.recipientEmployeeIds?.length ?? 0 },
im: { skipped: false, sent: 0, failed: params.recipientEmployeeIds?.length ?? 0 },
};
}
}
@@ -89,7 +106,7 @@ async function sendNotificationEmail({
return { skipped: true, sent: 0, failed: 0 };
}
const recipients = Array.from(new Set((recipientEmployeeIds ?? []).filter(Boolean)));
const recipients = uniq(recipientEmployeeIds);
if (!recipients.length) {
return { skipped: true, sent: 0, failed: 0 };
}
@@ -158,8 +175,111 @@ async function sendNotificationEmail({
return { skipped: false, sent, failed };
}
async function sendNotificationIm({
prisma,
config,
logger,
requestId,
recipientEmployeeIds,
subject,
body,
type = "notification",
targetPath,
}) {
if (!config.imNotifierEnabled) {
return { skipped: true, sent: 0, failed: 0 };
}
const notificationType = String(type ?? "notification");
const allowedTypePrefixes = ["reimbursement."];
const allowedTypes = new Set(["workflow.task_assigned"]);
const allowed =
allowedTypes.has(notificationType) ||
allowedTypePrefixes.some((prefix) => notificationType.startsWith(prefix));
if (!allowed) {
return { skipped: true, sent: 0, failed: 0 };
}
const recipients = uniq(recipientEmployeeIds);
if (!recipients.length) {
return { skipped: true, sent: 0, failed: 0 };
}
const channels = await prisma.notificationChannel.findMany({
where: {
enabled: true,
channel_type: {
in: ["dingtalk", "wechat", "wechat_work", "wecom"],
},
},
select: {
id: true,
channel_type: true,
name: true,
enabled: true,
config_json: true,
template_json: true,
},
});
if (!channels.length) {
return { skipped: true, sent: 0, failed: 0 };
}
const safeSubject = String(subject ?? "").trim().slice(0, 200) || "系统通知";
const safeBody = String(body ?? "").trim();
let sent = 0;
let failed = 0;
for (const channel of channels) {
try {
const result = await sendInstantMessageByChannel({
config,
channel,
payload: {
title: safeSubject,
body: safeBody,
targetType: type,
targetId: targetPath ?? null,
recipientCount: recipients.length,
},
});
if (result.ok) {
sent += 1;
} else {
failed += 1;
logger?.warn?.(
{
requestId,
channelId: channel.id,
channelType: channel.channel_type,
resultCode: result.code,
},
"IM notification skipped",
);
}
} catch (error) {
failed += 1;
logger?.error?.(
{
err: error,
requestId,
channelId: channel.id,
channelType: channel.channel_type,
},
"IM notification send failed",
);
}
}
return { skipped: false, sent, failed };
}
module.exports = {
sendNotificationEmailSafe,
sendNotificationEmail,
};
@@ -0,0 +1,40 @@
function normalizeTravelImportRows(rows) {
if (!Array.isArray(rows)) {
return [];
}
return rows
.map((item) => {
const source = String(item?.source ?? "manual").trim().toLowerCase();
const externalOrderNo = String(item?.externalOrderNo ?? "").trim();
const employeeId = String(item?.employeeId ?? "").trim();
if (!externalOrderNo || !employeeId) {
return null;
}
const amountRaw = item?.amount;
const amount = amountRaw == null || amountRaw === "" ? null : Number(amountRaw);
const occurredAt = item?.occurredAt
? new Date(`${String(item.occurredAt).slice(0, 10)}T00:00:00.000Z`)
: null;
return {
source,
externalOrderNo,
employeeId,
linkedReimbursementId:
item?.linkedReimbursementId && String(item.linkedReimbursementId).trim()
? String(item.linkedReimbursementId).trim()
: null,
amount: Number.isFinite(amount) ? amount.toFixed(2) : null,
currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY",
occurredAt,
rawPayload: item ?? null,
};
})
.filter(Boolean);
}
module.exports = {
normalizeTravelImportRows,
};
@@ -0,0 +1,75 @@
const { dispatchOutboxEvent } = require("../integrations/dispatcher");
function applyTemplate(template, context) {
if (typeof template !== "string") {
return "";
}
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => {
const value = context[key];
return value == null ? "" : String(value);
});
}
function buildNotificationMessage(channel, payload) {
const context = {
title: String(payload?.title ?? "系统通知").slice(0, 200),
body: String(payload?.body ?? "").slice(0, 2000),
targetType: payload?.targetType ?? "",
targetId: payload?.targetId ?? "",
};
const template =
channel?.template_json && typeof channel.template_json === "object"
? channel.template_json
: null;
const templatedTitle = template?.title ? applyTemplate(template.title, context) : "";
const templatedBody = template?.body ? applyTemplate(template.body, context) : "";
return {
title: (templatedTitle || context.title).slice(0, 200),
body: (templatedBody || context.body).slice(0, 2000),
targetType: context.targetType || null,
targetId: context.targetId || null,
};
}
async function sendInstantMessageByChannel({ config, channel, payload }) {
if (!config.imNotifierEnabled) {
return {
ok: false,
code: "im_notifier_disabled",
message: "IM notifier disabled",
};
}
if (!channel?.enabled) {
return {
ok: false,
code: "channel_disabled",
message: "notification channel disabled",
};
}
const message = buildNotificationMessage(channel, payload);
const result = await dispatchOutboxEvent({
config,
eventType: "notification.im",
payload: {
channelType: channel.channel_type,
channelName: channel.name,
message,
},
});
return {
ok: true,
code: result.code,
response: result.response,
};
}
module.exports = {
sendInstantMessageByChannel,
};
+37
查看文件
@@ -0,0 +1,37 @@
const { runMockVerification } = require("./mock");
const { runTaxVerification } = require("./tax");
function getProvider(config) {
return String(config.verificationProvider ?? "mock").trim().toLowerCase();
}
function isVerificationEnabled(config) {
return Boolean(config.invoiceVerificationEnabled);
}
async function verifyInvoiceByProvider({ config, invoice }) {
const provider = getProvider(config);
if (provider === "mock") {
return runMockVerification({ invoice });
}
if (provider === "tax") {
return runTaxVerification({ config, invoice });
}
return {
status: "error",
resultCode: "provider_unsupported",
resultMessage: `Unsupported verification provider: ${provider}`,
payload: {
provider,
},
};
}
module.exports = {
getProvider,
isVerificationEnabled,
verifyInvoiceByProvider,
};
+59
查看文件
@@ -0,0 +1,59 @@
function normalizeAmount(value) {
if (value == null) {
return null;
}
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) {
return null;
}
return numberValue.toFixed(2);
}
async function runMockVerification({ invoice }) {
const remarks = String(invoice.remarks ?? "").toLowerCase();
const invoiceCode = String(invoice.invoice_code ?? "").toUpperCase();
const invoiceNumber = String(invoice.invoice_number ?? "").toUpperCase();
if (remarks.includes("verify-error") || invoiceNumber.includes("ERR")) {
return {
status: "error",
resultCode: "mock_transport_error",
resultMessage: "mock provider transport error",
payload: {
provider: "mock",
marker: "verify-error",
},
};
}
if (remarks.includes("verify-fail") || invoiceCode.includes("FAIL") || invoiceNumber.includes("FAIL")) {
return {
status: "failed",
resultCode: "invoice_mismatch",
resultMessage: "invoice metadata mismatch",
payload: {
provider: "mock",
matched: false,
},
};
}
return {
status: "passed",
resultCode: "verified",
resultMessage: "invoice verified",
payload: {
provider: "mock",
matched: true,
invoiceCode: invoice.invoice_code ?? null,
invoiceNumber: invoice.invoice_number ?? null,
totalAmount: normalizeAmount(invoice.total_amount),
},
};
}
module.exports = {
runMockVerification,
};
+27
查看文件
@@ -0,0 +1,27 @@
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",
resultMessage: "tax provider is not configured in current environment",
payload: {
provider: "tax",
invoiceCode: invoice.invoice_code ?? null,
invoiceNumber: invoice.invoice_number ?? null,
},
};
}
module.exports = {
runTaxVerification,
};