diff --git a/server/prisma/migrations/20260310220559_p20_travel_2_corporate_payments/migration.sql b/server/prisma/migrations/20260310220559_p20_travel_2_corporate_payments/migration.sql new file mode 100644 index 00000000..c35ae66b --- /dev/null +++ b/server/prisma/migrations/20260310220559_p20_travel_2_corporate_payments/migration.sql @@ -0,0 +1,152 @@ +-- AlterTable +ALTER TABLE "invoice_pool" ADD COLUMN "travel_order_id" UUID; + +-- AlterTable +ALTER TABLE "travel_orders" ADD COLUMN "paid_at" TIMESTAMP(3), +ADD COLUMN "paid_by" UUID, +ADD COLUMN "payment_channel" VARCHAR(20), +ADD COLUMN "payment_date" DATE, +ADD COLUMN "payment_voucher_no" VARCHAR(50); + +-- CreateTable +CREATE TABLE "card_authorizations" ( + "id" UUID NOT NULL, + "source" VARCHAR(32) NOT NULL DEFAULT 'manual', + "external_auth_no" TEXT NOT NULL, + "employee_id" UUID NOT NULL, + "department_id" UUID, + "travel_request_id" UUID NOT NULL, + "budget_pool_id" UUID, + "legal_entity_id" UUID NOT NULL, + "cost_center_id" UUID NOT NULL, + "authorized_amount" DECIMAL(14,2) NOT NULL, + "settled_amount" DECIMAL(14,2) NOT NULL DEFAULT 0, + "reversed_amount" DECIMAL(14,2) NOT NULL DEFAULT 0, + "currency" VARCHAR(8) NOT NULL DEFAULT 'CNY', + "occurred_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3), + "status" VARCHAR(32) NOT NULL DEFAULT 'authorized', + "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 "card_authorizations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "card_transactions" ( + "id" UUID NOT NULL, + "source" VARCHAR(32) NOT NULL DEFAULT 'manual', + "external_txn_id" TEXT NOT NULL, + "card_authorization_id" UUID, + "employee_id" UUID NOT NULL, + "department_id" UUID, + "travel_request_id" UUID NOT NULL, + "travel_order_id" UUID, + "amount" DECIMAL(14,2) NOT NULL, + "currency" VARCHAR(8) NOT NULL DEFAULT 'CNY', + "txn_type" VARCHAR(20) NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'pending', + "occurred_at" TIMESTAMP(3), + "freeze_suggested_at" TIMESTAMP(3), + "freeze_suggested_by" UUID, + "freeze_reason_code" VARCHAR(32), + "freeze_reason_note" TEXT, + "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 "card_transactions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "idx_card_authorizations_employee_occurred" ON "card_authorizations"("employee_id", "occurred_at"); + +-- CreateIndex +CREATE INDEX "idx_card_authorizations_department_status" ON "card_authorizations"("department_id", "status"); + +-- CreateIndex +CREATE INDEX "idx_card_authorizations_request_status" ON "card_authorizations"("travel_request_id", "status"); + +-- CreateIndex +CREATE INDEX "idx_card_authorizations_budget_pool" ON "card_authorizations"("budget_pool_id"); + +-- CreateIndex +CREATE INDEX "idx_card_authorizations_legal_cost_center" ON "card_authorizations"("legal_entity_id", "cost_center_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "card_authorizations_source_auth_ux" ON "card_authorizations"("source", "external_auth_no"); + +-- CreateIndex +CREATE INDEX "idx_card_transactions_authorization" ON "card_transactions"("card_authorization_id"); + +-- CreateIndex +CREATE INDEX "idx_card_transactions_employee_occurred" ON "card_transactions"("employee_id", "occurred_at"); + +-- CreateIndex +CREATE INDEX "idx_card_transactions_department_status" ON "card_transactions"("department_id", "status"); + +-- CreateIndex +CREATE INDEX "idx_card_transactions_request" ON "card_transactions"("travel_request_id"); + +-- CreateIndex +CREATE INDEX "idx_card_transactions_order" ON "card_transactions"("travel_order_id"); + +-- CreateIndex +CREATE INDEX "idx_card_transactions_freeze_suggested_by" ON "card_transactions"("freeze_suggested_by"); + +-- CreateIndex +CREATE UNIQUE INDEX "card_transactions_source_txn_ux" ON "card_transactions"("source", "external_txn_id"); + +-- CreateIndex +CREATE INDEX "invoice_pool_travel_order_id_idx" ON "invoice_pool"("travel_order_id"); + +-- CreateIndex +CREATE INDEX "travel_orders_paid_by_idx" ON "travel_orders"("paid_by"); + +-- AddForeignKey +ALTER TABLE "invoice_pool" ADD CONSTRAINT "invoice_pool_travel_order_id_fkey" FOREIGN KEY ("travel_order_id") REFERENCES "travel_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "travel_orders" ADD CONSTRAINT "travel_orders_paid_by_fkey" FOREIGN KEY ("paid_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "card_authorizations" ADD CONSTRAINT "card_authorizations_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "card_authorizations" ADD CONSTRAINT "card_authorizations_department_id_fkey" FOREIGN KEY ("department_id") REFERENCES "departments"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "card_authorizations" ADD CONSTRAINT "card_authorizations_travel_request_id_fkey" FOREIGN KEY ("travel_request_id") REFERENCES "travel_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "card_authorizations" ADD CONSTRAINT "card_authorizations_budget_pool_id_fkey" FOREIGN KEY ("budget_pool_id") REFERENCES "budget_pools"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "card_authorizations" ADD CONSTRAINT "card_authorizations_legal_entity_id_fkey" FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "card_authorizations" ADD CONSTRAINT "card_authorizations_cost_center_id_fkey" FOREIGN KEY ("cost_center_id") REFERENCES "cost_centers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "card_transactions" ADD CONSTRAINT "card_transactions_card_authorization_id_fkey" FOREIGN KEY ("card_authorization_id") REFERENCES "card_authorizations"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "card_transactions" ADD CONSTRAINT "card_transactions_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "card_transactions" ADD CONSTRAINT "card_transactions_department_id_fkey" FOREIGN KEY ("department_id") REFERENCES "departments"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "card_transactions" ADD CONSTRAINT "card_transactions_travel_request_id_fkey" FOREIGN KEY ("travel_request_id") REFERENCES "travel_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "card_transactions" ADD CONSTRAINT "card_transactions_travel_order_id_fkey" FOREIGN KEY ("travel_order_id") REFERENCES "travel_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "card_transactions" ADD CONSTRAINT "card_transactions_freeze_suggested_by_fkey" FOREIGN KEY ("freeze_suggested_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index f52c3b0d..20326487 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -230,6 +230,8 @@ model Department { workflow_instances WorkflowInstance[] budget_pools BudgetPool[] travel_orders TravelOrder[] + card_authorizations CardAuthorization[] + card_transactions CardTransaction[] @@index([parent_id]) @@map("departments") @@ -300,7 +302,11 @@ model Employee { uploaded_invoices InvoicePool[] @relation("InvoiceUploader") overridden_invoices InvoicePool[] @relation("InvoiceOverrideBy") requested_invoice_verifications InvoiceVerificationRecord[] @relation("InvoiceVerificationRequester") - travel_orders TravelOrder[] + travel_orders TravelOrder[] @relation("TravelOrderEmployee") + paid_travel_orders TravelOrder[] @relation("TravelOrderPaidBy") + card_authorizations CardAuthorization[] + card_transactions CardTransaction[] @relation("CardTransactionEmployee") + freeze_suggested_card_transactions CardTransaction[] @relation("CardTransactionFreezeSuggestedBy") workflow_instances_started WorkflowInstance[] @relation("WorkflowInstanceSubmitter") workflow_history_actions WorkflowHistory[] @relation("WorkflowHistoryActor") @@ -487,6 +493,7 @@ model LegalEntity { suppliers Supplier[] purchase_requests PurchaseRequest[] travel_requests TravelRequest[] + card_authorizations CardAuthorization[] asset_cards AssetCard[] budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity") @@ -517,6 +524,7 @@ model CostCenter { budget_pools BudgetPool[] @relation("BudgetPoolCostCenter") purchase_requests PurchaseRequest[] travel_requests TravelRequest[] + card_authorizations CardAuthorization[] asset_cards AssetCard[] @@unique([legal_entity_id, code], map: "legal_entity_cost_center_code_ux") @@ -1061,6 +1069,7 @@ model InvoicePool { linked_reimbursement_id String? @db.Uuid linked_item_id String? @db.Uuid + travel_order_id String? @db.Uuid remarks String? ocr_provider OcrProvider @default(tencent) @@ -1099,6 +1108,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) + travel_order TravelOrder? @relation(fields: [travel_order_id], references: [id], onDelete: SetNull) verification_records InvoiceVerificationRecord[] @@index([owner_employee_id]) @@ -1109,6 +1119,7 @@ model InvoicePool { @@index([status, ocr_locked_at, created_at], map: "idx_invoice_pool_ocr_queue") @@index([owner_employee_id, status, created_at], map: "idx_invoice_pool_owner_status_created_at") @@index([linked_reimbursement_id]) + @@index([travel_order_id]) @@map("invoice_pool") } @@ -1169,6 +1180,7 @@ model BudgetPool { ledger_entries BudgetLedgerEntry[] purchase_requests PurchaseRequest[] travel_requests TravelRequest[] + card_authorizations CardAuthorization[] @@index([department_id, status], map: "idx_budget_pools_department_status") @@index([legal_entity_id, cost_center_id, status], map: "idx_budget_pools_scope_status") @@ -1307,9 +1319,11 @@ model TravelRequest { cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict) budget_pool BudgetPool? @relation(fields: [budget_pool_id], references: [id], onDelete: SetNull) - segments TravelRequestSegment[] - travel_orders TravelOrder[] - workflow_instances WorkflowInstance[] + segments TravelRequestSegment[] + travel_orders TravelOrder[] + card_authorizations CardAuthorization[] + card_transactions CardTransaction[] + workflow_instances WorkflowInstance[] @@index([applicant_employee_id, start_date], map: "idx_travel_requests_applicant_start") @@index([submitter_id], map: "idx_travel_requests_submitter") @@ -1354,6 +1368,11 @@ model TravelOrder { amount Decimal? @db.Decimal(14, 2) currency String @default("CNY") @db.VarChar(8) status String @default("imported") @db.VarChar(20) + payment_voucher_no String? @db.VarChar(50) + payment_date DateTime? @db.Date + payment_channel String? @db.VarChar(20) + paid_at DateTime? + paid_by String? @db.Uuid raw_payload Json? created_at DateTime @default(now()) @@ -1361,18 +1380,105 @@ model TravelOrder { updated_at DateTime @updatedAt updated_by String? @db.Uuid - employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict) + employee Employee @relation("TravelOrderEmployee", 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) travel_request TravelRequest? @relation(fields: [travel_request_id], references: [id], onDelete: SetNull) + paid_by_employee Employee? @relation("TravelOrderPaidBy", fields: [paid_by], references: [id], onDelete: SetNull) + invoice_pool_items InvoicePool[] + card_transactions CardTransaction[] @@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]) @@index([travel_request_id]) + @@index([paid_by]) @@map("travel_orders") } +model CardAuthorization { + id String @id @default(uuid()) @db.Uuid + source String @default("manual") @db.VarChar(32) + external_auth_no String + employee_id String @db.Uuid + department_id String? @db.Uuid + travel_request_id String @db.Uuid + budget_pool_id String? @db.Uuid + legal_entity_id String @db.Uuid + cost_center_id String @db.Uuid + authorized_amount Decimal @db.Decimal(14, 2) + settled_amount Decimal @default(0) @db.Decimal(14, 2) + reversed_amount Decimal @default(0) @db.Decimal(14, 2) + currency String @default("CNY") @db.VarChar(8) + occurred_at DateTime? + expires_at DateTime? + status String @default("authorized") @db.VarChar(32) + 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) + travel_request TravelRequest @relation(fields: [travel_request_id], references: [id], onDelete: Cascade) + budget_pool BudgetPool? @relation(fields: [budget_pool_id], references: [id], onDelete: SetNull) + legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict) + cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict) + card_transactions CardTransaction[] + + @@unique([source, external_auth_no], map: "card_authorizations_source_auth_ux") + @@index([employee_id, occurred_at], map: "idx_card_authorizations_employee_occurred") + @@index([department_id, status], map: "idx_card_authorizations_department_status") + @@index([travel_request_id, status], map: "idx_card_authorizations_request_status") + @@index([budget_pool_id], map: "idx_card_authorizations_budget_pool") + @@index([legal_entity_id, cost_center_id], map: "idx_card_authorizations_legal_cost_center") + @@map("card_authorizations") +} + +model CardTransaction { + id String @id @default(uuid()) @db.Uuid + source String @default("manual") @db.VarChar(32) + external_txn_id String + card_authorization_id String? @db.Uuid + employee_id String @db.Uuid + department_id String? @db.Uuid + travel_request_id String @db.Uuid + travel_order_id String? @db.Uuid + amount Decimal @db.Decimal(14, 2) + currency String @default("CNY") @db.VarChar(8) + txn_type String @db.VarChar(20) + status String @default("pending") @db.VarChar(20) + occurred_at DateTime? + freeze_suggested_at DateTime? + freeze_suggested_by String? @db.Uuid + freeze_reason_code String? @db.VarChar(32) + freeze_reason_note String? + raw_payload Json? + + created_at DateTime @default(now()) + created_by String? @db.Uuid + updated_at DateTime @updatedAt + updated_by String? @db.Uuid + + card_authorization CardAuthorization? @relation(fields: [card_authorization_id], references: [id], onDelete: SetNull) + employee Employee @relation("CardTransactionEmployee", fields: [employee_id], references: [id], onDelete: Restrict) + department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull) + travel_request TravelRequest @relation(fields: [travel_request_id], references: [id], onDelete: Cascade) + travel_order TravelOrder? @relation(fields: [travel_order_id], references: [id], onDelete: SetNull) + freeze_suggested_by_employee Employee? @relation("CardTransactionFreezeSuggestedBy", fields: [freeze_suggested_by], references: [id], onDelete: SetNull) + + @@unique([source, external_txn_id], map: "card_transactions_source_txn_ux") + @@index([card_authorization_id], map: "idx_card_transactions_authorization") + @@index([employee_id, occurred_at], map: "idx_card_transactions_employee_occurred") + @@index([department_id, status], map: "idx_card_transactions_department_status") + @@index([travel_request_id], map: "idx_card_transactions_request") + @@index([travel_order_id], map: "idx_card_transactions_order") + @@index([freeze_suggested_by], map: "idx_card_transactions_freeze_suggested_by") + @@map("card_transactions") +} + model NotificationChannel { id String @id @default(uuid()) @db.Uuid channel_type String @db.VarChar(32) diff --git a/server/src/app.js b/server/src/app.js index 060725d4..bd9f36b3 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -45,6 +45,8 @@ const inventoryChecksRoutes = require("./routes/inventoryChecks"); const integrationsRoutes = require("./routes/integrations"); const travelOrdersRoutes = require("./routes/travelOrders"); const travelRequestsRoutes = require("./routes/travelRequests"); +const travelAuthorizationsRoutes = require("./routes/travelAuthorizations"); +const travelTransactionsRoutes = require("./routes/travelTransactions"); const aiRoutes = require("./routes/ai"); const { startJobs } = require("./jobs/jobSystem"); const { initMetrics } = require("./observability"); @@ -308,6 +310,8 @@ async function buildApp() { await app.register(integrationsRoutes, { prefix: "/api" }); await app.register(travelOrdersRoutes, { prefix: "/api" }); await app.register(travelRequestsRoutes, { prefix: "/api" }); + await app.register(travelAuthorizationsRoutes, { prefix: "/api" }); + await app.register(travelTransactionsRoutes, { prefix: "/api" }); await app.register(aiRoutes, { prefix: "/api" }); await app.register(exportsRoutes, { prefix: "/api" }); diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index 42c27674..6b9e0cdb 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -155,6 +155,7 @@ const ROLE_PERMISSIONS = { "expense.view_all", "procurement.view_all", "travel.view_all", + "travel.payment.manage", "asset.view_all", ], finance_mgr: [ @@ -176,6 +177,7 @@ const ROLE_PERMISSIONS = { "procurement.manage", "travel.view_all", "travel.manage", + "travel.payment.manage", "asset.view_all", "asset.manage", "asset.inventory", @@ -189,6 +191,7 @@ const ROLE_PERMISSIONS = { "procurement.manage", "travel.view_all", "travel.manage", + "travel.payment.manage", "asset.view_all", "asset.manage", "asset.transfer", diff --git a/server/src/routes/invoicePool.js b/server/src/routes/invoicePool.js index b398f0ba..fa84aa7c 100644 --- a/server/src/routes/invoicePool.js +++ b/server/src/routes/invoicePool.js @@ -24,10 +24,19 @@ const MIME_ALLOWLIST = new Set([ ]); const OCR_TYPE_ALLOWLIST = new Set(["vat", "train", "flight", "taxi", "general"]); -const bindBodySchema = z.object({ - reimbursementId: z.string().uuid(), - linkedItemId: z.string().uuid().optional(), -}); +const bindBodySchema = z.union([ + z + .object({ + reimbursementId: z.string().uuid(), + linkedItemId: z.string().uuid().optional(), + }) + .strict(), + z + .object({ + travelOrderId: z.string().uuid(), + }) + .strict(), +]); const invoiceEditBodySchema = z .object({ @@ -692,6 +701,7 @@ async function invoicePoolRoutes(fastify) { id: true, status: true, linked_reimbursement_id: true, + travel_order_id: true, owner_employee_id: true, uploaded_by_employee_id: true, archived_reason: true, @@ -718,7 +728,7 @@ async function invoicePoolRoutes(fastify) { }); } - if (current.linked_reimbursement_id) { + if (current.linked_reimbursement_id || current.travel_order_id) { throw new AppError({ code: "VALID_001", message: "参数校验失败", @@ -777,7 +787,7 @@ async function invoicePoolRoutes(fastify) { fastify.post( "/invoice-pool/:id/unbind", { - preHandler: fastify.requirePermission("invoice.upload"), + preHandler: fastify.authenticate, }, async (request, reply) => { const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); @@ -795,7 +805,7 @@ async function invoicePoolRoutes(fastify) { await fastify.prisma.$transaction(async (tx) => { const invoiceRows = await tx.$queryRaw` - SELECT id, status, linked_reimbursement_id + SELECT id, status, linked_reimbursement_id, travel_order_id FROM "invoice_pool" WHERE id = ${invoiceId}::uuid FOR UPDATE @@ -810,7 +820,7 @@ async function invoicePoolRoutes(fastify) { } const invoice = invoiceRows[0]; - if (invoice.status !== "bound" || !invoice.linked_reimbursement_id) { + if (invoice.status !== "bound" || (!invoice.linked_reimbursement_id && !invoice.travel_order_id)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", @@ -819,41 +829,51 @@ async function invoicePoolRoutes(fastify) { }); } - const reimbursementRows = await tx.$queryRaw` - SELECT id, status, owner_id, submitter_id - FROM "reimbursement_forms" - WHERE id = ${invoice.linked_reimbursement_id}::uuid - FOR UPDATE - `; + if (invoice.linked_reimbursement_id) { + const reimbursementRows = await tx.$queryRaw` + SELECT id, status, owner_id, submitter_id + FROM "reimbursement_forms" + WHERE id = ${invoice.linked_reimbursement_id}::uuid + FOR UPDATE + `; - if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { - throw new AppError({ - code: "SYS_404", - message: "Not Found", - statusCode: 404, - }); - } + if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } - const reimbursement = reimbursementRows[0]; - const isOwn = - reimbursement.owner_id === request.employee.id || - reimbursement.submitter_id === request.employee.id; - const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); - if (!isOwn && !canViewAll) { - throw new AppError({ - code: "PERM_001", - message: "无权限", - statusCode: 403, - }); - } + const reimbursement = reimbursementRows[0]; + const isOwn = + reimbursement.owner_id === request.employee.id || + reimbursement.submitter_id === request.employee.id; + const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); + if (!isOwn && !canViewAll) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } - if (!["draft", "rejected"].includes(reimbursement.status)) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { reimbursement: "invalid_status_for_unbind" }, - }); + if (!["draft", "rejected"].includes(reimbursement.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { reimbursement: "invalid_status_for_unbind" }, + }); + } + } else { + if (!fastify.hasPermission(request.employee, "travel.payment.manage")) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } } responseInvoice = await tx.invoicePool.update({ @@ -862,6 +882,7 @@ async function invoicePoolRoutes(fastify) { status: "available", linked_reimbursement_id: null, linked_item_id: null, + travel_order_id: null, updated_by: request.employee.id, }, select: buildInvoiceDetailSelect(), @@ -1064,6 +1085,7 @@ async function invoicePoolRoutes(fastify) { id: true, status: true, linked_reimbursement_id: true, + travel_order_id: true, file_sha256: true, invoice_code: true, invoice_number: true, @@ -1079,7 +1101,7 @@ async function invoicePoolRoutes(fastify) { }); } - if (current.linked_reimbursement_id) { + if (current.linked_reimbursement_id || current.travel_order_id) { throw new AppError({ code: "VALID_001", message: "参数校验失败", @@ -1195,7 +1217,7 @@ async function invoicePoolRoutes(fastify) { fastify.post( "/invoice-pool/:id/bind", { - preHandler: fastify.requirePermission("invoice.upload"), + preHandler: fastify.authenticate, }, async (request, reply) => { const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); @@ -1219,67 +1241,9 @@ async function invoicePoolRoutes(fastify) { } const invoiceId = parsedInvoiceId.data; - const { reimbursementId, linkedItemId } = parsedBody.data; try { await fastify.prisma.$transaction(async (tx) => { - const reimbursementRows = await tx.$queryRaw` - SELECT id, owner_id, submitter_id, status - FROM "reimbursement_forms" - WHERE id = ${reimbursementId}::uuid - FOR UPDATE - `; - if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { - throw new AppError({ - code: "SYS_404", - message: "Not Found", - statusCode: 404, - }); - } - - const reimbursement = reimbursementRows[0]; - const isOwn = - reimbursement.owner_id === request.employee.id || - reimbursement.submitter_id === request.employee.id; - const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); - if (!isOwn && !canViewAll) { - throw new AppError({ - code: "PERM_001", - message: "无权限", - statusCode: 403, - }); - } - - if (![ - "draft", - "rejected", - ].includes(reimbursement.status)) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { reimbursement: "invalid_status_for_bind" }, - }); - } - - if (linkedItemId) { - const linkedItem = await tx.reimbursementItem.findFirst({ - where: { - id: linkedItemId, - reimbursement_id: reimbursementId, - }, - select: { id: true }, - }); - if (!linkedItem) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { linkedItemId: "item_not_in_reimbursement" }, - }); - } - } - const invoiceRows = await tx.$queryRaw` SELECT id FROM "invoice_pool" @@ -1299,6 +1263,7 @@ async function invoicePoolRoutes(fastify) { select: { status: true, linked_reimbursement_id: true, + travel_order_id: true, }, }); @@ -1310,10 +1275,10 @@ async function invoicePoolRoutes(fastify) { }); } - if (invoice.status === "bound" || invoice.linked_reimbursement_id) { + if (invoice.status === "bound" || invoice.linked_reimbursement_id || invoice.travel_order_id) { throw new AppError({ code: "VALID_005", - message: "发票已被其他报销单占用/绑定", + message: "发票已被其他业务单据占用/绑定", statusCode: 409, }); } @@ -1321,18 +1286,129 @@ async function invoicePoolRoutes(fastify) { if (invoice.status !== "available") { throw new AppError({ code: "VALID_005", - message: "发票已被其他报销单占用/绑定", + message: "发票已被其他业务单据占用/绑定", statusCode: 409, details: { status: invoice.status }, }); } + if ("reimbursementId" in parsedBody.data) { + if (!fastify.hasPermission(request.employee, "invoice.upload")) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + const { reimbursementId, linkedItemId } = parsedBody.data; + const reimbursementRows = await tx.$queryRaw` + SELECT id, owner_id, submitter_id, status + FROM "reimbursement_forms" + WHERE id = ${reimbursementId}::uuid + FOR UPDATE + `; + if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const reimbursement = reimbursementRows[0]; + const isOwn = + reimbursement.owner_id === request.employee.id || + reimbursement.submitter_id === request.employee.id; + const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); + if (!isOwn && !canViewAll) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + if (!["draft", "rejected"].includes(reimbursement.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { reimbursement: "invalid_status_for_bind" }, + }); + } + + if (linkedItemId) { + const linkedItem = await tx.reimbursementItem.findFirst({ + where: { + id: linkedItemId, + reimbursement_id: reimbursementId, + }, + select: { id: true }, + }); + if (!linkedItem) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { linkedItemId: "item_not_in_reimbursement" }, + }); + } + } + + await tx.invoicePool.update({ + where: { id: invoiceId }, + data: { + status: "bound", + linked_reimbursement_id: reimbursementId, + linked_item_id: linkedItemId ?? null, + travel_order_id: null, + updated_by: request.employee.id, + }, + }); + return; + } + + if (!fastify.hasPermission(request.employee, "travel.payment.manage")) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + const { travelOrderId } = parsedBody.data; + const travelOrderRows = await tx.$queryRaw` + SELECT id, status + FROM "travel_orders" + WHERE id = ${travelOrderId}::uuid + FOR UPDATE + `; + if (!Array.isArray(travelOrderRows) || travelOrderRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const travelOrder = travelOrderRows[0]; + if (!["confirmed", "paid"].includes(travelOrder.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { travelOrder: "invalid_status_for_bind" }, + }); + } + await tx.invoicePool.update({ where: { id: invoiceId }, data: { status: "bound", - linked_reimbursement_id: reimbursementId, - linked_item_id: linkedItemId ?? null, + linked_reimbursement_id: null, + linked_item_id: null, + travel_order_id: travelOrderId, updated_by: request.employee.id, }, }); @@ -1363,6 +1439,7 @@ async function invoicePoolRoutes(fastify) { status: true, linked_reimbursement_id: true, linked_item_id: true, + travel_order_id: true, total_amount: true, }, }); @@ -1372,6 +1449,7 @@ async function invoicePoolRoutes(fastify) { status: invoice.status, linkedReimbursementId: invoice.linked_reimbursement_id, linkedItemId: invoice.linked_item_id, + linkedTravelOrderId: invoice.travel_order_id, totalAmount: toMoneyString(invoice.total_amount), }); }, diff --git a/server/src/routes/invoicePool/read.js b/server/src/routes/invoicePool/read.js index 1f6e24bc..69d6758d 100644 --- a/server/src/routes/invoicePool/read.js +++ b/server/src/routes/invoicePool/read.js @@ -47,10 +47,12 @@ function parseInvoiceListQuery(query) { function ensureInvoiceReadableByUser(request, invoice) { const roleCodes = request.employee.roleCodes; + const permissionCodes = request.employee.permissionCodes ?? []; if ( roleCodes.includes("admin") || roleCodes.includes("finance") || - roleCodes.includes("finance_mgr") + roleCodes.includes("finance_mgr") || + permissionCodes.includes("travel.payment.manage") ) { return true; } @@ -110,7 +112,9 @@ function registerInvoicePoolReadRoutes(fastify) { const query = parseInvoiceListQuery(request.query); const canViewOwn = fastify.hasPermission(request.employee, "invoice.view_own"); - const canManage = fastify.hasPermission(request.employee, "invoice.manage"); + const canManage = + fastify.hasPermission(request.employee, "invoice.manage") || + fastify.hasPermission(request.employee, "travel.payment.manage"); if (!canViewOwn && !canManage) { throw new AppError({ diff --git a/server/src/routes/invoicePool/shared/mappers.js b/server/src/routes/invoicePool/shared/mappers.js index df848b8f..d668ce44 100644 --- a/server/src/routes/invoicePool/shared/mappers.js +++ b/server/src/routes/invoicePool/shared/mappers.js @@ -43,6 +43,7 @@ function mapInvoiceSummary(item) { totalAmount: toMoneyString(item.total_amount), linkedReimbursementId: item.linked_reimbursement_id, linkedItemId: item.linked_item_id, + linkedTravelOrderId: item.travel_order_id ?? null, originalName: item.original_name, mimeType: item.mime_type, fileSize: item.file_size, @@ -99,6 +100,7 @@ function normalizeInvoiceResponse(invoice) { totalAmount: toMoneyString(invoice.total_amount), linkedReimbursementId: invoice.linked_reimbursement_id, linkedItemId: invoice.linked_item_id, + linkedTravelOrderId: invoice.travel_order_id ?? null, verification: mapVerificationRecord(latestVerification), isDuplicateOverride: invoice.is_duplicate_override ?? false, overrideRefFormNumber: invoice.override_ref_form_number ?? null, diff --git a/server/src/routes/invoicePool/shared/selects.js b/server/src/routes/invoicePool/shared/selects.js index e10aee90..8a7218f1 100644 --- a/server/src/routes/invoicePool/shared/selects.js +++ b/server/src/routes/invoicePool/shared/selects.js @@ -41,6 +41,7 @@ function buildInvoiceSummarySelect() { total_amount: true, linked_reimbursement_id: true, linked_item_id: true, + travel_order_id: true, original_name: true, mime_type: true, file_size: true, @@ -85,6 +86,7 @@ function buildInvoiceDetailSelect(options = {}) { total_amount: true, linked_reimbursement_id: true, linked_item_id: true, + travel_order_id: true, is_duplicate_override: true, override_ref_form_number: true, override_reason: true, diff --git a/server/src/routes/travelAuthorizations.js b/server/src/routes/travelAuthorizations.js new file mode 100644 index 00000000..097449e0 --- /dev/null +++ b/server/src/routes/travelAuthorizations.js @@ -0,0 +1,314 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { buildAuditLogData } = require("../utils/audit"); +const { normalizeTravelAuthorizationRows } = require("../services/integrations/travel"); +const { authorizeBudgetForEntity } = require("../services/travelPayments"); + +const UUID_SCHEMA = z.string().uuid(); +const LIST_QUERY_SCHEMA = z.object({ + travelRequestId: UUID_SCHEMA.optional(), + status: z.string().trim().min(1).max(32).optional(), + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), +}); +const IMPORT_SCHEMA = z.object({ + authorizations: z.array(z.record(z.string(), z.unknown())).min(1).max(100), +}); + +const AUTHORIZATION_STATUS_LIST = new Set([ + "authorized", + "partially_settled", + "settled", + "reversed", + "expired", +]); + +function buildValidationError(details, statusCode = 400) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode, + details, + }); +} + +function normalizeAuthorizationStatus(value) { + const normalized = String(value ?? "authorized").trim().toLowerCase(); + return AUTHORIZATION_STATUS_LIST.has(normalized) ? normalized : "authorized"; +} + +function parseListQuery(query) { + const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + return { + travelRequestId: parsed.data.travelRequestId ?? null, + status: parsed.data.status ? normalizeAuthorizationStatus(parsed.data.status) : null, + page: parsed.data.page ?? 1, + pageSize: parsed.data.pageSize ?? 20, + }; +} + +function mapAuthorization(item) { + return { + id: item.id, + source: item.source, + externalAuthNo: item.external_auth_no, + employeeId: item.employee_id, + departmentId: item.department_id ?? null, + travelRequestId: item.travel_request_id, + budgetPoolId: item.budget_pool_id ?? null, + legalEntityId: item.legal_entity_id, + costCenterId: item.cost_center_id, + authorizedAmount: item.authorized_amount?.toString?.() ?? "0.00", + settledAmount: item.settled_amount?.toString?.() ?? "0.00", + reversedAmount: item.reversed_amount?.toString?.() ?? "0.00", + currency: item.currency, + occurredAt: item.occurred_at?.toISOString?.() ?? null, + expiresAt: item.expires_at?.toISOString?.() ?? null, + status: item.status, + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + }; +} + +function hasAuthorizationConflict(existing, row, travelRequest) { + const existingBudgetPoolId = existing.budget_pool_id ?? null; + const nextBudgetPoolId = row.budgetPoolId ?? travelRequest.budget_pool_id ?? null; + + return ( + existing.employee_id !== row.employeeId || + existing.travel_request_id !== row.travelRequestId || + String(existing.authorized_amount ?? "0.00") !== String(row.authorizedAmount ?? "0.00") || + String(existing.currency ?? "CNY") !== String(row.currency ?? "CNY") || + existingBudgetPoolId !== nextBudgetPoolId + ); +} + +async function travelAuthorizationsRoutes(fastify) { + fastify.get( + "/travel/authorizations", + { + preHandler: fastify.requirePermission("travel.payment.manage"), + }, + async (request, reply) => { + const query = parseListQuery(request.query); + const skip = (query.page - 1) * query.pageSize; + const where = { + ...(query.travelRequestId ? { travel_request_id: query.travelRequestId } : {}), + ...(query.status ? { status: query.status } : {}), + }; + + const [items, total] = await Promise.all([ + fastify.prisma.cardAuthorization.findMany({ + where, + orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], + skip, + take: query.pageSize, + }), + fastify.prisma.cardAuthorization.count({ where }), + ]); + + return sendData(reply, request, { + page: query.page, + pageSize: query.pageSize, + total, + items: items.map(mapAuthorization), + }); + }, + ); + + fastify.post( + "/travel/authorizations/import", + { + preHandler: fastify.requirePermission("integration.manage"), + }, + async (request, reply) => { + if (!fastify.config.travelSyncEnabled) { + throw buildValidationError({ integration: "travel_sync_disabled" }, 409); + } + + const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + const rows = normalizeTravelAuthorizationRows(parsed.data.authorizations); + if (!rows.length) { + throw buildValidationError({ authorizations: "empty_after_normalization" }); + } + + const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId))); + const travelRequestIds = Array.from(new Set(rows.map((item) => item.travelRequestId))); + + const [employees, travelRequests] = await Promise.all([ + fastify.prisma.employee.findMany({ + where: { id: { in: employeeIds }, status: "active" }, + select: { id: true, department_id: true }, + }), + fastify.prisma.travelRequest.findMany({ + where: { id: { in: travelRequestIds } }, + select: { + id: true, + applicant_employee_id: true, + department_id: true, + budget_pool_id: true, + legal_entity_id: true, + cost_center_id: true, + }, + }), + ]); + + const employeeMap = new Map(employees.map((item) => [item.id, item])); + const travelRequestMap = new Map(travelRequests.map((item) => [item.id, item])); + + const imported = []; + const skipped = []; + let authorizedCount = 0; + + for (const row of rows) { + const employee = employeeMap.get(row.employeeId); + if (!employee) { + skipped.push({ + source: row.source, + externalAuthNo: row.externalAuthNo, + reason: "employee_not_found", + }); + continue; + } + + const travelRequest = travelRequestMap.get(row.travelRequestId); + if (!travelRequest) { + skipped.push({ + source: row.source, + externalAuthNo: row.externalAuthNo, + reason: "travel_request_not_found", + }); + continue; + } + + if (travelRequest.applicant_employee_id !== row.employeeId) { + skipped.push({ + source: row.source, + externalAuthNo: row.externalAuthNo, + reason: "travel_request_employee_mismatch", + }); + continue; + } + + const existing = await fastify.prisma.cardAuthorization.findUnique({ + where: { + source_external_auth_no: { + source: row.source, + external_auth_no: row.externalAuthNo, + }, + }, + }); + + if (existing && hasAuthorizationConflict(existing, row, travelRequest)) { + skipped.push({ + source: row.source, + externalAuthNo: row.externalAuthNo, + reason: "authorization_conflict", + }); + continue; + } + + const status = normalizeAuthorizationStatus(row.status); + const nextBudgetPoolId = row.budgetPoolId ?? travelRequest.budget_pool_id ?? null; + + const record = await fastify.prisma.$transaction(async (tx) => { + const upserted = await tx.cardAuthorization.upsert({ + where: { + source_external_auth_no: { + source: row.source, + external_auth_no: row.externalAuthNo, + }, + }, + update: { + employee_id: row.employeeId, + department_id: employee.department_id, + travel_request_id: row.travelRequestId, + budget_pool_id: nextBudgetPoolId, + legal_entity_id: travelRequest.legal_entity_id, + cost_center_id: travelRequest.cost_center_id, + authorized_amount: row.authorizedAmount, + settled_amount: row.settledAmount, + reversed_amount: row.reversedAmount, + currency: row.currency, + occurred_at: row.occurredAt, + expires_at: row.expiresAt, + status, + raw_payload: row.rawPayload, + updated_by: request.employee.id, + }, + create: { + source: row.source, + external_auth_no: row.externalAuthNo, + employee_id: row.employeeId, + department_id: employee.department_id, + travel_request_id: row.travelRequestId, + budget_pool_id: nextBudgetPoolId, + legal_entity_id: travelRequest.legal_entity_id, + cost_center_id: travelRequest.cost_center_id, + authorized_amount: row.authorizedAmount, + settled_amount: row.settledAmount, + reversed_amount: row.reversedAmount, + currency: row.currency, + occurred_at: row.occurredAt, + expires_at: row.expiresAt, + status, + raw_payload: row.rawPayload, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + }); + + const budgetAuthorization = await authorizeBudgetForEntity(tx, { + budgetPoolId: nextBudgetPoolId, + entityId: upserted.id, + amount: row.authorizedAmount, + actorId: request.employee.id, + note: "travel_authorization_import_authorize", + }); + + if (budgetAuthorization.applied) { + authorizedCount += 1; + } + + return upserted; + }); + + imported.push(record); + } + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "TRAVEL_AUTHORIZATIONS_IMPORTED", + targetType: "card_authorization", + extra: { + requested: rows.length, + createdOrUpdated: imported.length, + authorizedCount, + skipped, + }, + }), + }); + + return sendData(reply, request, { + requested: rows.length, + imported: imported.length, + authorizedCount, + skipped, + items: imported.map(mapAuthorization), + }); + }, + ); +} + +module.exports = travelAuthorizationsRoutes; diff --git a/server/src/routes/travelOrders.js b/server/src/routes/travelOrders.js index 5a883af3..5ec79409 100644 --- a/server/src/routes/travelOrders.js +++ b/server/src/routes/travelOrders.js @@ -4,11 +4,31 @@ const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { normalizeTravelImportRows } = require("../services/integrations/travel"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); +const { + enqueueTravelOrderPaymentJournalExport, + buildTravelOrderPaymentJournalDedupeKey, +} = require("../services/integrations/erpJournal"); +const { parseDateOnlyString } = require("../utils/date"); const IMPORT_SCHEMA = z.object({ orders: z.array(z.record(z.string(), z.unknown())).min(1).max(100), }); const UUID_SCHEMA = z.string().uuid(); +const PAYMENT_CHANNEL_SCHEMA = z.enum([ + "bank_transfer", + "corporate_card", + "virtual_card", + "manual", + "other", +]); +const MARK_PAID_SCHEMA = z + .object({ + paymentVoucherNo: z.string().trim().max(50).nullable().optional(), + paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), + paymentChannel: PAYMENT_CHANNEL_SCHEMA.nullable().optional(), + }) + .strict(); function mapTravelOrder(item) { return { @@ -23,6 +43,11 @@ function mapTravelOrder(item) { amount: item.amount?.toString?.() ?? null, currency: item.currency, status: item.status, + paymentVoucherNo: item.payment_voucher_no ?? null, + paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null, + paymentChannel: item.payment_channel ?? null, + paidAt: item.paid_at?.toISOString?.() ?? null, + paidBy: item.paid_by ?? null, createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, }; @@ -37,6 +62,29 @@ function buildValidationError(details, statusCode = 400) { }); } +function normalizeNullableString(value) { + if (value == null) { + return null; + } + const normalized = String(value).trim(); + return normalized || null; +} + +function toDateOnlyString(value) { + if (!value) { + return null; + } + try { + return new Date(value).toISOString().slice(0, 10); + } catch { + return null; + } +} + +function buildOrderKey(source, externalOrderNo) { + return `${source}::${externalOrderNo}`; +} + async function travelOrdersRoutes(fastify) { fastify.post( "/travel/orders/import", @@ -67,7 +115,12 @@ async function travelOrdersRoutes(fastify) { ), ).filter((item) => UUID_SCHEMA.safeParse(item).success); - const [employees, travelRequests] = await Promise.all([ + const existingOrderWhere = rows.map((item) => ({ + source: item.source, + external_order_no: item.externalOrderNo, + })); + + const [employees, travelRequests, existingOrders] = await Promise.all([ fastify.prisma.employee.findMany({ where: { id: { in: employeeIds }, @@ -87,10 +140,25 @@ async function travelOrdersRoutes(fastify) { }, }) : Promise.resolve([]), + existingOrderWhere.length + ? fastify.prisma.travelOrder.findMany({ + where: { OR: existingOrderWhere }, + select: { + id: true, + source: true, + external_order_no: true, + status: true, + travel_request_id: true, + }, + }) + : Promise.resolve([]), ]); const employeeMap = new Map(employees.map((item) => [item.id, item])); const travelRequestMap = new Map(travelRequests.map((item) => [item.id, item])); + const existingOrderMap = new Map( + existingOrders.map((item) => [buildOrderKey(item.source, item.external_order_no), item]), + ); const imported = []; const skipped = []; @@ -131,6 +199,7 @@ async function travelOrdersRoutes(fastify) { nextTravelRequestId = undefined; } + const existingOrder = existingOrderMap.get(buildOrderKey(row.source, row.externalOrderNo)) ?? null; const updateData = { employee_id: row.employeeId, department_id: employee.department_id, @@ -138,7 +207,7 @@ async function travelOrdersRoutes(fastify) { occurred_at: row.occurredAt, amount: row.amount, currency: row.currency, - status: "imported", + status: existingOrder?.status ?? "imported", raw_payload: row.rawPayload, updated_by: request.employee.id, }; @@ -202,6 +271,219 @@ async function travelOrdersRoutes(fastify) { }); }, ); + + fastify.post( + "/travel-orders/:id/mark-paid", + { + preHandler: fastify.requirePermission("travel.payment.manage"), + }, + async (request, reply) => { + const parsedId = UUID_SCHEMA.safeParse(request.params?.id); + if (!parsedId.success) { + throw buildValidationError({ id: "invalid_uuid" }); + } + + const parsedBody = MARK_PAID_SCHEMA.safeParse(request.body ?? {}); + if (!parsedBody.success) { + throw buildValidationError(parsedBody.error.flatten()); + } + + const paymentVoucherNo = normalizeNullableString(parsedBody.data.paymentVoucherNo); + const paymentDate = parsedBody.data.paymentDate ?? null; + const paymentChannel = parsedBody.data.paymentChannel ?? null; + + let response = null; + + await fastify.prisma.$transaction(async (tx) => { + const orderRows = await tx.$queryRaw` + SELECT id, status, source, external_order_no, employee_id, department_id, + travel_request_id, amount, currency, + payment_voucher_no, payment_date, payment_channel, paid_at, paid_by, + created_at, updated_at + FROM "travel_orders" + WHERE id = ${parsedId.data}::uuid + FOR UPDATE + `; + + if (!Array.isArray(orderRows) || orderRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const current = orderRows[0]; + if (!["confirmed", "paid"].includes(current.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_pay_status" }, + }); + } + + const existingPaymentDate = toDateOnlyString(current.payment_date); + if (current.status === "paid") { + const voucherConflict = paymentVoucherNo !== (current.payment_voucher_no ?? null); + const paymentDateConflict = paymentDate !== existingPaymentDate; + const paymentChannelConflict = paymentChannel !== (current.payment_channel ?? null); + + if (voucherConflict || paymentDateConflict || paymentChannelConflict) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { payment: "already_paid_conflict" }, + }); + } + } + + const matchedTransaction = await tx.cardTransaction.findFirst({ + where: { + travel_order_id: parsedId.data, + txn_type: "charge", + status: "settled", + amount: current.amount, + currency: current.currency, + }, + orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], + select: { id: true }, + }); + + if (!matchedTransaction) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { payment: "missing_settled_transaction" }, + }); + } + + const now = new Date(); + if (current.status === "confirmed") { + await tx.travelOrder.update({ + where: { id: parsedId.data }, + data: { + status: "paid", + payment_voucher_no: paymentVoucherNo, + payment_date: paymentDate ? parseDateOnlyString(paymentDate) : null, + payment_channel: paymentChannel, + paid_at: now, + paid_by: request.employee.id, + updated_by: request.employee.id, + }, + }); + } + + const refreshed = await tx.travelOrder.findUnique({ + where: { id: parsedId.data }, + select: { + id: true, + source: true, + external_order_no: true, + employee_id: true, + department_id: true, + linked_reimbursement_id: true, + travel_request_id: true, + amount: true, + currency: true, + status: true, + payment_voucher_no: true, + payment_date: true, + payment_channel: true, + paid_at: true, + paid_by: true, + created_at: true, + updated_at: true, + travel_request: { + select: { + id: true, + request_no: true, + department_id: true, + applicant_employee_id: true, + submitter_id: true, + legal_entity: { select: { code: true } }, + cost_center: { select: { code: true } }, + budget_pool: { select: { code: true } }, + }, + }, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "TRAVEL_ORDER_MARKED_PAID", + targetType: "travel_order", + targetId: parsedId.data, + extra: { + paymentVoucherNo, + paymentDate, + paymentChannel, + idempotent: current.status === "paid", + matchedTransactionId: matchedTransaction.id, + }, + }), + }); + + const departmentId = refreshed?.department_id ?? refreshed?.travel_request?.department_id ?? null; + const erpGate = await evaluateFeatureFlag({ + prisma: tx, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.erpSync, + departmentId, + strictDepartment: true, + }); + + let erpJournal = { + eventType: "erp.journal.export", + outboxId: null, + dedupeKey: buildTravelOrderPaymentJournalDedupeKey(parsedId.data), + enqueued: false, + }; + + if (erpGate.enabled && refreshed) { + const outbox = await enqueueTravelOrderPaymentJournalExport(tx, fastify.config, { + travelOrder: refreshed, + actorId: request.employee.id, + }); + erpJournal = { + eventType: "erp.journal.export", + outboxId: outbox.id, + dedupeKey: buildTravelOrderPaymentJournalDedupeKey(parsedId.data), + enqueued: true, + }; + } else { + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG", + targetType: "travel_order", + targetId: parsedId.data, + extra: { + featureFlag: FEATURE_FLAG_KEYS.erpSync, + departmentId, + reason: erpGate.reason, + }, + }), + }); + } + + response = { + travelOrderId: refreshed.id, + travelRequestId: refreshed.travel_request_id ?? null, + status: refreshed.status, + paymentVoucherNo: refreshed.payment_voucher_no ?? null, + paymentDate: refreshed.payment_date?.toISOString?.()?.slice(0, 10) ?? null, + paymentChannel: refreshed.payment_channel ?? null, + erpJournal, + }; + }); + + return sendData(reply, request, response); + }, + ); } module.exports = travelOrdersRoutes; diff --git a/server/src/routes/travelRequests.js b/server/src/routes/travelRequests.js index ebe7fe5f..4993248f 100644 --- a/server/src/routes/travelRequests.js +++ b/server/src/routes/travelRequests.js @@ -11,6 +11,7 @@ const { parseWorkflowSteps, resolveActionableStep } = require("../utils/workflow const { sendNotificationEmailSafe } = require("../services/email"); const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications"); const { enqueueImNotifications } = require("../services/notifications/imEnqueue"); +const { buildTravelPaymentTrack } = require("../services/travelPayments"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const UUID_SCHEMA = z.string().uuid(); @@ -145,10 +146,16 @@ const TRAVEL_DETAIL_SELECT = { external_order_no: true, employee_id: true, linked_reimbursement_id: true, + travel_request_id: true, occurred_at: true, amount: true, currency: true, status: true, + payment_voucher_no: true, + payment_date: true, + payment_channel: true, + paid_at: true, + paid_by: true, created_at: true, updated_at: true, }, @@ -356,10 +363,16 @@ function mapTravelOrder(item) { externalOrderNo: item.external_order_no, employeeId: item.employee_id, linkedReimbursementId: item.linked_reimbursement_id ?? null, + travelRequestId: item.travel_request_id ?? null, occurredAt: toIsoDate(item.occurred_at), amount: moneyToFixed(item.amount), currency: item.currency, status: item.status, + paymentVoucherNo: item.payment_voucher_no ?? null, + paymentDate: toIsoDate(item.payment_date), + paymentChannel: item.payment_channel ?? null, + paidAt: toIsoDateTime(item.paid_at), + paidBy: item.paid_by ?? null, createdAt: toIsoDateTime(item.created_at), updatedAt: toIsoDateTime(item.updated_at), }; @@ -1054,6 +1067,22 @@ async function createTravelRequestsRoutes(fastify) { }, ); + fastify.get( + "/travel-requests/:id/payment-track", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + ensureTravelReadPermission(fastify, request); + const travelRequestId = parseTravelRequestId(request.params?.id); + await loadReadableTravelRequest(fastify.prisma, travelRequestId, request); + const track = await buildTravelPaymentTrack(fastify.prisma, travelRequestId, { + canManagePayments: fastify.hasPermission(request.employee, "travel.payment.manage"), + }); + return sendData(reply, request, track); + }, + ); + fastify.post( "/travel-requests/:id/withdraw", { diff --git a/server/src/routes/travelTransactions.js b/server/src/routes/travelTransactions.js new file mode 100644 index 00000000..2ecf3e09 --- /dev/null +++ b/server/src/routes/travelTransactions.js @@ -0,0 +1,666 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { buildAuditLogData } = require("../utils/audit"); +const { normalizeTravelTransactionRows } = require("../services/integrations/travel"); +const { + settleBudgetForEntity, + reverseBudgetForEntity, + isOrderAmountAndCurrencyMatched, +} = require("../services/travelPayments"); + +const UUID_SCHEMA = z.string().uuid(); +const LIST_QUERY_SCHEMA = z.object({ + travelRequestId: UUID_SCHEMA.optional(), + travelOrderId: UUID_SCHEMA.optional(), + status: z.string().trim().min(1).max(32).optional(), + txnType: z.string().trim().min(1).max(32).optional(), + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), +}); +const IMPORT_SCHEMA = z.object({ + transactions: z.array(z.record(z.string(), z.unknown())).min(1).max(100), +}); +const FREEZE_SUGGESTION_SCHEMA = z.object({ + reasonCode: z.enum([ + "amount_mismatch", + "currency_mismatch", + "missing_order", + "duplicate", + "manual_review", + ]), + note: z.string().trim().max(500).nullable().optional(), +}); + +const TRANSACTION_STATUS_LIST = new Set(["pending", "settled", "reversed", "failed"]); +const TRANSACTION_TYPE_LIST = new Set(["charge", "refund", "reversal"]); + +function buildValidationError(details, statusCode = 400) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode, + details, + }); +} + +function normalizeTransactionStatus(value) { + const normalized = String(value ?? "pending").trim().toLowerCase(); + return TRANSACTION_STATUS_LIST.has(normalized) ? normalized : "pending"; +} + +function normalizeTransactionType(value) { + const normalized = String(value ?? "charge").trim().toLowerCase(); + return TRANSACTION_TYPE_LIST.has(normalized) ? normalized : "charge"; +} + +function parseListQuery(query) { + const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + return { + travelRequestId: parsed.data.travelRequestId ?? null, + travelOrderId: parsed.data.travelOrderId ?? null, + status: parsed.data.status ? normalizeTransactionStatus(parsed.data.status) : null, + txnType: parsed.data.txnType ? normalizeTransactionType(parsed.data.txnType) : null, + page: parsed.data.page ?? 1, + pageSize: parsed.data.pageSize ?? 20, + }; +} + +function toMoneyFixed(value) { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric)) { + return "0.00"; + } + return numeric.toFixed(2); +} + +function mapMatchedOrder(order, transaction) { + if (!order) { + return null; + } + + return { + id: order.id, + externalOrderNo: order.external_order_no, + status: order.status, + amount: toMoneyFixed(order.amount), + currency: order.currency, + exactMatch: isOrderAmountAndCurrencyMatched(order, transaction), + }; +} + +function mapTransaction(item) { + return { + id: item.id, + source: item.source, + externalTxnId: item.external_txn_id, + cardAuthorizationId: item.card_authorization_id ?? null, + employeeId: item.employee_id, + departmentId: item.department_id ?? null, + travelRequestId: item.travel_request_id, + travelOrderId: item.travel_order_id ?? null, + amount: toMoneyFixed(item.amount), + currency: item.currency, + txnType: item.txn_type, + status: item.status, + occurredAt: item.occurred_at?.toISOString?.() ?? null, + freezeSuggestedAt: item.freeze_suggested_at?.toISOString?.() ?? null, + freezeSuggestedBy: item.freeze_suggested_by ?? null, + freezeReasonCode: item.freeze_reason_code ?? null, + freezeReasonNote: item.freeze_reason_note ?? null, + matchedOrder: mapMatchedOrder(item.travel_order ?? null, item), + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + }; +} + +function hasTransactionConflict(existing, row) { + if ( + existing.employee_id !== row.employeeId || + existing.travel_request_id !== row.travelRequestId || + String(existing.amount ?? "0.00") !== String(row.amount ?? "0.00") || + String(existing.currency ?? "CNY") !== String(row.currency ?? "CNY") || + String(existing.txn_type ?? "charge") !== String(row.txnType ?? "charge") + ) { + return true; + } + + if ( + row.hasCardAuthorizationId && + (existing.card_authorization_id ?? null) !== (row.cardAuthorizationId ?? null) + ) { + return true; + } + + if (row.hasTravelOrderId && (existing.travel_order_id ?? null) !== (row.travelOrderId ?? null)) { + return true; + } + + return false; +} + +async function syncAuthorizationSnapshot(tx, authorizationId, actorId) { + const authorization = await tx.cardAuthorization.findUnique({ + where: { id: authorizationId }, + select: { + id: true, + authorized_amount: true, + card_transactions: { + select: { + amount: true, + txn_type: true, + status: true, + }, + }, + }, + }); + + if (!authorization) { + return null; + } + + const settledAmount = authorization.card_transactions.reduce((total, item) => { + if (item.txn_type === "charge" && item.status === "settled") { + return total + Number(item.amount ?? 0); + } + return total; + }, 0); + + const reversedAmount = authorization.card_transactions.reduce((total, item) => { + if (["refund", "reversal"].includes(item.txn_type) || item.status === "reversed") { + return total + Number(item.amount ?? 0); + } + return total; + }, 0); + + const authorizedAmount = Number(authorization.authorized_amount ?? 0); + let nextStatus = "authorized"; + if (reversedAmount > 0 && reversedAmount >= authorizedAmount && authorizedAmount > 0) { + nextStatus = "reversed"; + } else if (settledAmount > 0 && settledAmount >= authorizedAmount && authorizedAmount > 0) { + nextStatus = "settled"; + } else if (settledAmount > 0) { + nextStatus = "partially_settled"; + } + + return tx.cardAuthorization.update({ + where: { id: authorizationId }, + data: { + settled_amount: settledAmount.toFixed(2), + reversed_amount: reversedAmount.toFixed(2), + status: nextStatus, + updated_by: actorId, + }, + }); +} + +async function loadOrderById(tx, orderId) { + return tx.travelOrder.findUnique({ + where: { id: orderId }, + select: { + id: true, + external_order_no: true, + employee_id: true, + travel_request_id: true, + amount: true, + currency: true, + status: true, + }, + }); +} + +async function travelTransactionsRoutes(fastify) { + fastify.get( + "/travel/transactions", + { + preHandler: fastify.requirePermission("travel.payment.manage"), + }, + async (request, reply) => { + const query = parseListQuery(request.query); + const skip = (query.page - 1) * query.pageSize; + const where = { + ...(query.travelRequestId ? { travel_request_id: query.travelRequestId } : {}), + ...(query.travelOrderId ? { travel_order_id: query.travelOrderId } : {}), + ...(query.status ? { status: query.status } : {}), + ...(query.txnType ? { txn_type: query.txnType } : {}), + }; + + const [items, total] = await Promise.all([ + fastify.prisma.cardTransaction.findMany({ + where, + orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], + skip, + take: query.pageSize, + include: { + travel_order: { + select: { + id: true, + external_order_no: true, + status: true, + amount: true, + currency: true, + }, + }, + }, + }), + fastify.prisma.cardTransaction.count({ where }), + ]); + + return sendData(reply, request, { + page: query.page, + pageSize: query.pageSize, + total, + items: items.map(mapTransaction), + }); + }, + ); + + fastify.post( + "/travel/transactions/import", + { + preHandler: fastify.requirePermission("integration.manage"), + }, + async (request, reply) => { + if (!fastify.config.travelSyncEnabled) { + throw buildValidationError({ integration: "travel_sync_disabled" }, 409); + } + + const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + const rows = normalizeTravelTransactionRows(parsed.data.transactions); + if (!rows.length) { + throw buildValidationError({ transactions: "empty_after_normalization" }); + } + + const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId))); + const travelRequestIds = Array.from(new Set(rows.map((item) => item.travelRequestId))); + const authorizationIds = Array.from( + new Set(rows.map((item) => item.cardAuthorizationId).filter(Boolean)), + ); + const travelOrderIds = Array.from(new Set(rows.map((item) => item.travelOrderId).filter(Boolean))); + + const [employees, travelRequests, authorizations, travelOrders] = await Promise.all([ + fastify.prisma.employee.findMany({ + where: { id: { in: employeeIds }, status: "active" }, + select: { id: true, department_id: true }, + }), + fastify.prisma.travelRequest.findMany({ + where: { id: { in: travelRequestIds } }, + select: { + id: true, + applicant_employee_id: true, + budget_pool_id: true, + }, + }), + authorizationIds.length + ? fastify.prisma.cardAuthorization.findMany({ + where: { id: { in: authorizationIds } }, + select: { + id: true, + employee_id: true, + travel_request_id: true, + budget_pool_id: true, + }, + }) + : Promise.resolve([]), + travelOrderIds.length + ? fastify.prisma.travelOrder.findMany({ + where: { id: { in: travelOrderIds } }, + select: { + id: true, + employee_id: true, + travel_request_id: true, + external_order_no: true, + amount: true, + currency: true, + status: true, + }, + }) + : Promise.resolve([]), + ]); + + const employeeMap = new Map(employees.map((item) => [item.id, item])); + const travelRequestMap = new Map(travelRequests.map((item) => [item.id, item])); + const authorizationMap = new Map(authorizations.map((item) => [item.id, item])); + const orderMap = new Map(travelOrders.map((item) => [item.id, item])); + + const imported = []; + const skipped = []; + let confirmedCount = 0; + let reversedCount = 0; + + for (const row of rows) { + const employee = employeeMap.get(row.employeeId); + if (!employee) { + skipped.push({ + source: row.source, + externalTxnId: row.externalTxnId, + reason: "employee_not_found", + }); + continue; + } + + const travelRequest = travelRequestMap.get(row.travelRequestId); + if (!travelRequest) { + skipped.push({ + source: row.source, + externalTxnId: row.externalTxnId, + reason: "travel_request_not_found", + }); + continue; + } + + if (travelRequest.applicant_employee_id !== row.employeeId) { + skipped.push({ + source: row.source, + externalTxnId: row.externalTxnId, + reason: "travel_request_employee_mismatch", + }); + continue; + } + + const authorization = row.cardAuthorizationId ? authorizationMap.get(row.cardAuthorizationId) ?? null : null; + if (row.cardAuthorizationId && !authorization) { + skipped.push({ + source: row.source, + externalTxnId: row.externalTxnId, + reason: "card_authorization_not_found", + }); + continue; + } + if ( + authorization && + (authorization.employee_id !== row.employeeId || authorization.travel_request_id !== row.travelRequestId) + ) { + skipped.push({ + source: row.source, + externalTxnId: row.externalTxnId, + reason: "card_authorization_mismatch", + }); + continue; + } + + const travelOrder = row.travelOrderId ? orderMap.get(row.travelOrderId) ?? null : null; + if (row.travelOrderId && !travelOrder) { + skipped.push({ + source: row.source, + externalTxnId: row.externalTxnId, + reason: "travel_order_not_found", + }); + continue; + } + if (travelOrder && travelOrder.employee_id !== row.employeeId) { + skipped.push({ + source: row.source, + externalTxnId: row.externalTxnId, + reason: "travel_order_employee_mismatch", + }); + continue; + } + if ( + travelOrder && + travelOrder.travel_request_id && + travelOrder.travel_request_id !== row.travelRequestId + ) { + skipped.push({ + source: row.source, + externalTxnId: row.externalTxnId, + reason: "travel_order_request_mismatch", + }); + continue; + } + + const existing = await fastify.prisma.cardTransaction.findUnique({ + where: { + source_external_txn_id: { + source: row.source, + external_txn_id: row.externalTxnId, + }, + }, + }); + + if (existing && hasTransactionConflict(existing, row)) { + skipped.push({ + source: row.source, + externalTxnId: row.externalTxnId, + reason: "transaction_conflict", + }); + continue; + } + + const txnType = normalizeTransactionType(row.txnType); + const status = normalizeTransactionStatus(row.status); + + const record = await fastify.prisma.$transaction(async (tx) => { + const nextCardAuthorizationId = row.hasCardAuthorizationId + ? row.cardAuthorizationId ?? null + : existing?.card_authorization_id; + const nextTravelOrderId = row.hasTravelOrderId + ? row.travelOrderId ?? null + : existing?.travel_order_id; + + const upserted = await tx.cardTransaction.upsert({ + where: { + source_external_txn_id: { + source: row.source, + external_txn_id: row.externalTxnId, + }, + }, + update: { + employee_id: row.employeeId, + department_id: employee.department_id, + travel_request_id: row.travelRequestId, + ...(row.hasCardAuthorizationId ? { card_authorization_id: nextCardAuthorizationId } : {}), + ...(row.hasTravelOrderId ? { travel_order_id: nextTravelOrderId } : {}), + amount: row.amount, + currency: row.currency, + txn_type: txnType, + status, + occurred_at: row.occurredAt, + raw_payload: row.rawPayload, + updated_by: request.employee.id, + }, + create: { + source: row.source, + external_txn_id: row.externalTxnId, + card_authorization_id: nextCardAuthorizationId ?? null, + employee_id: row.employeeId, + department_id: employee.department_id, + travel_request_id: row.travelRequestId, + travel_order_id: nextTravelOrderId ?? null, + amount: row.amount, + currency: row.currency, + txn_type: txnType, + status, + occurred_at: row.occurredAt, + raw_payload: row.rawPayload, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + }); + + const budgetPoolId = authorization?.budget_pool_id ?? travelRequest.budget_pool_id ?? null; + + if (txnType === "charge" && status === "settled") { + await settleBudgetForEntity(tx, { + budgetPoolId, + entityId: upserted.id, + amount: row.amount, + actorId: request.employee.id, + note: "travel_transaction_import_settle", + }); + } + + if (["refund", "reversal"].includes(txnType) || status === "reversed") { + const reverseBudget = await reverseBudgetForEntity(tx, { + budgetPoolId, + entityId: upserted.id, + amount: row.amount, + actorId: request.employee.id, + note: "travel_transaction_import_reverse", + }); + if (reverseBudget.applied) { + reversedCount += 1; + } + } + + if (nextCardAuthorizationId) { + await syncAuthorizationSnapshot(tx, nextCardAuthorizationId, request.employee.id); + } + + const nextOrder = nextTravelOrderId ? await loadOrderById(tx, nextTravelOrderId) : null; + if ( + nextOrder && + txnType === "charge" && + status === "settled" && + nextOrder.status === "imported" && + isOrderAmountAndCurrencyMatched(nextOrder, upserted) + ) { + await tx.travelOrder.update({ + where: { id: nextOrder.id }, + data: { + status: "confirmed", + updated_by: request.employee.id, + }, + }); + confirmedCount += 1; + } + + return tx.cardTransaction.findUnique({ + where: { id: upserted.id }, + include: { + travel_order: { + select: { + id: true, + external_order_no: true, + status: true, + amount: true, + currency: true, + }, + }, + }, + }); + }); + + imported.push(record); + } + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "TRAVEL_TRANSACTIONS_IMPORTED", + targetType: "card_transaction", + extra: { + requested: rows.length, + createdOrUpdated: imported.length, + confirmedCount, + reversedCount, + skipped, + }, + }), + }); + + return sendData(reply, request, { + requested: rows.length, + imported: imported.length, + confirmedCount, + reversedCount, + skipped, + items: imported.filter(Boolean).map(mapTransaction), + }); + }, + ); + + fastify.post( + "/travel/transactions/:id/freeze-suggestion", + { + preHandler: fastify.requirePermission("travel.payment.manage"), + }, + async (request, reply) => { + const parsedId = UUID_SCHEMA.safeParse(request.params?.id); + if (!parsedId.success) { + throw buildValidationError({ id: "invalid_uuid" }); + } + + const parsedBody = FREEZE_SUGGESTION_SCHEMA.safeParse(request.body ?? {}); + if (!parsedBody.success) { + throw buildValidationError(parsedBody.error.flatten()); + } + + const transaction = await fastify.prisma.$transaction(async (tx) => { + const current = await tx.cardTransaction.findUnique({ + where: { id: parsedId.data }, + include: { + travel_order: { + select: { + id: true, + external_order_no: true, + status: true, + amount: true, + currency: true, + }, + }, + }, + }); + + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const updated = await tx.cardTransaction.update({ + where: { id: parsedId.data }, + data: { + freeze_suggested_at: new Date(), + freeze_suggested_by: request.employee.id, + freeze_reason_code: parsedBody.data.reasonCode, + freeze_reason_note: parsedBody.data.note ?? null, + updated_by: request.employee.id, + }, + include: { + travel_order: { + select: { + id: true, + external_order_no: true, + status: true, + amount: true, + currency: true, + }, + }, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "TRAVEL_TRANSACTION_FREEZE_SUGGESTED", + targetType: "card_transaction", + targetId: updated.id, + extra: { + reasonCode: parsedBody.data.reasonCode, + note: parsedBody.data.note ?? null, + travelOrderId: updated.travel_order_id ?? null, + }, + }), + }); + + return updated; + }); + + return sendData(reply, request, mapTransaction(transaction)); + }, + ); +} + +module.exports = travelTransactionsRoutes; diff --git a/server/src/services/integrations/dispatcher.js b/server/src/services/integrations/dispatcher.js index 1ed15b31..5fef1d57 100644 --- a/server/src/services/integrations/dispatcher.js +++ b/server/src/services/integrations/dispatcher.js @@ -106,6 +106,17 @@ function resolveBindingSpecs(eventType, payload, response) { ), ].filter(Boolean); } + + if (journalType === 'travel_order_payment') { + return [ + createBindingSpec( + 'travel_order_payment_journal', + payload?.references?.travelOrderId ?? payload?.entityId, + extractResponseValue(response, 'externalId'), + extractResponseValue(response, 'externalCode') ?? payload?.references?.externalOrderNo ?? payload?.documentNo ?? null, + ), + ].filter(Boolean); + } } if (eventType === 'erp.procurement.export') { diff --git a/server/src/services/integrations/erpJournal.js b/server/src/services/integrations/erpJournal.js index 466c9858..0056b131 100644 --- a/server/src/services/integrations/erpJournal.js +++ b/server/src/services/integrations/erpJournal.js @@ -2,6 +2,7 @@ const { enqueueIntegrationEvent } = require('./outbox'); const ERP_JOURNAL_EVENT_TYPES = Object.freeze(['erp.journal.export', 'erp.reimbursement.paid']); const REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX = 'reimbursement'; +const TRAVEL_ORDER_JOURNAL_DEDUPE_PREFIX = 'travel_order'; function toDateOnly(value) { if (!value) { @@ -25,12 +26,20 @@ function toIsoString(value) { } } -function resolveLegalEntityCode(reimbursement) { - return ( - reimbursement?.owner?.legal_entity?.code ?? - reimbursement?.submitter?.legal_entity?.code ?? - null - ); +function resolveReimbursementLegalEntityCode(reimbursement) { + return reimbursement?.owner?.legal_entity?.code ?? reimbursement?.submitter?.legal_entity?.code ?? null; +} + +function resolveTravelRequestLegalEntityCode(travelOrder) { + return travelOrder?.travel_request?.legal_entity?.code ?? null; +} + +function resolveTravelRequestCostCenterCode(travelOrder) { + return travelOrder?.travel_request?.cost_center?.code ?? null; +} + +function resolveTravelRequestBudgetPoolCode(travelOrder) { + return travelOrder?.travel_request?.budget_pool?.code ?? null; } function buildReimbursementJournalPayload(input) { @@ -39,7 +48,7 @@ function buildReimbursementJournalPayload(input) { toIsoString(reimbursement.paid_at) ?? (reimbursement.payment_date ? `${toDateOnly(reimbursement.payment_date)}T00:00:00.000Z` : null); - const payload = { + return { journalType: 'reimbursement_paid', sourceEventName: 'reimbursement.paid', entityType: 'reimbursement_form', @@ -54,23 +63,65 @@ function buildReimbursementJournalPayload(input) { departmentId: reimbursement.department_id ?? null, ownerEmployeeId: reimbursement.owner_id ?? null, submitterEmployeeId: reimbursement.submitter_id ?? null, - legalEntityCode: resolveLegalEntityCode(reimbursement), + legalEntityCode: resolveReimbursementLegalEntityCode(reimbursement), costCenterCode: null, }, reimbursementId: reimbursement.id, formNumber: reimbursement.form_number ?? null, }; +} - return payload; +function buildTravelOrderPaymentJournalPayload(input) { + const travelOrder = input?.travelOrder ?? {}; + const travelRequest = travelOrder?.travel_request ?? {}; + const occurredAt = + toIsoString(travelOrder.paid_at) ?? + (travelOrder.payment_date ? `${toDateOnly(travelOrder.payment_date)}T00:00:00.000Z` : null) ?? + toIsoString(travelOrder.updated_at) ?? + toIsoString(travelOrder.created_at); + + return { + journalType: 'travel_order_payment', + sourceEventName: 'travel_order.paid', + entityType: 'travel_order', + entityId: travelOrder.id ?? null, + documentNo: travelOrder.external_order_no ?? null, + currencyCode: travelOrder.currency ?? 'CNY', + totalAmount: travelOrder.amount?.toString?.() ?? null, + occurredAt, + paymentVoucherNo: travelOrder.payment_voucher_no ?? null, + paymentDate: toDateOnly(travelOrder.payment_date), + paymentChannel: travelOrder.payment_channel ?? null, + dimensions: { + departmentId: travelOrder.department_id ?? travelRequest.department_id ?? null, + employeeId: travelOrder.employee_id ?? null, + applicantEmployeeId: travelRequest.applicant_employee_id ?? null, + submitterEmployeeId: travelRequest.submitter_id ?? null, + legalEntityCode: resolveTravelRequestLegalEntityCode(travelOrder), + costCenterCode: resolveTravelRequestCostCenterCode(travelOrder), + budgetPoolCode: resolveTravelRequestBudgetPoolCode(travelOrder), + }, + references: { + travelOrderId: travelOrder.id ?? null, + travelRequestId: travelOrder.travel_request_id ?? null, + requestNo: travelRequest.request_no ?? null, + externalOrderNo: travelOrder.external_order_no ?? null, + source: travelOrder.source ?? null, + linkedReimbursementId: travelOrder.linked_reimbursement_id ?? null, + }, + }; } function buildReimbursementJournalDedupeKey(reimbursementId) { return `${REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX}:${reimbursementId}:paid`; } +function buildTravelOrderPaymentJournalDedupeKey(travelOrderId) { + return `${TRAVEL_ORDER_JOURNAL_DEDUPE_PREFIX}:${travelOrderId}:paid`; +} + async function enqueueReimbursementJournalExport(prisma, config, input) { - const payload = - input?.payload ?? buildReimbursementJournalPayload({ reimbursement: input?.reimbursement }); + const payload = input?.payload ?? buildReimbursementJournalPayload({ reimbursement: input?.reimbursement }); const enqueued = await enqueueIntegrationEvent(prisma, { eventType: 'erp.journal.export', @@ -88,6 +139,25 @@ async function enqueueReimbursementJournalExport(prisma, config, input) { }; } +async function enqueueTravelOrderPaymentJournalExport(prisma, config, input) { + const payload = input?.payload ?? buildTravelOrderPaymentJournalPayload({ travelOrder: input?.travelOrder }); + + const enqueued = await enqueueIntegrationEvent(prisma, { + eventType: 'erp.journal.export', + payload, + dedupeKey: buildTravelOrderPaymentJournalDedupeKey(payload.entityId), + maxAttempts: config.integrationOutboxMaxAttempts, + actorId: input?.actorId ?? null, + requeueOnFailure: true, + }); + + return { + ...enqueued, + eventType: 'erp.journal.export', + payload, + }; +} + function mapIntegrationBinding(record) { if (!record) { return null; @@ -141,6 +211,16 @@ async function findLatestReimbursementJournalOutbox(prisma, reimbursementId) { }); } +async function findLatestTravelOrderPaymentJournalOutbox(prisma, travelOrderId) { + return prisma.integrationOutbox.findFirst({ + where: { + event_type: 'erp.journal.export', + dedupe_key: buildTravelOrderPaymentJournalDedupeKey(travelOrderId), + }, + orderBy: { updated_at: 'desc' }, + }); +} + async function buildReimbursementJournalPreview(prisma, reimbursement) { const [binding, outbox] = await Promise.all([ prisma.integrationBinding.findUnique({ @@ -172,11 +252,97 @@ async function buildReimbursementJournalPreview(prisma, reimbursement) { }; } +async function buildTravelOrderPaymentJournalPreview(prisma, travelOrderInput) { + const travelOrder = travelOrderInput?.travel_request + ? travelOrderInput + : await prisma.travelOrder.findUnique({ + where: { id: travelOrderInput.id }, + select: { + id: true, + source: true, + external_order_no: true, + employee_id: true, + department_id: true, + linked_reimbursement_id: true, + travel_request_id: true, + amount: true, + currency: true, + payment_voucher_no: true, + payment_date: true, + payment_channel: true, + paid_at: true, + created_at: true, + updated_at: true, + travel_request: { + select: { + id: true, + request_no: true, + department_id: true, + applicant_employee_id: true, + submitter_id: true, + legal_entity: { select: { code: true } }, + cost_center: { select: { code: true } }, + budget_pool: { select: { code: true } }, + }, + }, + }, + }); + + if (!travelOrder) { + return { + journal: null, + exportState: { + externalId: null, + lastOutboxId: null, + lastOutboxStatus: null, + lastError: null, + exportedAt: null, + }, + binding: null, + outbox: null, + }; + } + + const [binding, outbox] = await Promise.all([ + prisma.integrationBinding.findUnique({ + where: { + provider_entity_type_local_id: { + provider: 'erp', + entity_type: 'travel_order_payment_journal', + local_id: travelOrder.id, + }, + }, + }), + findLatestTravelOrderPaymentJournalOutbox(prisma, travelOrder.id), + ]); + + const journal = buildTravelOrderPaymentJournalPayload({ travelOrder }); + const exportState = { + externalId: binding?.external_id ?? null, + lastOutboxId: outbox?.id ?? null, + lastOutboxStatus: outbox?.status ?? null, + lastError: outbox?.last_error ?? null, + exportedAt: binding?.last_synced_at?.toISOString?.() ?? outbox?.processed_at?.toISOString?.() ?? null, + }; + + return { + journal, + exportState, + binding: mapIntegrationBinding(binding), + outbox: mapIntegrationOutboxState(outbox), + }; +} + module.exports = { ERP_JOURNAL_EVENT_TYPES, buildReimbursementJournalDedupeKey, buildReimbursementJournalPayload, buildReimbursementJournalPreview, + buildTravelOrderPaymentJournalDedupeKey, + buildTravelOrderPaymentJournalPayload, + buildTravelOrderPaymentJournalPreview, enqueueReimbursementJournalExport, + enqueueTravelOrderPaymentJournalExport, findLatestReimbursementJournalOutbox, + findLatestTravelOrderPaymentJournalOutbox, }; diff --git a/server/src/services/integrations/travel.js b/server/src/services/integrations/travel.js index 41946c65..b1d9f9c2 100644 --- a/server/src/services/integrations/travel.js +++ b/server/src/services/integrations/travel.js @@ -1,3 +1,30 @@ +function toDateOnly(value) { + if (!value) { + return null; + } + + const date = new Date(`${String(value).slice(0, 10)}T00:00:00.000Z`); + return Number.isNaN(date.getTime()) ? null : date; +} + +function toDateTime(value) { + if (!value) { + return null; + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function toMoneyString(value) { + if (value == null || value === "") { + return null; + } + + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric.toFixed(2) : null; +} + function normalizeTravelImportRows(rows) { if (!Array.isArray(rows)) { return []; @@ -12,12 +39,6 @@ function normalizeTravelImportRows(rows) { 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; - const hasTravelRequestId = Object.prototype.hasOwnProperty.call(item ?? {}, "travelRequestId"); const rawTravelRequestId = String(item?.travelRequestId ?? "").trim(); @@ -31,9 +52,84 @@ function normalizeTravelImportRows(rows) { : null, travelRequestId: rawTravelRequestId || null, hasTravelRequestId, - amount: Number.isFinite(amount) ? amount.toFixed(2) : null, + amount: toMoneyString(item?.amount), currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY", - occurredAt, + occurredAt: toDateOnly(item?.occurredAt), + rawPayload: item ?? null, + }; + }) + .filter(Boolean); +} + +function normalizeTravelAuthorizationRows(rows) { + if (!Array.isArray(rows)) { + return []; + } + + return rows + .map((item) => { + const source = String(item?.source ?? "manual").trim().toLowerCase(); + const externalAuthNo = String(item?.externalAuthNo ?? item?.externalAuthNumber ?? "").trim(); + const employeeId = String(item?.employeeId ?? "").trim(); + const travelRequestId = String(item?.travelRequestId ?? "").trim(); + if (!externalAuthNo || !employeeId || !travelRequestId) { + return null; + } + + return { + source, + externalAuthNo, + employeeId, + travelRequestId, + budgetPoolId: + item?.budgetPoolId && String(item.budgetPoolId).trim() + ? String(item.budgetPoolId).trim() + : null, + authorizedAmount: toMoneyString(item?.authorizedAmount), + settledAmount: toMoneyString(item?.settledAmount) ?? "0.00", + reversedAmount: toMoneyString(item?.reversedAmount) ?? "0.00", + currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY", + occurredAt: toDateTime(item?.occurredAt), + expiresAt: toDateTime(item?.expiresAt), + status: String(item?.status ?? "authorized").trim().toLowerCase() || "authorized", + rawPayload: item ?? null, + }; + }) + .filter(Boolean); +} + +function normalizeTravelTransactionRows(rows) { + if (!Array.isArray(rows)) { + return []; + } + + return rows + .map((item) => { + const source = String(item?.source ?? "manual").trim().toLowerCase(); + const externalTxnId = String(item?.externalTxnId ?? item?.externalTransactionId ?? "").trim(); + const employeeId = String(item?.employeeId ?? "").trim(); + const travelRequestId = String(item?.travelRequestId ?? "").trim(); + if (!externalTxnId || !employeeId || !travelRequestId) { + return null; + } + + const rawCardAuthorizationId = String(item?.cardAuthorizationId ?? "").trim(); + const rawTravelOrderId = String(item?.travelOrderId ?? "").trim(); + + return { + source, + externalTxnId, + employeeId, + travelRequestId, + cardAuthorizationId: rawCardAuthorizationId || null, + hasCardAuthorizationId: Object.prototype.hasOwnProperty.call(item ?? {}, "cardAuthorizationId"), + travelOrderId: rawTravelOrderId || null, + hasTravelOrderId: Object.prototype.hasOwnProperty.call(item ?? {}, "travelOrderId"), + amount: toMoneyString(item?.amount), + currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY", + txnType: String(item?.txnType ?? "charge").trim().toLowerCase() || "charge", + status: String(item?.status ?? "pending").trim().toLowerCase() || "pending", + occurredAt: toDateTime(item?.occurredAt), rawPayload: item ?? null, }; }) @@ -42,4 +138,6 @@ function normalizeTravelImportRows(rows) { module.exports = { normalizeTravelImportRows, + normalizeTravelAuthorizationRows, + normalizeTravelTransactionRows, }; diff --git a/server/src/services/travelPayments.js b/server/src/services/travelPayments.js new file mode 100644 index 00000000..ad9857aa --- /dev/null +++ b/server/src/services/travelPayments.js @@ -0,0 +1,725 @@ +const { AppError } = require("../utils/AppError"); +const { toMoneyString } = require("../utils/workflow"); +const { summarizeBudgetLedgerByPool } = require("./purchaseBudget"); +const { buildTravelOrderPaymentJournalPreview } = require("./integrations/erpJournal"); + +function buildTravelPaymentValidationError(details, statusCode = 409) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode, + details, + }); +} + +function toMoneyNumber(value) { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric)) { + return 0; + } + return Number(numeric.toFixed(2)); +} + +function toMoneyFixed(value) { + return toMoneyNumber(value).toFixed(2); +} + +function appendCorporateSummary(summary, entry) { + const poolId = entry?.budget_pool_id; + if (!poolId) { + return; + } + + if (!summary.has(poolId)) { + summary.set(poolId, { + budgetPoolId: poolId, + authorize: 0, + settle: 0, + reverse: 0, + }); + } + + const current = summary.get(poolId); + const amount = toMoneyNumber(entry.amount); + if (entry.kind === "authorize") { + current.authorize = toMoneyNumber(current.authorize + amount); + } else if (entry.kind === "settle") { + current.settle = toMoneyNumber(current.settle + amount); + } else if (entry.kind === "reverse") { + current.reverse = toMoneyNumber(current.reverse + amount); + } +} + +function summarizeCorporateBudgetEntries(entries = []) { + const summary = new Map(); + + for (const entry of entries) { + appendCorporateSummary(summary, entry); + } + + return Array.from(summary.values()).map((item) => ({ + ...item, + outstanding: toMoneyNumber(Math.max(0, item.authorize - item.reverse)), + settlePending: toMoneyNumber(Math.max(0, item.authorize - item.reverse - item.settle)), + })); +} + +async function listEntityBudgetEntries(tx, { entityType, entityId }) { + return tx.budgetLedgerEntry.findMany({ + where: { + entity_type: entityType, + entity_id: entityId, + }, + orderBy: [{ created_at: "asc" }, { id: "asc" }], + select: { + id: true, + budget_pool_id: true, + entity_type: true, + entity_id: true, + kind: true, + amount: true, + created_at: true, + }, + }); +} + +async function listCorporateBudgetEntries(tx, { authorizationIds = [], transactionIds = [] } = {}) { + const filters = []; + + if (authorizationIds.length) { + filters.push({ + entity_type: "card_authorization", + entity_id: { in: authorizationIds }, + }); + } + + if (transactionIds.length) { + filters.push({ + entity_type: "card_transaction", + entity_id: { in: transactionIds }, + }); + } + + if (!filters.length) { + return []; + } + + return tx.budgetLedgerEntry.findMany({ + where: { OR: filters }, + orderBy: [{ created_at: "asc" }, { id: "asc" }], + select: { + id: true, + budget_pool_id: true, + entity_type: true, + entity_id: true, + kind: true, + amount: true, + created_at: true, + }, + }); +} + +async function appendBudgetLedgerEntry(tx, { + budgetPoolId, + entityType, + entityId, + kind, + amount, + actorId, + note, +}) { + return tx.budgetLedgerEntry.create({ + data: { + budget_pool_id: budgetPoolId, + entity_type: entityType, + entity_id: entityId, + kind, + amount: toMoneyFixed(amount), + note, + created_by: actorId, + }, + }); +} + +async function applyBudgetPoolUsageDelta(tx, { budgetPoolId, deltaAmount, actorId }) { + if (!budgetPoolId || !deltaAmount) { + return; + } + + const budgetPool = await tx.budgetPool.findUnique({ + where: { id: budgetPoolId }, + select: { id: true, used_amount: true }, + }); + + if (!budgetPool) { + throw buildTravelPaymentValidationError({ budgetPoolId: "not_found" }); + } + + const nextUsedAmount = Math.max(0, toMoneyNumber(budgetPool.used_amount) + toMoneyNumber(deltaAmount)); + await tx.budgetPool.update({ + where: { id: budgetPoolId }, + data: { + used_amount: toMoneyFixed(nextUsedAmount), + updated_by: actorId, + }, + }); +} + +async function authorizeBudgetForEntity(tx, { + budgetPoolId, + entityId, + amount, + actorId, + note, +}) { + if (!budgetPoolId) { + return { applied: false, authorizedAmount: "0.00", duplicated: false }; + } + + const normalizedAmount = toMoneyNumber(amount); + if (normalizedAmount <= 0) { + return { applied: false, authorizedAmount: "0.00", duplicated: false }; + } + + const existingEntries = await listEntityBudgetEntries(tx, { + entityType: "card_authorization", + entityId, + }); + + if (existingEntries.some((item) => item.kind === "authorize")) { + const current = summarizeCorporateBudgetEntries(existingEntries)[0]; + return { + applied: false, + duplicated: true, + authorizedAmount: toMoneyFixed(current?.authorize ?? normalizedAmount), + }; + } + + await appendBudgetLedgerEntry(tx, { + budgetPoolId, + entityType: "card_authorization", + entityId, + kind: "authorize", + amount: normalizedAmount, + actorId, + note, + }); + + await applyBudgetPoolUsageDelta(tx, { + budgetPoolId, + deltaAmount: normalizedAmount, + actorId, + }); + + return { + applied: true, + duplicated: false, + authorizedAmount: toMoneyFixed(normalizedAmount), + }; +} + +async function settleBudgetForEntity(tx, { + budgetPoolId, + entityId, + amount, + actorId, + note, +}) { + if (!budgetPoolId) { + return { applied: false, settledAmount: "0.00", duplicated: false }; + } + + const normalizedAmount = toMoneyNumber(amount); + if (normalizedAmount <= 0) { + return { applied: false, settledAmount: "0.00", duplicated: false }; + } + + const existingEntries = await listEntityBudgetEntries(tx, { + entityType: "card_transaction", + entityId, + }); + + if (existingEntries.some((item) => item.kind === "settle")) { + const current = summarizeCorporateBudgetEntries(existingEntries)[0]; + return { + applied: false, + duplicated: true, + settledAmount: toMoneyFixed(current?.settle ?? normalizedAmount), + }; + } + + await appendBudgetLedgerEntry(tx, { + budgetPoolId, + entityType: "card_transaction", + entityId, + kind: "settle", + amount: normalizedAmount, + actorId, + note, + }); + + return { + applied: true, + duplicated: false, + settledAmount: toMoneyFixed(normalizedAmount), + }; +} + +async function reverseBudgetForEntity(tx, { + budgetPoolId, + entityId, + amount, + actorId, + note, +}) { + if (!budgetPoolId) { + return { applied: false, reversedAmount: "0.00", duplicated: false }; + } + + const normalizedAmount = toMoneyNumber(amount); + if (normalizedAmount <= 0) { + return { applied: false, reversedAmount: "0.00", duplicated: false }; + } + + const existingEntries = await listEntityBudgetEntries(tx, { + entityType: "card_transaction", + entityId, + }); + + if (existingEntries.some((item) => item.kind === "reverse")) { + const current = summarizeCorporateBudgetEntries(existingEntries)[0]; + return { + applied: false, + duplicated: true, + reversedAmount: toMoneyFixed(current?.reverse ?? normalizedAmount), + }; + } + + await appendBudgetLedgerEntry(tx, { + budgetPoolId, + entityType: "card_transaction", + entityId, + kind: "reverse", + amount: normalizedAmount, + actorId, + note, + }); + + await applyBudgetPoolUsageDelta(tx, { + budgetPoolId, + deltaAmount: -normalizedAmount, + actorId, + }); + + return { + applied: true, + duplicated: false, + reversedAmount: toMoneyFixed(normalizedAmount), + }; +} + +function mapBudgetPoolSummary(summaryItems, budgetPoolMap, mode) { + return summaryItems.map((item) => { + const pool = budgetPoolMap.get(item.budgetPoolId); + const base = { + budgetPoolId: item.budgetPoolId, + budgetPoolCode: pool?.code ?? null, + budgetPoolName: pool?.name ?? null, + outstanding: toMoneyFixed(item.outstanding), + }; + + if (mode === "request") { + return { + ...base, + reserve: toMoneyFixed(item.reserve), + confirm: toMoneyFixed(item.confirm), + release: toMoneyFixed(item.release), + confirmPending: toMoneyFixed(item.confirmPending), + }; + } + + return { + ...base, + authorize: toMoneyFixed(item.authorize), + settle: toMoneyFixed(item.settle), + reverse: toMoneyFixed(item.reverse), + settlePending: toMoneyFixed(item.settlePending), + }; + }); +} + +function buildTransactionAnomalies(transaction, order) { + const anomalies = []; + + if (!transaction.travel_order_id) { + anomalies.push({ + code: "missing_order", + level: "medium", + title: "未匹配差旅订单", + detail: `交易 ${transaction.external_txn_id} 尚未关联 travel_order。`, + transactionId: transaction.id, + travelOrderId: null, + }); + } + + if (order) { + if (String(order.currency ?? "") !== String(transaction.currency ?? "")) { + anomalies.push({ + code: "currency_mismatch", + level: "high", + title: "币种不一致", + detail: `订单 ${order.external_order_no} 与交易 ${transaction.external_txn_id} 币种不一致。`, + transactionId: transaction.id, + travelOrderId: order.id, + }); + } + + if (toMoneyFixed(order.amount) !== toMoneyFixed(transaction.amount)) { + anomalies.push({ + code: "amount_mismatch", + level: "high", + title: "金额不一致", + detail: `订单 ${order.external_order_no} 与交易 ${transaction.external_txn_id} 金额不一致。`, + transactionId: transaction.id, + travelOrderId: order.id, + }); + } + } + + if (transaction.freeze_reason_code) { + anomalies.push({ + code: transaction.freeze_reason_code, + level: "medium", + title: "已标记人工冻结建议", + detail: transaction.freeze_reason_note || `交易 ${transaction.external_txn_id} 已被标记人工复核。`, + transactionId: transaction.id, + travelOrderId: transaction.travel_order_id ?? null, + }); + } + + return anomalies; +} + +function isOrderAmountAndCurrencyMatched(order, transaction) { + if (!order || !transaction) { + return false; + } + + return ( + toMoneyFixed(order.amount) === toMoneyFixed(transaction.amount) && + String(order.currency ?? "").toUpperCase() === String(transaction.currency ?? "").toUpperCase() + ); +} + +function mapAuthorization(item) { + return { + id: item.id, + source: item.source, + externalAuthNo: item.external_auth_no, + employeeId: item.employee_id, + departmentId: item.department_id ?? null, + travelRequestId: item.travel_request_id, + budgetPoolId: item.budget_pool_id ?? null, + legalEntityId: item.legal_entity_id, + costCenterId: item.cost_center_id, + authorizedAmount: toMoneyFixed(item.authorized_amount), + settledAmount: toMoneyFixed(item.settled_amount), + reversedAmount: toMoneyFixed(item.reversed_amount), + currency: item.currency, + occurredAt: item.occurred_at?.toISOString?.() ?? null, + expiresAt: item.expires_at?.toISOString?.() ?? null, + status: item.status, + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + }; +} + +function mapTransaction(item, ordersById) { + const order = item.travel_order_id ? ordersById.get(item.travel_order_id) ?? null : null; + + return { + id: item.id, + source: item.source, + externalTxnId: item.external_txn_id, + cardAuthorizationId: item.card_authorization_id ?? null, + employeeId: item.employee_id, + departmentId: item.department_id ?? null, + travelRequestId: item.travel_request_id, + travelOrderId: item.travel_order_id ?? null, + amount: toMoneyFixed(item.amount), + currency: item.currency, + txnType: item.txn_type, + status: item.status, + occurredAt: item.occurred_at?.toISOString?.() ?? null, + freezeSuggestedAt: item.freeze_suggested_at?.toISOString?.() ?? null, + freezeSuggestedBy: item.freeze_suggested_by ?? null, + freezeReasonCode: item.freeze_reason_code ?? null, + freezeReasonNote: item.freeze_reason_note ?? null, + matchedOrder: order + ? { + id: order.id, + externalOrderNo: order.external_order_no, + status: order.status, + amount: toMoneyFixed(order.amount), + currency: order.currency, + exactMatch: isOrderAmountAndCurrencyMatched(order, item), + } + : null, + }; +} + +function mapInvoice(item) { + return { + id: item.id, + status: item.status, + originalName: item.original_name ?? null, + totalAmount: toMoneyString(item.total_amount), + invoiceCode: item.invoice_code ?? null, + invoiceNumber: item.invoice_number ?? null, + invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, + linkedReimbursementId: item.linked_reimbursement_id ?? null, + linkedTravelOrderId: item.travel_order_id ?? null, + }; +} + +async function buildTravelPaymentTrack(prisma, travelRequestId, { canManagePayments = false } = {}) { + const request = await prisma.travelRequest.findUnique({ + where: { id: travelRequestId }, + select: { + id: true, + request_no: true, + budget_pool_id: true, + travel_orders: { + orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], + select: { + id: true, + source: true, + external_order_no: true, + employee_id: true, + department_id: true, + linked_reimbursement_id: true, + travel_request_id: true, + occurred_at: true, + amount: true, + currency: true, + status: true, + payment_voucher_no: true, + payment_date: true, + payment_channel: true, + paid_at: true, + paid_by: true, + created_at: true, + updated_at: true, + }, + }, + card_authorizations: { + orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], + select: { + id: true, + source: true, + external_auth_no: true, + employee_id: true, + department_id: true, + travel_request_id: true, + budget_pool_id: true, + legal_entity_id: true, + cost_center_id: true, + authorized_amount: true, + settled_amount: true, + reversed_amount: true, + currency: true, + occurred_at: true, + expires_at: true, + status: true, + created_at: true, + updated_at: true, + }, + }, + card_transactions: { + orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], + select: { + id: true, + source: true, + external_txn_id: true, + card_authorization_id: true, + employee_id: true, + department_id: true, + travel_request_id: true, + travel_order_id: true, + amount: true, + currency: true, + txn_type: true, + status: true, + occurred_at: true, + freeze_suggested_at: true, + freeze_suggested_by: true, + freeze_reason_code: true, + freeze_reason_note: true, + created_at: true, + updated_at: true, + }, + }, + }, + }); + + if (!request) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const orderIds = request.travel_orders.map((item) => item.id); + const authorizationIds = request.card_authorizations.map((item) => item.id); + const transactionIds = request.card_transactions.map((item) => item.id); + + const [boundInvoices, requestBudgetEntries, corporateBudgetEntries] = await Promise.all([ + orderIds.length + ? prisma.invoicePool.findMany({ + where: { travel_order_id: { in: orderIds } }, + orderBy: [{ created_at: "desc" }], + select: { + id: true, + status: true, + original_name: true, + total_amount: true, + invoice_code: true, + invoice_number: true, + invoice_date: true, + linked_reimbursement_id: true, + travel_order_id: true, + }, + }) + : Promise.resolve([]), + prisma.budgetLedgerEntry.findMany({ + where: { + entity_type: "travel_request", + entity_id: travelRequestId, + }, + orderBy: [{ created_at: "asc" }, { id: "asc" }], + select: { + id: true, + budget_pool_id: true, + kind: true, + amount: true, + created_at: true, + }, + }), + listCorporateBudgetEntries(prisma, { authorizationIds, transactionIds }), + ]); + + const budgetPoolIds = Array.from(new Set([ + ...requestBudgetEntries.map((item) => item.budget_pool_id).filter(Boolean), + ...corporateBudgetEntries.map((item) => item.budget_pool_id).filter(Boolean), + ...request.card_authorizations.map((item) => item.budget_pool_id).filter(Boolean), + ])); + + const budgetPools = budgetPoolIds.length + ? await prisma.budgetPool.findMany({ + where: { id: { in: budgetPoolIds } }, + select: { id: true, code: true, name: true }, + }) + : []; + const budgetPoolMap = new Map(budgetPools.map((item) => [item.id, item])); + + const orderInvoicesMap = new Map(); + for (const invoice of boundInvoices) { + if (!invoice.travel_order_id) { + continue; + } + const items = orderInvoicesMap.get(invoice.travel_order_id) ?? []; + items.push(mapInvoice(invoice)); + orderInvoicesMap.set(invoice.travel_order_id, items); + } + + const ordersById = new Map(request.travel_orders.map((item) => [item.id, item])); + const transactionsByOrderId = new Map(); + for (const transaction of request.card_transactions) { + if (!transaction.travel_order_id) { + continue; + } + const items = transactionsByOrderId.get(transaction.travel_order_id) ?? []; + items.push(transaction); + transactionsByOrderId.set(transaction.travel_order_id, items); + } + + const orderStages = await Promise.all( + request.travel_orders.map(async (order) => { + const preview = await buildTravelOrderPaymentJournalPreview(prisma, order); + return [order.id, preview]; + }), + ); + const orderStageMap = new Map(orderStages); + + const anomalies = request.card_transactions.flatMap((transaction) => + buildTransactionAnomalies(transaction, transaction.travel_order_id ? ordersById.get(transaction.travel_order_id) : null), + ); + + return { + travelRequestId: request.id, + requestNo: request.request_no ?? null, + requestBudgetTrack: { + items: mapBudgetPoolSummary(summarizeBudgetLedgerByPool(requestBudgetEntries), budgetPoolMap, "request"), + }, + corporateBudgetTrack: { + items: mapBudgetPoolSummary(summarizeCorporateBudgetEntries(corporateBudgetEntries), budgetPoolMap, "corporate"), + }, + authorizations: request.card_authorizations.map(mapAuthorization), + transactions: request.card_transactions.map((item) => mapTransaction(item, ordersById)), + travelOrders: request.travel_orders.map((order) => { + const matchedTransactions = (transactionsByOrderId.get(order.id) ?? []).map((item) => mapTransaction(item, ordersById)); + const stage = orderStageMap.get(order.id) ?? { + journal: null, + exportState: { + externalId: null, + lastOutboxId: null, + lastOutboxStatus: null, + lastError: null, + exportedAt: null, + }, + binding: null, + outbox: null, + }; + + return { + id: order.id, + externalOrderNo: order.external_order_no, + source: order.source, + amount: toMoneyFixed(order.amount), + currency: order.currency, + status: order.status, + paymentVoucherNo: order.payment_voucher_no ?? null, + paymentDate: order.payment_date?.toISOString?.()?.slice(0, 10) ?? null, + paymentChannel: order.payment_channel ?? null, + paidAt: order.paid_at?.toISOString?.() ?? null, + paidBy: order.paid_by ?? null, + boundInvoices: orderInvoicesMap.get(order.id) ?? [], + matchedTransactions, + erpStage: { + binding: stage.binding, + outbox: stage.outbox, + exportState: stage.exportState, + }, + actions: { + canMarkPaid: canManagePayments && order.status === "confirmed", + canBindInvoice: canManagePayments && ["confirmed", "paid"].includes(order.status), + }, + }; + }), + anomalies, + permissions: { + canManagePayments, + }, + }; +} + +module.exports = { + authorizeBudgetForEntity, + settleBudgetForEntity, + reverseBudgetForEntity, + summarizeCorporateBudgetEntries, + buildTravelPaymentTrack, + buildTravelPaymentValidationError, + isOrderAmountAndCurrencyMatched, +}; diff --git a/server/tests/unit/constants/permissions.test.js b/server/tests/unit/constants/permissions.test.js index 1e47797f..91cd859d 100644 --- a/server/tests/unit/constants/permissions.test.js +++ b/server/tests/unit/constants/permissions.test.js @@ -53,6 +53,14 @@ describe("Permission Constants", () => { expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.manage")).toBe(true); }); + it("grants travel.payment.manage only to configured finance and administration roles", () => { + expect(hasPermission(resolvePermissionsForRoles(["finance"]), "travel.payment.manage")).toBe(true); + expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.payment.manage")).toBe(true); + expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.payment.manage")).toBe(true); + expect(hasPermission(resolvePermissionsForRoles(["manager"]), "travel.payment.manage")).toBe(false); + expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.payment.manage")).toBe(false); + }); + it("grants ai.assist only to finance manager and admin", () => { expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "ai.assist")).toBe(true); expect(hasPermission(resolvePermissionsForRoles(["finance"]), "ai.assist")).toBe(false); diff --git a/server/tests/unit/routes/invoicePool.travel-bind.test.js b/server/tests/unit/routes/invoicePool.travel-bind.test.js new file mode 100644 index 00000000..2bbd8bda --- /dev/null +++ b/server/tests/unit/routes/invoicePool.travel-bind.test.js @@ -0,0 +1,192 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const invoicePoolRoutes = require("../../../src/routes/invoicePool"); + +function buildQueryRawHandler(state) { + return vi.fn(async (strings) => { + const sql = Array.isArray(strings) ? strings.join("?") : String(strings); + if (sql.includes('FROM "invoice_pool"')) { + return state.invoice ? [{ + id: state.invoice.id, + status: state.invoice.status, + linked_reimbursement_id: state.invoice.linked_reimbursement_id, + travel_order_id: state.invoice.travel_order_id, + }] : []; + } + if (sql.includes('FROM "travel_orders"')) { + return state.travelOrder ? [{ id: state.travelOrder.id, status: state.travelOrder.status }] : []; + } + if (sql.includes('FROM "reimbursement_forms"')) { + return state.reimbursement ? [state.reimbursement] : []; + } + return []; + }); +} + +function buildPrisma({ invoice, travelOrder, reimbursement = null }) { + const state = { + invoice: { ...invoice }, + travelOrder: travelOrder ? { ...travelOrder } : null, + reimbursement, + }; + + const prisma = { + invoicePool: { + findUnique: vi.fn().mockImplementation(async () => state.invoice), + update: vi.fn().mockImplementation(async ({ data, select }) => { + state.invoice = { ...state.invoice, ...data }; + if (!select) { + return state.invoice; + } + return { + ...state.invoice, + verification_records: [], + }; + }), + }, + reimbursementItem: { + findFirst: vi.fn().mockResolvedValue({ id: "item-1" }), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit-1" }), + }, + $queryRaw: buildQueryRawHandler(state), + $transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)), + }; + + return { prisma, state }; +} + +async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"] }) { + const app = Fastify(); + + app.decorate("prisma", prisma); + app.decorate("config", { + storageDir: "/tmp", + appOrigin: "http://localhost:5173", + maxUploadSizeBytes: 10 * 1024 * 1024, + invoiceArchiveUnusedDays: 30, + }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + request.employee = { id: "actor-1", permissionCodes }; + }); + app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes("*") || (employee?.permissionCodes ?? []).includes(permissionCode)); + app.decorate("requirePermission", (permissionCode) => async (request) => { + await app.authenticate(request); + if (!app.hasPermission(request.employee, permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } + }); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(invoicePoolRoutes, { prefix: "/api" }); + return app; +} + +describe("invoicePool travel-order bind routes", () => { + it("binds invoice to confirmed travel order", async () => { + const { prisma, state } = buildPrisma({ + invoice: { + id: "11111111-1111-4111-8111-111111111111", + status: "available", + linked_reimbursement_id: null, + linked_item_id: null, + travel_order_id: null, + total_amount: "500.00", + verification_records: [], + }, + travelOrder: { id: "22222222-2222-4222-8222-222222222222", status: "confirmed" }, + }); + const app = await buildApp({ prisma }); + + const response = await app.inject({ + method: "POST", + url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind", + headers: { authorization: "Bearer test" }, + payload: { travelOrderId: "22222222-2222-4222-8222-222222222222" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.linkedTravelOrderId).toBe("22222222-2222-4222-8222-222222222222"); + expect(state.invoice.travel_order_id).toBe("22222222-2222-4222-8222-222222222222"); + + await app.close(); + }); + + it("rejects binding when invoice is already linked elsewhere", async () => { + const { prisma } = buildPrisma({ + invoice: { + id: "11111111-1111-4111-8111-111111111111", + status: "bound", + linked_reimbursement_id: "reim-1", + linked_item_id: null, + travel_order_id: null, + total_amount: "500.00", + verification_records: [], + }, + travelOrder: { id: "22222222-2222-4222-8222-222222222222", status: "confirmed" }, + }); + const app = await buildApp({ prisma }); + + const response = await app.inject({ + method: "POST", + url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind", + headers: { authorization: "Bearer test" }, + payload: { travelOrderId: "22222222-2222-4222-8222-222222222222" }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.code).toBe("VALID_005"); + + await app.close(); + }); + + it("unbinds invoice from travel order with payment manage permission", async () => { + const { prisma, state } = buildPrisma({ + invoice: { + id: "11111111-1111-4111-8111-111111111111", + status: "bound", + linked_reimbursement_id: null, + linked_item_id: null, + travel_order_id: "22222222-2222-4222-8222-222222222222", + total_amount: "500.00", + verification_records: [], + }, + travelOrder: { id: "22222222-2222-4222-8222-222222222222", status: "paid" }, + }); + const app = await buildApp({ prisma }); + + const response = await app.inject({ + method: "POST", + url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/unbind", + headers: { authorization: "Bearer test" }, + payload: {}, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.linkedTravelOrderId).toBe(null); + expect(state.invoice.status).toBe("available"); + + await app.close(); + }); +}); diff --git a/server/tests/unit/routes/travelAuthorizations.test.js b/server/tests/unit/routes/travelAuthorizations.test.js new file mode 100644 index 00000000..7a95d7f2 --- /dev/null +++ b/server/tests/unit/routes/travelAuthorizations.test.js @@ -0,0 +1,267 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const travelAuthorizationsRoutes = require("../../../src/routes/travelAuthorizations"); + +function buildAuthorizationRecord(overrides = {}) { + const now = new Date("2026-03-10T00:00:00.000Z"); + return { + id: "auth-1", + source: "corp-card", + external_auth_no: "AUTH-001", + employee_id: "employee-1", + department_id: "dept-1", + travel_request_id: "11111111-1111-4111-8111-111111111111", + budget_pool_id: "pool-1", + legal_entity_id: "legal-1", + cost_center_id: "cc-1", + authorized_amount: "1000.00", + settled_amount: "0.00", + reversed_amount: "0.00", + currency: "CNY", + occurred_at: now, + expires_at: null, + status: "authorized", + created_at: now, + updated_at: now, + ...overrides, + }; +} + +function buildPrisma({ employees = [], travelRequests = [], existing = null, existingLedger = [] } = {}) { + const prisma = { + employee: { + findMany: vi.fn().mockResolvedValue(employees), + }, + travelRequest: { + findMany: vi.fn().mockResolvedValue(travelRequests), + }, + cardAuthorization: { + findUnique: vi.fn().mockResolvedValue(existing), + findMany: vi.fn().mockResolvedValue(existing ? [existing] : []), + count: vi.fn().mockResolvedValue(existing ? 1 : 0), + upsert: vi.fn().mockImplementation(async ({ create }) => buildAuthorizationRecord({ + source: create.source, + external_auth_no: create.external_auth_no, + employee_id: create.employee_id, + department_id: create.department_id, + travel_request_id: create.travel_request_id, + budget_pool_id: create.budget_pool_id, + legal_entity_id: create.legal_entity_id, + cost_center_id: create.cost_center_id, + authorized_amount: create.authorized_amount, + settled_amount: create.settled_amount, + reversed_amount: create.reversed_amount, + currency: create.currency, + occurred_at: create.occurred_at, + expires_at: create.expires_at, + status: create.status, + })), + }, + budgetLedgerEntry: { + findMany: vi.fn().mockResolvedValue(existingLedger), + create: vi.fn().mockResolvedValue({ id: "ledger-1" }), + }, + budgetPool: { + findUnique: vi.fn().mockResolvedValue({ id: "pool-1", used_amount: "0.00" }), + update: vi.fn().mockResolvedValue({ id: "pool-1" }), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit-1" }), + }, + $transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)), + }; + + return prisma; +} + +async function buildApp({ prisma, permissionCodes = ["integration.manage", "travel.payment.manage"] }) { + const app = Fastify(); + + app.decorate("prisma", prisma); + app.decorate("config", { travelSyncEnabled: true }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + request.employee = { id: "actor-1", permissionCodes }; + }); + app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes(permissionCode)); + app.decorate("requirePermission", (permissionCode) => async (request) => { + await app.authenticate(request); + if (!app.hasPermission(request.employee, permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } + }); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(travelAuthorizationsRoutes, { prefix: "/api" }); + return app; +} + +describe("travelAuthorizations routes", () => { + it("imports authorizations on happy path", async () => { + const prisma = buildPrisma({ + employees: [{ id: "employee-1", department_id: "dept-1" }], + travelRequests: [{ + id: "11111111-1111-4111-8111-111111111111", + applicant_employee_id: "employee-1", + department_id: "dept-1", + budget_pool_id: "pool-1", + legal_entity_id: "legal-1", + cost_center_id: "cc-1", + }], + }); + const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); + + const response = await app.inject({ + method: "POST", + url: "/api/travel/authorizations/import", + headers: { authorization: "Bearer test" }, + payload: { + authorizations: [{ + source: "corp-card", + externalAuthNo: "AUTH-001", + employeeId: "employee-1", + travelRequestId: "11111111-1111-4111-8111-111111111111", + authorizedAmount: "1000.00", + }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual(expect.objectContaining({ imported: 1, authorizedCount: 1 })); + + await app.close(); + }); + + it("treats duplicate import as idempotent", async () => { + const existing = buildAuthorizationRecord(); + const prisma = buildPrisma({ + employees: [{ id: "employee-1", department_id: "dept-1" }], + travelRequests: [{ + id: "11111111-1111-4111-8111-111111111111", + applicant_employee_id: "employee-1", + department_id: "dept-1", + budget_pool_id: "pool-1", + legal_entity_id: "legal-1", + cost_center_id: "cc-1", + }], + existing, + existingLedger: [{ + id: "ledger-1", + budget_pool_id: "pool-1", + entity_type: "card_authorization", + entity_id: existing.id, + kind: "authorize", + amount: "1000.00", + created_at: new Date("2026-03-10T00:00:00.000Z"), + }], + }); + const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); + + const response = await app.inject({ + method: "POST", + url: "/api/travel/authorizations/import", + headers: { authorization: "Bearer test" }, + payload: { + authorizations: [{ + source: "corp-card", + externalAuthNo: "AUTH-001", + employeeId: "employee-1", + travelRequestId: "11111111-1111-4111-8111-111111111111", + authorizedAmount: "1000.00", + }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual(expect.objectContaining({ imported: 1, authorizedCount: 0 })); + + await app.close(); + }); + + it("skips rows when request missing", async () => { + const prisma = buildPrisma({ + employees: [{ id: "employee-1", department_id: "dept-1" }], + travelRequests: [], + }); + const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); + + const response = await app.inject({ + method: "POST", + url: "/api/travel/authorizations/import", + headers: { authorization: "Bearer test" }, + payload: { + authorizations: [{ + source: "corp-card", + externalAuthNo: "AUTH-404", + employeeId: "employee-1", + travelRequestId: "11111111-1111-4111-8111-111111111111", + authorizedAmount: "1000.00", + }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.skipped).toEqual([ + expect.objectContaining({ reason: "travel_request_not_found" }), + ]); + + await app.close(); + }); + + it("skips rows when request employee mismatches", async () => { + const prisma = buildPrisma({ + employees: [{ id: "employee-1", department_id: "dept-1" }], + travelRequests: [{ + id: "11111111-1111-4111-8111-111111111111", + applicant_employee_id: "employee-9", + department_id: "dept-1", + budget_pool_id: "pool-1", + legal_entity_id: "legal-1", + cost_center_id: "cc-1", + }], + }); + const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); + + const response = await app.inject({ + method: "POST", + url: "/api/travel/authorizations/import", + headers: { authorization: "Bearer test" }, + payload: { + authorizations: [{ + source: "corp-card", + externalAuthNo: "AUTH-002", + employeeId: "employee-1", + travelRequestId: "11111111-1111-4111-8111-111111111111", + authorizedAmount: "1000.00", + }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.skipped).toEqual([ + expect.objectContaining({ reason: "travel_request_employee_mismatch" }), + ]); + + await app.close(); + }); +}); diff --git a/server/tests/unit/routes/travelOrders.test.js b/server/tests/unit/routes/travelOrders.test.js index d4209bc7..f1aa65f3 100644 --- a/server/tests/unit/routes/travelOrders.test.js +++ b/server/tests/unit/routes/travelOrders.test.js @@ -3,8 +3,83 @@ const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const travelOrdersRoutes = require("../../../src/routes/travelOrders"); -function buildPrisma({ employees = [], travelRequests = [], upsertImpl } = {}) { +function buildQueryRawHandler({ currentOrder, reimbursement = null }) { + return vi.fn(async (strings) => { + const sql = Array.isArray(strings) ? strings.join("?") : String(strings); + if (sql.includes('FROM "travel_orders"')) { + return currentOrder ? [currentOrder] : []; + } + if (sql.includes('FROM "reimbursement_forms"')) { + return reimbursement ? [reimbursement] : []; + } + return []; + }); +} + +function buildTravelOrderRecord(overrides = {}) { + const now = new Date("2026-03-10T10:00:00.000Z"); return { + id: "11111111-1111-4111-8111-111111111111", + source: "ctrip", + external_order_no: "EXT-001", + employee_id: "employee-1", + department_id: "dept-1", + linked_reimbursement_id: null, + travel_request_id: "22222222-2222-4222-8222-222222222222", + amount: "1888.00", + currency: "CNY", + status: "confirmed", + payment_voucher_no: null, + payment_date: null, + payment_channel: null, + paid_at: null, + paid_by: null, + created_at: now, + updated_at: now, + ...overrides, + }; +} + +function buildTravelOrderWithRequest(order) { + return { + ...order, + travel_request: { + id: order.travel_request_id, + request_no: "CL-20260310-0001", + department_id: order.department_id, + applicant_employee_id: "employee-1", + submitter_id: null, + legal_entity: { code: "HY" }, + cost_center: { code: "CC001" }, + budget_pool: { code: "POOL-1" }, + }, + }; +} + +function buildPrisma({ + employees = [], + travelRequests = [], + existingOrders = [], + currentOrder = null, + matchedTransaction = { id: "txn-1" }, + featureFlagRecord = { + id: "flag-1", + flag_key: "erp_sync", + override_mode: "inherit", + rollout_type: "global", + department_ids: [], + note: "", + version: 1, + created_at: new Date("2026-03-10T00:00:00.000Z"), + updated_at: new Date("2026-03-10T00:00:00.000Z"), + }, + existingOutbox = null, +} = {}) { + const state = { + currentOrder: currentOrder ? { ...currentOrder } : null, + }; + + const prisma = { employee: { findMany: vi.fn().mockResolvedValue(employees), }, @@ -12,40 +87,79 @@ function buildPrisma({ employees = [], travelRequests = [], upsertImpl } = {}) { findMany: vi.fn().mockResolvedValue(travelRequests), }, travelOrder: { - upsert: vi.fn().mockImplementation(upsertImpl ?? (async ({ create }) => ({ - id: "order-1", + findMany: vi.fn().mockResolvedValue(existingOrders), + upsert: vi.fn().mockImplementation(async ({ create, update }) => ({ + id: state.currentOrder?.id ?? "order-1", source: create.source, external_order_no: create.external_order_no, employee_id: create.employee_id, department_id: create.department_id, linked_reimbursement_id: create.linked_reimbursement_id, - travel_request_id: create.travel_request_id, + travel_request_id: update.travel_request_id === undefined ? state.currentOrder?.travel_request_id ?? create.travel_request_id : update.travel_request_id, occurred_at: create.occurred_at, amount: create.amount, currency: create.currency, - status: create.status, + status: update.status, + payment_voucher_no: state.currentOrder?.payment_voucher_no ?? null, + payment_date: state.currentOrder?.payment_date ?? null, + payment_channel: state.currentOrder?.payment_channel ?? null, + paid_at: state.currentOrder?.paid_at ?? null, + paid_by: state.currentOrder?.paid_by ?? null, created_at: new Date("2026-03-10T00:00:00.000Z"), updated_at: new Date("2026-03-10T00:00:00.000Z"), - }))), + })), + update: vi.fn().mockImplementation(async ({ data }) => { + state.currentOrder = { + ...state.currentOrder, + ...data, + updated_at: new Date("2026-03-10T11:00:00.000Z"), + }; + return state.currentOrder; + }), + findUnique: vi.fn().mockImplementation(async () => { + if (!state.currentOrder) { + return null; + } + return buildTravelOrderWithRequest(state.currentOrder); + }), + }, + cardTransaction: { + findFirst: vi.fn().mockResolvedValue(matchedTransaction), + }, + featureFlag: { + findUnique: vi.fn().mockResolvedValue(featureFlagRecord), + }, + integrationOutbox: { + findUnique: vi.fn().mockResolvedValue(existingOutbox), + create: vi.fn().mockResolvedValue({ id: "outbox-1", status: "pending" }), }, auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }), }, + $queryRaw: buildQueryRawHandler({ currentOrder: state.currentOrder }), + $transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)), }; + + return { prisma, state }; } -async function buildApp({ prisma }) { +async function buildApp({ prisma, permissionCodes = ["integration.manage", "travel.payment.manage"] , config = {} }) { const app = Fastify(); app.decorate("prisma", prisma); - app.decorate("config", { travelSyncEnabled: true }); + app.decorate("config", { + travelSyncEnabled: true, + erpSyncEnabled: true, + integrationOutboxMaxAttempts: 5, + ...config, + }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } - request.employee = { id: "actor-1", permissionCodes: ["integration.manage"] }; + request.employee = { id: "actor-1", department_id: "dept-1", permissionCodes }; }); - app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes(permissionCode)); + app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes("*") || (employee?.permissionCodes ?? []).includes(permissionCode)); app.decorate("requirePermission", (permissionCode) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permissionCode)) { @@ -76,11 +190,11 @@ async function buildApp({ prisma }) { describe("travelOrders routes", () => { it("imports rows with legal travelRequestId linkage", async () => { - const prisma = buildPrisma({ + const { prisma } = buildPrisma({ employees: [{ id: "employee-1", department_id: "dept-1" }], travelRequests: [{ id: "11111111-1111-4111-8111-111111111111", applicant_employee_id: "employee-1" }], }); - const app = await buildApp({ prisma }); + const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); const response = await app.inject({ method: "POST", @@ -106,85 +220,24 @@ describe("travelOrders routes", () => { linkedTravelRequestCount: 1, }), ); - expect(response.json().data.items[0].travelRequestId).toBe("11111111-1111-4111-8111-111111111111"); - - await app.close(); - }); - - it("skips rows when travel request employee mismatches", async () => { - const prisma = buildPrisma({ - employees: [{ id: "employee-1", department_id: "dept-1" }], - travelRequests: [{ id: "11111111-1111-4111-8111-111111111111", applicant_employee_id: "employee-2" }], - }); - const app = await buildApp({ prisma }); - - const response = await app.inject({ - method: "POST", - url: "/api/travel/orders/import", - headers: { authorization: "Bearer test" }, - payload: { - orders: [{ source: "ctrip", externalOrderNo: "EXT-002", employeeId: "employee-1", travelRequestId: "11111111-1111-4111-8111-111111111111" }], - }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json().data.imported).toBe(0); - expect(response.json().data.skipped).toEqual([ - expect.objectContaining({ reason: "travel_request_employee_mismatch" }), - ]); - - await app.close(); - }); - - it("skips rows when travel request does not exist", async () => { - const prisma = buildPrisma({ - employees: [{ id: "employee-1", department_id: "dept-1" }], - travelRequests: [], - }); - const app = await buildApp({ prisma }); - - const response = await app.inject({ - method: "POST", - url: "/api/travel/orders/import", - headers: { authorization: "Bearer test" }, - payload: { - orders: [{ source: "ctrip", externalOrderNo: "EXT-003", employeeId: "employee-1", travelRequestId: "11111111-1111-4111-8111-111111111111" }], - }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json().data.imported).toBe(0); - expect(response.json().data.skipped).toEqual([ - expect.objectContaining({ reason: "travel_request_not_found" }), - ]); await app.close(); }); it("preserves existing travel linkage when new row omits travelRequestId", async () => { - const prisma = buildPrisma({ - employees: [{ id: "employee-1", department_id: "dept-1" }], - travelRequests: [], - upsertImpl: async ({ create, update }) => { - expect(update.travel_request_id).toBeUndefined(); - return { - id: "order-2", - source: create.source, - external_order_no: create.external_order_no, - employee_id: create.employee_id, - department_id: create.department_id, - linked_reimbursement_id: create.linked_reimbursement_id, - travel_request_id: "existing-travel-request", - occurred_at: create.occurred_at, - amount: create.amount, - currency: create.currency, - status: "imported", - created_at: new Date("2026-03-10T00:00:00.000Z"), - updated_at: new Date("2026-03-10T00:00:00.000Z"), - }; - }, + const existingOrder = buildTravelOrderRecord({ + id: "order-2", + source: "ctrip", + external_order_no: "EXT-004", + status: "paid", + travel_request_id: "existing-travel-request", }); - const app = await buildApp({ prisma }); + const { prisma } = buildPrisma({ + employees: [{ id: "employee-1", department_id: "dept-1" }], + existingOrders: [existingOrder], + currentOrder: existingOrder, + }); + const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); const response = await app.inject({ method: "POST", @@ -197,6 +250,153 @@ describe("travelOrders routes", () => { expect(response.statusCode).toBe(200); expect(response.json().data.items[0].travelRequestId).toBe("existing-travel-request"); + expect(response.json().data.items[0].status).toBe("paid"); + + await app.close(); + }); + + it("marks confirmed order as paid and enqueues ERP journal", async () => { + const { prisma, state } = buildPrisma({ + currentOrder: buildTravelOrderRecord(), + }); + const app = await buildApp({ prisma }); + + const response = await app.inject({ + method: "POST", + url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid", + headers: { authorization: "Bearer test" }, + payload: { + paymentVoucherNo: "BANK-001", + paymentDate: "2026-03-10", + paymentChannel: "bank_transfer", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual( + expect.objectContaining({ + status: "paid", + paymentVoucherNo: "BANK-001", + paymentDate: "2026-03-10", + paymentChannel: "bank_transfer", + erpJournal: expect.objectContaining({ enqueued: true, outboxId: "outbox-1" }), + }), + ); + expect(state.currentOrder.status).toBe("paid"); + expect(prisma.integrationOutbox.create).toHaveBeenCalledTimes(1); + + await app.close(); + }); + + it("returns idempotent success for already paid order with same payload", async () => { + const currentOrder = buildTravelOrderRecord({ + status: "paid", + payment_voucher_no: "BANK-001", + payment_date: new Date("2026-03-10T00:00:00.000Z"), + payment_channel: "bank_transfer", + paid_at: new Date("2026-03-10T12:00:00.000Z"), + paid_by: "actor-1", + }); + const { prisma } = buildPrisma({ + currentOrder, + existingOutbox: { id: "outbox-existing", status: "pending", payload: {} }, + }); + const app = await buildApp({ prisma }); + + const response = await app.inject({ + method: "POST", + url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid", + headers: { authorization: "Bearer test" }, + payload: { + paymentVoucherNo: "BANK-001", + paymentDate: "2026-03-10", + paymentChannel: "bank_transfer", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.erpJournal).toEqual( + expect.objectContaining({ enqueued: true, outboxId: "outbox-existing" }), + ); + expect(prisma.travelOrder.update).not.toHaveBeenCalled(); + + await app.close(); + }); + + it("rejects conflicting paid payload", async () => { + const { prisma } = buildPrisma({ + currentOrder: buildTravelOrderRecord({ + status: "paid", + payment_voucher_no: "BANK-001", + payment_date: new Date("2026-03-10T00:00:00.000Z"), + payment_channel: "bank_transfer", + }), + }); + const app = await buildApp({ prisma }); + + const response = await app.inject({ + method: "POST", + url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid", + headers: { authorization: "Bearer test" }, + payload: { + paymentVoucherNo: "BANK-002", + paymentDate: "2026-03-10", + paymentChannel: "bank_transfer", + }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ payment: "already_paid_conflict" }); + + await app.close(); + }); + + it("returns 409 when no settled transaction matches the order", async () => { + const { prisma } = buildPrisma({ + currentOrder: buildTravelOrderRecord(), + matchedTransaction: null, + }); + const app = await buildApp({ prisma }); + + const response = await app.inject({ + method: "POST", + url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid", + headers: { authorization: "Bearer test" }, + payload: { + paymentVoucherNo: null, + paymentDate: null, + paymentChannel: "manual", + }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ payment: "missing_settled_transaction" }); + + await app.close(); + }); + + it("skips ERP enqueue when gate is off", async () => { + const { prisma } = buildPrisma({ + currentOrder: buildTravelOrderRecord(), + }); + const app = await buildApp({ prisma, config: { erpSyncEnabled: false } }); + + const response = await app.inject({ + method: "POST", + url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid", + headers: { authorization: "Bearer test" }, + payload: { + paymentVoucherNo: "BANK-001", + paymentDate: "2026-03-10", + paymentChannel: "bank_transfer", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.erpJournal).toEqual( + expect.objectContaining({ enqueued: false, outboxId: null }), + ); + expect(prisma.integrationOutbox.create).not.toHaveBeenCalled(); await app.close(); }); diff --git a/server/tests/unit/routes/travelRequests.payment-track.test.js b/server/tests/unit/routes/travelRequests.payment-track.test.js new file mode 100644 index 00000000..08151d6b --- /dev/null +++ b/server/tests/unit/routes/travelRequests.payment-track.test.js @@ -0,0 +1,311 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const travelRequestsRoutes = require("../../../src/routes/travelRequests"); + +function buildTravelRequestRecord() { + const now = new Date("2026-03-10T00:00:00.000Z"); + return { + id: "11111111-1111-4111-8111-111111111111", + request_no: "CL-20260310-0001", + applicant_employee_id: "employee-1", + submitter_id: null, + department_id: "dept-1", + legal_entity_id: "legal-1", + cost_center_id: "cc-1", + budget_pool_id: "pool-1", + purpose: "上海客户拜访", + trip_type: "domestic", + start_date: new Date("2026-03-15T00:00:00.000Z"), + end_date: new Date("2026-03-17T00:00:00.000Z"), + estimated_amount: "1280.00", + status: "approved", + created_at: now, + updated_at: now, + applicant: { id: "employee-1", employee_no: "E001", name: "张三" }, + submitter: null, + department: { id: "dept-1", name: "销售部" }, + legal_entity: { id: "legal-1", code: "HY", name: "海音达" }, + cost_center: { id: "cc-1", code: "CC001", name: "销售中心", legal_entity_id: "legal-1" }, + budget_pool: { id: "pool-1", code: "POOL-1", name: "差旅池" }, + segments: [ + { + id: "seg-1", + sort_order: 1, + segment_role: "outbound", + transport_type: "flight", + from_city: "深圳", + to_city: "上海", + depart_at: new Date("2026-03-15T01:00:00.000Z"), + arrive_at: new Date("2026-03-15T03:30:00.000Z"), + created_at: now, + updated_at: now, + }, + ], + travel_orders: [ + { + id: "22222222-2222-4222-8222-222222222222", + source: "ctrip", + external_order_no: "EXT-001", + employee_id: "employee-1", + department_id: "dept-1", + linked_reimbursement_id: null, + travel_request_id: "11111111-1111-4111-8111-111111111111", + occurred_at: new Date("2026-03-12T00:00:00.000Z"), + amount: "500.00", + currency: "CNY", + status: "confirmed", + payment_voucher_no: null, + payment_date: null, + payment_channel: null, + paid_at: null, + paid_by: null, + created_at: now, + updated_at: now, + }, + ], + card_authorizations: [ + { + id: "33333333-3333-4333-8333-333333333333", + source: "corp-card", + external_auth_no: "AUTH-001", + employee_id: "employee-1", + department_id: "dept-1", + travel_request_id: "11111111-1111-4111-8111-111111111111", + budget_pool_id: "pool-1", + legal_entity_id: "legal-1", + cost_center_id: "cc-1", + authorized_amount: "500.00", + settled_amount: "0.00", + reversed_amount: "0.00", + currency: "CNY", + occurred_at: now, + expires_at: null, + status: "authorized", + created_at: now, + updated_at: now, + }, + ], + card_transactions: [ + { + id: "44444444-4444-4444-8444-444444444444", + source: "corp-card", + external_txn_id: "TXN-001", + card_authorization_id: "33333333-3333-4333-8333-333333333333", + employee_id: "employee-1", + department_id: "dept-1", + travel_request_id: "11111111-1111-4111-8111-111111111111", + travel_order_id: "22222222-2222-4222-8222-222222222222", + amount: "500.00", + currency: "CNY", + txn_type: "charge", + status: "settled", + occurred_at: now, + freeze_suggested_at: null, + freeze_suggested_by: null, + freeze_reason_code: null, + freeze_reason_note: null, + created_at: now, + updated_at: now, + }, + ], + workflow_instances: [], + }; +} + +function buildPrisma({ detail = buildTravelRequestRecord() } = {}) { + return { + travelRequest: { + findMany: vi.fn().mockResolvedValue([detail]), + count: vi.fn().mockResolvedValue(1), + findUnique: vi.fn().mockResolvedValue(detail), + }, + travelOrder: { + findUnique: vi.fn().mockResolvedValue({ + ...detail.travel_orders[0], + travel_request: { + id: detail.id, + request_no: detail.request_no, + department_id: detail.department_id, + applicant_employee_id: detail.applicant_employee_id, + submitter_id: detail.submitter_id, + legal_entity: { code: detail.legal_entity.code }, + cost_center: { code: detail.cost_center.code }, + budget_pool: { code: detail.budget_pool.code }, + }, + }), + }, + invoicePool: { + findMany: vi.fn().mockResolvedValue([ + { + id: "invoice-1", + status: "bound", + original_name: "flight.pdf", + total_amount: "500.00", + invoice_code: "INV001", + invoice_number: "NO001", + invoice_date: new Date("2026-03-16T00:00:00.000Z"), + linked_reimbursement_id: null, + travel_order_id: "22222222-2222-4222-8222-222222222222", + }, + ]), + }, + budgetLedgerEntry: { + findMany: vi.fn().mockImplementation(async ({ where }) => { + if (where?.entity_type === "travel_request") { + return [ + { + id: "ledger-r1", + budget_pool_id: "pool-1", + kind: "reserve", + amount: "500.00", + created_at: new Date("2026-03-10T00:00:00.000Z"), + }, + { + id: "ledger-r2", + budget_pool_id: "pool-1", + kind: "confirm", + amount: "500.00", + created_at: new Date("2026-03-10T01:00:00.000Z"), + }, + ]; + } + return [ + { + id: "ledger-c1", + budget_pool_id: "pool-1", + entity_type: "card_authorization", + entity_id: "33333333-3333-4333-8333-333333333333", + kind: "authorize", + amount: "500.00", + created_at: new Date("2026-03-10T00:00:00.000Z"), + }, + { + id: "ledger-c2", + budget_pool_id: "pool-1", + entity_type: "card_transaction", + entity_id: "44444444-4444-4444-8444-444444444444", + kind: "settle", + amount: "500.00", + created_at: new Date("2026-03-10T02:00:00.000Z"), + }, + ]; + }), + }, + budgetPool: { + findMany: vi.fn().mockResolvedValue([{ id: "pool-1", code: "POOL-1", name: "差旅池" }]), + }, + integrationBinding: { + findUnique: vi.fn().mockResolvedValue(null), + }, + integrationOutbox: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }; +} + +async function buildApp({ employee, prisma }) { + const app = Fastify(); + + app.decorate("prisma", prisma); + app.decorate("config", { appTimezone: "Asia/Shanghai" }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + request.employee = employee; + }); + app.decorate("hasPermission", (actor, permissionCode) => { + return (actor?.permissionCodes ?? []).includes("*") || (actor?.permissionCodes ?? []).includes(permissionCode); + }); + app.decorate("requirePermission", (permissionCode) => async (request) => { + await app.authenticate(request); + if (!app.hasPermission(request.employee, permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } + }); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(travelRequestsRoutes, { prefix: "/api" }); + return app; +} + +describe("travelRequests payment-track route", () => { + it("allows owner to view payment track in read-only mode", async () => { + const detail = buildTravelRequestRecord(); + const app = await buildApp({ + employee: { id: "employee-1", department_id: "dept-1", permissionCodes: ["travel.view_own"] }, + prisma: buildPrisma({ detail }), + }); + + const response = await app.inject({ + method: "GET", + url: "/api/travel-requests/11111111-1111-4111-8111-111111111111/payment-track", + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.permissions.canManagePayments).toBe(false); + expect(response.json().data.travelOrders[0].actions.canMarkPaid).toBe(false); + + await app.close(); + }); + + it("allows finance manager to view payment track with manage actions", async () => { + const detail = buildTravelRequestRecord(); + const app = await buildApp({ + employee: { id: "finance-1", department_id: "dept-9", permissionCodes: ["travel.view_all", "travel.payment.manage"] }, + prisma: buildPrisma({ detail }), + }); + + const response = await app.inject({ + method: "GET", + url: "/api/travel-requests/11111111-1111-4111-8111-111111111111/payment-track", + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.permissions.canManagePayments).toBe(true); + expect(response.json().data.travelOrders[0].actions).toEqual( + expect.objectContaining({ canMarkPaid: true, canBindInvoice: true }), + ); + + await app.close(); + }); + + it("blocks outsider without view_all permission", async () => { + const detail = buildTravelRequestRecord(); + const app = await buildApp({ + employee: { id: "employee-9", department_id: "dept-9", permissionCodes: ["travel.view_own"] }, + prisma: buildPrisma({ detail }), + }); + + const response = await app.inject({ + method: "GET", + url: "/api/travel-requests/11111111-1111-4111-8111-111111111111/payment-track", + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe("PERM_001"); + + await app.close(); + }); +}); diff --git a/server/tests/unit/routes/travelTransactions.test.js b/server/tests/unit/routes/travelTransactions.test.js new file mode 100644 index 00000000..c9a0d6fa --- /dev/null +++ b/server/tests/unit/routes/travelTransactions.test.js @@ -0,0 +1,306 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const travelTransactionsRoutes = require("../../../src/routes/travelTransactions"); + +function buildOrder(overrides = {}) { + return { + id: "order-1", + external_order_no: "EXT-001", + employee_id: "employee-1", + travel_request_id: "travel-1", + amount: "500.00", + currency: "CNY", + status: "imported", + ...overrides, + }; +} + +function buildPrisma({ + employees = [{ id: "employee-1", department_id: "dept-1" }], + travelRequests = [{ id: "travel-1", applicant_employee_id: "employee-1", budget_pool_id: "pool-1" }], + authorizations = [], + travelOrders = [buildOrder()], + existing = null, + existingLedger = [], + budgetPool = { id: "pool-1", used_amount: "1000.00" }, +} = {}) { + const state = { + order: travelOrders[0] ? { ...travelOrders[0] } : null, + transaction: null, + }; + + const prisma = { + employee: { + findMany: vi.fn().mockResolvedValue(employees), + }, + travelRequest: { + findMany: vi.fn().mockResolvedValue(travelRequests), + }, + cardAuthorization: { + findMany: vi.fn().mockResolvedValue(authorizations), + findUnique: vi.fn().mockImplementation(async () => null), + update: vi.fn().mockResolvedValue({ id: "auth-1" }), + }, + travelOrder: { + findMany: vi.fn().mockResolvedValue(travelOrders), + findUnique: vi.fn().mockImplementation(async () => state.order), + update: vi.fn().mockImplementation(async ({ data }) => { + state.order = { ...state.order, ...data }; + return state.order; + }), + }, + cardTransaction: { + findUnique: vi.fn().mockResolvedValue(existing), + upsert: vi.fn().mockImplementation(async ({ create, update }) => { + state.transaction = { + id: "33333333-3333-4333-8333-333333333333", + source: create.source, + external_txn_id: create.external_txn_id, + card_authorization_id: update.card_authorization_id ?? create.card_authorization_id ?? null, + employee_id: create.employee_id, + department_id: create.department_id, + travel_request_id: create.travel_request_id, + travel_order_id: update.travel_order_id ?? create.travel_order_id ?? null, + amount: create.amount, + currency: create.currency, + txn_type: create.txn_type, + status: create.status, + occurred_at: create.occurred_at, + freeze_suggested_at: null, + freeze_suggested_by: null, + freeze_reason_code: null, + freeze_reason_note: null, + created_at: new Date("2026-03-10T00:00:00.000Z"), + updated_at: new Date("2026-03-10T00:00:00.000Z"), + }; + return state.transaction; + }), + findFirst: vi.fn().mockResolvedValue(null), + update: vi.fn().mockImplementation(async ({ data }) => ({ + ...(state.transaction ?? { + id: "33333333-3333-4333-8333-333333333333", + source: "corp-card", + external_txn_id: "TXN-1", + card_authorization_id: null, + employee_id: "employee-1", + department_id: "dept-1", + travel_request_id: "travel-1", + travel_order_id: "order-1", + amount: "500.00", + currency: "CNY", + txn_type: "charge", + status: "settled", + occurred_at: new Date("2026-03-10T00:00:00.000Z"), + created_at: new Date("2026-03-10T00:00:00.000Z"), + updated_at: new Date("2026-03-10T00:00:00.000Z"), + }), + ...data, + travel_order: state.order, + })), + }, + budgetLedgerEntry: { + findMany: vi.fn().mockResolvedValue(existingLedger), + create: vi.fn().mockResolvedValue({ id: "ledger-1" }), + }, + budgetPool: { + findUnique: vi.fn().mockResolvedValue(budgetPool), + update: vi.fn().mockResolvedValue({ id: "pool-1" }), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit-1" }), + }, + $transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)), + }; + + prisma.cardTransaction.findUnique.mockImplementation(async ({ where }) => { + if (where?.id) { + return { + ...(state.transaction ?? { + id: where.id, + source: "corp-card", + external_txn_id: "TXN-1", + card_authorization_id: null, + employee_id: "employee-1", + department_id: "dept-1", + travel_request_id: "travel-1", + travel_order_id: state.order?.id ?? null, + amount: "500.00", + currency: "CNY", + txn_type: "charge", + status: "settled", + occurred_at: new Date("2026-03-10T00:00:00.000Z"), + freeze_suggested_at: null, + freeze_suggested_by: null, + freeze_reason_code: null, + freeze_reason_note: null, + created_at: new Date("2026-03-10T00:00:00.000Z"), + updated_at: new Date("2026-03-10T00:00:00.000Z"), + }), + travel_order: state.order, + }; + } + return existing; + }); + + return { prisma, state }; +} + +async function buildApp({ prisma, permissionCodes = ["integration.manage", "travel.payment.manage"] }) { + const app = Fastify(); + + app.decorate("prisma", prisma); + app.decorate("config", { travelSyncEnabled: true }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + request.employee = { id: "actor-1", permissionCodes }; + }); + app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes(permissionCode)); + app.decorate("requirePermission", (permissionCode) => async (request) => { + await app.authenticate(request); + if (!app.hasPermission(request.employee, permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } + }); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(travelTransactionsRoutes, { prefix: "/api" }); + return app; +} + +describe("travelTransactions routes", () => { + it("confirms travel order when settled charge exactly matches", async () => { + const { prisma, state } = buildPrisma(); + const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); + + const response = await app.inject({ + method: "POST", + url: "/api/travel/transactions/import", + headers: { authorization: "Bearer test" }, + payload: { + transactions: [{ + source: "corp-card", + externalTxnId: "TXN-001", + employeeId: "employee-1", + travelRequestId: "travel-1", + travelOrderId: "order-1", + amount: "500.00", + currency: "CNY", + txnType: "charge", + status: "settled", + }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual(expect.objectContaining({ confirmedCount: 1, imported: 1 })); + expect(state.order.status).toBe("confirmed"); + + await app.close(); + }); + + it("writes reverse ledger for refund transactions", async () => { + const { prisma } = buildPrisma({ + travelOrders: [], + }); + const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); + + const response = await app.inject({ + method: "POST", + url: "/api/travel/transactions/import", + headers: { authorization: "Bearer test" }, + payload: { + transactions: [{ + source: "corp-card", + externalTxnId: "TXN-REFUND-1", + employeeId: "employee-1", + travelRequestId: "travel-1", + amount: "120.00", + currency: "CNY", + txnType: "refund", + status: "settled", + }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual(expect.objectContaining({ reversedCount: 1, imported: 1 })); + expect(prisma.budgetPool.update).toHaveBeenCalled(); + + await app.close(); + }); + + it("skips rows when referenced authorization is missing", async () => { + const { prisma } = buildPrisma(); + const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); + + const response = await app.inject({ + method: "POST", + url: "/api/travel/transactions/import", + headers: { authorization: "Bearer test" }, + payload: { + transactions: [{ + source: "corp-card", + externalTxnId: "TXN-MISS-AUTH", + employeeId: "employee-1", + travelRequestId: "travel-1", + cardAuthorizationId: "missing-auth", + amount: "500.00", + currency: "CNY", + txnType: "charge", + status: "settled", + }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.skipped).toEqual([ + expect.objectContaining({ reason: "card_authorization_not_found" }), + ]); + + await app.close(); + }); + + it("stores freeze suggestion metadata", async () => { + const { prisma } = buildPrisma(); + const app = await buildApp({ prisma }); + + const response = await app.inject({ + method: "POST", + url: "/api/travel/transactions/33333333-3333-4333-8333-333333333333/freeze-suggestion", + headers: { authorization: "Bearer test" }, + payload: { + reasonCode: "manual_review", + note: "请人工复核", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual( + expect.objectContaining({ + freezeReasonCode: "manual_review", + freezeReasonNote: "请人工复核", + }), + ); + + await app.close(); + }); +}); diff --git a/server/tests/unit/services/erpJournal.travel.test.js b/server/tests/unit/services/erpJournal.travel.test.js new file mode 100644 index 00000000..e7638a8d --- /dev/null +++ b/server/tests/unit/services/erpJournal.travel.test.js @@ -0,0 +1,121 @@ +const { + buildTravelOrderPaymentJournalPayload, + buildTravelOrderPaymentJournalDedupeKey, + buildTravelOrderPaymentJournalPreview, +} = require("../../../src/services/integrations/erpJournal"); + +describe("erpJournal travel helpers", () => { + it("builds payment journal payload with travel dimensions", () => { + const payload = buildTravelOrderPaymentJournalPayload({ + travelOrder: { + id: "order-1", + source: "ctrip", + external_order_no: "EXT-001", + employee_id: "employee-1", + department_id: "dept-1", + travel_request_id: "travel-1", + amount: "500.00", + currency: "CNY", + payment_voucher_no: "BANK-001", + payment_date: new Date("2026-03-10T00:00:00.000Z"), + payment_channel: "bank_transfer", + paid_at: new Date("2026-03-10T09:00:00.000Z"), + created_at: new Date("2026-03-10T08:00:00.000Z"), + updated_at: new Date("2026-03-10T09:00:00.000Z"), + travel_request: { + id: "travel-1", + request_no: "CL-20260310-0001", + applicant_employee_id: "employee-1", + submitter_id: null, + legal_entity: { code: "HY" }, + cost_center: { code: "CC001" }, + budget_pool: { code: "POOL-1" }, + }, + }, + }); + + expect(payload).toEqual( + expect.objectContaining({ + journalType: "travel_order_payment", + sourceEventName: "travel_order.paid", + entityType: "travel_order", + entityId: "order-1", + documentNo: "EXT-001", + paymentVoucherNo: "BANK-001", + paymentDate: "2026-03-10", + paymentChannel: "bank_transfer", + dimensions: expect.objectContaining({ + legalEntityCode: "HY", + costCenterCode: "CC001", + budgetPoolCode: "POOL-1", + }), + }), + ); + }); + + it("builds stable travel-order dedupe key", () => { + expect(buildTravelOrderPaymentJournalDedupeKey("order-1")).toBe("travel_order:order-1:paid"); + }); + + it("builds preview with current binding and outbox state", async () => { + const prisma = { + integrationBinding: { + findUnique: vi.fn().mockResolvedValue({ + id: "binding-1", + provider: "erp", + entity_type: "travel_order_payment_journal", + local_id: "order-1", + external_id: "erp-1", + external_code: "EXT-001", + last_synced_at: new Date("2026-03-10T10:00:00.000Z"), + }), + }, + integrationOutbox: { + findFirst: vi.fn().mockResolvedValue({ + id: "outbox-1", + event_type: "erp.journal.export", + status: "succeeded", + last_error: null, + processed_at: new Date("2026-03-10T10:00:00.000Z"), + updated_at: new Date("2026-03-10T10:00:00.000Z"), + }), + }, + }; + + const preview = await buildTravelOrderPaymentJournalPreview(prisma, { + id: "order-1", + source: "ctrip", + external_order_no: "EXT-001", + employee_id: "employee-1", + department_id: "dept-1", + linked_reimbursement_id: null, + travel_request_id: "travel-1", + amount: "500.00", + currency: "CNY", + payment_voucher_no: "BANK-001", + payment_date: new Date("2026-03-10T00:00:00.000Z"), + payment_channel: "bank_transfer", + paid_at: new Date("2026-03-10T09:00:00.000Z"), + created_at: new Date("2026-03-10T08:00:00.000Z"), + updated_at: new Date("2026-03-10T09:00:00.000Z"), + travel_request: { + id: "travel-1", + request_no: "CL-20260310-0001", + applicant_employee_id: "employee-1", + submitter_id: null, + legal_entity: { code: "HY" }, + cost_center: { code: "CC001" }, + budget_pool: { code: "POOL-1" }, + }, + }); + + expect(preview.exportState).toEqual( + expect.objectContaining({ + externalId: "erp-1", + lastOutboxId: "outbox-1", + lastOutboxStatus: "succeeded", + }), + ); + expect(preview.binding).toEqual(expect.objectContaining({ localId: "order-1" })); + }); +});