diff --git a/src/record/record.service.spec.ts b/src/record/record.service.spec.ts index 5b362b6..fe2b3c5 100644 --- a/src/record/record.service.spec.ts +++ b/src/record/record.service.spec.ts @@ -304,6 +304,32 @@ describe('RecordService.itemSave', () => { }); }); + it('计划内 must 项 + 客户端传 extraFlag=true/photoRequired=false → 仍存 photoRequired=true(不可绕过)', 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', extraFlag: true, photoRequired: false }], + }), + ); + + expect(recordItemCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ svcId: 'svc1', photoRequired: true }), + }); + }); + it('svc_id 非本机构 L4 → SVC_ORG_NOT_FOUND', async () => { const recordFindUnique = vi.fn().mockResolvedValue(servingRecord); const svcOrgFindFirst = vi.fn().mockResolvedValue(null); @@ -449,6 +475,19 @@ describe('RecordService.offlineSync', () => { expect(recordCreate).not.toHaveBeenCalled(); }); + it('未派单工单(assigneeId 为 null)→ RECORD_ORDER_INVALID', async () => { + const orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'to_assign', elderId: 'e1', assigneeId: null }); + const { service, recordCreate } = makeService({ orderFindUnique }); + + await expect( + runWithRequestContext(ctx, () => + service.offlineSync({ clientMd5: 'md5-3', orderId: 'o1', elderId: 'e1', assigneeId: 'staff1', checkinTime: '2026-10-15T02:00:00Z' }), + ), + ).rejects.toMatchObject({ response: { code: ErrorCodes.RECORD_ORDER_INVALID } }); + + expect(recordCreate).not.toHaveBeenCalled(); + }); + it('离线新建完整 record(elder/assignee 以 order 为准 + offline_flag + is_prepaid + items/photos 一次性)', async () => { const orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', diff --git a/src/record/record.service.ts b/src/record/record.service.ts index c339b59..dee93ba 100644 --- a/src/record/record.service.ts +++ b/src/record/record.service.ts @@ -190,6 +190,9 @@ export class RecordService { if (!order) { throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单不存在', HttpStatus.NOT_FOUND); } + if (!order.assigneeId) { + throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单未派单,无法留痕', HttpStatus.BAD_REQUEST); + } // elderId/assigneeId 以 order 为准(忽略 DTO 的这两个字段,防 order/elder/assignee 不匹配)。 const elder = await this.prisma.client.elder.findFirst({ where: { id: order.elderId } }); if (!elder) { @@ -413,15 +416,16 @@ export class RecordService { } /** - * 必拍档来源是 order.item_list 的 photo_tier(快照自 plan_item),不可信客户端 DTO 的 photo_required。 - * 仅 extraFlag 临时加项(order.item_list 无对应项)回退 DTO 值。 + * 必拍档来源是 order.item_list 的 photo_tier(快照自 plan_item),不可信客户端 DTO 的 photo_required/extraFlag。 + * 先按 svcId 在 item_list 找匹配项:命中则 photoRequired = (photoTier === 'must'); + * 仅 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'; + if (entry) { + return entry.photoTier === 'must'; + } + return item.photoRequired ?? false; } }