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 () => {
|
it('整表覆盖 + L4 落库 + snapshot svc_name', async () => {
|
||||||
const recordFindUnique = vi.fn().mockResolvedValue(servingRecord);
|
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 svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'svc1', srcType: 'national', srcId: 'nat1' });
|
||||||
const svcNationalFindFirst = vi.fn().mockResolvedValue({ natName: '协助翻身叩背排痰', natCode: 'L0000106010010000' });
|
const svcNationalFindFirst = vi.fn().mockResolvedValue({ natName: '协助翻身叩背排痰', natCode: 'L0000106010010000' });
|
||||||
const recordItemCreate = vi.fn().mockResolvedValue({ id: 'ri1' });
|
const recordItemCreate = vi.fn().mockResolvedValue({ id: 'ri1' });
|
||||||
const { service, recordItemDeleteMany } = makeService({
|
const { service, recordItemDeleteMany } = makeService({
|
||||||
recordFindUnique,
|
recordFindUnique,
|
||||||
|
orderFindUnique,
|
||||||
svcOrgFindFirst,
|
svcOrgFindFirst,
|
||||||
svcNationalFindFirst,
|
svcNationalFindFirst,
|
||||||
recordItemCreate,
|
recordItemCreate,
|
||||||
@ -232,7 +234,7 @@ describe('RecordService.itemSave', () => {
|
|||||||
service.itemSave({
|
service.itemSave({
|
||||||
recordId: 'r1',
|
recordId: 'r1',
|
||||||
items: [
|
items: [
|
||||||
{ svcId: 'svc1', execResult: 'done', photoRequired: true },
|
{ svcId: 'svc1', execResult: 'done' },
|
||||||
{ svcId: 'svc2', execResult: 'undo_refused', refuseReason: '老人拒绝' },
|
{ svcId: 'svc2', execResult: 'undo_refused', refuseReason: '老人拒绝' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@ -247,12 +249,61 @@ describe('RecordService.itemSave', () => {
|
|||||||
svcId: 'svc1',
|
svcId: 'svc1',
|
||||||
svcNameSnapshot: '协助翻身叩背排痰',
|
svcNameSnapshot: '协助翻身叩背排痰',
|
||||||
execResult: 'done',
|
execResult: 'done',
|
||||||
photoRequired: true,
|
photoRequired: false,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(res).toHaveLength(2);
|
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 () => {
|
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);
|
||||||
@ -398,8 +449,14 @@ describe('RecordService.offlineSync', () => {
|
|||||||
expect(recordCreate).not.toHaveBeenCalled();
|
expect(recordCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('离线新建完整 record(offline_flag + is_prepaid + items/photos 一次性)', async () => {
|
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' });
|
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 elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'waiting_period' });
|
||||||
const recordCreate = vi.fn().mockResolvedValue({ id: 'r-new' });
|
const recordCreate = vi.fn().mockResolvedValue({ id: 'r-new' });
|
||||||
const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'svc1', srcType: 'national', srcId: 'nat1' });
|
const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'svc1', srcType: 'national', srcId: 'nat1' });
|
||||||
@ -416,17 +473,19 @@ describe('RecordService.offlineSync', () => {
|
|||||||
service.offlineSync({
|
service.offlineSync({
|
||||||
clientMd5: 'md5-2',
|
clientMd5: 'md5-2',
|
||||||
orderId: 'o1',
|
orderId: 'o1',
|
||||||
elderId: 'e1',
|
elderId: 'e-attacker',
|
||||||
assigneeId: 'staff1',
|
assigneeId: 'staff-attacker',
|
||||||
checkinTime: '2026-10-15T02:00:00Z',
|
checkinTime: '2026-10-15T02:00:00Z',
|
||||||
checkoutTime: '2026-10-15T03: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 }],
|
photos: [{ svcId: 'svc1', filePath: 'record/T001/a.jpg', fileSize: 100 }],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(recordCreate).toHaveBeenCalledWith({
|
expect(recordCreate).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
|
elderId: 'e1',
|
||||||
|
assigneeId: 'staff1',
|
||||||
offlineFlag: true,
|
offlineFlag: true,
|
||||||
clientMd5: 'md5-2',
|
clientMd5: 'md5-2',
|
||||||
isPrepaid: true,
|
isPrepaid: true,
|
||||||
@ -436,6 +495,9 @@ describe('RecordService.offlineSync', () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(recordItemCreate).toHaveBeenCalledTimes(1);
|
expect(recordItemCreate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(recordItemCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ svcId: 'svc1', photoRequired: true }),
|
||||||
|
});
|
||||||
expect(recordPhotoCreate).toHaveBeenCalledTimes(1);
|
expect(recordPhotoCreate).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -88,8 +88,10 @@ export class RecordService {
|
|||||||
async itemSave(dto: ItemSaveDto) {
|
async itemSave(dto: ItemSaveDto) {
|
||||||
const ctx = getRequestContext()!;
|
const ctx = getRequestContext()!;
|
||||||
const record = await this.requireServingRecord(dto.recordId);
|
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)并解析快照,任一失败不落库。
|
// 先全量校验(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 } });
|
await this.prisma.client.recordItem.deleteMany({ where: { recordId: record.id } });
|
||||||
const rows = [];
|
const rows = [];
|
||||||
@ -188,7 +190,8 @@ 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);
|
||||||
}
|
}
|
||||||
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) {
|
if (!elder) {
|
||||||
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
|
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
|
||||||
}
|
}
|
||||||
@ -202,9 +205,9 @@ export class RecordService {
|
|||||||
data: {
|
data: {
|
||||||
tenantCode: ctx.tenantCode,
|
tenantCode: ctx.tenantCode,
|
||||||
createdBy: ctx.userId,
|
createdBy: ctx.userId,
|
||||||
orderId: dto.orderId,
|
orderId: order.id,
|
||||||
elderId: dto.elderId,
|
elderId: order.elderId,
|
||||||
assigneeId: dto.assigneeId,
|
assigneeId: order.assigneeId!,
|
||||||
checkinTime,
|
checkinTime,
|
||||||
checkoutTime,
|
checkoutTime,
|
||||||
durationMin,
|
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) {
|
for (const item of resolved) {
|
||||||
await this.prisma.client.recordItem.create({
|
await this.prisma.client.recordItem.create({
|
||||||
data: {
|
data: {
|
||||||
@ -376,8 +379,8 @@ export class RecordService {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 校验 exec_result 枚举 + svc_id 落本机构 L4,并解析快照。 */
|
/** 校验 exec_result 枚举 + svc_id 落本机构 L4,并解析快照;photo_required 从 order.item_list 推导。 */
|
||||||
private async resolveItems(items: RecordItemDto[]) {
|
private async resolveItems(items: RecordItemDto[], order?: Record<string, any> | null) {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
items.map(async (item) => {
|
items.map(async (item) => {
|
||||||
if (!EXEC_RESULTS.includes(item.execResult as (typeof EXEC_RESULTS)[number])) {
|
if (!EXEC_RESULTS.includes(item.execResult as (typeof EXEC_RESULTS)[number])) {
|
||||||
@ -401,7 +404,7 @@ export class RecordService {
|
|||||||
execResult: item.execResult,
|
execResult: item.execResult,
|
||||||
undoReason: item.undoReason,
|
undoReason: item.undoReason,
|
||||||
extraFlag: item.extraFlag,
|
extraFlag: item.extraFlag,
|
||||||
photoRequired: item.photoRequired ?? false,
|
photoRequired: this.resolvePhotoRequired(item, order),
|
||||||
isRefusedPhoto: item.isRefusedPhoto,
|
isRefusedPhoto: item.isRefusedPhoto,
|
||||||
refuseReason: item.refuseReason,
|
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