diff --git a/src/app.module.ts b/src/app.module.ts index 44ad83f..6229c3f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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: [ diff --git a/src/staff/dto.ts b/src/staff/dto.ts new file mode 100644 index 0000000..a71fea6 --- /dev/null +++ b/src/staff/dto.ts @@ -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; +} diff --git a/src/staff/staff.controller.ts b/src/staff/staff.controller.ts new file mode 100644 index 0000000..8855e00 --- /dev/null +++ b/src/staff/staff.controller.ts @@ -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); + } +} diff --git a/src/staff/staff.module.ts b/src/staff/staff.module.ts new file mode 100644 index 0000000..ebb13be --- /dev/null +++ b/src/staff/staff.module.ts @@ -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 {} diff --git a/src/staff/staff.service.spec.ts b/src/staff/staff.service.spec.ts new file mode 100644 index 0000000..0a8efc7 --- /dev/null +++ b/src/staff/staff.service.spec.ts @@ -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; create?: ReturnType } = {}) { + 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(); + }); +}); diff --git a/src/staff/staff.service.ts b/src/staff/staff.service.ts new file mode 100644 index 0000000..d586e84 --- /dev/null +++ b/src/staff/staff.service.ts @@ -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 = {}; + 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; + } +}