R57 A1-A6:报销模板与差旅申请关联
这个提交包含在:
@@ -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;
|
||||
在新工单中引用
屏蔽一个用户