"use strict"; import { createReadStream, existsSync, statSync } from "node:fs"; import { join } from "node:path"; import { HttpService } from "@nestjs/axios"; import { BadRequestException, ConflictException, ForbiddenException, HttpException, HttpStatus, Injectable, Logger, NotFoundException, } from "@nestjs/common"; import { distance as stringDistance } from "fastest-levenshtein"; // حتما نصب بشه import { Types } from "mongoose"; import { lastValueFrom } from "rxjs"; import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service"; import { ClaimRequestManagementService, FanavaranAutoSubmitResult, } from "src/claim-request-management/claim-request-management.service"; import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service"; import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service"; import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service"; import { ClientDbService } from "src/client/entities/db-service/client.db.service"; import { buildFileLink, resolveStoredFileUrl } from "src/helpers/urlCreator"; import { toJalaliDateAndTime } from "src/helpers/date-jalali"; import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service"; import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service"; import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service"; import { SandHubService } from "src/sand-hub/sand-hub.service"; import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum"; import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.enum"; import { UserType } from "src/Types&Enums/userType.enum"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { ExpertFileAssignResultDto, ExpertFileAssignStatus, } from "src/common/dto/expert-file-assign-result.dto"; import { CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE, claimWorkflowAssigneeToPersistOnLockExpiry, buildExpertAssignConflictForOtherReviewer, resolvePersistentReviewAssigneeId, } from "src/helpers/expert-workflow-review-assignee"; import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto"; import { ClaimListDtoRs } from "./dto/claim-list-rs.dto"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { assertClaimCaseForTenant, assertClaimCaseForExpertActor, claimCaseInitiatedByFieldExpert, claimCaseTouchesClient, requireActorClientKey, } from "src/helpers/tenant-scope"; import { claimCaseStatusToReportBucket, initialClaimExpertReportBuckets, } from "src/helpers/expert-panel-status-report"; import { claimDocInExpertPortfolio, expertPortfolioFileIdsFromActivityEvents, objectIdsFromStringSet, } from "src/helpers/expert-portfolio"; import { buildCoefficientsFromPartSeverities, buildDamagedPartsPriceDropLines, buildPriceDropCatalogForApi, calculateClaimPriceDrop, PRICE_DROP_SEVERITY_OPTIONS, resolveVehicleModelYearFromBlame, } from "src/helpers/claim-price-drop"; import { UpsertClaimPriceDropV2Dto } from "./dto/claim-price-drop-v2.dto"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; import { GetClaimListV2ResponseDto, ClaimListItemV2Dto, } from "./dto/claim-list-v2.dto"; import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto"; import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto"; import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto"; import { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service"; import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service"; import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum"; import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum"; import { BranchDbService } from "src/client/entities/db-service/branch.db.service"; import { ClaimSubmitResendV2Dto } from "./dto/expert-claim-v2.dto"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { FactorValidationV2Dto } from "./dto/factor-validation.dto"; import { buildMutualAgreementExpertDecision, enrichBlamePartiesForAgreementView, } from "src/helpers/blame-party-agreement-decision"; import { normalizeResendDocumentKeys, resendRequestHasPayload, } from "src/helpers/claim-expert-resend"; import { claimCaseStatusAfterExpertReplyV2, classifyV2ExpertPricingParts, claimIsAwaitingExpertFactorValidationV2, } from "src/helpers/claim-v2-expert-reply-workflow"; import { getClaimCarAngleCaptureBlob, getDamagedPartCaptureBlob, hasDamagedPartCapture, type ClaimCarAngleKey, } from "src/helpers/claim-car-angle-media"; import { catalogLikeKeyFromPart, coerceDamagedPartsMediaToArray, normalizeCarPartDamageForExpertReply, normalizeDamageSelectedParts, catalogPartIdFromCarPartDamage, catalogPartIdFromSelectedPart, partLookupKey, resolvePartForExpertReply, sameCatalogPartId, sanitizeDamageSelectedPartV2, type DamageSelectedPartV2, } from "src/helpers/outer-damage-parts"; import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend"; import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot"; import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service"; import { ExpertFileActivityType, ExpertFileKind, } from "src/users/entities/schema/expert-file-activity.schema"; /** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */ import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { applyListQueryV2 } from "src/helpers/list-query-v2"; import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher"; import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys"; @Injectable() export class ExpertClaimService { private readonly logger = new Logger(ExpertClaimService.name); /** Matches blame v2 `workflow` lock TTL: locker may re-enter until expired, then others may open. */ private readonly claimV2WorkflowLockTtlMs = 15 * 60 * 1000; private readonly priceDropPart = { backFender: { Minor: 2, Moderate: 3, Severe: 5, }, backDoor: { Minor: 1, Moderate: 2, Severe: 3, }, frontDoor: { Minor: 1, Moderate: 2, Severe: 3, }, frontFender: { Minor: 1, Moderate: 2, Severe: 3, }, frontBumper: { Minor: 1, Moderate: 2, Severe: 3, }, Hood: { Minor: 2, moderate: 3, Severe: 4, }, Trunk: { minor: 1, moderate: 3, Severe: 5, }, Roof: { Minor: 3, Moderate: 5, Severe: 7, }, coil: { Minor: 2, Moderate: 3, Severe: 4, }, column: { Minor: 2, Moderate: 3, Severe: 4, }, frontTray: { Minor: 1, Moderate: 2, Severe: 3, }, backTray: { Minor: 2, moderate: 4, Severe: 5, }, frontChassis: { Minor: 3, Moderate: 5, Severe: 7, }, backChassis: { Minor: 2, Moderate: 4, Severe: 6, }, carFootrest: { Minor: 1, Moderate: 2, Severe: 3, }, carFloor: { Minor: 4, moderate: 6, Severe: 8, }, }; private readonly yearCoefficient = { 1393: 2.05, 1394: 2.1, 1395: 2.2, 1396: 2.3, 1397: 2.4, 1398: 2.5, 1399: 2.6, 1400: 2.7, 1401: 2.8, 1402: 2.9, 1403: 3, 1404: 3, }; constructor( private readonly blameVoiceDbService: BlameVoiceDbService, private readonly blameDocumentDbService: BlameDocumentDbService, private readonly claimRequestManagementDbService: ClaimRequestManagementDbService, private readonly damageImageDbService: DamageImageDbService, private readonly blameVideoDbService: BlameVideoDbService, private readonly claimVideoCaptureDbService: VideoCaptureDbService, private readonly httpService: HttpService, private readonly claimCaseDbService: ClaimCaseDbService, private readonly blameRequestDbService: BlameRequestDbService, private readonly sandHubService: SandHubService, private readonly damageExpertDbService: DamageExpertDbService, private readonly clientDbService: ClientDbService, private readonly claimFactorsImageDbService: ClaimFactorsImageDbService, private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService, private readonly branchDbService: BranchDbService, private readonly smsOrchestrationService: SmsOrchestrationService, private readonly userDbService: UserDbService, private readonly expertFileActivityDbService: ExpertFileActivityDbService, private readonly claimSignDbService: ClaimSignDbService, private readonly claimRequestManagementService: ClaimRequestManagementService, ) {} private appendFanavaranAutoSubmitToMessage( baseMessage: string, fanavaran: FanavaranAutoSubmitResult, ): string { if (fanavaran.submitted) { return `${baseMessage} The claim was sent to Fanavaran successfully.`; } if (fanavaran.warning) { return `${baseMessage} ${fanavaran.warning}`; } return baseMessage; } /** * Resolve a `claim-sign` document id to a downloadable file URL. * Mirrors the helper used in `ExpertInsurerService` so signature links are * exposed identically across the two expert-facing detail endpoints. */ private async claimSignLinkFromId( signDetailId: unknown, ): Promise { if (signDetailId == null || signDetailId === "") return undefined; const id = String(signDetailId); if (!Types.ObjectId.isValid(id)) return undefined; const doc = await this.claimSignDbService.findOne({ _id: new Types.ObjectId(id), }); const path = (doc as any)?.path; return path ? buildFileLink(String(path)) : undefined; } /** * Build a deep-cloned `evaluation` payload with resolved file URLs the * front-end can render directly: * * - `damageExpertReply.userComment.signLink` * - `damageExpertReplyFinal.userComment.signLink` * - `ownerInsurerApproval.signLink` * - `ownerPricedPartsApproval.signLink` * - `damageExpertReply.parts[i].factorLink` (ObjectId → URL) * - `damageExpertReplyFinal.parts[i].factorLink` (ObjectId → URL) * * Returns `undefined` when there's no evaluation to enrich. */ private async enrichClaimEvaluationForExpert( evaluation: Record | undefined | null, ): Promise | undefined> { if (!evaluation || typeof evaluation !== "object") return undefined; const ev = JSON.parse(JSON.stringify(evaluation)) as Record< string, unknown >; const enrichReply = async (key: string) => { const reply = ev[key] as Record | undefined; if (!reply) return; const uc = reply.userComment as Record | undefined; if (uc?.signDetailId != null) { const signLink = await this.claimSignLinkFromId(uc.signDetailId); reply.userComment = { ...uc, signLink }; } // Resolve each `parts[].factorLink` ObjectId to a downloadable URL. // `populateFactorLinks` mutates in place — safe here because `ev` was // deep-cloned via JSON above, so we never touch the live mongoose doc. await this.populateFactorLinks(reply); }; await enrichReply("damageExpertReply"); await enrichReply("damageExpertReplyFinal"); const enrichApproval = async (key: string) => { const o = ev[key] as Record | undefined; if (o?.signDetailId != null) { const signLink = await this.claimSignLinkFromId(o.signDetailId); ev[key] = { ...o, signLink }; } }; await enrichApproval("ownerInsurerApproval"); await enrichApproval("ownerPricedPartsApproval"); return ev; } /** Load immutable profile fields from `damage-expert` for the acting expert. */ private async snapshotDamageExpert(actorId: string) { const doc = await this.damageExpertDbService.findById(actorId); return snapshotFromDamageExpert(doc as DamageExpertModel); } private async recordClaimExpertActivity(args: { expertId: string; tenantId: string; claimId: string; eventType: ExpertFileActivityType; idempotencyKey?: string; }): Promise { if ( !Types.ObjectId.isValid(args.expertId) || !Types.ObjectId.isValid(args.tenantId) || !Types.ObjectId.isValid(args.claimId) ) { return; } await this.expertFileActivityDbService.recordEvent({ expertId: args.expertId, tenantId: args.tenantId, fileId: args.claimId, fileType: ExpertFileKind.CLAIM, eventType: args.eventType, idempotencyKey: args.idempotencyKey, }); } /** * Insurer tenant id stored on claim file-activity rows. Prefer `owner.userClientKey` * so V2 events line up with `expireClaimWorkflowLockV2IfStale` and insurer `findByTenant`. */ private claimActivityTenantId( claim: { owner?: { userClientKey?: unknown } } | null | undefined, actor: { clientKey?: string }, ): string { const fromOwner = claim?.owner?.userClientKey; if (fromOwner != null && String(fromOwner).length > 0) { return String(fromOwner); } return String(actor.clientKey ?? ""); } /** * `preLockQueueSnapshot` must only store {@link ClaimWorkflowStep} values. * Legacy / mistaken data sometimes put {@link ClaimCaseStatus} (e.g. WAITING_FOR_DAMAGE_EXPERT) * in `workflow.currentStep`, which would fail Mongoose enum validation when snapshotted. */ private normalizeClaimWorkflowStepForSnapshot( value: unknown, fallback: ClaimWorkflowStep, ): ClaimWorkflowStep { if ( typeof value === "string" && (Object.values(ClaimWorkflowStep) as string[]).includes(value) ) { return value as ClaimWorkflowStep; } return fallback; } /** Owner mobile: linked blame party phone when available, else `users.mobile`. */ private async resolveClaimOwnerPhone( claim: any, ): Promise { if (!claim?.owner?.userId) return undefined; const ownerUserId = String(claim.owner.userId); if (claim.blameRequestId) { const blame = await this.blameRequestDbService.findById( claim.blameRequestId.toString(), ); const ownerParty = (blame?.parties || []).find( (p: any) => p?.person?.userId && String(p.person.userId) === ownerUserId, ); const fromParty = ownerParty?.person?.phoneNumber; if (fromParty && typeof fromParty === "string") return fromParty; } const user = await this.userDbService.findOne({ _id: new Types.ObjectId(ownerUserId), }); const m = user?.mobile; if (m && typeof m === "string") return m; return undefined; } /** Map blame party `Vehicle` (name/model/type) to expert panel shape. */ private expertVehicleFromPartyVehicle(v: any): | { carName?: string; carModel?: string; carType?: string; plate?: { plateId?: string }; } | undefined { if (!v || typeof v !== "object") return undefined; const carName = v.name ?? v.carName; const carModel = v.model ?? v.carModel; const carType = v.type ?? v.carType; const plateId = v.plateId; const plate = plateId != null && String(plateId).length > 0 ? { plateId: String(plateId) } : undefined; if (!carName && !carModel && !carType && !plate) return undefined; return { carName, carModel, carType, ...(plate ? { plate } : {}) }; } /** Damaged party vehicle on blame (matches claim owner, or non-guilty party for THIRD_PARTY). */ private damagedPartyVehicleFromBlame( blame: any, claim: any, ): | { carName?: string; carModel?: string; carType?: string; plate?: { plateId?: string }; } | undefined { const parties = blame?.parties; if (!Array.isArray(parties) || parties.length === 0) return undefined; const ownerId = claim?.owner?.userId ? String(claim.owner.userId) : ""; let party = ownerId ? parties.find( (p: any) => p?.person?.userId && String(p.person.userId) === ownerId, ) : undefined; if (!party && blame?.expert?.decision?.guiltyPartyId) { const guilty = String(blame.expert.decision.guiltyPartyId); party = parties.find( (p: any) => p?.person?.userId && String(p.person.userId) !== guilty, ); } if (!party && parties.length === 1) party = parties[0]; return this.expertVehicleFromPartyVehicle(party?.vehicle); } private vehicleForExpertFromClaimAndBlameMap( claim: any, blameById: Map, ): | { carName?: string; carModel?: string; carType?: string; plate?: { plateId?: string }; } | undefined { const cv = claim?.vehicle; if (cv && (cv.carName || cv.carModel || cv.carType || (cv as any).plate)) { return { carName: cv.carName, carModel: cv.carModel, carType: cv.carType, ...((cv as any).plate ? { plate: (cv as any).plate } : {}), }; } const bid = claim?.blameRequestId?.toString(); if (!bid) return undefined; const blame = blameById.get(bid); if (!blame) return undefined; return this.damagedPartyVehicleFromBlame(blame, claim); } /** * Blame file kind for damage-expert UI: THIRD_PARTY vs CAR_BODY, and CAR_BODY first-step flags. */ private blameFileContextForExpert(blame: any): { blameRequestType?: BlameRequestType; carBodyFirstForm?: { car?: boolean; object?: boolean }; blameStatus?: string; } { if (!blame?.type) return {}; const blameRequestType = blame.type as BlameRequestType; const out: { blameRequestType?: BlameRequestType; carBodyFirstForm?: { car?: boolean; object?: boolean }; blameStatus?: string; } = { blameRequestType }; out.blameStatus = blame.blameStatus; if (blameRequestType !== BlameRequestType.CAR_BODY) return out; const parties = blame.parties; if (!Array.isArray(parties) || parties.length === 0) return out; const first = parties.find((p: any) => p?.role === PartyRole.FIRST) ?? parties[0]; const cbf = first?.carBodyFirstForm; delete out.blameStatus; if (cbf && typeof cbf === "object") { out.carBodyFirstForm = { ...(typeof cbf.car === "boolean" ? { car: cbf.car } : {}), ...(typeof cbf.object === "boolean" ? { object: cbf.object } : {}), }; } return out; } private isVisibleToClientType(client: any, actor: any): boolean { if (actor.userType === UserType.GENUINE) { return true; } if ( actor.userType === UserType.LEGAL && String(client._id) === actor.clientKey ) { return true; } return false; } private wasHandledByActor(request: any, actorSub: string): boolean { type ActorCheckerEntry = { CheckedRequest?: { actorId: string } }; const actorChecker = request.actorsChecker as ActorCheckerEntry[]; if (!Array.isArray(actorChecker)) { return false; } const matchingEntry = actorChecker.find( (entry) => String(entry?.CheckedRequest?.actorId) === actorSub, ); return !!matchingEntry; } async getClaimRequestsListForExpert(actor): Promise { // const requests = await this.claimRequestManagementDbService.findAllByStatus( // { // claimStatus: { // $in: [ // ReqClaimStatus.UnChecked, // ReqClaimStatus.ReviewRequest, // ReqClaimStatus.CheckAgain, // ReqClaimStatus.CloseRequest, // ReqClaimStatus.InPersonVisit, // ReqClaimStatus.CheckedRequest, // ReqClaimStatus.PendingFactorValidation, // ], // }, // "damageExpertReply.actorDetail.actorId":actor.sub // }, // ); const requests = await this.claimRequestManagementDbService.findAllByStatus( { $or: [ { claimStatus: ReqClaimStatus.UnChecked, }, { claimStatus: { $in: [ ReqClaimStatus.ReviewRequest, ReqClaimStatus.CheckAgain, ReqClaimStatus.CloseRequest, ReqClaimStatus.InPersonVisit, ReqClaimStatus.CheckedRequest, ReqClaimStatus.PendingFactorValidation, ], }, "damageExpertReply.actorDetail.actorId": actor.sub, }, ], }, ); const filteredRequests = []; for (const r of requests) { // For expert-initiated blame files, only show to the initiating expert if (r.blameFile?.expertInitiated && r.blameFile?.initiatedBy) { if (String(r.blameFile.initiatedBy) !== actor.sub) { continue; // Skip if not the initiating expert } // Expert-initiated claim files are always visible to the initiating expert filteredRequests.push(r); continue; } const client = await this.clientDbService.findOne({ _id: r.userClientKey, }); if (!client) { this.logger.warn( `Client not found for claim request with ID: ${r._id}. Skipping.`, ); continue; } const specialHandlingStatuses = [ ReqClaimStatus.CheckAgain, ReqClaimStatus.ReviewRequest, ReqClaimStatus.PendingFactorValidation, ]; const requiresSpecificActorCheck = specialHandlingStatuses.includes( r.claimStatus, ); if (requiresSpecificActorCheck) { if (this.wasHandledByActor(r, actor.sub)) { filteredRequests.push(r); } } else { if (this.isVisibleToClientType(client, actor)) { filteredRequests.push(r); } } } if (filteredRequests.length === 0) { throw new NotFoundException( "No claim requests found for you at this time.", ); } return new ClaimListDtoRs(filteredRequests); } public unlockApi(request, timer) { // ADD to project return setTimeout(() => { this.claimRequestManagementDbService.findOne(request._id).then((r) => { const updateExp = { lockFile: false, unlockTime: null, actorLocked: {}, }; if (r?.claimStatus == ReqClaimStatus.ReviewRequest) { Object.assign(updateExp, { claimStatus: ReqClaimStatus.UnChecked, }); } this.claimRequestManagementDbService.findOneAndUpdate(request._id, { ...updateExp, }); }); this.logger.log(`unlock this request : ${request._id}`); // TODO enum request claimStatus create }, timer); } async lockClaimRequest(requestId: string, actorDetail) { const request = await this.claimRequestManagementDbService.findOne(requestId); if (!request) throw new BadRequestException("Claim request not found"); const isLocked = !!request.lockFile; const lockedByCurrent = String(request?.actorLocked?.actorId || "") === String(actorDetail.sub); const isLockExpired = !!request.unlockTime && Date.now() >= new Date(request.unlockTime).getTime(); // Idempotent behavior: same expert can re-enter their locked file. if (isLocked && lockedByCurrent && !isLockExpired) { return { _id: requestId, lock: true, message: "Already locked by you" }; } if (isLocked && !lockedByCurrent && !isLockExpired) { throw new BadRequestException( "Claim request is locked by another expert", ); } if (request.claimStatus === ReqClaimStatus.UserPending) { throw new BadRequestException( "Cannot lock request because claimStatus is UserPending", ); } if ( request.damageExpertReplyFinal && request.claimStatus === ReqClaimStatus.CheckedRequest ) { throw new BadRequestException("Request has damage expert reply"); } const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000); const lockSnapshot = await this.snapshotDamageExpert(actorDetail.sub); await this.claimRequestManagementDbService.findOneAndUpdate( new Types.ObjectId(requestId), { lockFile: true, claimStatus: ReqClaimStatus.ReviewRequest, unlockTime: fifteenMinutes, lockTime: Date.now(), $set: { actorLocked: { actorId: new Types.ObjectId(actorDetail.sub), fullName: actorDetail.fullName, ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), }, }, $push: { actorsChecker: { [ReqClaimStatus.ReviewRequest]: { fullName: actorDetail.fullName, actorId: new Types.ObjectId(actorDetail.sub), }, Date: new Date(), }, }, }, ); await this.recordClaimExpertActivity({ expertId: String(actorDetail.sub), tenantId: String(request.userClientKey ?? actorDetail.clientKey ?? ""), claimId: String(requestId), eventType: ExpertFileActivityType.CHECKED, idempotencyKey: `claim:${requestId}:checked:${actorDetail.sub}`, }); this.unlockApi(request, fifteenMinutes.getTime() - Date.now()); return { _id: requestId, lock: true }; } private normalizeText(str: string) { if (!str || typeof str !== "string") { return ""; } return str .replace(/[٠-٩۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728)) .replace(/[\u064B-\u065F]/g, "") .replace(/\s+/g, " ") .trim() .toLowerCase(); } normalizeKey(str: string): string { return str.toLowerCase().replace(/[^a-z0-9]/g, ""); } parsePersianNumber(input: string): number { return Number( input .replace(/[۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728)) .replace(/,/g, ""), ); } /** Factor validation: expert must send a line amount (OCR is not used for factor photos). */ private expertFactorValidationDecisionHasLinePricing(decision: { totalPayment?: string; price?: string; salary?: string; }): boolean { const tp = decision.totalPayment != null && String(decision.totalPayment).trim() !== ""; const pr = decision.price != null && String(decision.price).trim() !== ""; const sa = decision.salary != null && String(decision.salary).trim() !== ""; return tp || (pr && sa); } /** Price-cap total for one reply line: `totalPayment` if set, otherwise `price` + `salary`. */ private claimReplyPartLineTotalForCap(part: { partId?: number | string; totalPayment?: unknown; price?: unknown; salary?: unknown; }): number { const tpRaw = part.totalPayment != null ? String(part.totalPayment).trim() : ""; if (tpRaw !== "") { return this.parsePersianNumber(tpRaw); } const pr = this.parsePersianNumber(String(part.price ?? "0")); const sa = this.parsePersianNumber(String(part.salary ?? "0")); return pr + sa; } /** Same rules as V1 `submitReplyRequest`: daghi option + conditional price / branchId; `branchId` stored as ObjectId. */ private validateAndNormalizeDaghiForExpertReplyV2( parts: import("./dto/expert-claim-v2.dto").PartPricingV2Dto[], ) { for (const part of parts) { if (!part.daghi || !part.daghi.option) { throw new BadRequestException( `Daghi option is required for part ${part.partId}`, ); } if (part.daghi.option === DaghiOption.RECYCLED_PARTS_VALUE) { if (!part.daghi.price) { throw new BadRequestException( `Price is required for part ${part.partId} when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`, ); } } else if (part.daghi.option === DaghiOption.DELIVER_DAMAGED_PART) { if (!part.daghi.branchId) { throw new BadRequestException( `Branch ID is required for part ${part.partId} when option is '${DaghiOption.DELIVER_DAMAGED_PART}'`, ); } if (!Types.ObjectId.isValid(part.daghi.branchId)) { throw new BadRequestException( `Invalid branch ID format for part ${part.partId}`, ); } } } return parts.map((part) => ({ ...part, daghi: { option: part.daghi.option, ...(part.daghi.price && { price: part.daghi.price }), ...(part.daghi.branchId && { branchId: new Types.ObjectId(part.daghi.branchId), }), }, })); } private findClosestCar(input: string, cars: { carName: string }[]) { if (!input || !cars || cars.length === 0) { this.logger.debug( `[PriceDrop] findClosestCar: Invalid input. input: ${input}, cars length: ${cars?.length || 0}`, ); return null; } const normalizedInput = this.normalizeText(input); if (!normalizedInput) { this.logger.debug( `[PriceDrop] findClosestCar: Could not normalize input "${input}"`, ); return null; } let bestMatch = null; let minDistance = Infinity; let validCarsChecked = 0; for (const car of cars) { if (!car || !car.carName) { continue; } const normalizedCarName = this.normalizeText(car.carName); if (!normalizedCarName) { continue; } validCarsChecked++; const dist = stringDistance(normalizedInput, normalizedCarName); if (dist < minDistance) { minDistance = dist; bestMatch = car; } } if (!bestMatch) { this.logger.debug( `[PriceDrop] findClosestCar: No match found for "${input}" after checking ${validCarsChecked} valid cars out of ${cars.length} total`, ); } else { this.logger.debug( `[PriceDrop] findClosestCar: Best match for "${input}" is "${bestMatch.carName}" with distance ${minDistance}`, ); } return bestMatch; } private priceDropFormula( carPrice: number | string, carModel: number, carValue: number[], ) { let normalizePrice: number; if (typeof carPrice === "string") { normalizePrice = this.parsePersianNumber(carPrice); } else if (typeof carPrice === "number") { normalizePrice = carPrice; } else { this.logger.warn( `Invalid carPrice type received in formula: ${typeof carPrice}`, ); normalizePrice = 0; } const coefficient = this.yearCoefficient[carModel] || 1; const sumOfSeverity = carValue.reduce((a, c) => a + c, 0); const totalDrop = (normalizePrice * coefficient * sumOfSeverity) / 400; return { carPrice: normalizePrice, carModel: carModel || null, carValue: carValue || [], sumOfSeverity: sumOfSeverity || 0, coefficientYear: coefficient || 1, total: totalDrop || 0, }; } async calculatePriceDrop(request: any, carPartsAi: any, query?: any) { const requestId = request?._id?.toString() || "unknown"; try { // Step 1: Check SandHub data if (!request?.blameFile?.sanHubId) { this.logger.warn( `[PriceDrop] Request ${requestId}: Missing sanHubId in blameFile. Cannot calculate price drop.`, ); return; } const sandHubData = await this.sandHubService.getSandHubDataFromId( request.blameFile.sanHubId, ); if (!sandHubData) { this.logger.warn( `[PriceDrop] Request ${requestId}: Could not retrieve SandHub data for sanHubId: ${request.blameFile.sanHubId}. Cannot calculate price drop.`, ); return; } if ( sandHubData.MapTypNam === undefined || sandHubData.MapTypNam === null ) { this.logger.warn( `[PriceDrop] Request ${requestId}: SandHub data missing ModelCii. Cannot calculate price drop.`, ); return; } const existingPriceDrop = request.priceDrop || {}; const manualOverride = query || {}; // Step 2: Check car parts and get severity values if ( !carPartsAi || (Array.isArray(carPartsAi) && carPartsAi.length === 0) ) { this.logger.warn( `[PriceDrop] Request ${requestId}: No car parts data provided. Cannot calculate severity values.`, ); } const severityValues = this.getSeverityValues(carPartsAi); let autoCalculatedData: any = {}; if (severityValues.length === 0) { this.logger.warn( `[PriceDrop] Request ${requestId}: No severity values found from car parts. Price drop calculation will use existing data or manual override only.`, ); } // Step 3: Check car detail and find closest car if (!request?.carDetail) { this.logger.warn( `[PriceDrop] Request ${requestId}: Missing carDetail in request. Cannot find car price.`, ); } else if (!request.carDetail.carName) { this.logger.warn( `[PriceDrop] Request ${requestId}: Missing carName in carDetail. Cannot find car price.`, ); } else if (severityValues.length > 0) { // Only fetch car prices if we have severity values to calculate const carPrices = await this.fetchCarPrices(); if (!carPrices || carPrices.length === 0) { this.logger.error( `[PriceDrop] Request ${requestId}: Failed to fetch car prices or car prices list is empty. Cannot find matching car.`, ); } else { this.logger.debug( `[PriceDrop] Request ${requestId}: Fetched ${carPrices.length} car prices. Searching for: "${request.carDetail.carName}"`, ); const closestCar = this.findClosestCar( request.carDetail.carName, carPrices, ); if (!closestCar) { this.logger.warn( `[PriceDrop] Request ${requestId}: Could not find closest matching car for "${request.carDetail.carName}". Available cars: ${carPrices.length}. Cannot determine car price.`, ); } else if (!closestCar.marketPrice) { this.logger.warn( `[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" but marketPrice is missing. Cannot calculate price drop.`, ); } else { this.logger.debug( `[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" with marketPrice: ${closestCar.marketPrice}`, ); autoCalculatedData = { carPrice: closestCar.marketPrice, carValue: severityValues, }; } } } // Step 4: Merge and recalculate const finalPriceDropData = this.mergeAndRecalculatePriceDrop( existingPriceDrop, autoCalculatedData, manualOverride, sandHubData.ModelCii, ); if (!finalPriceDropData) { this.logger.error( `[PriceDrop] Request ${requestId}: Failed to generate final price drop data.`, ); return; } // Step 5: Log calculation result if ( !finalPriceDropData.carPrice || !finalPriceDropData.carValue || finalPriceDropData.carValue.length === 0 ) { this.logger.warn( `[PriceDrop] Request ${requestId}: Price drop calculated but missing critical data. ` + `carPrice: ${finalPriceDropData.carPrice}, ` + `carValue length: ${finalPriceDropData.carValue?.length || 0}, ` + `total: ${finalPriceDropData.total}`, ); } else { this.logger.debug( `[PriceDrop] Request ${requestId}: Successfully calculated price drop. ` + `carPrice: ${finalPriceDropData.carPrice}, ` + `sumOfSeverity: ${finalPriceDropData.sumOfSeverity}, ` + `total: ${finalPriceDropData.total}`, ); } // Step 6: Save to database await this.claimRequestManagementDbService.findOneAndUpdate( { _id: new Types.ObjectId(request._id) }, { $set: { priceDrop: finalPriceDropData } }, ); this.logger.log( `[PriceDrop] Request ${requestId}: Price drop calculation completed and saved.`, ); } catch (err) { this.logger.error( `[PriceDrop] Request ${requestId}: An error occurred during the price drop calculation: ${err.message}`, err.stack, ); } } private mergeAndRecalculatePriceDrop( existingData: any, autoCalculatedData: any, manualOverride: any, carModel: number, ) { const mergedInputs = { ...existingData, ...autoCalculatedData, ...manualOverride, }; if (!mergedInputs.carPrice || !mergedInputs.carValue) { return { carPrice: mergedInputs.carPrice || null, carModel: carModel, carValue: mergedInputs.carValue || [], sumOfSeverity: 0, coefficientYear: this.yearCoefficient[carModel] || 1, total: 0, }; } return this.priceDropFormula( mergedInputs.carPrice, carModel, mergedInputs.carValue, ); } private getSeverityValues(carPartsAi: any[]): number[] { const severities: number[] = []; const knownPartKeyMap = new Map(); for (const originalKey of Object.keys(this.priceDropPart)) { if (typeof originalKey === "string") { knownPartKeyMap.set(this.normalizeKey(originalKey), originalKey); } } for (const part of carPartsAi) { const damagedPartTable = part?.aiReport?.damaged_part_table; if (Array.isArray(damagedPartTable) && damagedPartTable.length > 0) { for (const damagedPartInfo of damagedPartTable) { if (damagedPartInfo && typeof damagedPartInfo.name === "string") { const normalizedAiPartName = this.normalizeKey( damagedPartInfo.name, ); if (knownPartKeyMap.has(normalizedAiPartName)) { const originalPriceDropKey = knownPartKeyMap.get(normalizedAiPartName); const severityValue = this.priceDropPart[originalPriceDropKey]?.[ damagedPartInfo.severity ]; if (severityValue !== undefined) { severities.push(severityValue); } } } } } } return severities; } private async fetchCarPrices(): Promise< { carName: string; marketPrice: number }[] > { const carPrices: { carName: string; marketPrice: number }[] = []; const endpoints = ["akharin", "hamrah"]; for (const item of endpoints) { try { const url = `${process.env.CW_URL}price?${item}`; const response = await lastValueFrom(this.httpService.get(url)); if (Array.isArray(response.data)) { let validEntries = 0; for (const r of response.data) { if (r?.carName && r?.marketPrice) { carPrices.push({ carName: r.carName, marketPrice: r.marketPrice, }); validEntries++; } } this.logger.debug( `[PriceDrop] Fetched ${validEntries} valid car prices from endpoint '${item}' (total entries: ${response.data.length})`, ); } else { this.logger.warn( `[PriceDrop] Endpoint '${item}' returned non-array data: ${typeof response.data}`, ); } } catch (err) { this.logger.error( `[PriceDrop] Error fetching car prices for endpoint '${item}': ${err.message}`, err.stack, ); } } if (carPrices.length === 0) { this.logger.error( `[PriceDrop] No car prices fetched from any endpoint. Endpoints tried: ${endpoints.join(", ")}`, ); } else { this.logger.debug( `[PriceDrop] Total car prices fetched: ${carPrices.length}`, ); } return carPrices; } private calculateFinalDropPrice( closestCar: { marketPrice: number } | null, carModel: any, severities: number[], query?: any, ) { if (query?.carPrice && query?.carValue) { return this.priceDropFormula(query.carPrice, carModel, query.carValue); } if (closestCar?.marketPrice && severities.length > 0) { return this.priceDropFormula( closestCar.marketPrice, carModel, severities, ); } return null; } async requestPerId(requestId: string, currentUser: any, query?: any) { try { // 1. Fetch the initial request document and perform calculations const initialRequest = await this.claimRequestManagementDbService.findOneDocument(requestId); if (!initialRequest) { throw new HttpException( { responseCode: 1006, message: "Request not found" }, HttpStatus.NOT_FOUND, ); } await this.calculatePriceDrop( initialRequest, initialRequest.imageRequired.selectPartOfCar, query, ); const requestUpdated = await this.claimRequestManagementDbService.findOneDocument(requestId); await this.populateFactorLinks(requestUpdated.damageExpertReply); await this.populateFactorLinks(requestUpdated.damageExpertReplyFinal); // 2. Populate required documents with file links if ( requestUpdated.requiredDocuments && Object.keys(requestUpdated.requiredDocuments).length > 0 ) { for (const documentType in requestUpdated.requiredDocuments) { const documentId = requestUpdated.requiredDocuments[documentType]; if (documentId) { try { const doc = await this.claimRequiredDocumentDbService.findById( documentId.toString(), ); if (doc && doc.path) { // Replace ID with file URL requestUpdated.requiredDocuments[documentType] = buildFileLink( doc.path, ) as any; } } catch (error) { this.logger.warn( `Failed to populate required document ${documentType}: ${error.message}`, ); // Keep the ID if population fails } } } } // 3. Populate the resent document and voice links from the nested blameFile. if (requestUpdated.blameFile?.expertResendReply) { // Helper function to avoid repeating code const populatePartyLinks = async ( partyKey: "firstParty" | "secondParty", ) => { const partyReply = requestUpdated.blameFile.expertResendReply[partyKey]; if (!partyReply) return; // Populate the voice link if (partyReply.voice) { const voiceDoc = await this.blameVoiceDbService.findOne( partyReply.voice.toString(), ); if (voiceDoc) { partyReply.voice = buildFileLink(voiceDoc.path); } } // Populate the document links if (partyReply.documents) { for (const docType in partyReply.documents) { const docId = partyReply.documents[docType]; if (docId) { // This line will now work correctly after you inject the service. const doc = await this.blameDocumentDbService.findById( docId.toString(), ); if (doc) { partyReply.documents[docType] = buildFileLink(doc.path); // Replace ID with URL } } } } }; // Run the population for both parties await populatePartyLinks("firstParty"); await populatePartyLinks("secondParty"); } // 4. Perform authorization checks (as before) if (this.isCurrentUserAllowed(requestUpdated, currentUser)) { return new ClaimPerIdRs(requestUpdated); } // Check if locked by someone else (allow if locked by current user) if (this.isRequestLocked(requestUpdated, currentUser)) { throw new HttpException( { responseCode: 1007, message: "Request is locked" }, HttpStatus.FORBIDDEN, ); } // 5. Return the fully populated response return new ClaimPerIdRs(requestUpdated); } catch (error) { this.globalErrHandler(error); } } async imageRequired(requestId, currentUser) { const request = await this.claimRequestManagementDbService.findOneDocument(requestId); if (!request) { throw new HttpException( { responseCode: 1006, message: "Request not found" }, HttpStatus.NOT_FOUND, ); } if (this.isCurrentUserAllowed(request, currentUser)) { return (await this.claimRequestManagementDbService.findOne(requestId)) .imageRequired; } else if (this.isRequestLocked(request, currentUser)) { throw new HttpException( { responseCode: 1007, message: "Request is locked" }, HttpStatus.FORBIDDEN, ); } else { throw new HttpException( { responseCode: 1008, message: "notAllowed is locked" }, HttpStatus.FORBIDDEN, ); } } private async populateFactorLinks(reply: any): Promise { if (!reply || !Array.isArray(reply.parts)) { return; } for (const part of reply.parts) { if (part.factorLink && Types.ObjectId.isValid(part.factorLink)) { const factorDoc = await this.claimFactorsImageDbService.findById( part.factorLink.toString(), ); if (factorDoc) { part.factorLink = buildFileLink(factorDoc.path); } } } } async getRequiredDocuments(requestId: string, currentUser: any) { try { const request = await this.claimRequestManagementDbService.findOneDocument(requestId); if (!request) { throw new HttpException( { responseCode: 1006, message: "Request not found" }, HttpStatus.NOT_FOUND, ); } // Perform authorization checks - allow if locked by current user if (this.isRequestLocked(request, currentUser)) { throw new HttpException( { responseCode: 1007, message: "Request is locked" }, HttpStatus.FORBIDDEN, ); } // Fetch all required documents for this claim const documents = await this.claimRequiredDocumentDbService.findByClaimId(requestId); // Populate with file URLs const populatedDocuments = documents.map((doc) => ({ _id: doc._id, documentType: doc.documentType, fileName: doc.fileName, fileUrl: buildFileLink(doc.path), uploadedAt: doc.uploadedAt, })); return { claimId: requestId, totalDocuments: populatedDocuments.length, documents: populatedDocuments, }; } catch (error) { this.globalErrHandler(error); } } private async processAiImages( imageRequired: any, ): Promise<{ aroundTheCar: any[]; damagePartOfCar: any[] }> { const aiImages = { aroundTheCar: [], damagePartOfCar: [] }; return aiImages; } private async processImage(imageId: string) { return this.damageImageDbService.findOne(imageId); } private isCurrentUserAllowed(request: any, currentUser: any): boolean { // Check if locked by current user and lock is still active const isLockedByCurrentUser = String(request?.actorLocked?.actorId) === currentUser.sub && request.lockFile; if (!isLockedByCurrentUser) { return false; } // Also check if lock has expired (unlockTime has passed) if (request.unlockTime) { const unlockTime = new Date(request.unlockTime).getTime(); const now = Date.now(); if (now >= unlockTime) { // Lock has expired, but still allow access if they were the one who locked it // The unlockApi will handle the cleanup return true; } } return true; } private isRequestLocked(request: any, currentUser?: any): boolean { if ( !request.lockFile || request.claimStatus !== ReqClaimStatus.ReviewRequest ) { return false; } // Check if lock has expired if (request.unlockTime) { const unlockTime = new Date(request.unlockTime).getTime(); const now = Date.now(); if (now >= unlockTime) { // Lock has expired, treat as not locked return false; } } // If currentUser is provided, allow access if they are the one who locked it if (currentUser) { const isLockedByCurrentUser = String(request?.actorLocked?.actorId) === currentUser.sub; // Return false (not locked) if locked by current user, true if locked by someone else return !isLockedByCurrentUser; } // If no currentUser provided, treat as locked return true; } async submitReplyRequest( requestId: string, reply: ClaimSubmitReplyDto, userId: any, ) { try { const request = await this.claimRequestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Request not found"); } if ( String(request?.actorLocked?.actorId) !== userId.sub && !request?.objection ) { throw new ForbiddenException("Access denied to this request"); } // Check if lock has expired (unlockTime has passed) if (request?.unlockTime && !request?.objection) { const unlockTime = new Date(request.unlockTime).getTime(); const now = Date.now(); if (now >= unlockTime) { throw new ForbiddenException("Your time has expired"); } } else if (request?.unlockTime == null && !request?.objection) { throw new ForbiddenException("Your time has expired"); } if (!request.lockFile && !request?.objection) { throw new ForbiddenException( "For submit reply you must lock the request", ); } // Validate total price cap (priced lines sum), when enabled. const priceCap = getClaimV2TotalPaymentCapToman(); if (priceCap !== null && reply.parts && reply.parts.length > 0) { let totalPrice = 0; for (const part of reply.parts) { if (part.totalPayment) { let partPrice: number; if (typeof part.totalPayment === "string") { // Handle Persian numbers and remove commas partPrice = this.parsePersianNumber(part.totalPayment); } else if (typeof part.totalPayment === "number") { partPrice = part.totalPayment; } else { this.logger.warn( `Invalid totalPayment type for part ${part.partId}: ${typeof part.totalPayment}`, ); partPrice = 0; } if (isNaN(partPrice)) { this.logger.warn( `Could not parse totalPayment for part ${part.partId}: ${part.totalPayment}`, ); partPrice = 0; } totalPrice += partPrice; } } if (totalPrice > priceCap) { throw new BadRequestException({ message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`, error: "PRICE_CAP_ERROR", code: "PRICE_CAP_ERROR", totalPrice: totalPrice, priceCap, }); } } // Validate daghi fields if (reply.parts && reply.parts.length > 0) { for (const part of reply.parts) { if (!part.daghi || !part.daghi.option) { throw new BadRequestException( `Daghi option is required for part ${part.partId}`, ); } // Validate based on option if (part.daghi.option === DaghiOption.RECYCLED_PARTS_VALUE) { if (!part.daghi.price) { throw new BadRequestException( `Price is required for part ${part.partId} when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`, ); } } else if (part.daghi.option === DaghiOption.DELIVER_DAMAGED_PART) { if (!part.daghi.branchId) { throw new BadRequestException( `Branch ID is required for part ${part.partId} when option is '${DaghiOption.DELIVER_DAMAGED_PART}'`, ); } // Validate branchId is a valid ObjectId if (!Types.ObjectId.isValid(part.daghi.branchId)) { throw new BadRequestException( `Invalid branch ID format for part ${part.partId}`, ); } } // For NO_VALUE and WITH_DAMAGED_PART_CALCULATION, no additional fields needed } } const isObjection = !!request.objection; const replyFieldKey = isObjection ? "damageExpertReplyFinal" : "damageExpertReply"; const needsFactorUpload = reply.parts?.some((part) => part.factorNeeded === true) ?? false; const nextStatus = needsFactorUpload ? ReqClaimStatus.PendingFactorUpload : ReqClaimStatus.CheckedRequest; const nextStep = needsFactorUpload ? ClaimStepsEnum.WaitingForFactorUpload : ClaimStepsEnum.WaitingForUserToReact; // Process parts: convert branchId string to ObjectId if present const processedParts = reply.parts.map((part) => ({ ...part, daghi: { option: part.daghi.option, ...(part.daghi.price && { price: part.daghi.price }), ...(part.daghi.branchId && { branchId: new Types.ObjectId(part.daghi.branchId), }), }, })); const expertProfileSnapshot = await this.snapshotDamageExpert(userId.sub); const replyPayload = { description: reply.description, parts: processedParts, submitTime: new Date(), actorDetail: { actorName: userId.fullName, actorId: userId.sub, }, ...(expertProfileSnapshot && { expertProfileSnapshot }), }; const updatePayload = { $set: { lockFile: false, claimStatus: nextStatus, currentStep: nextStep, [replyFieldKey]: replyPayload, }, $push: { actorsChecker: { [ReqClaimStatus.CheckedRequest]: request.actorLocked, Date: new Date(), }, steps: nextStep, }, }; await this.claimRequestManagementDbService.findAndUpdate( requestId, updatePayload, ); if (!isObjection) { await this.recordClaimExpertActivity({ expertId: String(userId.sub), tenantId: String(request.userClientKey ?? userId.clientKey ?? ""), claimId: String(requestId), eventType: ExpertFileActivityType.HANDLED, idempotencyKey: `claim:${requestId}:handled:${userId.sub}`, }); } return { requestId: request._id, claimStatus: nextStatus, }; } catch (error) { this.logger.error( `Error in submitReplyRequest for claim ${requestId}:`, error.stack, ); if (error instanceof HttpException) { throw error; } throw new HttpException( "An internal server error occurred.", HttpStatus.INTERNAL_SERVER_ERROR, ); } } async submitResend( requestId: string, body: ClaimSubmitResendDto, userId: any, ) { const request = await this.claimRequestManagementDbService.findOne(requestId); if (!request) throw new NotFoundException("request_not_found"); if (String(request?.actorLocked?.actorId) !== userId) throw new ForbiddenException("access_denied_please_lock"); if (request.unlockTime == null) throw new ForbiddenException("time_expired"); if (!request.lockFile) throw new ForbiddenException( "for submit reply you must lock the request", ); if (request.damageExpertResend) throw new ForbiddenException("request already has resend reply"); if (request.damageExpertReplyFinal) throw new ForbiddenException("request already has final reply"); const resendSnapshot = await this.snapshotDamageExpert(userId); await this.claimRequestManagementDbService.findAndUpdate(requestId, { lockFile: false, claimStatus: ReqClaimStatus.WaitingForUserToResend, damageExpertResend: { resendDescription: body.resendDescription, resendDocuments: body.resendDocuments, resendCarParts: body.resendCarParts, ...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }), }, $push: { actorsChecker: { [ReqClaimStatus.WaitingForUserToResend]: request.actorLocked, }, Date: new Date(), }, }); return request; } async streamServiceV2(requestId, query, res, header) { const request = await this.claimCaseDbService.findById(requestId); // const blameCase = await this.blameCaseDbService return request; // let videoPath: string = ""; // switch (query) { // case "accident": // videoPath = await this.getAccidentVideoPath( // String( // request?.blameFile?.firstPartyDetails?.firstPartyFile // ?.firstPartyVideoId, // ), // ); // break; // case "car-capture": // videoPath = await this.getCarCapture(requestId); // break; // default: // throw new NotFoundException("Invalid query type"); // } // if (!videoPath) throw new NotFoundException("video_not_found"); // const absolutePath = join(process.cwd(), videoPath.replace(/^\/+/, "")); // if (!existsSync(absolutePath)) { // throw new NotFoundException( // `Video file not found at path: ${absolutePath}`, // ); // } // const { size } = statSync(absolutePath); // const range = header.range; // if (range) { // const [startStr, endStr] = range.replace(/bytes=/, "").split("-"); // const start = parseInt(startStr, 10); // const end = endStr ? parseInt(endStr, 10) : size - 1; // const chunkSize = end - start + 1; // const file = createReadStream(absolutePath, { start, end }); // res.writeHead(206, { // "Content-Range": `bytes ${start}-${end}/${size}`, // "Accept-Ranges": "bytes", // "Content-Length": chunkSize, // "Content-Type": "video/mp4", // }); // file.pipe(res); // } else { // res.writeHead(200, { // "Content-Length": size, // "Content-Type": "video/mp4", // }); // createReadStream(absolutePath).pipe(res); // } } async streamService(requestId, query, res, header) { const request = await this.claimRequestManagementDbService.findOne(requestId); let videoPath: string = ""; switch (query) { case "accident": videoPath = await this.getAccidentVideoPath( String( request?.blameFile?.firstPartyDetails?.firstPartyFile ?.firstPartyVideoId, ), ); break; case "car-capture": videoPath = await this.getCarCapture(requestId); break; default: throw new NotFoundException("Invalid query type"); } if (!videoPath) throw new NotFoundException("video_not_found"); const absolutePath = join(process.cwd(), videoPath.replace(/^\/+/, "")); if (!existsSync(absolutePath)) { throw new NotFoundException( `Video file not found at path: ${absolutePath}`, ); } const { size } = statSync(absolutePath); const range = header.range; if (range) { const [startStr, endStr] = range.replace(/bytes=/, "").split("-"); const start = parseInt(startStr, 10); const end = endStr ? parseInt(endStr, 10) : size - 1; const chunkSize = end - start + 1; const file = createReadStream(absolutePath, { start, end }); res.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${size}`, "Accept-Ranges": "bytes", "Content-Length": chunkSize, "Content-Type": "video/mp4", }); file.pipe(res); } else { res.writeHead(200, { "Content-Length": size, "Content-Type": "video/mp4", }); createReadStream(absolutePath).pipe(res); } } async getVideoLink( requestId: string, query: "car-capture" | "accident", ): Promise<{ url: string }> { const request = await this.claimRequestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Claim request not found"); } let videoPath: string = ""; switch (query) { case "accident": videoPath = await this.getAccidentVideoPath( String( request?.blameFile?.firstPartyDetails?.firstPartyFile ?.firstPartyVideoId, ), ); break; case "car-capture": videoPath = await this.getCarCapture(requestId); break; default: throw new BadRequestException("Invalid query type specified"); } if (!videoPath) { throw new NotFoundException( "Video file not found for the specified type.", ); } // Use your existing helper to build the full, public URL const fileUrl = buildFileLink(videoPath); // Return the URL in a clean JSON object return { url: fileUrl }; } async voiceStream(id, res, headers) { // audio/mpeg const voice = await this.blameVoiceDbService.findOne(id); if (!voice) throw new NotFoundException("video_not_found"); const { size } = statSync(voice.path); const voicePath = voice.path; const VoiceRange = headers.range; if (VoiceRange) { const parts = VoiceRange?.replace(/bytes=/, "").split("-"); const end = parts[1] ? parseInt(parts[1], 16) : size - 1; const start = parseInt(parts[0], 10); const chunksize = end - start + 1; const file = createReadStream(voicePath, { start, end }); const header = { "Content-Range": `bytes ${start}-${end}/${size}`, "Accept-Ranges": "bytes", "Content-Length": chunksize, "Content-Type": "audio/mpeg", }; res.writeHead(206, header); file.pipe(res); } else { const head = { "Content-Length": size, "Content-Type": "audio/mpeg", }; res.writeHead(200, head); createReadStream(voicePath).pipe(res); } } async getVoiceLink(id: string): Promise<{ url: string }> { const voice = await this.blameVoiceDbService.findOne(id); if (!voice) { throw new NotFoundException("Voice file not found"); } const fileUrl = buildFileLink(voice.path); return { url: fileUrl }; } async getCarCapture(requestId): Promise { const video = await this.claimVideoCaptureDbService.findOne({ claimId: new Types.ObjectId(requestId), }); if (!video?.path) { throw new NotFoundException("Car capture video not found"); } return String(video.path); } async getAccidentVideoPath(requestId: string): Promise { const video = await this.blameVideoDbService.findOneByRequestId(requestId); if (!video?.path) { throw new NotFoundException("Accident video not found"); } return video.path; } async validateClaimFactors( claimId: string, decisions: { partId: string; status: FactorStatus; rejectionReason?: string; }[], expertId: string, ) { const claim = await this.claimRequestManagementDbService.findOne(claimId); if (!claim) { throw new NotFoundException("Claim not found"); } const finalReply = claim.damageExpertReplyFinal || claim.damageExpertReply; const replyFieldKey = claim.damageExpertReplyFinal ? "damageExpertReplyFinal" : "damageExpertReply"; if (!finalReply) { throw new BadRequestException("No expert reply found in this claim."); } for (const decision of decisions) { const partIndex = finalReply.parts.findIndex( (p) => p.partId === decision.partId, ); if (partIndex === -1) { this.logger.warn( `Part with ID ${decision.partId} not found in claim ${claimId}. Skipping.`, ); continue; } await this.claimRequestManagementDbService.findOneAndUpdate( { _id: new Types.ObjectId(claimId) }, { $set: { [`${replyFieldKey}.parts.${partIndex}.factorStatus`]: decision.status, [`${replyFieldKey}.parts.${partIndex}.rejectionReason`]: decision.rejectionReason || null, }, }, ); } const factorSnap = await this.snapshotDamageExpert(expertId); if (factorSnap && decisions.length > 0) { await this.claimRequestManagementDbService.findOneAndUpdate( { _id: new Types.ObjectId(claimId) }, { $set: { factorValidationExpertProfileSnapshot: factorSnap }, }, ); } const updatedClaim = await this.claimRequestManagementDbService.findOne(claimId); const updatedReply = updatedClaim.damageExpertReplyFinal || updatedClaim.damageExpertReply; const requiredFactors = updatedReply.parts.filter((p) => p.factorNeeded); const anyRejected = requiredFactors.some( (p) => p.factorStatus === FactorStatus.REJECTED, ); const anyStillPending = requiredFactors.some( (p) => p.factorStatus === FactorStatus.PENDING || !p.factorStatus, ); let newStatus: ReqClaimStatus | null = null; let responseMessage = "Factor validation complete."; if (anyRejected) { // At least one factor is rejected → stay in factor flow and ask user to re-upload newStatus = ReqClaimStatus.FactorRejected; responseMessage = "Factors reviewed. Some factors were rejected, awaiting user resubmission."; // TODO: Send an SMS or notification to the user here. } else if (anyStillPending) { // Some factors still pending (e.g. not validated in this batch) responseMessage = "Some factors were approved, but others are still pending validation."; } else { // All required factors are approved: // Move claim into a state where the user can review the final decision and object if needed. newStatus = ReqClaimStatus.CheckedRequest; responseMessage = "All required factors have been approved. The claim is now ready for user review and possible objection."; } if (newStatus) { const update: any = { $set: { claimStatus: newStatus }, }; // When factors are fully approved, also move the step to WaitingForUserToReact, // just like the non-factor flow in submitReplyRequest. if (newStatus === ReqClaimStatus.CheckedRequest) { update.$set.currentStep = ClaimStepsEnum.WaitingForUserToReact; update.$set.nextStep = ClaimStepsEnum.WaitingForUserToReact; update.$push = { steps: ClaimStepsEnum.WaitingForUserToReact, }; } await this.claimRequestManagementDbService.findByIdAndUpdate( claimId, update, ); } return { message: responseMessage, newStatus: newStatus || updatedClaim.claimStatus, }; } /** * Close the claim after factor validation without a second insurer-review signature. * Owner already uploaded factor files; mixed replies already have priced-line acceptance on file. */ private async completeClaimCaseAfterFactorValidationV2( claimRequestId: string, claimForTenant: any, actor: { sub: string; fullName?: string; clientKey?: string }, historyType: string, historyActor: { actorId: Types.ObjectId; actorName?: string; actorType: string; }, metadata: Record, ): Promise { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.COMPLETED, claimStatus: ClaimStatus.APPROVED, "workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED, "workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, }, $push: { "workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW, history: { type: historyType, actor: historyActor, timestamp: new Date(), metadata, }, }, }); await this.recordClaimExpertActivity({ expertId: String(actor.sub), tenantId: this.claimActivityTenantId(claimForTenant, actor), claimId: String(claimRequestId), eventType: ExpertFileActivityType.HANDLED, idempotencyKey: `claim:${claimRequestId}:factor_validation:${historyType}:${actor.sub}`, }); } /** * V2 (ClaimCase): validate user-uploaded repair factors on the active expert reply. * Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION. * — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature). * — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later). * When enabled by env, total of all repair lines must be ≤ the claim v2 total payment cap. * Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`. */ async validateClaimFactorsV2( claimRequestId: string, body: FactorValidationV2Dto, actor: { sub: string; fullName?: string; clientKey?: string }, ) { const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } assertClaimCaseForExpertActor(claim, actor); const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub); if (!claimIsAwaitingExpertFactorValidationV2(claim)) { throw new BadRequestException( "Claim is not awaiting expert factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).", ); } const replyField = claim.evaluation?.damageExpertReplyFinal ? "damageExpertReplyFinal" : "damageExpertReply"; const finalReply = replyField === "damageExpertReplyFinal" ? claim.evaluation?.damageExpertReplyFinal : claim.evaluation?.damageExpertReply; if (!finalReply?.parts?.length) { throw new BadRequestException("No expert reply found in this claim."); } const requiredFactors = finalReply.parts.filter((p) => p.factorNeeded); if (requiredFactors.length === 0) { throw new BadRequestException("No parts require factor validation."); } if (!requiredFactors.every((p) => !!p.factorLink)) { throw new BadRequestException( "Not all required factors have been uploaded yet.", ); } const $set: Record = {}; for (const decision of body.decisions) { const partIndex = finalReply.parts.findIndex((p) => sameCatalogPartId(p.partId, decision.partId), ); if (partIndex === -1) { this.logger.warn( `Part ${decision.partId} not found in claim ${claimRequestId}, skipping.`, ); continue; } const part = finalReply.parts[partIndex]; if (!part.factorNeeded) { this.logger.warn( `Part ${decision.partId} is not factor-needed, skipping.`, ); continue; } const base = `evaluation.${replyField}.parts.${partIndex}`; $set[`${base}.factorStatus`] = decision.status; $set[`${base}.rejectionReason`] = decision.rejectionReason ?? null; if (decision.price !== undefined) { $set[`${base}.price`] = decision.price; } if (decision.salary !== undefined) { $set[`${base}.salary`] = decision.salary; } if (decision.totalPayment !== undefined) { $set[`${base}.totalPayment`] = decision.totalPayment; } if ( decision.status === FactorStatus.APPROVED || decision.status === FactorStatus.REJECTED ) { if (!this.expertFactorValidationDecisionHasLinePricing(decision)) { throw new BadRequestException( `Part ${decision.partId}: ${decision.status === FactorStatus.APPROVED ? "approved" : "rejected"} factor lines require expert totalPayment (or both price and salary).`, ); } } } if (Object.keys($set).length === 0) { throw new BadRequestException("No valid factor decisions to apply."); } if (factorValidationSnapshot) { $set["evaluation.factorValidationExpertProfileSnapshot"] = factorValidationSnapshot; } await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set, }); await this.recordClaimExpertActivity({ expertId: String(actor.sub), tenantId: this.claimActivityTenantId(claim, actor), claimId: String(claimRequestId), eventType: ExpertFileActivityType.CHECKED, idempotencyKey: `claim:${claimRequestId}:checked:${actor.sub}`, }); const updatedClaim = await this.claimCaseDbService.findById(claimRequestId); const updatedReply = replyField === "damageExpertReplyFinal" ? updatedClaim!.evaluation!.damageExpertReplyFinal! : updatedClaim!.evaluation!.damageExpertReply!; const reqParts = updatedReply.parts.filter((p) => p.factorNeeded); const anyRejected = reqParts.some( (p) => p.factorStatus === FactorStatus.REJECTED, ); const anyStillPending = reqParts.some( (p) => !p.factorStatus || p.factorStatus === FactorStatus.PENDING, ); const baseMessage = "Factor validation updated."; if (anyStillPending) { return { message: `${baseMessage} Some factors are still pending review.`, claimRequestId, claimStatus: updatedClaim!.claimStatus, caseStatus: updatedClaim!.status, pendingFactorValidation: true, }; } const priceCap = getClaimV2TotalPaymentCapToman(); if (priceCap !== null) { let totalPrice = 0; for (const part of updatedReply.parts || []) { const line = this.claimReplyPartLineTotalForCap(part); if (isNaN(line)) { throw new BadRequestException({ message: `Part ${String(part.partId ?? "")}: invalid amount for price total — use totalPayment or price and salary.`, error: "PRICE_CAP_ERROR", }); } totalPrice += line; } if (totalPrice > priceCap) { throw new BadRequestException({ message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`, error: "PRICE_CAP_ERROR", totalPrice, priceCap, }); } } const historyActor = { actorId: new Types.ObjectId(actor.sub), actorName: actor.fullName, actorType: "damage_expert", }; if (anyRejected) { await this.completeClaimCaseAfterFactorValidationV2( claimRequestId, claim, actor, "FACTORS_REJECTED_REPRICED_AUTO_COMPLETED", historyActor, { replyField }, ); const fanavaran = await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted( claimRequestId, ); return { message: this.appendFanavaranAutoSubmitToMessage( "Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).", fanavaran, ), claimRequestId, claimStatus: ClaimStatus.APPROVED, caseStatus: ClaimCaseStatus.COMPLETED, outcome: "REJECTED_REPRICED_AUTO_COMPLETED", fanavaran, }; } await this.completeClaimCaseAfterFactorValidationV2( claimRequestId, claim, actor, "FACTORS_VALIDATED_ALL_APPROVED_AUTO_COMPLETED", historyActor, { replyField }, ); const fanavaran = await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted( claimRequestId, ); return { message: this.appendFanavaranAutoSubmitToMessage( "All factors were approved by the expert. The claim is completed without an additional owner signature.", fanavaran, ), claimRequestId, claimStatus: ClaimStatus.APPROVED, caseStatus: ClaimCaseStatus.COMPLETED, outcome: "ALL_APPROVED_AUTO_COMPLETED", fanavaran, }; } async inPersonVisit(requestId: string, actorDetail: any) { const request = await this.claimRequestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Blame not found"); } const visitSnapshot = await this.snapshotDamageExpert(actorDetail.sub); const updated = await this.claimRequestManagementDbService.findAndUpdate( requestId, { claimStatus: ReqClaimStatus.InPersonVisit, ...(visitSnapshot && { damageExpertInPersonVisitProfileSnapshot: visitSnapshot, }), }, ); await this.recordClaimExpertActivity({ expertId: String(actorDetail.sub), tenantId: String(request.userClientKey ?? actorDetail.clientKey ?? ""), claimId: String(requestId), eventType: ExpertFileActivityType.HANDLED, idempotencyKey: `claim:${requestId}:handled:${actorDetail.sub}`, }); return updated; } async retrieveInsuranceBranches(insuranceId: string) { return await this.branchDbService.findAll(insuranceId); } /** * V2: branches for the damage expert's insurer tenant (JWT `clientKey`). * Same data as v1 `GET expert-claim/branches/:insuranceId` without passing client id. */ async retrieveInsuranceBranchesV2(actor: { clientKey?: string }) { const clientKey = requireActorClientKey(actor); return await this.branchDbService.findAll(clientKey); } private globalErrHandler(error) { this.logger.error(error.response?.responseCode, error.response); throw new HttpException(error.response, error.claimStatus); } /** * Assign the current damage expert to a claim for review (V2). * Free cases are locked atomically; already-assigned-to-self is idempotent. */ async assignClaimForReviewV2( claimRequestId: string, actor: { sub: string; fullName?: string; clientKey?: string; role?: string }, ): Promise { if ((actor as any).role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor); await this.expireClaimWorkflowLockV2IfStale(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } assertClaimCaseForExpertActor(claim, actor); const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim); const isDamageReviewLock = claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; if (!isDamageReviewLock && !isFactorValidationLock) { throw new BadRequestException({ success: false, status: "unavailable" satisfies ExpertFileAssignStatus, message: "Request is not available for expert review", }); } const expertOid = new Types.ObjectId(actor.sub); const reviewAssigneeId = resolvePersistentReviewAssigneeId( claim.workflow, claim.history, CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE, ); if (reviewAssigneeId && reviewAssigneeId !== actor.sub) { throw new ConflictException( buildExpertAssignConflictForOtherReviewer( reviewAssigneeId, claim.workflow, ), ); } const lockedById = String(claim.workflow?.lockedBy?.actorId ?? ""); const lockEnforced = claim.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(claim); if (lockEnforced && lockedById && lockedById !== actor.sub) { throw new ConflictException( buildExpertAssignConflictForOtherReviewer(lockedById, claim.workflow), ); } if (lockEnforced && lockedById === actor.sub) { return { success: true, status: "already_assigned_to_you", message: "You already started processing this request", assignedAt: claim.workflow?.lockedAt ? new Date(claim.workflow.lockedAt as Date).toISOString() : undefined, }; } const now = new Date(); const lockSnapshot = await this.snapshotDamageExpert(actor.sub); const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs); const lockedByPayload = { actorId: expertOid, actorName: actor.fullName, actorRole: "damage_expert" as const, ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), }; const baseLockUpdate: Record = { "workflow.locked": true, "workflow.lockedAt": now, "workflow.expiredAt": expiredAt, "workflow.lockedBy": lockedByPayload, "workflow.preLockQueueSnapshot": { claimStatus: claim.claimStatus, currentStep: this.normalizeClaimWorkflowStepForSnapshot( claim.workflow?.currentStep, ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, ), ...(claim.workflow?.nextStep != null ? { nextStep: this.normalizeClaimWorkflowStepForSnapshot( claim.workflow.nextStep, ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, ), } : {}), }, $push: { history: { type: "CLAIM_ASSIGNED", actor: { actorId: expertOid, actorName: actor.fullName, actorType: "damage_expert", }, timestamp: now, metadata: { note: isFactorValidationLock ? "Expert assigned for factor validation review" : "Expert assigned via review assign endpoint", ...(isFactorValidationLock ? { phase: "factorValidation" } : {}), }, }, }, }; if (!claim.workflow?.assignedForReviewBy) { baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload; } const lockUpdate: Record = isFactorValidationLock ? baseLockUpdate : { ...baseLockUpdate, status: ClaimCaseStatus.EXPERT_REVIEWING, claimStatus: ClaimStatus.UNDER_REVIEW, "workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, }; const assignFilter: Record = { _id: new Types.ObjectId(claimRequestId), $and: [ { $or: [ { "workflow.locked": { $ne: true } }, { "workflow.locked": false }, { "workflow.expiredAt": { $lte: now } }, { "workflow.lockedBy.actorId": expertOid }, ], }, { $or: [ { "workflow.assignedForReviewBy": { $exists: false } }, { "workflow.assignedForReviewBy": null }, { "workflow.assignedForReviewBy.actorId": expertOid }, ], }, ], }; if (isFactorValidationLock) { assignFilter.claimStatus = ClaimStatus.UNDER_REVIEW; assignFilter["workflow.currentStep"] = ClaimWorkflowStep.EXPERT_COST_EVALUATION; assignFilter.status = { $in: [ ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS, ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, ], }; } else { assignFilter.status = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; } const updated = await this.claimCaseDbService.findOneAndUpdate( assignFilter as any, lockUpdate, { new: true }, ); if (!updated) { await this.expireClaimWorkflowLockV2IfStale(claimRequestId); const latest = await this.claimCaseDbService.findById(claimRequestId); const otherAssigneeId = resolvePersistentReviewAssigneeId( latest?.workflow, latest?.history, CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE, ); if (otherAssigneeId && otherAssigneeId !== actor.sub) { throw new ConflictException( buildExpertAssignConflictForOtherReviewer( otherAssigneeId, latest?.workflow, ), ); } const otherId = String(latest?.workflow?.lockedBy?.actorId ?? ""); if ( latest?.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(latest) && otherId && otherId !== actor.sub ) { throw new ConflictException( buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow), ); } throw new BadRequestException({ success: false, status: "unavailable" satisfies ExpertFileAssignStatus, message: "Request could not be assigned", }); } await this.recordClaimExpertActivity({ expertId: String(actor.sub), tenantId: this.claimActivityTenantId(updated, actor), claimId: String(claimRequestId), eventType: ExpertFileActivityType.CHECKED, idempotencyKey: `claim:${claimRequestId}:checked:v2:${actor.sub}`, }); const ownerPhone = await this.resolveClaimOwnerPhone(updated); if (ownerPhone) { const expertLastName = actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice( { receptor: ownerPhone, fileKind: "claim", publicId: updated.publicId, expertLastName, }, ); } this.logger.log( `Claim ${claimRequestId} assigned to expert ${actor.sub} at ${now.toISOString()}`, ); return { success: true, status: "assigned", assignedAt: now.toISOString(), }; } /** * V2: Lock a claim request for expert review. * * Validations: * - Claim must exist * - Status must be either: * • `WAITING_FOR_DAMAGE_EXPERT` (initial damage review queue), OR * • the factor-validation queue (`EXPERT_VALIDATING_REPAIR_FACTORS`, * or legacy `WAITING_FOR_INSURER_APPROVAL`, with * `claimStatus=UNDER_REVIEW` + `workflow.currentStep=EXPERT_COST_EVALUATION`). * - Must not already be locked by another expert. * * On success: * - Always sets `workflow.locked=true`, `workflow.lockedBy=actor`, * `workflow.lockedAt/expiredAt`, and snapshots pre-lock queue state. * - For the **damage review** path: status → `EXPERT_REVIEWING`, * `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`. * - For the **factor-validation** path: leaves `status`, `claimStatus`, and * `workflow.currentStep` untouched so the claim stays in the factor-validation * queue (so `expireClaimWorkflowLockV2IfStale` and the queue endpoint keep * treating it correctly when the lock expires). * * Delegates to {@link assignClaimForReviewV2}; maps conflict to BadRequest for legacy clients. */ async lockClaimRequestV2(claimRequestId: string, actor: any) { try { const result = await this.assignClaimForReviewV2(claimRequestId, actor); const response: Record = { claimRequestId, locked: true, message: result.message, }; if (result.status === "assigned") { const claim = await this.claimCaseDbService.findById(claimRequestId); if (claim?.status === ClaimCaseStatus.EXPERT_REVIEWING) { response.status = ClaimCaseStatus.EXPERT_REVIEWING; response.claimStatus = ClaimStatus.UNDER_REVIEW; response.currentStep = ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; } } return response; } catch (error) { if (error instanceof ConflictException) { const body = error.getResponse(); throw new BadRequestException(body); } if (error instanceof HttpException) throw error; this.logger.error( "lockClaimRequestV2 failed", claimRequestId, error instanceof Error ? error.stack : String(error), ); throw new HttpException( error instanceof Error ? error.message : "Failed to lock claim", HttpStatus.INTERNAL_SERVER_ERROR, ); } } async submitResendDocsV2( claimRequestId: string, reply: ClaimSubmitResendV2Dto, actor: any, ) { const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } assertClaimCaseForExpertActor(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`, ); } if (!claim.workflow?.locked) { throw new ForbiddenException( "You must lock the claim before submitting a resend request", ); } if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) { throw new ForbiddenException("This claim is locked by another expert"); } // Block duplicate resend while a prior request is still unfinished (stale EXPERT_REVIEWING + pending resend). // After a successful submit, status becomes WAITING_FOR_USER_RESEND so this endpoint is not callable until the owner finishes. const existingResend = claim.evaluation?.damageExpertResend; if ( existingResend && !existingResend.fulfilledAt && resendRequestHasPayload(existingResend) ) { throw new ConflictException( "A resend request is still open for this claim. Wait until the owner completes uploads or contact support if the case is stuck.", ); } if (existingResend?.fulfilledAt) { throw new BadRequestException( "The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.", ); } const allowedDocTypes = new Set( Object.values(ClaimRequiredDocumentType), ); const rawDocs = reply.resendDocuments ?? []; const docs: ClaimRequiredDocumentType[] = []; for (const x of rawDocs) { const s = String(x).trim(); if (!allowedDocTypes.has(s)) { throw new BadRequestException( `Invalid resendDocuments value: "${String(x)}". Must be a ClaimRequiredDocumentType string (e.g. national_card, damaged_driving_license_front).`, ); } docs.push(s as ClaimRequiredDocumentType); } const uniqueDocs = [...new Set(docs)]; const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const normalizedParts = normalizeResendCarPartsForStorage( reply.resendCarParts, carType, claim.damage?.selectedParts, ); const desc = String(reply.resendDescription ?? "").trim(); if (!desc && uniqueDocs.length === 0 && normalizedParts.length === 0) { throw new BadRequestException( "Provide resendDescription and/or resendDocuments and/or resendCarParts.", ); } const resendSnapshot = await this.snapshotDamageExpert(actor.sub); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.WAITING_FOR_USER_RESEND, claimStatus: ClaimStatus.NEEDS_REVISION, "workflow.locked": false, "workflow.currentStep": ClaimWorkflowStep.USER_EXPERT_RESEND, "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, "evaluation.damageExpertResend": { resendDescription: desc || undefined, resendDocuments: uniqueDocs, resendCarParts: normalizedParts, requestedByExpertId: new Types.ObjectId(actor.sub), ...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }), }, }, $unset: { "workflow.lockedAt": "", "workflow.expiredAt": "", "workflow.lockedBy": "", "workflow.preLockQueueSnapshot": "", }, $push: { history: { type: "EXPERT_RESEND_REQUESTED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: actor.fullName, actorType: "damage_expert", }, timestamp: new Date(), metadata: { documentCount: uniqueDocs.length, carPartCount: normalizedParts.length, }, }, }, }); const ownerPhoneResend = await this.resolveClaimOwnerPhone(claim); if (ownerPhoneResend) { await this.smsOrchestrationService.sendResendDocumentsNotice({ receptor: ownerPhoneResend, fileKind: "claim", publicId: claim.publicId, link: this.smsOrchestrationService.buildClaimLink(String(claim._id)), }); } await this.recordClaimExpertActivity({ expertId: String(actor.sub), tenantId: this.claimActivityTenantId(claim, actor), claimId: String(claimRequestId), eventType: ExpertFileActivityType.HANDLED, idempotencyKey: `claim:${claimRequestId}:handled:resend:${actor.sub}`, }); return { claimRequestId, status: ClaimCaseStatus.WAITING_FOR_USER_RESEND, claimStatus: ClaimStatus.NEEDS_REVISION, currentStep: ClaimWorkflowStep.USER_EXPERT_RESEND, nextStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, message: "Resend request recorded. The owner must upload the requested documents and/or part photos (or confirm if only instructions were given). The claim will return to the damage expert queue when complete.", }; } /** * V2: Submit damage expert reply for a claim. * * Validations: * - Claim must exist * - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub) * - Must be in EXPERT_REVIEWING status * - Total payment across all parts must not exceed 53,000,000 (same cap as factor validation totals) * - Each part must include `daghi` (option + conditional price/branchId) like V1 * * On success: * - Stores reply; clears owner signature fields (`ownerInsurerApproval`, `ownerPricedPartsApproval`) * - Unlocks the workflow * - Pricing-only (`factorNeeded=false` everywhere): INSURER_REVIEW_AWAITING_OWNER_SIGN, APPROVED, INSURER_REVIEW → owner final sign/reject. * - All lines `factorNeeded`: NEEDS_REVISION, OWNER_UPLOAD_FACTOR_DOCUMENTS → uploads → UNDER_REVIEW, EXPERT_COST_EVALUATION (validate factors). * - Mixed priced + factor: NEEDS_REVISION, INSURER_REVIEW with next OWNER_UPLOAD_FACTOR_DOCUMENTS → owner signs priced lines first → upload factors → expert validates → final sign. */ async submitExpertReplyV2( claimRequestId: string, reply: import("./dto/expert-claim-v2.dto").SubmitExpertReplyV2Dto, actor: any, ) { if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } assertClaimCaseForExpertActor(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim is not in a reviewable state. Current status: ${claim.status}`, ); } if (!claim.workflow?.locked) { throw new ForbiddenException( "You must lock the claim before submitting a reply", ); } if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) { throw new ForbiddenException("This claim is locked by another expert"); } // Price cap validation, when enabled. const priceCap = getClaimV2TotalPaymentCapToman(); if (priceCap !== null) { let totalPrice = 0; for (const part of reply.parts || []) { const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0")); if (!isNaN(parsed)) totalPrice += parsed; } if (totalPrice > priceCap) { throw new BadRequestException({ message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`, error: "PRICE_CAP_ERROR", totalPrice, priceCap, }); } } const carTypeSubmit = claim.vehicle?.carType as | ClaimVehicleTypeV2 | undefined; const daghiNormalized = reply.parts?.length > 0 ? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts) : []; const ownerSelectedParts = normalizeDamageSelectedParts( (claim as any)?.damage?.selectedParts, carTypeSubmit, (claim as any)?.damage?.selectedOuterParts, ); const seenCatalogIds = new Set(); const expertAddedParts: DamageSelectedPartV2[] = []; const processedParts = daghiNormalized.map((p) => { const selected = resolvePartForExpertReply( p.partId, ownerSelectedParts, carTypeSubmit, ); if (!selected) { throw new BadRequestException( `Unknown partId "${p.partId}". Use numeric catalog id from claim detail damagedParts.`, ); } let carPartDamage: Record; try { carPartDamage = normalizeCarPartDamageForExpertReply( { id: selected.id, name: selected.name, side: selected.side, label_fa: selected.label_fa, ...(selected.catalogKey ? { catalogKey: selected.catalogKey } : {}), }, carTypeSubmit, ); } catch (err) { throw new BadRequestException( `Invalid part ${p.partId}: ${ err instanceof Error ? err.message : String(err) }`, ); } const catalogId = catalogPartIdFromCarPartDamage(carPartDamage); if (catalogId == null) { throw new BadRequestException( `Invalid part ${p.partId}: a numeric catalog id is required.`, ); } if (seenCatalogIds.has(catalogId)) { throw new BadRequestException( `Duplicate part submitted in expert reply: ${catalogId}.`, ); } seenCatalogIds.add(catalogId); const wasOnClaim = ownerSelectedParts.some( (op) => partLookupKey(op) === partLookupKey(selected), ); if (!wasOnClaim) { expertAddedParts.push(selected); } return { ...p, partId: catalogId, carPartDamage, }; }); const { mixedFactorAndPrice, allFactorLines } = classifyV2ExpertPricingParts(processedParts); const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false; const objectionSubmitted = !!claim.evaluation?.objection?.submittedAt; const hasFinalReply = !!claim.evaluation?.damageExpertReplyFinal; if (objectionSubmitted && hasFinalReply) { throw new ConflictException( "A final expert reply after objection already exists for this claim.", ); } const isFinalReplyAfterObjection = objectionSubmitted && !hasFinalReply; const replyField = isFinalReplyAfterObjection ? "damageExpertReplyFinal" : "damageExpertReply"; const completedStep = isFinalReplyAfterObjection ? ClaimWorkflowStep.EXPERT_FINAL_REPLY : ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; const expertProfileSnapshot = await this.snapshotDamageExpert(actor.sub); const replyPayload = { description: reply.description, parts: processedParts, submittedAt: new Date(), actorDetail: { actorId: actor.sub, actorName: actor.fullName, }, ...(expertProfileSnapshot && { expertProfileSnapshot }), }; let currentStep = ClaimWorkflowStep.INSURER_REVIEW; let nextWorkflowStep = ClaimWorkflowStep.CLAIM_COMPLETED; const nextCaseStatus = claimCaseStatusAfterExpertReplyV2(processedParts); let nextClaimStatus = ClaimStatus.APPROVED; if (needsFactorUpload) { nextClaimStatus = ClaimStatus.NEEDS_REVISION; if (mixedFactorAndPrice) { currentStep = ClaimWorkflowStep.INSURER_REVIEW; nextWorkflowStep = ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS; } else { currentStep = ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS; nextWorkflowStep = ClaimWorkflowStep.EXPERT_COST_EVALUATION; } } const mergedSelectedParts = [...ownerSelectedParts]; if (expertAddedParts.length > 0) { const mergedKeys = new Set( mergedSelectedParts.map((sp) => partLookupKey(sp)), ); for (const sp of expertAddedParts) { const k = partLookupKey(sp); if (!mergedKeys.has(k)) { mergedKeys.add(k); mergedSelectedParts.push(sp); } } } const updatePayload: Record = { status: nextCaseStatus, claimStatus: nextClaimStatus, ...(expertAddedParts.length > 0 ? { "damage.selectedParts": mergedSelectedParts } : {}), "workflow.locked": false, $unset: { "workflow.lockedAt": "", "workflow.expiredAt": "", "workflow.lockedBy": "", "workflow.preLockQueueSnapshot": "", "evaluation.ownerInsurerApproval": "", "evaluation.ownerPricedPartsApproval": "", }, "workflow.currentStep": currentStep, "workflow.nextStep": needsFactorUpload ? nextWorkflowStep : ClaimWorkflowStep.CLAIM_COMPLETED, [`evaluation.${replyField}`]: replyPayload, $push: { "workflow.completedSteps": completedStep, history: { type: isFinalReplyAfterObjection ? "EXPERT_FINAL_REPLY_SUBMITTED" : "EXPERT_REPLY_SUBMITTED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: actor.fullName, actorType: "damage_expert", }, timestamp: new Date(), metadata: { factorNeeded: needsFactorUpload, mixedFactorAndPrice, allFactorLines, partsCount: processedParts.length, isFinalReplyAfterObjection, replyField, }, }, }, }; await this.claimCaseDbService.findByIdAndUpdate( claimRequestId, updatePayload, ); await this.recordClaimExpertActivity({ expertId: String(actor.sub), tenantId: this.claimActivityTenantId(claim, actor), claimId: String(claimRequestId), eventType: ExpertFileActivityType.HANDLED, idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`, }); const ownerPhoneNotify = await this.resolveClaimOwnerPhone(claim); if (ownerPhoneNotify && !needsFactorUpload) { const expertLastName = actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; await this.smsOrchestrationService.sendSignatureReviewNotice({ receptor: ownerPhoneNotify, fileKind: "claim", publicId: claim.publicId, expertLastName, link: this.smsOrchestrationService.buildClaimLink(String(claim._id)), }); } return { claimRequestId, status: nextCaseStatus, claimStatus: nextClaimStatus, currentStep, workflowNextStep: needsFactorUpload ? nextWorkflowStep : ClaimWorkflowStep.CLAIM_COMPLETED, factorNeeded: needsFactorUpload, mixedPricingAndFactors: mixedFactorAndPrice, allPartsFactorNeeded: !!needsFactorUpload && !!allFactorLines, isFinalReplyAfterObjection, }; } /** * V2: Expert marks claim for in-person visit. * * This is used when the expert decides the user must attend in person * before a final assessment can be made. * * Validations: * - Claim must exist * - Must be locked by this expert * - Status must be EXPERT_REVIEWING * * On success: * - Sets status = EXPERT_REVIEWING (remains), claimStatus = NEEDS_REVISION * - Sets evaluation.visitLocation (optional note) * - Records history event IN_PERSON_VISIT_REQUESTED * - Unlocks the workflow so user can act */ async requestInPersonVisitV2( claimRequestId: string, actor: any, note?: string, ) { requireActorClientKey(actor); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } assertClaimCaseForTenant(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`, ); } if (!claim.workflow?.locked) { throw new ForbiddenException( "You must lock the claim before requesting an in-person visit", ); } if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) { throw new ForbiddenException("This claim is locked by another expert"); } const visitSnapshot = await this.snapshotDamageExpert(actor.sub); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { claimStatus: ClaimStatus.NEEDS_REVISION, "workflow.locked": false, $unset: { "workflow.lockedAt": "", "workflow.expiredAt": "", "workflow.lockedBy": "", "workflow.preLockQueueSnapshot": "", }, ...(note ? { "evaluation.visitLocation": note } : {}), ...(visitSnapshot && { "evaluation.inPersonVisitExpertProfileSnapshot": visitSnapshot, }), $push: { history: { type: "IN_PERSON_VISIT_REQUESTED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: actor.fullName, actorType: "damage_expert", }, timestamp: new Date(), metadata: { note: note || "Expert requested in-person visit", }, }, }, }); await this.recordClaimExpertActivity({ expertId: String(actor.sub), tenantId: this.claimActivityTenantId(claim, actor), claimId: String(claimRequestId), eventType: ExpertFileActivityType.HANDLED, idempotencyKey: `claim:${claimRequestId}:handled:inperson:${actor.sub}`, }); return { claimRequestId, status: claim.status, claimStatus: ClaimStatus.NEEDS_REVISION, message: "In-person visit requested. User will be notified.", }; } /** * V2: Count claim cases in this damage expert’s portfolio, grouped for dashboard. * Includes files the expert locked, replied on, or has CHECKED/HANDLED in expertFileActivities. * Does not include the open tenant queue (`WAITING_FOR_DAMAGE_EXPERT`) unless this expert touched the file. */ async getStatusReportBucketsV2(actor: any): Promise> { if (actor.role === RoleEnum.FIELD_EXPERT) { const rows = (await this.claimCaseDbService.find( { initiatedByFieldExpertId: new Types.ObjectId(actor.sub) }, { lean: true }, )) as any[]; const buckets = initialClaimExpertReportBuckets(); for (const doc of rows) { const key = claimCaseStatusToReportBucket(String(doc.status ?? "")); buckets.all++; buckets[key] = (buckets[key] ?? 0) + 1; } buckets["PENDING_ON_USER"] = (buckets[ClaimCaseStatus.WAITING_FOR_USER_RESEND] ?? 0) + (buckets[ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL] ?? 0) + (buckets[ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN] ?? 0) + (buckets[ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING] ?? 0) + (buckets[ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING] ?? 0); buckets["CHECK_NEEDED"] = buckets["PENDING_ON_USER"] + (buckets[ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT] ?? 0); return buckets; } const clientKey = requireActorClientKey(actor); const expertId = String(actor.sub); const expertOid = new Types.ObjectId(expertId); const activityEvents = await this.expertFileActivityDbService.findByExpert( expertId, ExpertFileKind.CLAIM, ); const activityFileIds = expertPortfolioFileIdsFromActivityEvents(activityEvents); const orClauses: Record[] = [ { "workflow.lockedBy.actorId": expertOid }, { "evaluation.damageExpertReply.actorDetail.actorId": expertOid }, { "evaluation.damageExpertReplyFinal.actorDetail.actorId": expertOid }, { "evaluation.damageExpertResend.requestedByExpertId": expertOid }, ]; const activityOids = objectIdsFromStringSet(activityFileIds); if (activityOids.length > 0) { orClauses.push({ _id: { $in: activityOids } }); } const rows = orClauses.length === 0 ? [] : await this.claimCaseDbService.find( { $or: orClauses }, { lean: true }, ); const buckets = initialClaimExpertReportBuckets(); const seen = new Set(); for (const doc of rows as Record[]) { const id = String(doc._id ?? ""); if (seen.has(id)) continue; if ( !claimDocInExpertPortfolio(doc, expertId, activityFileIds, clientKey) ) { continue; } seen.add(id); const key = claimCaseStatusToReportBucket(String(doc.status ?? "")); buckets.all++; buckets[key] = (buckets[key] ?? 0) + 1; } // Add new calculated fields for front-end buckets["PENDING_ON_USER"] = buckets[ClaimCaseStatus.WAITING_FOR_USER_RESEND] + buckets[ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL] + buckets[ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN] + buckets[ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING] + buckets[ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING]; buckets["CHECK_NEEDED"] = buckets["PENDING_ON_USER"] + buckets[ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT]; return buckets; } /** * V2: Get claim list for damage expert * * Returns claims that are (then filtered to the actor's insurer via `claimCaseTouchesClient`): * 1. WAITING_FOR_DAMAGE_EXPERT and not locked (open queue) * 2. Locked by this expert (in-progress) * 3. EXPERT_VALIDATING_REPAIR_FACTORS (or legacy WAITING_FOR_INSURER_APPROVAL) + UNDER_REVIEW at EXPERT_COST_EVALUATION (factor validation queue) */ async getClaimListV2( actor: any, query: ListQueryV2Dto = {}, ): Promise { if (actor.role === RoleEnum.FIELD_EXPERT) { return this.getFieldExpertClaimListV2(actor, query); } requireActorClientKey(actor); const actorId = actor.sub; const clientKey = actor.clientKey as string; await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor); const claims = await this.claimCaseDbService.find({ $or: [ // Available: waiting, not locked { status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, "workflow.locked": { $ne: true }, // No persistent assignee (or expired without decision) $or: [ { "workflow.assignedForReviewBy": { $exists: false } }, { "workflow.assignedForReviewBy": null }, ], }, // This expert's locked/in-progress claims { "workflow.locked": true, "workflow.lockedBy.actorId": new Types.ObjectId(actorId), }, // This expert's persistently assigned claims (decided or resend/objection follow-up) { "workflow.assignedForReviewBy.actorId": new Types.ObjectId(actorId), }, // Factor validation queue (open to all tenant experts — no lock needed) { $or: [ { status: ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS, claimStatus: ClaimStatus.UNDER_REVIEW, "workflow.currentStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION, }, { status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, claimStatus: ClaimStatus.UNDER_REVIEW, "workflow.currentStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION, }, ], }, // WAITING_FOR_USER_RESEND assigned to this expert { status: ClaimCaseStatus.WAITING_FOR_USER_RESEND, "workflow.assignedForReviewBy.actorId": new Types.ObjectId(actorId), }, // EXPERT_REVIEWING with stale lock (reconciliation will fix status, show in meantime) { status: ClaimCaseStatus.EXPERT_REVIEWING, "workflow.locked": { $ne: true }, }, ], }); const filtered = (claims as any[]).filter((c) => claimCaseTouchesClient(c, clientKey), ); // Reconcile stale locks — if no decision was made, also clear assignedForReviewBy const staleLockToReconcile = filtered.filter( (c) => c.workflow?.locked && !this.isClaimV2WorkflowLockCurrentlyEnforced(c), ); const touchedIds = ( await Promise.all( staleLockToReconcile.map(async (c) => { const updated = await this.expireClaimWorkflowLockV2IfStale( String(c._id), ); return updated ? String(c._id) : null; }), ) ).filter((id): id is string => id != null); if (touchedIds.length > 0) { const refreshed = await this.claimCaseDbService.find({ _id: { $in: touchedIds.map((id) => new Types.ObjectId(id)) }, }); const byId = new Map(refreshed.map((r) => [String(r._id), r] as const)); for (const c of filtered) { const r = byId.get(String(c._id)); if (r) Object.assign(c, r); } } // Post-filter: remove files that belong to another expert after reconciliation const finalFiltered = filtered.filter((c) => { const assignedId = String(c.workflow?.assignedForReviewBy?.actorId ?? ""); const lockEnforced = c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c); const lockedById = String(c.workflow?.lockedBy?.actorId ?? ""); const isFactorValidation = claimIsAwaitingExpertFactorValidationV2(c); // Factor validation is open to all tenant experts if (isFactorValidation) return true; // Available: no assignee, not locked by someone else const isAvailable = c.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT && !assignedId && (!lockEnforced || lockedById === actorId); // Mine: assigned to me (covers decided, resend, objection follow-up) const isAssignedToMe = !!assignedId && assignedId === actorId; // Mine: currently locked by me const isLockedByMe = lockEnforced && lockedById === actorId; // Mine: resend pending and I'm the assigned expert const isResendMine = c.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND && isAssignedToMe; return isAvailable || isAssignedToMe || isLockedByMe || isResendMine; }); const blameIds = [ ...new Set( finalFiltered .map((c) => c.blameRequestId?.toString()) .filter((id): id is string => !!id), ), ]; const blames = blameIds.length > 0 ? ((await this.blameRequestDbService.find( { _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } }, { lean: true, select: "type parties expert.decision blameStatus" }, )) as any[]) : []; const blameById = new Map( blames.map((b) => [String(b._id), b]), ); const list = finalFiltered.map((c) => { const awaitingFactorValidation = claimIsAwaitingExpertFactorValidationV2(c); const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById); const blame = c.blameRequestId ? blameById.get(c.blameRequestId.toString()) : undefined; const fileCtx = blame ? this.blameFileContextForExpert(blame) : {}; const lockActive = !!( c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c) ); const statusForList = c.status === ClaimCaseStatus.EXPERT_REVIEWING ? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT : c.status; return { claimRequestId: c._id.toString(), publicId: c.publicId, status: statusForList, currentStep: c.workflow?.currentStep || "", locked: lockActive, lockedBy: lockActive && c.workflow?.lockedBy ? { actorId: c.workflow.lockedBy.actorId?.toString(), actorName: c.workflow.lockedBy.actorName, lockedAt: (c.workflow as any).lockedAt?.toISOString?.(), expiredAt: (c.workflow as any).expiredAt?.toISOString?.(), } : undefined, vehicle: v ? { carName: v.carName, carModel: v.carModel, carType: v.carType } : undefined, ...fileCtx, createdAt: c.createdAt, awaitingFactorValidation, }; }) as ClaimListItemV2Dto[]; const paged = applyListQueryV2( list, { publicId: (r) => r.publicId, createdAt: (r) => r.createdAt, requestNo: (r) => r.publicId, status: (r) => r.status, searchExtras: (r) => [ r.claimRequestId, r.currentStep, r.vehicle?.carName, r.vehicle?.carModel, r.blameRequestType, ].filter(Boolean) as string[], }, query, ); return { list: paged.list, total: paged.total, page: paged.page, limit: paged.limit, totalPages: paged.totalPages, }; } /** * Claim list for a FIELD_EXPERT — returns only the claims they personally * initiated (expert-initiated IN_PERSON blame → createClaimFromBlameForExpertV2). */ private async getFieldExpertClaimListV2( actor: any, query: ListQueryV2Dto = {}, ): Promise { const claims = (await this.claimCaseDbService.find({ initiatedByFieldExpertId: new Types.ObjectId(actor.sub), })) as any[]; const blameIds = [ ...new Set( claims .map((c) => c.blameRequestId?.toString()) .filter((id): id is string => !!id), ), ]; const blames = blameIds.length > 0 ? ((await this.blameRequestDbService.find( { _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } }, { lean: true, select: "type parties expert.decision blameStatus" }, )) as any[]) : []; const blameById = new Map( blames.map((b) => [String(b._id), b]), ); const list = claims.map((c) => { const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById); const blame = c.blameRequestId ? blameById.get(c.blameRequestId.toString()) : undefined; const fileCtx = blame ? this.blameFileContextForExpert(blame) : {}; const lockActive = !!( c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c) ); return { claimRequestId: c._id.toString(), publicId: c.publicId, status: c.status, currentStep: c.workflow?.currentStep || "", locked: lockActive, lockedBy: lockActive && c.workflow?.lockedBy ? { actorId: c.workflow.lockedBy.actorId?.toString(), actorName: c.workflow.lockedBy.actorName, lockedAt: (c.workflow as any).lockedAt?.toISOString?.(), expiredAt: (c.workflow as any).expiredAt?.toISOString?.(), } : undefined, vehicle: v ? { carName: v.carName, carModel: v.carModel, carType: v.carType } : undefined, ...fileCtx, createdAt: c.createdAt, awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c), }; }) as ClaimListItemV2Dto[]; const paged = applyListQueryV2( list, { publicId: (r) => r.publicId, createdAt: (r) => r.createdAt, requestNo: (r) => r.publicId, status: (r) => r.status, searchExtras: (r) => [ r.claimRequestId, r.currentStep, r.vehicle?.carName, r.vehicle?.carModel, ].filter(Boolean) as string[], }, query, ); return { list: paged.list, total: paged.total, page: paged.page, limit: paged.limit, totalPages: paged.totalPages, }; } /** * Load linked blame case for damage-expert claim detail (same enrichment as expert-blame `findOneV2`: * party evidence `videoUrl` / `voiceUrls`, Jalali date strings). */ private async buildBlameCaseSnapshotForClaimDetail( blameRequestId: string, ): Promise | null> { const doc = await this.blameRequestDbService.findByIdWithoutHistory(blameRequestId); if (!doc) { return null; } const docRec = doc as Record; const built = buildMutualAgreementExpertDecision(docRec); const existingDecision = ( docRec.expert as Record | undefined )?.decision as Record | undefined; if (built && !existingDecision?.guiltyPartyId) { const prevExpert = typeof docRec.expert === "object" && docRec.expert !== null ? { ...(docRec.expert as Record) } : {}; const expert = { ...prevExpert, decision: built }; await this.blameRequestDbService.findByIdAndUpdate(blameRequestId, { $set: { expert }, }); docRec.expert = expert; } const parties = (doc.parties ?? []) as Array<{ evidence?: { videoId?: string | number; voices?: (string | number)[]; videoUrl?: string; voiceUrls?: string[]; }; }>; for (const party of parties) { if (!party.evidence) continue; const evidence = party.evidence as Record; if (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), ); if (voiceDoc?.path) { voiceUrls.push(buildFileLink(voiceDoc.path)); } } evidence.voiceUrls = voiceUrls; } } const createdAt = doc.createdAt ? new Date(doc.createdAt as string | number) : new Date(); const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | number) : new Date(); const [createdDate, createdTime] = toJalaliDateAndTime(createdAt); const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt); (doc as Record).createdAtFormatted = `${createdDate} ${createdTime}`; (doc as Record).updatedAtFormatted = `${updatedDate} ${updatedTime}`; docRec.parties = enrichBlamePartiesForAgreementView(docRec); return docRec; } /** Strip bulky insurer-inquiry payloads (`raw` / `mapped`) from vehicle.inquiry. */ private sanitizeVehicleInquiryForApi(vehicle: any) { if (!vehicle || typeof vehicle !== "object") return vehicle; const inquiry = vehicle.inquiry; if (!inquiry || typeof inquiry !== "object") return vehicle; const { raw, mapped, ...safeInquiry } = inquiry as Record; return { ...vehicle, inquiry: safeInquiry, }; } /** Linked blame snapshot for damage-expert detail: keep API response lean. */ private sanitizeBlameCaseForClaimDetailApi( blame: Record, ): Record { const out = { ...blame }; const parties = out.parties; if (Array.isArray(parties)) { out.parties = parties.map((p: any) => p && typeof p === "object" ? { ...p, vehicle: this.sanitizeVehicleInquiryForApi(p.vehicle) } : p, ); } return out; } /** JSON-safe `expert.decision` for API responses (ObjectIds → string). */ private serializeBlameExpertDecisionForClaimDetail( decision: unknown, ): Record | undefined { if (!decision || typeof decision !== "object") { return undefined; } const d = decision as Record; const guilty = d.guiltyPartyId; const decidedBy = d.decidedByExpertId; const decidedAt = d.decidedAt; return { ...d, guiltyPartyId: guilty != null && typeof (guilty as { toString?: () => string }).toString === "function" ? String(guilty) : guilty, decidedByExpertId: decidedBy != null && typeof (decidedBy as { toString?: () => string }).toString === "function" ? String(decidedBy) : decidedBy, decidedAt: decidedAt instanceof Date ? decidedAt.toISOString() : decidedAt, }; } private claimHasDamageExpertReply(claim: { evaluation?: { damageExpertReply?: unknown; damageExpertReplyFinal?: unknown; }; }): boolean { return !!( claim.evaluation?.damageExpertReply || claim.evaluation?.damageExpertReplyFinal ); } /** * True while a workflow lock is still within its window (`expiredAt` when set, else lockedAt + TTL). * Missing both timestamps on old documents: treat lock as enforced until cleared. */ private isClaimV2WorkflowLockCurrentlyEnforced(claim: any): boolean { if (!claim?.workflow?.locked) return false; const exp = claim.workflow?.expiredAt; if (exp) { return Date.now() < new Date(exp as Date).getTime(); } const la = claim.workflow?.lockedAt as Date | string | undefined; if (!la) return true; return Date.now() < new Date(la).getTime() + this.claimV2WorkflowLockTtlMs; } /** * EXPERT_REVIEWING + expired lock is not listed until status is reverted. Run expire for this tenant * so the next query returns those cases as WAITING_FOR_DAMAGE_EXPERT. */ private async reconcileStaleExpertReviewingClaimLocksForTenant(actor: { sub: string; clientKey?: string; role?: string; }): Promise { if ((actor as any).role === RoleEnum.FIELD_EXPERT) return; const clientKey = requireActorClientKey(actor); const lockTtlMs = this.claimV2WorkflowLockTtlMs; const now = new Date(); const candidates = (await this.claimCaseDbService.find( { status: ClaimCaseStatus.EXPERT_REVIEWING, "workflow.locked": true, $or: [ { "workflow.expiredAt": { $lte: now } }, { $expr: { $and: [ { $eq: ["$workflow.locked", true] }, { $lte: [{ $add: ["$workflow.lockedAt", lockTtlMs] }, now], }, ], }, }, ], }, { lean: true, select: "_id owner" }, )) as Array<{ _id: unknown; owner?: unknown }>; const relevant = candidates.filter((c) => claimCaseTouchesClient(c, clientKey), ); await Promise.all( relevant.map((c) => this.expireClaimWorkflowLockV2IfStale(String(c._id))), ); } /** * Persist unlock when claim V2 workflow lock TTL has passed. * When the case was in {@link ClaimCaseStatus.EXPERT_REVIEWING}, restores queue fields from * `workflow.preLockQueueSnapshot` (or defaults) and sets status back to WAITING_FOR_DAMAGE_EXPERT * so the claim reappears in the open expert queue (same intent as blame list expiry + in-memory unlock). */ private async expireClaimWorkflowLockV2IfStale( claimRequestId: string, ): Promise { const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim?.workflow?.locked) return false; const expiredAt = claim.workflow?.expiredAt; const lockedAt = claim.workflow?.lockedAt; const now = Date.now(); const ttl = this.claimV2WorkflowLockTtlMs; if (expiredAt && now < new Date(expiredAt as Date).getTime()) return false; if ( !expiredAt && lockedAt && now < new Date(lockedAt as Date).getTime() + ttl ) { return false; } const lockedById = claim.workflow.lockedBy?.actorId; const filter: Record = { _id: new Types.ObjectId(claimRequestId), "workflow.locked": true, }; if (expiredAt) { filter["workflow.expiredAt"] = expiredAt; } else if (lockedAt) { filter["workflow.lockedAt"] = lockedAt; } else if (lockedById) { filter["workflow.lockedBy.actorId"] = new Types.ObjectId( String(lockedById), ); } const needsQueueRestore = claim.status === ClaimCaseStatus.EXPERT_REVIEWING; const snap = claim.workflow?.preLockQueueSnapshot as | { claimStatus?: ClaimStatus; currentStep?: ClaimWorkflowStep; nextStep?: ClaimWorkflowStep; } | undefined; const restoreClaimStatus = snap?.claimStatus ?? ClaimStatus.PENDING; const restoreCurrent = this.normalizeClaimWorkflowStepForSnapshot( snap?.currentStep, ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, ); const restoreNext = snap?.nextStep != null ? this.normalizeClaimWorkflowStepForSnapshot( snap.nextStep, ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, ) : ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; const lockFieldsUnset = { "workflow.lockedAt": "", "workflow.expiredAt": "", "workflow.lockedBy": "", "workflow.preLockQueueSnapshot": "", }; const assigneeToPersist = claimWorkflowAssigneeToPersistOnLockExpiry( claim.workflow, claim, ); const expireLockSet: Record = { "workflow.locked": false }; if (assigneeToPersist) { expireLockSet["workflow.assignedForReviewBy"] = assigneeToPersist; } const update = needsQueueRestore ? { $set: { ...expireLockSet, status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, claimStatus: restoreClaimStatus, "workflow.currentStep": restoreCurrent, "workflow.nextStep": restoreNext, }, $unset: lockFieldsUnset, } : { $set: expireLockSet, $unset: lockFieldsUnset, }; const cleared = await this.claimCaseDbService.findOneAndUpdate( filter as any, update as any, { new: false }, ); if (cleared && lockedById && claim.owner?.userClientKey) { await this.recordClaimExpertActivity({ expertId: String(lockedById), tenantId: String(claim.owner.userClientKey), claimId: String(claimRequestId), eventType: ExpertFileActivityType.UNCHECKED, idempotencyKey: `claim:${claimRequestId}:unchecked:expired:${lockedById}`, }); } return !!cleared; } /** * V2: Get claim detail for damage expert * * Validations: * - Claim must exist and belong to the actor's insurer (`assertClaimCaseForTenant`) * - Allowed when: WAITING_FOR_DAMAGE_EXPERT (queue), or EXPERT_REVIEWING (after lock — same expert must be able to reopen detail), * or factor-validation queue (EXPERT_VALIDATING_REPAIR_FACTORS / legacy WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW + EXPERT_COST_EVALUATION) * - If an active workflow lock exists (15 min from lockedAt): only the locking expert; after expiry, any expert (tenant) may view */ async getClaimDetailV2( claimRequestId: string, actor: any, ): Promise { const actorId = actor.sub; if (actor.role !== RoleEnum.FIELD_EXPERT) { await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor); } await this.expireClaimWorkflowLockV2IfStale(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } assertClaimCaseForExpertActor(claim, actor); // Variables used both in the gate block and in the detail-build section below const isDamageExpertPhase = claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT || claim.status === ClaimCaseStatus.EXPERT_REVIEWING; const isFactorValidationPending = claimIsAwaitingExpertFactorValidationV2(claim); // Field experts can always view their own initiated claims regardless of status if (actor.role === RoleEnum.FIELD_EXPERT) { if (!claimCaseInitiatedByFieldExpert(claim, actor)) { throw new ForbiddenException("This claim was not initiated by you."); } // Fall through to the detail build below (skip the damage-expert gate) } else { const isResendPending = claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND; const assignedForReviewById = String( (claim.workflow as any)?.assignedForReviewBy?.actorId ?? "", ); const lockEnforced = this.isClaimV2WorkflowLockCurrentlyEnforced(claim); const lockedById = String(claim.workflow?.lockedBy?.actorId ?? ""); const isAssignedToMe = !!assignedForReviewById && assignedForReviewById === actorId; // Gate: must satisfy at least one bucket if ( !isDamageExpertPhase && !isFactorValidationPending && !isResendPending && !isAssignedToMe ) { throw new ForbiddenException( `This claim is not available for expert review. Current status: ${claim.status}`, ); } const isAvailable = isDamageExpertPhase && !assignedForReviewById && !lockEnforced; const isLockedByMe = lockEnforced && lockedById === actorId; const isFactorValidationOpen = isFactorValidationPending; if ( !isAvailable && !isAssignedToMe && !isLockedByMe && !isFactorValidationOpen ) { if (lockEnforced && lockedById && lockedById !== actorId) { throw new ForbiddenException( `This claim is locked by another expert: ${claim.workflow?.lockedBy?.actorName}`, ); } if (assignedForReviewById && assignedForReviewById !== actorId) { throw new ForbiddenException( "This claim has been assigned to another expert.", ); } throw new ForbiddenException( "You do not have permission to view this claim.", ); } } // end damage-expert gate (else block) // Build requiredDocuments map const requiredDocs = claim.requiredDocuments as any; const requiredDocumentsStatus: Record = {}; // Resend documents requested by expert — normalize to canonical keys for comparison const resendDocumentKeys = new Set( normalizeResendDocumentKeys( (claim as any).evaluation?.damageExpertResend?.resendDocuments, ), ); const resendFulfilled = !!(claim as any).evaluation?.damageExpertResend ?.fulfilledAt; if (requiredDocs) { const keys = requiredDocs instanceof Map ? Array.from(requiredDocs.keys()) : Object.keys(requiredDocs); for (const k of keys as string[]) { const doc = requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k]; const canonicalKey = canonicalizeResendDocumentKey(k) ?? k; const wasResendRequested = resendDocumentKeys.has(canonicalKey); requiredDocumentsStatus[k] = { uploaded: !!doc?.uploaded, fileId: doc?.fileId?.toString(), fileName: doc?.fileName, filePath: doc?.filePath ? buildFileLink(doc.filePath) : undefined, resend: { wasRequested: wasResendRequested, isFulfilled: wasResendRequested && resendFulfilled, }, }; } } // Build car angles map const carAnglesData = claim.media?.carAngles as any; const damagedPartsDataForAngles = claim.media?.damagedParts as any; const carAngles: Record = {}; for (const k of ["front", "back", "left", "right"]) { const cap = getClaimCarAngleCaptureBlob( carAnglesData, damagedPartsDataForAngles, k as ClaimCarAngleKey, ) as { url?: string; path?: string } | undefined; carAngles[k] = { captured: !!cap, url: resolveStoredFileUrl(cap), }; } // Build enriched damaged parts const carTypeExpert = claim.vehicle?.carType as | ClaimVehicleTypeV2 | undefined; const selectedNormExpert = normalizeDamageSelectedParts( claim.damage?.selectedParts, carTypeExpert, (claim.damage as any)?.selectedOuterParts, ); const damagedPartsDataExpert = claim.media?.damagedParts as any; const damagedParts = buildEnrichedDamagedParts({ selectedParts: selectedNormExpert, damagedPartsData: damagedPartsDataExpert, evaluationBlock: (claim as any).evaluation, expertAddedParts: (claim.damage as any)?.expertAddedParts ?? [], getDamagedPartCaptureBlob, hasDamagedPartCapture, catalogLikeKeyFromPart, buildFileLink, resolveStoredFileUrl, }); // Vehicle payload — fall back to blame inquiry if claim vehicle is sparse let vehiclePayload = claim.vehicle as any; const hasClaimVehicle = vehiclePayload && (vehiclePayload.carName || vehiclePayload.carModel || vehiclePayload.carType || vehiclePayload.plate); let blameLean: any = null; const videoCaptureIdRaw = (claim.media as any)?.videoCaptureId; const blameRequestIdStr = claim.blameRequestId?.toString(); const [videoCaptureRow, blameCase, blameLeanRows] = await Promise.all([ videoCaptureIdRaw ? this.claimVideoCaptureDbService.findById(String(videoCaptureIdRaw)) : Promise.resolve(null), blameRequestIdStr ? this.buildBlameCaseSnapshotForClaimDetail(blameRequestIdStr) : Promise.resolve(null), claim.blameRequestId ? this.blameRequestDbService.find( { _id: new Types.ObjectId(claim.blameRequestId.toString()) }, { lean: true, select: "type parties expert.decision" }, ) : Promise.resolve([]), ]); if (Array.isArray(blameLeanRows) && blameLeanRows.length) { blameLean = blameLeanRows[0] ?? null; } if (!hasClaimVehicle && blameLean) { const fromBlame = this.damagedPartyVehicleFromBlame(blameLean, claim); if (fromBlame) vehiclePayload = fromBlame; } const blameFileContext = blameLean ? this.blameFileContextForExpert(blameLean) : {}; let videoCapture: ClaimDetailV2ResponseDto["videoCapture"] = undefined; if (videoCaptureRow) { const vc = videoCaptureRow as any; videoCapture = { id: String(vc._id ?? videoCaptureIdRaw), fileName: vc.fileName, path: vc.path, url: vc.path ? buildFileLink(vc.path) : undefined, }; } const blameExpertDecision = blameCase ? this.serializeBlameExpertDecisionForClaimDetail( (blameCase.expert as Record | undefined)?.decision, ) : undefined; const money = ( claim as { money?: { sheba?: string; nationalCodeOfInsurer?: string } } ).money; const moneyPayload = money && (money.sheba != null || money.nationalCodeOfInsurer != null) ? { sheba: money.sheba, nationalCodeOfInsurer: money.nationalCodeOfInsurer, } : undefined; const statusForExpertApi = claim.status === ClaimCaseStatus.EXPERT_REVIEWING ? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT : claim.status; const blameCaseForApi = blameCase ? this.sanitizeBlameCaseForClaimDetailApi( blameCase as Record, ) : undefined; const claimEvaluationRaw = (claim as any).evaluation as | Record | undefined; const enrichedEvaluation = await this.enrichClaimEvaluationForExpert(claimEvaluationRaw); let evaluationForApi: Record | undefined; if (enrichedEvaluation) { evaluationForApi = {}; if (isFactorValidationPending) { if (enrichedEvaluation.damageExpertReply !== undefined) { evaluationForApi.damageExpertReply = enrichedEvaluation.damageExpertReply; } if (enrichedEvaluation.damageExpertReplyFinal !== undefined) { evaluationForApi.damageExpertReplyFinal = enrichedEvaluation.damageExpertReplyFinal; } } if (enrichedEvaluation.ownerInsurerApproval !== undefined) { evaluationForApi.ownerInsurerApproval = enrichedEvaluation.ownerInsurerApproval; } if (enrichedEvaluation.ownerPricedPartsApproval !== undefined) { evaluationForApi.ownerPricedPartsApproval = enrichedEvaluation.ownerPricedPartsApproval; } if (isDamageExpertPhase && enrichedEvaluation.priceDrop !== undefined) { evaluationForApi.priceDrop = enrichedEvaluation.priceDrop; } if (Object.keys(evaluationForApi).length === 0) { evaluationForApi = undefined; } } return { claimRequestId: claim._id.toString(), publicId: claim.publicId, status: statusForExpertApi, claimStatus: claim.claimStatus || "PENDING", currentStep: claim.workflow?.currentStep || "", nextStep: claim.workflow?.nextStep, locked: claim.workflow?.locked || false, lockedBy: claim.workflow?.lockedBy ? { actorId: claim.workflow.lockedBy.actorId?.toString(), actorName: claim.workflow.lockedBy.actorName, lockedAt: (claim.workflow as any).lockedAt?.toISOString(), expiredAt: (claim.workflow as any).expiredAt?.toISOString(), } : undefined, owner: claim.owner ? { userId: claim.owner.userId?.toString(), fullName: claim.owner.fullName, } : undefined, vehicle: vehiclePayload ? this.sanitizeVehicleInquiryForApi(vehiclePayload) : undefined, ...blameFileContext, blameRequestId: claim.blameRequestId?.toString(), blameRequestNo: claim.blameRequestNo, money: moneyPayload, otherParts: claim.damage?.otherParts, requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined, carAngles, damagedParts, awaitingFactorValidation: isFactorValidationPending, evaluation: evaluationForApi as | ClaimDetailV2ResponseDto["evaluation"] | undefined, videoCapture, blameCase: blameCaseForApi, blameExpertDecision, createdAt: (claim as any).createdAt, updatedAt: (claim as any).updatedAt, }; } /** V2 price-drop: claim locked by this expert during damage assessment. */ private assertExpertCanEditClaimDuringReviewV2(claim: any, actor: any): void { if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor); assertClaimCaseForExpertActor(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`, ); } if (!claim.workflow?.locked) { throw new ForbiddenException( "You must lock the claim before entering price-drop data", ); } if (claim.workflow.lockedBy?.actorId?.toString() !== String(actor.sub)) { throw new ForbiddenException("This claim is locked by another expert"); } } /** * V2: Context for manual price-drop (vehicle, inquiry year, catalog, damaged parts). */ async getPriceDropContextV2(claimRequestId: string, actor: any) { const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } this.assertExpertCanEditClaimDuringReviewV2(claim, actor); const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const selectedNorm = normalizeDamageSelectedParts( claim.damage?.selectedParts, carType, (claim.damage as any)?.selectedOuterParts, ); let suggestedCarModelYear: number | null = null; if (claim.blameRequestId) { const blameRows = await this.blameRequestDbService.find( { _id: new Types.ObjectId(claim.blameRequestId.toString()) }, { lean: true, select: "parties.role parties.person.userId parties.vehicle.inquiry", }, ); const blame = blameRows[0] as Record | undefined; suggestedCarModelYear = resolveVehicleModelYearFromBlame( claim, blame as Parameters[1], ); } const existing = (claim as any).evaluation?.priceDrop; return { claimRequestId: String(claim._id), vehicle: { carName: claim.vehicle?.carName, carModel: claim.vehicle?.carModel, carType: claim.vehicle?.carType, }, suggestedCarModelYear, priceDrop: existing ?? null, severityOptions: PRICE_DROP_SEVERITY_OPTIONS, priceDropCatalog: buildPriceDropCatalogForApi(), damagedParts: buildDamagedPartsPriceDropLines(selectedNorm), }; } /** * V2: Expert enters vehicle + per-part severities; persists `vehicle` and `evaluation.priceDrop`. */ async upsertPriceDropV2( claimRequestId: string, body: UpsertClaimPriceDropV2Dto, actor: any, ) { const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } 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.", ); } // Manual path — change totalPriceDrop → total to match calculated path const manualPayload = { manualOverride: true, total: body.manualPriceDrop, // ← was totalPriceDrop 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 // At the top of the calculated path, after the manualPriceDrop early return: const existingPriceDrop = (claim as any).evaluation?.priceDrop; if ( existingPriceDrop?.manualOverride === true && !body.partSeverities?.length ) { throw new BadRequestException( "This claim has a manual price-drop correction. " + "To recalculate, provide partSeverities explicitly — this will replace the manual correction.", ); } const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const selectedNorm = normalizeDamageSelectedParts( claim.damage?.selectedParts, carType, (claim.damage as any)?.selectedOuterParts, ); if (!body.partSeverities?.length) { throw new BadRequestException( "Provide at least one partSeverities line, or use manualPriceDrop for a direct override.", ); } let carModelYear = body.carModelYear; if (carModelYear == null && claim.blameRequestId) { const blameRows = await this.blameRequestDbService.find( { _id: new Types.ObjectId(claim.blameRequestId.toString()) }, { lean: true, select: "parties.role parties.person.userId parties.vehicle.inquiry", }, ); carModelYear = resolveVehicleModelYearFromBlame( claim, blameRows[0] as Parameters< typeof resolveVehicleModelYearFromBlame >[1], ) ?? undefined; } if (carModelYear == null || !Number.isFinite(carModelYear)) { throw new BadRequestException( "carModelYear is required when it cannot be resolved from the linked blame plate inquiry.", ); } const { coefficients, lines, errors } = buildCoefficientsFromPartSeverities( selectedNorm, body.partSeverities.map((p) => ({ partId: p.partId, severity: p.severity, })), carType, ); if (errors.length > 0) { throw new BadRequestException(errors.join(" ")); } if (coefficients.length === 0) { throw new BadRequestException( "No valid severity coefficients could be derived from partSeverities.", ); } const calculated = calculateClaimPriceDrop( body.carPrice, carModelYear, coefficients, ); const priceDropPayload = { ...calculated, partLines: lines, }; await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { ...vehicleSet, "evaluation.priceDrop": priceDropPayload, }, }); const updated = await this.claimCaseDbService.findById(claimRequestId); return { claimRequestId, vehicle: { carName: updated?.vehicle?.carName, carModel: updated?.vehicle?.carModel, carType: updated?.vehicle?.carType, }, priceDrop: priceDropPayload, partLines: lines, }; } /** * V2: Damage expert can modify selected damaged parts before final submit. * Allowed only when claim is EXPERT_REVIEWING and locked by this expert. */ async updateClaimDamagedPartsV2( claimRequestId: string, body: UpdateClaimDamagedPartsV2Dto, actor: any, ) { if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } assertClaimCaseForExpertActor(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`, ); } if (!claim.workflow?.locked) { throw new ForbiddenException( "You must lock the claim before modifying damaged parts", ); } if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) { throw new ForbiddenException("This claim is locked by another expert"); } const damagedPartsEditSnapshot = await this.snapshotDamageExpert(actor.sub); const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const previousNorm = normalizeDamageSelectedParts( (claim as any).damage?.selectedParts, carType, (claim as any).damage?.selectedOuterParts, ); const prevMedia = coerceDamagedPartsMediaToArray( (claim as any).media?.damagedParts, previousNorm, ); const nextNorm = body.selectedParts.map((p) => sanitizeDamageSelectedPartV2( { id: p.id ?? null, name: String(p.name || "").trim(), side: String(p.side || ""), label_fa: String(p.label_fa || "").trim() || String(p.name || "").trim(), ...(p.catalogKey?.trim() ? { catalogKey: p.catalogKey.trim() } : {}), }, carType, ), ); const matchPreviousIndex = ( next: (typeof nextNorm)[0], previous: typeof previousNorm, ): number => { if (next.id != null) { const byId = previous.findIndex((x) => x.id === next.id); if (byId >= 0) return byId; } if (next.catalogKey) { const byCk = previous.findIndex( (x) => x.catalogKey === next.catalogKey, ); if (byCk >= 0) return byCk; } return previous.findIndex( (x) => x.name === next.name && x.side === next.side, ); }; const nextMedia = nextNorm.map((sp) => { const j = matchPreviousIndex(sp, previousNorm); const row = j >= 0 && prevMedia[j] && typeof prevMedia[j] === "object" ? (prevMedia[j] as Record) : {}; return { ...(sp.id != null ? { id: sp.id } : {}), name: sp.name, side: sp.side, label_fa: sp.label_fa, ...(sp.catalogKey ? { catalogKey: sp.catalogKey } : {}), ...(row.path ? { path: row.path } : {}), ...(row.fileName ? { fileName: row.fileName } : {}), ...(row.url ? { url: row.url } : {}), ...(row.capturedAt ? { capturedAt: row.capturedAt } : {}), }; }); const previous = Array.isArray((claim as any).damage?.selectedParts) ? [...(claim as any).damage.selectedParts] : []; // Diff to find parts the expert is adding that weren't in the original selection const previousPartIds = new Set( previousNorm.map((p) => p.id ?? `${p.name}::${p.side}`), ); const expertAddedNow = nextNorm.filter( (p) => !previousPartIds.has(p.id ?? `${p.name}::${p.side}`), ); // Merge with parts the expert added in any previous edits on this claim (idempotent) const existingExpertAdded: any[] = (claim.damage as any)?.expertAddedParts ?? []; const existingExpertAddedIds = new Set( existingExpertAdded.map((p: any) => p.id ?? `${p.name}::${p.side}`), ); const expertAddedToAppend = expertAddedNow.filter( (p) => !existingExpertAddedIds.has(p.id ?? `${p.name}::${p.side}`), ); const mergedExpertAdded = [...existingExpertAdded, ...expertAddedToAppend]; const $set: Record = { "damage.selectedParts": nextNorm, "media.damagedParts": nextMedia, "damage.expertAddedParts": mergedExpertAdded, }; await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set, $push: { history: { type: "EXPERT_DAMAGED_PARTS_UPDATED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: actor.fullName, actorType: "damage_expert", }, timestamp: new Date(), metadata: { previousSelectedParts: previous, selectedParts: nextNorm, expertAddedParts: mergedExpertAdded, ...(damagedPartsEditSnapshot && { expertProfileSnapshot: damagedPartsEditSnapshot, }), }, }, }, }); return { claimRequestId: claim._id.toString(), selectedParts: nextNorm, previousSelectedParts: previous, expertAddedParts: mergedExpertAdded, message: "Damaged parts updated successfully.", }; } }