1
0
forked from Yara724/api

Merge pull request 'YARA-855, YARA-856' (#47) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#47
This commit is contained in:
2026-04-30 10:34:52 +03:30
3 changed files with 258 additions and 152 deletions

View File

@@ -205,6 +205,9 @@ export class ClaimFileRating {
@Prop({ type: Number, min: 0, max: 5 }) @Prop({ type: Number, min: 0, max: 5 })
guiltyVehicleIdentification?: number; guiltyVehicleIdentification?: number;
@Prop({ type: Number, min: 0, max: 5 })
botRating?: number;
} }
export const ClaimFileRatingSchema = export const ClaimFileRatingSchema =
SchemaFactory.createForClass(ClaimFileRating); SchemaFactory.createForClass(ClaimFileRating);

View File

@@ -13,6 +13,7 @@ import {
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody, ApiBody,
ApiOperation,
ApiParam, ApiParam,
ApiQuery, ApiQuery,
ApiTags, ApiTags,
@@ -105,6 +106,11 @@ export class ExpertInsurerController {
} }
@Get("experts/top") @Get("experts/top")
@ApiOperation({
summary: "Top blame vs claim experts for this insurer",
description:
"Response has two arrays: `blameExperts` (roster from expert / blame files) and `claimExperts` (damage-expert roster / claim files). Each item includes `overallAverageRating` derived from ratings stored on those files plus user ratings where present.",
})
async getTopExperts(@CurrentUser() actor) { async getTopExperts(@CurrentUser() actor) {
return await this.expertInsurerService.getTopExpertsForClient(actor); return await this.expertInsurerService.getTopExpertsForClient(actor);
} }
@@ -128,66 +134,9 @@ export class ExpertInsurerController {
); );
} }
@Get("top-files")
async getTopFiles(@CurrentUser() insurer) {
return await this.expertInsurerService.getTopFilesForClient(
insurer.clientKey,
);
}
@Get("statistics")
async getExpertStatistics(@CurrentUser() actor) {
return await this.expertInsurerService.getExpertStatisticsReport(actor);
}
@Get("report/status-counts")
@ApiQuery({
name: "from",
required: false,
description: "Optional start datetime (ISO string)",
})
@ApiQuery({
name: "to",
required: false,
description: "Optional end datetime (ISO string)",
})
async getInsurerStatusReport(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getInsurerFileStatusCounts(
actor,
from,
to,
);
}
@ApiParam({ name: "expertId" })
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
@Get("/:expertId")
async requestDetail(
@CurrentUser() insurer,
@Param("expertId") id: string,
@Query("role") role: "claim" | "blame",
) {
if (!Types.ObjectId.isValid(new Types.ObjectId(id))) {
throw new BadRequestException("Invalid expert ID");
}
if (!["claim", "blame"].includes(role)) {
throw new BadRequestException("Invalid role");
}
return await this.expertInsurerService.getAllFilesByExpertAndRole(
id,
role,
insurer.clientKey,
);
}
@ApiBody({ @ApiBody({
description: "Detailed expert rating by insurer", description:
"One insurer rating for the shared publicId; persisted on claim and/or blame case documents when both exist.",
schema: { schema: {
type: "object", type: "object",
properties: { properties: {
@@ -243,23 +192,69 @@ export class ExpertInsurerController {
}, },
}, },
}) })
@ApiParam({ name: "requestId" }) @ApiParam({ name: "publicId" })
@ApiQuery({ name: "role", enum: ["claim", "blame"] }) @Put("files/:publicId/rating")
@Put("/:requestId/rating") async rateExpertsByPublicId(
async rateExperts(
@CurrentUser() insurer, @CurrentUser() insurer,
@Param("requestId") requestId: string, @Param("publicId") publicId: string,
@Body() rating: FileRating, @Body() rating: FileRating,
@Query("role") role: "claim" | "blame",
) { ) {
if (!["claim", "blame"].includes(role)) { return await this.expertInsurerService.rateExpertByPublicId(
throw new BadRequestException("Invalid role"); publicId,
rating,
insurer.clientKey,
);
}
@Get("top-files")
async getTopFiles(@CurrentUser() insurer) {
return await this.expertInsurerService.getTopFilesForClient(
insurer.clientKey,
);
}
@Get("statistics")
async getExpertStatistics(@CurrentUser() actor) {
return await this.expertInsurerService.getExpertStatisticsReport(actor);
}
@Get("report/status-counts")
@ApiQuery({
name: "from",
required: false,
description: "Optional start datetime (ISO string)",
})
@ApiQuery({
name: "to",
required: false,
description: "Optional end datetime (ISO string)",
})
async getInsurerStatusReport(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getInsurerFileStatusCounts(
actor,
from,
to,
);
}
@ApiParam({ name: "expertId" })
@ApiOperation({
summary: "Files handled by one roster expert (summary rows)",
description:
"Resolves id against this insurer's `expert` then `damage-expert` roster. Blame branch runs when the id is on the expert roster (field experts). Each item is a small summary (`kind`, ids, statuses, dates)—not full file payloads. Claim matches use final or draft damage-expert reply.",
})
@Get("/:expertId")
async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) {
if (!Types.ObjectId.isValid(id)) {
throw new BadRequestException("Invalid expert ID");
} }
return await this.expertInsurerService.rateExpertOnFile( return await this.expertInsurerService.getAllFilesForInsurerExpert(
requestId, id,
rating,
role,
insurer.clientKey, insurer.clientKey,
); );
} }

View File

@@ -50,6 +50,16 @@ export class ExpertInsurerService {
return new Types.ObjectId(raw); 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( private async buildExpertActivityStatsMap(
tenantId: Types.ObjectId, tenantId: Types.ObjectId,
expertIds: string[], expertIds: string[],
@@ -134,6 +144,55 @@ export class ExpertInsurerService {
}; };
} }
/** 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) { private buildPartiesPreview(parties: any[] | undefined) {
if (!Array.isArray(parties) || !parties.length) return undefined; if (!Array.isArray(parties) || !parties.length) return undefined;
return parties.map((p: any) => ({ return parties.map((p: any) => ({
@@ -170,9 +229,14 @@ export class ExpertInsurerService {
const insurerRating = file?.rating; const insurerRating = file?.rating;
const userRating = file?.userRating; const userRating = file?.userRating;
const insurerValues = insurerRating const insurerValues = insurerRating
? Object.values(insurerRating).filter( ? (
(v): v is number => typeof v === "number" && !isNaN(v), [
) insurerRating.collisionMethodAccuracy,
insurerRating.evaluationTimeliness,
insurerRating.accidentCauseAccuracy,
insurerRating.guiltyVehicleIdentification,
] as unknown[]
).filter((v): v is number => typeof v === "number" && !isNaN(v))
: []; : [];
const userValues = userRating const userValues = userRating
? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter( ? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter(
@@ -210,9 +274,10 @@ export class ExpertInsurerService {
async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) { async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) {
const clientObjectId = this.getClientId(actor); const clientObjectId = this.getClientId(actor);
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([ const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
this.expertDbService.findAll({ clientKey: String(clientObjectId) }), this.expertDbService.findAll(ckFilter as never),
this.damageExpertDbService.findAll({ clientKey: clientObjectId }), this.damageExpertDbService.findAll(ckFilter as never),
this.getClientBlameFiles(clientObjectId), this.getClientBlameFiles(clientObjectId),
this.getClientClaimFiles(clientObjectId), this.getClientClaimFiles(clientObjectId),
]); ]);
@@ -237,6 +302,7 @@ export class ExpertInsurerService {
if (!rating || typeof rating !== "object") return; if (!rating || typeof rating !== "object") return;
if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {}; if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {};
for (const [category, value] of Object.entries(rating)) { for (const [category, value] of Object.entries(rating)) {
if (category === "botRating") continue;
if (typeof value === "number" && !isNaN(value)) { if (typeof value === "number" && !isNaN(value)) {
if (!expertRatingsByCategoryMap[expertId][category]) { if (!expertRatingsByCategoryMap[expertId][category]) {
expertRatingsByCategoryMap[expertId][category] = []; expertRatingsByCategoryMap[expertId][category] = [];
@@ -253,7 +319,7 @@ export class ExpertInsurerService {
processRatings(file?.damageExpertReply?.actorDetail?.actorId, file); processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
} }
const allExperts = allExpertsRaw.map((expert) => { const mapExpertRow = (expert: any, expertKind: "blame" | "claim") => {
const expertIdStr = expert._id.toString(); const expertIdStr = expert._id.toString();
const totalRatings = expertTotalRatingsMap[expertIdStr] || []; const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
const overallAverageRating = totalRatings.length const overallAverageRating = totalRatings.length
@@ -274,6 +340,7 @@ export class ExpertInsurerService {
_id: expert._id, _id: expert._id,
fullName: `${expert.firstName} ${expert.lastName}`, fullName: `${expert.firstName} ${expert.lastName}`,
role: expert.role, role: expert.role,
expertKind,
type: expert.userType, type: expert.userType,
requestStats: expertActivityStatsMap[expertIdStr] ?? { requestStats: expertActivityStatsMap[expertIdStr] ?? {
totalHandled: 0, totalHandled: 0,
@@ -283,7 +350,12 @@ export class ExpertInsurerService {
overallAverageRating, overallAverageRating,
averageRatingsByCategory, averageRatingsByCategory,
}; };
}); };
const allExperts = [
...experts.map((e) => mapExpertRow(e, "blame")),
...damageExperts.map((e) => mapExpertRow(e, "claim")),
];
const page = Number(currentPage) > 0 ? Number(currentPage) : 1; const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20; const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
@@ -297,20 +369,33 @@ export class ExpertInsurerService {
} }
/** /**
* Returns top 10 experts for the current insurer client based on * Top experts per roster: blame rows come from the `expert` collection (files they
* combined insurer + user ratings on their handled files. * 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 files user rating, average those
* combined scores per file, then average across that experts files (`overallAverageRating`).
*/ */
async getTopExpertsForClient(actor): Promise<any[]> { async getTopExpertsForClient(actor): Promise<{
blameExperts: any[];
claimExperts: any[];
}> {
const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000); const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
const experts = result?.experts || []; const rows = result?.experts || [];
const sorted = [...experts].sort((a, b) => { const byRatingDesc = (a: any, b: any) =>
const ar = a.overallAverageRating ?? 0; (b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
const br = b.overallAverageRating ?? 0;
return br - ar;
});
return sorted.slice(0, 10); 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 };
} }
/** /**
@@ -329,101 +414,124 @@ export class ExpertInsurerService {
return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10); return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10);
} }
private async assertExpertBelongsToInsurer( async getAllFilesForInsurerExpert(expertId: string, insurerClientKey: string) {
expertObjectId: Types.ObjectId, const expertObjectId = this.parseObjectId(expertId, "expert id");
insurerClientKey: string, const clientOid = this.getClientId(insurerClientKey);
) { const ckFilter = this.clientKeyScopeFilter(clientOid);
const ck = String(insurerClientKey);
const clientOid = new Types.ObjectId(ck);
const [experts, damageExperts] = await Promise.all([ const [experts, damageExperts] = await Promise.all([
this.expertDbService.findAll({ clientKey: ck }), this.expertDbService.findAll(ckFilter as never),
this.damageExpertDbService.findAll({ clientKey: clientOid }), this.damageExpertDbService.findAll(ckFilter as never),
]); ]);
const id = String(expertObjectId); const id = String(expertObjectId);
const ok = [...experts, ...damageExperts].some((e) => String(e._id) === id); const onBlameRoster = experts.some((e) => String(e._id) === id);
if (!ok) { const onClaimRoster = damageExperts.some((e) => String(e._id) === id);
if (!onBlameRoster && !onClaimRoster) {
throw new ForbiddenException( throw new ForbiddenException(
"This expert is not registered under your insurance company.", "This expert is not registered under your insurance company.",
); );
} }
}
async getAllFilesByExpertAndRole( const clientKeyStr = String(clientOid);
expertId: string, if (onBlameRoster) {
role: "claim" | "blame", const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
insurerClientKey: string, return blames
) { .filter(
const expertObjectId = this.parseObjectId(expertId, "expert id"); (b) =>
await this.assertExpertBelongsToInsurer(expertObjectId, insurerClientKey); blameCaseTouchesClient(b, clientKeyStr) &&
const clientKeyStr = String(this.getClientId(insurerClientKey)); this.blameFieldExpertIdCandidates(b).includes(id),
)
if (role === "claim") { .map((b) => this.mapBlameFileSummaryForInsurerExpert(b));
}
if (onClaimRoster) {
const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[]; const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
return claims return claims
.filter( .filter(
(c) => (c) =>
claimCaseTouchesClient(c, clientKeyStr) && claimCaseTouchesClient(c, clientKeyStr) &&
c?.evaluation?.damageExpertReply?.actorDetail?.actorId === this.claimDamageExpertActorId(c) === id,
String(expertObjectId),
) )
.map((c) => this.normalizeClaimCase(c)); .map((c) => this.mapClaimFileSummaryForInsurerExpert(c));
} }
const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[]; return [];
return blames
.filter(
(b) =>
blameCaseTouchesClient(b, clientKeyStr) &&
String(b?.expert?.assignedExpertId ?? "") === String(expertObjectId),
)
.map((b) => this.normalizeBlameCase(b));
} }
async rateExpertOnFile( private validateInsurerFileRating(rating: FileRating) {
requestId: string, const required: (keyof FileRating)[] = [
rating: FileRating, "collisionMethodAccuracy",
role: "claim" | "blame", "evaluationTimeliness",
insurerClientKey: string, "accidentCauseAccuracy",
) { "guiltyVehicleIdentification",
const _id = this.parseObjectId(requestId, "request id"); "botRating",
const clientKeyStr = String(this.getClientId(insurerClientKey)); ];
for (const [key, value] of Object.entries(rating || {})) { for (const key of required) {
const value = rating?.[key];
if (typeof value !== "number" || value < 0 || value > 5) { if (typeof value !== "number" || value < 0 || value > 5) {
throw new BadRequestException(`${key} must be a number between 0 and 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)) { * Applies one insurer rating to every underlying case (claim and/or blame) for
throw new ForbiddenException("This claim does not belong to your organization."); * the shared `publicId`, when that case exists and belongs to this insurer.
} */
const updated = await this.claimCaseDbService.findByIdAndUpdate(_id, { 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 }, $set: { "evaluation.rating": rating },
}); });
if (!updated) throw new NotFoundException("Claim file not found"); if (!updated) throw new NotFoundException("Claim file not found");
return { out.claim = {
message: "Claim expert rated successfully", requestId: String((updated as any)._id),
updatedRating: (updated as any)?.evaluation?.rating || rating, updatedRating: (updated as any)?.evaluation?.rating ?? rating,
requestId: updated._id,
}; };
} }
if (role === "blame") {
const existing = await this.blameRequestDbService.findById(_id); if (blame) {
if (!existing) throw new NotFoundException("Blame file not found"); const blameId = this.parseObjectId(String((blame as any)._id), "blame id");
if (!blameCaseTouchesClient(existing as any, clientKeyStr)) { const updated = await this.blameRequestDbService.findByIdAndUpdate(blameId, {
throw new ForbiddenException("This blame case does not belong to your organization.");
}
const updated = await this.blameRequestDbService.findByIdAndUpdate(_id, {
$set: { "expert.rating": rating }, $set: { "expert.rating": rating },
}); });
if (!updated) throw new NotFoundException("Blame file not found"); if (!updated) throw new NotFoundException("Blame file not found");
return { out.blame = {
message: "Blame expert rated successfully", requestId: String((updated as any)._id),
updatedRating: (updated as any)?.expert?.rating || rating, updatedRating: (updated as any)?.expert?.rating ?? rating,
requestId: updated._id,
}; };
} }
throw new BadRequestException("Invalid role");
return {
message: "Rating saved for this file",
...out,
};
} }
async retrieveAllFilesOfClient(insurerId: string) { async retrieveAllFilesOfClient(insurerId: string) {