Compare commits
9 Commits
64c3efd931
...
66da809a2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66da809a2e | ||
|
|
8791ca494b | ||
|
|
00c8fa4d4c | ||
|
|
9c4fd6f4ca | ||
|
|
161a1253ee | ||
|
|
418dfb9dae | ||
|
|
515e22453c | ||
|
|
766d8c7218 | ||
|
|
312712f86c |
@ -32,7 +32,7 @@ REDIS_KEY_PREFIX="sysmgr:"
|
|||||||
|
|
||||||
# MinIO(对象存储,供 elder 模块上传评估结论副本)
|
# MinIO(对象存储,供 elder 模块上传评估结论副本)
|
||||||
MINIO_ENDPOINT="localhost"
|
MINIO_ENDPOINT="localhost"
|
||||||
MINIO_PORT=9000
|
MINIO_PORT=9010
|
||||||
MINIO_ACCESS_KEY="minioadmin"
|
MINIO_ACCESS_KEY="admin"
|
||||||
MINIO_SECRET_KEY="minioadmin"
|
MINIO_SECRET_KEY="admin111"
|
||||||
MINIO_BUCKET="carelink"
|
MINIO_BUCKET="carelink"
|
||||||
|
|||||||
102
prisma/migrations/20261014160000_c2_plan_order/migration.sql
Normal file
102
prisma/migrations/20261014160000_c2_plan_order/migration.sql
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `svc_plan` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`elder_id` VARCHAR(36) NOT NULL,
|
||||||
|
`plan_period_start` DATE NOT NULL,
|
||||||
|
`plan_period_end` DATE NOT NULL,
|
||||||
|
`care_level` VARCHAR(20) NOT NULL,
|
||||||
|
`care_mode` VARCHAR(20) NOT NULL,
|
||||||
|
`item_count` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`item_count_rule` VARCHAR(100) NULL,
|
||||||
|
`extra_item_count` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`week_freq` INTEGER NULL,
|
||||||
|
`month_hours` INTEGER NULL,
|
||||||
|
`plan_status` VARCHAR(20) NOT NULL DEFAULT 'draft',
|
||||||
|
`confirm_elder` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`confirm_guardian` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`confirm_time` DATETIME(3) NULL,
|
||||||
|
`plan_basis` JSON NULL,
|
||||||
|
`change_from_plan_id` VARCHAR(36) NULL,
|
||||||
|
`biz_line` VARCHAR(20) NOT NULL DEFAULT 'ltci',
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`created_by` VARCHAR(36) NULL,
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
`deleted_at` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
INDEX `svc_plan_tenant_code_elder_id_idx`(`tenant_code`, `elder_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `svc_plan_item` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`plan_id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`svc_id` VARCHAR(36) NOT NULL,
|
||||||
|
`svc_name_snapshot` VARCHAR(100) NOT NULL,
|
||||||
|
`svc_code_snapshot` VARCHAR(20) NULL,
|
||||||
|
`freq_type` VARCHAR(20) NULL,
|
||||||
|
`freq_value` INTEGER NULL,
|
||||||
|
`duration_min` INTEGER NULL,
|
||||||
|
`photo_tier` VARCHAR(10) NOT NULL DEFAULT 'free',
|
||||||
|
`caution_note` TEXT NULL,
|
||||||
|
`is_medical` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`need_cert` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
INDEX `svc_plan_item_plan_id_idx`(`plan_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `svc_order` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`elder_id` VARCHAR(36) NOT NULL,
|
||||||
|
`plan_id` VARCHAR(36) NOT NULL,
|
||||||
|
`service_date` DATE NOT NULL,
|
||||||
|
`time_slot_start` DATETIME(3) NOT NULL,
|
||||||
|
`time_slot_end` DATETIME(3) NOT NULL,
|
||||||
|
`assignee_id` VARCHAR(36) NULL,
|
||||||
|
`assign_time` DATETIME(3) NULL,
|
||||||
|
`assign_mode` VARCHAR(20) NULL,
|
||||||
|
`order_status` VARCHAR(20) NOT NULL DEFAULT 'to_assign',
|
||||||
|
`item_list` JSON NOT NULL,
|
||||||
|
`need_photo` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`cancel_reason` VARCHAR(200) NULL,
|
||||||
|
`reschedule_log` JSON NULL,
|
||||||
|
`qualification_check` VARCHAR(20) NULL,
|
||||||
|
`billable` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`price_snapshot` JSON NULL,
|
||||||
|
`settle_period` VARCHAR(20) NULL,
|
||||||
|
`not_met_reason` VARCHAR(50) NULL,
|
||||||
|
`not_met_at` DATETIME(3) NULL,
|
||||||
|
`biz_line` VARCHAR(20) NOT NULL DEFAULT 'ltci',
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`created_by` VARCHAR(36) NULL,
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
`deleted_at` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
INDEX `svc_order_tenant_code_assignee_id_service_date_idx`(`tenant_code`, `assignee_id`, `service_date`),
|
||||||
|
INDEX `svc_order_tenant_code_elder_id_idx`(`tenant_code`, `elder_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ltc_elder_svc` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`elder_id` VARCHAR(36) NOT NULL,
|
||||||
|
`svc_id` VARCHAR(36) NOT NULL,
|
||||||
|
`svc_name_snapshot` VARCHAR(100) NOT NULL,
|
||||||
|
`svc_code_snapshot` VARCHAR(20) NULL,
|
||||||
|
`care_mode` VARCHAR(20) NOT NULL,
|
||||||
|
`is_active` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`created_by` VARCHAR(36) NULL,
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
`deleted_at` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
INDEX `ltc_elder_svc_tenant_code_elder_id_idx`(`tenant_code`, `elder_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
@ -488,3 +488,106 @@ model ElderStatusLog {
|
|||||||
@@index([elderId])
|
@@index([elderId])
|
||||||
@@map("ltc_elder_status_log")
|
@@map("ltc_elder_status_log")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 服务计划头
|
||||||
|
model Plan {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
elderId String @map("elder_id") @db.VarChar(36)
|
||||||
|
planPeriodStart DateTime @map("plan_period_start") @db.Date
|
||||||
|
planPeriodEnd DateTime @map("plan_period_end") @db.Date
|
||||||
|
careLevel String @map("care_level") @db.VarChar(20)
|
||||||
|
careMode String @map("care_mode") @db.VarChar(20)
|
||||||
|
itemCount Int @default(0) @map("item_count")
|
||||||
|
itemCountRule String? @map("item_count_rule") @db.VarChar(100)
|
||||||
|
extraItemCount Int @default(0) @map("extra_item_count")
|
||||||
|
weekFreq Int? @map("week_freq")
|
||||||
|
monthHours Int? @map("month_hours")
|
||||||
|
planStatus String @default("draft") @map("plan_status") @db.VarChar(20)
|
||||||
|
confirmElder Boolean @default(false) @map("confirm_elder")
|
||||||
|
confirmGuardian Boolean @default(false) @map("confirm_guardian")
|
||||||
|
confirmTime DateTime? @map("confirm_time")
|
||||||
|
planBasis Json? @map("plan_basis")
|
||||||
|
changeFromPlanId String? @map("change_from_plan_id") @db.VarChar(36)
|
||||||
|
bizLine String @default("ltci") @map("biz_line") @db.VarChar(20)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
createdBy String? @map("created_by") @db.VarChar(36)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
|
@@index([tenantCode, elderId])
|
||||||
|
@@map("svc_plan")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 服务计划项
|
||||||
|
model PlanItem {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
planId String @map("plan_id") @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
svcId String @map("svc_id") @db.VarChar(36)
|
||||||
|
svcNameSnapshot String @map("svc_name_snapshot") @db.VarChar(100)
|
||||||
|
svcCodeSnapshot String? @map("svc_code_snapshot") @db.VarChar(20)
|
||||||
|
freqType String? @map("freq_type") @db.VarChar(20)
|
||||||
|
freqValue Int? @map("freq_value")
|
||||||
|
durationMin Int? @map("duration_min")
|
||||||
|
photoTier String @default("free") @map("photo_tier") @db.VarChar(10)
|
||||||
|
cautionNote String? @map("caution_note") @db.Text
|
||||||
|
isMedical Boolean @default(false) @map("is_medical")
|
||||||
|
needCert Boolean @default(false) @map("need_cert")
|
||||||
|
|
||||||
|
@@index([planId])
|
||||||
|
@@map("svc_plan_item")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 工单
|
||||||
|
model Order {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
elderId String @map("elder_id") @db.VarChar(36)
|
||||||
|
planId String @map("plan_id") @db.VarChar(36)
|
||||||
|
serviceDate DateTime @map("service_date") @db.Date
|
||||||
|
timeSlotStart DateTime @map("time_slot_start")
|
||||||
|
timeSlotEnd DateTime @map("time_slot_end")
|
||||||
|
assigneeId String? @map("assignee_id") @db.VarChar(36)
|
||||||
|
assignTime DateTime? @map("assign_time")
|
||||||
|
assignMode String? @map("assign_mode") @db.VarChar(20)
|
||||||
|
orderStatus String @default("to_assign") @map("order_status") @db.VarChar(20)
|
||||||
|
itemList Json @map("item_list")
|
||||||
|
needPhoto Boolean @default(false) @map("need_photo")
|
||||||
|
cancelReason String? @map("cancel_reason") @db.VarChar(200)
|
||||||
|
rescheduleLog Json? @map("reschedule_log")
|
||||||
|
qualificationCheck String? @map("qualification_check") @db.VarChar(20)
|
||||||
|
billable Boolean @default(true) @map("billable")
|
||||||
|
priceSnapshot Json? @map("price_snapshot")
|
||||||
|
settlePeriod String? @map("settle_period") @db.VarChar(20)
|
||||||
|
notMetReason String? @map("not_met_reason") @db.VarChar(50)
|
||||||
|
notMetAt DateTime? @map("not_met_at")
|
||||||
|
bizLine String @default("ltci") @map("biz_line") @db.VarChar(20)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
createdBy String? @map("created_by") @db.VarChar(36)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
|
@@index([tenantCode, assigneeId, serviceDate])
|
||||||
|
@@index([tenantCode, elderId])
|
||||||
|
@@map("svc_order")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 老人个人项目库
|
||||||
|
model ElderSvc {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
elderId String @map("elder_id") @db.VarChar(36)
|
||||||
|
svcId String @map("svc_id") @db.VarChar(36)
|
||||||
|
svcNameSnapshot String @map("svc_name_snapshot") @db.VarChar(100)
|
||||||
|
svcCodeSnapshot String? @map("svc_code_snapshot") @db.VarChar(20)
|
||||||
|
careMode String @map("care_mode") @db.VarChar(20)
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
createdBy String? @map("created_by") @db.VarChar(36)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
|
@@index([tenantCode, elderId])
|
||||||
|
@@map("ltc_elder_svc")
|
||||||
|
}
|
||||||
|
|||||||
@ -30,6 +30,9 @@ 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 { OrderModule } from './order/order.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 +60,9 @@ import { AppService } from './app.service.js';
|
|||||||
CatalogModule,
|
CatalogModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
ElderModule,
|
ElderModule,
|
||||||
|
PlanModule,
|
||||||
|
ElderSvcModule,
|
||||||
|
OrderModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -44,6 +44,12 @@ export const ErrorCodes = {
|
|||||||
ELDER_ID_CARD_EXISTS: 43002,
|
ELDER_ID_CARD_EXISTS: 43002,
|
||||||
ELDER_STATUS_REASON_REQUIRED: 43003,
|
ELDER_STATUS_REASON_REQUIRED: 43003,
|
||||||
ELDER_LEVEL_REQUIRES_EVIDENCE: 43004,
|
ELDER_LEVEL_REQUIRES_EVIDENCE: 43004,
|
||||||
|
// 服务机构端·计划/工单(44)
|
||||||
|
PLAN_NOT_FOUND: 44001,
|
||||||
|
PLAN_ITEM_RULE: 44002,
|
||||||
|
ORDER_NOT_FOUND: 44003,
|
||||||
|
ORDER_CONFLICT: 44004,
|
||||||
|
ORDER_STATUS_INVALID: 44005,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
||||||
|
|||||||
@ -98,6 +98,18 @@ export const APIS: { code: string; name: string; method: string; path: string; p
|
|||||||
{ code: Permissions.elder.statusChange, name: '变更待遇状态', method: 'POST', path: '/api/v1/elder/:id/status', parent: 'elder' },
|
{ code: Permissions.elder.statusChange, name: '变更待遇状态', method: 'POST', path: '/api/v1/elder/:id/status', parent: 'elder' },
|
||||||
{ code: Permissions.elder.detail, name: '状态日志', method: 'GET', path: '/api/v1/elder/:id/status/log', parent: 'elder' },
|
{ code: Permissions.elder.detail, name: '状态日志', method: 'GET', path: '/api/v1/elder/:id/status/log', parent: 'elder' },
|
||||||
{ code: Permissions.elder.create, name: '上传评估结论', method: 'POST', path: '/api/v1/elder/assess-evidence', parent: 'elder' },
|
{ code: Permissions.elder.create, name: '上传评估结论', method: 'POST', path: '/api/v1/elder/assess-evidence', parent: 'elder' },
|
||||||
|
// 服务机构端·计划/工单(C2)
|
||||||
|
{ code: Permissions.plan.create, name: '制定计划', method: 'POST', path: '/api/v1/plan', parent: 'elder' },
|
||||||
|
{ code: Permissions.plan.change, name: '变更计划', method: 'POST', path: '/api/v1/plan/change', parent: 'elder' },
|
||||||
|
{ code: Permissions.plan.detail, name: '计划详情', method: 'GET', path: '/api/v1/plan/:id', parent: 'elder' },
|
||||||
|
{ code: Permissions.plan.confirm, name: '确认计划', method: 'POST', path: '/api/v1/plan/:id/confirm', parent: 'elder' },
|
||||||
|
{ code: Permissions.order.generate, name: '生成工单', method: 'POST', path: '/api/v1/order/generate', parent: 'schedule' },
|
||||||
|
{ code: Permissions.order.assign, name: '派单', method: 'POST', path: '/api/v1/order/assign', parent: 'schedule' },
|
||||||
|
{ code: Permissions.order.reassign, name: '调单', method: 'POST', path: '/api/v1/order/:id/reassign', parent: 'schedule' },
|
||||||
|
{ code: Permissions.order.cancel, name: '取消工单', method: 'POST', path: '/api/v1/order/:id/cancel', parent: 'schedule' },
|
||||||
|
{ code: Permissions.order.detail, name: '工单详情', method: 'GET', path: '/api/v1/order/:id', parent: 'schedule' },
|
||||||
|
{ code: Permissions.order.today, name: '今日任务', method: 'GET', path: '/api/v1/order/today', parent: 'schedule' },
|
||||||
|
{ code: Permissions.elder.svcSave, name: '个人项目库', method: 'PUT', path: '/api/v1/elder-svc/:elderId', parent: 'elder' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
|
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
|
||||||
|
|||||||
19
src/elder-svc/dto.ts
Normal file
19
src/elder-svc/dto.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { 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()
|
||||||
|
@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 {}
|
||||||
144
src/elder-svc/elder-svc.service.spec.ts
Normal file
144
src/elder-svc/elder-svc.service.spec.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
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 空 svcIds 清空项目库:软删旧行、不写新行、返回空', async () => {
|
||||||
|
const elderSvcUpdateMany = vi.fn().mockResolvedValue({ count: 2 });
|
||||||
|
const elderSvcCreate = vi.fn();
|
||||||
|
const { service } = makeService({ elderSvcUpdateMany, elderSvcCreate });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () =>
|
||||||
|
service.put('e1', { careMode: 'inst_medical', svcIds: [] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(elderSvcUpdateMany).toHaveBeenCalledWith({
|
||||||
|
where: { elderId: 'e1' },
|
||||||
|
data: { deletedAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
expect(elderSvcCreate).not.toHaveBeenCalled();
|
||||||
|
expect(res).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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/order/dto.ts
Normal file
134
src/order/dto.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsDateString, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/** 工单八态(order_status)。 */
|
||||||
|
export const ORDER_STATUSES = [
|
||||||
|
'to_assign',
|
||||||
|
'assigned',
|
||||||
|
'serving',
|
||||||
|
'done',
|
||||||
|
'abnormal',
|
||||||
|
'cancelled',
|
||||||
|
'overdue',
|
||||||
|
'not_met',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 派单方式:manual(人工)/ batch_recommend(批量推荐确认)。 */
|
||||||
|
export const ASSIGN_MODES = ['manual', 'batch_recommend'] as const;
|
||||||
|
|
||||||
|
/** 生成工单入参。plan_id 指向 active 计划,item_list/need_photo 由服务端从 plan_item 快照生成。 */
|
||||||
|
export class GenerateOrderDto {
|
||||||
|
@ApiProperty({ description: '老人 ID' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
elderId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '服务计划 ID(须为 active)' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
planId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '服务日期(YYYY-MM-DD)' })
|
||||||
|
@IsDateString()
|
||||||
|
serviceDate!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '时段起(ISO datetime)' })
|
||||||
|
@IsDateString()
|
||||||
|
timeSlotStart!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '时段止(ISO datetime)' })
|
||||||
|
@IsDateString()
|
||||||
|
timeSlotEnd!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 派单入参。工单态须为 to_assign,校验老人待遇/计划/当班/时间冲突/资质。 */
|
||||||
|
export class AssignOrderDto {
|
||||||
|
@ApiProperty({ description: '工单 ID' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
orderId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '护理员 staff.id' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
assigneeId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '派单方式:manual/batch_recommend', enum: ASSIGN_MODES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ASSIGN_MODES)
|
||||||
|
assignMode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调单入参:改派护理员与/或改时段(均可选,缺省保留原值);重新校验时间冲突并写 reschedule_log。 */
|
||||||
|
export class ReassignOrderDto {
|
||||||
|
@ApiPropertyOptional({ description: '新护理员 staff.id(缺省保留原护理员)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
assigneeId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '新时段起(ISO datetime)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
timeSlotStart?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '新时段止(ISO datetime)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
timeSlotEnd?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '调单原因(可选,留痕用)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消工单入参:cancel_reason 必填。 */
|
||||||
|
export class CancelOrderDto {
|
||||||
|
@ApiProperty({ description: '取消原因(必填)' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
cancelReason!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 今日工单查询:service_date 缺省为今天。 */
|
||||||
|
export class TodayOrderQuery {
|
||||||
|
@ApiPropertyOptional({ description: '服务日期(YYYY-MM-DD,缺省今天)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
serviceDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量推荐查询:推荐某老人某时段的可选护理员(只读,不自动派)。 */
|
||||||
|
export class BatchRecommendQuery {
|
||||||
|
@ApiProperty({ description: '老人 ID' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
elderId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '服务日期(YYYY-MM-DD)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
serviceDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '时段起(ISO datetime)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
timeSlotStart?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '时段止(ISO datetime)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
timeSlotEnd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按日列表查询:排班派单日视图加载当日工单。 */
|
||||||
|
export class ListOrderQuery {
|
||||||
|
@ApiProperty({ description: '服务日期(YYYY-MM-DD)' })
|
||||||
|
@IsDateString()
|
||||||
|
serviceDate!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '护理员 staff.id(可选过滤)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
assigneeId?: string;
|
||||||
|
}
|
||||||
71
src/order/order.controller.ts
Normal file
71
src/order/order.controller.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
|
import { Permissions } from '../common/permissions.js';
|
||||||
|
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
||||||
|
import {
|
||||||
|
AssignOrderDto,
|
||||||
|
BatchRecommendQuery,
|
||||||
|
CancelOrderDto,
|
||||||
|
GenerateOrderDto,
|
||||||
|
ListOrderQuery,
|
||||||
|
ReassignOrderDto,
|
||||||
|
TodayOrderQuery,
|
||||||
|
} from './dto.js';
|
||||||
|
import { OrderService } from './order.service.js';
|
||||||
|
|
||||||
|
@Controller('order')
|
||||||
|
export class OrderController {
|
||||||
|
constructor(private readonly service: OrderService) {}
|
||||||
|
|
||||||
|
@Post('generate')
|
||||||
|
@RequirePermissions(Permissions.order.generate)
|
||||||
|
@AuditLog('order', 'generate')
|
||||||
|
generate(@Body() dto: GenerateOrderDto) {
|
||||||
|
return this.service.generate(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('assign')
|
||||||
|
@RequirePermissions(Permissions.order.assign)
|
||||||
|
@AuditLog('order', 'assign')
|
||||||
|
assign(@Body() dto: AssignOrderDto) {
|
||||||
|
return this.service.assign(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:静态路径 batch-recommend/today 与列表根路径必须先于 :id 声明,否则 :id 会吞掉它们。
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.order.detail)
|
||||||
|
list(@Query() q: ListOrderQuery) {
|
||||||
|
return this.service.list(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('batch-recommend')
|
||||||
|
@RequirePermissions(Permissions.order.assign)
|
||||||
|
batchRecommend(@Query() q: BatchRecommendQuery) {
|
||||||
|
return this.service.batchRecommend(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('today')
|
||||||
|
@RequirePermissions(Permissions.order.today)
|
||||||
|
today(@Query() q: TodayOrderQuery) {
|
||||||
|
return this.service.today(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reassign')
|
||||||
|
@RequirePermissions(Permissions.order.reassign)
|
||||||
|
@AuditLog('order', 'reassign')
|
||||||
|
reassign(@Param('id') id: string, @Body() dto: ReassignOrderDto) {
|
||||||
|
return this.service.reassign(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/cancel')
|
||||||
|
@RequirePermissions(Permissions.order.cancel)
|
||||||
|
@AuditLog('order', 'cancel')
|
||||||
|
cancel(@Param('id') id: string, @Body() dto: CancelOrderDto) {
|
||||||
|
return this.service.cancel(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permissions.order.detail)
|
||||||
|
detail(@Param('id') id: string) {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/order/order.module.ts
Normal file
9
src/order/order.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { OrderController } from './order.controller.js';
|
||||||
|
import { OrderService } from './order.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [OrderController],
|
||||||
|
providers: [OrderService],
|
||||||
|
})
|
||||||
|
export class OrderModule {}
|
||||||
414
src/order/order.service.spec.ts
Normal file
414
src/order/order.service.spec.ts
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { OrderService, isTimeOverlap } from './order.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: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = (hh: string, mm: string) => new Date(`2026-10-14T${hh}:${mm}:00Z`);
|
||||||
|
|
||||||
|
function makeOrder(overrides: Record<string, unknown> = {}): Record<string, any> {
|
||||||
|
return {
|
||||||
|
id: 'o1',
|
||||||
|
tenantCode: 'T001',
|
||||||
|
elderId: 'e1',
|
||||||
|
planId: 'p1',
|
||||||
|
serviceDate: new Date('2026-10-14T00:00:00Z'),
|
||||||
|
timeSlotStart: t('09', '00'),
|
||||||
|
timeSlotEnd: t('10', '00'),
|
||||||
|
assigneeId: null,
|
||||||
|
assignTime: null,
|
||||||
|
assignMode: null,
|
||||||
|
orderStatus: 'to_assign',
|
||||||
|
itemList: [],
|
||||||
|
needPhoto: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService(
|
||||||
|
overrides: {
|
||||||
|
orderFindUnique?: ReturnType<typeof vi.fn>;
|
||||||
|
orderFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
orderCreate?: ReturnType<typeof vi.fn>;
|
||||||
|
orderUpdate?: ReturnType<typeof vi.fn>;
|
||||||
|
orderCount?: ReturnType<typeof vi.fn>;
|
||||||
|
planFindUnique?: ReturnType<typeof vi.fn>;
|
||||||
|
planFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
planItemFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
elderFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
staffFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
staffFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const orderFindUnique = overrides.orderFindUnique ?? vi.fn().mockResolvedValue(null);
|
||||||
|
const orderFindMany = overrides.orderFindMany ?? vi.fn().mockResolvedValue([]);
|
||||||
|
const orderCreate = overrides.orderCreate ?? vi.fn().mockResolvedValue(makeOrder());
|
||||||
|
const orderUpdate = overrides.orderUpdate ?? vi.fn().mockResolvedValue(makeOrder());
|
||||||
|
const orderCount = overrides.orderCount ?? vi.fn().mockResolvedValue(0);
|
||||||
|
const planFindUnique = overrides.planFindUnique ?? vi.fn().mockResolvedValue(null);
|
||||||
|
const planFindFirst = overrides.planFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||||
|
const planItemFindMany = overrides.planItemFindMany ?? vi.fn().mockResolvedValue([]);
|
||||||
|
const elderFindFirst = overrides.elderFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||||
|
const staffFindFirst = overrides.staffFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||||
|
const staffFindMany = overrides.staffFindMany ?? vi.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
client: {
|
||||||
|
order: {
|
||||||
|
findUnique: orderFindUnique,
|
||||||
|
findMany: orderFindMany,
|
||||||
|
create: orderCreate,
|
||||||
|
update: orderUpdate,
|
||||||
|
count: orderCount,
|
||||||
|
},
|
||||||
|
plan: { findUnique: planFindUnique, findFirst: planFindFirst },
|
||||||
|
planItem: { findMany: planItemFindMany },
|
||||||
|
elder: { findFirst: elderFindFirst },
|
||||||
|
staff: { findFirst: staffFindFirst, findMany: staffFindMany },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = new OrderService(prisma as unknown as PrismaService);
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
orderFindUnique,
|
||||||
|
orderFindMany,
|
||||||
|
orderCreate,
|
||||||
|
orderUpdate,
|
||||||
|
orderCount,
|
||||||
|
planFindUnique,
|
||||||
|
planFindFirst,
|
||||||
|
planItemFindMany,
|
||||||
|
elderFindFirst,
|
||||||
|
staffFindFirst,
|
||||||
|
staffFindMany,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isTimeOverlap', () => {
|
||||||
|
it('同 assignee 时段重叠 → true', () => {
|
||||||
|
expect(isTimeOverlap(t('09', '00'), t('10', '00'), t('09', '30'), t('10', '30'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('首尾相接(09:00-10:00 vs 10:00-11:00)不算重叠', () => {
|
||||||
|
expect(isTimeOverlap(t('09', '00'), t('10', '00'), t('10', '00'), t('11', '00'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('完全不相邻时段 → false', () => {
|
||||||
|
expect(isTimeOverlap(t('09', '00'), t('10', '00'), t('11', '00'), t('12', '00'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OrderService.generate', () => {
|
||||||
|
it('无 active 计划 → 拒绝(1002)', async () => {
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p1', elderId: 'e1', planStatus: 'draft' });
|
||||||
|
const { service, orderCreate } = makeService({ planFindUnique });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () =>
|
||||||
|
service.generate({
|
||||||
|
elderId: 'e1',
|
||||||
|
planId: 'p1',
|
||||||
|
serviceDate: '2026-10-14',
|
||||||
|
timeSlotStart: '2026-10-14T09:00:00Z',
|
||||||
|
timeSlotEnd: '2026-10-14T10:00:00Z',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: { code: ErrorCodes.ORDER_STATUS_INVALID, message: expect.stringContaining('1002') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(orderCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有 active 计划 → 从 plan_item 生成 item_list 快照 + need_photo', async () => {
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p1', elderId: 'e1', planStatus: 'active' });
|
||||||
|
const planItemFindMany = vi.fn().mockResolvedValue([
|
||||||
|
{ svcId: 'svc1', svcNameSnapshot: '协助翻身', svcCodeSnapshot: 'C001', photoTier: 'must', needCert: true },
|
||||||
|
{ svcId: 'svc2', svcNameSnapshot: '助浴', svcCodeSnapshot: 'C002', photoTier: 'free', needCert: false },
|
||||||
|
]);
|
||||||
|
const orderCreate = vi.fn().mockResolvedValue(makeOrder());
|
||||||
|
const { service } = makeService({ planFindUnique, planItemFindMany, orderCreate });
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () =>
|
||||||
|
service.generate({
|
||||||
|
elderId: 'e1',
|
||||||
|
planId: 'p1',
|
||||||
|
serviceDate: '2026-10-14',
|
||||||
|
timeSlotStart: '2026-10-14T09:00:00Z',
|
||||||
|
timeSlotEnd: '2026-10-14T10:00:00Z',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(orderCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
tenantCode: 'T001',
|
||||||
|
createdBy: 'u1',
|
||||||
|
elderId: 'e1',
|
||||||
|
planId: 'p1',
|
||||||
|
orderStatus: 'to_assign',
|
||||||
|
needPhoto: true,
|
||||||
|
itemList: [
|
||||||
|
expect.objectContaining({ svcId: 'svc1', svcName: '协助翻身', svcCode: 'C001' }),
|
||||||
|
expect.objectContaining({ svcId: 'svc2', svcName: '助浴', svcCode: 'C002' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无时区偏移的 09:00:00 时段 → 按北京时间解析为 01:00:00Z', async () => {
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p1', elderId: 'e1', planStatus: 'active' });
|
||||||
|
const orderCreate = vi.fn().mockResolvedValue(makeOrder());
|
||||||
|
const { service } = makeService({ planFindUnique, orderCreate });
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () =>
|
||||||
|
service.generate({
|
||||||
|
elderId: 'e1',
|
||||||
|
planId: 'p1',
|
||||||
|
serviceDate: '2026-10-14',
|
||||||
|
timeSlotStart: '2026-10-14T09:00:00',
|
||||||
|
timeSlotEnd: '2026-10-14T10:00:00',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(orderCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
timeSlotStart: new Date('2026-10-14T01:00:00Z'),
|
||||||
|
timeSlotEnd: new Date('2026-10-14T02:00:00Z'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OrderService.assign', () => {
|
||||||
|
const baseAssign = { orderId: 'o1', assigneeId: 's1', assignMode: 'manual' };
|
||||||
|
|
||||||
|
it('waiting_period 允许派单', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
||||||
|
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'waiting_period' });
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p1', elderId: 'e1', planStatus: 'active' });
|
||||||
|
const staffFindFirst = vi.fn().mockResolvedValue({ id: 's1', employStatus: 'on', certNo: null, certType: null });
|
||||||
|
const orderUpdate = vi.fn().mockResolvedValue(makeOrder({ orderStatus: 'assigned' }));
|
||||||
|
const { service } = makeService({
|
||||||
|
orderFindUnique,
|
||||||
|
elderFindFirst,
|
||||||
|
planFindUnique,
|
||||||
|
staffFindFirst,
|
||||||
|
orderUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () => service.assign(baseAssign));
|
||||||
|
|
||||||
|
expect(res.orderStatus).toBe('assigned');
|
||||||
|
expect(orderUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'o1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
orderStatus: 'assigned',
|
||||||
|
assigneeId: 's1',
|
||||||
|
assignMode: 'manual',
|
||||||
|
assignTime: expect.any(Date),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('benefit_expired 拒绝(1001)', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
||||||
|
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'benefit_expired' });
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p1', elderId: 'e1', planStatus: 'active' });
|
||||||
|
const { service, orderUpdate } = makeService({ orderFindUnique, elderFindFirst, planFindUnique });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.assign(baseAssign)),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: { code: ErrorCodes.ORDER_STATUS_INVALID, message: expect.stringContaining('1001') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(orderUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('计划已变更(changed)→ 仍可派单(历史工单指向旧计划)', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
||||||
|
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' });
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p1', elderId: 'e1', planStatus: 'changed' });
|
||||||
|
const staffFindFirst = vi.fn().mockResolvedValue({ id: 's1', employStatus: 'on', certNo: null, certType: null });
|
||||||
|
const orderUpdate = vi.fn().mockResolvedValue(makeOrder({ orderStatus: 'assigned' }));
|
||||||
|
const { service } = makeService({
|
||||||
|
orderFindUnique,
|
||||||
|
elderFindFirst,
|
||||||
|
planFindUnique,
|
||||||
|
staffFindFirst,
|
||||||
|
orderUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () => service.assign(baseAssign));
|
||||||
|
|
||||||
|
expect(res.orderStatus).toBe('assigned');
|
||||||
|
expect(orderUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同 assignee 时段重叠 → ORDER_CONFLICT(2001)拒绝', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
||||||
|
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' });
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p1', elderId: 'e1', planStatus: 'active' });
|
||||||
|
const staffFindFirst = vi.fn().mockResolvedValue({ id: 's1', employStatus: 'on', certNo: null, certType: null });
|
||||||
|
const orderFindMany = vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'o-other', assigneeId: 's1', timeSlotStart: t('09', '30'), timeSlotEnd: t('10', '30') },
|
||||||
|
]);
|
||||||
|
const { service, orderUpdate } = makeService({
|
||||||
|
orderFindUnique,
|
||||||
|
elderFindFirst,
|
||||||
|
planFindUnique,
|
||||||
|
staffFindFirst,
|
||||||
|
orderFindMany,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.assign(baseAssign)),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: { code: ErrorCodes.ORDER_CONFLICT, message: expect.stringContaining('2001') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(orderUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('首尾相接时段不算冲突 → 派单成功', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
||||||
|
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' });
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p1', elderId: 'e1', planStatus: 'active' });
|
||||||
|
const staffFindFirst = vi.fn().mockResolvedValue({ id: 's1', employStatus: 'on', certNo: null, certType: null });
|
||||||
|
const orderFindMany = vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'o-other', assigneeId: 's1', timeSlotStart: t('10', '00'), timeSlotEnd: t('11', '00') },
|
||||||
|
]);
|
||||||
|
const orderUpdate = vi.fn().mockResolvedValue(makeOrder({ orderStatus: 'assigned' }));
|
||||||
|
const { service } = makeService({
|
||||||
|
orderFindUnique,
|
||||||
|
elderFindFirst,
|
||||||
|
planFindUnique,
|
||||||
|
staffFindFirst,
|
||||||
|
orderFindMany,
|
||||||
|
orderUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () => service.assign(baseAssign));
|
||||||
|
|
||||||
|
expect(res.orderStatus).toBe('assigned');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OrderService.reassign', () => {
|
||||||
|
it('调单写 reschedule_log(改前改后)', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue(
|
||||||
|
makeOrder({
|
||||||
|
orderStatus: 'assigned',
|
||||||
|
assigneeId: 's-old',
|
||||||
|
assignTime: new Date('2026-10-14T08:00:00Z'),
|
||||||
|
assignMode: 'manual',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const staffFindFirst = vi.fn().mockResolvedValue({ id: 's-new', employStatus: 'on' });
|
||||||
|
const orderFindMany = vi.fn().mockResolvedValue([]);
|
||||||
|
const orderUpdate = vi.fn().mockResolvedValue(makeOrder({ orderStatus: 'assigned', assigneeId: 's-new' }));
|
||||||
|
const { service } = makeService({
|
||||||
|
orderFindUnique,
|
||||||
|
staffFindFirst,
|
||||||
|
orderFindMany,
|
||||||
|
orderUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () =>
|
||||||
|
service.reassign('o1', { assigneeId: 's-new', timeSlotStart: '2026-10-14T11:00:00Z', timeSlotEnd: '2026-10-14T12:00:00Z' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(orderUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'o1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
assigneeId: 's-new',
|
||||||
|
timeSlotStart: expect.any(Date),
|
||||||
|
timeSlotEnd: expect.any(Date),
|
||||||
|
rescheduleLog: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
from: expect.objectContaining({ assigneeId: 's-old' }),
|
||||||
|
to: expect.objectContaining({ assigneeId: 's-new' }),
|
||||||
|
operatorId: 'u1',
|
||||||
|
at: expect.any(Date),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('已完单(done)→ 拒绝调单', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder({ orderStatus: 'done' }));
|
||||||
|
const { service, orderUpdate } = makeService({ orderFindUnique });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.reassign('o1', { assigneeId: 's-new' })),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.ORDER_STATUS_INVALID } });
|
||||||
|
|
||||||
|
expect(orderUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OrderService.cancel', () => {
|
||||||
|
it('缺 cancel_reason → 拒绝', async () => {
|
||||||
|
const { service, orderUpdate } = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.cancel('o1', { cancelReason: '' })),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.ORDER_STATUS_INVALID } });
|
||||||
|
|
||||||
|
expect(orderUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有 cancel_reason → order_status=cancelled', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
||||||
|
const orderUpdate = vi.fn().mockResolvedValue(makeOrder({ orderStatus: 'cancelled' }));
|
||||||
|
const { service } = makeService({ orderFindUnique, orderUpdate });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () => service.cancel('o1', { cancelReason: '老人转院' }));
|
||||||
|
|
||||||
|
expect(res.orderStatus).toBe('cancelled');
|
||||||
|
expect(orderUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'o1' },
|
||||||
|
data: { orderStatus: 'cancelled', cancelReason: '老人转院' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OrderService.list', () => {
|
||||||
|
it('按 serviceDate 过滤(可选 assigneeId)', async () => {
|
||||||
|
const orderFindMany = vi.fn().mockResolvedValue([{ id: 'o1' }]);
|
||||||
|
const { service } = makeService({ orderFindMany });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () =>
|
||||||
|
service.list({ serviceDate: '2026-10-14', assigneeId: 's1' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res).toHaveLength(1);
|
||||||
|
expect(orderFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { serviceDate: new Date('2026-10-14T00:00:00Z'), assigneeId: 's1' },
|
||||||
|
orderBy: { timeSlotStart: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未传 assigneeId 时不加该过滤条件', async () => {
|
||||||
|
const orderFindMany = vi.fn().mockResolvedValue([]);
|
||||||
|
const { service } = makeService({ orderFindMany });
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () => service.list({ serviceDate: '2026-10-14' }));
|
||||||
|
|
||||||
|
expect(orderFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { serviceDate: new Date('2026-10-14T00:00:00Z') },
|
||||||
|
orderBy: { timeSlotStart: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
336
src/order/order.service.ts
Normal file
336
src/order/order.service.ts
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
import { BadRequestException, 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 {
|
||||||
|
AssignOrderDto,
|
||||||
|
BatchRecommendQuery,
|
||||||
|
CancelOrderDto,
|
||||||
|
GenerateOrderDto,
|
||||||
|
ListOrderQuery,
|
||||||
|
ReassignOrderDto,
|
||||||
|
TodayOrderQuery,
|
||||||
|
} from './dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时段重叠判定(严格 <,首尾相接不算重叠)。
|
||||||
|
* 即 aStart < bEnd && bStart < aEnd;09:00-10:00 与 10:00-11:00 不相邻重叠。
|
||||||
|
*/
|
||||||
|
export function isTimeOverlap(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
|
||||||
|
return aStart < bEnd && bStart < aEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地今天(YYYY-MM-DD)。 */
|
||||||
|
function todayStr(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO datetime 字符串 → Date。业务时区为北京时间(UTC+8)。 */
|
||||||
|
function toDateTime(v: string): Date {
|
||||||
|
// 业务时区为北京时间(UTC+8):前端发送无时区偏移的 naive 字符串(北京时间墙上时钟),
|
||||||
|
// 若直接 new Date(v) 会被当作服务器本地时间解析,非北京时区部署会产生 8 小时偏移。
|
||||||
|
// 因此对无偏移的 YYYY-MM-DDTHH:mm:ss 显式按 +08:00 解析,保证确定性。
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(v)) {
|
||||||
|
return new Date(`${v}+08:00`);
|
||||||
|
}
|
||||||
|
return new Date(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条计划项快照(item_list 元素)。 */
|
||||||
|
function planItemSnapshot(i: Record<string, any>): Record<string, any> {
|
||||||
|
return {
|
||||||
|
svcId: i.svcId,
|
||||||
|
svcName: i.svcNameSnapshot,
|
||||||
|
svcCode: i.svcCodeSnapshot ?? null,
|
||||||
|
freqType: i.freqType ?? null,
|
||||||
|
freqValue: i.freqValue ?? null,
|
||||||
|
durationMin: i.durationMin ?? null,
|
||||||
|
photoTier: i.photoTier ?? 'free',
|
||||||
|
needCert: i.needCert ?? false,
|
||||||
|
isMedical: i.isMedical ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单(svc_order)业务服务。
|
||||||
|
*
|
||||||
|
* - generate:L2 校验(老人有 active 计划),从 plan_item 快照 item_list + need_photo,
|
||||||
|
* order_status=to_assign;billable/price_snapshot/settle_period 留待 C3。
|
||||||
|
* - assign:按序校验 工单态(to_assign) → 老人待遇(L1, 1001) → 有计划(L2, 1002) →
|
||||||
|
* 当班(R9 pass-through) → 时间冲突(2001 硬拦) → 资质(1006 软提示)。派单后 order_status=assigned。
|
||||||
|
* - reassign:记 reschedule_log(改前/改后/时间/操作人),改派后重新校验时间冲突。
|
||||||
|
* - cancel:cancel_reason 必填。
|
||||||
|
* - today:当前登录护理员本人当日工单。
|
||||||
|
* - batchRecommend:只读推荐(同资质→历史服务过→距离近→负荷低,距离/负荷留 TODO),不自动派。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OrderService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** 生成工单(to_assign)。 */
|
||||||
|
async generate(dto: GenerateOrderDto) {
|
||||||
|
const plan = await this.requireActivePlan(dto.planId, dto.elderId);
|
||||||
|
const planItems = await this.prisma.client.planItem.findMany({ where: { planId: plan.id } });
|
||||||
|
const itemList = planItems.map(planItemSnapshot);
|
||||||
|
const needPhoto = planItems.some((i) => i.photoTier === 'must');
|
||||||
|
const ctx = getRequestContext();
|
||||||
|
|
||||||
|
this.assertSlotRange(dto.timeSlotStart, dto.timeSlotEnd);
|
||||||
|
|
||||||
|
return this.prisma.client.order.create({
|
||||||
|
data: {
|
||||||
|
tenantCode: ctx?.tenantCode ?? '',
|
||||||
|
createdBy: ctx?.userId ?? null,
|
||||||
|
elderId: plan.elderId,
|
||||||
|
planId: plan.id,
|
||||||
|
serviceDate: toDateOnly(dto.serviceDate)!,
|
||||||
|
timeSlotStart: toDateTime(dto.timeSlotStart),
|
||||||
|
timeSlotEnd: toDateTime(dto.timeSlotEnd),
|
||||||
|
orderStatus: 'to_assign',
|
||||||
|
itemList,
|
||||||
|
needPhoto,
|
||||||
|
billable: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 派单(to_assign → assigned)。 */
|
||||||
|
async assign(dto: AssignOrderDto) {
|
||||||
|
const order = await this.requireOrder(dto.orderId);
|
||||||
|
if (order.orderStatus !== 'to_assign') {
|
||||||
|
throw new BusinessException(ErrorCodes.ORDER_STATUS_INVALID, '工单状态非待派单,不可派单', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
// L1:老人待遇校验(waiting_period 为垫付例外,允许派单)。
|
||||||
|
const elder = await this.prisma.client.elder.findFirst({ where: { id: order.elderId } });
|
||||||
|
if (!elder) {
|
||||||
|
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (elder.treatStatus !== 'active' && elder.treatStatus !== 'waiting_period') {
|
||||||
|
throw new BusinessException(ErrorCodes.ORDER_STATUS_INVALID, '老人无有效待遇,不可派单(1001)', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
// L2:有计划校验(仅要求计划存在且属于该老人,不要求 active——历史工单仍指向旧计划)。
|
||||||
|
await this.requirePlan(order.planId, order.elderId);
|
||||||
|
// 护理员当班(R9)。
|
||||||
|
const staff = await this.requireStaff(dto.assigneeId);
|
||||||
|
this.assertOnDuty(staff);
|
||||||
|
// 时间冲突(2001 硬拦)。
|
||||||
|
await this.assertNoConflict(order.id, dto.assigneeId, order.timeSlotStart, order.timeSlotEnd);
|
||||||
|
// 资质(1006 软提示)。
|
||||||
|
const qualificationCheck = this.qualificationCheck(order.itemList ?? [], staff);
|
||||||
|
|
||||||
|
const data: Record<string, any> = {
|
||||||
|
orderStatus: 'assigned',
|
||||||
|
assigneeId: dto.assigneeId,
|
||||||
|
assignTime: new Date(),
|
||||||
|
assignMode: dto.assignMode ?? 'manual',
|
||||||
|
};
|
||||||
|
if (qualificationCheck) data.qualificationCheck = qualificationCheck;
|
||||||
|
const updated = await this.prisma.client.order.update({ where: { id: order.id }, data });
|
||||||
|
if (qualificationCheck === 'warn') {
|
||||||
|
return { ...updated, warning: '护理员资质不满足要求,已软提示(1006)' };
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调单:改派护理员与/或改时段,重新校验冲突并写 reschedule_log。 */
|
||||||
|
async reassign(id: string, dto: ReassignOrderDto) {
|
||||||
|
const order = await this.requireOrder(id);
|
||||||
|
if (order.orderStatus !== 'assigned' && order.orderStatus !== 'serving') {
|
||||||
|
throw new BusinessException(ErrorCodes.ORDER_STATUS_INVALID, '当前工单状态不可调单(仅已派单/服务中可调)', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAssigneeId = dto.assigneeId ?? order.assigneeId ?? null;
|
||||||
|
const nextStart = dto.timeSlotStart ? toDateTime(dto.timeSlotStart) : order.timeSlotStart;
|
||||||
|
const nextEnd = dto.timeSlotEnd ? toDateTime(dto.timeSlotEnd) : order.timeSlotEnd;
|
||||||
|
|
||||||
|
if (nextAssigneeId) {
|
||||||
|
const staff = await this.requireStaff(nextAssigneeId);
|
||||||
|
this.assertOnDuty(staff);
|
||||||
|
}
|
||||||
|
await this.assertNoConflict(order.id, nextAssigneeId, nextStart, nextEnd);
|
||||||
|
|
||||||
|
const ctx = getRequestContext();
|
||||||
|
const entry = {
|
||||||
|
from: { assigneeId: order.assigneeId ?? null, timeSlotStart: order.timeSlotStart, timeSlotEnd: order.timeSlotEnd },
|
||||||
|
to: { assigneeId: nextAssigneeId, timeSlotStart: nextStart, timeSlotEnd: nextEnd },
|
||||||
|
reason: dto.reason ?? null,
|
||||||
|
operatorId: ctx?.userId ?? '',
|
||||||
|
at: new Date(),
|
||||||
|
};
|
||||||
|
const rescheduleLog = Array.isArray(order.rescheduleLog) ? [...order.rescheduleLog, entry] : [entry];
|
||||||
|
|
||||||
|
const data: Record<string, any> = {
|
||||||
|
assigneeId: nextAssigneeId,
|
||||||
|
timeSlotStart: nextStart,
|
||||||
|
timeSlotEnd: nextEnd,
|
||||||
|
rescheduleLog,
|
||||||
|
};
|
||||||
|
if (dto.assigneeId && dto.assigneeId !== order.assigneeId) {
|
||||||
|
data.assignTime = new Date();
|
||||||
|
data.assignMode = 'manual';
|
||||||
|
}
|
||||||
|
return this.prisma.client.order.update({ where: { id: order.id }, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消:cancel_reason 必填。 */
|
||||||
|
async cancel(id: string, dto: CancelOrderDto) {
|
||||||
|
if (!dto.cancelReason) {
|
||||||
|
throw new BusinessException(ErrorCodes.ORDER_STATUS_INVALID, '取消工单须填写原因', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
await this.requireOrder(id);
|
||||||
|
return this.prisma.client.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: { orderStatus: 'cancelled', cancelReason: dto.cancelReason },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工单详情。 */
|
||||||
|
detail(id: string) {
|
||||||
|
return this.requireOrder(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按日列表(排班派单日视图):租户当日工单,可选按护理员过滤。 */
|
||||||
|
list(q: ListOrderQuery) {
|
||||||
|
const where: Record<string, any> = { serviceDate: toDateOnly(q.serviceDate)! };
|
||||||
|
if (q.assigneeId) where.assigneeId = q.assigneeId;
|
||||||
|
return this.prisma.client.order.findMany({ where, orderBy: { timeSlotStart: 'asc' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前登录护理员本人的当日工单(按 assignee_id + service_date)。 */
|
||||||
|
async today(q: TodayOrderQuery) {
|
||||||
|
const ctx = getRequestContext();
|
||||||
|
if (!ctx) return [];
|
||||||
|
const staff = await this.prisma.client.staff.findFirst({ where: { userId: ctx.userId } });
|
||||||
|
if (!staff) return [];
|
||||||
|
const serviceDate = q.serviceDate ? toDateOnly(q.serviceDate) : toDateOnly(todayStr());
|
||||||
|
return this.prisma.client.order.findMany({
|
||||||
|
where: { assigneeId: staff.id, serviceDate },
|
||||||
|
orderBy: { timeSlotStart: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量推荐:只读,不自动派。权重:同资质→历史服务过→距离近→负荷低(距离/负荷留 TODO)。 */
|
||||||
|
async batchRecommend(q: BatchRecommendQuery) {
|
||||||
|
const elder = await this.prisma.client.elder.findFirst({ where: { id: q.elderId } });
|
||||||
|
if (!elder) {
|
||||||
|
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
const activePlan = await this.prisma.client.plan.findFirst({
|
||||||
|
where: { elderId: q.elderId, planStatus: 'active' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
const planItems = activePlan
|
||||||
|
? await this.prisma.client.planItem.findMany({ where: { planId: activePlan.id } })
|
||||||
|
: [];
|
||||||
|
const needsCert = planItems.some((i) => i.needCert);
|
||||||
|
const staffs = await this.prisma.client.staff.findMany({});
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
staffs.map(async (s) => {
|
||||||
|
const reasons: string[] = [];
|
||||||
|
let score = 0;
|
||||||
|
// 同资质:计划项需要持证时,护理员有证书则匹配。
|
||||||
|
if (needsCert) {
|
||||||
|
if (s.certNo || s.certType) {
|
||||||
|
score += 2;
|
||||||
|
reasons.push('资质匹配');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 历史服务过该老人。
|
||||||
|
const servedCount = await this.prisma.client.order.count({
|
||||||
|
where: { assigneeId: s.id, elderId: q.elderId, orderStatus: { in: ['done', 'serving'] } },
|
||||||
|
});
|
||||||
|
if (servedCount > 0) {
|
||||||
|
score += 1;
|
||||||
|
reasons.push('历史服务过该老人');
|
||||||
|
}
|
||||||
|
// TODO(距离/负荷): 无距离与负荷模型,权重留待未来排班模型补充。
|
||||||
|
return { staffId: s.id, name: s.name, jobType: s.jobType, score, reasons };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return results.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 内部校验 ----
|
||||||
|
|
||||||
|
private async requireOrder(id: string): Promise<Record<string, any>> {
|
||||||
|
const order = await this.prisma.client.order.findUnique({ where: { id } });
|
||||||
|
if (!order) {
|
||||||
|
throw new BusinessException(ErrorCodes.ORDER_NOT_FOUND, '工单不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L2:计划须为 active(generate 专用);可选校验 elder 归属。 */
|
||||||
|
private async requireActivePlan(planId: string, elderId?: string): Promise<Record<string, any>> {
|
||||||
|
const plan = await this.requirePlan(planId, elderId);
|
||||||
|
if (plan.planStatus !== 'active') {
|
||||||
|
throw new BusinessException(ErrorCodes.ORDER_STATUS_INVALID, '该老人无有效服务计划(1002)', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L2:计划须存在且属于该老人(不要求 active,assign 专用——历史工单仍指向旧计划)。 */
|
||||||
|
private async requirePlan(planId: string, elderId?: string): Promise<Record<string, any>> {
|
||||||
|
const plan = await this.prisma.client.plan.findUnique({ where: { id: planId } });
|
||||||
|
if (!plan) {
|
||||||
|
throw new BusinessException(ErrorCodes.PLAN_NOT_FOUND, '服务计划不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (elderId && plan.elderId !== elderId) {
|
||||||
|
throw new BusinessException(ErrorCodes.PLAN_NOT_FOUND, '服务计划不属于该老人', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireStaff(id: string): Promise<Record<string, any>> {
|
||||||
|
const staff = await this.prisma.client.staff.findFirst({ where: { id } });
|
||||||
|
if (!staff) {
|
||||||
|
throw new BusinessException(ErrorCodes.STAFF_NOT_FOUND, '人员不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return staff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* R9「护理员当班」代理:C0 staff 无排班/当班字段,employStatus 亦无明确「在职」取值定义
|
||||||
|
* (src/staff/dto.ts 仅 IsString,无枚举/常量),故本轮 pass-through(不拦)。
|
||||||
|
* TODO(排班模型): 引入排班/当班模型后,在此校验护理员当班,非当班拒绝派单。
|
||||||
|
*/
|
||||||
|
private assertOnDuty(_staff: Record<string, any>): void {
|
||||||
|
// no-op:本轮不校验当班
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同 assignee 时段重叠 → ORDER_CONFLICT(2001)硬拦;排除自身与已取消工单。 */
|
||||||
|
private async assertNoConflict(orderId: string, assigneeId: string | null, start: Date, end: Date) {
|
||||||
|
if (!assigneeId) return;
|
||||||
|
const existing = await this.prisma.client.order.findMany({
|
||||||
|
where: { assigneeId, orderStatus: { not: 'cancelled' } },
|
||||||
|
});
|
||||||
|
const conflict = existing.find(
|
||||||
|
(o) => o.id !== orderId && isTimeOverlap(start, end, o.timeSlotStart, o.timeSlotEnd),
|
||||||
|
);
|
||||||
|
if (conflict) {
|
||||||
|
throw new BusinessException(ErrorCodes.ORDER_CONFLICT, '该时段已有工单,时间冲突(2001)', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 资质校验(1006 软提示):item_list 有 needCert 项而护理员无证书 → warn,仍派。 */
|
||||||
|
private qualificationCheck(itemList: Record<string, any>[], staff: Record<string, any>): string | undefined {
|
||||||
|
const needsCert = itemList.some((i) => i.needCert);
|
||||||
|
if (needsCert && !staff.certNo && !staff.certType) {
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时段止须晚于时段起。 */
|
||||||
|
private assertSlotRange(start: string, end: string) {
|
||||||
|
if (toDateTime(end) <= toDateTime(start)) {
|
||||||
|
throw new BadRequestException('时段止须晚于时段起');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {}
|
||||||
311
src/plan/plan.service.spec.ts
Normal file
311
src/plan/plan.service.spec.ts
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
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: 'active',
|
||||||
|
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('项数不满足 → 创建成功但 itemCountRule 提示 2002(不硬拦)', async () => {
|
||||||
|
const planCreate = vi.fn().mockResolvedValue({ id: 'p1' });
|
||||||
|
const { service } = makeService({ planCreate });
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () => service.create({ ...baseDto, items: [{ svcId: 'svc1' }] }));
|
||||||
|
|
||||||
|
expect(planCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
itemCount: 1,
|
||||||
|
itemCountRule: expect.stringContaining('2002'),
|
||||||
|
planStatus: 'active',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('附加项超过 5 → 创建成功但 itemCountRule 提示 2002(R4 EXTRA_MAX 分支,不硬拦)', async () => {
|
||||||
|
const planCreate = vi.fn().mockResolvedValue({ id: 'p1' });
|
||||||
|
const { service } = makeService({ planCreate });
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () =>
|
||||||
|
service.create({ ...baseDto, items: makeItems(18), extraItemCount: 6 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(planCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
itemCount: 18,
|
||||||
|
itemCountRule: expect.stringContaining('2002'),
|
||||||
|
planStatus: 'active',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 'active' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('变更留旧版:旧计划转 changed + 新计划 change_from_plan_id', async () => {
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p-old', elderId: 'e1', 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 布尔,不改变 plan_status', async () => {
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p1', planStatus: 'active' });
|
||||||
|
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: {
|
||||||
|
confirmElder: true,
|
||||||
|
confirmGuardian: false,
|
||||||
|
confirmTime: expect.any(Date),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('变更计划 elder 不匹配 → 拒绝,不写新计划', async () => {
|
||||||
|
const planFindUnique = vi.fn().mockResolvedValue({ id: 'p-old', elderId: 'e-other', planStatus: 'active' });
|
||||||
|
const planCreate = vi.fn();
|
||||||
|
const planUpdate = vi.fn();
|
||||||
|
const { service } = makeService({ planFindUnique, planCreate, planUpdate });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () =>
|
||||||
|
service.change({ ...baseDto, careMode: 'home_org', items: [], changeFromPlanId: 'p-old' }),
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.PLAN_NOT_FOUND } });
|
||||||
|
|
||||||
|
expect(planCreate).not.toHaveBeenCalled();
|
||||||
|
expect(planUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
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=active;写 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) {}
|
||||||
|
|
||||||
|
/** 制定服务计划(active)。 */
|
||||||
|
create(dto: CreatePlanDto) {
|
||||||
|
return this.buildPlan(dto, { planStatus: 'active' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更服务计划:新建 active 计划并指向旧计划,旧计划转 changed。 */
|
||||||
|
async change(dto: ChangePlanDto) {
|
||||||
|
const oldPlan = await this.requireExists(dto.changeFromPlanId);
|
||||||
|
if (oldPlan.elderId !== dto.elderId) {
|
||||||
|
throw new BusinessException(ErrorCodes.PLAN_NOT_FOUND, '被变更计划不属于该老人', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认计划:仅写确认时间与老人/家属布尔(不改变 plan_status)。 */
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计划详情(含计划项)。 */
|
||||||
|
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);
|
||||||
|
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,
|
||||||
|
// 属地项数不足仅提示不硬拦(2002):计划照常创建,规则以 2002 提示级落库。
|
||||||
|
itemCountRule: ok ? this.itemCountRuleSnapshot(dto.careMode, dto.careLevel) : `2002:${hint}`,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import { getRequestContext } from '../common/context/request-context.js';
|
|||||||
/** 不带 tenantCode 的模型(租户表自身)。 */
|
/** 不带 tenantCode 的模型(租户表自身)。 */
|
||||||
const TENANT_FREE = new Set(['Tenant', 'Agency', 'SvcNational', 'SvcLocal', 'SvcMapping', 'LocConfig']);
|
const TENANT_FREE = new Set(['Tenant', 'Agency', 'SvcNational', 'SvcLocal', 'SvcMapping', 'LocConfig']);
|
||||||
/** 软删模型:读操作统一注入 deletedAt IS NULL。 */
|
/** 软删模型:读操作统一注入 deletedAt IS NULL。 */
|
||||||
const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config', 'Notice', 'Staff', 'SvcOrg', 'Elder']);
|
const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config', 'Notice', 'Staff', 'SvcOrg', 'Elder', 'Plan', 'Order', 'ElderSvc']);
|
||||||
const READ_OPS = new Set(['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy']);
|
const READ_OPS = new Set(['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy']);
|
||||||
/** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */
|
/** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */
|
||||||
const NO_WHERE_OPS = new Set(['create', 'createMany']);
|
const NO_WHERE_OPS = new Set(['create', 'createMany']);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user