fix(c1): elder 十态校验/不可逆态守卫/待遇止>起 + statusLog 单测

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-14 17:29:41 +08:00
parent 1308675f80
commit 0cbdb7f825
3 changed files with 73 additions and 3 deletions

View File

@ -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: '变更原因(必填)' })

View File

@ -41,6 +41,7 @@ function makeService(
elderUpdate?: ReturnType<typeof vi.fn>;
userRoleFindMany?: ReturnType<typeof vi.fn>;
statusLogCreate?: ReturnType<typeof vi.fn>;
statusLogFindMany?: ReturnType<typeof vi.fn>;
auditRecord?: ReturnType<typeof vi.fn>;
} = {},
) {
@ -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' },

View File

@ -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<Record<string, any>> {
const existing = await this.findById(id);
if (!existing) {