feat(c2): 工单模块(派单/调单/冲突/批量推荐)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
161a1253ee
commit
9c4fd6f4ca
@ -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: [
|
||||
|
||||
122
src/order/dto.ts
Normal file
122
src/order/dto.ts
Normal file
@ -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;
|
||||
}
|
||||
64
src/order/order.controller.ts
Normal file
64
src/order/order.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
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 {}
|
||||
332
src/order/order.service.spec.ts
Normal file
332
src/order/order.service.spec.ts
Normal file
@ -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<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' }),
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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: '老人转院' },
|
||||
});
|
||||
});
|
||||
});
|
||||
316
src/order/order.service.ts
Normal file
316
src/order/order.service.ts
Normal file
@ -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<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:有计划校验。
|
||||
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<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 === '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<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);
|
||||
}
|
||||
|
||||
/** 当前登录护理员本人的当日工单(按 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/assign 共用);可选校验 elder 归属。 */
|
||||
private async requireActivePlan(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);
|
||||
}
|
||||
if (plan.planStatus !== 'active') {
|
||||
throw new BusinessException(ErrorCodes.ORDER_STATUS_INVALID, '该老人无有效服务计划(1002)', 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('时段止须晚于时段起');
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user