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 }),
);
expect(res).toEqual({ id: 'r1' });
expect(res).toMatchObject({ id: 'r1' });
expect(recordCreate).toHaveBeenCalledWith({
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' }),
});
});
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', () => {
@ -464,8 +480,8 @@ describe('RecordService.offlineSync', () => {
expect(recordCreate).not.toHaveBeenCalled();
});
it('clientMd5 命中 → 幂等返回已存在,不重复落库', async () => {
const existing = { id: 'r-exist', clientMd5: 'md5-1' };
it('clientMd5 命中 → 幂等返回已存在(坐标序列化为字符串),不重复落库', async () => {
const existing = { id: 'r-exist', clientMd5: 'md5-1', checkinLat: { toString: () => '25.010000' } };
const recordFindFirst = vi.fn().mockResolvedValue(existing);
const { service, recordCreate } = makeService({ recordFindFirst });
@ -474,7 +490,8 @@ describe('RecordService.offlineSync', () => {
);
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();
});
@ -491,6 +508,19 @@ describe('RecordService.offlineSync', () => {
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 () => {
const orderFindUnique = vi.fn().mockResolvedValue({
id: 'o1',
@ -591,7 +621,7 @@ describe('RecordService.prepaidCarryover', () => {
expect(recordUpdate).not.toHaveBeenCalled();
expect(recordItemCreate).toHaveBeenCalledTimes(1);
expect(recordPhotoCreate).toHaveBeenCalledTimes(1);
expect(res).toEqual([{ id: 'r2' }]);
expect(res).toMatchObject([{ id: 'r2' }]);
});
it('非垫付记录 → PREPAID_CARRYOVER_INVALID', async () => {
@ -627,6 +657,18 @@ describe('RecordService.prepaidCarryover', () => {
).rejects.toMatchObject({ response: { code: ErrorCodes.PREPAID_CARRYOVER_INVALID } });
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', () => {
@ -688,4 +730,21 @@ describe('RecordService.detail / list / prepaidList', () => {
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);
}
const isPrepaid = elder.treatStatus === 'waiting_period';
return this.prisma.client.record.create({
const record = await this.prisma.client.record.create({
data: {
tenantCode: ctx.tenantCode,
createdBy: ctx.userId,
@ -82,6 +82,7 @@ export class RecordService {
billable: !isPrepaid,
},
});
return this.serializeRecord(record);
}
/** 保存项目执行:整表覆盖(先删后写)。 */
@ -165,7 +166,7 @@ export class RecordService {
const durationMin = checkinTime
? Math.round((checkoutTime.getTime() - new Date(checkinTime).getTime()) / 60000)
: null;
return this.prisma.client.record.update({
const updated = await this.prisma.client.record.update({
where: { id: record.id },
data: {
checkoutTime,
@ -174,6 +175,7 @@ export class RecordService {
submitTime: new Date(),
},
});
return this.serializeRecord(updated);
}
/** 离线补传:client_md5 幂等去重,一次建完整 record。 */
@ -184,12 +186,16 @@ export class RecordService {
}
const existing = await this.prisma.client.record.findFirst({ where: { clientMd5: dto.clientMd5 } });
if (existing) {
return existing; // 幂等:静默返回已存在
return this.serializeRecord(existing); // 幂等:静默返回已存在
}
const order = await this.prisma.client.order.findUnique({ where: { id: dto.orderId } });
if (!order) {
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) {
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。 */
@ -289,7 +295,13 @@ export class RecordService {
/** 垫付台账:按老人返回 is_prepaid=1 的记录。 */
async prepaidList(q: PrepaidListQuery) {
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),原记录不改。 */
@ -311,6 +323,13 @@ export class RecordService {
if (!elder || elder.treatStatus !== 'active') {
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 = [];
for (const rec of records) {
@ -366,7 +385,7 @@ export class RecordService {
},
});
}
created.push(newRecord);
created.push(this.serializeRecord(newRecord));
}
return created;
}