feat(c2): 服务计划 + 老人个人项目库模块

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-14 22:27:34 +08:00
parent 515e22453c
commit 418dfb9dae
12 changed files with 950 additions and 0 deletions

View File

@ -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: [

20
src/elder-svc/dto.ts Normal file
View 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[];
}

View 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);
}
}

View 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 {}

View 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();
});
});

View 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
View 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
View 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: '' };
}

View 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
View 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 {}

View 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
View 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;
}
}