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

442 行
12 KiB
JavaScript

const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const {
resolveBudgetScope,
resolveBudgetScopeType,
} = require("../services/budgetPool");
const UUID_SCHEMA = z.string().uuid();
const BUDGET_CREATE_SCHEMA = z
.object({
code: z.string().trim().min(2).max(32),
name: z.string().trim().min(1).max(120),
departmentId: UUID_SCHEMA.nullable().optional(),
legalEntityId: UUID_SCHEMA.nullable().optional(),
costCenterId: UUID_SCHEMA.nullable().optional(),
periodStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
periodEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
allocatedAmount: z.string().regex(/^\d+(?:\.\d{1,2})?$/),
warningThresholdPercent: z.coerce.number().int().min(1).max(100).optional(),
})
.strict();
const BUDGET_PATCH_SCHEMA = z
.object({
name: z.string().trim().min(1).max(120).optional(),
allocatedAmount: z.string().regex(/^\d+(?:\.\d{1,2})?$/).optional(),
warningThresholdPercent: z.coerce.number().int().min(1).max(100).optional(),
status: z.enum(["active", "archived"]).optional(),
})
.strict();
const BUDGET_LIST_QUERY_SCHEMA = z.object({
status: z.enum(["active", "archived"]).optional(),
departmentId: UUID_SCHEMA.optional(),
legalEntityId: UUID_SCHEMA.optional(),
costCenterId: UUID_SCHEMA.optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
const BUDGET_RELATION_SELECT = {
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,
},
},
};
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function toDate(value) {
return new Date(`${value}T00:00:00.000Z`);
}
function toMoneyString(value) {
if (value == null) {
return "0.00";
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric.toFixed(2) : "0.00";
}
function mapBudget(item) {
const allocated = Number(item.allocated_amount ?? 0);
const used = Number(item.used_amount ?? 0);
const remaining = allocated - used;
const scopeType = resolveBudgetScopeType(item);
return {
id: item.id,
code: item.code,
name: item.name,
scopeType,
departmentId: item.department_id ?? null,
legalEntityId: item.legal_entity_id ?? null,
costCenterId: item.cost_center_id ?? null,
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,
status: item.status,
periodStart: item.period_start?.toISOString?.()?.slice(0, 10) ?? null,
periodEnd: item.period_end?.toISOString?.()?.slice(0, 10) ?? null,
allocatedAmount: toMoneyString(item.allocated_amount),
usedAmount: toMoneyString(item.used_amount),
remainingAmount: toMoneyString(remaining),
warningThresholdPercent: item.warning_threshold_percent,
archivedAt: item.archived_at?.toISOString?.() ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
function parseBudgetId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ id: "invalid_uuid" });
}
return parsed.data;
}
async function ensureDepartmentAvailable(tx, departmentId) {
if (!departmentId) {
return null;
}
const department = await tx.department.findUnique({
where: { id: departmentId },
select: { id: true, name: true, archived_at: true },
});
if (!department) {
throw buildValidationError({ departmentId: "not_found" });
}
if (department.archived_at) {
throw buildValidationError({ departmentId: "archived" }, 409);
}
return department;
}
async function ensureLegalEntityAvailable(tx, legalEntityId) {
if (!legalEntityId) {
return null;
}
const legalEntity = await tx.legalEntity.findUnique({
where: { id: legalEntityId },
select: { id: true, code: true, name: true, archived_at: true, enabled: true },
});
if (!legalEntity) {
throw buildValidationError({ legalEntityId: "not_found" });
}
if (legalEntity.archived_at || !legalEntity.enabled) {
throw buildValidationError({ legalEntityId: "disabled_or_archived" }, 409);
}
return legalEntity;
}
async function ensureCostCenterAvailable(tx, { costCenterId, legalEntityId }) {
if (!costCenterId) {
return null;
}
const costCenter = await tx.costCenter.findUnique({
where: { id: costCenterId },
select: {
id: true,
code: true,
name: true,
legal_entity_id: true,
archived_at: true,
enabled: true,
},
});
if (!costCenter) {
throw buildValidationError({ costCenterId: "not_found" });
}
if (costCenter.archived_at || !costCenter.enabled) {
throw buildValidationError({ costCenterId: "disabled_or_archived" }, 409);
}
if (legalEntityId && costCenter.legal_entity_id !== legalEntityId) {
throw buildValidationError({ costCenterId: "legalEntityId_mismatch" }, 409);
}
return costCenter;
}
async function fetchBudgetPoolOrThrow(tx, budgetId) {
const budget = await tx.budgetPool.findUnique({
where: { id: budgetId },
include: BUDGET_RELATION_SELECT,
});
if (!budget) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
return budget;
}
async function budgetsRoutes(fastify) {
fastify.get(
"/budgets",
{
preHandler: fastify.requirePermission("budget.view"),
},
async (request, reply) => {
const parsed = BUDGET_LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const query = parsed.data;
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where = {
...(query.status ? { status: query.status } : {}),
...(query.departmentId ? { department_id: query.departmentId } : {}),
...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}),
...(query.costCenterId ? { cost_center_id: query.costCenterId } : {}),
};
const [items, total] = await Promise.all([
fastify.prisma.budgetPool.findMany({
where,
orderBy: [{ period_start: "desc" }, { created_at: "desc" }],
skip,
take: pageSize,
include: BUDGET_RELATION_SELECT,
}),
fastify.prisma.budgetPool.count({ where }),
]);
return sendData(reply, request, {
page,
pageSize,
total,
items: items.map(mapBudget),
});
},
);
fastify.post(
"/budgets",
{
preHandler: fastify.requirePermission("budget.manage"),
},
async (request, reply) => {
const parsed = BUDGET_CREATE_SCHEMA.safeParse(request.body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const body = parsed.data;
const start = toDate(body.periodStart);
const end = toDate(body.periodEnd);
if (start > end) {
throw buildValidationError({ periodEnd: "must_be_after_periodStart" });
}
const scope = resolveBudgetScope({
departmentId: body.departmentId ?? null,
legalEntityId: body.legalEntityId ?? null,
costCenterId: body.costCenterId ?? null,
});
await ensureDepartmentAvailable(fastify.prisma, scope.departmentId);
await ensureLegalEntityAvailable(fastify.prisma, scope.legalEntityId);
await ensureCostCenterAvailable(fastify.prisma, {
costCenterId: scope.costCenterId,
legalEntityId: scope.legalEntityId,
});
const created = await fastify.prisma.budgetPool.create({
data: {
code: body.code.trim().toUpperCase(),
name: body.name.trim(),
department_id: scope.departmentId,
legal_entity_id: scope.legalEntityId,
cost_center_id: scope.costCenterId,
status: "active",
period_start: start,
period_end: end,
allocated_amount: body.allocatedAmount,
used_amount: "0.00",
warning_threshold_percent: body.warningThresholdPercent ?? 80,
created_by: request.employee.id,
updated_by: request.employee.id,
},
include: BUDGET_RELATION_SELECT,
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_CREATED",
targetType: "budget_pool",
targetId: created.id,
extra: {
code: created.code,
scopeType: scope.scopeType,
departmentId: scope.departmentId,
legalEntityId: scope.legalEntityId,
costCenterId: scope.costCenterId,
allocatedAmount: created.allocated_amount,
},
}),
});
return sendData(reply, request, mapBudget(created));
},
);
fastify.patch(
"/budgets/:id",
{
preHandler: fastify.requirePermission("budget.manage"),
},
async (request, reply) => {
const budgetId = parseBudgetId(request.params?.id);
const parsed = BUDGET_PATCH_SCHEMA.safeParse(request.body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
if (!Object.keys(parsed.data).length) {
throw buildValidationError({ body: "empty_patch" });
}
await fetchBudgetPoolOrThrow(fastify.prisma, budgetId);
const patch = parsed.data;
const updated = await fastify.prisma.budgetPool.update({
where: { id: budgetId },
data: {
...(patch.name != null ? { name: patch.name.trim() } : {}),
...(patch.allocatedAmount != null ? { allocated_amount: patch.allocatedAmount } : {}),
...(patch.warningThresholdPercent != null
? { warning_threshold_percent: patch.warningThresholdPercent }
: {}),
...(patch.status ? { status: patch.status } : {}),
...(patch.status === "archived" ? { archived_at: new Date() } : {}),
...(patch.status === "active" ? { archived_at: null } : {}),
updated_by: request.employee.id,
},
include: BUDGET_RELATION_SELECT,
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_UPDATED",
targetType: "budget_pool",
targetId: budgetId,
extra: {
patch,
},
}),
});
return sendData(reply, request, mapBudget(updated));
},
);
fastify.post(
"/budgets/:id/archive",
{
preHandler: fastify.requirePermission("budget.manage"),
},
async (request, reply) => {
const budgetId = parseBudgetId(request.params?.id);
const current = await fetchBudgetPoolOrThrow(fastify.prisma, budgetId);
const updated = await fastify.prisma.budgetPool.update({
where: { id: budgetId },
data: {
status: "archived",
archived_at: new Date(),
updated_by: request.employee.id,
},
include: BUDGET_RELATION_SELECT,
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_ARCHIVED",
targetType: "budget_pool",
targetId: budgetId,
extra: {
code: current.code,
scopeType: resolveBudgetScopeType(current),
},
}),
});
return sendData(reply, request, mapBudget(updated));
},
);
}
module.exports = budgetsRoutes;