From 563f5ab5b5fdab5e1e43ee37f4c4fc0a7aab0d64 Mon Sep 17 00:00:00 2001 From: liuyun Date: Thu, 15 Oct 2026 00:34:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(c3):=20record=20=E4=BB=8E=20order=20?= =?UTF-8?q?=E6=8E=A8=E5=AF=BC=20photo=5Frequired=20=E4=B8=8E=20elder/assig?= =?UTF-8?q?nee?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - itemSave:photo_required 由 order.item_list 的 photo_tier 推导(must→required), extraFlag 临时加项才回退 DTO 值,防客户端传 false 绕过 3001 必拍校验 - offlineSync:elder_id/assignee_id 以 order 为准,忽略 DTO 字段,防 order/elder/assignee 不匹配 Co-Authored-By: Claude Sonnet 4.6 --- src/record/record.service.spec.ts | 76 ++++++++++++++++++++++++++++--- src/record/record.service.ts | 33 ++++++++++---- 2 files changed, 93 insertions(+), 16 deletions(-) diff --git a/src/record/record.service.spec.ts b/src/record/record.service.spec.ts index 2bc8435..5b362b6 100644 --- a/src/record/record.service.spec.ts +++ b/src/record/record.service.spec.ts @@ -218,11 +218,13 @@ describe('RecordService.itemSave', () => { it('整表覆盖 + L4 落库 + snapshot svc_name', async () => { const recordFindUnique = vi.fn().mockResolvedValue(servingRecord); + const orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', itemList: [{ svcId: 'svc1', photoTier: 'free' }] }); const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'svc1', srcType: 'national', srcId: 'nat1' }); const svcNationalFindFirst = vi.fn().mockResolvedValue({ natName: '协助翻身叩背排痰', natCode: 'L0000106010010000' }); const recordItemCreate = vi.fn().mockResolvedValue({ id: 'ri1' }); const { service, recordItemDeleteMany } = makeService({ recordFindUnique, + orderFindUnique, svcOrgFindFirst, svcNationalFindFirst, recordItemCreate, @@ -232,7 +234,7 @@ describe('RecordService.itemSave', () => { service.itemSave({ recordId: 'r1', items: [ - { svcId: 'svc1', execResult: 'done', photoRequired: true }, + { svcId: 'svc1', execResult: 'done' }, { svcId: 'svc2', execResult: 'undo_refused', refuseReason: '老人拒绝' }, ], }), @@ -247,12 +249,61 @@ describe('RecordService.itemSave', () => { svcId: 'svc1', svcNameSnapshot: '协助翻身叩背排痰', execResult: 'done', - photoRequired: true, + photoRequired: false, }), }); expect(res).toHaveLength(2); }); + it('photoRequired 从 order.itemList 推导:photoTier=must 自动置 required,DTO 传 false 被忽略', async () => { + const recordFindUnique = vi.fn().mockResolvedValue(servingRecord); + const orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', itemList: [{ svcId: 'svc1', photoTier: 'must' }] }); + const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'svc1', srcType: 'national', srcId: 'nat1' }); + const svcNationalFindFirst = vi.fn().mockResolvedValue({ natName: '协助翻身' }); + const recordItemCreate = vi.fn().mockResolvedValue({ id: 'ri1' }); + const { service } = makeService({ + recordFindUnique, + orderFindUnique, + svcOrgFindFirst, + svcNationalFindFirst, + recordItemCreate, + }); + + await runWithRequestContext(ctx, () => + service.itemSave({ recordId: 'r1', items: [{ svcId: 'svc1', execResult: 'done', photoRequired: false }] }), + ); + + expect(recordItemCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ svcId: 'svc1', photoRequired: true }), + }); + }); + + it('extraFlag 临时加项回退 DTO photoRequired', async () => { + const recordFindUnique = vi.fn().mockResolvedValue(servingRecord); + const orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', itemList: [] }); + const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'svc-extra', srcType: 'national', srcId: 'nat1' }); + const svcNationalFindFirst = vi.fn().mockResolvedValue({ natName: '临时加项' }); + const recordItemCreate = vi.fn().mockResolvedValue({ id: 'ri1' }); + const { service } = makeService({ + recordFindUnique, + orderFindUnique, + svcOrgFindFirst, + svcNationalFindFirst, + recordItemCreate, + }); + + await runWithRequestContext(ctx, () => + service.itemSave({ + recordId: 'r1', + items: [{ svcId: 'svc-extra', execResult: 'done', extraFlag: true, photoRequired: true }], + }), + ); + + expect(recordItemCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ svcId: 'svc-extra', photoRequired: true, extraFlag: true }), + }); + }); + it('svc_id 非本机构 L4 → SVC_ORG_NOT_FOUND', async () => { const recordFindUnique = vi.fn().mockResolvedValue(servingRecord); const svcOrgFindFirst = vi.fn().mockResolvedValue(null); @@ -398,8 +449,14 @@ describe('RecordService.offlineSync', () => { expect(recordCreate).not.toHaveBeenCalled(); }); - it('离线新建完整 record(offline_flag + is_prepaid + items/photos 一次性)', async () => { - const orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'done', elderId: 'e1', assigneeId: 'staff1' }); + it('离线新建完整 record(elder/assignee 以 order 为准 + offline_flag + is_prepaid + items/photos 一次性)', async () => { + const orderFindUnique = vi.fn().mockResolvedValue({ + id: 'o1', + orderStatus: 'done', + elderId: 'e1', + assigneeId: 'staff1', + itemList: [{ svcId: 'svc1', photoTier: 'must' }], + }); const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'waiting_period' }); const recordCreate = vi.fn().mockResolvedValue({ id: 'r-new' }); const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'svc1', srcType: 'national', srcId: 'nat1' }); @@ -416,17 +473,19 @@ describe('RecordService.offlineSync', () => { service.offlineSync({ clientMd5: 'md5-2', orderId: 'o1', - elderId: 'e1', - assigneeId: 'staff1', + elderId: 'e-attacker', + assigneeId: 'staff-attacker', checkinTime: '2026-10-15T02:00:00Z', checkoutTime: '2026-10-15T03:00:00Z', - items: [{ svcId: 'svc1', execResult: 'done', photoRequired: true }], + items: [{ svcId: 'svc1', execResult: 'done', photoRequired: false }], photos: [{ svcId: 'svc1', filePath: 'record/T001/a.jpg', fileSize: 100 }], }), ); expect(recordCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ + elderId: 'e1', + assigneeId: 'staff1', offlineFlag: true, clientMd5: 'md5-2', isPrepaid: true, @@ -436,6 +495,9 @@ describe('RecordService.offlineSync', () => { }), }); expect(recordItemCreate).toHaveBeenCalledTimes(1); + expect(recordItemCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ svcId: 'svc1', photoRequired: true }), + }); expect(recordPhotoCreate).toHaveBeenCalledTimes(1); }); }); diff --git a/src/record/record.service.ts b/src/record/record.service.ts index b3a9775..c339b59 100644 --- a/src/record/record.service.ts +++ b/src/record/record.service.ts @@ -88,8 +88,10 @@ export class RecordService { async itemSave(dto: ItemSaveDto) { const ctx = getRequestContext()!; const record = await this.requireServingRecord(dto.recordId); + // photo_required 必须从 order.item_list 推导(不可信 DTO),故先取 order。 + const order = await this.prisma.client.order.findUnique({ where: { id: record.orderId } }); // 先全量校验(exec_result 枚举 + L4)并解析快照,任一失败不落库。 - const resolved = await this.resolveItems(dto.items); + const resolved = await this.resolveItems(dto.items, order); await this.prisma.client.recordItem.deleteMany({ where: { recordId: record.id } }); const rows = []; @@ -188,7 +190,8 @@ export class RecordService { if (!order) { throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单不存在', HttpStatus.NOT_FOUND); } - const elder = await this.prisma.client.elder.findFirst({ where: { id: dto.elderId } }); + // elderId/assigneeId 以 order 为准(忽略 DTO 的这两个字段,防 order/elder/assignee 不匹配)。 + const elder = await this.prisma.client.elder.findFirst({ where: { id: order.elderId } }); if (!elder) { throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND); } @@ -202,9 +205,9 @@ export class RecordService { data: { tenantCode: ctx.tenantCode, createdBy: ctx.userId, - orderId: dto.orderId, - elderId: dto.elderId, - assigneeId: dto.assigneeId, + orderId: order.id, + elderId: order.elderId, + assigneeId: order.assigneeId!, checkinTime, checkoutTime, durationMin, @@ -220,7 +223,7 @@ export class RecordService { }, }); - const resolved = await this.resolveItems(dto.items ?? []); + const resolved = await this.resolveItems(dto.items ?? [], order); for (const item of resolved) { await this.prisma.client.recordItem.create({ data: { @@ -376,8 +379,8 @@ export class RecordService { return record; } - /** 校验 exec_result 枚举 + svc_id 落本机构 L4,并解析快照。 */ - private async resolveItems(items: RecordItemDto[]) { + /** 校验 exec_result 枚举 + svc_id 落本机构 L4,并解析快照;photo_required 从 order.item_list 推导。 */ + private async resolveItems(items: RecordItemDto[], order?: Record | null) { return Promise.all( items.map(async (item) => { if (!EXEC_RESULTS.includes(item.execResult as (typeof EXEC_RESULTS)[number])) { @@ -401,7 +404,7 @@ export class RecordService { execResult: item.execResult, undoReason: item.undoReason, extraFlag: item.extraFlag, - photoRequired: item.photoRequired ?? false, + photoRequired: this.resolvePhotoRequired(item, order), isRefusedPhoto: item.isRefusedPhoto, refuseReason: item.refuseReason, }; @@ -409,4 +412,16 @@ export class RecordService { ); } + /** + * 必拍档来源是 order.item_list 的 photo_tier(快照自 plan_item),不可信客户端 DTO 的 photo_required。 + * 仅 extraFlag 临时加项(order.item_list 无对应项)回退 DTO 值。 + */ + private resolvePhotoRequired(item: RecordItemDto, order?: Record | null): boolean { + if (item.extraFlag) { + return item.photoRequired ?? false; + } + const itemList = Array.isArray(order?.itemList) ? order.itemList : []; + const entry = itemList.find((i: Record) => i.svcId === item.svcId); + return entry?.photoTier === 'must'; + } }