feat(server): P5 基础能力补齐(组织/报销/代办/认证安全)
- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40) - 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5) - 行政代提交/代上传后端校验与审计(F7/F8) - 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
这个提交包含在:
@@ -0,0 +1,484 @@
|
||||
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;
|
||||
在新工单中引用
屏蔽一个用户