From 9f232356e08d36eb1c2438059b51efb7726587b1 Mon Sep 17 00:00:00 2001 From: liuyun Date: Thu, 15 Oct 2026 01:08:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(c3):=20=E7=BB=93=E8=BD=AC=E5=B9=82=E7=AD=89?= =?UTF-8?q?=20+=20offlineSync=20=E6=89=91=E7=A9=BA=E6=8B=92=E7=AD=BE=20+?= =?UTF-8?q?=20=E5=9D=90=E6=A0=87=E5=85=A8=E8=B7=AF=E5=BE=84=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/record/record.service.spec.ts | 69 ++++++++++++++++++++++++++++--- src/record/record.service.ts | 31 +++++++++++--- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/src/record/record.service.spec.ts b/src/record/record.service.spec.ts index 39a08d3..e98eef0 100644 --- a/src/record/record.service.spec.ts +++ b/src/record/record.service.spec.ts @@ -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'); + }); }); diff --git a/src/record/record.service.ts b/src/record/record.service.ts index 892c3f7..6ba0795 100644 --- a/src/record/record.service.ts +++ b/src/record/record.service.ts @@ -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; }