From fc0295249e927eda55629b11a5dc835ff5e9d767 Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 11 Mar 2026 16:55:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(P20T3-1,P20T3-2,P20T3-3):=20=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=E6=89=B9=E6=AC=A1=E5=BC=95=E6=93=8E=E4=B8=8EERP?= =?UTF-8?q?=E6=91=98=E8=A6=81=E5=87=BA=E7=AB=99=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 116 +++ server/prisma/schema.prisma | 144 +++- server/src/app.js | 2 + server/src/routes/integrations.js | 221 ++++++ server/src/routes/travelReconciliation.js | 738 ++++++++++++++++++ server/src/services/documentSequence.js | 11 + .../src/services/integrations/dispatcher.js | 29 +- server/src/services/integrations/erpTravel.js | 333 ++++++++ server/src/services/travelReconciliation.js | 738 ++++++++++++++++++ .../unit/documentSequence.service.test.js | 22 + .../unit/integrations.dispatcher.test.js | 45 ++ .../routes/integrations.erp.travel.test.js | 331 ++++++++ .../unit/routes/travelReconciliation.test.js | 650 +++++++++++++++ server/tests/unit/services/erpTravel.test.js | 170 ++++ .../services/travelReconciliation.test.js | 211 +++++ 15 files changed, 3733 insertions(+), 28 deletions(-) create mode 100644 server/prisma/migrations/20260311095728_p20_travel_3_reconciliation_erp_bridge/migration.sql create mode 100644 server/src/routes/travelReconciliation.js create mode 100644 server/src/services/integrations/erpTravel.js create mode 100644 server/src/services/travelReconciliation.js create mode 100644 server/tests/unit/routes/integrations.erp.travel.test.js create mode 100644 server/tests/unit/routes/travelReconciliation.test.js create mode 100644 server/tests/unit/services/erpTravel.test.js create mode 100644 server/tests/unit/services/travelReconciliation.test.js diff --git a/server/prisma/migrations/20260311095728_p20_travel_3_reconciliation_erp_bridge/migration.sql b/server/prisma/migrations/20260311095728_p20_travel_3_reconciliation_erp_bridge/migration.sql new file mode 100644 index 00000000..78cdedc7 --- /dev/null +++ b/server/prisma/migrations/20260311095728_p20_travel_3_reconciliation_erp_bridge/migration.sql @@ -0,0 +1,116 @@ +ALTER TYPE "DocumentCategoryEntityType" ADD VALUE IF NOT EXISTS 'travel_reconciliation_batch'; + +DO $$ BEGIN + CREATE TYPE "TravelReconciliationBatchStatus" AS ENUM ('draft', 'reconciled'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE "TravelReconciliationItemKind" AS ENUM ('order', 'orphan_transaction'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE "TravelReconciliationMatchStatus" AS ENUM ( + 'matched', + 'amount_mismatch', + 'currency_mismatch', + 'missing_transaction', + 'duplicate_charge', + 'orphan_transaction', + 'reversed_after_payment' + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE "TravelReconciliationResolutionStatus" AS ENUM ('pending', 'confirmed'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +CREATE TABLE IF NOT EXISTS "travel_reconciliation_batches" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "batch_no" TEXT NOT NULL, + "legal_entity_id" UUID NOT NULL, + "cost_center_id" UUID, + "period_start" DATE NOT NULL, + "period_end" DATE NOT NULL, + "status" "TravelReconciliationBatchStatus" NOT NULL DEFAULT 'draft', + "summary_json" JSONB NOT NULL, + "confirmed_at" TIMESTAMP(3), + "confirmed_by" UUID, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_by" UUID, + CONSTRAINT "travel_reconciliation_batches_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "travel_reconciliation_batches_batch_no_key" ON "travel_reconciliation_batches"("batch_no"); +CREATE INDEX IF NOT EXISTS "idx_travel_reconciliation_batches_status_period" ON "travel_reconciliation_batches"("status", "period_start", "period_end"); +CREATE INDEX IF NOT EXISTS "idx_travel_reconciliation_batches_legal_cost_center" ON "travel_reconciliation_batches"("legal_entity_id", "cost_center_id"); +CREATE INDEX IF NOT EXISTS "idx_travel_reconciliation_batches_confirmed_by" ON "travel_reconciliation_batches"("confirmed_by"); + +ALTER TABLE "travel_reconciliation_batches" + ADD CONSTRAINT "travel_reconciliation_batches_legal_entity_id_fkey" + FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "travel_reconciliation_batches" + ADD CONSTRAINT "travel_reconciliation_batches_cost_center_id_fkey" + FOREIGN KEY ("cost_center_id") REFERENCES "cost_centers"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "travel_reconciliation_batches" + ADD CONSTRAINT "travel_reconciliation_batches_confirmed_by_fkey" + FOREIGN KEY ("confirmed_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE TABLE IF NOT EXISTS "travel_reconciliation_items" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "travel_reconciliation_batch_id" UUID NOT NULL, + "item_kind" "TravelReconciliationItemKind" NOT NULL, + "travel_request_id" UUID, + "travel_order_id" UUID, + "card_transaction_id" UUID, + "match_status" "TravelReconciliationMatchStatus" NOT NULL, + "resolution_status" "TravelReconciliationResolutionStatus" NOT NULL DEFAULT 'pending', + "order_amount" DECIMAL(14,2), + "transaction_amount" DECIMAL(14,2), + "currency" VARCHAR(8) NOT NULL DEFAULT 'CNY', + "anomaly_code" VARCHAR(32), + "snapshot_json" JSONB NOT NULL, + "confirmed_at" TIMESTAMP(3), + "confirmed_by" UUID, + "confirm_note" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "travel_reconciliation_items_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "idx_travel_reconciliation_items_batch_resolution" ON "travel_reconciliation_items"("travel_reconciliation_batch_id", "resolution_status"); +CREATE INDEX IF NOT EXISTS "idx_travel_reconciliation_items_request" ON "travel_reconciliation_items"("travel_request_id"); +CREATE INDEX IF NOT EXISTS "idx_travel_reconciliation_items_order" ON "travel_reconciliation_items"("travel_order_id"); +CREATE INDEX IF NOT EXISTS "idx_travel_reconciliation_items_transaction" ON "travel_reconciliation_items"("card_transaction_id"); +CREATE INDEX IF NOT EXISTS "idx_travel_reconciliation_items_confirmed_by" ON "travel_reconciliation_items"("confirmed_by"); + +ALTER TABLE "travel_reconciliation_items" + ADD CONSTRAINT "travel_reconciliation_items_batch_id_fkey" + FOREIGN KEY ("travel_reconciliation_batch_id") REFERENCES "travel_reconciliation_batches"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "travel_reconciliation_items" + ADD CONSTRAINT "travel_reconciliation_items_travel_request_id_fkey" + FOREIGN KEY ("travel_request_id") REFERENCES "travel_requests"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "travel_reconciliation_items" + ADD CONSTRAINT "travel_reconciliation_items_travel_order_id_fkey" + FOREIGN KEY ("travel_order_id") REFERENCES "travel_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "travel_reconciliation_items" + ADD CONSTRAINT "travel_reconciliation_items_card_transaction_id_fkey" + FOREIGN KEY ("card_transaction_id") REFERENCES "card_transactions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "travel_reconciliation_items" + ADD CONSTRAINT "travel_reconciliation_items_confirmed_by_fkey" + FOREIGN KEY ("confirmed_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 20326487..05fa16ce 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -78,6 +78,31 @@ enum TravelTransportType { other } +enum TravelReconciliationBatchStatus { + draft + reconciled +} + +enum TravelReconciliationItemKind { + order + orphan_transaction +} + +enum TravelReconciliationMatchStatus { + matched + amount_mismatch + currency_mismatch + missing_transaction + duplicate_charge + orphan_transaction + reversed_after_payment +} + +enum TravelReconciliationResolutionStatus { + pending + confirmed +} + enum AssetCardStatus { in_stock in_use @@ -157,6 +182,7 @@ enum DocumentCategoryEntityType { leave purchase_request travel_request + travel_reconciliation_batch asset_card general } @@ -302,11 +328,13 @@ model Employee { uploaded_invoices InvoicePool[] @relation("InvoiceUploader") overridden_invoices InvoicePool[] @relation("InvoiceOverrideBy") requested_invoice_verifications InvoiceVerificationRecord[] @relation("InvoiceVerificationRequester") - 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") + travel_orders TravelOrder[] @relation("TravelOrderEmployee") + paid_travel_orders TravelOrder[] @relation("TravelOrderPaidBy") + confirmed_travel_reconciliation_batches TravelReconciliationBatch[] @relation("TravelReconciliationBatchConfirmedBy") + card_authorizations CardAuthorization[] + card_transactions CardTransaction[] @relation("CardTransactionEmployee") + freeze_suggested_card_transactions CardTransaction[] @relation("CardTransactionFreezeSuggestedBy") + confirmed_travel_reconciliation_items TravelReconciliationItem[] @relation("TravelReconciliationItemConfirmedBy") workflow_instances_started WorkflowInstance[] @relation("WorkflowInstanceSubmitter") workflow_history_actions WorkflowHistory[] @relation("WorkflowHistoryActor") @@ -491,11 +519,12 @@ model LegalEntity { employees Employee[] holiday_calendars HolidayCalendar[] @relation("HolidayCalendarLegalEntity") suppliers Supplier[] - purchase_requests PurchaseRequest[] - travel_requests TravelRequest[] - card_authorizations CardAuthorization[] - asset_cards AssetCard[] - budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity") + purchase_requests PurchaseRequest[] + travel_requests TravelRequest[] + travel_reconciliation_batches TravelReconciliationBatch[] + card_authorizations CardAuthorization[] + asset_cards AssetCard[] + budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity") @@index([enabled, archived_at], map: "idx_legal_entities_status") @@index([default_holiday_calendar_id], map: "idx_legal_entities_default_calendar") @@ -521,11 +550,12 @@ model CostCenter { parent CostCenter? @relation("CostCenterToParent", fields: [parent_id], references: [id], onDelete: Restrict) children CostCenter[] @relation("CostCenterToParent") owner_employee Employee? @relation("CostCenterOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull) - budget_pools BudgetPool[] @relation("BudgetPoolCostCenter") - purchase_requests PurchaseRequest[] - travel_requests TravelRequest[] - card_authorizations CardAuthorization[] - asset_cards AssetCard[] + budget_pools BudgetPool[] @relation("BudgetPoolCostCenter") + purchase_requests PurchaseRequest[] + travel_requests TravelRequest[] + travel_reconciliation_batches TravelReconciliationBatch[] + card_authorizations CardAuthorization[] + asset_cards AssetCard[] @@unique([legal_entity_id, code], map: "legal_entity_cost_center_code_ux") @@index([legal_entity_id, enabled, archived_at], map: "idx_cost_centers_legal_entity_status") @@ -1319,11 +1349,12 @@ 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[] - card_authorizations CardAuthorization[] - card_transactions CardTransaction[] - workflow_instances WorkflowInstance[] + segments TravelRequestSegment[] + travel_orders TravelOrder[] + reconciliation_items TravelReconciliationItem[] + 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") @@ -1384,9 +1415,10 @@ model TravelOrder { 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) + paid_by_employee Employee? @relation("TravelOrderPaidBy", fields: [paid_by], references: [id], onDelete: SetNull) invoice_pool_items InvoicePool[] card_transactions CardTransaction[] + reconciliation_items TravelReconciliationItem[] @@unique([source, external_order_no], map: "travel_orders_source_order_ux") @@index([employee_id, occurred_at], map: "idx_travel_orders_employee_occurred") @@ -1465,9 +1497,10 @@ model CardTransaction { 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) + 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) + reconciliation_items TravelReconciliationItem[] @@unique([source, external_txn_id], map: "card_transactions_source_txn_ux") @@index([card_authorization_id], map: "idx_card_transactions_authorization") @@ -1479,6 +1512,69 @@ model CardTransaction { @@map("card_transactions") } +model TravelReconciliationBatch { + id String @id @default(uuid()) @db.Uuid + batch_no String @unique + legal_entity_id String @db.Uuid + cost_center_id String? @db.Uuid + period_start DateTime @db.Date + period_end DateTime @db.Date + status TravelReconciliationBatchStatus @default(draft) + summary_json Json + confirmed_at DateTime? + confirmed_by String? @db.Uuid + + created_at DateTime @default(now()) + created_by String? @db.Uuid + updated_at DateTime @updatedAt + updated_by String? @db.Uuid + + legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict) + cost_center CostCenter? @relation(fields: [cost_center_id], references: [id], onDelete: SetNull) + confirmed_by_employee Employee? @relation("TravelReconciliationBatchConfirmedBy", fields: [confirmed_by], references: [id], onDelete: SetNull) + items TravelReconciliationItem[] + + @@index([status, period_start, period_end], map: "idx_travel_reconciliation_batches_status_period") + @@index([legal_entity_id, cost_center_id], map: "idx_travel_reconciliation_batches_legal_cost_center") + @@index([confirmed_by], map: "idx_travel_reconciliation_batches_confirmed_by") + @@map("travel_reconciliation_batches") +} + +model TravelReconciliationItem { + id String @id @default(uuid()) @db.Uuid + travel_reconciliation_batch_id String @db.Uuid + item_kind TravelReconciliationItemKind + travel_request_id String? @db.Uuid + travel_order_id String? @db.Uuid + card_transaction_id String? @db.Uuid + match_status TravelReconciliationMatchStatus + resolution_status TravelReconciliationResolutionStatus @default(pending) + order_amount Decimal? @db.Decimal(14, 2) + transaction_amount Decimal? @db.Decimal(14, 2) + currency String @default("CNY") @db.VarChar(8) + anomaly_code String? @db.VarChar(32) + snapshot_json Json + confirmed_at DateTime? + confirmed_by String? @db.Uuid + confirm_note String? + + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + batch TravelReconciliationBatch @relation(fields: [travel_reconciliation_batch_id], references: [id], onDelete: Cascade) + travel_request TravelRequest? @relation(fields: [travel_request_id], references: [id], onDelete: SetNull) + travel_order TravelOrder? @relation(fields: [travel_order_id], references: [id], onDelete: SetNull) + card_transaction CardTransaction? @relation(fields: [card_transaction_id], references: [id], onDelete: SetNull) + confirmed_by_employee Employee? @relation("TravelReconciliationItemConfirmedBy", fields: [confirmed_by], references: [id], onDelete: SetNull) + + @@index([travel_reconciliation_batch_id, resolution_status], map: "idx_travel_reconciliation_items_batch_resolution") + @@index([travel_request_id], map: "idx_travel_reconciliation_items_request") + @@index([travel_order_id], map: "idx_travel_reconciliation_items_order") + @@index([card_transaction_id], map: "idx_travel_reconciliation_items_transaction") + @@index([confirmed_by], map: "idx_travel_reconciliation_items_confirmed_by") + @@map("travel_reconciliation_items") +} + 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 bd9f36b3..fe9be006 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -47,6 +47,7 @@ const travelOrdersRoutes = require("./routes/travelOrders"); const travelRequestsRoutes = require("./routes/travelRequests"); const travelAuthorizationsRoutes = require("./routes/travelAuthorizations"); const travelTransactionsRoutes = require("./routes/travelTransactions"); +const travelReconciliationRoutes = require("./routes/travelReconciliation"); const aiRoutes = require("./routes/ai"); const { startJobs } = require("./jobs/jobSystem"); const { initMetrics } = require("./observability"); @@ -312,6 +313,7 @@ async function buildApp() { await app.register(travelRequestsRoutes, { prefix: "/api" }); await app.register(travelAuthorizationsRoutes, { prefix: "/api" }); await app.register(travelTransactionsRoutes, { prefix: "/api" }); + await app.register(travelReconciliationRoutes, { prefix: "/api" }); await app.register(aiRoutes, { prefix: "/api" }); await app.register(exportsRoutes, { prefix: "/api" }); diff --git a/server/src/routes/integrations.js b/server/src/routes/integrations.js index 17d067e9..f69b87f5 100644 --- a/server/src/routes/integrations.js +++ b/server/src/routes/integrations.js @@ -36,6 +36,13 @@ const { loadAssetErpContext, resolveAssetLedgerSummaryDrift, } = require('../services/integrations/erpAsset'); +const { + TRAVEL_ERP_EVENT_TYPE, + buildTravelReconciliationErpTrack, + buildTravelReconciliationSummaryDedupeKey, + enqueueTravelReconciliationSummaryExport, + loadTravelReconciliationErpContext, +} = require('../services/integrations/erpTravel'); const UUID_SCHEMA = z.string().uuid(); const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']); @@ -320,6 +327,78 @@ async function fetchReadableAssetContext(prisma, assetId, request) { return context; } +function canReadTravelReconciliationBatch(request) { + const permissionCodes = request?.employee?.permissionCodes ?? []; + return ( + permissionCodes.includes('*') || + permissionCodes.includes('travel.payment.manage') || + permissionCodes.includes('integration.manage') + ); +} + +async function fetchReadableTravelReconciliationContext(prisma, batchId, request) { + const context = await loadTravelReconciliationErpContext(prisma, batchId); + if (!context) { + throw new AppError({ + code: 'SYS_404', + message: 'Not Found', + statusCode: 404, + }); + } + + if (!canReadTravelReconciliationBatch(request)) { + throw new AppError({ + code: 'PERM_001', + message: '无权限', + statusCode: 403, + }); + } + + return context; +} + +async function findTravelReconciliationOutboxByDedupeKey(prisma, batchId) { + return prisma.integrationOutbox.findUnique({ + where: { + event_type_dedupe_key: { + event_type: TRAVEL_ERP_EVENT_TYPE, + dedupe_key: buildTravelReconciliationSummaryDedupeKey(batchId), + }, + }, + select: { + id: true, + status: true, + payload: true, + }, + }); +} + +async function enqueueTravelReconciliationExportAndMaybeRun({ + prisma, + config, + logger, + context, + actorId, + runNow, + findExistingOutbox = findTravelReconciliationOutboxByDedupeKey, + enqueueExport = enqueueTravelReconciliationSummaryExport, + runOutboxOnce = runIntegrationOutboxOnce, +}) { + const existingOutbox = await findExistingOutbox(prisma, context.id); + const enqueued = await enqueueExport(prisma, config, { + batch: context, + payload: existingOutbox?.payload, + actorId, + requeueOnFailure: true, + }); + + if (runNow && (!enqueued.duplicated || enqueued.requeued)) { + await runOutboxOnce(prisma, config, logger); + } + + return enqueued; +} + async function findAssetOutboxByDedupeKey(prisma, assetId) { return prisma.integrationOutbox.findUnique({ where: { @@ -984,6 +1063,146 @@ async function integrationsRoutes(fastify) { }, ); + fastify.get( + '/integrations/erp/travel/reconciliation-batches/:batchId/track', + { + preHandler: fastify.authenticate, + schema: { + tags: ['integrations'], + summary: '查询差旅对账批次 ERP 同步轨道', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['batchId'], + properties: { + batchId: { type: 'string', format: 'uuid' }, + }, + }, + }, + }, + async (request, reply) => { + const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId); + if (!parsedId.success) { + throw new AppError({ + code: 'VALID_001', + message: '参数校验失败', + statusCode: 400, + details: { batchId: 'invalid_uuid' }, + }); + } + + const [context, gate] = await Promise.all([ + fetchReadableTravelReconciliationContext(fastify.prisma, parsedId.data, request), + resolveErpGate(fastify.prisma, fastify.config), + ]); + const canManage = fastify.hasPermission(request.employee, 'integration.manage'); + const track = await buildTravelReconciliationErpTrack(fastify.prisma, fastify.config, parsedId.data, { + context, + canManage, + gate, + }); + + return sendData(reply, request, track); + }, + ); + + fastify.post( + '/integrations/erp/travel/reconciliation-batches/:batchId/requeue', + { + preHandler: fastify.requirePermission('integration.manage'), + schema: { + tags: ['integrations'], + summary: '重试差旅对账批次 ERP 摘要桥同步', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['batchId'], + properties: { + batchId: { type: 'string', format: 'uuid' }, + }, + }, + querystring: { + type: 'object', + properties: { + runNow: { + anyOf: [ + { type: 'string', enum: ['0', '1', 'false', 'true'] }, + { type: 'boolean' }, + ], + }, + }, + additionalProperties: true, + }, + }, + }, + async (request, reply) => { + const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId); + if (!parsedId.success) { + throw new AppError({ + code: 'VALID_001', + message: '参数校验失败', + statusCode: 400, + details: { batchId: 'invalid_uuid' }, + }); + } + + const gate = await requireErpManagementGate(fastify.prisma, fastify.config); + const context = await loadTravelReconciliationErpContext(fastify.prisma, parsedId.data); + if (!context) { + throw new AppError({ + code: 'SYS_404', + message: 'Not Found', + statusCode: 404, + }); + } + if (context.status !== 'reconciled') { + throw new AppError({ + code: 'VALID_001', + message: '参数校验失败', + statusCode: 409, + details: { batch: 'travel_reconciliation_batch_not_reconciled' }, + }); + } + + const enqueued = await enqueueTravelReconciliationExportAndMaybeRun({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + context, + actorId: request.employee.id, + runNow: parseRunNow(request.query), + }); + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: 'ERP_SYNC_ENQUEUED', + targetType: 'travel_reconciliation_batch', + targetId: context.id, + extra: { + erpTarget: 'travel_reconciliation_summary', + eventType: enqueued.eventType, + batchId: context.id, + batchNo: context.batch_no, + outboxId: enqueued.id, + duplicated: enqueued.duplicated, + requeued: enqueued.requeued, + gateReason: gate.effectiveReason, + featureFlag: FEATURE_FLAG_KEYS.erpSync, + }, + }), + }); + + return sendData(reply, request, { + outboxId: enqueued.id, + status: enqueued.status, + duplicated: enqueued.duplicated, + requeued: enqueued.requeued, + eventType: enqueued.eventType, + }); + }, + ); + fastify.post( '/integrations/erp/sync/:reimbursementId', { @@ -1087,7 +1306,9 @@ async function integrationsRoutes(fastify) { integrationsRoutes.__testables = { canReadAsset, canReadPurchaseRequest, + canReadTravelReconciliationBatch, enqueueAssetExportAndMaybeRun, + enqueueTravelReconciliationExportAndMaybeRun, ensureProcurementTargetEligible, normalizeProcurementTargets, normalizeResourcesInput, diff --git a/server/src/routes/travelReconciliation.js b/server/src/routes/travelReconciliation.js new file mode 100644 index 00000000..7881cdb8 --- /dev/null +++ b/server/src/routes/travelReconciliation.js @@ -0,0 +1,738 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { buildAuditLogData } = require("../utils/audit"); +const { parseDateOnlyString } = require("../utils/date"); +const { sendData } = require("../utils/reply"); +const { issueDocumentNumber } = require("../services/documentSequence"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); +const { + TRAVEL_RECONCILIATION_BATCH_STATUSES, + TRAVEL_RECONCILIATION_RESOLUTION_STATUSES, + buildReconciliationItemsSnapshot, + buildValidationError, + ensureBatchFinalizable, + ensureConfirmedReconciliationItem, + ensureDraftBatch, + ensurePendingReconciliationItem, + ensureReopenableReconciliationItem, + listTravelReconciliationBatches, + loadReconciliationScope, + loadTravelReconciliationBatchDetail, + mapBatchItem, + normalizeSummaryJson, +} = require("../services/travelReconciliation"); +const { + TRAVEL_ERP_EVENT_TYPE, + buildTravelReconciliationSummaryDedupeKey, + enqueueTravelReconciliationSummaryExport, +} = require("../services/integrations/erpTravel"); + +const UUID_SCHEMA = z.string().uuid(); +const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "false", "true"]).optional(); +const BATCH_STATUS_SCHEMA = z.enum(["draft", "reconciled"]); + +const LIST_QUERY_SCHEMA = z.object({ + status: BATCH_STATUS_SCHEMA.optional(), + legalEntityId: UUID_SCHEMA.optional(), + costCenterId: UUID_SCHEMA.optional(), + periodStart: DATE_ONLY_SCHEMA.optional(), + periodEnd: DATE_ONLY_SCHEMA.optional(), + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), + runNow: BOOLEAN_QUERY_SCHEMA, +}); + +const CREATE_BODY_SCHEMA = z + .object({ + legalEntityId: UUID_SCHEMA, + costCenterId: UUID_SCHEMA.optional(), + periodStart: DATE_ONLY_SCHEMA, + periodEnd: DATE_ONLY_SCHEMA, + }) + .strict(); + +const NOTE_BODY_SCHEMA = z + .object({ + note: z.string().trim().max(500).nullable().optional(), + }) + .strict(); + +function normalizeNullableString(value) { + if (value == null) { + return null; + } + const normalized = String(value).trim(); + return normalized || null; +} + +function ensureReconciliationReadPermission(fastify, request) { + const allowed = + fastify.hasPermission(request.employee, "travel.payment.manage") || + fastify.hasPermission(request.employee, "integration.manage"); + if (!allowed) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } +} + +function parseListQuery(query) { + const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten(), 400); + } + + return { + status: parsed.data.status, + legalEntityId: parsed.data.legalEntityId ?? null, + costCenterId: parsed.data.costCenterId ?? null, + periodStart: parsed.data.periodStart ? parseDateOnlyString(parsed.data.periodStart) : null, + periodEnd: parsed.data.periodEnd ? parseDateOnlyString(parsed.data.periodEnd) : null, + page: parsed.data.page ?? 1, + pageSize: parsed.data.pageSize ?? 20, + }; +} + +function parseRunNow(query) { + return query?.runNow === "1" || query?.runNow === 1 || query?.runNow === true || query?.runNow === "true"; +} + +function ensureValidPeriod(periodStart, periodEnd) { + if (periodStart.getTime() > periodEnd.getTime()) { + throw buildValidationError({ period: "period_start_after_period_end" }); + } +} + +async function validateBatchScopeContext(prisma, { legalEntityId, costCenterId }) { + const [legalEntity, costCenter] = await Promise.all([ + prisma.legalEntity.findUnique({ + where: { id: legalEntityId }, + select: { id: true, code: true, name: true }, + }), + costCenterId + ? prisma.costCenter.findUnique({ + where: { id: costCenterId }, + select: { id: true, code: true, name: true, legal_entity_id: true }, + }) + : Promise.resolve(null), + ]); + + if (!legalEntity) { + throw buildValidationError({ legalEntityId: "not_found" }); + } + if (costCenterId && !costCenter) { + throw buildValidationError({ costCenterId: "not_found" }); + } + if (costCenter && costCenter.legal_entity_id !== legalEntityId) { + throw buildValidationError({ costCenterId: "legal_entity_mismatch" }); + } + + return { legalEntity, costCenter }; +} + +async function loadBatchForUpdate(tx, batchId) { + const rows = await tx.$queryRaw` + SELECT id + FROM "travel_reconciliation_batches" + WHERE id = ${batchId}::uuid + FOR UPDATE + `; + + if (!Array.isArray(rows) || rows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + return tx.travelReconciliationBatch.findUnique({ + where: { id: batchId }, + select: { + id: true, + batch_no: true, + legal_entity_id: true, + cost_center_id: true, + status: true, + summary_json: true, + confirmed_at: true, + confirmed_by: true, + items: { + select: { + id: true, + resolution_status: true, + }, + }, + }, + }); +} + +async function loadItemForUpdate(tx, itemId) { + const rows = await tx.$queryRaw` + SELECT id + FROM "travel_reconciliation_items" + WHERE id = ${itemId}::uuid + FOR UPDATE + `; + + if (!Array.isArray(rows) || rows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + return tx.travelReconciliationItem.findUnique({ + where: { id: itemId }, + select: { + id: true, + travel_reconciliation_batch_id: true, + match_status: true, + resolution_status: true, + confirmed_at: true, + confirmed_by: true, + confirm_note: true, + batch: { + select: { + id: true, + status: true, + summary_json: true, + }, + }, + travel_request: { + select: { + id: true, + request_no: true, + }, + }, + travel_order: { + select: { + id: true, + external_order_no: true, + status: true, + }, + }, + card_transaction: { + select: { + id: true, + external_txn_id: true, + status: true, + txn_type: true, + }, + }, + order_amount: true, + transaction_amount: true, + currency: true, + anomaly_code: true, + snapshot_json: true, + created_at: true, + updated_at: true, + travel_request_id: true, + travel_order_id: true, + card_transaction_id: true, + }, + }); +} + +async function syncBatchPendingSummary(tx, batchId) { + const batch = await tx.travelReconciliationBatch.findUnique({ + where: { id: batchId }, + select: { + id: true, + summary_json: true, + items: { + select: { + resolution_status: true, + }, + }, + }, + }); + + if (!batch) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const summary = normalizeSummaryJson(batch.summary_json); + summary.pendingItems = batch.items.filter( + (item) => item.resolution_status === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending, + ).length; + + await tx.travelReconciliationBatch.update({ + where: { id: batchId }, + data: { + summary_json: summary, + }, + }); + + return summary; +} + +async function travelReconciliationRoutes(fastify) { + fastify.get( + "/travel/reconciliation/batches", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + ensureReconciliationReadPermission(fastify, request); + const query = parseListQuery(request.query); + const result = await listTravelReconciliationBatches(fastify.prisma, query); + return sendData(reply, request, result); + }, + ); + + fastify.post( + "/travel/reconciliation/batches", + { + preHandler: fastify.requirePermission("travel.payment.manage"), + }, + async (request, reply) => { + const parsed = CREATE_BODY_SCHEMA.safeParse(request.body ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten(), 400); + } + + const costCenterId = parsed.data.costCenterId ?? null; + const periodStart = parseDateOnlyString(parsed.data.periodStart); + const periodEnd = parseDateOnlyString(parsed.data.periodEnd); + ensureValidPeriod(periodStart, periodEnd); + + const scopeContext = await validateBatchScopeContext(fastify.prisma, { + legalEntityId: parsed.data.legalEntityId, + costCenterId, + }); + const scope = await loadReconciliationScope(fastify.prisma, { + legalEntityId: parsed.data.legalEntityId, + costCenterId, + periodStart, + periodEnd, + }); + + if (!scope.orders.length && !scope.transactions.length) { + throw buildValidationError({ batch: "travel_reconciliation_empty_scope" }); + } + + const now = new Date(); + const snapshot = buildReconciliationItemsSnapshot({ + orders: scope.orders, + transactions: scope.transactions, + invoices: scope.invoices, + actorId: request.employee.id, + now, + }); + const issued = await issueDocumentNumber(fastify.prisma, { + documentType: "travel_reconciliation_batch", + actorId: request.employee.id, + timeZone: fastify.config.appTimezone, + }); + + let batchId = null; + await fastify.prisma.$transaction(async (tx) => { + const batch = await tx.travelReconciliationBatch.create({ + data: { + batch_no: issued.documentNumber, + legal_entity_id: parsed.data.legalEntityId, + cost_center_id: costCenterId, + period_start: periodStart, + period_end: periodEnd, + status: TRAVEL_RECONCILIATION_BATCH_STATUSES.draft, + summary_json: snapshot.summary, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + select: { id: true }, + }); + batchId = batch.id; + + if (snapshot.items.length) { + await tx.travelReconciliationItem.createMany({ + data: snapshot.items.map((item) => ({ + ...item, + travel_reconciliation_batch_id: batch.id, + })), + }); + } + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "TRAVEL_RECONCILIATION_BATCH_CREATED", + targetType: "travel_reconciliation_batch", + targetId: batch.id, + extra: { + batchNo: issued.documentNumber, + legalEntityId: parsed.data.legalEntityId, + costCenterId, + legalEntityCode: scopeContext.legalEntity.code, + costCenterCode: scopeContext.costCenter?.code ?? null, + periodStart: parsed.data.periodStart, + periodEnd: parsed.data.periodEnd, + summary: snapshot.summary, + }, + }), + }); + }); + + const detail = await loadTravelReconciliationBatchDetail(fastify.prisma, batchId, { + canManagePayments: true, + canRetryErp: fastify.hasPermission(request.employee, "integration.manage"), + }); + return sendData(reply, request, detail); + }, + ); + + fastify.get( + "/travel/reconciliation/batches/:batchId", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + ensureReconciliationReadPermission(fastify, request); + const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId); + if (!parsedId.success) { + throw buildValidationError({ batchId: "invalid_uuid" }, 400); + } + + const detail = await loadTravelReconciliationBatchDetail(fastify.prisma, parsedId.data, { + canManagePayments: fastify.hasPermission(request.employee, "travel.payment.manage"), + canRetryErp: fastify.hasPermission(request.employee, "integration.manage"), + }); + return sendData(reply, request, detail); + }, + ); + + fastify.post( + "/travel/reconciliation/items/:itemId/confirm", + { + preHandler: fastify.requirePermission("travel.payment.manage"), + }, + async (request, reply) => { + const parsedId = UUID_SCHEMA.safeParse(request.params?.itemId); + if (!parsedId.success) { + throw buildValidationError({ itemId: "invalid_uuid" }, 400); + } + const parsedBody = NOTE_BODY_SCHEMA.safeParse(request.body ?? {}); + if (!parsedBody.success) { + throw buildValidationError(parsedBody.error.flatten(), 400); + } + + let response = null; + await fastify.prisma.$transaction(async (tx) => { + const item = await loadItemForUpdate(tx, parsedId.data); + ensureDraftBatch(item.batch); + ensurePendingReconciliationItem(item); + + const updated = await tx.travelReconciliationItem.update({ + where: { id: parsedId.data }, + data: { + resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed, + confirmed_at: new Date(), + confirmed_by: request.employee.id, + confirm_note: normalizeNullableString(parsedBody.data.note), + }, + select: { + id: true, + item_kind: true, + travel_request_id: true, + travel_order_id: true, + card_transaction_id: true, + match_status: true, + resolution_status: true, + order_amount: true, + transaction_amount: true, + currency: true, + anomaly_code: true, + snapshot_json: true, + confirmed_at: true, + confirmed_by: true, + confirm_note: true, + created_at: true, + updated_at: true, + travel_request: { select: { id: true, request_no: true } }, + travel_order: { select: { id: true, external_order_no: true, status: true } }, + card_transaction: { select: { id: true, external_txn_id: true, status: true, txn_type: true } }, + }, + }); + + const summary = await syncBatchPendingSummary(tx, item.batch.id); + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "TRAVEL_RECONCILIATION_ITEM_CONFIRMED", + targetType: "travel_reconciliation_item", + targetId: item.id, + extra: { + batchId: item.batch.id, + matchStatus: item.match_status, + note: normalizeNullableString(parsedBody.data.note), + summary, + }, + }), + }); + + response = { + batchId: item.batch.id, + summary, + item: mapBatchItem(updated, { canManagePayments: true, batchStatus: item.batch.status }), + }; + }); + + return sendData(reply, request, response); + }, + ); + + fastify.post( + "/travel/reconciliation/items/:itemId/reopen", + { + preHandler: fastify.requirePermission("travel.payment.manage"), + }, + async (request, reply) => { + const parsedId = UUID_SCHEMA.safeParse(request.params?.itemId); + if (!parsedId.success) { + throw buildValidationError({ itemId: "invalid_uuid" }, 400); + } + + let response = null; + await fastify.prisma.$transaction(async (tx) => { + const item = await loadItemForUpdate(tx, parsedId.data); + ensureDraftBatch(item.batch); + ensureConfirmedReconciliationItem(item); + ensureReopenableReconciliationItem(item); + + const updated = await tx.travelReconciliationItem.update({ + where: { id: parsedId.data }, + data: { + resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending, + confirmed_at: null, + confirmed_by: null, + confirm_note: null, + }, + select: { + id: true, + item_kind: true, + travel_request_id: true, + travel_order_id: true, + card_transaction_id: true, + match_status: true, + resolution_status: true, + order_amount: true, + transaction_amount: true, + currency: true, + anomaly_code: true, + snapshot_json: true, + confirmed_at: true, + confirmed_by: true, + confirm_note: true, + created_at: true, + updated_at: true, + travel_request: { select: { id: true, request_no: true } }, + travel_order: { select: { id: true, external_order_no: true, status: true } }, + card_transaction: { select: { id: true, external_txn_id: true, status: true, txn_type: true } }, + }, + }); + + const summary = await syncBatchPendingSummary(tx, item.batch.id); + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "TRAVEL_RECONCILIATION_ITEM_REOPENED", + targetType: "travel_reconciliation_item", + targetId: item.id, + extra: { + batchId: item.batch.id, + matchStatus: item.match_status, + summary, + }, + }), + }); + + response = { + batchId: item.batch.id, + summary, + item: mapBatchItem(updated, { canManagePayments: true, batchStatus: item.batch.status }), + }; + }); + + return sendData(reply, request, response); + }, + ); + + fastify.post( + "/travel/reconciliation/batches/:batchId/finalize", + { + preHandler: fastify.requirePermission("travel.payment.manage"), + }, + async (request, reply) => { + const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId); + if (!parsedId.success) { + throw buildValidationError({ batchId: "invalid_uuid" }, 400); + } + + let response = null; + await fastify.prisma.$transaction(async (tx) => { + const batch = await loadBatchForUpdate(tx, parsedId.data); + const dedupeKey = buildTravelReconciliationSummaryDedupeKey(batch.id); + + if (batch.status === TRAVEL_RECONCILIATION_BATCH_STATUSES.reconciled) { + const existingOutbox = await tx.integrationOutbox.findUnique({ + where: { + event_type_dedupe_key: { + event_type: TRAVEL_ERP_EVENT_TYPE, + dedupe_key: dedupeKey, + }, + }, + select: { id: true }, + }); + + response = { + batchId: batch.id, + batchNo: batch.batch_no, + status: batch.status, + erpExport: { + eventType: TRAVEL_ERP_EVENT_TYPE, + outboxId: existingOutbox?.id ?? null, + duplicated: Boolean(existingOutbox), + requeued: false, + enqueued: Boolean(existingOutbox), + }, + }; + return; + } + + ensureDraftBatch(batch); + ensureBatchFinalizable(batch.items); + + const finalizedAt = new Date(); + await tx.travelReconciliationBatch.update({ + where: { id: batch.id }, + data: { + status: TRAVEL_RECONCILIATION_BATCH_STATUSES.reconciled, + confirmed_at: finalizedAt, + confirmed_by: request.employee.id, + updated_by: request.employee.id, + summary_json: { + ...normalizeSummaryJson(batch.summary_json), + pendingItems: 0, + }, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "TRAVEL_RECONCILIATION_FINALIZED", + targetType: "travel_reconciliation_batch", + targetId: batch.id, + extra: { + batchNo: batch.batch_no, + itemCount: batch.items.length, + }, + }), + }); + + const gate = await evaluateFeatureFlag({ + prisma: tx, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.erpSync, + departmentId: null, + strictDepartment: false, + }); + + let erpExport = { + eventType: TRAVEL_ERP_EVENT_TYPE, + outboxId: null, + duplicated: false, + requeued: false, + enqueued: false, + }; + + if (fastify.config.erpSyncEnabled && gate.enabled) { + const exportContext = await tx.travelReconciliationBatch.findUnique({ + where: { id: batch.id }, + select: { + id: true, + batch_no: true, + legal_entity_id: true, + cost_center_id: true, + period_start: true, + period_end: true, + status: true, + summary_json: true, + confirmed_at: true, + created_at: true, + updated_at: true, + legal_entity: { select: { id: true, code: true, name: true } }, + cost_center: { select: { id: true, code: true, name: true } }, + items: { + orderBy: [{ created_at: "asc" }, { id: "asc" }], + select: { + id: true, + travel_request_id: true, + travel_order_id: true, + card_transaction_id: true, + match_status: true, + resolution_status: true, + order_amount: true, + transaction_amount: true, + currency: true, + snapshot_json: true, + }, + }, + }, + }); + const outbox = await enqueueTravelReconciliationSummaryExport(tx, fastify.config, { + batch: exportContext, + actorId: request.employee.id, + requeueOnFailure: true, + }); + erpExport = { + eventType: TRAVEL_ERP_EVENT_TYPE, + outboxId: outbox.id, + duplicated: outbox.duplicated, + requeued: outbox.requeued, + enqueued: true, + }; + } else { + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG", + targetType: "travel_reconciliation_batch", + targetId: batch.id, + extra: { + featureFlag: FEATURE_FLAG_KEYS.erpSync, + reason: fastify.config.erpSyncEnabled ? gate.reason : "env_disabled", + }, + }), + }); + } + + response = { + batchId: batch.id, + batchNo: batch.batch_no, + status: TRAVEL_RECONCILIATION_BATCH_STATUSES.reconciled, + erpExport, + }; + }); + + return sendData(reply, request, response); + }, + ); +} + +travelReconciliationRoutes.__testables = { + ensureReconciliationReadPermission, + parseListQuery, + parseRunNow, +}; + +module.exports = travelReconciliationRoutes; diff --git a/server/src/services/documentSequence.js b/server/src/services/documentSequence.js index febbd7a6..8e43ba78 100644 --- a/server/src/services/documentSequence.js +++ b/server/src/services/documentSequence.js @@ -50,6 +50,17 @@ const DOCUMENT_TYPE_CONFIG = { resetPeriod: "yearly", }, }, + travel_reconciliation_batch: { + defaultCategoryCode: "TR_RECON", + preferLegacySequence: false, + fallback: { + prefix: "TRB", + dateFormat: "YYYYMMDD", + separator: "-", + numberLength: 4, + resetPeriod: "yearly", + }, + }, asset_card: { defaultCategoryCode: "ASSET_CARD_DEFAULT", preferLegacySequence: false, diff --git a/server/src/services/integrations/dispatcher.js b/server/src/services/integrations/dispatcher.js index 5fef1d57..792d290b 100644 --- a/server/src/services/integrations/dispatcher.js +++ b/server/src/services/integrations/dispatcher.js @@ -3,6 +3,7 @@ const { exportAssetToErp, exportJournalToErp, exportProcurementToErp, + exportTravelToErp, } = require('./erp'); const { sendInstantMessageByChannel } = require('../notifications/channels'); @@ -159,6 +160,23 @@ function resolveBindingSpecs(eventType, payload, response) { ].filter(Boolean); } + if (eventType === 'erp.travel.export') { + return [ + createBindingSpec( + 'travel_payable_summary', + payload?.entityId, + extractResponseValue(response, 'externalSummaryId'), + extractResponseValue(response, 'externalSummaryNo') ?? payload?.batchNo ?? null, + ), + createBindingSpec( + 'travel_settlement_batch', + payload?.entityId, + extractResponseValue(response, 'externalBatchId'), + extractResponseValue(response, 'externalBatchNo') ?? payload?.batchNo ?? null, + ), + ].filter(Boolean); + } + return []; } @@ -204,9 +222,11 @@ async function dispatchErpEvent({ prisma, config, eventType, payload }) { ? await exportProcurementToErp(config, payload) : eventType === 'erp.asset.export' ? await exportAssetToErp(config, payload) - : eventType === 'erp.journal.export' - ? await exportJournalToErp(config, payload) - : await pushPaidReimbursementToErp(config, payload); + : eventType === 'erp.travel.export' + ? await exportTravelToErp(config, payload) + : eventType === 'erp.journal.export' + ? await exportJournalToErp(config, payload) + : await pushPaidReimbursementToErp(config, payload); if (!result.ok) { throw toDispatchError(result); @@ -226,7 +246,8 @@ async function dispatchOutboxEvent({ prisma, config, eventType, payload }) { eventType === 'erp.reimbursement.paid' || eventType === 'erp.journal.export' || eventType === 'erp.procurement.export' || - eventType === 'erp.asset.export' + eventType === 'erp.asset.export' || + eventType === 'erp.travel.export' ) { return dispatchErpEvent({ prisma, config, eventType, payload }); } diff --git a/server/src/services/integrations/erpTravel.js b/server/src/services/integrations/erpTravel.js new file mode 100644 index 00000000..b34379f7 --- /dev/null +++ b/server/src/services/integrations/erpTravel.js @@ -0,0 +1,333 @@ +const { enqueueIntegrationEvent } = require("./outbox"); +const { normalizeSummaryJson, toMoneyFixed } = require("../travelReconciliation"); + +const TRAVEL_ERP_EVENT_TYPE = "erp.travel.export"; +const TRAVEL_ERP_EXPORT_TYPE = "travel_reconciliation_summary"; +const TRAVEL_ERP_BINDING_TYPES = Object.freeze({ + payableSummary: "travel_payable_summary", + settlementBatch: "travel_settlement_batch", +}); +const TRAVEL_ERP_TRACK_STATUSES = Object.freeze({ + notTriggered: "not_triggered", + skipped: "skipped", +}); + +const TRAVEL_RECONCILIATION_ERP_SELECT = { + id: true, + batch_no: true, + legal_entity_id: true, + cost_center_id: true, + period_start: true, + period_end: true, + status: true, + summary_json: true, + confirmed_at: true, + created_at: true, + updated_at: true, + legal_entity: { + select: { + id: true, + code: true, + name: true, + }, + }, + cost_center: { + select: { + id: true, + code: true, + name: true, + }, + }, + items: { + orderBy: [{ created_at: "asc" }, { id: "asc" }], + select: { + id: true, + travel_request_id: true, + travel_order_id: true, + card_transaction_id: true, + match_status: true, + resolution_status: true, + order_amount: true, + transaction_amount: true, + currency: true, + snapshot_json: true, + }, + }, +}; + +function toDateOnly(value) { + if (!value) { + return null; + } + try { + return new Date(value).toISOString().slice(0, 10); + } catch { + return null; + } +} + +function toIsoString(value) { + if (!value) { + return null; + } + try { + return new Date(value).toISOString(); + } catch { + return null; + } +} + +function mapIntegrationBinding(record) { + if (!record) { + return null; + } + + return { + id: record.id, + provider: record.provider, + entityType: record.entity_type, + localId: record.local_id, + externalId: record.external_id ?? null, + externalCode: record.external_code ?? null, + lastSyncedAt: record.last_synced_at?.toISOString?.() ?? null, + }; +} + +function redactBinding(binding, canManage) { + if (!binding) { + return null; + } + if (canManage) { + return binding; + } + return { + ...binding, + externalId: null, + externalCode: null, + }; +} + +function mapIntegrationOutboxState(record) { + if (!record) { + return null; + } + return { + id: record.id, + eventType: record.event_type, + status: record.status, + attempts: Number(record.attempts ?? 0), + lastError: record.last_error ?? null, + processedAt: record.processed_at?.toISOString?.() ?? null, + updatedAt: record.updated_at?.toISOString?.() ?? null, + }; +} + +function buildTravelReconciliationSummaryDedupeKey(batchId) { + return `travel_reconciliation_batch:${batchId}:summary`; +} + +function mapPayloadItem(item) { + const snapshot = item.snapshot_json && typeof item.snapshot_json === "object" ? item.snapshot_json : {}; + return { + travelRequestId: item.travel_request_id ?? null, + requestNo: snapshot.requestNo ?? null, + travelOrderId: item.travel_order_id ?? null, + externalOrderNo: snapshot.externalOrderNo ?? null, + cardTransactionId: item.card_transaction_id ?? null, + externalTxnId: snapshot.externalTxnId ?? null, + matchStatus: item.match_status, + resolutionStatus: item.resolution_status, + orderAmount: item.order_amount?.toString?.() ?? null, + transactionAmount: item.transaction_amount?.toString?.() ?? null, + currency: item.currency ?? "CNY", + linkedReimbursementId: snapshot.linkedReimbursementId ?? null, + boundInvoiceCount: Number(snapshot.boundInvoiceCount ?? 0), + }; +} + +function buildTravelReconciliationSummaryPayload(input) { + const batch = input?.batch ?? {}; + const summary = normalizeSummaryJson(batch.summary_json); + const items = Array.isArray(batch.items) ? batch.items.map(mapPayloadItem) : []; + + return { + exportType: TRAVEL_ERP_EXPORT_TYPE, + entityType: "travel_reconciliation_batch", + entityId: batch.id ?? null, + batchNo: batch.batch_no ?? null, + occurredAt: + toIsoString(batch.confirmed_at) ?? + toIsoString(batch.updated_at) ?? + toIsoString(batch.created_at), + periodStart: toDateOnly(batch.period_start), + periodEnd: toDateOnly(batch.period_end), + dimensions: { + legalEntityCode: batch.legal_entity?.code ?? null, + costCenterCode: batch.cost_center?.code ?? null, + }, + summary: { + totalOrders: Number(summary.totalOrders ?? 0), + totalTransactions: Number(summary.totalTransactions ?? 0), + anomalyItems: Number(summary.anomalyItems ?? 0), + matchRate: toMoneyFixed(summary.matchRate), + totalOrderAmount: toMoneyFixed(summary.totalOrderAmount), + totalSettledAmount: toMoneyFixed(summary.totalSettledAmount), + totalReversedAmount: toMoneyFixed(summary.totalReversedAmount), + }, + items, + }; +} + +async function enqueueTravelReconciliationSummaryExport(prisma, config, input) { + const payload = + input?.payload ?? buildTravelReconciliationSummaryPayload({ + batch: input?.batch, + }); + + const enqueued = await enqueueIntegrationEvent(prisma, { + eventType: TRAVEL_ERP_EVENT_TYPE, + payload, + dedupeKey: buildTravelReconciliationSummaryDedupeKey(payload.entityId), + maxAttempts: config.integrationOutboxMaxAttempts, + actorId: input?.actorId ?? null, + requeueOnFailure: input?.requeueOnFailure === true, + }); + + return { + ...enqueued, + eventType: TRAVEL_ERP_EVENT_TYPE, + payload, + }; +} + +async function findOutboxByKey(prisma, eventType, dedupeKey) { + if (!eventType || !dedupeKey) { + return null; + } + return prisma.integrationOutbox.findUnique({ + where: { + event_type_dedupe_key: { + event_type: eventType, + dedupe_key: dedupeKey, + }, + }, + }); +} + +async function findBindingByLocalId(prisma, entityType, localId) { + return prisma.integrationBinding.findUnique({ + where: { + provider_entity_type_local_id: { + provider: "erp", + entity_type: entityType, + local_id: localId, + }, + }, + }); +} + +async function loadTravelReconciliationErpContext(prisma, batchId) { + return prisma.travelReconciliationBatch.findUnique({ + where: { id: batchId }, + select: TRAVEL_RECONCILIATION_ERP_SELECT, + }); +} + +function resolveTrackStatus({ eligible, outbox, payableBinding, settlementBinding, gateEnabled }) { + if (outbox?.status) { + return outbox.status; + } + if (payableBinding && settlementBinding) { + return "succeeded"; + } + if (!eligible) { + return TRAVEL_ERP_TRACK_STATUSES.notTriggered; + } + if (!gateEnabled) { + return TRAVEL_ERP_TRACK_STATUSES.skipped; + } + return TRAVEL_ERP_TRACK_STATUSES.notTriggered; +} + +async function buildTravelReconciliationErpTrack(prisma, config, batchId, options = {}) { + const batch = options.context ?? (await loadTravelReconciliationErpContext(prisma, batchId)); + if (!batch) { + return null; + } + + const canManage = Boolean(options.canManage); + const gateEnabled = Boolean(options.gate?.effectiveEnabled ?? options.gate?.enabled ?? true); + const dedupeKey = buildTravelReconciliationSummaryDedupeKey(batch.id); + + const [outboxRow, payableBindingRow, settlementBindingRow] = await Promise.all([ + findOutboxByKey(prisma, TRAVEL_ERP_EVENT_TYPE, dedupeKey), + findBindingByLocalId(prisma, TRAVEL_ERP_BINDING_TYPES.payableSummary, batch.id), + findBindingByLocalId(prisma, TRAVEL_ERP_BINDING_TYPES.settlementBatch, batch.id), + ]); + + const eligible = batch.status === "reconciled"; + const reason = eligible ? null : "travel_reconciliation_batch_not_reconciled"; + const outbox = mapIntegrationOutboxState(outboxRow); + const payableBinding = mapIntegrationBinding(payableBindingRow); + const settlementBinding = mapIntegrationBinding(settlementBindingRow); + + return { + batch: { + id: batch.id, + batchNo: batch.batch_no, + status: batch.status, + periodStart: toDateOnly(batch.period_start), + periodEnd: toDateOnly(batch.period_end), + legalEntity: batch.legal_entity + ? { + id: batch.legal_entity.id, + code: batch.legal_entity.code, + name: batch.legal_entity.name, + } + : null, + costCenter: batch.cost_center + ? { + id: batch.cost_center.id, + code: batch.cost_center.code, + name: batch.cost_center.name, + } + : null, + }, + stage: { + summaryExport: { + eligible, + reason, + status: resolveTrackStatus({ + eligible, + outbox, + payableBinding: payableBindingRow, + settlementBinding: settlementBindingRow, + gateEnabled, + }), + preview: buildTravelReconciliationSummaryPayload({ batch }), + outbox, + bindings: { + payableSummary: redactBinding(payableBinding, canManage), + settlementBatch: redactBinding(settlementBinding, canManage), + }, + }, + }, + permissions: { + canRetry: Boolean(canManage && gateEnabled && eligible), + canSeeExternalRefs: canManage, + }, + }; +} + +module.exports = { + TRAVEL_ERP_BINDING_TYPES, + TRAVEL_ERP_EVENT_TYPE, + TRAVEL_ERP_EXPORT_TYPE, + TRAVEL_ERP_TRACK_STATUSES, + TRAVEL_RECONCILIATION_ERP_SELECT, + buildTravelReconciliationErpTrack, + buildTravelReconciliationSummaryDedupeKey, + buildTravelReconciliationSummaryPayload, + enqueueTravelReconciliationSummaryExport, + loadTravelReconciliationErpContext, +}; diff --git a/server/src/services/travelReconciliation.js b/server/src/services/travelReconciliation.js new file mode 100644 index 00000000..eadab6bb --- /dev/null +++ b/server/src/services/travelReconciliation.js @@ -0,0 +1,738 @@ +const { AppError } = require("../utils/AppError"); + +const TRAVEL_RECONCILIATION_BATCH_STATUSES = Object.freeze({ + draft: "draft", + reconciled: "reconciled", +}); + +const TRAVEL_RECONCILIATION_ITEM_KINDS = Object.freeze({ + order: "order", + orphanTransaction: "orphan_transaction", +}); + +const TRAVEL_RECONCILIATION_MATCH_STATUSES = Object.freeze({ + matched: "matched", + amountMismatch: "amount_mismatch", + currencyMismatch: "currency_mismatch", + missingTransaction: "missing_transaction", + duplicateCharge: "duplicate_charge", + orphanTransaction: "orphan_transaction", + reversedAfterPayment: "reversed_after_payment", +}); + +const TRAVEL_RECONCILIATION_RESOLUTION_STATUSES = Object.freeze({ + pending: "pending", + confirmed: "confirmed", +}); + +function buildValidationError(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 toDateOnlyString(value) { + if (!value) { + return null; + } + try { + return new Date(value).toISOString().slice(0, 10); + } catch { + return null; + } +} + +function normalizeSummaryJson(value) { + const summary = value && typeof value === "object" ? value : {}; + return { + totalOrders: Number(summary.totalOrders ?? 0), + totalTransactions: Number(summary.totalTransactions ?? 0), + matchedItems: Number(summary.matchedItems ?? 0), + pendingItems: Number(summary.pendingItems ?? 0), + anomalyItems: Number(summary.anomalyItems ?? 0), + totalOrderAmount: toMoneyFixed(summary.totalOrderAmount), + totalSettledAmount: toMoneyFixed(summary.totalSettledAmount), + totalReversedAmount: toMoneyFixed(summary.totalReversedAmount), + matchRate: toMoneyFixed(summary.matchRate), + }; +} + +function isSettledCharge(transaction) { + return transaction?.txn_type === "charge" && transaction?.status === "settled"; +} + +function isReversedTransaction(transaction) { + return ["refund", "reversal"].includes(String(transaction?.txn_type ?? "")) || transaction?.status === "reversed"; +} + +function sumTransactionAmounts(items, predicate) { + return items.reduce((total, item) => { + if (!predicate(item)) { + return total; + } + return toMoneyNumber(total + toMoneyNumber(item.amount)); + }, 0); +} + +function buildReasonDetail(matchStatus, order, settledCharges, reversedTransactions) { + if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched) { + return `订单 ${order.external_order_no} 与已结算交易精确匹配。`; + } + if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.missingTransaction) { + return `订单 ${order.external_order_no} 未找到已结算的 charge 交易。`; + } + if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.duplicateCharge) { + return `订单 ${order.external_order_no} 关联到 ${settledCharges.length} 条已结算 charge 交易,无法判定唯一匹配。`; + } + if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.currencyMismatch) { + return `订单 ${order.external_order_no} 与交易 ${settledCharges[0]?.external_txn_id ?? "-"} 币种不一致。`; + } + if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.amountMismatch) { + return `订单 ${order.external_order_no} 与交易 ${settledCharges[0]?.external_txn_id ?? "-"} 金额不一致。`; + } + if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.reversedAfterPayment) { + return `订单 ${order.external_order_no} 在支付后存在 ${reversedTransactions.length} 条 refund/reversal 交易。`; + } + return `订单 ${order.external_order_no} 存在待人工确认的差异。`; +} + +function analyzeOrderTransactions(order, orderTransactions = []) { + const settledCharges = orderTransactions.filter(isSettledCharge); + const reversedTransactions = orderTransactions.filter(isReversedTransaction); + const settledAmount = sumTransactionAmounts(settledCharges, () => true); + const primaryTransaction = settledCharges[0] ?? reversedTransactions[0] ?? null; + const exactMatch = + settledCharges.length === 1 && + String(order.currency ?? "").toUpperCase() === String(primaryTransaction?.currency ?? "").toUpperCase() && + toMoneyFixed(order.amount) === toMoneyFixed(primaryTransaction?.amount); + + let matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.missingTransaction; + + if (reversedTransactions.length > 0) { + matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.reversedAfterPayment; + } else if (settledCharges.length === 0) { + matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.missingTransaction; + } else if (settledCharges.length > 1) { + matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.duplicateCharge; + } else if (String(order.currency ?? "").toUpperCase() !== String(primaryTransaction?.currency ?? "").toUpperCase()) { + matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.currencyMismatch; + } else if (!exactMatch) { + matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.amountMismatch; + } else { + matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.matched; + } + + const anomalyCode = matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched ? null : matchStatus; + const resolutionStatus = + matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched + ? TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed + : TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending; + + return { + primaryTransaction, + matchStatus, + anomalyCode, + resolutionStatus, + transactionAmount: toMoneyFixed(settledAmount || primaryTransaction?.amount), + matchedTransactionIds: orderTransactions.map((item) => item.id), + reasonDetail: buildReasonDetail(matchStatus, order, settledCharges, reversedTransactions), + }; +} + +function buildInvoiceStatsMap(invoices = []) { + const stats = new Map(); + for (const invoice of invoices) { + if (!invoice.travel_order_id) { + continue; + } + const current = stats.get(invoice.travel_order_id) ?? { + count: 0, + amount: 0, + }; + current.count += 1; + current.amount = toMoneyNumber(current.amount + toMoneyNumber(invoice.total_amount)); + stats.set(invoice.travel_order_id, current); + } + return stats; +} + +function buildOrderItem({ order, orderTransactions = [], invoiceStatsMap, actorId, now }) { + const analysis = analyzeOrderTransactions(order, orderTransactions); + const invoiceStats = invoiceStatsMap.get(order.id) ?? { count: 0, amount: 0 }; + const primaryTransaction = analysis.primaryTransaction; + const autoConfirmed = analysis.resolutionStatus === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed; + + return { + item_kind: TRAVEL_RECONCILIATION_ITEM_KINDS.order, + travel_request_id: order.travel_request_id ?? null, + travel_order_id: order.id, + card_transaction_id: primaryTransaction?.id ?? null, + match_status: analysis.matchStatus, + resolution_status: analysis.resolutionStatus, + order_amount: toMoneyFixed(order.amount), + transaction_amount: analysis.transactionAmount, + currency: order.currency ?? primaryTransaction?.currency ?? "CNY", + anomaly_code: analysis.anomalyCode, + snapshot_json: { + requestNo: order.travel_request?.request_no ?? null, + externalOrderNo: order.external_order_no, + externalTxnId: primaryTransaction?.external_txn_id ?? null, + travelOrderStatus: order.status, + transactionStatus: primaryTransaction?.status ?? null, + linkedReimbursementId: order.linked_reimbursement_id ?? null, + boundInvoiceCount: invoiceStats.count, + boundInvoiceAmount: toMoneyFixed(invoiceStats.amount), + matchedTransactionIds: analysis.matchedTransactionIds, + reasonDetail: analysis.reasonDetail, + }, + confirmed_at: autoConfirmed ? now : null, + confirmed_by: autoConfirmed ? actorId : null, + confirm_note: null, + }; +} + +function buildOrphanTransactionItem({ transaction, actorId, now }) { + return { + item_kind: TRAVEL_RECONCILIATION_ITEM_KINDS.orphanTransaction, + travel_request_id: transaction.travel_request_id ?? null, + travel_order_id: transaction.travel_order_id ?? null, + card_transaction_id: transaction.id, + match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.orphanTransaction, + resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending, + order_amount: null, + transaction_amount: toMoneyFixed(transaction.amount), + currency: transaction.currency ?? "CNY", + anomaly_code: TRAVEL_RECONCILIATION_MATCH_STATUSES.orphanTransaction, + snapshot_json: { + requestNo: transaction.travel_request?.request_no ?? null, + externalOrderNo: transaction.travel_order?.external_order_no ?? null, + externalTxnId: transaction.external_txn_id, + travelOrderStatus: transaction.travel_order?.status ?? null, + transactionStatus: transaction.status, + linkedReimbursementId: transaction.travel_order?.linked_reimbursement_id ?? null, + boundInvoiceCount: 0, + boundInvoiceAmount: toMoneyFixed(0), + matchedTransactionIds: [transaction.id], + reasonDetail: `交易 ${transaction.external_txn_id} 未匹配到本批次范围内的差旅订单。`, + }, + confirmed_at: null, + confirmed_by: null, + confirm_note: null, + }; +} + +function buildTravelReconciliationSummary({ items, orders, transactions }) { + const matchedItems = items.filter((item) => item.match_status === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched).length; + const pendingItems = items.filter((item) => item.resolution_status === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending).length; + const anomalyItems = items.length - matchedItems; + const totalOrderAmount = orders.reduce((total, item) => toMoneyNumber(total + toMoneyNumber(item.amount)), 0); + const totalSettledAmount = transactions.reduce((total, item) => { + if (!isSettledCharge(item)) { + return total; + } + return toMoneyNumber(total + toMoneyNumber(item.amount)); + }, 0); + const totalReversedAmount = transactions.reduce((total, item) => { + if (!isReversedTransaction(item)) { + return total; + } + return toMoneyNumber(total + toMoneyNumber(item.amount)); + }, 0); + const matchRate = items.length ? (matchedItems / items.length) * 100 : 0; + + return { + totalOrders: orders.length, + totalTransactions: transactions.length, + matchedItems, + pendingItems, + anomalyItems, + totalOrderAmount: toMoneyFixed(totalOrderAmount), + totalSettledAmount: toMoneyFixed(totalSettledAmount), + totalReversedAmount: toMoneyFixed(totalReversedAmount), + matchRate: toMoneyFixed(matchRate), + }; +} + +function buildReconciliationItemsSnapshot({ orders = [], transactions = [], invoices = [], actorId = null, now = new Date() }) { + const invoiceStatsMap = buildInvoiceStatsMap(invoices); + const ordersById = new Map(orders.map((item) => [item.id, item])); + const transactionsByOrderId = new Map(); + + for (const transaction of transactions) { + if (!transaction.travel_order_id) { + continue; + } + const list = transactionsByOrderId.get(transaction.travel_order_id) ?? []; + list.push(transaction); + transactionsByOrderId.set(transaction.travel_order_id, list); + } + + const items = orders.map((order) => + buildOrderItem({ + order, + orderTransactions: transactionsByOrderId.get(order.id) ?? [], + invoiceStatsMap, + actorId, + now, + }), + ); + + const orphanTransactions = transactions.filter( + (transaction) => !transaction.travel_order_id || !ordersById.has(transaction.travel_order_id), + ); + for (const transaction of orphanTransactions) { + items.push(buildOrphanTransactionItem({ transaction, actorId, now })); + } + + return { + items, + summary: buildTravelReconciliationSummary({ items, orders, transactions }), + }; +} + +function buildOrderWhere({ legalEntityId, costCenterId, periodStart, periodEnd, periodEndExclusive }) { + return { + status: "paid", + travel_request: { + legal_entity_id: legalEntityId, + ...(costCenterId ? { cost_center_id: costCenterId } : {}), + }, + OR: [ + { + payment_date: { + gte: periodStart, + lte: periodEnd, + }, + }, + { + AND: [ + { payment_date: null }, + { paid_at: { gte: periodStart, lt: periodEndExclusive } }, + ], + }, + ], + }; +} + +function buildTransactionWhere({ legalEntityId, costCenterId, periodStart, periodEndExclusive }) { + return { + status: { in: ["settled", "reversed"] }, + travel_request: { + legal_entity_id: legalEntityId, + ...(costCenterId ? { cost_center_id: costCenterId } : {}), + }, + occurred_at: { + gte: periodStart, + lt: periodEndExclusive, + }, + }; +} + +const ORDER_SCOPE_SELECT = { + id: true, + source: true, + external_order_no: true, + linked_reimbursement_id: true, + travel_request_id: true, + amount: true, + currency: true, + status: true, + payment_date: true, + paid_at: true, + travel_request: { + select: { + id: true, + request_no: true, + legal_entity_id: true, + cost_center_id: true, + }, + }, +}; + +const TRANSACTION_SCOPE_SELECT = { + id: true, + source: true, + external_txn_id: true, + travel_request_id: true, + travel_order_id: true, + amount: true, + currency: true, + txn_type: true, + status: true, + occurred_at: true, + travel_request: { + select: { + id: true, + request_no: true, + }, + }, + travel_order: { + select: { + id: true, + external_order_no: true, + linked_reimbursement_id: true, + status: true, + }, + }, +}; + +async function loadReconciliationScope(prisma, { legalEntityId, costCenterId = null, periodStart, periodEnd }) { + const normalizedStart = new Date(`${toDateOnlyString(periodStart)}T00:00:00.000Z`); + const normalizedEnd = new Date(`${toDateOnlyString(periodEnd)}T00:00:00.000Z`); + const periodEndExclusive = new Date(normalizedEnd.getTime() + 24 * 60 * 60 * 1000); + + const [orders, transactions] = await Promise.all([ + prisma.travelOrder.findMany({ + where: buildOrderWhere({ + legalEntityId, + costCenterId, + periodStart: normalizedStart, + periodEnd: normalizedEnd, + periodEndExclusive, + }), + orderBy: [{ payment_date: "asc" }, { paid_at: "asc" }, { created_at: "asc" }], + select: ORDER_SCOPE_SELECT, + }), + prisma.cardTransaction.findMany({ + where: buildTransactionWhere({ + legalEntityId, + costCenterId, + periodStart: normalizedStart, + periodEndExclusive, + }), + orderBy: [{ occurred_at: "asc" }, { created_at: "asc" }], + select: TRANSACTION_SCOPE_SELECT, + }), + ]); + + const orderIds = orders.map((item) => item.id); + const invoices = orderIds.length + ? await prisma.invoicePool.findMany({ + where: { travel_order_id: { in: orderIds } }, + select: { + id: true, + travel_order_id: true, + total_amount: true, + }, + }) + : []; + + return { + orders, + transactions, + invoices, + periodStart: normalizedStart, + periodEnd: normalizedEnd, + }; +} + +function mapBatchSummary(batch) { + return { + id: batch.id, + batchNo: batch.batch_no, + legalEntityId: batch.legal_entity_id, + costCenterId: batch.cost_center_id ?? null, + periodStart: toDateOnlyString(batch.period_start), + periodEnd: toDateOnlyString(batch.period_end), + status: batch.status, + summary: normalizeSummaryJson(batch.summary_json), + confirmedAt: batch.confirmed_at?.toISOString?.() ?? null, + confirmedBy: batch.confirmed_by ?? null, + createdAt: batch.created_at?.toISOString?.() ?? null, + updatedAt: batch.updated_at?.toISOString?.() ?? null, + legalEntity: batch.legal_entity + ? { + id: batch.legal_entity.id, + code: batch.legal_entity.code, + name: batch.legal_entity.name, + } + : null, + costCenter: batch.cost_center + ? { + id: batch.cost_center.id, + code: batch.cost_center.code, + name: batch.cost_center.name, + legalEntityId: batch.cost_center.legal_entity_id, + } + : null, + confirmedByEmployee: batch.confirmed_by_employee + ? { + id: batch.confirmed_by_employee.id, + employeeNo: batch.confirmed_by_employee.employee_no, + name: batch.confirmed_by_employee.name, + } + : null, + }; +} + +function mapBatchItem(item, { canManagePayments = false, batchStatus = TRAVEL_RECONCILIATION_BATCH_STATUSES.draft } = {}) { + const snapshot = item.snapshot_json && typeof item.snapshot_json === "object" ? item.snapshot_json : {}; + return { + id: item.id, + itemKind: item.item_kind, + travelRequestId: item.travel_request_id ?? null, + travelOrderId: item.travel_order_id ?? null, + cardTransactionId: item.card_transaction_id ?? null, + matchStatus: item.match_status, + resolutionStatus: item.resolution_status, + orderAmount: item.order_amount?.toString?.() ?? null, + transactionAmount: item.transaction_amount?.toString?.() ?? null, + currency: item.currency, + anomalyCode: item.anomaly_code ?? null, + confirmedAt: item.confirmed_at?.toISOString?.() ?? null, + confirmedBy: item.confirmed_by ?? null, + confirmNote: item.confirm_note ?? null, + snapshot: { + requestNo: snapshot.requestNo ?? null, + externalOrderNo: snapshot.externalOrderNo ?? null, + externalTxnId: snapshot.externalTxnId ?? null, + travelOrderStatus: snapshot.travelOrderStatus ?? null, + transactionStatus: snapshot.transactionStatus ?? null, + linkedReimbursementId: snapshot.linkedReimbursementId ?? null, + boundInvoiceCount: Number(snapshot.boundInvoiceCount ?? 0), + boundInvoiceAmount: toMoneyFixed(snapshot.boundInvoiceAmount), + matchedTransactionIds: Array.isArray(snapshot.matchedTransactionIds) ? snapshot.matchedTransactionIds : [], + reasonDetail: snapshot.reasonDetail ?? null, + }, + travelRequest: item.travel_request + ? { + id: item.travel_request.id, + requestNo: item.travel_request.request_no ?? null, + } + : null, + travelOrder: item.travel_order + ? { + id: item.travel_order.id, + externalOrderNo: item.travel_order.external_order_no, + status: item.travel_order.status, + } + : null, + cardTransaction: item.card_transaction + ? { + id: item.card_transaction.id, + externalTxnId: item.card_transaction.external_txn_id, + status: item.card_transaction.status, + txnType: item.card_transaction.txn_type, + } + : null, + actions: { + canConfirm: + canManagePayments && + batchStatus === TRAVEL_RECONCILIATION_BATCH_STATUSES.draft && + item.match_status !== TRAVEL_RECONCILIATION_MATCH_STATUSES.matched && + item.resolution_status === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending, + canReopen: + canManagePayments && + batchStatus === TRAVEL_RECONCILIATION_BATCH_STATUSES.draft && + item.match_status !== TRAVEL_RECONCILIATION_MATCH_STATUSES.matched && + item.resolution_status === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed, + }, + }; +} + +const BATCH_SELECT = { + id: true, + batch_no: true, + legal_entity_id: true, + cost_center_id: true, + period_start: true, + period_end: true, + status: true, + summary_json: true, + confirmed_at: true, + confirmed_by: true, + created_at: true, + updated_at: true, + legal_entity: { + select: { + id: true, + code: true, + name: true, + }, + }, + cost_center: { + select: { + id: true, + code: true, + name: true, + legal_entity_id: true, + }, + }, + confirmed_by_employee: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, +}; + +const BATCH_DETAIL_SELECT = { + ...BATCH_SELECT, + items: { + orderBy: [{ created_at: "asc" }, { id: "asc" }], + select: { + id: true, + item_kind: true, + travel_request_id: true, + travel_order_id: true, + card_transaction_id: true, + match_status: true, + resolution_status: true, + order_amount: true, + transaction_amount: true, + currency: true, + anomaly_code: true, + snapshot_json: true, + confirmed_at: true, + confirmed_by: true, + confirm_note: true, + created_at: true, + updated_at: true, + travel_request: { + select: { + id: true, + request_no: true, + }, + }, + travel_order: { + select: { + id: true, + external_order_no: true, + status: true, + }, + }, + card_transaction: { + select: { + id: true, + external_txn_id: true, + status: true, + txn_type: true, + }, + }, + }, + }, +}; + +async function listTravelReconciliationBatches(prisma, query) { + const skip = (query.page - 1) * query.pageSize; + const where = { + ...(query.status ? { status: query.status } : {}), + ...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}), + ...(query.costCenterId ? { cost_center_id: query.costCenterId } : {}), + ...(query.periodStart ? { period_end: { gte: query.periodStart } } : {}), + ...(query.periodEnd ? { period_start: { lte: query.periodEnd } } : {}), + }; + + const [items, total] = await Promise.all([ + prisma.travelReconciliationBatch.findMany({ + where, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + skip, + take: query.pageSize, + select: BATCH_SELECT, + }), + prisma.travelReconciliationBatch.count({ where }), + ]); + + return { + page: query.page, + pageSize: query.pageSize, + total, + items: items.map(mapBatchSummary), + }; +} + +async function loadTravelReconciliationBatchDetail(prisma, batchId, options = {}) { + const batch = await prisma.travelReconciliationBatch.findUnique({ + where: { id: batchId }, + select: BATCH_DETAIL_SELECT, + }); + + if (!batch) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + return { + ...mapBatchSummary(batch), + items: batch.items.map((item) => mapBatchItem(item, { canManagePayments: options.canManagePayments, batchStatus: batch.status })), + permissions: { + canManagePayments: Boolean(options.canManagePayments), + canRetryErp: Boolean(options.canRetryErp), + }, + }; +} + +function ensureDraftBatch(batch) { + if (batch.status !== TRAVEL_RECONCILIATION_BATCH_STATUSES.draft) { + throw buildValidationError({ batch: "reconciliation_batch_not_editable" }); + } +} + +function ensurePendingReconciliationItem(item) { + if (item.resolution_status !== TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending) { + throw buildValidationError({ item: "reconciliation_item_not_pending" }); + } +} + +function ensureConfirmedReconciliationItem(item) { + if (item.resolution_status !== TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed) { + throw buildValidationError({ item: "reconciliation_item_not_confirmed" }); + } +} + +function ensureReopenableReconciliationItem(item) { + if (item.match_status === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched) { + throw buildValidationError({ item: "matched_item_not_reopenable" }); + } +} + +function ensureBatchFinalizable(items = []) { + if (items.some((item) => item.resolution_status !== TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed)) { + throw buildValidationError({ batch: "reconciliation_batch_has_pending_items" }); + } +} + +module.exports = { + TRAVEL_RECONCILIATION_BATCH_STATUSES, + TRAVEL_RECONCILIATION_ITEM_KINDS, + TRAVEL_RECONCILIATION_MATCH_STATUSES, + TRAVEL_RECONCILIATION_RESOLUTION_STATUSES, + BATCH_SELECT, + BATCH_DETAIL_SELECT, + buildReconciliationItemsSnapshot, + buildTravelReconciliationSummary, + buildValidationError, + ensureBatchFinalizable, + ensureConfirmedReconciliationItem, + ensureDraftBatch, + ensurePendingReconciliationItem, + ensureReopenableReconciliationItem, + listTravelReconciliationBatches, + loadReconciliationScope, + loadTravelReconciliationBatchDetail, + mapBatchItem, + mapBatchSummary, + normalizeSummaryJson, + toMoneyFixed, + toMoneyNumber, +}; diff --git a/server/tests/unit/documentSequence.service.test.js b/server/tests/unit/documentSequence.service.test.js index 5c476a04..83890847 100644 --- a/server/tests/unit/documentSequence.service.test.js +++ b/server/tests/unit/documentSequence.service.test.js @@ -164,6 +164,28 @@ describe("documentSequence service", () => { expect(config.prefix).toBe("CL"); }); + it("falls back to default travel reconciliation batch category code", async () => { + const prisma = buildPrisma({ + categoryMap: { + TR_RECON: { + code: "TR_RECON", + entity_type: "travel_reconciliation_batch", + sequence_prefix: "TRB", + number_length: 4, + reset_period: "yearly", + enabled: true, + archived_at: null, + }, + }, + }); + + const config = await resolveDocumentSequenceConfig(prisma, { documentType: "travel_reconciliation_batch" }); + + expect(config.source).toBe("document_category"); + expect(config.documentCategoryCode).toBe("TR_RECON"); + expect(config.prefix).toBe("TRB"); + }); + it("falls back to default asset category code for asset cards", async () => { const prisma = buildPrisma({ categoryMap: { diff --git a/server/tests/unit/integrations.dispatcher.test.js b/server/tests/unit/integrations.dispatcher.test.js index 9faa3d68..51dfb165 100644 --- a/server/tests/unit/integrations.dispatcher.test.js +++ b/server/tests/unit/integrations.dispatcher.test.js @@ -209,6 +209,51 @@ describe('dispatchOutboxEvent', () => { ); }); + it('records summary and settlement bindings for travel reconciliation export', async () => { + const prisma = { + integrationBinding: { + upsert: vi.fn().mockResolvedValue({ id: 'binding-travel-1' }), + }, + }; + + await dispatchOutboxEvent({ + prisma, + config: { erpSyncEnabled: true, erpSyncProvider: 'mock' }, + eventType: 'erp.travel.export', + payload: { + exportType: 'travel_reconciliation_summary', + entityType: 'travel_reconciliation_batch', + entityId: 'batch-1', + batchNo: 'TRB-20260311-0001', + }, + }); + + expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: { + provider_entity_type_local_id: { + provider: 'erp', + entity_type: 'travel_payable_summary', + local_id: 'batch-1', + }, + }, + }), + ); + expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + where: { + provider_entity_type_local_id: { + provider: 'erp', + entity_type: 'travel_settlement_batch', + local_id: 'batch-1', + }, + }, + }), + ); + }); + it('loads IM channel and dispatches mock wecom message', async () => { const prisma = { notificationChannel: { diff --git a/server/tests/unit/routes/integrations.erp.travel.test.js b/server/tests/unit/routes/integrations.erp.travel.test.js new file mode 100644 index 00000000..e735b259 --- /dev/null +++ b/server/tests/unit/routes/integrations.erp.travel.test.js @@ -0,0 +1,331 @@ +const Fastify = require('fastify'); + +const { AppError } = require('../../../src/utils/AppError'); +const integrationsRoutes = require('../../../src/routes/integrations'); + +function buildBatch(overrides = {}) { + return { + id: '66666666-6666-4666-8666-000000000001', + batch_no: 'TRB-20260311-0001', + legal_entity_id: '44444444-4444-4444-8444-444444444444', + cost_center_id: '55555555-5555-4555-8555-555555555555', + period_start: new Date('2026-03-01T00:00:00.000Z'), + period_end: new Date('2026-03-31T00:00:00.000Z'), + status: 'reconciled', + summary_json: { + totalOrders: 1, + totalTransactions: 1, + anomalyItems: 0, + matchRate: '100.00', + totalOrderAmount: '100.00', + totalSettledAmount: '100.00', + totalReversedAmount: '0.00', + }, + confirmed_at: new Date('2026-03-31T10:00:00.000Z'), + created_at: new Date('2026-03-31T09:00:00.000Z'), + updated_at: new Date('2026-03-31T10:00:00.000Z'), + legal_entity: { + id: '44444444-4444-4444-8444-444444444444', + code: 'HY', + name: '海音达', + }, + cost_center: { + id: '55555555-5555-4555-8555-555555555555', + code: 'CC001', + name: '销售中心', + }, + items: [ + { + id: 'item-1', + travel_request_id: '11111111-1111-4111-8111-111111111111', + travel_order_id: '22222222-2222-4222-8222-222222222222', + card_transaction_id: '33333333-3333-4333-8333-333333333333', + match_status: 'matched', + resolution_status: 'confirmed', + order_amount: '100.00', + transaction_amount: '100.00', + currency: 'CNY', + snapshot_json: { + requestNo: 'CL-20260310-0001', + externalOrderNo: 'EXT-001', + externalTxnId: 'TXN-001', + linkedReimbursementId: null, + boundInvoiceCount: 1, + }, + created_at: new Date('2026-03-31T09:00:00.000Z'), + }, + ], + ...overrides, + }; +} + +function buildPrisma({ batch = buildBatch(), featureFlag, existingOutbox = null }) { + const state = { + batch: batch ? { ...batch } : null, + outbox: existingOutbox ? { ...existingOutbox } : null, + auditLogs: [], + }; + + const payableBinding = { + id: 'binding-summary', + provider: 'erp', + entity_type: 'travel_payable_summary', + local_id: '66666666-6666-4666-8666-000000000001', + external_id: 'EXT-SUM-001', + external_code: 'SUM-001', + last_synced_at: new Date('2026-03-31T10:01:00.000Z'), + }; + const settlementBinding = { + id: 'binding-batch', + provider: 'erp', + entity_type: 'travel_settlement_batch', + local_id: '66666666-6666-4666-8666-000000000001', + external_id: 'EXT-BATCH-001', + external_code: 'BAT-001', + last_synced_at: new Date('2026-03-31T10:01:00.000Z'), + }; + + const prisma = { + travelReconciliationBatch: { + findUnique: vi.fn().mockImplementation(async ({ where }) => { + if (!state.batch || where.id !== state.batch.id) { + return null; + } + return state.batch; + }), + }, + featureFlag: { + findUnique: vi.fn().mockResolvedValue( + featureFlag ?? { + id: 'flag-1', + flag_key: 'erp_sync', + override_mode: 'force_on', + rollout_type: 'global', + department_ids: [], + note: '', + version: 1, + created_at: new Date('2026-03-31T00:00:00.000Z'), + updated_at: new Date('2026-03-31T00:00:00.000Z'), + }, + ), + }, + integrationOutbox: { + findUnique: vi.fn().mockImplementation(async ({ where }) => { + const key = where?.event_type_dedupe_key; + if (!key) { + return null; + } + if (!state.outbox) { + return null; + } + return state.outbox.event_type === key.event_type && state.outbox.dedupe_key === key.dedupe_key + ? state.outbox + : null; + }), + create: vi.fn().mockImplementation(async ({ data }) => { + state.outbox = { + id: 'outbox-created', + event_type: data.event_type, + status: data.status, + payload: data.payload, + dedupe_key: data.dedupe_key, + attempts: 0, + last_error: null, + processed_at: null, + updated_at: new Date('2026-03-31T10:05:00.000Z'), + }; + return { id: state.outbox.id, status: state.outbox.status }; + }), + update: vi.fn().mockImplementation(async ({ where, data }) => { + if (!state.outbox || state.outbox.id !== where.id) { + throw new Error('outbox_not_found'); + } + state.outbox = { + ...state.outbox, + ...data, + updated_at: new Date('2026-03-31T10:06:00.000Z'), + }; + return { id: state.outbox.id, status: state.outbox.status }; + }), + }, + integrationBinding: { + findUnique: vi.fn().mockImplementation(async ({ where }) => { + const bindingType = where.provider_entity_type_local_id.entity_type; + if (bindingType === 'travel_payable_summary') { + return payableBinding; + } + if (bindingType === 'travel_settlement_batch') { + return settlementBinding; + } + return null; + }), + }, + auditLog: { + create: vi.fn().mockImplementation(async ({ data }) => { + state.auditLogs.push(data); + return { id: `audit-${state.auditLogs.length}` }; + }), + }, + }; + + return { prisma, state }; +} + +async function buildApp({ prisma, permissionCodes, config = {} }) { + const app = Fastify(); + + app.decorate('prisma', prisma); + app.decorate('config', { + erpSyncEnabled: true, + erpSyncProvider: 'mock', + 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 }; + }); + app.decorate('hasPermission', (employee, permissionCode) => { + return (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(integrationsRoutes, { prefix: '/api' }); + return app; +} + +describe('Integrations travel reconciliation ERP routes', () => { + const { canReadTravelReconciliationBatch } = integrationsRoutes.__testables; + + it('recognizes readable reconciliation roles', () => { + expect( + canReadTravelReconciliationBatch({ employee: { permissionCodes: ['travel.payment.manage'] } }), + ).toBe(true); + expect( + canReadTravelReconciliationBatch({ employee: { permissionCodes: ['integration.manage'] } }), + ).toBe(true); + expect( + canReadTravelReconciliationBatch({ employee: { permissionCodes: ['travel.view_all'] } }), + ).toBe(false); + }); + + it('redacts ERP binding refs for non integration managers on track endpoint', async () => { + const { prisma } = buildPrisma({ + existingOutbox: { + id: 'outbox-1', + event_type: 'erp.travel.export', + dedupe_key: 'travel_reconciliation_batch:66666666-6666-4666-8666-000000000001:summary', + status: 'succeeded', + payload: { batchNo: 'TRB-20260311-0001' }, + attempts: 1, + last_error: null, + processed_at: new Date('2026-03-31T10:00:00.000Z'), + updated_at: new Date('2026-03-31T10:00:00.000Z'), + }, + }); + const app = await buildApp({ prisma, permissionCodes: ['travel.payment.manage'] }); + + const response = await app.inject({ + method: 'GET', + url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/track', + headers: { authorization: 'Bearer test' }, + }); + + expect(response.statusCode).toBe(200); + const track = response.json().data; + expect(track.stage.summaryExport.bindings.payableSummary.externalId).toBe(null); + expect(track.stage.summaryExport.bindings.settlementBatch.externalCode).toBe(null); + expect(track.permissions.canRetry).toBe(false); + + await app.close(); + }); + + it('shows full ERP binding refs to integration managers and allows requeue', async () => { + const { prisma, state } = buildPrisma({ + existingOutbox: { + id: 'outbox-1', + event_type: 'erp.travel.export', + dedupe_key: 'travel_reconciliation_batch:66666666-6666-4666-8666-000000000001:summary', + status: 'failed', + payload: { entityId: '66666666-6666-4666-8666-000000000001', batchNo: 'TRB-20260311-0001' }, + attempts: 2, + last_error: 'failed_once', + processed_at: null, + updated_at: new Date('2026-03-31T10:00:00.000Z'), + }, + }); + const app = await buildApp({ prisma, permissionCodes: ['integration.manage'] }); + + const trackResponse = await app.inject({ + method: 'GET', + url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/track', + headers: { authorization: 'Bearer test' }, + }); + expect(trackResponse.statusCode).toBe(200); + expect(trackResponse.json().data.stage.summaryExport.bindings.payableSummary.externalId).toBe('EXT-SUM-001'); + + const requeueResponse = await app.inject({ + method: 'POST', + url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/requeue', + headers: { authorization: 'Bearer test' }, + payload: {}, + }); + + expect(requeueResponse.statusCode).toBe(200); + expect(requeueResponse.json().data).toEqual( + expect.objectContaining({ + outboxId: 'outbox-1', + eventType: 'erp.travel.export', + requeued: true, + }), + ); + expect(state.outbox.status).toBe('pending'); + + await app.close(); + }); + + it('blocks requeue when ERP gate is disabled', async () => { + const { prisma } = buildPrisma({}); + const app = await buildApp({ + prisma, + permissionCodes: ['integration.manage'], + config: { erpSyncEnabled: false }, + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/requeue', + headers: { authorization: 'Bearer test' }, + payload: {}, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details.integration).toBe('erp_sync_disabled'); + + await app.close(); + }); +}); diff --git a/server/tests/unit/routes/travelReconciliation.test.js b/server/tests/unit/routes/travelReconciliation.test.js new file mode 100644 index 00000000..989860b9 --- /dev/null +++ b/server/tests/unit/routes/travelReconciliation.test.js @@ -0,0 +1,650 @@ +const Fastify = require('fastify'); + +const { AppError } = require('../../../src/utils/AppError'); +const travelReconciliationRoutes = require('../../../src/routes/travelReconciliation'); + +function buildTravelRequest(overrides = {}) { + return { + id: '11111111-1111-4111-8111-111111111111', + request_no: 'CL-20260311-0001', + ...overrides, + }; +} + +function buildOrder(overrides = {}) { + return { + id: '22222222-2222-4222-8222-222222222222', + source: 'ctrip', + external_order_no: 'EXT-001', + linked_reimbursement_id: null, + travel_request_id: '11111111-1111-4111-8111-111111111111', + amount: '100.00', + currency: 'CNY', + status: 'paid', + payment_date: new Date('2026-03-10T00:00:00.000Z'), + paid_at: new Date('2026-03-10T08:00:00.000Z'), + created_at: new Date('2026-03-10T08:00:00.000Z'), + updated_at: new Date('2026-03-10T08:00:00.000Z'), + travel_request: { + id: '11111111-1111-4111-8111-111111111111', + request_no: 'CL-20260311-0001', + legal_entity_id: '44444444-4444-4444-8444-444444444444', + cost_center_id: '55555555-5555-4555-8555-555555555555', + }, + ...overrides, + }; +} + +function buildTransaction(overrides = {}) { + return { + id: '33333333-3333-4333-8333-333333333333', + source: 'corp-card', + external_txn_id: 'TXN-001', + travel_request_id: '11111111-1111-4111-8111-111111111111', + travel_order_id: '22222222-2222-4222-8222-222222222222', + amount: '100.00', + currency: 'CNY', + txn_type: 'charge', + status: 'settled', + occurred_at: new Date('2026-03-10T08:30:00.000Z'), + created_at: new Date('2026-03-10T08:30:00.000Z'), + travel_request: { + id: '11111111-1111-4111-8111-111111111111', + request_no: 'CL-20260311-0001', + }, + travel_order: { + id: '22222222-2222-4222-8222-222222222222', + external_order_no: 'EXT-001', + linked_reimbursement_id: null, + status: 'paid', + }, + ...overrides, + }; +} + +function buildPrisma({ + orders = [], + transactions = [], + invoices = [], + featureFlag = { + id: 'flag-1', + flag_key: 'erp_sync', + override_mode: 'force_on', + rollout_type: 'global', + department_ids: [], + note: '', + version: 1, + created_at: new Date('2026-03-11T00:00:00.000Z'), + updated_at: new Date('2026-03-11T00:00:00.000Z'), + }, +} = {}) { + const state = { + legalEntities: [{ id: '44444444-4444-4444-8444-444444444444', code: 'HY', name: '海音达' }], + costCenters: [{ id: '55555555-5555-4555-8555-555555555555', code: 'CC001', name: '销售中心', legal_entity_id: '44444444-4444-4444-8444-444444444444' }], + travelRequests: [buildTravelRequest()], + orders: orders.map((item) => ({ ...item })), + transactions: transactions.map((item) => ({ ...item })), + invoices: invoices.map((item) => ({ ...item })), + batches: [], + items: [], + integrationOutboxes: [], + auditLogs: [], + sequenceMap: {}, + }; + + let batchCounter = 1; + let itemCounter = 1; + + function materializeTravelRequest(id) { + const request = state.travelRequests.find((item) => item.id === id); + return request ? { id: request.id, request_no: request.request_no } : null; + } + + function materializeOrder(id) { + const order = state.orders.find((item) => item.id === id); + return order + ? { + id: order.id, + external_order_no: order.external_order_no, + status: order.status, + } + : null; + } + + function materializeTransaction(id) { + const transaction = state.transactions.find((item) => item.id === id); + return transaction + ? { + id: transaction.id, + external_txn_id: transaction.external_txn_id, + status: transaction.status, + txn_type: transaction.txn_type, + } + : null; + } + + function materializeItem(item) { + const batch = state.batches.find((row) => row.id === item.travel_reconciliation_batch_id); + return { + ...item, + batch: batch + ? { + id: batch.id, + status: batch.status, + summary_json: batch.summary_json, + } + : null, + travel_request: materializeTravelRequest(item.travel_request_id), + travel_order: materializeOrder(item.travel_order_id), + card_transaction: materializeTransaction(item.card_transaction_id), + }; + } + + function materializeBatch(batch) { + if (!batch) { + return null; + } + return { + ...batch, + legal_entity: state.legalEntities.find((item) => item.id === batch.legal_entity_id) ?? null, + cost_center: state.costCenters.find((item) => item.id === batch.cost_center_id) ?? null, + confirmed_by_employee: batch.confirmed_by + ? { id: batch.confirmed_by, employee_no: 'E001', name: '财务一号' } + : null, + items: state.items + .filter((item) => item.travel_reconciliation_batch_id === batch.id) + .map(materializeItem), + }; + } + + const prisma = { + legalEntity: { + findUnique: vi.fn().mockImplementation(async ({ where }) => { + return state.legalEntities.find((item) => item.id === where.id) ?? null; + }), + }, + costCenter: { + findUnique: vi.fn().mockImplementation(async ({ where }) => { + return state.costCenters.find((item) => item.id === where.id) ?? null; + }), + }, + travelOrder: { + findMany: vi.fn().mockImplementation(async () => state.orders.map((item) => ({ ...item }))), + }, + cardTransaction: { + findMany: vi.fn().mockImplementation(async () => state.transactions.map((item) => ({ ...item }))), + }, + invoicePool: { + findMany: vi.fn().mockImplementation(async ({ where }) => { + const ids = new Set(where?.travel_order_id?.in ?? []); + return state.invoices.filter((item) => ids.has(item.travel_order_id)).map((item) => ({ ...item })); + }), + }, + documentCategory: { + findUnique: vi.fn().mockImplementation(async ({ where }) => { + if (where.code === 'TR_RECON') { + return { + code: 'TR_RECON', + entity_type: 'travel_reconciliation_batch', + sequence_prefix: 'TRB', + number_length: 4, + reset_period: 'yearly', + enabled: true, + archived_at: null, + }; + } + return null; + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + documentSequence: { + findUnique: vi.fn().mockImplementation(async ({ where }) => state.sequenceMap[where.document_type] ?? null), + upsert: vi.fn().mockImplementation(async ({ where, create, update }) => { + const current = state.sequenceMap[where.document_type]; + if (current) { + const next = { + ...current, + prefix: update.prefix, + date_format: update.date_format, + separator: update.separator, + number_length: update.number_length, + reset_period: update.reset_period, + current_number: current.current_number + 1, + updated_by: update.updated_by, + updated_at: new Date('2026-03-11T09:00:00.000Z'), + }; + state.sequenceMap[where.document_type] = next; + return next; + } + const created = { + document_type: create.document_type, + prefix: create.prefix, + date_format: create.date_format, + separator: create.separator, + number_length: create.number_length, + reset_period: create.reset_period, + current_number: create.current_number, + last_reset_date: null, + updated_at: new Date('2026-03-11T09:00:00.000Z'), + updated_by: create.updated_by, + }; + state.sequenceMap[where.document_type] = created; + return created; + }), + }, + travelReconciliationBatch: { + create: vi.fn().mockImplementation(async ({ data }) => { + const batch = { + id: `66666666-6666-4666-8666-${String(batchCounter++).padStart(12, '0')}`, + batch_no: data.batch_no, + legal_entity_id: data.legal_entity_id, + cost_center_id: data.cost_center_id ?? null, + period_start: data.period_start, + period_end: data.period_end, + status: data.status, + summary_json: data.summary_json, + confirmed_at: null, + confirmed_by: null, + created_at: new Date('2026-03-11T09:00:00.000Z'), + created_by: data.created_by ?? null, + updated_at: new Date('2026-03-11T09:00:00.000Z'), + updated_by: data.updated_by ?? null, + }; + state.batches.push(batch); + return { id: batch.id }; + }), + findUnique: vi.fn().mockImplementation(async ({ where }) => { + const batch = state.batches.find((item) => item.id === where.id) ?? null; + return materializeBatch(batch); + }), + findMany: vi.fn().mockImplementation(async ({ where }) => { + return state.batches + .filter((item) => { + if (where?.status && item.status !== where.status) return false; + if (where?.legal_entity_id && item.legal_entity_id !== where.legal_entity_id) return false; + if (where?.cost_center_id && item.cost_center_id !== where.cost_center_id) return false; + return true; + }) + .map(materializeBatch); + }), + count: vi.fn().mockImplementation(async ({ where }) => { + return state.batches.filter((item) => { + if (where?.status && item.status !== where.status) return false; + if (where?.legal_entity_id && item.legal_entity_id !== where.legal_entity_id) return false; + if (where?.cost_center_id && item.cost_center_id !== where.cost_center_id) return false; + return true; + }).length; + }), + update: vi.fn().mockImplementation(async ({ where, data }) => { + const index = state.batches.findIndex((item) => item.id === where.id); + state.batches[index] = { + ...state.batches[index], + ...data, + updated_at: new Date('2026-03-11T10:00:00.000Z'), + }; + return materializeBatch(state.batches[index]); + }), + }, + travelReconciliationItem: { + createMany: vi.fn().mockImplementation(async ({ data }) => { + data.forEach((row) => { + state.items.push({ + id: `77777777-7777-4777-8777-${String(itemCounter++).padStart(12, '0')}`, + ...row, + created_at: new Date('2026-03-11T09:00:00.000Z'), + updated_at: new Date('2026-03-11T09:00:00.000Z'), + }); + }); + return { count: data.length }; + }), + findUnique: vi.fn().mockImplementation(async ({ where }) => { + const item = state.items.find((row) => row.id === where.id) ?? null; + return item ? materializeItem(item) : null; + }), + update: vi.fn().mockImplementation(async ({ where, data }) => { + const index = state.items.findIndex((item) => item.id === where.id); + state.items[index] = { + ...state.items[index], + ...data, + updated_at: new Date('2026-03-11T10:00:00.000Z'), + }; + return materializeItem(state.items[index]); + }), + }, + integrationOutbox: { + findUnique: vi.fn().mockImplementation(async ({ where }) => { + const key = where?.event_type_dedupe_key; + if (!key) { + return null; + } + return ( + state.integrationOutboxes.find( + (item) => item.event_type === key.event_type && item.dedupe_key === key.dedupe_key, + ) ?? null + ); + }), + create: vi.fn().mockImplementation(async ({ data }) => { + const row = { + id: `outbox-${state.integrationOutboxes.length + 1}`, + event_type: data.event_type, + status: data.status, + payload: data.payload, + dedupe_key: data.dedupe_key ?? null, + max_attempts: data.max_attempts, + created_by: data.created_by ?? null, + updated_by: data.updated_by ?? null, + attempts: 0, + last_error: null, + processed_at: null, + dead_letter_at: null, + next_retry_at: null, + created_at: new Date('2026-03-11T10:00:00.000Z'), + updated_at: new Date('2026-03-11T10:00:00.000Z'), + }; + state.integrationOutboxes.push(row); + return { id: row.id, status: row.status }; + }), + update: vi.fn().mockImplementation(async ({ where, data }) => { + const index = state.integrationOutboxes.findIndex((item) => item.id === where.id); + state.integrationOutboxes[index] = { + ...state.integrationOutboxes[index], + ...data, + updated_at: new Date('2026-03-11T10:05:00.000Z'), + }; + return { id: state.integrationOutboxes[index].id, status: state.integrationOutboxes[index].status }; + }), + }, + auditLog: { + create: vi.fn().mockImplementation(async ({ data }) => { + state.auditLogs.push(data); + return { id: `audit-${state.auditLogs.length}` }; + }), + }, + featureFlag: { + findUnique: vi.fn().mockResolvedValue(featureFlag), + }, + $queryRaw: vi.fn().mockImplementation(async (strings, id) => { + const sql = Array.isArray(strings) ? strings.join('?') : String(strings); + if (sql.includes('FROM "travel_reconciliation_batches"')) { + return state.batches.some((item) => item.id === id) ? [{ id }] : []; + } + if (sql.includes('FROM "travel_reconciliation_items"')) { + return state.items.some((item) => item.id === id) ? [{ id }] : []; + } + return []; + }), + $transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)), + }; + + return { prisma, state }; +} + +async function buildApp({ prisma, permissionCodes = ['travel.payment.manage'], config = {} }) { + const app = Fastify(); + + app.decorate('prisma', prisma); + app.decorate('config', { + appTimezone: 'Asia/Shanghai', + 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', department_id: 'dept-1', permissionCodes }; + }); + app.decorate('hasPermission', (employee, permissionCode) => { + return (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(travelReconciliationRoutes, { prefix: '/api' }); + return app; +} + +describe('travelReconciliation routes', () => { + it('creates batches and supports list/detail queries', async () => { + const { prisma } = buildPrisma({ + orders: [buildOrder()], + transactions: [buildTransaction()], + invoices: [{ id: 'inv-1', travel_order_id: '22222222-2222-4222-8222-222222222222', total_amount: '100.00' }], + }); + const app = await buildApp({ prisma }); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/travel/reconciliation/batches', + headers: { authorization: 'Bearer test' }, + payload: { + legalEntityId: '44444444-4444-4444-8444-444444444444', + costCenterId: '55555555-5555-4555-8555-555555555555', + periodStart: '2026-03-01', + periodEnd: '2026-03-31', + }, + }); + + expect(createResponse.statusCode).toBe(200); + const created = createResponse.json().data; + expect(created).toEqual( + expect.objectContaining({ + batchNo: expect.stringMatching(/^TRB-\d{8}-\d{4}$/), + status: 'draft', + permissions: expect.objectContaining({ + canManagePayments: true, + }), + }), + ); + expect(created.summary).toEqual( + expect.objectContaining({ + totalOrders: 1, + totalTransactions: 1, + matchedItems: 1, + pendingItems: 0, + }), + ); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/travel/reconciliation/batches?status=draft', + headers: { authorization: 'Bearer test' }, + }); + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json().data.total).toBe(1); + + const detailResponse = await app.inject({ + method: 'GET', + url: `/api/travel/reconciliation/batches/${created.id}`, + headers: { authorization: 'Bearer test' }, + }); + expect(detailResponse.statusCode).toBe(200); + expect(detailResponse.json().data.items).toHaveLength(1); + + await app.close(); + }); + + it('confirms and reopens anomaly items inside draft batch', async () => { + const { prisma } = buildPrisma({ + orders: [buildOrder()], + transactions: [], + }); + const app = await buildApp({ prisma }); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/travel/reconciliation/batches', + headers: { authorization: 'Bearer test' }, + payload: { + legalEntityId: '44444444-4444-4444-8444-444444444444', + costCenterId: '55555555-5555-4555-8555-555555555555', + periodStart: '2026-03-01', + periodEnd: '2026-03-31', + }, + }); + const created = createResponse.json().data; + const item = created.items.find((row) => row.matchStatus !== 'matched'); + + const confirmResponse = await app.inject({ + method: 'POST', + url: `/api/travel/reconciliation/items/${item.id}/confirm`, + headers: { authorization: 'Bearer test' }, + payload: { note: '金额差异已人工确认' }, + }); + expect(confirmResponse.statusCode).toBe(200); + expect(confirmResponse.json().data.summary.pendingItems).toBe(0); + expect(confirmResponse.json().data.item.resolutionStatus).toBe('confirmed'); + expect(confirmResponse.json().data.item.confirmNote).toBe('金额差异已人工确认'); + + const reopenResponse = await app.inject({ + method: 'POST', + url: `/api/travel/reconciliation/items/${item.id}/reopen`, + headers: { authorization: 'Bearer test' }, + payload: {}, + }); + expect(reopenResponse.statusCode).toBe(200); + expect(reopenResponse.json().data.summary.pendingItems).toBe(1); + expect(reopenResponse.json().data.item.resolutionStatus).toBe('pending'); + + await app.close(); + }); + + it('rejects finalize when pending anomaly items remain', async () => { + const { prisma } = buildPrisma({ + orders: [buildOrder()], + transactions: [], + }); + const app = await buildApp({ prisma }); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/travel/reconciliation/batches', + headers: { authorization: 'Bearer test' }, + payload: { + legalEntityId: '44444444-4444-4444-8444-444444444444', + costCenterId: '55555555-5555-4555-8555-555555555555', + periodStart: '2026-03-01', + periodEnd: '2026-03-31', + }, + }); + + const batchId = createResponse.json().data.id; + const finalizeResponse = await app.inject({ + method: 'POST', + url: `/api/travel/reconciliation/batches/${batchId}/finalize`, + headers: { authorization: 'Bearer test' }, + payload: {}, + }); + + expect(finalizeResponse.statusCode).toBe(409); + expect(finalizeResponse.json().error.details.batch).toBe('reconciliation_batch_has_pending_items'); + + await app.close(); + }); + + it('finalizes fully confirmed batch and enqueues ERP export when gate is on', async () => { + const { prisma, state } = buildPrisma({ + orders: [buildOrder()], + transactions: [buildTransaction()], + }); + const app = await buildApp({ prisma, config: { erpSyncEnabled: true } }); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/travel/reconciliation/batches', + headers: { authorization: 'Bearer test' }, + payload: { + legalEntityId: '44444444-4444-4444-8444-444444444444', + costCenterId: '55555555-5555-4555-8555-555555555555', + periodStart: '2026-03-01', + periodEnd: '2026-03-31', + }, + }); + + const batchId = createResponse.json().data.id; + const finalizeResponse = await app.inject({ + method: 'POST', + url: `/api/travel/reconciliation/batches/${batchId}/finalize`, + headers: { authorization: 'Bearer test' }, + payload: {}, + }); + + expect(finalizeResponse.statusCode).toBe(200); + expect(finalizeResponse.json().data).toEqual( + expect.objectContaining({ + batchId, + status: 'reconciled', + erpExport: expect.objectContaining({ + eventType: 'erp.travel.export', + enqueued: true, + }), + }), + ); + expect(state.integrationOutboxes).toHaveLength(1); + expect(state.integrationOutboxes[0].event_type).toBe('erp.travel.export'); + + await app.close(); + }); + + it('returns 409 for empty reconciliation scope', async () => { + const { prisma } = buildPrisma({ orders: [], transactions: [] }); + const app = await buildApp({ prisma }); + + const response = await app.inject({ + method: 'POST', + url: '/api/travel/reconciliation/batches', + headers: { authorization: 'Bearer test' }, + payload: { + legalEntityId: '44444444-4444-4444-8444-444444444444', + costCenterId: '55555555-5555-4555-8555-555555555555', + periodStart: '2026-03-01', + periodEnd: '2026-03-31', + }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details.batch).toBe('travel_reconciliation_empty_scope'); + + await app.close(); + }); + + it('blocks reads without travel payment or integration management permission', async () => { + const { prisma } = buildPrisma({ + orders: [buildOrder()], + transactions: [buildTransaction()], + }); + const app = await buildApp({ prisma, permissionCodes: ['travel.view_own'] }); + + const response = await app.inject({ + method: 'GET', + url: '/api/travel/reconciliation/batches', + headers: { authorization: 'Bearer test' }, + }); + + expect(response.statusCode).toBe(403); + + await app.close(); + }); +}); diff --git a/server/tests/unit/services/erpTravel.test.js b/server/tests/unit/services/erpTravel.test.js new file mode 100644 index 00000000..b686a238 --- /dev/null +++ b/server/tests/unit/services/erpTravel.test.js @@ -0,0 +1,170 @@ +const { + TRAVEL_ERP_BINDING_TYPES, + TRAVEL_ERP_EVENT_TYPE, + buildTravelReconciliationErpTrack, + buildTravelReconciliationSummaryDedupeKey, + buildTravelReconciliationSummaryPayload, +} = require('../../../src/services/integrations/erpTravel'); + +describe('erpTravel service', () => { + function createBatch(overrides = {}) { + return { + id: 'batch-1', + batch_no: 'TRB-20260311-0001', + status: 'reconciled', + period_start: new Date('2026-03-01T00:00:00.000Z'), + period_end: new Date('2026-03-31T00:00:00.000Z'), + confirmed_at: new Date('2026-03-31T10:00:00.000Z'), + created_at: new Date('2026-03-31T09:00:00.000Z'), + updated_at: new Date('2026-03-31T10:00:00.000Z'), + summary_json: { + totalOrders: 2, + totalTransactions: 2, + anomalyItems: 1, + matchRate: '50.00', + totalOrderAmount: '200.00', + totalSettledAmount: '180.00', + totalReversedAmount: '20.00', + }, + legal_entity: { + id: 'legal-1', + code: 'HY', + name: '海音达', + }, + cost_center: { + id: 'cc-1', + code: 'CC001', + name: '销售中心', + }, + items: [ + { + id: 'item-1', + travel_request_id: 'travel-1', + travel_order_id: 'order-1', + card_transaction_id: 'txn-1', + match_status: 'matched', + resolution_status: 'confirmed', + order_amount: '100.00', + transaction_amount: '100.00', + currency: 'CNY', + snapshot_json: { + requestNo: 'CL-20260310-0001', + externalOrderNo: 'EXT-001', + externalTxnId: 'TXN-001', + linkedReimbursementId: null, + boundInvoiceCount: 1, + }, + }, + ], + ...overrides, + }; + } + + it('builds travel reconciliation summary payload with batch snapshot', () => { + const payload = buildTravelReconciliationSummaryPayload({ batch: createBatch() }); + + expect(payload).toEqual( + expect.objectContaining({ + exportType: 'travel_reconciliation_summary', + entityType: 'travel_reconciliation_batch', + entityId: 'batch-1', + batchNo: 'TRB-20260311-0001', + periodStart: '2026-03-01', + periodEnd: '2026-03-31', + dimensions: { + legalEntityCode: 'HY', + costCenterCode: 'CC001', + }, + summary: { + totalOrders: 2, + totalTransactions: 2, + anomalyItems: 1, + matchRate: '50.00', + totalOrderAmount: '200.00', + totalSettledAmount: '180.00', + totalReversedAmount: '20.00', + }, + }), + ); + expect(payload.items[0]).toEqual( + expect.objectContaining({ + travelRequestId: 'travel-1', + requestNo: 'CL-20260310-0001', + travelOrderId: 'order-1', + externalOrderNo: 'EXT-001', + cardTransactionId: 'txn-1', + externalTxnId: 'TXN-001', + linkedReimbursementId: null, + boundInvoiceCount: 1, + }), + ); + }); + + it('builds stable dedupe key for travel reconciliation batch summary export', () => { + expect(buildTravelReconciliationSummaryDedupeKey('batch-1')).toBe( + 'travel_reconciliation_batch:batch-1:summary', + ); + }); + + it('builds ERP track with redacted bindings for non integration managers', async () => { + const batch = createBatch(); + const prisma = { + integrationOutbox: { + findUnique: vi.fn().mockResolvedValue({ + id: 'outbox-1', + event_type: TRAVEL_ERP_EVENT_TYPE, + status: 'succeeded', + attempts: 1, + last_error: null, + processed_at: new Date('2026-03-31T10:01:00.000Z'), + updated_at: new Date('2026-03-31T10:01:00.000Z'), + }), + }, + integrationBinding: { + findUnique: vi + .fn() + .mockResolvedValueOnce({ + id: 'binding-summary', + provider: 'erp', + entity_type: TRAVEL_ERP_BINDING_TYPES.payableSummary, + local_id: 'batch-1', + external_id: 'SUM-001', + external_code: 'TRB-SUM-001', + last_synced_at: new Date('2026-03-31T10:02:00.000Z'), + }) + .mockResolvedValueOnce({ + id: 'binding-batch', + provider: 'erp', + entity_type: TRAVEL_ERP_BINDING_TYPES.settlementBatch, + local_id: 'batch-1', + external_id: 'BAT-001', + external_code: 'TRB-BAT-001', + last_synced_at: new Date('2026-03-31T10:02:00.000Z'), + }), + }, + }; + + const track = await buildTravelReconciliationErpTrack(prisma, {}, 'batch-1', { + context: batch, + canManage: false, + gate: { effectiveEnabled: true }, + }); + + expect(track.stage.summaryExport.status).toBe('succeeded'); + expect(track.stage.summaryExport.bindings.payableSummary).toEqual( + expect.objectContaining({ + entityType: TRAVEL_ERP_BINDING_TYPES.payableSummary, + externalId: null, + externalCode: null, + }), + ); + expect(track.stage.summaryExport.bindings.settlementBatch).toEqual( + expect.objectContaining({ + entityType: TRAVEL_ERP_BINDING_TYPES.settlementBatch, + externalId: null, + externalCode: null, + }), + ); + expect(track.permissions.canRetry).toBe(false); + }); +}); diff --git a/server/tests/unit/services/travelReconciliation.test.js b/server/tests/unit/services/travelReconciliation.test.js new file mode 100644 index 00000000..fa5593cc --- /dev/null +++ b/server/tests/unit/services/travelReconciliation.test.js @@ -0,0 +1,211 @@ +const { + TRAVEL_RECONCILIATION_MATCH_STATUSES, + TRAVEL_RECONCILIATION_RESOLUTION_STATUSES, + buildReconciliationItemsSnapshot, +} = require('../../../src/services/travelReconciliation'); + +function buildOrder(overrides = {}) { + return { + id: 'order-1', + external_order_no: 'EXT-001', + linked_reimbursement_id: null, + travel_request_id: 'travel-1', + amount: '100.00', + currency: 'CNY', + status: 'paid', + travel_request: { + id: 'travel-1', + request_no: 'CL-20260311-0001', + }, + ...overrides, + }; +} + +function buildTransaction(overrides = {}) { + return { + id: 'txn-1', + external_txn_id: 'TXN-001', + travel_request_id: 'travel-1', + travel_order_id: 'order-1', + amount: '100.00', + currency: 'CNY', + txn_type: 'charge', + status: 'settled', + travel_request: { + id: 'travel-1', + request_no: 'CL-20260311-0001', + }, + travel_order: { + id: 'order-1', + external_order_no: 'EXT-001', + linked_reimbursement_id: null, + status: 'paid', + }, + ...overrides, + }; +} + +function buildInvoice(overrides = {}) { + return { + id: 'inv-1', + travel_order_id: 'order-1', + total_amount: '100.00', + ...overrides, + }; +} + +describe('travelReconciliation service', () => { + it('auto-confirms exact matched order items', () => { + const now = new Date('2026-03-11T00:00:00.000Z'); + const snapshot = buildReconciliationItemsSnapshot({ + orders: [buildOrder()], + transactions: [buildTransaction()], + invoices: [buildInvoice()], + actorId: 'actor-1', + now, + }); + + expect(snapshot.summary).toEqual( + expect.objectContaining({ + totalOrders: 1, + totalTransactions: 1, + matchedItems: 1, + pendingItems: 0, + anomalyItems: 0, + totalOrderAmount: '100.00', + totalSettledAmount: '100.00', + totalReversedAmount: '0.00', + matchRate: '100.00', + }), + ); + + expect(snapshot.items[0]).toEqual( + expect.objectContaining({ + match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.matched, + resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed, + confirmed_by: 'actor-1', + confirmed_at: now, + snapshot_json: expect.objectContaining({ + boundInvoiceCount: 1, + boundInvoiceAmount: '100.00', + matchedTransactionIds: ['txn-1'], + }), + }), + ); + }); + + it('marks amount mismatch when settled charge amount differs', () => { + const snapshot = buildReconciliationItemsSnapshot({ + orders: [buildOrder()], + transactions: [buildTransaction({ amount: '88.00' })], + invoices: [], + actorId: 'actor-1', + now: new Date('2026-03-11T00:00:00.000Z'), + }); + + expect(snapshot.items[0]).toEqual( + expect.objectContaining({ + match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.amountMismatch, + resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending, + anomaly_code: TRAVEL_RECONCILIATION_MATCH_STATUSES.amountMismatch, + transaction_amount: '88.00', + }), + ); + }); + + it('marks currency mismatch when settled charge currency differs', () => { + const snapshot = buildReconciliationItemsSnapshot({ + orders: [buildOrder()], + transactions: [buildTransaction({ currency: 'USD' })], + invoices: [], + actorId: 'actor-1', + now: new Date('2026-03-11T00:00:00.000Z'), + }); + + expect(snapshot.items[0].match_status).toBe(TRAVEL_RECONCILIATION_MATCH_STATUSES.currencyMismatch); + expect(snapshot.items[0].resolution_status).toBe(TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending); + }); + + it('marks missing transaction when no settled charge exists', () => { + const snapshot = buildReconciliationItemsSnapshot({ + orders: [buildOrder()], + transactions: [], + invoices: [], + actorId: 'actor-1', + now: new Date('2026-03-11T00:00:00.000Z'), + }); + + expect(snapshot.items[0].match_status).toBe(TRAVEL_RECONCILIATION_MATCH_STATUSES.missingTransaction); + }); + + it('marks duplicate charge when multiple settled charges exist', () => { + const snapshot = buildReconciliationItemsSnapshot({ + orders: [buildOrder()], + transactions: [ + buildTransaction({ id: 'txn-1', external_txn_id: 'TXN-001' }), + buildTransaction({ id: 'txn-2', external_txn_id: 'TXN-002' }), + ], + invoices: [], + actorId: 'actor-1', + now: new Date('2026-03-11T00:00:00.000Z'), + }); + + expect(snapshot.items[0]).toEqual( + expect.objectContaining({ + match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.duplicateCharge, + resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending, + }), + ); + }); + + it('marks reversed after payment when refund or reversal appears', () => { + const snapshot = buildReconciliationItemsSnapshot({ + orders: [buildOrder()], + transactions: [buildTransaction({ txn_type: 'refund', status: 'settled' })], + invoices: [], + actorId: 'actor-1', + now: new Date('2026-03-11T00:00:00.000Z'), + }); + + expect(snapshot.items[0].match_status).toBe(TRAVEL_RECONCILIATION_MATCH_STATUSES.reversedAfterPayment); + }); + + it('creates orphan transaction items for unlinked settled or reversed transactions', () => { + const snapshot = buildReconciliationItemsSnapshot({ + orders: [buildOrder()], + transactions: [ + buildTransaction(), + buildTransaction({ + id: 'txn-orphan', + external_txn_id: 'TXN-ORPHAN', + travel_order_id: null, + txn_type: 'charge', + status: 'settled', + travel_order: null, + }), + ], + invoices: [], + actorId: 'actor-1', + now: new Date('2026-03-11T00:00:00.000Z'), + }); + + const orphan = snapshot.items.find((item) => item.card_transaction_id === 'txn-orphan'); + expect(orphan).toEqual( + expect.objectContaining({ + item_kind: 'orphan_transaction', + match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.orphanTransaction, + resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending, + }), + ); + expect(snapshot.summary).toEqual( + expect.objectContaining({ + totalOrders: 1, + totalTransactions: 2, + matchedItems: 1, + pendingItems: 1, + anomalyItems: 1, + matchRate: '50.00', + }), + ); + }); +});