YARA-1246

This commit is contained in:
SepehrYahyaee
2026-08-25 16:50:06 +03:30
parent dc3748ae94
commit 6566b5f112
5 changed files with 978 additions and 395 deletions

View File

@@ -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:

View File

@@ -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[];

View File

@@ -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 ────────────────────────────────────────────────────────

View File

@@ -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 instanceof Date
? file.createdAt.toISOString() ? file.createdAt.toISOString()
: String(file.createdAt ?? ""), : 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)));
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 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) { const filesInRange = mergedFiles.filter((file) =>
filesWithUserRating++; this.isInDateRange(file.createdAt, range.fromDate, range.toDate),
const dims = [ );
userRating.progressSpeed,
userRating.registrationEase, const summary = this.emptyExpertCaseBreakdown();
userRating.overallEvaluation, for (const file of filesInRange as any[]) {
].filter((v): v is number => typeof v === "number" && !isNaN(v)); this.addFileToExpertCaseBreakdown(
if (dims.length > 0) { summary,
userRatingDimensionSum += dims.reduce((a, b) => a + b, 0) / dims.length; String(file.fileType ?? BlameRequestType.THIRD_PARTY),
userRatingDimensionCount++; this.portfolioFileReviewed(file, handledAtByFileId),
);
} }
}
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 {

View File

@@ -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: {