forked from Yara724/api
YARA-1246
This commit is contained in:
@@ -27,6 +27,15 @@ export class UnifiedFileStatusReportQueryDto {
|
|||||||
@IsISO8601({ strict: false })
|
@IsISO8601({ strict: false })
|
||||||
to?: string;
|
to?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description:
|
||||||
|
"Preset reporting window in days. Default: 30 when from/to are omitted.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([30, 60, 90, "30", "60", "90"])
|
||||||
|
periodDays?: number | string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: LIST_FILE_TYPE_V2,
|
enum: LIST_FILE_TYPE_V2,
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -5,6 +5,25 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type ExpertKindFilter = "all" | "expert" | "damage_expert";
|
export type ExpertKindFilter = "all" | "expert" | "damage_expert";
|
||||||
|
export type ReportRangeMode = "preset" | "custom";
|
||||||
|
|
||||||
|
export class InsurerReportAppliedRangeDto {
|
||||||
|
@ApiProperty({ enum: ["preset", "custom"] })
|
||||||
|
mode: ReportRangeMode;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Effective range start (ISO string)" })
|
||||||
|
from: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Effective range end (ISO string)" })
|
||||||
|
to: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Preset day window used when the caller did not provide a full explicit from/to range.",
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
})
|
||||||
|
periodDays?: 30 | 60 | 90;
|
||||||
|
}
|
||||||
|
|
||||||
export class InsurerReportQueryDto {
|
export class InsurerReportQueryDto {
|
||||||
@ApiPropertyOptional({ description: "Start of date range (ISO string)" })
|
@ApiPropertyOptional({ description: "Start of date range (ISO string)" })
|
||||||
@@ -13,6 +32,13 @@ export class InsurerReportQueryDto {
|
|||||||
@ApiPropertyOptional({ description: "End of date range (ISO string)" })
|
@ApiPropertyOptional({ description: "End of date range (ISO string)" })
|
||||||
to?: string;
|
to?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description:
|
||||||
|
"Preset reporting window in days. Default: 30. Ignored when both from and to are provided.",
|
||||||
|
})
|
||||||
|
periodDays?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: ["all", "expert", "damage_expert"],
|
enum: ["all", "expert", "damage_expert"],
|
||||||
description:
|
description:
|
||||||
@@ -29,74 +55,93 @@ export class InsurerWorkLogQueryDto extends InsurerReportQueryDto {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared expert rows / counts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class InsurerExpertCaseBreakdownDto {
|
||||||
|
@ApiProperty({ description: "All files in scope for this row/summary within the selected range" })
|
||||||
|
totalFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Reviewed/handled files in scope for this row/summary" })
|
||||||
|
reviewedFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Unreviewed/not-yet-handled files in scope for this row/summary" })
|
||||||
|
unreviewedFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Third-party files in scope for this row/summary" })
|
||||||
|
thirdPartyFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Car-body files in scope for this row/summary" })
|
||||||
|
carBodyFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Reviewed third-party files in scope for this row/summary" })
|
||||||
|
thirdPartyReviewedFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Unreviewed third-party files in scope for this row/summary" })
|
||||||
|
thirdPartyUnreviewedFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Reviewed car-body files in scope for this row/summary" })
|
||||||
|
carBodyReviewedFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Unreviewed car-body files in scope for this row/summary" })
|
||||||
|
carBodyUnreviewedFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerExpertListItemDto extends InsurerExpertCaseBreakdownDto {
|
||||||
|
@ApiProperty()
|
||||||
|
_id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
fullName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ["blame", "claim"] })
|
||||||
|
expertKind: "blame" | "claim";
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
role: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
type?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "ISO creation datetime of the expert account" })
|
||||||
|
createdAt?: string | Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerExpertsListResponseDto {
|
||||||
|
@ApiProperty({ type: InsurerReportAppliedRangeDto })
|
||||||
|
range: InsurerReportAppliedRangeDto;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Total experts in roster before pagination" })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Current page (1-based)" })
|
||||||
|
page: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Requested page size" })
|
||||||
|
countPerPage: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [InsurerExpertListItemDto] })
|
||||||
|
experts: InsurerExpertListItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Statistics
|
// Statistics
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export class InsurerStatisticsBreakdownDto {
|
export class InsurerStatisticsDto extends InsurerExpertCaseBreakdownDto {
|
||||||
@ApiProperty({ description: "Files that have an insurer rating" })
|
@ApiProperty({ type: InsurerReportAppliedRangeDto })
|
||||||
filesWithInsurerRating: number;
|
range: InsurerReportAppliedRangeDto;
|
||||||
|
|
||||||
@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({
|
@ApiProperty({
|
||||||
description:
|
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.",
|
"Number of active experts in the selected range (experts with at least one attributed file in that range).",
|
||||||
})
|
})
|
||||||
averageUserRatingPercentage: number;
|
activeExperts: 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
|
// Legacy top files (kept for backward compatibility; not used by the new reports)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export class TopFileUserRatingDto {
|
export class TopFileUserRatingDto {
|
||||||
@@ -125,7 +170,7 @@ export class InsurerTopFileDto {
|
|||||||
// Top experts
|
// Top experts
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export class InsurerTopExpertDto {
|
export class InsurerTopExpertDto extends InsurerExpertCaseBreakdownDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
_id: string;
|
_id: string;
|
||||||
|
|
||||||
@@ -134,24 +179,81 @@ export class InsurerTopExpertDto {
|
|||||||
|
|
||||||
@ApiProperty({ enum: ["blame", "claim"] })
|
@ApiProperty({ enum: ["blame", "claim"] })
|
||||||
expertKind: "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 {
|
export class InsurerTopExpertsDto {
|
||||||
@ApiProperty({ type: [InsurerTopExpertDto], description: "Top blame-panel experts" })
|
@ApiProperty({ type: InsurerReportAppliedRangeDto })
|
||||||
blameExperts: InsurerTopExpertDto[];
|
range: InsurerReportAppliedRangeDto;
|
||||||
|
|
||||||
@ApiProperty({ type: [InsurerTopExpertDto], description: "Top damage experts (claim)" })
|
@ApiProperty({ type: [InsurerTopExpertDto], description: "Top 10 experts by total file count" })
|
||||||
claimExperts: InsurerTopExpertDto[];
|
experts: InsurerTopExpertDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Work-log (mirrors reports.dto.ts shapes; exposed from this module for swagger)
|
// Charts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class InsurerExpertChartPointDto {
|
||||||
|
@ApiProperty()
|
||||||
|
expertId: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
fullName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ["blame", "claim"] })
|
||||||
|
expertKind: "blame" | "claim";
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerFilesByDayPointDto {
|
||||||
|
@ApiProperty({ description: "UTC date key (YYYY-MM-DD)" })
|
||||||
|
date: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
thirdPartyFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
carBodyFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerAverageHandlingTimeByDayPointDto {
|
||||||
|
@ApiProperty({ description: "UTC date key (YYYY-MM-DD) based on handled/completed day" })
|
||||||
|
date: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Number of handled files included in the average for that day" })
|
||||||
|
handledFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Average handling time in whole/civil days, rounded to 2 decimals" })
|
||||||
|
averageHandlingDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerChartsDto {
|
||||||
|
@ApiProperty({ type: InsurerReportAppliedRangeDto })
|
||||||
|
range: InsurerReportAppliedRangeDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [InsurerTopExpertDto] })
|
||||||
|
topExperts: InsurerTopExpertDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [InsurerExpertChartPointDto] })
|
||||||
|
filesPerExpertThirdParty: InsurerExpertChartPointDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [InsurerExpertChartPointDto] })
|
||||||
|
filesPerExpertCarBody: InsurerExpertChartPointDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [InsurerFilesByDayPointDto] })
|
||||||
|
filesByDay: InsurerFilesByDayPointDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [InsurerAverageHandlingTimeByDayPointDto] })
|
||||||
|
averageHandlingTimeByDay: InsurerAverageHandlingTimeByDayPointDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Work-log (legacy; kept exposed from this module for swagger)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export class InsurerWorkLogEntryDto {
|
export class InsurerWorkLogEntryDto {
|
||||||
@@ -172,12 +274,15 @@ export class InsurerWorkLogEntryDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
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).",
|
"Distinct files with at least one CHECKED event in the reporting window (restricted to the effective selected range).",
|
||||||
})
|
})
|
||||||
distinctFilesCheckedInPeriod: number;
|
distinctFilesCheckedInPeriod: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InsurerWorkLogResponseDto {
|
export class InsurerWorkLogResponseDto {
|
||||||
|
@ApiProperty({ type: InsurerReportAppliedRangeDto })
|
||||||
|
range: InsurerReportAppliedRangeDto;
|
||||||
|
|
||||||
@ApiProperty({ type: [InsurerWorkLogEntryDto] })
|
@ApiProperty({ type: [InsurerWorkLogEntryDto] })
|
||||||
experts: InsurerWorkLogEntryDto[];
|
experts: InsurerWorkLogEntryDto[];
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
CreateFileReviewerByInsurerDto,
|
CreateFileReviewerByInsurerDto,
|
||||||
} from "./dto/create-insurer-expert.dto";
|
} from "./dto/create-insurer-expert.dto";
|
||||||
import {
|
import {
|
||||||
|
InsurerChartsDto,
|
||||||
|
InsurerExpertsListResponseDto,
|
||||||
InsurerStatisticsDto,
|
InsurerStatisticsDto,
|
||||||
InsurerTopExpertsDto,
|
InsurerTopExpertsDto,
|
||||||
InsurerTopFileDto,
|
InsurerTopFileDto,
|
||||||
@@ -148,13 +150,30 @@ export class ExpertInsurerController {
|
|||||||
|
|
||||||
@ApiQuery({ name: "page", type: Number })
|
@ApiQuery({ name: "page", type: Number })
|
||||||
@ApiQuery({ name: "response_count", type: Number })
|
@ApiQuery({ name: "response_count", type: Number })
|
||||||
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "periodDays",
|
||||||
|
required: false,
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: InsurerExpertsListResponseDto })
|
||||||
@Get("experts/list")
|
@Get("experts/list")
|
||||||
async getAllExperts(
|
async getAllExperts(
|
||||||
@Query("page") page: number,
|
@Query("page") page: number,
|
||||||
@Query("response_count") count: number,
|
@Query("response_count") count: number,
|
||||||
|
@Query("from") from: string | undefined,
|
||||||
|
@Query("to") to: string | undefined,
|
||||||
|
@Query("periodDays") periodDays: string | undefined,
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.retrieveAllExpertsOfClient(actor, page, count);
|
return await this.expertInsurerService.retrieveAllExpertsOfClient(
|
||||||
|
actor,
|
||||||
|
page,
|
||||||
|
count,
|
||||||
|
{ from, to, periodDays },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Reports: statistics ──────────────────────────────────────────────────
|
// ─── Reports: statistics ──────────────────────────────────────────────────
|
||||||
@@ -163,64 +182,91 @@ export class ExpertInsurerController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "نمایش کلی — KPI cards for insurer reports page",
|
summary: "نمایش کلی — KPI cards for insurer reports page",
|
||||||
description:
|
description:
|
||||||
"Returns `totalFilesReviewed` (distinct tenant claim files with ≥1 expert CHECKED activity), " +
|
"Returns the four main KPIs for the selected reporting range: total third-party files, total car-body files, total files, and active experts. " +
|
||||||
"`averageUserRatingPercentage` (mean of progressSpeed + registrationEase + overallEvaluation across rated files, normalised to 0–100), " +
|
"Also returns reviewed/unreviewed totals with third-party/car-body breakdown using the same range logic as the expert table and charts.",
|
||||||
"`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: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "periodDays",
|
||||||
|
required: false,
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, type: InsurerStatisticsDto })
|
@ApiResponse({ status: 200, type: InsurerStatisticsDto })
|
||||||
async getExpertStatistics(
|
async getExpertStatistics(
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.getExpertStatisticsReport(actor, { from, to });
|
return await this.expertInsurerService.getExpertStatisticsReport(actor, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
periodDays,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Reports: top files ───────────────────────────────────────────────────
|
// ─── Reports: top files ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Get("top-files")
|
@Get("top-files")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Top 10 highest-rated claim files for this insurer",
|
summary: "[Legacy] Top 10 highest-rated claim files for this insurer",
|
||||||
description:
|
description:
|
||||||
"Sorted by combined insurer + user rating blend (getCombinedFileScore). " +
|
"Legacy rating-based report endpoint. It is kept for backward compatibility but is no longer needed for the new insurer KPI/reporting page.",
|
||||||
"Returns slim DTO only: publicId, createdAt, combinedScore, userRating.{comment, overallEvaluation}. " +
|
deprecated: true,
|
||||||
"Use publicId to build مشاهده پرونده links.",
|
|
||||||
})
|
})
|
||||||
@ApiQuery({ name: "from", required: false, description: "Restrict to files created on or after this ISO date" })
|
@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" })
|
@ApiQuery({ name: "to", required: false, description: "Restrict to files created on or before this ISO date" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "periodDays",
|
||||||
|
required: false,
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, type: [InsurerTopFileDto] })
|
@ApiResponse({ status: 200, type: [InsurerTopFileDto] })
|
||||||
async getTopFiles(
|
async getTopFiles(
|
||||||
@CurrentUser() insurer,
|
@CurrentUser() insurer,
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.getTopFilesForClient(insurer.clientKey, { from, to });
|
return await this.expertInsurerService.getTopFilesForClient(insurer.clientKey, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
periodDays,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Reports: top experts (canonical + alias) ─────────────────────────────
|
// ─── Reports: top experts (canonical + alias) ─────────────────────────────
|
||||||
|
|
||||||
@Get("experts/top")
|
@Get("experts/top")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Top blame vs claim experts for this insurer",
|
summary: "Top 10 experts by file count for this insurer",
|
||||||
description:
|
description:
|
||||||
"Response: `{ blameExperts: [], claimExperts: [] }`. " +
|
"Returns a single top-10 expert list ranked by total attributed files inside the selected reporting range. " +
|
||||||
"Each item: `_id`, `fullName`, `expertKind`, `overallAverageRating`, `requestStats`. " +
|
"Each row includes reviewed/unreviewed and third-party/car-body breakdowns.",
|
||||||
"Sorted by overallAverageRating descending; up to 10 per group. " +
|
|
||||||
"Optional `from` / `to` accepted for forward-compat (currently accepted but roster is full-history).",
|
|
||||||
})
|
})
|
||||||
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "periodDays",
|
||||||
|
required: false,
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
|
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
|
||||||
async getTopExperts(
|
async getTopExperts(
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to });
|
return await this.expertInsurerService.getTopExpertsForClient(actor, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
periodDays,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,18 +275,52 @@ export class ExpertInsurerController {
|
|||||||
*/
|
*/
|
||||||
@Get("top-experts")
|
@Get("top-experts")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Alias for GET experts/top — top blame + claim experts",
|
summary: "Alias for GET experts/top — top 10 experts by file count",
|
||||||
description: "Same response as `GET experts/top`. Provided for front-end compatibility.",
|
description: "Same response as `GET experts/top`. Provided for front-end compatibility.",
|
||||||
})
|
})
|
||||||
@ApiQuery({ name: "from", required: false })
|
@ApiQuery({ name: "from", required: false })
|
||||||
@ApiQuery({ name: "to", required: false })
|
@ApiQuery({ name: "to", required: false })
|
||||||
|
@ApiQuery({ name: "periodDays", required: false, enum: [30, 60, 90] })
|
||||||
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
|
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
|
||||||
async getTopExpertsAlias(
|
async getTopExpertsAlias(
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to });
|
return await this.expertInsurerService.getTopExpertsForClient(actor, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
periodDays,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("report/charts")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Chart datasets for insurer reports",
|
||||||
|
description:
|
||||||
|
"Returns all chart-ready datasets for the selected reporting range: top 10 experts by total files, files per expert for third-party and car-body, daily file counts, and daily average handling time.",
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "periodDays",
|
||||||
|
required: false,
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: InsurerChartsDto })
|
||||||
|
async getReportCharts(
|
||||||
|
@CurrentUser() actor,
|
||||||
|
@Query("from") from?: string,
|
||||||
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
|
) {
|
||||||
|
return this.expertInsurerService.getReportCharts(actor, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
periodDays,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Reports: work-log (delegates to ReportsService) ─────────────────────
|
// ─── Reports: work-log (delegates to ReportsService) ─────────────────────
|
||||||
@@ -258,6 +338,12 @@ export class ExpertInsurerController {
|
|||||||
@ApiQuery({ name: "expertKind", required: false, enum: ["all", "expert", "damage_expert"], description: "Filter by expert kind" })
|
@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: "from", required: false, description: "Start of date window (ISO string)" })
|
||||||
@ApiQuery({ name: "to", required: false, description: "End of date window (ISO string)" })
|
@ApiQuery({ name: "to", required: false, description: "End of date window (ISO string)" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "periodDays",
|
||||||
|
required: false,
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
|
||||||
|
})
|
||||||
@ApiQuery({ name: "page", required: false, type: Number, description: "Page (1-based)" })
|
@ApiQuery({ name: "page", required: false, type: Number, description: "Page (1-based)" })
|
||||||
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page" })
|
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page" })
|
||||||
@ApiResponse({ status: 200, type: InsurerWorkLogResponseDto })
|
@ApiResponse({ status: 200, type: InsurerWorkLogResponseDto })
|
||||||
@@ -266,6 +352,7 @@ export class ExpertInsurerController {
|
|||||||
@Query("expertKind") expertKind?: "all" | "expert" | "damage_expert",
|
@Query("expertKind") expertKind?: "all" | "expert" | "damage_expert",
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
@Query("page") page?: string,
|
@Query("page") page?: string,
|
||||||
@Query("limit") limit?: string,
|
@Query("limit") limit?: string,
|
||||||
) {
|
) {
|
||||||
@@ -273,6 +360,7 @@ export class ExpertInsurerController {
|
|||||||
expertKind,
|
expertKind,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
|
periodDays,
|
||||||
page: page ? parseInt(page, 10) : undefined,
|
page: page ? parseInt(page, 10) : undefined,
|
||||||
limit: limit ? parseInt(limit, 10) : undefined,
|
limit: limit ? parseInt(limit, 10) : undefined,
|
||||||
});
|
});
|
||||||
@@ -355,12 +443,19 @@ export class ExpertInsurerController {
|
|||||||
})
|
})
|
||||||
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
|
@ApiQuery({ name: "periodDays", required: false, enum: [30, 60, 90] })
|
||||||
async getInsurerStatusReport(
|
async getInsurerStatusReport(
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.getInsurerFileStatusCounts(actor, from, to);
|
return await this.expertInsurerService.getInsurerFileStatusCounts(
|
||||||
|
actor,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
periodDays,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Expert detail ────────────────────────────────────────────────────────
|
// ─── Expert detail ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -859,186 +859,418 @@ export class ExpertInsurerService {
|
|||||||
.map((f) => this.normalizeClaimCase(f));
|
.map((f) => this.normalizeClaimCase(f));
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrieveAllExpertsOfClient(
|
private startOfDay(date: Date): Date {
|
||||||
actor,
|
return new Date(
|
||||||
currentPage: number,
|
date.getFullYear(),
|
||||||
countPerPage: number,
|
date.getMonth(),
|
||||||
|
date.getDate(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private endOfDay(date: Date): Date {
|
||||||
|
return new Date(
|
||||||
|
date.getFullYear(),
|
||||||
|
date.getMonth(),
|
||||||
|
date.getDate(),
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
999,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toUtcDateKey(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseReportPeriodDays(value?: string | number): 30 | 60 | 90 {
|
||||||
|
const parsed = Number(value ?? 30);
|
||||||
|
if (parsed === 30 || parsed === 60 || parsed === 90) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
throw new BadRequestException("periodDays must be one of 30, 60, or 90");
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeReportRange(range: {
|
||||||
|
mode: "preset" | "custom";
|
||||||
|
fromDate: Date;
|
||||||
|
toDate: Date;
|
||||||
|
periodDays?: 30 | 60 | 90;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
mode: range.mode,
|
||||||
|
from: range.fromDate.toISOString(),
|
||||||
|
to: range.toDate.toISOString(),
|
||||||
|
...(range.periodDays ? { periodDays: range.periodDays } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveReportDateRange(opts: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
periodDays?: string | number;
|
||||||
|
} = {}) {
|
||||||
|
const hasFrom = typeof opts.from === "string" && opts.from.trim().length > 0;
|
||||||
|
const hasTo = typeof opts.to === "string" && opts.to.trim().length > 0;
|
||||||
|
const periodDays = this.parseReportPeriodDays(opts.periodDays);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (!hasFrom && !hasTo) {
|
||||||
|
const toDate = this.endOfDay(now);
|
||||||
|
const fromBase = new Date(toDate);
|
||||||
|
fromBase.setDate(fromBase.getDate() - (periodDays - 1));
|
||||||
|
const fromDate = this.startOfDay(fromBase);
|
||||||
|
return {
|
||||||
|
mode: "preset" as const,
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
periodDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { fromDate, toDate } = this.parseDateRange(opts.from, opts.to);
|
||||||
|
|
||||||
|
if (!fromDate && toDate) {
|
||||||
|
const fromBase = new Date(toDate);
|
||||||
|
fromBase.setDate(fromBase.getDate() - (periodDays - 1));
|
||||||
|
fromDate = this.startOfDay(fromBase);
|
||||||
|
}
|
||||||
|
if (fromDate && !toDate) {
|
||||||
|
toDate = this.endOfDay(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fromDate || !toDate) {
|
||||||
|
throw new BadRequestException("Could not resolve reporting date range");
|
||||||
|
}
|
||||||
|
if (fromDate > toDate) {
|
||||||
|
throw new BadRequestException("'from' must be before or equal to 'to'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "custom" as const,
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
periodDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildExpertFileStateMap(
|
||||||
|
events: Array<{
|
||||||
|
expertId: Types.ObjectId | string;
|
||||||
|
fileId: Types.ObjectId | string;
|
||||||
|
eventType: ExpertFileActivityType;
|
||||||
|
occurredAt: Date | string;
|
||||||
|
}>,
|
||||||
|
cutoff: Date,
|
||||||
|
): Map<string, { checked: boolean; handled: boolean; handledAt?: Date }> {
|
||||||
|
const stateByExpertFile = new Map<
|
||||||
|
string,
|
||||||
|
{ checked: boolean; handled: boolean; handledAt?: Date }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const filtered = events
|
||||||
|
.map((event) => ({
|
||||||
|
expertId: String(event.expertId),
|
||||||
|
fileId: String(event.fileId),
|
||||||
|
eventType: event.eventType,
|
||||||
|
occurredAt:
|
||||||
|
event.occurredAt instanceof Date
|
||||||
|
? event.occurredAt
|
||||||
|
: new Date(event.occurredAt),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(event) =>
|
||||||
|
!Number.isNaN(event.occurredAt.getTime()) && event.occurredAt <= cutoff,
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
|
||||||
|
|
||||||
|
for (const event of filtered) {
|
||||||
|
const key = `${event.expertId}:${event.fileId}`;
|
||||||
|
const prev = stateByExpertFile.get(key) ?? {
|
||||||
|
checked: false,
|
||||||
|
handled: false,
|
||||||
|
};
|
||||||
|
if (event.eventType === ExpertFileActivityType.CHECKED) {
|
||||||
|
if (!prev.handled) prev.checked = true;
|
||||||
|
} else if (event.eventType === ExpertFileActivityType.UNCHECKED) {
|
||||||
|
prev.checked = false;
|
||||||
|
} else if (event.eventType === ExpertFileActivityType.HANDLED) {
|
||||||
|
prev.handled = true;
|
||||||
|
prev.checked = false;
|
||||||
|
prev.handledAt = event.occurredAt;
|
||||||
|
}
|
||||||
|
stateByExpertFile.set(key, prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stateByExpertFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emptyExpertCaseBreakdown() {
|
||||||
|
return {
|
||||||
|
totalFiles: 0,
|
||||||
|
reviewedFiles: 0,
|
||||||
|
unreviewedFiles: 0,
|
||||||
|
thirdPartyFiles: 0,
|
||||||
|
carBodyFiles: 0,
|
||||||
|
thirdPartyReviewedFiles: 0,
|
||||||
|
thirdPartyUnreviewedFiles: 0,
|
||||||
|
carBodyReviewedFiles: 0,
|
||||||
|
carBodyUnreviewedFiles: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private addFileToExpertCaseBreakdown(
|
||||||
|
counts: {
|
||||||
|
totalFiles: number;
|
||||||
|
reviewedFiles: number;
|
||||||
|
unreviewedFiles: number;
|
||||||
|
thirdPartyFiles: number;
|
||||||
|
carBodyFiles: number;
|
||||||
|
thirdPartyReviewedFiles: number;
|
||||||
|
thirdPartyUnreviewedFiles: number;
|
||||||
|
carBodyReviewedFiles: number;
|
||||||
|
carBodyUnreviewedFiles: number;
|
||||||
|
},
|
||||||
|
fileType: string | undefined,
|
||||||
|
reviewed: boolean,
|
||||||
|
) {
|
||||||
|
counts.totalFiles += 1;
|
||||||
|
if (reviewed) counts.reviewedFiles += 1;
|
||||||
|
else counts.unreviewedFiles += 1;
|
||||||
|
|
||||||
|
if (fileType === BlameRequestType.CAR_BODY) {
|
||||||
|
counts.carBodyFiles += 1;
|
||||||
|
if (reviewed) counts.carBodyReviewedFiles += 1;
|
||||||
|
else counts.carBodyUnreviewedFiles += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
counts.thirdPartyFiles += 1;
|
||||||
|
if (reviewed) counts.thirdPartyReviewedFiles += 1;
|
||||||
|
else counts.thirdPartyUnreviewedFiles += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildExpertReportRows(
|
||||||
|
actor: any,
|
||||||
|
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
||||||
) {
|
) {
|
||||||
const clientObjectId = this.getClientId(actor);
|
const clientObjectId = this.getClientId(actor);
|
||||||
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
||||||
const [experts, damageExperts, fieldExperts, blameFiles, claimFiles] =
|
const range = this.resolveReportDateRange(opts);
|
||||||
|
const [experts, damageExperts, fieldExperts, blameFiles, claimFiles, activityEvents] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.expertDbService.findAll(ckFilter as never),
|
this.expertDbService.findAll(ckFilter as never),
|
||||||
this.damageExpertDbService.findAll(ckFilter as never),
|
this.damageExpertDbService.findAll(ckFilter as never),
|
||||||
this.fieldExpertDbService.findAll(ckFilter as never),
|
this.fieldExpertDbService.findAll(ckFilter as never),
|
||||||
this.getClientBlameFiles(clientObjectId),
|
this.getClientBlameFiles(clientObjectId),
|
||||||
this.getClientClaimFiles(clientObjectId),
|
this.getClientClaimFiles(clientObjectId),
|
||||||
|
this.expertFileActivityDbService.findByTenant(clientObjectId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allExpertsRaw = [...experts, ...damageExperts, ...fieldExperts];
|
const roster = [
|
||||||
const expertIds = allExpertsRaw.map((e) => String(e._id));
|
...(experts as any[]).map((expert) => ({
|
||||||
const expertActivityStatsMap = await this.buildExpertActivityStatsMap(
|
_id: String(expert._id),
|
||||||
clientObjectId,
|
fullName: `${expert.firstName} ${expert.lastName}`.trim(),
|
||||||
expertIds,
|
expertKind: "blame" as const,
|
||||||
);
|
|
||||||
const expertTotalRatingsMap: Record<string, number[]> = {};
|
|
||||||
const expertRatingsByCategoryMap: Record<
|
|
||||||
string,
|
|
||||||
Record<string, number[]>
|
|
||||||
> = {};
|
|
||||||
|
|
||||||
const processRatings = (expertId: string | undefined, file: any) => {
|
|
||||||
if (!expertId) return;
|
|
||||||
const rating = file?.rating;
|
|
||||||
const combinedScore = this.getCombinedFileScore(file);
|
|
||||||
if (combinedScore !== null) {
|
|
||||||
if (!expertTotalRatingsMap[expertId])
|
|
||||||
expertTotalRatingsMap[expertId] = [];
|
|
||||||
expertTotalRatingsMap[expertId].push(combinedScore);
|
|
||||||
}
|
|
||||||
if (!rating || typeof rating !== "object") return;
|
|
||||||
if (!expertRatingsByCategoryMap[expertId])
|
|
||||||
expertRatingsByCategoryMap[expertId] = {};
|
|
||||||
for (const [category, value] of Object.entries(rating)) {
|
|
||||||
if (category === "botRating") continue;
|
|
||||||
if (typeof value === "number" && !isNaN(value)) {
|
|
||||||
if (!expertRatingsByCategoryMap[expertId][category]) {
|
|
||||||
expertRatingsByCategoryMap[expertId][category] = [];
|
|
||||||
}
|
|
||||||
expertRatingsByCategoryMap[expertId][category].push(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const file of blameFiles) {
|
|
||||||
processRatings(file?.actorLocked?.actorId?.toString?.(), file);
|
|
||||||
}
|
|
||||||
for (const file of claimFiles) {
|
|
||||||
processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapExpertRow = (expert: any, expertKind: "blame" | "claim") => {
|
|
||||||
const expertIdStr = expert._id.toString();
|
|
||||||
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
|
|
||||||
const overallAverageRating = totalRatings.length
|
|
||||||
? parseFloat(
|
|
||||||
(
|
|
||||||
totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length
|
|
||||||
).toFixed(2),
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const averageRatingsByCategory: Record<string, number> = {};
|
|
||||||
const ratingsByCat = expertRatingsByCategoryMap[expertIdStr];
|
|
||||||
if (ratingsByCat) {
|
|
||||||
for (const [category, values] of Object.entries(ratingsByCat)) {
|
|
||||||
averageRatingsByCategory[category] = parseFloat(
|
|
||||||
(values.reduce((a, b) => a + b, 0) / values.length).toFixed(2),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
_id: expert._id,
|
|
||||||
fullName: `${expert.firstName} ${expert.lastName}`,
|
|
||||||
role: expert.role,
|
role: expert.role,
|
||||||
expertKind,
|
|
||||||
type: expert.userType,
|
type: expert.userType,
|
||||||
requestStats: expertActivityStatsMap[expertIdStr] ?? {
|
createdAt: expert.createdAt ?? null,
|
||||||
totalHandled: 0,
|
})),
|
||||||
totalChecked: 0,
|
...(damageExperts as any[]).map((expert) => ({
|
||||||
},
|
_id: String(expert._id),
|
||||||
createdAt: expert.createdAt,
|
fullName: `${expert.firstName} ${expert.lastName}`.trim(),
|
||||||
overallAverageRating,
|
expertKind: "claim" as const,
|
||||||
averageRatingsByCategory,
|
role: expert.role,
|
||||||
};
|
type: expert.userType,
|
||||||
};
|
createdAt: expert.createdAt ?? null,
|
||||||
|
})),
|
||||||
const allExperts = [
|
...(fieldExperts as any[]).map((expert) => ({
|
||||||
...experts.map((e) => mapExpertRow(e, "blame")),
|
_id: String(expert._id),
|
||||||
...damageExperts.map((e) => mapExpertRow(e, "claim")),
|
fullName: `${expert.firstName} ${expert.lastName}`.trim(),
|
||||||
...fieldExperts.map((e) => mapExpertRow(e, "blame")),
|
expertKind: "blame" as const,
|
||||||
|
role: expert.role,
|
||||||
|
type: expert.userType,
|
||||||
|
createdAt: expert.createdAt ?? null,
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const rosterById = new Map(
|
||||||
|
roster.map((expert) => [expert._id, { ...expert, ...this.emptyExpertCaseBreakdown() }]),
|
||||||
|
);
|
||||||
|
const fileState = this.buildExpertFileStateMap(activityEvents as any[], range.toDate);
|
||||||
|
|
||||||
|
const blameFilesInRange = blameFiles.filter((file) =>
|
||||||
|
this.isInDateRange(file?.createdAt, range.fromDate, range.toDate),
|
||||||
|
);
|
||||||
|
const claimFilesInRange = claimFiles.filter((file) =>
|
||||||
|
this.isInDateRange(file?.createdAt, range.fromDate, range.toDate),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const file of blameFilesInRange as any[]) {
|
||||||
|
const expertIds = this.blameFieldExpertIdCandidates(file).filter((expertId) =>
|
||||||
|
rosterById.has(expertId),
|
||||||
|
);
|
||||||
|
for (const expertId of new Set(expertIds)) {
|
||||||
|
const row = rosterById.get(expertId);
|
||||||
|
if (!row) continue;
|
||||||
|
const state = fileState.get(`${expertId}:${String(file._id)}`);
|
||||||
|
this.addFileToExpertCaseBreakdown(
|
||||||
|
row,
|
||||||
|
String(file?.type ?? BlameRequestType.THIRD_PARTY),
|
||||||
|
!!state?.handled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of claimFilesInRange as any[]) {
|
||||||
|
const expertIds = [
|
||||||
|
file?.initiatedByFieldExpertId
|
||||||
|
? String(file.initiatedByFieldExpertId)
|
||||||
|
: undefined,
|
||||||
|
this.claimDamageExpertActorId(file) ?? undefined,
|
||||||
|
]
|
||||||
|
.filter((expertId): expertId is string => !!expertId)
|
||||||
|
.filter((expertId) => rosterById.has(expertId));
|
||||||
|
const fileType = String(
|
||||||
|
file?.snapshot?.accident?.type ??
|
||||||
|
file?.blameFile?.type ??
|
||||||
|
BlameRequestType.THIRD_PARTY,
|
||||||
|
);
|
||||||
|
for (const expertId of new Set(expertIds)) {
|
||||||
|
const row = rosterById.get(expertId);
|
||||||
|
if (!row) continue;
|
||||||
|
const state = fileState.get(`${expertId}:${String(file._id)}`);
|
||||||
|
this.addFileToExpertCaseBreakdown(row, fileType, !!state?.handled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
range,
|
||||||
|
experts: Array.from(rosterById.values()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private portfolioFileReviewed(
|
||||||
|
file: {
|
||||||
|
blame?: { requestId?: string };
|
||||||
|
claim?: { requestId?: string };
|
||||||
|
},
|
||||||
|
handledAtByFileId: Map<string, Date>,
|
||||||
|
): boolean {
|
||||||
|
const blameHandled = file.blame?.requestId
|
||||||
|
? handledAtByFileId.has(String(file.blame.requestId))
|
||||||
|
: false;
|
||||||
|
const claimHandled = file.claim?.requestId
|
||||||
|
? handledAtByFileId.has(String(file.claim.requestId))
|
||||||
|
: false;
|
||||||
|
return blameHandled || claimHandled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private portfolioFileHandledAt(
|
||||||
|
file: {
|
||||||
|
blame?: { requestId?: string };
|
||||||
|
claim?: { requestId?: string };
|
||||||
|
},
|
||||||
|
handledAtByFileId: Map<string, Date>,
|
||||||
|
): Date | null {
|
||||||
|
const handledDates = [
|
||||||
|
file.blame?.requestId
|
||||||
|
? handledAtByFileId.get(String(file.blame.requestId))
|
||||||
|
: undefined,
|
||||||
|
file.claim?.requestId
|
||||||
|
? handledAtByFileId.get(String(file.claim.requestId))
|
||||||
|
: undefined,
|
||||||
|
].filter((value): value is Date => value instanceof Date);
|
||||||
|
|
||||||
|
if (!handledDates.length) return null;
|
||||||
|
return new Date(Math.max(...handledDates.map((value) => value.getTime())));
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieveAllExpertsOfClient(
|
||||||
|
actor,
|
||||||
|
currentPage: number,
|
||||||
|
countPerPage: number,
|
||||||
|
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
||||||
|
) {
|
||||||
|
const report = await this.buildExpertReportRows(actor, opts);
|
||||||
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
|
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
|
||||||
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
|
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
|
||||||
const start = (page - 1) * perPage;
|
const start = (page - 1) * perPage;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: allExperts.length,
|
range: this.serializeReportRange(report.range),
|
||||||
|
total: report.experts.length,
|
||||||
page,
|
page,
|
||||||
countPerPage: perPage,
|
countPerPage: perPage,
|
||||||
experts: allExperts.slice(start, start + perPage),
|
experts: report.experts.slice(start, start + perPage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopExpertsForClient(
|
||||||
|
actor,
|
||||||
|
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
||||||
|
): Promise<{
|
||||||
|
range: {
|
||||||
|
mode: "preset" | "custom";
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
periodDays?: 30 | 60 | 90;
|
||||||
|
};
|
||||||
|
experts: any[];
|
||||||
|
}> {
|
||||||
|
const report = await this.buildExpertReportRows(actor, opts);
|
||||||
|
const experts = report.experts
|
||||||
|
.filter((expert) => expert.totalFiles > 0)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.totalFiles !== a.totalFiles) return b.totalFiles - a.totalFiles;
|
||||||
|
return a.fullName.localeCompare(b.fullName, "fa");
|
||||||
|
})
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((expert) => ({
|
||||||
|
_id: expert._id,
|
||||||
|
fullName: expert.fullName,
|
||||||
|
expertKind: expert.expertKind,
|
||||||
|
totalFiles: expert.totalFiles,
|
||||||
|
reviewedFiles: expert.reviewedFiles,
|
||||||
|
unreviewedFiles: expert.unreviewedFiles,
|
||||||
|
thirdPartyFiles: expert.thirdPartyFiles,
|
||||||
|
carBodyFiles: expert.carBodyFiles,
|
||||||
|
thirdPartyReviewedFiles: expert.thirdPartyReviewedFiles,
|
||||||
|
thirdPartyUnreviewedFiles: expert.thirdPartyUnreviewedFiles,
|
||||||
|
carBodyReviewedFiles: expert.carBodyReviewedFiles,
|
||||||
|
carBodyUnreviewedFiles: expert.carBodyUnreviewedFiles,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
range: this.serializeReportRange(report.range),
|
||||||
|
experts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top experts per roster: blame rows come from the `expert` collection (files they
|
* Legacy endpoint kept for backward compatibility.
|
||||||
* handled on blame cases); claim rows from `damage-expert` (claim evaluations).
|
|
||||||
* Scores are aggregated from **file documents** that insurer can see: for each such
|
|
||||||
* file we take `evaluation.rating` / `expert.rating` (insurer dimensions only,
|
|
||||||
* excluding `botRating`), optionally blend with the file’s user rating, average those
|
|
||||||
* combined scores per file, then average across that expert’s files (`overallAverageRating`).
|
|
||||||
*/
|
|
||||||
async getTopExpertsForClient(
|
|
||||||
actor,
|
|
||||||
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 rows = result?.experts || [];
|
|
||||||
|
|
||||||
const byRatingDesc = (a: any, b: any) =>
|
|
||||||
(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
|
|
||||||
.filter((e) => e.expertKind === "blame")
|
|
||||||
.sort(byRatingDesc)
|
|
||||||
.slice(0, 10)
|
|
||||||
.map(slim);
|
|
||||||
const claimExperts = rows
|
|
||||||
.filter((e) => e.expertKind === "claim")
|
|
||||||
.sort(byRatingDesc)
|
|
||||||
.slice(0, 10)
|
|
||||||
.map(slim);
|
|
||||||
|
|
||||||
return { blameExperts, claimExperts };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(
|
async getTopFilesForClient(
|
||||||
insurerId: string,
|
insurerId: string,
|
||||||
opts: { from?: string; to?: string } = {},
|
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
||||||
): Promise<Array<{
|
): Promise<Array<{
|
||||||
publicId: string;
|
publicId: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
combinedScore: number;
|
combinedScore: number;
|
||||||
userRating: { comment: string | null; overallEvaluation: number | null };
|
userRating: { comment: string | null; overallEvaluation: number | null };
|
||||||
}>> {
|
}>> {
|
||||||
const fromDate = opts.from ? new Date(opts.from) : undefined;
|
const range = this.resolveReportDateRange(opts);
|
||||||
const toDate = opts.to ? new Date(opts.to) : undefined;
|
|
||||||
|
|
||||||
let claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
|
let claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
|
||||||
|
|
||||||
if (fromDate || toDate) {
|
claimFiles = claimFiles.filter((file) =>
|
||||||
claimFiles = claimFiles.filter((f) => {
|
this.isInDateRange(file.createdAt, range.fromDate, range.toDate),
|
||||||
const d = new Date(f.createdAt);
|
);
|
||||||
if (fromDate && d < fromDate) return false;
|
|
||||||
if (toDate && d > toDate) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return claimFiles
|
return claimFiles
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
@@ -1047,13 +1279,17 @@ export class ExpertInsurerService {
|
|||||||
const ur = file?.userRating;
|
const ur = file?.userRating;
|
||||||
return {
|
return {
|
||||||
publicId: String(file.publicId ?? ""),
|
publicId: String(file.publicId ?? ""),
|
||||||
createdAt: file.createdAt instanceof Date
|
createdAt:
|
||||||
? file.createdAt.toISOString()
|
file.createdAt instanceof Date
|
||||||
: String(file.createdAt ?? ""),
|
? file.createdAt.toISOString()
|
||||||
|
: String(file.createdAt ?? ""),
|
||||||
combinedScore,
|
combinedScore,
|
||||||
userRating: {
|
userRating: {
|
||||||
comment: ur?.comment ?? null,
|
comment: ur?.comment ?? null,
|
||||||
overallEvaluation: typeof ur?.overallEvaluation === "number" ? ur.overallEvaluation : null,
|
overallEvaluation:
|
||||||
|
typeof ur?.overallEvaluation === "number"
|
||||||
|
? ur.overallEvaluation
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -1062,6 +1298,155 @@ export class ExpertInsurerService {
|
|||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getReportCharts(
|
||||||
|
actor: any,
|
||||||
|
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
||||||
|
) {
|
||||||
|
const clientObjectId = this.getClientId(actor);
|
||||||
|
const [expertReport, mergedFiles, activityEvents] = await Promise.all([
|
||||||
|
this.buildExpertReportRows(actor, opts),
|
||||||
|
this.buildMergedInsurerFiles(clientObjectId),
|
||||||
|
this.expertFileActivityDbService.findByTenant(clientObjectId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const range = expertReport.range;
|
||||||
|
const handledState = this.buildExpertFileStateMap(
|
||||||
|
activityEvents as any[],
|
||||||
|
range.toDate,
|
||||||
|
);
|
||||||
|
const handledAtByFileId = new Map<string, Date>();
|
||||||
|
for (const [key, state] of handledState.entries()) {
|
||||||
|
if (!state.handled || !state.handledAt) continue;
|
||||||
|
const fileId = key.split(":").slice(1).join(":");
|
||||||
|
const prev = handledAtByFileId.get(fileId);
|
||||||
|
if (!prev || prev < state.handledAt) {
|
||||||
|
handledAtByFileId.set(fileId, state.handledAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesInRange = mergedFiles.filter((file) =>
|
||||||
|
this.isInDateRange(file.createdAt, range.fromDate, range.toDate),
|
||||||
|
);
|
||||||
|
|
||||||
|
const filesPerDayMap = new Map<
|
||||||
|
string,
|
||||||
|
{ totalFiles: number; thirdPartyFiles: number; carBodyFiles: number }
|
||||||
|
>();
|
||||||
|
const avgHandlingByDayMap = new Map<
|
||||||
|
string,
|
||||||
|
{ handledFiles: number; totalDurationMs: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (
|
||||||
|
let cursor = this.startOfDay(range.fromDate);
|
||||||
|
cursor <= range.toDate;
|
||||||
|
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000)
|
||||||
|
) {
|
||||||
|
const key = this.toUtcDateKey(cursor);
|
||||||
|
filesPerDayMap.set(key, {
|
||||||
|
totalFiles: 0,
|
||||||
|
thirdPartyFiles: 0,
|
||||||
|
carBodyFiles: 0,
|
||||||
|
});
|
||||||
|
avgHandlingByDayMap.set(key, { handledFiles: 0, totalDurationMs: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of filesInRange as any[]) {
|
||||||
|
const createdAt = new Date(file.createdAt);
|
||||||
|
if (!Number.isNaN(createdAt.getTime())) {
|
||||||
|
const createdKey = this.toUtcDateKey(createdAt);
|
||||||
|
const createdBucket = filesPerDayMap.get(createdKey);
|
||||||
|
if (createdBucket) {
|
||||||
|
createdBucket.totalFiles += 1;
|
||||||
|
if (file.fileType === BlameRequestType.CAR_BODY) {
|
||||||
|
createdBucket.carBodyFiles += 1;
|
||||||
|
} else {
|
||||||
|
createdBucket.thirdPartyFiles += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handledAt = this.portfolioFileHandledAt(file, handledAtByFileId);
|
||||||
|
if (!handledAt || handledAt < range.fromDate || handledAt > range.toDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Number.isNaN(createdAt.getTime())) continue;
|
||||||
|
const handledKey = this.toUtcDateKey(handledAt);
|
||||||
|
const handledBucket = avgHandlingByDayMap.get(handledKey);
|
||||||
|
if (!handledBucket) continue;
|
||||||
|
handledBucket.handledFiles += 1;
|
||||||
|
handledBucket.totalDurationMs += Math.max(
|
||||||
|
handledAt.getTime() - createdAt.getTime(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topExperts = expertReport.experts
|
||||||
|
.filter((expert) => expert.totalFiles > 0)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.totalFiles !== a.totalFiles) return b.totalFiles - a.totalFiles;
|
||||||
|
return a.fullName.localeCompare(b.fullName, "fa");
|
||||||
|
})
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((expert) => ({
|
||||||
|
_id: expert._id,
|
||||||
|
fullName: expert.fullName,
|
||||||
|
expertKind: expert.expertKind,
|
||||||
|
totalFiles: expert.totalFiles,
|
||||||
|
reviewedFiles: expert.reviewedFiles,
|
||||||
|
unreviewedFiles: expert.unreviewedFiles,
|
||||||
|
thirdPartyFiles: expert.thirdPartyFiles,
|
||||||
|
carBodyFiles: expert.carBodyFiles,
|
||||||
|
thirdPartyReviewedFiles: expert.thirdPartyReviewedFiles,
|
||||||
|
thirdPartyUnreviewedFiles: expert.thirdPartyUnreviewedFiles,
|
||||||
|
carBodyReviewedFiles: expert.carBodyReviewedFiles,
|
||||||
|
carBodyUnreviewedFiles: expert.carBodyUnreviewedFiles,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
range: this.serializeReportRange(range),
|
||||||
|
topExperts,
|
||||||
|
filesPerExpertThirdParty: expertReport.experts
|
||||||
|
.filter((expert) => expert.thirdPartyFiles > 0)
|
||||||
|
.sort((a, b) => b.thirdPartyFiles - a.thirdPartyFiles)
|
||||||
|
.map((expert) => ({
|
||||||
|
expertId: expert._id,
|
||||||
|
fullName: expert.fullName,
|
||||||
|
expertKind: expert.expertKind,
|
||||||
|
value: expert.thirdPartyFiles,
|
||||||
|
})),
|
||||||
|
filesPerExpertCarBody: expertReport.experts
|
||||||
|
.filter((expert) => expert.carBodyFiles > 0)
|
||||||
|
.sort((a, b) => b.carBodyFiles - a.carBodyFiles)
|
||||||
|
.map((expert) => ({
|
||||||
|
expertId: expert._id,
|
||||||
|
fullName: expert.fullName,
|
||||||
|
expertKind: expert.expertKind,
|
||||||
|
value: expert.carBodyFiles,
|
||||||
|
})),
|
||||||
|
filesByDay: Array.from(filesPerDayMap.entries()).map(([date, value]) => ({
|
||||||
|
date,
|
||||||
|
...value,
|
||||||
|
})),
|
||||||
|
averageHandlingTimeByDay: Array.from(avgHandlingByDayMap.entries()).map(
|
||||||
|
([date, value]) => ({
|
||||||
|
date,
|
||||||
|
handledFiles: value.handledFiles,
|
||||||
|
averageHandlingDays:
|
||||||
|
value.handledFiles > 0
|
||||||
|
? parseFloat(
|
||||||
|
(
|
||||||
|
value.totalDurationMs /
|
||||||
|
value.handledFiles /
|
||||||
|
(24 * 60 * 60 * 1000)
|
||||||
|
).toFixed(2),
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getAllFilesForInsurerExpert(
|
async getAllFilesForInsurerExpert(
|
||||||
expertId: string,
|
expertId: string,
|
||||||
insurerClientKey: string,
|
insurerClientKey: string,
|
||||||
@@ -1426,11 +1811,15 @@ export class ExpertInsurerService {
|
|||||||
query: UnifiedFileStatusReportQueryDto = {},
|
query: UnifiedFileStatusReportQueryDto = {},
|
||||||
): Promise<UnifiedFileStatusReportDto> {
|
): Promise<UnifiedFileStatusReportDto> {
|
||||||
const clientObjectId = this.getClientId(actor);
|
const clientObjectId = this.getClientId(actor);
|
||||||
const { fromDate, toDate } = parseListDateRange(query.from, query.to);
|
const range = this.resolveReportDateRange({
|
||||||
|
from: query.from,
|
||||||
|
to: query.to,
|
||||||
|
periodDays: query.periodDays,
|
||||||
|
});
|
||||||
|
|
||||||
const files = await this.buildMergedInsurerFiles(clientObjectId);
|
const files = await this.buildMergedInsurerFiles(clientObjectId);
|
||||||
let inRange = files.filter((f) =>
|
let inRange = files.filter((f) =>
|
||||||
isInListDateRange(f.createdAt, fromDate, toDate),
|
this.isInDateRange(f.createdAt, range.fromDate, range.toDate),
|
||||||
);
|
);
|
||||||
if (query.fileType) {
|
if (query.fileType) {
|
||||||
inRange = inRange.filter((f) => f.fileType === query.fileType);
|
inRange = inRange.filter((f) => f.fileType === query.fileType);
|
||||||
@@ -1818,157 +2207,58 @@ export class ExpertInsurerService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get comprehensive statistics for all experts of a client
|
|
||||||
* Returns:
|
|
||||||
* - Percentage of insurer's rating to BOT
|
|
||||||
* - Percentage of expert's rating given by users
|
|
||||||
* - Percentage of files that have objection
|
|
||||||
* - Number of files created in the current month
|
|
||||||
*/
|
|
||||||
async getExpertStatisticsReport(
|
async getExpertStatisticsReport(
|
||||||
actor: any,
|
actor: any,
|
||||||
opts: { from?: string; to?: string } = {},
|
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
||||||
) {
|
) {
|
||||||
const clientObjectId = this.getClientId(actor);
|
const clientObjectId = this.getClientId(actor);
|
||||||
const [claimFiles, blameFiles, activityEvents] = await Promise.all([
|
const [expertReport, mergedFiles, activityEvents] = await Promise.all([
|
||||||
this.getClientClaimFiles(clientObjectId),
|
this.buildExpertReportRows(actor, opts),
|
||||||
this.getClientBlameFiles(clientObjectId),
|
this.buildMergedInsurerFiles(clientObjectId),
|
||||||
this.expertFileActivityDbService.findByTenant(clientObjectId),
|
this.expertFileActivityDbService.findByTenant(clientObjectId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Optional date range filter for portfolio counts
|
const range = expertReport.range;
|
||||||
const fromDate = opts.from ? new Date(opts.from) : undefined;
|
const fileState = this.buildExpertFileStateMap(
|
||||||
const toDate = opts.to ? new Date(opts.to) : undefined;
|
activityEvents as any[],
|
||||||
const inRange = (date: unknown) => {
|
range.toDate,
|
||||||
if (!fromDate && !toDate) return true;
|
);
|
||||||
if (!date) return false;
|
const handledAtByFileId = new Map<string, Date>();
|
||||||
const d = new Date(date as string);
|
for (const [key, state] of fileState.entries()) {
|
||||||
if (fromDate && d < fromDate) return false;
|
if (!state.handled || !state.handledAt) continue;
|
||||||
if (toDate && d > toDate) return false;
|
const fileId = key.split(":").slice(1).join(":");
|
||||||
return true;
|
const prev = handledAtByFileId.get(fileId);
|
||||||
};
|
if (!prev || prev < state.handledAt) {
|
||||||
const rangedClaimFiles = claimFiles.filter((f) => inRange(f.createdAt));
|
handledAtByFileId.set(fileId, state.handledAt);
|
||||||
|
|
||||||
// Current calendar month
|
|
||||||
const now = new Date();
|
|
||||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
||||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
|
|
||||||
const filesThisMonth = claimFiles.filter((f) => {
|
|
||||||
const d = new Date(f.createdAt);
|
|
||||||
return d >= monthStart && d <= monthEnd;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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)));
|
const filesInRange = mergedFiles.filter((file) =>
|
||||||
let totalFilesReviewed = 0;
|
this.isInDateRange(file.createdAt, range.fromDate, range.toDate),
|
||||||
for (const id of checkedFileIds) {
|
);
|
||||||
if (claimFileIds.has(id)) totalFilesReviewed++;
|
|
||||||
|
const summary = this.emptyExpertCaseBreakdown();
|
||||||
|
for (const file of filesInRange as any[]) {
|
||||||
|
this.addFileToExpertCaseBreakdown(
|
||||||
|
summary,
|
||||||
|
String(file.fileType ?? BlameRequestType.THIRD_PARTY),
|
||||||
|
this.portfolioFileReviewed(file, handledAtByFileId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 totalBotRatings = 0;
|
|
||||||
let filesWithInsurerRating = 0;
|
|
||||||
let filesWithBotRating = 0;
|
|
||||||
let filesWithUserRating = 0;
|
|
||||||
let filesWithObjection = 0;
|
|
||||||
|
|
||||||
// averageUserRatingPercentage: mean of (progressSpeed + registrationEase + overallEvaluation) / 5 * 100
|
|
||||||
let userRatingDimensionSum = 0;
|
|
||||||
let userRatingDimensionCount = 0;
|
|
||||||
|
|
||||||
for (const file of rangedClaimFiles) {
|
|
||||||
const insurerRating = file?.rating;
|
|
||||||
const userRating = file?.userRating;
|
|
||||||
|
|
||||||
if (insurerRating) {
|
|
||||||
const insurerValues = [
|
|
||||||
insurerRating.collisionMethodAccuracy,
|
|
||||||
insurerRating.evaluationTimeliness,
|
|
||||||
insurerRating.accidentCauseAccuracy,
|
|
||||||
insurerRating.guiltyVehicleIdentification,
|
|
||||||
].filter((v): v is number => typeof v === "number" && !isNaN(v));
|
|
||||||
if (insurerValues.length > 0) {
|
|
||||||
filesWithInsurerRating++;
|
|
||||||
totalInsurerRatings += insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length;
|
|
||||||
}
|
|
||||||
const botRating = (insurerRating as any)?.botRating;
|
|
||||||
if (typeof botRating === "number" && !isNaN(botRating)) {
|
|
||||||
filesWithBotRating++;
|
|
||||||
totalBotRatings += botRating;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userRating) {
|
|
||||||
filesWithUserRating++;
|
|
||||||
const dims = [
|
|
||||||
userRating.progressSpeed,
|
|
||||||
userRating.registrationEase,
|
|
||||||
userRating.overallEvaluation,
|
|
||||||
].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++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file?.objection) filesWithObjection++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalFiles = rangedClaimFiles.length;
|
|
||||||
const averageInsurerRating = filesWithInsurerRating > 0 ? totalInsurerRatings / filesWithInsurerRating : 0;
|
|
||||||
const averageBotRating = filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0;
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
const insurerToBotPercentage =
|
|
||||||
averageBotRating > 0 && averageInsurerRating > 0
|
|
||||||
? parseFloat(((averageInsurerRating / averageBotRating) * 100).toFixed(2))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// New / corrected KPI fields
|
range: this.serializeReportRange(range),
|
||||||
totalFilesReviewed,
|
thirdPartyFiles: summary.thirdPartyFiles,
|
||||||
averageUserRatingPercentage,
|
carBodyFiles: summary.carBodyFiles,
|
||||||
inPersonAccompaniedCount,
|
totalFiles: summary.totalFiles,
|
||||||
// Unchanged
|
activeExperts: expertReport.experts.filter((expert) => expert.totalFiles > 0)
|
||||||
filesCreatedThisMonth: filesThisMonth.length,
|
.length,
|
||||||
totalFiles,
|
reviewedFiles: summary.reviewedFiles,
|
||||||
objectionPercentage:
|
unreviewedFiles: summary.unreviewedFiles,
|
||||||
totalFiles > 0 ? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2)) : 0,
|
thirdPartyReviewedFiles: summary.thirdPartyReviewedFiles,
|
||||||
insurerToBotRatingPercentage: insurerToBotPercentage,
|
thirdPartyUnreviewedFiles: summary.thirdPartyUnreviewedFiles,
|
||||||
// Deprecated: percentage of files that HAVE a user rating; not a satisfaction metric
|
carBodyReviewedFiles: summary.carBodyReviewedFiles,
|
||||||
filesWithUserRatingPercentage:
|
carBodyUnreviewedFiles: summary.carBodyUnreviewedFiles,
|
||||||
totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0,
|
|
||||||
/** @deprecated use filesWithUserRatingPercentage */
|
|
||||||
userRatingPercentage:
|
|
||||||
totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0,
|
|
||||||
breakdown: {
|
|
||||||
filesWithInsurerRating,
|
|
||||||
filesWithBotRating,
|
|
||||||
filesWithUserRating,
|
|
||||||
filesWithObjection,
|
|
||||||
averageInsurerRating: parseFloat(averageInsurerRating.toFixed(2)),
|
|
||||||
averageBotRating: parseFloat(averageBotRating.toFixed(2)),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2133,6 +2423,7 @@ export class ExpertInsurerService {
|
|||||||
actor: any,
|
actor: any,
|
||||||
from?: string,
|
from?: string,
|
||||||
to?: string,
|
to?: string,
|
||||||
|
periodDays?: string | number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
all: number;
|
all: number;
|
||||||
completed: number;
|
completed: number;
|
||||||
@@ -2142,6 +2433,7 @@ export class ExpertInsurerService {
|
|||||||
const report = await this.getInsurerUnifiedFileStatusReport(actor, {
|
const report = await this.getInsurerUnifiedFileStatusReport(actor, {
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
|
periodDays,
|
||||||
});
|
});
|
||||||
const counts = report.counts;
|
const counts = report.counts;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -58,6 +58,89 @@ export class ReportsService {
|
|||||||
return { fromDate, toDate };
|
return { fromDate, toDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startOfDay(date: Date): Date {
|
||||||
|
return new Date(
|
||||||
|
date.getFullYear(),
|
||||||
|
date.getMonth(),
|
||||||
|
date.getDate(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private endOfDay(date: Date): Date {
|
||||||
|
return new Date(
|
||||||
|
date.getFullYear(),
|
||||||
|
date.getMonth(),
|
||||||
|
date.getDate(),
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
999,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseReportPeriodDays(value?: string | number): 30 | 60 | 90 {
|
||||||
|
const parsed = Number(value ?? 30);
|
||||||
|
if (parsed === 30 || parsed === 60 || parsed === 90) return parsed;
|
||||||
|
throw new BadRequestException("periodDays must be one of 30, 60, or 90");
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveReportDateRange(opts: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
periodDays?: string | number;
|
||||||
|
} = {}) {
|
||||||
|
const hasFrom = typeof opts.from === "string" && opts.from.trim().length > 0;
|
||||||
|
const hasTo = typeof opts.to === "string" && opts.to.trim().length > 0;
|
||||||
|
const periodDays = this.parseReportPeriodDays(opts.periodDays);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (!hasFrom && !hasTo) {
|
||||||
|
const toDate = this.endOfDay(now);
|
||||||
|
const fromBase = new Date(toDate);
|
||||||
|
fromBase.setDate(fromBase.getDate() - (periodDays - 1));
|
||||||
|
const fromDate = this.startOfDay(fromBase);
|
||||||
|
return { mode: "preset" as const, fromDate, toDate, periodDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
let { fromDate, toDate } = this.parseDateRange(opts.from, opts.to);
|
||||||
|
|
||||||
|
if (!fromDate && toDate) {
|
||||||
|
const fromBase = new Date(toDate);
|
||||||
|
fromBase.setDate(fromBase.getDate() - (periodDays - 1));
|
||||||
|
fromDate = this.startOfDay(fromBase);
|
||||||
|
}
|
||||||
|
if (fromDate && !toDate) {
|
||||||
|
toDate = this.endOfDay(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fromDate || !toDate) {
|
||||||
|
throw new BadRequestException("Could not resolve reporting date range");
|
||||||
|
}
|
||||||
|
if (fromDate > toDate) {
|
||||||
|
throw new BadRequestException("'from' must be before or equal to 'to'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode: "custom" as const, fromDate, toDate, periodDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeReportRange(range: {
|
||||||
|
mode: "preset" | "custom";
|
||||||
|
fromDate: Date;
|
||||||
|
toDate: Date;
|
||||||
|
periodDays?: 30 | 60 | 90;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
mode: range.mode,
|
||||||
|
from: range.fromDate.toISOString(),
|
||||||
|
to: range.toDate.toISOString(),
|
||||||
|
...(range.periodDays ? { periodDays: range.periodDays } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private isInDateRange(
|
private isInDateRange(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
fromDate?: Date,
|
fromDate?: Date,
|
||||||
@@ -442,41 +525,40 @@ export class ReportsService {
|
|||||||
expertKind?: "all" | "expert" | "damage_expert";
|
expertKind?: "all" | "expert" | "damage_expert";
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
periodDays?: string | number;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<{ experts: InsurerExpertWorkLogEntryDtoRs[]; total: number }> {
|
): Promise<{
|
||||||
|
range: { mode: "preset" | "custom"; from: string; to: string; periodDays?: 30 | 60 | 90 };
|
||||||
|
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 =
|
const filteredRows =
|
||||||
!opts.expertKind || opts.expertKind === "all"
|
!opts.expertKind || opts.expertKind === "all"
|
||||||
? expertRows
|
? expertRows
|
||||||
: expertRows.filter((r) => r.kind === opts.expertKind);
|
: expertRows.filter((r) => r.kind === opts.expertKind);
|
||||||
|
|
||||||
const now = new Date();
|
const range = this.resolveReportDateRange(opts);
|
||||||
|
const entries = this.buildWorkLogEntries(filteredRows, events, range.toDate, {
|
||||||
// When from/to supplied, distinctFilesCheckedInPeriod is restricted to that window;
|
from: range.fromDate,
|
||||||
// totalHandled and currentlyChecking are snapshot-at-'to' (or now when to is absent).
|
to: range.toDate,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optional pagination
|
|
||||||
const page = Number(opts.page) > 0 ? Number(opts.page) : 1;
|
const page = Number(opts.page) > 0 ? Number(opts.page) : 1;
|
||||||
const limit = Number(opts.limit) > 0 ? Number(opts.limit) : entries.length;
|
const limit = Number(opts.limit) > 0 ? Number(opts.limit) : entries.length;
|
||||||
const start = (page - 1) * limit;
|
const start = (page - 1) * limit;
|
||||||
const paged = entries.slice(start, start + limit);
|
const paged = entries.slice(start, start + limit);
|
||||||
|
|
||||||
return { experts: paged, total: entries.length };
|
return {
|
||||||
|
range: this.serializeReportRange(range),
|
||||||
|
experts: paged,
|
||||||
|
total: entries.length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInsurerExpertWorkLogPerMonth(actor: {
|
async getInsurerExpertWorkLogPerMonth(actor: {
|
||||||
|
|||||||
Reference in New Issue
Block a user