From 93e532aa7d4b0412561cc6fbc40226d9e03c092c Mon Sep 17 00:00:00 2001 From: liuyun Date: Thu, 15 Oct 2026 00:26:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(c3):=20=E5=BC=82=E5=B8=B8=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=E6=A8=A1=E5=9D=97=EF=BC=889=E5=AE=AB=E6=A0=BC/?= =?UTF-8?q?=E6=89=91=E7=A9=BA=E8=BD=AC=20not=5Fmet=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/abnormal/abnormal.controller.ts | 23 ++++ src/abnormal/abnormal.module.ts | 9 ++ src/abnormal/abnormal.service.spec.ts | 178 ++++++++++++++++++++++++++ src/abnormal/abnormal.service.ts | 96 ++++++++++++++ src/abnormal/dto.ts | 110 ++++++++++++++++ 5 files changed, 416 insertions(+) create mode 100644 src/abnormal/abnormal.controller.ts create mode 100644 src/abnormal/abnormal.module.ts create mode 100644 src/abnormal/abnormal.service.spec.ts create mode 100644 src/abnormal/abnormal.service.ts create mode 100644 src/abnormal/dto.ts diff --git a/src/abnormal/abnormal.controller.ts b/src/abnormal/abnormal.controller.ts new file mode 100644 index 0000000..8389997 --- /dev/null +++ b/src/abnormal/abnormal.controller.ts @@ -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); + } +} diff --git a/src/abnormal/abnormal.module.ts b/src/abnormal/abnormal.module.ts new file mode 100644 index 0000000..5a9d2d3 --- /dev/null +++ b/src/abnormal/abnormal.module.ts @@ -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 {} diff --git a/src/abnormal/abnormal.service.spec.ts b/src/abnormal/abnormal.service.spec.ts new file mode 100644 index 0000000..75e81ac --- /dev/null +++ b/src/abnormal/abnormal.service.spec.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AbnormalService } from './abnormal.service.js'; +import type { PrismaService } from '../prisma/prisma.service.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; + recordAbnormalFindMany?: ReturnType; + recordAbnormalCount?: ReturnType; + orderUpdate?: ReturnType; + } = {}, +) { + 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 prisma = { + client: { + recordAbnormal: { + create: recordAbnormalCreate, + findMany: recordAbnormalFindMany, + count: recordAbnormalCount, + }, + order: { update: orderUpdate }, + }, + }; + const service = new AbnormalService(prisma as unknown as PrismaService); + return { service, recordAbnormalCreate, recordAbnormalFindMany, recordAbnormalCount, orderUpdate }; +} + +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' }); + }); + + 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 扑空缺定位 → 拒(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('list 按 abnormal_type/notify_status/handle_status/老人 筛选', async () => { + const recordAbnormalFindMany = vi.fn().mockResolvedValue([{ id: 'a1' }]); + const recordAbnormalCount = vi.fn().mockResolvedValue(1); + 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 }); + expect(res).toEqual({ list: [{ id: 'a1' }], total: 1, page: 1, pageSize: 20 }); + }); +}); diff --git a/src/abnormal/abnormal.service.ts b/src/abnormal/abnormal.service.ts new file mode 100644 index 0000000..cae6960 --- /dev/null +++ b/src/abnormal/abnormal.service.ts @@ -0,0 +1,96 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service.js'; +import { getRequestContext } from '../common/context/request-context.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.prisma.client.order.update({ + where: { id: dto.orderId! }, + data: { + orderStatus: 'not_met', + notMetReason: dto.abnormalDesc ?? null, + notMetAt: toDateTime(dto.visitTime) ?? new Date(), + }, + }); + } + + return 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', + }, + }); + } + + /** 异常列表(后台 B4)。 */ + async list(q: ListAbnormalQuery) { + const where: Record = {}; + 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 }), + ]); + return { list, 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)'); + } + } +} diff --git a/src/abnormal/dto.ts b/src/abnormal/dto.ts new file mode 100644 index 0000000..a577dfa --- /dev/null +++ b/src/abnormal/dto.ts @@ -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; +}