"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 { 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 } 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 { 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 { assertClaimCaseForTenant, claimCaseTouchesClient, requireActorClientKey, } from "src/helpers/tenant-scope"; import { claimCaseStatusToReportBucket, initialClaimExpertReportBuckets, } from "src/helpers/expert-panel-status-report"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.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 { resendRequestHasPayload } from "src/helpers/claim-expert-resend"; 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"; @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, ) { } /** 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); } /** 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 { console.log(actor); // 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.damageExpertDbService.updateStats( actorDetail.sub, "checked", requestId, ); 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, ""), ); } /** 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 (30,000,000) if (reply.parts && reply.parts.length > 0) { const PRICE_CAP = 30000000; 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 > PRICE_CAP) { throw new BadRequestException({ message: `[PRICE_CAP_ERROR] Total price of damaged parts (${totalPrice.toLocaleString()}) exceeds the maximum allowed amount (${PRICE_CAP.toLocaleString()}). Please adjust the prices.`, error: "PRICE_CAP_ERROR", code: "PRICE_CAP_ERROR", totalPrice: totalPrice, priceCap: PRICE_CAP, }); } } // 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.damageExpertDbService.updateStats( userId.sub, "handled", requestId, ); } 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); console.log(request) // 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, }; } /** * 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 → same as non-factor path: claimStatus APPROVED, step INSURER_REVIEW. * — Any rejected (expert repriced) → case COMPLETED with expert amounts (damage-expert finalization). */ 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"); } assertClaimCaseForTenant(claim, actor); const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub); if ( claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL || claim.claimStatus !== ClaimStatus.UNDER_REVIEW || claim.workflow?.currentStep !== ClaimWorkflowStep.EXPERT_COST_EVALUATION ) { 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) => String(p.partId) === String(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.REJECTED) { const hasOverride = decision.totalPayment != null || (decision.price != null && decision.salary != null); if (!hasOverride) { throw new BadRequestException( `Part ${decision.partId}: rejected factors 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, }); 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 PRICE_CAP = 30_000_000; let totalPrice = 0; for (const part of updatedReply.parts || []) { const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0")); if (!isNaN(parsed)) { totalPrice += parsed; } } if (totalPrice > PRICE_CAP) { throw new BadRequestException({ message: `Total price (${totalPrice.toLocaleString()}) exceeds the cap of ${PRICE_CAP.toLocaleString()}`, error: "PRICE_CAP_ERROR", totalPrice, priceCap: PRICE_CAP, }); } const historyActor = { actorId: new Types.ObjectId(actor.sub), actorName: actor.fullName, actorType: "damage_expert", }; if (anyRejected) { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.COMPLETED, claimStatus: ClaimStatus.APPROVED, "workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED, "workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, }, $push: { history: { type: "FACTORS_VALIDATED_REJECTED_EXPERT_FINALIZED", actor: historyActor, timestamp: new Date(), metadata: { replyField }, }, }, }); return { message: "Factors reviewed. Rejected parts were repriced by the expert; the claim is completed.", claimRequestId, claimStatus: ClaimStatus.APPROVED, caseStatus: ClaimCaseStatus.COMPLETED, outcome: "REJECTED_REPRICED_COMPLETED", }; } await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { claimStatus: ClaimStatus.APPROVED, "workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW, "workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, }, $push: { history: { type: "FACTORS_VALIDATED_ALL_APPROVED", actor: historyActor, timestamp: new Date(), metadata: { replyField }, }, }, }); return { message: "All factors approved. The claim proceeds to insurer review (same as non-factor expert reply).", claimRequestId, claimStatus: ClaimStatus.APPROVED, caseStatus: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, outcome: "ALL_APPROVED_INSURER_REVIEW", }; } 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.damageExpertDbService.updateStats( actorDetail.sub, "handled", requestId, ); return updated; } async retrieveInsuranceBranches(insuranceId: string) { return await this.branchDbService.findAll(insuranceId); } private globalErrHandler(error) { this.logger.error(error.response?.responseCode, error.response); throw new HttpException(error.response, error.claimStatus); } /** * V2: Lock a claim request for expert review. * * Validations: * - Claim must exist * - Status must be WAITING_FOR_DAMAGE_EXPERT * - Must not already be locked by another expert * * On success: * - Sets workflow.locked = true, workflow.lockedBy = actor * - Sets status = EXPERT_REVIEWING * - Sets claimStatus = UNDER_REVIEW * - Sets currentStep = EXPERT_DAMAGE_ASSESSMENT */ async lockClaimRequestV2(claimRequestId: string, actor: any) { 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.WAITING_FOR_DAMAGE_EXPERT) { throw new BadRequestException( `Claim is not available for locking. Current status: ${claim.status}`, ); } if ( claim.workflow?.locked && claim.workflow.lockedBy?.actorId?.toString() !== actor.sub ) { throw new BadRequestException( `Claim is already locked by another expert: ${claim.workflow.lockedBy?.actorName}`, ); } // Already locked by this same expert — idempotent if ( claim.workflow?.locked && claim.workflow.lockedBy?.actorId?.toString() === actor.sub ) { return { claimRequestId, locked: true, message: 'Already locked by you' }; } const lockSnapshot = await this.snapshotDamageExpert(actor.sub); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { status: ClaimCaseStatus.EXPERT_REVIEWING, claimStatus: ClaimStatus.UNDER_REVIEW, 'workflow.locked': true, 'workflow.lockedAt': new Date(), 'workflow.lockedBy': { actorId: new Types.ObjectId(actor.sub), actorName: actor.fullName, actorRole: 'damage_expert', ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), }, 'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, $push: { history: { event: 'CLAIM_LOCKED', performedBy: actor.sub, performedByName: actor.fullName, performedAt: new Date(), note: `Claim locked by damage expert ${actor.fullName}`, }, }, }); return { claimRequestId, locked: true, status: ClaimCaseStatus.EXPERT_REVIEWING, claimStatus: ClaimStatus.UNDER_REVIEW, currentStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, }; } async submitResendDocsV2( claimRequestId: string, reply: ClaimSubmitResendV2Dto, actor: any, ) { 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 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.", ); } const docs = reply.resendDocuments ?? []; const parts = reply.resendCarParts ?? []; const desc = String(reply.resendDescription ?? "").trim(); if (!desc && docs.length === 0 && parts.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: docs, resendCarParts: parts, ...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }), }, }, $unset: { "workflow.lockedAt": "", "workflow.lockedBy": "", }, $push: { history: { type: "EXPERT_RESEND_REQUESTED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: actor.fullName, actorType: "damage_expert", }, timestamp: new Date(), metadata: { documentCount: docs.length, carPartCount: parts.length, }, }, }, }); if (claim.blameRequestId) { const blame = await this.blameRequestDbService.findById( claim.blameRequestId.toString(), ); if (blame?.type === BlameRequestType.THIRD_PARTY) { const ownerUserId = claim.owner?.userId ? String(claim.owner.userId) : ""; const ownerParty = (blame.parties || []).find( (p: any) => p?.person?.userId && String(p.person.userId) === ownerUserId, ); const ownerPhone = ownerParty?.person?.phoneNumber; if (ownerPhone && typeof ownerPhone === "string") { await this.smsOrchestrationService.sendResendDocumentsNotice({ receptor: ownerPhone, fileKind: "claim", publicId: claim.publicId, link: this.smsOrchestrationService.buildClaimLink( String(claim._id), ), }); } } } 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 30,000,000 * - Each part must include `daghi` (option + conditional price/branchId) like V1 * * On success: * - Stores reply in evaluation.damageExpertReply * - Unlocks the workflow * - If any part has factorNeeded=true → status = WAITING_FOR_INSURER_APPROVAL, step = EXPERT_COST_EVALUATION * - Otherwise → status = WAITING_FOR_INSURER_APPROVAL, step = INSURER_REVIEW, claimStatus = APPROVED */ async submitExpertReplyV2( claimRequestId: string, reply: import('./dto/expert-claim-v2.dto').SubmitExpertReplyV2Dto, actor: any, ) { 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 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 const PRICE_CAP = 30_000_000; let totalPrice = 0; for (const part of reply.parts || []) { const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0')); if (!isNaN(parsed)) totalPrice += parsed; } if (totalPrice > PRICE_CAP) { throw new BadRequestException({ message: `Total price (${totalPrice.toLocaleString()}) exceeds the cap of ${PRICE_CAP.toLocaleString()}`, error: 'PRICE_CAP_ERROR', totalPrice, priceCap: PRICE_CAP, }); } const processedParts = reply.parts?.length > 0 ? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts) : []; 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 }), }; const nextStep = needsFactorUpload ? ClaimWorkflowStep.EXPERT_COST_EVALUATION : ClaimWorkflowStep.INSURER_REVIEW; const nextCaseStatus = ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL; const nextClaimStatus = needsFactorUpload ? ClaimStatus.NEEDS_REVISION : ClaimStatus.APPROVED; const updatePayload: Record = { status: nextCaseStatus, claimStatus: nextClaimStatus, 'workflow.locked': false, 'workflow.currentStep': nextStep, 'workflow.nextStep': needsFactorUpload ? ClaimWorkflowStep.INSURER_REVIEW : 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, partsCount: processedParts.length, isFinalReplyAfterObjection, replyField, }, }, }, }; await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updatePayload); if (!needsFactorUpload && claim.blameRequestId) { const blame = await this.blameRequestDbService.findById( claim.blameRequestId.toString(), ); if (blame?.type === BlameRequestType.THIRD_PARTY) { const ownerUserId = claim.owner?.userId ? String(claim.owner.userId) : ""; const ownerParty = (blame.parties || []).find( (p: any) => p?.person?.userId && String(p.person.userId) === ownerUserId, ); const ownerPhone = ownerParty?.person?.phoneNumber; if (ownerPhone && typeof ownerPhone === "string") { const expertLastName = actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; await this.smsOrchestrationService.sendSignatureReviewNotice({ receptor: ownerPhone, fileKind: "claim", publicId: claim.publicId, expertLastName, link: this.smsOrchestrationService.buildClaimLink( String(claim._id), ), }); } } } return { claimRequestId, status: nextCaseStatus, claimStatus: nextClaimStatus, currentStep: nextStep, factorNeeded: needsFactorUpload, 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, ...(note ? { 'evaluation.visitLocation': note } : {}), ...(visitSnapshot && { 'evaluation.inPersonVisitExpertProfileSnapshot': visitSnapshot, }), $push: { history: { event: 'IN_PERSON_VISIT_REQUESTED', performedBy: actor.sub, performedByName: actor.fullName, performedAt: new Date(), note: note || 'Expert requested in-person visit', }, }, }); return { claimRequestId, status: claim.status, claimStatus: ClaimStatus.NEEDS_REVISION, message: 'In-person visit requested. User will be notified.', }; } /** * V2: Count claim cases for this damage expert’s tenant, grouped for dashboard: * `IN_PROGRESS` = user flow before submission complete (CREATED … CAPTURING_PART_DAMAGES); * other {@link ClaimCaseStatus} keys unchanged. */ async getStatusReportBucketsV2(actor: any): Promise> { const clientKey = requireActorClientKey(actor); const rows = await this.claimCaseDbService.find({}, { lean: true }); const buckets = initialClaimExpertReportBuckets(); for (const doc of rows as Record[]) { if (!claimCaseTouchesClient(doc, clientKey)) continue; const st = String(doc.status ?? ""); const key = claimCaseStatusToReportBucket(st); buckets.all++; buckets[key] = (buckets[key] ?? 0) + 1; } 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. WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW at EXPERT_COST_EVALUATION (factor validation queue) */ async getClaimListV2(actor: any): Promise { requireActorClientKey(actor); const actorId = actor.sub; const clientKey = actor.clientKey as string; const claims = await this.claimCaseDbService.find({ $or: [ // Available claims: waiting for expert, not locked { status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, 'workflow.locked': { $ne: true }, }, // This expert's own locked/in-progress claims { 'workflow.locked': true, 'workflow.lockedBy.actorId': new Types.ObjectId(actorId), }, // User uploaded all factors; expert must approve/reject (unlocked queue) { status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, claimStatus: ClaimStatus.UNDER_REVIEW, 'workflow.currentStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION, }, ], }); const filtered = (claims as any[]).filter((c) => claimCaseTouchesClient(c, clientKey), ); const blameIds = [ ...new Set( filtered .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 = filtered.map((c) => { const awaitingFactorValidation = c.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL && c.claimStatus === ClaimStatus.UNDER_REVIEW && c.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION; const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById); const blame = c.blameRequestId ? blameById.get(c.blameRequestId.toString()) : undefined; const fileCtx = blame ? this.blameFileContextForExpert(blame) : {}; return { claimRequestId: c._id.toString(), publicId: c.publicId, status: c.status, currentStep: c.workflow?.currentStep || "", locked: c.workflow?.locked || false, lockedBy: c.workflow?.lockedBy ? { actorId: c.workflow.lockedBy.actorId?.toString(), actorName: c.workflow.lockedBy.actorName, } : undefined, vehicle: v ? { carName: v.carName, carModel: v.carModel, carType: v.carType, } : undefined, ...fileCtx, createdAt: c.createdAt, awaitingFactorValidation, }; }) as ClaimListItemV2Dto[]; return { list, total: list.length }; } /** * 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; } /** 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, }; } /** * True when another expert still holds an active (non-expired) workflow lock. * Missing `lockedAt` on old documents: treat lock as still enforced until cleared. */ private isClaimV2WorkflowLockCurrentlyEnforced(claim: any): boolean { if (!claim?.workflow?.locked) return false; const la = claim.workflow?.lockedAt as Date | string | undefined; if (!la) return true; return ( Date.now() < new Date(la).getTime() + this.claimV2WorkflowLockTtlMs ); } /** * 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 (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; const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException('Claim request not found'); } assertClaimCaseForTenant(claim, actor); const isDamageExpertPhase = claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT || claim.status === ClaimCaseStatus.EXPERT_REVIEWING; const isFactorValidationPending = claim.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL && claim.claimStatus === ClaimStatus.UNDER_REVIEW && claim.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION; if (!isDamageExpertPhase && !isFactorValidationPending) { throw new ForbiddenException( `This claim is not available for expert review. Current status: ${claim.status}`, ); } const lockBlocksOthers = this.isClaimV2WorkflowLockCurrentlyEnforced(claim); if (lockBlocksOthers) { const lockerId = claim.workflow?.lockedBy?.actorId?.toString(); if (lockerId && lockerId !== actorId) { throw new ForbiddenException( `This claim is locked by another expert: ${claim.workflow.lockedBy?.actorName}`, ); } } const hasCapture = (data: any, key: string) => data && (data instanceof Map ? data.get(key) : data[key]); // Build requiredDocuments map const requiredDocs = claim.requiredDocuments as any; const requiredDocumentsStatus: Record = {}; 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]; requiredDocumentsStatus[k] = { uploaded: !!doc?.uploaded, fileId: doc?.fileId?.toString(), fileName: doc?.fileName, filePath: doc?.filePath ? buildFileLink(doc.filePath) : undefined, }; } } // Build car angles map const carAnglesData = claim.media?.carAngles as any; const carAngles: Record = {}; for (const k of ['front', 'back', 'left', 'right']) { const cap = hasCapture(carAnglesData, k); carAngles[k] = { captured: !!cap, url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined), }; } // Build damaged parts map const getPartLabelFa = (key: string): string => { const labels: Record = { hood: "کاپوت", trunk: "صندوق عقب", roof: "سقف", front_right_door: "درب جلو راست", front_left_door: "درب جلو چپ", rear_right_door: "درب عقب راست", rear_left_door: "درب عقب چپ", front_bumper: "سپر جلو", rear_bumper: "سپر عقب", front_right_fender: "گلگیر جلو راست", front_left_fender: "گلگیر جلو چپ", rear_right_fender: "گلگیر عقب راست", rear_left_fender: "گلگیر عقب چپ", }; return labels[key] || key; }; const damagedPartsData = claim.media?.damagedParts as any; const damagedParts: Record< string, { label_fa: string; captured: boolean; url?: string } > = {}; for (const p of claim.damage?.selectedParts || []) { const cap = hasCapture(damagedPartsData, p); damagedParts[p] = { label_fa: getPartLabelFa(p), captured: !!cap, url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined), }; } // --- Combine both branches of the conflict, preserving necessary logic from both --- // 1. Vehicle payload logic from "upstream" let vehiclePayload = claim.vehicle as any; const hasClaimVehicle = vehiclePayload && (vehiclePayload.carName || vehiclePayload.carModel || vehiclePayload.carType || vehiclePayload.plate); let blameLean: any = null; // 2. Video capture and blame case db queries from "stashed" 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 claim vehicle not found and blameLean exists, replace vehiclePayload if (!hasClaimVehicle && blameLean) { const fromBlame = this.damagedPartyVehicleFromBlame(blameLean, claim); if (fromBlame) vehiclePayload = fromBlame; } // 3. Blame file context logic from "upstream" const blameFileContext = blameLean ? this.blameFileContextForExpert(blameLean) : {}; // 4. videoCapture logic from "stashed" 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; return { claimRequestId: claim._id.toString(), publicId: claim.publicId, status: claim.status, 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(), } : undefined, owner: claim.owner ? { userId: claim.owner.userId?.toString(), fullName: claim.owner.fullName, } : undefined, vehicle: vehiclePayload, ...blameFileContext, blameRequestId: claim.blameRequestId?.toString(), blameRequestNo: claim.blameRequestNo, money: moneyPayload, selectedParts: claim.damage?.selectedParts, otherParts: claim.damage?.otherParts, requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined, carAngles, damagedParts, awaitingFactorValidation: isFactorValidationPending, evaluation: isFactorValidationPending ? { damageExpertReply: (claim as any).evaluation?.damageExpertReply, damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal, } : undefined, videoCapture, blameCase: blameCase ?? undefined, blameExpertDecision, createdAt: (claim as any).createdAt, updatedAt: (claim as any).updatedAt, }; } /** * 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, ) { 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 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 previous = Array.isArray((claim as any).damage?.selectedParts) ? [...(claim as any).damage.selectedParts] : []; const selectedParts = body.selectedParts as string[]; if (!(claim as any).damage) (claim as any).damage = {}; (claim as any).damage.selectedParts = selectedParts; // Keep damaged-parts captures consistent with updated selection. const damagedPartsMedia = (claim as any).media?.damagedParts; if (damagedPartsMedia) { const selectedSet = new Set(selectedParts); if (damagedPartsMedia instanceof Map) { for (const key of Array.from(damagedPartsMedia.keys())) { if (!selectedSet.has(key)) damagedPartsMedia.delete(key); } } else { for (const key of Object.keys(damagedPartsMedia)) { if (!selectedSet.has(key)) delete damagedPartsMedia[key]; } } } if (!Array.isArray((claim as any).history)) (claim as any).history = []; (claim as any).history.push({ event: "EXPERT_DAMAGED_PARTS_UPDATED", performedBy: actor.sub, performedByName: actor.fullName, performedAt: new Date(), note: "Damage expert edited selected damaged parts", metadata: { previousSelectedParts: previous, selectedParts, ...(damagedPartsEditSnapshot && { expertProfileSnapshot: damagedPartsEditSnapshot, }), }, }); await (claim as any).save(); return { claimRequestId: claim._id.toString(), selectedParts, previousSelectedParts: previous, message: "Damaged parts updated successfully.", }; } }