feat(c3): 履约留痕模块(签到/签退/项目/照片/离线/垫付结转)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
93e532aa7d
commit
5bfd0f4748
242
src/record/dto.ts
Normal file
242
src/record/dto.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** 项目执行结果枚举(措辞纪律:本次未执行用 undo_not_done,非 not_done)。 */
|
||||
export const EXEC_RESULTS = ['done', 'undo_not_done', 'undo_refused'] as const;
|
||||
|
||||
/** 定位状态:ok/failed/denied。 */
|
||||
export const LOCATE_STATUSES = ['ok', 'failed', 'denied'] as const;
|
||||
|
||||
/**
|
||||
* 签到入参。checkin_time/checkout_time/duration_min 一律系统取,不在 DTO(防客户端补录时间)。
|
||||
*/
|
||||
export class CheckinDto {
|
||||
@ApiProperty({ description: '工单 ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
orderId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '签到纬度' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
checkinLat?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '签到经度' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
checkinLng?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '定位状态:ok/failed/denied', enum: LOCATE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn(LOCATE_STATUSES)
|
||||
locateStatus?: string;
|
||||
}
|
||||
|
||||
/** 留痕项目明细(ho_record_item)入参。svc_id 引用本机构 L4(svc_org.id)。 */
|
||||
export class RecordItemDto {
|
||||
@ApiProperty({ description: '本机构 L4 项目库 svc_org.id' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
svcId!: string;
|
||||
|
||||
@ApiProperty({ description: '执行结果:done/undo_not_done/undo_refused', enum: EXEC_RESULTS })
|
||||
@IsIn(EXEC_RESULTS)
|
||||
execResult!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '未执行原因' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
undoReason?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '是否附加项' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
extraFlag?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: '是否必拍(快照自 order.item_list)' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
photoRequired?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: '是否已走拒拍出路' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRefusedPhoto?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: '拒拍原因' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refuseReason?: string;
|
||||
}
|
||||
|
||||
/** 保存项目执行入参(整表覆盖)。 */
|
||||
export class ItemSaveDto {
|
||||
@ApiProperty({ description: '留痕 ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
recordId!: string;
|
||||
|
||||
@ApiProperty({ description: '项目执行明细', type: [RecordItemDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RecordItemDto)
|
||||
items!: RecordItemDto[];
|
||||
}
|
||||
|
||||
/** 上传照片入参(file 走 multipart,record_id/svc_id 走 body)。 */
|
||||
export class PhotoUploadDto {
|
||||
@ApiProperty({ description: '留痕 ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
recordId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '关联项目 svc_id' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
svcId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '上传批次' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
uploadBatch?: string;
|
||||
}
|
||||
|
||||
/** 签退入参。checkout_time/duration_min 系统取。 */
|
||||
export class CheckoutDto {
|
||||
@ApiProperty({ description: '留痕 ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
recordId!: string;
|
||||
}
|
||||
|
||||
/** 离线补传照片(已上传的文件引用)。 */
|
||||
export class OfflinePhotoDto {
|
||||
@ApiPropertyOptional({ description: '关联项目 svc_id' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
svcId?: string;
|
||||
|
||||
@ApiProperty({ description: 'MinIO 文件 key' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
filePath!: string;
|
||||
|
||||
@ApiProperty({ description: '文件大小(字节)' })
|
||||
@IsNumber()
|
||||
fileSize!: number;
|
||||
}
|
||||
|
||||
/** 离线补传入参:一次建完整 record(checkin/checkout/items/photos)。 */
|
||||
export class OfflineSyncDto {
|
||||
@ApiProperty({ description: '客户端生成的内容指纹(幂等去重)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
clientMd5!: string;
|
||||
|
||||
@ApiProperty({ description: '工单 ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
orderId!: string;
|
||||
|
||||
@ApiProperty({ description: '老人 ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
elderId!: string;
|
||||
|
||||
@ApiProperty({ description: '护理员 staff.id' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
assigneeId!: string;
|
||||
|
||||
@ApiProperty({ description: '签到时间(离线原始值,ISO datetime)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
checkinTime!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '签退时间(离线原始值,ISO datetime)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
checkoutTime?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '签到纬度' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
checkinLat?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '签到经度' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
checkinLng?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '定位状态:ok/failed/denied', enum: LOCATE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn(LOCATE_STATUSES)
|
||||
locateStatus?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '项目执行明细', type: [RecordItemDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RecordItemDto)
|
||||
items?: RecordItemDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: '照片引用', type: [OfflinePhotoDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OfflinePhotoDto)
|
||||
photos?: OfflinePhotoDto[];
|
||||
}
|
||||
|
||||
/** 留痕列表查询:按老人/护理员/状态筛选。 */
|
||||
export class ListRecordQuery {
|
||||
@ApiPropertyOptional({ description: '老人 ID' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
elderId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '护理员 staff.id' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '服务日期(YYYY-MM-DD,按 checkin_time 过滤)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
serviceDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '留痕状态:serving/submitted' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
recordStatus?: string;
|
||||
}
|
||||
|
||||
/** 垫付台账查询:按老人查 is_prepaid=1 的记录。 */
|
||||
export class PrepaidListQuery {
|
||||
@ApiProperty({ description: '老人 ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
elderId!: string;
|
||||
}
|
||||
|
||||
/** 垫付结转入参:勾选的垫付留痕 ID 列表。 */
|
||||
export class PrepaidCarryoverDto {
|
||||
@ApiProperty({ description: '待结转的垫付留痕 ID 列表', type: [String] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty()
|
||||
recordIds!: string[];
|
||||
}
|
||||
82
src/record/record.controller.ts
Normal file
82
src/record/record.controller.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Permissions } from '../common/permissions.js';
|
||||
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
||||
import {
|
||||
CheckinDto,
|
||||
CheckoutDto,
|
||||
ItemSaveDto,
|
||||
ListRecordQuery,
|
||||
OfflineSyncDto,
|
||||
PhotoUploadDto,
|
||||
PrepaidCarryoverDto,
|
||||
PrepaidListQuery,
|
||||
} from './dto.js';
|
||||
import { RecordService } from './record.service.js';
|
||||
|
||||
@Controller('record')
|
||||
export class RecordController {
|
||||
constructor(private readonly service: RecordService) {}
|
||||
|
||||
@Post('checkin')
|
||||
@RequirePermissions(Permissions.record.checkin)
|
||||
@AuditLog('record', 'checkin')
|
||||
checkin(@Body() dto: CheckinDto) {
|
||||
return this.service.checkin(dto);
|
||||
}
|
||||
|
||||
@Post('item/save')
|
||||
@RequirePermissions(Permissions.record.itemSave)
|
||||
@AuditLog('record', 'item-save')
|
||||
itemSave(@Body() dto: ItemSaveDto) {
|
||||
return this.service.itemSave(dto);
|
||||
}
|
||||
|
||||
@Post('photo/upload')
|
||||
@RequirePermissions(Permissions.record.photoUpload)
|
||||
@AuditLog('record', 'photo-upload')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
photoUpload(@Body() dto: PhotoUploadDto, @UploadedFile() file: { buffer: Buffer; originalname: string; size?: number }) {
|
||||
return this.service.photoUpload(dto, file);
|
||||
}
|
||||
|
||||
@Post('checkout')
|
||||
@RequirePermissions(Permissions.record.checkout)
|
||||
@AuditLog('record', 'checkout')
|
||||
checkout(@Body() dto: CheckoutDto) {
|
||||
return this.service.checkout(dto);
|
||||
}
|
||||
|
||||
@Post('offline/sync')
|
||||
@RequirePermissions(Permissions.record.offlineSync)
|
||||
@AuditLog('record', 'offline-sync')
|
||||
offlineSync(@Body() dto: OfflineSyncDto) {
|
||||
return this.service.offlineSync(dto);
|
||||
}
|
||||
|
||||
@Post('prepaid/carryover')
|
||||
@RequirePermissions(Permissions.record.prepaidCarryover)
|
||||
@AuditLog('record', 'prepaid-carryover')
|
||||
prepaidCarryover(@Body() dto: PrepaidCarryoverDto) {
|
||||
return this.service.prepaidCarryover(dto);
|
||||
}
|
||||
|
||||
// 静态 GET 路径必须先于 :id 声明,否则会被 :id 吞掉。
|
||||
@Get()
|
||||
@RequirePermissions(Permissions.record.list)
|
||||
list(@Query() q: ListRecordQuery) {
|
||||
return this.service.list(q);
|
||||
}
|
||||
|
||||
@Get('prepaid-list')
|
||||
@RequirePermissions(Permissions.record.prepaidList)
|
||||
prepaidList(@Query() q: PrepaidListQuery) {
|
||||
return this.service.prepaidList(q);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(Permissions.record.detail)
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(id);
|
||||
}
|
||||
}
|
||||
11
src/record/record.module.ts
Normal file
11
src/record/record.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RecordController } from './record.controller.js';
|
||||
import { RecordService } from './record.service.js';
|
||||
import { StorageModule } from '../storage/storage.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule],
|
||||
controllers: [RecordController],
|
||||
providers: [RecordService],
|
||||
})
|
||||
export class RecordModule {}
|
||||
553
src/record/record.service.spec.ts
Normal file
553
src/record/record.service.spec.ts
Normal file
@ -0,0 +1,553 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RecordService } from './record.service.js';
|
||||
import type { PrismaService } from '../prisma/prisma.service.js';
|
||||
import type { StorageService } from '../storage/storage.service.js';
|
||||
import { ErrorCodes } from '../common/error-codes.js';
|
||||
import { runWithRequestContext } from '../common/context/request-context.js';
|
||||
import type { RequestContext } from '../common/context/request-context.js';
|
||||
|
||||
const ctx: RequestContext = {
|
||||
tenantCode: 'T001',
|
||||
userId: 'u1',
|
||||
username: 'admin',
|
||||
super: false,
|
||||
dataScope: 'all',
|
||||
orgCode: null,
|
||||
orgCodes: [],
|
||||
};
|
||||
|
||||
function makeService(
|
||||
overrides: {
|
||||
orderFindUnique?: ReturnType<typeof vi.fn>;
|
||||
staffFindFirst?: ReturnType<typeof vi.fn>;
|
||||
elderFindFirst?: ReturnType<typeof vi.fn>;
|
||||
recordFindUnique?: ReturnType<typeof vi.fn>;
|
||||
recordFindFirst?: ReturnType<typeof vi.fn>;
|
||||
recordFindMany?: ReturnType<typeof vi.fn>;
|
||||
recordCreate?: ReturnType<typeof vi.fn>;
|
||||
recordUpdate?: ReturnType<typeof vi.fn>;
|
||||
recordItemFindMany?: ReturnType<typeof vi.fn>;
|
||||
recordItemCreate?: ReturnType<typeof vi.fn>;
|
||||
recordItemDeleteMany?: ReturnType<typeof vi.fn>;
|
||||
recordPhotoFindMany?: ReturnType<typeof vi.fn>;
|
||||
recordPhotoCreate?: ReturnType<typeof vi.fn>;
|
||||
recordAbnormalFindMany?: ReturnType<typeof vi.fn>;
|
||||
svcOrgFindFirst?: ReturnType<typeof vi.fn>;
|
||||
svcNationalFindFirst?: ReturnType<typeof vi.fn>;
|
||||
svcLocalFindFirst?: ReturnType<typeof vi.fn>;
|
||||
} = {},
|
||||
) {
|
||||
const orderFindUnique = overrides.orderFindUnique ?? vi.fn().mockResolvedValue(null);
|
||||
const staffFindFirst = overrides.staffFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||
const elderFindFirst = overrides.elderFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||
const recordFindUnique = overrides.recordFindUnique ?? vi.fn().mockResolvedValue(null);
|
||||
const recordFindFirst = overrides.recordFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||
const recordFindMany = overrides.recordFindMany ?? vi.fn().mockResolvedValue([]);
|
||||
const recordCreate = overrides.recordCreate ?? vi.fn().mockResolvedValue({ id: 'r1' });
|
||||
const recordUpdate = overrides.recordUpdate ?? vi.fn().mockResolvedValue({ id: 'r1' });
|
||||
const recordItemFindMany = overrides.recordItemFindMany ?? vi.fn().mockResolvedValue([]);
|
||||
const recordItemCreate = overrides.recordItemCreate ?? vi.fn().mockResolvedValue({ id: 'ri1' });
|
||||
const recordItemDeleteMany = overrides.recordItemDeleteMany ?? vi.fn().mockResolvedValue({ count: 0 });
|
||||
const recordPhotoFindMany = overrides.recordPhotoFindMany ?? vi.fn().mockResolvedValue([]);
|
||||
const recordPhotoCreate = overrides.recordPhotoCreate ?? vi.fn().mockResolvedValue({ id: 'rp1' });
|
||||
const recordAbnormalFindMany = overrides.recordAbnormalFindMany ?? vi.fn().mockResolvedValue([]);
|
||||
const svcOrgFindFirst = overrides.svcOrgFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||
const svcNationalFindFirst = overrides.svcNationalFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||
const svcLocalFindFirst = overrides.svcLocalFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||
|
||||
const prisma = {
|
||||
client: {
|
||||
order: { findUnique: orderFindUnique },
|
||||
staff: { findFirst: staffFindFirst },
|
||||
elder: { findFirst: elderFindFirst },
|
||||
record: {
|
||||
findUnique: recordFindUnique,
|
||||
findFirst: recordFindFirst,
|
||||
findMany: recordFindMany,
|
||||
create: recordCreate,
|
||||
update: recordUpdate,
|
||||
},
|
||||
recordItem: { findMany: recordItemFindMany, create: recordItemCreate, deleteMany: recordItemDeleteMany },
|
||||
recordPhoto: { findMany: recordPhotoFindMany, create: recordPhotoCreate },
|
||||
recordAbnormal: { findMany: recordAbnormalFindMany },
|
||||
svcOrg: { findFirst: svcOrgFindFirst },
|
||||
svcNational: { findFirst: svcNationalFindFirst },
|
||||
svcLocal: { findFirst: svcLocalFindFirst },
|
||||
},
|
||||
};
|
||||
const storage = {
|
||||
buildKey: vi.fn().mockReturnValue('record/T001/1-a.jpg'),
|
||||
upload: vi.fn().mockResolvedValue('record/T001/1-a.jpg'),
|
||||
};
|
||||
const service = new RecordService(
|
||||
prisma as unknown as PrismaService,
|
||||
storage as unknown as StorageService,
|
||||
);
|
||||
return {
|
||||
service,
|
||||
orderFindUnique,
|
||||
staffFindFirst,
|
||||
elderFindFirst,
|
||||
recordFindUnique,
|
||||
recordFindFirst,
|
||||
recordFindMany,
|
||||
recordCreate,
|
||||
recordUpdate,
|
||||
recordItemFindMany,
|
||||
recordItemCreate,
|
||||
recordItemDeleteMany,
|
||||
recordPhotoFindMany,
|
||||
recordPhotoCreate,
|
||||
recordAbnormalFindMany,
|
||||
svcOrgFindFirst,
|
||||
svcNationalFindFirst,
|
||||
svcLocalFindFirst,
|
||||
storage,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RecordService.checkin', () => {
|
||||
const servingOrder = { id: 'o1', orderStatus: 'assigned', elderId: 'e1', assigneeId: 'staff1' };
|
||||
|
||||
it('工单不存在 → RECORD_ORDER_INVALID,不写 record', async () => {
|
||||
const { service, recordCreate } = makeService();
|
||||
await expect(
|
||||
runWithRequestContext(ctx, () => service.checkin({ orderId: 'missing' })),
|
||||
).rejects.toMatchObject({ response: { code: ErrorCodes.RECORD_ORDER_INVALID } });
|
||||
expect(recordCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('工单非 assigned/serving → RECORD_ORDER_INVALID', async () => {
|
||||
const orderFindUnique = vi.fn().mockResolvedValue({ ...servingOrder, orderStatus: 'to_assign' });
|
||||
const { service, recordCreate } = makeService({ orderFindUnique });
|
||||
await expect(
|
||||
runWithRequestContext(ctx, () => service.checkin({ orderId: 'o1' })),
|
||||
).rejects.toMatchObject({ response: { code: ErrorCodes.RECORD_ORDER_INVALID } });
|
||||
expect(recordCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('非本人工单(assignee 不是当前护理员)→ RECORD_ORDER_INVALID', async () => {
|
||||
const orderFindUnique = vi.fn().mockResolvedValue(servingOrder);
|
||||
const staffFindFirst = vi.fn().mockResolvedValue({ id: 'staff-other', userId: 'u1' });
|
||||
const { service, recordCreate } = makeService({ orderFindUnique, staffFindFirst });
|
||||
await expect(
|
||||
runWithRequestContext(ctx, () => service.checkin({ orderId: 'o1' })),
|
||||
).rejects.toMatchObject({ response: { code: ErrorCodes.RECORD_ORDER_INVALID } });
|
||||
expect(recordCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('签到服务器取时:checkin_time 为 Date,客户端传入被忽略', 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' });
|
||||
const { service } = makeService({ orderFindUnique, staffFindFirst, elderFindFirst, recordCreate });
|
||||
|
||||
await runWithRequestContext(ctx, () =>
|
||||
service.checkin({ orderId: 'o1', checkinTime: '2020-01-01T00:00:00' } as never),
|
||||
);
|
||||
|
||||
const data = recordCreate.mock.calls[0][0].data;
|
||||
expect(data.checkinTime).toBeInstanceOf(Date);
|
||||
expect(data.checkinTime.toISOString()).not.toBe('2020-01-01T00:00:00.000Z');
|
||||
expect(data.recordStatus).toBe('serving');
|
||||
});
|
||||
|
||||
it('定位失败不阻塞:locate_status=failed 照常提交', 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' });
|
||||
const { service } = makeService({ orderFindUnique, staffFindFirst, elderFindFirst, recordCreate });
|
||||
|
||||
const res = await runWithRequestContext(ctx, () =>
|
||||
service.checkin({ orderId: 'o1', locateStatus: 'failed', checkinLat: 25.01, checkinLng: 102.7 }),
|
||||
);
|
||||
|
||||
expect(res).toEqual({ id: 'r1' });
|
||||
expect(recordCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ locateStatus: 'failed', checkinLat: 25.01, checkinLng: 102.7 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('waiting_period 老人 → is_prepaid=true、billable=false(3004)', async () => {
|
||||
const orderFindUnique = vi.fn().mockResolvedValue(servingOrder);
|
||||
const staffFindFirst = vi.fn().mockResolvedValue({ id: 'staff1', userId: 'u1' });
|
||||
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'waiting_period' });
|
||||
const recordCreate = vi.fn().mockResolvedValue({ id: 'r1' });
|
||||
const { service } = makeService({ orderFindUnique, staffFindFirst, elderFindFirst, recordCreate });
|
||||
|
||||
await runWithRequestContext(ctx, () => service.checkin({ orderId: 'o1' }));
|
||||
|
||||
expect(recordCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ isPrepaid: true, billable: false }),
|
||||
});
|
||||
});
|
||||
|
||||
it('active 老人 → is_prepaid=false、billable=true', 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' });
|
||||
const { service } = makeService({ orderFindUnique, staffFindFirst, elderFindFirst, recordCreate });
|
||||
|
||||
await runWithRequestContext(ctx, () => service.checkin({ orderId: 'o1' }));
|
||||
|
||||
expect(recordCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ isPrepaid: false, billable: true, tenantCode: 'T001', createdBy: 'u1' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RecordService.itemSave', () => {
|
||||
const servingRecord = { id: 'r1', recordStatus: 'serving', orderId: 'o1', elderId: 'e1', assigneeId: 'staff1' };
|
||||
|
||||
it('exec_result 非法 → 拒绝', async () => {
|
||||
const recordFindUnique = vi.fn().mockResolvedValue(servingRecord);
|
||||
const { service, recordItemDeleteMany, recordItemCreate } = makeService({ recordFindUnique });
|
||||
|
||||
await expect(
|
||||
runWithRequestContext(ctx, () =>
|
||||
service.itemSave({ recordId: 'r1', items: [{ svcId: 'svc1', execResult: 'not_done' }] }),
|
||||
),
|
||||
).rejects.toThrowError('exec_result 非法');
|
||||
|
||||
expect(recordItemDeleteMany).not.toHaveBeenCalled();
|
||||
expect(recordItemCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('整表覆盖 + L4 落库 + snapshot svc_name', async () => {
|
||||
const recordFindUnique = vi.fn().mockResolvedValue(servingRecord);
|
||||
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,
|
||||
svcOrgFindFirst,
|
||||
svcNationalFindFirst,
|
||||
recordItemCreate,
|
||||
});
|
||||
|
||||
const res = await runWithRequestContext(ctx, () =>
|
||||
service.itemSave({
|
||||
recordId: 'r1',
|
||||
items: [
|
||||
{ svcId: 'svc1', execResult: 'done', photoRequired: true },
|
||||
{ svcId: 'svc2', execResult: 'undo_refused', refuseReason: '老人拒绝' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(recordItemDeleteMany).toHaveBeenCalledWith({ where: { recordId: 'r1' } });
|
||||
expect(recordItemCreate).toHaveBeenCalledTimes(2);
|
||||
expect(recordItemCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
recordId: 'r1',
|
||||
tenantCode: 'T001',
|
||||
svcId: 'svc1',
|
||||
svcNameSnapshot: '协助翻身叩背排痰',
|
||||
execResult: 'done',
|
||||
photoRequired: true,
|
||||
}),
|
||||
});
|
||||
expect(res).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('svc_id 非本机构 L4 → SVC_ORG_NOT_FOUND', async () => {
|
||||
const recordFindUnique = vi.fn().mockResolvedValue(servingRecord);
|
||||
const svcOrgFindFirst = vi.fn().mockResolvedValue(null);
|
||||
const { service, recordItemDeleteMany, recordItemCreate } = makeService({ recordFindUnique, svcOrgFindFirst });
|
||||
|
||||
await expect(
|
||||
runWithRequestContext(ctx, () =>
|
||||
service.itemSave({ recordId: 'r1', items: [{ svcId: 'bad-svc', execResult: 'done' }] }),
|
||||
),
|
||||
).rejects.toMatchObject({ response: { code: ErrorCodes.SVC_ORG_NOT_FOUND } });
|
||||
|
||||
expect(recordItemDeleteMany).not.toHaveBeenCalled();
|
||||
expect(recordItemCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RecordService.checkout', () => {
|
||||
it('必拍未拍 → RECORD_PHOTO_REQUIRED(3001)', async () => {
|
||||
const recordFindUnique = vi.fn().mockResolvedValue({
|
||||
id: 'r1',
|
||||
recordStatus: 'serving',
|
||||
checkinTime: new Date('2026-10-15T02:00:00Z'),
|
||||
});
|
||||
const recordItemFindMany = vi.fn().mockResolvedValue([
|
||||
{ id: 'ri1', svcId: 'svc1', photoRequired: true, execResult: 'done', isRefusedPhoto: false },
|
||||
]);
|
||||
const recordPhotoFindMany = vi.fn().mockResolvedValue([]);
|
||||
const { service, recordUpdate } = makeService({ recordFindUnique, recordItemFindMany, recordPhotoFindMany });
|
||||
|
||||
await expect(
|
||||
runWithRequestContext(ctx, () => service.checkout({ recordId: 'r1' })),
|
||||
).rejects.toMatchObject({
|
||||
response: { code: ErrorCodes.RECORD_PHOTO_REQUIRED, message: expect.stringContaining('3001') },
|
||||
});
|
||||
|
||||
expect(recordUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('拒拍出路放行:is_refused_photo=1 不视为未拍', async () => {
|
||||
const recordFindUnique = vi.fn().mockResolvedValue({
|
||||
id: 'r1',
|
||||
recordStatus: 'serving',
|
||||
checkinTime: new Date('2026-10-15T02:00:00Z'),
|
||||
});
|
||||
const recordItemFindMany = vi.fn().mockResolvedValue([
|
||||
{ id: 'ri1', svcId: 'svc1', photoRequired: true, execResult: 'done', isRefusedPhoto: true },
|
||||
]);
|
||||
const recordPhotoFindMany = vi.fn().mockResolvedValue([]);
|
||||
const recordUpdate = vi.fn().mockResolvedValue({ id: 'r1' });
|
||||
const { service } = makeService({ recordFindUnique, recordItemFindMany, recordPhotoFindMany, recordUpdate });
|
||||
|
||||
await runWithRequestContext(ctx, () => service.checkout({ recordId: 'r1' }));
|
||||
|
||||
expect(recordUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'r1' },
|
||||
data: expect.objectContaining({ recordStatus: 'submitted', checkoutTime: expect.any(Date), submitTime: expect.any(Date) }),
|
||||
});
|
||||
});
|
||||
|
||||
it('签退服务器取时长:duration_min 由两端时间差计算,转 submitted', async () => {
|
||||
const checkinTime = new Date(Date.now() - 30 * 60 * 1000);
|
||||
const recordFindUnique = vi.fn().mockResolvedValue({ id: 'r1', recordStatus: 'serving', checkinTime });
|
||||
const recordItemFindMany = vi.fn().mockResolvedValue([]);
|
||||
const recordUpdate = vi.fn().mockResolvedValue({ id: 'r1' });
|
||||
const { service } = makeService({ recordFindUnique, recordItemFindMany, recordUpdate });
|
||||
|
||||
await runWithRequestContext(ctx, () => service.checkout({ recordId: 'r1' }));
|
||||
|
||||
const data = recordUpdate.mock.calls[0][0].data;
|
||||
expect(data.recordStatus).toBe('submitted');
|
||||
expect(data.durationMin).toBe(30);
|
||||
expect(data.checkoutTime).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RecordService.photoUpload', () => {
|
||||
it('超过 500KB → 拒绝', async () => {
|
||||
const recordFindUnique = vi.fn().mockResolvedValue({ id: 'r1', recordStatus: 'serving' });
|
||||
const { service, recordPhotoCreate, storage } = makeService({ recordFindUnique });
|
||||
|
||||
await expect(
|
||||
runWithRequestContext(ctx, () =>
|
||||
service.photoUpload(
|
||||
{ recordId: 'r1', svcId: 'svc1' },
|
||||
{ buffer: Buffer.alloc(500 * 1024 + 1), originalname: 'a.jpg', size: 500 * 1024 + 1 },
|
||||
),
|
||||
),
|
||||
).rejects.toThrowError('500KB');
|
||||
|
||||
expect(storage.upload).not.toHaveBeenCalled();
|
||||
expect(recordPhotoCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('合规照片 → buildKey 带 record 前缀 + 落 photo + keep_until ≥7 天', async () => {
|
||||
const recordFindUnique = vi.fn().mockResolvedValue({ id: 'r1', recordStatus: 'serving' });
|
||||
const recordPhotoCreate = vi.fn().mockResolvedValue({ id: 'rp1' });
|
||||
const { service, storage } = makeService({ recordFindUnique, recordPhotoCreate });
|
||||
|
||||
await runWithRequestContext(ctx, () =>
|
||||
service.photoUpload(
|
||||
{ recordId: 'r1', svcId: 'svc1' },
|
||||
{ buffer: Buffer.from('x'), originalname: 'a.jpg', size: 100 },
|
||||
),
|
||||
);
|
||||
|
||||
expect(storage.buildKey).toHaveBeenCalledWith('T001', 'a.jpg', 'record');
|
||||
expect(storage.upload).toHaveBeenCalledWith(expect.any(Buffer), 'record/T001/1-a.jpg');
|
||||
expect(recordPhotoCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
recordId: 'r1',
|
||||
svcId: 'svc1',
|
||||
filePath: 'record/T001/1-a.jpg',
|
||||
fileSize: 100,
|
||||
visibleToFamily: false,
|
||||
keepUntil: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RecordService.offlineSync', () => {
|
||||
it('clientMd5 缺失 → 拒绝', async () => {
|
||||
const { service, recordCreate } = makeService();
|
||||
await expect(
|
||||
runWithRequestContext(ctx, () =>
|
||||
service.offlineSync({ clientMd5: '', orderId: 'o1', elderId: 'e1', assigneeId: 'staff1', checkinTime: '2026-10-15T02:00:00Z' }),
|
||||
),
|
||||
).rejects.toThrowError('client_md5');
|
||||
expect(recordCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clientMd5 命中 → 幂等返回已存在,不重复落库', async () => {
|
||||
const existing = { id: 'r-exist', clientMd5: 'md5-1' };
|
||||
const recordFindFirst = vi.fn().mockResolvedValue(existing);
|
||||
const { service, recordCreate } = makeService({ recordFindFirst });
|
||||
|
||||
const res = await runWithRequestContext(ctx, () =>
|
||||
service.offlineSync({ clientMd5: 'md5-1', orderId: 'o1', elderId: 'e1', assigneeId: 'staff1', checkinTime: '2026-10-15T02:00:00Z' }),
|
||||
);
|
||||
|
||||
expect(recordFindFirst).toHaveBeenCalledWith({ where: { clientMd5: 'md5-1' } });
|
||||
expect(res).toEqual(existing);
|
||||
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' });
|
||||
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' });
|
||||
const svcNationalFindFirst = vi.fn().mockResolvedValue({ natName: '协助翻身叩背排痰' });
|
||||
const { service, recordItemCreate, recordPhotoCreate } = makeService({
|
||||
orderFindUnique,
|
||||
elderFindFirst,
|
||||
recordCreate,
|
||||
svcOrgFindFirst,
|
||||
svcNationalFindFirst,
|
||||
});
|
||||
|
||||
await runWithRequestContext(ctx, () =>
|
||||
service.offlineSync({
|
||||
clientMd5: 'md5-2',
|
||||
orderId: 'o1',
|
||||
elderId: 'e1',
|
||||
assigneeId: 'staff1',
|
||||
checkinTime: '2026-10-15T02:00:00Z',
|
||||
checkoutTime: '2026-10-15T03:00:00Z',
|
||||
items: [{ svcId: 'svc1', execResult: 'done', photoRequired: true }],
|
||||
photos: [{ svcId: 'svc1', filePath: 'record/T001/a.jpg', fileSize: 100 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(recordCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
offlineFlag: true,
|
||||
clientMd5: 'md5-2',
|
||||
isPrepaid: true,
|
||||
billable: false,
|
||||
recordStatus: 'submitted',
|
||||
durationMin: 60,
|
||||
}),
|
||||
});
|
||||
expect(recordItemCreate).toHaveBeenCalledTimes(1);
|
||||
expect(recordPhotoCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RecordService.prepaidCarryover', () => {
|
||||
const original = {
|
||||
id: 'r1',
|
||||
orderId: 'o1',
|
||||
elderId: 'e1',
|
||||
assigneeId: 'staff1',
|
||||
isPrepaid: true,
|
||||
billable: false,
|
||||
recordStatus: 'submitted',
|
||||
checkinTime: new Date('2026-10-15T02:00:00Z'),
|
||||
checkoutTime: new Date('2026-10-15T03:00:00Z'),
|
||||
durationMin: 60,
|
||||
checkinLat: 25.01,
|
||||
checkinLng: 102.7,
|
||||
locateStatus: 'ok',
|
||||
};
|
||||
|
||||
it('结转不改时间戳:新 record 原样复制 checkin/checkout/定位,原记录不改', async () => {
|
||||
const recordFindMany = vi.fn().mockResolvedValue([original]);
|
||||
const recordItemFindMany = vi.fn().mockResolvedValue([{ id: 'ri1', svcId: 'svc1', svcNameSnapshot: '协助翻身', execResult: 'done', photoRequired: true, isRefusedPhoto: false, refuseReason: null, extraFlag: false, undoReason: null }]);
|
||||
const recordPhotoFindMany = vi.fn().mockResolvedValue([{ id: 'rp1', svcId: 'svc1', filePath: 'record/T001/a.jpg', fileSize: 100, visibleToFamily: false, keepUntil: null }]);
|
||||
const recordCreate = vi.fn().mockResolvedValue({ id: 'r2' });
|
||||
const { service, recordItemCreate, recordPhotoCreate, recordUpdate } = makeService({
|
||||
recordFindMany,
|
||||
recordItemFindMany,
|
||||
recordPhotoFindMany,
|
||||
recordCreate,
|
||||
});
|
||||
|
||||
const res = await runWithRequestContext(ctx, () => service.prepaidCarryover({ recordIds: ['r1'] }));
|
||||
|
||||
expect(recordCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
isPrepaid: false,
|
||||
billable: true,
|
||||
sourcePrepaidOf: 'r1',
|
||||
checkinTime: original.checkinTime,
|
||||
checkoutTime: original.checkoutTime,
|
||||
checkinLat: 25.01,
|
||||
checkinLng: 102.7,
|
||||
}),
|
||||
});
|
||||
expect(recordUpdate).not.toHaveBeenCalled();
|
||||
expect(recordItemCreate).toHaveBeenCalledTimes(1);
|
||||
expect(recordPhotoCreate).toHaveBeenCalledTimes(1);
|
||||
expect(res).toEqual([{ id: 'r2' }]);
|
||||
});
|
||||
|
||||
it('非垫付记录 → PREPAID_CARRYOVER_INVALID', async () => {
|
||||
const recordFindMany = vi.fn().mockResolvedValue([{ ...original, isPrepaid: false }]);
|
||||
const { service, recordCreate } = makeService({ recordFindMany });
|
||||
|
||||
await expect(
|
||||
runWithRequestContext(ctx, () => service.prepaidCarryover({ recordIds: ['r1'] })),
|
||||
).rejects.toMatchObject({ response: { code: ErrorCodes.PREPAID_CARRYOVER_INVALID } });
|
||||
expect(recordCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('不同老人 → PREPAID_CARRYOVER_INVALID', async () => {
|
||||
const recordFindMany = vi.fn().mockResolvedValue([
|
||||
original,
|
||||
{ ...original, id: 'r2', elderId: 'e2' },
|
||||
]);
|
||||
const { service, recordCreate } = makeService({ recordFindMany });
|
||||
|
||||
await expect(
|
||||
runWithRequestContext(ctx, () => service.prepaidCarryover({ recordIds: ['r1', 'r2'] })),
|
||||
).rejects.toMatchObject({ response: { code: ErrorCodes.PREPAID_CARRYOVER_INVALID } });
|
||||
expect(recordCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RecordService.detail / list / prepaidList', () => {
|
||||
it('detail 返回 record + items + photos + abnormals', async () => {
|
||||
const recordFindUnique = vi.fn().mockResolvedValue({ id: 'r1', recordStatus: 'submitted' });
|
||||
const recordItemFindMany = vi.fn().mockResolvedValue([{ id: 'ri1' }]);
|
||||
const recordPhotoFindMany = vi.fn().mockResolvedValue([{ id: 'rp1' }]);
|
||||
const recordAbnormalFindMany = vi.fn().mockResolvedValue([{ id: 'ra1' }]);
|
||||
const { service } = makeService({
|
||||
recordFindUnique,
|
||||
recordItemFindMany,
|
||||
recordPhotoFindMany,
|
||||
recordAbnormalFindMany,
|
||||
});
|
||||
|
||||
const res = await runWithRequestContext(ctx, () => service.detail('r1'));
|
||||
|
||||
expect(res).toMatchObject({ id: 'r1', items: [{ id: 'ri1' }], photos: [{ id: 'rp1' }], abnormals: [{ id: 'ra1' }] });
|
||||
});
|
||||
|
||||
it('list 按 elder/assignee/状态筛选', async () => {
|
||||
const recordFindMany = vi.fn().mockResolvedValue([{ id: 'r1' }]);
|
||||
const { service } = makeService({ recordFindMany });
|
||||
|
||||
await runWithRequestContext(ctx, () => service.list({ elderId: 'e1', assigneeId: 'staff1', recordStatus: 'submitted' }));
|
||||
|
||||
expect(recordFindMany).toHaveBeenCalledWith({
|
||||
where: { elderId: 'e1', assigneeId: 'staff1', recordStatus: 'submitted' },
|
||||
orderBy: { checkinTime: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('prepaidList 按老人返回 is_prepaid=1 记录', async () => {
|
||||
const recordFindMany = vi.fn().mockResolvedValue([{ id: 'r1', isPrepaid: true }]);
|
||||
const { service } = makeService({ recordFindMany });
|
||||
|
||||
await runWithRequestContext(ctx, () => service.prepaidList({ elderId: 'e1' }));
|
||||
|
||||
expect(recordFindMany).toHaveBeenCalledWith({ where: { elderId: 'e1', isPrepaid: true } });
|
||||
});
|
||||
});
|
||||
412
src/record/record.service.ts
Normal file
412
src/record/record.service.ts
Normal file
@ -0,0 +1,412 @@
|
||||
import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { getRequestContext } from '../common/context/request-context.js';
|
||||
import { BusinessException } from '../common/business-exception.js';
|
||||
import { ErrorCodes } from '../common/error-codes.js';
|
||||
import { StorageService } from '../storage/storage.service.js';
|
||||
import {
|
||||
CheckinDto,
|
||||
CheckoutDto,
|
||||
EXEC_RESULTS,
|
||||
ItemSaveDto,
|
||||
ListRecordQuery,
|
||||
OfflineSyncDto,
|
||||
PhotoUploadDto,
|
||||
PrepaidCarryoverDto,
|
||||
PrepaidListQuery,
|
||||
RecordItemDto,
|
||||
} from './dto.js';
|
||||
|
||||
/** 签到允许的工单状态(八态中的 assigned/serving)。 */
|
||||
const CHECKIN_ORDER_STATUSES = ['assigned', 'serving'];
|
||||
/** 照片大小上限:500KB。 */
|
||||
const PHOTO_MAX_BYTES = 500 * 1024;
|
||||
/** 照片保留期:7 天(毫秒)。 */
|
||||
const PHOTO_KEEP_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 履约留痕(ho_record/ho_record_item/ho_record_photo)业务服务。
|
||||
*
|
||||
* - checkin:L3 校验(order 存在 + assigned/serving + assignee 为当前护理员)+ 服务器取时 + 3002 定位标记不阻塞。
|
||||
* - itemSave:exec_result 枚举 + svc_id 落本机构 L4 + 整表覆盖(先删后写)。
|
||||
* - photoUpload:≤500KB → MinIO → record_photo。
|
||||
* - checkout:必拍未拍 3001 硬拒(拒拍出路放行)+ 服务器取 checkout_time/duration_min → submitted。
|
||||
* - offlineSync:client_md5 幂等去重,一次建完整 record。
|
||||
* - prepaidCarryover:复制垫付 record → 正式 record(is_prepaid=0 + source_prepaid_of),原记录不改时间戳。
|
||||
*/
|
||||
@Injectable()
|
||||
export class RecordService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storage: StorageService,
|
||||
) {}
|
||||
|
||||
/** 签到:建 serving 留痕。 */
|
||||
async checkin(dto: CheckinDto) {
|
||||
const ctx = getRequestContext()!;
|
||||
const order = await this.prisma.client.order.findUnique({ where: { id: dto.orderId } });
|
||||
if (!order) {
|
||||
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单不存在(L3)', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (!CHECKIN_ORDER_STATUSES.includes(order.orderStatus)) {
|
||||
throw new BusinessException(
|
||||
ErrorCodes.RECORD_ORDER_INVALID,
|
||||
'工单状态不可签到(须已派单/服务中)',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
// assignee 须为当前登录护理员本人(ctx.userId → staff.id === order.assigneeId)。
|
||||
const staff = await this.prisma.client.staff.findFirst({ where: { userId: ctx.userId } });
|
||||
if (!staff || staff.id !== order.assigneeId) {
|
||||
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '非本人工单,不可签到', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
// 老人待遇决定 is_prepaid / billable(L1 例外:waiting_period 垫付,不计费)。
|
||||
const elder = await this.prisma.client.elder.findFirst({ where: { id: order.elderId } });
|
||||
if (!elder) {
|
||||
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
const isPrepaid = elder.treatStatus === 'waiting_period';
|
||||
return this.prisma.client.record.create({
|
||||
data: {
|
||||
tenantCode: ctx.tenantCode,
|
||||
createdBy: ctx.userId,
|
||||
orderId: order.id,
|
||||
elderId: order.elderId,
|
||||
assigneeId: order.assigneeId!,
|
||||
checkinTime: new Date(), // 服务器取时,忽略客户端传入
|
||||
checkinLat: dto.checkinLat ?? null,
|
||||
checkinLng: dto.checkinLng ?? null,
|
||||
locateStatus: dto.locateStatus ?? 'ok', // 定位失败/拒绝不阻塞(3002)
|
||||
recordStatus: 'serving',
|
||||
isPrepaid,
|
||||
billable: !isPrepaid,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存项目执行:整表覆盖(先删后写)。 */
|
||||
async itemSave(dto: ItemSaveDto) {
|
||||
const ctx = getRequestContext()!;
|
||||
const record = await this.requireServingRecord(dto.recordId);
|
||||
// 先全量校验(exec_result 枚举 + L4)并解析快照,任一失败不落库。
|
||||
const resolved = await this.resolveItems(dto.items);
|
||||
|
||||
await this.prisma.client.recordItem.deleteMany({ where: { recordId: record.id } });
|
||||
const rows = [];
|
||||
for (const item of resolved) {
|
||||
rows.push(
|
||||
await this.prisma.client.recordItem.create({
|
||||
data: {
|
||||
tenantCode: ctx.tenantCode,
|
||||
recordId: record.id,
|
||||
svcId: item.svcId,
|
||||
svcNameSnapshot: item.svcName,
|
||||
execResult: item.execResult,
|
||||
undoReason: item.undoReason ?? null,
|
||||
extraFlag: item.extraFlag ?? false,
|
||||
photoRequired: item.photoRequired,
|
||||
isRefusedPhoto: item.isRefusedPhoto ?? false,
|
||||
refuseReason: item.refuseReason ?? null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 上传照片:≤500KB → MinIO(record 前缀)→ record_photo。 */
|
||||
async photoUpload(dto: PhotoUploadDto, file: { buffer: Buffer; originalname: string; size?: number }) {
|
||||
const ctx = getRequestContext()!;
|
||||
await this.requireServingRecord(dto.recordId);
|
||||
const size = file.size ?? file.buffer.length;
|
||||
if (size > PHOTO_MAX_BYTES) {
|
||||
throw new BadRequestException('照片超过 500KB,请压缩后上传');
|
||||
}
|
||||
const key = this.storage.buildKey(ctx.tenantCode, file.originalname, 'record');
|
||||
await this.storage.upload(file.buffer, key);
|
||||
const photo = await this.prisma.client.recordPhoto.create({
|
||||
data: {
|
||||
tenantCode: ctx.tenantCode,
|
||||
recordId: dto.recordId,
|
||||
svcId: dto.svcId ?? null,
|
||||
filePath: key,
|
||||
fileSize: size,
|
||||
visibleToFamily: false,
|
||||
uploadBatch: dto.uploadBatch ?? null,
|
||||
keepUntil: new Date(Date.now() + PHOTO_KEEP_MS),
|
||||
},
|
||||
});
|
||||
return { photoId: photo.id, filePath: key };
|
||||
}
|
||||
|
||||
/** 签退提交:必拍未拍 3001 硬拒 → 服务器取 checkout_time/duration_min → submitted。 */
|
||||
async checkout(dto: CheckoutDto) {
|
||||
const record = await this.requireServingRecord(dto.recordId);
|
||||
const [items, photos] = await Promise.all([
|
||||
this.prisma.client.recordItem.findMany({ where: { recordId: record.id } }),
|
||||
this.prisma.client.recordPhoto.findMany({ where: { recordId: record.id } }),
|
||||
]);
|
||||
for (const item of items) {
|
||||
if (item.photoRequired && item.execResult === 'done' && !item.isRefusedPhoto) {
|
||||
const hasPhoto = photos.some((p) => p.svcId === item.svcId);
|
||||
if (!hasPhoto) {
|
||||
throw new BusinessException(
|
||||
ErrorCodes.RECORD_PHOTO_REQUIRED,
|
||||
'必拍项目未拍照,请补拍或走拒拍出路(3001)',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const checkoutTime = new Date();
|
||||
const checkinTime = record.checkinTime;
|
||||
const durationMin = checkinTime
|
||||
? Math.round((checkoutTime.getTime() - new Date(checkinTime).getTime()) / 60000)
|
||||
: null;
|
||||
return this.prisma.client.record.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
checkoutTime,
|
||||
durationMin,
|
||||
recordStatus: 'submitted',
|
||||
submitTime: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 离线补传:client_md5 幂等去重,一次建完整 record。 */
|
||||
async offlineSync(dto: OfflineSyncDto) {
|
||||
const ctx = getRequestContext()!;
|
||||
if (!dto.clientMd5) {
|
||||
throw new BadRequestException('离线补传须提供 client_md5');
|
||||
}
|
||||
const existing = await this.prisma.client.record.findFirst({ where: { clientMd5: dto.clientMd5 } });
|
||||
if (existing) {
|
||||
return 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);
|
||||
}
|
||||
const elder = await this.prisma.client.elder.findFirst({ where: { id: dto.elderId } });
|
||||
if (!elder) {
|
||||
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
const isPrepaid = elder.treatStatus === 'waiting_period';
|
||||
const checkinTime = dto.checkinTime ? new Date(dto.checkinTime) : null;
|
||||
const checkoutTime = dto.checkoutTime ? new Date(dto.checkoutTime) : null;
|
||||
const durationMin =
|
||||
checkinTime && checkoutTime ? Math.round((checkoutTime.getTime() - checkinTime.getTime()) / 60000) : null;
|
||||
|
||||
const record = await this.prisma.client.record.create({
|
||||
data: {
|
||||
tenantCode: ctx.tenantCode,
|
||||
createdBy: ctx.userId,
|
||||
orderId: dto.orderId,
|
||||
elderId: dto.elderId,
|
||||
assigneeId: dto.assigneeId,
|
||||
checkinTime,
|
||||
checkoutTime,
|
||||
durationMin,
|
||||
checkinLat: dto.checkinLat ?? null,
|
||||
checkinLng: dto.checkinLng ?? null,
|
||||
locateStatus: dto.locateStatus ?? 'ok',
|
||||
recordStatus: 'submitted',
|
||||
offlineFlag: true,
|
||||
clientMd5: dto.clientMd5,
|
||||
submitTime: new Date(),
|
||||
isPrepaid,
|
||||
billable: !isPrepaid,
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await this.resolveItems(dto.items ?? []);
|
||||
for (const item of resolved) {
|
||||
await this.prisma.client.recordItem.create({
|
||||
data: {
|
||||
tenantCode: ctx.tenantCode,
|
||||
recordId: record.id,
|
||||
svcId: item.svcId,
|
||||
svcNameSnapshot: item.svcName,
|
||||
execResult: item.execResult,
|
||||
undoReason: item.undoReason ?? null,
|
||||
extraFlag: item.extraFlag ?? false,
|
||||
photoRequired: item.photoRequired,
|
||||
isRefusedPhoto: item.isRefusedPhoto ?? false,
|
||||
refuseReason: item.refuseReason ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const photo of dto.photos ?? []) {
|
||||
await this.prisma.client.recordPhoto.create({
|
||||
data: {
|
||||
tenantCode: ctx.tenantCode,
|
||||
recordId: record.id,
|
||||
svcId: photo.svcId ?? null,
|
||||
filePath: photo.filePath,
|
||||
fileSize: photo.fileSize,
|
||||
visibleToFamily: false,
|
||||
keepUntil: new Date(Date.now() + PHOTO_KEEP_MS),
|
||||
},
|
||||
});
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/** 详情:record + items + photos + abnormals。 */
|
||||
async detail(id: string) {
|
||||
const record = await this.requireRecord(id);
|
||||
const [items, photos, abnormals] = await Promise.all([
|
||||
this.prisma.client.recordItem.findMany({ where: { recordId: id } }),
|
||||
this.prisma.client.recordPhoto.findMany({ where: { recordId: id } }),
|
||||
this.prisma.client.recordAbnormal.findMany({ where: { recordId: id } }),
|
||||
]);
|
||||
return { ...record, items, photos, abnormals };
|
||||
}
|
||||
|
||||
/** 列表:按老人/护理员/日期/状态筛选。 */
|
||||
list(q: ListRecordQuery) {
|
||||
const where: Record<string, any> = {};
|
||||
if (q.elderId) where.elderId = q.elderId;
|
||||
if (q.assigneeId) where.assigneeId = q.assigneeId;
|
||||
if (q.recordStatus) where.recordStatus = q.recordStatus;
|
||||
if (q.serviceDate) {
|
||||
where.checkinTime = {
|
||||
gte: new Date(`${q.serviceDate}T00:00:00+08:00`),
|
||||
lte: new Date(`${q.serviceDate}T23:59:59.999+08:00`),
|
||||
};
|
||||
}
|
||||
return this.prisma.client.record.findMany({ where, orderBy: { checkinTime: 'desc' } });
|
||||
}
|
||||
|
||||
/** 垫付台账:按老人返回 is_prepaid=1 的记录。 */
|
||||
prepaidList(q: PrepaidListQuery) {
|
||||
return this.prisma.client.record.findMany({ where: { elderId: q.elderId, isPrepaid: true } });
|
||||
}
|
||||
|
||||
/** 结转:复制垫付 record → 正式 record(is_prepaid=0 + source_prepaid_of),原记录不改。 */
|
||||
async prepaidCarryover(dto: PrepaidCarryoverDto) {
|
||||
const ctx = getRequestContext()!;
|
||||
const records = await this.prisma.client.record.findMany({ where: { id: { in: dto.recordIds } } });
|
||||
if (records.length !== dto.recordIds.length) {
|
||||
throw new BusinessException(ErrorCodes.RECORD_NOT_FOUND, '部分留痕不存在', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
const elderIds = new Set(records.map((r) => r.elderId));
|
||||
if (elderIds.size !== 1) {
|
||||
throw new BusinessException(ErrorCodes.PREPAID_CARRYOVER_INVALID, '结转须为同一老人', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (records.some((r) => !r.isPrepaid)) {
|
||||
throw new BusinessException(ErrorCodes.PREPAID_CARRYOVER_INVALID, '仅垫付记录可结转', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const created = [];
|
||||
for (const rec of records) {
|
||||
const [items, photos] = await Promise.all([
|
||||
this.prisma.client.recordItem.findMany({ where: { recordId: rec.id } }),
|
||||
this.prisma.client.recordPhoto.findMany({ where: { recordId: rec.id } }),
|
||||
]);
|
||||
const newRecord = await this.prisma.client.record.create({
|
||||
data: {
|
||||
tenantCode: ctx.tenantCode,
|
||||
createdBy: ctx.userId,
|
||||
orderId: rec.orderId,
|
||||
elderId: rec.elderId,
|
||||
assigneeId: rec.assigneeId,
|
||||
checkinTime: rec.checkinTime, // 原样复制,不改时间戳
|
||||
checkoutTime: rec.checkoutTime,
|
||||
durationMin: rec.durationMin,
|
||||
checkinLat: rec.checkinLat,
|
||||
checkinLng: rec.checkinLng,
|
||||
locateStatus: rec.locateStatus,
|
||||
recordStatus: rec.recordStatus,
|
||||
isPrepaid: false,
|
||||
billable: true,
|
||||
sourcePrepaidOf: rec.id,
|
||||
},
|
||||
});
|
||||
for (const item of items) {
|
||||
await this.prisma.client.recordItem.create({
|
||||
data: {
|
||||
tenantCode: ctx.tenantCode,
|
||||
recordId: newRecord.id,
|
||||
svcId: item.svcId,
|
||||
svcNameSnapshot: item.svcNameSnapshot,
|
||||
execResult: item.execResult,
|
||||
undoReason: item.undoReason,
|
||||
extraFlag: item.extraFlag,
|
||||
photoRequired: item.photoRequired,
|
||||
isRefusedPhoto: item.isRefusedPhoto,
|
||||
refuseReason: item.refuseReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const photo of photos) {
|
||||
await this.prisma.client.recordPhoto.create({
|
||||
data: {
|
||||
tenantCode: ctx.tenantCode,
|
||||
recordId: newRecord.id,
|
||||
svcId: photo.svcId,
|
||||
filePath: photo.filePath,
|
||||
fileSize: photo.fileSize,
|
||||
visibleToFamily: photo.visibleToFamily,
|
||||
keepUntil: photo.keepUntil,
|
||||
},
|
||||
});
|
||||
}
|
||||
created.push(newRecord);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
// ---- 内部校验 ----
|
||||
|
||||
private async requireRecord(id: string): Promise<Record<string, any>> {
|
||||
const record = await this.prisma.client.record.findUnique({ where: { id } });
|
||||
if (!record) {
|
||||
throw new BusinessException(ErrorCodes.RECORD_NOT_FOUND, '留痕不存在', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private async requireServingRecord(id: string): Promise<Record<string, any>> {
|
||||
const record = await this.requireRecord(id);
|
||||
if (record.recordStatus !== 'serving') {
|
||||
throw new BusinessException(ErrorCodes.RECORD_STATUS_INVALID, '留痕状态不可操作(须服务中)', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/** 校验 exec_result 枚举 + svc_id 落本机构 L4,并解析快照。 */
|
||||
private async resolveItems(items: RecordItemDto[]) {
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
if (!EXEC_RESULTS.includes(item.execResult as (typeof EXEC_RESULTS)[number])) {
|
||||
throw new BadRequestException('exec_result 非法(须 done/undo_not_done/undo_refused)');
|
||||
}
|
||||
const svcOrg = await this.prisma.client.svcOrg.findFirst({ where: { id: item.svcId } });
|
||||
if (!svcOrg) {
|
||||
throw new BusinessException(ErrorCodes.SVC_ORG_NOT_FOUND, '机构项目不存在', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
let svcName = '';
|
||||
if (svcOrg.srcType === 'local') {
|
||||
const local = await this.prisma.client.svcLocal.findFirst({ where: { id: svcOrg.srcId } });
|
||||
svcName = local?.localItemName ?? '';
|
||||
} else {
|
||||
const national = await this.prisma.client.svcNational.findFirst({ where: { id: svcOrg.srcId } });
|
||||
svcName = national?.natName ?? '';
|
||||
}
|
||||
return {
|
||||
svcId: item.svcId,
|
||||
svcName,
|
||||
execResult: item.execResult,
|
||||
undoReason: item.undoReason,
|
||||
extraFlag: item.extraFlag,
|
||||
photoRequired: item.photoRequired ?? false,
|
||||
isRefusedPhoto: item.isRefusedPhoto,
|
||||
refuseReason: item.refuseReason,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -19,8 +19,8 @@ export class StorageService {
|
||||
this.bucket = process.env.MINIO_BUCKET ?? 'carelink';
|
||||
}
|
||||
|
||||
buildKey(tenantCode: string, filename: string): string {
|
||||
return `elder/${tenantCode}/${Date.now()}-${filename}`;
|
||||
buildKey(tenantCode: string, filename: string, prefix = 'elder'): string {
|
||||
return `${prefix}/${tenantCode}/${Date.now()}-${filename}`;
|
||||
}
|
||||
|
||||
async upload(buffer: Buffer, key: string): Promise<string> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user