490 行
16 KiB
JavaScript
490 行
16 KiB
JavaScript
const { z } = require("zod");
|
|
|
|
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 {
|
|
id: item.id,
|
|
source: item.source,
|
|
externalOrderNo: item.external_order_no,
|
|
employeeId: item.employee_id,
|
|
departmentId: item.department_id ?? null,
|
|
linkedReimbursementId: item.linked_reimbursement_id ?? null,
|
|
travelRequestId: item.travel_request_id ?? null,
|
|
occurredAt: item.occurred_at?.toISOString?.()?.slice(0, 10) ?? null,
|
|
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,
|
|
};
|
|
}
|
|
|
|
function buildValidationError(details, statusCode = 400) {
|
|
return new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode,
|
|
details,
|
|
});
|
|
}
|
|
|
|
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",
|
|
{
|
|
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 = normalizeTravelImportRows(parsed.data.orders);
|
|
if (!rows.length) {
|
|
throw buildValidationError({ orders: "empty_after_normalization" });
|
|
}
|
|
|
|
const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId)));
|
|
const travelRequestIds = Array.from(
|
|
new Set(
|
|
rows
|
|
.filter((item) => item.hasTravelRequestId && item.travelRequestId)
|
|
.map((item) => item.travelRequestId),
|
|
),
|
|
).filter((item) => UUID_SCHEMA.safeParse(item).success);
|
|
|
|
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 },
|
|
status: "active",
|
|
},
|
|
select: {
|
|
id: true,
|
|
department_id: true,
|
|
},
|
|
}),
|
|
travelRequestIds.length
|
|
? fastify.prisma.travelRequest.findMany({
|
|
where: { id: { in: travelRequestIds } },
|
|
select: {
|
|
id: true,
|
|
applicant_employee_id: true,
|
|
},
|
|
})
|
|
: 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 = [];
|
|
let linkedTravelRequestCount = 0;
|
|
|
|
for (const row of rows) {
|
|
const employee = employeeMap.get(row.employeeId);
|
|
if (!employee) {
|
|
skipped.push({
|
|
source: row.source,
|
|
externalOrderNo: row.externalOrderNo,
|
|
reason: "employee_not_found",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
let nextTravelRequestId;
|
|
if (row.hasTravelRequestId && row.travelRequestId) {
|
|
const travelRequest = travelRequestMap.get(row.travelRequestId);
|
|
if (!travelRequest) {
|
|
skipped.push({
|
|
source: row.source,
|
|
externalOrderNo: row.externalOrderNo,
|
|
reason: "travel_request_not_found",
|
|
});
|
|
continue;
|
|
}
|
|
if (travelRequest.applicant_employee_id !== row.employeeId) {
|
|
skipped.push({
|
|
source: row.source,
|
|
externalOrderNo: row.externalOrderNo,
|
|
reason: "travel_request_employee_mismatch",
|
|
});
|
|
continue;
|
|
}
|
|
nextTravelRequestId = travelRequest.id;
|
|
} else if (row.hasTravelRequestId && !row.travelRequestId) {
|
|
nextTravelRequestId = undefined;
|
|
}
|
|
|
|
const existingOrder = existingOrderMap.get(buildOrderKey(row.source, row.externalOrderNo)) ?? null;
|
|
const updateData = {
|
|
employee_id: row.employeeId,
|
|
department_id: employee.department_id,
|
|
linked_reimbursement_id: row.linkedReimbursementId,
|
|
occurred_at: row.occurredAt,
|
|
amount: row.amount,
|
|
currency: row.currency,
|
|
status: existingOrder?.status ?? "imported",
|
|
raw_payload: row.rawPayload,
|
|
updated_by: request.employee.id,
|
|
};
|
|
if (nextTravelRequestId !== undefined) {
|
|
updateData.travel_request_id = nextTravelRequestId;
|
|
}
|
|
|
|
const createData = {
|
|
source: row.source,
|
|
external_order_no: row.externalOrderNo,
|
|
employee_id: row.employeeId,
|
|
department_id: employee.department_id,
|
|
linked_reimbursement_id: row.linkedReimbursementId,
|
|
travel_request_id: nextTravelRequestId ?? null,
|
|
occurred_at: row.occurredAt,
|
|
amount: row.amount,
|
|
currency: row.currency,
|
|
status: "imported",
|
|
raw_payload: row.rawPayload,
|
|
created_by: request.employee.id,
|
|
updated_by: request.employee.id,
|
|
};
|
|
|
|
const upserted = await fastify.prisma.travelOrder.upsert({
|
|
where: {
|
|
source_external_order_no: {
|
|
source: row.source,
|
|
external_order_no: row.externalOrderNo,
|
|
},
|
|
},
|
|
update: updateData,
|
|
create: createData,
|
|
});
|
|
|
|
if (upserted.travel_request_id) {
|
|
linkedTravelRequestCount += 1;
|
|
}
|
|
imported.push(upserted);
|
|
}
|
|
|
|
await fastify.prisma.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "TRAVEL_ORDERS_IMPORTED",
|
|
targetType: "travel_order",
|
|
extra: {
|
|
requested: rows.length,
|
|
createdOrUpdated: imported.length,
|
|
linkedTravelRequestCount,
|
|
skipped,
|
|
},
|
|
}),
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
requested: rows.length,
|
|
imported: imported.length,
|
|
linkedTravelRequestCount,
|
|
skipped,
|
|
items: imported.map(mapTravelOrder),
|
|
});
|
|
},
|
|
);
|
|
|
|
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;
|