fix(c2): 属地项数提示级 + assign L2 放松 + reassign 守卫 + 时区标准化
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8791ca494b
commit
66da809a2e
@ -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', () => {
|
||||
|
||||
@ -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<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);
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user