From 8ba97537a45acb241400998d31f4b554d3d92a77 Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Mon, 20 Jul 2026 16:28:25 +0330 Subject: [PATCH] update the fanavaran for both tejarat no and parsian clients , dont forget about the env files --- .env.example | 21 + package.json | 1 + src/ai/ai.module.ts | 3 +- src/app.module.ts | 9 +- .../claim-request-management.module.ts | 10 +- .../claim-request-management.service.ts | 902 +++++++++++------- .../entites/schema/claim-cases.schema.ts | 9 + src/common/auth/guards/auth.guard.ts | 4 +- src/core/config/fanavaran-client.config.ts | 7 +- src/core/config/http-proxy.factory.ts | 26 + src/expert-claim/expert-claim.module.ts | 8 +- src/expert-claim/expert-claim.service.ts | 13 +- src/fanavaran/fanavaran-lookup.config.ts | 10 + src/fanavaran/fanavaran-lookup.module.ts | 10 +- src/fanavaran/fanavaran-lookup.service.ts | 132 ++- src/lookups/lookups.controller.ts | 101 +- src/lookups/lookups.service.ts | 26 + .../entities/schema/partyRole.enum.ts | 4 + src/sand-hub/sand-hub.module.ts | 8 +- 19 files changed, 925 insertions(+), 379 deletions(-) create mode 100644 src/core/config/http-proxy.factory.ts diff --git a/.env.example b/.env.example index d799680..51a6cd4 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ NODE_ENV = PORT = CLIENT_ID = CLIENT_NAME = +FANAVARAN_CLIENT=parsian +INSURANCE_CORP_ID='شرکت بيمه پارسيان(بيمه گر)' +CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=false +CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN=53000000 # --------------------------------------------- # 🌐 Application URLs # --------------------------------------------- @@ -63,6 +67,23 @@ AUTH_SMS_TEMPLATE = EXP_OTP_TIME = FAKE_OTP = +# --------------------------------------------- +# 🌐 Proxy Configuration (Local Development Only) +# --------------------------------------------- +# NOTE: These proxy settings are for local development only. +# When deploying to the server, comment out or remove these lines +# as the server IP is already whitelisted by Fanavaran. +# SOCKS_PROXY_HOST = localhost +# SOCKS_PROXY_PORT = 6565 + +# --------------------------------------------- +# 🏢 Fanavaran Insurance Corp +# --------------------------------------------- +# Caption from Fanavaran insurance-corp lookup used to resolve InsuranceCorpId +# for damage-case payloads. Must match a Caption in the insurance-corp code-list. +# Example: "شرکت بيمه تجارت نو" +INSURANCE_CORP_ID = + # --------------------------------------------- # ⚙️ Application Features / Flags # --------------------------------------------- diff --git a/package.json b/package.json index ea527ee..bd449eb 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "pdfkit": "^0.19.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socks-proxy-agent": "^8.0.4", "svg-captcha": "^1.4.0" }, "devDependencies": { diff --git a/src/ai/ai.module.ts b/src/ai/ai.module.ts index dc61f17..a7d4d44 100644 --- a/src/ai/ai.module.ts +++ b/src/ai/ai.module.ts @@ -1,9 +1,8 @@ -import { HttpModule } from "@nestjs/axios"; import { Module } from "@nestjs/common"; import { AiService } from "./ai.service"; @Module({ - imports: [HttpModule], + imports: [], providers: [AiService], exports: [AiService], }) diff --git a/src/app.module.ts b/src/app.module.ts index 3d42844..3162f06 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,7 +1,8 @@ import { join } from "node:path"; import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core"; import { Module } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { HttpModule } from "@nestjs/axios"; import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor"; import { MongooseModule } from "@nestjs/mongoose"; import { ServeStaticModule } from "@nestjs/serve-static"; @@ -28,9 +29,15 @@ import { WorkflowStepManagementModule } from "./workflow-step-management/workflo import { DatabaseModule } from "./core/database/database.module"; import { AppConfigModule } from "./core/config/config.module"; import { SuperAdminModule } from "./super-admin/super-admin.module"; +import { createHttpModuleOptions } from "./core/config/http-proxy.factory"; @Module({ imports: [ + HttpModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: createHttpModuleOptions, + }), AppConfigModule, DatabaseModule, CronModule, diff --git a/src/claim-request-management/claim-request-management.module.ts b/src/claim-request-management/claim-request-management.module.ts index 9afeab6..a61076f 100644 --- a/src/claim-request-management/claim-request-management.module.ts +++ b/src/claim-request-management/claim-request-management.module.ts @@ -1,5 +1,8 @@ import { Module } from "@nestjs/common"; import { MongooseModule } from "@nestjs/mongoose"; +import { HttpModule } from "@nestjs/axios"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { createHttpModuleOptions } from "src/core/config/http-proxy.factory"; import { AiModule } from "src/ai/ai.module"; import { SandHubModule } from "src/sand-hub/sand-hub.module"; import { RequestManagementModule } from "src/request-management/request-management.module"; @@ -52,13 +55,16 @@ import { ClientModule } from "src/client/client.module"; import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard"; import { JwtModule } from "@nestjs/jwt"; import { MediaPolicyModule } from "src/media-policy/media-policy.module"; -import { HttpModule } from "@nestjs/axios"; import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module"; import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module"; @Module({ imports: [ - HttpModule, + HttpModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: createHttpModuleOptions, + }), FanavaranAuditModule, FanavaranLookupModule, PublicIdModule, diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 7cf5e69..2b2aad8 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -14,8 +14,8 @@ import { Types } from "mongoose"; import { isAxiosError } from "axios"; import { unlink } from "node:fs/promises"; import { createReadStream, existsSync } from "node:fs"; -import { basename } from "node:path"; -import FormData from "form-data"; +import { basename, resolve } from "node:path"; +import FormData = require("form-data"); import { AiService } from "src/ai/ai.service"; import { buildFileLink, resolveStoredFileUrl } from "src/helpers/urlCreator"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; @@ -99,6 +99,8 @@ import { import { PublicIdService } from "src/utils/public-id/public-id.service"; import { ImageRequiredModel } from "./entites/schema/image-required.schema"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; +import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service"; +import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service"; import { SandHubService } from "src/sand-hub/sand-hub.service"; import { @@ -240,6 +242,7 @@ interface FanavaranAttachmentCandidate { const FANAVARAN_ACCIDENT_LOCATION_ADDRESS = "استان تهران شهر تهران"; const FANAVARAN_DEFAULT_ACCIDENT_CAUSE_ID = 6; +const FANAVARAN_DEFAULT_ACCIDENT_LEVEL = 5456; const FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT = 1000; /** Local carType → Fanavaran vehicle-kind Caption keywords for matching. */ @@ -316,18 +319,7 @@ export class ClaimRequestManagementService { 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, + ClaimExpertId: 4543092, CompensationReferenceId: 167, CulpritLicenceTypeId: 2, CulpritTypeId: 337, @@ -358,6 +350,8 @@ export class ClaimRequestManagementService { private readonly httpService: HttpService, private readonly fanavaranAuditService: FanavaranAuditService, private readonly fanavaranLookupService: FanavaranLookupService, + private readonly fileMakerDbService: FileMakerDbService, + private readonly fieldExpertDbService: FieldExpertDbService, ) {} private requiredDocumentKeysV2(isCarBody: boolean): string[] { @@ -3710,7 +3704,7 @@ export class ClaimRequestManagementService { ): Promise { if (!nationalCode) { this.logger.warn( - `resolveDriverFanavaranId: no nationalCode provided, skipping party inquiry`, + `[resolveDriverFanavaranId] SKIP: no nationalCode provided`, ); return null; } @@ -3718,12 +3712,15 @@ export class ClaimRequestManagementService { const parsed = this.parseJalaliBirthday(driverBirthday); if (!parsed) { this.logger.warn( - `resolveDriverFanavaranId: could not parse driverBirthday "${driverBirthday}" for nationalCode=${nationalCode}`, + `[resolveDriverFanavaranId] SKIP: could not parse driverBirthday "${driverBirthday}" for nationalCode=${nationalCode}`, ); return null; } const targetRoleId = driverIsInsurer ? 161 : 166; + this.logger.log( + `[resolveDriverFanavaranId] QUERYING Fanavaran: nationalCode=${nationalCode} birthday=${parsed.year}/${parsed.month}/${parsed.day} driverIsInsurer=${driverIsInsurer} targetRoleId=${targetRoleId}`, + ); try { const response = (await this.fanavaranLookupService.inquiryByUniqueIdentifier( @@ -3742,9 +3739,13 @@ export class ClaimRequestManagementService { LastName?: string; }>; + this.logger.log( + `[resolveDriverFanavaranId] RESPONSE: ${JSON.stringify(response)}`, + ); + if (!Array.isArray(response) || response.length === 0) { this.logger.warn( - `resolveDriverFanavaranId: empty response for nationalCode=${nationalCode}`, + `[resolveDriverFanavaranId] EMPTY response for nationalCode=${nationalCode}`, ); return null; } @@ -3752,20 +3753,19 @@ export class ClaimRequestManagementService { const match = response.find((entry) => entry.RoleId === targetRoleId); if (match) { this.logger.log( - `resolveDriverFanavaranId: foundRoleId=${targetRoleId} Id=${match.Id} for nationalCode=${nationalCode} (${match.Name} ${match.LastName})`, + `[resolveDriverFanavaranId] MATCH: Id=${match.Id} RoleId=${match.RoleId} name=${match.Name} ${match.LastName} for nationalCode=${nationalCode}`, ); return match.Id; } this.logger.warn( - `resolveDriverFanavaranId: no person with RoleId=${targetRoleId} (driverIsInsurer=${driverIsInsurer}) for nationalCode=${nationalCode}. ` + - `Available RoleIds: ${response.map((e) => e.RoleId).join(", ")}. ` + - `Person is not defined in Fanavaran system for this role.`, + `[resolveDriverFanavaranId] NO MATCH for targetRoleId=${targetRoleId} (driverIsInsurer=${driverIsInsurer}) nationalCode=${nationalCode}. ` + + `Available entries: ${response.map((e) => `{Id=${e.Id}, RoleId=${e.RoleId}, Name=${e.Name} ${e.LastName}}`).join("; ")}`, ); return null; } catch (error) { this.logger.error( - `resolveDriverFanavaranId: failed to call inquiry-by-unique-identifier for nationalCode=${nationalCode}: ${error instanceof Error ? error.message : error}`, + `[resolveDriverFanavaranId] API ERROR for nationalCode=${nationalCode}: ${error instanceof Error ? error.message : JSON.stringify(error)}`, ); return null; } @@ -3804,6 +3804,20 @@ export class ClaimRequestManagementService { const inquiryMapped = vehicle?.inquiry?.mapped ?? {}; const inquiryRaw = vehicle?.inquiry?.raw ?? {}; + this.logger.log( + `[buildFanavaranDamageCasePayload] damagedParty resolved: userId=${damagedParty?.person?.userId ?? "NONE"}, ` + + `nationalCodeOfDriver=${person.nationalCodeOfDriver ?? "MISSING"}, ` + + `driverBirthday=${person.driverBirthday ?? "MISSING"}, ` + + `driverIsInsurer=${person.driverIsInsurer ?? "MISSING"}, ` + + `driverLicense=${person.driverLicense ?? "MISSING"}`, + ); + if (!damagedParty) { + this.logger.warn( + `[buildFanavaranDamageCasePayload] No matching party found for claimCase.owner.userId=${input.claimCase?.owner?.userId}. ` + + `Blame parties: ${input.blameCase?.parties?.map((p: any) => `userId=${p?.person?.userId}`).join(", ") ?? "EMPTY"}`, + ); + } + const guiltyUserId = input.blameCase?.expertSubmitReplyFinal?.guiltyUserId ?? input.blameCase?.expertSubmitReply?.guiltyUserId; @@ -3827,11 +3841,42 @@ export class ClaimRequestManagementService { carType, ); - const driverFanavaranId = await this.resolveDriverFanavaranId( - input.clientKey, - person.nationalCodeOfDriver, - person.driverBirthday, - person.driverIsInsurer, + // Check cache first — person.fanavaranDriverId may already be persisted from a prior lookup + let driverFanavaranId = person.fanavaranDriverId ?? null; + + if (driverFanavaranId) { + this.logger.log( + `[buildFanavaranDamageCasePayload] Using CACHED DriverId=${driverFanavaranId} from person.fanavaranDriverId`, + ); + } else { + driverFanavaranId = await this.resolveDriverFanavaranId( + input.clientKey, + person.nationalCodeOfDriver, + person.driverBirthday, + person.driverIsInsurer, + ); + + // Persist the resolved ID back to the blame case party for future use + if (driverFanavaranId && input.blameCase?._id && damagedParty) { + const partyIndex = input.blameCase.parties.findIndex( + (p: any) => + String(p?.person?.userId ?? "") === + String(input.claimCase?.owner?.userId ?? ""), + ); + if (partyIndex >= 0) { + await this.blameRequestDbService.findByIdAndUpdate( + String(input.blameCase._id), + { $set: { [`parties.${partyIndex}.person.fanavaranDriverId`]: driverFanavaranId } }, + ); + this.logger.log( + `[buildFanavaranDamageCasePayload] CACHED DriverId=${driverFanavaranId} on blameCase=${input.blameCase._id} party[${partyIndex}]`, + ); + } + } + } + + this.logger.log( + `[buildFanavaranDamageCasePayload] Final DriverId=${driverFanavaranId ?? "NULL"} for nationalCodeOfDriver=${person.nationalCodeOfDriver ?? "MISSING"}`, ); return { @@ -3851,7 +3896,7 @@ export class ClaimRequestManagementService { EndDate: insurance.endDate ?? null, EstimateAmount: FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT, FaultPercent: input.defaults.FaultPercent, - InsuranceCorpId: null, + InsuranceCorpId: await this.fanavaranLookupService.resolveInsuranceCorpId(input.clientKey), DriverIsOwner: person.driverIsInsurer ? 1 : input.defaults.DriverIsOwner, LicenceCityId: null, LicenceCountryId: null, @@ -3951,7 +3996,7 @@ export class ClaimRequestManagementService { AccidentCityId: 701, AccidentReportTypeId: 155, AccidentVehicleUsedId: 1, - ClaimExpertId: 1589, + ClaimExpertId: 4543092, CompensationReferenceId: 167, CulpritLicenceTypeId: 2, CulpritTypeId: 337, @@ -4900,12 +4945,14 @@ export class ClaimRequestManagementService { clientKey, claimCaseId, claimId: claimCase.claimId ?? null, + claimNo: claimCase.claimNo ?? null, + dmgCaseId: claimCase.dmgCaseId ?? null, submitUrl: claimCase.claimId ? `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/files` : null, warning: claimCase.claimId ? undefined - : "Fanavaran base claimId is required before attachment submit. Submit will retry base claim first.", + : "Fanavaran base claimId is required before attachment submit.", totalLocalImages: candidates.length, alreadySubmitted: candidates.filter( (candidate) => candidate.alreadySubmitted, @@ -4926,40 +4973,321 @@ export class ClaimRequestManagementService { clientKey, ); const pending = candidates.filter( - (candidate) => !candidate.alreadySubmitted, + (candidate) => !candidate.alreadySubmitted && existsSync(candidate.path), ); - const results: FanavaranAttachmentSubmitResult[] = []; - for (const candidate of pending) { - results.push( - await this.autoSubmitFanavaranAttachment( - claimCaseId, - { - path: candidate.path, - fileName: candidate.fileName, - source: candidate.source, - }, - { - clientKey, - auditSource: FanavaranAuditSource.SUBMIT, - }, - ), - ); + if (pending.length === 0) { + return { + clientKey, + claimCaseId, + totalLocalImages: candidates.length, + skippedAlreadySubmitted: candidates.length, + attempted: 0, + submitted: 0, + failed: 0, + skipped: candidates.length, + results: [], + }; } - return { - clientKey, - claimCaseId, - totalLocalImages: candidates.length, - skippedAlreadySubmitted: candidates.length - pending.length, - attempted: results.length, - submitted: results.filter((result) => result.submitted).length, - failed: results.filter( - (result) => result.attempted && !result.submitted && !result.skipped, - ).length, - skipped: results.filter((result) => result.skipped).length, - results, - }; + const profile = getFanavaranClientProfile(clientKey); + const claimCase = await this.claimCaseDbService.findById(claimCaseId); + if (!claimCase?.claimId) { + return { + clientKey, + claimCaseId, + totalLocalImages: candidates.length, + skippedAlreadySubmitted: candidates.length - pending.length, + attempted: 0, + submitted: 0, + failed: 0, + skipped: pending.length, + warning: "Fanavaran base claimId is missing", + results: pending.map((c) => ({ + attempted: false, + submitted: false, + skipped: true, + skipReason: "Fanavaran base claimId is missing", + fileName: c.fileName, + })), + }; + } + + const url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/files`; + const fileTypeId = profile.defaults.ClaimFileTypeId; + + const logPrefix = `[Fanavaran ${clientKey} V2 Attachment Batch] claimCaseId=${claimCaseId}`; + + const results: any[] = []; + + for (let i = 0; i < pending.length; i++) { + const candidate = pending[i]; + const content = { + FileName: candidate.fileName, + FileTypeId: fileTypeId, + }; + + this.logger.log( + `${logPrefix} [${i + 1}/${pending.length}] UPLOADING: ${candidate.fileName}`, + ); + + try { + const response = await this.postFanavaranMultipart( + url, + content, + [{ path: candidate.path, fileName: candidate.fileName }], + clientKey, + ); + this.logger.log( + `${logPrefix} [${i + 1}/${pending.length}] SUCCESS: status=${response.status} body=${JSON.stringify(response.data)}`, + ); + results.push({ + attempted: true, + submitted: true, + claimId: claimCase.claimId, + fileName: candidate.fileName, + fanavaranResponse: response.data, + }); + } catch (error) { + const warning = this.extractFanavaranErrorMessage(error); + this.logger.warn( + `${logPrefix} [${i + 1}/${pending.length}] FAILED: ${warning}`, + ); + results.push({ + attempted: true, + submitted: false, + warning, + fileName: candidate.fileName, + }); + } + + if (i < pending.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + } + + return results; + } + + async autoSubmitFanavaranAttachment( + claimCaseId: string, + file: { path?: string | null; fileName?: string | null; source?: string }, + options?: { + clientKey?: FanavaranClientKey; + auditSource?: FanavaranAuditSource; + }, + ): Promise { + const clientKey = options?.clientKey ?? resolveFanavaranClientKey(); + const auditSource = + options?.auditSource ?? FanavaranAuditSource.AUTO_SUBMIT; + const logPrefix = `[Fanavaran ${clientKey} V2 Attachment Auto] claimCaseId=${claimCaseId}`; + const filePath = file.path ? String(file.path) : ""; + const fileName = file.fileName + ? String(file.fileName) + : filePath + ? basename(filePath) + : ""; + + const profile = getFanavaranClientProfile(clientKey); + let url = ""; + let content: Record = {}; + + try { + if (!filePath || !fileName) { + return { + attempted: false, + submitted: false, + skipped: true, + skipReason: "Attachment file path or filename is missing", + }; + } + if (!existsSync(filePath)) { + return { + attempted: false, + submitted: false, + skipped: true, + skipReason: `Attachment file does not exist: ${filePath}`, + fileName, + }; + } + + let claimCase = await this.claimCaseDbService.findById(claimCaseId); + if (!claimCase) { + return { + attempted: false, + submitted: false, + warning: "Claim case not found for Fanavaran attachment submit.", + fileName, + }; + } + + if (!claimCase.claimId) { + if (options?.clientKey) { + await this.executeFanavaranV2Submit(claimCaseId, clientKey); + } else { + await this.autoSubmitToFanavaranV2OnClaimCreated(claimCaseId); + } + claimCase = await this.claimCaseDbService.findById(claimCaseId); + } + + if (!claimCase?.claimId) { + const warning = + "Fanavaran base claim is missing, so attachment was not submitted."; + await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { + $set: { + "fanavaranSync.attachments.status": "skipped", + "fanavaranSync.attachments.lastTriedAt": new Date(), + "fanavaranSync.attachments.lastError": warning, + }, + }); + return { + attempted: false, + submitted: false, + skipped: true, + skipReason: warning, + warning, + fileName, + }; + } + + url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/files`; + content = { + FileName: fileName, + FileTypeId: profile.defaults.ClaimFileTypeId, + Files: [ + { FileName: fileName, FileTypeId: profile.defaults.ClaimFileTypeId }, + ], + }; + + const auditSession: FanavaranAuditSession = { + trackingCode: this.fanavaranAuditService.generateTrackingCode(), + clientKey, + source: auditSource, + claimCaseId, + }; + const startedAt = Date.now(); + + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.SUBMIT_ATTACHMENT, + status: FanavaranAuditStatus.STARTED, + requestUrl: url, + requestMeta: { + claimId: claimCase.claimId, + claimNo: claimCase.claimNo, + dmgCaseId: claimCase.dmgCaseId, + fileName, + source: file.source, + content, + }, + }); + + const response = await this.postFanavaranMultipart( + url, + content, + [{ path: filePath, fileName }], + clientKey, + ); + + this.logger.log(`${logPrefix} Fanavaran response: ${JSON.stringify(response.data)}`); + + const fileId = response.data?.Id; + + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.SUBMIT_ATTACHMENT, + status: FanavaranAuditStatus.SUCCESS, + requestUrl: url, + httpStatus: response.status, + responseMeta: { claimId: claimCase.claimId, fileId, fileName }, + durationMs: Date.now() - startedAt, + }); + + await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { + $set: { + "fanavaranSync.attachments.status": "success", + "fanavaranSync.attachments.lastTriedAt": new Date(), + "fanavaranSync.attachments.claimId": claimCase.claimId, + "fanavaranSync.attachments.response": response.data, + }, + $push: { + "fanavaranSync.attachments.files": { + fileId, + fileName, + source: file.source, + response: response.data, + submittedAt: new Date(), + }, + history: { + type: "FANAVARAN_ATTACHMENT_AUTO_SUBMIT_SUCCEEDED", + actor: { actorType: "system" }, + timestamp: new Date(), + metadata: { + clientKey, + claimId: claimCase.claimId, + fileId, + fileName, + source: file.source, + }, + }, + }, + }); + + return { + attempted: true, + submitted: true, + claimId: claimCase.claimId, + fileId, + fileName, + fanavaranResponse: response.data, + }; + } catch (error) { + const warning = this.extractFanavaranErrorMessage(error); + this.logger.warn(`${logPrefix} Fanavaran error: ${warning}`); + + try { + await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { + $set: { + "fanavaranSync.attachments.status": "failed", + "fanavaranSync.attachments.lastTriedAt": new Date(), + "fanavaranSync.attachments.lastError": warning, + }, + $push: { + history: { + type: "FANAVARAN_ATTACHMENT_AUTO_SUBMIT_FAILED", + actor: { actorType: "system" }, + timestamp: new Date(), + metadata: { + clientKey, + error: warning, + fileName, + source: file.source, + }, + }, + }, + }); + } catch (historyError) { + this.logger.error( + `${logPrefix} Failed to record Fanavaran attachment failure history`, + historyError, + ); + } + + // Schedule retry for attachments + await this.scheduleFanavaranRetry( + claimCaseId, + "attachments", + () => this.autoSubmitFanavaranAttachment(claimCaseId, file, options), + logPrefix, + ); + + return { + attempted: true, + submitted: false, + warning, + fileName, + }; + } } private fanavaranLookupDefinition(name: string) { @@ -4977,12 +5305,19 @@ export class ClaimRequestManagementService { name: string, ): Promise { const definition = this.fanavaranLookupDefinition(name); - const data = await this.fanavaranLookupService.getRemoteLookup( - clientKey, - definition.url, - definition.cacheFile, - ); - return Array.isArray(data) ? data : []; + try { + const data = await this.fanavaranLookupService.getRemoteLookup( + clientKey, + definition.url, + definition.cacheFile, + ); + return Array.isArray(data) ? data : []; + } catch { + this.logger.warn( + `Lookup "${name}" unavailable for ${clientKey}; returning empty.`, + ); + return []; + } } private normalizeFanavaranLookupText(value: unknown): string { @@ -5127,39 +5462,27 @@ export class ClaimRequestManagementService { private buildFanavaranExpertiseDmgSection(input: { part: any; - priceDrop: any; - carComponents: unknown[]; - accidentLevels: unknown[]; warnings: string[]; }): Record { - const priceDropLine = this.findPriceDropLineForPart( - input.priceDrop, - input.part, - ); - const sectionId = this.findLookupIdByText( - input.carComponents, - this.fanavaranPartLookupTexts(input.part), - ); - const accidentLevel = this.findLookupIdByText(input.accidentLevels, [ - priceDropLine?.severity, - input.part?.typeOfDamage, - ]); + const partId = parseCatalogPartIdInput(input.part?.partId); + const accidentLevel = + typeof input.part?.typeOfDamage === "number" + ? input.part.typeOfDamage + : typeof input.part?.typeOfDamage === "string" && + /^\d+$/.test(input.part.typeOfDamage) + ? Number(input.part.typeOfDamage) + : FANAVARAN_DEFAULT_ACCIDENT_LEVEL; const label = this.fanavaranPartLabel(input.part); - if (sectionId == null) { + if (partId == null) { input.warnings.push( - `No Fanavaran DmgSectionId mapping for part "${label}".`, - ); - } - if (accidentLevel == null) { - input.warnings.push( - `No Fanavaran AccidentLevel mapping for part "${label}".`, + `No DmgSectionId (partId) for part "${label}".`, ); } return { Desc: input.part?.typeOfDamage ?? label, - DmgSectionId: sectionId, + DmgSectionId: partId, AccidentLevel: accidentLevel, ComponentReplacementCost: this.parseFanavaranMoney(input.part?.price), RepairWage: this.parseFanavaranMoney(input.part?.salary), @@ -5177,6 +5500,8 @@ export class ClaimRequestManagementService { }> { const profile = getFanavaranClientProfile(input.clientKey); const active = getActiveV2ExpertReply(input.claimCase as any); + + const claimExpertId = profile.defaults.ExpertiseClaimExpertId; const warnings: string[] = []; if (!active?.parts?.length) { @@ -5196,42 +5521,19 @@ export class ClaimRequestManagementService { const [ inspectionPlaces, dropAmountStatuses, - carComponents, - accidentLevels, ] = await Promise.all([ this.getFanavaranLookupRows(input.clientKey, "inspection-place"), this.getFanavaranLookupRows(input.clientKey, "drop-amount-status"), - this.getFanavaranLookupRows(input.clientKey, "car-components"), - this.getFanavaranLookupRows(input.clientKey, "accident-level"), ]); const priceDrop = input.claimCase.evaluation?.priceDrop ?? {}; const priceDropTotal = this.parseFanavaranMoney(priceDrop?.total); - const inspectionPlaceId = this.findLookupIdByText(inspectionPlaces, [ - input.claimCase.evaluation?.visitLocation, - "محل بازدید", - "کارشناسی", - ]); - const dropAmountStatus = this.findDropAmountStatusId( - dropAmountStatuses, - priceDropTotal, - ); - - if (inspectionPlaceId == null) { - warnings.push( - "No Fanavaran InspectionPlaceId mapping could be resolved.", - ); - } - if (dropAmountStatus == null) { - warnings.push("No Fanavaran DropAmountStatus mapping could be resolved."); - } + const inspectionPlaceId = 282; + const dropAmountStatus = 5458; const dmgSections = parts.map((part) => this.buildFanavaranExpertiseDmgSection({ part, - priceDrop, - carComponents, - accidentLevels, warnings, }), ); @@ -5254,9 +5556,7 @@ export class ClaimRequestManagementService { replyKey: active?.replyKey, warnings, payload: { - ClaimExpertId: - reply?.expertProfileSnapshot?.fanavaranExpertId ?? - profile.defaults.ClaimExpertId, + ClaimExpertId: claimExpertId, ComponentReplacementCost: componentReplacementCost, DmgAssessmentDate: this.convertToPersianDate(submittedAt), DmgCaseId: input.claimCase.dmgCaseId ?? null, @@ -5541,6 +5841,15 @@ export class ClaimRequestManagementService { historyError, ); } + + // Schedule retry for expertise + await this.scheduleFanavaranRetry( + claimCaseId, + "expertise", + () => this.autoSubmitFanavaranExpertiseOnExpertReply(claimCaseId), + logPrefix, + ); + return { attempted: true, submitted: false, @@ -5620,16 +5929,32 @@ export class ClaimRequestManagementService { const authenticationToken = await this.login(appToken, profile.auth); const form = new FormData(); - form.append("content", JSON.stringify(content), { + form.append("Param", JSON.stringify(content), { contentType: "application/json", }); for (const file of files) { - form.append("files", createReadStream(file.path), { + form.append("Param1", createReadStream(resolve(file.path)), { filename: file.fileName, }); } - return await firstValueFrom( + const curlParts = [ + `curl -X POST '${url}'`, + `-H 'authenticationToken: ${authenticationToken}'`, + `-H 'CorpId: ${profile.auth.corpId}'`, + `-H 'ContractId: ${profile.auth.contractId}'`, + `-H 'Location: ${profile.auth.location}'`, + `-F 'Param=${JSON.stringify(content)}'`, + ...files.map((f) => `-F 'Param1=@${f.path};filename=${f.fileName}'`), + ]; + this.logger.log(`\n====== COPY THIS CURL ======\n${curlParts.join(" \\\n ")}\n====== END CURL ======\n`); + + const logPrefix = `[Fanavaran Multipart]`; + this.logger.log( + `${logPrefix} REQUEST: url=${url} | Param=${JSON.stringify(content)} | files=[${files.map((f) => f.fileName).join(", ")}] | fileCount=${files.length}`, + ); + + const response = await firstValueFrom( this.httpService.post(url, form, { headers: { ...form.getHeaders(), @@ -5642,212 +5967,12 @@ export class ClaimRequestManagementService { maxContentLength: Infinity, }), ); - } - async autoSubmitFanavaranAttachment( - claimCaseId: string, - file: { path?: string | null; fileName?: string | null; source?: string }, - options?: { - clientKey?: FanavaranClientKey; - auditSource?: FanavaranAuditSource; - }, - ): Promise { - const clientKey = options?.clientKey ?? resolveFanavaranClientKey(); - const auditSource = - options?.auditSource ?? FanavaranAuditSource.AUTO_SUBMIT; - const logPrefix = `[Fanavaran ${clientKey} V2 Attachment Auto] claimCaseId=${claimCaseId}`; - const filePath = file.path ? String(file.path) : ""; - const fileName = file.fileName - ? String(file.fileName) - : filePath - ? basename(filePath) - : ""; + this.logger.log( + `${logPrefix} RESPONSE: status=${response.status} | body=${JSON.stringify(response.data)}`, + ); - try { - if (!filePath || !fileName) { - return { - attempted: false, - submitted: false, - skipped: true, - skipReason: "Attachment file path or filename is missing", - }; - } - if (!existsSync(filePath)) { - return { - attempted: false, - submitted: false, - skipped: true, - skipReason: `Attachment file does not exist: ${filePath}`, - fileName, - }; - } - - let claimCase = await this.claimCaseDbService.findById(claimCaseId); - if (!claimCase) { - return { - attempted: false, - submitted: false, - warning: "Claim case not found for Fanavaran attachment submit.", - fileName, - }; - } - - if (!claimCase.claimId) { - if (options?.clientKey) { - await this.executeFanavaranV2Submit(claimCaseId, clientKey); - } else { - await this.autoSubmitToFanavaranV2OnClaimCreated(claimCaseId); - } - claimCase = await this.claimCaseDbService.findById(claimCaseId); - } - - if (!claimCase?.claimId) { - const warning = - "Fanavaran base claim is missing, so attachment was not submitted."; - await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { - $set: { - "fanavaranSync.attachments.status": "skipped", - "fanavaranSync.attachments.lastTriedAt": new Date(), - "fanavaranSync.attachments.lastError": warning, - }, - }); - return { - attempted: false, - submitted: false, - skipped: true, - skipReason: warning, - warning, - fileName, - }; - } - - const profile = getFanavaranClientProfile(clientKey); - const url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/files`; - const content = { - FileName: fileName, - FileTypeId: profile.defaults.ClaimFileTypeId, - Files: [ - { FileName: fileName, FileTypeId: profile.defaults.ClaimFileTypeId }, - ], - }; - const auditSession: FanavaranAuditSession = { - trackingCode: this.fanavaranAuditService.generateTrackingCode(), - clientKey, - source: auditSource, - claimCaseId, - }; - const startedAt = Date.now(); - - await this.fanavaranAuditService.recordStep({ - session: auditSession, - step: FanavaranAuditStep.SUBMIT_ATTACHMENT, - status: FanavaranAuditStatus.STARTED, - requestUrl: url, - requestMeta: { - claimId: claimCase.claimId, - fileName, - source: file.source, - content, - }, - }); - - const response = await this.postFanavaranMultipart( - url, - content, - [{ path: filePath, fileName }], - clientKey, - ); - const fileId = response.data?.Id; - - await this.fanavaranAuditService.recordStep({ - session: auditSession, - step: FanavaranAuditStep.SUBMIT_ATTACHMENT, - status: FanavaranAuditStatus.SUCCESS, - requestUrl: url, - httpStatus: response.status, - responseMeta: { claimId: claimCase.claimId, fileId, fileName }, - durationMs: Date.now() - startedAt, - }); - - await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { - $set: { - "fanavaranSync.attachments.status": "success", - "fanavaranSync.attachments.lastTriedAt": new Date(), - "fanavaranSync.attachments.claimId": claimCase.claimId, - "fanavaranSync.attachments.response": response.data, - }, - $push: { - "fanavaranSync.attachments.files": { - fileId, - fileName, - source: file.source, - response: response.data, - submittedAt: new Date(), - }, - history: { - type: "FANAVARAN_ATTACHMENT_AUTO_SUBMIT_SUCCEEDED", - actor: { actorType: "system" }, - timestamp: new Date(), - metadata: { - clientKey, - claimId: claimCase.claimId, - fileId, - fileName, - source: file.source, - }, - }, - }, - }); - - return { - attempted: true, - submitted: true, - claimId: claimCase.claimId, - fileId, - fileName, - fanavaranResponse: response.data, - }; - } catch (error) { - const warning = this.extractFanavaranErrorMessage(error); - this.logger.warn( - `${logPrefix} Attachment auto-submit failed: ${warning}`, - ); - - try { - await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { - $set: { - "fanavaranSync.attachments.status": "failed", - "fanavaranSync.attachments.lastTriedAt": new Date(), - "fanavaranSync.attachments.lastError": warning, - }, - $push: { - history: { - type: "FANAVARAN_ATTACHMENT_AUTO_SUBMIT_FAILED", - actor: { actorType: "system" }, - timestamp: new Date(), - metadata: { - clientKey, - error: warning, - fileName, - source: file.source, - }, - }, - }, - }); - } catch (historyError) { - this.logger.error( - `${logPrefix} Failed to record Fanavaran attachment failure history`, - historyError, - ); - } - - return { - attempted: true, - submitted: false, - warning, - fileName, - }; - } + return response; } private async executeFanavaranDamageCaseSubmit( @@ -5891,6 +6016,9 @@ export class ClaimRequestManagementService { defaults: profile.defaults, }); const url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/dmg-cases`; + this.logger.log( + `[executeFanavaranDamageCaseSubmit] claimCaseId=${claimCaseId} claimId=${claimCase.claimId} payload.DriverId=${payload.DriverId ?? "NULL"} payload keys=${Object.keys(payload).join(", ")}`, + ); const startedAt = Date.now(); const auditSession: FanavaranAuditSession = { trackingCode: this.fanavaranAuditService.generateTrackingCode(), @@ -5938,6 +6066,14 @@ export class ClaimRequestManagementService { return response.data; } catch (error) { + const errDetail = isAxiosError(error) + ? `status=${error.response?.status} data=${JSON.stringify(error.response?.data)}` + : error instanceof Error + ? error.message + : String(error); + this.logger.error( + `[executeFanavaranDamageCaseSubmit] FAILED claimCaseId=${claimCaseId} claimId=${claimCase.claimId} payload.DriverId=${payload.DriverId ?? "NULL"} error: ${errDetail}`, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, @@ -6065,6 +6201,14 @@ export class ClaimRequestManagementService { ); } + // Schedule retry for damage case + await this.scheduleFanavaranRetry( + claimCaseId, + "damageCase", + () => this.autoSubmitFanavaranDamageCaseOnOuterPartsSelected(claimCaseId, selectedParts), + logPrefix, + ); + return { attempted: true, submitted: false, @@ -6271,6 +6415,14 @@ export class ClaimRequestManagementService { ); } + // Schedule retry for base claim + await this.scheduleFanavaranRetry( + claimCaseId, + "baseClaim", + () => this.autoSubmitToFanavaranV2OnClaimCreated(claimCaseId), + logPrefix, + ); + return { attempted: true, submitted: false, @@ -6312,6 +6464,66 @@ export class ClaimRequestManagementService { return "Failed to submit data to Fanavaran API"; } + private static readonly FANAVARAN_RETRY_DELAY_MS = 5 * 60 * 1000; // 5 minutes + private static readonly FANAVARAN_MAX_RETRIES = 2; + + /** + * After a Fanavaran stage failure, schedule a retry if retries remain. + * Increments retryCount, sets nextRetryAt, and after the delay calls the + * provided retryFn. Returns true if a retry was scheduled. + */ + private async scheduleFanavaranRetry( + claimCaseId: string, + stage: "baseClaim" | "damageCase" | "attachments" | "expertise", + retryFn: () => Promise, + logPrefix: string, + ): Promise { + const claimCase = await this.claimCaseDbService.findById(claimCaseId); + const syncStage = (claimCase as any)?.fanavaranSync?.[stage]; + const retryCount = syncStage?.retryCount ?? 0; + const maxRetries = syncStage?.maxRetries ?? ClaimRequestManagementService.FANAVARAN_MAX_RETRIES; + + if (retryCount >= maxRetries) { + this.logger.warn( + `${logPrefix} Max retries (${maxRetries}) exhausted for ${stage}.`, + ); + return false; + } + + const nextRetryCount = retryCount + 1; + const nextRetryAt = new Date(Date.now() + ClaimRequestManagementService.FANAVARAN_RETRY_DELAY_MS); + + await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { + $set: { + [`fanavaranSync.${stage}.retryCount`]: nextRetryCount, + [`fanavaranSync.${stage}.nextRetryAt`]: nextRetryAt, + [`fanavaranSync.${stage}.status`]: "pending", + }, + }); + + this.logger.log( + `${logPrefix} Scheduling retry ${nextRetryCount}/${maxRetries} for ${stage} at ${nextRetryAt.toISOString()}`, + ); + + setTimeout(async () => { + try { + const fresh = await this.claimCaseDbService.findById(claimCaseId); + const freshStage = (fresh as any)?.fanavaranSync?.[stage]; + // Only retry if still in pending state (not manually resolved) + if (freshStage?.status === "pending") { + await retryFn(); + } + } catch (retryError) { + this.logger.error( + `${logPrefix} Retry ${nextRetryCount}/${maxRetries} for ${stage} failed`, + retryError, + ); + } + }, ClaimRequestManagementService.FANAVARAN_RETRY_DELAY_MS); + + return true; + } + private async executeFanavaranV2Submit( claimCaseId: string, clientKey: FanavaranClientKey, @@ -6370,20 +6582,25 @@ export class ClaimRequestManagementService { 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, - ); + const updateData: any = { $set: {} as Record }; + if (claimNo !== undefined) { + updateData.$set.claimNo = claimNo; } + if (claimId !== undefined) { + updateData.$set.claimId = claimId; + } + updateData.$set["fanavaranSync.baseClaim"] = { + status: "success", + lastTriedAt: new Date(), + claimId, + claimNo, + response: response.data, + }; + + await this.claimCaseDbService.findByIdAndUpdate( + claimCaseId, + updateData, + ); } return response.data; @@ -8503,6 +8720,13 @@ export class ClaimRequestManagementService { updatePayload, ); + // Auto-submit Fanavaran attachments when all documents are uploaded + if (allDocumentsUploaded && !isResendUpload && !options?.v3InPersonFlow) { + this.submitFanavaranAttachmentsV2(claimRequestId, resolveFanavaranClientKey()).catch( + (err) => this.logger.warn(`Fanavaran attachments auto-submit failed: ${err?.message}`), + ); + } + if (isResendUpload) { await this.tryFinalizeExpertResendAfterUserAction( claimRequestId, @@ -9590,6 +9814,10 @@ export class ClaimRequestManagementService { }; } + // Submit Fanavaran expertise when owner accepts the expert reply + const fanavaranExpertise = + await this.autoSubmitFanavaranExpertiseOnExpertReply(claimRequestId); + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.COMPLETED, diff --git a/src/claim-request-management/entites/schema/claim-cases.schema.ts b/src/claim-request-management/entites/schema/claim-cases.schema.ts index 6b6845d..5037f40 100644 --- a/src/claim-request-management/entites/schema/claim-cases.schema.ts +++ b/src/claim-request-management/entites/schema/claim-cases.schema.ts @@ -86,6 +86,15 @@ export class FanavaranSyncStage { @Prop({ type: MongooseSchema.Types.Mixed }) response?: unknown; + + @Prop({ type: Number, default: 0 }) + retryCount?: number; + + @Prop({ type: Number, default: 2 }) + maxRetries?: number; + + @Prop({ type: Date }) + nextRetryAt?: Date; } export const FanavaranSyncStageSchema = SchemaFactory.createForClass(FanavaranSyncStage); diff --git a/src/common/auth/guards/auth.guard.ts b/src/common/auth/guards/auth.guard.ts index d6fdd28..0ac78cd 100644 --- a/src/common/auth/guards/auth.guard.ts +++ b/src/common/auth/guards/auth.guard.ts @@ -20,7 +20,9 @@ export class AuthGuard implements CanActivate { } try { const payload: JwtPayload = - await this.jwtService.verifyAsync(token); + await this.jwtService.verifyAsync(token, { + secret: `${process.env.JWT_SECRET}`, + }); request["user"] = payload; } catch { throw new UnauthorizedException("Invalid or expired token"); diff --git a/src/core/config/fanavaran-client.config.ts b/src/core/config/fanavaran-client.config.ts index 26da272..48a4406 100644 --- a/src/core/config/fanavaran-client.config.ts +++ b/src/core/config/fanavaran-client.config.ts @@ -37,6 +37,7 @@ export interface FanavaranPayloadDefaults { AccidentReportTypeId: number; AccidentVehicleUsedId: number; ClaimExpertId: number; + ExpertiseClaimExpertId: number; CompensationReferenceId: number; CulpritLicenceTypeId: number; CulpritTypeId: number; @@ -74,7 +75,8 @@ const FANAVARAN_CLIENT_PROFILES: Record< AccidentCityId: 701, AccidentReportTypeId: 155, AccidentVehicleUsedId: 1, - ClaimExpertId: 1589, + ClaimExpertId: 4543092, + ExpertiseClaimExpertId: 4543092, CompensationReferenceId: 167, CulpritLicenceTypeId: 2, CulpritTypeId: 337, @@ -84,7 +86,7 @@ const FANAVARAN_CLIENT_PROFILES: Record< PlaqueSampleId: 10, DriverIsOwner: 0, FaultPercent: 100, - ClaimFileTypeId: 70, + ClaimFileTypeId: 23, }, }, parsian: { @@ -103,6 +105,7 @@ const FANAVARAN_CLIENT_PROFILES: Record< AccidentReportTypeId: 155, AccidentVehicleUsedId: 1, ClaimExpertId: 154, + ExpertiseClaimExpertId: 29, CompensationReferenceId: 167, CulpritLicenceTypeId: 2, CulpritTypeId: 337, diff --git a/src/core/config/http-proxy.factory.ts b/src/core/config/http-proxy.factory.ts new file mode 100644 index 0000000..d8a9c72 --- /dev/null +++ b/src/core/config/http-proxy.factory.ts @@ -0,0 +1,26 @@ +import { ConfigService } from "@nestjs/config"; +import { HttpModuleOptions } from "@nestjs/axios"; +import { SocksProxyAgent } from "socks-proxy-agent"; + +/** + * Shared HttpModule.registerAsync factory that applies the SOCKS proxy + * when SOCKS_PROXY_HOST + SOCKS_PROXY_PORT env vars are set. + * Used by every module that imports HttpModule. + */ +export function createHttpModuleOptions( + configService: ConfigService, +): HttpModuleOptions | Promise { + const socksHost = configService.get("SOCKS_PROXY_HOST"); + const socksPort = configService.get("SOCKS_PROXY_PORT"); + + if (socksHost && socksPort) { + const proxyUrl = `socks5://${socksHost}:${socksPort}`; + const agent = new SocksProxyAgent(proxyUrl); + return { + httpsAgent: agent, + httpAgent: agent, + proxy: false, + }; + } + return {}; +} diff --git a/src/expert-claim/expert-claim.module.ts b/src/expert-claim/expert-claim.module.ts index faa8631..c80b18a 100644 --- a/src/expert-claim/expert-claim.module.ts +++ b/src/expert-claim/expert-claim.module.ts @@ -1,5 +1,7 @@ import { HttpModule } from "@nestjs/axios"; import { Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { createHttpModuleOptions } from "src/core/config/http-proxy.factory"; import { MongooseModule } from "@nestjs/mongoose"; import { AiModule } from "src/ai/ai.module"; import { AuthModule } from "src/auth/auth.module"; @@ -21,7 +23,11 @@ import { ExpertClaimService } from "./expert-claim.service"; @Module({ imports: [ - HttpModule, + HttpModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: createHttpModuleOptions, + }), SandHubModule, MongooseModule.forFeature([ { name: ClaimFactorsImage.name, schema: ClaimFactorsImageSchema }, diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 8ec4c2a..3b9281a 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -3466,17 +3466,7 @@ export class ExpertClaimService { }); } - const fanavaranExpertise = !needsFactorUpload - ? await this.claimRequestManagementService.autoSubmitFanavaranExpertiseOnExpertReply( - claimRequestId, - ) - : { - attempted: false, - submitted: false, - skipped: true, - skipReason: - "Expertise submit waits until factor-needed repair lines are validated.", - }; + // Fanavaran expertise is now triggered on owner final sign, not here. return { claimRequestId, @@ -3490,7 +3480,6 @@ export class ExpertClaimService { mixedPricingAndFactors: mixedFactorAndPrice, allPartsFactorNeeded: !!needsFactorUpload && !!allFactorLines, isFinalReplyAfterObjection, - fanavaranExpertise, }; } diff --git a/src/fanavaran/fanavaran-lookup.config.ts b/src/fanavaran/fanavaran-lookup.config.ts index 785abe5..2058bd6 100644 --- a/src/fanavaran/fanavaran-lookup.config.ts +++ b/src/fanavaran/fanavaran-lookup.config.ts @@ -76,6 +76,16 @@ export const FANAVARAN_REMOTE_LOOKUPS: FanavaranRemoteLookupDefinition[] = [ url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/person-role`, cacheFile: "person-role.json", }, + { + name: "insurance-corp", + url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/insurance-corp`, + cacheFile: "insurance-corp.json", + }, + { + name: "file-types", + url: `${FANAVARAN_LOOKUP_BASE_URL}/common/base-info/file-types`, + cacheFile: "file-types.json", + }, ]; export const TEJARAT_STATIC_ACCIDENT_FILES = { diff --git a/src/fanavaran/fanavaran-lookup.module.ts b/src/fanavaran/fanavaran-lookup.module.ts index 5a376f9..7033bcb 100644 --- a/src/fanavaran/fanavaran-lookup.module.ts +++ b/src/fanavaran/fanavaran-lookup.module.ts @@ -1,9 +1,17 @@ import { Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { createHttpModuleOptions } from "src/core/config/http-proxy.factory"; import { FanavaranLookupService } from "./fanavaran-lookup.service"; @Module({ - imports: [HttpModule], + imports: [ + HttpModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: createHttpModuleOptions, + }), + ], providers: [FanavaranLookupService], exports: [FanavaranLookupService], }) diff --git a/src/fanavaran/fanavaran-lookup.service.ts b/src/fanavaran/fanavaran-lookup.service.ts index 9df8365..7339361 100644 --- a/src/fanavaran/fanavaran-lookup.service.ts +++ b/src/fanavaran/fanavaran-lookup.service.ts @@ -183,24 +183,6 @@ export class FanavaranLookupService { this.logger.log( `[${clientKey}] Fanavaran lookup response status=${response.status} dataCount=${dataCount}`, ); - this.logger.log( - `[${clientKey}] Fanavaran lookup raw response: ${JSON.stringify( - response.data, - null, - 2, - )}`, - ); - - if (Array.isArray(response.data) && response.data.length > 0) { - const firstItem = response.data[0]; - if (firstItem && typeof firstItem === "object") { - this.logger.log( - `[${clientKey}] Fanavaran lookup first item keys: ${Object.keys( - firstItem, - ).join(", ")}`, - ); - } - } return response.data; } catch (error) { @@ -255,6 +237,34 @@ export class FanavaranLookupService { return this.fetchFromFanavaran(clientKey, url); } + async myPolicies( + clientKey: FanavaranClientKey, + nationalCode: string, + insuranceLineId: number = 5, + ): Promise { + const url = + `${FANAVARAN_LOOKUP_BASE_URL}/common/Policies/inquiry-my-policies` + + `?InsuranceLineId=${insuranceLineId}` + + `&NationalCode=${encodeURIComponent(nationalCode)}`; + return this.fetchFromFanavaran(clientKey, url); + } + + async thirdPartyPolicyById( + clientKey: FanavaranClientKey, + policyId: number, + ): Promise { + const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/third-party-car-policies/${policyId}`; + return this.fetchFromFanavaran(clientKey, url); + } + + async bodyPolicyById( + clientKey: FanavaranClientKey, + policyId: number, + ): Promise { + const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicle-hull-policies/${policyId}`; + return this.fetchFromFanavaran(clientKey, url); + } + async inquiryByUniqueIdentifier( clientKey: FanavaranClientKey, params: { @@ -273,6 +283,91 @@ export class FanavaranLookupService { return this.fetchFromFanavaran(clientKey, url); } + async resolveInsuranceCorpId( + clientKey: FanavaranClientKey, + ): Promise { + const caption = process.env.INSURANCE_CORP_ID?.trim(); + if (!caption) { + this.logger.warn("resolveInsuranceCorpId: INSURANCE_CORP_ID env not set"); + return null; + } + + // Check resolved cache first + const cachedId = await this.readCacheFile(clientKey, "insurance-corp-id-resolved.json"); + if (cachedId !== null) { + this.logger.log(`resolveInsuranceCorpId: using cached id=${cachedId} for "${caption}"`); + return cachedId; + } + + // Try fetching the full list directly from Fanavaran + let companies: unknown; + const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/insurance-corp`; + try { + companies = await this.fetchFromFanavaran(clientKey, url); + } catch (error) { + this.logger.warn( + `resolveInsuranceCorpId: Fanavaran fetch failed, trying local lookup endpoint`, + ); + // Fallback: call our own local lookup endpoint + try { + const localPort = process.env.PORT || 3000; + const response = await firstValueFrom( + this.httpService.get(`http://localhost:${localPort}/lookups/fanavaran/insurance-corp`, { + timeout: 10000, + }), + ); + companies = response.data; + } catch (localError) { + this.logger.error( + `resolveInsuranceCorpId: both Fanavaran and local lookup failed`, + ); + return null; + } + } + + if (!Array.isArray(companies)) { + this.logger.warn( + `resolveInsuranceCorpId: insurance-corp response is not an array, type=${typeof companies}`, + ); + return null; + } + + this.logger.log( + `resolveInsuranceCorpId: got ${companies.length} companies, searching for "${caption}"`, + ); + + const normalizedCaption = caption + .replace(/\s+/g, " ") + .toLowerCase() + .trim(); + + const match = companies.find((c: any) => { + if (c?.IsActive !== 1) return false; + if (typeof c?.Id !== "number") return false; + const cCaption = typeof c?.Caption === "string" ? c.Caption : ""; + const normalized = cCaption.replace(/\s+/g, " ").toLowerCase().trim(); + return normalized.includes(normalizedCaption) || normalizedCaption.includes(normalized); + }); + + if (!match) { + this.logger.warn( + `resolveInsuranceCorpId: no active company matching "${caption}". Available: ` + + companies + .filter((c: any) => c?.IsActive === 1) + .map((c: any) => `${c.Caption} (${c.Id})`) + .join(", "), + ); + return null; + } + + const corpId = match.Id as number; + // Cache both the resolved id and the full list for future use + await this.writeCacheFile(clientKey, "insurance-corp-id-resolved.json", corpId); + await this.writeCacheFile(clientKey, "insurance-corp.json", companies); + this.logger.log(`resolveInsuranceCorpId: resolved id=${corpId} for "${caption}"`); + return corpId; + } + mapFanavaranAccidentCausesToReasonOptions( causes: unknown, ): { id: number; label: string; fanavaran: number }[] { @@ -297,4 +392,5 @@ export class FanavaranLookupService { }; }); } + } diff --git a/src/lookups/lookups.controller.ts b/src/lookups/lookups.controller.ts index 88f1265..9a22fc6 100644 --- a/src/lookups/lookups.controller.ts +++ b/src/lookups/lookups.controller.ts @@ -1,14 +1,18 @@ -import { Controller, Get, Param, Query } from "@nestjs/common"; +import { Controller, Get, Param, ParseIntPipe, Query, UseGuards } from "@nestjs/common"; import { + ApiBearerAuth, ApiOkResponse, ApiOperation, ApiParam, ApiQuery, ApiTags, } from "@nestjs/swagger"; +import { AuthGuard } from "src/common/auth/guards"; import { LookupsService } from "./lookups.service"; @ApiTags("lookups") +@ApiBearerAuth() +@UseGuards(AuthGuard) @Controller("lookups") export class LookupsController { constructor(private readonly lookupsService: LookupsService) {} @@ -210,6 +214,30 @@ export class LookupsController { return await this.lookupsService.getPersonRole(); } + @Get("file-types") + @ApiOperation({ + summary: "Fanavaran file types lookup", + description: + "Returns file type codes from common/base-info/file-types. Use FileTypeId in attachment uploads.", + }) + @ApiOkResponse({ + description: "Returns Fanavaran file types lookup data", + schema: { + type: "array", + items: { + type: "object", + properties: { + Caption: { type: "string" }, + Id: { type: "number" }, + IsActive: { type: "number" }, + }, + }, + }, + }) + async getFileTypes() { + return await this.lookupsService.getFileTypes(); + } + @Get("inquiry-by-vin") @ApiOperation({ summary: "Fanavaran vehicle inquiry by VIN", @@ -371,4 +399,75 @@ export class LookupsController { async getAccidentFields() { return await this.lookupsService.getAccidentFields(); } + + @Get("my-policies") + @ApiOperation({ + summary: "My policies inquiry", + description: + "Returns the list of policies for the given national code via common/Policies/inquiry-my-policies.", + }) + @ApiQuery({ + name: "nationalCode", + description: "National code of the insured person", + example: "0012345678", + }) + @ApiQuery({ + name: "insuranceLineId", + description: "Insurance line ID (default: 5 for car)", + required: false, + example: 5, + }) + @ApiOkResponse({ + description: "Returns list of policies", + schema: { type: "array", items: { type: "object" } }, + }) + async myPolicies( + @Query("nationalCode") nationalCode: string, + @Query("insuranceLineId") insuranceLineId?: number, + ) { + return await this.lookupsService.myPolicies( + nationalCode, + insuranceLineId ?? 5, + ); + } + + @Get("third-party-policy/:policyId") + @ApiOperation({ + summary: "Third-party car policy inquiry by policy ID", + description: + "Returns third-party car insurance policy details for the given policy ID via car/third-party-car-policies/{policyId}.", + }) + @ApiParam({ + name: "policyId", + description: "Fanavaran policy ID", + example: 12345, + }) + @ApiOkResponse({ + description: "Returns third-party policy details", + schema: { type: "object" }, + }) + async thirdPartyPolicyById( + @Param("policyId", ParseIntPipe) policyId: number, + ) { + return await this.lookupsService.thirdPartyPolicyById(policyId); + } + + @Get("body-policy/:policyId") + @ApiOperation({ + summary: "Vehicle hull (body) policy inquiry by policy ID", + description: + "Returns vehicle hull (body) insurance policy details for the given policy ID via car/vehicle-hull-policies/{policyId}.", + }) + @ApiParam({ + name: "policyId", + description: "Fanavaran policy ID", + example: 12345, + }) + @ApiOkResponse({ + description: "Returns body policy details", + schema: { type: "object" }, + }) + async bodyPolicyById(@Param("policyId", ParseIntPipe) policyId: number) { + return await this.lookupsService.bodyPolicyById(policyId); + } } diff --git a/src/lookups/lookups.service.ts b/src/lookups/lookups.service.ts index 346dd93..e0910fe 100644 --- a/src/lookups/lookups.service.ts +++ b/src/lookups/lookups.service.ts @@ -123,11 +123,37 @@ export class LookupsService { return await this.getClientRemoteLookup("person-role"); } + async getFileTypes(): Promise { + return await this.getClientRemoteLookup("file-types"); + } + async inquiryByVin(vin: string): Promise { const clientKey = this.activeClientKey(); return this.fanavaranLookupService.inquiryByVin(clientKey, vin); } + async myPolicies( + nationalCode: string, + insuranceLineId: number = 5, + ): Promise { + const clientKey = this.activeClientKey(); + return this.fanavaranLookupService.myPolicies( + clientKey, + nationalCode, + insuranceLineId, + ); + } + + async thirdPartyPolicyById(policyId: number): Promise { + const clientKey = this.activeClientKey(); + return this.fanavaranLookupService.thirdPartyPolicyById(clientKey, policyId); + } + + async bodyPolicyById(policyId: number): Promise { + const clientKey = this.activeClientKey(); + return this.fanavaranLookupService.bodyPolicyById(clientKey, policyId); + } + async getAccidentWay(): Promise<{ id: number; label: string }[]> { const clientKey = this.activeClientKey(); const fileName = TEJARAT_STATIC_ACCIDENT_FILES.accidentWay; diff --git a/src/request-management/entities/schema/partyRole.enum.ts b/src/request-management/entities/schema/partyRole.enum.ts index 38e5fff..2b94764 100644 --- a/src/request-management/entities/schema/partyRole.enum.ts +++ b/src/request-management/entities/schema/partyRole.enum.ts @@ -41,6 +41,10 @@ export class Person { @Prop({ type: String }) driverBirthday?: string | null; + + /** Cached Fanavaran party ID resolved from nationalCodeOfDriver + driverBirthday + driverIsInsurer */ + @Prop({ type: Number }) + fanavaranDriverId?: number; } export const PersonSchema = SchemaFactory.createForClass(Person); diff --git a/src/sand-hub/sand-hub.module.ts b/src/sand-hub/sand-hub.module.ts index 4b3393c..dbc48fc 100644 --- a/src/sand-hub/sand-hub.module.ts +++ b/src/sand-hub/sand-hub.module.ts @@ -1,5 +1,7 @@ import { Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { createHttpModuleOptions } from "src/core/config/http-proxy.factory"; import { MongooseModule } from "@nestjs/mongoose"; import { ClientModule } from "src/client/client.module"; import { SystemSettingsModule } from "src/system-settings/system-settings.module"; @@ -9,7 +11,11 @@ import { SandHubService } from "./sand-hub.service"; @Module({ imports: [ - HttpModule, + HttpModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: createHttpModuleOptions, + }), SystemSettingsModule, ClientModule, MongooseModule.forFeature([