feat(c0): staff 人员模块(user_id 关联账号 + 归属组长)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
856e906740
commit
5a3beb4923
@ -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
207
src/staff/dto.ts
Normal 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;
|
||||
}
|
||||
44
src/staff/staff.controller.ts
Normal file
44
src/staff/staff.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
src/staff/staff.module.ts
Normal file
9
src/staff/staff.module.ts
Normal 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 {}
|
||||
67
src/staff/staff.service.spec.ts
Normal file
67
src/staff/staff.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
72
src/staff/staff.service.ts
Normal file
72
src/staff/staff.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user