1363 行
42 KiB
JavaScript
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,
|
|
};
|