feat(P20T1-1,P20T1-2,P20T1-3): 差旅模型、申请/订单接口与工作流分支

这个提交包含在:
2026-03-10 19:58:46 +08:00
父节点 427ae2db53
当前提交 7b78945112
共修改 19 个文件,包含 2266 行新增和 59 行删除
+1
查看文件
@@ -10,6 +10,7 @@ const DOCUMENT_CATEGORY_ENTITY_TYPES = [
"reimbursement",
"leave",
"purchase_request",
"travel_request",
"asset_card",
"general",
];
+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),
});
},
);
文件差异内容过多而无法显示 加载差异
+166
查看文件
@@ -458,6 +458,15 @@ function getWorkflowEntityMeta(entityType, entityRecord, entityId) {
};
}
if (normalizedEntityType === "travel_request") {
return {
label: "差旅申请",
reference: entityRecord?.request_no ?? entityId,
targetType: "travel_request",
detailPath: `/travel-requests?id=${entityId}`,
};
}
return {
label: "审批单",
reference: entityId,
@@ -525,6 +534,18 @@ async function loadPurchaseNotifyContext(tx, purchaseRequestId) {
});
}
async function loadTravelNotifyContext(tx, travelRequestId) {
return tx.travelRequest.findUnique({
where: { id: travelRequestId },
select: {
id: true,
request_no: true,
applicant_employee_id: true,
submitter_id: true,
},
});
}
async function loadWorkflowNotifyContext(tx, entityType, entityId) {
const normalizedEntityType = entityType === 'procurement' ? 'purchase_request' : entityType;
@@ -552,6 +573,14 @@ async function loadWorkflowNotifyContext(tx, entityType, entityId) {
};
}
if (normalizedEntityType === 'travel_request') {
const record = await loadTravelNotifyContext(tx, entityId);
return {
record,
recipientEmployeeIds: uniq([record?.applicant_employee_id, record?.submitter_id]),
};
}
return {
record: null,
recipientEmployeeIds: [],
@@ -565,12 +594,14 @@ function resolveWorkflowOutcomeNotificationType(entityType, outcome) {
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementApproved;
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveApproved;
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestApproved;
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestApproved;
}
if (outcome === 'rejected') {
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementRejected;
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveRejected;
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestRejected;
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestRejected;
}
if (outcome === 'returned') {
@@ -973,6 +1004,50 @@ async function workflowRoutes(fastify) {
},
},
},
travel_request: {
select: {
id: true,
request_no: true,
status: true,
purpose: true,
trip_type: true,
start_date: true,
end_date: true,
estimated_amount: true,
applicant: {
select: {
id: true,
employee_no: true,
name: true,
},
},
legal_entity: {
select: {
id: true,
code: true,
name: true,
},
},
cost_center: {
select: {
id: true,
code: true,
name: true,
},
},
segments: {
orderBy: [{ sort_order: "asc" }, { created_at: "asc" }],
select: {
id: true,
sort_order: true,
segment_role: true,
transport_type: true,
from_city: true,
to_city: true,
},
},
},
},
},
},
},
@@ -1166,6 +1241,47 @@ async function workflowRoutes(fastify) {
: null,
}
: null,
travelRequest: task.workflow_instance?.travel_request
? {
id: task.workflow_instance.travel_request.id,
requestNo: task.workflow_instance.travel_request.request_no ?? null,
status: task.workflow_instance.travel_request.status,
purpose: task.workflow_instance.travel_request.purpose,
tripType: task.workflow_instance.travel_request.trip_type,
startDate: formatDateOnly(task.workflow_instance.travel_request.start_date),
endDate: formatDateOnly(task.workflow_instance.travel_request.end_date),
estimatedAmount: toMoneyString(task.workflow_instance.travel_request.estimated_amount),
applicant: task.workflow_instance.travel_request.applicant
? {
id: task.workflow_instance.travel_request.applicant.id,
employeeNo: task.workflow_instance.travel_request.applicant.employee_no,
name: task.workflow_instance.travel_request.applicant.name,
}
: null,
legalEntity: task.workflow_instance.travel_request.legal_entity
? {
id: task.workflow_instance.travel_request.legal_entity.id,
code: task.workflow_instance.travel_request.legal_entity.code,
name: task.workflow_instance.travel_request.legal_entity.name,
}
: null,
costCenter: task.workflow_instance.travel_request.cost_center
? {
id: task.workflow_instance.travel_request.cost_center.id,
code: task.workflow_instance.travel_request.cost_center.code,
name: task.workflow_instance.travel_request.cost_center.name,
}
: null,
segments: task.workflow_instance.travel_request.segments.map((item) => ({
id: item.id,
sortOrder: item.sort_order,
segmentRole: item.segment_role,
transportType: item.transport_type,
fromCity: item.from_city,
toCity: item.to_city,
})),
}
: null,
allowedActions: listAllowedActionsForTask(request, task, actionable),
history: historyItems.map((item) => ({
id: item.id,
@@ -1477,6 +1593,21 @@ async function workflowRoutes(fastify) {
"Failed to enqueue purchase approval ERP events",
);
}
} else if (workflowInstance.entity_type === "travel_request") {
await tx.travelRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "approved",
updated_by: request.employee.id,
},
});
await confirmBudgetForEntity(tx, {
entityType: "travel_request",
entityId: workflowInstance.entity_id,
actorId: request.employee.id,
note: "approved_confirm_travel_budget",
});
}
await tx.workflowHistory.create({
@@ -1520,6 +1651,7 @@ async function workflowRoutes(fastify) {
nextTaskId: null,
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null,
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "approved" : null,
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "approved" : null,
};
});
@@ -1645,6 +1777,21 @@ async function workflowRoutes(fastify) {
actorId: request.employee.id,
note: "rejected_release_purchase_budget",
});
} else if (workflowInstance.entity_type === "travel_request") {
await tx.travelRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "rejected",
updated_by: request.employee.id,
},
});
await releaseReservedBudgetIfAny(tx, {
entityType: "travel_request",
entityId: workflowInstance.entity_id,
actorId: request.employee.id,
note: "rejected_release_travel_budget",
});
}
await tx.workflowHistory.create({
@@ -1688,6 +1835,7 @@ async function workflowRoutes(fastify) {
workflowInstanceId: workflowInstance.id,
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null,
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "rejected" : null,
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "rejected" : null,
};
});
@@ -1813,6 +1961,21 @@ async function workflowRoutes(fastify) {
actorId: request.employee.id,
note: "returned_release_purchase_budget",
});
} else if (workflowInstance.entity_type === "travel_request") {
await tx.travelRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
await releaseReservedBudgetIfAny(tx, {
entityType: "travel_request",
entityId: workflowInstance.entity_id,
actorId: request.employee.id,
note: "returned_release_travel_budget",
});
}
await tx.workflowHistory.create({
@@ -1856,6 +2019,7 @@ async function workflowRoutes(fastify) {
workflowInstanceId: workflowInstance.id,
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "draft" : null,
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "draft" : null,
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "draft" : null,
};
});
@@ -2012,4 +2176,6 @@ module.exports = workflowRoutes;
module.exports.__testables = {
handlePurchaseApprovalErpSync,
listAllowedActionsForTask,
getWorkflowEntityMeta,
resolveWorkflowOutcomeNotificationType,
};