fix(c3): 结转幂等 + offlineSync 扑空拒签 + 坐标全路径序列化
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5f060292c6
commit
9f232356e0
@ -167,7 +167,7 @@ describe('RecordService.checkin', () => {
|
|||||||
service.checkin({ orderId: 'o1', locateStatus: 'failed', checkinLat: 25.01, checkinLng: 102.7 }),
|
service.checkin({ orderId: 'o1', locateStatus: 'failed', checkinLat: 25.01, checkinLng: 102.7 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res).toEqual({ id: 'r1' });
|
expect(res).toMatchObject({ id: 'r1' });
|
||||||
expect(recordCreate).toHaveBeenCalledWith({
|
expect(recordCreate).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({ locateStatus: 'failed', checkinLat: 25.01, checkinLng: 102.7 }),
|
data: expect.objectContaining({ locateStatus: 'failed', checkinLat: 25.01, checkinLng: 102.7 }),
|
||||||
});
|
});
|
||||||
@ -200,6 +200,22 @@ describe('RecordService.checkin', () => {
|
|||||||
data: expect.objectContaining({ isPrepaid: false, billable: true, tenantCode: 'T001', createdBy: 'u1' }),
|
data: expect.objectContaining({ isPrepaid: false, billable: true, tenantCode: 'T001', createdBy: 'u1' }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('checkin 返回坐标序列化为字符串(F3)', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue(servingOrder);
|
||||||
|
const staffFindFirst = vi.fn().mockResolvedValue({ id: 'staff1', userId: 'u1' });
|
||||||
|
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' });
|
||||||
|
const recordCreate = vi.fn().mockResolvedValue({
|
||||||
|
id: 'r1',
|
||||||
|
checkinLat: { toString: () => '25.010000' },
|
||||||
|
checkinLng: { toString: () => '102.700000' },
|
||||||
|
});
|
||||||
|
const { service } = makeService({ orderFindUnique, staffFindFirst, elderFindFirst, recordCreate });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () => service.checkin({ orderId: 'o1' }));
|
||||||
|
expect(res.checkinLat).toBe('25.010000');
|
||||||
|
expect(res.checkinLng).toBe('102.700000');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('RecordService.itemSave', () => {
|
describe('RecordService.itemSave', () => {
|
||||||
@ -464,8 +480,8 @@ describe('RecordService.offlineSync', () => {
|
|||||||
expect(recordCreate).not.toHaveBeenCalled();
|
expect(recordCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clientMd5 命中 → 幂等返回已存在,不重复落库', async () => {
|
it('clientMd5 命中 → 幂等返回已存在(坐标序列化为字符串),不重复落库', async () => {
|
||||||
const existing = { id: 'r-exist', clientMd5: 'md5-1' };
|
const existing = { id: 'r-exist', clientMd5: 'md5-1', checkinLat: { toString: () => '25.010000' } };
|
||||||
const recordFindFirst = vi.fn().mockResolvedValue(existing);
|
const recordFindFirst = vi.fn().mockResolvedValue(existing);
|
||||||
const { service, recordCreate } = makeService({ recordFindFirst });
|
const { service, recordCreate } = makeService({ recordFindFirst });
|
||||||
|
|
||||||
@ -474,7 +490,8 @@ describe('RecordService.offlineSync', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(recordFindFirst).toHaveBeenCalledWith({ where: { clientMd5: 'md5-1' } });
|
expect(recordFindFirst).toHaveBeenCalledWith({ where: { clientMd5: 'md5-1' } });
|
||||||
expect(res).toEqual(existing);
|
expect(res).toMatchObject({ id: 'r-exist', clientMd5: 'md5-1' });
|
||||||
|
expect(res.checkinLat).toBe('25.010000');
|
||||||
expect(recordCreate).not.toHaveBeenCalled();
|
expect(recordCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -491,6 +508,19 @@ describe('RecordService.offlineSync', () => {
|
|||||||
expect(recordCreate).not.toHaveBeenCalled();
|
expect(recordCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('not_met(扑空)工单 → RECORD_ORDER_INVALID,不产生 record', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'not_met', elderId: 'e1', assigneeId: 'staff1' });
|
||||||
|
const { service, recordCreate } = makeService({ orderFindUnique });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () =>
|
||||||
|
service.offlineSync({ clientMd5: 'md5-4', 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',
|
||||||
@ -591,7 +621,7 @@ describe('RecordService.prepaidCarryover', () => {
|
|||||||
expect(recordUpdate).not.toHaveBeenCalled();
|
expect(recordUpdate).not.toHaveBeenCalled();
|
||||||
expect(recordItemCreate).toHaveBeenCalledTimes(1);
|
expect(recordItemCreate).toHaveBeenCalledTimes(1);
|
||||||
expect(recordPhotoCreate).toHaveBeenCalledTimes(1);
|
expect(recordPhotoCreate).toHaveBeenCalledTimes(1);
|
||||||
expect(res).toEqual([{ id: 'r2' }]);
|
expect(res).toMatchObject([{ id: 'r2' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('非垫付记录 → PREPAID_CARRYOVER_INVALID', async () => {
|
it('非垫付记录 → PREPAID_CARRYOVER_INVALID', async () => {
|
||||||
@ -627,6 +657,18 @@ describe('RecordService.prepaidCarryover', () => {
|
|||||||
).rejects.toMatchObject({ response: { code: ErrorCodes.PREPAID_CARRYOVER_INVALID } });
|
).rejects.toMatchObject({ response: { code: ErrorCodes.PREPAID_CARRYOVER_INVALID } });
|
||||||
expect(recordCreate).not.toHaveBeenCalled();
|
expect(recordCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('R10 幂等:已结转的垫付记录 → PREPAID_CARRYOVER_INVALID', async () => {
|
||||||
|
const recordFindMany = vi.fn().mockResolvedValue([original]);
|
||||||
|
const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' });
|
||||||
|
const recordFindFirst = vi.fn().mockResolvedValue({ id: 'formal1', sourcePrepaidOf: 'r1' });
|
||||||
|
const { service, recordCreate } = makeService({ recordFindMany, elderFindUnique, recordFindFirst });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.prepaidCarryover({ recordIds: ['r1'] })),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.PREPAID_CARRYOVER_INVALID } });
|
||||||
|
expect(recordCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('RecordService.detail / list / prepaidList', () => {
|
describe('RecordService.detail / list / prepaidList', () => {
|
||||||
@ -688,4 +730,21 @@ describe('RecordService.detail / list / prepaidList', () => {
|
|||||||
|
|
||||||
expect(recordFindMany).toHaveBeenCalledWith({ where: { elderId: 'e1', isPrepaid: true } });
|
expect(recordFindMany).toHaveBeenCalledWith({ where: { elderId: 'e1', isPrepaid: true } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prepaidList 排除已结转的垫付记录(R10)', async () => {
|
||||||
|
const recordFindMany = vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'r1', isPrepaid: true },
|
||||||
|
{ id: 'r2', isPrepaid: true },
|
||||||
|
]);
|
||||||
|
const recordFindFirst = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 'formal1', sourcePrepaidOf: 'r1' })
|
||||||
|
.mockResolvedValueOnce(null);
|
||||||
|
const { service } = makeService({ recordFindMany, recordFindFirst });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () => service.prepaidList({ elderId: 'e1' }));
|
||||||
|
|
||||||
|
expect(recordFindFirst).toHaveBeenCalledTimes(2);
|
||||||
|
expect(res).toHaveLength(1);
|
||||||
|
expect(res[0].id).toBe('r2');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -66,7 +66,7 @@ export class RecordService {
|
|||||||
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
|
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
|
||||||
}
|
}
|
||||||
const isPrepaid = elder.treatStatus === 'waiting_period';
|
const isPrepaid = elder.treatStatus === 'waiting_period';
|
||||||
return this.prisma.client.record.create({
|
const record = await this.prisma.client.record.create({
|
||||||
data: {
|
data: {
|
||||||
tenantCode: ctx.tenantCode,
|
tenantCode: ctx.tenantCode,
|
||||||
createdBy: ctx.userId,
|
createdBy: ctx.userId,
|
||||||
@ -82,6 +82,7 @@ export class RecordService {
|
|||||||
billable: !isPrepaid,
|
billable: !isPrepaid,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return this.serializeRecord(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存项目执行:整表覆盖(先删后写)。 */
|
/** 保存项目执行:整表覆盖(先删后写)。 */
|
||||||
@ -165,7 +166,7 @@ export class RecordService {
|
|||||||
const durationMin = checkinTime
|
const durationMin = checkinTime
|
||||||
? Math.round((checkoutTime.getTime() - new Date(checkinTime).getTime()) / 60000)
|
? Math.round((checkoutTime.getTime() - new Date(checkinTime).getTime()) / 60000)
|
||||||
: null;
|
: null;
|
||||||
return this.prisma.client.record.update({
|
const updated = await this.prisma.client.record.update({
|
||||||
where: { id: record.id },
|
where: { id: record.id },
|
||||||
data: {
|
data: {
|
||||||
checkoutTime,
|
checkoutTime,
|
||||||
@ -174,6 +175,7 @@ export class RecordService {
|
|||||||
submitTime: new Date(),
|
submitTime: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return this.serializeRecord(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 离线补传:client_md5 幂等去重,一次建完整 record。 */
|
/** 离线补传:client_md5 幂等去重,一次建完整 record。 */
|
||||||
@ -184,12 +186,16 @@ export class RecordService {
|
|||||||
}
|
}
|
||||||
const existing = await this.prisma.client.record.findFirst({ where: { clientMd5: dto.clientMd5 } });
|
const existing = await this.prisma.client.record.findFirst({ where: { clientMd5: dto.clientMd5 } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing; // 幂等:静默返回已存在
|
return this.serializeRecord(existing); // 幂等:静默返回已存在
|
||||||
}
|
}
|
||||||
const order = await this.prisma.client.order.findUnique({ where: { id: dto.orderId } });
|
const order = await this.prisma.client.order.findUnique({ where: { id: dto.orderId } });
|
||||||
if (!order) {
|
if (!order) {
|
||||||
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单不存在', HttpStatus.NOT_FOUND);
|
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单不存在', HttpStatus.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
// L3:扑空/取消工单不产生 record(扑空不产生留痕)。
|
||||||
|
if (order.orderStatus === 'cancelled' || order.orderStatus === 'not_met') {
|
||||||
|
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单已取消/扑空,不产生留痕', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
if (!order.assigneeId) {
|
if (!order.assigneeId) {
|
||||||
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单未派单,无法留痕', HttpStatus.BAD_REQUEST);
|
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单未派单,无法留痕', HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
@ -256,7 +262,7 @@ export class RecordService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return record;
|
return this.serializeRecord(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 详情:record + items + photos + abnormals。 */
|
/** 详情:record + items + photos + abnormals。 */
|
||||||
@ -289,7 +295,13 @@ export class RecordService {
|
|||||||
/** 垫付台账:按老人返回 is_prepaid=1 的记录。 */
|
/** 垫付台账:按老人返回 is_prepaid=1 的记录。 */
|
||||||
async prepaidList(q: PrepaidListQuery) {
|
async prepaidList(q: PrepaidListQuery) {
|
||||||
const rows = await this.prisma.client.record.findMany({ where: { elderId: q.elderId, isPrepaid: true } });
|
const rows = await this.prisma.client.record.findMany({ where: { elderId: q.elderId, isPrepaid: true } });
|
||||||
return rows.map((r) => this.serializeRecord(r));
|
// R10:排除已结转的垫付记录(存在 formal record.source_prepaid_of 指向它)。
|
||||||
|
const result = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const formal = await this.prisma.client.record.findFirst({ where: { sourcePrepaidOf: row.id } });
|
||||||
|
if (!formal) result.push(row);
|
||||||
|
}
|
||||||
|
return result.map((r) => this.serializeRecord(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 结转:复制垫付 record → 正式 record(is_prepaid=0 + source_prepaid_of),原记录不改。 */
|
/** 结转:复制垫付 record → 正式 record(is_prepaid=0 + source_prepaid_of),原记录不改。 */
|
||||||
@ -311,6 +323,13 @@ export class RecordService {
|
|||||||
if (!elder || elder.treatStatus !== 'active') {
|
if (!elder || elder.treatStatus !== 'active') {
|
||||||
throw new BusinessException(ErrorCodes.PREPAID_CARRYOVER_INVALID, '待遇未转正常享受,不可结转', HttpStatus.BAD_REQUEST);
|
throw new BusinessException(ErrorCodes.PREPAID_CARRYOVER_INVALID, '待遇未转正常享受,不可结转', HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
// R10 幂等:已结转的垫付记录不可重复结转。
|
||||||
|
for (const rec of records) {
|
||||||
|
const formal = await this.prisma.client.record.findFirst({ where: { sourcePrepaidOf: rec.id } });
|
||||||
|
if (formal) {
|
||||||
|
throw new BusinessException(ErrorCodes.PREPAID_CARRYOVER_INVALID, '该垫付记录已结转', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const created = [];
|
const created = [];
|
||||||
for (const rec of records) {
|
for (const rec of records) {
|
||||||
@ -366,7 +385,7 @@ export class RecordService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
created.push(newRecord);
|
created.push(this.serializeRecord(newRecord));
|
||||||
}
|
}
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user