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:
@@ -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" })
|
||||
|
||||
@@ -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<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(
|
||||
claimId: string,
|
||||
partId: string,
|
||||
|
||||
39
src/claim-request-management/dto/user-rating.dto.ts
Normal file
39
src/claim-request-management/dto/user-rating.dto.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -100,20 +100,61 @@ export class ExpertInsurerService {
|
||||
Record<string, number[]>
|
||||
> = {};
|
||||
|
||||
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<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") {
|
||||
const expertObjectId = new Types.ObjectId(expertId);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user