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

这个提交包含在:
2026-03-11 06:41:11 +08:00
父节点 539c2cc4dd
当前提交 dc74d9e53f
共修改 23 个文件,包含 4265 行新增和 218 行删除
+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;