feat(P20T2-1,P20T2-2,P20T2-3): 卡授权/交易/支付标记/发票绑定/ERP出站后端

这个提交包含在:
2026-03-11 06:41:11 +08:00
父节点 539c2cc4dd
当前提交 dc74d9e53f
共修改 23 个文件,包含 4265 行新增和 218 行删除
@@ -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)
+4
查看文件
@@ -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" });
+3
查看文件
@@ -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),
});
},
+6 -2
查看文件
@@ -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,
+314
查看文件
@@ -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;
+284 -2
查看文件
@@ -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;
+29
查看文件
@@ -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",
{
+666
查看文件
@@ -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') {
+177 -11
查看文件
@@ -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,
};
+106 -8
查看文件
@@ -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,
};
+725
查看文件
@@ -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();
});
});
+285 -85
查看文件
@@ -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" }));
});
});