fix(c2): 属地项数提示级 + assign L2 放松 + reassign 守卫 + 时区标准化

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-14 23:41:20 +08:00
parent 8791ca494b
commit 66da809a2e
4 changed files with 104 additions and 31 deletions

View File

@ -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', () => {

View File

@ -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;
}

View File

@ -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, () =>
await runWithRequestContext(ctx, () =>
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 () => {

View File

@ -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,