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

1094 行
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",
"travel_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") ||
fastify.hasPermission(request.employee, "procurement.create") ||
fastify.hasPermission(request.employee, "procurement.view_own") ||
fastify.hasPermission(request.employee, "procurement.view_all") ||
fastify.hasPermission(request.employee, "procurement.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;