1
0
forked from Yara724/api

merge upstream

This commit is contained in:
2026-06-27 09:30:55 +03:30
20 changed files with 1186 additions and 169 deletions

View File

@@ -10,6 +10,7 @@ import { AuthModule } from "./auth/auth.module";
import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module"; import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module";
import { ClientModule } from "./client/client.module"; import { ClientModule } from "./client/client.module";
import { ExpertBlameModule } from "./expert-blame/expert-blame.module"; import { ExpertBlameModule } from "./expert-blame/expert-blame.module";
import { FanavaranModule } from "./fanavaran/fanavaran.module";
import { ExpertClaimModule } from "./expert-claim/expert-claim.module"; import { ExpertClaimModule } from "./expert-claim/expert-claim.module";
import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.module"; import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.module";
import { CaseExpertReportModule } from "./case-expert-report/case-expert-report.module"; import { CaseExpertReportModule } from "./case-expert-report/case-expert-report.module";
@@ -50,6 +51,7 @@ import { AppConfigModule } from "./core/config/config.module";
SystemSettingsModule, SystemSettingsModule,
ExpertBlameModule, ExpertBlameModule,
ClaimRequestManagementModule, ClaimRequestManagementModule,
FanavaranModule,
ExpertClaimModule, ExpertClaimModule,
CaseExpertReportModule, CaseExpertReportModule,
AiModule, AiModule,

View File

@@ -53,10 +53,12 @@ import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { MediaPolicyModule } from "src/media-policy/media-policy.module"; import { MediaPolicyModule } from "src/media-policy/media-policy.module";
import { HttpModule } from "@nestjs/axios"; import { HttpModule } from "@nestjs/axios";
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
@Module({ @Module({
imports: [ imports: [
HttpModule, HttpModule,
FanavaranAuditModule,
PublicIdModule, PublicIdModule,
UsersModule, UsersModule,
RequestManagementModule, RequestManagementModule,

View File

@@ -163,6 +163,19 @@ import {
import { HttpService } from "@nestjs/axios"; import { HttpService } from "@nestjs/axios";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher"; 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 { export interface FanavaranAutoSubmitResult {
attempted: boolean; attempted: boolean;
@@ -175,15 +188,17 @@ export interface FanavaranAutoSubmitResult {
fanavaranResponse?: unknown; fanavaranResponse?: unknown;
} }
const FANAVARAN_ACCIDENT_LOCATION_ADDRESS = "استان تهران شهر تهران";
@Injectable() @Injectable()
export class ClaimRequestManagementService { export class ClaimRequestManagementService {
private readonly logger = new Logger(ClaimRequestManagementService.name); private readonly logger = new Logger(ClaimRequestManagementService.name);
// Authentication URLs and credentials (same as lookups service) // Authentication URLs and credentials (same as lookups service)
private readonly GET_APP_TOKEN_URL = 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 = private readonly LOGIN_URL =
"https://insapm.ir/bimeapimanager/api/EITAuthentication/Login"; "https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login";
private readonly FANAVARAN_SUBMIT_URL = private readonly FANAVARAN_SUBMIT_URL =
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/third-party-car-financial-claims"; "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 publicIdService: PublicIdService,
private readonly sandHubService: SandHubService, private readonly sandHubService: SandHubService,
private readonly httpService: HttpService, private readonly httpService: HttpService,
private readonly fanavaranAuditService: FanavaranAuditService,
) {} ) {}
private requiredDocumentKeysV2(isCarBody: boolean): string[] { private requiredDocumentKeysV2(isCarBody: boolean): string[] {
@@ -3349,9 +3365,15 @@ export class ClaimRequestManagementService {
} }
private applyFanavaranDefaultFields(result: Record<string, unknown>): void { private applyFanavaranDefaultFields(result: Record<string, unknown>): void {
result.ActualPremium = null;
result.ArchiveNo = null;
result.AuthorityCulpritId = null;
result.AccidentCulpritId = null; result.AccidentCulpritId = null;
result.ClaimCompletionDate = null;
result.CostSeparationToDmgSections = null;
result.CouponNo = null; result.CouponNo = null;
result.CourtArchiveNo = null; result.CourtArchiveNo = null;
result.CustomerFaultPercent = null;
result.CulpritLicenceCityId = null; result.CulpritLicenceCityId = null;
result.CulpritLicenceCountryId = null; result.CulpritLicenceCountryId = null;
result.CulpritLicenceForeignCityName = null; result.CulpritLicenceForeignCityName = null;
@@ -3360,11 +3382,14 @@ export class ClaimRequestManagementService {
result.DamagedCount = 1; result.DamagedCount = 1;
result.DmgAssessorFirstCreationTime = null; result.DmgAssessorFirstCreationTime = null;
result.EntryDate = null; result.EntryDate = null;
result.GlassBreakReasonId = null;
result.IsLicenseMatchWithVehicleKind = 1; result.IsLicenseMatchWithVehicleKind = 1;
result.HasOtherCulprit = 0; result.HasOtherCulprit = 0;
result.IsAccidentOutOfBorder = 0; result.IsAccidentOutOfBorder = 0;
result.IsFatalAccident = 0; result.IsFatalAccident = 0;
result.IsOwnerChanged = null;
result.IsPlaqueChanged = 0; result.IsPlaqueChanged = 0;
result.IsSurplusArticleEighthLaw = null;
result.PlaqueCityId = null; result.PlaqueCityId = null;
result.PlaqueKindId = null; result.PlaqueKindId = null;
result.PlaqueLeftNo = null; result.PlaqueLeftNo = null;
@@ -3379,6 +3404,7 @@ export class ClaimRequestManagementService {
result.PoliceReportSerial = null; result.PoliceReportSerial = null;
result.PolicyId = null; result.PolicyId = null;
result.PreviousPolicyEndDate = ""; result.PreviousPolicyEndDate = "";
result.StatusChangeDate = null;
result.TrackingCode = null; result.TrackingCode = null;
result.IsLicenseReplacement = 0; result.IsLicenseReplacement = 0;
result.UnknownCulpritCauseId = null; result.UnknownCulpritCauseId = null;
@@ -3425,12 +3451,7 @@ export class ClaimRequestManagementService {
result.AccidentTime = this.getTime24Hour(input.createdAt); result.AccidentTime = this.getTime24Hour(input.createdAt);
} }
if (input.location?.lat && input.location?.lon) { result.AccidentLocationAddress = FANAVARAN_ACCIDENT_LOCATION_ADDRESS;
result.AccidentLocationAddress = await this.getAddressFromCoordinates(
input.location.lat,
input.location.lon,
);
}
result.EstimateAmount = input.damageParts result.EstimateAmount = input.damageParts
? this.calculateEstimateAmount(input.damageParts) ? this.calculateEstimateAmount(input.damageParts)
@@ -3589,16 +3610,7 @@ export class ClaimRequestManagementService {
result.AccidentTime = this.getTime24Hour(blameRequest.createdAt); result.AccidentTime = this.getTime24Hour(blameRequest.createdAt);
} }
// AccidentLocationAddress: Get from reverse geocoding API result.AccidentLocationAddress = FANAVARAN_ACCIDENT_LOCATION_ADDRESS;
if (
blameRequest.firstPartyLocation?.lat &&
blameRequest.firstPartyLocation?.lon
) {
result.AccidentLocationAddress = await this.getAddressFromCoordinates(
blameRequest.firstPartyLocation.lat,
blameRequest.firstPartyLocation.lon,
);
}
// EstimateAmount: Sum of totalPayment from damageExpertReply parts // EstimateAmount: Sum of totalPayment from damageExpertReply parts
const damageReply = const damageReply =
@@ -3610,39 +3622,7 @@ export class ClaimRequestManagementService {
} }
// Keep other fields from template with default values // Keep other fields from template with default values
result.AccidentCulpritId = null; //todo this.applyFanavaranDefaultFields(result);
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;
// Get PolicyId from external API // Get PolicyId from external API
try { try {
@@ -3676,7 +3656,19 @@ export class ClaimRequestManagementService {
appName: string; appName: string;
secret: string; secret: string;
} = this.getTejaratnoFanavaranConfig(), } = this.getTejaratnoFanavaranConfig(),
auditSession?: FanavaranAuditSession,
): Promise<string> { ): Promise<string> {
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 { try {
const requestHeaders: any = { const requestHeaders: any = {
appname: config.appName, 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`); this.logger.log(`Successfully obtained appToken`);
return appToken; return appToken;
} catch (error) { } 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); this.logger.error("Failed to get appToken", error);
if (isAxiosError(error)) { if (isAxiosError(error)) {
const errorMessage = const errorMessage =
@@ -3723,9 +3740,19 @@ export class ClaimRequestManagementService {
error.response?.data || error.response?.data ||
error.message || error.message ||
"Failed to get appToken from external API"; "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; username: string;
password: string; password: string;
} = this.getTejaratnoFanavaranConfig(), } = this.getTejaratnoFanavaranConfig(),
auditSession?: FanavaranAuditSession,
): Promise<string> { ): Promise<string> {
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 { try {
const requestHeaders: any = { const requestHeaders: any = {
appToken: appToken, appToken: appToken,
@@ -3763,7 +3802,6 @@ export class ClaimRequestManagementService {
}), }),
); );
// Check response headers first (authenticationToken is in headers)
const authenticationToken = const authenticationToken =
response.headers.authenticationtoken || response.headers.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`); this.logger.log(`Successfully obtained authenticationToken`);
return authenticationToken; return authenticationToken;
} catch (error) { } 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); this.logger.error("Failed to login", error);
if (isAxiosError(error)) { if (isAxiosError(error)) {
const errorMessage = const errorMessage =
@@ -3791,9 +3854,19 @@ export class ClaimRequestManagementService {
error.response?.data || error.response?.data ||
error.message || error.message ||
"Failed to login to external API"; "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; location: string;
}, },
logPrefix: string, logPrefix: string,
auditSession?: FanavaranAuditSession,
): Promise<number | null> { ): Promise<number | null> {
try { const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`;
const appToken = await this.getAppToken(config); const startedAt = Date.now();
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}`; 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( this.logger.log(
`${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`, `${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`,
@@ -3840,15 +3932,55 @@ export class ClaimRequestManagementService {
this.logger.log( this.logger.log(
`${logPrefix} Successfully retrieved PolicyId from last policy entry: ${policyId}`, `${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; return policyId;
} }
} }
this.logger.warn(`${logPrefix} No PolicyId found in API response`); 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; return null;
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : "unknown policy inquiry error"; 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( this.logger.warn(
`${logPrefix} Policy inquiry failed; continuing with empty PolicyId: ${errorMessage}`, `${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, claimCaseId: string,
options?: { debug?: boolean }, clientKey: FanavaranClientKey,
options?: {
debug?: boolean;
auditSession?: FanavaranAuditSession;
},
): Promise<any> { ): Promise<any> {
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 { try {
const debug = { const debug = {
clientKey,
claimCaseId, claimCaseId,
steps: { steps: {
claimCaseFound: false, claimCaseFound: false,
@@ -4022,7 +4177,8 @@ export class ClaimRequestManagementService {
const expertDecision = blameCase.expert?.decision; const expertDecision = blameCase.expert?.decision;
debug.steps.expertDecisionFound = !!expertDecision; debug.steps.expertDecisionFound = !!expertDecision;
const fallbackAccidentReason = claimCase.snapshot?.accident?.classification?.accidentReason; const fallbackAccidentReason =
claimCase.snapshot?.accident?.classification?.accidentReason;
if (!expertDecision?.fields?.accidentReason && fallbackAccidentReason) { if (!expertDecision?.fields?.accidentReason && fallbackAccidentReason) {
debug.steps.accidentReasonFallbackFromSnapshot = true; debug.steps.accidentReasonFallbackFromSnapshot = true;
} }
@@ -4051,8 +4207,8 @@ export class ClaimRequestManagementService {
createdAt: (blameCase as { createdAt?: Date }).createdAt, createdAt: (blameCase as { createdAt?: Date }).createdAt,
location: firstParty?.location, location: firstParty?.location,
damageParts, damageParts,
defaults: this.PARSIAN_FANAVARAN_DEFAULTS, defaults: profile.defaults,
logPrefix: `[Fanavaran Parsian V2] claimCaseId=${claimCaseId}`, logPrefix,
resolvePolicyId: async () => { resolvePolicyId: async () => {
const guiltyPartyId = this.resolveGuiltyPartyIdV2( const guiltyPartyId = this.resolveGuiltyPartyIdV2(
blameCase.parties ?? [], blameCase.parties ?? [],
@@ -4065,16 +4221,17 @@ export class ClaimRequestManagementService {
debug.values.guiltyPartyId = guiltyPartyId debug.values.guiltyPartyId = guiltyPartyId
? guiltyPartyId.toString() ? guiltyPartyId.toString()
: null; : null;
const nationalCodeOfInsurer = this.getNationalCodeOfInsurerForGuiltyPartyV2( const nationalCodeOfInsurer =
blameCase.parties ?? [], this.getNationalCodeOfInsurerForGuiltyPartyV2(
guiltyPartyId, blameCase.parties ?? [],
); guiltyPartyId,
);
debug.steps.nationalCodeOfInsurerFound = !!nationalCodeOfInsurer; debug.steps.nationalCodeOfInsurerFound = !!nationalCodeOfInsurer;
debug.values.nationalCodeOfInsurer = nationalCodeOfInsurer; debug.values.nationalCodeOfInsurer = nationalCodeOfInsurer;
if (!guiltyPartyId) { if (!guiltyPartyId) {
this.logger.warn( 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"; debug.failureReason = "expert.decision.guiltyPartyId is missing";
return null; return null;
@@ -4082,7 +4239,7 @@ export class ClaimRequestManagementService {
if (!nationalCodeOfInsurer) { if (!nationalCodeOfInsurer) {
this.logger.warn( this.logger.warn(
`[Fanavaran Parsian V2] nationalCodeOfInsurer not found for guiltyPartyId: ${guiltyPartyId}`, `${logPrefix} nationalCodeOfInsurer not found for guiltyPartyId: ${guiltyPartyId}`,
); );
debug.failureReason = debug.failureReason =
"nationalCodeOfInsurer not found for guilty party in blameCase.parties"; "nationalCodeOfInsurer not found for guilty party in blameCase.parties";
@@ -4092,8 +4249,9 @@ export class ClaimRequestManagementService {
debug.steps.policyInquiryAttempted = true; debug.steps.policyInquiryAttempted = true;
const policyId = await this.getPolicyIdFromNationalCode( const policyId = await this.getPolicyIdFromNationalCode(
nationalCodeOfInsurer, nationalCodeOfInsurer,
this.PARSIAN_FANAVARAN_CONFIG, profile.auth,
`[Fanavaran Parsian V2] claimCaseId=${claimCaseId}`, logPrefix,
auditSession,
); );
debug.values.policyId = policyId; debug.values.policyId = policyId;
debug.steps.policyInquirySucceeded = policyId !== null; debug.steps.policyInquirySucceeded = policyId !== null;
@@ -4108,6 +4266,7 @@ export class ClaimRequestManagementService {
if (options?.debug) { if (options?.debug) {
return { return {
clientKey,
payload, payload,
debug, debug,
}; };
@@ -4115,34 +4274,52 @@ export class ClaimRequestManagementService {
return payload; return payload;
} catch (error) { } catch (error) {
this.logger.error("Error in fanavaranSubmitParsianV2", error); this.logger.error(`Error in previewFanavaranSubmitV2 (${clientKey})`, error);
throw error; throw error;
} }
} }
/** @deprecated Use previewFanavaranSubmitV2(claimCaseId, "parsian", options) */
async fanavaranSubmitParsianV2(
claimCaseId: string,
options?: { debug?: boolean },
): Promise<any> {
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<any> { async submitFanavaranV2(
claimCaseId: string,
clientKey: FanavaranClientKey,
): Promise<any> {
try { try {
return await this.executeFanavaranParsianV2Submit(claimCaseId); return await this.executeFanavaranV2Submit(claimCaseId, clientKey);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`[Fanavaran Parsian V2] Error submitting to Fanavaran`, `[Fanavaran ${clientKey} V2] Error submitting to Fanavaran`,
error, error,
); );
throw new BadGatewayException(this.extractFanavaranErrorMessage(error)); throw new BadGatewayException(this.extractFanavaranErrorMessage(error));
} }
} }
/** @deprecated Use submitFanavaranV2(claimCaseId, "parsian") */
async submitToFanavaranParsianV2(claimCaseId: string): Promise<any> {
return this.submitFanavaranV2(claimCaseId, "parsian");
}
/** /**
* Auto-submit to Fanavaran when a claim case reaches COMPLETED. * 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. * Never throws — failures are returned as warnings so the main flow continues.
*/ */
async autoSubmitToFanavaranParsianV2OnClaimCompleted( async autoSubmitToFanavaranV2OnClaimCompleted(
claimCaseId: string, claimCaseId: string,
): Promise<FanavaranAutoSubmitResult> { ): Promise<FanavaranAutoSubmitResult> {
const logPrefix = `[Fanavaran Parsian V2 Auto] claimCaseId=${claimCaseId}`; const clientKey = resolveFanavaranClientKey();
const logPrefix = `[Fanavaran ${clientKey} V2 Auto] claimCaseId=${claimCaseId}`;
try { try {
const claimCase = await this.claimCaseDbService.findById(claimCaseId); 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, claimCaseId,
clientKey,
); );
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
@@ -4177,6 +4355,7 @@ export class ClaimRequestManagementService {
actor: { actorType: "system" }, actor: { actorType: "system" },
timestamp: new Date(), timestamp: new Date(),
metadata: { metadata: {
clientKey,
claimNo: fanavaranResponse?.ClaimNo, claimNo: fanavaranResponse?.ClaimNo,
claimId: fanavaranResponse?.Id, claimId: fanavaranResponse?.Id,
}, },
@@ -4202,7 +4381,7 @@ export class ClaimRequestManagementService {
type: "FANAVARAN_AUTO_SUBMIT_FAILED", type: "FANAVARAN_AUTO_SUBMIT_FAILED",
actor: { actorType: "system" }, actor: { actorType: "system" },
timestamp: new Date(), timestamp: new Date(),
metadata: { error: warning }, metadata: { clientKey, error: warning },
}, },
}, },
}); });
@@ -4216,11 +4395,18 @@ export class ClaimRequestManagementService {
return { return {
attempted: true, attempted: true,
submitted: false, 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<FanavaranAutoSubmitResult> {
return this.autoSubmitToFanavaranV2OnClaimCompleted(claimCaseId);
}
private extractFanavaranErrorMessage(error: unknown): string { private extractFanavaranErrorMessage(error: unknown): string {
if (isAxiosError(error)) { if (isAxiosError(error)) {
return ( return (
@@ -4247,16 +4433,20 @@ export class ClaimRequestManagementService {
return "Failed to submit data to Fanavaran API"; return "Failed to submit data to Fanavaran API";
} }
private async executeFanavaranParsianV2Submit( private async executeFanavaranV2Submit(
claimCaseId: string, claimCaseId: string,
clientKey: FanavaranClientKey,
): Promise<any> { ): Promise<any> {
const config = this.PARSIAN_FANAVARAN_CONFIG; const profile = getFanavaranClientProfile(clientKey);
const logPrefix = `[Fanavaran Parsian V2]`; const logPrefix = `[Fanavaran ${clientKey} V2]`;
this.logger.log( this.logger.log(
`${logPrefix} Starting submission for claimCaseId: ${claimCaseId}`, `${logPrefix} Starting submission for claimCaseId: ${claimCaseId}`,
); );
const fanavaranData = await this.fanavaranSubmitParsianV2(claimCaseId); const fanavaranData = await this.previewFanavaranSubmitV2(
claimCaseId,
clientKey,
);
this.logger.log( this.logger.log(
`${logPrefix} Mapped data prepared:`, `${logPrefix} Mapped data prepared:`,
JSON.stringify(fanavaranData, null, 2), JSON.stringify(fanavaranData, null, 2),
@@ -4275,16 +4465,16 @@ export class ClaimRequestManagementService {
); );
} }
const appToken = await this.getAppToken(config); const appToken = await this.getAppToken(profile.auth);
const authenticationToken = await this.login(appToken, config); const authenticationToken = await this.login(appToken, profile.auth);
const response = await firstValueFrom( const response = await firstValueFrom(
this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, { this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, {
headers: { headers: {
authenticationToken: authenticationToken, authenticationToken: authenticationToken,
CorpId: config.corpId, CorpId: profile.auth.corpId,
ContractId: config.contractId, ContractId: profile.auth.contractId,
Location: config.location, Location: profile.auth.location,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}), }),
@@ -4319,6 +4509,13 @@ export class ClaimRequestManagementService {
return response.data; return response.data;
} }
/** @deprecated Use executeFanavaranV2Submit(claimCaseId, "parsian") */
private async executeFanavaranParsianV2Submit(
claimCaseId: string,
): Promise<any> {
return this.executeFanavaranV2Submit(claimCaseId, "parsian");
}
/** /**
* Submit fanavaran data to external API * Submit fanavaran data to external API
*/ */
@@ -7427,7 +7624,7 @@ export class ClaimRequestManagementService {
}); });
const fanavaran = const fanavaran =
await this.autoSubmitToFanavaranParsianV2OnClaimCompleted(claimRequestId); await this.autoSubmitToFanavaranV2OnClaimCompleted(claimRequestId);
let message = "Your signature has been recorded. The claim is completed."; let message = "Your signature has been recorded. The claim is completed.";
if (fanavaran.submitted) { if (fanavaran.submitted) {

View File

@@ -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,
);
}
} }

View File

@@ -1,5 +1,25 @@
export type FanavaranClientKey = "parsian" | "tejaratno"; 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 { export interface FanavaranAuthConfig {
appName: string; appName: string;
secret: string; secret: string;
@@ -93,6 +113,30 @@ export function resolveFanavaranClientProfile(): FanavaranClientProfile {
return FANAVARAN_CLIENT_PROFILES[resolveFanavaranClientKey()]; return FANAVARAN_CLIENT_PROFILES[resolveFanavaranClientKey()];
} }
export function fanavaranManualSubmitPath(claimCaseId: string): string { export function getFanavaranClientProfile(
return `/v2/claim-request-management/fanavaran-submit/${claimCaseId}`; 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);
} }

View File

@@ -7,6 +7,7 @@ import { ExpertBlameV2Controller } from "./expert-blame.v2.controller";
import { ExpertBlameService } from "./expert-blame.service"; import { ExpertBlameService } from "./expert-blame.service";
import { RequestManagementModule } from "src/request-management/request-management.module"; import { RequestManagementModule } from "src/request-management/request-management.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.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"; import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
@Module({ @Module({
@@ -17,6 +18,7 @@ import { ClaimRequestManagementModule } from "src/claim-request-management/claim
PlatesModule, PlatesModule,
SmsOrchestrationModule, SmsOrchestrationModule,
ClaimRequestManagementModule, ClaimRequestManagementModule,
LookupsModule,
], ],
controllers: [ExpertBlameController, ExpertBlameV2Controller], controllers: [ExpertBlameController, ExpertBlameV2Controller],
providers: [ExpertBlameService], providers: [ExpertBlameService],

View File

@@ -30,6 +30,7 @@ import {
} from "src/helpers/unified-file-status"; } from "src/helpers/unified-file-status";
import { applyListQueryV2, isInListDateRange, parseListDateRange } from "src/helpers/list-query-v2"; 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 { 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 { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { import {
UnifiedFileStatusReportDto, UnifiedFileStatusReportDto,
@@ -127,6 +128,7 @@ export class ExpertBlameService {
private readonly smsOrchestrationService: SmsOrchestrationService, private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService, private readonly expertFileActivityDbService: ExpertFileActivityDbService,
private readonly claimCaseDbService: ClaimCaseDbService, private readonly claimCaseDbService: ClaimCaseDbService,
private readonly lookupsService: LookupsService,
) {} ) {}
private async loadClaimsByPublicIds( private async loadClaimsByPublicIds(
@@ -2425,21 +2427,7 @@ export class ExpertBlameService {
} }
async getAccidentField() { async getAccidentField() {
try { return await this.lookupsService.getAccidentFields();
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);
}
} }
async inPersonVisit(requestId: string, actorDetail: any) { async inPersonVisit(requestId: string, actorDetail: any) {

View File

@@ -2365,7 +2365,7 @@ export class ExpertClaimService {
{ replyField }, { replyField },
); );
const fanavaran = const fanavaran =
await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted( await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
claimRequestId, claimRequestId,
); );
return { return {
@@ -2391,7 +2391,7 @@ export class ExpertClaimService {
); );
const fanavaran = const fanavaran =
await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted( await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
claimRequestId, claimRequestId,
); );

View File

@@ -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 {}

View File

@@ -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<string, unknown>;
responseMeta?: Record<string, unknown>;
errorMessage?: string;
errorDetails?: Record<string, unknown>;
durationMs?: number;
}
@Injectable()
export class FanavaranAuditService {
private readonly logger = new Logger(FanavaranAuditService.name);
constructor(
@InjectModel(FanavaranAuditLog.name)
private readonly auditModel: Model<FanavaranAuditLogDocument>,
) {}
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<string, unknown> {
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<void> {
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();
}
}

View File

@@ -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;
}

View File

@@ -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}`;
}

View File

@@ -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 {}

View File

@@ -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<T>(
clientKey: FanavaranClientKey,
fileName: string,
): Promise<T | null> {
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<void> {
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<T>(fileName: string): Promise<T> {
const cached = await readFile(tejaratStaticAccidentFilePath(fileName), "utf-8");
return JSON.parse(cached) as T;
}
private async getAppToken(config: {
appName: string;
secret: string;
}): Promise<string> {
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<string> {
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<unknown> {
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<unknown>,
): Promise<unknown> {
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,
};
});
}
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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<string, unknown>;
@Prop({ type: Object, required: false })
responseMeta?: Record<string, unknown>;
@Prop({ type: String, required: false })
errorMessage?: string;
@Prop({ type: Object, required: false })
errorDetails?: Record<string, unknown>;
@Prop({ type: Number, required: false })
durationMs?: number;
}
export type FanavaranAuditLogDocument = HydratedDocument<FanavaranAuditLog>;
export const FanavaranAuditLogSchema =
SchemaFactory.createForClass(FanavaranAuditLog);
FanavaranAuditLogSchema.index({ trackingCode: 1, createdAt: 1 });

View File

@@ -163,6 +163,7 @@ export class LookupsController {
schema: { schema: {
type: "object", type: "object",
properties: { properties: {
client: { type: "string", example: "tejaratno" },
accidentWay: { accidentWay: {
type: "array", type: "array",
items: { items: {

View File

@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
import { LookupDbService } from "./entities/db-service/lookup.db.service"; import { LookupDbService } from "./entities/db-service/lookup.db.service";
import { LookupModel, LookupSchema } from "./entities/schema/lookup.schema"; import { LookupModel, LookupSchema } from "./entities/schema/lookup.schema";
import { LookupsController } from "./lookups.controller"; import { LookupsController } from "./lookups.controller";
@@ -7,6 +8,7 @@ import { LookupsService } from "./lookups.service";
@Module({ @Module({
imports: [ imports: [
FanavaranLookupModule,
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: LookupModel.name, schema: LookupSchema }, { name: LookupModel.name, schema: LookupSchema },
]), ]),

View File

@@ -1,15 +1,47 @@
import { readFile } from "node:fs/promises";
import { Injectable, Logger, NotFoundException } from "@nestjs/common"; 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"; import { LookupDbService } from "./entities/db-service/lookup.db.service";
type TejaratAccidentReasonRow = {
id: number;
persianLabel: string;
fanavaranID: number;
};
type TejaratAccidentLabelRow = {
id: number;
persianLabel: string;
};
@Injectable() @Injectable()
export class LookupsService { export class LookupsService {
private readonly logger = new Logger(LookupsService.name); 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. * 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<any> { async getLookup(lookupName: string): Promise<any> {
const doc = await this.lookupDbService.findOne({ name: lookupName }); const doc = await this.lookupDbService.findOne({ name: lookupName });
@@ -20,42 +52,114 @@ export class LookupsService {
return doc.response; return doc.response;
} }
private async getClientRemoteLookup(lookupName: string): Promise<unknown> {
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<any> { async getAccidentCauses(): Promise<any> {
return await this.getLookup("accident-causes"); return await this.getClientRemoteLookup("accident-causes");
} }
async getAccidentReportType(): Promise<any> { async getAccidentReportType(): Promise<any> {
return await this.getLookup("accident-report-type"); return await this.getClientRemoteLookup("accident-report-type");
} }
async getVehicleUseTypes(): Promise<any> { async getVehicleUseTypes(): Promise<any> {
return await this.getLookup("vehicle-use-types"); return await this.getClientRemoteLookup("vehicle-use-types");
} }
async getDmgPayMethod(): Promise<any> { async getDmgPayMethod(): Promise<any> {
return await this.getLookup("dmg-pay-method"); return await this.getClientRemoteLookup("dmg-pay-method");
} }
async getDrivingLicenceTypes(): Promise<any> { async getDrivingLicenceTypes(): Promise<any> {
return await this.getLookup("driving-licence-types"); return await this.getClientRemoteLookup("driving-licence-types");
} }
async getAccidentCulpritType(): Promise<any> { async getAccidentCulpritType(): Promise<any> {
return await this.getLookup("accident-culprit-type"); return await this.getClientRemoteLookup("accident-culprit-type");
} }
async getAccidentWay(): Promise<{ id: number; label: string }[]> { async getAccidentWay(): Promise<{ id: number; label: string }[]> {
const raw: { id: number; persianLabel: string }[] = JSON.parse( const clientKey = this.activeClientKey();
await readFile("src/static/ACCIDENT_WAY.json", "utf-8"), 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 })); return raw.map((item) => ({ id: item.id, label: item.persianLabel }));
} }
async getAccidentReason(): Promise< async getAccidentReason(): Promise<
{ id: number; label: string; fanavaran: number }[] { id: number; label: string; fanavaran: number }[]
> { > {
const raw: { id: number; persianLabel: string; fanavaranID: number }[] = const clientKey = this.activeClientKey();
JSON.parse(await readFile("src/static/ACCIDENT_REASON.json", "utf-8"));
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) => ({ return raw.map((item) => ({
id: item.id, id: item.id,
label: item.persianLabel, label: item.persianLabel,
@@ -64,9 +168,34 @@ export class LookupsService {
} }
async getAccidentType(): Promise<{ id: number; label: string }[]> { async getAccidentType(): Promise<{ id: number; label: string }[]> {
const raw: { id: number; persianLabel: string }[] = JSON.parse( const clientKey = this.activeClientKey();
await readFile("src/static/ACCIDENT_TYPE.json", "utf-8"), 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 })); return raw.map((item) => ({ id: item.id, label: item.persianLabel }));
} }
@@ -74,12 +203,18 @@ export class LookupsService {
accidentWay: { id: number; label: string }[]; accidentWay: { id: number; label: string }[];
accidentReason: { id: number; label: string; fanavaran: number }[]; accidentReason: { id: number; label: string; fanavaran: number }[];
accidentType: { id: number; label: string }[]; accidentType: { id: number; label: string }[];
client: string;
}> { }> {
const [accidentWay, accidentReason, accidentType] = await Promise.all([ const [accidentWay, accidentReason, accidentType] = await Promise.all([
this.getAccidentWay(), this.getAccidentWay(),
this.getAccidentReason(), this.getAccidentReason(),
this.getAccidentType(), this.getAccidentType(),
]); ]);
return { accidentWay, accidentReason, accidentType }; return {
client: this.activeClientKey(),
accidentWay,
accidentReason,
accidentType,
};
} }
} }