Compare commits
13 Commits
66da809a2e
...
9f232356e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f232356e0 | ||
|
|
5f060292c6 | ||
|
|
c00c4ee007 | ||
|
|
ab09210d28 | ||
|
|
169b4e2c51 | ||
|
|
1966a68bc8 | ||
|
|
55f432b8e7 | ||
|
|
563f5ab5b5 | ||
|
|
2e4c916949 | ||
|
|
5bfd0f4748 | ||
|
|
93e532aa7d | ||
|
|
4295683f04 | ||
|
|
71b9155ccf |
133
prisma/migrations/20261015001939_c3_record/migration.sql
Normal file
133
prisma/migrations/20261015001939_c3_record/migration.sql
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ho_record` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`order_id` VARCHAR(36) NOT NULL,
|
||||||
|
`elder_id` VARCHAR(36) NOT NULL,
|
||||||
|
`assignee_id` VARCHAR(36) NOT NULL,
|
||||||
|
`checkin_time` DATETIME(3) NULL,
|
||||||
|
`checkout_time` DATETIME(3) NULL,
|
||||||
|
`duration_min` INTEGER NULL,
|
||||||
|
`checkin_lat` DECIMAL(10, 6) NULL,
|
||||||
|
`checkin_lng` DECIMAL(10, 6) NULL,
|
||||||
|
`locate_status` VARCHAR(20) NOT NULL DEFAULT 'ok',
|
||||||
|
`record_status` VARCHAR(20) NOT NULL DEFAULT 'serving',
|
||||||
|
`voice_file` VARCHAR(200) NULL,
|
||||||
|
`offline_flag` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`client_md5` VARCHAR(32) NULL,
|
||||||
|
`submit_time` DATETIME(3) NULL,
|
||||||
|
`actual_duration_tier` VARCHAR(10) NULL,
|
||||||
|
`biz_line` VARCHAR(20) NOT NULL DEFAULT 'ltci',
|
||||||
|
`is_prepaid` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`billable` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`source_prepaid_of` VARCHAR(36) NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`created_by` VARCHAR(36) NULL,
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
`deleted_at` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
INDEX `ho_record_tenant_code_order_id_idx`(`tenant_code`, `order_id`),
|
||||||
|
INDEX `ho_record_tenant_code_elder_id_idx`(`tenant_code`, `elder_id`),
|
||||||
|
INDEX `ho_record_tenant_code_assignee_id_checkin_time_idx`(`tenant_code`, `assignee_id`, `checkin_time`),
|
||||||
|
UNIQUE INDEX `ho_record_tenant_code_client_md5_key`(`tenant_code`, `client_md5`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ho_record_item` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`record_id` VARCHAR(36) NOT NULL,
|
||||||
|
`svc_id` VARCHAR(36) NOT NULL,
|
||||||
|
`svc_name_snapshot` VARCHAR(100) NOT NULL,
|
||||||
|
`exec_result` VARCHAR(20) NOT NULL,
|
||||||
|
`undo_reason` VARCHAR(200) NULL,
|
||||||
|
`extra_flag` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`photo_required` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`is_refused_photo` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`refuse_reason` VARCHAR(200) NULL,
|
||||||
|
|
||||||
|
INDEX `ho_record_item_record_id_idx`(`record_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ho_record_photo` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`record_id` VARCHAR(36) NOT NULL,
|
||||||
|
`svc_id` VARCHAR(36) NULL,
|
||||||
|
`file_path` VARCHAR(255) NOT NULL,
|
||||||
|
`file_size` INTEGER NOT NULL,
|
||||||
|
`visible_to_family` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`upload_batch` VARCHAR(36) NULL,
|
||||||
|
`keep_until` DATETIME(3) NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `ho_record_photo_record_id_idx`(`record_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ho_record_abnormal` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`record_id` VARCHAR(36) NULL,
|
||||||
|
`order_id` VARCHAR(36) NULL,
|
||||||
|
`elder_id` VARCHAR(36) NOT NULL,
|
||||||
|
`reporter_id` VARCHAR(36) NOT NULL,
|
||||||
|
`abnormal_type` VARCHAR(20) NOT NULL,
|
||||||
|
`abnormal_desc` TEXT NULL,
|
||||||
|
`voice_file` VARCHAR(200) NULL,
|
||||||
|
`has_photo` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`visit_time` DATETIME(3) NULL,
|
||||||
|
`visit_lat` DECIMAL(10, 6) NULL,
|
||||||
|
`visit_lng` DECIMAL(10, 6) NULL,
|
||||||
|
`notify_status` VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
`handle_status` VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||||
|
`handle_result` VARCHAR(200) NULL,
|
||||||
|
`link_plan_change` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`created_by` VARCHAR(36) NULL,
|
||||||
|
|
||||||
|
INDEX `ho_record_abnormal_tenant_code_elder_id_idx`(`tenant_code`, `elder_id`),
|
||||||
|
INDEX `ho_record_abnormal_tenant_code_notify_status_idx`(`tenant_code`, `notify_status`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `settlement` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`settle_period` VARCHAR(20) NOT NULL,
|
||||||
|
`amount_total` DECIMAL(12, 2) NULL,
|
||||||
|
`amount_passed` DECIMAL(12, 2) NULL,
|
||||||
|
`amount_rejected` DECIMAL(12, 2) NULL,
|
||||||
|
`amount_fund` DECIMAL(12, 2) NULL,
|
||||||
|
`amount_self_pay` DECIMAL(12, 2) NULL,
|
||||||
|
`submit_date` DATE NULL,
|
||||||
|
`pay_date` DATE NULL,
|
||||||
|
`exception_reason` VARCHAR(200) NULL,
|
||||||
|
`handle_deadline` DATE NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `report` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`record_id` VARCHAR(36) NOT NULL,
|
||||||
|
`order_id` VARCHAR(36) NOT NULL,
|
||||||
|
`report_status` VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
`report_time` DATETIME(3) NULL,
|
||||||
|
`official_no` VARCHAR(50) NULL,
|
||||||
|
`loc_code` VARCHAR(12) NOT NULL,
|
||||||
|
`nat_code_snapshot` JSON NULL,
|
||||||
|
`adapter_result` VARCHAR(200) NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
@ -591,3 +591,136 @@ model ElderSvc {
|
|||||||
@@index([tenantCode, elderId])
|
@@index([tenantCode, elderId])
|
||||||
@@map("ltc_elder_svc")
|
@@map("ltc_elder_svc")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 履约留痕(主)
|
||||||
|
model Record {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
orderId String @map("order_id") @db.VarChar(36)
|
||||||
|
elderId String @map("elder_id") @db.VarChar(36)
|
||||||
|
assigneeId String @map("assignee_id") @db.VarChar(36)
|
||||||
|
checkinTime DateTime? @map("checkin_time")
|
||||||
|
checkoutTime DateTime? @map("checkout_time")
|
||||||
|
durationMin Int? @map("duration_min")
|
||||||
|
checkinLat Decimal? @map("checkin_lat") @db.Decimal(10, 6)
|
||||||
|
checkinLng Decimal? @map("checkin_lng") @db.Decimal(10, 6)
|
||||||
|
locateStatus String @default("ok") @map("locate_status") @db.VarChar(20)
|
||||||
|
recordStatus String @default("serving") @map("record_status") @db.VarChar(20)
|
||||||
|
voiceFile String? @map("voice_file") @db.VarChar(200)
|
||||||
|
offlineFlag Boolean @default(false) @map("offline_flag")
|
||||||
|
clientMd5 String? @map("client_md5") @db.VarChar(32)
|
||||||
|
submitTime DateTime? @map("submit_time")
|
||||||
|
actualDurationTier String? @map("actual_duration_tier") @db.VarChar(10)
|
||||||
|
bizLine String @default("ltci") @map("biz_line") @db.VarChar(20)
|
||||||
|
isPrepaid Boolean @default(false) @map("is_prepaid")
|
||||||
|
billable Boolean @default(true) @map("billable")
|
||||||
|
sourcePrepaidOf String? @map("source_prepaid_of") @db.VarChar(36)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
createdBy String? @map("created_by") @db.VarChar(36)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
|
@@unique([tenantCode, clientMd5])
|
||||||
|
@@index([tenantCode, orderId])
|
||||||
|
@@index([tenantCode, elderId])
|
||||||
|
@@index([tenantCode, assigneeId, checkinTime])
|
||||||
|
@@map("ho_record")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 留痕项目明细
|
||||||
|
model RecordItem {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
recordId String @map("record_id") @db.VarChar(36)
|
||||||
|
svcId String @map("svc_id") @db.VarChar(36)
|
||||||
|
svcNameSnapshot String @map("svc_name_snapshot") @db.VarChar(100)
|
||||||
|
execResult String @map("exec_result") @db.VarChar(20)
|
||||||
|
undoReason String? @map("undo_reason") @db.VarChar(200)
|
||||||
|
extraFlag Boolean @default(false) @map("extra_flag")
|
||||||
|
photoRequired Boolean @default(false) @map("photo_required")
|
||||||
|
isRefusedPhoto Boolean @default(false) @map("is_refused_photo")
|
||||||
|
refuseReason String? @map("refuse_reason") @db.VarChar(200)
|
||||||
|
|
||||||
|
@@index([recordId])
|
||||||
|
@@map("ho_record_item")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 留痕照片
|
||||||
|
model RecordPhoto {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
recordId String @map("record_id") @db.VarChar(36)
|
||||||
|
svcId String? @map("svc_id") @db.VarChar(36)
|
||||||
|
filePath String @map("file_path") @db.VarChar(255)
|
||||||
|
fileSize Int @map("file_size")
|
||||||
|
visibleToFamily Boolean @default(false) @map("visible_to_family")
|
||||||
|
uploadBatch String? @map("upload_batch") @db.VarChar(36)
|
||||||
|
keepUntil DateTime? @map("keep_until")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([recordId])
|
||||||
|
@@map("ho_record_photo")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 异常上报
|
||||||
|
model RecordAbnormal {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
recordId String? @map("record_id") @db.VarChar(36)
|
||||||
|
orderId String? @map("order_id") @db.VarChar(36)
|
||||||
|
elderId String @map("elder_id") @db.VarChar(36)
|
||||||
|
reporterId String @map("reporter_id") @db.VarChar(36)
|
||||||
|
abnormalType String @map("abnormal_type") @db.VarChar(20)
|
||||||
|
abnormalDesc String? @map("abnormal_desc") @db.Text
|
||||||
|
voiceFile String? @map("voice_file") @db.VarChar(200)
|
||||||
|
hasPhoto Boolean @default(false) @map("has_photo")
|
||||||
|
visitTime DateTime? @map("visit_time")
|
||||||
|
visitLat Decimal? @map("visit_lat") @db.Decimal(10, 6)
|
||||||
|
visitLng Decimal? @map("visit_lng") @db.Decimal(10, 6)
|
||||||
|
notifyStatus String @default("pending") @map("notify_status") @db.VarChar(20)
|
||||||
|
handleStatus String @default("open") @map("handle_status") @db.VarChar(20)
|
||||||
|
handleResult String? @map("handle_result") @db.VarChar(200)
|
||||||
|
linkPlanChange Boolean @default(false) @map("link_plan_change")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
createdBy String? @map("created_by") @db.VarChar(36)
|
||||||
|
|
||||||
|
@@index([tenantCode, elderId])
|
||||||
|
@@index([tenantCode, notifyStatus])
|
||||||
|
@@map("ho_record_abnormal")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 结算表(二期预留,建空表不写业务数据)
|
||||||
|
model Settlement {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
settlePeriod String @map("settle_period") @db.VarChar(20)
|
||||||
|
amountTotal Decimal? @map("amount_total") @db.Decimal(12, 2)
|
||||||
|
amountPassed Decimal? @map("amount_passed") @db.Decimal(12, 2)
|
||||||
|
amountRejected Decimal? @map("amount_rejected") @db.Decimal(12, 2)
|
||||||
|
amountFund Decimal? @map("amount_fund") @db.Decimal(12, 2)
|
||||||
|
amountSelfPay Decimal? @map("amount_self_pay") @db.Decimal(12, 2)
|
||||||
|
submitDate DateTime? @map("submit_date") @db.Date
|
||||||
|
payDate DateTime? @map("pay_date") @db.Date
|
||||||
|
exceptionReason String? @map("exception_reason") @db.VarChar(200)
|
||||||
|
handleDeadline DateTime? @map("handle_deadline") @db.Date
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("settlement")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上报表(二期预留,建空表不写业务数据)
|
||||||
|
model Report {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
recordId String @map("record_id") @db.VarChar(36)
|
||||||
|
orderId String @map("order_id") @db.VarChar(36)
|
||||||
|
reportStatus String @default("pending") @map("report_status") @db.VarChar(20)
|
||||||
|
reportTime DateTime? @map("report_time")
|
||||||
|
officialNo String? @map("official_no") @db.VarChar(50)
|
||||||
|
locCode String @map("loc_code") @db.VarChar(12)
|
||||||
|
natCodeSnapshot Json? @map("nat_code_snapshot")
|
||||||
|
adapterResult String? @map("adapter_result") @db.VarChar(200)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("report")
|
||||||
|
}
|
||||||
|
|||||||
23
src/abnormal/abnormal.controller.ts
Normal file
23
src/abnormal/abnormal.controller.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
|
||||||
|
import { Permissions } from '../common/permissions.js';
|
||||||
|
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
||||||
|
import { ListAbnormalQuery, ReportAbnormalDto } from './dto.js';
|
||||||
|
import { AbnormalService } from './abnormal.service.js';
|
||||||
|
|
||||||
|
@Controller('abnormal')
|
||||||
|
export class AbnormalController {
|
||||||
|
constructor(private readonly service: AbnormalService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions(Permissions.abnormal.report)
|
||||||
|
@AuditLog('abnormal', 'report')
|
||||||
|
report(@Body() dto: ReportAbnormalDto) {
|
||||||
|
return this.service.report(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.abnormal.list)
|
||||||
|
list(@Query() q: ListAbnormalQuery) {
|
||||||
|
return this.service.list(q);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/abnormal/abnormal.module.ts
Normal file
9
src/abnormal/abnormal.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AbnormalController } from './abnormal.controller.js';
|
||||||
|
import { AbnormalService } from './abnormal.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AbnormalController],
|
||||||
|
providers: [AbnormalService],
|
||||||
|
})
|
||||||
|
export class AbnormalModule {}
|
||||||
265
src/abnormal/abnormal.service.spec.ts
Normal file
265
src/abnormal/abnormal.service.spec.ts
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AbnormalService } from './abnormal.service.js';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.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: {
|
||||||
|
recordAbnormalCreate?: ReturnType<typeof vi.fn>;
|
||||||
|
recordAbnormalFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
recordAbnormalCount?: ReturnType<typeof vi.fn>;
|
||||||
|
orderUpdate?: ReturnType<typeof vi.fn>;
|
||||||
|
orderFindUnique?: ReturnType<typeof vi.fn>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const recordAbnormalCreate = overrides.recordAbnormalCreate ?? vi.fn().mockResolvedValue({ id: 'a1' });
|
||||||
|
const recordAbnormalFindMany = overrides.recordAbnormalFindMany ?? vi.fn().mockResolvedValue([]);
|
||||||
|
const recordAbnormalCount = overrides.recordAbnormalCount ?? vi.fn().mockResolvedValue(0);
|
||||||
|
const orderUpdate = overrides.orderUpdate ?? vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'not_met' });
|
||||||
|
const orderFindUnique = overrides.orderFindUnique ?? vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'assigned' });
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
client: {
|
||||||
|
recordAbnormal: {
|
||||||
|
create: recordAbnormalCreate,
|
||||||
|
findMany: recordAbnormalFindMany,
|
||||||
|
count: recordAbnormalCount,
|
||||||
|
},
|
||||||
|
order: { update: orderUpdate, findUnique: orderFindUnique },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = new AbnormalService(prisma as unknown as PrismaService);
|
||||||
|
return { service, recordAbnormalCreate, recordAbnormalFindMany, recordAbnormalCount, orderUpdate, orderFindUnique };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AbnormalService', () => {
|
||||||
|
it('report 普通异常:只给分类+老人,其余可选 → 写 record_abnormal(pending/open),不转工单', async () => {
|
||||||
|
const { service, recordAbnormalCreate, orderUpdate } = makeService();
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () =>
|
||||||
|
service.report({ abnormalType: 'fall', elderId: 'e1' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recordAbnormalCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
tenantCode: 'T001',
|
||||||
|
elderId: 'e1',
|
||||||
|
reporterId: 'u1',
|
||||||
|
abnormalType: 'fall',
|
||||||
|
abnormalDesc: null,
|
||||||
|
voiceFile: null,
|
||||||
|
hasPhoto: false,
|
||||||
|
orderId: null,
|
||||||
|
recordId: null,
|
||||||
|
notifyStatus: 'pending',
|
||||||
|
handleStatus: 'open',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(orderUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(res).toEqual({ id: 'a1', visitLat: null, visitLng: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('report 扑空(not_met)→ 工单转 not_met + 写 record_abnormal(到访时间/定位/原因),不产生 ho_record', async () => {
|
||||||
|
const { service, recordAbnormalCreate, orderUpdate } = makeService();
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () =>
|
||||||
|
service.report({
|
||||||
|
abnormalType: 'not_met',
|
||||||
|
elderId: 'e1',
|
||||||
|
orderId: 'o1',
|
||||||
|
abnormalDesc: '老人不在家',
|
||||||
|
visitTime: '2026-10-15T09:30:00',
|
||||||
|
visitLat: 25.01,
|
||||||
|
visitLng: 102.7,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(orderUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'o1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
orderStatus: 'not_met',
|
||||||
|
notMetReason: '老人不在家',
|
||||||
|
notMetAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// 扑空落 record_abnormal(异常表),不落 ho_record(履约留痕主表)——本服务未触碰 record 模型。
|
||||||
|
expect(recordAbnormalCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
tenantCode: 'T001',
|
||||||
|
elderId: 'e1',
|
||||||
|
reporterId: 'u1',
|
||||||
|
abnormalType: 'not_met',
|
||||||
|
abnormalDesc: '老人不在家',
|
||||||
|
orderId: 'o1',
|
||||||
|
visitTime: expect.any(Date),
|
||||||
|
visitLat: 25.01,
|
||||||
|
visitLng: 102.7,
|
||||||
|
notifyStatus: 'pending',
|
||||||
|
handleStatus: 'open',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('report 扑空 reason 超 50 字 → not_met_reason 截断为 50,record_abnormal 保留全文', async () => {
|
||||||
|
const longReason = 'x'.repeat(80);
|
||||||
|
const { service, recordAbnormalCreate, orderUpdate } = makeService();
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () =>
|
||||||
|
service.report({
|
||||||
|
abnormalType: 'not_met',
|
||||||
|
elderId: 'e1',
|
||||||
|
orderId: 'o1',
|
||||||
|
abnormalDesc: longReason,
|
||||||
|
visitTime: '2026-10-15T09:30:00',
|
||||||
|
visitLat: 25.01,
|
||||||
|
visitLng: 102.7,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(orderUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'o1' },
|
||||||
|
data: expect.objectContaining({ notMetReason: 'x'.repeat(50) }),
|
||||||
|
});
|
||||||
|
expect(recordAbnormalCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ abnormalDesc: longReason }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('report 扑空返回 visitLat/visitLng 为字符串(Decimal 不泄漏)', async () => {
|
||||||
|
const recordAbnormalCreate = vi.fn().mockResolvedValue({
|
||||||
|
id: 'a1',
|
||||||
|
abnormalType: 'not_met',
|
||||||
|
visitLat: { toString: () => '25.010000' },
|
||||||
|
visitLng: { toString: () => '102.700000' },
|
||||||
|
});
|
||||||
|
const { service } = makeService({ recordAbnormalCreate });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () =>
|
||||||
|
service.report({
|
||||||
|
abnormalType: 'not_met',
|
||||||
|
elderId: 'e1',
|
||||||
|
orderId: 'o1',
|
||||||
|
abnormalDesc: '老人不在家',
|
||||||
|
visitTime: '2026-10-15T09:30:00',
|
||||||
|
visitLat: 25.01,
|
||||||
|
visitLng: 102.7,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res).toMatchObject({ id: 'a1', visitLat: '25.010000', visitLng: '102.700000' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('report 扑空缺定位 → 拒(3003),不写工单/异常', async () => {
|
||||||
|
const { service, recordAbnormalCreate, orderUpdate } = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () =>
|
||||||
|
service.report({
|
||||||
|
abnormalType: 'not_met',
|
||||||
|
elderId: 'e1',
|
||||||
|
orderId: 'o1',
|
||||||
|
abnormalDesc: '老人不在家',
|
||||||
|
visitTime: '2026-10-15T09:30:00',
|
||||||
|
visitLat: 25.01,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toThrowError('3003');
|
||||||
|
|
||||||
|
expect(orderUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(recordAbnormalCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('report 扑空缺原因 → 拒(3003),不写工单/异常', async () => {
|
||||||
|
const { service, recordAbnormalCreate, orderUpdate } = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () =>
|
||||||
|
service.report({
|
||||||
|
abnormalType: 'not_met',
|
||||||
|
elderId: 'e1',
|
||||||
|
orderId: 'o1',
|
||||||
|
visitTime: '2026-10-15T09:30:00',
|
||||||
|
visitLat: 25.01,
|
||||||
|
visitLng: 102.7,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toThrowError('3003');
|
||||||
|
|
||||||
|
expect(orderUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(recordAbnormalCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('report 扑空 order 不存在 → ORDER_NOT_FOUND(44003),不写工单/异常', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue(null);
|
||||||
|
const { service, recordAbnormalCreate, orderUpdate } = makeService({ orderFindUnique });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () =>
|
||||||
|
service.report({
|
||||||
|
abnormalType: 'not_met',
|
||||||
|
elderId: 'e1',
|
||||||
|
orderId: 'o-missing',
|
||||||
|
abnormalDesc: '老人不在家',
|
||||||
|
visitTime: '2026-10-15T09:30:00',
|
||||||
|
visitLat: 25.01,
|
||||||
|
visitLng: 102.7,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.ORDER_NOT_FOUND } });
|
||||||
|
|
||||||
|
expect(orderFindUnique).toHaveBeenCalledWith({ where: { id: 'o-missing' } });
|
||||||
|
expect(orderUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(recordAbnormalCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list 按 abnormal_type/notify_status/handle_status/老人 筛选', async () => {
|
||||||
|
const recordAbnormalFindMany = vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'a1', visitLat: { toString: () => '25.010000' }, visitLng: { toString: () => '102.700000' } },
|
||||||
|
{ id: 'a2', visitLat: null, visitLng: null },
|
||||||
|
]);
|
||||||
|
const recordAbnormalCount = vi.fn().mockResolvedValue(2);
|
||||||
|
const { service } = makeService({ recordAbnormalFindMany, recordAbnormalCount });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () =>
|
||||||
|
service.list({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
abnormalType: 'fall',
|
||||||
|
notifyStatus: 'pending',
|
||||||
|
handleStatus: 'open',
|
||||||
|
elderId: 'e1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const where = { abnormalType: 'fall', notifyStatus: 'pending', handleStatus: 'open', elderId: 'e1' };
|
||||||
|
expect(recordAbnormalFindMany).toHaveBeenCalledWith({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: 0,
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
expect(recordAbnormalCount).toHaveBeenCalledWith({ where });
|
||||||
|
// Decimal → string(null 保持 null)。
|
||||||
|
expect(res).toEqual({
|
||||||
|
list: [
|
||||||
|
{ id: 'a1', visitLat: '25.010000', visitLng: '102.700000' },
|
||||||
|
{ id: 'a2', visitLat: null, visitLng: null },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
125
src/abnormal/abnormal.service.ts
Normal file
125
src/abnormal/abnormal.service.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
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 { ListAbnormalQuery, NOT_MET, ReportAbnormalDto } from './dto.js';
|
||||||
|
|
||||||
|
/** ISO datetime 字符串 → Date。业务时区为北京时间(UTC+8),与 order.service 口径一致。 */
|
||||||
|
function toDateTime(v?: string): Date | undefined {
|
||||||
|
if (!v) return undefined;
|
||||||
|
// 无偏移的 YYYY-MM-DDTHH:mm:ss 显式按 +08:00 解析,保证跨时区部署确定性。
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(v)) {
|
||||||
|
return new Date(`${v}+08:00`);
|
||||||
|
}
|
||||||
|
return new Date(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异常上报(record_abnormal)业务服务。
|
||||||
|
*
|
||||||
|
* - report:仅 abnormal_type 必选,其余可选;写 record_abnormal(notify_status=pending、handle_status=open)。
|
||||||
|
* - 扑空分支(abnormal_type=not_met):到访时间/定位/原因/关联工单必留(3003)→ 工单转
|
||||||
|
* not_met(not_met_reason=原因、not_met_at=到访时间)→ 定位/到访时间落 record_abnormal;
|
||||||
|
* 不产生 ho_record(履约留痕主表)、不计费。
|
||||||
|
* - list:按 abnormal_type/notify_status/handle_status/老人筛选。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AbnormalService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** 上报异常(普通 9 宫格或扑空专门形态)。 */
|
||||||
|
async report(dto: ReportAbnormalDto) {
|
||||||
|
const ctx = getRequestContext();
|
||||||
|
const tenantCode = ctx?.tenantCode ?? '';
|
||||||
|
const reporterId = ctx?.userId ?? '';
|
||||||
|
const createdBy = ctx?.userId ?? null;
|
||||||
|
|
||||||
|
if (dto.abnormalType === NOT_MET) {
|
||||||
|
this.assertNotMetEvidence(dto);
|
||||||
|
// 扑空:工单须存在,再转 not_met(原因 + 到访时间)。
|
||||||
|
await this.requireOrder(dto.orderId!);
|
||||||
|
await this.prisma.client.order.update({
|
||||||
|
where: { id: dto.orderId! },
|
||||||
|
data: {
|
||||||
|
orderStatus: 'not_met',
|
||||||
|
// not_met_reason 为 VarChar(50),abnormalDesc 为 Text,须截断避免超长 500。
|
||||||
|
notMetReason: (dto.abnormalDesc ?? '').slice(0, 50),
|
||||||
|
notMetAt: toDateTime(dto.visitTime) ?? new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.prisma.client.recordAbnormal.create({
|
||||||
|
data: {
|
||||||
|
tenantCode,
|
||||||
|
elderId: dto.elderId,
|
||||||
|
reporterId,
|
||||||
|
createdBy,
|
||||||
|
abnormalType: dto.abnormalType,
|
||||||
|
abnormalDesc: dto.abnormalDesc ?? null,
|
||||||
|
voiceFile: dto.voiceFile ?? null,
|
||||||
|
hasPhoto: dto.hasPhoto ?? false,
|
||||||
|
orderId: dto.orderId ?? null,
|
||||||
|
recordId: dto.recordId ?? null,
|
||||||
|
visitTime: toDateTime(dto.visitTime) ?? null,
|
||||||
|
visitLat: dto.visitLat ?? null,
|
||||||
|
visitLng: dto.visitLng ?? null,
|
||||||
|
notifyStatus: 'pending',
|
||||||
|
handleStatus: 'open',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Prisma Decimal(visit_lat/visit_lng)转字符串,避免经 TransformInterceptor 泄漏为 {s,e,d}。
|
||||||
|
return {
|
||||||
|
...created,
|
||||||
|
visitLat: created.visitLat != null ? created.visitLat.toString() : null,
|
||||||
|
visitLng: created.visitLng != null ? created.visitLng.toString() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异常列表(后台 B4)。 */
|
||||||
|
async list(q: ListAbnormalQuery) {
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (q.abnormalType) where.abnormalType = q.abnormalType;
|
||||||
|
if (q.notifyStatus) where.notifyStatus = q.notifyStatus;
|
||||||
|
if (q.handleStatus) where.handleStatus = q.handleStatus;
|
||||||
|
if (q.elderId) where.elderId = q.elderId;
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
this.prisma.client.recordAbnormal.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (q.page - 1) * q.pageSize,
|
||||||
|
take: q.pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.client.recordAbnormal.count({ where }),
|
||||||
|
]);
|
||||||
|
// Prisma Decimal(visit_lat/visit_lng)经 TransformInterceptor 不会转字符串,会泄漏为 {s,e,d};显式序列化。
|
||||||
|
return {
|
||||||
|
list: list.map((r) => ({
|
||||||
|
...r,
|
||||||
|
visitLat: r.visitLat != null ? r.visitLat.toString() : null,
|
||||||
|
visitLng: r.visitLng != null ? r.visitLng.toString() : null,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page: q.page,
|
||||||
|
pageSize: q.pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扑空证据必留:到访时间、定位(经纬)、原因、关联工单(3003)。 */
|
||||||
|
private assertNotMetEvidence(dto: ReportAbnormalDto) {
|
||||||
|
if (!dto.orderId || !dto.visitTime || dto.visitLat == null || dto.visitLng == null || !dto.abnormalDesc) {
|
||||||
|
throw new BadRequestException('扑空须填写到访时间、定位、原因与关联工单(3003)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工单须存在(扑空转 not_met 前校验,44003),循 order.service 的 requireOrder。 */
|
||||||
|
private async requireOrder(id: string): Promise<Record<string, unknown>> {
|
||||||
|
const order = await this.prisma.client.order.findUnique({ where: { id } });
|
||||||
|
if (!order) {
|
||||||
|
throw new BusinessException(ErrorCodes.ORDER_NOT_FOUND, '工单不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/abnormal/dto.ts
Normal file
110
src/abnormal/dto.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { PageQuery } from '../common/page.dto.js';
|
||||||
|
|
||||||
|
/** 异常分类(9 宫格 + 未遇/扑空),唯一来源见 tenant-template DICTS abnormal_type。 */
|
||||||
|
export const ABNORMAL_TYPES = [
|
||||||
|
'pressure_sore',
|
||||||
|
'choking',
|
||||||
|
'fall',
|
||||||
|
'fever',
|
||||||
|
'skin_break',
|
||||||
|
'mood_abnormal',
|
||||||
|
'eat_difficulty',
|
||||||
|
'excretion_abnormal',
|
||||||
|
'other',
|
||||||
|
'not_met',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 扑空专门形态:唯一异常分类值,触发工单转 not_met。 */
|
||||||
|
export const NOT_MET = 'not_met';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异常上报入参。字段字典以 shared/data-model 为准(ho_record_abnormal)。
|
||||||
|
* - 仅 abnormal_type 必选;desc/voice/photo/关联(record_id/order_id)可选;
|
||||||
|
* - elder_id 由前端自动带入(当前任务对象),服务端不再推断;
|
||||||
|
* - tenantCode/reporterId/createdBy/notifyStatus/handleStatus 由服务端注入。
|
||||||
|
*/
|
||||||
|
export class ReportAbnormalDto {
|
||||||
|
@ApiProperty({ description: '异常分类(9 宫格 + not_met 扑空)', enum: ABNORMAL_TYPES })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsIn(ABNORMAL_TYPES)
|
||||||
|
abnormalType!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '老人 ID(自动带入)' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
elderId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '异常描述(扑空时必填)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
abnormalDesc?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '语音文件 MinIO key' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
voiceFile?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '是否附带照片' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
hasPhoto?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '关联留痕记录 ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
recordId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '关联工单 ID(扑空必填)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
orderId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '到访时间(ISO datetime,扑空必填)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
visitTime?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '到访纬度(扑空必填)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
visitLat?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '到访经度(扑空必填)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
visitLng?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异常列表查询(后台 B4),按分类/通知状态/处理状态/老人筛选。 */
|
||||||
|
export class ListAbnormalQuery extends PageQuery {
|
||||||
|
@ApiPropertyOptional({ description: '异常分类' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
abnormalType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '通知状态(pending/notified/...)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notifyStatus?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '处理状态(open/handling/closed/...)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
handleStatus?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '老人 ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
elderId?: string;
|
||||||
|
}
|
||||||
@ -33,6 +33,8 @@ import { ElderModule } from './elder/elder.module.js';
|
|||||||
import { PlanModule } from './plan/plan.module.js';
|
import { PlanModule } from './plan/plan.module.js';
|
||||||
import { ElderSvcModule } from './elder-svc/elder-svc.module.js';
|
import { ElderSvcModule } from './elder-svc/elder-svc.module.js';
|
||||||
import { OrderModule } from './order/order.module.js';
|
import { OrderModule } from './order/order.module.js';
|
||||||
|
import { RecordModule } from './record/record.module.js';
|
||||||
|
import { AbnormalModule } from './abnormal/abnormal.module.js';
|
||||||
import { AppController } from './app.controller.js';
|
import { AppController } from './app.controller.js';
|
||||||
import { AppService } from './app.service.js';
|
import { AppService } from './app.service.js';
|
||||||
|
|
||||||
@ -63,6 +65,8 @@ import { AppService } from './app.service.js';
|
|||||||
PlanModule,
|
PlanModule,
|
||||||
ElderSvcModule,
|
ElderSvcModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
|
RecordModule,
|
||||||
|
AbnormalModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -50,6 +50,14 @@ export const ErrorCodes = {
|
|||||||
ORDER_NOT_FOUND: 44003,
|
ORDER_NOT_FOUND: 44003,
|
||||||
ORDER_CONFLICT: 44004,
|
ORDER_CONFLICT: 44004,
|
||||||
ORDER_STATUS_INVALID: 44005,
|
ORDER_STATUS_INVALID: 44005,
|
||||||
|
// 服务机构端·履约留痕/异常(45)
|
||||||
|
RECORD_NOT_FOUND: 45001,
|
||||||
|
RECORD_ORDER_INVALID: 45002,
|
||||||
|
RECORD_STATUS_INVALID: 45003,
|
||||||
|
RECORD_PHOTO_REQUIRED: 45004, // 必拍未拍(3001)
|
||||||
|
RECORD_MD5_DUP: 45005,
|
||||||
|
ABNORMAL_NOT_FOUND: 45006,
|
||||||
|
PREPAID_CARRYOVER_INVALID: 45007,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
||||||
|
|||||||
@ -68,7 +68,11 @@ export const Permissions = {
|
|||||||
elder: { list: 'elder:list', detail: 'elder:detail', create: 'elder:create', update: 'elder:update', statusChange: 'elder:status-change', import: 'elder:import', svcSave: 'elder:svc-save' },
|
elder: { list: 'elder:list', detail: 'elder:detail', create: 'elder:create', update: 'elder:update', statusChange: 'elder:status-change', import: 'elder:import', svcSave: 'elder:svc-save' },
|
||||||
plan: { create: 'plan:create', change: 'plan:change', detail: 'plan:detail', confirm: 'plan:confirm' },
|
plan: { create: 'plan:create', change: 'plan:change', detail: 'plan:detail', confirm: 'plan:confirm' },
|
||||||
order: { generate: 'order:generate', assign: 'order:assign', reassign: 'order:reassign', cancel: 'order:cancel', detail: 'order:detail', today: 'order:today' },
|
order: { generate: 'order:generate', assign: 'order:assign', reassign: 'order:reassign', cancel: 'order:cancel', detail: 'order:detail', today: 'order:today' },
|
||||||
record: { checkin: 'record:checkin', checkout: 'record:checkout', itemSave: 'record:item-save', photoUpload: 'record:photo-upload', detail: 'record:detail' },
|
record: {
|
||||||
|
checkin: 'record:checkin', itemSave: 'record:item-save', photoUpload: 'record:photo-upload',
|
||||||
|
checkout: 'record:checkout', offlineSync: 'record:offline-sync', detail: 'record:detail',
|
||||||
|
list: 'record:list', prepaidList: 'record:prepaid-list', prepaidCarryover: 'record:prepaid-carryover',
|
||||||
|
},
|
||||||
abnormal: { report: 'abnormal:report', list: 'abnormal:list', handle: 'abnormal:handle' },
|
abnormal: { report: 'abnormal:report', list: 'abnormal:list', handle: 'abnormal:handle' },
|
||||||
qc: { pendingList: 'qc:pending-list', detail: 'qc:detail', pass: 'qc:pass', return: 'qc:return', exceptionLink: 'qc:exception-link' },
|
qc: { pendingList: 'qc:pending-list', detail: 'qc:detail', pass: 'qc:pass', return: 'qc:return', exceptionLink: 'qc:exception-link' },
|
||||||
export: { export: 'export:export' },
|
export: { export: 'export:export' },
|
||||||
|
|||||||
@ -110,6 +110,18 @@ export const APIS: { code: string; name: string; method: string; path: string; p
|
|||||||
{ code: Permissions.order.detail, name: '工单详情', method: 'GET', path: '/api/v1/order/:id', parent: 'schedule' },
|
{ code: Permissions.order.detail, name: '工单详情', method: 'GET', path: '/api/v1/order/:id', parent: 'schedule' },
|
||||||
{ code: Permissions.order.today, name: '今日任务', method: 'GET', path: '/api/v1/order/today', parent: 'schedule' },
|
{ code: Permissions.order.today, name: '今日任务', method: 'GET', path: '/api/v1/order/today', parent: 'schedule' },
|
||||||
{ code: Permissions.elder.svcSave, name: '个人项目库', method: 'PUT', path: '/api/v1/elder-svc/:elderId', parent: 'elder' },
|
{ code: Permissions.elder.svcSave, name: '个人项目库', method: 'PUT', path: '/api/v1/elder-svc/:elderId', parent: 'elder' },
|
||||||
|
// 服务机构端·履约留痕/异常(C3)
|
||||||
|
{ code: Permissions.record.checkin, name: '签到', method: 'POST', path: '/api/v1/record/checkin', parent: 'record' },
|
||||||
|
{ code: Permissions.record.itemSave, name: '保存项目', method: 'POST', path: '/api/v1/record/item/save', parent: 'record' },
|
||||||
|
{ code: Permissions.record.photoUpload, name: '上传照片', method: 'POST', path: '/api/v1/record/photo/upload', parent: 'record' },
|
||||||
|
{ code: Permissions.record.checkout, name: '签退', method: 'POST', path: '/api/v1/record/checkout', parent: 'record' },
|
||||||
|
{ code: Permissions.record.offlineSync, name: '离线补传', method: 'POST', path: '/api/v1/record/offline/sync', parent: 'record' },
|
||||||
|
{ code: Permissions.record.detail, name: '留痕详情', method: 'GET', path: '/api/v1/record/:id', parent: 'record' },
|
||||||
|
{ code: Permissions.record.list, name: '留痕列表', method: 'GET', path: '/api/v1/record', parent: 'record' },
|
||||||
|
{ code: Permissions.record.prepaidList, name: '垫付台账', method: 'GET', path: '/api/v1/record/prepaid-list', parent: 'record' },
|
||||||
|
{ code: Permissions.record.prepaidCarryover, name: '结转', method: 'POST', path: '/api/v1/record/prepaid/carryover', parent: 'record' },
|
||||||
|
{ code: Permissions.abnormal.report, name: '异常上报', method: 'POST', path: '/api/v1/abnormal', parent: 'record' },
|
||||||
|
{ code: Permissions.abnormal.list, name: '异常列表', method: 'GET', path: '/api/v1/abnormal', parent: 'record' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
|
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
|
||||||
@ -191,6 +203,7 @@ export const DICTS: { code: string; name: string; items: { label: string; value:
|
|||||||
{ label: '进食困难', value: 'eat_difficulty', colorType: 'warning', sort: 7 },
|
{ label: '进食困难', value: 'eat_difficulty', colorType: 'warning', sort: 7 },
|
||||||
{ label: '排泄异常', value: 'excretion_abnormal', colorType: 'info', sort: 8 },
|
{ label: '排泄异常', value: 'excretion_abnormal', colorType: 'info', sort: 8 },
|
||||||
{ label: '其他', value: 'other', colorType: 'info', sort: 9 },
|
{ label: '其他', value: 'other', colorType: 'info', sort: 9 },
|
||||||
|
{ label: '未遇(扑空)', value: 'not_met', colorType: 'info', sort: 10 },
|
||||||
] },
|
] },
|
||||||
{ code: 'photo_tier', name: '拍照分档', items: [
|
{ code: 'photo_tier', name: '拍照分档', items: [
|
||||||
{ label: '必拍', value: 'must', colorType: 'danger', sort: 1 },
|
{ label: '必拍', value: 'must', colorType: 'danger', sort: 1 },
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { getRequestContext } from '../common/context/request-context.js';
|
|||||||
/** 不带 tenantCode 的模型(租户表自身)。 */
|
/** 不带 tenantCode 的模型(租户表自身)。 */
|
||||||
const TENANT_FREE = new Set(['Tenant', 'Agency', 'SvcNational', 'SvcLocal', 'SvcMapping', 'LocConfig']);
|
const TENANT_FREE = new Set(['Tenant', 'Agency', 'SvcNational', 'SvcLocal', 'SvcMapping', 'LocConfig']);
|
||||||
/** 软删模型:读操作统一注入 deletedAt IS NULL。 */
|
/** 软删模型:读操作统一注入 deletedAt IS NULL。 */
|
||||||
const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config', 'Notice', 'Staff', 'SvcOrg', 'Elder', 'Plan', 'Order', 'ElderSvc']);
|
const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config', 'Notice', 'Staff', 'SvcOrg', 'Elder', 'Plan', 'Order', 'ElderSvc', 'Record']);
|
||||||
const READ_OPS = new Set(['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy']);
|
const READ_OPS = new Set(['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy']);
|
||||||
/** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */
|
/** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */
|
||||||
const NO_WHERE_OPS = new Set(['create', 'createMany']);
|
const NO_WHERE_OPS = new Set(['create', 'createMany']);
|
||||||
|
|||||||
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 {}
|
||||||
750
src/record/record.service.spec.ts
Normal file
750
src/record/record.service.spec.ts
Normal file
@ -0,0 +1,750 @@
|
|||||||
|
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>;
|
||||||
|
elderFindUnique?: 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 elderFindUnique = overrides.elderFindUnique ?? 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, findUnique: elderFindUnique },
|
||||||
|
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,
|
||||||
|
elderFindUnique,
|
||||||
|
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).toMatchObject({ 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' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkin 返回坐标序列化为字符串(F3)', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue(servingOrder);
|
||||||
|
const staffFindFirst = vi.fn().mockResolvedValue({ id: 'staff1', userId: 'u1' });
|
||||||
|
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' });
|
||||||
|
const recordCreate = vi.fn().mockResolvedValue({
|
||||||
|
id: 'r1',
|
||||||
|
checkinLat: { toString: () => '25.010000' },
|
||||||
|
checkinLng: { toString: () => '102.700000' },
|
||||||
|
});
|
||||||
|
const { service } = makeService({ orderFindUnique, staffFindFirst, elderFindFirst, recordCreate });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () => service.checkin({ orderId: 'o1' }));
|
||||||
|
expect(res.checkinLat).toBe('25.010000');
|
||||||
|
expect(res.checkinLng).toBe('102.700000');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecordService.itemSave', () => {
|
||||||
|
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 orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', itemList: [{ svcId: 'svc1', photoTier: 'free' }] });
|
||||||
|
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,
|
||||||
|
orderFindUnique,
|
||||||
|
svcOrgFindFirst,
|
||||||
|
svcNationalFindFirst,
|
||||||
|
recordItemCreate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () =>
|
||||||
|
service.itemSave({
|
||||||
|
recordId: 'r1',
|
||||||
|
items: [
|
||||||
|
{ svcId: 'svc1', execResult: 'done' },
|
||||||
|
{ 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: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('photoRequired 从 order.itemList 推导:photoTier=must 自动置 required,DTO 传 false 被忽略', async () => {
|
||||||
|
const recordFindUnique = vi.fn().mockResolvedValue(servingRecord);
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', itemList: [{ svcId: 'svc1', photoTier: 'must' }] });
|
||||||
|
const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'svc1', srcType: 'national', srcId: 'nat1' });
|
||||||
|
const svcNationalFindFirst = vi.fn().mockResolvedValue({ natName: '协助翻身' });
|
||||||
|
const recordItemCreate = vi.fn().mockResolvedValue({ id: 'ri1' });
|
||||||
|
const { service } = makeService({
|
||||||
|
recordFindUnique,
|
||||||
|
orderFindUnique,
|
||||||
|
svcOrgFindFirst,
|
||||||
|
svcNationalFindFirst,
|
||||||
|
recordItemCreate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () =>
|
||||||
|
service.itemSave({ recordId: 'r1', items: [{ svcId: 'svc1', execResult: 'done', photoRequired: false }] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recordItemCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ svcId: 'svc1', photoRequired: true }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extraFlag 临时加项回退 DTO photoRequired', async () => {
|
||||||
|
const recordFindUnique = vi.fn().mockResolvedValue(servingRecord);
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', itemList: [] });
|
||||||
|
const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'svc-extra', srcType: 'national', srcId: 'nat1' });
|
||||||
|
const svcNationalFindFirst = vi.fn().mockResolvedValue({ natName: '临时加项' });
|
||||||
|
const recordItemCreate = vi.fn().mockResolvedValue({ id: 'ri1' });
|
||||||
|
const { service } = makeService({
|
||||||
|
recordFindUnique,
|
||||||
|
orderFindUnique,
|
||||||
|
svcOrgFindFirst,
|
||||||
|
svcNationalFindFirst,
|
||||||
|
recordItemCreate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () =>
|
||||||
|
service.itemSave({
|
||||||
|
recordId: 'r1',
|
||||||
|
items: [{ svcId: 'svc-extra', execResult: 'done', extraFlag: true, photoRequired: true }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recordItemCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ svcId: 'svc-extra', photoRequired: true, extraFlag: true }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('计划内 must 项 + 客户端传 extraFlag=true/photoRequired=false → 仍存 photoRequired=true(不可绕过)', async () => {
|
||||||
|
const recordFindUnique = vi.fn().mockResolvedValue(servingRecord);
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', itemList: [{ svcId: 'svc1', photoTier: 'must' }] });
|
||||||
|
const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'svc1', srcType: 'national', srcId: 'nat1' });
|
||||||
|
const svcNationalFindFirst = vi.fn().mockResolvedValue({ natName: '协助翻身' });
|
||||||
|
const recordItemCreate = vi.fn().mockResolvedValue({ id: 'ri1' });
|
||||||
|
const { service } = makeService({
|
||||||
|
recordFindUnique,
|
||||||
|
orderFindUnique,
|
||||||
|
svcOrgFindFirst,
|
||||||
|
svcNationalFindFirst,
|
||||||
|
recordItemCreate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () =>
|
||||||
|
service.itemSave({
|
||||||
|
recordId: 'r1',
|
||||||
|
items: [{ svcId: 'svc1', execResult: 'done', extraFlag: true, photoRequired: false }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recordItemCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ svcId: 'svc1', photoRequired: true }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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', checkinLat: { toString: () => '25.010000' } };
|
||||||
|
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).toMatchObject({ id: 'r-exist', clientMd5: 'md5-1' });
|
||||||
|
expect(res.checkinLat).toBe('25.010000');
|
||||||
|
expect(recordCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未派单工单(assigneeId 为 null)→ RECORD_ORDER_INVALID', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'to_assign', elderId: 'e1', assigneeId: null });
|
||||||
|
const { service, recordCreate } = makeService({ orderFindUnique });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () =>
|
||||||
|
service.offlineSync({ clientMd5: 'md5-3', orderId: 'o1', elderId: 'e1', assigneeId: 'staff1', checkinTime: '2026-10-15T02:00:00Z' }),
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.RECORD_ORDER_INVALID } });
|
||||||
|
|
||||||
|
expect(recordCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('not_met(扑空)工单 → RECORD_ORDER_INVALID,不产生 record', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'not_met', elderId: 'e1', assigneeId: 'staff1' });
|
||||||
|
const { service, recordCreate } = makeService({ orderFindUnique });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () =>
|
||||||
|
service.offlineSync({ clientMd5: 'md5-4', orderId: 'o1', elderId: 'e1', assigneeId: 'staff1', checkinTime: '2026-10-15T02:00:00Z' }),
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.RECORD_ORDER_INVALID } });
|
||||||
|
|
||||||
|
expect(recordCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('离线新建完整 record(elder/assignee 以 order 为准 + offline_flag + is_prepaid + items/photos 一次性)', async () => {
|
||||||
|
const orderFindUnique = vi.fn().mockResolvedValue({
|
||||||
|
id: 'o1',
|
||||||
|
orderStatus: 'done',
|
||||||
|
elderId: 'e1',
|
||||||
|
assigneeId: 'staff1',
|
||||||
|
itemList: [{ svcId: 'svc1', photoTier: 'must' }],
|
||||||
|
});
|
||||||
|
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: 'e-attacker',
|
||||||
|
assigneeId: 'staff-attacker',
|
||||||
|
checkinTime: '2026-10-15T02:00:00Z',
|
||||||
|
checkoutTime: '2026-10-15T03:00:00Z',
|
||||||
|
items: [{ svcId: 'svc1', execResult: 'done', photoRequired: false }],
|
||||||
|
photos: [{ svcId: 'svc1', filePath: 'record/T001/a.jpg', fileSize: 100 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recordCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
elderId: 'e1',
|
||||||
|
assigneeId: 'staff1',
|
||||||
|
offlineFlag: true,
|
||||||
|
clientMd5: 'md5-2',
|
||||||
|
isPrepaid: true,
|
||||||
|
billable: false,
|
||||||
|
recordStatus: 'submitted',
|
||||||
|
durationMin: 60,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(recordItemCreate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(recordItemCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ svcId: 'svc1', photoRequired: true }),
|
||||||
|
});
|
||||||
|
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 elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' });
|
||||||
|
const { service, recordItemCreate, recordPhotoCreate, recordUpdate } = makeService({
|
||||||
|
recordFindMany,
|
||||||
|
recordItemFindMany,
|
||||||
|
recordPhotoFindMany,
|
||||||
|
recordCreate,
|
||||||
|
elderFindUnique,
|
||||||
|
});
|
||||||
|
|
||||||
|
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).toMatchObject([{ 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('R9 门控:elder 仍 waiting_period → PREPAID_CARRYOVER_INVALID', async () => {
|
||||||
|
const recordFindMany = vi.fn().mockResolvedValue([original]);
|
||||||
|
const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'waiting_period' });
|
||||||
|
const { service, recordCreate } = makeService({ recordFindMany, elderFindUnique });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.prepaidCarryover({ recordIds: ['r1'] })),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.PREPAID_CARRYOVER_INVALID } });
|
||||||
|
expect(recordCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('R10 幂等:已结转的垫付记录 → PREPAID_CARRYOVER_INVALID', async () => {
|
||||||
|
const recordFindMany = vi.fn().mockResolvedValue([original]);
|
||||||
|
const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'active' });
|
||||||
|
const recordFindFirst = vi.fn().mockResolvedValue({ id: 'formal1', sourcePrepaidOf: 'r1' });
|
||||||
|
const { service, recordCreate } = makeService({ recordFindMany, elderFindUnique, recordFindFirst });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.prepaidCarryover({ recordIds: ['r1'] })),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.PREPAID_CARRYOVER_INVALID } });
|
||||||
|
expect(recordCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecordService.detail / list / prepaidList', () => {
|
||||||
|
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('detail/list 将 Decimal 坐标序列化为字符串(null 保持 null)', async () => {
|
||||||
|
const recordFindUnique = vi.fn().mockResolvedValue({
|
||||||
|
id: 'r1',
|
||||||
|
recordStatus: 'submitted',
|
||||||
|
checkinLat: { toString: () => '25.010000' },
|
||||||
|
checkinLng: null,
|
||||||
|
});
|
||||||
|
const recordFindMany = vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'r2', checkinLat: { toString: () => '26.500000' }, checkinLng: { toString: () => '103.100000' } },
|
||||||
|
]);
|
||||||
|
const { service } = makeService({ recordFindUnique, recordFindMany });
|
||||||
|
|
||||||
|
const detail = await runWithRequestContext(ctx, () => service.detail('r1'));
|
||||||
|
expect(detail.checkinLat).toBe('25.010000');
|
||||||
|
expect(detail.checkinLng).toBeNull();
|
||||||
|
|
||||||
|
const list = await runWithRequestContext(ctx, () => service.list({}));
|
||||||
|
expect(list[0].checkinLat).toBe('26.500000');
|
||||||
|
expect(list[0].checkinLng).toBe('103.100000');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepaidList 排除已结转的垫付记录(R10)', async () => {
|
||||||
|
const recordFindMany = vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'r1', isPrepaid: true },
|
||||||
|
{ id: 'r2', isPrepaid: true },
|
||||||
|
]);
|
||||||
|
const recordFindFirst = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 'formal1', sourcePrepaidOf: 'r1' })
|
||||||
|
.mockResolvedValueOnce(null);
|
||||||
|
const { service } = makeService({ recordFindMany, recordFindFirst });
|
||||||
|
|
||||||
|
const res = await runWithRequestContext(ctx, () => service.prepaidList({ elderId: 'e1' }));
|
||||||
|
|
||||||
|
expect(recordFindFirst).toHaveBeenCalledTimes(2);
|
||||||
|
expect(res).toHaveLength(1);
|
||||||
|
expect(res[0].id).toBe('r2');
|
||||||
|
});
|
||||||
|
});
|
||||||
466
src/record/record.service.ts
Normal file
466
src/record/record.service.ts
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
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';
|
||||||
|
const record = await 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.serializeRecord(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存项目执行:整表覆盖(先删后写)。 */
|
||||||
|
async itemSave(dto: ItemSaveDto) {
|
||||||
|
const ctx = getRequestContext()!;
|
||||||
|
const record = await this.requireServingRecord(dto.recordId);
|
||||||
|
// photo_required 必须从 order.item_list 推导(不可信 DTO),故先取 order。
|
||||||
|
const order = await this.prisma.client.order.findUnique({ where: { id: record.orderId } });
|
||||||
|
// 先全量校验(exec_result 枚举 + L4)并解析快照,任一失败不落库。
|
||||||
|
const resolved = await this.resolveItems(dto.items, order);
|
||||||
|
|
||||||
|
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;
|
||||||
|
const updated = await this.prisma.client.record.update({
|
||||||
|
where: { id: record.id },
|
||||||
|
data: {
|
||||||
|
checkoutTime,
|
||||||
|
durationMin,
|
||||||
|
recordStatus: 'submitted',
|
||||||
|
submitTime: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.serializeRecord(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 离线补传: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 this.serializeRecord(existing); // 幂等:静默返回已存在
|
||||||
|
}
|
||||||
|
const order = await this.prisma.client.order.findUnique({ where: { id: dto.orderId } });
|
||||||
|
if (!order) {
|
||||||
|
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
// L3:扑空/取消工单不产生 record(扑空不产生留痕)。
|
||||||
|
if (order.orderStatus === 'cancelled' || order.orderStatus === 'not_met') {
|
||||||
|
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单已取消/扑空,不产生留痕', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if (!order.assigneeId) {
|
||||||
|
throw new BusinessException(ErrorCodes.RECORD_ORDER_INVALID, '工单未派单,无法留痕', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
// elderId/assigneeId 以 order 为准(忽略 DTO 的这两个字段,防 order/elder/assignee 不匹配)。
|
||||||
|
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';
|
||||||
|
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: order.id,
|
||||||
|
elderId: order.elderId,
|
||||||
|
assigneeId: order.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 ?? [], order);
|
||||||
|
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 this.serializeRecord(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 this.serializeRecord({ ...record, items, photos, abnormals });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表:按老人/护理员/日期/状态筛选。 */
|
||||||
|
async 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`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const rows = await this.prisma.client.record.findMany({ where, orderBy: { checkinTime: 'desc' } });
|
||||||
|
return rows.map((r) => this.serializeRecord(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 垫付台账:按老人返回 is_prepaid=1 的记录。 */
|
||||||
|
async prepaidList(q: PrepaidListQuery) {
|
||||||
|
const rows = await this.prisma.client.record.findMany({ where: { elderId: q.elderId, isPrepaid: true } });
|
||||||
|
// R10:排除已结转的垫付记录(存在 formal record.source_prepaid_of 指向它)。
|
||||||
|
const result = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const formal = await this.prisma.client.record.findFirst({ where: { sourcePrepaidOf: row.id } });
|
||||||
|
if (!formal) result.push(row);
|
||||||
|
}
|
||||||
|
return result.map((r) => this.serializeRecord(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结转:复制垫付 record → 正式 record(is_prepaid=0 + source_prepaid_of),原记录不改。 */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// R9 门控:结转仅在待遇转「正常享受」后可用。
|
||||||
|
const elder = await this.prisma.client.elder.findUnique({ where: { id: [...elderIds][0] } });
|
||||||
|
if (!elder || elder.treatStatus !== 'active') {
|
||||||
|
throw new BusinessException(ErrorCodes.PREPAID_CARRYOVER_INVALID, '待遇未转正常享受,不可结转', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
// R10 幂等:已结转的垫付记录不可重复结转。
|
||||||
|
for (const rec of records) {
|
||||||
|
const formal = await this.prisma.client.record.findFirst({ where: { sourcePrepaidOf: rec.id } });
|
||||||
|
if (formal) {
|
||||||
|
throw new BusinessException(ErrorCodes.PREPAID_CARRYOVER_INVALID, '该垫付记录已结转', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = [];
|
||||||
|
for (const rec of records) {
|
||||||
|
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(this.serializeRecord(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prisma Decimal 经 TransformInterceptor 不会转,泄漏为 {s,e,d};返回前把坐标转字符串(null 保持 null)。 */
|
||||||
|
private serializeRecord(record: Record<string, any>): Record<string, any> {
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
checkinLat: record.checkinLat != null ? record.checkinLat.toString() : null,
|
||||||
|
checkinLng: record.checkinLng != null ? record.checkinLng.toString() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验 exec_result 枚举 + svc_id 落本机构 L4,并解析快照;photo_required 从 order.item_list 推导。 */
|
||||||
|
private async resolveItems(items: RecordItemDto[], order?: Record<string, any> | null) {
|
||||||
|
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: this.resolvePhotoRequired(item, order),
|
||||||
|
isRefusedPhoto: item.isRefusedPhoto,
|
||||||
|
refuseReason: item.refuseReason,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 必拍档来源是 order.item_list 的 photo_tier(快照自 plan_item),不可信客户端 DTO 的 photo_required/extraFlag。
|
||||||
|
* 先按 svcId 在 item_list 找匹配项:命中则 photoRequired = (photoTier === 'must');
|
||||||
|
* 仅 item_list 无对应项(真正的临时加项)才回退 DTO 值。
|
||||||
|
*/
|
||||||
|
private resolvePhotoRequired(item: RecordItemDto, order?: Record<string, any> | null): boolean {
|
||||||
|
const itemList = Array.isArray(order?.itemList) ? order.itemList : [];
|
||||||
|
const entry = itemList.find((i: Record<string, any>) => i.svcId === item.svcId);
|
||||||
|
if (entry) {
|
||||||
|
return entry.photoTier === 'must';
|
||||||
|
}
|
||||||
|
return item.photoRequired ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,8 +19,8 @@ export class StorageService {
|
|||||||
this.bucket = process.env.MINIO_BUCKET ?? 'carelink';
|
this.bucket = process.env.MINIO_BUCKET ?? 'carelink';
|
||||||
}
|
}
|
||||||
|
|
||||||
buildKey(tenantCode: string, filename: string): string {
|
buildKey(tenantCode: string, filename: string, prefix = 'elder'): string {
|
||||||
return `elder/${tenantCode}/${Date.now()}-${filename}`;
|
return `${prefix}/${tenantCode}/${Date.now()}-${filename}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload(buffer: Buffer, key: string): Promise<string> {
|
async upload(buffer: Buffer, key: string): Promise<string> {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user