diff --git a/src/app.module.ts b/src/app.module.ts index 3e76fbc..8c8bcb0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -32,6 +32,7 @@ import { StorageModule } from './storage/storage.module.js'; import { ElderModule } from './elder/elder.module.js'; import { PlanModule } from './plan/plan.module.js'; import { ElderSvcModule } from './elder-svc/elder-svc.module.js'; +import { OrderModule } from './order/order.module.js'; import { AppController } from './app.controller.js'; import { AppService } from './app.service.js'; @@ -61,6 +62,7 @@ import { AppService } from './app.service.js'; ElderModule, PlanModule, ElderSvcModule, + OrderModule, ], controllers: [AppController], providers: [ diff --git a/src/order/dto.ts b/src/order/dto.ts new file mode 100644 index 0000000..c91a77e --- /dev/null +++ b/src/order/dto.ts @@ -0,0 +1,122 @@ +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; +} diff --git a/src/order/order.controller.ts b/src/order/order.controller.ts new file mode 100644 index 0000000..59fe8b4 --- /dev/null +++ b/src/order/order.controller.ts @@ -0,0 +1,64 @@ +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, + 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('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); + } +} diff --git a/src/order/order.module.ts b/src/order/order.module.ts new file mode 100644 index 0000000..fc0b234 --- /dev/null +++ b/src/order/order.module.ts @@ -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 {} diff --git a/src/order/order.service.spec.ts b/src/order/order.service.spec.ts new file mode 100644 index 0000000..8623332 --- /dev/null +++ b/src/order/order.service.spec.ts @@ -0,0 +1,332 @@ +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 = {}): Record { + 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; + orderFindMany?: ReturnType; + orderCreate?: ReturnType; + orderUpdate?: ReturnType; + orderCount?: ReturnType; + planFindUnique?: ReturnType; + planFindFirst?: ReturnType; + planItemFindMany?: ReturnType; + elderFindFirst?: ReturnType; + staffFindFirst?: ReturnType; + staffFindMany?: ReturnType; + } = {}, +) { + 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' }), + ], + }), + }); + }); +}); + +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', 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', 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('同 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', 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', 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), + }), + ]), + }), + }); + }); +}); + +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: '老人转院' }, + }); + }); +}); diff --git a/src/order/order.service.ts b/src/order/order.service.ts new file mode 100644 index 0000000..58d08e3 --- /dev/null +++ b/src/order/order.service.ts @@ -0,0 +1,316 @@ +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, + 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。 */ +function toDateTime(v: string): Date { + return new Date(v); +} + +/** 单条计划项快照(item_list 元素)。 */ +function planItemSnapshot(i: Record): Record { + 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:有计划校验。 + await this.requireActivePlan(order.planId); + // 护理员当班(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 = { + 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 === 'cancelled') { + 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 = { + 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); + } + + /** 当前登录护理员本人的当日工单(按 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> { + 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/assign 共用);可选校验 elder 归属。 */ + private async requireActivePlan(planId: string, elderId?: string): Promise> { + 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); + } + if (plan.planStatus !== 'active') { + throw new BusinessException(ErrorCodes.ORDER_STATUS_INVALID, '该老人无有效服务计划(1002)', HttpStatus.BAD_REQUEST); + } + return plan; + } + + private async requireStaff(id: string): Promise> { + 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): 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[], staff: Record): 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('时段止须晚于时段起'); + } + } +}