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:
parent
2e4c916949
commit
563f5ab5b5
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user