feat(P20T3-1,P20T3-2,P20T3-3): 对账批次引擎与ERP摘要出站后端
这个提交包含在:
+116
@@ -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;
|
||||||
+120
-24
@@ -78,6 +78,31 @@ enum TravelTransportType {
|
|||||||
other
|
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 {
|
enum AssetCardStatus {
|
||||||
in_stock
|
in_stock
|
||||||
in_use
|
in_use
|
||||||
@@ -157,6 +182,7 @@ enum DocumentCategoryEntityType {
|
|||||||
leave
|
leave
|
||||||
purchase_request
|
purchase_request
|
||||||
travel_request
|
travel_request
|
||||||
|
travel_reconciliation_batch
|
||||||
asset_card
|
asset_card
|
||||||
general
|
general
|
||||||
}
|
}
|
||||||
@@ -302,11 +328,13 @@ model Employee {
|
|||||||
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
|
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
|
||||||
overridden_invoices InvoicePool[] @relation("InvoiceOverrideBy")
|
overridden_invoices InvoicePool[] @relation("InvoiceOverrideBy")
|
||||||
requested_invoice_verifications InvoiceVerificationRecord[] @relation("InvoiceVerificationRequester")
|
requested_invoice_verifications InvoiceVerificationRecord[] @relation("InvoiceVerificationRequester")
|
||||||
travel_orders TravelOrder[] @relation("TravelOrderEmployee")
|
travel_orders TravelOrder[] @relation("TravelOrderEmployee")
|
||||||
paid_travel_orders TravelOrder[] @relation("TravelOrderPaidBy")
|
paid_travel_orders TravelOrder[] @relation("TravelOrderPaidBy")
|
||||||
card_authorizations CardAuthorization[]
|
confirmed_travel_reconciliation_batches TravelReconciliationBatch[] @relation("TravelReconciliationBatchConfirmedBy")
|
||||||
card_transactions CardTransaction[] @relation("CardTransactionEmployee")
|
card_authorizations CardAuthorization[]
|
||||||
freeze_suggested_card_transactions CardTransaction[] @relation("CardTransactionFreezeSuggestedBy")
|
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_instances_started WorkflowInstance[] @relation("WorkflowInstanceSubmitter")
|
||||||
workflow_history_actions WorkflowHistory[] @relation("WorkflowHistoryActor")
|
workflow_history_actions WorkflowHistory[] @relation("WorkflowHistoryActor")
|
||||||
@@ -491,11 +519,12 @@ model LegalEntity {
|
|||||||
employees Employee[]
|
employees Employee[]
|
||||||
holiday_calendars HolidayCalendar[] @relation("HolidayCalendarLegalEntity")
|
holiday_calendars HolidayCalendar[] @relation("HolidayCalendarLegalEntity")
|
||||||
suppliers Supplier[]
|
suppliers Supplier[]
|
||||||
purchase_requests PurchaseRequest[]
|
purchase_requests PurchaseRequest[]
|
||||||
travel_requests TravelRequest[]
|
travel_requests TravelRequest[]
|
||||||
card_authorizations CardAuthorization[]
|
travel_reconciliation_batches TravelReconciliationBatch[]
|
||||||
asset_cards AssetCard[]
|
card_authorizations CardAuthorization[]
|
||||||
budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity")
|
asset_cards AssetCard[]
|
||||||
|
budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity")
|
||||||
|
|
||||||
@@index([enabled, archived_at], map: "idx_legal_entities_status")
|
@@index([enabled, archived_at], map: "idx_legal_entities_status")
|
||||||
@@index([default_holiday_calendar_id], map: "idx_legal_entities_default_calendar")
|
@@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)
|
parent CostCenter? @relation("CostCenterToParent", fields: [parent_id], references: [id], onDelete: Restrict)
|
||||||
children CostCenter[] @relation("CostCenterToParent")
|
children CostCenter[] @relation("CostCenterToParent")
|
||||||
owner_employee Employee? @relation("CostCenterOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull)
|
owner_employee Employee? @relation("CostCenterOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull)
|
||||||
budget_pools BudgetPool[] @relation("BudgetPoolCostCenter")
|
budget_pools BudgetPool[] @relation("BudgetPoolCostCenter")
|
||||||
purchase_requests PurchaseRequest[]
|
purchase_requests PurchaseRequest[]
|
||||||
travel_requests TravelRequest[]
|
travel_requests TravelRequest[]
|
||||||
card_authorizations CardAuthorization[]
|
travel_reconciliation_batches TravelReconciliationBatch[]
|
||||||
asset_cards AssetCard[]
|
card_authorizations CardAuthorization[]
|
||||||
|
asset_cards AssetCard[]
|
||||||
|
|
||||||
@@unique([legal_entity_id, code], map: "legal_entity_cost_center_code_ux")
|
@@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")
|
@@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)
|
cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict)
|
||||||
budget_pool BudgetPool? @relation(fields: [budget_pool_id], references: [id], onDelete: SetNull)
|
budget_pool BudgetPool? @relation(fields: [budget_pool_id], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
segments TravelRequestSegment[]
|
segments TravelRequestSegment[]
|
||||||
travel_orders TravelOrder[]
|
travel_orders TravelOrder[]
|
||||||
card_authorizations CardAuthorization[]
|
reconciliation_items TravelReconciliationItem[]
|
||||||
card_transactions CardTransaction[]
|
card_authorizations CardAuthorization[]
|
||||||
workflow_instances WorkflowInstance[]
|
card_transactions CardTransaction[]
|
||||||
|
workflow_instances WorkflowInstance[]
|
||||||
|
|
||||||
@@index([applicant_employee_id, start_date], map: "idx_travel_requests_applicant_start")
|
@@index([applicant_employee_id, start_date], map: "idx_travel_requests_applicant_start")
|
||||||
@@index([submitter_id], map: "idx_travel_requests_submitter")
|
@@index([submitter_id], map: "idx_travel_requests_submitter")
|
||||||
@@ -1384,9 +1415,10 @@ model TravelOrder {
|
|||||||
department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull)
|
department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull)
|
||||||
linked_reimbursement ReimbursementForm? @relation(fields: [linked_reimbursement_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)
|
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[]
|
invoice_pool_items InvoicePool[]
|
||||||
card_transactions CardTransaction[]
|
card_transactions CardTransaction[]
|
||||||
|
reconciliation_items TravelReconciliationItem[]
|
||||||
|
|
||||||
@@unique([source, external_order_no], map: "travel_orders_source_order_ux")
|
@@unique([source, external_order_no], map: "travel_orders_source_order_ux")
|
||||||
@@index([employee_id, occurred_at], map: "idx_travel_orders_employee_occurred")
|
@@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)
|
card_authorization CardAuthorization? @relation(fields: [card_authorization_id], references: [id], onDelete: SetNull)
|
||||||
employee Employee @relation("CardTransactionEmployee", fields: [employee_id], references: [id], onDelete: Restrict)
|
employee Employee @relation("CardTransactionEmployee", fields: [employee_id], references: [id], onDelete: Restrict)
|
||||||
department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull)
|
department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull)
|
||||||
travel_request TravelRequest @relation(fields: [travel_request_id], references: [id], onDelete: Cascade)
|
travel_request TravelRequest @relation(fields: [travel_request_id], references: [id], onDelete: Cascade)
|
||||||
travel_order TravelOrder? @relation(fields: [travel_order_id], references: [id], onDelete: SetNull)
|
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)
|
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")
|
@@unique([source, external_txn_id], map: "card_transactions_source_txn_ux")
|
||||||
@@index([card_authorization_id], map: "idx_card_transactions_authorization")
|
@@index([card_authorization_id], map: "idx_card_transactions_authorization")
|
||||||
@@ -1479,6 +1512,69 @@ model CardTransaction {
|
|||||||
@@map("card_transactions")
|
@@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 {
|
model NotificationChannel {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
channel_type String @db.VarChar(32)
|
channel_type String @db.VarChar(32)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const travelOrdersRoutes = require("./routes/travelOrders");
|
|||||||
const travelRequestsRoutes = require("./routes/travelRequests");
|
const travelRequestsRoutes = require("./routes/travelRequests");
|
||||||
const travelAuthorizationsRoutes = require("./routes/travelAuthorizations");
|
const travelAuthorizationsRoutes = require("./routes/travelAuthorizations");
|
||||||
const travelTransactionsRoutes = require("./routes/travelTransactions");
|
const travelTransactionsRoutes = require("./routes/travelTransactions");
|
||||||
|
const travelReconciliationRoutes = require("./routes/travelReconciliation");
|
||||||
const aiRoutes = require("./routes/ai");
|
const aiRoutes = require("./routes/ai");
|
||||||
const { startJobs } = require("./jobs/jobSystem");
|
const { startJobs } = require("./jobs/jobSystem");
|
||||||
const { initMetrics } = require("./observability");
|
const { initMetrics } = require("./observability");
|
||||||
@@ -312,6 +313,7 @@ async function buildApp() {
|
|||||||
await app.register(travelRequestsRoutes, { prefix: "/api" });
|
await app.register(travelRequestsRoutes, { prefix: "/api" });
|
||||||
await app.register(travelAuthorizationsRoutes, { prefix: "/api" });
|
await app.register(travelAuthorizationsRoutes, { prefix: "/api" });
|
||||||
await app.register(travelTransactionsRoutes, { prefix: "/api" });
|
await app.register(travelTransactionsRoutes, { prefix: "/api" });
|
||||||
|
await app.register(travelReconciliationRoutes, { prefix: "/api" });
|
||||||
await app.register(aiRoutes, { prefix: "/api" });
|
await app.register(aiRoutes, { prefix: "/api" });
|
||||||
await app.register(exportsRoutes, { prefix: "/api" });
|
await app.register(exportsRoutes, { prefix: "/api" });
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ const {
|
|||||||
loadAssetErpContext,
|
loadAssetErpContext,
|
||||||
resolveAssetLedgerSummaryDrift,
|
resolveAssetLedgerSummaryDrift,
|
||||||
} = require('../services/integrations/erpAsset');
|
} = require('../services/integrations/erpAsset');
|
||||||
|
const {
|
||||||
|
TRAVEL_ERP_EVENT_TYPE,
|
||||||
|
buildTravelReconciliationErpTrack,
|
||||||
|
buildTravelReconciliationSummaryDedupeKey,
|
||||||
|
enqueueTravelReconciliationSummaryExport,
|
||||||
|
loadTravelReconciliationErpContext,
|
||||||
|
} = require('../services/integrations/erpTravel');
|
||||||
|
|
||||||
const UUID_SCHEMA = z.string().uuid();
|
const UUID_SCHEMA = z.string().uuid();
|
||||||
const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']);
|
const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']);
|
||||||
@@ -320,6 +327,78 @@ async function fetchReadableAssetContext(prisma, assetId, request) {
|
|||||||
return context;
|
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) {
|
async function findAssetOutboxByDedupeKey(prisma, assetId) {
|
||||||
return prisma.integrationOutbox.findUnique({
|
return prisma.integrationOutbox.findUnique({
|
||||||
where: {
|
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(
|
fastify.post(
|
||||||
'/integrations/erp/sync/:reimbursementId',
|
'/integrations/erp/sync/:reimbursementId',
|
||||||
{
|
{
|
||||||
@@ -1087,7 +1306,9 @@ async function integrationsRoutes(fastify) {
|
|||||||
integrationsRoutes.__testables = {
|
integrationsRoutes.__testables = {
|
||||||
canReadAsset,
|
canReadAsset,
|
||||||
canReadPurchaseRequest,
|
canReadPurchaseRequest,
|
||||||
|
canReadTravelReconciliationBatch,
|
||||||
enqueueAssetExportAndMaybeRun,
|
enqueueAssetExportAndMaybeRun,
|
||||||
|
enqueueTravelReconciliationExportAndMaybeRun,
|
||||||
ensureProcurementTargetEligible,
|
ensureProcurementTargetEligible,
|
||||||
normalizeProcurementTargets,
|
normalizeProcurementTargets,
|
||||||
normalizeResourcesInput,
|
normalizeResourcesInput,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -50,6 +50,17 @@ const DOCUMENT_TYPE_CONFIG = {
|
|||||||
resetPeriod: "yearly",
|
resetPeriod: "yearly",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
travel_reconciliation_batch: {
|
||||||
|
defaultCategoryCode: "TR_RECON",
|
||||||
|
preferLegacySequence: false,
|
||||||
|
fallback: {
|
||||||
|
prefix: "TRB",
|
||||||
|
dateFormat: "YYYYMMDD",
|
||||||
|
separator: "-",
|
||||||
|
numberLength: 4,
|
||||||
|
resetPeriod: "yearly",
|
||||||
|
},
|
||||||
|
},
|
||||||
asset_card: {
|
asset_card: {
|
||||||
defaultCategoryCode: "ASSET_CARD_DEFAULT",
|
defaultCategoryCode: "ASSET_CARD_DEFAULT",
|
||||||
preferLegacySequence: false,
|
preferLegacySequence: false,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const {
|
|||||||
exportAssetToErp,
|
exportAssetToErp,
|
||||||
exportJournalToErp,
|
exportJournalToErp,
|
||||||
exportProcurementToErp,
|
exportProcurementToErp,
|
||||||
|
exportTravelToErp,
|
||||||
} = require('./erp');
|
} = require('./erp');
|
||||||
const { sendInstantMessageByChannel } = require('../notifications/channels');
|
const { sendInstantMessageByChannel } = require('../notifications/channels');
|
||||||
|
|
||||||
@@ -159,6 +160,23 @@ function resolveBindingSpecs(eventType, payload, response) {
|
|||||||
].filter(Boolean);
|
].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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +222,11 @@ async function dispatchErpEvent({ prisma, config, eventType, payload }) {
|
|||||||
? await exportProcurementToErp(config, payload)
|
? await exportProcurementToErp(config, payload)
|
||||||
: eventType === 'erp.asset.export'
|
: eventType === 'erp.asset.export'
|
||||||
? await exportAssetToErp(config, payload)
|
? await exportAssetToErp(config, payload)
|
||||||
: eventType === 'erp.journal.export'
|
: eventType === 'erp.travel.export'
|
||||||
? await exportJournalToErp(config, payload)
|
? await exportTravelToErp(config, payload)
|
||||||
: await pushPaidReimbursementToErp(config, payload);
|
: eventType === 'erp.journal.export'
|
||||||
|
? await exportJournalToErp(config, payload)
|
||||||
|
: await pushPaidReimbursementToErp(config, payload);
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw toDispatchError(result);
|
throw toDispatchError(result);
|
||||||
@@ -226,7 +246,8 @@ async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
|
|||||||
eventType === 'erp.reimbursement.paid' ||
|
eventType === 'erp.reimbursement.paid' ||
|
||||||
eventType === 'erp.journal.export' ||
|
eventType === 'erp.journal.export' ||
|
||||||
eventType === 'erp.procurement.export' ||
|
eventType === 'erp.procurement.export' ||
|
||||||
eventType === 'erp.asset.export'
|
eventType === 'erp.asset.export' ||
|
||||||
|
eventType === 'erp.travel.export'
|
||||||
) {
|
) {
|
||||||
return dispatchErpEvent({ prisma, config, eventType, payload });
|
return dispatchErpEvent({ prisma, config, eventType, payload });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -164,6 +164,28 @@ describe("documentSequence service", () => {
|
|||||||
expect(config.prefix).toBe("CL");
|
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 () => {
|
it("falls back to default asset category code for asset cards", async () => {
|
||||||
const prisma = buildPrisma({
|
const prisma = buildPrisma({
|
||||||
categoryMap: {
|
categoryMap: {
|
||||||
|
|||||||
@@ -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 () => {
|
it('loads IM channel and dispatches mock wecom message', async () => {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
notificationChannel: {
|
notificationChannel: {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户