forked from Yara724/api
YARA-1246
This commit is contained in:
@@ -859,186 +859,418 @@ export class ExpertInsurerService {
|
||||
.map((f) => this.normalizeClaimCase(f));
|
||||
}
|
||||
|
||||
async retrieveAllExpertsOfClient(
|
||||
actor,
|
||||
currentPage: number,
|
||||
countPerPage: number,
|
||||
private startOfDay(date: Date): Date {
|
||||
return new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
private endOfDay(date: Date): Date {
|
||||
return new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
);
|
||||
}
|
||||
|
||||
private toUtcDateKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private parseReportPeriodDays(value?: string | number): 30 | 60 | 90 {
|
||||
const parsed = Number(value ?? 30);
|
||||
if (parsed === 30 || parsed === 60 || parsed === 90) {
|
||||
return parsed;
|
||||
}
|
||||
throw new BadRequestException("periodDays must be one of 30, 60, or 90");
|
||||
}
|
||||
|
||||
private serializeReportRange(range: {
|
||||
mode: "preset" | "custom";
|
||||
fromDate: Date;
|
||||
toDate: Date;
|
||||
periodDays?: 30 | 60 | 90;
|
||||
}) {
|
||||
return {
|
||||
mode: range.mode,
|
||||
from: range.fromDate.toISOString(),
|
||||
to: range.toDate.toISOString(),
|
||||
...(range.periodDays ? { periodDays: range.periodDays } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveReportDateRange(opts: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
periodDays?: string | number;
|
||||
} = {}) {
|
||||
const hasFrom = typeof opts.from === "string" && opts.from.trim().length > 0;
|
||||
const hasTo = typeof opts.to === "string" && opts.to.trim().length > 0;
|
||||
const periodDays = this.parseReportPeriodDays(opts.periodDays);
|
||||
const now = new Date();
|
||||
|
||||
if (!hasFrom && !hasTo) {
|
||||
const toDate = this.endOfDay(now);
|
||||
const fromBase = new Date(toDate);
|
||||
fromBase.setDate(fromBase.getDate() - (periodDays - 1));
|
||||
const fromDate = this.startOfDay(fromBase);
|
||||
return {
|
||||
mode: "preset" as const,
|
||||
fromDate,
|
||||
toDate,
|
||||
periodDays,
|
||||
};
|
||||
}
|
||||
|
||||
let { fromDate, toDate } = this.parseDateRange(opts.from, opts.to);
|
||||
|
||||
if (!fromDate && toDate) {
|
||||
const fromBase = new Date(toDate);
|
||||
fromBase.setDate(fromBase.getDate() - (periodDays - 1));
|
||||
fromDate = this.startOfDay(fromBase);
|
||||
}
|
||||
if (fromDate && !toDate) {
|
||||
toDate = this.endOfDay(now);
|
||||
}
|
||||
|
||||
if (!fromDate || !toDate) {
|
||||
throw new BadRequestException("Could not resolve reporting date range");
|
||||
}
|
||||
if (fromDate > toDate) {
|
||||
throw new BadRequestException("'from' must be before or equal to 'to'");
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "custom" as const,
|
||||
fromDate,
|
||||
toDate,
|
||||
periodDays,
|
||||
};
|
||||
}
|
||||
|
||||
private buildExpertFileStateMap(
|
||||
events: Array<{
|
||||
expertId: Types.ObjectId | string;
|
||||
fileId: Types.ObjectId | string;
|
||||
eventType: ExpertFileActivityType;
|
||||
occurredAt: Date | string;
|
||||
}>,
|
||||
cutoff: Date,
|
||||
): Map<string, { checked: boolean; handled: boolean; handledAt?: Date }> {
|
||||
const stateByExpertFile = new Map<
|
||||
string,
|
||||
{ checked: boolean; handled: boolean; handledAt?: Date }
|
||||
>();
|
||||
|
||||
const filtered = events
|
||||
.map((event) => ({
|
||||
expertId: String(event.expertId),
|
||||
fileId: String(event.fileId),
|
||||
eventType: event.eventType,
|
||||
occurredAt:
|
||||
event.occurredAt instanceof Date
|
||||
? event.occurredAt
|
||||
: new Date(event.occurredAt),
|
||||
}))
|
||||
.filter(
|
||||
(event) =>
|
||||
!Number.isNaN(event.occurredAt.getTime()) && event.occurredAt <= cutoff,
|
||||
)
|
||||
.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
|
||||
|
||||
for (const event of filtered) {
|
||||
const key = `${event.expertId}:${event.fileId}`;
|
||||
const prev = stateByExpertFile.get(key) ?? {
|
||||
checked: false,
|
||||
handled: false,
|
||||
};
|
||||
if (event.eventType === ExpertFileActivityType.CHECKED) {
|
||||
if (!prev.handled) prev.checked = true;
|
||||
} else if (event.eventType === ExpertFileActivityType.UNCHECKED) {
|
||||
prev.checked = false;
|
||||
} else if (event.eventType === ExpertFileActivityType.HANDLED) {
|
||||
prev.handled = true;
|
||||
prev.checked = false;
|
||||
prev.handledAt = event.occurredAt;
|
||||
}
|
||||
stateByExpertFile.set(key, prev);
|
||||
}
|
||||
|
||||
return stateByExpertFile;
|
||||
}
|
||||
|
||||
private emptyExpertCaseBreakdown() {
|
||||
return {
|
||||
totalFiles: 0,
|
||||
reviewedFiles: 0,
|
||||
unreviewedFiles: 0,
|
||||
thirdPartyFiles: 0,
|
||||
carBodyFiles: 0,
|
||||
thirdPartyReviewedFiles: 0,
|
||||
thirdPartyUnreviewedFiles: 0,
|
||||
carBodyReviewedFiles: 0,
|
||||
carBodyUnreviewedFiles: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private addFileToExpertCaseBreakdown(
|
||||
counts: {
|
||||
totalFiles: number;
|
||||
reviewedFiles: number;
|
||||
unreviewedFiles: number;
|
||||
thirdPartyFiles: number;
|
||||
carBodyFiles: number;
|
||||
thirdPartyReviewedFiles: number;
|
||||
thirdPartyUnreviewedFiles: number;
|
||||
carBodyReviewedFiles: number;
|
||||
carBodyUnreviewedFiles: number;
|
||||
},
|
||||
fileType: string | undefined,
|
||||
reviewed: boolean,
|
||||
) {
|
||||
counts.totalFiles += 1;
|
||||
if (reviewed) counts.reviewedFiles += 1;
|
||||
else counts.unreviewedFiles += 1;
|
||||
|
||||
if (fileType === BlameRequestType.CAR_BODY) {
|
||||
counts.carBodyFiles += 1;
|
||||
if (reviewed) counts.carBodyReviewedFiles += 1;
|
||||
else counts.carBodyUnreviewedFiles += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
counts.thirdPartyFiles += 1;
|
||||
if (reviewed) counts.thirdPartyReviewedFiles += 1;
|
||||
else counts.thirdPartyUnreviewedFiles += 1;
|
||||
}
|
||||
|
||||
private async buildExpertReportRows(
|
||||
actor: any,
|
||||
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
||||
) {
|
||||
const clientObjectId = this.getClientId(actor);
|
||||
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
||||
const [experts, damageExperts, fieldExperts, blameFiles, claimFiles] =
|
||||
const range = this.resolveReportDateRange(opts);
|
||||
const [experts, damageExperts, fieldExperts, blameFiles, claimFiles, activityEvents] =
|
||||
await Promise.all([
|
||||
this.expertDbService.findAll(ckFilter as never),
|
||||
this.damageExpertDbService.findAll(ckFilter as never),
|
||||
this.fieldExpertDbService.findAll(ckFilter as never),
|
||||
this.getClientBlameFiles(clientObjectId),
|
||||
this.getClientClaimFiles(clientObjectId),
|
||||
this.expertFileActivityDbService.findByTenant(clientObjectId),
|
||||
]);
|
||||
|
||||
const allExpertsRaw = [...experts, ...damageExperts, ...fieldExperts];
|
||||
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}`,
|
||||
const roster = [
|
||||
...(experts as any[]).map((expert) => ({
|
||||
_id: String(expert._id),
|
||||
fullName: `${expert.firstName} ${expert.lastName}`.trim(),
|
||||
expertKind: "blame" as const,
|
||||
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")),
|
||||
...fieldExperts.map((e) => mapExpertRow(e, "blame")),
|
||||
createdAt: expert.createdAt ?? null,
|
||||
})),
|
||||
...(damageExperts as any[]).map((expert) => ({
|
||||
_id: String(expert._id),
|
||||
fullName: `${expert.firstName} ${expert.lastName}`.trim(),
|
||||
expertKind: "claim" as const,
|
||||
role: expert.role,
|
||||
type: expert.userType,
|
||||
createdAt: expert.createdAt ?? null,
|
||||
})),
|
||||
...(fieldExperts as any[]).map((expert) => ({
|
||||
_id: String(expert._id),
|
||||
fullName: `${expert.firstName} ${expert.lastName}`.trim(),
|
||||
expertKind: "blame" as const,
|
||||
role: expert.role,
|
||||
type: expert.userType,
|
||||
createdAt: expert.createdAt ?? null,
|
||||
})),
|
||||
];
|
||||
|
||||
const rosterById = new Map(
|
||||
roster.map((expert) => [expert._id, { ...expert, ...this.emptyExpertCaseBreakdown() }]),
|
||||
);
|
||||
const fileState = this.buildExpertFileStateMap(activityEvents as any[], range.toDate);
|
||||
|
||||
const blameFilesInRange = blameFiles.filter((file) =>
|
||||
this.isInDateRange(file?.createdAt, range.fromDate, range.toDate),
|
||||
);
|
||||
const claimFilesInRange = claimFiles.filter((file) =>
|
||||
this.isInDateRange(file?.createdAt, range.fromDate, range.toDate),
|
||||
);
|
||||
|
||||
for (const file of blameFilesInRange as any[]) {
|
||||
const expertIds = this.blameFieldExpertIdCandidates(file).filter((expertId) =>
|
||||
rosterById.has(expertId),
|
||||
);
|
||||
for (const expertId of new Set(expertIds)) {
|
||||
const row = rosterById.get(expertId);
|
||||
if (!row) continue;
|
||||
const state = fileState.get(`${expertId}:${String(file._id)}`);
|
||||
this.addFileToExpertCaseBreakdown(
|
||||
row,
|
||||
String(file?.type ?? BlameRequestType.THIRD_PARTY),
|
||||
!!state?.handled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of claimFilesInRange as any[]) {
|
||||
const expertIds = [
|
||||
file?.initiatedByFieldExpertId
|
||||
? String(file.initiatedByFieldExpertId)
|
||||
: undefined,
|
||||
this.claimDamageExpertActorId(file) ?? undefined,
|
||||
]
|
||||
.filter((expertId): expertId is string => !!expertId)
|
||||
.filter((expertId) => rosterById.has(expertId));
|
||||
const fileType = String(
|
||||
file?.snapshot?.accident?.type ??
|
||||
file?.blameFile?.type ??
|
||||
BlameRequestType.THIRD_PARTY,
|
||||
);
|
||||
for (const expertId of new Set(expertIds)) {
|
||||
const row = rosterById.get(expertId);
|
||||
if (!row) continue;
|
||||
const state = fileState.get(`${expertId}:${String(file._id)}`);
|
||||
this.addFileToExpertCaseBreakdown(row, fileType, !!state?.handled);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
range,
|
||||
experts: Array.from(rosterById.values()),
|
||||
};
|
||||
}
|
||||
|
||||
private portfolioFileReviewed(
|
||||
file: {
|
||||
blame?: { requestId?: string };
|
||||
claim?: { requestId?: string };
|
||||
},
|
||||
handledAtByFileId: Map<string, Date>,
|
||||
): boolean {
|
||||
const blameHandled = file.blame?.requestId
|
||||
? handledAtByFileId.has(String(file.blame.requestId))
|
||||
: false;
|
||||
const claimHandled = file.claim?.requestId
|
||||
? handledAtByFileId.has(String(file.claim.requestId))
|
||||
: false;
|
||||
return blameHandled || claimHandled;
|
||||
}
|
||||
|
||||
private portfolioFileHandledAt(
|
||||
file: {
|
||||
blame?: { requestId?: string };
|
||||
claim?: { requestId?: string };
|
||||
},
|
||||
handledAtByFileId: Map<string, Date>,
|
||||
): Date | null {
|
||||
const handledDates = [
|
||||
file.blame?.requestId
|
||||
? handledAtByFileId.get(String(file.blame.requestId))
|
||||
: undefined,
|
||||
file.claim?.requestId
|
||||
? handledAtByFileId.get(String(file.claim.requestId))
|
||||
: undefined,
|
||||
].filter((value): value is Date => value instanceof Date);
|
||||
|
||||
if (!handledDates.length) return null;
|
||||
return new Date(Math.max(...handledDates.map((value) => value.getTime())));
|
||||
}
|
||||
|
||||
async retrieveAllExpertsOfClient(
|
||||
actor,
|
||||
currentPage: number,
|
||||
countPerPage: number,
|
||||
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
||||
) {
|
||||
const report = await this.buildExpertReportRows(actor, opts);
|
||||
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,
|
||||
range: this.serializeReportRange(report.range),
|
||||
total: report.experts.length,
|
||||
page,
|
||||
countPerPage: perPage,
|
||||
experts: allExperts.slice(start, start + perPage),
|
||||
experts: report.experts.slice(start, start + perPage),
|
||||
};
|
||||
}
|
||||
|
||||
async getTopExpertsForClient(
|
||||
actor,
|
||||
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
||||
): Promise<{
|
||||
range: {
|
||||
mode: "preset" | "custom";
|
||||
from: string;
|
||||
to: string;
|
||||
periodDays?: 30 | 60 | 90;
|
||||
};
|
||||
experts: any[];
|
||||
}> {
|
||||
const report = await this.buildExpertReportRows(actor, opts);
|
||||
const experts = report.experts
|
||||
.filter((expert) => expert.totalFiles > 0)
|
||||
.sort((a, b) => {
|
||||
if (b.totalFiles !== a.totalFiles) return b.totalFiles - a.totalFiles;
|
||||
return a.fullName.localeCompare(b.fullName, "fa");
|
||||
})
|
||||
.slice(0, 10)
|
||||
.map((expert) => ({
|
||||
_id: expert._id,
|
||||
fullName: expert.fullName,
|
||||
expertKind: expert.expertKind,
|
||||
totalFiles: expert.totalFiles,
|
||||
reviewedFiles: expert.reviewedFiles,
|
||||
unreviewedFiles: expert.unreviewedFiles,
|
||||
thirdPartyFiles: expert.thirdPartyFiles,
|
||||
carBodyFiles: expert.carBodyFiles,
|
||||
thirdPartyReviewedFiles: expert.thirdPartyReviewedFiles,
|
||||
thirdPartyUnreviewedFiles: expert.thirdPartyUnreviewedFiles,
|
||||
carBodyReviewedFiles: expert.carBodyReviewedFiles,
|
||||
carBodyUnreviewedFiles: expert.carBodyUnreviewedFiles,
|
||||
}));
|
||||
|
||||
return {
|
||||
range: this.serializeReportRange(report.range),
|
||||
experts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
opts: { from?: string; to?: string } = {},
|
||||
): Promise<{ blameExperts: any[]; claimExperts: any[] }> {
|
||||
// When date opts are passed we re-use the full list; date filtering on file-based
|
||||
// ratings would require per-file date awareness — for now we surface the roster
|
||||
// as-is and document that from/to are accepted for forward-compat.
|
||||
const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
|
||||
const rows = result?.experts || [];
|
||||
|
||||
const byRatingDesc = (a: any, b: any) =>
|
||||
(b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
|
||||
|
||||
// Return only the fields the spec mandates — keep shape minimal
|
||||
const slim = (e: any) => ({
|
||||
_id: e._id,
|
||||
fullName: e.fullName,
|
||||
expertKind: e.expertKind,
|
||||
overallAverageRating: e.overallAverageRating ?? null,
|
||||
requestStats: e.requestStats ?? { totalHandled: 0, totalChecked: 0 },
|
||||
});
|
||||
|
||||
const blameExperts = rows
|
||||
.filter((e) => e.expertKind === "blame")
|
||||
.sort(byRatingDesc)
|
||||
.slice(0, 10)
|
||||
.map(slim);
|
||||
const claimExperts = rows
|
||||
.filter((e) => e.expertKind === "claim")
|
||||
.sort(byRatingDesc)
|
||||
.slice(0, 10)
|
||||
.map(slim);
|
||||
|
||||
return { blameExperts, claimExperts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy endpoint kept for backward compatibility.
|
||||
* Returns top 10 claim files for the current insurer client based on
|
||||
* combined insurer + user ratings.
|
||||
*/
|
||||
async getTopFilesForClient(
|
||||
insurerId: string,
|
||||
opts: { from?: string; to?: string } = {},
|
||||
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
||||
): Promise<Array<{
|
||||
publicId: string;
|
||||
createdAt: string;
|
||||
combinedScore: number;
|
||||
userRating: { comment: string | null; overallEvaluation: number | null };
|
||||
}>> {
|
||||
const fromDate = opts.from ? new Date(opts.from) : undefined;
|
||||
const toDate = opts.to ? new Date(opts.to) : undefined;
|
||||
|
||||
const range = this.resolveReportDateRange(opts);
|
||||
let claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
|
||||
|
||||
if (fromDate || toDate) {
|
||||
claimFiles = claimFiles.filter((f) => {
|
||||
const d = new Date(f.createdAt);
|
||||
if (fromDate && d < fromDate) return false;
|
||||
if (toDate && d > toDate) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
claimFiles = claimFiles.filter((file) =>
|
||||
this.isInDateRange(file.createdAt, range.fromDate, range.toDate),
|
||||
);
|
||||
|
||||
return claimFiles
|
||||
.map((file) => {
|
||||
@@ -1047,13 +1279,17 @@ export class ExpertInsurerService {
|
||||
const ur = file?.userRating;
|
||||
return {
|
||||
publicId: String(file.publicId ?? ""),
|
||||
createdAt: file.createdAt instanceof Date
|
||||
? file.createdAt.toISOString()
|
||||
: String(file.createdAt ?? ""),
|
||||
createdAt:
|
||||
file.createdAt instanceof Date
|
||||
? file.createdAt.toISOString()
|
||||
: String(file.createdAt ?? ""),
|
||||
combinedScore,
|
||||
userRating: {
|
||||
comment: ur?.comment ?? null,
|
||||
overallEvaluation: typeof ur?.overallEvaluation === "number" ? ur.overallEvaluation : null,
|
||||
overallEvaluation:
|
||||
typeof ur?.overallEvaluation === "number"
|
||||
? ur.overallEvaluation
|
||||
: null,
|
||||
},
|
||||
};
|
||||
})
|
||||
@@ -1062,6 +1298,155 @@ export class ExpertInsurerService {
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
async getReportCharts(
|
||||
actor: any,
|
||||
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
||||
) {
|
||||
const clientObjectId = this.getClientId(actor);
|
||||
const [expertReport, mergedFiles, activityEvents] = await Promise.all([
|
||||
this.buildExpertReportRows(actor, opts),
|
||||
this.buildMergedInsurerFiles(clientObjectId),
|
||||
this.expertFileActivityDbService.findByTenant(clientObjectId),
|
||||
]);
|
||||
|
||||
const range = expertReport.range;
|
||||
const handledState = this.buildExpertFileStateMap(
|
||||
activityEvents as any[],
|
||||
range.toDate,
|
||||
);
|
||||
const handledAtByFileId = new Map<string, Date>();
|
||||
for (const [key, state] of handledState.entries()) {
|
||||
if (!state.handled || !state.handledAt) continue;
|
||||
const fileId = key.split(":").slice(1).join(":");
|
||||
const prev = handledAtByFileId.get(fileId);
|
||||
if (!prev || prev < state.handledAt) {
|
||||
handledAtByFileId.set(fileId, state.handledAt);
|
||||
}
|
||||
}
|
||||
|
||||
const filesInRange = mergedFiles.filter((file) =>
|
||||
this.isInDateRange(file.createdAt, range.fromDate, range.toDate),
|
||||
);
|
||||
|
||||
const filesPerDayMap = new Map<
|
||||
string,
|
||||
{ totalFiles: number; thirdPartyFiles: number; carBodyFiles: number }
|
||||
>();
|
||||
const avgHandlingByDayMap = new Map<
|
||||
string,
|
||||
{ handledFiles: number; totalDurationMs: number }
|
||||
>();
|
||||
|
||||
for (
|
||||
let cursor = this.startOfDay(range.fromDate);
|
||||
cursor <= range.toDate;
|
||||
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000)
|
||||
) {
|
||||
const key = this.toUtcDateKey(cursor);
|
||||
filesPerDayMap.set(key, {
|
||||
totalFiles: 0,
|
||||
thirdPartyFiles: 0,
|
||||
carBodyFiles: 0,
|
||||
});
|
||||
avgHandlingByDayMap.set(key, { handledFiles: 0, totalDurationMs: 0 });
|
||||
}
|
||||
|
||||
for (const file of filesInRange as any[]) {
|
||||
const createdAt = new Date(file.createdAt);
|
||||
if (!Number.isNaN(createdAt.getTime())) {
|
||||
const createdKey = this.toUtcDateKey(createdAt);
|
||||
const createdBucket = filesPerDayMap.get(createdKey);
|
||||
if (createdBucket) {
|
||||
createdBucket.totalFiles += 1;
|
||||
if (file.fileType === BlameRequestType.CAR_BODY) {
|
||||
createdBucket.carBodyFiles += 1;
|
||||
} else {
|
||||
createdBucket.thirdPartyFiles += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handledAt = this.portfolioFileHandledAt(file, handledAtByFileId);
|
||||
if (!handledAt || handledAt < range.fromDate || handledAt > range.toDate) {
|
||||
continue;
|
||||
}
|
||||
if (Number.isNaN(createdAt.getTime())) continue;
|
||||
const handledKey = this.toUtcDateKey(handledAt);
|
||||
const handledBucket = avgHandlingByDayMap.get(handledKey);
|
||||
if (!handledBucket) continue;
|
||||
handledBucket.handledFiles += 1;
|
||||
handledBucket.totalDurationMs += Math.max(
|
||||
handledAt.getTime() - createdAt.getTime(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
const topExperts = expertReport.experts
|
||||
.filter((expert) => expert.totalFiles > 0)
|
||||
.sort((a, b) => {
|
||||
if (b.totalFiles !== a.totalFiles) return b.totalFiles - a.totalFiles;
|
||||
return a.fullName.localeCompare(b.fullName, "fa");
|
||||
})
|
||||
.slice(0, 10)
|
||||
.map((expert) => ({
|
||||
_id: expert._id,
|
||||
fullName: expert.fullName,
|
||||
expertKind: expert.expertKind,
|
||||
totalFiles: expert.totalFiles,
|
||||
reviewedFiles: expert.reviewedFiles,
|
||||
unreviewedFiles: expert.unreviewedFiles,
|
||||
thirdPartyFiles: expert.thirdPartyFiles,
|
||||
carBodyFiles: expert.carBodyFiles,
|
||||
thirdPartyReviewedFiles: expert.thirdPartyReviewedFiles,
|
||||
thirdPartyUnreviewedFiles: expert.thirdPartyUnreviewedFiles,
|
||||
carBodyReviewedFiles: expert.carBodyReviewedFiles,
|
||||
carBodyUnreviewedFiles: expert.carBodyUnreviewedFiles,
|
||||
}));
|
||||
|
||||
return {
|
||||
range: this.serializeReportRange(range),
|
||||
topExperts,
|
||||
filesPerExpertThirdParty: expertReport.experts
|
||||
.filter((expert) => expert.thirdPartyFiles > 0)
|
||||
.sort((a, b) => b.thirdPartyFiles - a.thirdPartyFiles)
|
||||
.map((expert) => ({
|
||||
expertId: expert._id,
|
||||
fullName: expert.fullName,
|
||||
expertKind: expert.expertKind,
|
||||
value: expert.thirdPartyFiles,
|
||||
})),
|
||||
filesPerExpertCarBody: expertReport.experts
|
||||
.filter((expert) => expert.carBodyFiles > 0)
|
||||
.sort((a, b) => b.carBodyFiles - a.carBodyFiles)
|
||||
.map((expert) => ({
|
||||
expertId: expert._id,
|
||||
fullName: expert.fullName,
|
||||
expertKind: expert.expertKind,
|
||||
value: expert.carBodyFiles,
|
||||
})),
|
||||
filesByDay: Array.from(filesPerDayMap.entries()).map(([date, value]) => ({
|
||||
date,
|
||||
...value,
|
||||
})),
|
||||
averageHandlingTimeByDay: Array.from(avgHandlingByDayMap.entries()).map(
|
||||
([date, value]) => ({
|
||||
date,
|
||||
handledFiles: value.handledFiles,
|
||||
averageHandlingDays:
|
||||
value.handledFiles > 0
|
||||
? parseFloat(
|
||||
(
|
||||
value.totalDurationMs /
|
||||
value.handledFiles /
|
||||
(24 * 60 * 60 * 1000)
|
||||
).toFixed(2),
|
||||
)
|
||||
: 0,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async getAllFilesForInsurerExpert(
|
||||
expertId: string,
|
||||
insurerClientKey: string,
|
||||
@@ -1426,11 +1811,15 @@ export class ExpertInsurerService {
|
||||
query: UnifiedFileStatusReportQueryDto = {},
|
||||
): Promise<UnifiedFileStatusReportDto> {
|
||||
const clientObjectId = this.getClientId(actor);
|
||||
const { fromDate, toDate } = parseListDateRange(query.from, query.to);
|
||||
const range = this.resolveReportDateRange({
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
periodDays: query.periodDays,
|
||||
});
|
||||
|
||||
const files = await this.buildMergedInsurerFiles(clientObjectId);
|
||||
let inRange = files.filter((f) =>
|
||||
isInListDateRange(f.createdAt, fromDate, toDate),
|
||||
this.isInDateRange(f.createdAt, range.fromDate, range.toDate),
|
||||
);
|
||||
if (query.fileType) {
|
||||
inRange = inRange.filter((f) => f.fileType === query.fileType);
|
||||
@@ -1818,157 +2207,58 @@ export class ExpertInsurerService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
opts: { from?: string; to?: string } = {},
|
||||
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
||||
) {
|
||||
const clientObjectId = this.getClientId(actor);
|
||||
const [claimFiles, blameFiles, activityEvents] = await Promise.all([
|
||||
this.getClientClaimFiles(clientObjectId),
|
||||
this.getClientBlameFiles(clientObjectId),
|
||||
const [expertReport, mergedFiles, activityEvents] = await Promise.all([
|
||||
this.buildExpertReportRows(actor, opts),
|
||||
this.buildMergedInsurerFiles(clientObjectId),
|
||||
this.expertFileActivityDbService.findByTenant(clientObjectId),
|
||||
]);
|
||||
|
||||
// Optional date range filter for portfolio counts
|
||||
const fromDate = opts.from ? new Date(opts.from) : undefined;
|
||||
const toDate = opts.to ? new Date(opts.to) : undefined;
|
||||
const inRange = (date: unknown) => {
|
||||
if (!fromDate && !toDate) return true;
|
||||
if (!date) return false;
|
||||
const d = new Date(date as string);
|
||||
if (fromDate && d < fromDate) return false;
|
||||
if (toDate && d > toDate) return false;
|
||||
return true;
|
||||
};
|
||||
const rangedClaimFiles = claimFiles.filter((f) => inRange(f.createdAt));
|
||||
|
||||
// Current calendar month
|
||||
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);
|
||||
const filesThisMonth = claimFiles.filter((f) => {
|
||||
const d = new Date(f.createdAt);
|
||||
return d >= monthStart && d <= monthEnd;
|
||||
});
|
||||
|
||||
// totalFilesReviewed: distinct claim files with at least one CHECKED activity
|
||||
const checkedFileIds = new Set<string>();
|
||||
for (const ev of activityEvents) {
|
||||
if (ev.eventType === ExpertFileActivityType.CHECKED) {
|
||||
checkedFileIds.add(String(ev.fileId));
|
||||
const range = expertReport.range;
|
||||
const fileState = this.buildExpertFileStateMap(
|
||||
activityEvents as any[],
|
||||
range.toDate,
|
||||
);
|
||||
const handledAtByFileId = new Map<string, Date>();
|
||||
for (const [key, state] of fileState.entries()) {
|
||||
if (!state.handled || !state.handledAt) continue;
|
||||
const fileId = key.split(":").slice(1).join(":");
|
||||
const prev = handledAtByFileId.get(fileId);
|
||||
if (!prev || prev < state.handledAt) {
|
||||
handledAtByFileId.set(fileId, state.handledAt);
|
||||
}
|
||||
}
|
||||
// Intersect with this insurer's claim file ids
|
||||
const claimFileIds = new Set(claimFiles.map((f) => String(f._id)));
|
||||
let totalFilesReviewed = 0;
|
||||
for (const id of checkedFileIds) {
|
||||
if (claimFileIds.has(id)) totalFilesReviewed++;
|
||||
|
||||
const filesInRange = mergedFiles.filter((file) =>
|
||||
this.isInDateRange(file.createdAt, range.fromDate, range.toDate),
|
||||
);
|
||||
|
||||
const summary = this.emptyExpertCaseBreakdown();
|
||||
for (const file of filesInRange as any[]) {
|
||||
this.addFileToExpertCaseBreakdown(
|
||||
summary,
|
||||
String(file.fileType ?? BlameRequestType.THIRD_PARTY),
|
||||
this.portfolioFileReviewed(file, handledAtByFileId),
|
||||
);
|
||||
}
|
||||
|
||||
// inPersonAccompaniedCount: blame files where expertInitiated === true AND creationMethod === IN_PERSON
|
||||
const inPersonAccompaniedCount = blameFiles.filter(
|
||||
(b) => !!(b as any).expertInitiated && (b as any).creationMethod === "IN_PERSON",
|
||||
).length;
|
||||
|
||||
// Per-file statistics over the (optionally date-ranged) claim portfolio
|
||||
let totalInsurerRatings = 0;
|
||||
let totalBotRatings = 0;
|
||||
let filesWithInsurerRating = 0;
|
||||
let filesWithBotRating = 0;
|
||||
let filesWithUserRating = 0;
|
||||
let filesWithObjection = 0;
|
||||
|
||||
// averageUserRatingPercentage: mean of (progressSpeed + registrationEase + overallEvaluation) / 5 * 100
|
||||
let userRatingDimensionSum = 0;
|
||||
let userRatingDimensionCount = 0;
|
||||
|
||||
for (const file of rangedClaimFiles) {
|
||||
const insurerRating = file?.rating;
|
||||
const userRating = file?.userRating;
|
||||
|
||||
if (insurerRating) {
|
||||
const insurerValues = [
|
||||
insurerRating.collisionMethodAccuracy,
|
||||
insurerRating.evaluationTimeliness,
|
||||
insurerRating.accidentCauseAccuracy,
|
||||
insurerRating.guiltyVehicleIdentification,
|
||||
].filter((v): v is number => typeof v === "number" && !isNaN(v));
|
||||
if (insurerValues.length > 0) {
|
||||
filesWithInsurerRating++;
|
||||
totalInsurerRatings += insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length;
|
||||
}
|
||||
const botRating = (insurerRating as any)?.botRating;
|
||||
if (typeof botRating === "number" && !isNaN(botRating)) {
|
||||
filesWithBotRating++;
|
||||
totalBotRatings += botRating;
|
||||
}
|
||||
}
|
||||
|
||||
if (userRating) {
|
||||
filesWithUserRating++;
|
||||
const dims = [
|
||||
userRating.progressSpeed,
|
||||
userRating.registrationEase,
|
||||
userRating.overallEvaluation,
|
||||
].filter((v): v is number => typeof v === "number" && !isNaN(v));
|
||||
if (dims.length > 0) {
|
||||
userRatingDimensionSum += dims.reduce((a, b) => a + b, 0) / dims.length;
|
||||
userRatingDimensionCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (file?.objection) filesWithObjection++;
|
||||
}
|
||||
|
||||
const totalFiles = rangedClaimFiles.length;
|
||||
const averageInsurerRating = filesWithInsurerRating > 0 ? totalInsurerRatings / filesWithInsurerRating : 0;
|
||||
const averageBotRating = filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0;
|
||||
|
||||
// averageUserRatingPercentage: (mean score / 5) * 100; mean score is avg of the three
|
||||
// per-file dimension averages across all rated files.
|
||||
const averageUserRatingPercentage =
|
||||
userRatingDimensionCount > 0
|
||||
? parseFloat(((userRatingDimensionSum / userRatingDimensionCount / 5) * 100).toFixed(2))
|
||||
: 0;
|
||||
|
||||
const insurerToBotPercentage =
|
||||
averageBotRating > 0 && averageInsurerRating > 0
|
||||
? parseFloat(((averageInsurerRating / averageBotRating) * 100).toFixed(2))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
// New / corrected KPI fields
|
||||
totalFilesReviewed,
|
||||
averageUserRatingPercentage,
|
||||
inPersonAccompaniedCount,
|
||||
// Unchanged
|
||||
filesCreatedThisMonth: filesThisMonth.length,
|
||||
totalFiles,
|
||||
objectionPercentage:
|
||||
totalFiles > 0 ? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2)) : 0,
|
||||
insurerToBotRatingPercentage: insurerToBotPercentage,
|
||||
// Deprecated: percentage of files that HAVE a user rating; not a satisfaction metric
|
||||
filesWithUserRatingPercentage:
|
||||
totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0,
|
||||
/** @deprecated use filesWithUserRatingPercentage */
|
||||
userRatingPercentage:
|
||||
totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0,
|
||||
breakdown: {
|
||||
filesWithInsurerRating,
|
||||
filesWithBotRating,
|
||||
filesWithUserRating,
|
||||
filesWithObjection,
|
||||
averageInsurerRating: parseFloat(averageInsurerRating.toFixed(2)),
|
||||
averageBotRating: parseFloat(averageBotRating.toFixed(2)),
|
||||
},
|
||||
range: this.serializeReportRange(range),
|
||||
thirdPartyFiles: summary.thirdPartyFiles,
|
||||
carBodyFiles: summary.carBodyFiles,
|
||||
totalFiles: summary.totalFiles,
|
||||
activeExperts: expertReport.experts.filter((expert) => expert.totalFiles > 0)
|
||||
.length,
|
||||
reviewedFiles: summary.reviewedFiles,
|
||||
unreviewedFiles: summary.unreviewedFiles,
|
||||
thirdPartyReviewedFiles: summary.thirdPartyReviewedFiles,
|
||||
thirdPartyUnreviewedFiles: summary.thirdPartyUnreviewedFiles,
|
||||
carBodyReviewedFiles: summary.carBodyReviewedFiles,
|
||||
carBodyUnreviewedFiles: summary.carBodyUnreviewedFiles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2133,6 +2423,7 @@ export class ExpertInsurerService {
|
||||
actor: any,
|
||||
from?: string,
|
||||
to?: string,
|
||||
periodDays?: string | number,
|
||||
): Promise<{
|
||||
all: number;
|
||||
completed: number;
|
||||
@@ -2142,6 +2433,7 @@ export class ExpertInsurerService {
|
||||
const report = await this.getInsurerUnifiedFileStatusReport(actor, {
|
||||
from,
|
||||
to,
|
||||
periodDays,
|
||||
});
|
||||
const counts = report.counts;
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user