From 715a9f246765c4a478872fe9a916172e949e8334 Mon Sep 17 00:00:00 2001 From: "s.yahyaee" Date: Thu, 30 Apr 2026 09:57:28 +0330 Subject: [PATCH 1/2] YARA-856 --- .../schema/claim-case.evaluation.schema.ts | 3 + .../expert-insurer.controller.ts | 156 ++++++++-------- src/expert-insurer/expert-insurer.service.ts | 173 ++++++++++++------ 3 files changed, 201 insertions(+), 131 deletions(-) diff --git a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts index da6440b..8105e57 100644 --- a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts @@ -205,6 +205,9 @@ export class ClaimFileRating { @Prop({ type: Number, min: 0, max: 5 }) guiltyVehicleIdentification?: number; + + @Prop({ type: Number, min: 0, max: 5 }) + botRating?: number; } export const ClaimFileRatingSchema = SchemaFactory.createForClass(ClaimFileRating); diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index 926306a..d1457c0 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -13,6 +13,7 @@ import { import { ApiBearerAuth, ApiBody, + ApiOperation, ApiParam, ApiQuery, ApiTags, @@ -105,6 +106,11 @@ export class ExpertInsurerController { } @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) { return await this.expertInsurerService.getTopExpertsForClient(actor); } @@ -128,6 +134,78 @@ export class ExpertInsurerController { ); } + @ApiBody({ + description: + "One insurer rating for the shared publicId; persisted on claim and/or blame case documents when both exist.", + schema: { + type: "object", + properties: { + collisionMethodAccuracy: { + type: "number", + minimum: 0, + maximum: 5, + example: 4, + description: "تشخیص درست نحوه برخورد", + }, + evaluationTimeliness: { + type: "number", + minimum: 0, + maximum: 5, + example: 3, + description: "زمان ارزیابی", + }, + accidentCauseAccuracy: { + type: "number", + minimum: 0, + maximum: 5, + example: 5, + description: "تشخیص درست علت تصادف", + }, + guiltyVehicleIdentification: { + type: "number", + minimum: 0, + maximum: 5, + example: 4, + description: "تشخیص درست وسیله نقلیه مقصر", + }, + botRating: { + type: "number", + minimum: 0, + maximum: 5, + example: 4, + description: "برای امتیاز دادن به عملکرد بات", + }, + }, + required: [ + "collisionMethodAccuracy", + "evaluationTimeliness", + "accidentCauseAccuracy", + "guiltyVehicleIdentification", + "botRating", + ], + example: { + collisionMethodAccuracy: 4, + evaluationTimeliness: 3, + accidentCauseAccuracy: 5, + guiltyVehicleIdentification: 4, + botRating: 4, + }, + }, + }) + @ApiParam({ name: "publicId" }) + @Put("files/:publicId/rating") + async rateExpertsByPublicId( + @CurrentUser() insurer, + @Param("publicId") publicId: string, + @Body() rating: FileRating, + ) { + return await this.expertInsurerService.rateExpertByPublicId( + publicId, + rating, + insurer.clientKey, + ); + } + @Get("top-files") async getTopFiles(@CurrentUser() insurer) { return await this.expertInsurerService.getTopFilesForClient( @@ -185,82 +263,4 @@ export class ExpertInsurerController { insurer.clientKey, ); } - - @ApiBody({ - description: "Detailed expert rating by insurer", - schema: { - type: "object", - properties: { - collisionMethodAccuracy: { - type: "number", - minimum: 0, - maximum: 5, - example: 4, - description: "تشخیص درست نحوه برخورد", - }, - evaluationTimeliness: { - type: "number", - minimum: 0, - maximum: 5, - example: 3, - description: "زمان ارزیابی", - }, - accidentCauseAccuracy: { - type: "number", - minimum: 0, - maximum: 5, - example: 5, - description: "تشخیص درست علت تصادف", - }, - guiltyVehicleIdentification: { - type: "number", - minimum: 0, - maximum: 5, - example: 4, - description: "تشخیص درست وسیله نقلیه مقصر", - }, - botRating: { - type: "number", - minimum: 0, - maximum: 5, - example: 4, - description: "برای امتیاز دادن به عملکرد بات", - }, - }, - required: [ - "collisionMethodAccuracy", - "evaluationTimeliness", - "accidentCauseAccuracy", - "guiltyVehicleIdentification", - "botRating", - ], - example: { - collisionMethodAccuracy: 4, - evaluationTimeliness: 3, - accidentCauseAccuracy: 5, - guiltyVehicleIdentification: 4, - botRating: 4, - }, - }, - }) - @ApiParam({ name: "requestId" }) - @ApiQuery({ name: "role", enum: ["claim", "blame"] }) - @Put("/:requestId/rating") - async rateExperts( - @CurrentUser() insurer, - @Param("requestId") requestId: string, - @Body() rating: FileRating, - @Query("role") role: "claim" | "blame", - ) { - if (!["claim", "blame"].includes(role)) { - throw new BadRequestException("Invalid role"); - } - - return await this.expertInsurerService.rateExpertOnFile( - requestId, - rating, - role, - insurer.clientKey, - ); - } } diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index ff44f3b..a49fedc 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -50,6 +50,16 @@ export class ExpertInsurerService { 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[], @@ -170,9 +180,14 @@ export class ExpertInsurerService { const insurerRating = file?.rating; const userRating = file?.userRating; 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 ? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter( @@ -210,9 +225,10 @@ export class ExpertInsurerService { 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({ clientKey: String(clientObjectId) }), - this.damageExpertDbService.findAll({ clientKey: clientObjectId }), + this.expertDbService.findAll(ckFilter as never), + this.damageExpertDbService.findAll(ckFilter as never), this.getClientBlameFiles(clientObjectId), this.getClientClaimFiles(clientObjectId), ]); @@ -237,6 +253,7 @@ export class ExpertInsurerService { 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] = []; @@ -253,7 +270,7 @@ export class ExpertInsurerService { processRatings(file?.damageExpertReply?.actorDetail?.actorId, file); } - const allExperts = allExpertsRaw.map((expert) => { + const mapExpertRow = (expert: any, expertKind: "blame" | "claim") => { const expertIdStr = expert._id.toString(); const totalRatings = expertTotalRatingsMap[expertIdStr] || []; const overallAverageRating = totalRatings.length @@ -274,6 +291,7 @@ export class ExpertInsurerService { _id: expert._id, fullName: `${expert.firstName} ${expert.lastName}`, role: expert.role, + expertKind, type: expert.userType, requestStats: expertActivityStatsMap[expertIdStr] ?? { totalHandled: 0, @@ -283,7 +301,12 @@ export class ExpertInsurerService { 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; @@ -297,20 +320,33 @@ export class ExpertInsurerService { } /** - * Returns top 10 experts for the current insurer client based on - * combined insurer + user ratings on their handled files. + * 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 { + async getTopExpertsForClient(actor): Promise<{ + blameExperts: any[]; + claimExperts: any[]; + }> { const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000); - const experts = result?.experts || []; + const rows = result?.experts || []; - const sorted = [...experts].sort((a, b) => { - const ar = a.overallAverageRating ?? 0; - const br = b.overallAverageRating ?? 0; - return br - ar; - }); + const byRatingDesc = (a: any, b: any) => + (b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0); - 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 }; } /** @@ -333,11 +369,11 @@ export class ExpertInsurerService { expertObjectId: Types.ObjectId, insurerClientKey: string, ) { - const ck = String(insurerClientKey); - const clientOid = new Types.ObjectId(ck); + const clientOid = this.getClientId(insurerClientKey); + const ckFilter = this.clientKeyScopeFilter(clientOid); const [experts, damageExperts] = await Promise.all([ - this.expertDbService.findAll({ clientKey: ck }), - this.damageExpertDbService.findAll({ clientKey: clientOid }), + this.expertDbService.findAll(ckFilter as never), + this.damageExpertDbService.findAll(ckFilter as never), ]); const id = String(expertObjectId); const ok = [...experts, ...damageExperts].some((e) => String(e._id) === id); @@ -378,52 +414,83 @@ export class ExpertInsurerService { .map((b) => this.normalizeBlameCase(b)); } - async rateExpertOnFile( - requestId: string, - rating: FileRating, - role: "claim" | "blame", - insurerClientKey: string, - ) { - const _id = this.parseObjectId(requestId, "request id"); - const clientKeyStr = String(this.getClientId(insurerClientKey)); - for (const [key, value] of Object.entries(rating || {})) { + 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`); } } - if (role === "claim") { - const existing = await this.claimCaseDbService.findById(_id); - if (!existing) throw new NotFoundException("Claim file not found"); - if (!claimCaseTouchesClient(existing as any, clientKeyStr)) { - throw new ForbiddenException("This claim does not belong to your organization."); - } - const updated = await this.claimCaseDbService.findByIdAndUpdate(_id, { + } + + /** + * 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"); - return { - message: "Claim expert rated successfully", - updatedRating: (updated as any)?.evaluation?.rating || rating, - requestId: updated._id, + out.claim = { + requestId: String((updated as any)._id), + updatedRating: (updated as any)?.evaluation?.rating ?? rating, }; } - if (role === "blame") { - const existing = await this.blameRequestDbService.findById(_id); - if (!existing) throw new NotFoundException("Blame file not found"); - if (!blameCaseTouchesClient(existing as any, clientKeyStr)) { - throw new ForbiddenException("This blame case does not belong to your organization."); - } - const updated = await this.blameRequestDbService.findByIdAndUpdate(_id, { + + 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"); - return { - message: "Blame expert rated successfully", - updatedRating: (updated as any)?.expert?.rating || rating, - requestId: updated._id, + out.blame = { + requestId: String((updated as any)._id), + updatedRating: (updated as any)?.expert?.rating ?? rating, }; } - throw new BadRequestException("Invalid role"); + + return { + message: "Rating saved for this file", + ...out, + }; } async retrieveAllFilesOfClient(insurerId: string) { From bffb8a3b979da9aab869e445af893bf337fe2714 Mon Sep 17 00:00:00 2001 From: "s.yahyaee" Date: Thu, 30 Apr 2026 10:32:53 +0330 Subject: [PATCH 2/2] YARA-855 --- .../expert-insurer.controller.ts | 21 ++-- src/expert-insurer/expert-insurer.service.ts | 97 +++++++++++++------ 2 files changed, 77 insertions(+), 41 deletions(-) diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index d1457c0..f0193be 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -242,24 +242,19 @@ export class ExpertInsurerController { } @ApiParam({ name: "expertId" }) - @ApiQuery({ name: "role", enum: ["claim", "blame"] }) + @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, - @Query("role") role: "claim" | "blame", - ) { - if (!Types.ObjectId.isValid(new Types.ObjectId(id))) { + async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) { + if (!Types.ObjectId.isValid(id)) { throw new BadRequestException("Invalid expert ID"); } - if (!["claim", "blame"].includes(role)) { - throw new BadRequestException("Invalid role"); - } - - return await this.expertInsurerService.getAllFilesByExpertAndRole( + return await this.expertInsurerService.getAllFilesForInsurerExpert( id, - role, insurer.clientKey, ); } diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index a49fedc..62755e9 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -144,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(); + 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) => ({ @@ -365,10 +414,8 @@ export class ExpertInsurerService { return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10); } - private async assertExpertBelongsToInsurer( - expertObjectId: Types.ObjectId, - insurerClientKey: string, - ) { + 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([ @@ -376,42 +423,36 @@ export class ExpertInsurerService { this.damageExpertDbService.findAll(ckFilter as never), ]); const id = String(expertObjectId); - const ok = [...experts, ...damageExperts].some((e) => String(e._id) === id); - if (!ok) { + 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.", ); } - } - async getAllFilesByExpertAndRole( - expertId: string, - role: "claim" | "blame", - insurerClientKey: string, - ) { - const expertObjectId = this.parseObjectId(expertId, "expert id"); - await this.assertExpertBelongsToInsurer(expertObjectId, insurerClientKey); - const clientKeyStr = String(this.getClientId(insurerClientKey)); - - if (role === "claim") { + const 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) && - c?.evaluation?.damageExpertReply?.actorDetail?.actorId === - String(expertObjectId), + this.claimDamageExpertActorId(c) === id, ) - .map((c) => this.normalizeClaimCase(c)); + .map((c) => this.mapClaimFileSummaryForInsurerExpert(c)); } - const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[]; - return blames - .filter( - (b) => - blameCaseTouchesClient(b, clientKeyStr) && - String(b?.expert?.assignedExpertId ?? "") === String(expertObjectId), - ) - .map((b) => this.normalizeBlameCase(b)); + return []; } private validateInsurerFileRating(rating: FileRating) {