From 292300c3442dce6d2f85e6416ecdce4c3eb60d8f Mon Sep 17 00:00:00 2001 From: liuyun Date: Wed, 21 Oct 2026 11:49:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=9D=9E=E4=BA=8B=E5=8A=A1=E5=A4=9A?= =?UTF-8?q?=E5=86=99=E5=8C=85=E8=A3=B9=20\$transaction=EF=BC=88plan/elder-?= =?UTF-8?q?svc/record/abnormal/qc=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/abnormal/abnormal.service.spec.ts | 17 +-- src/abnormal/abnormal.service.ts | 59 +++++---- src/elder-svc/elder-svc.service.spec.ts | 23 ++-- src/elder-svc/elder-svc.service.ts | 48 ++++---- src/plan/plan.service.spec.ts | 27 ++-- src/plan/plan.service.ts | 44 ++++--- src/qc/qc.service.spec.ts | 56 +++++---- src/qc/qc.service.ts | 18 +-- src/record/record.service.spec.ts | 37 +++--- src/record/record.service.ts | 157 +++++++++++++----------- 10 files changed, 265 insertions(+), 221 deletions(-) diff --git a/src/abnormal/abnormal.service.spec.ts b/src/abnormal/abnormal.service.spec.ts index 8e49a22..b5800f6 100644 --- a/src/abnormal/abnormal.service.spec.ts +++ b/src/abnormal/abnormal.service.spec.ts @@ -30,16 +30,17 @@ function makeService( const orderUpdate = overrides.orderUpdate ?? vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'not_met' }); const orderFindUnique = overrides.orderFindUnique ?? vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'assigned' }); - const prisma = { - client: { - recordAbnormal: { - create: recordAbnormalCreate, - findMany: recordAbnormalFindMany, - count: recordAbnormalCount, - }, - order: { update: orderUpdate, findUnique: orderFindUnique }, + const client: Record = { + recordAbnormal: { + create: recordAbnormalCreate, + findMany: recordAbnormalFindMany, + count: recordAbnormalCount, }, + order: { update: orderUpdate, findUnique: orderFindUnique }, }; + // $transaction:交互式回调以 client 自身作为 tx(事务内写仍命中同一 mock)。 + 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 }; } diff --git a/src/abnormal/abnormal.service.ts b/src/abnormal/abnormal.service.ts index 6ad8ccb..6a8b914 100644 --- a/src/abnormal/abnormal.service.ts +++ b/src/abnormal/abnormal.service.ts @@ -39,35 +39,40 @@ export class AbnormalService { this.assertNotMetEvidence(dto); // 扑空:工单须存在,再转 not_met(原因 + 到访时间)。 await this.requireOrder(dto.orderId!); - await this.prisma.client.order.update({ - where: { id: dto.orderId! }, - data: { - orderStatus: 'not_met', - // not_met_reason 为 VarChar(50),abnormalDesc 为 Text,须截断避免超长 500。 - notMetReason: (dto.abnormalDesc ?? '').slice(0, 50), - notMetAt: toDateTime(dto.visitTime) ?? new Date(), - }, - }); } - const created = await this.prisma.client.recordAbnormal.create({ - data: { - tenantCode, - elderId: dto.elderId, - reporterId, - createdBy, - abnormalType: dto.abnormalType, - abnormalDesc: dto.abnormalDesc ?? null, - voiceFile: dto.voiceFile ?? null, - hasPhoto: dto.hasPhoto ?? false, - orderId: dto.orderId ?? null, - recordId: dto.recordId ?? null, - visitTime: toDateTime(dto.visitTime) ?? null, - visitLat: dto.visitLat ?? null, - visitLng: dto.visitLng ?? null, - notifyStatus: 'pending', - handleStatus: 'open', - }, + // 扑空的「工单转 not_met + 写 record_abnormal」须原子落库,防止工单已转 not_met 而异常未写入。 + const created = await this.prisma.client.$transaction(async (tx) => { + if (dto.abnormalType === NOT_MET) { + await tx.order.update({ + where: { id: dto.orderId! }, + data: { + orderStatus: 'not_met', + // not_met_reason 为 VarChar(50),abnormalDesc 为 Text,须截断避免超长 500。 + notMetReason: (dto.abnormalDesc ?? '').slice(0, 50), + notMetAt: toDateTime(dto.visitTime) ?? new Date(), + }, + }); + } + return tx.recordAbnormal.create({ + data: { + tenantCode, + elderId: dto.elderId, + reporterId, + createdBy, + abnormalType: dto.abnormalType, + abnormalDesc: dto.abnormalDesc ?? null, + voiceFile: dto.voiceFile ?? null, + hasPhoto: dto.hasPhoto ?? false, + orderId: dto.orderId ?? null, + recordId: dto.recordId ?? null, + visitTime: toDateTime(dto.visitTime) ?? null, + visitLat: dto.visitLat ?? null, + visitLng: dto.visitLng ?? null, + notifyStatus: 'pending', + handleStatus: 'open', + }, + }); }); // Prisma Decimal(visit_lat/visit_lng)转字符串,避免经 TransformInterceptor 泄漏为 {s,e,d}。 return { diff --git a/src/elder-svc/elder-svc.service.spec.ts b/src/elder-svc/elder-svc.service.spec.ts index 3340d6a..89b96e7 100644 --- a/src/elder-svc/elder-svc.service.spec.ts +++ b/src/elder-svc/elder-svc.service.spec.ts @@ -36,19 +36,20 @@ function makeService( const elderSvcUpdateMany = overrides.elderSvcUpdateMany ?? vi.fn().mockResolvedValue({ count: 2 }); const elderSvcCreate = overrides.elderSvcCreate ?? vi.fn().mockResolvedValue({ id: 'es1' }); - const prisma = { - client: { - elder: { findFirst: elderFindFirst }, - svcOrg: { findFirst: svcOrgFindFirst }, - svcNational: { findFirst: svcNationalFindFirst }, - svcLocal: { findFirst: svcLocalFindFirst }, - elderSvc: { - findMany: elderSvcFindMany, - updateMany: elderSvcUpdateMany, - create: elderSvcCreate, - }, + const client: Record = { + elder: { findFirst: elderFindFirst }, + svcOrg: { findFirst: svcOrgFindFirst }, + svcNational: { findFirst: svcNationalFindFirst }, + svcLocal: { findFirst: svcLocalFindFirst }, + elderSvc: { + findMany: elderSvcFindMany, + updateMany: elderSvcUpdateMany, + create: elderSvcCreate, }, }; + // $transaction:交互式回调以 client 自身作为 tx(事务内写仍命中同一 mock)。 + client.$transaction = vi.fn(async (arg: any) => (typeof arg === 'function' ? arg(client) : Promise.all(arg))); + const prisma = { client }; const service = new ElderSvcService(prisma as unknown as PrismaService); return { service, diff --git a/src/elder-svc/elder-svc.service.ts b/src/elder-svc/elder-svc.service.ts index 03f6559..931167a 100644 --- a/src/elder-svc/elder-svc.service.ts +++ b/src/elder-svc/elder-svc.service.ts @@ -27,33 +27,35 @@ export class ElderSvcService { return this.prisma.client.elderSvc.findMany({ where: { elderId } }); } - /** 整表覆盖保存个人项目库。 */ + /** 整表覆盖保存个人项目库(软删旧行 + 写新行同一事务原子落库)。 */ async put(elderId: string, dto: PutElderSvcDto) { await this.assertElderExists(elderId); const resolved = await this.resolveSvcItems(dto.svcIds); - // 软删旧行留档(care_mode 变更/重新保存均重制旧库)。 - await this.prisma.client.elderSvc.updateMany({ - where: { elderId }, - data: { deletedAt: new Date() }, - }); const ctx = getRequestContext()!; - const rows = []; - for (const item of resolved) { - rows.push( - await this.prisma.client.elderSvc.create({ - data: { - elderId, - tenantCode: ctx.tenantCode, - svcId: item.svcId, - svcNameSnapshot: item.svcName, - svcCodeSnapshot: item.svcCode, - careMode: dto.careMode, - isActive: true, - }, - }), - ); - } - return rows; + return this.prisma.client.$transaction(async (tx) => { + // 软删旧行留档(care_mode 变更/重新保存均重制旧库)。 + await tx.elderSvc.updateMany({ + where: { elderId }, + data: { deletedAt: new Date() }, + }); + const rows = []; + for (const item of resolved) { + rows.push( + await tx.elderSvc.create({ + data: { + elderId, + tenantCode: ctx.tenantCode, + svcId: item.svcId, + svcNameSnapshot: item.svcName, + svcCodeSnapshot: item.svcCode, + careMode: dto.careMode, + isActive: true, + }, + }), + ); + } + return rows; + }); } /** 校验每个 svc_id 落本机构 L4,并解析名称/代码快照。 */ diff --git a/src/plan/plan.service.spec.ts b/src/plan/plan.service.spec.ts index 29dbdbc..f893fd0 100644 --- a/src/plan/plan.service.spec.ts +++ b/src/plan/plan.service.spec.ts @@ -56,21 +56,22 @@ function makeService( const planItemCreate = overrides.planItemCreate ?? vi.fn().mockResolvedValue({ id: 'pi1' }); const planItemFindMany = overrides.planItemFindMany ?? vi.fn().mockResolvedValue([]); - const prisma = { - client: { - svcOrg: { findFirst: svcOrgFindFirst }, - svcNational: { findFirst: svcNationalFindFirst }, - svcLocal: { findFirst: svcLocalFindFirst }, - elder: { findFirst: elderFindFirst }, - plan: { - create: planCreate, - update: planUpdate, - findUnique: planFindUnique, - findMany: planFindMany, - }, - planItem: { create: planItemCreate, findMany: planItemFindMany }, + const client: Record = { + svcOrg: { findFirst: svcOrgFindFirst }, + svcNational: { findFirst: svcNationalFindFirst }, + svcLocal: { findFirst: svcLocalFindFirst }, + elder: { findFirst: elderFindFirst }, + plan: { + create: planCreate, + update: planUpdate, + findUnique: planFindUnique, + findMany: planFindMany, }, + planItem: { create: planItemCreate, findMany: planItemFindMany }, }; + // $transaction:交互式回调以 client 自身作为 tx(事务内写仍命中同一 mock)。 + client.$transaction = vi.fn(async (arg: any) => (typeof arg === 'function' ? arg(client) : Promise.all(arg))); + const prisma = { client }; const service = new PlanService(prisma as unknown as PrismaService); return { service, diff --git a/src/plan/plan.service.ts b/src/plan/plan.service.ts index ce89bfa..6225e51 100644 --- a/src/plan/plan.service.ts +++ b/src/plan/plan.service.ts @@ -27,18 +27,25 @@ export class PlanService { /** 制定服务计划(active)。 */ create(dto: CreatePlanDto) { - return this.buildPlan(dto, { planStatus: 'active' }); + return this.createPlan(dto, { planStatus: 'active' }); } - /** 变更服务计划:新建 active 计划并指向旧计划,旧计划转 changed。 */ + /** 变更服务计划:新建 active 计划并指向旧计划,旧计划转 changed(同一事务原子落库)。 */ async change(dto: ChangePlanDto) { const oldPlan = await this.requireExists(dto.changeFromPlanId); if (oldPlan.elderId !== dto.elderId) { throw new BusinessException(ErrorCodes.PLAN_NOT_FOUND, '被变更计划不属于该老人', HttpStatus.BAD_REQUEST); } - const newPlan = await this.buildPlan(dto, { planStatus: 'active', changeFromPlanId: dto.changeFromPlanId }); - await this.prisma.client.plan.update({ where: { id: dto.changeFromPlanId }, data: { planStatus: 'changed' } }); - return newPlan; + const { planData, resolved } = await this.preparePlan(dto, { + planStatus: 'active', + changeFromPlanId: dto.changeFromPlanId, + }); + return this.prisma.client.$transaction(async (tx) => { + const newPlan = await this.createPlanHead(tx, planData); + await this.createPlanItems(tx, newPlan.id, resolved); + await tx.plan.update({ where: { id: dto.changeFromPlanId }, data: { planStatus: 'changed' } }); + return newPlan; + }); } /** 确认计划:仅写确认时间与老人/家属布尔(不改变 plan_status)。 */ @@ -67,13 +74,23 @@ export class PlanService { } /** 制定/变更共用的建计划流程。 */ - private async buildPlan(dto: CreatePlanDto, opts: { planStatus: string; changeFromPlanId?: string }) { + private async createPlan(dto: CreatePlanDto, opts: { planStatus: string; changeFromPlanId?: string }) { + const { planData, resolved } = await this.preparePlan(dto, opts); + return this.prisma.client.$transaction(async (tx) => { + const plan = await this.createPlanHead(tx, planData); + await this.createPlanItems(tx, plan.id, resolved); + return plan; + }); + } + + /** 制定/变更共用的前置校验与解析(读操作,事务外执行)。 */ + private async preparePlan(dto: CreatePlanDto, opts: { planStatus: string; changeFromPlanId?: string }) { await this.assertElderExists(dto.elderId); const items = dto.items ?? []; const extraCount = dto.extraItemCount ?? 0; const resolved = await this.resolvePlanItems(items); const { ok, hint } = checkItemCount(dto.careMode, dto.careLevel, items.length, extraCount); - const plan = await this.createPlanHead({ + const planData: Record = { elderId: dto.elderId, planPeriodStart: toDateOnly(dto.planPeriodStart), planPeriodEnd: toDateOnly(dto.planPeriodEnd), @@ -88,26 +105,25 @@ export class PlanService { planStatus: opts.planStatus, planBasis: dto.planBasis, changeFromPlanId: opts.changeFromPlanId, - }); - await this.createPlanItems(plan.id, resolved); - return plan; + }; + return { planData, resolved }; } /** 写计划头,注入 tenantCode/createdBy(plan 为租户级模型)。 */ - private createPlanHead(data: Record) { + private createPlanHead(tx: any, data: Record) { const ctx = getRequestContext(); if (ctx) { data.tenantCode = ctx.tenantCode; data.createdBy = ctx.userId; } - return this.prisma.client.plan.create({ data } as any); + return tx.plan.create({ data } as any); } /** 逐个写 plan_item(含 svc 名称/代码快照)。 */ - private async createPlanItems(planId: string, items: ResolvedPlanItem[]) { + private async createPlanItems(tx: any, planId: string, items: ResolvedPlanItem[]) { const ctx = getRequestContext()!; for (const item of items) { - await this.prisma.client.planItem.create({ + await tx.planItem.create({ data: { planId, tenantCode: ctx.tenantCode, diff --git a/src/qc/qc.service.spec.ts b/src/qc/qc.service.spec.ts index afc4542..d8677f7 100644 --- a/src/qc/qc.service.spec.ts +++ b/src/qc/qc.service.spec.ts @@ -82,37 +82,41 @@ function makeService( const planChange = overrides.planChange ?? vi.fn().mockResolvedValue({ id: 'p-new' }); + const client: Record = { + qc: { + findUnique: qcFindUnique, + findFirst: qcFindFirst, + findMany: qcFindMany, + update: qcUpdate, + create: qcCreate, + }, + record: { + findUnique: recordFindUnique, + findFirst: recordFindFirst, + update: recordUpdate, + }, + recordItem: { findMany: recordItemFindMany }, + recordPhoto: { findMany: recordPhotoFindMany }, + recordAbnormal: { + findMany: recordAbnormalFindMany, + count: recordAbnormalCount, + findUnique: recordAbnormalFindUnique, + update: recordAbnormalUpdate, + }, + recordCorrection: { findMany: recordCorrectionFindMany, create: recordCorrectionCreate, count: recordCorrectionCount }, + order: { findMany: orderFindMany, findFirst: orderFindFirst }, + elder: { findMany: elderFindMany }, + plan: { findFirst: planFindFirst }, + }; + // $transaction:交互式回调以 client 自身作为 tx(事务内写仍命中同一 mock)。 + client.$transaction = vi.fn(async (arg: any) => (typeof arg === 'function' ? arg(client) : Promise.all(arg))); + const prisma = { raw: { record: { findMany: rawRecordFindMany }, qc: { findFirst: rawQcFindFirst, create: rawQcCreate }, }, - client: { - qc: { - findUnique: qcFindUnique, - findFirst: qcFindFirst, - findMany: qcFindMany, - update: qcUpdate, - create: qcCreate, - }, - record: { - findUnique: recordFindUnique, - findFirst: recordFindFirst, - update: recordUpdate, - }, - recordItem: { findMany: recordItemFindMany }, - recordPhoto: { findMany: recordPhotoFindMany }, - recordAbnormal: { - findMany: recordAbnormalFindMany, - count: recordAbnormalCount, - findUnique: recordAbnormalFindUnique, - update: recordAbnormalUpdate, - }, - recordCorrection: { findMany: recordCorrectionFindMany, create: recordCorrectionCreate, count: recordCorrectionCount }, - order: { findMany: orderFindMany, findFirst: orderFindFirst }, - elder: { findMany: elderFindMany }, - plan: { findFirst: planFindFirst }, - }, + client, }; const service = new QcService( diff --git a/src/qc/qc.service.ts b/src/qc/qc.service.ts index e09e7c5..126730d 100644 --- a/src/qc/qc.service.ts +++ b/src/qc/qc.service.ts @@ -170,7 +170,7 @@ export class QcService { const qc = await this.requireQc(id); const record = await this.requireRecord(qc.recordId); await this.assertPassable(qc, record, ctx.userId); - return this.markPassed(id, ctx.userId); + return this.markPassed(this.prisma.client, id, ctx.userId); } /** 退回:幂等守卫 → qc_note 必填 → returned。 */ @@ -260,7 +260,7 @@ export class QcService { /** 批量放行:先全量预检(批量边界 + 逐条自检),全过才逐条落库(原子,不部分放行)。 */ async batchPass(dto: BatchPassDto) { const ctx = getRequestContext()!; - const pendingQcs = []; + const pendingQcs: Array> = []; // 阶段一:全量预检,任一不过整体抛错,不落任何 pass。 for (const recordId of dto.recordIds) { const record = await this.requireRecord(recordId); @@ -289,18 +289,20 @@ export class QcService { await this.assertPassable(qc, record, ctx.userId); pendingQcs.push(qc); } - // 阶段二:全部预检通过后逐条放行。 - for (const qc of pendingQcs) { - await this.markPassed(qc.id, ctx.userId); - } + // 阶段二:全部预检通过后,同一事务内逐条放行(原子,不部分放行)。 + await this.prisma.client.$transaction(async (tx) => { + for (const qc of pendingQcs) { + await this.markPassed(tx, qc.id, ctx.userId); + } + }); return { passed: dto.recordIds.length }; } // ---- 内部 ---- /** 放行落库:qc_result=passed + qc_staff_id + qc_time + pass_time。 */ - private markPassed(id: string, userId: string) { - return this.prisma.client.qc.update({ + private markPassed(tx: any, id: string, userId: string) { + return tx.qc.update({ where: { id }, data: { qcResult: 'passed', qcStaffId: userId, qcTime: new Date(), passTime: new Date() }, }); diff --git a/src/record/record.service.spec.ts b/src/record/record.service.spec.ts index 1d9c505..392fbfd 100644 --- a/src/record/record.service.spec.ts +++ b/src/record/record.service.spec.ts @@ -59,26 +59,27 @@ function makeService( const svcNationalFindFirst = overrides.svcNationalFindFirst ?? vi.fn().mockResolvedValue(null); const svcLocalFindFirst = overrides.svcLocalFindFirst ?? vi.fn().mockResolvedValue(null); - const prisma = { - client: { - order: { findUnique: orderFindUnique }, - staff: { findFirst: staffFindFirst }, - elder: { findFirst: elderFindFirst, findUnique: elderFindUnique }, - record: { - findUnique: recordFindUnique, - findFirst: recordFindFirst, - findMany: recordFindMany, - create: recordCreate, - update: recordUpdate, - }, - recordItem: { findMany: recordItemFindMany, create: recordItemCreate, deleteMany: recordItemDeleteMany }, - recordPhoto: { findMany: recordPhotoFindMany, findFirst: recordPhotoFindFirst, create: recordPhotoCreate }, - recordAbnormal: { findMany: recordAbnormalFindMany }, - svcOrg: { findFirst: svcOrgFindFirst }, - svcNational: { findFirst: svcNationalFindFirst }, - svcLocal: { findFirst: svcLocalFindFirst }, + const client: Record = { + order: { findUnique: orderFindUnique }, + staff: { findFirst: staffFindFirst }, + elder: { findFirst: elderFindFirst, findUnique: elderFindUnique }, + record: { + findUnique: recordFindUnique, + findFirst: recordFindFirst, + findMany: recordFindMany, + create: recordCreate, + update: recordUpdate, }, + recordItem: { findMany: recordItemFindMany, create: recordItemCreate, deleteMany: recordItemDeleteMany }, + recordPhoto: { findMany: recordPhotoFindMany, findFirst: recordPhotoFindFirst, create: recordPhotoCreate }, + recordAbnormal: { findMany: recordAbnormalFindMany }, + svcOrg: { findFirst: svcOrgFindFirst }, + svcNational: { findFirst: svcNationalFindFirst }, + svcLocal: { findFirst: svcLocalFindFirst }, }; + // $transaction:交互式回调以 client 自身作为 tx(事务内写仍命中同一 mock)。 + client.$transaction = vi.fn(async (arg: any) => (typeof arg === 'function' ? arg(client) : Promise.all(arg))); + const prisma = { client }; const storage = { buildKey: vi.fn().mockReturnValue('record/T001/1-a.jpg'), upload: vi.fn().mockResolvedValue('record/T001/1-a.jpg'), diff --git a/src/record/record.service.ts b/src/record/record.service.ts index 450131a..b4bcf55 100644 --- a/src/record/record.service.ts +++ b/src/record/record.service.ts @@ -94,27 +94,29 @@ export class RecordService { // 先全量校验(exec_result 枚举 + L4)并解析快照,任一失败不落库。 const resolved = await this.resolveItems(dto.items, order); - await this.prisma.client.recordItem.deleteMany({ where: { recordId: record.id } }); - const rows = []; - for (const item of resolved) { - rows.push( - await this.prisma.client.recordItem.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, - }, - }), - ); - } - return rows; + return this.prisma.client.$transaction(async (tx) => { + await tx.recordItem.deleteMany({ where: { recordId: record.id } }); + const rows = []; + for (const item of resolved) { + rows.push( + await tx.recordItem.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, + }, + }), + ); + } + return rows; + }); } /** 上传照片:≤500KB → MinIO(record 前缀)→ record_photo。 */ @@ -340,63 +342,72 @@ export class RecordService { } } - const created = []; - for (const rec of records) { - const [items, photos] = await Promise.all([ - this.prisma.client.recordItem.findMany({ where: { recordId: rec.id } }), - this.prisma.client.recordPhoto.findMany({ where: { recordId: rec.id } }), - ]); - const newRecord = await this.prisma.client.record.create({ - data: { - tenantCode: ctx.tenantCode, - createdBy: ctx.userId, - orderId: rec.orderId, - elderId: rec.elderId, - assigneeId: rec.assigneeId, - checkinTime: rec.checkinTime, // 原样复制,不改时间戳 - checkoutTime: rec.checkoutTime, - durationMin: rec.durationMin, - checkinLat: rec.checkinLat, - checkinLng: rec.checkinLng, - locateStatus: rec.locateStatus, - recordStatus: rec.recordStatus, - isPrepaid: false, - billable: true, - sourcePrepaidOf: rec.id, - }, - }); - for (const item of items) { - await this.prisma.client.recordItem.create({ + // 预读各垫付记录的 items/photos(读操作,事务外),再在同一事务内全量写入(原子,不部分结转)。 + const snapshots = await Promise.all( + records.map(async (rec) => { + const [items, photos] = await Promise.all([ + this.prisma.client.recordItem.findMany({ where: { recordId: rec.id } }), + this.prisma.client.recordPhoto.findMany({ where: { recordId: rec.id } }), + ]); + return { rec, items, photos }; + }), + ); + + return this.prisma.client.$transaction(async (tx) => { + const created = []; + for (const { rec, items, photos } of snapshots) { + const newRecord = await tx.record.create({ data: { tenantCode: ctx.tenantCode, - recordId: newRecord.id, - svcId: item.svcId, - svcNameSnapshot: item.svcNameSnapshot, - execResult: item.execResult, - undoReason: item.undoReason, - extraFlag: item.extraFlag, - photoRequired: item.photoRequired, - isRefusedPhoto: item.isRefusedPhoto, - refuseReason: item.refuseReason, + createdBy: ctx.userId, + orderId: rec.orderId, + elderId: rec.elderId, + assigneeId: rec.assigneeId, + checkinTime: rec.checkinTime, // 原样复制,不改时间戳 + checkoutTime: rec.checkoutTime, + durationMin: rec.durationMin, + checkinLat: rec.checkinLat, + checkinLng: rec.checkinLng, + locateStatus: rec.locateStatus, + recordStatus: rec.recordStatus, + isPrepaid: false, + billable: true, + sourcePrepaidOf: rec.id, }, }); + for (const item of items) { + await tx.recordItem.create({ + data: { + tenantCode: ctx.tenantCode, + recordId: newRecord.id, + svcId: item.svcId, + svcNameSnapshot: item.svcNameSnapshot, + execResult: item.execResult, + undoReason: item.undoReason, + extraFlag: item.extraFlag, + photoRequired: item.photoRequired, + isRefusedPhoto: item.isRefusedPhoto, + refuseReason: item.refuseReason, + }, + }); + } + for (const photo of photos) { + await tx.recordPhoto.create({ + data: { + tenantCode: ctx.tenantCode, + recordId: newRecord.id, + svcId: photo.svcId, + filePath: photo.filePath, + fileSize: photo.fileSize, + visibleToFamily: photo.visibleToFamily, + keepUntil: photo.keepUntil, + }, + }); + } + created.push(this.serializeRecord(newRecord)); } - for (const photo of photos) { - await this.prisma.client.recordPhoto.create({ - data: { - tenantCode: ctx.tenantCode, - recordId: newRecord.id, - svcId: photo.svcId, - filePath: photo.filePath, - fileSize: photo.fileSize, - visibleToFamily: photo.visibleToFamily, - keepUntil: photo.keepUntil, - }, - }); - } - created.push(this.serializeRecord(newRecord)); - } - return created; + return created; + }); } // ---- 内部校验 ----