feat(G2-1,G2-2,G2-3): 扩展预算编号与工作流跨域桥接
这个提交包含在:
+213
-52
@@ -3,16 +3,26 @@ 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 BUDGET_CREATE_SCHEMA = z.object({
|
||||
code: z.string().trim().min(2).max(32),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
departmentId: z.string().uuid().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(),
|
||||
});
|
||||
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({
|
||||
@@ -25,11 +35,46 @@ const BUDGET_PATCH_SCHEMA = z
|
||||
|
||||
const BUDGET_LIST_QUERY_SCHEMA = z.object({
|
||||
status: z.enum(["active", "archived"]).optional(),
|
||||
departmentId: z.string().uuid().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`);
|
||||
}
|
||||
@@ -46,12 +91,37 @@ 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,
|
||||
@@ -66,19 +136,105 @@ function mapBudget(item) {
|
||||
}
|
||||
|
||||
function parseBudgetId(value) {
|
||||
const parsed = z.string().uuid().safeParse(value);
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { id: "invalid_uuid" },
|
||||
});
|
||||
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",
|
||||
@@ -88,12 +244,7 @@ async function budgetsRoutes(fastify) {
|
||||
async (request, reply) => {
|
||||
const parsed = BUDGET_LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
|
||||
const query = parsed.data;
|
||||
@@ -104,6 +255,8 @@ async function budgetsRoutes(fastify) {
|
||||
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([
|
||||
@@ -112,6 +265,7 @@ async function budgetsRoutes(fastify) {
|
||||
orderBy: [{ period_start: "desc" }, { created_at: "desc" }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: BUDGET_RELATION_SELECT,
|
||||
}),
|
||||
fastify.prisma.budgetPool.count({ where }),
|
||||
]);
|
||||
@@ -133,31 +287,36 @@ async function budgetsRoutes(fastify) {
|
||||
async (request, reply) => {
|
||||
const parsed = BUDGET_CREATE_SCHEMA.safeParse(request.body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
|
||||
const body = parsed.data;
|
||||
const start = toDate(body.periodStart);
|
||||
const end = toDate(body.periodEnd);
|
||||
if (start > end) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { periodEnd: "must_be_after_periodStart" },
|
||||
});
|
||||
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.toUpperCase(),
|
||||
name: body.name,
|
||||
department_id: body.departmentId ?? null,
|
||||
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,
|
||||
@@ -167,6 +326,7 @@ async function budgetsRoutes(fastify) {
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
include: BUDGET_RELATION_SELECT,
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
@@ -177,6 +337,10 @@ async function budgetsRoutes(fastify) {
|
||||
targetId: created.id,
|
||||
extra: {
|
||||
code: created.code,
|
||||
scopeType: scope.scopeType,
|
||||
departmentId: scope.departmentId,
|
||||
legalEntityId: scope.legalEntityId,
|
||||
costCenterId: scope.costCenterId,
|
||||
allocatedAmount: created.allocated_amount,
|
||||
},
|
||||
}),
|
||||
@@ -195,36 +359,30 @@ async function budgetsRoutes(fastify) {
|
||||
const budgetId = parseBudgetId(request.params?.id);
|
||||
const parsed = BUDGET_PATCH_SCHEMA.safeParse(request.body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
|
||||
if (!Object.keys(parsed.data).length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { body: "empty_patch" },
|
||||
});
|
||||
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 } : {}),
|
||||
...(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({
|
||||
@@ -250,6 +408,7 @@ async function budgetsRoutes(fastify) {
|
||||
},
|
||||
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 },
|
||||
@@ -258,6 +417,7 @@ async function budgetsRoutes(fastify) {
|
||||
archived_at: new Date(),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
include: BUDGET_RELATION_SELECT,
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
@@ -267,7 +427,8 @@ async function budgetsRoutes(fastify) {
|
||||
targetType: "budget_pool",
|
||||
targetId: budgetId,
|
||||
extra: {
|
||||
code: updated.code,
|
||||
code: current.code,
|
||||
scopeType: resolveBudgetScopeType(current),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户