From 077bae429eb91ec56aff1136da8168cff04f493c Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 3 Jun 2026 11:34:21 +0330 Subject: [PATCH 1/4] Fixed damagedParts unified structure and resend problems --- .../user/user.auth.controller.ts | 5 +- .../claim-request-management.service.ts | 33 +++---- src/helpers/claim-expert-resend.ts | 83 +++++++++++------ src/helpers/claim-resend-document-keys.ts | 13 ++- src/helpers/outer-damage-parts.ts | 90 ++++++++++++------- .../sms-orchestration.service.ts | 25 +++--- 6 files changed, 154 insertions(+), 95 deletions(-) diff --git a/src/auth/auth-controllers/user/user.auth.controller.ts b/src/auth/auth-controllers/user/user.auth.controller.ts index fee6fad..79cba20 100644 --- a/src/auth/auth-controllers/user/user.auth.controller.ts +++ b/src/auth/auth-controllers/user/user.auth.controller.ts @@ -22,7 +22,7 @@ export class UserAuthController { @Post("/send-otp") @ApiBody({ type: UserLoginDto, - description: "user login api -- call this api and send otp", + description: "Users can ask for OTP via this API and receive it", }) @ApiAcceptedResponse() async sendOtpRq(@Body() body: UserLoginDto) { @@ -40,7 +40,8 @@ export class UserAuthController { @UseGuards(LocalUserAuthGuard) @ApiBody({ type: UserVerifyOtp, - description: "user verify otp -- call this api and get a tokens", + description: + "Users can send their credentials and get their access token to server", }) @ApiAcceptedResponse() async login(@Body() body, @Req() req, @CurrentUser() user) { diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 4ffb03c..11e3a12 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -143,6 +143,7 @@ import { } from "src/helpers/claim-capture-phase"; import { HttpService } from "@nestjs/axios"; import { firstValueFrom } from "rxjs"; +import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher"; @Injectable() export class ClaimRequestManagementService { @@ -4660,7 +4661,7 @@ export class ClaimRequestManagementService { "money.sheba": shebaNumber, "money.nationalCodeOfInsurer": nationalCode, - status: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, + status: ClaimCaseStatus.CAPTURING_PART_DAMAGES, "workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, "workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, @@ -6074,6 +6075,9 @@ export class ClaimRequestManagementService { "workflow.nextStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION, "evaluation.objection": objectionPayload, "damage.selectedParts": mergedNorm, + ...(pendingResendGate && { + "evaluation.damageExpertResend.fulfilledAt": new Date(), + }), }, $unset: { "evaluation.ownerPricedPartsApproval": "", @@ -6657,24 +6661,15 @@ export class ClaimRequestManagementService { } } - const damagedParts = displayParts.map((sp, index) => { - const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp); - const cap = getDamagedPartCaptureBlob( - damagedPartsData, - ck, - selectedNormDetails, - ) as { url?: string; path?: string; fileName?: string } | undefined; - return { - index, - id: sp.id, - name: sp.name, - side: sp.side, - label_fa: sp.label_fa, - captured: !!cap, - url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined), - ...(cap?.path ? { path: cap.path } : {}), - ...(cap?.fileName ? { fileName: cap.fileName } : {}), - }; + const damagedParts = buildEnrichedDamagedParts({ + selectedParts: displayParts, + damagedPartsData: damagedPartsData, + evaluationBlock: (claim as any).evaluation, + expertAddedParts: (claim.damage as any)?.expertAddedParts ?? [], + getDamagedPartCaptureBlob, + hasDamagedPartCapture, + catalogLikeKeyFromPart, + buildFileLink, }); const er = claim.evaluation?.damageExpertResend; diff --git a/src/helpers/claim-expert-resend.ts b/src/helpers/claim-expert-resend.ts index 3b50371..ecf700a 100644 --- a/src/helpers/claim-expert-resend.ts +++ b/src/helpers/claim-expert-resend.ts @@ -32,13 +32,17 @@ export function resendRequestHasPayload(r: { }): boolean { if (!r) return false; if (String(r.resendDescription || "").trim() !== "") return true; - if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) return true; - if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0) return true; + if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) + return true; + if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0) + return true; return false; } /** Normalize expert-requested document keys (strings or `{ key }`) to canonical enum strings. */ -export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] { +export function normalizeResendDocumentKeys( + docs: unknown[] | undefined, +): string[] { if (!Array.isArray(docs)) return []; const keys: string[] = []; for (const d of docs) { @@ -61,10 +65,7 @@ export function normalizeResendPartKeys( selectedParts?: unknown, ): string[] { if (!Array.isArray(parts)) return []; - const selectedNorm = normalizeDamageSelectedParts( - selectedParts, - carType, - ); + const selectedNorm = normalizeDamageSelectedParts(selectedParts, carType); const keys: string[] = []; for (const p of parts) { const enriched = @@ -72,8 +73,7 @@ export function normalizeResendPartKeys( matchResendPartFromSelected(p, selectedNorm); if (enriched) { const ck = - enriched.catalogKey?.trim() || - catalogLikeKeyFromPart(enriched); + enriched.catalogKey?.trim() || catalogLikeKeyFromPart(enriched); if (ck) keys.push(ck); continue; } @@ -108,7 +108,10 @@ function resolveResendPartRow( const fromSelected = matchResendPartFromSelected(raw, selectedNorm ?? []); if (fromSelected) return fromSelected; if (fromRaw) return fromRaw; - const o = (raw && typeof raw === "object" ? raw : {}) as Record; + const o = (raw && typeof raw === "object" ? raw : {}) as Record< + string, + unknown + >; const fallbackKey = typeof o.key === "string" && o.key.trim() ? o.key.trim() @@ -123,8 +126,7 @@ function resolveResendPartRow( typeof o.label_fa === "string" && o.label_fa.trim() ? o.label_fa.trim() : fallbackKey, - catalogKey: - typeof o.catalogKey === "string" ? o.catalogKey : undefined, + catalogKey: typeof o.catalogKey === "string" ? o.catalogKey : undefined, }; } @@ -149,8 +151,7 @@ export function mapExpertResendCarPartsForClient( const stored = raw && typeof raw === "object" ? (raw as Record) : {}; const sp = resolveResendPartRow(raw, options.carType, selectedNorm); - const catalogKey = - sp.catalogKey?.trim() || catalogLikeKeyFromPart(sp); + const catalogKey = sp.catalogKey?.trim() || catalogLikeKeyFromPart(sp); const captureKey = catalogKey || partLookupKey(sp); const cap = getDamagedPartCaptureBlob( options.damagedPartsData, @@ -193,8 +194,7 @@ export function normalizeResendCarPartsForStorage( const stored = raw && typeof raw === "object" ? (raw as Record) : {}; const sp = resolveResendPartRow(raw, carType, selectedNorm); - const catalogKey = - sp.catalogKey?.trim() || catalogLikeKeyFromPart(sp); + const catalogKey = sp.catalogKey?.trim() || catalogLikeKeyFromPart(sp); const row: Record = { id: sp.id, name: sp.name, @@ -217,8 +217,13 @@ function getRequiredDocEntry(claim: any, documentKey: string): unknown { return rd instanceof Map ? rd.get(documentKey) : rd[documentKey]; } -export function isRequiredDocumentUploaded(claim: any, documentKey: string): boolean { - const doc = getRequiredDocEntry(claim, documentKey) as { uploaded?: boolean } | undefined; +export function isRequiredDocumentUploaded( + claim: any, + documentKey: string, +): boolean { + const doc = getRequiredDocEntry(claim, documentKey) as + | { uploaded?: boolean } + | undefined; return !!doc?.uploaded; } @@ -241,7 +246,8 @@ export function hasDamagedPartCapture(claim: any, partKey: string): boolean { export function canFinalizeExpertResend(claim: any): boolean { if (!claim) return false; if (claim.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) return false; - if (claim.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND) return false; + if (claim.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND) + return false; const r = claim.evaluation?.damageExpertResend; if (!r || r.fulfilledAt) return false; @@ -279,16 +285,39 @@ export function documentKeyAllowedForExpertResend( return allowed.has(normalized); } -export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean { +export function partKeyAllowedForExpertResend( + claim: any, + partKey: string, +): boolean { const r = claim?.evaluation?.damageExpertResend; if (!r || r.fulfilledAt) return false; + const carType = claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined; - const allowed = new Set( - normalizeResendPartKeys( - r.resendCarParts, - carType, - claim?.damage?.selectedParts, - ), + const selectedParts = claim?.damage?.selectedParts; + + // Normalize the stored resend parts to their canonical catalog keys + const allowedKeys = new Set( + normalizeResendPartKeys(r.resendCarParts, carType, selectedParts), ); - return allowed.has(partKey); + + // Normalize the incoming partKey the same way so the comparison is apples-to-apples + const resolvedKeys = normalizeResendPartKeys( + [partKey], + carType, + selectedParts, + ); + const resolvedKey = resolvedKeys[0]; + + // Also try direct catalogKey/name match against the stored objects as fallback + const directMatch = ((r.resendCarParts as any[]) ?? []).some((p: any) => { + if (!p || typeof p !== "object") return String(p) === partKey; + return ( + p.catalogKey === partKey || + p.key === partKey || + p.name === partKey || + (p.id != null && String(p.id) === partKey) + ); + }); + + return directMatch || (resolvedKey != null && allowedKeys.has(resolvedKey)); } diff --git a/src/helpers/claim-resend-document-keys.ts b/src/helpers/claim-resend-document-keys.ts index 67739b5..eb7d8cf 100644 --- a/src/helpers/claim-resend-document-keys.ts +++ b/src/helpers/claim-resend-document-keys.ts @@ -8,20 +8,27 @@ const REQUIRED_VALUES = new Set( * Non-canonical document keys that may still appear on stored * `evaluation.damageExpertResend.resendDocuments` → {@link ClaimRequiredDocumentType}. */ -const RESEND_DOCUMENT_KEY_ALIASES: Readonly< - Record -> = { +const RESEND_DOCUMENT_KEY_ALIASES = { + // existing entries nationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD, NationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD, + national_card: ClaimRequiredDocumentType.NATIONAL_CARD, // ← add drivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT, DrivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT, + driving_license: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT, // ← add carGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD, CarGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD, + car_green_card: ClaimRequiredDocumentType.CAR_GREEN_CARD, // ← add (redundant but safe) carCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT, CarCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT, + car_certificate: ClaimRequiredDocumentType.CAR_CERTIFICATE, // ← add plate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE, carPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE, CarPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE, + // capture-phase docs — already in REQUIRED_VALUES so these are just safety nets + damaged_chassis_number: ClaimRequiredDocumentType.DAMAGED_CHASSIS_NUMBER, + damaged_engine_photo: ClaimRequiredDocumentType.DAMAGED_ENGINE_PHOTO, + damaged_metal_plate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE, }; /** Normalize a single key to a {@link ClaimRequiredDocumentType} value, or null if unknown. */ diff --git a/src/helpers/outer-damage-parts.ts b/src/helpers/outer-damage-parts.ts index 2cf577d..1f97cfb 100644 --- a/src/helpers/outer-damage-parts.ts +++ b/src/helpers/outer-damage-parts.ts @@ -62,7 +62,9 @@ export function findCatalogItemByPersianLabel( /** Non-catalog lines (expert-added interior / free-text); never use catalog numeric ids. */ export function isInternalDamageSide(side: string): boolean { - const s = String(side ?? "").trim().toLowerCase(); + const s = String(side ?? "") + .trim() + .toLowerCase(); if (!s || s === "internal" || s === "other") return true; return !SIDE_SET.has(s); } @@ -120,7 +122,9 @@ const SIDE_LABEL_FA: Record = { [OuterPartSideV2.TOP]: "بالا", }; -function outerCatalogListForItem(item: OuterPartCatalogItem): OuterPartCatalogItem[] { +function outerCatalogListForItem( + item: OuterPartCatalogItem, +): OuterPartCatalogItem[] { for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) { if (list.some((x) => x.key === item.key || x.id === item.id)) { return list; @@ -165,9 +169,10 @@ function findCatalogItemByKey(key: string): OuterPartCatalogItem | undefined { } /** Strip leading `left_|right_|…` when present so `name` is side-agnostic for clients. */ -export function splitCatalogKeyToNameAndSide( - fullKey: string, -): { name: string; side: string } { +export function splitCatalogKeyToNameAndSide(fullKey: string): { + name: string; + side: string; +} { const t = String(fullKey ?? "").trim(); const i = t.indexOf("_"); if (i <= 0) return { name: t, side: "" }; @@ -329,7 +334,8 @@ export function normalizeDamageSelectedParts( let catItem: OuterPartCatalogItem | undefined; if (catalog && byCatalogKey) { if (ck) catItem = byCatalogKey.get(ck); - if (!catItem && id != null) catItem = catalog.find((c) => c.id === id); + if (!catItem && id != null) + catItem = catalog.find((c) => c.id === id); if (!catItem && side && name) { catItem = byCatalogKey.get(catalogLikeKeyFromPart({ side, name })); } @@ -377,7 +383,9 @@ export function normalizeDamageSelectedParts( const cat = byCatalogKey?.get(k) ?? findCatalogItemByKey(k) ?? - (toNum(o.id) != null ? CATALOG_ITEM_BY_ID.get(toNum(o.id)!) : undefined); + (toNum(o.id) != null + ? CATALOG_ITEM_BY_ID.get(toNum(o.id)!) + : undefined); if (cat) { const list = catalog ?? outerCatalogListForItem(cat); out.push(catalogItemToSelectedPart(cat, list)); @@ -581,7 +589,9 @@ export function catalogPartIdFromCarPartDamage( export const partIdentityKeyFromCarPartDamage = catalogPartIdFromCarPartDamage; function normPartSegment(s: string): string { - return String(s ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase(); + return String(s ?? "") + .replace(/[^a-z0-9]/gi, "") + .toLowerCase(); } function looseCatalogNameMatch( @@ -601,9 +611,13 @@ export function findCatalogItemFromLegacyExpertPart( side: string, carType?: ClaimVehicleTypeV2, ): OuterPartCatalogItem | undefined { - const s = String(side ?? "").toLowerCase().trim(); + const s = String(side ?? "") + .toLowerCase() + .trim(); const lists = carType - ? [OUTER_PARTS_BY_CAR_TYPE[carType]].filter(Boolean) as OuterPartCatalogItem[][] + ? ([OUTER_PARTS_BY_CAR_TYPE[carType]].filter( + Boolean, + ) as OuterPartCatalogItem[][]) : (Object.values(OUTER_PARTS_BY_CAR_TYPE) as OuterPartCatalogItem[][]); for (const catalog of lists) { if (!catalog?.length) continue; @@ -615,7 +629,9 @@ export function findCatalogItemFromLegacyExpertPart( return undefined; } -function selectedPartToStoredRecord(p: DamageSelectedPartV2): Record { +function selectedPartToStoredRecord( + p: DamageSelectedPartV2, +): Record { const r: Record = { name: p.name, side: String(p.side || "").toLowerCase(), @@ -780,9 +796,7 @@ export function isDamagedPartsMediaArray(data: unknown): boolean { return Array.isArray(data); } -function getLegacyMapRecord( - data: unknown, -): Record | null { +function getLegacyMapRecord(data: unknown): Record | null { if (data == null) return null; if (data instanceof Map) { return Object.fromEntries((data as Map).entries()); @@ -819,11 +833,9 @@ export function migrateLegacyDamagedPartsMapToArray( selected: DamageSelectedPartV2[], ): Record[] { return selected.map((sp) => { - const keysTry = [ - sp.catalogKey, - catalogLikeKeyFromPart(sp), - sp.name, - ].filter(Boolean) as string[]; + const keysTry = [sp.catalogKey, catalogLikeKeyFromPart(sp), sp.name].filter( + Boolean, + ) as string[]; let blob: unknown; for (const k of keysTry) { blob = getCaptureBlobLoose(legacyMap, k); @@ -887,12 +899,14 @@ export function resolvePartCaptureIndex( if (!raw) return -1; if (selected.length) { + // Pass 1: exact matches on catalogKey and catalogLikeKey for (let i = 0; i < selected.length; i++) { const sp = selected[i]; if (sp.catalogKey === raw) return i; if (catalogLikeKeyFromPart(sp) === raw) return i; } + // Pass 2: numeric id or array index if (/^\d+$/.test(raw)) { const n = Number(raw); const byId = selected.findIndex((p) => p.id === n); @@ -901,31 +915,35 @@ export function resolvePartCaptureIndex( if (!hasIdCollision && n >= 0 && n < selected.length) return n; } + // Pass 3: loose normalized match (handles spaces, underscores, case) const loose = raw.toLowerCase().replace(/[\s_-]/g, ""); for (let i = 0; i < selected.length; i++) { const sp = selected[i]; - const cand = [ + const candidates = [ sp.catalogKey, catalogLikeKeyFromPart(sp), sp.name, ].filter(Boolean) as string[]; - for (const c of cand) { + for (const c of candidates) { if (c.toLowerCase().replace(/[\s_-]/g, "") === loose) return i; } } - } - if (damagedPartsArray && damagedPartsArray.length) { - for (let i = 0; i < damagedPartsArray.length; i++) { - const row = damagedPartsArray[i]; - if (!row || typeof row !== "object") continue; - const id = toNum((row as { id?: unknown }).id); - if (id != null && String(id) === raw) return i; - const ck = String((row as { catalogKey?: unknown }).catalogKey || ""); - if (ck && ck === raw) return i; - const name = String((row as { name?: unknown }).name || ""); - const side = String((row as { side?: unknown }).side || ""); - if (side && name && `${side}_${name}` === raw) return i; + // Pass 4: suffix match — handles car-type-prefixed catalog keys + // e.g. "sedan_front_hood" should match sp.catalogKey="front_hood" + // or normalizeResendPartKeys output "sedan_front_hood" vs stored "front_hood" + for (let i = 0; i < selected.length; i++) { + const sp = selected[i]; + const candidates = [ + sp.catalogKey, + catalogLikeKeyFromPart(sp), + sp.name, + ].filter(Boolean) as string[]; + for (const c of candidates) { + const cLoose = c.toLowerCase().replace(/[\s_-]/g, ""); + // raw ends with the candidate (prefix stripped) + if (loose.endsWith(cLoose) || cLoose.endsWith(loose)) return i; + } } } @@ -939,7 +957,11 @@ export function mediaRowOrLegacyHasCapture( ): boolean { if (damagedPartsData == null) return false; if (Array.isArray(damagedPartsData)) { - const idx = resolvePartCaptureIndex(partKey, selected, damagedPartsData as any[]); + const idx = resolvePartCaptureIndex( + partKey, + selected, + damagedPartsData as any[], + ); if (idx < 0) return false; return mediaRowHasCapture((damagedPartsData as unknown[])[idx]); } diff --git a/src/sms-orchestration/sms-orchestration.service.ts b/src/sms-orchestration/sms-orchestration.service.ts index 65d0b5a..29247aa 100644 --- a/src/sms-orchestration/sms-orchestration.service.ts +++ b/src/sms-orchestration/sms-orchestration.service.ts @@ -41,7 +41,10 @@ export class SmsOrchestrationService implements OnModuleInit { return `${process.env.URL}/${frontendRoute}?token=${requestId}`; } - buildBlamePartyLink(requestId: string, partyRole: "FIRST" | "SECOND"): string { + buildBlamePartyLink( + requestId: string, + partyRole: "FIRST" | "SECOND", + ): string { const route = partyRole === "SECOND" ? "user2" : "user"; return `${process.env.URL}/${route}?token=${requestId}`; } @@ -50,7 +53,11 @@ export class SmsOrchestrationService implements OnModuleInit { return `${process.env.URL}/caseClaim?token=${claimRequestId}`; } - async sendInviteLink(phoneNumber: string, publicId: string, link: string): Promise { + async sendInviteLink( + phoneNumber: string, + publicId: string, + link: string, + ): Promise { return this.sendTemplate({ template: "yara724-invite-link", receptor: phoneNumber, @@ -132,14 +139,12 @@ export class SmsOrchestrationService implements OnModuleInit { }); } - async sendThirdPartyExpertStartedReviewNotice( - params: { - receptor: string; - fileKind: "blame" | "claim"; - publicId: string; - expertLastName: string; - }, - ): Promise { + async sendThirdPartyExpertStartedReviewNotice(params: { + receptor: string; + fileKind: "blame" | "claim"; + publicId: string; + expertLastName: string; + }): Promise { return this.sendTemplate({ template: "yara-expert-lock", receptor: params.receptor, From 2c810afcb616ac8c4a5a303a9650a8933c6e278f Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 3 Jun 2026 12:05:19 +0330 Subject: [PATCH 2/4] YARA-948 --- .../expert-blame.v2.controller.ts | 33 +++++++++--- .../dto/claim-price-drop-v2.dto.ts | 14 ++++- src/expert-claim/expert-claim.service.ts | 54 +++++++++++++++---- .../expert-claim.v2.controller.ts | 45 +++++++++++++--- 4 files changed, 121 insertions(+), 25 deletions(-) diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index c1f111f..2fe856f 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -10,7 +10,14 @@ import { Query, UseGuards, } from "@nestjs/common"; -import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { RolesGuard } from "src/auth/guards/role.guard"; @@ -29,7 +36,7 @@ import { ResendRequestDto } from "./dto/resend.dto"; @UseGuards(LocalActorAuthGuard, RolesGuard) @Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT) export class ExpertBlameV2Controller { - constructor(private readonly expertBlameService: ExpertBlameService) { } + constructor(private readonly expertBlameService: ExpertBlameService) {} @Get() @ApiOperation({ @@ -63,7 +70,9 @@ export class ExpertBlameV2Controller { } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( - error instanceof Error ? error.message : "Failed to load blame status report", + error instanceof Error + ? error.message + : "Failed to load blame status report", ); } } @@ -76,7 +85,9 @@ export class ExpertBlameV2Controller { } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( - error instanceof Error ? error.message : "Failed to get blame case details", + error instanceof Error + ? error.message + : "Failed to get blame case details", ); } } @@ -96,7 +107,10 @@ export class ExpertBlameV2Controller { }) async assignForReview(@Param("id") id: string, @CurrentUser() actor: any) { try { - return await this.expertBlameService.assignBlameCaseForReviewV2(id, actor); + return await this.expertBlameService.assignBlameCaseForReviewV2( + id, + actor, + ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( @@ -110,6 +124,7 @@ export class ExpertBlameV2Controller { summary: "Lock blame case for review (legacy)", description: "Same assignment logic as POST `:id/assign`, but returns the legacy `{ _id, lock }` shape and maps conflicts to 400 instead of 409.", + deprecated: true, }) @ApiParam({ name: "id", description: "Blame case request id" }) async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) { @@ -156,7 +171,9 @@ export class ExpertBlameV2Controller { } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( - error instanceof Error ? error.message : "Failed to submit expert reply", + error instanceof Error + ? error.message + : "Failed to submit expert reply", ); } } @@ -174,7 +191,9 @@ export class ExpertBlameV2Controller { } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( - error instanceof Error ? error.message : "Failed to submit expert reply for in person", + error instanceof Error + ? error.message + : "Failed to submit expert reply for in person", ); } } diff --git a/src/expert-claim/dto/claim-price-drop-v2.dto.ts b/src/expert-claim/dto/claim-price-drop-v2.dto.ts index 45ccaff..9dae4c5 100644 --- a/src/expert-claim/dto/claim-price-drop-v2.dto.ts +++ b/src/expert-claim/dto/claim-price-drop-v2.dto.ts @@ -66,6 +66,16 @@ export class UpsertClaimPriceDropV2Dto { @ValidateNested({ each: true }) @Type(() => PriceDropPartSeverityV2Dto) partSeverities: PriceDropPartSeverityV2Dto[]; + + @ApiPropertyOptional({ + description: + "Override: expert manually sets the final price-drop amount (in Rials). " + + "When provided, carPrice and partSeverities are not required and calculation is skipped entirely.", + example: 150000000, + }) + @IsOptional() + @IsNumber() + manualPriceDrop?: number; } export class ClaimPriceDropContextV2Dto { @@ -99,7 +109,9 @@ export class ClaimPriceDropContextV2Dto { }) severityOptions: Array<{ value: string; label_fa: string }>; - @ApiProperty({ description: "Full coefficient table for all price-drop parts" }) + @ApiProperty({ + description: "Full coefficient table for all price-drop parts", + }) priceDropCatalog: Array>; @ApiProperty({ diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index aa14f85..22e45b9 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -4226,6 +4226,49 @@ export class ExpertClaimService { } this.assertExpertCanEditClaimDuringReviewV2(claim, actor); + const vehicleSet: Record = {}; + if (body.carName != null && String(body.carName).trim()) { + vehicleSet["vehicle.carName"] = String(body.carName).trim(); + } + if (body.carModel != null && String(body.carModel).trim()) { + vehicleSet["vehicle.carModel"] = String(body.carModel).trim(); + } + + // Manual override path — expert knows the number, skip calculation entirely + if (body.manualPriceDrop != null) { + if (!Number.isFinite(body.manualPriceDrop) || body.manualPriceDrop < 0) { + throw new BadRequestException( + "manualPriceDrop must be a non-negative finite number.", + ); + } + + const manualPayload = { + manualOverride: true, + totalPriceDrop: body.manualPriceDrop, + partLines: [], + }; + + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set: { + ...vehicleSet, + "evaluation.priceDrop": manualPayload, + }, + }); + + const updated = await this.claimCaseDbService.findById(claimRequestId); + return { + claimRequestId, + vehicle: { + carName: updated?.vehicle?.carName, + carModel: updated?.vehicle?.carModel, + carType: updated?.vehicle?.carType, + }, + priceDrop: manualPayload, + partLines: [], + }; + } + + // Calculated path — existing logic unchanged const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const selectedNorm = normalizeDamageSelectedParts( claim.damage?.selectedParts, @@ -4235,7 +4278,7 @@ export class ExpertClaimService { if (!body.partSeverities?.length) { throw new BadRequestException( - "Provide at least one partSeverities line for a damaged part.", + "Provide at least one partSeverities line, or use manualPriceDrop for a direct override.", ); } @@ -4290,14 +4333,6 @@ export class ExpertClaimService { partLines: lines, }; - const vehicleSet: Record = {}; - if (body.carName != null && String(body.carName).trim()) { - vehicleSet["vehicle.carName"] = String(body.carName).trim(); - } - if (body.carModel != null && String(body.carModel).trim()) { - vehicleSet["vehicle.carModel"] = String(body.carModel).trim(); - } - await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { ...vehicleSet, @@ -4306,7 +4341,6 @@ export class ExpertClaimService { }); const updated = await this.claimCaseDbService.findById(claimRequestId); - return { claimRequestId, vehicle: { diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 3689e87..e7ba6e5 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -1,4 +1,17 @@ -import { Body, Controller, Get, Header, Param, Headers, Patch, Post, Put, UseGuards, Query, Res } from "@nestjs/common"; +import { + Body, + Controller, + Get, + Header, + Param, + Headers, + Patch, + Post, + Put, + UseGuards, + Query, + Res, +} from "@nestjs/common"; import { ApiBearerAuth, ApiBody, @@ -19,7 +32,10 @@ import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-res import { ExpertClaimService } from "./expert-claim.service"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto"; -import { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto"; +import { + ClaimSubmitResendV2Dto, + SubmitExpertReplyV2Dto, +} from "./dto/expert-claim-v2.dto"; import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto"; import { ClaimPriceDropContextV2Dto, @@ -32,7 +48,7 @@ import { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; class InPersonVisitV2Dto { - @ApiPropertyOptional({ example: 'Paint damage requires physical inspection' }) + @ApiPropertyOptional({ example: "Paint damage requires physical inspection" }) @IsOptional() @IsString() note?: string; @@ -120,7 +136,10 @@ export class ExpertClaimV2Controller { @Param("claimRequestId") claimRequestId: string, @CurrentUser() actor, ) { - return await this.expertClaimService.getClaimDetailV2(claimRequestId, actor); + return await this.expertClaimService.getClaimDetailV2( + claimRequestId, + actor, + ); } @Get("request/:claimRequestId/price-drop") @@ -193,13 +212,17 @@ export class ExpertClaimV2Controller { "1. **Damage review queue** — claim status `WAITING_FOR_DAMAGE_EXPERT`. Locking advances the claim to `status=EXPERT_REVIEWING`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.\n" + "2. **Factor validation queue** — claim status `EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `workflow.currentStep=EXPERT_COST_EVALUATION`. Locking only sets the workflow lock fields; status/claimStatus/currentStep are left untouched so the claim stays in the factor-validation queue when the lock expires.\n\n" + "Only one expert can hold the lock at a time. Re-locking by the same expert is idempotent. Same assignment logic as POST `assign/:claimRequestId`, but returns the legacy lock shape and maps conflicts to 400 instead of 409.", + deprecated: true, }) @ApiParam({ name: "claimRequestId" }) async lockClaimRequestV2( @Param("claimRequestId") claimRequestId: string, @CurrentUser() actor, ) { - return await this.expertClaimService.lockClaimRequestV2(claimRequestId, actor); + return await this.expertClaimService.lockClaimRequestV2( + claimRequestId, + actor, + ); } @Put("reply/submit/:claimRequestId") @@ -221,7 +244,11 @@ export class ExpertClaimV2Controller { @Body() body: SubmitExpertReplyV2Dto, @CurrentUser() actor, ) { - return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor); + return await this.expertClaimService.submitExpertReplyV2( + claimRequestId, + body, + actor, + ); } @Put("reply/resend/:claimRequestId") @@ -239,7 +266,11 @@ export class ExpertClaimV2Controller { @Body() body: ClaimSubmitResendV2Dto, @CurrentUser() actor, ) { - return await this.expertClaimService.submitResendDocsV2(claimRequestId, body, actor); + return await this.expertClaimService.submitResendDocsV2( + claimRequestId, + body, + actor, + ); } @Patch(":claimRequestId/visit") From 0b47e8789b44716614de4b93be7b57418f3ce2e8 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 3 Jun 2026 12:23:30 +0330 Subject: [PATCH 3/4] YARA-977 --- src/expert-insurer/expert-insurer.service.ts | 320 +++++++++++++------ src/expert-insurer/helper/insurer.helper.ts | 42 +++ 2 files changed, 273 insertions(+), 89 deletions(-) create mode 100644 src/expert-insurer/helper/insurer.helper.ts diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index 135a7b5..5fab25d 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -51,6 +51,10 @@ import { } from "src/helpers/outer-damage-parts"; import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service"; +import { + extractExpertNamesFromBlame, + extractExpertNamesFromClaim, +} from "./helper/insurer.helper"; @Injectable() export class ExpertInsurerService { @@ -70,7 +74,8 @@ export class ExpertInsurerService { ) {} private getClientId(actorOrId: any): Types.ObjectId { - const raw = typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey; + const raw = + typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey; if (!raw || !Types.ObjectId.isValid(raw)) { throw new BadRequestException("Client key is required"); } @@ -91,15 +96,22 @@ export class ExpertInsurerService { tenantId: Types.ObjectId, expertIds: string[], ): Promise> { - const events = await this.expertFileActivityDbService.findByTenantAndExperts( - tenantId, - expertIds, - ); - const stateByExpertFile = new Map(); + 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 }; + 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) { @@ -111,13 +123,17 @@ export class ExpertInsurerService { stateByExpertFile.set(key, prev); } - const result: Record = {}; + 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 (!result[expertId]) + result[expertId] = { totalHandled: 0, totalChecked: 0 }; if (st.handled) result[expertId].totalHandled += 1; else if (st.checked) result[expertId].totalChecked += 1; } @@ -265,18 +281,18 @@ export class ExpertInsurerService { return { ...d, guiltyPartyId: - guilty != null && typeof (guilty as { toString?: () => string }).toString === "function" + guilty != null && + typeof (guilty as { toString?: () => string }).toString === "function" ? String(guilty) : guilty, decidedByExpertId: decidedBy != null && - typeof (decidedBy as { toString?: () => string }).toString === "function" + typeof (decidedBy as { toString?: () => string }).toString === + "function" ? String(decidedBy) : decidedBy, decidedAt: - decidedAt instanceof Date - ? decidedAt.toISOString() - : decidedAt, + decidedAt instanceof Date ? decidedAt.toISOString() : decidedAt, }; } @@ -300,7 +316,8 @@ export class ExpertInsurerService { if (!Array.isArray(parties) || parties.length === 0) return out; const first = - (parties as any[]).find((p: any) => p?.role === PartyRole.FIRST) ?? (parties as any[])[0]; + (parties as any[]).find((p: any) => p?.role === PartyRole.FIRST) ?? + (parties as any[])[0]; const cbf = first?.carBodyFirstForm; delete out.blameStatus; if (cbf && typeof cbf === "object") { @@ -340,7 +357,8 @@ export class ExpertInsurerService { ) { if (!person || typeof person !== "object") return person; const cid = - person.clientId?.toString?.() ?? (person.clientId != null ? String(person.clientId) : undefined); + person.clientId?.toString?.() ?? + (person.clientId != null ? String(person.clientId) : undefined); return { ...person, userId: person.userId?.toString?.() ?? undefined, @@ -412,7 +430,11 @@ export class ExpertInsurerService { side: sp.side, label_fa: sp.label_fa, catalogKey: sp.catalogKey, - captured: hasDamagedPartCapture(damagedPartsDataExpert, ck, selectedNormExpert), + captured: hasDamagedPartCapture( + damagedPartsDataExpert, + ck, + selectedNormExpert, + ), path: cap?.path, fileName: cap?.fileName, url, @@ -439,7 +461,9 @@ export class ExpertInsurerService { }); } - private async claimSignLinkFromId(signDetailId: unknown): Promise { + private async claimSignLinkFromId( + signDetailId: unknown, + ): Promise { if (signDetailId == null || signDetailId === "") return undefined; const id = String(signDetailId); if (!Types.ObjectId.isValid(id)) return undefined; @@ -454,7 +478,10 @@ export class ExpertInsurerService { evaluation: Record | undefined, ): Promise | undefined> { if (!evaluation || typeof evaluation !== "object") return evaluation; - const ev = JSON.parse(JSON.stringify(evaluation)) as Record; + const ev = JSON.parse(JSON.stringify(evaluation)) as Record< + string, + unknown + >; const enrichReply = async (reply: any) => { if (!reply || typeof reply !== "object") return; @@ -487,14 +514,18 @@ export class ExpertInsurerService { const evidence = party.evidence as Record; if (evidence.videoId) { - const videoDoc = await this.blameVideoDbService.findById(String(evidence.videoId)); + const videoDoc = await this.blameVideoDbService.findById( + String(evidence.videoId), + ); if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path); } if (evidence.voices && Array.isArray(evidence.voices)) { const voiceUrls: string[] = []; for (const voiceId of evidence.voices) { - const voiceDoc = await this.blameVoiceDbService.findById(String(voiceId)); + const voiceDoc = await this.blameVoiceDbService.findById( + String(voiceId), + ); if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path)); } evidence.voiceUrls = voiceUrls; @@ -511,14 +542,20 @@ export class ExpertInsurerService { doc.parties = enrichBlamePartiesForAgreementView(doc); const mergedParties = doc.parties as any[]; - const clientIds = mergedParties.map((p) => p?.person?.clientId?.toString?.()).filter(Boolean); + const clientIds = mergedParties + .map((p) => p?.person?.clientId?.toString?.()) + .filter(Boolean); const clientNames = await this.clientNamesByClientIds(clientIds); - doc.parties = mergedParties.map((p) => this.mapPartyForInsurerFull(p, clientNames)); + doc.parties = mergedParties.map((p) => + this.mapPartyForInsurerFull(p, clientNames), + ); this.enrichPartiesConfirmationSignatures(doc.parties as any[]); if (doc.expert && typeof doc.expert === "object") { const ex = { ...(doc.expert as Record) }; - const serialized = this.serializeBlameExpertDecisionForInsurer(ex.decision); + const serialized = this.serializeBlameExpertDecisionForInsurer( + ex.decision, + ); if (serialized !== undefined) ex.decision = serialized; doc.expert = ex; } @@ -551,7 +588,8 @@ export class ExpertInsurerService { ? Array.from(requiredDocs.keys()) : Object.keys(requiredDocs); for (const k of keys as string[]) { - const row = requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k]; + const row = + requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k]; requiredDocumentsStatus[k] = { uploaded: !!row?.uploaded, fileId: row?.fileId?.toString?.(), @@ -576,7 +614,9 @@ export class ExpertInsurerService { }; } - const carTypeExpert = (claim.vehicle as any)?.carType as ClaimVehicleTypeV2 | undefined; + const carTypeExpert = (claim.vehicle as any)?.carType as + | ClaimVehicleTypeV2 + | undefined; const selectedNormExpert = normalizeDamageSelectedParts( (claim.damage as any)?.selectedParts, carTypeExpert, @@ -646,13 +686,17 @@ export class ExpertInsurerService { }; } - const evaluationEnriched = await this.enrichClaimEvaluationForInsurer(evaluation); + const evaluationEnriched = + await this.enrichClaimEvaluationForInsurer(evaluation); const vehicleSanitized = claim.vehicle ? this.sanitizeVehicleInquiry(claim.vehicle as any) : undefined; const vehicleOut = vehicleSanitized ? (() => { - const { carType: _omit, ...rest } = vehicleSanitized as Record; + const { carType: _omit, ...rest } = vehicleSanitized as Record< + string, + unknown + >; return rest; })() : undefined; @@ -705,9 +749,11 @@ export class ExpertInsurerService { ).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), - ) + ? [ + 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 @@ -715,30 +761,50 @@ export class ExpertInsurerService { 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"); + 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)); + return parseFloat( + (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2), + ); } - private async getClientBlameFiles(clientObjectId: Types.ObjectId): Promise { - const all = (await this.blameRequestDbService.find({}, { lean: true })) as any[]; + private async getClientBlameFiles( + clientObjectId: Types.ObjectId, + ): Promise { + 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), + (f?.parties || []).some( + (p) => String(p?.person?.clientId || "") === idStr, + ), ) .map((f) => this.normalizeBlameCase(f)); } - private async getClientClaimFiles(clientObjectId: Types.ObjectId): Promise { - const all = (await this.claimCaseDbService.find({}, { lean: true })) as any[]; + private async getClientClaimFiles( + clientObjectId: Types.ObjectId, + ): Promise { + 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) { + 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([ @@ -755,18 +821,23 @@ export class ExpertInsurerService { expertIds, ); const expertTotalRatingsMap: Record = {}; - const expertRatingsByCategoryMap: Record> = {}; + const expertRatingsByCategoryMap: Record< + string, + Record + > = {}; 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] = []; + if (!expertTotalRatingsMap[expertId]) + expertTotalRatingsMap[expertId] = []; expertTotalRatingsMap[expertId].push(combinedScore); } if (!rating || typeof rating !== "object") return; - if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {}; + if (!expertRatingsByCategoryMap[expertId]) + expertRatingsByCategoryMap[expertId] = {}; for (const [category, value] of Object.entries(rating)) { if (category === "botRating") continue; if (typeof value === "number" && !isNaN(value)) { @@ -790,7 +861,9 @@ export class ExpertInsurerService { const totalRatings = expertTotalRatingsMap[expertIdStr] || []; const overallAverageRating = totalRatings.length ? parseFloat( - (totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length).toFixed(2), + ( + totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length + ).toFixed(2), ) : null; const averageRatingsByCategory: Record = {}; @@ -869,7 +942,9 @@ export class ExpertInsurerService { * combined insurer + user ratings. */ async getTopFilesForClient(insurerId: string): Promise { - const claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId)); + const claimFiles = await this.getClientClaimFiles( + this.getClientId(insurerId), + ); const scored = claimFiles .map((file) => { const combinedScore = this.getCombinedFileScore(file); @@ -877,10 +952,15 @@ export class ExpertInsurerService { return { ...file, combinedScore }; }) .filter((f) => f !== null); - return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10); + return scored + .sort((a, b) => b.combinedScore - a.combinedScore) + .slice(0, 10); } - async getAllFilesForInsurerExpert(expertId: string, 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); @@ -899,7 +979,10 @@ export class ExpertInsurerService { const clientKeyStr = String(clientOid); if (onBlameRoster) { - const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[]; + const blames = (await this.blameRequestDbService.find( + {}, + { lean: true }, + )) as any[]; return blames .filter( (b) => @@ -909,7 +992,10 @@ export class ExpertInsurerService { .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 .filter( (c) => @@ -932,7 +1018,9 @@ export class ExpertInsurerService { 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`); + throw new BadRequestException( + `${key} must be a number between 0 and 5`, + ); } } } @@ -957,8 +1045,12 @@ export class ExpertInsurerService { 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); + 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"); @@ -971,7 +1063,10 @@ export class ExpertInsurerService { } = { publicId }; if (claim) { - const claimId = this.parseObjectId(String((claim as any)._id), "claim id"); + const claimId = this.parseObjectId( + String((claim as any)._id), + "claim id", + ); const updated = await this.claimCaseDbService.findByIdAndUpdate(claimId, { $set: { "evaluation.rating": rating }, }); @@ -983,10 +1078,16 @@ export class ExpertInsurerService { } if (blame) { - const blameId = this.parseObjectId(String((blame as any)._id), "blame id"); - const updated = await this.blameRequestDbService.findByIdAndUpdate(blameId, { - $set: { "expert.rating": rating }, - }); + 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), @@ -1034,6 +1135,7 @@ export class ExpertInsurerService { plate?: string; }; }>; + expertNames?: string[]; createdAt?: Date | string; updatedAt?: Date | string; }; @@ -1043,6 +1145,7 @@ export class ExpertInsurerService { status?: string; claimStatus?: string; currentStep?: string; + expertNames?: string[]; createdAt?: Date | string; updatedAt?: Date | string; }; @@ -1058,10 +1161,13 @@ export class ExpertInsurerService { 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 || ""), + 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, + expertNames: extractExpertNamesFromBlame(b), // ← add createdAt: (b as any).createdAt, updatedAt: (b as any).updatedAt, }; @@ -1075,14 +1181,19 @@ export class ExpertInsurerService { 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); + 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 || ""), + 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, + expertNames: extractExpertNamesFromClaim(c), // ← add createdAt: (c as any).createdAt, updatedAt: (c as any).updatedAt, }; @@ -1127,8 +1238,12 @@ export class ExpertInsurerService { 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); + 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"); @@ -1303,13 +1418,17 @@ export class ExpertInsurerService { const email = payload.email.trim().toLowerCase(); const existingByEmail = await this.expertDbService.findOne({ email }); - const existingDamageByEmail = await this.damageExpertDbService.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 existingByNational = await this.expertDbService.findOne({ + nationalCode, + }); const existingDamageByNational = await this.damageExpertDbService.findOne({ nationalCode, }); @@ -1388,7 +1507,14 @@ export class ExpertInsurerService { // 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); + const monthEnd = new Date( + now.getFullYear(), + now.getMonth() + 1, + 0, + 23, + 59, + 59, + ); // Filter files created this month const filesThisMonth = claimFiles.filter((file) => { @@ -1416,7 +1542,9 @@ export class ExpertInsurerService { insurerRating.evaluationTimeliness, insurerRating.accidentCauseAccuracy, insurerRating.guiltyVehicleIdentification, - ].filter((val): val is number => typeof val === "number" && !isNaN(val)); + ].filter( + (val): val is number => typeof val === "number" && !isNaN(val), + ); if (insurerValues.length > 0) { filesWithInsurerRating++; @@ -1428,7 +1556,11 @@ export class ExpertInsurerService { // 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") { + if ( + botRating !== undefined && + !isNaN(botRating) && + typeof botRating === "number" + ) { filesWithBotRating++; totalBotRatings += botRating; } @@ -1446,7 +1578,7 @@ export class ExpertInsurerService { // Calculate percentages const totalFiles = claimFiles.length; - + // Calculate average insurer rating (excluding botRating) and average bot rating const averageInsurerRating = filesWithInsurerRating > 0 @@ -1511,12 +1643,15 @@ export class ExpertInsurerService { const clientObjectId = this.getClientId(insuranceId); const { fromDate, toDate } = this.parseDateRange(opts?.from, opts?.to); const isActiveFilter = this.parseOptionalBoolean(opts?.isActive); - const branches = await this.branchDbService.findAllWithFilters(String(clientObjectId), { - search: opts?.search, - from: fromDate, - to: toDate, - isActive: isActiveFilter, - }); + const branches = await this.branchDbService.findAllWithFilters( + String(clientObjectId), + { + search: opts?.search, + from: fromDate, + to: toDate, + isActive: isActiveFilter, + }, + ); const branchIdSet = new Set(branches.map((b: any) => String(b._id))); const ckFilter = this.clientKeyScopeFilter(clientObjectId); @@ -1536,7 +1671,8 @@ export class ExpertInsurerService { const eid = String(e?._id || ""); if (!eid) continue; expertBranchById.set(eid, bid); - if (!activeExpertsByBranch.has(bid)) activeExpertsByBranch.set(bid, new Set()); + if (!activeExpertsByBranch.has(bid)) + activeExpertsByBranch.set(bid, new Set()); activeExpertsByBranch.get(bid)!.add(eid); } for (const e of damageExperts as any[]) { @@ -1545,14 +1681,20 @@ export class ExpertInsurerService { const eid = String(e?._id || ""); if (!eid) continue; claimExpertBranchById.set(eid, bid); - if (!activeExpertsByBranch.has(bid)) activeExpertsByBranch.set(bid, new Set()); + if (!activeExpertsByBranch.has(bid)) + activeExpertsByBranch.set(bid, new Set()); activeExpertsByBranch.get(bid)!.add(eid); } const completedByBranch = new Map>(); const handlingByBranch = new Map>(); - const { blameInHandling, claimInHandling } = this.buildHandlingBranchStatusSets(); - const addPublicId = (map: Map>, branchId: string, fileKey: string) => { + const { blameInHandling, claimInHandling } = + this.buildHandlingBranchStatusSets(); + const addPublicId = ( + map: Map>, + branchId: string, + fileKey: string, + ) => { if (!map.has(branchId)) map.set(branchId, new Set()); map.get(branchId)!.add(fileKey); }; @@ -1565,8 +1707,10 @@ export class ExpertInsurerService { const fileKey = String(b?.publicId || b?._id || ""); if (!fileKey) continue; const status = String(b?.status || ""); - if (status === CaseStatus.COMPLETED) addPublicId(completedByBranch, bid, fileKey); - if (blameInHandling.has(status)) addPublicId(handlingByBranch, bid, fileKey); + if (status === CaseStatus.COMPLETED) + addPublicId(completedByBranch, bid, fileKey); + if (blameInHandling.has(status)) + addPublicId(handlingByBranch, bid, fileKey); } for (const c of claimFiles as any[]) { @@ -1577,8 +1721,10 @@ export class ExpertInsurerService { const fileKey = String(c?.publicId || c?._id || ""); if (!fileKey) continue; const status = String(c?.status || ""); - if (status === ClaimCaseStatus.COMPLETED) addPublicId(completedByBranch, bid, fileKey); - if (claimInHandling.has(status)) addPublicId(handlingByBranch, bid, fileKey); + if (status === ClaimCaseStatus.COMPLETED) + addPublicId(completedByBranch, bid, fileKey); + if (claimInHandling.has(status)) + addPublicId(handlingByBranch, bid, fileKey); } const list = branches.map((branch: any) => { @@ -1654,10 +1800,7 @@ export class ExpertInsurerService { this.isInDateRange((f as any)?.createdAt, fromDate, toDate), ); - const fileMap = new Map< - string, - { blame?: any; claim?: any } - >(); + const fileMap = new Map(); for (const b of blameFiles) { const key = String((b as any).publicId || (b as any)._id || ""); @@ -1686,8 +1829,7 @@ export class ExpertInsurerService { completed += 1; } - const blameUnderReview = - blameStatus === CaseStatus.WAITING_FOR_EXPERT; + const blameUnderReview = blameStatus === CaseStatus.WAITING_FOR_EXPERT; const claimUnderReview = claimStatus === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT || claimStatus === ClaimCaseStatus.EXPERT_REVIEWING; diff --git a/src/expert-insurer/helper/insurer.helper.ts b/src/expert-insurer/helper/insurer.helper.ts new file mode 100644 index 0000000..992e923 --- /dev/null +++ b/src/expert-insurer/helper/insurer.helper.ts @@ -0,0 +1,42 @@ +export function extractExpertNamesFromBlame(b: any): string[] { + const names = new Set(); + + const add = (v: unknown) => { + if (typeof v === "string" && v.trim()) names.add(v.trim()); + }; + + add(b?.workflow?.assignedForReviewBy?.actorName); + add(b?.workflow?.lockedBy?.actorName); + + // History entries from expert actions + for (const h of b?.history ?? []) { + if ( + h?.actor?.actorType === "field_expert" || + h?.actor?.actorType === "damage_expert" + ) { + add(h?.actor?.actorName); + } + } + + return [...names]; +} + +export function extractExpertNamesFromClaim(c: any): string[] { + const names = new Set(); + + const add = (v: unknown) => { + if (typeof v === "string" && v.trim()) names.add(v.trim()); + }; + + add(c?.workflow?.assignedForReviewBy?.actorName); + add(c?.workflow?.lockedBy?.actorName); + + // Evaluation snapshots + add(c?.evaluation?.damageExpertResend?.expertProfileSnapshot?.fullName); + add(c?.evaluation?.damageExpertReply?.expertProfileSnapshot?.fullName); + add(c?.evaluation?.damageExpertReplyFinal?.expertProfileSnapshot?.fullName); + add(c?.evaluation?.factorValidationExpertProfileSnapshot?.fullName); + add(c?.evaluation?.inPersonVisitExpertProfileSnapshot?.fullName); + + return [...names]; +} From bd5a33e2ba46b68189baa40c48be6b59c59c2450 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 3 Jun 2026 12:30:42 +0330 Subject: [PATCH 4/4] Fixed clientId of claim error --- .../claim-request-management.service.ts | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 11e3a12..6eb088e 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -3984,16 +3984,28 @@ export class ClaimRequestManagementService { owner: { userId: new Types.ObjectId(currentUserId), userRole: currentUserParty.role as any, - ...(currentUserParty.person?.clientId - ? { - clientId: new Types.ObjectId( - String(blameRequest?.expert?.decision?.guiltyPartyId) === - String(blameRequest?.parties[0]?.person?.userId) - ? blameRequest?.parties[0]?.person?.clientId - : blameRequest?.parties[1]?.person?.clientId, - ), - } - : {}), + fullName: currentUserParty.person?.fullName, + userClientKey: currentUserParty.person?.clientId + ? String(currentUserParty.person.clientId) + : undefined, + // clientId must be the GUILTY party's clientId — their insurer pays the claim + ...(() => { + if (isCarBody) { + // CAR_BODY has no guilty party — use the first party's own clientId + const firstParty = parties.find((p) => p.role === "FIRST"); + const cid = firstParty?.person?.clientId; + return cid ? { clientId: new Types.ObjectId(String(cid)) } : {}; + } + + const guiltyPartyId = String( + blameRequest?.expert?.decision?.guiltyPartyId ?? "", + ); + const guiltyParty = parties.find( + (p) => String(p.person?.userId) === guiltyPartyId, + ); + const cid = guiltyParty?.person?.clientId; + return cid ? { clientId: new Types.ObjectId(String(cid)) } : {}; + })(), }, history: [ {