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 }),
|
||||
);
|
||||
|
||||
expect(res).toEqual({ id: 'r1' });
|
||||
expect(res).toMatchObject({ id: 'r1' });
|
||||
expect(recordCreate).toHaveBeenCalledWith({
|
||||
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' }),
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@ -464,8 +480,8 @@ describe('RecordService.offlineSync', () => {
|
||||
expect(recordCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clientMd5 命中 → 幂等返回已存在,不重复落库', async () => {
|
||||
const existing = { id: 'r-exist', clientMd5: 'md5-1' };
|
||||
it('clientMd5 命中 → 幂等返回已存在(坐标序列化为字符串),不重复落库', async () => {
|
||||
const existing = { id: 'r-exist', clientMd5: 'md5-1', checkinLat: { toString: () => '25.010000' } };
|
||||
const recordFindFirst = vi.fn().mockResolvedValue(existing);
|
||||
const { service, recordCreate } = makeService({ recordFindFirst });
|
||||
|
||||
@ -474,7 +490,8 @@ describe('RecordService.offlineSync', () => {
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@ -491,6 +508,19 @@ describe('RecordService.offlineSync', () => {
|
||||
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 () => {
|
||||
const orderFindUnique = vi.fn().mockResolvedValue({
|
||||
id: 'o1',
|
||||
@ -591,7 +621,7 @@ describe('RecordService.prepaidCarryover', () => {
|
||||
expect(recordUpdate).not.toHaveBeenCalled();
|
||||
expect(recordItemCreate).toHaveBeenCalledTimes(1);
|
||||
expect(recordPhotoCreate).toHaveBeenCalledTimes(1);
|
||||
expect(res).toEqual([{ id: 'r2' }]);
|
||||
expect(res).toMatchObject([{ id: 'r2' }]);
|
||||
});
|
||||
|
||||
it('非垫付记录 → PREPAID_CARRYOVER_INVALID', async () => {
|
||||
@ -627,6 +657,18 @@ describe('RecordService.prepaidCarryover', () => {
|
||||
).rejects.toMatchObject({ response: { code: ErrorCodes.PREPAID_CARRYOVER_INVALID } });
|
||||
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', () => {
|
||||
@ -688,4 +730,21 @@ describe('RecordService.detail / list / prepaidList', () => {
|
||||
|
||||
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);
|
||||
}
|
||||
const isPrepaid = elder.treatStatus === 'waiting_period';
|
||||
return this.prisma.client.record.create({
|
||||
const record = await this.prisma.client.record.create({
|
||||
data: {
|
||||
tenantCode: ctx.tenantCode,
|
||||
createdBy: ctx.userId,
|
||||
@ -82,6 +82,7 @@ export class RecordService {
|
||||
billable: !isPrepaid,
|
||||
},
|
||||
});
|
||||
return this.serializeRecord(record);
|
||||
}
|
||||
|
||||
/** 保存项目执行:整表覆盖(先删后写)。 */
|
||||
@ -165,7 +166,7 @@ export class RecordService {
|
||||
const durationMin = checkinTime
|
||||
? Math.round((checkoutTime.getTime() - new Date(checkinTime).getTime()) / 60000)
|
||||
: null;
|
||||
return this.prisma.client.record.update({
|
||||
const updated = await this.prisma.client.record.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
checkoutTime,
|
||||
@ -174,6 +175,7 @@ export class RecordService {
|
||||
submitTime: new Date(),
|
||||
},
|
||||
});
|
||||
return this.serializeRecord(updated);
|
||||
}
|
||||
|
||||
/** 离线补传:client_md5 幂等去重,一次建完整 record。 */
|
||||
@ -184,12 +186,16 @@ export class RecordService {
|
||||
}
|
||||
const existing = await this.prisma.client.record.findFirst({ where: { clientMd5: dto.clientMd5 } });
|
||||
if (existing) {
|
||||
return existing; // 幂等:静默返回已存在
|
||||
return this.serializeRecord(existing); // 幂等:静默返回已存在
|
||||
}
|
||||
const order = await this.prisma.client.order.findUnique({ where: { id: dto.orderId } });
|
||||
if (!order) {
|
||||
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) {
|
||||
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。 */
|
||||
@ -289,7 +295,13 @@ export class RecordService {
|
||||
/** 垫付台账:按老人返回 is_prepaid=1 的记录。 */
|
||||
async prepaidList(q: PrepaidListQuery) {
|
||||
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),原记录不改。 */
|
||||
@ -311,6 +323,13 @@ export class RecordService {
|
||||
if (!elder || elder.treatStatus !== 'active') {
|
||||
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 = [];
|
||||
for (const rec of records) {
|
||||
@ -366,7 +385,7 @@ export class RecordService {
|
||||
},
|
||||
});
|
||||
}
|
||||
created.push(newRecord);
|
||||
created.push(this.serializeRecord(newRecord));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user