feat: 完成P6-P8后端能力与P9路由重构(V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3)
这个提交包含在:
+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;
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户