From 0cbdb7f8255fb11204ec79377a58c8cc7f178403 Mon Sep 17 00:00:00 2001 From: liuyun Date: Wed, 14 Oct 2026 17:29:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(c1):=20elder=20=E5=8D=81=E6=80=81=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C/=E4=B8=8D=E5=8F=AF=E9=80=86=E6=80=81=E5=AE=88?= =?UTF-8?q?=E5=8D=AB/=E5=BE=85=E9=81=87=E6=AD=A2>=E8=B5=B7=20+=20statusLog?= =?UTF-8?q?=20=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/elder/dto.ts | 20 ++++++++++++++- src/elder/elder.service.spec.ts | 43 ++++++++++++++++++++++++++++++++- src/elder/elder.service.ts | 13 +++++++++- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/elder/dto.ts b/src/elder/dto.ts index ad6fbcf..37908bb 100644 --- a/src/elder/dto.ts +++ b/src/elder/dto.ts @@ -2,6 +2,23 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsDateString, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; import { PageQuery } from '../common/page.dto.js'; +/** 十态待遇状态机(唯一来源,见 docs/superpowers/specs C1 设计 §4)。 */ +export const TREAT_STATUSES = [ + 'pending_file', + 'waiting_period', + 'active', + 'hosp_pause', + 'review_waiting', + 'transferring', + 'dead', + 'long_away', + 'benefit_expired', + 'terminated', +] as const; + +/** 不可逆态:处于这些状态时禁止再变更。 */ +export const IRREVERSIBLE_TREAT_STATUSES: readonly string[] = ['dead', 'terminated']; + /** * 老人档案(elder)DTO。字段字典以 shared/data-model 为准(ltc_elder)。 * - 不含 tenantCode/createdBy/createdAt/updatedAt/deletedAt(系统字段,服务端注入); @@ -147,9 +164,10 @@ export class ListElderQuery extends PageQuery { } export class ChangeStatusDto { - @ApiProperty({ description: '目标待遇状态(十态之一)' }) + @ApiProperty({ description: '目标待遇状态(十态之一)', enum: TREAT_STATUSES }) @IsString() @IsNotEmpty() + @IsIn(TREAT_STATUSES) toStatus!: string; @ApiProperty({ description: '变更原因(必填)' }) diff --git a/src/elder/elder.service.spec.ts b/src/elder/elder.service.spec.ts index fd5a574..dcc1e20 100644 --- a/src/elder/elder.service.spec.ts +++ b/src/elder/elder.service.spec.ts @@ -41,6 +41,7 @@ function makeService( elderUpdate?: ReturnType; userRoleFindMany?: ReturnType; statusLogCreate?: ReturnType; + statusLogFindMany?: ReturnType; auditRecord?: ReturnType; } = {}, ) { @@ -52,6 +53,7 @@ function makeService( const elderUpdate = overrides.elderUpdate ?? vi.fn().mockResolvedValue({ id: 'e1' }); const userRoleFindMany = overrides.userRoleFindMany ?? vi.fn().mockResolvedValue([]); const statusLogCreate = overrides.statusLogCreate ?? vi.fn().mockResolvedValue({ id: 'sl1' }); + const statusLogFindMany = overrides.statusLogFindMany ?? vi.fn().mockResolvedValue([]); const auditRecord = overrides.auditRecord ?? vi.fn().mockResolvedValue(undefined); const prisma = { @@ -64,7 +66,7 @@ function makeService( create: elderCreate, update: elderUpdate, }, - elderStatusLog: { create: statusLogCreate, findMany: vi.fn().mockResolvedValue([]) }, + elderStatusLog: { create: statusLogCreate, findMany: statusLogFindMany }, userRole: { findMany: userRoleFindMany }, }, }; @@ -88,6 +90,7 @@ function makeService( elderUpdate, userRoleFindMany, statusLogCreate, + statusLogFindMany, auditRecord, storage, }; @@ -185,6 +188,44 @@ describe('ElderService', () => { }); }); + it('changeStatus 对不可逆态(dead/terminated)拒绝变更', async () => { + const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'dead', name: '张三' }); + const { service, statusLogCreate } = makeService({ elderFindUnique }); + + await expect( + runWithRequestContext(ctx, () => service.changeStatus('e1', { toStatus: 'active', changeReason: '恢复' })), + ).rejects.toThrowError('当前待遇状态不可再变更'); + + expect(statusLogCreate).not.toHaveBeenCalled(); + }); + + it('changeStatus 后 statusLog 返回写入的日志', async () => { + const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'waiting_period', name: '张三' }); + const statusLogFindMany = vi.fn().mockResolvedValue([ + { id: 'sl1', elderId: 'e1', fromStatus: 'waiting_period', toStatus: 'active', changeReason: '待遇生效', operatorId: 'u1' }, + ]); + const { service, statusLogCreate } = makeService({ elderFindUnique, statusLogFindMany }); + + await runWithRequestContext(ctx, () => service.changeStatus('e1', { toStatus: 'active', changeReason: '待遇生效' })); + const logs = await runWithRequestContext(ctx, () => service.statusLog('e1')); + + expect(statusLogCreate).toHaveBeenCalled(); + expect(logs).toHaveLength(1); + expect(logs[0]).toMatchObject({ fromStatus: 'waiting_period', toStatus: 'active', operatorId: 'u1' }); + }); + + it('create 待遇止早于起 → 抛 400', async () => { + const { service, elderCreate } = makeService(); + + await expect( + runWithRequestContext(ctx, () => + service.createElder({ ...baseDto, benefitStart: '2025-01-01', benefitEnd: '2024-01-01' }), + ), + ).rejects.toThrowError('待遇有效期止须晚于待遇有效期起'); + + expect(elderCreate).not.toHaveBeenCalled(); + }); + it('list 返回掩码 id_card_no', async () => { const elderFindMany = vi.fn().mockResolvedValue([ { id: 'e1', idCardNo: VALID_ID_CARD, name: '张三', treatStatus: 'active' }, diff --git a/src/elder/elder.service.ts b/src/elder/elder.service.ts index f8126c6..d992ae2 100644 --- a/src/elder/elder.service.ts +++ b/src/elder/elder.service.ts @@ -6,7 +6,7 @@ import { BusinessException } from '../common/business-exception.js'; import { ErrorCodes } from '../common/error-codes.js'; import { StorageService } from '../storage/storage.service.js'; import { AuditLogsService } from '../audit-logs/audit-logs.service.js'; -import { ChangeStatusDto, CreateElderDto, ListElderQuery } from './dto.js'; +import { ChangeStatusDto, CreateElderDto, IRREVERSIBLE_TREAT_STATUSES, ListElderQuery } from './dto.js'; /** * 身份证掩码:18 位 → 前4 + 10个* + 后4;15 位 → 前3 + 8个* + 后4;其余原样。 @@ -87,6 +87,7 @@ export class ElderService extends BaseCrudService { async createElder(dto: CreateElderDto) { const idCardNo = dto.idCardNo.trim(); const birthDate = deriveBirthDate(idCardNo); + this.assertBenefitRange(dto.benefitStart, dto.benefitEnd); await this.assertIdCardUnique(idCardNo); if ((dto.assessLevel || dto.careLevel) && !dto.assessEvidenceFile) { throw new BusinessException( @@ -135,6 +136,9 @@ export class ElderService extends BaseCrudService { ); } const elder = await this.requireExists(id); + if (IRREVERSIBLE_TREAT_STATUSES.includes(elder.treatStatus)) { + throw new BadRequestException('当前待遇状态不可再变更'); + } const ctx = getRequestContext(); const fromStatus = elder.treatStatus; await this.update(id, { @@ -193,6 +197,13 @@ export class ElderService extends BaseCrudService { } } + /** 待遇有效期须「止 > 起」。 */ + private assertBenefitRange(benefitStart?: string, benefitEnd?: string) { + if (benefitStart && benefitEnd && benefitEnd <= benefitStart) { + throw new BadRequestException('待遇有效期止须晚于待遇有效期起'); + } + } + private async requireExists(id: string): Promise> { const existing = await this.findById(id); if (!existing) {