feat(c3): 异常上报模块(9宫格/扑空转 not_met)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-15 00:26:15 +08:00
parent 4295683f04
commit 93e532aa7d
5 changed files with 416 additions and 0 deletions

View 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);
}
}

View 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 {}

View File

@ -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<typeof vi.fn>;
recordAbnormalFindMany?: ReturnType<typeof vi.fn>;
recordAbnormalCount?: ReturnType<typeof vi.fn>;
orderUpdate?: 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 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 });
});
});

View File

@ -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<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 }),
]);
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)');
}
}
}

110
src/abnormal/dto.ts Normal file
View 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;
}