文件
hyb-oa/server/src/routes/departments.js
T
wangsli f755ff0dda feat(server): P5 基础能力补齐(组织/报销/代办/认证安全)
- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40)

- 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5)

- 行政代提交/代上传后端校验与审计(F7/F8)

- 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
2026-02-09 16:20:31 +08:00

485 行
13 KiB
JavaScript

const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const UUID_SCHEMA = z.string().uuid();
const LIST_QUERY_SCHEMA = z.object({
includeArchived: z.enum(["0", "1", "true", "false"]).optional(),
});
const CREATE_BODY_SCHEMA = z.object({
name: z.string().trim().min(1).max(100),
parentId: z.string().uuid().nullable().optional(),
});
const UPDATE_BODY_SCHEMA = z.object({
name: z.string().trim().min(1).max(100).optional(),
parentId: z.string().uuid().nullable().optional(),
});
function parseDepartmentId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
return parsed.data;
}
function parseListQuery(query) {
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const raw = parsed.data.includeArchived ?? "0";
const includeArchived = raw === "1" || raw === "true";
return { includeArchived };
}
function parseCreateBody(body) {
const parsed = CREATE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
name: parsed.data.name.trim(),
parentId: parsed.data.parentId ?? null,
};
}
function parseUpdateBody(body) {
const parsed = UPDATE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const hasUpdate = typeof parsed.data.name === "string" || "parentId" in parsed.data;
if (!hasUpdate) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { body: "empty" },
});
}
return {
...(typeof parsed.data.name === "string" ? { name: parsed.data.name.trim() } : {}),
...(Object.prototype.hasOwnProperty.call(parsed.data, "parentId")
? { parentId: parsed.data.parentId ?? null }
: {}),
};
}
function mapDepartmentItem(record) {
return {
id: record.id,
name: record.name,
parentId: record.parent_id,
archivedAt: record.archived_at ? record.archived_at.toISOString() : null,
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
function buildDepartmentTree(items) {
const nodesById = new Map();
for (const record of items) {
nodesById.set(record.id, { ...mapDepartmentItem(record), children: [] });
}
const roots = [];
for (const record of items) {
const node = nodesById.get(record.id);
if (!node) continue;
const parentId = record.parent_id;
if (parentId && nodesById.has(parentId)) {
nodesById.get(parentId).children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
function detectParentCycle(departments, departmentId, parentId) {
const parentMap = new Map(departments.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 === departmentId) {
return true;
}
cursor = parentMap.get(cursor) ?? null;
}
return false;
}
async function departmentsRoutes(fastify) {
fastify.get(
"/departments",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const query = parseListQuery(request.query);
const where = query.includeArchived ? {} : { archived_at: null };
const items = await fastify.prisma.department.findMany({
where,
orderBy: [{ name: "asc" }, { created_at: "asc" }],
select: {
id: true,
name: true,
parent_id: true,
archived_at: true,
created_at: true,
updated_at: true,
},
});
return sendData(reply, request, {
includeArchived: query.includeArchived,
items: items.map(mapDepartmentItem),
tree: buildDepartmentTree(items),
});
},
);
fastify.post(
"/departments",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const input = parseCreateBody(request.body);
const now = new Date();
if (input.parentId) {
const parent = await fastify.prisma.department.findUnique({
where: { id: input.parentId },
select: { id: true, archived_at: true },
});
if (!parent) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { parentId: "not_found" },
});
}
if (parent.archived_at) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { parentId: "archived" },
});
}
}
const created = await fastify.prisma.$transaction(async (tx) => {
const department = await tx.department.create({
data: {
name: input.name,
parent_id: input.parentId,
created_by: request.employee.id,
updated_by: request.employee.id,
created_at: now,
},
select: {
id: true,
name: true,
parent_id: true,
archived_at: true,
created_at: true,
updated_at: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "DEPARTMENT_CREATE",
targetType: "department",
targetId: department.id,
extra: {
name: department.name,
parentId: department.parent_id,
},
}),
});
return department;
});
return sendData(reply, request, { department: mapDepartmentItem(created) });
},
);
fastify.patch(
"/departments/:id",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const departmentId = parseDepartmentId(request.params?.id);
const input = parseUpdateBody(request.body);
const now = new Date();
const updated = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.department.findUnique({
where: { id: departmentId },
select: {
id: true,
name: true,
parent_id: true,
archived_at: true,
created_at: true,
updated_at: true,
},
});
if (!current) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (current.archived_at) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "archived" },
});
}
const parentId =
Object.prototype.hasOwnProperty.call(input, "parentId") ? input.parentId : current.parent_id;
if (parentId === departmentId) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { parentId: "self_not_allowed" },
});
}
if (parentId) {
const parent = await tx.department.findUnique({
where: { id: parentId },
select: { id: true, archived_at: true },
});
if (!parent) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { parentId: "not_found" },
});
}
if (parent.archived_at) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { parentId: "archived" },
});
}
const allDepartments = await tx.department.findMany({
select: { id: true, parent_id: true },
});
if (detectParentCycle(allDepartments, departmentId, parentId)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { parentId: "cycle_not_allowed" },
});
}
}
const changes = {};
if (typeof input.name === "string" && input.name !== current.name) {
changes.name = { before: current.name, after: input.name };
}
if (Object.prototype.hasOwnProperty.call(input, "parentId")) {
changes.parentId = { before: current.parent_id, after: parentId };
}
const department = await tx.department.update({
where: { id: departmentId },
data: {
...(typeof input.name === "string" ? { name: input.name } : {}),
...(Object.prototype.hasOwnProperty.call(input, "parentId") ? { parent_id: parentId } : {}),
updated_by: request.employee.id,
updated_at: now,
},
select: {
id: true,
name: true,
parent_id: true,
archived_at: true,
created_at: true,
updated_at: true,
},
});
if (Object.keys(changes).length) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "DEPARTMENT_UPDATE",
targetType: "department",
targetId: department.id,
extra: { changes },
}),
});
}
return department;
});
return sendData(reply, request, { department: mapDepartmentItem(updated) });
},
);
fastify.post(
"/departments/:id/archive",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const departmentId = parseDepartmentId(request.params?.id);
const now = new Date();
const result = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.department.findUnique({
where: { id: departmentId },
select: {
id: true,
name: true,
parent_id: true,
archived_at: true,
created_at: true,
updated_at: true,
},
});
if (!current) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (current.archived_at) {
return { department: current, outcome: "already_archived" };
}
const [childCount, activeEmployeeCount] = await Promise.all([
tx.department.count({ where: { parent_id: departmentId } }),
tx.employee.count({ where: { department_id: departmentId, status: "active" } }),
]);
if (childCount > 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { department: "has_children" },
});
}
if (activeEmployeeCount > 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { department: "has_active_employees" },
});
}
const department = await tx.department.update({
where: { id: departmentId },
data: {
archived_at: now,
archived_by: request.employee.id,
updated_by: request.employee.id,
updated_at: now,
},
select: {
id: true,
name: true,
parent_id: true,
archived_at: true,
created_at: true,
updated_at: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "DEPARTMENT_ARCHIVE",
targetType: "department",
targetId: department.id,
extra: { name: department.name },
}),
});
return { department, outcome: "archived" };
});
return sendData(reply, request, {
department: mapDepartmentItem(result.department),
outcome: result.outcome,
});
},
);
}
module.exports = departmentsRoutes;