1
0
forked from Yara724/api

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

Reviewed-on: Yara724/api#2
This commit is contained in:
2026-01-28 15:48:33 +03:30
9 changed files with 305 additions and 25 deletions

View File

@@ -35,6 +35,7 @@ import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto";
import { UserCommentDto } from "./dto/user-comment.dto"; import { UserCommentDto } from "./dto/user-comment.dto";
import { UserObjectionDto } from "./dto/user-objection.dto"; import { UserObjectionDto } from "./dto/user-objection.dto";
import { InPersonVisitDto } from "./dto/in-person-visit.dto"; import { InPersonVisitDto } from "./dto/in-person-visit.dto";
import { UserRatingDto } from "./dto/user-rating.dto";
@Controller("claim-request-management") @Controller("claim-request-management")
@ApiTags("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") @Patch("request/reply/:claimRequestId/:partId/upload-factor")
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })

View File

@@ -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 { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
import { VideoCaptureDbService } from "./entites/db-service/video-capture.db.service"; import { VideoCaptureDbService } from "./entites/db-service/video-capture.db.service";
import { ClaimRequiredDocumentDbService } from "./entites/db-service/claim-required-document.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 { ImageRequiredModel } from "./entites/schema/image-required.schema";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; 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 { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
import { BranchDbService } from "src/client/entities/db-service/branch.db.service"; 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 { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { UserRatingDto } from "./dto/user-rating.dto";
@Injectable() @Injectable()
export class ClaimRequestManagementService { 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<UserClaimRating> {
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( async uploadClaimFactor(
claimId: string, claimId: string,
partId: string, partId: string,

View File

@@ -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;
}

View File

@@ -176,6 +176,28 @@ export class UserObjection {
typeOfDamage: string; 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 }) @Schema({ _id: false })
export class FileRating { export class FileRating {
@Prop({ type: Number, min: 0, max: 5 }) @Prop({ type: Number, min: 0, max: 5 })
@@ -315,6 +337,10 @@ export class ClaimRequestManagementModel {
@Prop({ type: FileRating, required: false }) @Prop({ type: FileRating, required: false })
rating?: FileRating; rating?: FileRating;
// User-facing satisfaction rating for this claim
@Prop({ type: UserClaimRating, required: false })
userRating?: UserClaimRating;
@Prop({ type: String, required: false }) @Prop({ type: String, required: false })
visitLocation?: string; visitLocation?: string;

View File

@@ -285,7 +285,7 @@ export class ExpertBlameService {
// The file is locked by someone else, or lock expired but status hasn't updated yet // 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 // Only block if lock is still active and not by current expert
if (request.lockFile && !isLockExpired && !isLockedByCurrentExpert) { if (request.lockFile && !isLockExpired && !isLockedByCurrentExpert) {
throw new BadRequestException("Request is locked by another expert"); throw new BadRequestException("Request is locked by another expert");
} }
} }

View File

@@ -941,7 +941,7 @@ export class ExpertClaimService {
if (!isLockedByCurrentUser) { if (!isLockedByCurrentUser) {
return false; return false;
} }
// Also check if lock has expired (unlockTime has passed) // Also check if lock has expired (unlockTime has passed)
if (request.unlockTime) { if (request.unlockTime) {
@@ -1008,8 +1008,8 @@ export class ExpertClaimService {
const unlockTime = new Date(request.unlockTime).getTime(); const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now(); const now = Date.now();
if (now >= unlockTime) { if (now >= unlockTime) {
throw new ForbiddenException("Your time has expired"); throw new ForbiddenException("Your time has expired");
} }
} else if (request?.unlockTime == null && !request?.objection) { } else if (request?.unlockTime == null && !request?.objection) {
throw new ForbiddenException("Your time has expired"); throw new ForbiddenException("Your time has expired");
} }

View File

@@ -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" }) @ApiParam({ name: "expertId" })
@ApiQuery({ name: "role", enum: ["claim", "blame"] }) @ApiQuery({ name: "role", enum: ["claim", "blame"] })
@Get("/:expertId") @Get("/:expertId")

View File

@@ -100,20 +100,61 @@ export class ExpertInsurerService {
Record<string, number[]> Record<string, number[]>
> = {}; > = {};
const processRatings = (expertId: string, rating: any) => { /**
if (!expertId || !rating || typeof rating !== "object") { * Converts insurer FileRating and optional userRating into a single
return; * 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( const insurerValues = insurerRating
(val): val is number => typeof val === "number" && !isNaN(val), ? 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]) if (!expertTotalRatingsMap[expertId])
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]) { if (!expertRatingsByCategoryMap[expertId]) {
expertRatingsByCategoryMap[expertId] = {}; expertRatingsByCategoryMap[expertId] = {};
} }
@@ -129,12 +170,12 @@ export class ExpertInsurerService {
for (const file of blameFiles) { for (const file of blameFiles) {
const expertId = file?.actorLocked?.actorId?.toString(); const expertId = file?.actorLocked?.actorId?.toString();
processRatings(expertId, file.rating); processRatings(expertId, file);
} }
for (const file of claimFiles) { for (const file of claimFiles) {
const expertId = file?.damageExpertReply?.actorDetail?.actorId; const expertId = file?.damageExpertReply?.actorDetail?.actorId;
processRatings(expertId, file.rating); processRatings(expertId, file);
} }
const allExperts = allExpertsRaw.map((expert) => { 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<any[]> {
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<any[]> {
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") { async getAllFilesByExpertAndRole(expertId: string, role: "claim" | "blame") {
const expertObjectId = new Types.ObjectId(expertId); const expertObjectId = new Types.ObjectId(expertId);

View File

@@ -238,16 +238,16 @@ export class ReportsService {
} }
} else { } else {
// Fallback to simple count if actor not provided (shouldn't happen for damage_expert) // Fallback to simple count if actor not provided (shouldn't happen for damage_expert)
for (const status of statuses) { for (const status of statuses) {
const filter = { const filter = {
claimStatus: status, claimStatus: status,
userClientKey: new Types.ObjectId(client), userClientKey: new Types.ObjectId(client),
}; };
const count = const count =
await this.claimRequestManagementDbService.countByFilter(filter); await this.claimRequestManagementDbService.countByFilter(filter);
data[status] = count; data[status] = count;
data.all += count; data.all += count;
} }
} }