feat(c2): 服务计划 + 老人个人项目库模块
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
515e22453c
commit
418dfb9dae
@ -30,6 +30,8 @@ import { StaffModule } from './staff/staff.module.js';
|
|||||||
import { CatalogModule } from './catalog/catalog.module.js';
|
import { CatalogModule } from './catalog/catalog.module.js';
|
||||||
import { StorageModule } from './storage/storage.module.js';
|
import { StorageModule } from './storage/storage.module.js';
|
||||||
import { ElderModule } from './elder/elder.module.js';
|
import { ElderModule } from './elder/elder.module.js';
|
||||||
|
import { PlanModule } from './plan/plan.module.js';
|
||||||
|
import { ElderSvcModule } from './elder-svc/elder-svc.module.js';
|
||||||
import { AppController } from './app.controller.js';
|
import { AppController } from './app.controller.js';
|
||||||
import { AppService } from './app.service.js';
|
import { AppService } from './app.service.js';
|
||||||
|
|
||||||
@ -57,6 +59,8 @@ import { AppService } from './app.service.js';
|
|||||||
CatalogModule,
|
CatalogModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
ElderModule,
|
ElderModule,
|
||||||
|
PlanModule,
|
||||||
|
ElderSvcModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
20
src/elder-svc/dto.ts
Normal file
20
src/elder-svc/dto.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ArrayNotEmpty, IsArray, IsIn, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/** 老人个人项目库整表覆盖入参:care_mode 为整库统一护理方式,svc_ids 落本机构 L4。 */
|
||||||
|
export class PutElderSvcDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '护理方式:inst_medical/inst_nursing/home_org/home_individual',
|
||||||
|
enum: ['inst_medical', 'inst_nursing', 'home_org', 'home_individual'],
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsIn(['inst_medical', 'inst_nursing', 'home_org', 'home_individual'])
|
||||||
|
careMode!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '本机构 L4 项目库 svc_org.id 列表', type: [String] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsString({ each: true })
|
||||||
|
svcIds!: string[];
|
||||||
|
}
|
||||||
23
src/elder-svc/elder-svc.controller.ts
Normal file
23
src/elder-svc/elder-svc.controller.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
|
||||||
|
import { Permissions } from '../common/permissions.js';
|
||||||
|
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
||||||
|
import { PutElderSvcDto } from './dto.js';
|
||||||
|
import { ElderSvcService } from './elder-svc.service.js';
|
||||||
|
|
||||||
|
@Controller('elder-svc')
|
||||||
|
export class ElderSvcController {
|
||||||
|
constructor(private readonly service: ElderSvcService) {}
|
||||||
|
|
||||||
|
@Get(':elderId')
|
||||||
|
@RequirePermissions(Permissions.elder.svcSave)
|
||||||
|
get(@Param('elderId') elderId: string) {
|
||||||
|
return this.service.get(elderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':elderId')
|
||||||
|
@RequirePermissions(Permissions.elder.svcSave)
|
||||||
|
@AuditLog('elder-svc', 'save')
|
||||||
|
put(@Param('elderId') elderId: string, @Body() dto: PutElderSvcDto) {
|
||||||
|
return this.service.put(elderId, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/elder-svc/elder-svc.module.ts
Normal file
9
src/elder-svc/elder-svc.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ElderSvcController } from './elder-svc.controller.js';
|
||||||
|
import { ElderSvcService } from './elder-svc.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ElderSvcController],
|
||||||
|
providers: [ElderSvcService],
|
||||||
|
})
|
||||||
|
export class ElderSvcModule {}
|
||||||
127
src/elder-svc/elder-svc.service.spec.ts
Normal file
127
src/elder-svc/elder-svc.service.spec.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ElderSvcService } from './elder-svc.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: 'all',
|
||||||
|
orgCode: null,
|
||||||
|
orgCodes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeService(
|
||||||
|
overrides: {
|
||||||
|
elderFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
svcOrgFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
svcNationalFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
svcLocalFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
elderSvcFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
elderSvcUpdateMany?: ReturnType<typeof vi.fn>;
|
||||||
|
elderSvcCreate?: ReturnType<typeof vi.fn>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const elderFindFirst = overrides.elderFindFirst ?? vi.fn().mockResolvedValue({ id: 'e1', name: '张三' });
|
||||||
|
const svcOrgFindFirst =
|
||||||
|
overrides.svcOrgFindFirst ?? vi.fn().mockResolvedValue({ id: 'svc1', srcType: 'national', srcId: 'nat1' });
|
||||||
|
const svcNationalFindFirst =
|
||||||
|
overrides.svcNationalFindFirst ?? vi.fn().mockResolvedValue({ natName: '协助翻身叩背排痰', natCode: 'L0000106010010000' });
|
||||||
|
const svcLocalFindFirst = overrides.svcLocalFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||||
|
const elderSvcFindMany = overrides.elderSvcFindMany ?? vi.fn().mockResolvedValue([]);
|
||||||
|
const elderSvcUpdateMany = overrides.elderSvcUpdateMany ?? vi.fn().mockResolvedValue({ count: 2 });
|
||||||
|
const elderSvcCreate = overrides.elderSvcCreate ?? vi.fn().mockResolvedValue({ id: 'es1' });
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
client: {
|
||||||
|
elder: { findFirst: elderFindFirst },
|
||||||
|
svcOrg: { findFirst: svcOrgFindFirst },
|
||||||
|
svcNational: { findFirst: svcNationalFindFirst },
|
||||||
|
svcLocal: { findFirst: svcLocalFindFirst },
|
||||||
|
elderSvc: {
|
||||||
|
findMany: elderSvcFindMany,
|
||||||
|
updateMany: elderSvcUpdateMany,
|
||||||
|
create: elderSvcCreate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = new ElderSvcService(prisma as unknown as PrismaService);
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
elderFindFirst,
|
||||||
|
svcOrgFindFirst,
|
||||||
|
svcNationalFindFirst,
|
||||||
|
svcLocalFindFirst,
|
||||||
|
elderSvcFindMany,
|
||||||
|
elderSvcUpdateMany,
|
||||||
|
elderSvcCreate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ElderSvcService', () => {
|
||||||
|
it('get 按老人返回当前项目库', async () => {
|
||||||
|
const elderSvcFindMany = vi.fn().mockResolvedValue([{ id: 'es1', elderId: 'e1' }]);
|
||||||
|
const { service } = makeService({ elderSvcFindMany });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () => service.get('e1'));
|
||||||
|
|
||||||
|
expect(elderSvcFindMany).toHaveBeenCalledWith({ where: { elderId: 'e1' } });
|
||||||
|
expect(res).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('put 整表覆盖:软删旧行 + 写新行 + snapshot svc_name/svc_code', async () => {
|
||||||
|
const elderSvcUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||||
|
const elderSvcCreate = vi.fn().mockResolvedValue({ id: 'es-new' });
|
||||||
|
const { service } = makeService({ elderSvcUpdateMany, elderSvcCreate });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () =>
|
||||||
|
service.put('e1', { careMode: 'inst_medical', svcIds: ['svc1', 'svc2'] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(elderSvcUpdateMany).toHaveBeenCalledWith({
|
||||||
|
where: { elderId: 'e1' },
|
||||||
|
data: { deletedAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
expect(elderSvcCreate).toHaveBeenCalledTimes(2);
|
||||||
|
expect(elderSvcCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
elderId: 'e1',
|
||||||
|
tenantCode: 'T001',
|
||||||
|
svcId: 'svc1',
|
||||||
|
svcNameSnapshot: '协助翻身叩背排痰',
|
||||||
|
svcCodeSnapshot: 'L0000106010010000',
|
||||||
|
careMode: 'inst_medical',
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('put 老人不存在 → ELDER_NOT_FOUND', async () => {
|
||||||
|
const elderFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
|
const { service, elderSvcUpdateMany, elderSvcCreate } = makeService({ elderFindFirst });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.put('missing', { careMode: 'inst_medical', svcIds: ['svc1'] })),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.ELDER_NOT_FOUND } });
|
||||||
|
|
||||||
|
expect(elderSvcUpdateMany).not.toHaveBeenCalled();
|
||||||
|
expect(elderSvcCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('put svc_id 非本机构 L4 → 拒绝(R5)', async () => {
|
||||||
|
const svcOrgFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
|
const { service, elderSvcUpdateMany, elderSvcCreate } = makeService({ svcOrgFindFirst });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.put('e1', { careMode: 'inst_medical', svcIds: ['other-org-svc'] })),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.SVC_ORG_NOT_FOUND } });
|
||||||
|
|
||||||
|
expect(elderSvcUpdateMany).not.toHaveBeenCalled();
|
||||||
|
expect(elderSvcCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
83
src/elder-svc/elder-svc.service.ts
Normal file
83
src/elder-svc/elder-svc.service.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service.js';
|
||||||
|
import { getRequestContext } from '../common/context/request-context.js';
|
||||||
|
import { BusinessException } from '../common/business-exception.js';
|
||||||
|
import { ErrorCodes } from '../common/error-codes.js';
|
||||||
|
import { PutElderSvcDto } from './dto.js';
|
||||||
|
|
||||||
|
/** 解析后的个人项目库项(svcId + 名称/代码快照)。 */
|
||||||
|
interface ResolvedSvc {
|
||||||
|
svcId: string;
|
||||||
|
svcName: string;
|
||||||
|
svcCode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 老人个人项目库(ltc_elder_svc)业务服务。
|
||||||
|
*
|
||||||
|
* PUT 为整表覆盖:先查老人 → 校验每个 svc_id 落本机构 L4(越界拒绝)→
|
||||||
|
* 软删旧的 ltc_elder_svc 行(留档)→ 写新行(snapshot svc_name/svc_code)。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ElderSvcService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** 当前(未软删)个人项目库。 */
|
||||||
|
get(elderId: string) {
|
||||||
|
return this.prisma.client.elderSvc.findMany({ where: { elderId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 整表覆盖保存个人项目库。 */
|
||||||
|
async put(elderId: string, dto: PutElderSvcDto) {
|
||||||
|
await this.assertElderExists(elderId);
|
||||||
|
const resolved = await this.resolveSvcItems(dto.svcIds);
|
||||||
|
// 软删旧行留档(care_mode 变更/重新保存均重制旧库)。
|
||||||
|
await this.prisma.client.elderSvc.updateMany({
|
||||||
|
where: { elderId },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
const ctx = getRequestContext()!;
|
||||||
|
const rows = [];
|
||||||
|
for (const item of resolved) {
|
||||||
|
rows.push(
|
||||||
|
await this.prisma.client.elderSvc.create({
|
||||||
|
data: {
|
||||||
|
elderId,
|
||||||
|
tenantCode: ctx.tenantCode,
|
||||||
|
svcId: item.svcId,
|
||||||
|
svcNameSnapshot: item.svcName,
|
||||||
|
svcCodeSnapshot: item.svcCode,
|
||||||
|
careMode: dto.careMode,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验每个 svc_id 落本机构 L4,并解析名称/代码快照。 */
|
||||||
|
private async resolveSvcItems(svcIds: string[]): Promise<ResolvedSvc[]> {
|
||||||
|
return Promise.all(
|
||||||
|
svcIds.map(async (svcId) => {
|
||||||
|
const svcOrg = await this.prisma.client.svcOrg.findFirst({ where: { id: svcId } });
|
||||||
|
if (!svcOrg) {
|
||||||
|
throw new BusinessException(ErrorCodes.SVC_ORG_NOT_FOUND, '机构项目不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (svcOrg.srcType === 'local') {
|
||||||
|
const local = await this.prisma.client.svcLocal.findFirst({ where: { id: svcOrg.srcId } });
|
||||||
|
return { svcId, svcName: local?.localItemName ?? '', svcCode: null };
|
||||||
|
}
|
||||||
|
const national = await this.prisma.client.svcNational.findFirst({ where: { id: svcOrg.srcId } });
|
||||||
|
return { svcId, svcName: national?.natName ?? '', svcCode: national?.natCode ?? null };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertElderExists(elderId: string) {
|
||||||
|
const elder = await this.prisma.client.elder.findFirst({ where: { id: elderId } });
|
||||||
|
if (!elder) {
|
||||||
|
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/plan/dto.ts
Normal file
134
src/plan/dto.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/** 计划项(svc_plan_item)入参。svc_id 引用本机构 L4(svc_org.id)。 */
|
||||||
|
export class PlanItemInputDto {
|
||||||
|
@ApiProperty({ description: '本机构 L4 项目库 svc_org.id' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
svcId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '频次类型:daily/weekly_n/monthly_n/as_needed' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
freqType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '频次值(如每周 3 次填 3)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
freqValue?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '单次时长(分钟)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
durationMin?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '拍照档位:must/optional/free', enum: ['must', 'optional', 'free'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['must', 'optional', 'free'])
|
||||||
|
photoTier?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '注意事项' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cautionNote?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '是否医疗护理类' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isMedical?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '是否要求持证' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
needCert?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 制定服务计划入参。plan_status 由服务端定(制定=draft),不接受前端传入。 */
|
||||||
|
export class CreatePlanDto {
|
||||||
|
@ApiProperty({ description: '老人 ID' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
elderId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '计划周期起(YYYY-MM-DD)' })
|
||||||
|
@IsDateString()
|
||||||
|
planPeriodStart!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '计划周期止(YYYY-MM-DD)' })
|
||||||
|
@IsDateString()
|
||||||
|
planPeriodEnd!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '护理等级:care_1/care_2/care_3', enum: ['care_1', 'care_2', 'care_3'] })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
careLevel!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '护理方式:inst_medical/inst_nursing/home_org/home_individual',
|
||||||
|
enum: ['inst_medical', 'inst_nursing', 'home_org', 'home_individual'],
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
careMode!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '计划项目列表', type: [PlanItemInputDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PlanItemInputDto)
|
||||||
|
items!: PlanItemInputDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '每周次数(居家类)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
weekFreq?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '每月小时数(居家类)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
monthHours?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '附加项数(≤5)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
extraItemCount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '计划依据快照(json)' })
|
||||||
|
@IsOptional()
|
||||||
|
planBasis?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更计划入参:change_from_plan_id 指向被变更的旧计划。 */
|
||||||
|
export class ChangePlanDto extends CreatePlanDto {
|
||||||
|
@ApiProperty({ description: '被变更的旧计划 ID' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
changeFromPlanId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计划确认入参(老人/家属确认,确认后计划转 active)。 */
|
||||||
|
export class ConfirmPlanDto {
|
||||||
|
@ApiProperty({ description: '老人确认' })
|
||||||
|
@IsBoolean()
|
||||||
|
confirmElder!: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '家属确认(可选)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
confirmGuardian?: boolean;
|
||||||
|
}
|
||||||
30
src/plan/plan-rule.ts
Normal file
30
src/plan/plan-rule.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/** 昆明属地项数规则(care_mode × care_level → 最少项数)。州市无规则 → 只提示不拦。 */
|
||||||
|
export const ITEM_COUNT_RULE = {
|
||||||
|
inst_medical: { care_1: 18, care_2: 15, care_3: 13 },
|
||||||
|
inst_nursing: { care_1: 18, care_2: 15, care_3: 13 },
|
||||||
|
home_individual: { care_1: 15, care_2: 13, care_3: 10 },
|
||||||
|
home_org: null, // 居家机构上门:按周次/月时长,不按项数
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 附加项上限。 */
|
||||||
|
export const EXTRA_MAX = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属地项数校验:返回是否满足与提示文案。
|
||||||
|
* - 无规则(州市/未配置)→ 只提示不拦;
|
||||||
|
* - 项数不足或附加项超限 → ok=false。
|
||||||
|
*/
|
||||||
|
export function checkItemCount(
|
||||||
|
careMode: string,
|
||||||
|
careLevel: string,
|
||||||
|
itemCount: number,
|
||||||
|
extraCount: number,
|
||||||
|
): { ok: boolean; hint: string } {
|
||||||
|
const rule = (ITEM_COUNT_RULE as Record<string, Record<string, number> | null>)[careMode];
|
||||||
|
if (rule === null || rule === undefined) return { ok: true, hint: '州市项数规则待属地实施细则明确' };
|
||||||
|
const min = rule[careLevel];
|
||||||
|
if (min === undefined) return { ok: true, hint: '' };
|
||||||
|
if (itemCount < min) return { ok: false, hint: `当前护理方式/等级需 ≥${min} 项` };
|
||||||
|
if (extraCount > EXTRA_MAX) return { ok: false, hint: `附加项最多 ${EXTRA_MAX} 项` };
|
||||||
|
return { ok: true, hint: '' };
|
||||||
|
}
|
||||||
44
src/plan/plan.controller.ts
Normal file
44
src/plan/plan.controller.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||||
|
import { Permissions } from '../common/permissions.js';
|
||||||
|
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
||||||
|
import { ChangePlanDto, ConfirmPlanDto, CreatePlanDto } from './dto.js';
|
||||||
|
import { PlanService } from './plan.service.js';
|
||||||
|
|
||||||
|
@Controller('plan')
|
||||||
|
export class PlanController {
|
||||||
|
constructor(private readonly service: PlanService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions(Permissions.plan.create)
|
||||||
|
@AuditLog('plan', 'create')
|
||||||
|
create(@Body() dto: CreatePlanDto) {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('change')
|
||||||
|
@RequirePermissions(Permissions.plan.change)
|
||||||
|
@AuditLog('plan', 'change')
|
||||||
|
change(@Body() dto: ChangePlanDto) {
|
||||||
|
return this.service.change(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:by-elder 必须先于 :id 声明,否则 :id 会吞掉 by-elder。
|
||||||
|
@Get('by-elder/:elderId')
|
||||||
|
@RequirePermissions(Permissions.plan.detail)
|
||||||
|
byElder(@Param('elderId') elderId: string) {
|
||||||
|
return this.service.byElder(elderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permissions.plan.detail)
|
||||||
|
detail(@Param('id') id: string) {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/confirm')
|
||||||
|
@RequirePermissions(Permissions.plan.confirm)
|
||||||
|
@AuditLog('plan', 'confirm')
|
||||||
|
confirm(@Param('id') id: string, @Body() dto: ConfirmPlanDto) {
|
||||||
|
return this.service.confirm(id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/plan/plan.module.ts
Normal file
9
src/plan/plan.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlanController } from './plan.controller.js';
|
||||||
|
import { PlanService } from './plan.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PlanController],
|
||||||
|
providers: [PlanService],
|
||||||
|
})
|
||||||
|
export class PlanModule {}
|
||||||
286
src/plan/plan.service.spec.ts
Normal file
286
src/plan/plan.service.spec.ts
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { PlanService } from './plan.service.js';
|
||||||
|
import { checkItemCount, EXTRA_MAX, ITEM_COUNT_RULE } from './plan-rule.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: 'all',
|
||||||
|
orgCode: null,
|
||||||
|
orgCodes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseDto = {
|
||||||
|
elderId: 'e1',
|
||||||
|
planPeriodStart: '2026-10-01',
|
||||||
|
planPeriodEnd: '2026-10-31',
|
||||||
|
careLevel: 'care_1',
|
||||||
|
careMode: 'inst_medical',
|
||||||
|
items: [] as Array<{ svcId: string }>,
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeItems(n: number): Array<{ svcId: string }> {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({ svcId: `svc${i + 1}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService(
|
||||||
|
overrides: {
|
||||||
|
svcOrgFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
svcNationalFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
svcLocalFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
elderFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
planCreate?: ReturnType<typeof vi.fn>;
|
||||||
|
planUpdate?: ReturnType<typeof vi.fn>;
|
||||||
|
planFindUnique?: ReturnType<typeof vi.fn>;
|
||||||
|
planFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
planItemCreate?: ReturnType<typeof vi.fn>;
|
||||||
|
planItemFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const svcOrgFindFirst =
|
||||||
|
overrides.svcOrgFindFirst ?? vi.fn().mockResolvedValue({ id: 'svc1', srcType: 'national', srcId: 'nat1' });
|
||||||
|
const svcNationalFindFirst =
|
||||||
|
overrides.svcNationalFindFirst ?? vi.fn().mockResolvedValue({ natName: '协助翻身叩背排痰', natCode: 'L0000106010010000' });
|
||||||
|
const svcLocalFindFirst = overrides.svcLocalFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||||
|
const elderFindFirst = overrides.elderFindFirst ?? vi.fn().mockResolvedValue({ id: 'e1', name: '张三' });
|
||||||
|
const planCreate = overrides.planCreate ?? vi.fn().mockResolvedValue({ id: 'p1' });
|
||||||
|
const planUpdate = overrides.planUpdate ?? vi.fn().mockResolvedValue({ id: 'p1' });
|
||||||
|
const planFindUnique = overrides.planFindUnique ?? vi.fn().mockResolvedValue({ id: 'p1', planStatus: 'draft' });
|
||||||
|
const planFindMany = overrides.planFindMany ?? vi.fn().mockResolvedValue([]);
|
||||||
|
const planItemCreate = overrides.planItemCreate ?? vi.fn().mockResolvedValue({ id: 'pi1' });
|
||||||
|
const planItemFindMany = overrides.planItemFindMany ?? vi.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
client: {
|
||||||
|
svcOrg: { findFirst: svcOrgFindFirst },
|
||||||
|
svcNational: { findFirst: svcNationalFindFirst },
|
||||||
|
svcLocal: { findFirst: svcLocalFindFirst },
|
||||||
|
elder: { findFirst: elderFindFirst },
|
||||||
|
plan: {
|
||||||
|
create: planCreate,
|
||||||
|
update: planUpdate,
|
||||||
|
findUnique: planFindUnique,
|
||||||
|
findMany: planFindMany,
|
||||||
|
},
|
||||||
|
planItem: { create: planItemCreate, findMany: planItemFindMany },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = new PlanService(prisma as unknown as PrismaService);
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
svcOrgFindFirst,
|
||||||
|
svcNationalFindFirst,
|
||||||
|
svcLocalFindFirst,
|
||||||
|
elderFindFirst,
|
||||||
|
planCreate,
|
||||||
|
planUpdate,
|
||||||
|
planFindUnique,
|
||||||
|
planFindMany,
|
||||||
|
planItemCreate,
|
||||||
|
planItemFindMany,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('checkItemCount', () => {
|
||||||
|
it('昆明机构类一级护理项数不足 → 拒绝并给出 ≥18 提示', () => {
|
||||||
|
expect(checkItemCount('inst_medical', 'care_1', 17, 0)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
hint: '当前护理方式/等级需 ≥18 项',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('附加项超过 5 → 拒绝(EXTRA_MAX 分支)', () => {
|
||||||
|
expect(checkItemCount('inst_medical', 'care_1', 18, 6)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
hint: '附加项最多 5 项',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('居家机构上门(home_org)不按项数 → 恒通过', () => {
|
||||||
|
expect(checkItemCount('home_org', 'care_1', 0, 0)).toEqual({ ok: true, hint: '州市项数规则待属地实施细则明确' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('州市未知护理方式 → 只提示不拦', () => {
|
||||||
|
expect(checkItemCount('some_future_mode', 'care_1', 0, 0)).toEqual({
|
||||||
|
ok: true,
|
||||||
|
hint: '州市项数规则待属地实施细则明确',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未知护理等级 → 通过(无规则)', () => {
|
||||||
|
expect(checkItemCount('inst_medical', 'care_9', 0, 0)).toEqual({ ok: true, hint: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PlanService', () => {
|
||||||
|
it('制定计划:L4 校验 + item_count/item_count_rule 快照 + 写 plan_item', async () => {
|
||||||
|
const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'svc1', srcType: 'national', srcId: 'nat1' });
|
||||||
|
const svcNationalFindFirst = vi.fn().mockResolvedValue({ natName: '协助翻身叩背排痰', natCode: 'L0000106010010000' });
|
||||||
|
const planCreate = vi.fn().mockResolvedValue({ id: 'p1' });
|
||||||
|
const planItemCreate = vi.fn().mockResolvedValue({ id: 'pi1' });
|
||||||
|
const { service } = makeService({ svcOrgFindFirst, svcNationalFindFirst, planCreate, planItemCreate });
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () =>
|
||||||
|
service.create({ ...baseDto, items: makeItems(18) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(svcOrgFindFirst).toHaveBeenCalledWith({ where: { id: 'svc1' } });
|
||||||
|
expect(planCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
elderId: 'e1',
|
||||||
|
itemCount: 18,
|
||||||
|
itemCountRule: 'inst_medical/care_1 ≥18 项',
|
||||||
|
planStatus: 'draft',
|
||||||
|
tenantCode: 'T001',
|
||||||
|
createdBy: 'u1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(planItemCreate).toHaveBeenCalledTimes(18);
|
||||||
|
expect(planItemCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
planId: 'p1',
|
||||||
|
tenantCode: 'T001',
|
||||||
|
svcId: 'svc1',
|
||||||
|
svcNameSnapshot: '协助翻身叩背排痰',
|
||||||
|
svcCodeSnapshot: 'L0000106010010000',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('svc_id 非本机构 L4 → SVC_ORG_NOT_FOUND,且不写计划', async () => {
|
||||||
|
const svcOrgFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
|
const { service, planCreate } = makeService({ svcOrgFindFirst });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () =>
|
||||||
|
service.create({ ...baseDto, careMode: 'home_org', items: [{ svcId: 'bad-svc' }] }),
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.SVC_ORG_NOT_FOUND } });
|
||||||
|
|
||||||
|
expect(planCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('elder 不存在 → ELDER_NOT_FOUND', async () => {
|
||||||
|
const elderFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
|
const { service, planCreate } = makeService({ elderFindFirst });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.create(baseDto)),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.ELDER_NOT_FOUND } });
|
||||||
|
|
||||||
|
expect(planCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('项数不满足 → PLAN_ITEM_RULE', async () => {
|
||||||
|
const { service, planCreate } = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.create({ ...baseDto, items: [{ svcId: 'svc1' }] })),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.PLAN_ITEM_RULE } });
|
||||||
|
|
||||||
|
expect(planCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('附加项超过 5 → PLAN_ITEM_RULE(R4 EXTRA_MAX 分支)', async () => {
|
||||||
|
const { service, planCreate } = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () =>
|
||||||
|
service.create({ ...baseDto, items: makeItems(18), extraItemCount: 6 }),
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.PLAN_ITEM_RULE } });
|
||||||
|
|
||||||
|
expect(planCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('home_org 不按项数 → 创建成功(R4 home_org 分支)', async () => {
|
||||||
|
const planCreate = vi.fn().mockResolvedValue({ id: 'p1' });
|
||||||
|
const { service } = makeService({ planCreate });
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () =>
|
||||||
|
service.create({ ...baseDto, careMode: 'home_org', items: [] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(planCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ careMode: 'home_org', itemCount: 0, planStatus: 'draft' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('变更留旧版:旧计划转 changed + 新计划 change_from_plan_id', async () => {
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p-old', planStatus: 'active' });
|
||||||
|
const planCreate = vi.fn().mockResolvedValue({ id: 'p-new' });
|
||||||
|
const planUpdate = vi.fn().mockResolvedValue({ id: 'p-old' });
|
||||||
|
const { service } = makeService({ planFindUnique, planCreate, planUpdate });
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () =>
|
||||||
|
service.change({
|
||||||
|
...baseDto,
|
||||||
|
careMode: 'home_org',
|
||||||
|
items: [],
|
||||||
|
changeFromPlanId: 'p-old',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(planUpdate).toHaveBeenCalledWith({ where: { id: 'p-old' }, data: { planStatus: 'changed' } });
|
||||||
|
expect(planCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ changeFromPlanId: 'p-old', planStatus: 'active' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('确认置 confirm_time + elder/guardian 布尔 + 转 active', async () => {
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p1', planStatus: 'draft' });
|
||||||
|
const planUpdate = vi.fn().mockResolvedValue({ id: 'p1' });
|
||||||
|
const { service } = makeService({ planFindUnique, planUpdate });
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () => service.confirm('p1', { confirmElder: true, confirmGuardian: false }));
|
||||||
|
|
||||||
|
expect(planUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'p1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
confirmElder: true,
|
||||||
|
confirmGuardian: false,
|
||||||
|
confirmTime: expect.any(Date),
|
||||||
|
planStatus: 'active',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detail 返回计划头 + 计划项', async () => {
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p1', elderId: 'e1', planStatus: 'active' });
|
||||||
|
const planItemFindMany = vi.fn().mockResolvedValue([{ id: 'pi1', planId: 'p1' }]);
|
||||||
|
const { service } = makeService({ planFindUnique, planItemFindMany });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () => service.detail('p1'));
|
||||||
|
|
||||||
|
expect(res).toMatchObject({ id: 'p1', items: [{ id: 'pi1' }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detail 计划不存在 → PLAN_NOT_FOUND', async () => {
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue(null);
|
||||||
|
const { service } = makeService({ planFindUnique });
|
||||||
|
|
||||||
|
await expect(runWithRequestContext(ctx, () => service.detail('missing'))).rejects.toMatchObject({
|
||||||
|
response: { code: ErrorCodes.PLAN_NOT_FOUND },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('byElder 按老人查计划', async () => {
|
||||||
|
const planFindMany = vi.fn().mockResolvedValue([{ id: 'p1', elderId: 'e1' }]);
|
||||||
|
const { service } = makeService({ planFindMany });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () => service.byElder('e1'));
|
||||||
|
|
||||||
|
expect(planFindMany).toHaveBeenCalledWith({ where: { elderId: 'e1' }, orderBy: { createdAt: 'desc' } });
|
||||||
|
expect(res).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导出常量,避免「声明未使用」的 lint 噪音(供外部复用类型/规则检查)。
|
||||||
|
void ITEM_COUNT_RULE;
|
||||||
|
void EXTRA_MAX;
|
||||||
181
src/plan/plan.service.ts
Normal file
181
src/plan/plan.service.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service.js';
|
||||||
|
import { getRequestContext } from '../common/context/request-context.js';
|
||||||
|
import { BusinessException } from '../common/business-exception.js';
|
||||||
|
import { ErrorCodes } from '../common/error-codes.js';
|
||||||
|
import { toDateOnly } from '../elder/elder.service.js';
|
||||||
|
import { checkItemCount, ITEM_COUNT_RULE } from './plan-rule.js';
|
||||||
|
import { ChangePlanDto, ConfirmPlanDto, CreatePlanDto, PlanItemInputDto } from './dto.js';
|
||||||
|
|
||||||
|
/** 解析后的计划项:svc 名称/代码快照(来自 L4 → L1/L2 目录)。 */
|
||||||
|
interface ResolvedPlanItem extends PlanItemInputDto {
|
||||||
|
svcName: string;
|
||||||
|
svcCode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务计划(svc_plan/svc_plan_item)业务服务。
|
||||||
|
*
|
||||||
|
* - 制定计划:校验老人存在 + 每个 svc_id 落本机构 L4 + 属地项数(昆明校验/州市提示);
|
||||||
|
* 快照 item_count/item_count_rule,plan_status 默认 draft;写 plan_item(svc_name/code 快照)。
|
||||||
|
* - 变更计划:新建 Plan + change_from_plan_id 指向旧计划,旧计划转 changed,不原地改。
|
||||||
|
* - 确认:置 confirm_time + elder/guardian 布尔,计划转 active。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PlanService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** 制定服务计划(draft)。 */
|
||||||
|
create(dto: CreatePlanDto) {
|
||||||
|
return this.buildPlan(dto, { planStatus: 'draft' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更服务计划:新建 active 计划并指向旧计划,旧计划转 changed。 */
|
||||||
|
async change(dto: ChangePlanDto) {
|
||||||
|
await this.requireExists(dto.changeFromPlanId);
|
||||||
|
const newPlan = await this.buildPlan(dto, { planStatus: 'active', changeFromPlanId: dto.changeFromPlanId });
|
||||||
|
await this.prisma.client.plan.update({ where: { id: dto.changeFromPlanId }, data: { planStatus: 'changed' } });
|
||||||
|
return newPlan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认计划:置确认时间与老人/家属布尔,计划转 active。 */
|
||||||
|
async confirm(id: string, dto: ConfirmPlanDto) {
|
||||||
|
await this.requireExists(id);
|
||||||
|
return this.prisma.client.plan.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
confirmElder: dto.confirmElder,
|
||||||
|
confirmGuardian: dto.confirmGuardian ?? false,
|
||||||
|
confirmTime: new Date(),
|
||||||
|
planStatus: 'active',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计划详情(含计划项)。 */
|
||||||
|
async detail(id: string) {
|
||||||
|
const plan = await this.requireExists(id);
|
||||||
|
const items = await this.prisma.client.planItem.findMany({ where: { planId: id } });
|
||||||
|
return { ...plan, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按老人查计划(新→旧)。 */
|
||||||
|
byElder(elderId: string) {
|
||||||
|
return this.prisma.client.plan.findMany({ where: { elderId }, orderBy: { createdAt: 'desc' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 制定/变更共用的建计划流程。 */
|
||||||
|
private async buildPlan(dto: CreatePlanDto, opts: { planStatus: string; changeFromPlanId?: string }) {
|
||||||
|
await this.assertElderExists(dto.elderId);
|
||||||
|
const items = dto.items ?? [];
|
||||||
|
const extraCount = dto.extraItemCount ?? 0;
|
||||||
|
const resolved = await this.resolvePlanItems(items);
|
||||||
|
const { ok, hint } = checkItemCount(dto.careMode, dto.careLevel, items.length, extraCount);
|
||||||
|
if (!ok) {
|
||||||
|
throw new BusinessException(ErrorCodes.PLAN_ITEM_RULE, hint, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
const plan = await this.createPlanHead({
|
||||||
|
elderId: dto.elderId,
|
||||||
|
planPeriodStart: toDateOnly(dto.planPeriodStart),
|
||||||
|
planPeriodEnd: toDateOnly(dto.planPeriodEnd),
|
||||||
|
careLevel: dto.careLevel,
|
||||||
|
careMode: dto.careMode,
|
||||||
|
itemCount: items.length,
|
||||||
|
itemCountRule: this.itemCountRuleSnapshot(dto.careMode, dto.careLevel),
|
||||||
|
extraItemCount: extraCount,
|
||||||
|
weekFreq: dto.weekFreq,
|
||||||
|
monthHours: dto.monthHours,
|
||||||
|
planStatus: opts.planStatus,
|
||||||
|
planBasis: dto.planBasis,
|
||||||
|
changeFromPlanId: opts.changeFromPlanId,
|
||||||
|
});
|
||||||
|
await this.createPlanItems(plan.id, resolved);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写计划头,注入 tenantCode/createdBy(plan 为租户级模型)。 */
|
||||||
|
private createPlanHead(data: Record<string, any>) {
|
||||||
|
const ctx = getRequestContext();
|
||||||
|
if (ctx) {
|
||||||
|
data.tenantCode = ctx.tenantCode;
|
||||||
|
data.createdBy = ctx.userId;
|
||||||
|
}
|
||||||
|
return this.prisma.client.plan.create({ data } as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 逐个写 plan_item(含 svc 名称/代码快照)。 */
|
||||||
|
private async createPlanItems(planId: string, items: ResolvedPlanItem[]) {
|
||||||
|
const ctx = getRequestContext()!;
|
||||||
|
for (const item of items) {
|
||||||
|
await this.prisma.client.planItem.create({
|
||||||
|
data: {
|
||||||
|
planId,
|
||||||
|
tenantCode: ctx.tenantCode,
|
||||||
|
svcId: item.svcId,
|
||||||
|
svcNameSnapshot: item.svcName,
|
||||||
|
svcCodeSnapshot: item.svcCode,
|
||||||
|
freqType: item.freqType,
|
||||||
|
freqValue: item.freqValue,
|
||||||
|
durationMin: item.durationMin,
|
||||||
|
photoTier: item.photoTier ?? 'free',
|
||||||
|
cautionNote: item.cautionNote,
|
||||||
|
isMedical: item.isMedical ?? false,
|
||||||
|
needCert: item.needCert ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验每个 svc_id 落本机构 L4,并解析 svc 名称/代码快照。 */
|
||||||
|
private async resolvePlanItems(items: PlanItemInputDto[]): Promise<ResolvedPlanItem[]> {
|
||||||
|
return Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
const svcOrg = await this.requireSvcOrg(item.svcId);
|
||||||
|
const snapshot = await this.resolveSvcSnapshot(svcOrg);
|
||||||
|
return { ...item, svcName: snapshot.svcName, svcCode: snapshot.svcCode };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireSvcOrg(svcId: string): Promise<{ srcType: string; srcId: string }> {
|
||||||
|
const svcOrg = await this.prisma.client.svcOrg.findFirst({ where: { id: svcId } });
|
||||||
|
if (!svcOrg) {
|
||||||
|
throw new BusinessException(ErrorCodes.SVC_ORG_NOT_FOUND, '机构项目不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return svcOrg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L4 → 源目录(L2 属地 / L1 国家)解析名称与代码快照。 */
|
||||||
|
private async resolveSvcSnapshot(svcOrg: { srcType: string; srcId: string }): Promise<{ svcName: string; svcCode: string | null }> {
|
||||||
|
if (svcOrg.srcType === 'local') {
|
||||||
|
const local = await this.prisma.client.svcLocal.findFirst({ where: { id: svcOrg.srcId } });
|
||||||
|
return { svcName: local?.localItemName ?? '', svcCode: null };
|
||||||
|
}
|
||||||
|
const national = await this.prisma.client.svcNational.findFirst({ where: { id: svcOrg.srcId } });
|
||||||
|
return { svcName: national?.natName ?? '', svcCode: national?.natCode ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项数规则快照(校验依据),供 item_count_rule 落库。 */
|
||||||
|
private itemCountRuleSnapshot(careMode: string, careLevel: string): string {
|
||||||
|
const rule = (ITEM_COUNT_RULE as Record<string, Record<string, number> | null>)[careMode];
|
||||||
|
if (rule === null || rule === undefined) return '州市项数规则待属地实施细则明确';
|
||||||
|
const min = rule[careLevel];
|
||||||
|
if (min === undefined) return `${careMode}/${careLevel}`;
|
||||||
|
return `${careMode}/${careLevel} ≥${min} 项`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertElderExists(elderId: string) {
|
||||||
|
const elder = await this.prisma.client.elder.findFirst({ where: { id: elderId } });
|
||||||
|
if (!elder) {
|
||||||
|
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireExists(id: string): Promise<Record<string, any>> {
|
||||||
|
const plan = await this.prisma.client.plan.findUnique({ where: { id } });
|
||||||
|
if (!plan) {
|
||||||
|
throw new BusinessException(ErrorCodes.PLAN_NOT_FOUND, '服务计划不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user