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