fix: 异常 client_md5 幂等 + checkin/checkout 更新工单状态

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-21 14:56:40 +08:00
parent 394d7a9731
commit c18187e658
7 changed files with 275 additions and 78 deletions

View File

@ -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`);

View File

@ -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")

View File

@ -20,6 +20,7 @@ function makeService(
recordAbnormalCreate?: ReturnType<typeof vi.fn>;
recordAbnormalFindMany?: ReturnType<typeof vi.fn>;
recordAbnormalCount?: ReturnType<typeof vi.fn>;
recordAbnormalFindFirst?: ReturnType<typeof vi.fn>;
orderUpdate?: ReturnType<typeof vi.fn>;
orderFindUnique?: ReturnType<typeof vi.fn>;
} = {},
@ -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();

View File

@ -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<string, any>): Record<string, any> {
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<Record<string, unknown>> {
const order = await this.prisma.client.order.findUnique({ where: { id } });

View File

@ -84,6 +84,11 @@ export class ReportAbnormalDto {
@IsOptional()
@IsNumber()
visitLng?: number;
@ApiPropertyOptional({ description: '客户端内容指纹(离线补传幂等去重,在线上报可空)' })
@IsOptional()
@IsString()
clientMd5?: string;
}
/** 异常列表查询(后台 B4),按分类/通知状态/处理状态/老人筛选。 */

View File

@ -19,6 +19,7 @@ const ctx: RequestContext = {
function makeService(
overrides: {
orderFindUnique?: ReturnType<typeof vi.fn>;
orderUpdate?: ReturnType<typeof vi.fn>;
staffFindFirst?: ReturnType<typeof vi.fn>;
elderFindFirst?: ReturnType<typeof vi.fn>;
elderFindUnique?: ReturnType<typeof vi.fn>;
@ -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<string, any> = {
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', () => {

View File

@ -66,7 +66,15 @@ 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({
// 「工单转 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,
@ -82,6 +90,7 @@ export class RecordService {
billable: !isPrepaid,
},
});
});
return this.serializeRecord(record);
}
@ -168,7 +177,13 @@ export class RecordService {
const durationMin = checkinTime
? Math.round((checkoutTime.getTime() - new Date(checkinTime).getTime()) / 60000)
: null;
const updated = await this.prisma.client.record.update({
// 「工单转 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,
@ -177,6 +192,7 @@ export class RecordService {
submitTime: new Date(),
},
});
});
return this.serializeRecord(updated);
}
@ -212,7 +228,16 @@ export class RecordService {
const durationMin =
checkinTime && checkoutTime ? Math.round((checkoutTime.getTime() - checkinTime.getTime()) / 60000) : null;
const record = await this.prisma.client.record.create({
// 项目解析为只读操作(读 L4/快照),放在事务外;写入与工单状态流转同一事务原子落库。
const resolved = await this.resolveItems(dto.items ?? [], order);
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,
createdBy: ctx.userId,
@ -233,13 +258,11 @@ export class RecordService {
billable: !isPrepaid,
},
});
const resolved = await this.resolveItems(dto.items ?? [], order);
for (const item of resolved) {
await this.prisma.client.recordItem.create({
await tx.recordItem.create({
data: {
tenantCode: ctx.tenantCode,
recordId: record.id,
recordId: created.id,
svcId: item.svcId,
svcNameSnapshot: item.svcName,
execResult: item.execResult,
@ -252,10 +275,10 @@ export class RecordService {
});
}
for (const photo of dto.photos ?? []) {
await this.prisma.client.recordPhoto.create({
await tx.recordPhoto.create({
data: {
tenantCode: ctx.tenantCode,
recordId: record.id,
recordId: created.id,
svcId: photo.svcId ?? null,
filePath: photo.filePath,
fileSize: photo.fileSize,
@ -264,6 +287,8 @@ export class RecordService {
},
});
}
return created;
});
return this.serializeRecord(record);
}