feat(identity): 补齐部门管理(两级组织树、停用启用)
ci / 质量门禁 (push) Canceled after 0s

背景:
- A1 只实现了部门树读取与新建 / 改名接口,没有部门管理入口,
  负责人、排序、停用状态都无法维护;
- 用户必须关联部门,部门无法维护会让 A2 的数据范围缺少组织基础。

改动:
- shared:DepartmentNode 增加 status / sortOrder / directMemberCount;
  新增 createDepartmentInput / updateDepartmentInput / departmentTreeQuery 契约;
- api:GET /departments/tree?include_disabled;POST/PATCH /departments
  (名称 / 负责人 / 排序);POST /departments/{id}/disable|enable;
  只停用不删除,有在职成员 / 启用子部门 / 下级在职成员时禁止停用;
  新建 / 编辑用户校验部门必须启用;@Audit 与 OpenAPI 同步;
- web:管理中心新增「部门管理」/system/departments,左两级组织树 +
  右详情 / 内联编辑 + 新建部门/小组 + 停用启用二次确认;显示已停用、
  ?id= 选中可恢复、无 user:manage 只读;用户页部门选择仍只取启用部门;
- 测试:shared 契约、api 单测 + int、web 树/表单/接口单测同步。

影响面:
- 新增部门写接口与页面;GET tree 默认仍只返回启用部门,对用户页无破坏;
- 权限复用 user:view / user:manage,不新增权限点;
- 无数据库迁移(status / leader_user_id / sort_order 列已存在)。

回滚方式:
- 回滚本提交即移除部门管理页与写接口,保留原只读树;数据库无需回滚。

验证证据:
- pnpm format:check / lint / typecheck 全绿;
- pnpm test:shared 121 / api 90 / web 120;test:int 4 files / 33 tests;
- pnpm openapi:diff:无破坏性变更;
- 真实 API + 浏览器(jiang.lan):新建一级部门 / 小组、改名、停用空小组、
  重名 40903、树/详情/停用确认均正常;
  截图 design/a1-ui-v1/shots/impl/70-departments.png。
This commit is contained in:
Stev_Wang committed 2026-11-03 15:01:07 +08:00
1 parent f4da1f3c02
commit 6661c42e84
33 files changed
+3237 -125

No files matched your search

@@ -1,6 +1,6 @@
import { ErrorCode } from '@lingxi/shared';
import { type PipeTransform } from '@nestjs/common';
import type { ZodSchema } from 'zod';
import type { ZodType, ZodTypeDef } from 'zod';
import { AppException } from '../errors/app-exception';
@@ -9,7 +9,7 @@ import { AppException } from '../errors/app-exception';
* 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。
*/
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodSchema<T>) {}
constructor(private readonly schema: ZodType<T, ZodTypeDef, unknown>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
@@ -1,28 +1,21 @@
import { Permission, ulidSchema } from '@lingxi/shared';
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
import {
type CreateDepartmentInput,
createDepartmentInputSchema,
type DepartmentNode,
type DepartmentTreeQuery,
departmentTreeQuerySchema,
Permission,
type UpdateDepartmentInput,
updateDepartmentInputSchema,
} from '@lingxi/shared';
import { Body, Controller, Get, HttpCode, Inject, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { Audit, RequirePermissions } from '../../common/security/decorators';
import { DepartmentsService, type DepartmentTreeNode } from './departments.service';
const createDepartmentSchema = z
.object({
name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符'),
parentId: ulidSchema.nullable().default(null),
})
.strict();
type CreateDepartmentBody = z.infer<typeof createDepartmentSchema>;
const updateDepartmentSchema = z
.object({ name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符') })
.strict();
type UpdateDepartmentBody = z.infer<typeof updateDepartmentSchema>;
/** 组织架构:两级部门树(A1 前端只读;写接口先备好,供后续批次使用) */
/** 组织架构:两级部门树 + 新建 / 更新 / 停用 / 启用(不提供删除) */
@ApiTags('identity-departments')
@ApiBearerAuth()
@Controller('departments')
@@ -31,30 +24,70 @@ export class DepartmentsController {
@Get('tree')
@RequirePermissions(Permission.USER_VIEW)
@ApiOperation({ summary: '部门树(两级,含成员数与负责人)' })
async tree(): Promise<{ items: DepartmentTreeNode[] }> {
return { items: await this.departments.tree() };
@ApiOperation({
summary: '部门树(两级)',
description:
'memberCount 含下级成员、directMemberCount 仅本部门直属成员;默认只返回启用部门,管理页传 include_disabled=true',
})
@ApiQuery({ name: 'include_disabled', required: false, enum: ['true', 'false'] })
async tree(
@Query(new ZodValidationPipe(departmentTreeQuerySchema)) query: DepartmentTreeQuery,
): Promise<{ items: DepartmentTreeNode[] }> {
return { items: await this.departments.tree(query.include_disabled) };
}
@Post()
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.create')
@ApiOperation({ summary: '新建部门 / 小组', description: '最多两级,在小组下新建返回 42201' })
@ApiOperation({
summary: '新建部门 / 小组',
description: '最多两级;父级必须启用;负责人必须是启用用户;同级重名返回 40903',
})
async create(
@Body(new ZodValidationPipe(createDepartmentSchema)) input: CreateDepartmentBody,
@Body(new ZodValidationPipe(createDepartmentInputSchema)) input: CreateDepartmentInput,
): Promise<{ id: string }> {
return this.departments.create(input);
}
@Patch(':id')
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.rename')
@ApiOperation({ summary: '部门改名' })
async rename(
@Audit('identity.department.update')
@ApiOperation({
summary: '更新部门(名称 / 负责人 / 排序)',
description: '上级关系创建后不可移动;负责人必须是启用用户',
})
async update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateDepartmentSchema)) input: UpdateDepartmentBody,
@Body(new ZodValidationPipe(updateDepartmentInputSchema)) input: UpdateDepartmentInput,
): Promise<{ id: string }> {
await this.departments.rename(id, input.name);
await this.departments.update(id, input);
return { id };
}
@Post(':id/disable')
@HttpCode(200)
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.disable')
@ApiOperation({
summary: '停用部门',
description:
'本部门仍有在职成员、或一级部门存在启用中的下级小组 / 下级在职成员时返回 42201;已停用返回 40903',
})
async disable(
@Param('id') id: string,
): Promise<{ id: string; status: DepartmentNode['status'] }> {
return this.departments.disable(id);
}
@Post(':id/enable')
@HttpCode(200)
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.enable')
@ApiOperation({
summary: '启用部门',
description: '上级必须启用;已启用返回 40903',
})
async enable(@Param('id') id: string): Promise<{ id: string; status: DepartmentNode['status'] }> {
return this.departments.enable(id);
}
}
@@ -0,0 +1,284 @@
import { API_PREFIX, ErrorCode, ulid } from '@lingxi/shared';
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp, type CreatedApiApp } from '../../bootstrap';
import { loadDotenv } from '../../config/load-dotenv';
/** 与 prisma/seed.ts 一致的示例账号初始密码 lingxi2026(Argon2id) */
const PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
const PASSWORD = 'lingxi2026';
const API = API_PREFIX;
const SUFFIX = String(Date.now()).slice(-6);
const prisma = new PrismaClient();
interface Node {
id: string;
name: string;
parentId: string | null;
status: 'active' | 'disabled';
sortOrder: number;
memberCount: number;
directMemberCount: number;
leader: { id: string; name: string } | null;
children: Node[];
}
interface ErrorBody {
code: number;
message: string;
}
function departmentName(label: string): string {
return `测试${label}${SUFFIX}`;
}
async function api(
app: CreatedApiApp['app'],
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
url: string,
token: string,
payload?: unknown,
): Promise<{ status: number; body: unknown }> {
const response = await app.inject({
method,
url: `${API}${url}`,
headers: { authorization: `Bearer ${token}` },
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
});
return { status: response.statusCode, body: response.json() };
}
describe('departments 集成(真实 PG)', () => {
let created: CreatedApiApp;
let token = '';
let managerId = '';
let rootId = '';
let childId = '';
let siblingId = '';
let disabledRootId = '';
async function createDept(
name: string,
parentId: string | null,
extra: Record<string, unknown> = {},
): Promise<string> {
const response = await api(created.app, 'POST', '/departments', token, {
name,
parentId,
...extra,
});
expect(response.status).toBe(201);
return (response.body as { id: string }).id;
}
async function tree(includeDisabled: boolean): Promise<Node[]> {
const response = await api(
created.app,
'GET',
`/departments/tree?include_disabled=${String(includeDisabled)}`,
token,
);
expect(response.status).toBe(200);
return (response.body as { items: Node[] }).items;
}
function findNode(nodes: Node[], id: string): Node | undefined {
for (const node of nodes) {
if (node.id === id) return node;
const found = findNode(node.children, id);
if (found !== undefined) return found;
}
return undefined;
}
beforeAll(async () => {
loadDotenv();
created = await createApp();
await created.app.init();
await prisma.user.deleteMany({ where: { account: { startsWith: 'it.dep.' } } });
await prisma.department.deleteMany({ where: { name: { startsWith: '测试' } } });
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'tech' } });
const role = await prisma.role.findUniqueOrThrow({ where: { code: 'platform_admin' } });
managerId = ulid();
const account = `it.dep.${SUFFIX}`;
await prisma.user.create({
data: {
id: managerId,
name: '部门集成测试',
account,
email: `${account}@lingxi.cn`,
phone: `136${String(Date.now()).slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId: role.id }] },
},
});
const login = await created.app.inject({
method: 'POST',
url: `${API}/auth/login`,
payload: { account, password: PASSWORD },
});
expect(login.statusCode).toBe(200);
token = login.json<{ accessToken: string }>().accessToken;
rootId = await createDept(departmentName('一级部门'), null);
childId = await createDept(departmentName('小组A'), rootId);
siblingId = await createDept(departmentName('小组B'), rootId);
const disabledRoot = await prisma.department.create({
data: {
id: ulid(),
code: `test-disabled-${SUFFIX}`,
name: departmentName('停用部门'),
status: 'DISABLED',
},
select: { id: true },
});
disabledRootId = disabledRoot.id;
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { account: { startsWith: 'it.dep.' } } });
// 只清理本测试创建的部门(先子后父),避免误删种子 / 其他用例的数据
await prisma.department.deleteMany({
where: { name: { startsWith: '测试' }, parentId: { not: null } },
});
await prisma.department.deleteMany({ where: { name: { startsWith: '测试' } } });
await prisma.$disconnect();
await created.app.close();
});
it('部门树:默认过滤停用,include_disabled=true 返回停用节点与新增字段', async () => {
const activeTree = await tree(false);
expect(findNode(activeTree, disabledRootId)).toBeUndefined();
const root = findNode(activeTree, rootId);
expect(root).toBeDefined();
expect(root?.status).toBe('active');
expect(root?.sortOrder).toBe(0);
expect(root?.directMemberCount).toBe(0);
expect(root?.memberCount).toBe(0);
expect(root?.children.map((child) => child.id).sort()).toEqual([childId, siblingId].sort());
const fullTree = await tree(true);
expect(findNode(fullTree, disabledRootId)?.status).toBe('disabled');
});
it('新建:两级可建、超两级 42201、同级重名 40903、负责人非法 42201', async () => {
const tooDeep = await api(created.app, 'POST', '/departments', token, {
name: departmentName('三级组'),
parentId: childId,
});
expect(tooDeep.status).toBe(422);
expect((tooDeep.body as ErrorBody).code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
const duplicated = await api(created.app, 'POST', '/departments', token, {
name: departmentName('小组A'),
parentId: rootId,
});
expect(duplicated.status).toBe(409);
expect((duplicated.body as ErrorBody).code).toBe(ErrorCode.STATE_NOT_ALLOWED);
const badLeader = await api(created.app, 'POST', '/departments', token, {
name: departmentName('小组C'),
parentId: rootId,
leaderUserId: ulid(),
});
expect(badLeader.status).toBe(422);
expect((badLeader.body as ErrorBody).code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
});
it('更新:名称 / 排序 / 负责人生效,重名 40903', async () => {
const renamed = departmentName('小组A改');
const response = await api(created.app, 'PATCH', `/departments/${childId}`, token, {
name: renamed,
sortOrder: 3,
leaderUserId: managerId,
});
expect(response.status).toBe(200);
const root = findNode(await tree(false), rootId);
const child = root?.children.find((item) => item.id === childId);
expect(child?.name).toBe(renamed);
expect(child?.sortOrder).toBe(3);
expect(child?.leader?.id).toBe(managerId);
const conflict = await api(created.app, 'PATCH', `/departments/${childId}`, token, {
name: departmentName('小组B'),
});
expect(conflict.status).toBe(409);
});
it('停用:有在职成员 42201;存在启用子部门 42201;全部停用后可停用一级部门', async () => {
const member = await prisma.user.create({
data: {
id: ulid(),
name: '临时成员',
account: `it.dep.member.${SUFFIX}`,
email: `it.dep.member.${SUFFIX}@lingxi.cn`,
phone: `138${String(Date.now()).slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: childId,
mustChangePassword: false,
},
select: { id: true },
});
const blockedByMember = await api(
created.app,
'POST',
`/departments/${childId}/disable`,
token,
);
expect(blockedByMember.status).toBe(422);
expect((blockedByMember.body as ErrorBody).message).toContain('在职成员');
const blockedByChild = await api(created.app, 'POST', `/departments/${rootId}/disable`, token);
expect(blockedByChild.status).toBe(422);
expect((blockedByChild.body as ErrorBody).message).toMatch(/下级小组|在职成员/);
await prisma.user.delete({ where: { id: member.id } });
const disabledChild = await api(created.app, 'POST', `/departments/${childId}/disable`, token);
expect(disabledChild.status).toBe(200);
expect((disabledChild.body as { status: string }).status).toBe('disabled');
expect(findNode(await tree(false), childId)).toBeUndefined();
const disabledSibling = await api(
created.app,
'POST',
`/departments/${siblingId}/disable`,
token,
);
expect(disabledSibling.status).toBe(200);
const disabledRoot = await api(created.app, 'POST', `/departments/${rootId}/disable`, token);
expect(disabledRoot.status).toBe(200);
const again = await api(created.app, 'POST', `/departments/${rootId}/disable`, token);
expect(again.status).toBe(409);
expect((again.body as ErrorBody).code).toBe(ErrorCode.STATE_NOT_ALLOWED);
});
it('启用:上级停用 42201;启用后上级可启用,子部门可再启用', async () => {
const childBlocked = await api(created.app, 'POST', `/departments/${childId}/enable`, token);
expect(childBlocked.status).toBe(422);
expect((childBlocked.body as ErrorBody).message).toContain('上级部门');
const rootEnabled = await api(created.app, 'POST', `/departments/${rootId}/enable`, token);
expect(rootEnabled.status).toBe(200);
const childEnabled = await api(created.app, 'POST', `/departments/${childId}/enable`, token);
expect(childEnabled.status).toBe(200);
expect(findNode(await tree(false), childId)?.status).toBe('active');
const again = await api(created.app, 'POST', `/departments/${childId}/enable`, token);
expect(again.status).toBe(409);
});
it('不提供删除接口(DELETE 返回 404)', async () => {
const response = await api(created.app, 'DELETE', `/departments/${childId}`, token);
expect(response.status).toBe(404);
});
});
@@ -1,4 +1,10 @@
import { type DepartmentNode, ErrorCode, ulid } from '@lingxi/shared';
import {
type CreateDepartmentInput,
type DepartmentNode,
ErrorCode,
ulid,
type UpdateDepartmentInput,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
@@ -6,14 +12,24 @@ import { AppException } from '../../common/errors/app-exception';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
/** 带负责人的部门行(树查询 select 结果) */
type DepartmentRow = Prisma.DepartmentGetPayload<{
include: { leader: { select: { id: true; name: true } } };
}>;
/** 部门树节点:契约节点 + 子节点(最多两级) */
export interface DepartmentTreeNode extends DepartmentNode {
children: DepartmentTreeNode[];
}
export interface CreateDepartmentPayload {
name: string;
parentId: string | null;
/** 状态在 DB 中用大写枚举值(与 users/roles 一致),API 契约用小写 */
const DB_ACTIVE = 'ACTIVE';
const DB_DISABLED = 'DISABLED';
const DUPLICATE_NAME_MESSAGE = '同级部门名称已存在';
function toApiStatus(status: string): DepartmentNode['status'] {
return status === DB_ACTIVE ? 'active' : 'disabled';
}
function codeOf(name: string): string {
@@ -27,19 +43,20 @@ function codeOf(name: string): string {
function mapDepartmentError(error: unknown): AppException {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '同级部门名称已存在' });
return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: DUPLICATE_NAME_MESSAGE });
}
return new AppException({ code: ErrorCode.INTERNAL_ERROR, message: '部门保存失败' });
}
/** 组织架构:两级部门树(含成员数与负责人)、新建、改名 */
/** 组织架构:两级部门树、新建、更新、停用 / 启用(不提供删除) */
@Injectable()
export class DepartmentsService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async tree(): Promise<DepartmentTreeNode[]> {
const [departments, counts] = await Promise.all([
async tree(includeDisabled: boolean): Promise<DepartmentTreeNode[]> {
const [departments, counts, lineage] = await Promise.all([
this.prisma.department.findMany({
where: includeDisabled ? {} : { status: DB_ACTIVE },
orderBy: [{ parentId: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
include: { leader: { select: { id: true, name: true } } },
}),
@@ -48,16 +65,30 @@ export class DepartmentsService {
where: { status: 'ACTIVE' },
_count: { _all: true },
}),
// 全量父子关系:即使子部门被过滤,也要把其直属在职成员计入一级部门 memberCount
this.prisma.department.findMany({ select: { id: true, parentId: true } }),
]);
const byDept = new Map(counts.map((row) => [row.departmentId, row._count._all]));
return this.buildTree(departments, byDept, lineage);
}
private buildTree(
departments: DepartmentRow[],
byDept: Map<string, number>,
lineage: { id: string; parentId: string | null }[],
): DepartmentTreeNode[] {
const nodes = new Map<string, DepartmentTreeNode>();
for (const department of departments) {
const direct = byDept.get(department.id) ?? 0;
nodes.set(department.id, {
id: department.id,
name: department.name,
parentId: department.parentId,
leader: department.leader,
memberCount: byDept.get(department.id) ?? 0,
status: toApiStatus(department.status),
sortOrder: department.sortOrder,
memberCount: direct,
directMemberCount: direct,
createdAt: department.createdAt.toISOString(),
children: [],
});
@@ -68,25 +99,36 @@ export class DepartmentsService {
roots.push(node);
continue;
}
const parent = nodes.get(node.parentId);
if (parent !== undefined) {
parent.children.push(node);
nodes.get(node.parentId)?.children.push(node);
}
const childDirect = new Map<string, number>();
for (const row of lineage) {
if (row.parentId !== null) {
childDirect.set(
row.parentId,
(childDirect.get(row.parentId) ?? 0) + (byDept.get(row.id) ?? 0),
);
}
}
for (const root of roots) {
root.memberCount += root.children.reduce((sum, child) => sum + child.memberCount, 0);
root.memberCount += childDirect.get(root.id) ?? 0;
}
return roots;
}
async create(input: CreateDepartmentPayload): Promise<{ id: string }> {
async create(input: CreateDepartmentInput): Promise<{ id: string }> {
await this.assertParentUsable(input.parentId);
await this.assertLeaderUsable(input.leaderUserId);
await this.assertNameFree(input.name, input.parentId, null);
try {
const created = await this.prisma.department.create({
data: {
id: ulid(),
name: input.name,
parentId: input.parentId,
leaderUserId: input.leaderUserId ?? null,
sortOrder: input.sortOrder ?? 0,
status: DB_ACTIVE,
code: codeOf(input.name),
},
select: { id: true },
@@ -97,26 +139,85 @@ export class DepartmentsService {
}
}
async rename(id: string, name: string): Promise<void> {
const existing = await this.prisma.department.findUnique({ where: { id } });
if (existing === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' });
async update(id: string, input: UpdateDepartmentInput): Promise<void> {
const existing = await this.findOrThrow(id);
if (input.leaderUserId !== undefined) {
await this.assertLeaderUsable(input.leaderUserId);
}
if (input.name !== undefined) {
await this.assertNameFree(input.name, existing.parentId, id);
}
const data: Prisma.DepartmentUpdateInput = {
...(input.name === undefined ? {} : { name: input.name }),
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
...(input.leaderUserId === undefined
? {}
: {
leader:
input.leaderUserId === null
? { disconnect: true }
: { connect: { id: input.leaderUserId } },
}),
};
try {
await this.prisma.department.update({ where: { id }, data: { name } });
await this.prisma.department.update({ where: { id }, data });
} catch (error) {
throw mapDepartmentError(error);
}
}
async disable(id: string): Promise<{ id: string; status: DepartmentNode['status'] }> {
const existing = await this.findOrThrow(id);
if (existing.status !== DB_ACTIVE) {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '部门已是停用状态' });
}
await this.assertNoActiveMembers(existing);
await this.prisma.department.update({ where: { id }, data: { status: DB_DISABLED } });
return { id, status: 'disabled' };
}
async enable(id: string): Promise<{ id: string; status: DepartmentNode['status'] }> {
const existing = await this.findOrThrow(id);
if (existing.status === DB_ACTIVE) {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '部门已是启用状态' });
}
await this.assertParentActiveForEnable(existing.parentId);
await this.prisma.department.update({ where: { id }, data: { status: DB_ACTIVE } });
return { id, status: 'active' };
}
private async findOrThrow(id: string): Promise<{
id: string;
parentId: string | null;
status: string;
}> {
const department = await this.prisma.department.findUnique({
where: { id },
select: { id: true, parentId: true, status: true },
});
if (department === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' });
}
return department;
}
private async assertParentUsable(parentId: string | null): Promise<void> {
if (parentId === null) {
return;
}
const parent = await this.prisma.department.findUnique({ where: { id: parentId } });
const parent = await this.prisma.department.findUnique({
where: { id: parentId },
select: { parentId: true, status: true },
});
if (parent === null) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '上级部门不存在' });
}
if (parent.status !== DB_ACTIVE) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '上级部门已停用,无法在其下新建部门',
});
}
if (parent.parentId !== null) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
@@ -124,4 +225,93 @@ export class DepartmentsService {
});
}
}
private async assertLeaderUsable(leaderUserId: string | null | undefined): Promise<void> {
if (leaderUserId === null || leaderUserId === undefined) {
return;
}
const user = await this.prisma.user.findUnique({
where: { id: leaderUserId },
select: { status: true },
});
if (user?.status !== 'ACTIVE') {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '负责人必须是启用状态的用户',
});
}
}
private async assertNameFree(
name: string,
parentId: string | null,
excludeId: string | null,
): Promise<void> {
const duplicated = await this.prisma.department.findFirst({
where: {
parentId,
name,
...(excludeId === null ? {} : { id: { not: excludeId } }),
},
select: { id: true },
});
if (duplicated !== null) {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: DUPLICATE_NAME_MESSAGE,
});
}
}
private async assertNoActiveMembers(department: {
id: string;
parentId: string | null;
}): Promise<void> {
const direct = await this.prisma.user.count({
where: { departmentId: department.id, status: 'ACTIVE' },
});
if (direct > 0) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: `部门下仍有 ${String(direct)} 名在职成员,请先调整成员所属部门后再停用`,
});
}
if (department.parentId !== null) {
return;
}
const activeChildren = await this.prisma.department.count({
where: { parentId: department.id, status: DB_ACTIVE },
});
if (activeChildren > 0) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: `存在 ${String(activeChildren)} 个启用中的下级小组,请先停用下级小组`,
});
}
const childMembers = await this.prisma.user.count({
where: { status: 'ACTIVE', department: { parentId: department.id } },
});
if (childMembers > 0) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: `下级小组仍有 ${String(childMembers)} 名在职成员,请先调整成员所属部门`,
});
}
}
private async assertParentActiveForEnable(parentId: string | null): Promise<void> {
if (parentId === null) {
return;
}
const parent = await this.prisma.department.findUnique({
where: { id: parentId },
select: { status: true },
});
if (parent?.status !== DB_ACTIVE) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '上级部门已停用,请先启用上级部门',
});
}
}
}
@@ -293,73 +293,253 @@ describe('UsersService', () => {
});
describe('DepartmentsService', () => {
it('部门树:两级嵌套且一级成员数含下级', async () => {
const departments = [
{
id: 'd1',
code: 'content',
name: '内容生产中心',
parentId: null,
leaderUserId: null,
sortOrder: 1,
status: 'ACTIVE',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date(),
leader: { id: 'u1', name: '陈默' },
},
{
id: 'g1',
code: 'content-g1',
name: '素材一组',
parentId: 'd1',
leaderUserId: null,
sortOrder: 1,
status: 'ACTIVE',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date(),
leader: null,
},
function serviceOf(prisma: Record<string, unknown>): DepartmentsService {
return new DepartmentsService(prisma as unknown as PrismaService);
}
function departmentRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'd1',
code: 'content',
name: '内容生产中心',
parentId: null,
leaderUserId: null,
sortOrder: 0,
status: 'ACTIVE',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date(),
leader: null,
...overrides,
};
}
it('部门树:默认只返回启用部门;memberCount 含下级、directMemberCount 仅直属', async () => {
const rows = [
departmentRow({ id: 'd1', name: '内容生产中心' }),
departmentRow({ id: 'g1', name: '素材一组', parentId: 'd1' }),
];
const service = new DepartmentsService({
department: { findMany: vi.fn().mockResolvedValue(departments) },
user: { groupBy: vi.fn().mockResolvedValue([{ departmentId: 'g1', _count: { _all: 5 } }]) },
} as unknown as PrismaService);
const tree = await service.tree();
const findMany = vi.fn().mockResolvedValue(rows);
const service = serviceOf({
department: { findMany },
user: {
groupBy: vi.fn().mockResolvedValue([
{ departmentId: 'd1', _count: { _all: 3 } },
{ departmentId: 'g1', _count: { _all: 5 } },
]),
},
});
const tree = await service.tree(false);
expect(findMany.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ where: { status: 'ACTIVE' } }),
);
expect(tree).toHaveLength(1);
expect(tree[0]?.memberCount).toBe(5);
expect(tree[0]?.memberCount).toBe(8);
expect(tree[0]?.directMemberCount).toBe(3);
expect(tree[0]?.children[0]?.name).toBe('素材一组');
expect(tree[0]?.children[0]?.memberCount).toBe(5);
expect(tree[0]?.status).toBe('active');
});
it('新建:父级不存在 40001,超过两级 42201', async () => {
const service = new DepartmentsService({
it('部门树:include_disabled=true 时包含停用部门且状态映射为 disabled', async () => {
const service = serviceOf({
department: {
findUnique: vi.fn().mockResolvedValueOnce(null),
findMany: vi
.fn()
.mockResolvedValue([
departmentRow({ id: 'd1' }),
departmentRow({ id: 'd2', status: 'DISABLED' }),
]),
},
} as unknown as PrismaService);
user: { groupBy: vi.fn().mockResolvedValue([]) },
});
const tree = await service.tree(true);
expect(tree).toHaveLength(2);
expect(tree[1]?.status).toBe('disabled');
expect(tree[0]?.sortOrder).toBe(0);
});
it('新建:父级不存在 40001、父级停用 / 超两级 42201、负责人非法 42201', async () => {
await expectRejected(
service.create({ name: '新组', parentId: 'missing' }),
serviceOf({ department: { findUnique: vi.fn().mockResolvedValue(null) } }).create({
name: '新部门',
parentId: 'missing',
}),
ErrorCode.PARAM_INVALID,
);
const service2 = new DepartmentsService({
department: {
findUnique: vi.fn().mockResolvedValue({
id: 'g1',
parentId: 'd1',
}),
},
} as unknown as PrismaService);
await expectRejected(
service2.create({ name: '三级', parentId: 'g1' }),
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue({ parentId: null, status: 'DISABLED' }),
},
}).create({ name: '新小组', parentId: 'd1' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue({ parentId: 'd0', status: 'ACTIVE' }),
},
}).create({ name: '三级组', parentId: 'g1' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: { findFirst: vi.fn() },
user: { findUnique: vi.fn().mockResolvedValue({ status: 'DISABLED' }) },
}).create({ name: '新部门', parentId: null, leaderUserId: 'u-disabled' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
});
it('重命名不存在的部门返回 40401', async () => {
const service = new DepartmentsService({
department: { findUnique: vi.fn().mockResolvedValue(null) },
} as unknown as PrismaService);
await expectRejected(service.rename('missing', '新名字'), ErrorCode.NOT_FOUND);
it('新建:同级重名 40903,成功后落库 sortOrder / status / 负责人', async () => {
const duplicated = serviceOf({
department: { findFirst: vi.fn().mockResolvedValue({ id: 'other' }) },
});
await expectRejected(
duplicated.create({ name: '内容生产中心', parentId: null }),
ErrorCode.STATE_NOT_ALLOWED,
);
const create = vi.fn().mockResolvedValue({ id: 'new-id' });
const service = serviceOf({
department: { findFirst: vi.fn().mockResolvedValue(null), create },
user: { findUnique: vi.fn().mockResolvedValue({ status: 'ACTIVE' }) },
});
await expect(
service.create({ name: '内容生产中心', parentId: null, leaderUserId: 'u1', sortOrder: 5 }),
).resolves.toEqual({ id: 'new-id' });
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ sortOrder: 5, status: 'ACTIVE', leaderUserId: 'u1' }),
}),
);
});
it('更新:不存在 40401、负责人非法 42201、重名 40903、支持清空负责人', async () => {
await expectRejected(
serviceOf({ department: { findUnique: vi.fn().mockResolvedValue(null) } }).update('missing', {
name: '新名',
}),
ErrorCode.NOT_FOUND,
);
await expectRejected(
serviceOf({
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) },
user: { findUnique: vi.fn().mockResolvedValue(null) },
}).update('d1', { leaderUserId: 'u-missing' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow()),
findFirst: vi.fn().mockResolvedValue({ id: 'other' }),
},
}).update('d1', { name: '旧部门' }),
ErrorCode.STATE_NOT_ALLOWED,
);
const update = vi.fn().mockResolvedValue(departmentRow());
const service = serviceOf({
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()), update },
});
await service.update('d1', { leaderUserId: null, sortOrder: 2 });
expect(update).toHaveBeenCalledWith({
where: { id: 'd1' },
data: { sortOrder: 2, leader: { disconnect: true } },
});
});
it('停用:已停用 40903;本部门有在职成员 / 启用子部门 / 下级成员分别 42201', async () => {
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' })),
},
}).disable('d1'),
ErrorCode.STATE_NOT_ALLOWED,
);
await expectRejected(
serviceOf({
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) },
user: { count: vi.fn().mockResolvedValue(2) },
}).disable('d1'),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow()),
count: vi.fn().mockResolvedValue(1),
},
user: { count: vi.fn().mockResolvedValue(0) },
}).disable('d1'),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow()),
count: vi.fn().mockResolvedValue(0),
},
user: { count: vi.fn().mockResolvedValueOnce(0).mockResolvedValueOnce(3) },
}).disable('d1'),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
});
it('停用成功:无成员无启用子部门时落库 DISABLED;不提供删除方法', async () => {
const update = vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' }));
const service = serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow()),
count: vi.fn().mockResolvedValue(0),
update,
},
user: { count: vi.fn().mockResolvedValue(0) },
});
await expect(service.disable('d1')).resolves.toEqual({ id: 'd1', status: 'disabled' });
expect(update).toHaveBeenCalledWith({ where: { id: 'd1' }, data: { status: 'DISABLED' } });
expect('delete' in service).toBe(false);
});
it('启用:已启用 40903、上级停用 42201、成功后落库 ACTIVE', async () => {
await expectRejected(
serviceOf({
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) },
}).enable('d1'),
ErrorCode.STATE_NOT_ALLOWED,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi
.fn()
.mockResolvedValueOnce(departmentRow({ status: 'DISABLED', parentId: 'd0' }))
.mockResolvedValueOnce({ status: 'DISABLED' }),
},
}).enable('g1'),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
const update = vi.fn().mockResolvedValue(departmentRow());
const service = serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' })),
update,
},
});
await expect(service.enable('d1')).resolves.toEqual({ id: 'd1', status: 'active' });
expect(update).toHaveBeenCalledWith({ where: { id: 'd1' }, data: { status: 'ACTIVE' } });
});
});
+10 -1
View File
@@ -219,10 +219,19 @@ export class UsersService {
}
private async ensureDepartment(departmentId: string): Promise<void> {
const department = await this.prisma.department.findUnique({ where: { id: departmentId } });
const department = await this.prisma.department.findUnique({
where: { id: departmentId },
select: { status: true },
});
if (department === null) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '所属部门不存在' });
}
if (department.status !== 'ACTIVE') {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '所属部门已停用,请选择启用中的部门',
});
}
}
private async ensureRoles(roleIds: string[]): Promise<void> {