forked from Yara724/api
YARA-1246
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<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 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<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}`,
|
||||
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<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 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<Array<{
|
||||
publicId: string;
|
||||
createdAt: string;
|
||||
combinedScore: number;
|
||||
userRating: { comment: string | null; overallEvaluation: number | null };
|
||||
}>> {
|
||||
const fromDate = opts.from ? new Date(opts.from) : undefined;
|
||||
const toDate = opts.to ? new Date(opts.to) : undefined;
|
||||
|
||||
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<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(
|
||||
expertId: string,
|
||||
insurerClientKey: string,
|
||||
@@ -1426,11 +1811,15 @@ export class ExpertInsurerService {
|
||||
query: UnifiedFileStatusReportQueryDto = {},
|
||||
): Promise<UnifiedFileStatusReportDto> {
|
||||
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<string>();
|
||||
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<string, Date>();
|
||||
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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user