forked from Yara724/api
1151 lines
39 KiB
TypeScript
1151 lines
39 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 {
|
||
CreateBlameExpertByInsurerDto,
|
||
CreateClaimExpertByInsurerDto,
|
||
CreateInsurerExpertDto,
|
||
} from "./dto/create-insurer-expert.dto";
|
||
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 { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.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";
|
||
import { HashService } from "src/utils/hash/hash.service";
|
||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||
import { UserType } from "src/Types&Enums/userType.enum";
|
||
|
||
@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 readonly hashService: HashService,
|
||
) {}
|
||
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Tenant scope on expert / damage-expert: `clientKey` may be stored as ObjectId or
|
||
* string (legacy and collection-specific writes). Match both, same as reports service.
|
||
*/
|
||
private clientKeyScopeFilter(clientObjectId: Types.ObjectId) {
|
||
const oid = clientObjectId;
|
||
const str = String(oid);
|
||
return { $or: [{ clientKey: oid }, { clientKey: str }] };
|
||
}
|
||
|
||
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 },
|
||
},
|
||
};
|
||
}
|
||
|
||
/** BlameCases may tie the field expert via assignment, expert-initiated id, or decision author. */
|
||
private blameFieldExpertIdCandidates(b: any): string[] {
|
||
const raw: unknown[] = [
|
||
b?.expert?.assignedExpertId,
|
||
b?.initiatedByFieldExpertId,
|
||
b?.expert?.decision?.decidedByExpertId,
|
||
];
|
||
const out = new Set<string>();
|
||
for (const x of raw) {
|
||
if (x != null && x !== "") out.add(String(x));
|
||
}
|
||
return [...out];
|
||
}
|
||
|
||
private claimDamageExpertActorId(c: any): string | null {
|
||
const reply =
|
||
c?.evaluation?.damageExpertReplyFinal || c?.evaluation?.damageExpertReply;
|
||
const aid = reply?.actorDetail?.actorId;
|
||
if (aid == null || aid === "") return null;
|
||
return String(aid);
|
||
}
|
||
|
||
private mapBlameFileSummaryForInsurerExpert(b: any) {
|
||
return {
|
||
kind: "blame" as const,
|
||
requestId: String(b._id),
|
||
publicId: b.publicId,
|
||
requestNo: b.requestNo,
|
||
type: b.type,
|
||
status: b.status,
|
||
blameStatus: b.blameStatus,
|
||
createdAt: b.createdAt,
|
||
updatedAt: b.updatedAt,
|
||
};
|
||
}
|
||
|
||
private mapClaimFileSummaryForInsurerExpert(c: any) {
|
||
return {
|
||
kind: "claim" as const,
|
||
requestId: String(c._id),
|
||
publicId: c.publicId,
|
||
requestNo: c.requestNo ?? c.requestNumber,
|
||
status: c.status,
|
||
claimStatus: c.claimStatus,
|
||
createdAt: c.createdAt,
|
||
updatedAt: c.updatedAt,
|
||
};
|
||
}
|
||
|
||
private buildPartiesPreview(parties: any[] | undefined) {
|
||
if (!Array.isArray(parties) || !parties.length) return undefined;
|
||
return parties.map((p: any) => ({
|
||
role: p?.role,
|
||
fullName: p?.person?.fullName,
|
||
vehicle: {
|
||
carName: p?.vehicle?.carName ?? p?.vehicle?.name,
|
||
carModel: p?.vehicle?.carModel ?? p?.vehicle?.model,
|
||
plate: p?.vehicle?.plate ?? p?.vehicle?.plateId,
|
||
},
|
||
}));
|
||
}
|
||
|
||
private sanitizeVehicleInquiry(vehicle: any) {
|
||
if (!vehicle || typeof vehicle !== "object") return vehicle;
|
||
const inquiry = vehicle.inquiry;
|
||
if (!inquiry || typeof inquiry !== "object") return vehicle;
|
||
const { raw, mapped, ...safeInquiry } = inquiry;
|
||
return {
|
||
...vehicle,
|
||
inquiry: safeInquiry,
|
||
};
|
||
}
|
||
|
||
private sanitizePartyForInsurerView(party: any) {
|
||
if (!party || typeof party !== "object") return party;
|
||
return {
|
||
...party,
|
||
vehicle: this.sanitizeVehicleInquiry(party.vehicle),
|
||
};
|
||
}
|
||
|
||
private getCombinedFileScore(file: any): number | null {
|
||
const insurerRating = file?.rating;
|
||
const userRating = file?.userRating;
|
||
const insurerValues = insurerRating
|
||
? (
|
||
[
|
||
insurerRating.collisionMethodAccuracy,
|
||
insurerRating.evaluationTimeliness,
|
||
insurerRating.accidentCauseAccuracy,
|
||
insurerRating.guiltyVehicleIdentification,
|
||
] as unknown[]
|
||
).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),
|
||
)
|
||
.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) => claimCaseTouchesClient(f, idStr))
|
||
.map((f) => this.normalizeClaimCase(f));
|
||
}
|
||
|
||
async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) {
|
||
const clientObjectId = this.getClientId(actor);
|
||
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
||
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
|
||
this.expertDbService.findAll(ckFilter as never),
|
||
this.damageExpertDbService.findAll(ckFilter as never),
|
||
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 (category === "botRating") continue;
|
||
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 mapExpertRow = (expert: any, expertKind: "blame" | "claim") => {
|
||
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,
|
||
expertKind,
|
||
type: expert.userType,
|
||
requestStats: expertActivityStatsMap[expertIdStr] ?? {
|
||
totalHandled: 0,
|
||
totalChecked: 0,
|
||
},
|
||
createdAt: expert.createdAt,
|
||
overallAverageRating,
|
||
averageRatingsByCategory,
|
||
};
|
||
};
|
||
|
||
const allExperts = [
|
||
...experts.map((e) => mapExpertRow(e, "blame")),
|
||
...damageExperts.map((e) => mapExpertRow(e, "claim")),
|
||
];
|
||
|
||
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),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Top experts per roster: blame rows come from the `expert` collection (files they
|
||
* handled on blame cases); claim rows from `damage-expert` (claim evaluations).
|
||
* Scores are aggregated from **file documents** that insurer can see: for each such
|
||
* file we take `evaluation.rating` / `expert.rating` (insurer dimensions only,
|
||
* excluding `botRating`), optionally blend with the file’s user rating, average those
|
||
* combined scores per file, then average across that expert’s files (`overallAverageRating`).
|
||
*/
|
||
async getTopExpertsForClient(actor): Promise<{
|
||
blameExperts: any[];
|
||
claimExperts: any[];
|
||
}> {
|
||
const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
|
||
const rows = result?.experts || [];
|
||
|
||
const byRatingDesc = (a: any, b: any) =>
|
||
(b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
|
||
|
||
const blameExperts = rows
|
||
.filter((e) => e.expertKind === "blame")
|
||
.sort(byRatingDesc)
|
||
.slice(0, 10);
|
||
const claimExperts = rows
|
||
.filter((e) => e.expertKind === "claim")
|
||
.sort(byRatingDesc)
|
||
.slice(0, 10);
|
||
|
||
return { blameExperts, claimExperts };
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
|
||
async getAllFilesForInsurerExpert(expertId: string, insurerClientKey: string) {
|
||
const expertObjectId = this.parseObjectId(expertId, "expert id");
|
||
const clientOid = this.getClientId(insurerClientKey);
|
||
const ckFilter = this.clientKeyScopeFilter(clientOid);
|
||
const [experts, damageExperts] = await Promise.all([
|
||
this.expertDbService.findAll(ckFilter as never),
|
||
this.damageExpertDbService.findAll(ckFilter as never),
|
||
]);
|
||
const id = String(expertObjectId);
|
||
const onBlameRoster = experts.some((e) => String(e._id) === id);
|
||
const onClaimRoster = damageExperts.some((e) => String(e._id) === id);
|
||
if (!onBlameRoster && !onClaimRoster) {
|
||
throw new ForbiddenException(
|
||
"This expert is not registered under your insurance company.",
|
||
);
|
||
}
|
||
|
||
const clientKeyStr = String(clientOid);
|
||
if (onBlameRoster) {
|
||
const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
|
||
return blames
|
||
.filter(
|
||
(b) =>
|
||
blameCaseTouchesClient(b, clientKeyStr) &&
|
||
this.blameFieldExpertIdCandidates(b).includes(id),
|
||
)
|
||
.map((b) => this.mapBlameFileSummaryForInsurerExpert(b));
|
||
}
|
||
if (onClaimRoster) {
|
||
const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
|
||
return claims
|
||
.filter(
|
||
(c) =>
|
||
claimCaseTouchesClient(c, clientKeyStr) &&
|
||
this.claimDamageExpertActorId(c) === id,
|
||
)
|
||
.map((c) => this.mapClaimFileSummaryForInsurerExpert(c));
|
||
}
|
||
return [];
|
||
}
|
||
|
||
private validateInsurerFileRating(rating: FileRating) {
|
||
const required: (keyof FileRating)[] = [
|
||
"collisionMethodAccuracy",
|
||
"evaluationTimeliness",
|
||
"accidentCauseAccuracy",
|
||
"guiltyVehicleIdentification",
|
||
"botRating",
|
||
];
|
||
for (const key of required) {
|
||
const value = rating?.[key];
|
||
if (typeof value !== "number" || value < 0 || value > 5) {
|
||
throw new BadRequestException(`${key} must be a number between 0 and 5`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Applies one insurer rating to every underlying case (claim and/or blame) for
|
||
* the shared `publicId`, when that case exists and belongs to this insurer.
|
||
*/
|
||
async rateExpertByPublicId(
|
||
publicId: string,
|
||
rating: FileRating,
|
||
insurerClientKey: string,
|
||
) {
|
||
if (!publicId?.trim()) {
|
||
throw new BadRequestException("publicId is required");
|
||
}
|
||
this.validateInsurerFileRating(rating);
|
||
|
||
const id = this.getClientId(insurerClientKey);
|
||
const [blameFiles, claimFiles] = await Promise.all([
|
||
this.getClientBlameFiles(id),
|
||
this.getClientClaimFiles(id),
|
||
]);
|
||
|
||
const blame = blameFiles.find((b) => String((b as any).publicId) === publicId);
|
||
const claim = claimFiles.find((c) => String((c as any).publicId) === publicId);
|
||
|
||
if (!blame && !claim) {
|
||
throw new NotFoundException("File not found for this publicId");
|
||
}
|
||
|
||
const out: {
|
||
publicId: string;
|
||
claim?: { requestId: string; updatedRating: FileRating };
|
||
blame?: { requestId: string; updatedRating: FileRating };
|
||
} = { publicId };
|
||
|
||
if (claim) {
|
||
const claimId = this.parseObjectId(String((claim as any)._id), "claim id");
|
||
const updated = await this.claimCaseDbService.findByIdAndUpdate(claimId, {
|
||
$set: { "evaluation.rating": rating },
|
||
});
|
||
if (!updated) throw new NotFoundException("Claim file not found");
|
||
out.claim = {
|
||
requestId: String((updated as any)._id),
|
||
updatedRating: (updated as any)?.evaluation?.rating ?? rating,
|
||
};
|
||
}
|
||
|
||
if (blame) {
|
||
const blameId = this.parseObjectId(String((blame as any)._id), "blame id");
|
||
const updated = await this.blameRequestDbService.findByIdAndUpdate(blameId, {
|
||
$set: { "expert.rating": rating },
|
||
});
|
||
if (!updated) throw new NotFoundException("Blame file not found");
|
||
out.blame = {
|
||
requestId: String((updated as any)._id),
|
||
updatedRating: (updated as any)?.expert?.rating ?? rating,
|
||
};
|
||
}
|
||
|
||
return {
|
||
message: "Rating saved for this file",
|
||
...out,
|
||
};
|
||
}
|
||
|
||
async retrieveAllFilesOfClient(insurerId: string) {
|
||
const id = this.getClientId(insurerId);
|
||
const [blameFiles, claimFiles] = await Promise.all([
|
||
this.getClientBlameFiles(id),
|
||
this.getClientClaimFiles(id),
|
||
]);
|
||
|
||
const byPublicId = new Map<
|
||
string,
|
||
{
|
||
publicId: string;
|
||
fileType?: string;
|
||
parties?: Array<{
|
||
role?: string;
|
||
fullName?: string;
|
||
vehicle?: {
|
||
carName?: string;
|
||
carModel?: string;
|
||
plate?: string;
|
||
};
|
||
}>;
|
||
blame?: {
|
||
requestId?: string;
|
||
requestNo?: string;
|
||
status?: string;
|
||
parties?: Array<{
|
||
role?: string;
|
||
fullName?: string;
|
||
vehicle?: {
|
||
carName?: string;
|
||
carModel?: string;
|
||
plate?: string;
|
||
};
|
||
}>;
|
||
createdAt?: Date | string;
|
||
updatedAt?: Date | string;
|
||
};
|
||
claim?: {
|
||
requestId?: string;
|
||
requestNo?: string;
|
||
status?: string;
|
||
claimStatus?: string;
|
||
currentStep?: string;
|
||
createdAt?: Date | string;
|
||
updatedAt?: Date | string;
|
||
};
|
||
createdAt?: Date | string;
|
||
updatedAt?: Date | string;
|
||
}
|
||
>();
|
||
|
||
for (const b of blameFiles) {
|
||
const key = String((b as any).publicId || (b as any)._id || "");
|
||
if (!key) continue;
|
||
const prev = byPublicId.get(key) ?? { publicId: key };
|
||
const partiesPreview = this.buildPartiesPreview((b as any).parties);
|
||
prev.fileType = prev.fileType ?? (b as any).type;
|
||
prev.blame = {
|
||
requestId: (b as any)?._id?.toString?.() || String((b as any)?._id || ""),
|
||
requestNo: (b as any).requestNo || String((b as any).requestNumber || ""),
|
||
status: (b as any).status,
|
||
parties: partiesPreview,
|
||
createdAt: (b as any).createdAt,
|
||
updatedAt: (b as any).updatedAt,
|
||
};
|
||
prev.parties = prev.parties ?? partiesPreview;
|
||
prev.createdAt = prev.createdAt ?? (b as any).createdAt;
|
||
prev.updatedAt = (b as any).updatedAt ?? prev.updatedAt;
|
||
byPublicId.set(key, prev);
|
||
}
|
||
|
||
for (const c of claimFiles) {
|
||
const key = String((c as any).publicId || (c as any)._id || "");
|
||
if (!key) continue;
|
||
const prev = byPublicId.get(key) ?? { publicId: key };
|
||
const claimPartiesPreview = this.buildPartiesPreview((c as any)?.snapshot?.parties);
|
||
prev.fileType = prev.fileType ?? (c as any)?.snapshot?.accident?.type;
|
||
prev.claim = {
|
||
requestId: (c as any)?._id?.toString?.() || String((c as any)?._id || ""),
|
||
requestNo: (c as any).requestNo || String((c as any).requestNumber || ""),
|
||
status: (c as any).status,
|
||
claimStatus: (c as any).claimStatus,
|
||
currentStep: (c as any).currentStep,
|
||
createdAt: (c as any).createdAt,
|
||
updatedAt: (c as any).updatedAt,
|
||
};
|
||
prev.parties = prev.parties ?? claimPartiesPreview;
|
||
prev.createdAt = prev.createdAt ?? (c as any).createdAt;
|
||
prev.updatedAt = (c as any).updatedAt ?? prev.updatedAt;
|
||
byPublicId.set(key, prev);
|
||
}
|
||
|
||
const files = Array.from(byPublicId.values())
|
||
.map((f) => ({
|
||
publicId: f.publicId,
|
||
fileType: f.fileType,
|
||
hasClaim: !!f.claim,
|
||
parties: f.parties,
|
||
blame: f.blame,
|
||
claim: f.claim,
|
||
createdAt: f.createdAt,
|
||
updatedAt: f.updatedAt,
|
||
}))
|
||
.sort((a, b) => {
|
||
const at = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||
const bt = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||
return bt - at;
|
||
});
|
||
|
||
return {
|
||
total: files.length,
|
||
files,
|
||
};
|
||
}
|
||
|
||
async retrieveFileDetailsByPublicId(insurerId: string, publicId: string) {
|
||
if (!publicId?.trim()) {
|
||
throw new BadRequestException("publicId is required");
|
||
}
|
||
|
||
const id = this.getClientId(insurerId);
|
||
const [blameFiles, claimFiles] = await Promise.all([
|
||
this.getClientBlameFiles(id),
|
||
this.getClientClaimFiles(id),
|
||
]);
|
||
|
||
const blame = blameFiles.find((b) => String((b as any).publicId) === publicId);
|
||
const claim = claimFiles.find((c) => String((c as any).publicId) === publicId);
|
||
|
||
if (!blame && !claim) {
|
||
throw new NotFoundException("File not found for this publicId");
|
||
}
|
||
|
||
const parties = Array.isArray((blame as any)?.parties) ? (blame as any).parties : [];
|
||
const firstParty = parties.find((p: any) => p?.role === "FIRST");
|
||
const secondParty = parties.find((p: any) => p?.role === "SECOND");
|
||
const claimSnapshotParties = Array.isArray((claim as any)?.snapshot?.parties)
|
||
? (claim as any).snapshot.parties
|
||
: undefined;
|
||
|
||
const overview = {
|
||
publicId,
|
||
requestNo:
|
||
(blame as any)?.requestNo ||
|
||
(claim as any)?.requestNo ||
|
||
(blame as any)?.requestNumber ||
|
||
(claim as any)?.requestNumber,
|
||
type: (blame as any)?.type,
|
||
hasClaim: !!claim,
|
||
blameStatus: (blame as any)?.status,
|
||
claimStatus: (claim as any)?.status,
|
||
claimReviewStatus: (claim as any)?.claimStatus,
|
||
createdAt: (blame as any)?.createdAt ?? (claim as any)?.createdAt,
|
||
updatedAt: (claim as any)?.updatedAt ?? (blame as any)?.updatedAt,
|
||
};
|
||
|
||
const blameDetails = blame
|
||
? {
|
||
requestId:
|
||
(blame as any)?._id?.toString?.() || String((blame as any)?._id || ""),
|
||
requestNo:
|
||
(blame as any).requestNo || String((blame as any).requestNumber || ""),
|
||
status: (blame as any).status,
|
||
blameStatus: (blame as any).blameStatus,
|
||
workflow: (blame as any).workflow,
|
||
parties: {
|
||
first: firstParty
|
||
? {
|
||
fullName: firstParty?.person?.fullName,
|
||
phoneNumber: firstParty?.person?.phoneNumber,
|
||
role: firstParty?.role,
|
||
vehicle: this.sanitizeVehicleInquiry(firstParty?.vehicle),
|
||
}
|
||
: undefined,
|
||
second: secondParty
|
||
? {
|
||
fullName: secondParty?.person?.fullName,
|
||
phoneNumber: secondParty?.person?.phoneNumber,
|
||
role: secondParty?.role,
|
||
vehicle: this.sanitizeVehicleInquiry(secondParty?.vehicle),
|
||
}
|
||
: undefined,
|
||
},
|
||
expert: (blame as any).expert
|
||
? {
|
||
assignedExpertId: (blame as any).expert?.assignedExpertId,
|
||
decision: (blame as any).expert?.decision,
|
||
resend: (blame as any).expert?.resend,
|
||
rating: (blame as any).expert?.rating,
|
||
}
|
||
: undefined,
|
||
createdAt: (blame as any).createdAt,
|
||
updatedAt: (blame as any).updatedAt,
|
||
}
|
||
: undefined;
|
||
|
||
// Keep claim section claim-specific to avoid duplicating shared data already in blame/overview.
|
||
const claimDetails = claim
|
||
? {
|
||
requestId:
|
||
(claim as any)?._id?.toString?.() || String((claim as any)?._id || ""),
|
||
requestNo:
|
||
(claim as any).requestNo || String((claim as any).requestNumber || ""),
|
||
status: (claim as any).status,
|
||
claimStatus: (claim as any).claimStatus,
|
||
owner: (claim as any).owner,
|
||
vehicle: (claim as any).vehicle,
|
||
money: (claim as any).money,
|
||
damage: (claim as any).damage,
|
||
media: (claim as any).media,
|
||
requiredDocuments: (claim as any).requiredDocuments,
|
||
snapshot: (claim as any).snapshot
|
||
? {
|
||
...(claim as any).snapshot,
|
||
parties: claimSnapshotParties?.map((p: any) =>
|
||
this.sanitizePartyForInsurerView(p),
|
||
),
|
||
}
|
||
: undefined,
|
||
workflow: {
|
||
currentStep: (claim as any).currentStep,
|
||
nextStep: (claim as any).nextStep,
|
||
},
|
||
evaluation: {
|
||
damageExpertReply: (claim as any).damageExpertReply,
|
||
damageExpertReplyFinal: (claim as any).damageExpertReplyFinal,
|
||
damageExpertResend: (claim as any).damageExpertResend,
|
||
objection: (claim as any).objection,
|
||
priceDrop: (claim as any).priceDrop,
|
||
visitLocation: (claim as any).visitLocation,
|
||
factors: (claim as any).evaluation?.factors,
|
||
},
|
||
userResendDocuments: (claim as any).userResendDocuments,
|
||
userRating: (claim as any).userRating,
|
||
insurerRating: (claim as any).rating,
|
||
createdAt: (claim as any).createdAt,
|
||
updatedAt: (claim as any).updatedAt,
|
||
}
|
||
: undefined;
|
||
|
||
return {
|
||
overview,
|
||
blame: blameDetails,
|
||
claim: claimDetails,
|
||
};
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
private async assertBranchBelongsToClient(
|
||
branchId: string,
|
||
clientId: Types.ObjectId,
|
||
) {
|
||
const branch = await this.branchDbService.findById(branchId);
|
||
if (!branch) {
|
||
throw new NotFoundException("Branch not found");
|
||
}
|
||
if (String(branch.clientKey) !== String(clientId)) {
|
||
throw new ForbiddenException(
|
||
"Selected branch does not belong to your insurance company.",
|
||
);
|
||
}
|
||
return branch;
|
||
}
|
||
|
||
private sanitizeExpertResponse(doc: any) {
|
||
const obj = typeof doc?.toObject === "function" ? doc.toObject() : doc;
|
||
if (!obj) return obj;
|
||
const { password, otp, ...rest } = obj;
|
||
return rest;
|
||
}
|
||
|
||
private async createExpertForInsurer(
|
||
insurerClientKey: string,
|
||
payload: CreateInsurerExpertDto,
|
||
role: RoleEnum.EXPERT | RoleEnum.DAMAGE_EXPERT,
|
||
) {
|
||
const clientObjectId = this.getClientId(insurerClientKey);
|
||
const branch = await this.assertBranchBelongsToClient(
|
||
payload.branchId,
|
||
clientObjectId,
|
||
);
|
||
|
||
const email = payload.email.trim().toLowerCase();
|
||
const existingByEmail = await this.expertDbService.findOne({ email });
|
||
const existingDamageByEmail = await this.damageExpertDbService.findOne({ email });
|
||
if (existingByEmail || existingDamageByEmail) {
|
||
throw new ConflictException("An expert with this email already exists.");
|
||
}
|
||
|
||
const nationalCode = payload.nationalCode.trim();
|
||
const existingByNational = await this.expertDbService.findOne({ nationalCode });
|
||
const existingDamageByNational = await this.damageExpertDbService.findOne({
|
||
nationalCode,
|
||
});
|
||
if (existingByNational || existingDamageByNational) {
|
||
throw new ConflictException(
|
||
"An expert with this national code already exists.",
|
||
);
|
||
}
|
||
|
||
const hashedPassword = await this.hashService.hash(payload.password);
|
||
const basePayload: any = {
|
||
...payload,
|
||
email,
|
||
nationalCode,
|
||
password: hashedPassword,
|
||
role,
|
||
userType: payload.userType ?? UserType.LEGAL,
|
||
clientKey:
|
||
role === RoleEnum.EXPERT ? String(clientObjectId) : clientObjectId,
|
||
branchId: new Types.ObjectId(payload.branchId),
|
||
insuActivityCo: String(clientObjectId),
|
||
};
|
||
|
||
const created =
|
||
role === RoleEnum.EXPERT
|
||
? await this.expertDbService.create(basePayload)
|
||
: await this.damageExpertDbService.create(basePayload);
|
||
|
||
return {
|
||
expert: this.sanitizeExpertResponse(created),
|
||
branch: {
|
||
_id: (branch as any)._id,
|
||
name: branch.name,
|
||
code: branch.code,
|
||
city: branch.city,
|
||
state: branch.state,
|
||
address: branch.address,
|
||
},
|
||
};
|
||
}
|
||
|
||
async addBlameExpert(
|
||
insurerClientKey: string,
|
||
payload: CreateBlameExpertByInsurerDto,
|
||
) {
|
||
return this.createExpertForInsurer(
|
||
insurerClientKey,
|
||
payload,
|
||
RoleEnum.EXPERT,
|
||
);
|
||
}
|
||
|
||
async addClaimExpert(
|
||
insurerClientKey: string,
|
||
payload: CreateClaimExpertByInsurerDto,
|
||
) {
|
||
return this.createExpertForInsurer(
|
||
insurerClientKey,
|
||
payload,
|
||
RoleEnum.DAMAGE_EXPERT,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
|
||
private parseDateRange(from?: string, to?: string) {
|
||
const fromDate = from ? new Date(from) : undefined;
|
||
const toDate = to ? new Date(to) : undefined;
|
||
|
||
if (fromDate && Number.isNaN(fromDate.getTime())) {
|
||
throw new BadRequestException("Invalid 'from' date");
|
||
}
|
||
if (toDate && Number.isNaN(toDate.getTime())) {
|
||
throw new BadRequestException("Invalid 'to' date");
|
||
}
|
||
if (fromDate && toDate && fromDate > toDate) {
|
||
throw new BadRequestException("'from' must be before or equal to 'to'");
|
||
}
|
||
return { fromDate, toDate };
|
||
}
|
||
|
||
private isInDateRange(value: any, fromDate?: Date, toDate?: Date): boolean {
|
||
if (!fromDate && !toDate) return true;
|
||
if (!value) return false;
|
||
const d = new Date(value);
|
||
if (Number.isNaN(d.getTime())) return false;
|
||
if (fromDate && d < fromDate) return false;
|
||
if (toDate && d > toDate) return false;
|
||
return true;
|
||
}
|
||
|
||
async getInsurerFileStatusCounts(
|
||
actor: any,
|
||
from?: string,
|
||
to?: string,
|
||
): Promise<{
|
||
all: number;
|
||
completed: number;
|
||
under_review: number;
|
||
waiting_for_documents_resend: number;
|
||
}> {
|
||
const clientObjectId = this.getClientId(actor);
|
||
const { fromDate, toDate } = this.parseDateRange(from, to);
|
||
|
||
const [blameFilesRaw, claimFilesRaw] = await Promise.all([
|
||
this.getClientBlameFiles(clientObjectId),
|
||
this.getClientClaimFiles(clientObjectId),
|
||
]);
|
||
|
||
const blameFiles = blameFilesRaw.filter((f) =>
|
||
this.isInDateRange((f as any)?.createdAt, fromDate, toDate),
|
||
);
|
||
const claimFiles = claimFilesRaw.filter((f) =>
|
||
this.isInDateRange((f as any)?.createdAt, fromDate, toDate),
|
||
);
|
||
|
||
const fileMap = new Map<
|
||
string,
|
||
{ blame?: any; claim?: any }
|
||
>();
|
||
|
||
for (const b of blameFiles) {
|
||
const key = String((b as any).publicId || (b as any)._id || "");
|
||
if (!key) continue;
|
||
const prev = fileMap.get(key) ?? {};
|
||
prev.blame = b;
|
||
fileMap.set(key, prev);
|
||
}
|
||
for (const c of claimFiles) {
|
||
const key = String((c as any).publicId || (c as any)._id || "");
|
||
if (!key) continue;
|
||
const prev = fileMap.get(key) ?? {};
|
||
prev.claim = c;
|
||
fileMap.set(key, prev);
|
||
}
|
||
|
||
let completed = 0;
|
||
let underReview = 0;
|
||
let waitingForDocumentsResend = 0;
|
||
|
||
for (const entry of fileMap.values()) {
|
||
const blameStatus = entry.blame?.status;
|
||
const claimStatus = entry.claim?.status;
|
||
|
||
if (claimStatus === ClaimCaseStatus.COMPLETED) {
|
||
completed += 1;
|
||
}
|
||
|
||
const blameUnderReview =
|
||
blameStatus === CaseStatus.WAITING_FOR_EXPERT;
|
||
const claimUnderReview =
|
||
claimStatus === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
|
||
claimStatus === ClaimCaseStatus.EXPERT_REVIEWING;
|
||
if (blameUnderReview || claimUnderReview) {
|
||
underReview += 1;
|
||
}
|
||
|
||
const blameResend =
|
||
blameStatus === CaseStatus.WAITING_FOR_DOCUMENT_RESEND;
|
||
const claimResend =
|
||
claimStatus === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||
if (blameResend || claimResend) {
|
||
waitingForDocumentsResend += 1;
|
||
}
|
||
}
|
||
|
||
return {
|
||
all: fileMap.size,
|
||
completed,
|
||
under_review: underReview,
|
||
waiting_for_documents_resend: waitingForDocumentsResend,
|
||
};
|
||
}
|
||
}
|