R57 A1-A6:报销模板与差旅申请关联
这个提交包含在:
@@ -35,6 +35,7 @@ const performanceRoutes = require("./routes/performance");
|
||||
const payrollRoutes = require("./routes/payroll");
|
||||
const expenseTypesRoutes = require("./routes/expenseTypes");
|
||||
const expenseStandardsRoutes = require("./routes/expenseStandards");
|
||||
const reimbursementTemplatesRoutes = require("./routes/reimbursementTemplates");
|
||||
const configReadRoutes = require("./routes/configRead");
|
||||
const approvalRulesRoutes = require("./routes/approvalRules");
|
||||
const featureFlagsRoutes = require("./routes/featureFlags");
|
||||
@@ -324,6 +325,7 @@ async function buildApp() {
|
||||
await app.register(masterDataSuppliersRoutes, { prefix: "/api" });
|
||||
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
||||
await app.register(expenseStandardsRoutes, { prefix: "/api" });
|
||||
await app.register(reimbursementTemplatesRoutes, { prefix: "/api" });
|
||||
await app.register(configReadRoutes, { prefix: "/api" });
|
||||
await app.register(approvalRulesRoutes, { prefix: "/api" });
|
||||
await app.register(featureFlagsRoutes, { prefix: "/api" });
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const MONEY_STRING_SCHEMA = z.string().trim().regex(/^\d+(?:\.\d{1,2})?$/);
|
||||
|
||||
const TEMPLATE_ITEM_SCHEMA = z
|
||||
.object({
|
||||
expenseTypeId: UUID_SCHEMA,
|
||||
description: z.string().trim().min(1).max(500),
|
||||
defaultAmount: MONEY_STRING_SCHEMA.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.defaultAmount != null && Number(value.defaultAmount) <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "must_be_positive",
|
||||
path: ["defaultAmount"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const LIST_QUERY_SCHEMA = z
|
||||
.object({
|
||||
enabled: z.enum(["true", "false", "1", "0"]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const CREATE_BODY_SCHEMA = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
description: z.string().trim().max(500).nullable().optional(),
|
||||
items: z.array(TEMPLATE_ITEM_SCHEMA).max(50).optional(),
|
||||
sortOrder: z.number().int().min(0).max(10000).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const UPDATE_BODY_SCHEMA = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
description: z.string().trim().max(500).nullable().optional(),
|
||||
items: z.array(TEMPLATE_ITEM_SCHEMA).max(50).optional(),
|
||||
sortOrder: z.number().int().min(0).max(10000).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
function parseTemplateId(value) {
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError({ id: "invalid_uuid" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseListQuery(query) {
|
||||
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
|
||||
const enabledRaw = parsed.data.enabled;
|
||||
const enabled =
|
||||
enabledRaw == null
|
||||
? null
|
||||
: enabledRaw === "true" || enabledRaw === "1";
|
||||
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function normalizeTemplateItems(items) {
|
||||
return (items ?? []).map((item) => ({
|
||||
expenseTypeId: item.expenseTypeId,
|
||||
description: item.description.trim(),
|
||||
...(item.defaultAmount == null ? {} : { defaultAmount: item.defaultAmount.trim() }),
|
||||
}));
|
||||
}
|
||||
|
||||
async function ensureExpenseTypesEnabled(tx, items) {
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expenseTypeIds = Array.from(new Set(items.map((item) => item.expenseTypeId)));
|
||||
const expenseTypes = await tx.expenseType.findMany({
|
||||
where: {
|
||||
id: { in: expenseTypeIds },
|
||||
enabled: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const enabledIds = new Set(expenseTypes.map((item) => item.id));
|
||||
const invalidItems = items
|
||||
.map((item, index) => ({ index, expenseTypeId: item.expenseTypeId }))
|
||||
.filter((item) => !enabledIds.has(item.expenseTypeId));
|
||||
|
||||
if (invalidItems.length) {
|
||||
throw buildValidationError(
|
||||
{
|
||||
items: invalidItems.map((item) => ({
|
||||
index: item.index,
|
||||
expenseTypeId: item.expenseTypeId,
|
||||
reason: "expense_type_not_found_or_disabled",
|
||||
})),
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapTemplate(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description ?? null,
|
||||
items: Array.isArray(record.items) ? record.items : [],
|
||||
sortOrder: record.sort_order,
|
||||
enabled: record.enabled,
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSnapshot(record) {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description ?? null,
|
||||
items: Array.isArray(record.items) ? record.items : [],
|
||||
sortOrder: record.sort_order,
|
||||
enabled: record.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function reimbursementTemplatesRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/reimbursement-templates",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseListQuery(request.query);
|
||||
const items = await fastify.prisma.reimbursementTemplate.findMany({
|
||||
where: {
|
||||
...(query.enabled == null ? {} : { enabled: query.enabled }),
|
||||
},
|
||||
orderBy: [{ sort_order: "asc" }, { created_at: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
items: true,
|
||||
sort_order: true,
|
||||
enabled: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
enabled: query.enabled,
|
||||
items: items.map(mapTemplate),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursement-templates",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = parseBody(CREATE_BODY_SCHEMA, request.body);
|
||||
const items = normalizeTemplateItems(body.items);
|
||||
await ensureExpenseTypesEnabled(fastify.prisma, items);
|
||||
|
||||
const now = new Date();
|
||||
const created = await fastify.prisma.$transaction(async (tx) => {
|
||||
const record = await tx.reimbursementTemplate.create({
|
||||
data: {
|
||||
name: body.name.trim(),
|
||||
description: body.description?.trim?.() || null,
|
||||
items,
|
||||
sort_order: body.sortOrder ?? 0,
|
||||
enabled: body.enabled ?? true,
|
||||
created_at: now,
|
||||
created_by: request.employee.id,
|
||||
updated_at: now,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
items: true,
|
||||
sort_order: true,
|
||||
enabled: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_TEMPLATE_CREATED",
|
||||
targetType: "reimbursement_template",
|
||||
targetId: record.id,
|
||||
extra: {
|
||||
after: normalizeSnapshot(record),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return record;
|
||||
});
|
||||
|
||||
return sendData(reply, request, mapTemplate(created));
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
"/reimbursement-templates/:id",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const templateId = parseTemplateId(request.params?.id);
|
||||
const body = parseBody(UPDATE_BODY_SCHEMA, request.body);
|
||||
|
||||
if (!Object.keys(body).length) {
|
||||
throw buildValidationError({ body: "empty" });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.reimbursementTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
items: true,
|
||||
sort_order: true,
|
||||
enabled: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const nextItems =
|
||||
body.items == null
|
||||
? Array.isArray(current.items)
|
||||
? current.items
|
||||
: []
|
||||
: normalizeTemplateItems(body.items);
|
||||
|
||||
await ensureExpenseTypesEnabled(tx, nextItems);
|
||||
|
||||
const record = await tx.reimbursementTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
...(body.name === undefined ? {} : { name: body.name.trim() }),
|
||||
...(body.description === undefined ? {} : { description: body.description?.trim?.() || null }),
|
||||
...(body.items === undefined ? {} : { items: nextItems }),
|
||||
...(body.sortOrder === undefined ? {} : { sort_order: body.sortOrder }),
|
||||
...(body.enabled === undefined ? {} : { enabled: body.enabled }),
|
||||
updated_at: now,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
items: true,
|
||||
sort_order: true,
|
||||
enabled: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_TEMPLATE_UPDATED",
|
||||
targetType: "reimbursement_template",
|
||||
targetId: record.id,
|
||||
extra: {
|
||||
before: normalizeSnapshot(current),
|
||||
after: normalizeSnapshot(record),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return record;
|
||||
});
|
||||
|
||||
return sendData(reply, request, mapTemplate(updated));
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete(
|
||||
"/reimbursement-templates/:id",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const templateId = parseTemplateId(request.params?.id);
|
||||
const now = new Date();
|
||||
|
||||
const deleted = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.reimbursementTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
items: true,
|
||||
sort_order: true,
|
||||
enabled: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (!current.enabled) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const record = await tx.reimbursementTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
enabled: false,
|
||||
updated_at: now,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
items: true,
|
||||
sort_order: true,
|
||||
enabled: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_TEMPLATE_DELETED",
|
||||
targetType: "reimbursement_template",
|
||||
targetId: record.id,
|
||||
extra: {
|
||||
before: normalizeSnapshot(current),
|
||||
after: normalizeSnapshot(record),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return record;
|
||||
});
|
||||
|
||||
return sendData(reply, request, mapTemplate(deleted));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = reimbursementTemplatesRoutes;
|
||||
+546
-53
@@ -55,6 +55,30 @@ const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
|
||||
description: z.string().trim().max(500).nullable().optional(),
|
||||
});
|
||||
|
||||
const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
|
||||
.object({
|
||||
ownerEmployeeId: z.string().uuid().optional(),
|
||||
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
templateId: z.string().uuid().optional(),
|
||||
linkedTravelRequestId: z.string().uuid().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.templateId && value.linkedTravelRequestId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "mutually_exclusive",
|
||||
path: ["templateId"],
|
||||
});
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "mutually_exclusive",
|
||||
path: ["linkedTravelRequestId"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
|
||||
expenseTypeId: z.string().uuid(),
|
||||
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
@@ -74,6 +98,11 @@ const REIMBURSEMENT_TREND_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend
|
||||
const REIMBURSEMENT_TOP_EXPENSE_TYPES_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend({
|
||||
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||
});
|
||||
const LINKABLE_TRAVEL_REQUEST_QUERY_SCHEMA = z
|
||||
.object({
|
||||
ownerEmployeeId: UUID_SCHEMA.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const REIMBURSEMENT_STATUS_VALUES = ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"];
|
||||
const ROLE_LABELS = {
|
||||
@@ -86,6 +115,28 @@ const ROLE_LABELS = {
|
||||
hr: "HR",
|
||||
};
|
||||
|
||||
const TRAVEL_SEGMENT_ROLE_LABELS = {
|
||||
outbound: "去程",
|
||||
return: "返程",
|
||||
connection: "中转",
|
||||
};
|
||||
|
||||
const TRAVEL_TRANSPORT_LABELS = {
|
||||
flight: "航班",
|
||||
train: "火车",
|
||||
bus: "大巴",
|
||||
car: "汽车",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = {
|
||||
flight: "travel_airfare",
|
||||
train: "travel_train",
|
||||
bus: "travel_bus",
|
||||
car: "travel_taxi",
|
||||
other: "transport",
|
||||
};
|
||||
|
||||
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||
if (canViewAll) {
|
||||
@@ -254,6 +305,36 @@ function parseStatsQuery(query) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseCreateBody(body) {
|
||||
const parsed = REIMBURSEMENT_CREATE_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseLinkableTravelRequestsQuery(query) {
|
||||
const parsed = LINKABLE_TRAVEL_REQUEST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ownerEmployeeId: parsed.data.ownerEmployeeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTrendQuery(query) {
|
||||
const parsed = parseQuery(REIMBURSEMENT_TREND_QUERY_SCHEMA, query);
|
||||
const base = parseStatsQuery(parsed);
|
||||
@@ -554,6 +635,255 @@ function toDateOnlyString(dateValue) {
|
||||
return new Date(dateValue).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function mapLinkedTravelRequestSummary(travelRequest) {
|
||||
if (!travelRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: travelRequest.id,
|
||||
requestNo: travelRequest.request_no ?? null,
|
||||
purpose: travelRequest.purpose ?? null,
|
||||
startDate: toDateOnlyString(travelRequest.start_date),
|
||||
endDate: toDateOnlyString(travelRequest.end_date),
|
||||
estimatedAmount: toMoneyString(travelRequest.estimated_amount ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function mapCreatedReimbursementItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
expenseTypeId: item.expense_type_id,
|
||||
expenseDate: toDateOnlyString(item.expense_date),
|
||||
amount: toMoneyString(item.amount),
|
||||
description: item.description ?? null,
|
||||
expenseType: item.expense_type
|
||||
? {
|
||||
id: item.expense_type.id,
|
||||
code: item.expense_type.code,
|
||||
name: item.expense_type.name,
|
||||
category: item.expense_type.category,
|
||||
enabled: item.expense_type.enabled,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplateSeedItems(rawItems) {
|
||||
if (!Array.isArray(rawItems)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawItems.map((item, index) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { template: { itemIndex: index, reason: "invalid_item_shape" } },
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof item.expenseTypeId !== "string" || typeof item.description !== "string") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { template: { itemIndex: index, reason: "invalid_item_shape" } },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
expenseTypeId: item.expenseTypeId,
|
||||
description: item.description,
|
||||
defaultAmount: typeof item.defaultAmount === "string" ? item.defaultAmount : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveEnabledExpenseTypesByIds(tx, expenseTypeIds) {
|
||||
if (!expenseTypeIds.length) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const items = await tx.expenseType.findMany({
|
||||
where: {
|
||||
id: { in: expenseTypeIds },
|
||||
enabled: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
category: true,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
return new Map(items.map((item) => [item.id, item]));
|
||||
}
|
||||
|
||||
async function resolveTravelExpenseTypeMap(tx) {
|
||||
const codes = Array.from(new Set(Object.values(TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES)));
|
||||
const items = await tx.expenseType.findMany({
|
||||
where: {
|
||||
code: { in: codes },
|
||||
enabled: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
category: true,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
return new Map(items.map((item) => [item.code, item]));
|
||||
}
|
||||
|
||||
function buildTravelSegmentDescription(segment) {
|
||||
const segmentRoleLabel = TRAVEL_SEGMENT_ROLE_LABELS[segment.segment_role] ?? segment.segment_role;
|
||||
const transportLabel = TRAVEL_TRANSPORT_LABELS[segment.transport_type] ?? segment.transport_type;
|
||||
return `${segmentRoleLabel} ${transportLabel} ${segment.from_city}→${segment.to_city}`;
|
||||
}
|
||||
|
||||
async function resolveReimbursementOwnerContext(tx, fastify, request, ownerEmployeeId) {
|
||||
const isProxyCreate = ownerEmployeeId !== request.employee.id;
|
||||
|
||||
if (!isProxyCreate) {
|
||||
return {
|
||||
ownerEmployeeId,
|
||||
ownerEmployee: {
|
||||
id: request.employee.id,
|
||||
department_id: request.employee.department_id ?? null,
|
||||
status: "active",
|
||||
},
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
delegationGrantId: null,
|
||||
isProxyCreate: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isAdministration = request.employee.roleCodes?.includes("administration");
|
||||
const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit");
|
||||
if (!isAdministration || !canProxySubmit) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const ownerEmployee = await tx.employee.findUnique({
|
||||
where: { id: ownerEmployeeId },
|
||||
select: { id: true, status: true, department_id: true },
|
||||
});
|
||||
if (!ownerEmployee || ownerEmployee.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { ownerEmployeeId: "invalid_owner" },
|
||||
});
|
||||
}
|
||||
|
||||
const delegation = await findActiveDelegationForDelegate(tx, {
|
||||
principalEmployeeId: ownerEmployeeId,
|
||||
delegateEmployeeId: request.employee.id,
|
||||
allowedScopes: ["reimbursement_submit", "all"],
|
||||
now: new Date(),
|
||||
});
|
||||
if (!delegation) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { delegation: "missing_active_grant" },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ownerEmployeeId,
|
||||
ownerEmployee,
|
||||
departmentId: ownerEmployee.department_id,
|
||||
delegationGrantId: delegation.id,
|
||||
isProxyCreate: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadLinkableTravelRequests(tx, ownerEmployeeId) {
|
||||
return tx.travelRequest.findMany({
|
||||
where: {
|
||||
applicant_employee_id: ownerEmployeeId,
|
||||
status: "approved",
|
||||
linked_reimbursements: {
|
||||
none: {},
|
||||
},
|
||||
travel_orders: {
|
||||
none: {
|
||||
linked_reimbursement_id: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ start_date: "desc" }, { created_at: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
request_no: true,
|
||||
purpose: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
estimated_amount: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTravelRequestForLinking(tx, linkedTravelRequestId) {
|
||||
return tx.travelRequest.findUnique({
|
||||
where: { id: linkedTravelRequestId },
|
||||
select: {
|
||||
id: true,
|
||||
request_no: true,
|
||||
purpose: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
estimated_amount: true,
|
||||
applicant_employee_id: true,
|
||||
status: true,
|
||||
segments: {
|
||||
orderBy: [{ sort_order: "asc" }, { created_at: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
segment_role: true,
|
||||
transport_type: true,
|
||||
from_city: true,
|
||||
to_city: true,
|
||||
},
|
||||
},
|
||||
linked_reimbursements: {
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
travel_orders: {
|
||||
where: {
|
||||
linked_reimbursement_id: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
linked_reimbursement_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function ensureNonNegativeAmount(totalAmount) {
|
||||
const asNumber = Number(totalAmount);
|
||||
if (!Number.isFinite(asNumber)) {
|
||||
@@ -895,12 +1225,6 @@ async function finalizeReimbursementSubmitSideEffects({
|
||||
}
|
||||
|
||||
async function reimbursementRoutes(fastify) {
|
||||
const createBodySchema = z.object({
|
||||
ownerEmployeeId: z.string().uuid().optional(),
|
||||
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements",
|
||||
{
|
||||
@@ -915,76 +1239,148 @@ async function reimbursementRoutes(fastify) {
|
||||
domain: "expense",
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
});
|
||||
const parsed = createBodySchema.safeParse(request.body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
const body = parseCreateBody(request.body);
|
||||
|
||||
const reimbursementDate =
|
||||
parsed.data.reimbursementDate ?? currentDateString(fastify.config.appTimezone);
|
||||
body.reimbursementDate ?? currentDateString(fastify.config.appTimezone);
|
||||
|
||||
const ownerEmployeeId = parsed.data.ownerEmployeeId ?? request.employee.id;
|
||||
const isProxyCreate = ownerEmployeeId !== request.employee.id;
|
||||
const ownerEmployeeId = body.ownerEmployeeId ?? request.employee.id;
|
||||
|
||||
const reimbursement = await fastify.prisma.$transaction(async (tx) => {
|
||||
let departmentId = request.employee.department_id;
|
||||
let delegationGrantId = null;
|
||||
const ownerContext = await resolveReimbursementOwnerContext(tx, fastify, request, ownerEmployeeId);
|
||||
|
||||
if (isProxyCreate) {
|
||||
const isAdministration = request.employee.roleCodes?.includes("administration");
|
||||
const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit");
|
||||
if (!isAdministration || !canProxySubmit) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
let linkedTravelRequestRecord = null;
|
||||
let generatedItemSeeds = [];
|
||||
let resolvedDescription = body.description ?? null;
|
||||
|
||||
const ownerEmployee = await tx.employee.findUnique({
|
||||
where: { id: ownerEmployeeId },
|
||||
select: { id: true, status: true, department_id: true },
|
||||
if (body.templateId) {
|
||||
const template = await tx.reimbursementTemplate.findFirst({
|
||||
where: {
|
||||
id: body.templateId,
|
||||
enabled: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
if (!ownerEmployee || ownerEmployee.status !== "active") {
|
||||
|
||||
if (!template) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { ownerEmployeeId: "invalid_owner" },
|
||||
details: { templateId: "invalid_template" },
|
||||
});
|
||||
}
|
||||
|
||||
const delegation = await findActiveDelegationForDelegate(tx, {
|
||||
principalEmployeeId: ownerEmployeeId,
|
||||
delegateEmployeeId: request.employee.id,
|
||||
allowedScopes: ["reimbursement_submit", "all"],
|
||||
now: new Date(),
|
||||
});
|
||||
if (!delegation) {
|
||||
const templateItems = normalizeTemplateSeedItems(template.items);
|
||||
const expenseTypeMap = await resolveEnabledExpenseTypesByIds(
|
||||
tx,
|
||||
Array.from(new Set(templateItems.map((item) => item.expenseTypeId))),
|
||||
);
|
||||
const invalidItem = templateItems.find((item) => !expenseTypeMap.has(item.expenseTypeId));
|
||||
if (invalidItem) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { delegation: "missing_active_grant" },
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { templateId: "contains_disabled_expense_type" },
|
||||
});
|
||||
}
|
||||
|
||||
delegationGrantId = delegation.id;
|
||||
departmentId = ownerEmployee.department_id;
|
||||
generatedItemSeeds = templateItems.map((item) => ({
|
||||
expense_type_id: item.expenseTypeId,
|
||||
expense_date: parseDateOnlyString(reimbursementDate),
|
||||
amount: item.defaultAmount ?? "0.00",
|
||||
description: item.description,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
}));
|
||||
}
|
||||
|
||||
if (body.linkedTravelRequestId) {
|
||||
linkedTravelRequestRecord = await loadTravelRequestForLinking(tx, body.linkedTravelRequestId);
|
||||
if (!linkedTravelRequestRecord) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { linkedTravelRequestId: "not_found" },
|
||||
});
|
||||
}
|
||||
|
||||
if (linkedTravelRequestRecord.status !== "approved") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { linkedTravelRequestId: "not_approved" },
|
||||
});
|
||||
}
|
||||
|
||||
if (linkedTravelRequestRecord.applicant_employee_id !== ownerContext.ownerEmployeeId) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { linkedTravelRequestId: "owner_mismatch" },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
linkedTravelRequestRecord.linked_reimbursements?.length ||
|
||||
linkedTravelRequestRecord.travel_orders?.length
|
||||
) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { linkedTravelRequestId: "already_linked" },
|
||||
});
|
||||
}
|
||||
|
||||
if (body.description == null) {
|
||||
resolvedDescription = `差旅报销 - ${linkedTravelRequestRecord.request_no ?? linkedTravelRequestRecord.id} ${linkedTravelRequestRecord.purpose}`;
|
||||
}
|
||||
|
||||
const expenseTypeMap = await resolveTravelExpenseTypeMap(tx);
|
||||
generatedItemSeeds = linkedTravelRequestRecord.segments.map((segment) => {
|
||||
const expenseTypeCode = TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES[segment.transport_type];
|
||||
const expenseType = expenseTypeMap.get(expenseTypeCode);
|
||||
if (!expenseType) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
linkedTravelRequestId: "missing_expense_type_mapping",
|
||||
transportType: segment.transport_type,
|
||||
expenseTypeCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
expense_type_id: expenseType.id,
|
||||
expense_date: parseDateOnlyString(reimbursementDate),
|
||||
amount: "0.00",
|
||||
description: buildTravelSegmentDescription(segment),
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const created = await tx.reimbursementForm.create({
|
||||
data: {
|
||||
submitter_id: request.employee.id,
|
||||
owner_id: ownerEmployeeId,
|
||||
department_id: departmentId,
|
||||
department_id: ownerContext.departmentId,
|
||||
reimbursement_date: parseDateOnlyString(reimbursementDate),
|
||||
description: parsed.data.description ?? null,
|
||||
description: resolvedDescription,
|
||||
linked_travel_request_id: linkedTravelRequestRecord?.id ?? null,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
@@ -997,10 +1393,54 @@ async function reimbursementRoutes(fastify) {
|
||||
form_number: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
linked_travel_request_id: true,
|
||||
travel_request: {
|
||||
select: {
|
||||
id: true,
|
||||
request_no: true,
|
||||
purpose: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
estimated_amount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (isProxyCreate) {
|
||||
if (generatedItemSeeds.length) {
|
||||
await tx.reimbursementItem.createMany({
|
||||
data: generatedItemSeeds.map((item) => ({
|
||||
reimbursement_id: created.id,
|
||||
...item,
|
||||
})),
|
||||
});
|
||||
created.total_amount = await recomputeTotalAmount(tx, created.id, request.employee.id);
|
||||
}
|
||||
|
||||
const createdItems = generatedItemSeeds.length
|
||||
? await tx.reimbursementItem.findMany({
|
||||
where: { reimbursement_id: created.id },
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
expense_type_id: true,
|
||||
expense_date: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
expense_type: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
category: true,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
if (ownerContext.isProxyCreate) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
@@ -1010,13 +1450,18 @@ async function reimbursementRoutes(fastify) {
|
||||
extra: {
|
||||
ownerEmployeeId,
|
||||
submitterEmployeeId: request.employee.id,
|
||||
delegationGrantId,
|
||||
delegationGrantId: ownerContext.delegationGrantId,
|
||||
...(body.templateId ? { templateId: body.templateId } : {}),
|
||||
...(linkedTravelRequestRecord ? { linkedTravelRequestId: linkedTravelRequestRecord.id } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
return {
|
||||
...created,
|
||||
items: createdItems,
|
||||
};
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
@@ -1030,6 +1475,9 @@ async function reimbursementRoutes(fastify) {
|
||||
projectId: reimbursement.project_id ?? null,
|
||||
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
||||
totalAmount: toMoneyString(reimbursement.total_amount),
|
||||
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
|
||||
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
|
||||
items: Array.isArray(reimbursement.items) ? reimbursement.items.map(mapCreatedReimbursementItem) : [],
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1372,6 +1820,38 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/reimbursements/linkable-travel-requests",
|
||||
{
|
||||
schema: { tags: ["Reimbursements"], summary: "List approved travel requests that can be linked to reimbursement" },
|
||||
preHandler: fastify.requirePermission("expense.create"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseLinkableTravelRequestsQuery(request.query);
|
||||
const ownerEmployeeId = query.ownerEmployeeId ?? request.employee.id;
|
||||
|
||||
const ownerContext = await resolveReimbursementOwnerContext(
|
||||
fastify.prisma,
|
||||
fastify,
|
||||
request,
|
||||
ownerEmployeeId,
|
||||
);
|
||||
const items = await loadLinkableTravelRequests(fastify.prisma, ownerContext.ownerEmployeeId);
|
||||
|
||||
return sendData(reply, request, {
|
||||
ownerEmployeeId: ownerContext.ownerEmployeeId,
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
requestNo: item.request_no ?? null,
|
||||
purpose: item.purpose,
|
||||
startDate: toDateOnlyString(item.start_date),
|
||||
endDate: toDateOnlyString(item.end_date),
|
||||
estimatedAmount: toMoneyString(item.estimated_amount ?? 0),
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/reimbursements/:id",
|
||||
{
|
||||
@@ -1394,7 +1874,18 @@ async function reimbursementRoutes(fastify) {
|
||||
status: true,
|
||||
description: true,
|
||||
project_id: true,
|
||||
linked_travel_request_id: true,
|
||||
project: { select: { id: true, project_no: true, name: true } },
|
||||
travel_request: {
|
||||
select: {
|
||||
id: true,
|
||||
request_no: true,
|
||||
purpose: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
estimated_amount: true,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: { id: true, employee_no: true, name: true },
|
||||
},
|
||||
@@ -1507,6 +1998,8 @@ async function reimbursementRoutes(fastify) {
|
||||
description: reimbursement.description,
|
||||
projectId: reimbursement.project_id ?? null,
|
||||
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
||||
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
|
||||
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
|
||||
owner: reimbursement.owner,
|
||||
submitter: reimbursement.submitter,
|
||||
items: reimbursement.items.map((item) => ({
|
||||
|
||||
在新工单中引用
屏蔽一个用户