From ae53ef60320fd134bcaca698eca425aef7b95865 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 28 Jan 2026 15:34:38 +0330 Subject: [PATCH] YARA-739, YARA-740, YARA-741 --- .../claim-request-management.controller.ts | 20 +++ .../claim-request-management.service.ts | 52 +++++- .../dto/user-rating.dto.ts | 39 +++++ .../schema/claim-request-management.schema.ts | 26 +++ src/expert-blame/expert-blame.service.ts | 2 +- src/expert-claim/expert-claim.service.ts | 8 +- .../expert-insurer.controller.ts | 12 ++ src/expert-insurer/expert-insurer.service.ts | 153 ++++++++++++++++-- src/reports/reports.service.ts | 18 +-- 9 files changed, 305 insertions(+), 25 deletions(-) create mode 100644 src/claim-request-management/dto/user-rating.dto.ts diff --git a/src/claim-request-management/claim-request-management.controller.ts b/src/claim-request-management/claim-request-management.controller.ts index 9823fb8..d582ecb 100644 --- a/src/claim-request-management/claim-request-management.controller.ts +++ b/src/claim-request-management/claim-request-management.controller.ts @@ -35,6 +35,7 @@ import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto"; import { UserCommentDto } from "./dto/user-comment.dto"; import { UserObjectionDto } from "./dto/user-objection.dto"; import { InPersonVisitDto } from "./dto/in-person-visit.dto"; +import { UserRatingDto } from "./dto/user-rating.dto"; @Controller("claim-request-management") @ApiTags("claim-request-management") @@ -400,6 +401,25 @@ export class ClaimRequestManagementController { ); } + /** + * User satisfaction rating for a completed claim file. + * Only the damaged user (claim owner) can rate their claim after it is closed. + */ + @Put("request/:claimRequestId/user-rating") + @ApiParam({ name: "claimRequestId" }) + @ApiBody({ type: UserRatingDto }) + async addUserRating( + @Param("claimRequestId") claimRequestId: string, + @Body() ratingDto: UserRatingDto, + @CurrentUser() user, + ) { + return await this.claimRequestManagementService.addUserRating( + claimRequestId, + user, + ratingDto, + ); + } + @Patch("request/reply/:claimRequestId/:partId/upload-factor") @ApiConsumes("multipart/form-data") @ApiParam({ name: "claimRequestId" }) diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index b036b35..50cfa0e 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -37,13 +37,17 @@ import { DamageImageDbService } from "./entites/db-service/damage-image.db.servi import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service"; import { VideoCaptureDbService } from "./entites/db-service/video-capture.db.service"; import { ClaimRequiredDocumentDbService } from "./entites/db-service/claim-required-document.db.service"; -import { ClaimRequestManagementModel } from "./entites/schema/claim-request-management.schema"; +import { + ClaimRequestManagementModel, + UserClaimRating, +} from "./entites/schema/claim-request-management.schema"; import { ImageRequiredModel } from "./entites/schema/image-required.schema"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum"; import { BranchDbService } from "src/client/entities/db-service/branch.db.service"; import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; import { RoleEnum } from "src/Types&Enums/role.enum"; +import { UserRatingDto } from "./dto/user-rating.dto"; @Injectable() export class ClaimRequestManagementService { @@ -1568,6 +1572,52 @@ export class ClaimRequestManagementService { }); } + /** + * Allows the damaged user to rate their claim file after completion. + * Only the claim owner (damaged user) can submit this rating and only + * when the claim is in CloseRequest status. + */ + async addUserRating( + claimRequestId: string, + actor: any, + ratingDto: UserRatingDto, + ): Promise { + const claim = await this.claimDbService.findOne(claimRequestId); + + if (!claim) { + throw new NotFoundException("Claim file not found"); + } + + // Only the damaged user (claim owner) can rate the claim + const actorId = actor?.sub; + if (!actorId || String(claim.userId) !== actorId) { + throw new ForbiddenException( + "Only the claim owner can submit a satisfaction rating.", + ); + } + + // Rating is only allowed after the claim is fully closed + if (claim.claimStatus !== ReqClaimStatus.CloseRequest) { + throw new BadRequestException( + "You can only rate a claim after it has been closed.", + ); + } + + const userRating: UserClaimRating = { + progressSpeed: ratingDto.progressSpeed, + registrationEase: ratingDto.registrationEase, + overallEvaluation: ratingDto.overallEvaluation, + comment: ratingDto.comment, + createdAt: new Date(), + }; + + await this.claimDbService.findAndUpdate(claimRequestId, { + $set: { userRating }, + }); + + return userRating; + } + async uploadClaimFactor( claimId: string, partId: string, diff --git a/src/claim-request-management/dto/user-rating.dto.ts b/src/claim-request-management/dto/user-rating.dto.ts new file mode 100644 index 0000000..4571699 --- /dev/null +++ b/src/claim-request-management/dto/user-rating.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class UserRatingDto { + @ApiProperty({ + type: Number, + minimum: 0, + maximum: 5, + description: "سرعت پیشرفت پرونده", + example: 4, + }) + progressSpeed: number; + + @ApiProperty({ + type: Number, + minimum: 0, + maximum: 5, + description: "سهولت ثبت پرونده", + example: 4, + }) + registrationEase: number; + + @ApiProperty({ + type: Number, + minimum: 0, + maximum: 5, + description: "ارزیابی کلی", + example: 5, + }) + overallEvaluation: number; + + @ApiProperty({ + type: String, + required: false, + description: "متن رضایت یا عدم آن", + }) + comment?: string; +} + + diff --git a/src/claim-request-management/entites/schema/claim-request-management.schema.ts b/src/claim-request-management/entites/schema/claim-request-management.schema.ts index f9090ce..77d68a1 100644 --- a/src/claim-request-management/entites/schema/claim-request-management.schema.ts +++ b/src/claim-request-management/entites/schema/claim-request-management.schema.ts @@ -176,6 +176,28 @@ export class UserObjection { typeOfDamage: string; } +@Schema({ _id: false }) +export class UserClaimRating { + // 1. سرعت پیشرفت پرونده (speed of progress) + @Prop({ type: Number, min: 0, max: 5, required: true }) + progressSpeed: number; + + // 2. سهولت ثبت پرونده (ease of registration) + @Prop({ type: Number, min: 0, max: 5, required: true }) + registrationEase: number; + + // 3. ارزیابی کلی (overall evaluation) + @Prop({ type: Number, min: 0, max: 5, required: true }) + overallEvaluation: number; + + // 4. متن رضایت یا عدم آن (optional text feedback) + @Prop({ type: String, required: false }) + comment?: string; + + @Prop({ type: Date, default: () => new Date() }) + createdAt?: Date; +} + @Schema({ _id: false }) export class FileRating { @Prop({ type: Number, min: 0, max: 5 }) @@ -315,6 +337,10 @@ export class ClaimRequestManagementModel { @Prop({ type: FileRating, required: false }) rating?: FileRating; + // User-facing satisfaction rating for this claim + @Prop({ type: UserClaimRating, required: false }) + userRating?: UserClaimRating; + @Prop({ type: String, required: false }) visitLocation?: string; diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 3a66091..d88286f 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -285,7 +285,7 @@ export class ExpertBlameService { // The file is locked by someone else, or lock expired but status hasn't updated yet // Only block if lock is still active and not by current expert if (request.lockFile && !isLockExpired && !isLockedByCurrentExpert) { - throw new BadRequestException("Request is locked by another expert"); + throw new BadRequestException("Request is locked by another expert"); } } diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index f12700b..53844f3 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -941,8 +941,8 @@ export class ExpertClaimService { if (!isLockedByCurrentUser) { return false; - } - + } + // Also check if lock has expired (unlockTime has passed) if (request.unlockTime) { const unlockTime = new Date(request.unlockTime).getTime(); @@ -1008,8 +1008,8 @@ export class ExpertClaimService { const unlockTime = new Date(request.unlockTime).getTime(); const now = Date.now(); if (now >= unlockTime) { - throw new ForbiddenException("Your time has expired"); - } + throw new ForbiddenException("Your time has expired"); + } } else if (request?.unlockTime == null && !request?.objection) { throw new ForbiddenException("Your time has expired"); } diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index c0ae414..b16e826 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -57,6 +57,18 @@ export class ExpertInsurerController { ); } + @Get("top-experts") + async getTopExperts(@CurrentUser() actor) { + return await this.expertInsurerService.getTopExpertsForClient(actor); + } + + @Get("top-files") + async getTopFiles(@CurrentUser() insurer) { + return await this.expertInsurerService.getTopFilesForClient( + insurer.clientKey, + ); + } + @ApiParam({ name: "expertId" }) @ApiQuery({ name: "role", enum: ["claim", "blame"] }) @Get("/:expertId") diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index 9eb1b4d..b1f8d21 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -100,20 +100,61 @@ export class ExpertInsurerService { Record > = {}; - const processRatings = (expertId: string, rating: any) => { - if (!expertId || !rating || typeof rating !== "object") { - return; - } + /** + * Converts insurer FileRating and optional userRating into a single + * combined score for a file. + */ + const getCombinedFileScore = (file: any): number | null => { + const insurerRating = file?.rating; + const userRating = file?.userRating; - const allRatingValues = Object.values(rating).filter( - (val): val is number => typeof val === "number" && !isNaN(val), + const insurerValues = insurerRating + ? Object.values(insurerRating).filter( + (val): val is number => typeof val === "number" && !isNaN(val), + ) + : []; + const insurerAvg = + insurerValues.length > 0 + ? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length + : null; + + const userValues = userRating + ? [ + userRating.progressSpeed, + userRating.registrationEase, + userRating.overallEvaluation, + ].filter((val) => typeof val === "number" && !isNaN(val)) + : []; + const userAvg = + userValues.length > 0 + ? userValues.reduce((a, b) => a + b, 0) / userValues.length + : null; + + const scores = [insurerAvg, userAvg].filter( + (v): v is number => typeof v === "number" && !isNaN(v), ); - if (allRatingValues.length > 0) { + if (scores.length === 0) return null; + return parseFloat( + (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2), + ); + }; + + const processRatings = (expertId: string, file: any) => { + if (!expertId) return; + + const rating = file?.rating; + const combinedScore = getCombinedFileScore(file); + + // Aggregate overall combined score for this expert + if (combinedScore !== null) { if (!expertTotalRatingsMap[expertId]) expertTotalRatingsMap[expertId] = []; - expertTotalRatingsMap[expertId].push(...allRatingValues); + expertTotalRatingsMap[expertId].push(combinedScore); } + // Keep backward-compatible per-category insurer ratings + if (!rating || typeof rating !== "object") return; + if (!expertRatingsByCategoryMap[expertId]) { expertRatingsByCategoryMap[expertId] = {}; } @@ -129,12 +170,12 @@ export class ExpertInsurerService { for (const file of blameFiles) { const expertId = file?.actorLocked?.actorId?.toString(); - processRatings(expertId, file.rating); + processRatings(expertId, file); } for (const file of claimFiles) { const expertId = file?.damageExpertReply?.actorDetail?.actorId; - processRatings(expertId, file.rating); + processRatings(expertId, file); } const allExperts = allExpertsRaw.map((expert) => { @@ -182,6 +223,98 @@ export class ExpertInsurerService { }; } + /** + * Returns top 10 experts for the current insurer client based on + * combined insurer + user ratings on their handled files. + */ + async getTopExpertsForClient(actor): Promise { + const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000); + const experts = result?.experts || []; + + const sorted = [...experts].sort((a, b) => { + const ar = a.overallAverageRating ?? 0; + const br = b.overallAverageRating ?? 0; + return br - ar; + }); + + return sorted.slice(0, 10); + } + + /** + * Returns top 10 claim files for the current insurer client based on + * combined insurer + user ratings. + */ + async getTopFilesForClient(insurerId: string): Promise { + const id = new Types.ObjectId(insurerId); + + const claimFiles = await this.claimRequestManagementModel + .find( + { + userClientKey: id, + }, + { + requestNumber: 1, + userClientKey: 1, + userId: 1, + fullName: 1, + carDetail: 1, + carPlate: 1, + claimStatus: 1, + rating: 1, + userRating: 1, + damageExpertReply: 1, + damageExpertReplyFinal: 1, + createdAt: 1, + }, + ) + .lean(); + + const scored = claimFiles + .map((file) => { + const insurerRating = file?.rating; + const userRating = file?.userRating; + + const insurerValues = insurerRating + ? Object.values(insurerRating).filter( + (val): val is number => typeof val === "number" && !isNaN(val), + ) + : []; + const insurerAvg = + insurerValues.length > 0 + ? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length + : null; + + const userValues = userRating + ? [ + userRating.progressSpeed, + userRating.registrationEase, + userRating.overallEvaluation, + ].filter((val) => typeof val === "number" && !isNaN(val)) + : []; + const userAvg = + userValues.length > 0 + ? userValues.reduce((a, b) => a + b, 0) / userValues.length + : null; + + const scores = [insurerAvg, userAvg].filter( + (v): v is number => typeof v === "number" && !isNaN(v), + ); + if (scores.length === 0) return null; + + const combinedScore = + scores.reduce((a, b) => a + b, 0) / scores.length; + + return { + ...file, + combinedScore: parseFloat(combinedScore.toFixed(2)), + }; + }) + .filter((f) => f !== null); + + const sorted = scored.sort((a, b) => b.combinedScore - a.combinedScore); + return sorted.slice(0, 10); + } + async getAllFilesByExpertAndRole(expertId: string, role: "claim" | "blame") { const expertObjectId = new Types.ObjectId(expertId); diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts index 131559b..e34a808 100644 --- a/src/reports/reports.service.ts +++ b/src/reports/reports.service.ts @@ -238,16 +238,16 @@ export class ReportsService { } } else { // Fallback to simple count if actor not provided (shouldn't happen for damage_expert) - for (const status of statuses) { - const filter = { - claimStatus: status, - userClientKey: new Types.ObjectId(client), - }; + for (const status of statuses) { + const filter = { + claimStatus: status, + userClientKey: new Types.ObjectId(client), + }; - const count = - await this.claimRequestManagementDbService.countByFilter(filter); - data[status] = count; - data.all += count; + const count = + await this.claimRequestManagementDbService.countByFilter(filter); + data[status] = count; + data.all += count; } }