feat(P20T1-1,P20T1-2,P20T1-3): 差旅模型、申请/订单接口与工作流分支
这个提交包含在:
+114
-59
@@ -8,6 +8,7 @@ const { normalizeTravelImportRows } = require("../services/integrations/travel")
|
||||
const IMPORT_SCHEMA = z.object({
|
||||
orders: z.array(z.record(z.string(), z.unknown())).min(1).max(100),
|
||||
});
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
|
||||
function mapTravelOrder(item) {
|
||||
return {
|
||||
@@ -17,6 +18,7 @@ function mapTravelOrder(item) {
|
||||
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,
|
||||
@@ -26,6 +28,15 @@ function mapTravelOrder(item) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
async function travelOrdersRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/travel/orders/import",
|
||||
@@ -34,49 +45,56 @@ async function travelOrdersRoutes(fastify) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!fastify.config.travelSyncEnabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { integration: "travel_sync_disabled" },
|
||||
});
|
||||
throw buildValidationError({ integration: "travel_sync_disabled" }, 409);
|
||||
}
|
||||
|
||||
const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
|
||||
const rows = normalizeTravelImportRows(parsed.data.orders);
|
||||
if (!rows.length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { orders: "empty_after_normalization" },
|
||||
});
|
||||
throw buildValidationError({ orders: "empty_after_normalization" });
|
||||
}
|
||||
|
||||
const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId)));
|
||||
const employees = await fastify.prisma.employee.findMany({
|
||||
where: {
|
||||
id: { in: employeeIds },
|
||||
status: "active",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
department_id: true,
|
||||
},
|
||||
});
|
||||
const employeeMap = new Map(employees.map((item) => [item.id, item]));
|
||||
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 created = [];
|
||||
const [employees, travelRequests] = 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([]),
|
||||
]);
|
||||
|
||||
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 linkedTravelRequestCount = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const employee = employeeMap.get(row.employeeId);
|
||||
@@ -89,6 +107,61 @@ async function travelOrdersRoutes(fastify) {
|
||||
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 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: "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: {
|
||||
@@ -96,34 +169,14 @@ async function travelOrdersRoutes(fastify) {
|
||||
external_order_no: row.externalOrderNo,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
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: "imported",
|
||||
raw_payload: row.rawPayload,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
create: {
|
||||
source: row.source,
|
||||
external_order_no: row.externalOrderNo,
|
||||
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: "imported",
|
||||
raw_payload: row.rawPayload,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
update: updateData,
|
||||
create: createData,
|
||||
});
|
||||
|
||||
created.push(upserted);
|
||||
if (upserted.travel_request_id) {
|
||||
linkedTravelRequestCount += 1;
|
||||
}
|
||||
imported.push(upserted);
|
||||
}
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
@@ -133,7 +186,8 @@ async function travelOrdersRoutes(fastify) {
|
||||
targetType: "travel_order",
|
||||
extra: {
|
||||
requested: rows.length,
|
||||
createdOrUpdated: created.length,
|
||||
createdOrUpdated: imported.length,
|
||||
linkedTravelRequestCount,
|
||||
skipped,
|
||||
},
|
||||
}),
|
||||
@@ -141,9 +195,10 @@ async function travelOrdersRoutes(fastify) {
|
||||
|
||||
return sendData(reply, request, {
|
||||
requested: rows.length,
|
||||
imported: created.length,
|
||||
imported: imported.length,
|
||||
linkedTravelRequestCount,
|
||||
skipped,
|
||||
items: created.map(mapTravelOrder),
|
||||
items: imported.map(mapTravelOrder),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
在新工单中引用
屏蔽一个用户