diff --git a/src/expert-insurer/dto/insurer-reports.dto.ts b/src/expert-insurer/dto/insurer-reports.dto.ts new file mode 100644 index 0000000..588c852 --- /dev/null +++ b/src/expert-insurer/dto/insurer-reports.dto.ts @@ -0,0 +1,186 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +// --------------------------------------------------------------------------- +// Query helpers +// --------------------------------------------------------------------------- + +export type ExpertKindFilter = "all" | "expert" | "damage_expert"; + +export class InsurerReportQueryDto { + @ApiPropertyOptional({ description: "Start of date range (ISO string)" }) + from?: string; + + @ApiPropertyOptional({ description: "End of date range (ISO string)" }) + to?: string; + + @ApiPropertyOptional({ + enum: ["all", "expert", "damage_expert"], + description: + "Filter by expert kind. `expert` = blame-panel expert; `damage_expert` = claim damage expert; `all` (default) = both.", + }) + expertKind?: ExpertKindFilter; +} + +export class InsurerWorkLogQueryDto extends InsurerReportQueryDto { + @ApiPropertyOptional({ type: Number, description: "Page number (1-based)" }) + page?: number; + + @ApiPropertyOptional({ type: Number, description: "Items per page" }) + limit?: number; +} + +// --------------------------------------------------------------------------- +// 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; + + @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.", + }) + 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; +} + +// --------------------------------------------------------------------------- +// Top files +// --------------------------------------------------------------------------- + +export class TopFileUserRatingDto { + @ApiPropertyOptional({ description: "User's free-text comment" }) + comment?: string | null; + + @ApiPropertyOptional({ description: "User's overall evaluation score (0–5)" }) + overallEvaluation?: number | null; +} + +export class InsurerTopFileDto { + @ApiProperty({ description: "Public file ID — use for front-end link مشاهده پرونده" }) + publicId: string; + + @ApiProperty({ description: "ISO creation datetime" }) + createdAt: string; + + @ApiProperty({ description: "Combined insurer + user rating blend (0–5)" }) + combinedScore: number; + + @ApiProperty({ type: TopFileUserRatingDto }) + userRating: TopFileUserRatingDto; +} + +// --------------------------------------------------------------------------- +// Top experts +// --------------------------------------------------------------------------- + +export class InsurerTopExpertDto { + @ApiProperty() + _id: string; + + @ApiProperty() + fullName: string; + + @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: [InsurerTopExpertDto], description: "Top damage experts (claim)" }) + claimExperts: InsurerTopExpertDto[]; +} + +// --------------------------------------------------------------------------- +// Work-log (mirrors reports.dto.ts shapes; exposed from this module for swagger) +// --------------------------------------------------------------------------- + +export class InsurerWorkLogEntryDto { + @ApiProperty() + expertId: string; + + @ApiProperty() + fullName: string; + + @ApiProperty({ enum: ["expert", "damage_expert"] }) + expertKind: "expert" | "damage_expert"; + + @ApiProperty({ description: "Distinct files in HANDLED state up to cutoff" }) + totalHandled: number; + + @ApiProperty({ description: "Distinct files currently CHECKED but not yet HANDLED" }) + currentlyChecking: number; + + @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).", + }) + distinctFilesCheckedInPeriod: number; +} + +export class InsurerWorkLogResponseDto { + @ApiProperty({ type: [InsurerWorkLogEntryDto] }) + experts: InsurerWorkLogEntryDto[]; + + @ApiProperty({ description: "Total experts in roster" }) + total: number; +} diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index 71d9dc6..1cfcb83 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -39,6 +39,13 @@ import { CreateFileMakerByInsurerDto, CreateFileReviewerByInsurerDto, } from "./dto/create-insurer-expert.dto"; +import { + InsurerStatisticsDto, + InsurerTopExpertsDto, + InsurerTopFileDto, + InsurerWorkLogResponseDto, +} from "./dto/insurer-reports.dto"; +import { ReportsService } from "src/reports/reports.service"; @Controller("expert-insurer") @ApiTags("expert-insurer-panel") @@ -46,25 +53,18 @@ import { @UseGuards(LocalActorAuthGuard, RolesGuard) @Roles(RoleEnum.COMPANY) export class ExpertInsurerController { - constructor(private readonly expertInsurerService: ExpertInsurerService) {} + constructor( + private readonly expertInsurerService: ExpertInsurerService, + private readonly reportsService: ReportsService, + ) {} + + // ─── Branch management ──────────────────────────────────────────────────── @Get("branches") @ApiQuery({ name: "search", required: false, type: String }) - @ApiQuery({ - name: "from", - required: false, - description: "Optional start datetime (ISO string)", - }) - @ApiQuery({ - name: "to", - required: false, - description: "Optional end datetime (ISO string)", - }) - @ApiQuery({ - name: "isActive", - required: false, - description: "Filter active state (true/false)", - }) + @ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" }) + @ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" }) + @ApiQuery({ name: "isActive", required: false, description: "Filter active state (true/false)" }) async getInsuranceBranches( @CurrentUser() insurer, @Query("search") search?: string, @@ -72,9 +72,7 @@ export class ExpertInsurerController { @Query("to") to?: string, @Query("isActive") isActive?: string, ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); return await this.expertInsurerService.retrieveInsuranceBranches( insurer.clientKey, { search, from, to, isActive }, @@ -83,18 +81,9 @@ export class ExpertInsurerController { @Post("branches") @ApiBody({ type: CreateBranchDto }) - async addBranch( - @CurrentUser() insurer, - @Body() createBranchDto: CreateBranchDto, - ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } - - return await this.expertInsurerService.addBranch( - insurer.clientKey, - createBranchDto, - ); + async addBranch(@CurrentUser() insurer, @Body() createBranchDto: CreateBranchDto) { + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); + return await this.expertInsurerService.addBranch(insurer.clientKey, createBranchDto); } @Put("branches/:branchId/status") @@ -111,10 +100,7 @@ export class ExpertInsurerController { @Param("branchId") branchId: string, @Body("isActive") isActive: unknown, ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } - // Accept native boolean (JSON body) or string coercion (legacy query/form usage) + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); let active: boolean; if (typeof isActive === "boolean") { active = isActive; @@ -125,60 +111,38 @@ export class ExpertInsurerController { } active = ["true", "1", "yes"].includes(normalized); } - return this.expertInsurerService.setBranchActive( - insurer.clientKey, - branchId, - active, - ); + return this.expertInsurerService.setBranchActive(insurer.clientKey, branchId, active); } + // ─── Expert management ──────────────────────────────────────────────────── + @Post("experts/blame") @ApiBody({ type: CreateBlameExpertByInsurerDto }) - async addBlameExpert( - @CurrentUser() insurer, - @Body() body: CreateBlameExpertByInsurerDto, - ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } + async addBlameExpert(@CurrentUser() insurer, @Body() body: CreateBlameExpertByInsurerDto) { + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); return this.expertInsurerService.addBlameExpert(insurer.clientKey, body); } @Post("experts/claim") @ApiBody({ type: CreateClaimExpertByInsurerDto }) - async addClaimExpert( - @CurrentUser() insurer, - @Body() body: CreateClaimExpertByInsurerDto, - ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } + async addClaimExpert(@CurrentUser() insurer, @Body() body: CreateClaimExpertByInsurerDto) { + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); return this.expertInsurerService.addClaimExpert(insurer.clientKey, body); } @Post("experts/file-maker") @ApiBody({ type: CreateFileMakerByInsurerDto }) @ApiOperation({ summary: "Create a FileMaker account under this insurer" }) - async addFileMaker( - @CurrentUser() insurer, - @Body() body: CreateFileMakerByInsurerDto, - ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } + async addFileMaker(@CurrentUser() insurer, @Body() body: CreateFileMakerByInsurerDto) { + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); return this.expertInsurerService.addFileMaker(insurer.clientKey, body); } @Post("experts/file-reviewer") @ApiBody({ type: CreateFileReviewerByInsurerDto }) @ApiOperation({ summary: "Create a FileReviewer account under this insurer" }) - async addFileReviewer( - @CurrentUser() insurer, - @Body() body: CreateFileReviewerByInsurerDto, - ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } + async addFileReviewer(@CurrentUser() insurer, @Body() body: CreateFileReviewerByInsurerDto) { + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); return this.expertInsurerService.addFileReviewer(insurer.clientKey, body); } @@ -190,37 +154,140 @@ export class ExpertInsurerController { @Query("response_count") count: number, @CurrentUser() actor, ) { - return await this.expertInsurerService.retrieveAllExpertsOfClient( - actor, - page, - count, - ); + return await this.expertInsurerService.retrieveAllExpertsOfClient(actor, page, count); } + // ─── Reports: statistics ────────────────────────────────────────────────── + + @Get("statistics") + @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 رضایت کاربران.", + }) + @ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string) — restricts counted claim portfolio" }) + @ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" }) + @ApiResponse({ status: 200, type: InsurerStatisticsDto }) + async getExpertStatistics( + @CurrentUser() actor, + @Query("from") from?: string, + @Query("to") to?: string, + ) { + return await this.expertInsurerService.getExpertStatisticsReport(actor, { from, to }); + } + + // ─── Reports: top files ─────────────────────────────────────────────────── + + @Get("top-files") + @ApiOperation({ + summary: "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.", + }) + @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" }) + @ApiResponse({ status: 200, type: [InsurerTopFileDto] }) + async getTopFiles( + @CurrentUser() insurer, + @Query("from") from?: string, + @Query("to") to?: string, + ) { + return await this.expertInsurerService.getTopFilesForClient(insurer.clientKey, { from, to }); + } + + // ─── Reports: top experts (canonical + alias) ───────────────────────────── + @Get("experts/top") @ApiOperation({ summary: "Top blame vs claim experts for this insurer", description: - "Response has two arrays: `blameExperts` (roster from expert / blame files) and `claimExperts` (damage-expert roster / claim files). Each item includes `overallAverageRating` derived from ratings stored on those files plus user ratings where present.", + "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).", }) - async getTopExperts(@CurrentUser() actor) { - return await this.expertInsurerService.getTopExpertsForClient(actor); + @ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" }) + @ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" }) + @ApiResponse({ status: 200, type: InsurerTopExpertsDto }) + async getTopExperts( + @CurrentUser() actor, + @Query("from") from?: string, + @Query("to") to?: string, + ) { + return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to }); } + /** + * Alias: some BFF/frontend paths call /top-experts instead of /experts/top. + * Both routes point to the same handler. + */ + @Get("top-experts") + @ApiOperation({ + summary: "Alias for GET experts/top — top blame + claim experts", + description: "Same response as `GET experts/top`. Provided for front-end compatibility.", + }) + @ApiQuery({ name: "from", required: false }) + @ApiQuery({ name: "to", required: false }) + @ApiResponse({ status: 200, type: InsurerTopExpertsDto }) + async getTopExpertsAlias( + @CurrentUser() actor, + @Query("from") from?: string, + @Query("to") to?: string, + ) { + return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to }); + } + + // ─── Reports: work-log (delegates to ReportsService) ───────────────────── + + @Get("expert-work-log") + @ApiOperation({ + summary: "نمایش جدولی — per-expert work log (blame panel + damage experts)", + description: + "Rows for every tenant expert in the `expert` (blame) and `damage-expert` (claim) collections — same scope as GET experts/list, field-expert excluded. " + + "Columns: expertId, fullName, expertKind, totalHandled (files in HANDLED state up to `to`/now), " + + "currentlyChecking (CHECKED but not yet HANDLED up to `to`/now), " + + "distinctFilesCheckedInPeriod (distinct files with ≥1 CHECKED event inside from–to window; all-time when omitted). " + + "Optional `expertKind` filter: `expert` = blame panel, `damage_expert` = claim damage experts, `all` (default).", + }) + @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: "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 }) + async getExpertWorkLog( + @CurrentUser() actor, + @Query("expertKind") expertKind?: "all" | "expert" | "damage_expert", + @Query("from") from?: string, + @Query("to") to?: string, + @Query("page") page?: string, + @Query("limit") limit?: string, + ) { + return this.reportsService.getInsurerExpertWorkLog(actor, { + expertKind, + from, + to, + page: page ? parseInt(page, 10) : undefined, + limit: limit ? parseInt(limit, 10) : undefined, + }); + } + + // ─── Files ──────────────────────────────────────────────────────────────── + @Get("files") @ApiOperation({ summary: "List insurer files (blame + claim merged by publicId)", description: "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).", }) - async getAllFiles( - @CurrentUser() insurer, - @Query() query: ListQueryV2Dto, - ) { - return await this.expertInsurerService.retrieveAllFilesOfClient( - insurer.clientKey, - query, - ); + async getAllFiles(@CurrentUser() insurer, @Query() query: ListQueryV2Dto) { + return await this.expertInsurerService.retrieveAllFilesOfClient(insurer.clientKey, query); } @Get("report/unified-file-statuses") @@ -234,10 +301,7 @@ export class ExpertInsurerController { @CurrentUser() actor, @Query() query: UnifiedFileStatusReportQueryDto, ): Promise { - return await this.expertInsurerService.getInsurerUnifiedFileStatusReport( - actor, - query, - ); + return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(actor, query); } @Get("files/:publicId/timeline") @@ -247,26 +311,14 @@ export class ExpertInsurerController { description: "Returns a chronological list of all history events for the blame and/or claim associated with the given publicId. Each event has: source, type, timestamp, actor, metadata.", }) - async getFileTimeline( - @CurrentUser() insurer, - @Param("publicId") publicId: string, - ) { - return await this.expertInsurerService.getFileTimeline( - insurer.clientKey, - publicId, - ); + async getFileTimeline(@CurrentUser() insurer, @Param("publicId") publicId: string) { + return await this.expertInsurerService.getFileTimeline(insurer.clientKey, publicId); } @Get("files/:publicId") @ApiParam({ name: "publicId" }) - async getFileDetailsByPublicId( - @CurrentUser() insurer, - @Param("publicId") publicId: string, - ) { - return await this.expertInsurerService.retrieveFileDetailsByPublicId( - insurer.clientKey, - publicId, - ); + async getFileDetailsByPublicId(@CurrentUser() insurer, @Param("publicId") publicId: string) { + return await this.expertInsurerService.retrieveFileDetailsByPublicId(insurer.clientKey, publicId); } @ApiBody({ @@ -275,56 +327,14 @@ export class ExpertInsurerController { schema: { type: "object", properties: { - collisionMethodAccuracy: { - type: "number", - minimum: 0, - maximum: 5, - example: 4, - description: "تشخیص درست نحوه برخورد", - }, - evaluationTimeliness: { - type: "number", - minimum: 0, - maximum: 5, - example: 3, - description: "زمان ارزیابی", - }, - accidentCauseAccuracy: { - type: "number", - minimum: 0, - maximum: 5, - example: 5, - description: "تشخیص درست علت تصادف", - }, - guiltyVehicleIdentification: { - type: "number", - minimum: 0, - maximum: 5, - example: 4, - description: "تشخیص درست وسیله نقلیه مقصر", - }, - botRating: { - type: "number", - minimum: 0, - maximum: 5, - example: 4, - description: "برای امتیاز دادن به عملکرد بات", - }, - }, - required: [ - "collisionMethodAccuracy", - "evaluationTimeliness", - "accidentCauseAccuracy", - "guiltyVehicleIdentification", - "botRating", - ], - example: { - collisionMethodAccuracy: 4, - evaluationTimeliness: 3, - accidentCauseAccuracy: 5, - guiltyVehicleIdentification: 4, - botRating: 4, + collisionMethodAccuracy: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست نحوه برخورد" }, + evaluationTimeliness: { type: "number", minimum: 0, maximum: 5, example: 3, description: "زمان ارزیابی" }, + accidentCauseAccuracy: { type: "number", minimum: 0, maximum: 5, example: 5, description: "تشخیص درست علت تصادف" }, + guiltyVehicleIdentification: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست وسیله نقلیه مقصر" }, + botRating: { type: "number", minimum: 0, maximum: 5, example: 4, description: "برای امتیاز دادن به عملکرد بات" }, }, + required: ["collisionMethodAccuracy", "evaluationTimeliness", "accidentCauseAccuracy", "guiltyVehicleIdentification", "botRating"], + example: { collisionMethodAccuracy: 4, evaluationTimeliness: 3, accidentCauseAccuracy: 5, guiltyVehicleIdentification: 4, botRating: 4 }, }, }) @ApiParam({ name: "publicId" }) @@ -334,23 +344,7 @@ export class ExpertInsurerController { @Param("publicId") publicId: string, @Body() rating: FileRating, ) { - return await this.expertInsurerService.rateExpertByPublicId( - publicId, - rating, - insurer.clientKey, - ); - } - - @Get("top-files") - async getTopFiles(@CurrentUser() insurer) { - return await this.expertInsurerService.getTopFilesForClient( - insurer.clientKey, - ); - } - - @Get("statistics") - async getExpertStatistics(@CurrentUser() actor) { - return await this.expertInsurerService.getExpertStatisticsReport(actor); + return await this.expertInsurerService.rateExpertByPublicId(publicId, rating, insurer.clientKey); } @Get("report/status-counts") @@ -359,28 +353,18 @@ export class ExpertInsurerController { deprecated: true, description: "Prefer GET report/unified-file-statuses for full calculatable blame+claim statuses.", }) - @ApiQuery({ - name: "from", - required: false, - description: "Optional start datetime (ISO string)", - }) - @ApiQuery({ - name: "to", - required: false, - description: "Optional end datetime (ISO string)", - }) + @ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" }) + @ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" }) async getInsurerStatusReport( @CurrentUser() actor, @Query("from") from?: string, @Query("to") to?: string, ) { - return await this.expertInsurerService.getInsurerFileStatusCounts( - actor, - from, - to, - ); + return await this.expertInsurerService.getInsurerFileStatusCounts(actor, from, to); } + // ─── Expert detail ──────────────────────────────────────────────────────── + @ApiParam({ name: "expertId" }) @ApiOperation({ summary: "Files handled by one roster expert (summary rows)", @@ -389,13 +373,7 @@ export class ExpertInsurerController { }) @Get("/:expertId") async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) { - if (!Types.ObjectId.isValid(id)) { - throw new BadRequestException("Invalid expert ID"); - } - - return await this.expertInsurerService.getAllFilesForInsurerExpert( - id, - insurer.clientKey, - ); + if (!Types.ObjectId.isValid(id)) throw new BadRequestException("Invalid expert ID"); + return await this.expertInsurerService.getAllFilesForInsurerExpert(id, insurer.clientKey); } } diff --git a/src/expert-insurer/expert-insurer.module.ts b/src/expert-insurer/expert-insurer.module.ts index 5d9226b..f0624ca 100644 --- a/src/expert-insurer/expert-insurer.module.ts +++ b/src/expert-insurer/expert-insurer.module.ts @@ -1,6 +1,7 @@ import { Module } from "@nestjs/common"; import { MongooseModule } from "@nestjs/mongoose"; import { AuthModule } from "src/auth/auth.module"; +import { ReportsModule } from "src/reports/reports.module"; import { ClaimRequestManagementModel, ClaimRequestManagementSchema, @@ -29,6 +30,7 @@ import { HashModule } from "src/utils/hash/hash.module"; HashModule, UsersModule, ClientModule, + ReportsModule, MongooseModule.forFeature([ { name: ClaimRequestManagementModel.name, diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index 8b82424..238617e 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -971,24 +971,38 @@ export class ExpertInsurerService { * 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): Promise<{ - blameExperts: any[]; - claimExperts: any[]; - }> { + 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); + .slice(0, 10) + .map(slim); const claimExperts = rows .filter((e) => e.expertKind === "claim") .sort(byRatingDesc) - .slice(0, 10); + .slice(0, 10) + .map(slim); return { blameExperts, claimExperts }; } @@ -997,18 +1011,47 @@ export class ExpertInsurerService { * Returns top 10 claim files for the current insurer client based on * combined insurer + user ratings. */ - async getTopFilesForClient(insurerId: string): Promise { - const claimFiles = await this.getClientClaimFiles( - this.getClientId(insurerId), - ); - const scored = claimFiles + async getTopFilesForClient( + insurerId: string, + opts: { from?: string; to?: string } = {}, + ): Promise> { + const fromDate = opts.from ? new Date(opts.from) : undefined; + const toDate = opts.to ? new Date(opts.to) : undefined; + + 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; + }); + } + + return claimFiles .map((file) => { const combinedScore = this.getCombinedFileScore(file); if (combinedScore === null) return null; - return { ...file, combinedScore }; + const ur = file?.userRating; + return { + publicId: String(file.publicId ?? ""), + 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, + }, + }; }) - .filter((f) => f !== null); - return scored + .filter((f): f is NonNullable => f !== null) .sort((a, b) => b.combinedScore - a.combinedScore) .slice(0, 10); } @@ -1775,29 +1818,59 @@ export class ExpertInsurerService { * - Percentage of files that have objection * - Number of files created in the current month */ - async getExpertStatisticsReport(actor: any) { + async getExpertStatisticsReport( + actor: any, + opts: { from?: string; to?: string } = {}, + ) { const clientObjectId = this.getClientId(actor); - const claimFiles = await this.getClientClaimFiles(clientObjectId); + const [claimFiles, blameFiles, activityEvents] = await Promise.all([ + this.getClientClaimFiles(clientObjectId), + this.getClientBlameFiles(clientObjectId), + this.expertFileActivityDbService.findByTenant(clientObjectId), + ]); - // Calculate current month date range + // 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, - ); - - // Filter files created this month - const filesThisMonth = claimFiles.filter((file) => { - const createdAt = new Date(file.createdAt); - return createdAt >= monthStart && createdAt <= monthEnd; + 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; }); - // Calculate statistics + // totalFilesReviewed: distinct claim files with at least one CHECKED activity + const checkedFileIds = new Set(); + for (const ev of activityEvents) { + if (ev.eventType === ExpertFileActivityType.CHECKED) { + checkedFileIds.add(String(ev.fileId)); + } + } + // Intersect with this insurer's claim file ids + const claimFileIds = new Set(claimFiles.map((f) => String(f._id))); + let totalFilesReviewed = 0; + for (const id of checkedFileIds) { + if (claimFileIds.has(id)) totalFilesReviewed++; + } + + // inPersonAccompaniedCount: blame files where expertInitiated === true AND creationMethod === IN_PERSON + const inPersonAccompaniedCount = blameFiles.filter( + (b) => !!(b as any).expertInitiated && (b as any).creationMethod === "IN_PERSON", + ).length; + + // Per-file statistics over the (optionally date-ranged) claim portfolio let totalInsurerRatings = 0; let totalBotRatings = 0; let filesWithInsurerRating = 0; @@ -1805,103 +1878,88 @@ export class ExpertInsurerService { let filesWithUserRating = 0; let filesWithObjection = 0; - for (const file of claimFiles) { + // 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; - const objection = file?.objection; - // Check for insurer rating (excluding botRating) if (insurerRating) { const insurerValues = [ insurerRating.collisionMethodAccuracy, insurerRating.evaluationTimeliness, insurerRating.accidentCauseAccuracy, insurerRating.guiltyVehicleIdentification, - ].filter( - (val): val is number => typeof val === "number" && !isNaN(val), - ); - + ].filter((v): v is number => typeof v === "number" && !isNaN(v)); if (insurerValues.length > 0) { filesWithInsurerRating++; - const insurerAvg = - insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length; - totalInsurerRatings += insurerAvg; + 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; } } - // Check for bot rating (if botRating field exists in rating object) - const botRating = (insurerRating as any)?.botRating; - if ( - botRating !== undefined && - !isNaN(botRating) && - typeof botRating === "number" - ) { - filesWithBotRating++; - totalBotRatings += botRating; - } - - // Check for user rating 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++; + } } - // Check for objection - if (objection) { - filesWithObjection++; - } + if (file?.objection) filesWithObjection++; } - // Calculate percentages - const totalFiles = claimFiles.length; + const totalFiles = rangedClaimFiles.length; + const averageInsurerRating = filesWithInsurerRating > 0 ? totalInsurerRatings / filesWithInsurerRating : 0; + const averageBotRating = filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0; - // Calculate average insurer rating (excluding botRating) and average bot rating - const averageInsurerRating = - filesWithInsurerRating > 0 - ? totalInsurerRatings / filesWithInsurerRating + // 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 averageBotRating = - filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0; - // Calculate percentage: (averageInsurerRating / averageBotRating) * 100 - // This shows what percentage the insurer rating is compared to bot rating const insurerToBotPercentage = averageBotRating > 0 && averageInsurerRating > 0 - ? parseFloat( - ((averageInsurerRating / averageBotRating) * 100).toFixed(2), - ) - : 0; - - const userRatingPercentage = - totalFiles > 0 - ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) - : 0; - - const objectionPercentage = - totalFiles > 0 - ? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2)) + ? parseFloat(((averageInsurerRating / averageBotRating) * 100).toFixed(2)) : 0; return { - insurerToBotRatingPercentage: insurerToBotPercentage, - userRatingPercentage: userRatingPercentage, - objectionPercentage: objectionPercentage, + // New / corrected KPI fields + totalFilesReviewed, + averageUserRatingPercentage, + inPersonAccompaniedCount, + // Unchanged filesCreatedThisMonth: filesThisMonth.length, - totalFiles: totalFiles, + 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: - filesWithInsurerRating > 0 - ? parseFloat( - (totalInsurerRatings / filesWithInsurerRating).toFixed(2), - ) - : 0, - averageBotRating: - filesWithBotRating > 0 - ? parseFloat((totalBotRatings / filesWithBotRating).toFixed(2)) - : 0, + averageInsurerRating: parseFloat(averageInsurerRating.toFixed(2)), + averageBotRating: parseFloat(averageBotRating.toFixed(2)), }, }; } diff --git a/src/reports/reports.module.ts b/src/reports/reports.module.ts index ddcb007..f5840b2 100644 --- a/src/reports/reports.module.ts +++ b/src/reports/reports.module.ts @@ -13,5 +13,6 @@ import { ReportsService } from "./reports.service"; ], controllers: [ReportsController], providers: [ReportsService], + exports: [ReportsService], }) export class ReportsModule {} diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts index 55a9e90..4d5e230 100644 --- a/src/reports/reports.service.ts +++ b/src/reports/reports.service.ts @@ -436,19 +436,47 @@ export class ReportsService { }); } - async getInsurerExpertWorkLog(actor: { - clientKey?: string; - }): Promise { + async getInsurerExpertWorkLog( + actor: { clientKey?: string }, + opts: { + expertKind?: "all" | "expert" | "damage_expert"; + from?: string; + to?: string; + page?: number; + limit?: number; + } = {}, + ): Promise<{ 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(); - const epoch = new Date(0); - const entries = this.buildWorkLogEntries(expertRows, events, now, { - from: epoch, - to: now, + + // 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, }); - return new InsurerExpertWorkLogResponseDtoRs(entries); + + // 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 }; } async getInsurerExpertWorkLogPerMonth(actor: {