feat(c0): staff 人员模块(user_id 关联账号 + 归属组长)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-14 12:56:07 +08:00
parent 856e906740
commit 5a3beb4923
6 changed files with 401 additions and 0 deletions

View File

@ -26,6 +26,7 @@ import { LoginLogsModule } from './login-logs/login-logs.module.js';
import { SessionsModule } from './sessions/sessions.module.js';
import { NoticesModule } from './notices/notices.module.js';
import { AgencyModule } from './agency/agency.module.js';
import { StaffModule } from './staff/staff.module.js';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
@ -49,6 +50,7 @@ import { AppService } from './app.service.js';
SessionsModule,
NoticesModule,
AgencyModule,
StaffModule,
],
controllers: [AppController],
providers: [

207
src/staff/dto.ts Normal file
View File

@ -0,0 +1,207 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
/**
* 人员(staff)DTO。字段字典以 shared/data-model 为准,本期只落地 C0 骨架字段。
* - 不含 tenantCode/createdBy/createdAt/updatedAt/deletedAt(系统字段,服务端注入);
* - id_card_no 本期存明文(加密机制由后续安全 change 统一落地,见 staff.service.ts TODO)。
*/
export class CreateStaffDto {
@ApiPropertyOptional({ description: '关联账号 user.id(可空,1:1;院长/质控/管理员无 staff 记录)' })
@IsOptional()
@IsString()
userId?: string;
@ApiProperty({ description: '姓名' })
@IsString()
@IsNotEmpty()
name!: string;
@ApiProperty({ description: '身份证号(本期明文)' })
@IsString()
@IsNotEmpty()
idCardNo!: string;
@ApiPropertyOptional({ description: '工号(预留)' })
@IsOptional()
@IsString()
personCode?: string;
@ApiProperty({ description: '手机号' })
@IsString()
@IsNotEmpty()
phone!: string;
@ApiProperty({ description: '岗位类型:nurse / nurse_medical / manager' })
@IsString()
@IsNotEmpty()
jobType!: string;
@ApiProperty({ description: '用工类型' })
@IsString()
@IsNotEmpty()
workType!: string;
@ApiPropertyOptional({ description: '入职日期(YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
entryDate?: string;
@ApiPropertyOptional({ description: '离职日期(YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
leaveDate?: string;
@ApiProperty({ description: '在职状态' })
@IsString()
@IsNotEmpty()
employStatus!: string;
@ApiPropertyOptional({ description: '证书类型' })
@IsOptional()
@IsString()
certType?: string;
@ApiPropertyOptional({ description: '证书编号' })
@IsOptional()
@IsString()
certNo?: string;
@ApiPropertyOptional({ description: '证书等级' })
@IsOptional()
@IsString()
certLevel?: string;
@ApiPropertyOptional({ description: '证书有效期(YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
certExpire?: string;
@ApiPropertyOptional({ description: '证书状态' })
@IsOptional()
@IsString()
certStatus?: string;
@ApiPropertyOptional({ description: '证书核验链接(预留)' })
@IsOptional()
@IsString()
certVerifyUrl?: string;
@ApiPropertyOptional({ description: '归属组长 staff.id' })
@IsOptional()
@IsString()
groupLeaderId?: string;
}
/** 更新人员信息:全部 optional;`userId` 允许改绑账号(service 层校验唯一)。 */
export class UpdateStaffDto {
@ApiPropertyOptional({ description: '关联账号 user.id(可空,改绑需唯一)' })
@IsOptional()
@IsString()
userId?: string;
@ApiPropertyOptional({ description: '姓名' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: '身份证号(本期明文)' })
@IsOptional()
@IsString()
idCardNo?: string;
@ApiPropertyOptional({ description: '工号(预留)' })
@IsOptional()
@IsString()
personCode?: string;
@ApiPropertyOptional({ description: '手机号' })
@IsOptional()
@IsString()
phone?: string;
@ApiPropertyOptional({ description: '岗位类型' })
@IsOptional()
@IsString()
jobType?: string;
@ApiPropertyOptional({ description: '用工类型' })
@IsOptional()
@IsString()
workType?: string;
@ApiPropertyOptional({ description: '入职日期(YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
entryDate?: string;
@ApiPropertyOptional({ description: '离职日期(YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
leaveDate?: string;
@ApiPropertyOptional({ description: '在职状态' })
@IsOptional()
@IsString()
employStatus?: string;
@ApiPropertyOptional({ description: '证书类型' })
@IsOptional()
@IsString()
certType?: string;
@ApiPropertyOptional({ description: '证书编号' })
@IsOptional()
@IsString()
certNo?: string;
@ApiPropertyOptional({ description: '证书等级' })
@IsOptional()
@IsString()
certLevel?: string;
@ApiPropertyOptional({ description: '证书有效期(YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
certExpire?: string;
@ApiPropertyOptional({ description: '证书状态' })
@IsOptional()
@IsString()
certStatus?: string;
@ApiPropertyOptional({ description: '证书核验链接(预留)' })
@IsOptional()
@IsString()
certVerifyUrl?: string;
@ApiPropertyOptional({ description: '归属组长 staff.id' })
@IsOptional()
@IsString()
groupLeaderId?: string;
}
export class ListStaffQuery extends PageQuery {
@ApiPropertyOptional({ description: '姓名(模糊)' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: '岗位类型' })
@IsOptional()
@IsString()
jobType?: string;
@ApiPropertyOptional({ description: '在职状态' })
@IsOptional()
@IsString()
employStatus?: string;
}
/** 指定归属组长;传 null 解除归属。 */
export class AssignGroupDto {
@ApiPropertyOptional({ description: '归属组长 staff.id(传 null 解除归属)', nullable: true })
@IsOptional()
groupLeaderId?: string | null;
}

View File

@ -0,0 +1,44 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { AssignGroupDto, CreateStaffDto, ListStaffQuery, UpdateStaffDto } from './dto.js';
import { StaffService } from './staff.service.js';
@Controller('staff')
export class StaffController {
constructor(private readonly service: StaffService) {}
@Get()
@RequirePermissions(Permissions.staff.list)
list(@Query() q: ListStaffQuery) {
return this.service.listStaff(q);
}
@Post()
@RequirePermissions(Permissions.staff.create)
@AuditLog('staff', 'create')
create(@Body() dto: CreateStaffDto) {
return this.service.createStaff(dto);
}
@Patch(':id')
@RequirePermissions(Permissions.staff.update)
@AuditLog('staff', 'update')
update(@Param('id') id: string, @Body() dto: UpdateStaffDto) {
return this.service.updateStaff(id, dto);
}
@Delete(':id')
@RequirePermissions(Permissions.staff.delete)
@AuditLog('staff', 'delete')
remove(@Param('id') id: string) {
return this.service.removeStaff(id);
}
@Post(':id/group')
@RequirePermissions(Permissions.staff.groupAssign)
@AuditLog('staff', 'group-assign')
assignGroup(@Param('id') id: string, @Body() dto: AssignGroupDto) {
return this.service.assignGroup(id, dto);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { StaffController } from './staff.controller.js';
import { StaffService } from './staff.service.js';
@Module({
controllers: [StaffController],
providers: [StaffService],
})
export class StaffModule {}

View File

@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest';
import { StaffService } from './staff.service.js';
import type { PrismaService } from '../prisma/prisma.service.js';
import { ErrorCodes } from '../common/error-codes.js';
import { runWithRequestContext } from '../common/context/request-context.js';
import type { RequestContext } from '../common/context/request-context.js';
const ctx: RequestContext = {
tenantCode: 'T001',
userId: 'u1',
username: 'admin',
super: false,
dataScope: 'self',
orgCode: null,
orgCodes: [],
};
const baseDto = {
name: '张三',
idCardNo: '530102199001011234',
phone: '13800000000',
jobType: 'nurse',
workType: 'full',
employStatus: 'on',
};
function makeService(overrides: { findFirst?: ReturnType<typeof vi.fn>; create?: ReturnType<typeof vi.fn> } = {}) {
const findFirst = overrides.findFirst ?? vi.fn().mockResolvedValue(null);
const create = overrides.create ?? vi.fn().mockResolvedValue({ id: 's1', ...baseDto, tenantCode: 'T001' });
const prisma = { client: { staff: { findFirst, create } } };
return { service: new StaffService(prisma as unknown as PrismaService), findFirst, create };
}
describe('StaffService', () => {
it('create 自动注入 tenantCode/createdBy', async () => {
const { service, create } = makeService();
await runWithRequestContext(ctx, () => service.createStaff(baseDto));
expect(create).toHaveBeenCalledWith({
data: { ...baseDto, tenantCode: 'T001', createdBy: 'u1' },
});
});
it('userId 已被占用时 create 抛 STAFF_USER_LINKED', async () => {
const findFirst = vi.fn().mockResolvedValue({ id: 's-other', userId: 'u9' });
const create = vi.fn();
const { service } = makeService({ findFirst, create });
await expect(
runWithRequestContext(ctx, () => service.createStaff({ ...baseDto, userId: 'u9' })),
).rejects.toMatchObject({ response: { code: ErrorCodes.STAFF_USER_LINKED } });
expect(findFirst).toHaveBeenCalledWith({ where: { userId: 'u9' } });
expect(create).not.toHaveBeenCalled();
});
it('userId 为空时不校验、直接创建', async () => {
const findFirst = vi.fn();
const { service, create } = makeService({ findFirst });
await runWithRequestContext(ctx, () => service.createStaff(baseDto));
expect(findFirst).not.toHaveBeenCalled();
expect(create).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,72 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { BusinessException } from '../common/business-exception.js';
import { ErrorCodes } from '../common/error-codes.js';
import { AssignGroupDto, CreateStaffDto, ListStaffQuery, UpdateStaffDto } from './dto.js';
/**
* 人员(staff)业务服务。staff 是租户级软删模型,读写经 Prisma 扩展自动注入
* tenantCode / deletedAt 过滤;创建时 tenantCode/createdBy 由 BaseCrudService 注入。
* 沿用 tenants 范式:BaseCrudService 的 create/update/softRemove 原样可用,业务校验
* 收敛在 createStaff/updateStaff 等包装方法内。
*/
@Injectable()
export class StaffService extends BaseCrudService {
constructor(prisma: PrismaService) {
super(prisma, 'staff');
}
listStaff(q: ListStaffQuery) {
const where: Record<string, unknown> = {};
if (q.name) where.name = { contains: q.name };
if (q.jobType) where.jobType = q.jobType;
if (q.employStatus) where.employStatus = q.employStatus;
return this.paginate(q.page, q.pageSize, where);
}
/** 建档:先校验 userId 未被其他人员占用,再走 BaseCrudService 注入 tenantCode/createdBy。 */
async createStaff(dto: CreateStaffDto) {
await this.assertUserIdFree(dto.userId);
// TODO(id_card_no 加密): 本期直接存明文,加密机制由后续安全 change 统一落地后再在此处加解密。
return super.create({ ...dto });
}
/** 改人员:改绑 userId 时同样校验唯一;不存在抛 STAFF_NOT_FOUND。 */
async updateStaff(id: string, dto: UpdateStaffDto) {
await this.requireExists(id);
if (dto.userId !== undefined) {
await this.assertUserIdFree(dto.userId, id);
}
return super.update(id, { ...dto });
}
/** 删除(软删)。 */
async removeStaff(id: string) {
await this.requireExists(id);
return this.softRemove(id);
}
/** 指定归属组长(groupLeaderId 传 null 解除归属)。 */
async assignGroup(id: string, dto: AssignGroupDto) {
await this.requireExists(id);
return super.update(id, { groupLeaderId: dto.groupLeaderId });
}
/** userId 为 1:1 关联(可空):非空时校验是否已被其他 staff 占用。 */
private async assertUserIdFree(userId: string | undefined, excludeId?: string) {
if (!userId) return;
const existing = await this.db().findFirst({ where: { userId } });
if (existing && existing.id !== excludeId) {
throw new BusinessException(ErrorCodes.STAFF_USER_LINKED, '该账号已关联其他人员', HttpStatus.CONFLICT);
}
}
private async requireExists(id: string) {
const existing = await this.findById(id);
if (!existing) {
throw new BusinessException(ErrorCodes.STAFF_NOT_FOUND, '人员不存在', HttpStatus.NOT_FOUND);
}
return existing;
}
}