1089 行
34 KiB
JavaScript
1089 行
34 KiB
JavaScript
const { z } = require("zod");
|
|
|
|
const { AppError } = require("../utils/AppError");
|
|
const { buildAuditLogData } = require("../utils/audit");
|
|
const { sendData } = require("../utils/reply");
|
|
|
|
const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional();
|
|
const UUID_SCHEMA = z.string().uuid();
|
|
const DOCUMENT_CATEGORY_ENTITY_TYPES = [
|
|
"reimbursement",
|
|
"leave",
|
|
"purchase_request",
|
|
"asset_card",
|
|
"general",
|
|
];
|
|
const RESET_PERIOD_VALUES = ["none", "daily", "monthly", "yearly"];
|
|
|
|
const legalEntityListQuerySchema = z.object({
|
|
includeArchived: BOOLEAN_QUERY_SCHEMA,
|
|
enabled: BOOLEAN_QUERY_SCHEMA,
|
|
});
|
|
|
|
const costCenterListQuerySchema = z.object({
|
|
includeArchived: BOOLEAN_QUERY_SCHEMA,
|
|
enabled: BOOLEAN_QUERY_SCHEMA,
|
|
legalEntityId: UUID_SCHEMA.optional(),
|
|
});
|
|
|
|
const documentCategoryListQuerySchema = z.object({
|
|
includeArchived: BOOLEAN_QUERY_SCHEMA,
|
|
enabled: BOOLEAN_QUERY_SCHEMA,
|
|
entityType: z.enum(DOCUMENT_CATEGORY_ENTITY_TYPES).optional(),
|
|
});
|
|
|
|
const legalEntityCreateBodySchema = z
|
|
.object({
|
|
code: z.string().trim().min(2).max(32),
|
|
name: z.string().trim().min(1).max(120),
|
|
shortName: z.string().trim().max(120).nullable().optional(),
|
|
currencyCode: z.string().trim().min(3).max(8),
|
|
taxNo: z.string().trim().max(64).nullable().optional(),
|
|
enabled: z.boolean().optional(),
|
|
})
|
|
.strict();
|
|
|
|
const legalEntityPatchBodySchema = legalEntityCreateBodySchema.partial();
|
|
|
|
const costCenterCreateBodySchema = z
|
|
.object({
|
|
legalEntityId: UUID_SCHEMA,
|
|
code: z.string().trim().min(2).max(32),
|
|
name: z.string().trim().min(1).max(120),
|
|
parentId: UUID_SCHEMA.nullable().optional(),
|
|
ownerEmployeeId: UUID_SCHEMA.nullable().optional(),
|
|
enabled: z.boolean().optional(),
|
|
})
|
|
.strict();
|
|
|
|
const costCenterPatchBodySchema = costCenterCreateBodySchema.partial();
|
|
|
|
const documentCategoryCreateBodySchema = z
|
|
.object({
|
|
code: z.string().trim().min(2).max(64),
|
|
name: z.string().trim().min(1).max(120),
|
|
entityType: z.enum(DOCUMENT_CATEGORY_ENTITY_TYPES),
|
|
sequencePrefix: z.string().trim().min(1).max(20),
|
|
numberLength: z.number().int().min(2).max(8).optional(),
|
|
resetPeriod: z.enum(RESET_PERIOD_VALUES),
|
|
defaultWorkflowKey: z.string().trim().max(64).nullable().optional(),
|
|
enabled: z.boolean().optional(),
|
|
})
|
|
.strict();
|
|
|
|
const documentCategoryPatchBodySchema = documentCategoryCreateBodySchema.partial();
|
|
|
|
function buildValidationError(details, statusCode = 400) {
|
|
return new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode,
|
|
details,
|
|
});
|
|
}
|
|
|
|
function parseUuid(value) {
|
|
const parsed = UUID_SCHEMA.safeParse(value);
|
|
if (!parsed.success) {
|
|
throw buildValidationError({ id: "invalid_uuid" });
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
function parseBooleanQueryValue(rawValue) {
|
|
if (rawValue == null) {
|
|
return null;
|
|
}
|
|
return rawValue === "1" || rawValue === "true";
|
|
}
|
|
|
|
function parseListQuery(schema, query) {
|
|
const parsed = schema.safeParse(query ?? {});
|
|
if (!parsed.success) {
|
|
throw buildValidationError(parsed.error.flatten());
|
|
}
|
|
|
|
return {
|
|
...parsed.data,
|
|
includeArchived: parseBooleanQueryValue(parsed.data.includeArchived) ?? false,
|
|
enabled: parseBooleanQueryValue(parsed.data.enabled),
|
|
};
|
|
}
|
|
|
|
function parseCreateBody(schema, body) {
|
|
const parsed = schema.safeParse(body ?? {});
|
|
if (!parsed.success) {
|
|
throw buildValidationError(parsed.error.flatten());
|
|
}
|
|
|
|
return parsed.data;
|
|
}
|
|
|
|
function parsePatchBody(schema, body) {
|
|
const parsed = schema.safeParse(body ?? {});
|
|
if (!parsed.success) {
|
|
throw buildValidationError(parsed.error.flatten());
|
|
}
|
|
|
|
if (!Object.keys(parsed.data).length) {
|
|
throw buildValidationError({ body: "empty" });
|
|
}
|
|
|
|
return parsed.data;
|
|
}
|
|
|
|
function isUniqueConstraint(error, targetField) {
|
|
if (!error || error.code !== "P2002") {
|
|
return false;
|
|
}
|
|
|
|
if (!targetField) {
|
|
return true;
|
|
}
|
|
|
|
const target = error.meta?.target;
|
|
if (Array.isArray(target)) {
|
|
return target.includes(targetField);
|
|
}
|
|
|
|
return typeof target === "string" && target.includes(targetField);
|
|
}
|
|
|
|
function mapLegalEntity(record) {
|
|
return {
|
|
id: record.id,
|
|
code: record.code,
|
|
name: record.name,
|
|
shortName: record.short_name ?? null,
|
|
currencyCode: record.currency_code,
|
|
taxNo: record.tax_no ?? null,
|
|
enabled: Boolean(record.enabled),
|
|
archivedAt: record.archived_at?.toISOString?.() ?? null,
|
|
createdAt: record.created_at?.toISOString?.() ?? null,
|
|
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
|
};
|
|
}
|
|
|
|
function mapCostCenter(record) {
|
|
return {
|
|
id: record.id,
|
|
legalEntityId: record.legal_entity_id,
|
|
code: record.code,
|
|
name: record.name,
|
|
parentId: record.parent_id ?? null,
|
|
ownerEmployeeId: record.owner_employee_id ?? null,
|
|
enabled: Boolean(record.enabled),
|
|
archivedAt: record.archived_at?.toISOString?.() ?? null,
|
|
createdAt: record.created_at?.toISOString?.() ?? null,
|
|
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
|
legalEntity: record.legal_entity
|
|
? {
|
|
id: record.legal_entity.id,
|
|
code: record.legal_entity.code,
|
|
name: record.legal_entity.name,
|
|
}
|
|
: null,
|
|
parent: record.parent
|
|
? {
|
|
id: record.parent.id,
|
|
name: record.parent.name,
|
|
}
|
|
: null,
|
|
owner: record.owner_employee
|
|
? {
|
|
id: record.owner_employee.id,
|
|
employeeNo: record.owner_employee.employee_no,
|
|
name: record.owner_employee.name,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function mapDocumentCategory(record) {
|
|
return {
|
|
id: record.id,
|
|
code: record.code,
|
|
name: record.name,
|
|
entityType: record.entity_type,
|
|
sequencePrefix: record.sequence_prefix,
|
|
numberLength: record.number_length,
|
|
resetPeriod: record.reset_period,
|
|
defaultWorkflowKey: record.default_workflow_key ?? null,
|
|
enabled: Boolean(record.enabled),
|
|
archivedAt: record.archived_at?.toISOString?.() ?? null,
|
|
createdAt: record.created_at?.toISOString?.() ?? null,
|
|
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
|
};
|
|
}
|
|
|
|
function normalizeLegalEntityPayload(body) {
|
|
return {
|
|
code: typeof body.code === "string" ? body.code.trim().toUpperCase() : undefined,
|
|
name: typeof body.name === "string" ? body.name.trim() : undefined,
|
|
shortName:
|
|
body.shortName === undefined ? undefined : body.shortName?.trim() ? body.shortName.trim() : null,
|
|
currencyCode:
|
|
typeof body.currencyCode === "string" ? body.currencyCode.trim().toUpperCase() : undefined,
|
|
taxNo: body.taxNo === undefined ? undefined : body.taxNo?.trim() ? body.taxNo.trim() : null,
|
|
enabled: body.enabled,
|
|
};
|
|
}
|
|
|
|
function normalizeCostCenterPayload(body) {
|
|
return {
|
|
legalEntityId: body.legalEntityId,
|
|
code: typeof body.code === "string" ? body.code.trim().toUpperCase() : undefined,
|
|
name: typeof body.name === "string" ? body.name.trim() : undefined,
|
|
parentId: Object.prototype.hasOwnProperty.call(body, "parentId") ? body.parentId ?? null : undefined,
|
|
ownerEmployeeId: Object.prototype.hasOwnProperty.call(body, "ownerEmployeeId")
|
|
? body.ownerEmployeeId ?? null
|
|
: undefined,
|
|
enabled: body.enabled,
|
|
};
|
|
}
|
|
|
|
function normalizeDocumentCategoryPayload(body) {
|
|
return {
|
|
code: typeof body.code === "string" ? body.code.trim().toLowerCase() : undefined,
|
|
name: typeof body.name === "string" ? body.name.trim() : undefined,
|
|
entityType: body.entityType,
|
|
sequencePrefix: typeof body.sequencePrefix === "string" ? body.sequencePrefix.trim().toUpperCase() : undefined,
|
|
numberLength: body.numberLength,
|
|
resetPeriod: body.resetPeriod,
|
|
defaultWorkflowKey:
|
|
body.defaultWorkflowKey === undefined
|
|
? undefined
|
|
: body.defaultWorkflowKey?.trim()
|
|
? body.defaultWorkflowKey.trim()
|
|
: null,
|
|
enabled: body.enabled,
|
|
};
|
|
}
|
|
|
|
function detectParentCycle(items, selfId, parentId) {
|
|
const parentMap = new Map(items.map((item) => [item.id, item.parent_id]));
|
|
let cursor = parentId;
|
|
const visited = new Set();
|
|
|
|
while (cursor) {
|
|
if (visited.has(cursor)) {
|
|
break;
|
|
}
|
|
visited.add(cursor);
|
|
|
|
if (cursor === selfId) {
|
|
return true;
|
|
}
|
|
|
|
cursor = parentMap.get(cursor) ?? null;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function ensureLegalEntityAvailable(tx, legalEntityId) {
|
|
const legalEntity = await tx.legalEntity.findUnique({
|
|
where: { id: legalEntityId },
|
|
select: { id: true, code: true, name: true, archived_at: true },
|
|
});
|
|
|
|
if (!legalEntity) {
|
|
throw buildValidationError({ legalEntityId: "not_found" });
|
|
}
|
|
|
|
if (legalEntity.archived_at) {
|
|
throw buildValidationError({ legalEntityId: "archived" }, 409);
|
|
}
|
|
|
|
return legalEntity;
|
|
}
|
|
|
|
async function ensureOwnerEmployeeAvailable(tx, ownerEmployeeId) {
|
|
if (!ownerEmployeeId) {
|
|
return null;
|
|
}
|
|
|
|
const employee = await tx.employee.findUnique({
|
|
where: { id: ownerEmployeeId },
|
|
select: { id: true, name: true, employee_no: true, status: true },
|
|
});
|
|
|
|
if (!employee || employee.status !== "active") {
|
|
throw buildValidationError({ ownerEmployeeId: "not_found" });
|
|
}
|
|
|
|
return employee;
|
|
}
|
|
|
|
async function ensureCostCenterParent(tx, { parentId, legalEntityId, selfId }) {
|
|
if (!parentId) {
|
|
return null;
|
|
}
|
|
|
|
if (selfId && parentId === selfId) {
|
|
throw buildValidationError({ parentId: "self_not_allowed" }, 409);
|
|
}
|
|
|
|
const parent = await tx.costCenter.findUnique({
|
|
where: { id: parentId },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
legal_entity_id: true,
|
|
archived_at: true,
|
|
},
|
|
});
|
|
|
|
if (!parent) {
|
|
throw buildValidationError({ parentId: "not_found" });
|
|
}
|
|
|
|
if (parent.archived_at) {
|
|
throw buildValidationError({ parentId: "archived" }, 409);
|
|
}
|
|
|
|
if (parent.legal_entity_id !== legalEntityId) {
|
|
throw buildValidationError({ parentId: "cross_legal_entity_not_allowed" }, 409);
|
|
}
|
|
|
|
if (selfId) {
|
|
const items = await tx.costCenter.findMany({
|
|
select: { id: true, parent_id: true },
|
|
});
|
|
|
|
if (detectParentCycle(items, selfId, parentId)) {
|
|
throw buildValidationError({ parentId: "cycle_not_allowed" }, 409);
|
|
}
|
|
}
|
|
|
|
return parent;
|
|
}
|
|
|
|
function buildMasterDataViewPreHandler(fastify) {
|
|
return async (request) => {
|
|
await fastify.authenticate(request);
|
|
const canView =
|
|
fastify.hasPermission(request.employee, "masterdata.view") ||
|
|
fastify.hasPermission(request.employee, "masterdata.manage");
|
|
|
|
if (!canView) {
|
|
throw new AppError({
|
|
code: "PERM_001",
|
|
message: "无权限",
|
|
statusCode: 403,
|
|
details: { permission: "masterdata.view" },
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
async function masterDataRoutes(fastify) {
|
|
const requireMasterDataView = buildMasterDataViewPreHandler(fastify);
|
|
|
|
fastify.get(
|
|
"/master-data/legal-entities",
|
|
{
|
|
preHandler: requireMasterDataView,
|
|
schema: {
|
|
tags: ["master-data"],
|
|
summary: "List legal entities",
|
|
security: [{ bearerAuth: [] }],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const query = parseListQuery(legalEntityListQuerySchema, request.query);
|
|
const items = await fastify.prisma.legalEntity.findMany({
|
|
where: {
|
|
...(query.includeArchived ? {} : { archived_at: null }),
|
|
...(query.enabled == null ? {} : { enabled: query.enabled }),
|
|
},
|
|
orderBy: [{ code: "asc" }],
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
includeArchived: query.includeArchived,
|
|
items: items.map(mapLegalEntity),
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
"/master-data/legal-entities",
|
|
{
|
|
preHandler: fastify.requirePermission("masterdata.manage"),
|
|
schema: {
|
|
tags: ["master-data"],
|
|
summary: "Create legal entity",
|
|
security: [{ bearerAuth: [] }],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const body = normalizeLegalEntityPayload(parseCreateBody(legalEntityCreateBodySchema, request.body));
|
|
|
|
try {
|
|
const legalEntity = await fastify.prisma.legalEntity.create({
|
|
data: {
|
|
code: body.code,
|
|
name: body.name,
|
|
short_name: body.shortName ?? null,
|
|
currency_code: body.currencyCode,
|
|
tax_no: body.taxNo ?? null,
|
|
enabled: body.enabled ?? true,
|
|
created_by: request.employee.id,
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await fastify.prisma.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "LEGAL_ENTITY_CREATE",
|
|
targetType: "legal_entity",
|
|
targetId: legalEntity.id,
|
|
extra: { code: legalEntity.code, name: legalEntity.name },
|
|
}),
|
|
});
|
|
|
|
return sendData(reply, request, { legalEntity: mapLegalEntity(legalEntity) });
|
|
} catch (error) {
|
|
if (isUniqueConstraint(error, "code")) {
|
|
throw buildValidationError({ code: "duplicate" }, 409);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
);
|
|
|
|
fastify.patch(
|
|
"/master-data/legal-entities/:id",
|
|
{
|
|
preHandler: fastify.requirePermission("masterdata.manage"),
|
|
schema: {
|
|
tags: ["master-data"],
|
|
summary: "Patch legal entity",
|
|
security: [{ bearerAuth: [] }],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const legalEntityId = parseUuid(request.params?.id);
|
|
const body = normalizeLegalEntityPayload(parsePatchBody(legalEntityPatchBodySchema, request.body));
|
|
|
|
const current = await fastify.prisma.legalEntity.findUnique({
|
|
where: { id: legalEntityId },
|
|
});
|
|
|
|
if (!current) {
|
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
}
|
|
|
|
if (current.archived_at) {
|
|
throw buildValidationError({ status: "archived" }, 409);
|
|
}
|
|
|
|
try {
|
|
const legalEntity = await fastify.prisma.legalEntity.update({
|
|
where: { id: legalEntityId },
|
|
data: {
|
|
...(body.code !== undefined ? { code: body.code } : {}),
|
|
...(body.name !== undefined ? { name: body.name } : {}),
|
|
...(body.shortName !== undefined ? { short_name: body.shortName } : {}),
|
|
...(body.currencyCode !== undefined ? { currency_code: body.currencyCode } : {}),
|
|
...(body.taxNo !== undefined ? { tax_no: body.taxNo } : {}),
|
|
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await fastify.prisma.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "LEGAL_ENTITY_UPDATE",
|
|
targetType: "legal_entity",
|
|
targetId: legalEntity.id,
|
|
extra: { code: legalEntity.code, name: legalEntity.name },
|
|
}),
|
|
});
|
|
|
|
return sendData(reply, request, { legalEntity: mapLegalEntity(legalEntity) });
|
|
} catch (error) {
|
|
if (isUniqueConstraint(error, "code")) {
|
|
throw buildValidationError({ code: "duplicate" }, 409);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
"/master-data/legal-entities/:id/archive",
|
|
{
|
|
preHandler: fastify.requirePermission("masterdata.manage"),
|
|
schema: {
|
|
tags: ["master-data"],
|
|
summary: "Archive legal entity",
|
|
security: [{ bearerAuth: [] }],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const legalEntityId = parseUuid(request.params?.id);
|
|
const now = new Date();
|
|
|
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
|
const current = await tx.legalEntity.findUnique({ where: { id: legalEntityId } });
|
|
if (!current) {
|
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
}
|
|
|
|
if (current.archived_at) {
|
|
return { legalEntity: current, outcome: "already_archived" };
|
|
}
|
|
|
|
const activeCostCenterCount = await tx.costCenter.count({
|
|
where: { legal_entity_id: legalEntityId, archived_at: null },
|
|
});
|
|
if (activeCostCenterCount > 0) {
|
|
throw buildValidationError({ legalEntity: "has_cost_centers" }, 409);
|
|
}
|
|
|
|
const legalEntity = await tx.legalEntity.update({
|
|
where: { id: legalEntityId },
|
|
data: {
|
|
enabled: false,
|
|
archived_at: now,
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "LEGAL_ENTITY_ARCHIVE",
|
|
targetType: "legal_entity",
|
|
targetId: legalEntity.id,
|
|
extra: { code: legalEntity.code, name: legalEntity.name },
|
|
}),
|
|
});
|
|
|
|
return { legalEntity, outcome: "archived" };
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
legalEntity: mapLegalEntity(result.legalEntity),
|
|
outcome: result.outcome,
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.get(
|
|
"/master-data/cost-centers",
|
|
{
|
|
preHandler: requireMasterDataView,
|
|
schema: {
|
|
tags: ["master-data"],
|
|
summary: "List cost centers",
|
|
security: [{ bearerAuth: [] }],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const query = parseListQuery(costCenterListQuerySchema, request.query);
|
|
const items = await fastify.prisma.costCenter.findMany({
|
|
where: {
|
|
...(query.includeArchived ? {} : { archived_at: null }),
|
|
...(query.enabled == null ? {} : { enabled: query.enabled }),
|
|
...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}),
|
|
},
|
|
orderBy: [{ legal_entity_id: "asc" }, { code: "asc" }],
|
|
select: {
|
|
id: true,
|
|
legal_entity_id: true,
|
|
code: true,
|
|
name: true,
|
|
parent_id: true,
|
|
owner_employee_id: true,
|
|
enabled: true,
|
|
archived_at: true,
|
|
created_at: true,
|
|
updated_at: true,
|
|
legal_entity: { select: { id: true, code: true, name: true } },
|
|
parent: { select: { id: true, name: true } },
|
|
owner_employee: { select: { id: true, employee_no: true, name: true } },
|
|
},
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
includeArchived: query.includeArchived,
|
|
items: items.map(mapCostCenter),
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
"/master-data/cost-centers",
|
|
{
|
|
preHandler: fastify.requirePermission("masterdata.manage"),
|
|
schema: {
|
|
tags: ["master-data"],
|
|
summary: "Create cost center",
|
|
security: [{ bearerAuth: [] }],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const body = normalizeCostCenterPayload(parseCreateBody(costCenterCreateBodySchema, request.body));
|
|
|
|
try {
|
|
const costCenter = await fastify.prisma.$transaction(async (tx) => {
|
|
await ensureLegalEntityAvailable(tx, body.legalEntityId);
|
|
await ensureOwnerEmployeeAvailable(tx, body.ownerEmployeeId ?? null);
|
|
await ensureCostCenterParent(tx, {
|
|
parentId: body.parentId ?? null,
|
|
legalEntityId: body.legalEntityId,
|
|
selfId: null,
|
|
});
|
|
|
|
const created = await tx.costCenter.create({
|
|
data: {
|
|
legal_entity_id: body.legalEntityId,
|
|
code: body.code,
|
|
name: body.name,
|
|
parent_id: body.parentId ?? null,
|
|
owner_employee_id: body.ownerEmployeeId ?? null,
|
|
enabled: body.enabled ?? true,
|
|
created_by: request.employee.id,
|
|
updated_by: request.employee.id,
|
|
},
|
|
select: {
|
|
id: true,
|
|
legal_entity_id: true,
|
|
code: true,
|
|
name: true,
|
|
parent_id: true,
|
|
owner_employee_id: true,
|
|
enabled: true,
|
|
archived_at: true,
|
|
created_at: true,
|
|
updated_at: true,
|
|
legal_entity: { select: { id: true, code: true, name: true } },
|
|
parent: { select: { id: true, name: true } },
|
|
owner_employee: { select: { id: true, employee_no: true, name: true } },
|
|
},
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "COST_CENTER_CREATE",
|
|
targetType: "cost_center",
|
|
targetId: created.id,
|
|
extra: { code: created.code, name: created.name, legalEntityId: created.legal_entity_id },
|
|
}),
|
|
});
|
|
|
|
return created;
|
|
});
|
|
|
|
return sendData(reply, request, { costCenter: mapCostCenter(costCenter) });
|
|
} catch (error) {
|
|
if (isUniqueConstraint(error)) {
|
|
throw buildValidationError({ code: "duplicate" }, 409);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
);
|
|
|
|
fastify.patch(
|
|
"/master-data/cost-centers/:id",
|
|
{
|
|
preHandler: fastify.requirePermission("masterdata.manage"),
|
|
schema: {
|
|
tags: ["master-data"],
|
|
summary: "Patch cost center",
|
|
security: [{ bearerAuth: [] }],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const costCenterId = parseUuid(request.params?.id);
|
|
const body = normalizeCostCenterPayload(parsePatchBody(costCenterPatchBodySchema, request.body));
|
|
|
|
try {
|
|
const costCenter = await fastify.prisma.$transaction(async (tx) => {
|
|
const current = await tx.costCenter.findUnique({
|
|
where: { id: costCenterId },
|
|
select: {
|
|
id: true,
|
|
legal_entity_id: true,
|
|
code: true,
|
|
name: true,
|
|
parent_id: true,
|
|
owner_employee_id: true,
|
|
enabled: true,
|
|
archived_at: true,
|
|
},
|
|
});
|
|
|
|
if (!current) {
|
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
}
|
|
|
|
if (current.archived_at) {
|
|
throw buildValidationError({ status: "archived" }, 409);
|
|
}
|
|
|
|
const nextLegalEntityId = body.legalEntityId ?? current.legal_entity_id;
|
|
const nextParentId = body.parentId !== undefined ? body.parentId : current.parent_id;
|
|
const nextOwnerEmployeeId =
|
|
body.ownerEmployeeId !== undefined ? body.ownerEmployeeId : current.owner_employee_id;
|
|
|
|
await ensureLegalEntityAvailable(tx, nextLegalEntityId);
|
|
await ensureOwnerEmployeeAvailable(tx, nextOwnerEmployeeId ?? null);
|
|
await ensureCostCenterParent(tx, {
|
|
parentId: nextParentId ?? null,
|
|
legalEntityId: nextLegalEntityId,
|
|
selfId: costCenterId,
|
|
});
|
|
|
|
const updated = await tx.costCenter.update({
|
|
where: { id: costCenterId },
|
|
data: {
|
|
...(body.legalEntityId !== undefined ? { legal_entity_id: body.legalEntityId } : {}),
|
|
...(body.code !== undefined ? { code: body.code } : {}),
|
|
...(body.name !== undefined ? { name: body.name } : {}),
|
|
...(body.parentId !== undefined ? { parent_id: body.parentId } : {}),
|
|
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}),
|
|
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
|
updated_by: request.employee.id,
|
|
},
|
|
select: {
|
|
id: true,
|
|
legal_entity_id: true,
|
|
code: true,
|
|
name: true,
|
|
parent_id: true,
|
|
owner_employee_id: true,
|
|
enabled: true,
|
|
archived_at: true,
|
|
created_at: true,
|
|
updated_at: true,
|
|
legal_entity: { select: { id: true, code: true, name: true } },
|
|
parent: { select: { id: true, name: true } },
|
|
owner_employee: { select: { id: true, employee_no: true, name: true } },
|
|
},
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "COST_CENTER_UPDATE",
|
|
targetType: "cost_center",
|
|
targetId: updated.id,
|
|
extra: { code: updated.code, name: updated.name, legalEntityId: updated.legal_entity_id },
|
|
}),
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
return sendData(reply, request, { costCenter: mapCostCenter(costCenter) });
|
|
} catch (error) {
|
|
if (isUniqueConstraint(error)) {
|
|
throw buildValidationError({ code: "duplicate" }, 409);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
"/master-data/cost-centers/:id/archive",
|
|
{
|
|
preHandler: fastify.requirePermission("masterdata.manage"),
|
|
schema: {
|
|
tags: ["master-data"],
|
|
summary: "Archive cost center",
|
|
security: [{ bearerAuth: [] }],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const costCenterId = parseUuid(request.params?.id);
|
|
const now = new Date();
|
|
|
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
|
const current = await tx.costCenter.findUnique({
|
|
where: { id: costCenterId },
|
|
select: {
|
|
id: true,
|
|
legal_entity_id: true,
|
|
code: true,
|
|
name: true,
|
|
parent_id: true,
|
|
owner_employee_id: true,
|
|
enabled: true,
|
|
archived_at: true,
|
|
created_at: true,
|
|
updated_at: true,
|
|
legal_entity: { select: { id: true, code: true, name: true } },
|
|
parent: { select: { id: true, name: true } },
|
|
owner_employee: { select: { id: true, employee_no: true, name: true } },
|
|
},
|
|
});
|
|
|
|
if (!current) {
|
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
}
|
|
|
|
if (current.archived_at) {
|
|
return { costCenter: current, outcome: "already_archived" };
|
|
}
|
|
|
|
const childCount = await tx.costCenter.count({
|
|
where: { parent_id: costCenterId, archived_at: null },
|
|
});
|
|
if (childCount > 0) {
|
|
throw buildValidationError({ costCenter: "has_children" }, 409);
|
|
}
|
|
|
|
const costCenter = await tx.costCenter.update({
|
|
where: { id: costCenterId },
|
|
data: {
|
|
enabled: false,
|
|
archived_at: now,
|
|
updated_by: request.employee.id,
|
|
},
|
|
select: {
|
|
id: true,
|
|
legal_entity_id: true,
|
|
code: true,
|
|
name: true,
|
|
parent_id: true,
|
|
owner_employee_id: true,
|
|
enabled: true,
|
|
archived_at: true,
|
|
created_at: true,
|
|
updated_at: true,
|
|
legal_entity: { select: { id: true, code: true, name: true } },
|
|
parent: { select: { id: true, name: true } },
|
|
owner_employee: { select: { id: true, employee_no: true, name: true } },
|
|
},
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "COST_CENTER_ARCHIVE",
|
|
targetType: "cost_center",
|
|
targetId: costCenter.id,
|
|
extra: { code: costCenter.code, name: costCenter.name },
|
|
}),
|
|
});
|
|
|
|
return { costCenter, outcome: "archived" };
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
costCenter: mapCostCenter(result.costCenter),
|
|
outcome: result.outcome,
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.get(
|
|
"/master-data/document-categories",
|
|
{
|
|
preHandler: requireMasterDataView,
|
|
schema: {
|
|
tags: ["master-data"],
|
|
summary: "List document categories",
|
|
security: [{ bearerAuth: [] }],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const query = parseListQuery(documentCategoryListQuerySchema, request.query);
|
|
const items = await fastify.prisma.documentCategory.findMany({
|
|
where: {
|
|
...(query.includeArchived ? {} : { archived_at: null }),
|
|
...(query.enabled == null ? {} : { enabled: query.enabled }),
|
|
...(query.entityType ? { entity_type: query.entityType } : {}),
|
|
},
|
|
orderBy: [{ entity_type: "asc" }, { code: "asc" }],
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
includeArchived: query.includeArchived,
|
|
items: items.map(mapDocumentCategory),
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
"/master-data/document-categories",
|
|
{
|
|
preHandler: fastify.requirePermission("masterdata.manage"),
|
|
schema: {
|
|
tags: ["master-data"],
|
|
summary: "Create document category",
|
|
security: [{ bearerAuth: [] }],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const body = normalizeDocumentCategoryPayload(
|
|
parseCreateBody(documentCategoryCreateBodySchema, request.body),
|
|
);
|
|
|
|
try {
|
|
const documentCategory = await fastify.prisma.documentCategory.create({
|
|
data: {
|
|
code: body.code,
|
|
name: body.name,
|
|
entity_type: body.entityType,
|
|
sequence_prefix: body.sequencePrefix,
|
|
number_length: body.numberLength ?? 4,
|
|
reset_period: body.resetPeriod,
|
|
default_workflow_key: body.defaultWorkflowKey ?? null,
|
|
enabled: body.enabled ?? true,
|
|
created_by: request.employee.id,
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await fastify.prisma.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "DOCUMENT_CATEGORY_CREATE",
|
|
targetType: "document_category",
|
|
targetId: documentCategory.id,
|
|
extra: { code: documentCategory.code, entityType: documentCategory.entity_type },
|
|
}),
|
|
});
|
|
|
|
return sendData(reply, request, { documentCategory: mapDocumentCategory(documentCategory) });
|
|
} catch (error) {
|
|
if (isUniqueConstraint(error, "code")) {
|
|
throw buildValidationError({ code: "duplicate" }, 409);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
);
|
|
|
|
fastify.patch(
|
|
"/master-data/document-categories/:id",
|
|
{
|
|
preHandler: fastify.requirePermission("masterdata.manage"),
|
|
schema: {
|
|
tags: ["master-data"],
|
|
summary: "Patch document category",
|
|
security: [{ bearerAuth: [] }],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const documentCategoryId = parseUuid(request.params?.id);
|
|
const body = normalizeDocumentCategoryPayload(
|
|
parsePatchBody(documentCategoryPatchBodySchema, request.body),
|
|
);
|
|
|
|
const current = await fastify.prisma.documentCategory.findUnique({
|
|
where: { id: documentCategoryId },
|
|
});
|
|
|
|
if (!current) {
|
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
}
|
|
|
|
if (current.archived_at) {
|
|
throw buildValidationError({ status: "archived" }, 409);
|
|
}
|
|
|
|
try {
|
|
const documentCategory = await fastify.prisma.documentCategory.update({
|
|
where: { id: documentCategoryId },
|
|
data: {
|
|
...(body.code !== undefined ? { code: body.code } : {}),
|
|
...(body.name !== undefined ? { name: body.name } : {}),
|
|
...(body.entityType !== undefined ? { entity_type: body.entityType } : {}),
|
|
...(body.sequencePrefix !== undefined ? { sequence_prefix: body.sequencePrefix } : {}),
|
|
...(body.numberLength !== undefined ? { number_length: body.numberLength } : {}),
|
|
...(body.resetPeriod !== undefined ? { reset_period: body.resetPeriod } : {}),
|
|
...(body.defaultWorkflowKey !== undefined
|
|
? { default_workflow_key: body.defaultWorkflowKey }
|
|
: {}),
|
|
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await fastify.prisma.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "DOCUMENT_CATEGORY_UPDATE",
|
|
targetType: "document_category",
|
|
targetId: documentCategory.id,
|
|
extra: { code: documentCategory.code, entityType: documentCategory.entity_type },
|
|
}),
|
|
});
|
|
|
|
return sendData(reply, request, { documentCategory: mapDocumentCategory(documentCategory) });
|
|
} catch (error) {
|
|
if (isUniqueConstraint(error, "code")) {
|
|
throw buildValidationError({ code: "duplicate" }, 409);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
"/master-data/document-categories/:id/archive",
|
|
{
|
|
preHandler: fastify.requirePermission("masterdata.manage"),
|
|
schema: {
|
|
tags: ["master-data"],
|
|
summary: "Archive document category",
|
|
security: [{ bearerAuth: [] }],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const documentCategoryId = parseUuid(request.params?.id);
|
|
const now = new Date();
|
|
|
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
|
const current = await tx.documentCategory.findUnique({ where: { id: documentCategoryId } });
|
|
if (!current) {
|
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
}
|
|
|
|
if (current.archived_at) {
|
|
return { documentCategory: current, outcome: "already_archived" };
|
|
}
|
|
|
|
const documentCategory = await tx.documentCategory.update({
|
|
where: { id: documentCategoryId },
|
|
data: {
|
|
enabled: false,
|
|
archived_at: now,
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "DOCUMENT_CATEGORY_ARCHIVE",
|
|
targetType: "document_category",
|
|
targetId: documentCategory.id,
|
|
extra: { code: documentCategory.code, entityType: documentCategory.entity_type },
|
|
}),
|
|
});
|
|
|
|
return { documentCategory, outcome: "archived" };
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
documentCategory: mapDocumentCategory(result.documentCategory),
|
|
outcome: result.outcome,
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
module.exports = masterDataRoutes;
|