fix: 非事务多写包裹 \$transaction(plan/elder-svc/record/abnormal/qc)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-21 11:49:33 +08:00
parent 6ea9ad097b
commit 292300c344
10 changed files with 265 additions and 221 deletions

View File

@ -30,16 +30,17 @@ function makeService(
const orderUpdate = overrides.orderUpdate ?? vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'not_met' }); const orderUpdate = overrides.orderUpdate ?? vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'not_met' });
const orderFindUnique = overrides.orderFindUnique ?? vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'assigned' }); const orderFindUnique = overrides.orderFindUnique ?? vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'assigned' });
const prisma = { const client: Record<string, any> = {
client: { recordAbnormal: {
recordAbnormal: { create: recordAbnormalCreate,
create: recordAbnormalCreate, findMany: recordAbnormalFindMany,
findMany: recordAbnormalFindMany, count: recordAbnormalCount,
count: recordAbnormalCount,
},
order: { update: orderUpdate, findUnique: orderFindUnique },
}, },
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); const service = new AbnormalService(prisma as unknown as PrismaService);
return { service, recordAbnormalCreate, recordAbnormalFindMany, recordAbnormalCount, orderUpdate, orderFindUnique }; return { service, recordAbnormalCreate, recordAbnormalFindMany, recordAbnormalCount, orderUpdate, orderFindUnique };
} }

View File

@ -39,35 +39,40 @@ export class AbnormalService {
this.assertNotMetEvidence(dto); this.assertNotMetEvidence(dto);
// 扑空:工单须存在,再转 not_met(原因 + 到访时间)。 // 扑空:工单须存在,再转 not_met(原因 + 到访时间)。
await this.requireOrder(dto.orderId!); 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({ // 扑空的「工单转 not_met + 写 record_abnormal」须原子落库,防止工单已转 not_met 而异常未写入。
data: { const created = await this.prisma.client.$transaction(async (tx) => {
tenantCode, if (dto.abnormalType === NOT_MET) {
elderId: dto.elderId, await tx.order.update({
reporterId, where: { id: dto.orderId! },
createdBy, data: {
abnormalType: dto.abnormalType, orderStatus: 'not_met',
abnormalDesc: dto.abnormalDesc ?? null, // not_met_reason 为 VarChar(50),abnormalDesc 为 Text,须截断避免超长 500。
voiceFile: dto.voiceFile ?? null, notMetReason: (dto.abnormalDesc ?? '').slice(0, 50),
hasPhoto: dto.hasPhoto ?? false, notMetAt: toDateTime(dto.visitTime) ?? new Date(),
orderId: dto.orderId ?? null, },
recordId: dto.recordId ?? null, });
visitTime: toDateTime(dto.visitTime) ?? null, }
visitLat: dto.visitLat ?? null, return tx.recordAbnormal.create({
visitLng: dto.visitLng ?? null, data: {
notifyStatus: 'pending', tenantCode,
handleStatus: 'open', 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}。 // Prisma Decimal(visit_lat/visit_lng)转字符串,避免经 TransformInterceptor 泄漏为 {s,e,d}。
return { return {

View File

@ -36,19 +36,20 @@ function makeService(
const elderSvcUpdateMany = overrides.elderSvcUpdateMany ?? vi.fn().mockResolvedValue({ count: 2 }); const elderSvcUpdateMany = overrides.elderSvcUpdateMany ?? vi.fn().mockResolvedValue({ count: 2 });
const elderSvcCreate = overrides.elderSvcCreate ?? vi.fn().mockResolvedValue({ id: 'es1' }); const elderSvcCreate = overrides.elderSvcCreate ?? vi.fn().mockResolvedValue({ id: 'es1' });
const prisma = { const client: Record<string, any> = {
client: { elder: { findFirst: elderFindFirst },
elder: { findFirst: elderFindFirst }, svcOrg: { findFirst: svcOrgFindFirst },
svcOrg: { findFirst: svcOrgFindFirst }, svcNational: { findFirst: svcNationalFindFirst },
svcNational: { findFirst: svcNationalFindFirst }, svcLocal: { findFirst: svcLocalFindFirst },
svcLocal: { findFirst: svcLocalFindFirst }, elderSvc: {
elderSvc: { findMany: elderSvcFindMany,
findMany: elderSvcFindMany, updateMany: elderSvcUpdateMany,
updateMany: elderSvcUpdateMany, create: elderSvcCreate,
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); const service = new ElderSvcService(prisma as unknown as PrismaService);
return { return {
service, service,

View File

@ -27,33 +27,35 @@ export class ElderSvcService {
return this.prisma.client.elderSvc.findMany({ where: { elderId } }); return this.prisma.client.elderSvc.findMany({ where: { elderId } });
} }
/** 整表覆盖保存个人项目库。 */ /** 整表覆盖保存个人项目库(软删旧行 + 写新行同一事务原子落库)。 */
async put(elderId: string, dto: PutElderSvcDto) { async put(elderId: string, dto: PutElderSvcDto) {
await this.assertElderExists(elderId); await this.assertElderExists(elderId);
const resolved = await this.resolveSvcItems(dto.svcIds); 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 ctx = getRequestContext()!;
const rows = []; return this.prisma.client.$transaction(async (tx) => {
for (const item of resolved) { // 软删旧行留档(care_mode 变更/重新保存均重制旧库)。
rows.push( await tx.elderSvc.updateMany({
await this.prisma.client.elderSvc.create({ where: { elderId },
data: { data: { deletedAt: new Date() },
elderId, });
tenantCode: ctx.tenantCode, const rows = [];
svcId: item.svcId, for (const item of resolved) {
svcNameSnapshot: item.svcName, rows.push(
svcCodeSnapshot: item.svcCode, await tx.elderSvc.create({
careMode: dto.careMode, data: {
isActive: true, elderId,
}, tenantCode: ctx.tenantCode,
}), svcId: item.svcId,
); svcNameSnapshot: item.svcName,
} svcCodeSnapshot: item.svcCode,
return rows; careMode: dto.careMode,
isActive: true,
},
}),
);
}
return rows;
});
} }
/** 校验每个 svc_id 落本机构 L4,并解析名称/代码快照。 */ /** 校验每个 svc_id 落本机构 L4,并解析名称/代码快照。 */

View File

@ -56,21 +56,22 @@ function makeService(
const planItemCreate = overrides.planItemCreate ?? vi.fn().mockResolvedValue({ id: 'pi1' }); const planItemCreate = overrides.planItemCreate ?? vi.fn().mockResolvedValue({ id: 'pi1' });
const planItemFindMany = overrides.planItemFindMany ?? vi.fn().mockResolvedValue([]); const planItemFindMany = overrides.planItemFindMany ?? vi.fn().mockResolvedValue([]);
const prisma = { const client: Record<string, any> = {
client: { svcOrg: { findFirst: svcOrgFindFirst },
svcOrg: { findFirst: svcOrgFindFirst }, svcNational: { findFirst: svcNationalFindFirst },
svcNational: { findFirst: svcNationalFindFirst }, svcLocal: { findFirst: svcLocalFindFirst },
svcLocal: { findFirst: svcLocalFindFirst }, elder: { findFirst: elderFindFirst },
elder: { findFirst: elderFindFirst }, plan: {
plan: { create: planCreate,
create: planCreate, update: planUpdate,
update: planUpdate, findUnique: planFindUnique,
findUnique: planFindUnique, findMany: planFindMany,
findMany: planFindMany,
},
planItem: { create: planItemCreate, findMany: planItemFindMany },
}, },
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); const service = new PlanService(prisma as unknown as PrismaService);
return { return {
service, service,

View File

@ -27,18 +27,25 @@ export class PlanService {
/** 制定服务计划(active)。 */ /** 制定服务计划(active)。 */
create(dto: CreatePlanDto) { create(dto: CreatePlanDto) {
return this.buildPlan(dto, { planStatus: 'active' }); return this.createPlan(dto, { planStatus: 'active' });
} }
/** 变更服务计划:新建 active 计划并指向旧计划,旧计划转 changed。 */ /** 变更服务计划:新建 active 计划并指向旧计划,旧计划转 changed(同一事务原子落库)。 */
async change(dto: ChangePlanDto) { async change(dto: ChangePlanDto) {
const oldPlan = await this.requireExists(dto.changeFromPlanId); const oldPlan = await this.requireExists(dto.changeFromPlanId);
if (oldPlan.elderId !== dto.elderId) { if (oldPlan.elderId !== dto.elderId) {
throw new BusinessException(ErrorCodes.PLAN_NOT_FOUND, '被变更计划不属于该老人', HttpStatus.BAD_REQUEST); throw new BusinessException(ErrorCodes.PLAN_NOT_FOUND, '被变更计划不属于该老人', HttpStatus.BAD_REQUEST);
} }
const newPlan = await this.buildPlan(dto, { planStatus: 'active', changeFromPlanId: dto.changeFromPlanId }); const { planData, resolved } = await this.preparePlan(dto, {
await this.prisma.client.plan.update({ where: { id: dto.changeFromPlanId }, data: { planStatus: 'changed' } }); planStatus: 'active',
return newPlan; 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)。 */ /** 确认计划:仅写确认时间与老人/家属布尔(不改变 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); await this.assertElderExists(dto.elderId);
const items = dto.items ?? []; const items = dto.items ?? [];
const extraCount = dto.extraItemCount ?? 0; const extraCount = dto.extraItemCount ?? 0;
const resolved = await this.resolvePlanItems(items); const resolved = await this.resolvePlanItems(items);
const { ok, hint } = checkItemCount(dto.careMode, dto.careLevel, items.length, extraCount); const { ok, hint } = checkItemCount(dto.careMode, dto.careLevel, items.length, extraCount);
const plan = await this.createPlanHead({ const planData: Record<string, any> = {
elderId: dto.elderId, elderId: dto.elderId,
planPeriodStart: toDateOnly(dto.planPeriodStart), planPeriodStart: toDateOnly(dto.planPeriodStart),
planPeriodEnd: toDateOnly(dto.planPeriodEnd), planPeriodEnd: toDateOnly(dto.planPeriodEnd),
@ -88,26 +105,25 @@ export class PlanService {
planStatus: opts.planStatus, planStatus: opts.planStatus,
planBasis: dto.planBasis, planBasis: dto.planBasis,
changeFromPlanId: opts.changeFromPlanId, changeFromPlanId: opts.changeFromPlanId,
}); };
await this.createPlanItems(plan.id, resolved); return { planData, resolved };
return plan;
} }
/** 写计划头,注入 tenantCode/createdBy(plan 为租户级模型)。 */ /** 写计划头,注入 tenantCode/createdBy(plan 为租户级模型)。 */
private createPlanHead(data: Record<string, any>) { private createPlanHead(tx: any, data: Record<string, any>) {
const ctx = getRequestContext(); const ctx = getRequestContext();
if (ctx) { if (ctx) {
data.tenantCode = ctx.tenantCode; data.tenantCode = ctx.tenantCode;
data.createdBy = ctx.userId; data.createdBy = ctx.userId;
} }
return this.prisma.client.plan.create({ data } as any); return tx.plan.create({ data } as any);
} }
/** 逐个写 plan_item(含 svc 名称/代码快照)。 */ /** 逐个写 plan_item(含 svc 名称/代码快照)。 */
private async createPlanItems(planId: string, items: ResolvedPlanItem[]) { private async createPlanItems(tx: any, planId: string, items: ResolvedPlanItem[]) {
const ctx = getRequestContext()!; const ctx = getRequestContext()!;
for (const item of items) { for (const item of items) {
await this.prisma.client.planItem.create({ await tx.planItem.create({
data: { data: {
planId, planId,
tenantCode: ctx.tenantCode, tenantCode: ctx.tenantCode,

View File

@ -82,37 +82,41 @@ function makeService(
const planChange = overrides.planChange ?? vi.fn().mockResolvedValue({ id: 'p-new' }); const planChange = overrides.planChange ?? vi.fn().mockResolvedValue({ id: 'p-new' });
const client: Record<string, any> = {
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 = { const prisma = {
raw: { raw: {
record: { findMany: rawRecordFindMany }, record: { findMany: rawRecordFindMany },
qc: { findFirst: rawQcFindFirst, create: rawQcCreate }, qc: { findFirst: rawQcFindFirst, create: rawQcCreate },
}, },
client: { 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 },
},
}; };
const service = new QcService( const service = new QcService(

View File

@ -170,7 +170,7 @@ export class QcService {
const qc = await this.requireQc(id); const qc = await this.requireQc(id);
const record = await this.requireRecord(qc.recordId); const record = await this.requireRecord(qc.recordId);
await this.assertPassable(qc, record, ctx.userId); 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。 */ /** 退回:幂等守卫 → qc_note 必填 → returned。 */
@ -260,7 +260,7 @@ export class QcService {
/** 批量放行:先全量预检(批量边界 + 逐条自检),全过才逐条落库(原子,不部分放行)。 */ /** 批量放行:先全量预检(批量边界 + 逐条自检),全过才逐条落库(原子,不部分放行)。 */
async batchPass(dto: BatchPassDto) { async batchPass(dto: BatchPassDto) {
const ctx = getRequestContext()!; const ctx = getRequestContext()!;
const pendingQcs = []; const pendingQcs: Array<Record<string, any>> = [];
// 阶段一:全量预检,任一不过整体抛错,不落任何 pass。 // 阶段一:全量预检,任一不过整体抛错,不落任何 pass。
for (const recordId of dto.recordIds) { for (const recordId of dto.recordIds) {
const record = await this.requireRecord(recordId); const record = await this.requireRecord(recordId);
@ -289,18 +289,20 @@ export class QcService {
await this.assertPassable(qc, record, ctx.userId); await this.assertPassable(qc, record, ctx.userId);
pendingQcs.push(qc); pendingQcs.push(qc);
} }
// 阶段二:全部预检通过后逐条放行。 // 阶段二:全部预检通过后,同一事务内逐条放行(原子,不部分放行)。
for (const qc of pendingQcs) { await this.prisma.client.$transaction(async (tx) => {
await this.markPassed(qc.id, ctx.userId); for (const qc of pendingQcs) {
} await this.markPassed(tx, qc.id, ctx.userId);
}
});
return { passed: dto.recordIds.length }; return { passed: dto.recordIds.length };
} }
// ---- 内部 ---- // ---- 内部 ----
/** 放行落库:qc_result=passed + qc_staff_id + qc_time + pass_time。 */ /** 放行落库:qc_result=passed + qc_staff_id + qc_time + pass_time。 */
private markPassed(id: string, userId: string) { private markPassed(tx: any, id: string, userId: string) {
return this.prisma.client.qc.update({ return tx.qc.update({
where: { id }, where: { id },
data: { qcResult: 'passed', qcStaffId: userId, qcTime: new Date(), passTime: new Date() }, data: { qcResult: 'passed', qcStaffId: userId, qcTime: new Date(), passTime: new Date() },
}); });

View File

@ -59,26 +59,27 @@ function makeService(
const svcNationalFindFirst = overrides.svcNationalFindFirst ?? vi.fn().mockResolvedValue(null); const svcNationalFindFirst = overrides.svcNationalFindFirst ?? vi.fn().mockResolvedValue(null);
const svcLocalFindFirst = overrides.svcLocalFindFirst ?? vi.fn().mockResolvedValue(null); const svcLocalFindFirst = overrides.svcLocalFindFirst ?? vi.fn().mockResolvedValue(null);
const prisma = { const client: Record<string, any> = {
client: { order: { findUnique: orderFindUnique },
order: { findUnique: orderFindUnique }, staff: { findFirst: staffFindFirst },
staff: { findFirst: staffFindFirst }, elder: { findFirst: elderFindFirst, findUnique: elderFindUnique },
elder: { findFirst: elderFindFirst, findUnique: elderFindUnique }, record: {
record: { findUnique: recordFindUnique,
findUnique: recordFindUnique, findFirst: recordFindFirst,
findFirst: recordFindFirst, findMany: recordFindMany,
findMany: recordFindMany, create: recordCreate,
create: recordCreate, update: recordUpdate,
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 },
}, },
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 = { const storage = {
buildKey: vi.fn().mockReturnValue('record/T001/1-a.jpg'), buildKey: vi.fn().mockReturnValue('record/T001/1-a.jpg'),
upload: vi.fn().mockResolvedValue('record/T001/1-a.jpg'), upload: vi.fn().mockResolvedValue('record/T001/1-a.jpg'),

View File

@ -94,27 +94,29 @@ export class RecordService {
// 先全量校验(exec_result 枚举 + L4)并解析快照,任一失败不落库。 // 先全量校验(exec_result 枚举 + L4)并解析快照,任一失败不落库。
const resolved = await this.resolveItems(dto.items, order); const resolved = await this.resolveItems(dto.items, order);
await this.prisma.client.recordItem.deleteMany({ where: { recordId: record.id } }); return this.prisma.client.$transaction(async (tx) => {
const rows = []; await tx.recordItem.deleteMany({ where: { recordId: record.id } });
for (const item of resolved) { const rows = [];
rows.push( for (const item of resolved) {
await this.prisma.client.recordItem.create({ rows.push(
data: { await tx.recordItem.create({
tenantCode: ctx.tenantCode, data: {
recordId: record.id, tenantCode: ctx.tenantCode,
svcId: item.svcId, recordId: record.id,
svcNameSnapshot: item.svcName, svcId: item.svcId,
execResult: item.execResult, svcNameSnapshot: item.svcName,
undoReason: item.undoReason ?? null, execResult: item.execResult,
extraFlag: item.extraFlag ?? false, undoReason: item.undoReason ?? null,
photoRequired: item.photoRequired, extraFlag: item.extraFlag ?? false,
isRefusedPhoto: item.isRefusedPhoto ?? false, photoRequired: item.photoRequired,
refuseReason: item.refuseReason ?? null, isRefusedPhoto: item.isRefusedPhoto ?? false,
}, refuseReason: item.refuseReason ?? null,
}), },
); }),
} );
return rows; }
return rows;
});
} }
/** 上传照片:≤500KB → MinIO(record 前缀)→ record_photo。 */ /** 上传照片:≤500KB → MinIO(record 前缀)→ record_photo。 */
@ -340,63 +342,72 @@ export class RecordService {
} }
} }
const created = []; // 预读各垫付记录的 items/photos(读操作,事务外),再在同一事务内全量写入(原子,不部分结转)。
for (const rec of records) { const snapshots = await Promise.all(
const [items, photos] = await Promise.all([ records.map(async (rec) => {
this.prisma.client.recordItem.findMany({ where: { recordId: rec.id } }), const [items, photos] = await Promise.all([
this.prisma.client.recordPhoto.findMany({ where: { recordId: rec.id } }), 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: { return { rec, items, photos };
tenantCode: ctx.tenantCode, }),
createdBy: ctx.userId, );
orderId: rec.orderId,
elderId: rec.elderId, return this.prisma.client.$transaction(async (tx) => {
assigneeId: rec.assigneeId, const created = [];
checkinTime: rec.checkinTime, // 原样复制,不改时间戳 for (const { rec, items, photos } of snapshots) {
checkoutTime: rec.checkoutTime, const newRecord = await tx.record.create({
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({
data: { data: {
tenantCode: ctx.tenantCode, tenantCode: ctx.tenantCode,
recordId: newRecord.id, createdBy: ctx.userId,
svcId: item.svcId, orderId: rec.orderId,
svcNameSnapshot: item.svcNameSnapshot, elderId: rec.elderId,
execResult: item.execResult, assigneeId: rec.assigneeId,
undoReason: item.undoReason, checkinTime: rec.checkinTime, // 原样复制,不改时间戳
extraFlag: item.extraFlag, checkoutTime: rec.checkoutTime,
photoRequired: item.photoRequired, durationMin: rec.durationMin,
isRefusedPhoto: item.isRefusedPhoto, checkinLat: rec.checkinLat,
refuseReason: item.refuseReason, 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) { return created;
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;
} }
// ---- 内部校验 ---- // ---- 内部校验 ----