diff --git a/src/app.module.ts b/src/app.module.ts index 36ee2ce..3e76fbc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -30,6 +30,8 @@ import { StaffModule } from './staff/staff.module.js'; import { CatalogModule } from './catalog/catalog.module.js'; import { StorageModule } from './storage/storage.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 { AppService } from './app.service.js'; @@ -57,6 +59,8 @@ import { AppService } from './app.service.js'; CatalogModule, StorageModule, ElderModule, + PlanModule, + ElderSvcModule, ], controllers: [AppController], providers: [ diff --git a/src/elder-svc/dto.ts b/src/elder-svc/dto.ts new file mode 100644 index 0000000..0d6f593 --- /dev/null +++ b/src/elder-svc/dto.ts @@ -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[]; +} diff --git a/src/elder-svc/elder-svc.controller.ts b/src/elder-svc/elder-svc.controller.ts new file mode 100644 index 0000000..942d1e5 --- /dev/null +++ b/src/elder-svc/elder-svc.controller.ts @@ -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); + } +} diff --git a/src/elder-svc/elder-svc.module.ts b/src/elder-svc/elder-svc.module.ts new file mode 100644 index 0000000..829aa63 --- /dev/null +++ b/src/elder-svc/elder-svc.module.ts @@ -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 {} diff --git a/src/elder-svc/elder-svc.service.spec.ts b/src/elder-svc/elder-svc.service.spec.ts new file mode 100644 index 0000000..efca83d --- /dev/null +++ b/src/elder-svc/elder-svc.service.spec.ts @@ -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; + svcOrgFindFirst?: ReturnType; + svcNationalFindFirst?: ReturnType; + svcLocalFindFirst?: ReturnType; + elderSvcFindMany?: ReturnType; + elderSvcUpdateMany?: ReturnType; + elderSvcCreate?: ReturnType; + } = {}, +) { + 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(); + }); +}); diff --git a/src/elder-svc/elder-svc.service.ts b/src/elder-svc/elder-svc.service.ts new file mode 100644 index 0000000..03f6559 --- /dev/null +++ b/src/elder-svc/elder-svc.service.ts @@ -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 { + 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); + } + } +} diff --git a/src/plan/dto.ts b/src/plan/dto.ts new file mode 100644 index 0000000..f8449aa --- /dev/null +++ b/src/plan/dto.ts @@ -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; +} diff --git a/src/plan/plan-rule.ts b/src/plan/plan-rule.ts new file mode 100644 index 0000000..93c8aa5 --- /dev/null +++ b/src/plan/plan-rule.ts @@ -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 | 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: '' }; +} diff --git a/src/plan/plan.controller.ts b/src/plan/plan.controller.ts new file mode 100644 index 0000000..23bd676 --- /dev/null +++ b/src/plan/plan.controller.ts @@ -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); + } +} diff --git a/src/plan/plan.module.ts b/src/plan/plan.module.ts new file mode 100644 index 0000000..a257886 --- /dev/null +++ b/src/plan/plan.module.ts @@ -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 {} diff --git a/src/plan/plan.service.spec.ts b/src/plan/plan.service.spec.ts new file mode 100644 index 0000000..8bf31d4 --- /dev/null +++ b/src/plan/plan.service.spec.ts @@ -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; + svcNationalFindFirst?: ReturnType; + svcLocalFindFirst?: ReturnType; + elderFindFirst?: ReturnType; + planCreate?: ReturnType; + planUpdate?: ReturnType; + planFindUnique?: ReturnType; + planFindMany?: ReturnType; + planItemCreate?: ReturnType; + planItemFindMany?: ReturnType; + } = {}, +) { + 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; diff --git a/src/plan/plan.service.ts b/src/plan/plan.service.ts new file mode 100644 index 0000000..d813b10 --- /dev/null +++ b/src/plan/plan.service.ts @@ -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) { + 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 { + 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 | 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> { + const plan = await this.prisma.client.plan.findUnique({ where: { id } }); + if (!plan) { + throw new BusinessException(ErrorCodes.PLAN_NOT_FOUND, '服务计划不存在', HttpStatus.NOT_FOUND); + } + return plan; + } +}