fix(c3): 结转幂等 + offlineSync 扑空拒签 + 坐标全路径序列化

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-15 01:08:36 +08:00
parent 5f060292c6
commit 9f232356e0
2 changed files with 89 additions and 11 deletions

View File

@ -167,7 +167,7 @@ describe('RecordService.checkin', () => {
service.checkin({ orderId: 'o1', locateStatus: 'failed', checkinLat: 25.01, checkinLng: 102.7 }), service.checkin({ orderId: 'o1', locateStatus: 'failed', checkinLat: 25.01, checkinLng: 102.7 }),
); );
expect(res).toEqual({ id: 'r1' }); expect(res).toMatchObject({ id: 'r1' });
expect(recordCreate).toHaveBeenCalledWith({ expect(recordCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ locateStatus: 'failed', checkinLat: 25.01, checkinLng: 102.7 }), data: expect.objectContaining({ locateStatus: 'failed', checkinLat: 25.01, checkinLng: 102.7 }),
}); });
@ -200,6 +200,22 @@ describe('RecordService.checkin', () => {
data: expect.objectContaining({ isPrepaid: false, billable: true, tenantCode: 'T001', createdBy: 'u1' }), data: expect.objectContaining({ isPrepaid: false, billable: true, tenantCode: 'T001', createdBy: 'u1' }),
}); });
}); });
it('checkin 返回坐标序列化为字符串(F3)', async () => {
const orderFindUnique = vi.fn().mockResolvedValue(servingOrder);
const staffFindFirst = vi.fn().mockResolvedValue({ id: 'staff1', userId: 'u1' });
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' });
const recordCreate = vi.fn().mockResolvedValue({
id: 'r1',
checkinLat: { toString: () => '25.010000' },
checkinLng: { toString: () => '102.700000' },
});
const { service } = makeService({ orderFindUnique, staffFindFirst, elderFindFirst, recordCreate });
const res = await runWithRequestContext(ctx, () => service.checkin({ orderId: 'o1' }));
expect(res.checkinLat).toBe('25.010000');
expect(res.checkinLng).toBe('102.700000');
});
}); });
describe('RecordService.itemSave', () => { describe('RecordService.itemSave', () => {
@ -464,8 +480,8 @@ describe('RecordService.offlineSync', () => {
expect(recordCreate).not.toHaveBeenCalled(); expect(recordCreate).not.toHaveBeenCalled();
}); });
it('clientMd5 命中 → 幂等返回已存在,不重复落库', async () => { it('clientMd5 命中 → 幂等返回已存在(坐标序列化为字符串),不重复落库', async () => {
const existing = { id: 'r-exist', clientMd5: 'md5-1' }; const existing = { id: 'r-exist', clientMd5: 'md5-1', checkinLat: { toString: () => '25.010000' } };
const recordFindFirst = vi.fn().mockResolvedValue(existing); const recordFindFirst = vi.fn().mockResolvedValue(existing);
const { service, recordCreate } = makeService({ recordFindFirst }); const { service, recordCreate } = makeService({ recordFindFirst });
@ -474,7 +490,8 @@ describe('RecordService.offlineSync', () => {
); );
expect(recordFindFirst).toHaveBeenCalledWith({ where: { clientMd5: 'md5-1' } }); expect(recordFindFirst).toHaveBeenCalledWith({ where: { clientMd5: 'md5-1' } });
expect(res).toEqual(existing); expect(res).toMatchObject({ id: 'r-exist', clientMd5: 'md5-1' });
expect(res.checkinLat).toBe('25.010000');
expect(recordCreate).not.toHaveBeenCalled(); expect(recordCreate).not.toHaveBeenCalled();
}); });
@ -491,6 +508,19 @@ describe('RecordService.offlineSync', () => {
expect(recordCreate).not.toHaveBeenCalled(); expect(recordCreate).not.toHaveBeenCalled();
}); });
it('not_met(扑空)工单 → RECORD_ORDER_INVALID,不产生 record', async () => {
const orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'not_met', elderId: 'e1', assigneeId: 'staff1' });
const { service, recordCreate } = makeService({ orderFindUnique });
await expect(
runWithRequestContext(ctx, () =>
service.offlineSync({ clientMd5: 'md5-4', orderId: 'o1', elderId: 'e1', assigneeId: 'staff1', checkinTime: '2026-10-15T02:00:00Z' }),
),
).rejects.toMatchObject({ response: { code: ErrorCodes.RECORD_ORDER_INVALID } });
expect(recordCreate).not.toHaveBeenCalled();
});
it('离线新建完整 record(elder/assignee 以 order 为准 + offline_flag + is_prepaid + items/photos 一次性)', async () => { it('离线新建完整 record(elder/assignee 以 order 为准 + offline_flag + is_prepaid + items/photos 一次性)', async () => {
const orderFindUnique = vi.fn().mockResolvedValue({ const orderFindUnique = vi.fn().mockResolvedValue({
id: 'o1', id: 'o1',
@ -591,7 +621,7 @@ describe('RecordService.prepaidCarryover', () => {
expect(recordUpdate).not.toHaveBeenCalled(); expect(recordUpdate).not.toHaveBeenCalled();
expect(recordItemCreate).toHaveBeenCalledTimes(1); expect(recordItemCreate).toHaveBeenCalledTimes(1);
expect(recordPhotoCreate).toHaveBeenCalledTimes(1); expect(recordPhotoCreate).toHaveBeenCalledTimes(1);
expect(res).toEqual([{ id: 'r2' }]); expect(res).toMatchObject([{ id: 'r2' }]);
}); });
it('非垫付记录 → PREPAID_CARRYOVER_INVALID', async () => { it('非垫付记录 → PREPAID_CARRYOVER_INVALID', async () => {
@ -627,6 +657,18 @@ describe('RecordService.prepaidCarryover', () => {
).rejects.toMatchObject({ response: { code: ErrorCodes.PREPAID_CARRYOVER_INVALID } }); ).rejects.toMatchObject({ response: { code: ErrorCodes.PREPAID_CARRYOVER_INVALID } });
expect(recordCreate).not.toHaveBeenCalled(); expect(recordCreate).not.toHaveBeenCalled();
}); });
it('R10 幂等:已结转的垫付记录 → PREPAID_CARRYOVER_INVALID', async () => {
const recordFindMany = vi.fn().mockResolvedValue([original]);
const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' });
const recordFindFirst = vi.fn().mockResolvedValue({ id: 'formal1', sourcePrepaidOf: 'r1' });
const { service, recordCreate } = makeService({ recordFindMany, elderFindUnique, recordFindFirst });
await expect(
runWithRequestContext(ctx, () => service.prepaidCarryover({ recordIds: ['r1'] })),
).rejects.toMatchObject({ response: { code: ErrorCodes.PREPAID_CARRYOVER_INVALID } });
expect(recordCreate).not.toHaveBeenCalled();
});
}); });
describe('RecordService.detail / list / prepaidList', () => { describe('RecordService.detail / list / prepaidList', () => {
@ -688,4 +730,21 @@ describe('RecordService.detail / list / prepaidList', () => {
expect(recordFindMany).toHaveBeenCalledWith({ where: { elderId: 'e1', isPrepaid: true } }); expect(recordFindMany).toHaveBeenCalledWith({ where: { elderId: 'e1', isPrepaid: true } });
}); });
it('prepaidList 排除已结转的垫付记录(R10)', async () => {
const recordFindMany = vi.fn().mockResolvedValue([
{ id: 'r1', isPrepaid: true },
{ id: 'r2', isPrepaid: true },
]);
const recordFindFirst = vi.fn()
.mockResolvedValueOnce({ id: 'formal1', sourcePrepaidOf: 'r1' })
.mockResolvedValueOnce(null);
const { service } = makeService({ recordFindMany, recordFindFirst });
const res = await runWithRequestContext(ctx, () => service.prepaidList({ elderId: 'e1' }));
expect(recordFindFirst).toHaveBeenCalledTimes(2);
expect(res).toHaveLength(1);
expect(res[0].id).toBe('r2');
});
}); });

View File

@ -66,7 +66,7 @@ export class RecordService {
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND); throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
} }
const isPrepaid = elder.treatStatus === 'waiting_period'; const isPrepaid = elder.treatStatus === 'waiting_period';
return this.prisma.client.record.create({ const record = await this.prisma.client.record.create({
data: { data: {
tenantCode: ctx.tenantCode, tenantCode: ctx.tenantCode,
createdBy: ctx.userId, createdBy: ctx.userId,
@ -82,6 +82,7 @@ export class RecordService {
billable: !isPrepaid, billable: !isPrepaid,
}, },
}); });
return this.serializeRecord(record);
} }
/** 保存项目执行:整表覆盖(先删后写)。 */ /** 保存项目执行:整表覆盖(先删后写)。 */
@ -165,7 +166,7 @@ export class RecordService {
const durationMin = checkinTime const durationMin = checkinTime
? Math.round((checkoutTime.getTime() - new Date(checkinTime).getTime()) / 60000) ? Math.round((checkoutTime.getTime() - new Date(checkinTime).getTime()) / 60000)
: null; : null;
return this.prisma.client.record.update({ const updated = await this.prisma.client.record.update({
where: { id: record.id }, where: { id: record.id },
data: { data: {
checkoutTime, checkoutTime,
@ -174,6 +175,7 @@ export class RecordService {
submitTime: new Date(), submitTime: new Date(),
}, },
}); });
return this.serializeRecord(updated);
} }
/** 离线补传:client_md5 幂等去重,一次建完整 record。 */ /** 离线补传:client_md5 幂等去重,一次建完整 record。 */
@ -184,12 +186,16 @@ export class RecordService {
} }
const existing = await this.prisma.client.record.findFirst({ where: { clientMd5: dto.clientMd5 } }); const existing = await this.prisma.client.record.findFirst({ where: { clientMd5: dto.clientMd5 } });
if (existing) { if (existing) {
return existing; // 幂等:静默返回已存在 return this.serializeRecord(existing); // 幂等:静默返回已存在
} }
const order = await this.prisma.client.order.findUnique({ where: { id: dto.orderId } }); const order = await this.prisma.client.order.findUnique({ where: { id: dto.orderId } });
if (!order) { if (!order) {
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单不存在', HttpStatus.NOT_FOUND); throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单不存在', HttpStatus.NOT_FOUND);
} }
// L3:扑空/取消工单不产生 record(扑空不产生留痕)。
if (order.orderStatus === 'cancelled' || order.orderStatus === 'not_met') {
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单已取消/扑空,不产生留痕', HttpStatus.BAD_REQUEST);
}
if (!order.assigneeId) { if (!order.assigneeId) {
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单未派单,无法留痕', HttpStatus.BAD_REQUEST); throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单未派单,无法留痕', HttpStatus.BAD_REQUEST);
} }
@ -256,7 +262,7 @@ export class RecordService {
}, },
}); });
} }
return record; return this.serializeRecord(record);
} }
/** 详情:record + items + photos + abnormals。 */ /** 详情:record + items + photos + abnormals。 */
@ -289,7 +295,13 @@ export class RecordService {
/** 垫付台账:按老人返回 is_prepaid=1 的记录。 */ /** 垫付台账:按老人返回 is_prepaid=1 的记录。 */
async prepaidList(q: PrepaidListQuery) { async prepaidList(q: PrepaidListQuery) {
const rows = await this.prisma.client.record.findMany({ where: { elderId: q.elderId, isPrepaid: true } }); const rows = await this.prisma.client.record.findMany({ where: { elderId: q.elderId, isPrepaid: true } });
return rows.map((r) => this.serializeRecord(r)); // R10:排除已结转的垫付记录(存在 formal record.source_prepaid_of 指向它)。
const result = [];
for (const row of rows) {
const formal = await this.prisma.client.record.findFirst({ where: { sourcePrepaidOf: row.id } });
if (!formal) result.push(row);
}
return result.map((r) => this.serializeRecord(r));
} }
/** 结转:复制垫付 record → 正式 record(is_prepaid=0 + source_prepaid_of),原记录不改。 */ /** 结转:复制垫付 record → 正式 record(is_prepaid=0 + source_prepaid_of),原记录不改。 */
@ -311,6 +323,13 @@ export class RecordService {
if (!elder || elder.treatStatus !== 'active') { if (!elder || elder.treatStatus !== 'active') {
throw new BusinessException(ErrorCodes.PREPAID_CARRYOVER_INVALID, '待遇未转正常享受,不可结转', HttpStatus.BAD_REQUEST); throw new BusinessException(ErrorCodes.PREPAID_CARRYOVER_INVALID, '待遇未转正常享受,不可结转', HttpStatus.BAD_REQUEST);
} }
// R10 幂等:已结转的垫付记录不可重复结转。
for (const rec of records) {
const formal = await this.prisma.client.record.findFirst({ where: { sourcePrepaidOf: rec.id } });
if (formal) {
throw new BusinessException(ErrorCodes.PREPAID_CARRYOVER_INVALID, '该垫付记录已结转', HttpStatus.BAD_REQUEST);
}
}
const created = []; const created = [];
for (const rec of records) { for (const rec of records) {
@ -366,7 +385,7 @@ export class RecordService {
}, },
}); });
} }
created.push(newRecord); created.push(this.serializeRecord(newRecord));
} }
return created; return created;
} }