fix(c3): record 从 order 推导 photo_required 与 elder/assignee

- 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 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-15 00:34:39 +08:00
parent 2e4c916949
commit 563f5ab5b5
2 changed files with 93 additions and 16 deletions

View File

@ -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);
});
});

View File

@ -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<string, any> | 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<string, any> | null): boolean {
if (item.extraFlag) {
return item.photoRequired ?? false;
}
const itemList = Array.isArray(order?.itemList) ? order.itemList : [];
const entry = itemList.find((i: Record<string, any>) => i.svcId === item.svcId);
return entry?.photoTier === 'must';
}
}