文件
hyb-oa/server/src/routes/travelRequests.js
T

1363 行
42 KiB
JavaScript

const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { parseDateOnlyString } = require("../utils/date");
const { sendData } = require("../utils/reply");
const { issueDocumentNumber } = require("../services/documentSequence");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
const { parseWorkflowSteps, resolveActionableStep } = require("../utils/workflow");
const { sendNotificationEmailSafe } = require("../services/email");
const { createNotificationsWithRecords, resolveRecipientsForAssignee } = require("../services/notifications");
const {
buildPendingTaskCopy,
buildWorkflowEntityMeta,
} = require("../services/notifications/workflowCopy");
const { buildTravelPaymentTrack } = require("../services/travelPayments");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const {
buildPersistedRiskFlag,
createRiskFlagsFromPrecheck,
resolveEntityPrecheck,
} = require("../services/feeControl");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
const UUID_SCHEMA = z.string().uuid();
const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional();
const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(?:\.\d{1,2})?$/);
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const DATE_TIME_SCHEMA = z.string().min(1).max(64);
const TRAVEL_STATUS_SCHEMA = z.enum(["draft", "reviewing", "approved", "rejected"]);
const TRIP_TYPE_SCHEMA = z.enum(["domestic", "international"]);
const SEGMENT_ROLE_SCHEMA = z.enum(["outbound", "return", "connection"]);
const TRANSPORT_TYPE_SCHEMA = z.enum(["flight", "train", "car", "bus", "other"]);
const TRAVEL_LIST_QUERY_SCHEMA = z.object({
status: TRAVEL_STATUS_SCHEMA.optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
mine: BOOLEAN_QUERY_SCHEMA,
tripType: TRIP_TYPE_SCHEMA.optional(),
legalEntityId: UUID_SCHEMA.optional(),
costCenterId: UUID_SCHEMA.optional(),
});
const SEGMENT_BODY_SCHEMA = z
.object({
segmentRole: SEGMENT_ROLE_SCHEMA,
transportType: TRANSPORT_TYPE_SCHEMA,
fromCity: z.string().trim().min(1).max(120),
toCity: z.string().trim().min(1).max(120),
departAt: DATE_TIME_SCHEMA,
arriveAt: DATE_TIME_SCHEMA,
})
.strict();
const TRAVEL_CREATE_BODY_SCHEMA = z
.object({
purpose: z.string().trim().min(1).max(500),
tripType: TRIP_TYPE_SCHEMA,
startDate: DATE_ONLY_SCHEMA,
endDate: DATE_ONLY_SCHEMA,
legalEntityId: UUID_SCHEMA,
costCenterId: UUID_SCHEMA,
estimatedAmount: z.union([MONEY_STRING_SCHEMA, z.number().min(0)]),
segments: z.array(SEGMENT_BODY_SCHEMA).min(1).max(20),
})
.strict();
const TRAVEL_PATCH_BODY_SCHEMA = TRAVEL_CREATE_BODY_SCHEMA.partial();
const TRAVEL_SUMMARY_SELECT = {
id: true,
request_no: true,
applicant_employee_id: true,
submitter_id: true,
department_id: true,
legal_entity_id: true,
cost_center_id: true,
budget_pool_id: true,
project_id: true,
purpose: true,
trip_type: true,
start_date: true,
end_date: true,
estimated_amount: true,
status: true,
created_at: true,
updated_at: true,
applicant: {
select: {
id: true,
employee_no: true,
name: true,
},
},
submitter: {
select: {
id: true,
employee_no: true,
name: true,
},
},
department: {
select: {
id: true,
name: true,
},
},
legal_entity: {
select: {
id: true,
code: true,
name: true,
},
},
cost_center: {
select: {
id: true,
code: true,
name: true,
legal_entity_id: true,
},
},
budget_pool: {
select: {
id: true,
code: true,
name: true,
},
},
project: {
select: {
id: true,
project_no: true,
name: true,
},
},
};
const TRAVEL_DETAIL_SELECT = {
...TRAVEL_SUMMARY_SELECT,
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,
depart_at: true,
arrive_at: true,
created_at: true,
updated_at: true,
},
},
travel_orders: {
orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }],
select: {
id: true,
source: true,
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,
},
},
workflow_instances: {
where: {
entity_type: "travel_request",
current_status: "pending",
},
orderBy: { created_at: "desc" },
take: 1,
select: {
id: true,
current_step_id: true,
current_status: true,
workflow_tasks: {
where: { status: "pending" },
orderBy: { created_at: "asc" },
take: 1,
select: {
id: true,
step_id: true,
step_name: true,
assignee_id: true,
assignee_role: true,
},
},
},
},
};
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseBooleanQueryValue(rawValue) {
if (rawValue == null) {
return null;
}
return rawValue === "1" || rawValue === "true";
}
function parseListQuery(query) {
const parsed = TRAVEL_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return {
status: parsed.data.status,
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
mine: parseBooleanQueryValue(parsed.data.mine) ?? true,
tripType: parsed.data.tripType,
legalEntityId: parsed.data.legalEntityId ?? null,
costCenterId: parsed.data.costCenterId ?? null,
};
}
function parseTravelRequestId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ id: "invalid_uuid" });
}
return parsed.data;
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function parseDateTimeValue(value, field) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw buildValidationError({ [field]: "invalid_datetime" });
}
return parsed;
}
function decimalInputToString(value, { field, min = 0 }) {
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric)) {
throw buildValidationError({ [field]: "invalid_decimal" });
}
if (numeric < min) {
throw buildValidationError({ [field]: min === 0 ? "must_be_non_negative" : "must_be_positive" });
}
return numeric.toFixed(2);
}
function normalizeSegments(segments) {
return segments.map((item, index) => {
const departAt = parseDateTimeValue(item.departAt, `segments.${index}.departAt`);
const arriveAt = parseDateTimeValue(item.arriveAt, `segments.${index}.arriveAt`);
if (arriveAt < departAt) {
throw buildValidationError({ [`segments.${index}.arriveAt`]: "must_be_after_departAt" });
}
return {
sortOrder: index + 1,
segmentRole: item.segmentRole,
transportType: item.transportType,
fromCity: item.fromCity.trim(),
toCity: item.toCity.trim(),
departAt,
arriveAt,
};
});
}
function ensureDateRangeValid(startDate, endDate) {
if (endDate < startDate) {
throw buildValidationError({ endDate: "must_be_after_startDate" });
}
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { precheck: precheck.blockingReasons },
});
}
async function ensureFinancialDimensions(tx, { legalEntityId, costCenterId }) {
const [legalEntity, costCenter] = await Promise.all([
tx.legalEntity.findUnique({
where: { id: legalEntityId },
select: { id: true, code: true, name: true, enabled: true, archived_at: true },
}),
tx.costCenter.findUnique({
where: { id: costCenterId },
select: {
id: true,
code: true,
name: true,
legal_entity_id: true,
enabled: true,
archived_at: true,
},
}),
]);
if (!legalEntity || !legalEntity.enabled || legalEntity.archived_at) {
throw buildValidationError({ legalEntityId: "not_found_or_disabled" }, 409);
}
if (!costCenter || !costCenter.enabled || costCenter.archived_at) {
throw buildValidationError({ costCenterId: "not_found_or_disabled" }, 409);
}
if (costCenter.legal_entity_id !== legalEntityId) {
throw buildValidationError({ costCenterId: "legal_entity_mismatch" }, 409);
}
return { legalEntity, costCenter };
}
function toIsoDate(value) {
return value?.toISOString?.()?.slice(0, 10) ?? null;
}
function toIsoDateTime(value) {
return value?.toISOString?.() ?? null;
}
function moneyToFixed(value) {
if (value == null) {
return "0.00";
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric.toFixed(2) : "0.00";
}
function mapEmployeeBasic(employee) {
if (!employee) {
return null;
}
return {
id: employee.id,
employeeNo: employee.employee_no,
name: employee.name,
};
}
function mapTravelSegment(item) {
return {
id: item.id,
sortOrder: item.sort_order,
segmentRole: item.segment_role,
transportType: item.transport_type,
fromCity: item.from_city,
toCity: item.to_city,
departAt: toIsoDateTime(item.depart_at),
arriveAt: toIsoDateTime(item.arrive_at),
createdAt: toIsoDateTime(item.created_at),
updatedAt: toIsoDateTime(item.updated_at),
};
}
function mapTravelOrder(item) {
return {
id: item.id,
source: item.source,
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),
};
}
function mapTravelRequestSummary(item) {
return {
id: item.id,
requestNo: item.request_no ?? null,
applicantEmployeeId: item.applicant_employee_id,
submitterId: item.submitter_id ?? null,
departmentId: item.department_id,
legalEntityId: item.legal_entity_id,
costCenterId: item.cost_center_id,
budgetPoolId: item.budget_pool_id ?? null,
projectId: item.project_id ?? null,
purpose: item.purpose,
tripType: item.trip_type,
startDate: toIsoDate(item.start_date),
endDate: toIsoDate(item.end_date),
estimatedAmount: moneyToFixed(item.estimated_amount),
status: item.status,
createdAt: toIsoDateTime(item.created_at),
updatedAt: toIsoDateTime(item.updated_at),
applicant: mapEmployeeBasic(item.applicant),
submitter: mapEmployeeBasic(item.submitter),
department: item.department
? {
id: item.department.id,
name: item.department.name,
}
: null,
legalEntity: item.legal_entity
? {
id: item.legal_entity.id,
code: item.legal_entity.code,
name: item.legal_entity.name,
}
: null,
costCenter: item.cost_center
? {
id: item.cost_center.id,
code: item.cost_center.code,
name: item.cost_center.name,
legalEntityId: item.cost_center.legal_entity_id,
}
: null,
budgetPool: item.budget_pool
? {
id: item.budget_pool.id,
code: item.budget_pool.code,
name: item.budget_pool.name,
}
: null,
project: item.project
? {
id: item.project.id,
projectNo: item.project.project_no,
name: item.project.name,
}
: null,
};
}
function mapTravelRequestDetail(item, request) {
const summary = mapTravelRequestSummary(item);
const isApplicant = item.applicant_employee_id === request.employee.id;
const canManage = request.employee.permissionCodes.includes("*") || request.employee.permissionCodes.includes("travel.manage");
return {
...summary,
segments: item.segments.map(mapTravelSegment),
travelOrders: item.travel_orders.map(mapTravelOrder),
pendingWorkflow: item.workflow_instances[0]
? {
instanceId: item.workflow_instances[0].id,
currentStatus: item.workflow_instances[0].current_status,
currentStepId: item.workflow_instances[0].current_step_id,
task: item.workflow_instances[0].workflow_tasks[0]
? {
id: item.workflow_instances[0].workflow_tasks[0].id,
stepId: item.workflow_instances[0].workflow_tasks[0].step_id,
stepName: item.workflow_instances[0].workflow_tasks[0].step_name,
assigneeId: item.workflow_instances[0].workflow_tasks[0].assignee_id ?? null,
assigneeRole: item.workflow_instances[0].workflow_tasks[0].assignee_role ?? null,
}
: null,
}
: null,
permissions: {
canEditDraft: item.status === "draft" && isApplicant,
canSubmit: item.status === "draft" && isApplicant,
canWithdraw: item.status === "reviewing" && isApplicant,
canManage,
},
};
}
function ensureTravelReadPermission(fastify, request) {
const canViewOwn = fastify.hasPermission(request.employee, "travel.view_own");
const canViewAll =
fastify.hasPermission(request.employee, "travel.view_all") ||
fastify.hasPermission(request.employee, "travel.manage");
if (!canViewOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
return { canViewOwn, canViewAll };
}
function canReadTravelRequest(request, travelRequest) {
if (!travelRequest) {
return false;
}
if (
request.employee.permissionCodes.includes("*") ||
request.employee.permissionCodes.includes("travel.view_all") ||
request.employee.permissionCodes.includes("travel.manage")
) {
return true;
}
return (
travelRequest.applicant_employee_id === request.employee.id ||
travelRequest.submitter_id === request.employee.id
);
}
function ensureDraftOwner(request, travelRequest, detailsKey = "travelRequest") {
if (travelRequest.applicant_employee_id !== request.employee.id) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { [detailsKey]: "only_applicant" },
});
}
}
async function loadTravelRequestForUpdate(tx, travelRequestId) {
const rows = await tx.$queryRaw`
SELECT id
FROM "travel_requests"
WHERE id = ${travelRequestId}::uuid
FOR UPDATE
`;
if (!Array.isArray(rows) || rows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const item = await tx.travelRequest.findUnique({
where: { id: travelRequestId },
select: {
id: true,
request_no: true,
applicant_employee_id: true,
submitter_id: true,
department_id: true,
legal_entity_id: true,
cost_center_id: true,
budget_pool_id: true,
purpose: true,
trip_type: true,
start_date: true,
end_date: true,
estimated_amount: true,
status: true,
applicant: {
select: {
id: true,
name: true,
},
},
segments: {
orderBy: [{ sort_order: "asc" }, { created_at: "asc" }],
select: { id: true },
},
},
});
if (!item) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
return item;
}
async function loadReadableTravelRequest(tx, travelRequestId, request) {
const travelRequest = await tx.travelRequest.findUnique({
where: { id: travelRequestId },
select: TRAVEL_DETAIL_SELECT,
});
if (!travelRequest) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (!canReadTravelRequest(request, travelRequest)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
return travelRequest;
}
async function createTravelRequestsRoutes(fastify) {
fastify.get(
"/travel-requests",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const visibility = ensureTravelReadPermission(fastify, request);
const query = parseListQuery(request.query);
const mine = visibility.canViewAll ? query.mine : true;
const skip = (query.page - 1) * query.pageSize;
const where = {
...(query.status ? { status: query.status } : {}),
...(query.tripType ? { trip_type: query.tripType } : {}),
...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}),
...(query.costCenterId ? { cost_center_id: query.costCenterId } : {}),
...(mine
? {
OR: [
{ applicant_employee_id: request.employee.id },
{ submitter_id: request.employee.id },
],
}
: {}),
};
const [items, total] = await Promise.all([
fastify.prisma.travelRequest.findMany({
where,
orderBy: [{ created_at: "desc" }],
skip,
take: query.pageSize,
select: TRAVEL_SUMMARY_SELECT,
}),
fastify.prisma.travelRequest.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
mine,
items: items.map(mapTravelRequestSummary),
});
},
);
fastify.post(
"/travel-requests",
{
preHandler: fastify.requirePermission("travel.create"),
},
async (request, reply) => {
const body = parseBody(TRAVEL_CREATE_BODY_SCHEMA, request.body);
await ensureCutoverAllowed({
prisma: fastify.prisma,
config: fastify.config,
request,
domain: "travel",
departmentId: request.employee.department_id ?? null,
legalEntityId: body.legalEntityId,
});
const startDate = parseDateOnlyString(body.startDate);
const endDate = parseDateOnlyString(body.endDate);
ensureDateRangeValid(startDate, endDate);
const estimatedAmount = decimalInputToString(body.estimatedAmount, {
field: "estimatedAmount",
min: 0,
});
const segments = normalizeSegments(body.segments);
await ensureFinancialDimensions(fastify.prisma, {
legalEntityId: body.legalEntityId,
costCenterId: body.costCenterId,
});
const created = await fastify.prisma.$transaction(async (tx) => {
const travelRequest = await tx.travelRequest.create({
data: {
applicant_employee_id: request.employee.id,
submitter_id: null,
department_id: request.employee.department_id,
legal_entity_id: body.legalEntityId,
cost_center_id: body.costCenterId,
purpose: body.purpose,
trip_type: body.tripType,
start_date: startDate,
end_date: endDate,
estimated_amount: estimatedAmount,
created_by: request.employee.id,
updated_by: request.employee.id,
segments: {
create: segments.map((item) => ({
sort_order: item.sortOrder,
segment_role: item.segmentRole,
transport_type: item.transportType,
from_city: item.fromCity,
to_city: item.toCity,
depart_at: item.departAt,
arrive_at: item.arriveAt,
created_by: request.employee.id,
updated_by: request.employee.id,
})),
},
},
select: TRAVEL_DETAIL_SELECT,
});
return travelRequest;
});
void fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_REQUEST_CREATED",
targetType: "travel_request",
targetId: created.id,
extra: {
tripType: created.trip_type,
startDate: toIsoDate(created.start_date),
endDate: toIsoDate(created.end_date),
estimatedAmount: moneyToFixed(created.estimated_amount),
segmentCount: created.segments.length,
},
}),
}).catch((error) => {
fastify.log.error(
{ err: error, travelRequestId: created.id },
"Failed to write travel request create audit log",
);
});
return sendData(reply, request, mapTravelRequestDetail(created, request));
},
);
fastify.get(
"/travel-requests/:id",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
ensureTravelReadPermission(fastify, request);
const travelRequestId = parseTravelRequestId(request.params?.id);
const detail = await loadReadableTravelRequest(fastify.prisma, travelRequestId, request);
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
where: {
entity_type: "travel_request",
entity_id: travelRequestId,
status: "open",
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return sendData(reply, request, {
...mapTravelRequestDetail(detail, request),
riskFlags: riskFlags.map(buildPersistedRiskFlag),
});
},
);
fastify.patch(
"/travel-requests/:id",
{
preHandler: fastify.requirePermission("travel.create"),
},
async (request, reply) => {
const travelRequestId = parseTravelRequestId(request.params?.id);
const body = parseBody(TRAVEL_PATCH_BODY_SCHEMA, request.body);
if (Object.keys(body).length === 0) {
throw buildValidationError({ body: "empty_patch" });
}
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const locked = await loadTravelRequestForUpdate(tx, travelRequestId);
ensureDraftOwner(request, locked);
if (locked.status !== "draft") {
throw buildValidationError({ status: "only_draft_editable" }, 409);
}
const nextStartDate = Object.prototype.hasOwnProperty.call(body, "startDate")
? parseDateOnlyString(body.startDate)
: locked.start_date;
const nextEndDate = Object.prototype.hasOwnProperty.call(body, "endDate")
? parseDateOnlyString(body.endDate)
: locked.end_date;
ensureDateRangeValid(nextStartDate, nextEndDate);
const nextLegalEntityId = body.legalEntityId ?? locked.legal_entity_id;
const nextCostCenterId = body.costCenterId ?? locked.cost_center_id;
await ensureFinancialDimensions(tx, {
legalEntityId: nextLegalEntityId,
costCenterId: nextCostCenterId,
});
const normalizedSegments = Object.prototype.hasOwnProperty.call(body, "segments")
? normalizeSegments(body.segments)
: null;
await tx.travelRequest.update({
where: { id: travelRequestId },
data: {
...(Object.prototype.hasOwnProperty.call(body, "purpose") ? { purpose: body.purpose } : {}),
...(Object.prototype.hasOwnProperty.call(body, "tripType") ? { trip_type: body.tripType } : {}),
...(Object.prototype.hasOwnProperty.call(body, "startDate") ? { start_date: nextStartDate } : {}),
...(Object.prototype.hasOwnProperty.call(body, "endDate") ? { end_date: nextEndDate } : {}),
...(Object.prototype.hasOwnProperty.call(body, "legalEntityId")
? { legal_entity_id: body.legalEntityId, budget_pool_id: null }
: {}),
...(Object.prototype.hasOwnProperty.call(body, "costCenterId")
? { cost_center_id: body.costCenterId, budget_pool_id: null }
: {}),
...(Object.prototype.hasOwnProperty.call(body, "estimatedAmount")
? {
estimated_amount: decimalInputToString(body.estimatedAmount, {
field: "estimatedAmount",
min: 0,
}),
budget_pool_id: null,
}
: {}),
updated_by: request.employee.id,
},
});
if (normalizedSegments) {
await tx.travelRequestSegment.deleteMany({
where: { travel_request_id: travelRequestId },
});
if (normalizedSegments.length) {
await tx.travelRequestSegment.createMany({
data: normalizedSegments.map((item) => ({
travel_request_id: travelRequestId,
sort_order: item.sortOrder,
segment_role: item.segmentRole,
transport_type: item.transportType,
from_city: item.fromCity,
to_city: item.toCity,
depart_at: item.departAt,
arrive_at: item.arriveAt,
created_by: request.employee.id,
updated_by: request.employee.id,
})),
});
}
}
const updated = await tx.travelRequest.findUnique({
where: { id: travelRequestId },
select: TRAVEL_DETAIL_SELECT,
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_REQUEST_UPDATED",
targetType: "travel_request",
targetId: travelRequestId,
extra: {
fields: Object.keys(body),
segmentCount: updated?.segments.length ?? locked.segments.length,
},
}),
});
response = updated;
});
return sendData(reply, request, mapTravelRequestDetail(response, request));
},
);
fastify.post(
"/travel-requests/:id/precheck",
{
preHandler: fastify.requirePermission("travel.submit"),
},
async (request, reply) => {
const travelRequestId = parseTravelRequestId(request.params?.id);
const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
entityType: "travel_request",
entityId: travelRequestId,
actorDepartmentId: request.employee.department_id ?? null,
});
return sendData(reply, request, precheck);
},
);
fastify.post(
"/travel-requests/:id/submit",
{
preHandler: fastify.requirePermission("travel.submit"),
},
async (request, reply) => {
const travelRequestId = parseTravelRequestId(request.params?.id);
let response = null;
const emailTasks = [];
const dispatchTasks = [];
await fastify.prisma.$transaction(async (tx) => {
const travelRequest = await loadTravelRequestForUpdate(tx, travelRequestId);
ensureDraftOwner(request, travelRequest);
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "travel",
departmentId: travelRequest.department_id ?? request.employee.department_id ?? null,
legalEntityId: travelRequest.legal_entity_id ?? null,
});
if (travelRequest.status !== "draft") {
throw buildValidationError({ status: "only_draft_submittable" }, 409);
}
if (!travelRequest.segments.length) {
throw buildValidationError({ segments: "required" }, 409);
}
const authoritativePrecheck = await resolveEntityPrecheck({
prisma: tx,
config: fastify.config,
logger: fastify.log,
entityType: "travel_request",
entityId: travelRequestId,
actorDepartmentId: request.employee.department_id ?? null,
});
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "travel_request",
entityId: travelRequestId,
actorId: request.employee.id,
precheck: authoritativePrecheck,
});
const workflowDefinition = await tx.workflowDefinition.findFirst({
where: { entity_type: "travel_request", is_active: true },
orderBy: { version: "desc" },
select: {
id: true,
definition_json: true,
},
});
if (!workflowDefinition) {
throw buildValidationError({ workflow: "missing_definition" }, 409);
}
const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json);
const { actionable, skipped } = await resolveActionableStep({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: travelRequest.applicant_employee_id,
logger: fastify.log,
});
if (!actionable) {
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
}
let requestNo = travelRequest.request_no;
if (!requestNo) {
const issued = await issueDocumentNumber(tx, {
documentType: "travel_request",
actorId: request.employee.id,
timeZone: fastify.config.appTimezone,
});
requestNo = issued.documentNumber;
}
const resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, {
legalEntityId: travelRequest.legal_entity_id,
costCenterId: travelRequest.cost_center_id,
effectiveDate: travelRequest.start_date,
});
const selectedBudgetPoolId = resolvedBudgetPool?.id ?? null;
await tx.travelRequest.update({
where: { id: travelRequestId },
data: {
request_no: requestNo,
submitter_id: request.employee.id,
budget_pool_id: selectedBudgetPoolId,
status: "reviewing",
updated_by: request.employee.id,
},
});
const budgetReservation = await reserveBudgetForEntity(tx, {
budgetPoolId: selectedBudgetPoolId,
entityType: "travel_request",
entityId: travelRequestId,
amount: travelRequest.estimated_amount,
actorId: request.employee.id,
note: `submit_reserve:${requestNo ?? travelRequestId}`,
});
const workflowInstance = await tx.workflowInstance.create({
data: {
workflow_id: workflowDefinition.id,
entity_type: "travel_request",
entity_id: travelRequestId,
travel_request_id: travelRequestId,
submitter_id: request.employee.id,
current_status: "pending",
current_step_id: actionable.step.id,
context_data: {
owner_id: travelRequest.applicant_employee_id,
department_id: travelRequest.department_id,
workflow_steps_snapshot: workflowSteps,
legal_entity_id: travelRequest.legal_entity_id,
cost_center_id: travelRequest.cost_center_id,
request_no: requestNo,
trip_type: travelRequest.trip_type,
},
context_department_id: travelRequest.department_id,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: { id: true },
});
for (const skippedStep of skipped) {
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
actor_id: request.employee.id,
action: "skip",
payload: {
step_id: skippedStep.step.id,
reason: skippedStep.reason,
owner_id: travelRequest.applicant_employee_id,
depth_checked: skippedStep.depthChecked,
},
},
});
}
const workflowTask = await tx.workflowTask.create({
data: {
instance_id: workflowInstance.id,
step_id: actionable.step.id,
step_name: actionable.step.name ?? actionable.step.id,
assignee_id: actionable.assigneeId,
assignee_role: actionable.assigneeRole,
},
select: {
id: true,
assignee_id: true,
assignee_role: true,
},
});
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: workflowTask.id,
actor_id: request.employee.id,
action: "start",
payload: {
step_id: actionable.step.id,
skipped_step_count: skipped.length,
},
},
});
const recipients = await resolveRecipientsForAssignee(tx, {
assigneeId: workflowTask.assignee_id,
assigneeRole: workflowTask.assignee_role,
});
const pendingCopy = buildPendingTaskCopy(
buildWorkflowEntityMeta(
"travel_request",
{
request_no: requestNo,
estimated_amount: travelRequest.estimated_amount,
applicant: travelRequest.applicant,
},
travelRequestId,
),
);
const { title, body } = pendingCopy;
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
title,
body,
targetType: "workflow_task",
targetId: workflowTask.id,
});
dispatchTasks.push({
notifications,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
title,
body,
targetType: "workflow_task",
targetId: workflowTask.id,
targetPath: `/workflow/tasks/${workflowTask.id}`,
actorId: request.employee.id,
});
emailTasks.push({
type: NOTIFICATION_TYPES.workflowTaskAssigned,
recipientEmployeeIds: recipients,
subject: title,
body,
targetPath: `/workflow/tasks/${workflowTask.id}`,
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_REQUEST_SUBMITTED",
targetType: "travel_request",
targetId: travelRequestId,
extra: {
requestNo,
estimatedAmount: moneyToFixed(travelRequest.estimated_amount),
workflowInstanceId: workflowInstance.id,
workflowTaskId: workflowTask.id,
budgetPoolId: selectedBudgetPoolId,
budgetReservation,
},
}),
});
response = {
travelRequestId,
requestNo,
status: "reviewing",
workflowInstanceId: workflowInstance.id,
currentTaskId: workflowTask.id,
budgetPoolId: selectedBudgetPoolId,
budgetReservation,
precheckDecision: authoritativePrecheck.decision,
openedRiskFlagCount: openedRiskFlags.length,
};
});
for (const emailTask of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...emailTask,
});
}
await flushNotificationDispatchTasks({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
tasks: dispatchTasks,
});
return sendData(reply, request, response);
},
);
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/link-project",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.projectLinking,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "project_linking_v1_disabled", reason: gate.reason }, 409);
}
const travelRequestId = parseUuid(request.params?.id, "id");
const body = parseBody(z.object({ projectId: UUID_SCHEMA.nullable() }).strict(), request.body);
const current = await fastify.prisma.travelRequest.findUnique({
where: { id: travelRequestId },
select: { id: true, applicant_employee_id: true, submitter_id: true, status: true },
});
if (!current) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (!canReadTravelRequest(request, current)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "travel.view_own" } });
}
const updated = await fastify.prisma.travelRequest.update({
where: { id: travelRequestId },
data: { project_id: body.projectId ?? null, updated_by: request.employee.id },
select: { id: true, project_id: true, project: { select: { id: true, project_no: true, name: true } } },
});
return sendData(reply, request, {
item: {
travelRequestId: updated.id,
projectId: updated.project_id ?? null,
project: updated.project ? { id: updated.project.id, projectNo: updated.project.project_no, name: updated.project.name } : null,
},
});
},
);
fastify.post(
"/travel-requests/:id/withdraw",
{
preHandler: fastify.requirePermission("travel.submit"),
},
async (request, reply) => {
const travelRequestId = parseTravelRequestId(request.params?.id);
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const locked = await loadTravelRequestForUpdate(tx, travelRequestId);
ensureDraftOwner(request, locked);
if (locked.status !== "reviewing") {
throw buildValidationError({ status: "only_reviewing_withdrawable" }, 409);
}
const workflowInstance = await tx.workflowInstance.findFirst({
where: {
entity_type: "travel_request",
entity_id: travelRequestId,
current_status: "pending",
},
orderBy: { created_at: "desc" },
select: { id: true },
});
if (workflowInstance) {
await tx.workflowTask.updateMany({
where: {
instance_id: workflowInstance.id,
status: "pending",
},
data: {
status: "cancelled",
completed_at: new Date(),
completed_by: request.employee.id,
},
});
await tx.workflowInstance.update({
where: { id: workflowInstance.id },
data: {
current_status: "cancelled",
completed_at: new Date(),
updated_by: request.employee.id,
},
});
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
actor_id: request.employee.id,
action: "withdraw",
payload: {
source: "travel_request_withdraw",
},
},
});
}
const budgetRelease = await releaseReservedBudgetIfAny(tx, {
entityType: "travel_request",
entityId: travelRequestId,
actorId: request.employee.id,
note: "withdraw_release_travel_budget",
});
await tx.travelRequest.update({
where: { id: travelRequestId },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_REQUEST_WITHDRAWN",
targetType: "travel_request",
targetId: travelRequestId,
extra: {
workflowInstanceId: workflowInstance?.id ?? null,
budgetRelease,
},
}),
});
response = {
travelRequestId,
status: "draft",
outcome: "withdrawn",
workflowInstanceId: workflowInstance?.id ?? null,
budgetRelease,
};
});
return sendData(reply, request, response);
},
);
}
module.exports = createTravelRequestsRoutes;
module.exports.__testables = {
normalizeSegments,
canReadTravelRequest,
ensureDateRangeValid,
mapTravelRequestSummary,
};