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', () => {
|
describe('OrderService.assign', () => {
|
||||||
@ -173,7 +196,7 @@ describe('OrderService.assign', () => {
|
|||||||
it('waiting_period 允许派单', async () => {
|
it('waiting_period 允许派单', async () => {
|
||||||
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
||||||
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'waiting_period' });
|
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 staffFindFirst = vi.fn().mockResolvedValue({ id: 's1', employStatus: 'on', certNo: null, certType: null });
|
||||||
const orderUpdate = vi.fn().mockResolvedValue(makeOrder({ orderStatus: 'assigned' }));
|
const orderUpdate = vi.fn().mockResolvedValue(makeOrder({ orderStatus: 'assigned' }));
|
||||||
const { service } = makeService({
|
const { service } = makeService({
|
||||||
@ -201,7 +224,7 @@ describe('OrderService.assign', () => {
|
|||||||
it('benefit_expired 拒绝(1001)', async () => {
|
it('benefit_expired 拒绝(1001)', async () => {
|
||||||
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
||||||
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'benefit_expired' });
|
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 });
|
const { service, orderUpdate } = makeService({ orderFindUnique, elderFindFirst, planFindUnique });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@ -213,10 +236,30 @@ describe('OrderService.assign', () => {
|
|||||||
expect(orderUpdate).not.toHaveBeenCalled();
|
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 () => {
|
it('同 assignee 时段重叠 → ORDER_CONFLICT(2001)拒绝', async () => {
|
||||||
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
||||||
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' });
|
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 staffFindFirst = vi.fn().mockResolvedValue({ id: 's1', employStatus: 'on', certNo: null, certType: null });
|
||||||
const orderFindMany = vi.fn().mockResolvedValue([
|
const orderFindMany = vi.fn().mockResolvedValue([
|
||||||
{ id: 'o-other', assigneeId: 's1', timeSlotStart: t('09', '30'), timeSlotEnd: t('10', '30') },
|
{ id: 'o-other', assigneeId: 's1', timeSlotStart: t('09', '30'), timeSlotEnd: t('10', '30') },
|
||||||
@ -241,7 +284,7 @@ describe('OrderService.assign', () => {
|
|||||||
it('首尾相接时段不算冲突 → 派单成功', async () => {
|
it('首尾相接时段不算冲突 → 派单成功', async () => {
|
||||||
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
const orderFindUnique = vi.fn().mockResolvedValue(makeOrder());
|
||||||
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' });
|
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 staffFindFirst = vi.fn().mockResolvedValue({ id: 's1', employStatus: 'on', certNo: null, certType: null });
|
||||||
const orderFindMany = vi.fn().mockResolvedValue([
|
const orderFindMany = vi.fn().mockResolvedValue([
|
||||||
{ id: 'o-other', assigneeId: 's1', timeSlotStart: t('10', '00'), timeSlotEnd: t('11', '00') },
|
{ 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', () => {
|
describe('OrderService.cancel', () => {
|
||||||
|
|||||||
@ -30,8 +30,14 @@ function todayStr(): string {
|
|||||||
return `${d.getFullYear()}-${m}-${day}`;
|
return `${d.getFullYear()}-${m}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ISO datetime 字符串 → Date。 */
|
/** ISO datetime 字符串 → Date。业务时区为北京时间(UTC+8)。 */
|
||||||
function toDateTime(v: string): Date {
|
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);
|
return new Date(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,8 +113,8 @@ export class OrderService {
|
|||||||
if (elder.treatStatus !== 'active' && elder.treatStatus !== 'waiting_period') {
|
if (elder.treatStatus !== 'active' && elder.treatStatus !== 'waiting_period') {
|
||||||
throw new BusinessException(ErrorCodes.ORDER_STATUS_INVALID, '老人无有效待遇,不可派单(1001)', HttpStatus.BAD_REQUEST);
|
throw new BusinessException(ErrorCodes.ORDER_STATUS_INVALID, '老人无有效待遇,不可派单(1001)', HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
// L2:有计划校验。
|
// L2:有计划校验(仅要求计划存在且属于该老人,不要求 active——历史工单仍指向旧计划)。
|
||||||
await this.requireActivePlan(order.planId);
|
await this.requirePlan(order.planId, order.elderId);
|
||||||
// 护理员当班(R9)。
|
// 护理员当班(R9)。
|
||||||
const staff = await this.requireStaff(dto.assigneeId);
|
const staff = await this.requireStaff(dto.assigneeId);
|
||||||
this.assertOnDuty(staff);
|
this.assertOnDuty(staff);
|
||||||
@ -134,8 +140,8 @@ export class OrderService {
|
|||||||
/** 调单:改派护理员与/或改时段,重新校验冲突并写 reschedule_log。 */
|
/** 调单:改派护理员与/或改时段,重新校验冲突并写 reschedule_log。 */
|
||||||
async reassign(id: string, dto: ReassignOrderDto) {
|
async reassign(id: string, dto: ReassignOrderDto) {
|
||||||
const order = await this.requireOrder(id);
|
const order = await this.requireOrder(id);
|
||||||
if (order.orderStatus === 'cancelled') {
|
if (order.orderStatus !== 'assigned' && order.orderStatus !== 'serving') {
|
||||||
throw new BusinessException(ErrorCodes.ORDER_STATUS_INVALID, '已取消工单不可调单', HttpStatus.BAD_REQUEST);
|
throw new BusinessException(ErrorCodes.ORDER_STATUS_INVALID, '当前工单状态不可调单(仅已派单/服务中可调)', HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAssigneeId = dto.assigneeId ?? order.assigneeId ?? null;
|
const nextAssigneeId = dto.assigneeId ?? order.assigneeId ?? null;
|
||||||
@ -260,8 +266,17 @@ export class OrderService {
|
|||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** L2:计划须为 active(generate/assign 共用);可选校验 elder 归属。 */
|
/** L2:计划须为 active(generate 专用);可选校验 elder 归属。 */
|
||||||
private async requireActivePlan(planId: string, elderId?: string): Promise<Record<string, any>> {
|
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 } });
|
const plan = await this.prisma.client.plan.findUnique({ where: { id: planId } });
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
throw new BusinessException(ErrorCodes.PLAN_NOT_FOUND, '服务计划不存在', HttpStatus.NOT_FOUND);
|
throw new BusinessException(ErrorCodes.PLAN_NOT_FOUND, '服务计划不存在', HttpStatus.NOT_FOUND);
|
||||||
@ -269,9 +284,6 @@ export class OrderService {
|
|||||||
if (elderId && plan.elderId !== elderId) {
|
if (elderId && plan.elderId !== elderId) {
|
||||||
throw new BusinessException(ErrorCodes.PLAN_NOT_FOUND, '服务计划不属于该老人', HttpStatus.BAD_REQUEST);
|
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;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -177,26 +177,36 @@ describe('PlanService', () => {
|
|||||||
expect(planCreate).not.toHaveBeenCalled();
|
expect(planCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('项数不满足 → PLAN_ITEM_RULE', async () => {
|
it('项数不满足 → 创建成功但 itemCountRule 提示 2002(不硬拦)', async () => {
|
||||||
const { service, planCreate } = makeService();
|
const planCreate = vi.fn().mockResolvedValue({ id: 'p1' });
|
||||||
|
const { service } = makeService({ planCreate });
|
||||||
|
|
||||||
await expect(
|
await runWithRequestContext(ctx, () => service.create({ ...baseDto, items: [{ svcId: 'svc1' }] }));
|
||||||
runWithRequestContext(ctx, () => service.create({ ...baseDto, items: [{ svcId: 'svc1' }] })),
|
|
||||||
).rejects.toMatchObject({ response: { code: ErrorCodes.PLAN_ITEM_RULE } });
|
|
||||||
|
|
||||||
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 () => {
|
it('附加项超过 5 → 创建成功但 itemCountRule 提示 2002(R4 EXTRA_MAX 分支,不硬拦)', async () => {
|
||||||
const { service, planCreate } = makeService();
|
const planCreate = vi.fn().mockResolvedValue({ id: 'p1' });
|
||||||
|
const { service } = makeService({ planCreate });
|
||||||
|
|
||||||
await expect(
|
await runWithRequestContext(ctx, () =>
|
||||||
runWithRequestContext(ctx, () =>
|
|
||||||
service.create({ ...baseDto, items: makeItems(18), extraItemCount: 6 }),
|
service.create({ ...baseDto, items: makeItems(18), extraItemCount: 6 }),
|
||||||
),
|
);
|
||||||
).rejects.toMatchObject({ response: { code: ErrorCodes.PLAN_ITEM_RULE } });
|
|
||||||
|
|
||||||
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 () => {
|
it('home_org 不按项数 → 创建成功(R4 home_org 分支)', async () => {
|
||||||
|
|||||||
@ -73,9 +73,6 @@ export class PlanService {
|
|||||||
const extraCount = dto.extraItemCount ?? 0;
|
const extraCount = dto.extraItemCount ?? 0;
|
||||||
const resolved = await this.resolvePlanItems(items);
|
const resolved = await this.resolvePlanItems(items);
|
||||||
const { ok, hint } = checkItemCount(dto.careMode, dto.careLevel, items.length, extraCount);
|
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({
|
const plan = await this.createPlanHead({
|
||||||
elderId: dto.elderId,
|
elderId: dto.elderId,
|
||||||
planPeriodStart: toDateOnly(dto.planPeriodStart),
|
planPeriodStart: toDateOnly(dto.planPeriodStart),
|
||||||
@ -83,7 +80,8 @@ export class PlanService {
|
|||||||
careLevel: dto.careLevel,
|
careLevel: dto.careLevel,
|
||||||
careMode: dto.careMode,
|
careMode: dto.careMode,
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
itemCountRule: this.itemCountRuleSnapshot(dto.careMode, dto.careLevel),
|
// 属地项数不足仅提示不硬拦(2002):计划照常创建,规则以 2002 提示级落库。
|
||||||
|
itemCountRule: ok ? this.itemCountRuleSnapshot(dto.careMode, dto.careLevel) : `2002:${hint}`,
|
||||||
extraItemCount: extraCount,
|
extraItemCount: extraCount,
|
||||||
weekFreq: dto.weekFreq,
|
weekFreq: dto.weekFreq,
|
||||||
monthHours: dto.monthHours,
|
monthHours: dto.monthHours,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user