Merge pull request 'YARA-1241' (#279) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#279
This commit is contained in:
2026-08-26 15:58:03 +03:30
8 changed files with 1122 additions and 405 deletions

View File

@@ -41,7 +41,21 @@ describe("buildInsurerFileReport", () => {
phoneNumber: "09120000000",
clientId: "client-guilty",
},
statement: { admitsGuilt: true },
statement: {
admitsGuilt: true,
acceptsExpertOpinion: true,
description: "توضیحات مقصر",
},
vehicle: {
carName: "206",
carModel: "1401",
plate: {
leftDigits: 98,
centerAlphabet: "ج",
centerDigits: 765,
ir: 22,
},
},
insurance: {
policyNumber: "TP-GUILTY-001",
company: "بیمه ثالث مقصر",
@@ -68,6 +82,7 @@ describe("buildInsurerFileReport", () => {
},
statement: {
claimsDamage: true,
acceptsExpertOpinion: false,
description: "توضیحات زیان‌دیده",
accidentDate: "2026-08-10",
accidentTime: "13:23",
@@ -153,6 +168,32 @@ describe("buildInsurerFileReport", () => {
"CB-GUILTY-001",
);
expect(getFieldValue(report, PR.damagedVehicleSection, "خودرو / نام خودرو")).toBe(
"207",
);
expect(getFieldValue(report, PR.guiltyVehicleSection, "خودرو / نام خودرو")).toBe(
"206",
);
expect(getFieldValue(report, PR.damagedStatementSection, PR.partyDescription)).toBe(
"توضیحات زیان‌دیده",
);
expect(getFieldValue(report, PR.damagedStatementSection, PR.claimsDamage)).toBe(
"بله",
);
expect(
getFieldValue(report, PR.damagedStatementSection, PR.acceptsExpertOpinion),
).toBe("خیر");
expect(getFieldValue(report, PR.guiltyStatementSection, PR.partyDescription)).toBe(
"توضیحات مقصر",
);
expect(getFieldValue(report, PR.guiltyStatementSection, PR.admitsGuilt)).toBe(
"بله",
);
expect(
getFieldValue(report, PR.guiltyStatementSection, PR.acceptsExpertOpinion),
).toBe("بله");
expect(getFieldValue(report, PR.fanavaranSection, PR.fanavaranClaimNo)).toBe(
"111",
);

View File

@@ -258,6 +258,28 @@ function getPartyRole(party: ReportParty | null | undefined): string | undefined
return typeof role === "string" ? role : undefined;
}
function partyKindLabel(
party: ReportParty | null | undefined,
damagedParty: ReportParty | null,
guiltyParty: ReportParty | null,
): string | undefined {
if (sameParty(party, damagedParty)) return "damaged";
if (sameParty(party, guiltyParty)) return "guilty";
return undefined;
}
function partyRoleLabel(role: string | undefined): string | undefined {
if (!role) return undefined;
if (role === PartyRole.FIRST) return "طرف اول";
if (role === PartyRole.SECOND) return "طرف دوم";
return role;
}
function statementBoolean(value: unknown): string | undefined {
if (typeof value !== "boolean") return undefined;
return persianStatus(value);
}
function sameParty(
first: ReportParty | null | undefined,
second: ReportParty | null | undefined,
@@ -536,16 +558,60 @@ function buildDriverSection(
]);
}
function buildVehicleSection(
damagedParty: ReportParty | null,
claim?: Record<string, unknown> | null,
): InsurerFileReportSection {
const partyVehicle = damagedParty?.vehicle;
const claimVehicle = claim?.vehicle as Record<string, unknown> | undefined;
function buildPartyVehicleSection(
title: string,
party: ReportParty | null | undefined,
claimVehicle?: Record<string, unknown>,
): InsurerFileReportSection | undefined {
if (!party && !claimVehicle) return undefined;
return buildSection(PR.vehicleSection, [
return buildSection(title, [
...flattenObject(claimVehicle, "claim.vehicle"),
...flattenObject(partyVehicle, "party.vehicle"),
...flattenObject(party?.vehicle, "party.vehicle"),
]);
}
function buildPartyStatementSection(
title: string,
party: ReportParty | null | undefined,
damagedParty: ReportParty | null,
guiltyParty: ReportParty | null,
): InsurerFileReportSection | undefined {
if (!party) return undefined;
const statement = (party.statement ?? {}) as ReportRecord;
const kind = partyKindLabel(party, damagedParty, guiltyParty);
return buildSection(title, [
{
label: PR.partyRole,
value: partyRoleLabel(getPartyRole(party)),
},
{
label: PR.name,
value: firstDefined(party.person?.fullName),
},
{
label: PR.admitsGuilt,
value:
kind === "damaged"
? undefined
: statementBoolean(statement.admitsGuilt),
},
{
label: PR.claimsDamage,
value:
kind === "guilty"
? undefined
: statementBoolean(statement.claimsDamage),
},
{
label: PR.acceptsExpertOpinion,
value: statementBoolean(statement.acceptsExpertOpinion),
},
{
label: PR.partyDescription,
value: asString(statement.description),
},
]);
}
@@ -768,6 +834,8 @@ export function buildInsurerFileReport(file: {
String((blameContext?.type as string | undefined) ?? "") === "CAR_BODY";
const includeGuiltySections = !!guiltyParty && !(isCarBody && sameParty(damagedParty, guiltyParty));
const claimVehicle = claim?.vehicle as Record<string, unknown> | undefined;
const sections = filterEmptySections([
buildCaseTimelineSection(overview, claim),
buildOwnerSection(damagedParty, claim),
@@ -792,7 +860,24 @@ export function buildInsurerFileReport(file: {
buildCarBodyInsuranceFields(guiltyParty, blameContext, claim),
)
: undefined,
buildVehicleSection(damagedParty, claim),
buildPartyVehicleSection(PR.damagedVehicleSection, damagedParty, claimVehicle),
includeGuiltySections
? buildPartyVehicleSection(PR.guiltyVehicleSection, guiltyParty)
: undefined,
buildPartyStatementSection(
PR.damagedStatementSection,
damagedParty,
damagedParty,
guiltyParty,
),
includeGuiltySections
? buildPartyStatementSection(
PR.guiltyStatementSection,
guiltyParty,
damagedParty,
guiltyParty,
)
: undefined,
buildFanavaranCodesSection(claim),
buildEvaluationSection(claim),
buildAccidentReportSection(blameContext, claim, damagedParty),

View File

@@ -9,7 +9,11 @@ export const PR = {
damagedCarBodyInsuranceSection: "بیمه بدنه زیان‌دیده",
guiltyThirdPartyInsuranceSection: "بیمه شخص ثالث مقصر",
guiltyCarBodyInsuranceSection: "بیمه بدنه مقصر",
damagedVehicleSection: "اطلاعات خودروی زیان‌دیده",
guiltyVehicleSection: "اطلاعات خودروی مقصر",
vehicleSection: "اطلاعات خودرو",
damagedStatementSection: "اظهارات و اقرار زیان‌دیده",
guiltyStatementSection: "اظهارات و اقرار مقصر",
timelineSection: "زمان‌بندی پرونده",
fanavaranSection: "کدهای فناوران",
evaluationSection: "نتیجه ارزیابی",
@@ -43,6 +47,10 @@ export const PR = {
evaluationExpert: "کارشناس ارزیاب",
evaluationSubmittedAt: "تاریخ و ساعت ثبت ارزیابی",
evaluationResponse: "پاسخ / توضیحات کارشناس",
admitsGuilt: "اقرار به تقصیر",
claimsDamage: "ادعای خسارت",
acceptsExpertOpinion: "پذیرش نظر کارشناس",
partyRole: "نقش طرف",
data: "اطلاعات",
date: "تاریخ",
time: "زمان",

View File

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

View File

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

View File

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

View File

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

View File

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