From 6566b5f112679aeaec0077e826c5daa891345f58 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Tue, 25 Aug 2026 16:50:06 +0330 Subject: [PATCH] YARA-1246 --- .../dto/unified-file-status-report.dto.ts | 9 + src/expert-insurer/dto/insurer-reports.dto.ts | 245 +++-- .../expert-insurer.controller.ts | 139 ++- src/expert-insurer/expert-insurer.service.ts | 866 ++++++++++++------ src/reports/reports.service.ts | 114 ++- 5 files changed, 978 insertions(+), 395 deletions(-) diff --git a/src/common/dto/unified-file-status-report.dto.ts b/src/common/dto/unified-file-status-report.dto.ts index 187260b..cf2c087 100644 --- a/src/common/dto/unified-file-status-report.dto.ts +++ b/src/common/dto/unified-file-status-report.dto.ts @@ -27,6 +27,15 @@ export class UnifiedFileStatusReportQueryDto { @IsISO8601({ strict: false }) 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({ enum: LIST_FILE_TYPE_V2, description: diff --git a/src/expert-insurer/dto/insurer-reports.dto.ts b/src/expert-insurer/dto/insurer-reports.dto.ts index 588c852..2736340 100644 --- a/src/expert-insurer/dto/insurer-reports.dto.ts +++ b/src/expert-insurer/dto/insurer-reports.dto.ts @@ -5,6 +5,25 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; // --------------------------------------------------------------------------- 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 { @ApiPropertyOptional({ description: "Start of date range (ISO string)" }) @@ -13,6 +32,13 @@ export class InsurerReportQueryDto { @ApiPropertyOptional({ description: "End of date range (ISO 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({ enum: ["all", "expert", "damage_expert"], description: @@ -29,74 +55,93 @@ export class InsurerWorkLogQueryDto extends InsurerReportQueryDto { 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 // --------------------------------------------------------------------------- -export class InsurerStatisticsBreakdownDto { - @ApiProperty({ description: "Files that have an insurer rating" }) - filesWithInsurerRating: number; - - @ApiProperty({ description: "Files that have a bot rating" }) - filesWithBotRating: number; - - @ApiProperty({ description: "Files that have any user rating" }) - filesWithUserRating: number; - - @ApiProperty({ description: "Files that have an objection" }) - filesWithObjection: number; - - @ApiProperty({ description: "Average insurer rating (0–5)" }) - averageInsurerRating: number; - - @ApiProperty({ description: "Average bot rating (0–5)" }) - averageBotRating: number; -} - -export class InsurerStatisticsDto { - @ApiProperty({ - description: - "تعداد کل پرونده‌های بررسی‌شده — distinct tenant claim files with at least one expert CHECKED activity.", - }) - totalFilesReviewed: number; +export class InsurerStatisticsDto extends InsurerExpertCaseBreakdownDto { + @ApiProperty({ type: InsurerReportAppliedRangeDto }) + range: InsurerReportAppliedRangeDto; @ApiProperty({ description: - "رضایت کاربران از روند پرونده — average of progressSpeed + registrationEase + overallEvaluation across rated claim files, normalised to 0–100. Formula: (avg of three dimensions across all rated files / 5) * 100.", + "Number of active experts in the selected range (experts with at least one attributed file in that range).", }) - averageUserRatingPercentage: number; - - @ApiProperty({ - description: - "پرونده‌های همراه — blame files where expertInitiated === true AND creationMethod === IN_PERSON.", - }) - inPersonAccompaniedCount: number; - - @ApiProperty({ description: "Claim files created in the current calendar month." }) - filesCreatedThisMonth: number; - - @ApiProperty({ description: "Total claim files scoped to this insurer." }) - totalFiles: number; - - @ApiProperty({ - description: - "Percentage of total claim files that have any user rating recorded. (NOT a satisfaction metric — do not render as رضایت کاربران.)", - deprecated: true, - }) - filesWithUserRatingPercentage: number; - - @ApiProperty({ description: "Percentage of files with an objection." }) - objectionPercentage: number; - - @ApiProperty({ description: "Insurer-to-bot rating ratio (0–100)." }) - insurerToBotRatingPercentage: number; - - @ApiProperty({ type: InsurerStatisticsBreakdownDto }) - breakdown: InsurerStatisticsBreakdownDto; + activeExperts: number; } // --------------------------------------------------------------------------- -// Top files +// Legacy top files (kept for backward compatibility; not used by the new reports) // --------------------------------------------------------------------------- export class TopFileUserRatingDto { @@ -125,7 +170,7 @@ export class InsurerTopFileDto { // Top experts // --------------------------------------------------------------------------- -export class InsurerTopExpertDto { +export class InsurerTopExpertDto extends InsurerExpertCaseBreakdownDto { @ApiProperty() _id: string; @@ -134,24 +179,81 @@ export class InsurerTopExpertDto { @ApiProperty({ enum: ["blame", "claim"] }) expertKind: "blame" | "claim"; - - @ApiPropertyOptional({ description: "Overall average combined rating (0–5)" }) - overallAverageRating: number | null; - - @ApiProperty({ description: "Activity stats from expertFileActivity log" }) - requestStats: { totalHandled: number; totalChecked: number }; } export class InsurerTopExpertsDto { - @ApiProperty({ type: [InsurerTopExpertDto], description: "Top blame-panel experts" }) - blameExperts: InsurerTopExpertDto[]; + @ApiProperty({ type: InsurerReportAppliedRangeDto }) + range: InsurerReportAppliedRangeDto; - @ApiProperty({ type: [InsurerTopExpertDto], description: "Top damage experts (claim)" }) - claimExperts: InsurerTopExpertDto[]; + @ApiProperty({ type: [InsurerTopExpertDto], description: "Top 10 experts by total file count" }) + 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 { @@ -172,12 +274,15 @@ export class InsurerWorkLogEntryDto { @ApiProperty({ description: - "Distinct files with at least one CHECKED event in the reporting window (all-time when no date range given; restricted to from–to otherwise).", + "Distinct files with at least one CHECKED event in the reporting window (restricted to the effective selected range).", }) distinctFilesCheckedInPeriod: number; } export class InsurerWorkLogResponseDto { + @ApiProperty({ type: InsurerReportAppliedRangeDto }) + range: InsurerReportAppliedRangeDto; + @ApiProperty({ type: [InsurerWorkLogEntryDto] }) experts: InsurerWorkLogEntryDto[]; diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index e3fcc09..e5363b2 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -40,6 +40,8 @@ import { CreateFileReviewerByInsurerDto, } from "./dto/create-insurer-expert.dto"; import { + InsurerChartsDto, + InsurerExpertsListResponseDto, InsurerStatisticsDto, InsurerTopExpertsDto, InsurerTopFileDto, @@ -148,13 +150,30 @@ export class ExpertInsurerController { @ApiQuery({ name: "page", 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") async getAllExperts( @Query("page") page: number, @Query("response_count") count: number, + @Query("from") from: string | undefined, + @Query("to") to: string | undefined, + @Query("periodDays") periodDays: string | undefined, @CurrentUser() actor, ) { - return await this.expertInsurerService.retrieveAllExpertsOfClient(actor, page, count); + return await this.expertInsurerService.retrieveAllExpertsOfClient( + actor, + page, + count, + { from, to, periodDays }, + ); } // ─── Reports: statistics ────────────────────────────────────────────────── @@ -163,64 +182,91 @@ export class ExpertInsurerController { @ApiOperation({ summary: "نمایش کلی — KPI cards for insurer reports page", description: - "Returns `totalFilesReviewed` (distinct tenant claim files with ≥1 expert CHECKED activity), " + - "`averageUserRatingPercentage` (mean of progressSpeed + registrationEase + overallEvaluation across rated files, normalised to 0–100), " + - "`inPersonAccompaniedCount` (blame files where expertInitiated=true AND creationMethod=IN_PERSON), " + - "`filesCreatedThisMonth`, `totalFiles`, `objectionPercentage`, `insurerToBotRatingPercentage`. " + - "⚠️ `userRatingPercentage` and `filesWithUserRatingPercentage` are the share of files *that have any user rating* — NOT a satisfaction score. Use `averageUserRatingPercentage` for رضایت کاربران.", + "Returns the four main KPIs for the selected reporting range: total third-party files, total car-body files, total files, and active experts. " + + "Also returns reviewed/unreviewed totals with third-party/car-body breakdown using the same range logic as the expert table and charts.", }) - @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: "periodDays", + required: false, + enum: [30, 60, 90], + description: "Preset reporting window. Default: 30 days when from/to are omitted.", + }) @ApiResponse({ status: 200, type: InsurerStatisticsDto }) async getExpertStatistics( @CurrentUser() actor, @Query("from") from?: 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 ─────────────────────────────────────────────────── @Get("top-files") @ApiOperation({ - summary: "Top 10 highest-rated claim files for this insurer", + summary: "[Legacy] Top 10 highest-rated claim files for this insurer", description: - "Sorted by combined insurer + user rating blend (getCombinedFileScore). " + - "Returns slim DTO only: publicId, createdAt, combinedScore, userRating.{comment, overallEvaluation}. " + - "Use publicId to build مشاهده پرونده links.", + "Legacy rating-based report endpoint. It is kept for backward compatibility but is no longer needed for the new insurer KPI/reporting page.", + deprecated: true, }) @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: "periodDays", + required: false, + enum: [30, 60, 90], + description: "Preset reporting window. Default: 30 days when from/to are omitted.", + }) @ApiResponse({ status: 200, type: [InsurerTopFileDto] }) async getTopFiles( @CurrentUser() insurer, @Query("from") from?: 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) ───────────────────────────── @Get("experts/top") @ApiOperation({ - summary: "Top blame vs claim experts for this insurer", + summary: "Top 10 experts by file count for this insurer", description: - "Response: `{ blameExperts: [], claimExperts: [] }`. " + - "Each item: `_id`, `fullName`, `expertKind`, `overallAverageRating`, `requestStats`. " + - "Sorted by overallAverageRating descending; up to 10 per group. " + - "Optional `from` / `to` accepted for forward-compat (currently accepted but roster is full-history).", + "Returns a single top-10 expert list ranked by total attributed files inside the selected reporting range. " + + "Each row includes reviewed/unreviewed and third-party/car-body breakdowns.", }) @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: InsurerTopExpertsDto }) async getTopExperts( @CurrentUser() actor, @Query("from") from?: 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") @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.", }) @ApiQuery({ name: "from", required: false }) @ApiQuery({ name: "to", required: false }) + @ApiQuery({ name: "periodDays", required: false, enum: [30, 60, 90] }) @ApiResponse({ status: 200, type: InsurerTopExpertsDto }) async getTopExpertsAlias( @CurrentUser() actor, @Query("from") from?: 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) ───────────────────── @@ -258,6 +338,12 @@ export class ExpertInsurerController { @ApiQuery({ name: "expertKind", required: false, enum: ["all", "expert", "damage_expert"], description: "Filter by expert kind" }) @ApiQuery({ name: "from", required: false, description: "Start of date window (ISO string)" }) @ApiQuery({ name: "to", required: false, description: "End of date window (ISO string)" }) + @ApiQuery({ + name: "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: "limit", required: false, type: Number, description: "Items per page" }) @ApiResponse({ status: 200, type: InsurerWorkLogResponseDto }) @@ -266,6 +352,7 @@ export class ExpertInsurerController { @Query("expertKind") expertKind?: "all" | "expert" | "damage_expert", @Query("from") from?: string, @Query("to") to?: string, + @Query("periodDays") periodDays?: string, @Query("page") page?: string, @Query("limit") limit?: string, ) { @@ -273,6 +360,7 @@ export class ExpertInsurerController { expertKind, from, to, + periodDays, page: page ? parseInt(page, 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: "to", required: false, description: "Optional end datetime (ISO string)" }) + @ApiQuery({ name: "periodDays", required: false, enum: [30, 60, 90] }) async getInsurerStatusReport( @CurrentUser() actor, @Query("from") from?: 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 ──────────────────────────────────────────────────────── diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index eff8d9d..4b5b532 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -859,186 +859,418 @@ export class ExpertInsurerService { .map((f) => this.normalizeClaimCase(f)); } - async retrieveAllExpertsOfClient( - actor, - currentPage: number, - countPerPage: number, + 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 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 { + 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 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([ this.expertDbService.findAll(ckFilter as never), this.damageExpertDbService.findAll(ckFilter as never), this.fieldExpertDbService.findAll(ckFilter as never), this.getClientBlameFiles(clientObjectId), this.getClientClaimFiles(clientObjectId), + this.expertFileActivityDbService.findByTenant(clientObjectId), ]); - const allExpertsRaw = [...experts, ...damageExperts, ...fieldExperts]; - const expertIds = allExpertsRaw.map((e) => String(e._id)); - const expertActivityStatsMap = await this.buildExpertActivityStatsMap( - clientObjectId, - expertIds, - ); - const expertTotalRatingsMap: Record = {}; - const expertRatingsByCategoryMap: Record< - string, - Record - > = {}; - - 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 = {}; - 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}`, + const roster = [ + ...(experts as any[]).map((expert) => ({ + _id: String(expert._id), + fullName: `${expert.firstName} ${expert.lastName}`.trim(), + expertKind: "blame" as const, role: expert.role, - expertKind, type: expert.userType, - requestStats: expertActivityStatsMap[expertIdStr] ?? { - totalHandled: 0, - totalChecked: 0, - }, - createdAt: expert.createdAt, - overallAverageRating, - averageRatingsByCategory, - }; - }; - - const allExperts = [ - ...experts.map((e) => mapExpertRow(e, "blame")), - ...damageExperts.map((e) => mapExpertRow(e, "claim")), - ...fieldExperts.map((e) => mapExpertRow(e, "blame")), + createdAt: expert.createdAt ?? null, + })), + ...(damageExperts as any[]).map((expert) => ({ + _id: String(expert._id), + fullName: `${expert.firstName} ${expert.lastName}`.trim(), + expertKind: "claim" as const, + role: expert.role, + type: expert.userType, + createdAt: expert.createdAt ?? null, + })), + ...(fieldExperts as any[]).map((expert) => ({ + _id: String(expert._id), + fullName: `${expert.firstName} ${expert.lastName}`.trim(), + 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, + ): 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, + ): 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 perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20; const start = (page - 1) * perPage; + return { - total: allExperts.length, + range: this.serializeReportRange(report.range), + total: report.experts.length, page, 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 - * 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 }; - } - - /** + * Legacy endpoint kept for backward compatibility. * Returns top 10 claim files for the current insurer client based on * combined insurer + user ratings. */ async getTopFilesForClient( insurerId: string, - opts: { from?: string; to?: string } = {}, + opts: { from?: string; to?: string; periodDays?: string | number } = {}, ): Promise> { - const fromDate = opts.from ? new Date(opts.from) : undefined; - const toDate = opts.to ? new Date(opts.to) : undefined; - + const range = this.resolveReportDateRange(opts); let claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId)); - if (fromDate || toDate) { - claimFiles = claimFiles.filter((f) => { - const d = new Date(f.createdAt); - if (fromDate && d < fromDate) return false; - if (toDate && d > toDate) return false; - return true; - }); - } + claimFiles = claimFiles.filter((file) => + this.isInDateRange(file.createdAt, range.fromDate, range.toDate), + ); return claimFiles .map((file) => { @@ -1047,13 +1279,17 @@ export class ExpertInsurerService { const ur = file?.userRating; return { publicId: String(file.publicId ?? ""), - createdAt: file.createdAt instanceof Date - ? file.createdAt.toISOString() - : String(file.createdAt ?? ""), + createdAt: + file.createdAt instanceof Date + ? file.createdAt.toISOString() + : String(file.createdAt ?? ""), combinedScore, userRating: { comment: ur?.comment ?? null, - overallEvaluation: typeof ur?.overallEvaluation === "number" ? ur.overallEvaluation : null, + overallEvaluation: + typeof ur?.overallEvaluation === "number" + ? ur.overallEvaluation + : null, }, }; }) @@ -1062,6 +1298,155 @@ export class ExpertInsurerService { .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(); + 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( expertId: string, insurerClientKey: string, @@ -1426,11 +1811,15 @@ export class ExpertInsurerService { query: UnifiedFileStatusReportQueryDto = {}, ): Promise { 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); let inRange = files.filter((f) => - isInListDateRange(f.createdAt, fromDate, toDate), + this.isInDateRange(f.createdAt, range.fromDate, range.toDate), ); if (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( actor: any, - opts: { from?: string; to?: string } = {}, + opts: { from?: string; to?: string; periodDays?: string | number } = {}, ) { const clientObjectId = this.getClientId(actor); - const [claimFiles, blameFiles, activityEvents] = await Promise.all([ - this.getClientClaimFiles(clientObjectId), - this.getClientBlameFiles(clientObjectId), + const [expertReport, mergedFiles, activityEvents] = await Promise.all([ + this.buildExpertReportRows(actor, opts), + this.buildMergedInsurerFiles(clientObjectId), this.expertFileActivityDbService.findByTenant(clientObjectId), ]); - // Optional date range filter for portfolio counts - const fromDate = opts.from ? new Date(opts.from) : undefined; - const toDate = opts.to ? new Date(opts.to) : undefined; - const inRange = (date: unknown) => { - if (!fromDate && !toDate) return true; - if (!date) return false; - const d = new Date(date as string); - if (fromDate && d < fromDate) return false; - if (toDate && d > toDate) return false; - return true; - }; - const rangedClaimFiles = claimFiles.filter((f) => inRange(f.createdAt)); - - // Current calendar month - const now = new Date(); - const 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(); - for (const ev of activityEvents) { - if (ev.eventType === ExpertFileActivityType.CHECKED) { - checkedFileIds.add(String(ev.fileId)); + const range = expertReport.range; + const fileState = this.buildExpertFileStateMap( + activityEvents as any[], + range.toDate, + ); + const handledAtByFileId = new Map(); + for (const [key, state] of fileState.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); } } - // 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++; + + const filesInRange = mergedFiles.filter((file) => + this.isInDateRange(file.createdAt, range.fromDate, range.toDate), + ); + + 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 { - // New / corrected KPI fields - totalFilesReviewed, - averageUserRatingPercentage, - inPersonAccompaniedCount, - // Unchanged - filesCreatedThisMonth: filesThisMonth.length, - totalFiles, - objectionPercentage: - totalFiles > 0 ? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2)) : 0, - insurerToBotRatingPercentage: insurerToBotPercentage, - // Deprecated: percentage of files that HAVE a user rating; not a satisfaction metric - filesWithUserRatingPercentage: - totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0, - /** @deprecated use filesWithUserRatingPercentage */ - userRatingPercentage: - totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0, - breakdown: { - filesWithInsurerRating, - filesWithBotRating, - filesWithUserRating, - filesWithObjection, - averageInsurerRating: parseFloat(averageInsurerRating.toFixed(2)), - averageBotRating: parseFloat(averageBotRating.toFixed(2)), - }, + range: this.serializeReportRange(range), + thirdPartyFiles: summary.thirdPartyFiles, + carBodyFiles: summary.carBodyFiles, + totalFiles: summary.totalFiles, + activeExperts: expertReport.experts.filter((expert) => expert.totalFiles > 0) + .length, + reviewedFiles: summary.reviewedFiles, + unreviewedFiles: summary.unreviewedFiles, + thirdPartyReviewedFiles: summary.thirdPartyReviewedFiles, + thirdPartyUnreviewedFiles: summary.thirdPartyUnreviewedFiles, + carBodyReviewedFiles: summary.carBodyReviewedFiles, + carBodyUnreviewedFiles: summary.carBodyUnreviewedFiles, }; } @@ -2133,6 +2423,7 @@ export class ExpertInsurerService { actor: any, from?: string, to?: string, + periodDays?: string | number, ): Promise<{ all: number; completed: number; @@ -2142,6 +2433,7 @@ export class ExpertInsurerService { const report = await this.getInsurerUnifiedFileStatusReport(actor, { from, to, + periodDays, }); const counts = report.counts; return { diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts index 4d5e230..483b1c5 100644 --- a/src/reports/reports.service.ts +++ b/src/reports/reports.service.ts @@ -58,6 +58,89 @@ export class ReportsService { 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( value: unknown, fromDate?: Date, @@ -442,41 +525,40 @@ export class ReportsService { expertKind?: "all" | "expert" | "damage_expert"; from?: string; to?: string; + periodDays?: string | number; page?: 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 { events, expertRows } = await this.loadTenantExpertsAndActivities(clientKey); - // expertKind filter const filteredRows = !opts.expertKind || opts.expertKind === "all" ? expertRows : expertRows.filter((r) => r.kind === opts.expertKind); - const now = new Date(); - - // When from/to supplied, distinctFilesCheckedInPeriod is restricted to that window; - // totalHandled and currentlyChecking are snapshot-at-'to' (or now when to is absent). - 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, + const range = this.resolveReportDateRange(opts); + const entries = this.buildWorkLogEntries(filteredRows, events, range.toDate, { + from: range.fromDate, + to: range.toDate, }); - // Optional pagination const page = Number(opts.page) > 0 ? Number(opts.page) : 1; const limit = Number(opts.limit) > 0 ? Number(opts.limit) : entries.length; const start = (page - 1) * limit; const paged = entries.slice(start, start + limit); - return { experts: paged, total: entries.length }; + return { + range: this.serializeReportRange(range), + experts: paged, + total: entries.length, + }; } async getInsurerExpertWorkLogPerMonth(actor: {