feat(c3): 异常上报模块(9宫格/扑空转 not_met)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4295683f04
commit
93e532aa7d
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 {}
|
||||||
178
src/abnormal/abnormal.service.spec.ts
Normal file
178
src/abnormal/abnormal.service.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
96
src/abnormal/abnormal.service.ts
Normal file
96
src/abnormal/abnormal.service.ts
Normal 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
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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user