From 5bfd0f47488ac6f0f8710550a86ce6b415a114c1 Mon Sep 17 00:00:00 2001 From: liuyun Date: Thu, 15 Oct 2026 00:28:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(c3):=20=E5=B1=A5=E7=BA=A6=E7=95=99?= =?UTF-8?q?=E7=97=95=E6=A8=A1=E5=9D=97=EF=BC=88=E7=AD=BE=E5=88=B0/?= =?UTF-8?q?=E7=AD=BE=E9=80=80/=E9=A1=B9=E7=9B=AE/=E7=85=A7=E7=89=87/?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF/=E5=9E=AB=E4=BB=98=E7=BB=93=E8=BD=AC?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/record/dto.ts | 242 +++++++++++++ src/record/record.controller.ts | 82 +++++ src/record/record.module.ts | 11 + src/record/record.service.spec.ts | 553 ++++++++++++++++++++++++++++++ src/record/record.service.ts | 412 ++++++++++++++++++++++ src/storage/storage.service.ts | 4 +- 6 files changed, 1302 insertions(+), 2 deletions(-) create mode 100644 src/record/dto.ts create mode 100644 src/record/record.controller.ts create mode 100644 src/record/record.module.ts create mode 100644 src/record/record.service.spec.ts create mode 100644 src/record/record.service.ts diff --git a/src/record/dto.ts b/src/record/dto.ts new file mode 100644 index 0000000..6b4cc14 --- /dev/null +++ b/src/record/dto.ts @@ -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[]; +} diff --git a/src/record/record.controller.ts b/src/record/record.controller.ts new file mode 100644 index 0000000..c1771e9 --- /dev/null +++ b/src/record/record.controller.ts @@ -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); + } +} diff --git a/src/record/record.module.ts b/src/record/record.module.ts new file mode 100644 index 0000000..7b62f67 --- /dev/null +++ b/src/record/record.module.ts @@ -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 {} diff --git a/src/record/record.service.spec.ts b/src/record/record.service.spec.ts new file mode 100644 index 0000000..2bc8435 --- /dev/null +++ b/src/record/record.service.spec.ts @@ -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; + staffFindFirst?: ReturnType; + elderFindFirst?: ReturnType; + recordFindUnique?: ReturnType; + recordFindFirst?: ReturnType; + recordFindMany?: ReturnType; + recordCreate?: ReturnType; + recordUpdate?: ReturnType; + recordItemFindMany?: ReturnType; + recordItemCreate?: ReturnType; + recordItemDeleteMany?: ReturnType; + recordPhotoFindMany?: ReturnType; + recordPhotoCreate?: ReturnType; + recordAbnormalFindMany?: ReturnType; + svcOrgFindFirst?: ReturnType; + svcNationalFindFirst?: ReturnType; + svcLocalFindFirst?: ReturnType; + } = {}, +) { + 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 } }); + }); +}); diff --git a/src/record/record.service.ts b/src/record/record.service.ts new file mode 100644 index 0000000..b3a9775 --- /dev/null +++ b/src/record/record.service.ts @@ -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 = {}; + 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> { + 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> { + 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, + }; + }), + ); + } + +} diff --git a/src/storage/storage.service.ts b/src/storage/storage.service.ts index 8d0aa9b..971a88e 100644 --- a/src/storage/storage.service.ts +++ b/src/storage/storage.service.ts @@ -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 {