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 { 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" })
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -941,8 +941,8 @@ 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) {
|
||||||
const unlockTime = new Date(request.unlockTime).getTime();
|
const unlockTime = new Date(request.unlockTime).getTime();
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user