fix(c1): elder 十态校验/不可逆态守卫/待遇止>起 + statusLog 单测
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1308675f80
commit
0cbdb7f825
@ -2,6 +2,23 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { IsDateString, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
import { IsDateString, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
import { PageQuery } from '../common/page.dto.js';
|
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)。
|
* 老人档案(elder)DTO。字段字典以 shared/data-model 为准(ltc_elder)。
|
||||||
* - 不含 tenantCode/createdBy/createdAt/updatedAt/deletedAt(系统字段,服务端注入);
|
* - 不含 tenantCode/createdBy/createdAt/updatedAt/deletedAt(系统字段,服务端注入);
|
||||||
@ -147,9 +164,10 @@ export class ListElderQuery extends PageQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ChangeStatusDto {
|
export class ChangeStatusDto {
|
||||||
@ApiProperty({ description: '目标待遇状态(十态之一)' })
|
@ApiProperty({ description: '目标待遇状态(十态之一)', enum: TREAT_STATUSES })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@IsIn(TREAT_STATUSES)
|
||||||
toStatus!: string;
|
toStatus!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '变更原因(必填)' })
|
@ApiProperty({ description: '变更原因(必填)' })
|
||||||
|
|||||||
@ -41,6 +41,7 @@ function makeService(
|
|||||||
elderUpdate?: ReturnType<typeof vi.fn>;
|
elderUpdate?: ReturnType<typeof vi.fn>;
|
||||||
userRoleFindMany?: ReturnType<typeof vi.fn>;
|
userRoleFindMany?: ReturnType<typeof vi.fn>;
|
||||||
statusLogCreate?: ReturnType<typeof vi.fn>;
|
statusLogCreate?: ReturnType<typeof vi.fn>;
|
||||||
|
statusLogFindMany?: ReturnType<typeof vi.fn>;
|
||||||
auditRecord?: 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 elderUpdate = overrides.elderUpdate ?? vi.fn().mockResolvedValue({ id: 'e1' });
|
||||||
const userRoleFindMany = overrides.userRoleFindMany ?? vi.fn().mockResolvedValue([]);
|
const userRoleFindMany = overrides.userRoleFindMany ?? vi.fn().mockResolvedValue([]);
|
||||||
const statusLogCreate = overrides.statusLogCreate ?? vi.fn().mockResolvedValue({ id: 'sl1' });
|
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 auditRecord = overrides.auditRecord ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
const prisma = {
|
const prisma = {
|
||||||
@ -64,7 +66,7 @@ function makeService(
|
|||||||
create: elderCreate,
|
create: elderCreate,
|
||||||
update: elderUpdate,
|
update: elderUpdate,
|
||||||
},
|
},
|
||||||
elderStatusLog: { create: statusLogCreate, findMany: vi.fn().mockResolvedValue([]) },
|
elderStatusLog: { create: statusLogCreate, findMany: statusLogFindMany },
|
||||||
userRole: { findMany: userRoleFindMany },
|
userRole: { findMany: userRoleFindMany },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -88,6 +90,7 @@ function makeService(
|
|||||||
elderUpdate,
|
elderUpdate,
|
||||||
userRoleFindMany,
|
userRoleFindMany,
|
||||||
statusLogCreate,
|
statusLogCreate,
|
||||||
|
statusLogFindMany,
|
||||||
auditRecord,
|
auditRecord,
|
||||||
storage,
|
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 () => {
|
it('list 返回掩码 id_card_no', async () => {
|
||||||
const elderFindMany = vi.fn().mockResolvedValue([
|
const elderFindMany = vi.fn().mockResolvedValue([
|
||||||
{ id: 'e1', idCardNo: VALID_ID_CARD, name: '张三', treatStatus: 'active' },
|
{ id: 'e1', idCardNo: VALID_ID_CARD, name: '张三', treatStatus: 'active' },
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { BusinessException } from '../common/business-exception.js';
|
|||||||
import { ErrorCodes } from '../common/error-codes.js';
|
import { ErrorCodes } from '../common/error-codes.js';
|
||||||
import { StorageService } from '../storage/storage.service.js';
|
import { StorageService } from '../storage/storage.service.js';
|
||||||
import { AuditLogsService } from '../audit-logs/audit-logs.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;其余原样。
|
* 身份证掩码:18 位 → 前4 + 10个* + 后4;15 位 → 前3 + 8个* + 后4;其余原样。
|
||||||
@ -87,6 +87,7 @@ export class ElderService extends BaseCrudService {
|
|||||||
async createElder(dto: CreateElderDto) {
|
async createElder(dto: CreateElderDto) {
|
||||||
const idCardNo = dto.idCardNo.trim();
|
const idCardNo = dto.idCardNo.trim();
|
||||||
const birthDate = deriveBirthDate(idCardNo);
|
const birthDate = deriveBirthDate(idCardNo);
|
||||||
|
this.assertBenefitRange(dto.benefitStart, dto.benefitEnd);
|
||||||
await this.assertIdCardUnique(idCardNo);
|
await this.assertIdCardUnique(idCardNo);
|
||||||
if ((dto.assessLevel || dto.careLevel) && !dto.assessEvidenceFile) {
|
if ((dto.assessLevel || dto.careLevel) && !dto.assessEvidenceFile) {
|
||||||
throw new BusinessException(
|
throw new BusinessException(
|
||||||
@ -135,6 +136,9 @@ export class ElderService extends BaseCrudService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const elder = await this.requireExists(id);
|
const elder = await this.requireExists(id);
|
||||||
|
if (IRREVERSIBLE_TREAT_STATUSES.includes(elder.treatStatus)) {
|
||||||
|
throw new BadRequestException('当前待遇状态不可再变更');
|
||||||
|
}
|
||||||
const ctx = getRequestContext();
|
const ctx = getRequestContext();
|
||||||
const fromStatus = elder.treatStatus;
|
const fromStatus = elder.treatStatus;
|
||||||
await this.update(id, {
|
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>> {
|
private async requireExists(id: string): Promise<Record<string, any>> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user