diff --git a/src/order/order.service.spec.ts b/src/order/order.service.spec.ts index 64135e2..3ba1c15 100644 --- a/src/order/order.service.spec.ts +++ b/src/order/order.service.spec.ts @@ -165,6 +165,29 @@ describe('OrderService.generate', () => { }), }); }); + + 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', () => { @@ -173,7 +196,7 @@ describe('OrderService.assign', () => { 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 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({ @@ -201,7 +224,7 @@ describe('OrderService.assign', () => { 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 planFindUnique = vi.fn().mockResolvedValue({ id: 'p1', elderId: 'e1', planStatus: 'active' }); const { service, orderUpdate } = makeService({ orderFindUnique, elderFindFirst, planFindUnique }); await expect( @@ -213,10 +236,30 @@ describe('OrderService.assign', () => { 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', planStatus: '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') }, @@ -241,7 +284,7 @@ describe('OrderService.assign', () => { 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 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') }, @@ -303,6 +346,16 @@ describe('OrderService.reassign', () => { }), }); }); + 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', () => { diff --git a/src/order/order.service.ts b/src/order/order.service.ts index 5bcf501..e83bda2 100644 --- a/src/order/order.service.ts +++ b/src/order/order.service.ts @@ -30,8 +30,14 @@ function todayStr(): string { return `${d.getFullYear()}-${m}-${day}`; } -/** ISO datetime 字符串 → Date。 */ +/** 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); } @@ -107,8 +113,8 @@ export class OrderService { 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); + // L2:有计划校验(仅要求计划存在且属于该老人,不要求 active——历史工单仍指向旧计划)。 + await this.requirePlan(order.planId, order.elderId); // 护理员当班(R9)。 const staff = await this.requireStaff(dto.assigneeId); this.assertOnDuty(staff); @@ -134,8 +140,8 @@ export class OrderService { /** 调单:改派护理员与/或改时段,重新校验冲突并写 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); + if (order.orderStatus !== 'assigned' && order.orderStatus !== 'serving') { + throw new BusinessException(ErrorCodes.ORDER_STATUS_INVALID, '当前工单状态不可调单(仅已派单/服务中可调)', HttpStatus.BAD_REQUEST); } const nextAssigneeId = dto.assigneeId ?? order.assigneeId ?? null; @@ -260,8 +266,17 @@ export class OrderService { return order; } - /** L2:计划须为 active(generate/assign 共用);可选校验 elder 归属。 */ + /** L2:计划须为 active(generate 专用);可选校验 elder 归属。 */ private async requireActivePlan(planId: string, elderId?: string): Promise> { + 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> { const plan = await this.prisma.client.plan.findUnique({ where: { id: planId } }); if (!plan) { throw new BusinessException(ErrorCodes.PLAN_NOT_FOUND, '服务计划不存在', HttpStatus.NOT_FOUND); @@ -269,9 +284,6 @@ export class OrderService { 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; } diff --git a/src/plan/plan.service.spec.ts b/src/plan/plan.service.spec.ts index 2f633b9..b237ee6 100644 --- a/src/plan/plan.service.spec.ts +++ b/src/plan/plan.service.spec.ts @@ -177,26 +177,36 @@ describe('PlanService', () => { expect(planCreate).not.toHaveBeenCalled(); }); - it('项数不满足 → PLAN_ITEM_RULE', async () => { - const { service, planCreate } = makeService(); + it('项数不满足 → 创建成功但 itemCountRule 提示 2002(不硬拦)', async () => { + const planCreate = vi.fn().mockResolvedValue({ id: 'p1' }); + const { service } = makeService({ planCreate }); - await expect( - runWithRequestContext(ctx, () => service.create({ ...baseDto, items: [{ svcId: 'svc1' }] })), - ).rejects.toMatchObject({ response: { code: ErrorCodes.PLAN_ITEM_RULE } }); + await runWithRequestContext(ctx, () => service.create({ ...baseDto, items: [{ svcId: 'svc1' }] })); - expect(planCreate).not.toHaveBeenCalled(); + expect(planCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + itemCount: 1, + itemCountRule: expect.stringContaining('2002'), + planStatus: 'active', + }), + }); }); - it('附加项超过 5 → PLAN_ITEM_RULE(R4 EXTRA_MAX 分支)', async () => { - const { service, planCreate } = makeService(); + it('附加项超过 5 → 创建成功但 itemCountRule 提示 2002(R4 EXTRA_MAX 分支,不硬拦)', async () => { + const planCreate = vi.fn().mockResolvedValue({ id: 'p1' }); + const { service } = makeService({ planCreate }); - await expect( - runWithRequestContext(ctx, () => - service.create({ ...baseDto, items: makeItems(18), extraItemCount: 6 }), - ), - ).rejects.toMatchObject({ response: { code: ErrorCodes.PLAN_ITEM_RULE } }); + await runWithRequestContext(ctx, () => + service.create({ ...baseDto, items: makeItems(18), extraItemCount: 6 }), + ); - expect(planCreate).not.toHaveBeenCalled(); + expect(planCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + itemCount: 18, + itemCountRule: expect.stringContaining('2002'), + planStatus: 'active', + }), + }); }); it('home_org 不按项数 → 创建成功(R4 home_org 分支)', async () => { diff --git a/src/plan/plan.service.ts b/src/plan/plan.service.ts index 375f48a..cedb014 100644 --- a/src/plan/plan.service.ts +++ b/src/plan/plan.service.ts @@ -73,9 +73,6 @@ export class PlanService { const extraCount = dto.extraItemCount ?? 0; const resolved = await this.resolvePlanItems(items); const { ok, hint } = checkItemCount(dto.careMode, dto.careLevel, items.length, extraCount); - if (!ok) { - throw new BusinessException(ErrorCodes.PLAN_ITEM_RULE, hint, HttpStatus.BAD_REQUEST); - } const plan = await this.createPlanHead({ elderId: dto.elderId, planPeriodStart: toDateOnly(dto.planPeriodStart), @@ -83,7 +80,8 @@ export class PlanService { careLevel: dto.careLevel, careMode: dto.careMode, itemCount: items.length, - itemCountRule: this.itemCountRuleSnapshot(dto.careMode, dto.careLevel), + // 属地项数不足仅提示不硬拦(2002):计划照常创建,规则以 2002 提示级落库。 + itemCountRule: ok ? this.itemCountRuleSnapshot(dto.careMode, dto.careLevel) : `2002:${hint}`, extraItemCount: extraCount, weekFreq: dto.weekFreq, monthHours: dto.monthHours,