From 6bc09f557bb573332b5da3daf30315b46a041354 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 10 Feb 2026 11:28:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90P6-P8=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E8=83=BD=E5=8A=9B=E4=B8=8EP9=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=88V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/package.json | 4 +- .../migration.sql | 163 ++++++++ server/prisma/schema.prisma | 151 +++++++ server/scripts/regression-all.sh | 42 +- server/scripts/regression-budget.sh | 168 ++++++++ server/scripts/regression-dashboard.sh | 146 +++++++ server/scripts/regression-verification.sh | 186 +++++++++ server/src/app.js | 8 + server/src/constants/permissions.js | 30 +- server/src/jobs/integrationOutboxWorker.js | 41 ++ server/src/jobs/jobSystem.js | 3 +- server/src/jobs/pgBossJobs.js | 117 ++++++ server/src/jobs/verificationJob.js | 96 +++++ server/src/routes/budgets.js | 280 +++++++++++++ server/src/routes/dashboard.js | 278 +++++++++++++ server/src/routes/exports.js | 172 +++++++- server/src/routes/integrations.js | 106 +++++ .../src/routes/invoicePool/shared/helpers.js | 20 + .../src/routes/invoicePool/shared/mappers.js | 116 ++++++ .../src/routes/invoicePool/shared/schemas.js | 27 ++ .../src/routes/invoicePool/shared/selects.js | 114 ++++++ server/src/routes/invoicePool/verification.js | 384 ++++++++++++++++++ server/src/routes/notifications.js | 115 +++++- server/src/routes/reimbursements.js | 144 +++++++ server/src/routes/travelOrders.js | 152 +++++++ .../scripts/run-integration-outbox-once.js | 23 ++ server/src/scripts/run-verification-once.js | 26 ++ server/src/services/email/index.js | 130 +++++- server/src/services/integrations/travel.js | 40 ++ server/src/services/notifications/channels.js | 75 ++++ server/src/services/verification/index.js | 37 ++ server/src/services/verification/mock.js | 59 +++ server/src/services/verification/tax.js | 27 ++ 33 files changed, 3467 insertions(+), 13 deletions(-) create mode 100644 server/prisma/migrations/20260210003500_p6_p8_remaining_work/migration.sql create mode 100755 server/scripts/regression-budget.sh create mode 100755 server/scripts/regression-dashboard.sh create mode 100755 server/scripts/regression-verification.sh create mode 100644 server/src/jobs/integrationOutboxWorker.js create mode 100644 server/src/jobs/verificationJob.js create mode 100644 server/src/routes/budgets.js create mode 100644 server/src/routes/dashboard.js create mode 100644 server/src/routes/integrations.js create mode 100644 server/src/routes/invoicePool/shared/helpers.js create mode 100644 server/src/routes/invoicePool/shared/mappers.js create mode 100644 server/src/routes/invoicePool/shared/schemas.js create mode 100644 server/src/routes/invoicePool/shared/selects.js create mode 100644 server/src/routes/invoicePool/verification.js create mode 100644 server/src/routes/travelOrders.js create mode 100644 server/src/scripts/run-integration-outbox-once.js create mode 100644 server/src/scripts/run-verification-once.js create mode 100644 server/src/services/integrations/travel.js create mode 100644 server/src/services/notifications/channels.js create mode 100644 server/src/services/verification/index.js create mode 100644 server/src/services/verification/mock.js create mode 100644 server/src/services/verification/tax.js diff --git a/server/package.json b/server/package.json index 7adbdceb..e426b069 100644 --- a/server/package.json +++ b/server/package.json @@ -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": "", diff --git a/server/prisma/migrations/20260210003500_p6_p8_remaining_work/migration.sql b/server/prisma/migrations/20260210003500_p6_p8_remaining_work/migration.sql new file mode 100644 index 00000000..15dfa22e --- /dev/null +++ b/server/prisma/migrations/20260210003500_p6_p8_remaining_work/migration.sql @@ -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; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b9ac7d4f..41832379 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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 diff --git a/server/scripts/regression-all.sh b/server/scripts/regression-all.sh index 92e7e069..b88a78af 100755 --- a/server/scripts/regression-all.sh +++ b/server/scripts/regression-all.sh @@ -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" diff --git a/server/scripts/regression-budget.sh b/server/scripts/regression-budget.sh new file mode 100755 index 00000000..710b22dc --- /dev/null +++ b/server/scripts/regression-budget.sh @@ -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 diff --git a/server/scripts/regression-dashboard.sh b/server/scripts/regression-dashboard.sh new file mode 100755 index 00000000..81be40ef --- /dev/null +++ b/server/scripts/regression-dashboard.sh @@ -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" diff --git a/server/scripts/regression-verification.sh b/server/scripts/regression-verification.sh new file mode 100755 index 00000000..99533b5e --- /dev/null +++ b/server/scripts/regression-verification.sh @@ -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: 'finance_mgr@hyinda.com' }, + 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" diff --git a/server/src/app.js b/server/src/app.js index f4fcf32a..286bceba 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -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); diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index 042bf1f7..db61dda7 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -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: ["*"], }; diff --git a/server/src/jobs/integrationOutboxWorker.js b/server/src/jobs/integrationOutboxWorker.js new file mode 100644 index 00000000..8e5f2dcf --- /dev/null +++ b/server/src/jobs/integrationOutboxWorker.js @@ -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, +}; diff --git a/server/src/jobs/jobSystem.js b/server/src/jobs/jobSystem.js index c14e6e83..a1e8226e 100644 --- a/server/src/jobs/jobSystem.js +++ b/server/src/jobs/jobSystem.js @@ -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, }; - diff --git a/server/src/jobs/pgBossJobs.js b/server/src/jobs/pgBossJobs.js index 6d6a1df1..e459090a 100644 --- a/server/src/jobs/pgBossJobs.js +++ b/server/src/jobs/pgBossJobs.js @@ -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, }; diff --git a/server/src/jobs/verificationJob.js b/server/src/jobs/verificationJob.js new file mode 100644 index 00000000..1796a81e --- /dev/null +++ b/server/src/jobs/verificationJob.js @@ -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, +}; diff --git a/server/src/routes/budgets.js b/server/src/routes/budgets.js new file mode 100644 index 00000000..5c8af644 --- /dev/null +++ b/server/src/routes/budgets.js @@ -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; diff --git a/server/src/routes/dashboard.js b/server/src/routes/dashboard.js new file mode 100644 index 00000000..57b1ac54 --- /dev/null +++ b/server/src/routes/dashboard.js @@ -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; diff --git a/server/src/routes/exports.js b/server/src/routes/exports.js index 3cf5bf56..eb7fb58f 100644 --- a/server/src/routes/exports.js +++ b/server/src/routes/exports.js @@ -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; - diff --git a/server/src/routes/integrations.js b/server/src/routes/integrations.js new file mode 100644 index 00000000..10dadede --- /dev/null +++ b/server/src/routes/integrations.js @@ -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; diff --git a/server/src/routes/invoicePool/shared/helpers.js b/server/src/routes/invoicePool/shared/helpers.js new file mode 100644 index 00000000..6e9b3a19 --- /dev/null +++ b/server/src/routes/invoicePool/shared/helpers.js @@ -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, +}; diff --git a/server/src/routes/invoicePool/shared/mappers.js b/server/src/routes/invoicePool/shared/mappers.js new file mode 100644 index 00000000..df848b8f --- /dev/null +++ b/server/src/routes/invoicePool/shared/mappers.js @@ -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, +}; diff --git a/server/src/routes/invoicePool/shared/schemas.js b/server/src/routes/invoicePool/shared/schemas.js new file mode 100644 index 00000000..06586d43 --- /dev/null +++ b/server/src/routes/invoicePool/shared/schemas.js @@ -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, +}; diff --git a/server/src/routes/invoicePool/shared/selects.js b/server/src/routes/invoicePool/shared/selects.js new file mode 100644 index 00000000..e10aee90 --- /dev/null +++ b/server/src/routes/invoicePool/shared/selects.js @@ -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, +}; diff --git a/server/src/routes/invoicePool/verification.js b/server/src/routes/invoicePool/verification.js new file mode 100644 index 00000000..09083e50 --- /dev/null +++ b/server/src/routes/invoicePool/verification.js @@ -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, +}; diff --git a/server/src/routes/notifications.js b/server/src/routes/notifications.js index 5ad05b9a..5a95dca7 100644 --- a/server/src/routes/notifications.js +++ b/server/src/routes/notifications.js @@ -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; - diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index d4d1c628..b73676d6 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -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)), diff --git a/server/src/routes/travelOrders.js b/server/src/routes/travelOrders.js new file mode 100644 index 00000000..6efa906c --- /dev/null +++ b/server/src/routes/travelOrders.js @@ -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; diff --git a/server/src/scripts/run-integration-outbox-once.js b/server/src/scripts/run-integration-outbox-once.js new file mode 100644 index 00000000..b5e94f34 --- /dev/null +++ b/server/src/scripts/run-integration-outbox-once.js @@ -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); +}); diff --git a/server/src/scripts/run-verification-once.js b/server/src/scripts/run-verification-once.js new file mode 100644 index 00000000..a789ad21 --- /dev/null +++ b/server/src/scripts/run-verification-once.js @@ -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); +}); diff --git a/server/src/services/email/index.js b/server/src/services/email/index.js index c1cf8fe0..ed1ff586 100644 --- a/server/src/services/email/index.js +++ b/server/src/services/email/index.js @@ -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, }; - diff --git a/server/src/services/integrations/travel.js b/server/src/services/integrations/travel.js new file mode 100644 index 00000000..30b4513f --- /dev/null +++ b/server/src/services/integrations/travel.js @@ -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, +}; diff --git a/server/src/services/notifications/channels.js b/server/src/services/notifications/channels.js new file mode 100644 index 00000000..4ac5d967 --- /dev/null +++ b/server/src/services/notifications/channels.js @@ -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, +}; diff --git a/server/src/services/verification/index.js b/server/src/services/verification/index.js new file mode 100644 index 00000000..66e09741 --- /dev/null +++ b/server/src/services/verification/index.js @@ -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, +}; diff --git a/server/src/services/verification/mock.js b/server/src/services/verification/mock.js new file mode 100644 index 00000000..aed9cbb4 --- /dev/null +++ b/server/src/services/verification/mock.js @@ -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, +}; diff --git a/server/src/services/verification/tax.js b/server/src/services/verification/tax.js new file mode 100644 index 00000000..965ddac1 --- /dev/null +++ b/server/src/services/verification/tax.js @@ -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, +};