diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 89e32d3..f384348 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -145,6 +145,17 @@ import { HttpService } from "@nestjs/axios"; import { firstValueFrom } from "rxjs"; import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher"; +export interface FanavaranAutoSubmitResult { + attempted: boolean; + submitted: boolean; + skipped?: boolean; + skipReason?: string; + warning?: string; + claimNo?: number; + claimId?: number; + fanavaranResponse?: unknown; +} + @Injectable() export class ClaimRequestManagementService { private readonly logger = new Logger(ClaimRequestManagementService.name); @@ -168,6 +179,41 @@ export class ClaimRequestManagementService { private readonly CONTRACT_ID = "263"; private readonly LOCATION = "100"; + private readonly PARSIAN_FANAVARAN_CONFIG = { + appName: "ParsianService", + secret: "P@r30@n$erv!ce", + username: "ParsianServiceUser", + password: "P@r30@n123", + corpId: "543", + contractId: "28", + location: "210050", + } as const; + + /** Tejaratno hardcoded Fanavaran lookup defaults (legacy). */ + private readonly TEJARATNO_FANAVARAN_DEFAULTS = { + AccidentCityId: 701, + AccidentReportTypeId: 155, + AccidentVehicleUsedId: 1, + ClaimExpertId: 1589, + CompensationReferenceId: 167, + CulpritLicenceTypeId: 2, + CulpritTypeId: 337, + } as const; + + /** Parsian-validated Fanavaran lookup defaults (verified via direct submit). */ + private readonly PARSIAN_FANAVARAN_DEFAULTS = { + AccidentCityId: 701, + AccidentReportTypeId: 155, + AccidentVehicleUsedId: 1, + ClaimExpertId: 154, + CompensationReferenceId: 167, + CulpritLicenceTypeId: 2, + CulpritTypeId: 337, + } as const; + + private readonly MAP_IR_API_KEY = + "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg"; + constructor( private readonly blameDbService: RequestManagementDbService, private readonly claimDbService: ClaimRequestManagementDbService, @@ -3164,15 +3210,13 @@ export class ClaimRequestManagementService { lon: number, ): Promise { try { - const apiKey = - "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg"; const response = await firstValueFrom( this.httpService.get( `https://map.ir/fast-reverse?lat=${lat}&lon=${lon}`, { headers: { accept: "application/json", - "x-api-key": apiKey, + "x-api-key": this.MAP_IR_API_KEY, }, }, ), @@ -3220,6 +3264,140 @@ export class ClaimRequestManagementService { return ids; } + private getTejaratnoFanavaranConfig() { + return { + appName: this.APP_NAME, + secret: this.SECRET, + username: this.USERNAME, + password: this.PASSWORD, + corpId: this.CORP_ID, + contractId: this.CONTRACT_ID, + location: this.LOCATION, + }; + } + + private resolveAccidentCauseId(accidentReason?: { + id?: string | number | null; + fanavaran?: number | null; + }): number | null { + const fanavaranValue = accidentReason?.fanavaran; + const idValue = accidentReason?.id; + + if (fanavaranValue !== undefined && fanavaranValue !== null) { + if (fanavaranValue === 0) { + return idValue !== undefined && idValue !== null ? Number(idValue) : null; + } + return fanavaranValue; + } + + return null; + } + + private applyFanavaranDefaultFields(result: Record): void { + result.AccidentCulpritId = null; + result.CouponNo = null; + result.CourtArchiveNo = null; + result.CulpritLicenceCityId = null; + result.CulpritLicenceCountryId = null; + result.CulpritLicenceForeignCityName = null; + result.CulpritLicenceIssuDate = "1394/10/13"; + result.CulpritLicenceNo = "1124242"; + result.DamagedCount = 1; + result.DmgAssessorFirstCreationTime = null; + result.EntryDate = null; + result.IsLicenseMatchWithVehicleKind = 1; + result.HasOtherCulprit = 0; + result.IsAccidentOutOfBorder = 0; + result.IsFatalAccident = 0; + result.IsPlaqueChanged = 0; + result.PlaqueCityId = null; + result.PlaqueKindId = null; + result.PlaqueLeftNo = null; + result.PlaqueMiddleCodeId = null; + result.PlaqueNo = null; + result.PlaqueRightNo = null; + result.PlaqueSampleId = null; + result.PlaqueSerial = null; + result.PoliceOfficerId = 1; + result.PoliceReportDesc = null; + result.PoliceReportSeri = null; + result.PoliceReportSerial = null; + result.PolicyId = null; + result.PreviousPolicyEndDate = ""; + result.TrackingCode = null; + result.IsLicenseReplacement = 0; + result.UnknownCulpritCauseId = null; + } + + private async buildFanavaranSubmitPayload(input: { + accidentReason?: { id?: string | number | null; fanavaran?: number | null }; + createdAt?: Date | string; + location?: { lat?: number; lon?: number }; + damageParts?: any[]; + resolvePolicyId: () => Promise; + logPrefix: string; + defaults?: { + AccidentCityId: number; + AccidentReportTypeId: number; + AccidentVehicleUsedId: number; + ClaimExpertId: number; + CompensationReferenceId: number; + CulpritLicenceTypeId: number; + CulpritTypeId: number; + }; + }): Promise> { + const lookupDefaults = + input.defaults ?? this.TEJARATNO_FANAVARAN_DEFAULTS; + const result: Record = { + ...lookupDefaults, + AccidentCauseId: this.resolveAccidentCauseId(input.accidentReason), + }; + + if (input.accidentReason && result.AccidentCauseId === null) { + this.logger.warn( + `${input.logPrefix} accidentReason exists but fanavaran is null or undefined`, + ); + } else if (!input.accidentReason) { + this.logger.error( + `${input.logPrefix} expert decision / accidentReason is missing`, + ); + } + + if (input.createdAt) { + result.AccidentDate = this.convertToPersianDate(input.createdAt); + result.AnnouncementDate = result.AccidentDate; + result.DocReceivedDate = result.AccidentDate; + result.AccidentTime = this.getTime24Hour(input.createdAt); + } + + if (input.location?.lat && input.location?.lon) { + result.AccidentLocationAddress = await this.getAddressFromCoordinates( + input.location.lat, + input.location.lon, + ); + } + + result.EstimateAmount = input.damageParts + ? this.calculateEstimateAmount(input.damageParts) + : 0; + + this.applyFanavaranDefaultFields(result); + + try { + const policyId = await input.resolvePolicyId(); + if (policyId !== undefined && policyId !== null) { + result.PolicyId = policyId; + } + } catch (error) { + this.logger.error( + `${input.logPrefix} Failed to get PolicyId from external API:`, + error, + ); + } + + return result; + } + /** * Fanavaran Submit - Map data from claim-request-management and request-management to third-party-car-financial-claims format */ @@ -3438,11 +3616,16 @@ export class ClaimRequestManagementService { /** * Step 1: Get appToken from GetAppToken API */ - private async getAppToken(): Promise { + private async getAppToken( + config: { + appName: string; + secret: string; + } = this.getTejaratnoFanavaranConfig(), + ): Promise { try { const requestHeaders: any = { - appname: this.APP_NAME, - secret: this.SECRET, + appname: config.appName, + secret: config.secret, "Content-Length": "0", }; delete requestHeaders["Content-Type"]; @@ -3494,12 +3677,18 @@ export class ClaimRequestManagementService { /** * Step 2: Login to get authenticationToken */ - private async login(appToken: string): Promise { + private async login( + appToken: string, + config: { + username: string; + password: string; + } = this.getTejaratnoFanavaranConfig(), + ): Promise { try { const requestHeaders: any = { appToken: appToken, - userName: this.USERNAME, - password: this.PASSWORD, + userName: config.username, + password: config.password, "Content-Length": "0", }; delete requestHeaders["Content-Type"]; @@ -3553,6 +3742,65 @@ export class ClaimRequestManagementService { } } + private async getPolicyIdFromNationalCode( + nationalCodeOfInsurer: string, + config: { + appName: string; + secret: string; + username: string; + password: string; + corpId: string; + contractId: string; + location: string; + }, + logPrefix: string, + ): Promise { + try { + const appToken = await this.getAppToken(config); + const authenticationToken = await this.login(appToken, config); + + const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`; + + this.logger.log( + `${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`, + ); + + const response = await firstValueFrom( + this.httpService.get(policyInquiryUrl, { + headers: { + authenticationToken: authenticationToken, + CorpId: config.corpId, + ContractId: config.contractId, + Location: config.location, + "Content-Type": "application/json", + }, + timeout: 15000, + }), + ); + + if (Array.isArray(response.data) && response.data.length > 0) { + const lastPolicy = response.data[response.data.length - 1]; + const policyId = lastPolicy?.PolicyId; + if (policyId !== undefined && policyId !== null) { + this.logger.log( + `${logPrefix} Successfully retrieved PolicyId from last policy entry: ${policyId}`, + ); + return policyId; + } + } + + this.logger.warn(`${logPrefix} No PolicyId found in API response`); + return null; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "unknown policy inquiry error"; + this.logger.warn( + `${logPrefix} Policy inquiry failed; continuing with empty PolicyId: ${errorMessage}`, + ); + return null; + } + } + /** * Get PolicyId from external API */ @@ -3600,63 +3848,422 @@ export class ClaimRequestManagementService { return null; } - // Get authenticationToken - const appToken = await this.getAppToken(); - const authenticationToken = await this.login(appToken); - - // Call external API to get PolicyId - const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`; - - this.logger.log( - `[Fanavaran Submit] Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`, + return await this.getPolicyIdFromNationalCode( + nationalCodeOfInsurer, + this.getTejaratnoFanavaranConfig(), + `[Fanavaran Submit] claimRequestId=${claimRequestId}`, ); - - const response = await firstValueFrom( - this.httpService.get(policyInquiryUrl, { - headers: { - authenticationToken: authenticationToken, - CorpId: this.CORP_ID, - ContractId: this.CONTRACT_ID, - Location: this.LOCATION, - "Content-Type": "application/json", - }, - }), - ); - - if ( - Array.isArray(response.data) && - response.data.length > 0 && - response.data[0].PolicyId - ) { - const policyId = response.data[0].PolicyId; - this.logger.log( - `[Fanavaran Submit] Successfully retrieved PolicyId: ${policyId}`, - ); - return policyId; - } else { - this.logger.warn( - `[Fanavaran Submit] No PolicyId found in API response`, - ); - return null; - } } catch (error) { this.logger.error( `[Fanavaran Submit] Error getting PolicyId from external API:`, error, ); - if (isAxiosError(error)) { - const errorMessage = - error.response?.data?.Message || - error.response?.data?.message || - error.response?.data || - error.message || - "Failed to get PolicyId from external API"; - this.logger.error(`[Fanavaran Submit] Error message: ${errorMessage}`); + return null; + } + } + + private getNationalCodeOfInsurerForGuiltyPartyV2( + parties: Array<{ + role?: PartyRole; + person?: { userId?: Types.ObjectId; nationalCodeOfInsurer?: string }; + statement?: { admitsGuilt?: boolean }; + }>, + guiltyPartyId?: Types.ObjectId | string | null, + ): string | null { + if (!guiltyPartyId) { + return null; + } + + const guiltyParty = parties.find( + (party) => party.person?.userId?.toString() === guiltyPartyId.toString(), + ); + + return guiltyParty?.person?.nationalCodeOfInsurer ?? null; + } + + private resolveGuiltyPartyIdV2( + parties: Array<{ + role?: PartyRole; + person?: { userId?: Types.ObjectId }; + statement?: { admitsGuilt?: boolean }; + }>, + decisionGuiltyPartyId?: Types.ObjectId | string | null, + ): Types.ObjectId | string | null { + if (decisionGuiltyPartyId) { + return decisionGuiltyPartyId; + } + + const admitsGuiltParty = parties.find( + (party) => party.statement?.admitsGuilt && party.person?.userId, + ); + if (admitsGuiltParty?.person?.userId) { + return admitsGuiltParty.person.userId; + } + + // Legacy-compatible safety fallback: when no explicit guilty marker exists, + // use FIRST party if available (same heuristic used by older flows). + const firstParty = parties.find( + (party) => party.role === PartyRole.FIRST && party.person?.userId, + ); + return firstParty?.person?.userId ?? null; + } + + /** + * Fanavaran Submit (Parsian / V2) - Map data from claimCases + blameCases + */ + async fanavaranSubmitParsianV2( + claimCaseId: string, + options?: { debug?: boolean }, + ): Promise { + try { + const debug = { + claimCaseId, + steps: { + claimCaseFound: false, + blameRequestLinked: false, + blameCaseFound: false, + expertDecisionFound: false, + accidentReasonFound: false, + firstPartyFound: false, + firstPartyLocationFound: false, + damageReplyFound: false, + guiltyPartyIdFound: false, + nationalCodeOfInsurerFound: false, + policyInquiryAttempted: false, + policyInquirySucceeded: false, + accidentReasonFallbackFromSnapshot: false, + guiltyPartyFallbackUsed: false, + }, + values: { + blameRequestId: null as string | null, + guiltyPartyId: null as string | null, + nationalCodeOfInsurer: null as string | null, + policyId: null as number | null, + estimateAmount: null as number | null, + }, + failureReason: null as string | null, + }; + + const claimCase = await this.claimCaseDbService.findById(claimCaseId); + if (!claimCase) { + throw new NotFoundException("Claim case not found"); } + debug.steps.claimCaseFound = true; + + if (!claimCase.blameRequestId) { + debug.failureReason = "claimCase.blameRequestId is missing"; + throw new BadRequestException("Blame case not linked to claim case"); + } + debug.steps.blameRequestLinked = true; + debug.values.blameRequestId = claimCase.blameRequestId.toString(); + + const blameCase = await this.blameRequestDbService.findById( + claimCase.blameRequestId, + ); + if (!blameCase) { + throw new NotFoundException("Blame case not found"); + } + debug.steps.blameCaseFound = true; + + const expertDecision = blameCase.expert?.decision; + debug.steps.expertDecisionFound = !!expertDecision; + const fallbackAccidentReason = claimCase.snapshot?.accident?.classification?.accidentReason; + if (!expertDecision?.fields?.accidentReason && fallbackAccidentReason) { + debug.steps.accidentReasonFallbackFromSnapshot = true; + } + const selectedAccidentReason = + expertDecision?.fields?.accidentReason ?? fallbackAccidentReason; + debug.steps.accidentReasonFound = !!selectedAccidentReason; + + const firstParty = blameCase.parties?.find( + (party) => party.role === PartyRole.FIRST, + ); + debug.steps.firstPartyFound = !!firstParty; + debug.steps.firstPartyLocationFound = !!firstParty?.location; + const activeExpertReply = getActiveV2ExpertReply( + claimCase as unknown as { evaluation?: Record }, + ); + const damageParts = activeExpertReply?.parts; + debug.steps.damageReplyFound = !!damageParts?.length; + if (options?.debug) { + debug.values.estimateAmount = damageParts + ? this.calculateEstimateAmount(damageParts) + : 0; + } + + const payload = await this.buildFanavaranSubmitPayload({ + accidentReason: selectedAccidentReason, + createdAt: (blameCase as { createdAt?: Date }).createdAt, + location: firstParty?.location, + damageParts, + defaults: this.PARSIAN_FANAVARAN_DEFAULTS, + logPrefix: `[Fanavaran Parsian V2] claimCaseId=${claimCaseId}`, + resolvePolicyId: async () => { + const guiltyPartyId = this.resolveGuiltyPartyIdV2( + blameCase.parties ?? [], + expertDecision?.guiltyPartyId, + ); + if (!expertDecision?.guiltyPartyId && guiltyPartyId) { + debug.steps.guiltyPartyFallbackUsed = true; + } + debug.steps.guiltyPartyIdFound = !!guiltyPartyId; + debug.values.guiltyPartyId = guiltyPartyId + ? guiltyPartyId.toString() + : null; + const nationalCodeOfInsurer = this.getNationalCodeOfInsurerForGuiltyPartyV2( + blameCase.parties ?? [], + guiltyPartyId, + ); + debug.steps.nationalCodeOfInsurerFound = !!nationalCodeOfInsurer; + debug.values.nationalCodeOfInsurer = nationalCodeOfInsurer; + + if (!guiltyPartyId) { + this.logger.warn( + `[Fanavaran Parsian V2] guiltyPartyId not found in expert.decision`, + ); + debug.failureReason = "expert.decision.guiltyPartyId is missing"; + return null; + } + + if (!nationalCodeOfInsurer) { + this.logger.warn( + `[Fanavaran Parsian V2] nationalCodeOfInsurer not found for guiltyPartyId: ${guiltyPartyId}`, + ); + debug.failureReason = + "nationalCodeOfInsurer not found for guilty party in blameCase.parties"; + return null; + } + + debug.steps.policyInquiryAttempted = true; + const policyId = await this.getPolicyIdFromNationalCode( + nationalCodeOfInsurer, + this.PARSIAN_FANAVARAN_CONFIG, + `[Fanavaran Parsian V2] claimCaseId=${claimCaseId}`, + ); + debug.values.policyId = policyId; + debug.steps.policyInquirySucceeded = policyId !== null; + if (policyId === null) { + debug.failureReason = + debug.failureReason ?? + "policy inquiry returned no PolicyId (timeout, network error, or empty response)"; + } + return policyId; + }, + }); + + if (options?.debug) { + return { + payload, + debug, + }; + } + + return payload; + } catch (error) { + this.logger.error("Error in fanavaranSubmitParsianV2", error); throw error; } } + /** + * Submit Parsian fanavaran data (V2 collections) to external API + */ + async submitToFanavaranParsianV2(claimCaseId: string): Promise { + try { + return await this.executeFanavaranParsianV2Submit(claimCaseId); + } catch (error) { + this.logger.error( + `[Fanavaran Parsian V2] Error submitting to Fanavaran`, + error, + ); + throw new BadGatewayException(this.extractFanavaranErrorMessage(error)); + } + } + + /** + * Auto-submit to Fanavaran when a claim case reaches COMPLETED. + * Never throws — failures are returned as warnings so the main flow continues. + */ + async autoSubmitToFanavaranParsianV2OnClaimCompleted( + claimCaseId: string, + ): Promise { + const logPrefix = `[Fanavaran Parsian V2 Auto] claimCaseId=${claimCaseId}`; + + try { + const claimCase = await this.claimCaseDbService.findById(claimCaseId); + if (!claimCase) { + return { + attempted: false, + submitted: false, + warning: + "Claim case not found for Fanavaran auto-submit. Retry manually when available.", + }; + } + + if (claimCase.claimId != null || claimCase.claimNo != null) { + return { + attempted: false, + submitted: false, + skipped: true, + skipReason: "Already submitted to Fanavaran", + claimId: claimCase.claimId, + claimNo: claimCase.claimNo, + }; + } + + const fanavaranResponse = await this.executeFanavaranParsianV2Submit( + claimCaseId, + ); + + await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { + $push: { + history: { + type: "FANAVARAN_AUTO_SUBMIT_SUCCEEDED", + actor: { actorType: "system" }, + timestamp: new Date(), + metadata: { + claimNo: fanavaranResponse?.ClaimNo, + claimId: fanavaranResponse?.Id, + }, + }, + }, + }); + + return { + attempted: true, + submitted: true, + claimNo: fanavaranResponse?.ClaimNo, + claimId: fanavaranResponse?.Id, + fanavaranResponse, + }; + } catch (error) { + const warning = this.extractFanavaranErrorMessage(error); + this.logger.warn(`${logPrefix} Auto-submit failed: ${warning}`); + + try { + await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { + $push: { + history: { + type: "FANAVARAN_AUTO_SUBMIT_FAILED", + actor: { actorType: "system" }, + timestamp: new Date(), + metadata: { error: warning }, + }, + }, + }); + } catch (historyError) { + this.logger.error( + `${logPrefix} Failed to record Fanavaran auto-submit failure history`, + historyError, + ); + } + + return { + attempted: true, + submitted: false, + warning: `${warning} Case was not sent to Fanavaran. Retry manually via POST /v2/claim-request-management/fanavaran-submit/parsian/${claimCaseId}.`, + }; + } + } + + private extractFanavaranErrorMessage(error: unknown): string { + if (isAxiosError(error)) { + return ( + error.response?.data?.message || + error.response?.data?.Message || + (typeof error.response?.data === "string" + ? error.response.data + : undefined) || + error.message || + "Failed to submit data to Fanavaran API" + ); + } + if (error instanceof HttpException) { + const response = error.getResponse(); + if (typeof response === "string") return response; + if (response && typeof response === "object" && "message" in response) { + const message = (response as { message?: string | string[] }).message; + return Array.isArray(message) ? message.join(", ") : String(message); + } + } + if (error instanceof Error) { + return error.message; + } + return "Failed to submit data to Fanavaran API"; + } + + private async executeFanavaranParsianV2Submit( + claimCaseId: string, + ): Promise { + const config = this.PARSIAN_FANAVARAN_CONFIG; + const logPrefix = `[Fanavaran Parsian V2]`; + + this.logger.log( + `${logPrefix} Starting submission for claimCaseId: ${claimCaseId}`, + ); + const fanavaranData = await this.fanavaranSubmitParsianV2(claimCaseId); + this.logger.log( + `${logPrefix} Mapped data prepared:`, + JSON.stringify(fanavaranData, null, 2), + ); + + if ( + fanavaranData.AccidentCauseId === undefined || + fanavaranData.AccidentCauseId === null + ) { + this.logger.error( + `${logPrefix} CRITICAL: AccidentCauseId is missing or null!`, + ); + } else { + this.logger.log( + `${logPrefix} AccidentCauseId validated: ${fanavaranData.AccidentCauseId}`, + ); + } + + const appToken = await this.getAppToken(config); + const authenticationToken = await this.login(appToken, config); + + const response = await firstValueFrom( + this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, { + headers: { + authenticationToken: authenticationToken, + CorpId: config.corpId, + ContractId: config.contractId, + Location: config.location, + "Content-Type": "application/json", + }, + }), + ); + + this.logger.log(`${logPrefix} API Response Status: ${response.status}`); + this.logger.log( + `${logPrefix} API Response Data:`, + JSON.stringify(response.data, null, 2), + ); + + if (response.data) { + const claimNo = response.data.ClaimNo; + const claimId = response.data.Id; + + if (claimNo !== undefined || claimId !== undefined) { + const updateData: any = { $set: {} as Record }; + if (claimNo !== undefined) { + updateData.$set.claimNo = claimNo; + } + if (claimId !== undefined) { + updateData.$set.claimId = claimId; + } + + await this.claimCaseDbService.findByIdAndUpdate( + claimCaseId, + updateData, + ); + } + } + + return response.data; + } + /** * Submit fanavaran data to external API */ @@ -6241,6 +6848,7 @@ export class ClaimRequestManagementService { currentStep?: ClaimWorkflowStep; accepted: boolean; phase?: "PRICED_PARTS_FOR_FACTORS" | "FINAL_APPROVAL"; + fanavaran?: FanavaranAutoSubmitResult; }> { if (!Types.ObjectId.isValid(claimRequestId)) { throw new BadRequestException("Invalid claim request id"); @@ -6493,14 +7101,26 @@ export class ClaimRequestManagementService { }, }); + const fanavaran = + await this.autoSubmitToFanavaranParsianV2OnClaimCompleted(claimRequestId); + + let message = "Your signature has been recorded. The claim is completed."; + if (fanavaran.submitted) { + message += + " The claim was sent to Fanavaran successfully."; + } else if (fanavaran.warning) { + message += ` ${fanavaran.warning}`; + } + return { - message: "Your signature has been recorded. The claim is completed.", + message, claimRequestId, status: ClaimCaseStatus.COMPLETED, claimStatus: ClaimStatus.APPROVED, currentStep: ClaimWorkflowStep.CLAIM_COMPLETED, accepted: true, phase: "FINAL_APPROVAL", + fanavaran, }; } diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index c1edcf8..70fcbd5 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -1029,4 +1029,44 @@ Returns status of each item (uploaded/captured or not). ); } } + + @Get("fanavaran-submit/parsian/:claimCaseId") + @ApiOperation({ + summary: "Preview Fanavaran submit body (Parsian / V2)", + description: + "Builds the third-party-car-financial-claims payload from claimCases + blameCases without calling Fanavaran.", + }) + @ApiParam({ + name: "claimCaseId", + description: "The claim case ID (MongoDB ObjectId)", + }) + async fanavaranSubmitParsianV2( + @Param("claimCaseId") claimCaseId: string, + @Query("debug") debug?: string, + ) { + return await this.claimRequestManagementService.fanavaranSubmitParsianV2( + claimCaseId, + { + debug: debug === "1" || debug === "true", + }, + ); + } + + @Post("fanavaran-submit/parsian/:claimCaseId") + @ApiOperation({ + summary: "Submit claim to Fanavaran (Parsian / V2)", + description: + "Authenticates with Parsian Fanavaran credentials and submits the mapped claimCases + blameCases payload.", + }) + @ApiParam({ + name: "claimCaseId", + description: "The claim case ID (MongoDB ObjectId)", + }) + async submitToFanavaranParsianV2( + @Param("claimCaseId") claimCaseId: string, + ) { + return await this.claimRequestManagementService.submitToFanavaranParsianV2( + claimCaseId, + ); + } } diff --git a/src/constants/repair-amount-limits.ts b/src/constants/repair-amount-limits.ts index 9166cc8..76b4a8c 100644 --- a/src/constants/repair-amount-limits.ts +++ b/src/constants/repair-amount-limits.ts @@ -10,3 +10,27 @@ export const REPAIR_LINE_AMOUNT_TOMAN = { /** Max sum of all priced + factor lines in one expert reply / validation (Toman). */ export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX; + +const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]); + +/** + * Returns null when the claim v2 total cap is disabled. + * + * Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again. + * Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount. + */ +export function getClaimV2TotalPaymentCapToman(): number | null { + const capEnabled = ENABLED_VALUES.has( + String(process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED ?? "") + .trim() + .toLowerCase(), + ); + + if (!capEnabled) return null; + + const configuredCap = Number(process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN); + + return Number.isFinite(configuredCap) && configuredCap > 0 + ? configuredCap + : CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN; +} diff --git a/src/core/config/config.schema.ts b/src/core/config/config.schema.ts index 3f414d5..e0c6804 100644 --- a/src/core/config/config.schema.ts +++ b/src/core/config/config.schema.ts @@ -84,4 +84,26 @@ export class EnvironmentVariables { @IsOptional() HASH_PEPPER?: string; // --------------------------------------------------------- // + + @IsOptional() + @IsString({ message: "CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED must be string" }) + CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED?: string; + + // --------------------------------------------------------- // + + @IsOptional() + @IsNumber( + { + allowNaN: false, + allowInfinity: false, + }, + { + message: "CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN must be a valid number", + }, + ) + @IsPositive({ + message: "CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN must be a positive number", + }) + CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN?: number; + // --------------------------------------------------------- // } diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 594a80c..139076f 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -16,6 +16,10 @@ 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"; @@ -135,14 +139,12 @@ import { } 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 { CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN } from "src/constants/repair-amount-limits"; +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"; -const CLAIM_V2_TOTAL_PAYMENT_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN; - @Injectable() export class ExpertClaimService { private readonly logger = new Logger(ExpertClaimService.name); @@ -268,8 +270,22 @@ export class ExpertClaimService { 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 @@ -1539,9 +1555,9 @@ export class ExpertClaimService { ); } - // Validate total price cap (priced lines sum) - if (reply.parts && reply.parts.length > 0) { - const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP; + // 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) { @@ -1570,13 +1586,13 @@ export class ExpertClaimService { } } - if (totalPrice > PRICE_CAP) { + 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 (${PRICE_CAP.toLocaleString()}).`, + 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: PRICE_CAP, + priceCap, }); } } @@ -2138,7 +2154,7 @@ export class ExpertClaimService { * 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). - * Total of all repair lines must be ≤ `CLAIM_V2_TOTAL_PAYMENT_CAP` Toman (53_000_000; same as initial expert reply). + * When enabled by env, total of all repair lines must be ≤ the claim v2 total payment cap. * Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`. */ async validateClaimFactorsV2( @@ -2273,25 +2289,27 @@ export class ExpertClaimService { }; } - const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP; - let totalPrice = 0; - for (const part of updatedReply.parts || []) { - const line = this.claimReplyPartLineTotalForCap(part); - if (isNaN(line)) { + 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: `Part ${String(part.partId ?? "")}: invalid amount for price total — use totalPayment or price and salary.`, + 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, }); } - totalPrice += line; - } - if (totalPrice > PRICE_CAP) { - 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 (${PRICE_CAP.toLocaleString()}).`, - error: "PRICE_CAP_ERROR", - totalPrice, - priceCap: PRICE_CAP, - }); } const historyActor = { @@ -2309,13 +2327,20 @@ export class ExpertClaimService { historyActor, { replyField }, ); + const fanavaran = + await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted( + claimRequestId, + ); return { - message: + 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, }; } @@ -2328,13 +2353,21 @@ export class ExpertClaimService { { replyField }, ); + const fanavaran = + await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted( + claimRequestId, + ); + return { - message: + 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, }; } @@ -2878,20 +2911,22 @@ export class ExpertClaimService { throw new ForbiddenException("This claim is locked by another expert"); } - // Price cap validation - const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP; - 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: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`, - error: "PRICE_CAP_ERROR", - totalPrice, - priceCap: PRICE_CAP, - }); + // 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