背景:
- 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:
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' } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user