fix(c3): record 必拍档不可被 extraFlag 绕过 + 未派单离线补传守卫

- resolvePhotoRequired 先按 svcId 查 order.item_list,命中以 photo_tier 为准,
  忽略客户端 extraFlag/photo_required;仅无对应项才回退 DTO
- offlineSync 对 assigneeId 为 null(未派单)工单抛 RECORD_ORDER_INVALID,防 null 入 NOT NULL 列

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-15 00:36:54 +08:00
parent 55f432b8e7
commit 1966a68bc8
2 changed files with 49 additions and 6 deletions

View File

@ -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 () => { it('svc_id 非本机构 L4 → SVC_ORG_NOT_FOUND', async () => {
const recordFindUnique = vi.fn().mockResolvedValue(servingRecord); const recordFindUnique = vi.fn().mockResolvedValue(servingRecord);
const svcOrgFindFirst = vi.fn().mockResolvedValue(null); const svcOrgFindFirst = vi.fn().mockResolvedValue(null);
@ -449,6 +475,19 @@ describe('RecordService.offlineSync', () => {
expect(recordCreate).not.toHaveBeenCalled(); 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 () => { it('离线新建完整 record(elder/assignee 以 order 为准 + offline_flag + is_prepaid + items/photos 一次性)', async () => {
const orderFindUnique = vi.fn().mockResolvedValue({ const orderFindUnique = vi.fn().mockResolvedValue({
id: 'o1', id: 'o1',

View File

@ -190,6 +190,9 @@ export class RecordService {
if (!order) { if (!order) {
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单不存在', HttpStatus.NOT_FOUND); 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 不匹配)。 // elderId/assigneeId 以 order 为准(忽略 DTO 的这两个字段,防 order/elder/assignee 不匹配)。
const elder = await this.prisma.client.elder.findFirst({ where: { id: order.elderId } }); const elder = await this.prisma.client.elder.findFirst({ where: { id: order.elderId } });
if (!elder) { if (!elder) {
@ -413,15 +416,16 @@ export class RecordService {
} }
/** /**
* 必拍档来源是 order.item_list 的 photo_tier(快照自 plan_item),不可信客户端 DTO 的 photo_required。 * 必拍档来源是 order.item_list 的 photo_tier(快照自 plan_item),不可信客户端 DTO 的 photo_required/extraFlag。
* 仅 extraFlag 临时加项(order.item_list 无对应项)回退 DTO 值。 * 先按 svcId 在 item_list 找匹配项:命中则 photoRequired = (photoTier === 'must');
* 仅 item_list 无对应项(真正的临时加项)才回退 DTO 值。
*/ */
private resolvePhotoRequired(item: RecordItemDto, order?: Record<string, any> | null): boolean { private resolvePhotoRequired(item: RecordItemDto, order?: Record<string, any> | null): boolean {
if (item.extraFlag) {
return item.photoRequired ?? false;
}
const itemList = Array.isArray(order?.itemList) ? order.itemList : []; const itemList = Array.isArray(order?.itemList) ? order.itemList : [];
const entry = itemList.find((i: Record<string, any>) => i.svcId === item.svcId); const entry = itemList.find((i: Record<string, any>) => i.svcId === item.svcId);
return entry?.photoTier === 'must'; if (entry) {
return entry.photoTier === 'must';
}
return item.photoRequired ?? false;
} }
} }