diff --git a/prisma/migrations/20261021140000_abnormal_client_md5_dedup/migration.sql b/prisma/migrations/20261021140000_abnormal_client_md5_dedup/migration.sql new file mode 100644 index 0000000..1330ca5 --- /dev/null +++ b/prisma/migrations/20261021140000_abnormal_client_md5_dedup/migration.sql @@ -0,0 +1,7 @@ +-- 异常上报离线补传幂等:ho_record_abnormal 增加内容指纹 client_md5 + 唯一索引。 +-- 唯一索引允许多个 NULL,在线上报(不传 client_md5)不受影响。 +-- AlterTable +ALTER TABLE `ho_record_abnormal` ADD COLUMN `client_md5` VARCHAR(64) NULL; + +-- CreateIndex +CREATE UNIQUE INDEX `ho_record_abnormal_tenant_code_client_md5_key` ON `ho_record_abnormal`(`tenant_code`, `client_md5`); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cbeb4fe..2d7f559 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -687,9 +687,11 @@ model RecordAbnormal { handleStatus String @default("open") @map("handle_status") @db.VarChar(20) handleResult String? @map("handle_result") @db.VarChar(200) linkPlanChange Boolean @default(false) @map("link_plan_change") + clientMd5 String? @map("client_md5") @db.VarChar(64) createdAt DateTime @default(now()) @map("created_at") createdBy String? @map("created_by") @db.VarChar(36) + @@unique([tenantCode, clientMd5]) @@index([tenantCode, elderId]) @@index([tenantCode, notifyStatus]) @@map("ho_record_abnormal") diff --git a/src/abnormal/abnormal.service.spec.ts b/src/abnormal/abnormal.service.spec.ts index b5800f6..42ad5a7 100644 --- a/src/abnormal/abnormal.service.spec.ts +++ b/src/abnormal/abnormal.service.spec.ts @@ -20,6 +20,7 @@ function makeService( recordAbnormalCreate?: ReturnType; recordAbnormalFindMany?: ReturnType; recordAbnormalCount?: ReturnType; + recordAbnormalFindFirst?: ReturnType; orderUpdate?: ReturnType; orderFindUnique?: ReturnType; } = {}, @@ -27,6 +28,7 @@ function makeService( const recordAbnormalCreate = overrides.recordAbnormalCreate ?? vi.fn().mockResolvedValue({ id: 'a1' }); const recordAbnormalFindMany = overrides.recordAbnormalFindMany ?? vi.fn().mockResolvedValue([]); const recordAbnormalCount = overrides.recordAbnormalCount ?? vi.fn().mockResolvedValue(0); + const recordAbnormalFindFirst = overrides.recordAbnormalFindFirst ?? vi.fn().mockResolvedValue(null); const orderUpdate = overrides.orderUpdate ?? vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'not_met' }); const orderFindUnique = overrides.orderFindUnique ?? vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'assigned' }); @@ -35,6 +37,7 @@ function makeService( create: recordAbnormalCreate, findMany: recordAbnormalFindMany, count: recordAbnormalCount, + findFirst: recordAbnormalFindFirst, }, order: { update: orderUpdate, findUnique: orderFindUnique }, }; @@ -42,7 +45,15 @@ function makeService( client.$transaction = vi.fn(async (arg: any) => (typeof arg === 'function' ? arg(client) : Promise.all(arg))); const prisma = { client }; const service = new AbnormalService(prisma as unknown as PrismaService); - return { service, recordAbnormalCreate, recordAbnormalFindMany, recordAbnormalCount, orderUpdate, orderFindUnique }; + return { + service, + recordAbnormalCreate, + recordAbnormalFindMany, + recordAbnormalCount, + recordAbnormalFindFirst, + orderUpdate, + orderFindUnique, + }; } describe('AbnormalService', () => { @@ -72,6 +83,39 @@ describe('AbnormalService', () => { expect(res).toEqual({ id: 'a1', visitLat: null, visitLng: null }); }); + it('report 带 clientMd5 命中 → 幂等返回已存在,不重复落库、不转工单', async () => { + const recordAbnormalFindFirst = vi.fn().mockResolvedValue({ + id: 'a-exist', + clientMd5: 'md5-1', + abnormalType: 'fall', + }); + const { service, recordAbnormalCreate, orderUpdate } = makeService({ recordAbnormalFindFirst }); + + const res = await runWithRequestContext(ctx, () => + service.report({ abnormalType: 'fall', elderId: 'e1', clientMd5: 'md5-1' }), + ); + + expect(recordAbnormalFindFirst).toHaveBeenCalledWith({ + where: { tenantCode: 'T001', clientMd5: 'md5-1' }, + }); + expect(res).toMatchObject({ id: 'a-exist', clientMd5: 'md5-1', visitLat: null, visitLng: null }); + expect(recordAbnormalCreate).not.toHaveBeenCalled(); + expect(orderUpdate).not.toHaveBeenCalled(); + }); + + it('report 带 clientMd5 未命中 → 正常落库并写入 clientMd5', async () => { + const recordAbnormalCreate = vi.fn().mockResolvedValue({ id: 'a1', clientMd5: 'md5-2' }); + const { service } = makeService({ recordAbnormalCreate }); + + await runWithRequestContext(ctx, () => + service.report({ abnormalType: 'fall', elderId: 'e1', clientMd5: 'md5-2' }), + ); + + expect(recordAbnormalCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ clientMd5: 'md5-2' }), + }); + }); + it('report 扑空(not_met)→ 工单转 not_met + 写 record_abnormal(到访时间/定位/原因),不产生 ho_record', async () => { const { service, recordAbnormalCreate, orderUpdate } = makeService(); diff --git a/src/abnormal/abnormal.service.ts b/src/abnormal/abnormal.service.ts index 6a8b914..c66be75 100644 --- a/src/abnormal/abnormal.service.ts +++ b/src/abnormal/abnormal.service.ts @@ -35,6 +35,16 @@ export class AbnormalService { const reporterId = ctx?.userId ?? ''; const createdBy = ctx?.userId ?? null; + // 幂等:离线补传带 clientMd5,命中即返回已存在记录,超时重试不重复落库(R2)。 + if (dto.clientMd5) { + const existing = await this.prisma.client.recordAbnormal.findFirst({ + where: { tenantCode, clientMd5: dto.clientMd5 }, + }); + if (existing) { + return this.serializeAbnormal(existing); + } + } + if (dto.abnormalType === NOT_MET) { this.assertNotMetEvidence(dto); // 扑空:工单须存在,再转 not_met(原因 + 到访时间)。 @@ -71,15 +81,11 @@ export class AbnormalService { visitLng: dto.visitLng ?? null, notifyStatus: 'pending', handleStatus: 'open', + clientMd5: dto.clientMd5 ?? null, }, }); }); - // Prisma Decimal(visit_lat/visit_lng)转字符串,避免经 TransformInterceptor 泄漏为 {s,e,d}。 - return { - ...created, - visitLat: created.visitLat != null ? created.visitLat.toString() : null, - visitLng: created.visitLng != null ? created.visitLng.toString() : null, - }; + return this.serializeAbnormal(created); } /** 异常列表(后台 B4)。 */ @@ -119,6 +125,15 @@ export class AbnormalService { } } + /** Prisma Decimal(visit_lat/visit_lng)转字符串,避免经 TransformInterceptor 泄漏为 {s,e,d}。 */ + private serializeAbnormal(r: Record): Record { + return { + ...r, + visitLat: r.visitLat != null ? r.visitLat.toString() : null, + visitLng: r.visitLng != null ? r.visitLng.toString() : null, + }; + } + /** 工单须存在(扑空转 not_met 前校验,44003),循 order.service 的 requireOrder。 */ private async requireOrder(id: string): Promise> { const order = await this.prisma.client.order.findUnique({ where: { id } }); diff --git a/src/abnormal/dto.ts b/src/abnormal/dto.ts index a577dfa..6f878df 100644 --- a/src/abnormal/dto.ts +++ b/src/abnormal/dto.ts @@ -84,6 +84,11 @@ export class ReportAbnormalDto { @IsOptional() @IsNumber() visitLng?: number; + + @ApiPropertyOptional({ description: '客户端内容指纹(离线补传幂等去重,在线上报可空)' }) + @IsOptional() + @IsString() + clientMd5?: string; } /** 异常列表查询(后台 B4),按分类/通知状态/处理状态/老人筛选。 */ diff --git a/src/record/record.service.spec.ts b/src/record/record.service.spec.ts index 392fbfd..041b22d 100644 --- a/src/record/record.service.spec.ts +++ b/src/record/record.service.spec.ts @@ -19,6 +19,7 @@ const ctx: RequestContext = { function makeService( overrides: { orderFindUnique?: ReturnType; + orderUpdate?: ReturnType; staffFindFirst?: ReturnType; elderFindFirst?: ReturnType; elderFindUnique?: ReturnType; @@ -40,6 +41,7 @@ function makeService( } = {}, ) { const orderFindUnique = overrides.orderFindUnique ?? vi.fn().mockResolvedValue(null); + const orderUpdate = overrides.orderUpdate ?? vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'done' }); const staffFindFirst = overrides.staffFindFirst ?? vi.fn().mockResolvedValue(null); const elderFindFirst = overrides.elderFindFirst ?? vi.fn().mockResolvedValue(null); const elderFindUnique = overrides.elderFindUnique ?? vi.fn().mockResolvedValue(null); @@ -60,7 +62,7 @@ function makeService( const svcLocalFindFirst = overrides.svcLocalFindFirst ?? vi.fn().mockResolvedValue(null); const client: Record = { - order: { findUnique: orderFindUnique }, + order: { findUnique: orderFindUnique, update: orderUpdate }, staff: { findFirst: staffFindFirst }, elder: { findFirst: elderFindFirst, findUnique: elderFindUnique }, record: { @@ -92,6 +94,7 @@ function makeService( return { service, orderFindUnique, + orderUpdate, staffFindFirst, elderFindFirst, elderFindUnique, @@ -206,6 +209,31 @@ describe('RecordService.checkin', () => { }); }); + it('签到 assigned 工单 → 同事务把工单转 serving', 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 orderUpdate = vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'serving' }); + const { service, recordCreate } = makeService({ orderFindUnique, staffFindFirst, elderFindFirst, orderUpdate }); + + await runWithRequestContext(ctx, () => service.checkin({ orderId: 'o1' })); + + expect(orderUpdate).toHaveBeenCalledWith({ where: { id: 'o1' }, data: { orderStatus: 'serving' } }); + expect(recordCreate).toHaveBeenCalledTimes(1); + }); + + it('签到 serving 工单 → 不再重复转 serving(仅建留痕)', async () => { + const orderFindUnique = vi.fn().mockResolvedValue({ ...servingOrder, orderStatus: 'serving' }); + const staffFindFirst = vi.fn().mockResolvedValue({ id: 'staff1', userId: 'u1' }); + const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' }); + const orderUpdate = vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'serving' }); + const { service } = makeService({ orderFindUnique, staffFindFirst, elderFindFirst, orderUpdate }); + + await runWithRequestContext(ctx, () => service.checkin({ orderId: 'o1' })); + + expect(orderUpdate).not.toHaveBeenCalled(); + }); + it('checkin 返回坐标序列化为字符串(F3)', async () => { const orderFindUnique = vi.fn().mockResolvedValue(servingOrder); const staffFindFirst = vi.fn().mockResolvedValue({ id: 'staff1', userId: 'u1' }); @@ -427,6 +455,26 @@ describe('RecordService.checkout', () => { expect(data.durationMin).toBe(30); expect(data.checkoutTime).toBeInstanceOf(Date); }); + + it('签退 → 同事务把工单转 done', async () => { + const recordFindUnique = vi.fn().mockResolvedValue({ + id: 'r1', + orderId: 'o1', + recordStatus: 'serving', + checkinTime: new Date(Date.now() - 30 * 60 * 1000), + }); + const recordItemFindMany = vi.fn().mockResolvedValue([]); + const orderUpdate = vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'done' }); + const { service, recordUpdate } = makeService({ recordFindUnique, recordItemFindMany, orderUpdate }); + + await runWithRequestContext(ctx, () => service.checkout({ recordId: 'r1' })); + + expect(orderUpdate).toHaveBeenCalledWith({ where: { id: 'o1' }, data: { orderStatus: 'done' } }); + expect(recordUpdate).toHaveBeenCalledWith({ + where: { id: 'r1' }, + data: expect.objectContaining({ recordStatus: 'submitted' }), + }); + }); }); describe('RecordService.photoUpload', () => { @@ -615,6 +663,57 @@ describe('RecordService.offlineSync', () => { }); expect(recordPhotoCreate).toHaveBeenCalledTimes(1); }); + + it('offlineSync 含 checkoutTime → 工单转 done', async () => { + const orderFindUnique = vi.fn().mockResolvedValue({ + id: 'o1', + orderStatus: 'assigned', + elderId: 'e1', + assigneeId: 'staff1', + itemList: [], + }); + const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' }); + const orderUpdate = vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'done' }); + const { service } = makeService({ orderFindUnique, elderFindFirst, orderUpdate }); + + await runWithRequestContext(ctx, () => + service.offlineSync({ + clientMd5: 'md5-done', + orderId: 'o1', + elderId: 'e1', + assigneeId: 'staff1', + checkinTime: '2026-10-15T02:00:00Z', + checkoutTime: '2026-10-15T03:00:00Z', + }), + ); + + expect(orderUpdate).toHaveBeenCalledWith({ where: { id: 'o1' }, data: { orderStatus: 'done' } }); + }); + + it('offlineSync 不含 checkoutTime → 工单转 serving', async () => { + const orderFindUnique = vi.fn().mockResolvedValue({ + id: 'o1', + orderStatus: 'assigned', + elderId: 'e1', + assigneeId: 'staff1', + itemList: [], + }); + const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' }); + const orderUpdate = vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'serving' }); + const { service } = makeService({ orderFindUnique, elderFindFirst, orderUpdate }); + + await runWithRequestContext(ctx, () => + service.offlineSync({ + clientMd5: 'md5-serving', + orderId: 'o1', + elderId: 'e1', + assigneeId: 'staff1', + checkinTime: '2026-10-15T02:00:00Z', + }), + ); + + expect(orderUpdate).toHaveBeenCalledWith({ where: { id: 'o1' }, data: { orderStatus: 'serving' } }); + }); }); describe('RecordService.prepaidCarryover', () => { diff --git a/src/record/record.service.ts b/src/record/record.service.ts index b4bcf55..a4c0864 100644 --- a/src/record/record.service.ts +++ b/src/record/record.service.ts @@ -66,21 +66,30 @@ export class RecordService { throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND); } const isPrepaid = elder.treatStatus === 'waiting_period'; - const record = await this.prisma.client.record.create({ - data: { - tenantCode: ctx.tenantCode, - createdBy: ctx.userId, - orderId: order.id, - elderId: order.elderId, - assigneeId: order.assigneeId!, - checkinTime: new Date(), // 服务器取时,忽略客户端传入 - checkinLat: dto.checkinLat ?? null, - checkinLng: dto.checkinLng ?? null, - locateStatus: dto.locateStatus ?? 'ok', // 定位失败/拒绝不阻塞(3002) - recordStatus: 'serving', - isPrepaid, - billable: !isPrepaid, - }, + // 「工单转 serving + 建 serving 留痕」同一事务原子落库(仅 assigned → serving,serving 不重复转)。 + const record = await this.prisma.client.$transaction(async (tx) => { + if (order.orderStatus === 'assigned') { + await tx.order.update({ + where: { id: order.id }, + data: { orderStatus: 'serving' }, + }); + } + return tx.record.create({ + data: { + tenantCode: ctx.tenantCode, + createdBy: ctx.userId, + orderId: order.id, + elderId: order.elderId, + assigneeId: order.assigneeId!, + checkinTime: new Date(), // 服务器取时,忽略客户端传入 + checkinLat: dto.checkinLat ?? null, + checkinLng: dto.checkinLng ?? null, + locateStatus: dto.locateStatus ?? 'ok', // 定位失败/拒绝不阻塞(3002) + recordStatus: 'serving', + isPrepaid, + billable: !isPrepaid, + }, + }); }); return this.serializeRecord(record); } @@ -168,14 +177,21 @@ export class RecordService { const durationMin = checkinTime ? Math.round((checkoutTime.getTime() - new Date(checkinTime).getTime()) / 60000) : null; - const updated = await this.prisma.client.record.update({ - where: { id: record.id }, - data: { - checkoutTime, - durationMin, - recordStatus: 'submitted', - submitTime: new Date(), - }, + // 「工单转 done + 留痕 submitted」同一事务原子落库。 + const updated = await this.prisma.client.$transaction(async (tx) => { + await tx.order.update({ + where: { id: record.orderId }, + data: { orderStatus: 'done' }, + }); + return tx.record.update({ + where: { id: record.id }, + data: { + checkoutTime, + durationMin, + recordStatus: 'submitted', + submitTime: new Date(), + }, + }); }); return this.serializeRecord(updated); } @@ -212,58 +228,67 @@ export class RecordService { const durationMin = checkinTime && checkoutTime ? Math.round((checkoutTime.getTime() - checkinTime.getTime()) / 60000) : null; - const record = await this.prisma.client.record.create({ - data: { - tenantCode: ctx.tenantCode, - createdBy: ctx.userId, - orderId: order.id, - elderId: order.elderId, - assigneeId: order.assigneeId!, - checkinTime, - checkoutTime, - durationMin, - checkinLat: dto.checkinLat ?? null, - checkinLng: dto.checkinLng ?? null, - locateStatus: dto.locateStatus ?? 'ok', - recordStatus: 'submitted', - offlineFlag: true, - clientMd5: dto.clientMd5, - submitTime: new Date(), - isPrepaid, - billable: !isPrepaid, - }, - }); - + // 项目解析为只读操作(读 L4/快照),放在事务外;写入与工单状态流转同一事务原子落库。 const resolved = await this.resolveItems(dto.items ?? [], order); - for (const item of resolved) { - await this.prisma.client.recordItem.create({ + + const record = await this.prisma.client.$transaction(async (tx) => { + // 按 checkin/checkout 语义更新工单:含 checkoutTime → done,仅签到 → serving。 + await tx.order.update({ + where: { id: order.id }, + data: { orderStatus: checkoutTime ? 'done' : 'serving' }, + }); + const created = await tx.record.create({ data: { tenantCode: ctx.tenantCode, - recordId: record.id, - svcId: item.svcId, - svcNameSnapshot: item.svcName, - execResult: item.execResult, - undoReason: item.undoReason ?? null, - extraFlag: item.extraFlag ?? false, - photoRequired: item.photoRequired, - isRefusedPhoto: item.isRefusedPhoto ?? false, - refuseReason: item.refuseReason ?? null, + createdBy: ctx.userId, + orderId: order.id, + elderId: order.elderId, + assigneeId: order.assigneeId!, + checkinTime, + checkoutTime, + durationMin, + checkinLat: dto.checkinLat ?? null, + checkinLng: dto.checkinLng ?? null, + locateStatus: dto.locateStatus ?? 'ok', + recordStatus: 'submitted', + offlineFlag: true, + clientMd5: dto.clientMd5, + submitTime: new Date(), + isPrepaid, + billable: !isPrepaid, }, }); - } - for (const photo of dto.photos ?? []) { - await this.prisma.client.recordPhoto.create({ - data: { - tenantCode: ctx.tenantCode, - recordId: record.id, - svcId: photo.svcId ?? null, - filePath: photo.filePath, - fileSize: photo.fileSize, - visibleToFamily: false, - keepUntil: new Date(Date.now() + PHOTO_KEEP_MS), - }, - }); - } + for (const item of resolved) { + await tx.recordItem.create({ + data: { + tenantCode: ctx.tenantCode, + recordId: created.id, + svcId: item.svcId, + svcNameSnapshot: item.svcName, + execResult: item.execResult, + undoReason: item.undoReason ?? null, + extraFlag: item.extraFlag ?? false, + photoRequired: item.photoRequired, + isRefusedPhoto: item.isRefusedPhoto ?? false, + refuseReason: item.refuseReason ?? null, + }, + }); + } + for (const photo of dto.photos ?? []) { + await tx.recordPhoto.create({ + data: { + tenantCode: ctx.tenantCode, + recordId: created.id, + svcId: photo.svcId ?? null, + filePath: photo.filePath, + fileSize: photo.fileSize, + visibleToFamily: false, + keepUntil: new Date(Date.now() + PHOTO_KEEP_MS), + }, + }); + } + return created; + }); return this.serializeRecord(record); }