418 行
11 KiB
JavaScript
418 行
11 KiB
JavaScript
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;
|