forked from Yara724/api
553 lines
20 KiB
TypeScript
553 lines
20 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import { Types } from "mongoose";
|
|
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
|
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
|
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
|
import {
|
|
blameCaseTouchesClient,
|
|
claimCaseTouchesClient,
|
|
} from "src/helpers/tenant-scope";
|
|
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
|
import { FileRating } from "src/request-management/entities/schema/request-management.schema";
|
|
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
|
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
|
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
|
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
|
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
|
|
|
|
@Injectable()
|
|
export class ExpertInsurerService {
|
|
constructor(
|
|
private readonly expertDbService: ExpertDbService,
|
|
private readonly damageExpertDbService: DamageExpertDbService,
|
|
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
|
private readonly blameRequestDbService: BlameRequestDbService,
|
|
private readonly claimCaseDbService: ClaimCaseDbService,
|
|
private readonly branchDbService: BranchDbService,
|
|
) {}
|
|
|
|
private getClientId(actorOrId: any): Types.ObjectId {
|
|
const raw = typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey;
|
|
if (!raw || !Types.ObjectId.isValid(raw)) {
|
|
throw new BadRequestException("Client key is required");
|
|
}
|
|
return new Types.ObjectId(raw);
|
|
}
|
|
|
|
private async buildExpertActivityStatsMap(
|
|
tenantId: Types.ObjectId,
|
|
expertIds: string[],
|
|
): Promise<Record<string, { totalHandled: number; totalChecked: number }>> {
|
|
const events = await this.expertFileActivityDbService.findByTenantAndExperts(
|
|
tenantId,
|
|
expertIds,
|
|
);
|
|
const stateByExpertFile = new Map<string, { checked: boolean; handled: boolean }>();
|
|
|
|
for (const e of events) {
|
|
const key = `${String(e.expertId)}:${String(e.fileId)}`;
|
|
const prev = stateByExpertFile.get(key) ?? { checked: false, handled: false };
|
|
if (e.eventType === ExpertFileActivityType.CHECKED) {
|
|
if (!prev.handled) prev.checked = true;
|
|
} else if (e.eventType === ExpertFileActivityType.UNCHECKED) {
|
|
prev.checked = false;
|
|
} else if (e.eventType === ExpertFileActivityType.HANDLED) {
|
|
prev.handled = true;
|
|
prev.checked = false;
|
|
}
|
|
stateByExpertFile.set(key, prev);
|
|
}
|
|
|
|
const result: Record<string, { totalHandled: number; totalChecked: number }> = {};
|
|
for (const expertId of expertIds) {
|
|
result[expertId] = { totalHandled: 0, totalChecked: 0 };
|
|
}
|
|
for (const [key, st] of stateByExpertFile.entries()) {
|
|
const [expertId] = key.split(":");
|
|
if (!result[expertId]) result[expertId] = { totalHandled: 0, totalChecked: 0 };
|
|
if (st.handled) result[expertId].totalHandled += 1;
|
|
else if (st.checked) result[expertId].totalChecked += 1;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private parseObjectId(value: string, label: string): Types.ObjectId {
|
|
if (!Types.ObjectId.isValid(value)) {
|
|
throw new BadRequestException(`Invalid ${label}`);
|
|
}
|
|
return new Types.ObjectId(value);
|
|
}
|
|
|
|
private normalizeClaimCase(claim: any): any {
|
|
return {
|
|
...claim,
|
|
requestNumber: claim.requestNo,
|
|
userClientKey: claim.owner?.userClientKey,
|
|
fullName: claim.owner?.fullName,
|
|
carDetail: claim.vehicle,
|
|
carPlate: claim.vehicle?.plate,
|
|
claimStatus: claim.status,
|
|
rating: claim.evaluation?.rating,
|
|
userRating: claim.evaluation?.userRating,
|
|
damageExpertReply: claim.evaluation?.damageExpertReply,
|
|
damageExpertReplyFinal: claim.evaluation?.damageExpertReplyFinal,
|
|
damageExpertResend: claim.evaluation?.damageExpertResend,
|
|
objection: claim.evaluation?.objection,
|
|
userResendDocuments: claim.evaluation?.userResendDocuments,
|
|
priceDrop: claim.evaluation?.priceDrop,
|
|
visitLocation: claim.evaluation?.visitLocation,
|
|
currentStep: claim.workflow?.currentStep,
|
|
nextStep: claim.workflow?.nextStep,
|
|
};
|
|
}
|
|
|
|
private normalizeBlameCase(blame: any): any {
|
|
const firstParty = blame?.parties?.find((p) => p?.role === "FIRST");
|
|
const secondParty = blame?.parties?.find((p) => p?.role === "SECOND");
|
|
return {
|
|
...blame,
|
|
requestNumber: blame.requestNo,
|
|
rating: blame?.expert?.rating,
|
|
actorLocked: { actorId: blame?.expert?.assignedExpertId },
|
|
firstPartyDetails: {
|
|
firstPartyClient: { clientId: firstParty?.person?.clientId },
|
|
},
|
|
secondPartyDetails: {
|
|
secondPartyClient: { clientId: secondParty?.person?.clientId },
|
|
},
|
|
};
|
|
}
|
|
|
|
private getCombinedFileScore(file: any): number | null {
|
|
const insurerRating = file?.rating;
|
|
const userRating = file?.userRating;
|
|
const insurerValues = insurerRating
|
|
? Object.values(insurerRating).filter(
|
|
(v): v is number => typeof v === "number" && !isNaN(v),
|
|
)
|
|
: [];
|
|
const userValues = userRating
|
|
? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter(
|
|
(v) => typeof v === "number" && !isNaN(v),
|
|
)
|
|
: [];
|
|
const insurerAvg = insurerValues.length
|
|
? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length
|
|
: null;
|
|
const userAvg = userValues.length
|
|
? userValues.reduce((a, b) => a + b, 0) / userValues.length
|
|
: null;
|
|
const scores = [insurerAvg, userAvg].filter((v): v is number => typeof v === "number");
|
|
if (!scores.length) return null;
|
|
return parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2));
|
|
}
|
|
|
|
private async getClientBlameFiles(clientObjectId: Types.ObjectId): Promise<any[]> {
|
|
const all = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
|
|
const idStr = String(clientObjectId);
|
|
return all
|
|
.filter((f) =>
|
|
(f?.parties || []).some((p) => String(p?.person?.clientId || "") === idStr),
|
|
)
|
|
.filter(
|
|
(f) =>
|
|
f?.status !== CaseStatus.OPEN &&
|
|
f?.status !== CaseStatus.WAITING_FOR_SECOND_PARTY,
|
|
)
|
|
.map((f) => this.normalizeBlameCase(f));
|
|
}
|
|
|
|
private async getClientClaimFiles(clientObjectId: Types.ObjectId): Promise<any[]> {
|
|
const all = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
|
|
const idStr = String(clientObjectId);
|
|
return all
|
|
.filter((f) => String(f?.owner?.userClientKey || "") === idStr)
|
|
.map((f) => this.normalizeClaimCase(f));
|
|
}
|
|
|
|
async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) {
|
|
const clientObjectId = this.getClientId(actor);
|
|
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
|
|
this.expertDbService.findAll({ clientKey: String(clientObjectId) }),
|
|
this.damageExpertDbService.findAll({ clientKey: clientObjectId }),
|
|
this.getClientBlameFiles(clientObjectId),
|
|
this.getClientClaimFiles(clientObjectId),
|
|
]);
|
|
|
|
const allExpertsRaw = [...experts, ...damageExperts];
|
|
const expertIds = allExpertsRaw.map((e) => String(e._id));
|
|
const expertActivityStatsMap = await this.buildExpertActivityStatsMap(
|
|
clientObjectId,
|
|
expertIds,
|
|
);
|
|
const expertTotalRatingsMap: Record<string, number[]> = {};
|
|
const expertRatingsByCategoryMap: Record<string, Record<string, number[]>> = {};
|
|
|
|
const processRatings = (expertId: string | undefined, file: any) => {
|
|
if (!expertId) return;
|
|
const rating = file?.rating;
|
|
const combinedScore = this.getCombinedFileScore(file);
|
|
if (combinedScore !== null) {
|
|
if (!expertTotalRatingsMap[expertId]) expertTotalRatingsMap[expertId] = [];
|
|
expertTotalRatingsMap[expertId].push(combinedScore);
|
|
}
|
|
if (!rating || typeof rating !== "object") return;
|
|
if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {};
|
|
for (const [category, value] of Object.entries(rating)) {
|
|
if (typeof value === "number" && !isNaN(value)) {
|
|
if (!expertRatingsByCategoryMap[expertId][category]) {
|
|
expertRatingsByCategoryMap[expertId][category] = [];
|
|
}
|
|
expertRatingsByCategoryMap[expertId][category].push(value);
|
|
}
|
|
}
|
|
};
|
|
|
|
for (const file of blameFiles) {
|
|
processRatings(file?.actorLocked?.actorId?.toString?.(), file);
|
|
}
|
|
for (const file of claimFiles) {
|
|
processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
|
|
}
|
|
|
|
const allExperts = allExpertsRaw.map((expert) => {
|
|
const expertIdStr = expert._id.toString();
|
|
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
|
|
const overallAverageRating = totalRatings.length
|
|
? parseFloat(
|
|
(totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length).toFixed(2),
|
|
)
|
|
: null;
|
|
const averageRatingsByCategory: Record<string, number> = {};
|
|
const ratingsByCat = expertRatingsByCategoryMap[expertIdStr];
|
|
if (ratingsByCat) {
|
|
for (const [category, values] of Object.entries(ratingsByCat)) {
|
|
averageRatingsByCategory[category] = parseFloat(
|
|
(values.reduce((a, b) => a + b, 0) / values.length).toFixed(2),
|
|
);
|
|
}
|
|
}
|
|
return {
|
|
_id: expert._id,
|
|
fullName: `${expert.firstName} ${expert.lastName}`,
|
|
role: expert.role,
|
|
type: expert.userType,
|
|
requestStats: expertActivityStatsMap[expertIdStr] ?? {
|
|
totalHandled: 0,
|
|
totalChecked: 0,
|
|
},
|
|
createdAt: expert.createdAt,
|
|
overallAverageRating,
|
|
averageRatingsByCategory,
|
|
};
|
|
});
|
|
|
|
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
|
|
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
|
|
const start = (page - 1) * perPage;
|
|
return {
|
|
total: allExperts.length,
|
|
page,
|
|
countPerPage: perPage,
|
|
experts: allExperts.slice(start, start + perPage),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
|
|
const scored = claimFiles
|
|
.map((file) => {
|
|
const combinedScore = this.getCombinedFileScore(file);
|
|
if (combinedScore === null) return null;
|
|
return { ...file, combinedScore };
|
|
})
|
|
.filter((f) => f !== null);
|
|
return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10);
|
|
}
|
|
|
|
private async assertExpertBelongsToInsurer(
|
|
expertObjectId: Types.ObjectId,
|
|
insurerClientKey: string,
|
|
) {
|
|
const ck = String(insurerClientKey);
|
|
const clientOid = new Types.ObjectId(ck);
|
|
const [experts, damageExperts] = await Promise.all([
|
|
this.expertDbService.findAll({ clientKey: ck }),
|
|
this.damageExpertDbService.findAll({ clientKey: clientOid }),
|
|
]);
|
|
const id = String(expertObjectId);
|
|
const ok = [...experts, ...damageExperts].some((e) => String(e._id) === id);
|
|
if (!ok) {
|
|
throw new ForbiddenException(
|
|
"This expert is not registered under your insurance company.",
|
|
);
|
|
}
|
|
}
|
|
|
|
async getAllFilesByExpertAndRole(
|
|
expertId: string,
|
|
role: "claim" | "blame",
|
|
insurerClientKey: string,
|
|
) {
|
|
const expertObjectId = this.parseObjectId(expertId, "expert id");
|
|
await this.assertExpertBelongsToInsurer(expertObjectId, insurerClientKey);
|
|
const clientKeyStr = String(this.getClientId(insurerClientKey));
|
|
|
|
if (role === "claim") {
|
|
const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
|
|
return claims
|
|
.filter(
|
|
(c) =>
|
|
claimCaseTouchesClient(c, clientKeyStr) &&
|
|
c?.evaluation?.damageExpertReply?.actorDetail?.actorId ===
|
|
String(expertObjectId),
|
|
)
|
|
.map((c) => this.normalizeClaimCase(c));
|
|
}
|
|
const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
|
|
return blames
|
|
.filter(
|
|
(b) =>
|
|
blameCaseTouchesClient(b, clientKeyStr) &&
|
|
String(b?.expert?.assignedExpertId ?? "") === String(expertObjectId),
|
|
)
|
|
.map((b) => this.normalizeBlameCase(b));
|
|
}
|
|
|
|
async rateExpertOnFile(
|
|
requestId: string,
|
|
rating: FileRating,
|
|
role: "claim" | "blame",
|
|
insurerClientKey: string,
|
|
) {
|
|
const _id = this.parseObjectId(requestId, "request id");
|
|
const clientKeyStr = String(this.getClientId(insurerClientKey));
|
|
for (const [key, value] of Object.entries(rating || {})) {
|
|
if (typeof value !== "number" || value < 0 || value > 5) {
|
|
throw new BadRequestException(`${key} must be a number between 0 and 5`);
|
|
}
|
|
}
|
|
if (role === "claim") {
|
|
const existing = await this.claimCaseDbService.findById(_id);
|
|
if (!existing) throw new NotFoundException("Claim file not found");
|
|
if (!claimCaseTouchesClient(existing as any, clientKeyStr)) {
|
|
throw new ForbiddenException("This claim does not belong to your organization.");
|
|
}
|
|
const updated = await this.claimCaseDbService.findByIdAndUpdate(_id, {
|
|
$set: { "evaluation.rating": rating },
|
|
});
|
|
if (!updated) throw new NotFoundException("Claim file not found");
|
|
return {
|
|
message: "Claim expert rated successfully",
|
|
updatedRating: (updated as any)?.evaluation?.rating || rating,
|
|
requestId: updated._id,
|
|
};
|
|
}
|
|
if (role === "blame") {
|
|
const existing = await this.blameRequestDbService.findById(_id);
|
|
if (!existing) throw new NotFoundException("Blame file not found");
|
|
if (!blameCaseTouchesClient(existing as any, clientKeyStr)) {
|
|
throw new ForbiddenException("This blame case does not belong to your organization.");
|
|
}
|
|
const updated = await this.blameRequestDbService.findByIdAndUpdate(_id, {
|
|
$set: { "expert.rating": rating },
|
|
});
|
|
if (!updated) throw new NotFoundException("Blame file not found");
|
|
return {
|
|
message: "Blame expert rated successfully",
|
|
updatedRating: (updated as any)?.expert?.rating || rating,
|
|
requestId: updated._id,
|
|
};
|
|
}
|
|
throw new BadRequestException("Invalid role");
|
|
}
|
|
|
|
async retrieveAllFilesOfClient(insurerId: string) {
|
|
const id = this.getClientId(insurerId);
|
|
const [blameFiles, claimFiles] = await Promise.all([
|
|
this.getClientBlameFiles(id),
|
|
this.getClientClaimFiles(id),
|
|
]);
|
|
return { blameFiles, claimFiles };
|
|
}
|
|
|
|
async addBranch(clientKey: string, branchDto: CreateBranchDto) {
|
|
const clientId = this.getClientId(clientKey);
|
|
|
|
const existingBranch = await this.branchDbService.findOne({
|
|
clientKey: clientId,
|
|
code: branchDto.code,
|
|
});
|
|
|
|
if (existingBranch) {
|
|
throw new ConflictException(
|
|
`A branch with code '${branchDto.code}' already exists for this client.`,
|
|
);
|
|
}
|
|
|
|
const newBranch = await this.branchDbService.create({
|
|
...branchDto,
|
|
clientKey: clientId,
|
|
});
|
|
|
|
return newBranch;
|
|
}
|
|
|
|
/**
|
|
* Get comprehensive statistics for all experts of a client
|
|
* Returns:
|
|
* - Percentage of insurer's rating to BOT
|
|
* - Percentage of expert's rating given by users
|
|
* - Percentage of files that have objection
|
|
* - Number of files created in the current month
|
|
*/
|
|
async getExpertStatisticsReport(actor: any) {
|
|
const clientObjectId = this.getClientId(actor);
|
|
const claimFiles = await this.getClientClaimFiles(clientObjectId);
|
|
|
|
// Calculate current month date range
|
|
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;
|
|
});
|
|
|
|
// Calculate statistics
|
|
let totalInsurerRatings = 0;
|
|
let totalBotRatings = 0;
|
|
let filesWithInsurerRating = 0;
|
|
let filesWithBotRating = 0;
|
|
let filesWithUserRating = 0;
|
|
let filesWithObjection = 0;
|
|
|
|
for (const file of claimFiles) {
|
|
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));
|
|
|
|
if (insurerValues.length > 0) {
|
|
filesWithInsurerRating++;
|
|
const insurerAvg =
|
|
insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length;
|
|
totalInsurerRatings += insurerAvg;
|
|
}
|
|
}
|
|
|
|
// 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++;
|
|
}
|
|
|
|
// Check for objection
|
|
if (objection) {
|
|
filesWithObjection++;
|
|
}
|
|
}
|
|
|
|
// Calculate percentages
|
|
const totalFiles = claimFiles.length;
|
|
|
|
// Calculate average insurer rating (excluding botRating) and average bot rating
|
|
const averageInsurerRating =
|
|
filesWithInsurerRating > 0
|
|
? totalInsurerRatings / filesWithInsurerRating
|
|
: 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))
|
|
: 0;
|
|
|
|
return {
|
|
insurerToBotRatingPercentage: insurerToBotPercentage,
|
|
userRatingPercentage: userRatingPercentage,
|
|
objectionPercentage: objectionPercentage,
|
|
filesCreatedThisMonth: filesThisMonth.length,
|
|
totalFiles: totalFiles,
|
|
breakdown: {
|
|
filesWithInsurerRating,
|
|
filesWithBotRating,
|
|
filesWithUserRating,
|
|
filesWithObjection,
|
|
averageInsurerRating:
|
|
filesWithInsurerRating > 0
|
|
? parseFloat(
|
|
(totalInsurerRatings / filesWithInsurerRating).toFixed(2),
|
|
)
|
|
: 0,
|
|
averageBotRating:
|
|
filesWithBotRating > 0
|
|
? parseFloat((totalBotRatings / filesWithBotRating).toFixed(2))
|
|
: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
async retrieveInsuranceBranches(insuranceId: string) {
|
|
return this.branchDbService.findAll(insuranceId);
|
|
}
|
|
}
|