forked from Yara724/api
YARA-1078
This commit is contained in:
186
src/expert-insurer/dto/insurer-reports.dto.ts
Normal file
186
src/expert-insurer/dto/insurer-reports.dto.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ExpertKindFilter = "all" | "expert" | "damage_expert";
|
||||||
|
|
||||||
|
export class InsurerReportQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: "Start of date range (ISO string)" })
|
||||||
|
from?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "End of date range (ISO string)" })
|
||||||
|
to?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ["all", "expert", "damage_expert"],
|
||||||
|
description:
|
||||||
|
"Filter by expert kind. `expert` = blame-panel expert; `damage_expert` = claim damage expert; `all` (default) = both.",
|
||||||
|
})
|
||||||
|
expertKind?: ExpertKindFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerWorkLogQueryDto extends InsurerReportQueryDto {
|
||||||
|
@ApiPropertyOptional({ type: Number, description: "Page number (1-based)" })
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, description: "Items per page" })
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Statistics
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class InsurerStatisticsBreakdownDto {
|
||||||
|
@ApiProperty({ description: "Files that have an insurer rating" })
|
||||||
|
filesWithInsurerRating: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Files that have a bot rating" })
|
||||||
|
filesWithBotRating: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Files that have any user rating" })
|
||||||
|
filesWithUserRating: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Files that have an objection" })
|
||||||
|
filesWithObjection: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Average insurer rating (0–5)" })
|
||||||
|
averageInsurerRating: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Average bot rating (0–5)" })
|
||||||
|
averageBotRating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerStatisticsDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"تعداد کل پروندههای بررسیشده — distinct tenant claim files with at least one expert CHECKED activity.",
|
||||||
|
})
|
||||||
|
totalFilesReviewed: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"رضایت کاربران از روند پرونده — average of progressSpeed + registrationEase + overallEvaluation across rated claim files, normalised to 0–100. Formula: (avg of three dimensions across all rated files / 5) * 100.",
|
||||||
|
})
|
||||||
|
averageUserRatingPercentage: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"پروندههای همراه — blame files where expertInitiated === true AND creationMethod === IN_PERSON.",
|
||||||
|
})
|
||||||
|
inPersonAccompaniedCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Claim files created in the current calendar month." })
|
||||||
|
filesCreatedThisMonth: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Total claim files scoped to this insurer." })
|
||||||
|
totalFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"Percentage of total claim files that have any user rating recorded. (NOT a satisfaction metric — do not render as رضایت کاربران.)",
|
||||||
|
deprecated: true,
|
||||||
|
})
|
||||||
|
filesWithUserRatingPercentage: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Percentage of files with an objection." })
|
||||||
|
objectionPercentage: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Insurer-to-bot rating ratio (0–100)." })
|
||||||
|
insurerToBotRatingPercentage: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: InsurerStatisticsBreakdownDto })
|
||||||
|
breakdown: InsurerStatisticsBreakdownDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Top files
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class TopFileUserRatingDto {
|
||||||
|
@ApiPropertyOptional({ description: "User's free-text comment" })
|
||||||
|
comment?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "User's overall evaluation score (0–5)" })
|
||||||
|
overallEvaluation?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerTopFileDto {
|
||||||
|
@ApiProperty({ description: "Public file ID — use for front-end link مشاهده پرونده" })
|
||||||
|
publicId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "ISO creation datetime" })
|
||||||
|
createdAt: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Combined insurer + user rating blend (0–5)" })
|
||||||
|
combinedScore: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: TopFileUserRatingDto })
|
||||||
|
userRating: TopFileUserRatingDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Top experts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class InsurerTopExpertDto {
|
||||||
|
@ApiProperty()
|
||||||
|
_id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
fullName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ["blame", "claim"] })
|
||||||
|
expertKind: "blame" | "claim";
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Overall average combined rating (0–5)" })
|
||||||
|
overallAverageRating: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Activity stats from expertFileActivity log" })
|
||||||
|
requestStats: { totalHandled: number; totalChecked: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerTopExpertsDto {
|
||||||
|
@ApiProperty({ type: [InsurerTopExpertDto], description: "Top blame-panel experts" })
|
||||||
|
blameExperts: InsurerTopExpertDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [InsurerTopExpertDto], description: "Top damage experts (claim)" })
|
||||||
|
claimExperts: InsurerTopExpertDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Work-log (mirrors reports.dto.ts shapes; exposed from this module for swagger)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class InsurerWorkLogEntryDto {
|
||||||
|
@ApiProperty()
|
||||||
|
expertId: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
fullName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ["expert", "damage_expert"] })
|
||||||
|
expertKind: "expert" | "damage_expert";
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Distinct files in HANDLED state up to cutoff" })
|
||||||
|
totalHandled: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Distinct files currently CHECKED but not yet HANDLED" })
|
||||||
|
currentlyChecking: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"Distinct files with at least one CHECKED event in the reporting window (all-time when no date range given; restricted to from–to otherwise).",
|
||||||
|
})
|
||||||
|
distinctFilesCheckedInPeriod: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerWorkLogResponseDto {
|
||||||
|
@ApiProperty({ type: [InsurerWorkLogEntryDto] })
|
||||||
|
experts: InsurerWorkLogEntryDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Total experts in roster" })
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
@@ -39,6 +39,13 @@ import {
|
|||||||
CreateFileMakerByInsurerDto,
|
CreateFileMakerByInsurerDto,
|
||||||
CreateFileReviewerByInsurerDto,
|
CreateFileReviewerByInsurerDto,
|
||||||
} from "./dto/create-insurer-expert.dto";
|
} from "./dto/create-insurer-expert.dto";
|
||||||
|
import {
|
||||||
|
InsurerStatisticsDto,
|
||||||
|
InsurerTopExpertsDto,
|
||||||
|
InsurerTopFileDto,
|
||||||
|
InsurerWorkLogResponseDto,
|
||||||
|
} from "./dto/insurer-reports.dto";
|
||||||
|
import { ReportsService } from "src/reports/reports.service";
|
||||||
|
|
||||||
@Controller("expert-insurer")
|
@Controller("expert-insurer")
|
||||||
@ApiTags("expert-insurer-panel")
|
@ApiTags("expert-insurer-panel")
|
||||||
@@ -46,25 +53,18 @@ import {
|
|||||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.COMPANY)
|
@Roles(RoleEnum.COMPANY)
|
||||||
export class ExpertInsurerController {
|
export class ExpertInsurerController {
|
||||||
constructor(private readonly expertInsurerService: ExpertInsurerService) {}
|
constructor(
|
||||||
|
private readonly expertInsurerService: ExpertInsurerService,
|
||||||
|
private readonly reportsService: ReportsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ─── Branch management ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Get("branches")
|
@Get("branches")
|
||||||
@ApiQuery({ name: "search", required: false, type: String })
|
@ApiQuery({ name: "search", required: false, type: String })
|
||||||
@ApiQuery({
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
name: "from",
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
required: false,
|
@ApiQuery({ name: "isActive", required: false, description: "Filter active state (true/false)" })
|
||||||
description: "Optional start datetime (ISO string)",
|
|
||||||
})
|
|
||||||
@ApiQuery({
|
|
||||||
name: "to",
|
|
||||||
required: false,
|
|
||||||
description: "Optional end datetime (ISO string)",
|
|
||||||
})
|
|
||||||
@ApiQuery({
|
|
||||||
name: "isActive",
|
|
||||||
required: false,
|
|
||||||
description: "Filter active state (true/false)",
|
|
||||||
})
|
|
||||||
async getInsuranceBranches(
|
async getInsuranceBranches(
|
||||||
@CurrentUser() insurer,
|
@CurrentUser() insurer,
|
||||||
@Query("search") search?: string,
|
@Query("search") search?: string,
|
||||||
@@ -72,9 +72,7 @@ export class ExpertInsurerController {
|
|||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
@Query("isActive") isActive?: string,
|
@Query("isActive") isActive?: string,
|
||||||
) {
|
) {
|
||||||
if (!insurer) {
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
return await this.expertInsurerService.retrieveInsuranceBranches(
|
return await this.expertInsurerService.retrieveInsuranceBranches(
|
||||||
insurer.clientKey,
|
insurer.clientKey,
|
||||||
{ search, from, to, isActive },
|
{ search, from, to, isActive },
|
||||||
@@ -83,18 +81,9 @@ export class ExpertInsurerController {
|
|||||||
|
|
||||||
@Post("branches")
|
@Post("branches")
|
||||||
@ApiBody({ type: CreateBranchDto })
|
@ApiBody({ type: CreateBranchDto })
|
||||||
async addBranch(
|
async addBranch(@CurrentUser() insurer, @Body() createBranchDto: CreateBranchDto) {
|
||||||
@CurrentUser() insurer,
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
@Body() createBranchDto: CreateBranchDto,
|
return await this.expertInsurerService.addBranch(insurer.clientKey, createBranchDto);
|
||||||
) {
|
|
||||||
if (!insurer) {
|
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.expertInsurerService.addBranch(
|
|
||||||
insurer.clientKey,
|
|
||||||
createBranchDto,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put("branches/:branchId/status")
|
@Put("branches/:branchId/status")
|
||||||
@@ -111,10 +100,7 @@ export class ExpertInsurerController {
|
|||||||
@Param("branchId") branchId: string,
|
@Param("branchId") branchId: string,
|
||||||
@Body("isActive") isActive: unknown,
|
@Body("isActive") isActive: unknown,
|
||||||
) {
|
) {
|
||||||
if (!insurer) {
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
// Accept native boolean (JSON body) or string coercion (legacy query/form usage)
|
|
||||||
let active: boolean;
|
let active: boolean;
|
||||||
if (typeof isActive === "boolean") {
|
if (typeof isActive === "boolean") {
|
||||||
active = isActive;
|
active = isActive;
|
||||||
@@ -125,60 +111,38 @@ export class ExpertInsurerController {
|
|||||||
}
|
}
|
||||||
active = ["true", "1", "yes"].includes(normalized);
|
active = ["true", "1", "yes"].includes(normalized);
|
||||||
}
|
}
|
||||||
return this.expertInsurerService.setBranchActive(
|
return this.expertInsurerService.setBranchActive(insurer.clientKey, branchId, active);
|
||||||
insurer.clientKey,
|
|
||||||
branchId,
|
|
||||||
active,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Expert management ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Post("experts/blame")
|
@Post("experts/blame")
|
||||||
@ApiBody({ type: CreateBlameExpertByInsurerDto })
|
@ApiBody({ type: CreateBlameExpertByInsurerDto })
|
||||||
async addBlameExpert(
|
async addBlameExpert(@CurrentUser() insurer, @Body() body: CreateBlameExpertByInsurerDto) {
|
||||||
@CurrentUser() insurer,
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
@Body() body: CreateBlameExpertByInsurerDto,
|
|
||||||
) {
|
|
||||||
if (!insurer) {
|
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
return this.expertInsurerService.addBlameExpert(insurer.clientKey, body);
|
return this.expertInsurerService.addBlameExpert(insurer.clientKey, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("experts/claim")
|
@Post("experts/claim")
|
||||||
@ApiBody({ type: CreateClaimExpertByInsurerDto })
|
@ApiBody({ type: CreateClaimExpertByInsurerDto })
|
||||||
async addClaimExpert(
|
async addClaimExpert(@CurrentUser() insurer, @Body() body: CreateClaimExpertByInsurerDto) {
|
||||||
@CurrentUser() insurer,
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
@Body() body: CreateClaimExpertByInsurerDto,
|
|
||||||
) {
|
|
||||||
if (!insurer) {
|
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
return this.expertInsurerService.addClaimExpert(insurer.clientKey, body);
|
return this.expertInsurerService.addClaimExpert(insurer.clientKey, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("experts/file-maker")
|
@Post("experts/file-maker")
|
||||||
@ApiBody({ type: CreateFileMakerByInsurerDto })
|
@ApiBody({ type: CreateFileMakerByInsurerDto })
|
||||||
@ApiOperation({ summary: "Create a FileMaker account under this insurer" })
|
@ApiOperation({ summary: "Create a FileMaker account under this insurer" })
|
||||||
async addFileMaker(
|
async addFileMaker(@CurrentUser() insurer, @Body() body: CreateFileMakerByInsurerDto) {
|
||||||
@CurrentUser() insurer,
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
@Body() body: CreateFileMakerByInsurerDto,
|
|
||||||
) {
|
|
||||||
if (!insurer) {
|
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
return this.expertInsurerService.addFileMaker(insurer.clientKey, body);
|
return this.expertInsurerService.addFileMaker(insurer.clientKey, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("experts/file-reviewer")
|
@Post("experts/file-reviewer")
|
||||||
@ApiBody({ type: CreateFileReviewerByInsurerDto })
|
@ApiBody({ type: CreateFileReviewerByInsurerDto })
|
||||||
@ApiOperation({ summary: "Create a FileReviewer account under this insurer" })
|
@ApiOperation({ summary: "Create a FileReviewer account under this insurer" })
|
||||||
async addFileReviewer(
|
async addFileReviewer(@CurrentUser() insurer, @Body() body: CreateFileReviewerByInsurerDto) {
|
||||||
@CurrentUser() insurer,
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
@Body() body: CreateFileReviewerByInsurerDto,
|
|
||||||
) {
|
|
||||||
if (!insurer) {
|
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
return this.expertInsurerService.addFileReviewer(insurer.clientKey, body);
|
return this.expertInsurerService.addFileReviewer(insurer.clientKey, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,37 +154,140 @@ export class ExpertInsurerController {
|
|||||||
@Query("response_count") count: number,
|
@Query("response_count") count: number,
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.retrieveAllExpertsOfClient(
|
return await this.expertInsurerService.retrieveAllExpertsOfClient(actor, page, count);
|
||||||
actor,
|
|
||||||
page,
|
|
||||||
count,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Reports: statistics ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get("statistics")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "نمایش کلی — KPI cards for insurer reports page",
|
||||||
|
description:
|
||||||
|
"Returns `totalFilesReviewed` (distinct tenant claim files with ≥1 expert CHECKED activity), " +
|
||||||
|
"`averageUserRatingPercentage` (mean of progressSpeed + registrationEase + overallEvaluation across rated files, normalised to 0–100), " +
|
||||||
|
"`inPersonAccompaniedCount` (blame files where expertInitiated=true AND creationMethod=IN_PERSON), " +
|
||||||
|
"`filesCreatedThisMonth`, `totalFiles`, `objectionPercentage`, `insurerToBotRatingPercentage`. " +
|
||||||
|
"⚠️ `userRatingPercentage` and `filesWithUserRatingPercentage` are the share of files *that have any user rating* — NOT a satisfaction score. Use `averageUserRatingPercentage` for رضایت کاربران.",
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string) — restricts counted claim portfolio" })
|
||||||
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
|
@ApiResponse({ status: 200, type: InsurerStatisticsDto })
|
||||||
|
async getExpertStatistics(
|
||||||
|
@CurrentUser() actor,
|
||||||
|
@Query("from") from?: string,
|
||||||
|
@Query("to") to?: string,
|
||||||
|
) {
|
||||||
|
return await this.expertInsurerService.getExpertStatisticsReport(actor, { from, to });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reports: top files ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get("top-files")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Top 10 highest-rated claim files for this insurer",
|
||||||
|
description:
|
||||||
|
"Sorted by combined insurer + user rating blend (getCombinedFileScore). " +
|
||||||
|
"Returns slim DTO only: publicId, createdAt, combinedScore, userRating.{comment, overallEvaluation}. " +
|
||||||
|
"Use publicId to build مشاهده پرونده links.",
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: "from", required: false, description: "Restrict to files created on or after this ISO date" })
|
||||||
|
@ApiQuery({ name: "to", required: false, description: "Restrict to files created on or before this ISO date" })
|
||||||
|
@ApiResponse({ status: 200, type: [InsurerTopFileDto] })
|
||||||
|
async getTopFiles(
|
||||||
|
@CurrentUser() insurer,
|
||||||
|
@Query("from") from?: string,
|
||||||
|
@Query("to") to?: string,
|
||||||
|
) {
|
||||||
|
return await this.expertInsurerService.getTopFilesForClient(insurer.clientKey, { from, to });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reports: top experts (canonical + alias) ─────────────────────────────
|
||||||
|
|
||||||
@Get("experts/top")
|
@Get("experts/top")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Top blame vs claim experts for this insurer",
|
summary: "Top blame vs claim experts for this insurer",
|
||||||
description:
|
description:
|
||||||
"Response has two arrays: `blameExperts` (roster from expert / blame files) and `claimExperts` (damage-expert roster / claim files). Each item includes `overallAverageRating` derived from ratings stored on those files plus user ratings where present.",
|
"Response: `{ blameExperts: [], claimExperts: [] }`. " +
|
||||||
|
"Each item: `_id`, `fullName`, `expertKind`, `overallAverageRating`, `requestStats`. " +
|
||||||
|
"Sorted by overallAverageRating descending; up to 10 per group. " +
|
||||||
|
"Optional `from` / `to` accepted for forward-compat (currently accepted but roster is full-history).",
|
||||||
})
|
})
|
||||||
async getTopExperts(@CurrentUser() actor) {
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
return await this.expertInsurerService.getTopExpertsForClient(actor);
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
|
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
|
||||||
|
async getTopExperts(
|
||||||
|
@CurrentUser() actor,
|
||||||
|
@Query("from") from?: string,
|
||||||
|
@Query("to") to?: string,
|
||||||
|
) {
|
||||||
|
return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias: some BFF/frontend paths call /top-experts instead of /experts/top.
|
||||||
|
* Both routes point to the same handler.
|
||||||
|
*/
|
||||||
|
@Get("top-experts")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Alias for GET experts/top — top blame + claim experts",
|
||||||
|
description: "Same response as `GET experts/top`. Provided for front-end compatibility.",
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: "from", required: false })
|
||||||
|
@ApiQuery({ name: "to", required: false })
|
||||||
|
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
|
||||||
|
async getTopExpertsAlias(
|
||||||
|
@CurrentUser() actor,
|
||||||
|
@Query("from") from?: string,
|
||||||
|
@Query("to") to?: string,
|
||||||
|
) {
|
||||||
|
return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reports: work-log (delegates to ReportsService) ─────────────────────
|
||||||
|
|
||||||
|
@Get("expert-work-log")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "نمایش جدولی — per-expert work log (blame panel + damage experts)",
|
||||||
|
description:
|
||||||
|
"Rows for every tenant expert in the `expert` (blame) and `damage-expert` (claim) collections — same scope as GET experts/list, field-expert excluded. " +
|
||||||
|
"Columns: expertId, fullName, expertKind, totalHandled (files in HANDLED state up to `to`/now), " +
|
||||||
|
"currentlyChecking (CHECKED but not yet HANDLED up to `to`/now), " +
|
||||||
|
"distinctFilesCheckedInPeriod (distinct files with ≥1 CHECKED event inside from–to window; all-time when omitted). " +
|
||||||
|
"Optional `expertKind` filter: `expert` = blame panel, `damage_expert` = claim damage experts, `all` (default).",
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: "expertKind", required: false, enum: ["all", "expert", "damage_expert"], description: "Filter by expert kind" })
|
||||||
|
@ApiQuery({ name: "from", required: false, description: "Start of date window (ISO string)" })
|
||||||
|
@ApiQuery({ name: "to", required: false, description: "End of date window (ISO string)" })
|
||||||
|
@ApiQuery({ name: "page", required: false, type: Number, description: "Page (1-based)" })
|
||||||
|
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page" })
|
||||||
|
@ApiResponse({ status: 200, type: InsurerWorkLogResponseDto })
|
||||||
|
async getExpertWorkLog(
|
||||||
|
@CurrentUser() actor,
|
||||||
|
@Query("expertKind") expertKind?: "all" | "expert" | "damage_expert",
|
||||||
|
@Query("from") from?: string,
|
||||||
|
@Query("to") to?: string,
|
||||||
|
@Query("page") page?: string,
|
||||||
|
@Query("limit") limit?: string,
|
||||||
|
) {
|
||||||
|
return this.reportsService.getInsurerExpertWorkLog(actor, {
|
||||||
|
expertKind,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
page: page ? parseInt(page, 10) : undefined,
|
||||||
|
limit: limit ? parseInt(limit, 10) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Files ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Get("files")
|
@Get("files")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List insurer files (blame + claim merged by publicId)",
|
summary: "List insurer files (blame + claim merged by publicId)",
|
||||||
description:
|
description:
|
||||||
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).",
|
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).",
|
||||||
})
|
})
|
||||||
async getAllFiles(
|
async getAllFiles(@CurrentUser() insurer, @Query() query: ListQueryV2Dto) {
|
||||||
@CurrentUser() insurer,
|
return await this.expertInsurerService.retrieveAllFilesOfClient(insurer.clientKey, query);
|
||||||
@Query() query: ListQueryV2Dto,
|
|
||||||
) {
|
|
||||||
return await this.expertInsurerService.retrieveAllFilesOfClient(
|
|
||||||
insurer.clientKey,
|
|
||||||
query,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("report/unified-file-statuses")
|
@Get("report/unified-file-statuses")
|
||||||
@@ -234,10 +301,7 @@ export class ExpertInsurerController {
|
|||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
@Query() query: UnifiedFileStatusReportQueryDto,
|
@Query() query: UnifiedFileStatusReportQueryDto,
|
||||||
): Promise<UnifiedFileStatusReportDto> {
|
): Promise<UnifiedFileStatusReportDto> {
|
||||||
return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(
|
return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(actor, query);
|
||||||
actor,
|
|
||||||
query,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("files/:publicId/timeline")
|
@Get("files/:publicId/timeline")
|
||||||
@@ -247,26 +311,14 @@ export class ExpertInsurerController {
|
|||||||
description:
|
description:
|
||||||
"Returns a chronological list of all history events for the blame and/or claim associated with the given publicId. Each event has: source, type, timestamp, actor, metadata.",
|
"Returns a chronological list of all history events for the blame and/or claim associated with the given publicId. Each event has: source, type, timestamp, actor, metadata.",
|
||||||
})
|
})
|
||||||
async getFileTimeline(
|
async getFileTimeline(@CurrentUser() insurer, @Param("publicId") publicId: string) {
|
||||||
@CurrentUser() insurer,
|
return await this.expertInsurerService.getFileTimeline(insurer.clientKey, publicId);
|
||||||
@Param("publicId") publicId: string,
|
|
||||||
) {
|
|
||||||
return await this.expertInsurerService.getFileTimeline(
|
|
||||||
insurer.clientKey,
|
|
||||||
publicId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("files/:publicId")
|
@Get("files/:publicId")
|
||||||
@ApiParam({ name: "publicId" })
|
@ApiParam({ name: "publicId" })
|
||||||
async getFileDetailsByPublicId(
|
async getFileDetailsByPublicId(@CurrentUser() insurer, @Param("publicId") publicId: string) {
|
||||||
@CurrentUser() insurer,
|
return await this.expertInsurerService.retrieveFileDetailsByPublicId(insurer.clientKey, publicId);
|
||||||
@Param("publicId") publicId: string,
|
|
||||||
) {
|
|
||||||
return await this.expertInsurerService.retrieveFileDetailsByPublicId(
|
|
||||||
insurer.clientKey,
|
|
||||||
publicId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
@@ -275,56 +327,14 @@ export class ExpertInsurerController {
|
|||||||
schema: {
|
schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
collisionMethodAccuracy: {
|
collisionMethodAccuracy: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست نحوه برخورد" },
|
||||||
type: "number",
|
evaluationTimeliness: { type: "number", minimum: 0, maximum: 5, example: 3, description: "زمان ارزیابی" },
|
||||||
minimum: 0,
|
accidentCauseAccuracy: { type: "number", minimum: 0, maximum: 5, example: 5, description: "تشخیص درست علت تصادف" },
|
||||||
maximum: 5,
|
guiltyVehicleIdentification: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست وسیله نقلیه مقصر" },
|
||||||
example: 4,
|
botRating: { type: "number", minimum: 0, maximum: 5, example: 4, description: "برای امتیاز دادن به عملکرد بات" },
|
||||||
description: "تشخیص درست نحوه برخورد",
|
|
||||||
},
|
|
||||||
evaluationTimeliness: {
|
|
||||||
type: "number",
|
|
||||||
minimum: 0,
|
|
||||||
maximum: 5,
|
|
||||||
example: 3,
|
|
||||||
description: "زمان ارزیابی",
|
|
||||||
},
|
|
||||||
accidentCauseAccuracy: {
|
|
||||||
type: "number",
|
|
||||||
minimum: 0,
|
|
||||||
maximum: 5,
|
|
||||||
example: 5,
|
|
||||||
description: "تشخیص درست علت تصادف",
|
|
||||||
},
|
|
||||||
guiltyVehicleIdentification: {
|
|
||||||
type: "number",
|
|
||||||
minimum: 0,
|
|
||||||
maximum: 5,
|
|
||||||
example: 4,
|
|
||||||
description: "تشخیص درست وسیله نقلیه مقصر",
|
|
||||||
},
|
|
||||||
botRating: {
|
|
||||||
type: "number",
|
|
||||||
minimum: 0,
|
|
||||||
maximum: 5,
|
|
||||||
example: 4,
|
|
||||||
description: "برای امتیاز دادن به عملکرد بات",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: [
|
|
||||||
"collisionMethodAccuracy",
|
|
||||||
"evaluationTimeliness",
|
|
||||||
"accidentCauseAccuracy",
|
|
||||||
"guiltyVehicleIdentification",
|
|
||||||
"botRating",
|
|
||||||
],
|
|
||||||
example: {
|
|
||||||
collisionMethodAccuracy: 4,
|
|
||||||
evaluationTimeliness: 3,
|
|
||||||
accidentCauseAccuracy: 5,
|
|
||||||
guiltyVehicleIdentification: 4,
|
|
||||||
botRating: 4,
|
|
||||||
},
|
},
|
||||||
|
required: ["collisionMethodAccuracy", "evaluationTimeliness", "accidentCauseAccuracy", "guiltyVehicleIdentification", "botRating"],
|
||||||
|
example: { collisionMethodAccuracy: 4, evaluationTimeliness: 3, accidentCauseAccuracy: 5, guiltyVehicleIdentification: 4, botRating: 4 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "publicId" })
|
@ApiParam({ name: "publicId" })
|
||||||
@@ -334,23 +344,7 @@ export class ExpertInsurerController {
|
|||||||
@Param("publicId") publicId: string,
|
@Param("publicId") publicId: string,
|
||||||
@Body() rating: FileRating,
|
@Body() rating: FileRating,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.rateExpertByPublicId(
|
return await this.expertInsurerService.rateExpertByPublicId(publicId, rating, insurer.clientKey);
|
||||||
publicId,
|
|
||||||
rating,
|
|
||||||
insurer.clientKey,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("top-files")
|
|
||||||
async getTopFiles(@CurrentUser() insurer) {
|
|
||||||
return await this.expertInsurerService.getTopFilesForClient(
|
|
||||||
insurer.clientKey,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("statistics")
|
|
||||||
async getExpertStatistics(@CurrentUser() actor) {
|
|
||||||
return await this.expertInsurerService.getExpertStatisticsReport(actor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("report/status-counts")
|
@Get("report/status-counts")
|
||||||
@@ -359,28 +353,18 @@ export class ExpertInsurerController {
|
|||||||
deprecated: true,
|
deprecated: true,
|
||||||
description: "Prefer GET report/unified-file-statuses for full calculatable blame+claim statuses.",
|
description: "Prefer GET report/unified-file-statuses for full calculatable blame+claim statuses.",
|
||||||
})
|
})
|
||||||
@ApiQuery({
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
name: "from",
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
required: false,
|
|
||||||
description: "Optional start datetime (ISO string)",
|
|
||||||
})
|
|
||||||
@ApiQuery({
|
|
||||||
name: "to",
|
|
||||||
required: false,
|
|
||||||
description: "Optional end datetime (ISO string)",
|
|
||||||
})
|
|
||||||
async getInsurerStatusReport(
|
async getInsurerStatusReport(
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.getInsurerFileStatusCounts(
|
return await this.expertInsurerService.getInsurerFileStatusCounts(actor, from, to);
|
||||||
actor,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Expert detail ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ApiParam({ name: "expertId" })
|
@ApiParam({ name: "expertId" })
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Files handled by one roster expert (summary rows)",
|
summary: "Files handled by one roster expert (summary rows)",
|
||||||
@@ -389,13 +373,7 @@ export class ExpertInsurerController {
|
|||||||
})
|
})
|
||||||
@Get("/:expertId")
|
@Get("/:expertId")
|
||||||
async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) {
|
async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) {
|
||||||
if (!Types.ObjectId.isValid(id)) {
|
if (!Types.ObjectId.isValid(id)) throw new BadRequestException("Invalid expert ID");
|
||||||
throw new BadRequestException("Invalid expert ID");
|
return await this.expertInsurerService.getAllFilesForInsurerExpert(id, insurer.clientKey);
|
||||||
}
|
|
||||||
|
|
||||||
return await this.expertInsurerService.getAllFilesForInsurerExpert(
|
|
||||||
id,
|
|
||||||
insurer.clientKey,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { MongooseModule } from "@nestjs/mongoose";
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
import { AuthModule } from "src/auth/auth.module";
|
import { AuthModule } from "src/auth/auth.module";
|
||||||
|
import { ReportsModule } from "src/reports/reports.module";
|
||||||
import {
|
import {
|
||||||
ClaimRequestManagementModel,
|
ClaimRequestManagementModel,
|
||||||
ClaimRequestManagementSchema,
|
ClaimRequestManagementSchema,
|
||||||
@@ -29,6 +30,7 @@ import { HashModule } from "src/utils/hash/hash.module";
|
|||||||
HashModule,
|
HashModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
|
ReportsModule,
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{
|
{
|
||||||
name: ClaimRequestManagementModel.name,
|
name: ClaimRequestManagementModel.name,
|
||||||
|
|||||||
@@ -971,24 +971,38 @@ export class ExpertInsurerService {
|
|||||||
* excluding `botRating`), optionally blend with the file’s user rating, average those
|
* excluding `botRating`), optionally blend with the file’s user rating, average those
|
||||||
* combined scores per file, then average across that expert’s files (`overallAverageRating`).
|
* combined scores per file, then average across that expert’s files (`overallAverageRating`).
|
||||||
*/
|
*/
|
||||||
async getTopExpertsForClient(actor): Promise<{
|
async getTopExpertsForClient(
|
||||||
blameExperts: any[];
|
actor,
|
||||||
claimExperts: any[];
|
opts: { from?: string; to?: string } = {},
|
||||||
}> {
|
): Promise<{ blameExperts: any[]; claimExperts: any[] }> {
|
||||||
|
// When date opts are passed we re-use the full list; date filtering on file-based
|
||||||
|
// ratings would require per-file date awareness — for now we surface the roster
|
||||||
|
// as-is and document that from/to are accepted for forward-compat.
|
||||||
const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
|
const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
|
||||||
const rows = result?.experts || [];
|
const rows = result?.experts || [];
|
||||||
|
|
||||||
const byRatingDesc = (a: any, b: any) =>
|
const byRatingDesc = (a: any, b: any) =>
|
||||||
(b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
|
(b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
|
||||||
|
|
||||||
|
// Return only the fields the spec mandates — keep shape minimal
|
||||||
|
const slim = (e: any) => ({
|
||||||
|
_id: e._id,
|
||||||
|
fullName: e.fullName,
|
||||||
|
expertKind: e.expertKind,
|
||||||
|
overallAverageRating: e.overallAverageRating ?? null,
|
||||||
|
requestStats: e.requestStats ?? { totalHandled: 0, totalChecked: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
const blameExperts = rows
|
const blameExperts = rows
|
||||||
.filter((e) => e.expertKind === "blame")
|
.filter((e) => e.expertKind === "blame")
|
||||||
.sort(byRatingDesc)
|
.sort(byRatingDesc)
|
||||||
.slice(0, 10);
|
.slice(0, 10)
|
||||||
|
.map(slim);
|
||||||
const claimExperts = rows
|
const claimExperts = rows
|
||||||
.filter((e) => e.expertKind === "claim")
|
.filter((e) => e.expertKind === "claim")
|
||||||
.sort(byRatingDesc)
|
.sort(byRatingDesc)
|
||||||
.slice(0, 10);
|
.slice(0, 10)
|
||||||
|
.map(slim);
|
||||||
|
|
||||||
return { blameExperts, claimExperts };
|
return { blameExperts, claimExperts };
|
||||||
}
|
}
|
||||||
@@ -997,18 +1011,47 @@ export class ExpertInsurerService {
|
|||||||
* Returns top 10 claim files for the current insurer client based on
|
* Returns top 10 claim files for the current insurer client based on
|
||||||
* combined insurer + user ratings.
|
* combined insurer + user ratings.
|
||||||
*/
|
*/
|
||||||
async getTopFilesForClient(insurerId: string): Promise<any[]> {
|
async getTopFilesForClient(
|
||||||
const claimFiles = await this.getClientClaimFiles(
|
insurerId: string,
|
||||||
this.getClientId(insurerId),
|
opts: { from?: string; to?: string } = {},
|
||||||
);
|
): Promise<Array<{
|
||||||
const scored = claimFiles
|
publicId: string;
|
||||||
|
createdAt: string;
|
||||||
|
combinedScore: number;
|
||||||
|
userRating: { comment: string | null; overallEvaluation: number | null };
|
||||||
|
}>> {
|
||||||
|
const fromDate = opts.from ? new Date(opts.from) : undefined;
|
||||||
|
const toDate = opts.to ? new Date(opts.to) : undefined;
|
||||||
|
|
||||||
|
let claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
|
||||||
|
|
||||||
|
if (fromDate || toDate) {
|
||||||
|
claimFiles = claimFiles.filter((f) => {
|
||||||
|
const d = new Date(f.createdAt);
|
||||||
|
if (fromDate && d < fromDate) return false;
|
||||||
|
if (toDate && d > toDate) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return claimFiles
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
const combinedScore = this.getCombinedFileScore(file);
|
const combinedScore = this.getCombinedFileScore(file);
|
||||||
if (combinedScore === null) return null;
|
if (combinedScore === null) return null;
|
||||||
return { ...file, combinedScore };
|
const ur = file?.userRating;
|
||||||
|
return {
|
||||||
|
publicId: String(file.publicId ?? ""),
|
||||||
|
createdAt: file.createdAt instanceof Date
|
||||||
|
? file.createdAt.toISOString()
|
||||||
|
: String(file.createdAt ?? ""),
|
||||||
|
combinedScore,
|
||||||
|
userRating: {
|
||||||
|
comment: ur?.comment ?? null,
|
||||||
|
overallEvaluation: typeof ur?.overallEvaluation === "number" ? ur.overallEvaluation : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.filter((f) => f !== null);
|
.filter((f): f is NonNullable<typeof f> => f !== null)
|
||||||
return scored
|
|
||||||
.sort((a, b) => b.combinedScore - a.combinedScore)
|
.sort((a, b) => b.combinedScore - a.combinedScore)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
}
|
}
|
||||||
@@ -1775,29 +1818,59 @@ export class ExpertInsurerService {
|
|||||||
* - Percentage of files that have objection
|
* - Percentage of files that have objection
|
||||||
* - Number of files created in the current month
|
* - Number of files created in the current month
|
||||||
*/
|
*/
|
||||||
async getExpertStatisticsReport(actor: any) {
|
async getExpertStatisticsReport(
|
||||||
|
actor: any,
|
||||||
|
opts: { from?: string; to?: string } = {},
|
||||||
|
) {
|
||||||
const clientObjectId = this.getClientId(actor);
|
const clientObjectId = this.getClientId(actor);
|
||||||
const claimFiles = await this.getClientClaimFiles(clientObjectId);
|
const [claimFiles, blameFiles, activityEvents] = await Promise.all([
|
||||||
|
this.getClientClaimFiles(clientObjectId),
|
||||||
|
this.getClientBlameFiles(clientObjectId),
|
||||||
|
this.expertFileActivityDbService.findByTenant(clientObjectId),
|
||||||
|
]);
|
||||||
|
|
||||||
// Calculate current month date range
|
// Optional date range filter for portfolio counts
|
||||||
|
const fromDate = opts.from ? new Date(opts.from) : undefined;
|
||||||
|
const toDate = opts.to ? new Date(opts.to) : undefined;
|
||||||
|
const inRange = (date: unknown) => {
|
||||||
|
if (!fromDate && !toDate) return true;
|
||||||
|
if (!date) return false;
|
||||||
|
const d = new Date(date as string);
|
||||||
|
if (fromDate && d < fromDate) return false;
|
||||||
|
if (toDate && d > toDate) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const rangedClaimFiles = claimFiles.filter((f) => inRange(f.createdAt));
|
||||||
|
|
||||||
|
// Current calendar month
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const monthEnd = new Date(
|
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
|
||||||
now.getFullYear(),
|
const filesThisMonth = claimFiles.filter((f) => {
|
||||||
now.getMonth() + 1,
|
const d = new Date(f.createdAt);
|
||||||
0,
|
return d >= monthStart && d <= monthEnd;
|
||||||
23,
|
|
||||||
59,
|
|
||||||
59,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter files created this month
|
|
||||||
const filesThisMonth = claimFiles.filter((file) => {
|
|
||||||
const createdAt = new Date(file.createdAt);
|
|
||||||
return createdAt >= monthStart && createdAt <= monthEnd;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate statistics
|
// totalFilesReviewed: distinct claim files with at least one CHECKED activity
|
||||||
|
const checkedFileIds = new Set<string>();
|
||||||
|
for (const ev of activityEvents) {
|
||||||
|
if (ev.eventType === ExpertFileActivityType.CHECKED) {
|
||||||
|
checkedFileIds.add(String(ev.fileId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Intersect with this insurer's claim file ids
|
||||||
|
const claimFileIds = new Set(claimFiles.map((f) => String(f._id)));
|
||||||
|
let totalFilesReviewed = 0;
|
||||||
|
for (const id of checkedFileIds) {
|
||||||
|
if (claimFileIds.has(id)) totalFilesReviewed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inPersonAccompaniedCount: blame files where expertInitiated === true AND creationMethod === IN_PERSON
|
||||||
|
const inPersonAccompaniedCount = blameFiles.filter(
|
||||||
|
(b) => !!(b as any).expertInitiated && (b as any).creationMethod === "IN_PERSON",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Per-file statistics over the (optionally date-ranged) claim portfolio
|
||||||
let totalInsurerRatings = 0;
|
let totalInsurerRatings = 0;
|
||||||
let totalBotRatings = 0;
|
let totalBotRatings = 0;
|
||||||
let filesWithInsurerRating = 0;
|
let filesWithInsurerRating = 0;
|
||||||
@@ -1805,103 +1878,88 @@ export class ExpertInsurerService {
|
|||||||
let filesWithUserRating = 0;
|
let filesWithUserRating = 0;
|
||||||
let filesWithObjection = 0;
|
let filesWithObjection = 0;
|
||||||
|
|
||||||
for (const file of claimFiles) {
|
// averageUserRatingPercentage: mean of (progressSpeed + registrationEase + overallEvaluation) / 5 * 100
|
||||||
|
let userRatingDimensionSum = 0;
|
||||||
|
let userRatingDimensionCount = 0;
|
||||||
|
|
||||||
|
for (const file of rangedClaimFiles) {
|
||||||
const insurerRating = file?.rating;
|
const insurerRating = file?.rating;
|
||||||
const userRating = file?.userRating;
|
const userRating = file?.userRating;
|
||||||
const objection = file?.objection;
|
|
||||||
|
|
||||||
// Check for insurer rating (excluding botRating)
|
|
||||||
if (insurerRating) {
|
if (insurerRating) {
|
||||||
const insurerValues = [
|
const insurerValues = [
|
||||||
insurerRating.collisionMethodAccuracy,
|
insurerRating.collisionMethodAccuracy,
|
||||||
insurerRating.evaluationTimeliness,
|
insurerRating.evaluationTimeliness,
|
||||||
insurerRating.accidentCauseAccuracy,
|
insurerRating.accidentCauseAccuracy,
|
||||||
insurerRating.guiltyVehicleIdentification,
|
insurerRating.guiltyVehicleIdentification,
|
||||||
].filter(
|
].filter((v): v is number => typeof v === "number" && !isNaN(v));
|
||||||
(val): val is number => typeof val === "number" && !isNaN(val),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (insurerValues.length > 0) {
|
if (insurerValues.length > 0) {
|
||||||
filesWithInsurerRating++;
|
filesWithInsurerRating++;
|
||||||
const insurerAvg =
|
totalInsurerRatings += insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length;
|
||||||
insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length;
|
|
||||||
totalInsurerRatings += insurerAvg;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check for bot rating (if botRating field exists in rating object)
|
|
||||||
const botRating = (insurerRating as any)?.botRating;
|
const botRating = (insurerRating as any)?.botRating;
|
||||||
if (
|
if (typeof botRating === "number" && !isNaN(botRating)) {
|
||||||
botRating !== undefined &&
|
|
||||||
!isNaN(botRating) &&
|
|
||||||
typeof botRating === "number"
|
|
||||||
) {
|
|
||||||
filesWithBotRating++;
|
filesWithBotRating++;
|
||||||
totalBotRatings += botRating;
|
totalBotRatings += botRating;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for user rating
|
|
||||||
if (userRating) {
|
if (userRating) {
|
||||||
filesWithUserRating++;
|
filesWithUserRating++;
|
||||||
}
|
const dims = [
|
||||||
|
userRating.progressSpeed,
|
||||||
// Check for objection
|
userRating.registrationEase,
|
||||||
if (objection) {
|
userRating.overallEvaluation,
|
||||||
filesWithObjection++;
|
].filter((v): v is number => typeof v === "number" && !isNaN(v));
|
||||||
|
if (dims.length > 0) {
|
||||||
|
userRatingDimensionSum += dims.reduce((a, b) => a + b, 0) / dims.length;
|
||||||
|
userRatingDimensionCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate percentages
|
if (file?.objection) filesWithObjection++;
|
||||||
const totalFiles = claimFiles.length;
|
}
|
||||||
|
|
||||||
// Calculate average insurer rating (excluding botRating) and average bot rating
|
const totalFiles = rangedClaimFiles.length;
|
||||||
const averageInsurerRating =
|
const averageInsurerRating = filesWithInsurerRating > 0 ? totalInsurerRatings / filesWithInsurerRating : 0;
|
||||||
filesWithInsurerRating > 0
|
const averageBotRating = filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0;
|
||||||
? totalInsurerRatings / filesWithInsurerRating
|
|
||||||
|
// averageUserRatingPercentage: (mean score / 5) * 100; mean score is avg of the three
|
||||||
|
// per-file dimension averages across all rated files.
|
||||||
|
const averageUserRatingPercentage =
|
||||||
|
userRatingDimensionCount > 0
|
||||||
|
? parseFloat(((userRatingDimensionSum / userRatingDimensionCount / 5) * 100).toFixed(2))
|
||||||
: 0;
|
: 0;
|
||||||
const averageBotRating =
|
|
||||||
filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0;
|
|
||||||
|
|
||||||
// Calculate percentage: (averageInsurerRating / averageBotRating) * 100
|
|
||||||
// This shows what percentage the insurer rating is compared to bot rating
|
|
||||||
const insurerToBotPercentage =
|
const insurerToBotPercentage =
|
||||||
averageBotRating > 0 && averageInsurerRating > 0
|
averageBotRating > 0 && averageInsurerRating > 0
|
||||||
? parseFloat(
|
? parseFloat(((averageInsurerRating / averageBotRating) * 100).toFixed(2))
|
||||||
((averageInsurerRating / averageBotRating) * 100).toFixed(2),
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const userRatingPercentage =
|
|
||||||
totalFiles > 0
|
|
||||||
? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const objectionPercentage =
|
|
||||||
totalFiles > 0
|
|
||||||
? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2))
|
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
insurerToBotRatingPercentage: insurerToBotPercentage,
|
// New / corrected KPI fields
|
||||||
userRatingPercentage: userRatingPercentage,
|
totalFilesReviewed,
|
||||||
objectionPercentage: objectionPercentage,
|
averageUserRatingPercentage,
|
||||||
|
inPersonAccompaniedCount,
|
||||||
|
// Unchanged
|
||||||
filesCreatedThisMonth: filesThisMonth.length,
|
filesCreatedThisMonth: filesThisMonth.length,
|
||||||
totalFiles: totalFiles,
|
totalFiles,
|
||||||
|
objectionPercentage:
|
||||||
|
totalFiles > 0 ? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2)) : 0,
|
||||||
|
insurerToBotRatingPercentage: insurerToBotPercentage,
|
||||||
|
// Deprecated: percentage of files that HAVE a user rating; not a satisfaction metric
|
||||||
|
filesWithUserRatingPercentage:
|
||||||
|
totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0,
|
||||||
|
/** @deprecated use filesWithUserRatingPercentage */
|
||||||
|
userRatingPercentage:
|
||||||
|
totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0,
|
||||||
breakdown: {
|
breakdown: {
|
||||||
filesWithInsurerRating,
|
filesWithInsurerRating,
|
||||||
filesWithBotRating,
|
filesWithBotRating,
|
||||||
filesWithUserRating,
|
filesWithUserRating,
|
||||||
filesWithObjection,
|
filesWithObjection,
|
||||||
averageInsurerRating:
|
averageInsurerRating: parseFloat(averageInsurerRating.toFixed(2)),
|
||||||
filesWithInsurerRating > 0
|
averageBotRating: parseFloat(averageBotRating.toFixed(2)),
|
||||||
? parseFloat(
|
|
||||||
(totalInsurerRatings / filesWithInsurerRating).toFixed(2),
|
|
||||||
)
|
|
||||||
: 0,
|
|
||||||
averageBotRating:
|
|
||||||
filesWithBotRating > 0
|
|
||||||
? parseFloat((totalBotRatings / filesWithBotRating).toFixed(2))
|
|
||||||
: 0,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ import { ReportsService } from "./reports.service";
|
|||||||
],
|
],
|
||||||
controllers: [ReportsController],
|
controllers: [ReportsController],
|
||||||
providers: [ReportsService],
|
providers: [ReportsService],
|
||||||
|
exports: [ReportsService],
|
||||||
})
|
})
|
||||||
export class ReportsModule {}
|
export class ReportsModule {}
|
||||||
|
|||||||
@@ -436,19 +436,47 @@ export class ReportsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInsurerExpertWorkLog(actor: {
|
async getInsurerExpertWorkLog(
|
||||||
clientKey?: string;
|
actor: { clientKey?: string },
|
||||||
}): Promise<InsurerExpertWorkLogResponseDtoRs> {
|
opts: {
|
||||||
|
expertKind?: "all" | "expert" | "damage_expert";
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<{ experts: InsurerExpertWorkLogEntryDtoRs[]; total: number }> {
|
||||||
const clientKey = requireActorClientKey(actor);
|
const clientKey = requireActorClientKey(actor);
|
||||||
const { events, expertRows } =
|
const { events, expertRows } =
|
||||||
await this.loadTenantExpertsAndActivities(clientKey);
|
await this.loadTenantExpertsAndActivities(clientKey);
|
||||||
|
|
||||||
|
// expertKind filter
|
||||||
|
const filteredRows =
|
||||||
|
!opts.expertKind || opts.expertKind === "all"
|
||||||
|
? expertRows
|
||||||
|
: expertRows.filter((r) => r.kind === opts.expertKind);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const epoch = new Date(0);
|
|
||||||
const entries = this.buildWorkLogEntries(expertRows, events, now, {
|
// When from/to supplied, distinctFilesCheckedInPeriod is restricted to that window;
|
||||||
from: epoch,
|
// totalHandled and currentlyChecking are snapshot-at-'to' (or now when to is absent).
|
||||||
to: now,
|
const { fromDate, toDate } = this.parseDateRange(opts.from, opts.to);
|
||||||
|
const cutoff = toDate ?? now;
|
||||||
|
const checkedFrom = fromDate ?? new Date(0);
|
||||||
|
const checkedTo = toDate ?? now;
|
||||||
|
|
||||||
|
const entries = this.buildWorkLogEntries(filteredRows, events, cutoff, {
|
||||||
|
from: checkedFrom,
|
||||||
|
to: checkedTo,
|
||||||
});
|
});
|
||||||
return new InsurerExpertWorkLogResponseDtoRs(entries);
|
|
||||||
|
// Optional pagination
|
||||||
|
const page = Number(opts.page) > 0 ? Number(opts.page) : 1;
|
||||||
|
const limit = Number(opts.limit) > 0 ? Number(opts.limit) : entries.length;
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
const paged = entries.slice(start, start + limit);
|
||||||
|
|
||||||
|
return { experts: paged, total: entries.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInsurerExpertWorkLogPerMonth(actor: {
|
async getInsurerExpertWorkLogPerMonth(actor: {
|
||||||
|
|||||||
Reference in New Issue
Block a user