import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { HttpService } from "@nestjs/axios"; import { BadGatewayException, Injectable, Logger, NotFoundException, } from "@nestjs/common"; import { InjectModel } from "@nestjs/mongoose"; import { Model } from "mongoose"; import { firstValueFrom } from "rxjs"; import { isAxiosError } from "axios"; import { type FanavaranClientKey } from "src/core/config/fanavaran-client.config"; import { LookupModel, type LookupDocument, } from "src/lookups/entities/schema/lookup.schema"; import { FANAVARAN_LOOKUP_BASE_URL, fanavaranLookupCacheDir, tejaratStaticAccidentFilePath, } from "./fanavaran-lookup.config"; import { FanavaranAuthService } from "./fanavaran-auth.service"; @Injectable() export class FanavaranLookupService { private readonly logger = new Logger(FanavaranLookupService.name); constructor( private readonly httpService: HttpService, private readonly fanavaranAuthService: FanavaranAuthService, @InjectModel(LookupModel.name) private readonly lookupModel: Model, ) {} private cacheFilePath(clientKey: FanavaranClientKey, fileName: string): string { return join(fanavaranLookupCacheDir(clientKey), fileName); } async readCacheFile( clientKey: FanavaranClientKey, fileName: string, ): Promise { try { const content = await readFile( this.cacheFilePath(clientKey, fileName), "utf-8", ); return JSON.parse(content) as T; } catch { return null; } } async writeCacheFile( clientKey: FanavaranClientKey, fileName: string, data: unknown, ): Promise { const dir = fanavaranLookupCacheDir(clientKey); await mkdir(dir, { recursive: true }); await writeFile( join(dir, fileName), `${JSON.stringify(data, null, 2)}\n`, "utf-8", ); this.logger.log( `Cached Fanavaran lookup ${fileName} for client ${clientKey}`, ); } async readTejaratStaticAccidentFile(fileName: string): Promise { const cached = await readFile(tejaratStaticAccidentFilePath(fileName), "utf-8"); return JSON.parse(cached) as T; } async fetchFromFanavaran( clientKey: FanavaranClientKey, url: string, options?: { contractIdOverride?: string }, ): Promise { try { const headers = await this.fanavaranAuthService.getRequestHeaders( clientKey, { contractIdOverride: options?.contractIdOverride }, ); this.logger.log( `[${clientKey}] Calling Fanavaran lookup API: ${url}`, ); const response = await firstValueFrom( this.httpService.get(url, { headers: { ...headers, "Content-Type": "application/json", }, timeout: 20000, }), ); const dataCount = Array.isArray(response.data) ? response.data.length : typeof response.data === "object" && response.data !== null ? Object.keys(response.data).length : 0; this.logger.log( `[${clientKey}] Fanavaran lookup response status=${response.status} dataCount=${dataCount}`, ); this.fanavaranAuthService.clearBackoff(clientKey); return response.data; } catch (error) { this.fanavaranAuthService.registerFailure(clientKey, error); const message = isAxiosError(error) ? error.response?.data?.Message || error.response?.data?.message || error.message : error instanceof Error ? error.message : "Fanavaran lookup request failed"; this.logger.error( `Fanavaran lookup fetch failed for ${clientKey} (${url}): ${message}`, ); throw new BadGatewayException(String(message)); } } /** * Resolve a remote Fanavaran lookup. * * Parsian (default): file → `lookups` collection → Fanavaran API * Tejaratno with dbSource: file → Fanavaran → DB on API failure * Others: file → Fanavaran */ async getRemoteLookup( clientKey: FanavaranClientKey, url: string, cacheFile: string, options?: | (() => Promise) | { /** Soft/hard DB read — return null/undefined to miss. */ dbSource?: () => Promise; /** When true: try DB after file miss, before Fanavaran. */ preferDbBeforeRemote?: boolean; }, ): Promise { const normalized = typeof options === "function" ? { dbSource: options, preferDbBeforeRemote: false } : options ?? {}; const preferDbBeforeRemote = normalized.preferDbBeforeRemote ?? clientKey === "parsian"; const dbSource = normalized.dbSource ?? (preferDbBeforeRemote ? () => this.readLookupsCollectionByCacheFile(cacheFile) : undefined); const cached = await this.readCacheFile(clientKey, cacheFile); if (cached !== null) { return cached; } if (preferDbBeforeRemote && dbSource) { const fromDb = await this.tryDbSource(clientKey, cacheFile, dbSource); if (fromDb !== null) { await this.writeCacheFile(clientKey, cacheFile, fromDb); return fromDb; } } try { const data = await this.fetchFromFanavaran(clientKey, url); await this.writeCacheFile(clientKey, cacheFile, data); return data; } catch (error) { if (dbSource && !preferDbBeforeRemote) { this.logger.warn( `Fanavaran lookup fetch failed for ${clientKey}/${cacheFile}; using DB fallback`, ); const data = await dbSource(); if (data != null) { await this.writeCacheFile(clientKey, cacheFile, data); return data; } } throw error; } } private lookupNameFromCacheFile(cacheFile: string): string { return cacheFile.replace(/\.json$/i, ""); } private async readLookupsCollectionByCacheFile( cacheFile: string, ): Promise { const name = this.lookupNameFromCacheFile(cacheFile); const doc = await this.lookupModel.findOne({ name }).lean().exec(); if (!doc || doc.response == null) { return null; } return doc.response; } private async tryDbSource( clientKey: FanavaranClientKey, cacheFile: string, dbSource: () => Promise, ): Promise { try { const data = await dbSource(); if (data == null) { this.logger.debug( `[${clientKey}] No DB lookup for ${cacheFile}; will try Fanavaran`, ); return null; } this.logger.log( `[${clientKey}] Using lookups collection for ${cacheFile} (before Fanavaran)`, ); return data; } catch (error) { this.logger.warn( `[${clientKey}] DB lookup miss for ${cacheFile}: ${ error instanceof Error ? error.message : error }; will try Fanavaran`, ); return null; } } async inquiryByVin( clientKey: FanavaranClientKey, vin: string, options?: { contractIdOverride?: string }, ): Promise { const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicles/inquiry-by-vin?vin=${encodeURIComponent(vin)}`; return this.fetchFromFanavaran(clientKey, url, options); } async myPolicies( clientKey: FanavaranClientKey, nationalCode: string, insuranceLineId: number = 5, options?: { contractIdOverride?: string }, ): Promise { const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/Policies/inquiry-my-policies` + `?InsuranceLineId=${insuranceLineId}` + `&NationalCode=${encodeURIComponent(nationalCode)}`; return this.fetchFromFanavaran(clientKey, url, options); } async thirdPartyPolicyById( clientKey: FanavaranClientKey, policyId: number, options?: { contractIdOverride?: string }, ): Promise { const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/third-party-car-policies/${policyId}`; return this.fetchFromFanavaran(clientKey, url, options); } async bodyPolicyById( clientKey: FanavaranClientKey, policyId: number, options?: { contractIdOverride?: string }, ): Promise { const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicle-hull-policies/${policyId}`; return this.fetchFromFanavaran(clientKey, url, options); } /** GEN.06 VehicleHullAccessoryId — requires hull PolicyId (کد رایانه بیمه‌نامه). */ async vehicleHullDmgAccessoriesByPolicyId( clientKey: FanavaranClientKey, policyId: number, options?: { contractIdOverride?: string }, ): Promise { const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicle-hull-policies/${policyId}/dmg-accessories`; return this.fetchFromFanavaran(clientKey, url, options); } async vehicleById( clientKey: FanavaranClientKey, vehicleId: number, versionNo?: number, options?: { contractIdOverride?: string }, ): Promise { const query = versionNo == null ? "" : `?versionno=${versionNo}`; const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicles/${vehicleId}${query}`; return this.fetchFromFanavaran(clientKey, url, options); } async customerById( clientKey: FanavaranClientKey, customerId: number, options?: { contractIdOverride?: string }, ): Promise { const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/customers/${customerId}`; return this.fetchFromFanavaran(clientKey, url, options); } async inquiryByUniqueIdentifier( clientKey: FanavaranClientKey, params: { nationalCode: string; birthYear: number; birthMonth: number; birthDay: number; }, ): Promise { const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/parties/inquiry-by-unique-identifier` + `?NationalCode=${encodeURIComponent(params.nationalCode)}` + `&BirthYear=${params.birthYear}` + `&BirthMonth=${params.birthMonth}` + `&BirthDay=${params.birthDay}`; return this.fetchFromFanavaran(clientKey, url); } async getOtherPerson( clientKey: FanavaranClientKey, personId: number, ): Promise { const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/other-people/${personId}`; return this.fetchFromFanavaran(clientKey, url); } async createOtherPerson( clientKey: FanavaranClientKey, payload: Record, ): Promise { const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/other-people`; return this.postToFanavaran(clientKey, url, payload); } async postToFanavaran( clientKey: FanavaranClientKey, url: string, payload: Record, ): Promise { try { const headers = await this.fanavaranAuthService.getRequestHeaders( clientKey, ); this.logger.log(`[${clientKey}] POST Fanavaran: ${url}`); const response = await firstValueFrom( this.httpService.post(url, payload, { headers: { ...headers, "Content-Type": "application/json", }, timeout: 20000, }), ); this.fanavaranAuthService.clearBackoff(clientKey); return response.data; } catch (error) { this.fanavaranAuthService.registerFailure(clientKey, error); const message = isAxiosError(error) ? error.response?.data?.Message || error.response?.data?.message || error.message : error instanceof Error ? error.message : "Fanavaran POST request failed"; this.logger.error( `Fanavaran POST failed for ${clientKey} (${url}): ${message}`, ); throw new BadGatewayException(String(message)); } } 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 }[] { if (!Array.isArray(causes)) { throw new NotFoundException("Fanavaran accident-causes response is invalid"); } return causes .filter( (item) => item && typeof item === "object" && (item as { IsActive?: number }).IsActive === 1 && typeof (item as { Id?: unknown }).Id === "number", ) .map((item) => { const row = item as { Id: number; Caption?: string }; return { id: row.Id, label: row.Caption ?? String(row.Id), fanavaran: row.Id, }; }); } }