diff --git a/src/app.module.ts b/src/app.module.ts index bb62079..0652b14 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,6 +10,7 @@ import { AuthModule } from "./auth/auth.module"; import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module"; import { ClientModule } from "./client/client.module"; import { ExpertBlameModule } from "./expert-blame/expert-blame.module"; +import { FanavaranModule } from "./fanavaran/fanavaran.module"; import { ExpertClaimModule } from "./expert-claim/expert-claim.module"; import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.module"; import { CaseExpertReportModule } from "./case-expert-report/case-expert-report.module"; @@ -50,6 +51,7 @@ import { AppConfigModule } from "./core/config/config.module"; SystemSettingsModule, ExpertBlameModule, ClaimRequestManagementModule, + FanavaranModule, ExpertClaimModule, CaseExpertReportModule, AiModule, diff --git a/src/claim-request-management/claim-request-management.module.ts b/src/claim-request-management/claim-request-management.module.ts index d2c522f..a2692ab 100644 --- a/src/claim-request-management/claim-request-management.module.ts +++ b/src/claim-request-management/claim-request-management.module.ts @@ -53,10 +53,12 @@ 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"; @Module({ imports: [ HttpModule, + FanavaranAuditModule, PublicIdModule, UsersModule, RequestManagementModule, diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 902716e..821baff 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -163,6 +163,19 @@ import { import { HttpService } from "@nestjs/axios"; import { firstValueFrom } from "rxjs"; import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher"; +import { + type FanavaranClientKey, + fanavaranSubmitPath, + getFanavaranClientProfile, + resolveFanavaranClientKey, +} from "src/core/config/fanavaran-client.config"; +import { FanavaranAuditService } from "src/fanavaran/fanavaran-audit.service"; +import type { FanavaranAuditSession } from "src/fanavaran/fanavaran-audit.types"; +import { + FanavaranAuditSource, + FanavaranAuditStatus, + FanavaranAuditStep, +} from "src/fanavaran/schema/fanavaran-audit-log.schema"; export interface FanavaranAutoSubmitResult { attempted: boolean; @@ -175,15 +188,17 @@ export interface FanavaranAutoSubmitResult { fanavaranResponse?: unknown; } +const FANAVARAN_ACCIDENT_LOCATION_ADDRESS = "استان تهران شهر تهران"; + @Injectable() export class ClaimRequestManagementService { private readonly logger = new Logger(ClaimRequestManagementService.name); // Authentication URLs and credentials (same as lookups service) private readonly GET_APP_TOKEN_URL = - "https://insapm.ir/bimeapimanager/api/EITAuthentication/GetAppToken"; + "https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken"; private readonly LOGIN_URL = - "https://insapm.ir/bimeapimanager/api/EITAuthentication/Login"; + "https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login"; private readonly FANAVARAN_SUBMIT_URL = "https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/third-party-car-financial-claims"; @@ -253,6 +268,7 @@ export class ClaimRequestManagementService { private readonly publicIdService: PublicIdService, private readonly sandHubService: SandHubService, private readonly httpService: HttpService, + private readonly fanavaranAuditService: FanavaranAuditService, ) {} private requiredDocumentKeysV2(isCarBody: boolean): string[] { @@ -3349,9 +3365,15 @@ export class ClaimRequestManagementService { } private applyFanavaranDefaultFields(result: Record): void { + result.ActualPremium = null; + result.ArchiveNo = null; + result.AuthorityCulpritId = null; result.AccidentCulpritId = null; + result.ClaimCompletionDate = null; + result.CostSeparationToDmgSections = null; result.CouponNo = null; result.CourtArchiveNo = null; + result.CustomerFaultPercent = null; result.CulpritLicenceCityId = null; result.CulpritLicenceCountryId = null; result.CulpritLicenceForeignCityName = null; @@ -3360,11 +3382,14 @@ export class ClaimRequestManagementService { result.DamagedCount = 1; result.DmgAssessorFirstCreationTime = null; result.EntryDate = null; + result.GlassBreakReasonId = null; result.IsLicenseMatchWithVehicleKind = 1; result.HasOtherCulprit = 0; result.IsAccidentOutOfBorder = 0; result.IsFatalAccident = 0; + result.IsOwnerChanged = null; result.IsPlaqueChanged = 0; + result.IsSurplusArticleEighthLaw = null; result.PlaqueCityId = null; result.PlaqueKindId = null; result.PlaqueLeftNo = null; @@ -3379,6 +3404,7 @@ export class ClaimRequestManagementService { result.PoliceReportSerial = null; result.PolicyId = null; result.PreviousPolicyEndDate = ""; + result.StatusChangeDate = null; result.TrackingCode = null; result.IsLicenseReplacement = 0; result.UnknownCulpritCauseId = null; @@ -3425,12 +3451,7 @@ export class ClaimRequestManagementService { 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.AccidentLocationAddress = FANAVARAN_ACCIDENT_LOCATION_ADDRESS; result.EstimateAmount = input.damageParts ? this.calculateEstimateAmount(input.damageParts) @@ -3589,16 +3610,7 @@ export class ClaimRequestManagementService { result.AccidentTime = this.getTime24Hour(blameRequest.createdAt); } - // AccidentLocationAddress: Get from reverse geocoding API - if ( - blameRequest.firstPartyLocation?.lat && - blameRequest.firstPartyLocation?.lon - ) { - result.AccidentLocationAddress = await this.getAddressFromCoordinates( - blameRequest.firstPartyLocation.lat, - blameRequest.firstPartyLocation.lon, - ); - } + result.AccidentLocationAddress = FANAVARAN_ACCIDENT_LOCATION_ADDRESS; // EstimateAmount: Sum of totalPayment from damageExpertReply parts const damageReply = @@ -3610,39 +3622,7 @@ export class ClaimRequestManagementService { } // Keep other fields from template with default values - result.AccidentCulpritId = null; //todo - result.CouponNo = null; - result.CourtArchiveNo = null; - result.CulpritLicenceCityId = null; - result.CulpritLicenceCountryId = null; - result.CulpritLicenceForeignCityName = null; - result.CulpritLicenceIssuDate = "1394/10/13"; //todo - 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; + this.applyFanavaranDefaultFields(result); // Get PolicyId from external API try { @@ -3676,7 +3656,19 @@ export class ClaimRequestManagementService { appName: string; secret: string; } = this.getTejaratnoFanavaranConfig(), + auditSession?: FanavaranAuditSession, ): Promise { + const startedAt = Date.now(); + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.GET_APP_TOKEN, + status: FanavaranAuditStatus.STARTED, + requestUrl: this.GET_APP_TOKEN_URL, + requestMeta: { appName: config.appName }, + }); + } + try { const requestHeaders: any = { appname: config.appName, @@ -3712,9 +3704,34 @@ export class ClaimRequestManagementService { ); } + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.GET_APP_TOKEN, + status: FanavaranAuditStatus.SUCCESS, + requestUrl: this.GET_APP_TOKEN_URL, + httpStatus: response.status, + responseMeta: { hasAppToken: true }, + durationMs: Date.now() - startedAt, + }); + } + this.logger.log(`Successfully obtained appToken`); return appToken; } catch (error) { + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.GET_APP_TOKEN, + status: FanavaranAuditStatus.FAILURE, + requestUrl: this.GET_APP_TOKEN_URL, + httpStatus: isAxiosError(error) ? error.response?.status : undefined, + errorMessage: this.fanavaranAuditService.extractErrorMessage(error), + errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), + durationMs: Date.now() - startedAt, + }); + } + this.logger.error("Failed to get appToken", error); if (isAxiosError(error)) { const errorMessage = @@ -3723,9 +3740,19 @@ export class ClaimRequestManagementService { error.response?.data || error.message || "Failed to get appToken from external API"; - throw new BadGatewayException(errorMessage); + throw new BadGatewayException( + this.fanavaranAuditService.formatErrorWithTrackingCode( + String(errorMessage), + auditSession?.trackingCode, + ), + ); } - throw new BadGatewayException("Failed to get appToken from external API"); + throw new BadGatewayException( + this.fanavaranAuditService.formatErrorWithTrackingCode( + "Failed to get appToken from external API", + auditSession?.trackingCode, + ), + ); } } @@ -3738,7 +3765,19 @@ export class ClaimRequestManagementService { username: string; password: string; } = this.getTejaratnoFanavaranConfig(), + auditSession?: FanavaranAuditSession, ): Promise { + const startedAt = Date.now(); + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.LOGIN, + status: FanavaranAuditStatus.STARTED, + requestUrl: this.LOGIN_URL, + requestMeta: { userName: config.username }, + }); + } + try { const requestHeaders: any = { appToken: appToken, @@ -3763,7 +3802,6 @@ export class ClaimRequestManagementService { }), ); - // Check response headers first (authenticationToken is in headers) const authenticationToken = response.headers.authenticationtoken || response.headers.authenticationToken || @@ -3780,9 +3818,34 @@ export class ClaimRequestManagementService { ); } + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.LOGIN, + status: FanavaranAuditStatus.SUCCESS, + requestUrl: this.LOGIN_URL, + httpStatus: response.status, + responseMeta: { hasAuthenticationToken: true }, + durationMs: Date.now() - startedAt, + }); + } + this.logger.log(`Successfully obtained authenticationToken`); return authenticationToken; } catch (error) { + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.LOGIN, + status: FanavaranAuditStatus.FAILURE, + requestUrl: this.LOGIN_URL, + httpStatus: isAxiosError(error) ? error.response?.status : undefined, + errorMessage: this.fanavaranAuditService.extractErrorMessage(error), + errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), + durationMs: Date.now() - startedAt, + }); + } + this.logger.error("Failed to login", error); if (isAxiosError(error)) { const errorMessage = @@ -3791,9 +3854,19 @@ export class ClaimRequestManagementService { error.response?.data || error.message || "Failed to login to external API"; - throw new BadGatewayException(errorMessage); + throw new BadGatewayException( + this.fanavaranAuditService.formatErrorWithTrackingCode( + String(errorMessage), + auditSession?.trackingCode, + ), + ); } - throw new BadGatewayException("Failed to login to external API"); + throw new BadGatewayException( + this.fanavaranAuditService.formatErrorWithTrackingCode( + "Failed to login to external API", + auditSession?.trackingCode, + ), + ); } } @@ -3809,12 +3882,31 @@ export class ClaimRequestManagementService { location: string; }, logPrefix: string, + auditSession?: FanavaranAuditSession, ): 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}`; + const startedAt = Date.now(); - const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`; + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.POLICY_INQUIRY, + status: FanavaranAuditStatus.STARTED, + requestUrl: policyInquiryUrl, + requestMeta: { + corpId: config.corpId, + contractId: config.contractId, + location: config.location, + nationalCode: this.fanavaranAuditService.maskNationalCode( + nationalCodeOfInsurer, + ), + }, + }); + } + + try { + const appToken = await this.getAppToken(config, auditSession); + const authenticationToken = await this.login(appToken, config, auditSession); this.logger.log( `${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`, @@ -3840,15 +3932,55 @@ export class ClaimRequestManagementService { this.logger.log( `${logPrefix} Successfully retrieved PolicyId from last policy entry: ${policyId}`, ); + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.POLICY_INQUIRY, + status: FanavaranAuditStatus.SUCCESS, + requestUrl: policyInquiryUrl, + httpStatus: response.status, + responseMeta: { + policyId, + policyCount: response.data.length, + }, + durationMs: Date.now() - startedAt, + }); + } return policyId; } } this.logger.warn(`${logPrefix} No PolicyId found in API response`); + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.POLICY_INQUIRY, + status: FanavaranAuditStatus.SUCCESS, + requestUrl: policyInquiryUrl, + httpStatus: response.status, + responseMeta: { + policyId: null, + policyCount: Array.isArray(response.data) ? response.data.length : 0, + }, + durationMs: Date.now() - startedAt, + }); + } return null; } catch (error) { const errorMessage = error instanceof Error ? error.message : "unknown policy inquiry error"; + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.POLICY_INQUIRY, + status: FanavaranAuditStatus.FAILURE, + requestUrl: policyInquiryUrl, + httpStatus: isAxiosError(error) ? error.response?.status : undefined, + errorMessage, + errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), + durationMs: Date.now() - startedAt, + }); + } this.logger.warn( `${logPrefix} Policy inquiry failed; continuing with empty PolicyId: ${errorMessage}`, ); @@ -3964,14 +4096,37 @@ export class ClaimRequestManagementService { } /** - * Fanavaran Submit (Parsian / V2) - Map data from claimCases + blameCases + * Preview Fanavaran submit body (V2 claimCases + blameCases) for a specific client. */ - async fanavaranSubmitParsianV2( + async previewFanavaranSubmitV2( claimCaseId: string, - options?: { debug?: boolean }, + clientKey: FanavaranClientKey, + options?: { + debug?: boolean; + auditSession?: FanavaranAuditSession; + }, ): Promise { + const profile = getFanavaranClientProfile(clientKey); + const logPrefix = `[Fanavaran ${clientKey} V2] claimCaseId=${claimCaseId}`; + const auditSession = + options?.auditSession ?? + ({ + trackingCode: this.fanavaranAuditService.generateTrackingCode(), + clientKey, + source: FanavaranAuditSource.PREVIEW, + claimCaseId, + } satisfies FanavaranAuditSession); + const buildStartedAt = Date.now(); + + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.BUILD_PAYLOAD, + status: FanavaranAuditStatus.STARTED, + }); + try { const debug = { + clientKey, claimCaseId, steps: { claimCaseFound: false, @@ -4022,7 +4177,8 @@ export class ClaimRequestManagementService { const expertDecision = blameCase.expert?.decision; debug.steps.expertDecisionFound = !!expertDecision; - const fallbackAccidentReason = claimCase.snapshot?.accident?.classification?.accidentReason; + const fallbackAccidentReason = + claimCase.snapshot?.accident?.classification?.accidentReason; if (!expertDecision?.fields?.accidentReason && fallbackAccidentReason) { debug.steps.accidentReasonFallbackFromSnapshot = true; } @@ -4051,8 +4207,8 @@ export class ClaimRequestManagementService { createdAt: (blameCase as { createdAt?: Date }).createdAt, location: firstParty?.location, damageParts, - defaults: this.PARSIAN_FANAVARAN_DEFAULTS, - logPrefix: `[Fanavaran Parsian V2] claimCaseId=${claimCaseId}`, + defaults: profile.defaults, + logPrefix, resolvePolicyId: async () => { const guiltyPartyId = this.resolveGuiltyPartyIdV2( blameCase.parties ?? [], @@ -4065,16 +4221,17 @@ export class ClaimRequestManagementService { debug.values.guiltyPartyId = guiltyPartyId ? guiltyPartyId.toString() : null; - const nationalCodeOfInsurer = this.getNationalCodeOfInsurerForGuiltyPartyV2( - blameCase.parties ?? [], - guiltyPartyId, - ); + 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`, + `${logPrefix} guiltyPartyId not found in expert.decision`, ); debug.failureReason = "expert.decision.guiltyPartyId is missing"; return null; @@ -4082,7 +4239,7 @@ export class ClaimRequestManagementService { if (!nationalCodeOfInsurer) { this.logger.warn( - `[Fanavaran Parsian V2] nationalCodeOfInsurer not found for guiltyPartyId: ${guiltyPartyId}`, + `${logPrefix} nationalCodeOfInsurer not found for guiltyPartyId: ${guiltyPartyId}`, ); debug.failureReason = "nationalCodeOfInsurer not found for guilty party in blameCase.parties"; @@ -4092,8 +4249,9 @@ export class ClaimRequestManagementService { debug.steps.policyInquiryAttempted = true; const policyId = await this.getPolicyIdFromNationalCode( nationalCodeOfInsurer, - this.PARSIAN_FANAVARAN_CONFIG, - `[Fanavaran Parsian V2] claimCaseId=${claimCaseId}`, + profile.auth, + logPrefix, + auditSession, ); debug.values.policyId = policyId; debug.steps.policyInquirySucceeded = policyId !== null; @@ -4108,6 +4266,7 @@ export class ClaimRequestManagementService { if (options?.debug) { return { + clientKey, payload, debug, }; @@ -4115,34 +4274,52 @@ export class ClaimRequestManagementService { return payload; } catch (error) { - this.logger.error("Error in fanavaranSubmitParsianV2", error); + this.logger.error(`Error in previewFanavaranSubmitV2 (${clientKey})`, error); throw error; } } + /** @deprecated Use previewFanavaranSubmitV2(claimCaseId, "parsian", options) */ + async fanavaranSubmitParsianV2( + claimCaseId: string, + options?: { debug?: boolean }, + ): Promise { + return this.previewFanavaranSubmitV2(claimCaseId, "parsian", options); + } + /** - * Submit Parsian fanavaran data (V2 collections) to external API + * Submit Fanavaran data (V2 claimCases + blameCases) for a specific client. */ - async submitToFanavaranParsianV2(claimCaseId: string): Promise { + async submitFanavaranV2( + claimCaseId: string, + clientKey: FanavaranClientKey, + ): Promise { try { - return await this.executeFanavaranParsianV2Submit(claimCaseId); + return await this.executeFanavaranV2Submit(claimCaseId, clientKey); } catch (error) { this.logger.error( - `[Fanavaran Parsian V2] Error submitting to Fanavaran`, + `[Fanavaran ${clientKey} V2] Error submitting to Fanavaran`, error, ); throw new BadGatewayException(this.extractFanavaranErrorMessage(error)); } } + /** @deprecated Use submitFanavaranV2(claimCaseId, "parsian") */ + async submitToFanavaranParsianV2(claimCaseId: string): Promise { + return this.submitFanavaranV2(claimCaseId, "parsian"); + } + /** * Auto-submit to Fanavaran when a claim case reaches COMPLETED. + * Uses FANAVARAN_CLIENT env (or CLIENT_ID fallback) to pick the tenant. * Never throws — failures are returned as warnings so the main flow continues. */ - async autoSubmitToFanavaranParsianV2OnClaimCompleted( + async autoSubmitToFanavaranV2OnClaimCompleted( claimCaseId: string, ): Promise { - const logPrefix = `[Fanavaran Parsian V2 Auto] claimCaseId=${claimCaseId}`; + const clientKey = resolveFanavaranClientKey(); + const logPrefix = `[Fanavaran ${clientKey} V2 Auto] claimCaseId=${claimCaseId}`; try { const claimCase = await this.claimCaseDbService.findById(claimCaseId); @@ -4166,8 +4343,9 @@ export class ClaimRequestManagementService { }; } - const fanavaranResponse = await this.executeFanavaranParsianV2Submit( + const fanavaranResponse = await this.executeFanavaranV2Submit( claimCaseId, + clientKey, ); await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { @@ -4177,6 +4355,7 @@ export class ClaimRequestManagementService { actor: { actorType: "system" }, timestamp: new Date(), metadata: { + clientKey, claimNo: fanavaranResponse?.ClaimNo, claimId: fanavaranResponse?.Id, }, @@ -4202,7 +4381,7 @@ export class ClaimRequestManagementService { type: "FANAVARAN_AUTO_SUBMIT_FAILED", actor: { actorType: "system" }, timestamp: new Date(), - metadata: { error: warning }, + metadata: { clientKey, error: warning }, }, }, }); @@ -4216,11 +4395,18 @@ export class ClaimRequestManagementService { 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}.`, + warning: `${warning} Case was not sent to Fanavaran. Retry manually via POST ${fanavaranSubmitPath(clientKey, claimCaseId)}.`, }; } } + /** @deprecated Use autoSubmitToFanavaranV2OnClaimCompleted */ + async autoSubmitToFanavaranParsianV2OnClaimCompleted( + claimCaseId: string, + ): Promise { + return this.autoSubmitToFanavaranV2OnClaimCompleted(claimCaseId); + } + private extractFanavaranErrorMessage(error: unknown): string { if (isAxiosError(error)) { return ( @@ -4247,16 +4433,20 @@ export class ClaimRequestManagementService { return "Failed to submit data to Fanavaran API"; } - private async executeFanavaranParsianV2Submit( + private async executeFanavaranV2Submit( claimCaseId: string, + clientKey: FanavaranClientKey, ): Promise { - const config = this.PARSIAN_FANAVARAN_CONFIG; - const logPrefix = `[Fanavaran Parsian V2]`; + const profile = getFanavaranClientProfile(clientKey); + const logPrefix = `[Fanavaran ${clientKey} V2]`; this.logger.log( `${logPrefix} Starting submission for claimCaseId: ${claimCaseId}`, ); - const fanavaranData = await this.fanavaranSubmitParsianV2(claimCaseId); + const fanavaranData = await this.previewFanavaranSubmitV2( + claimCaseId, + clientKey, + ); this.logger.log( `${logPrefix} Mapped data prepared:`, JSON.stringify(fanavaranData, null, 2), @@ -4275,16 +4465,16 @@ export class ClaimRequestManagementService { ); } - const appToken = await this.getAppToken(config); - const authenticationToken = await this.login(appToken, config); + const appToken = await this.getAppToken(profile.auth); + const authenticationToken = await this.login(appToken, profile.auth); const response = await firstValueFrom( this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, { headers: { authenticationToken: authenticationToken, - CorpId: config.corpId, - ContractId: config.contractId, - Location: config.location, + CorpId: profile.auth.corpId, + ContractId: profile.auth.contractId, + Location: profile.auth.location, "Content-Type": "application/json", }, }), @@ -4319,6 +4509,13 @@ export class ClaimRequestManagementService { return response.data; } + /** @deprecated Use executeFanavaranV2Submit(claimCaseId, "parsian") */ + private async executeFanavaranParsianV2Submit( + claimCaseId: string, + ): Promise { + return this.executeFanavaranV2Submit(claimCaseId, "parsian"); + } + /** * Submit fanavaran data to external API */ @@ -7427,7 +7624,7 @@ export class ClaimRequestManagementService { }); const fanavaran = - await this.autoSubmitToFanavaranParsianV2OnClaimCompleted(claimRequestId); + await this.autoSubmitToFanavaranV2OnClaimCompleted(claimRequestId); let message = "Your signature has been recorded. The claim is completed."; if (fanavaran.submitted) { 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 cd951c9..ff3bf64 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -1030,43 +1030,4 @@ 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/core/config/fanavaran-client.config.ts b/src/core/config/fanavaran-client.config.ts index 0777f68..f1c591a 100644 --- a/src/core/config/fanavaran-client.config.ts +++ b/src/core/config/fanavaran-client.config.ts @@ -1,5 +1,25 @@ export type FanavaranClientKey = "parsian" | "tejaratno"; +export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [ + "parsian", + "tejaratno", +] as const; + +export function isFanavaranClientKey(value: string): value is FanavaranClientKey { + const normalized = value?.trim().toLowerCase(); + return normalized === "parsian" || normalized === "tejaratno"; +} + +export function normalizeFanavaranClientKey(value: string): FanavaranClientKey { + const normalized = value?.trim().toLowerCase(); + if (normalized === "parsian" || normalized === "tejaratno") { + return normalized; + } + throw new Error( + `Invalid Fanavaran client "${value}". Expected one of: ${FANAVARAN_CLIENT_KEYS.join(", ")}`, + ); +} + export interface FanavaranAuthConfig { appName: string; secret: string; @@ -93,6 +113,30 @@ export function resolveFanavaranClientProfile(): FanavaranClientProfile { return FANAVARAN_CLIENT_PROFILES[resolveFanavaranClientKey()]; } -export function fanavaranManualSubmitPath(claimCaseId: string): string { - return `/v2/claim-request-management/fanavaran-submit/${claimCaseId}`; +export function getFanavaranClientProfile( + clientKey: FanavaranClientKey, +): FanavaranClientProfile { + return FANAVARAN_CLIENT_PROFILES[clientKey]; +} + +export function listFanavaranClientProfiles(): FanavaranClientProfile[] { + return FANAVARAN_CLIENT_KEYS.map((key) => FANAVARAN_CLIENT_PROFILES[key]); +} + +export function fanavaranPreviewPath( + clientKey: FanavaranClientKey, + claimCaseId: string, +): string { + return `/v2/fanavaran/${clientKey}/claim-cases/${claimCaseId}`; +} + +export function fanavaranSubmitPath( + clientKey: FanavaranClientKey, + claimCaseId: string, +): string { + return `/v2/fanavaran/${clientKey}/claim-cases/${claimCaseId}`; +} + +export function fanavaranManualSubmitPath(claimCaseId: string): string { + return fanavaranSubmitPath(resolveFanavaranClientKey(), claimCaseId); } diff --git a/src/expert-blame/expert-blame.module.ts b/src/expert-blame/expert-blame.module.ts index 0471094..d118bfd 100644 --- a/src/expert-blame/expert-blame.module.ts +++ b/src/expert-blame/expert-blame.module.ts @@ -7,6 +7,7 @@ import { ExpertBlameV2Controller } from "./expert-blame.v2.controller"; import { ExpertBlameService } from "./expert-blame.service"; import { RequestManagementModule } from "src/request-management/request-management.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module"; +import { LookupsModule } from "src/lookups/lookups.module"; import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module"; @Module({ @@ -17,6 +18,7 @@ import { ClaimRequestManagementModule } from "src/claim-request-management/claim PlatesModule, SmsOrchestrationModule, ClaimRequestManagementModule, + LookupsModule, ], controllers: [ExpertBlameController, ExpertBlameV2Controller], providers: [ExpertBlameService], diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index cc6f987..753f543 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -30,6 +30,7 @@ import { } from "src/helpers/unified-file-status"; import { applyListQueryV2, isInListDateRange, parseListDateRange } from "src/helpers/list-query-v2"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; +import { LookupsService } from "src/lookups/lookups.service"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { UnifiedFileStatusReportDto, @@ -127,6 +128,7 @@ export class ExpertBlameService { private readonly smsOrchestrationService: SmsOrchestrationService, private readonly expertFileActivityDbService: ExpertFileActivityDbService, private readonly claimCaseDbService: ClaimCaseDbService, + private readonly lookupsService: LookupsService, ) {} private async loadClaimsByPublicIds( @@ -2425,21 +2427,7 @@ export class ExpertBlameService { } async getAccidentField() { - try { - const ac_reason = await readFile( - "src/static/ACCIDENT_REASON.json", - "utf-8", - ); - const ac_type = await readFile("src/static/ACCIDENT_TYPE.json", "utf-8"); - const ac_way = await readFile("src/static/ACCIDENT_WAY.json", "utf-8"); - return { - accidentReason: JSON.parse(ac_reason), - accidentType: JSON.parse(ac_type), - accidentWay: JSON.parse(ac_way), - }; - } catch (err) { - this.logger.error(err); - } + return await this.lookupsService.getAccidentFields(); } async inPersonVisit(requestId: string, actorDetail: any) { diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 7134a87..b142f01 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -2365,7 +2365,7 @@ export class ExpertClaimService { { replyField }, ); const fanavaran = - await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted( + await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted( claimRequestId, ); return { @@ -2391,7 +2391,7 @@ export class ExpertClaimService { ); const fanavaran = - await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted( + await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted( claimRequestId, ); diff --git a/src/fanavaran/fanavaran-audit.module.ts b/src/fanavaran/fanavaran-audit.module.ts new file mode 100644 index 0000000..64974e0 --- /dev/null +++ b/src/fanavaran/fanavaran-audit.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; +import { FanavaranAuditService } from "./fanavaran-audit.service"; +import { + FanavaranAuditLog, + FanavaranAuditLogSchema, +} from "./schema/fanavaran-audit-log.schema"; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: FanavaranAuditLog.name, schema: FanavaranAuditLogSchema }, + ]), + ], + providers: [FanavaranAuditService], + exports: [FanavaranAuditService], +}) +export class FanavaranAuditModule {} diff --git a/src/fanavaran/fanavaran-audit.service.ts b/src/fanavaran/fanavaran-audit.service.ts new file mode 100644 index 0000000..2ed314f --- /dev/null +++ b/src/fanavaran/fanavaran-audit.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { isAxiosError } from "axios"; +import { randomBytes } from "node:crypto"; +import { Model, Types } from "mongoose"; +import { + FanavaranAuditLog, + FanavaranAuditLogDocument, + FanavaranAuditStatus, + FanavaranAuditStep, +} from "./schema/fanavaran-audit-log.schema"; +import type { FanavaranAuditSession } from "./fanavaran-audit.types"; + +export interface RecordFanavaranAuditStepInput { + session: FanavaranAuditSession; + step: FanavaranAuditStep; + status: FanavaranAuditStatus; + requestUrl?: string; + httpStatus?: number; + requestMeta?: Record; + responseMeta?: Record; + errorMessage?: string; + errorDetails?: Record; + durationMs?: number; +} + +@Injectable() +export class FanavaranAuditService { + private readonly logger = new Logger(FanavaranAuditService.name); + + constructor( + @InjectModel(FanavaranAuditLog.name) + private readonly auditModel: Model, + ) {} + + generateTrackingCode(): string { + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + const suffix = randomBytes(3).toString("hex").toUpperCase(); + return `FNV-${date}-${suffix}`; + } + + maskNationalCode(nationalCode: string): string { + const trimmed = nationalCode.trim(); + if (trimmed.length <= 4) { + return "****"; + } + return `${trimmed.slice(0, 3)}****${trimmed.slice(-2)}`; + } + + sanitizeErrorDetails(error: unknown): Record { + if (isAxiosError(error)) { + const data = error.response?.data; + return { + type: "axios", + status: error.response?.status, + statusText: error.response?.statusText, + data: + typeof data === "object" && data !== null + ? data + : typeof data === "string" + ? data.slice(0, 2000) + : data, + }; + } + if (error instanceof Error) { + return { type: "error", name: error.name, message: error.message }; + } + return { type: "unknown", value: String(error) }; + } + + extractErrorMessage(error: unknown): string { + if (isAxiosError(error)) { + const data = error.response?.data as + | { Message?: string; message?: string } + | string + | undefined; + if (typeof data === "string") { + return data; + } + return ( + data?.Message || + data?.message || + error.message || + "Fanavaran request failed" + ); + } + if (error instanceof Error) { + return error.message; + } + return "Fanavaran request failed"; + } + + formatErrorWithTrackingCode(message: string, trackingCode?: string): string { + if (!trackingCode) { + return message; + } + return `${message} (trackingCode: ${trackingCode})`; + } + + async recordStep(input: RecordFanavaranAuditStepInput): Promise { + try { + await this.auditModel.create({ + trackingCode: input.session.trackingCode, + step: input.step, + status: input.status, + clientKey: input.session.clientKey, + source: input.session.source, + ...(input.session.claimCaseId + ? { claimCaseId: new Types.ObjectId(input.session.claimCaseId) } + : {}), + ...(input.session.claimRequestId + ? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) } + : {}), + requestUrl: input.requestUrl, + httpStatus: input.httpStatus, + requestMeta: input.requestMeta, + responseMeta: input.responseMeta, + errorMessage: input.errorMessage, + errorDetails: input.errorDetails, + durationMs: input.durationMs, + }); + } catch (error) { + this.logger.error( + `Failed to persist Fanavaran audit step ${input.step} (${input.status})`, + error, + ); + } + } + + async findByTrackingCode(trackingCode: string) { + return this.auditModel + .find({ trackingCode }) + .sort({ createdAt: 1 }) + .lean() + .exec(); + } +} diff --git a/src/fanavaran/fanavaran-audit.types.ts b/src/fanavaran/fanavaran-audit.types.ts new file mode 100644 index 0000000..43beb5f --- /dev/null +++ b/src/fanavaran/fanavaran-audit.types.ts @@ -0,0 +1,10 @@ +import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config"; +import { FanavaranAuditSource } from "./schema/fanavaran-audit-log.schema"; + +export interface FanavaranAuditSession { + trackingCode: string; + clientKey: FanavaranClientKey; + source: FanavaranAuditSource; + claimCaseId?: string; + claimRequestId?: string; +} diff --git a/src/fanavaran/fanavaran-lookup.config.ts b/src/fanavaran/fanavaran-lookup.config.ts new file mode 100644 index 0000000..597f3b3 --- /dev/null +++ b/src/fanavaran/fanavaran-lookup.config.ts @@ -0,0 +1,58 @@ +import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config"; + +export const FANAVARAN_LOOKUP_BASE_URL = + "https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0"; + +export interface FanavaranRemoteLookupDefinition { + name: string; + url: string; + cacheFile: string; +} + +/** Fanavaran API lookups synced per client (Parsian fetches on first call). */ +export const FANAVARAN_REMOTE_LOOKUPS: FanavaranRemoteLookupDefinition[] = [ + { + name: "accident-causes", + url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/accident-causes`, + cacheFile: "accident-causes.json", + }, + { + name: "accident-report-type", + url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/accident-report-type`, + cacheFile: "accident-report-type.json", + }, + { + name: "vehicle-use-types", + url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/vehicle-use-types`, + cacheFile: "vehicle-use-types.json", + }, + { + name: "dmg-pay-method", + url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/dmg-pay-method`, + cacheFile: "dmg-pay-method.json", + }, + { + name: "driving-licence-types", + url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/driving-licence-types`, + cacheFile: "driving-licence-types.json", + }, + { + name: "accident-culprit-type", + url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/accident-culprit-type`, + cacheFile: "accident-culprit-type.json", + }, +]; + +export const TEJARAT_STATIC_ACCIDENT_FILES = { + accidentReason: "ACCIDENT_REASON.json", + accidentWay: "ACCIDENT_WAY.json", + accidentType: "ACCIDENT_TYPE.json", +} as const; + +export function fanavaranLookupCacheDir(clientKey: FanavaranClientKey): string { + return `${process.cwd()}/files/fanavaran-lookups/${clientKey}`; +} + +export function tejaratStaticAccidentFilePath(fileName: string): string { + return `${process.cwd()}/src/static/${fileName}`; +} diff --git a/src/fanavaran/fanavaran-lookup.module.ts b/src/fanavaran/fanavaran-lookup.module.ts new file mode 100644 index 0000000..5a376f9 --- /dev/null +++ b/src/fanavaran/fanavaran-lookup.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { FanavaranLookupService } from "./fanavaran-lookup.service"; + +@Module({ + imports: [HttpModule], + providers: [FanavaranLookupService], + exports: [FanavaranLookupService], +}) +export class FanavaranLookupModule {} diff --git a/src/fanavaran/fanavaran-lookup.service.ts b/src/fanavaran/fanavaran-lookup.service.ts new file mode 100644 index 0000000..e281f97 --- /dev/null +++ b/src/fanavaran/fanavaran-lookup.service.ts @@ -0,0 +1,241 @@ +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 { firstValueFrom } from "rxjs"; +import { isAxiosError } from "axios"; +import { + getFanavaranClientProfile, + type FanavaranClientKey, +} from "src/core/config/fanavaran-client.config"; +import { + fanavaranLookupCacheDir, + tejaratStaticAccidentFilePath, +} from "./fanavaran-lookup.config"; + +@Injectable() +export class FanavaranLookupService { + private readonly logger = new Logger(FanavaranLookupService.name); + + private readonly getAppTokenUrl = + "https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken"; + private readonly loginUrl = + "https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login"; + + constructor(private readonly httpService: HttpService) {} + + 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; + } + + private async getAppToken(config: { + appName: string; + secret: string; + }): Promise { + const response = await firstValueFrom( + this.httpService.post(this.getAppTokenUrl, "", { + headers: { + appname: config.appName, + secret: config.secret, + "Content-Length": "0", + }, + transformRequest: [ + (_data, headers) => { + if (headers) { + delete headers["Content-Type"]; + delete headers["content-type"]; + } + return _data; + }, + ], + }), + ); + + const appToken = + response.headers.apptoken || + response.headers.appToken || + response.headers["apptoken"] || + response.headers["appToken"]; + + if (!appToken) { + throw new BadGatewayException("Failed to get Fanavaran appToken"); + } + + return appToken; + } + + private async login( + appToken: string, + config: { username: string; password: string }, + ): Promise { + const response = await firstValueFrom( + this.httpService.post(this.loginUrl, "", { + headers: { + appToken, + userName: config.username, + password: config.password, + "Content-Length": "0", + }, + transformRequest: [ + (_data, headers) => { + if (headers) { + delete headers["Content-Type"]; + delete headers["content-type"]; + } + return _data; + }, + ], + }), + ); + + const authenticationToken = + response.headers.authenticationtoken || + response.headers.authenticationToken || + response.headers["authenticationtoken"] || + response.headers["authenticationToken"] || + response.data?.authenticationtoken || + response.data?.authenticationToken || + response.data?.authentication_token; + + if (!authenticationToken) { + throw new BadGatewayException("Failed to get Fanavaran authenticationToken"); + } + + return authenticationToken; + } + + async fetchFromFanavaran( + clientKey: FanavaranClientKey, + url: string, + ): Promise { + const profile = getFanavaranClientProfile(clientKey); + + try { + const appToken = await this.getAppToken(profile.auth); + const authenticationToken = await this.login(appToken, profile.auth); + + const response = await firstValueFrom( + this.httpService.get(url, { + headers: { + authenticationToken, + CorpId: profile.auth.corpId, + ContractId: profile.auth.contractId, + Location: profile.auth.location, + "Content-Type": "application/json", + }, + timeout: 20000, + }), + ); + + return response.data; + } catch (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)); + } + } + + async getRemoteLookup( + clientKey: FanavaranClientKey, + url: string, + cacheFile: string, + fallback?: () => Promise, + ): Promise { + const cached = await this.readCacheFile(clientKey, cacheFile); + if (cached !== null) { + return cached; + } + + try { + const data = await this.fetchFromFanavaran(clientKey, url); + await this.writeCacheFile(clientKey, cacheFile, data); + return data; + } catch (error) { + if (fallback) { + this.logger.warn( + `Fanavaran lookup fetch failed for ${clientKey}/${cacheFile}; using fallback`, + ); + const data = await fallback(); + await this.writeCacheFile(clientKey, cacheFile, data); + return data; + } + throw error; + } + } + + 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, + }; + }); + } +} diff --git a/src/fanavaran/fanavaran.controller.ts b/src/fanavaran/fanavaran.controller.ts new file mode 100644 index 0000000..70ff83d --- /dev/null +++ b/src/fanavaran/fanavaran.controller.ts @@ -0,0 +1,124 @@ +import { + BadRequestException, + Controller, + Get, + Param, + Post, + Query, + UseGuards, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from "@nestjs/swagger"; +import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; +import { RolesGuard } from "src/auth/guards/role.guard"; +import { Roles } from "src/decorators/roles.decorator"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service"; +import { + isFanavaranClientKey, + listFanavaranClientProfiles, + normalizeFanavaranClientKey, + resolveFanavaranClientKey, +} from "src/core/config/fanavaran-client.config"; + +@ApiTags("fanavaran") +@Controller("v2/fanavaran") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.ADMIN, RoleEnum.FIELD_EXPERT) +export class FanavaranController { + constructor( + private readonly claimRequestManagementService: ClaimRequestManagementService, + ) {} + + @Get("clients") + @ApiOperation({ + summary: "List supported Fanavaran insurance clients", + description: + "Returns configured Fanavaran tenants (parsian, tejaratno) and which client is active for this deployment.", + }) + listClients() { + const activeClient = resolveFanavaranClientKey(); + return { + activeClient, + clients: listFanavaranClientProfiles().map((profile) => ({ + key: profile.key, + defaults: profile.defaults, + isActive: profile.key === activeClient, + })), + }; + } + + @Get(":client/claim-cases/:claimCaseId") + @ApiOperation({ + summary: "Preview Fanavaran submit payload (V2)", + description: + "Builds the third-party-car-financial-claims body from claimCases + blameCases without calling Fanavaran.", + }) + @ApiParam({ + name: "client", + description: "Fanavaran tenant key", + enum: ["parsian", "tejaratno"], + }) + @ApiParam({ + name: "claimCaseId", + description: "Claim case MongoDB ObjectId", + }) + @ApiQuery({ + name: "debug", + required: false, + description: "When true, returns payload plus mapping debug steps", + }) + async preview( + @Param("client") client: string, + @Param("claimCaseId") claimCaseId: string, + @Query("debug") debug?: string, + ) { + const clientKey = this.parseClientParam(client); + return await this.claimRequestManagementService.previewFanavaranSubmitV2( + claimCaseId, + clientKey, + { debug: debug === "1" || debug === "true" }, + ); + } + + @Post(":client/claim-cases/:claimCaseId") + @ApiOperation({ + summary: "Submit claim to Fanavaran (V2)", + description: + "Authenticates with the selected client credentials and submits the mapped claimCases + blameCases payload.", + }) + @ApiParam({ + name: "client", + description: "Fanavaran tenant key", + enum: ["parsian", "tejaratno"], + }) + @ApiParam({ + name: "claimCaseId", + description: "Claim case MongoDB ObjectId", + }) + async submit( + @Param("client") client: string, + @Param("claimCaseId") claimCaseId: string, + ) { + const clientKey = this.parseClientParam(client); + return await this.claimRequestManagementService.submitFanavaranV2( + claimCaseId, + clientKey, + ); + } + + private parseClientParam(client: string) { + if (!isFanavaranClientKey(client)) { + throw new BadRequestException( + `Invalid Fanavaran client "${client}". Expected one of: parsian, tejaratno`, + ); + } + return normalizeFanavaranClientKey(client); + } +} diff --git a/src/fanavaran/fanavaran.module.ts b/src/fanavaran/fanavaran.module.ts new file mode 100644 index 0000000..b2b4e9f --- /dev/null +++ b/src/fanavaran/fanavaran.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module"; +import { FanavaranAuditModule } from "./fanavaran-audit.module"; +import { FanavaranController } from "./fanavaran.controller"; + +@Module({ + imports: [ClaimRequestManagementModule, FanavaranAuditModule], + controllers: [FanavaranController], +}) +export class FanavaranModule {} diff --git a/src/fanavaran/schema/fanavaran-audit-log.schema.ts b/src/fanavaran/schema/fanavaran-audit-log.schema.ts new file mode 100644 index 0000000..aad2666 --- /dev/null +++ b/src/fanavaran/schema/fanavaran-audit-log.schema.ts @@ -0,0 +1,75 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { HydratedDocument, Types } from "mongoose"; +import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config"; + +export enum FanavaranAuditStep { + GET_APP_TOKEN = "GET_APP_TOKEN", + LOGIN = "LOGIN", + POLICY_INQUIRY = "POLICY_INQUIRY", + BUILD_PAYLOAD = "BUILD_PAYLOAD", + SUBMIT_CLAIM = "SUBMIT_CLAIM", +} + +export enum FanavaranAuditStatus { + STARTED = "started", + SUCCESS = "success", + FAILURE = "failure", +} + +export enum FanavaranAuditSource { + PREVIEW = "preview", + SUBMIT = "submit", + AUTO_SUBMIT = "auto_submit", + LEGACY_SUBMIT = "legacy_submit", +} + +@Schema({ collection: "fanavaranAuditLogs", timestamps: true }) +export class FanavaranAuditLog { + @Prop({ type: String, required: true, index: true }) + trackingCode: string; + + @Prop({ type: String, required: true, enum: FanavaranAuditStep, index: true }) + step: FanavaranAuditStep; + + @Prop({ type: String, required: true, enum: FanavaranAuditStatus, index: true }) + status: FanavaranAuditStatus; + + @Prop({ type: String, required: true, index: true }) + clientKey: FanavaranClientKey; + + @Prop({ type: String, required: true, enum: FanavaranAuditSource, index: true }) + source: FanavaranAuditSource; + + @Prop({ type: Types.ObjectId, required: false, index: true }) + claimCaseId?: Types.ObjectId; + + @Prop({ type: Types.ObjectId, required: false, index: true }) + claimRequestId?: Types.ObjectId; + + @Prop({ type: String, required: false }) + requestUrl?: string; + + @Prop({ type: Number, required: false }) + httpStatus?: number; + + @Prop({ type: Object, required: false }) + requestMeta?: Record; + + @Prop({ type: Object, required: false }) + responseMeta?: Record; + + @Prop({ type: String, required: false }) + errorMessage?: string; + + @Prop({ type: Object, required: false }) + errorDetails?: Record; + + @Prop({ type: Number, required: false }) + durationMs?: number; +} + +export type FanavaranAuditLogDocument = HydratedDocument; +export const FanavaranAuditLogSchema = + SchemaFactory.createForClass(FanavaranAuditLog); + +FanavaranAuditLogSchema.index({ trackingCode: 1, createdAt: 1 }); diff --git a/src/lookups/lookups.controller.ts b/src/lookups/lookups.controller.ts index caafd56..6223343 100644 --- a/src/lookups/lookups.controller.ts +++ b/src/lookups/lookups.controller.ts @@ -163,6 +163,7 @@ export class LookupsController { schema: { type: "object", properties: { + client: { type: "string", example: "tejaratno" }, accidentWay: { type: "array", items: { diff --git a/src/lookups/lookups.module.ts b/src/lookups/lookups.module.ts index 0b60716..0719158 100644 --- a/src/lookups/lookups.module.ts +++ b/src/lookups/lookups.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { MongooseModule } from "@nestjs/mongoose"; +import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module"; import { LookupDbService } from "./entities/db-service/lookup.db.service"; import { LookupModel, LookupSchema } from "./entities/schema/lookup.schema"; import { LookupsController } from "./lookups.controller"; @@ -7,6 +8,7 @@ import { LookupsService } from "./lookups.service"; @Module({ imports: [ + FanavaranLookupModule, MongooseModule.forFeature([ { name: LookupModel.name, schema: LookupSchema }, ]), diff --git a/src/lookups/lookups.service.ts b/src/lookups/lookups.service.ts index 43bb4bc..5551cc3 100644 --- a/src/lookups/lookups.service.ts +++ b/src/lookups/lookups.service.ts @@ -1,15 +1,47 @@ -import { readFile } from "node:fs/promises"; import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { resolveFanavaranClientKey } from "src/core/config/fanavaran-client.config"; +import { + FANAVARAN_REMOTE_LOOKUPS, + TEJARAT_STATIC_ACCIDENT_FILES, +} from "src/fanavaran/fanavaran-lookup.config"; +import { FanavaranLookupService } from "src/fanavaran/fanavaran-lookup.service"; import { LookupDbService } from "./entities/db-service/lookup.db.service"; +type TejaratAccidentReasonRow = { + id: number; + persianLabel: string; + fanavaranID: number; +}; + +type TejaratAccidentLabelRow = { + id: number; + persianLabel: string; +}; + @Injectable() export class LookupsService { private readonly logger = new Logger(LookupsService.name); - constructor(private readonly lookupDbService: LookupDbService) {} + constructor( + private readonly lookupDbService: LookupDbService, + private readonly fanavaranLookupService: FanavaranLookupService, + ) {} + + private activeClientKey() { + return resolveFanavaranClientKey(); + } + + private findRemoteLookup(name: string) { + const lookup = FANAVARAN_REMOTE_LOOKUPS.find((item) => item.name === name); + if (!lookup) { + throw new Error(`Unknown Fanavaran remote lookup: ${name}`); + } + return lookup; + } /** * Returns stored `response` for a lookup document by `name` in the lookups collection. + * Legacy Tejarat seed data — used as fallback when live fetch is unavailable. */ async getLookup(lookupName: string): Promise { const doc = await this.lookupDbService.findOne({ name: lookupName }); @@ -20,42 +52,114 @@ export class LookupsService { return doc.response; } + private async getClientRemoteLookup(lookupName: string): Promise { + const clientKey = this.activeClientKey(); + const definition = this.findRemoteLookup(lookupName); + + return this.fanavaranLookupService.getRemoteLookup( + clientKey, + definition.url, + definition.cacheFile, + clientKey === "tejaratno" + ? async () => this.getLookup(lookupName) + : undefined, + ); + } + async getAccidentCauses(): Promise { - return await this.getLookup("accident-causes"); + return await this.getClientRemoteLookup("accident-causes"); } async getAccidentReportType(): Promise { - return await this.getLookup("accident-report-type"); + return await this.getClientRemoteLookup("accident-report-type"); } async getVehicleUseTypes(): Promise { - return await this.getLookup("vehicle-use-types"); + return await this.getClientRemoteLookup("vehicle-use-types"); } async getDmgPayMethod(): Promise { - return await this.getLookup("dmg-pay-method"); + return await this.getClientRemoteLookup("dmg-pay-method"); } async getDrivingLicenceTypes(): Promise { - return await this.getLookup("driving-licence-types"); + return await this.getClientRemoteLookup("driving-licence-types"); } async getAccidentCulpritType(): Promise { - return await this.getLookup("accident-culprit-type"); + return await this.getClientRemoteLookup("accident-culprit-type"); } async getAccidentWay(): Promise<{ id: number; label: string }[]> { - const raw: { id: number; persianLabel: string }[] = JSON.parse( - await readFile("src/static/ACCIDENT_WAY.json", "utf-8"), - ); + const clientKey = this.activeClientKey(); + const fileName = TEJARAT_STATIC_ACCIDENT_FILES.accidentWay; + + const cached = await this.fanavaranLookupService.readCacheFile< + TejaratAccidentLabelRow[] + >(clientKey, fileName); + const raw = + cached ?? + (clientKey === "tejaratno" + ? await this.fanavaranLookupService.readTejaratStaticAccidentFile< + TejaratAccidentLabelRow[] + >(fileName) + : null); + + if (!raw) { + this.logger.warn( + `No accident-way lookup for client ${clientKey}; falling back to tejarat static file`, + ); + const fallback = + await this.fanavaranLookupService.readTejaratStaticAccidentFile< + TejaratAccidentLabelRow[] + >(fileName); + return fallback.map((item) => ({ + id: item.id, + label: item.persianLabel, + })); + } + return raw.map((item) => ({ id: item.id, label: item.persianLabel })); } async getAccidentReason(): Promise< { id: number; label: string; fanavaran: number }[] > { - const raw: { id: number; persianLabel: string; fanavaranID: number }[] = - JSON.parse(await readFile("src/static/ACCIDENT_REASON.json", "utf-8")); + const clientKey = this.activeClientKey(); + + if (clientKey === "parsian") { + const cacheFile = "accident-reason-options.json"; + const cached = + await this.fanavaranLookupService.readCacheFile< + { id: number; label: string; fanavaran: number }[] + >(clientKey, cacheFile); + if (cached) { + return cached; + } + + const causes = await this.getAccidentCauses(); + const mapped = + this.fanavaranLookupService.mapFanavaranAccidentCausesToReasonOptions( + causes, + ); + await this.fanavaranLookupService.writeCacheFile( + clientKey, + cacheFile, + mapped, + ); + return mapped; + } + + const fileName = TEJARAT_STATIC_ACCIDENT_FILES.accidentReason; + const cached = await this.fanavaranLookupService.readCacheFile< + TejaratAccidentReasonRow[] + >(clientKey, fileName); + const raw = + cached ?? + (await this.fanavaranLookupService.readTejaratStaticAccidentFile< + TejaratAccidentReasonRow[] + >(fileName)); + return raw.map((item) => ({ id: item.id, label: item.persianLabel, @@ -64,9 +168,34 @@ export class LookupsService { } async getAccidentType(): Promise<{ id: number; label: string }[]> { - const raw: { id: number; persianLabel: string }[] = JSON.parse( - await readFile("src/static/ACCIDENT_TYPE.json", "utf-8"), - ); + const clientKey = this.activeClientKey(); + const fileName = TEJARAT_STATIC_ACCIDENT_FILES.accidentType; + + const cached = await this.fanavaranLookupService.readCacheFile< + TejaratAccidentLabelRow[] + >(clientKey, fileName); + const raw = + cached ?? + (clientKey === "tejaratno" + ? await this.fanavaranLookupService.readTejaratStaticAccidentFile< + TejaratAccidentLabelRow[] + >(fileName) + : null); + + if (!raw) { + this.logger.warn( + `No accident-type lookup for client ${clientKey}; falling back to tejarat static file`, + ); + const fallback = + await this.fanavaranLookupService.readTejaratStaticAccidentFile< + TejaratAccidentLabelRow[] + >(fileName); + return fallback.map((item) => ({ + id: item.id, + label: item.persianLabel, + })); + } + return raw.map((item) => ({ id: item.id, label: item.persianLabel })); } @@ -74,12 +203,18 @@ export class LookupsService { accidentWay: { id: number; label: string }[]; accidentReason: { id: number; label: string; fanavaran: number }[]; accidentType: { id: number; label: string }[]; + client: string; }> { const [accidentWay, accidentReason, accidentType] = await Promise.all([ this.getAccidentWay(), this.getAccidentReason(), this.getAccidentType(), ]); - return { accidentWay, accidentReason, accidentType }; + return { + client: this.activeClientKey(), + accidentWay, + accidentReason, + accidentType, + }; } }