1
0
forked from Yara724/api

fanavaran parsian done

This commit is contained in:
2026-06-17 16:57:21 +03:30
parent b008eda11b
commit 1ef17ce337
5 changed files with 838 additions and 97 deletions

View File

@@ -145,6 +145,17 @@ 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";
export interface FanavaranAutoSubmitResult {
attempted: boolean;
submitted: boolean;
skipped?: boolean;
skipReason?: string;
warning?: string;
claimNo?: number;
claimId?: number;
fanavaranResponse?: unknown;
}
@Injectable() @Injectable()
export class ClaimRequestManagementService { export class ClaimRequestManagementService {
private readonly logger = new Logger(ClaimRequestManagementService.name); private readonly logger = new Logger(ClaimRequestManagementService.name);
@@ -168,6 +179,41 @@ export class ClaimRequestManagementService {
private readonly CONTRACT_ID = "263"; private readonly CONTRACT_ID = "263";
private readonly LOCATION = "100"; private readonly LOCATION = "100";
private readonly PARSIAN_FANAVARAN_CONFIG = {
appName: "ParsianService",
secret: "P@r30@n$erv!ce",
username: "ParsianServiceUser",
password: "P@r30@n123",
corpId: "543",
contractId: "28",
location: "210050",
} as const;
/** Tejaratno hardcoded Fanavaran lookup defaults (legacy). */
private readonly TEJARATNO_FANAVARAN_DEFAULTS = {
AccidentCityId: 701,
AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1,
ClaimExpertId: 1589,
CompensationReferenceId: 167,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
} as const;
/** Parsian-validated Fanavaran lookup defaults (verified via direct submit). */
private readonly PARSIAN_FANAVARAN_DEFAULTS = {
AccidentCityId: 701,
AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1,
ClaimExpertId: 154,
CompensationReferenceId: 167,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
} as const;
private readonly MAP_IR_API_KEY =
"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg";
constructor( constructor(
private readonly blameDbService: RequestManagementDbService, private readonly blameDbService: RequestManagementDbService,
private readonly claimDbService: ClaimRequestManagementDbService, private readonly claimDbService: ClaimRequestManagementDbService,
@@ -3164,15 +3210,13 @@ export class ClaimRequestManagementService {
lon: number, lon: number,
): Promise<string> { ): Promise<string> {
try { try {
const apiKey =
"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg";
const response = await firstValueFrom( const response = await firstValueFrom(
this.httpService.get( this.httpService.get(
`https://map.ir/fast-reverse?lat=${lat}&lon=${lon}`, `https://map.ir/fast-reverse?lat=${lat}&lon=${lon}`,
{ {
headers: { headers: {
accept: "application/json", accept: "application/json",
"x-api-key": apiKey, "x-api-key": this.MAP_IR_API_KEY,
}, },
}, },
), ),
@@ -3220,6 +3264,140 @@ export class ClaimRequestManagementService {
return ids; return ids;
} }
private getTejaratnoFanavaranConfig() {
return {
appName: this.APP_NAME,
secret: this.SECRET,
username: this.USERNAME,
password: this.PASSWORD,
corpId: this.CORP_ID,
contractId: this.CONTRACT_ID,
location: this.LOCATION,
};
}
private resolveAccidentCauseId(accidentReason?: {
id?: string | number | null;
fanavaran?: number | null;
}): number | null {
const fanavaranValue = accidentReason?.fanavaran;
const idValue = accidentReason?.id;
if (fanavaranValue !== undefined && fanavaranValue !== null) {
if (fanavaranValue === 0) {
return idValue !== undefined && idValue !== null ? Number(idValue) : null;
}
return fanavaranValue;
}
return null;
}
private applyFanavaranDefaultFields(result: Record<string, unknown>): void {
result.AccidentCulpritId = null;
result.CouponNo = null;
result.CourtArchiveNo = null;
result.CulpritLicenceCityId = null;
result.CulpritLicenceCountryId = null;
result.CulpritLicenceForeignCityName = null;
result.CulpritLicenceIssuDate = "1394/10/13";
result.CulpritLicenceNo = "1124242";
result.DamagedCount = 1;
result.DmgAssessorFirstCreationTime = null;
result.EntryDate = null;
result.IsLicenseMatchWithVehicleKind = 1;
result.HasOtherCulprit = 0;
result.IsAccidentOutOfBorder = 0;
result.IsFatalAccident = 0;
result.IsPlaqueChanged = 0;
result.PlaqueCityId = null;
result.PlaqueKindId = null;
result.PlaqueLeftNo = null;
result.PlaqueMiddleCodeId = null;
result.PlaqueNo = null;
result.PlaqueRightNo = null;
result.PlaqueSampleId = null;
result.PlaqueSerial = null;
result.PoliceOfficerId = 1;
result.PoliceReportDesc = null;
result.PoliceReportSeri = null;
result.PoliceReportSerial = null;
result.PolicyId = null;
result.PreviousPolicyEndDate = "";
result.TrackingCode = null;
result.IsLicenseReplacement = 0;
result.UnknownCulpritCauseId = null;
}
private async buildFanavaranSubmitPayload(input: {
accidentReason?: { id?: string | number | null; fanavaran?: number | null };
createdAt?: Date | string;
location?: { lat?: number; lon?: number };
damageParts?: any[];
resolvePolicyId: () => Promise<number | null>;
logPrefix: string;
defaults?: {
AccidentCityId: number;
AccidentReportTypeId: number;
AccidentVehicleUsedId: number;
ClaimExpertId: number;
CompensationReferenceId: number;
CulpritLicenceTypeId: number;
CulpritTypeId: number;
};
}): Promise<Record<string, unknown>> {
const lookupDefaults =
input.defaults ?? this.TEJARATNO_FANAVARAN_DEFAULTS;
const result: Record<string, unknown> = {
...lookupDefaults,
AccidentCauseId: this.resolveAccidentCauseId(input.accidentReason),
};
if (input.accidentReason && result.AccidentCauseId === null) {
this.logger.warn(
`${input.logPrefix} accidentReason exists but fanavaran is null or undefined`,
);
} else if (!input.accidentReason) {
this.logger.error(
`${input.logPrefix} expert decision / accidentReason is missing`,
);
}
if (input.createdAt) {
result.AccidentDate = this.convertToPersianDate(input.createdAt);
result.AnnouncementDate = result.AccidentDate;
result.DocReceivedDate = result.AccidentDate;
result.AccidentTime = this.getTime24Hour(input.createdAt);
}
if (input.location?.lat && input.location?.lon) {
result.AccidentLocationAddress = await this.getAddressFromCoordinates(
input.location.lat,
input.location.lon,
);
}
result.EstimateAmount = input.damageParts
? this.calculateEstimateAmount(input.damageParts)
: 0;
this.applyFanavaranDefaultFields(result);
try {
const policyId = await input.resolvePolicyId();
if (policyId !== undefined && policyId !== null) {
result.PolicyId = policyId;
}
} catch (error) {
this.logger.error(
`${input.logPrefix} Failed to get PolicyId from external API:`,
error,
);
}
return result;
}
/** /**
* Fanavaran Submit - Map data from claim-request-management and request-management to third-party-car-financial-claims format * Fanavaran Submit - Map data from claim-request-management and request-management to third-party-car-financial-claims format
*/ */
@@ -3438,11 +3616,16 @@ export class ClaimRequestManagementService {
/** /**
* Step 1: Get appToken from GetAppToken API * Step 1: Get appToken from GetAppToken API
*/ */
private async getAppToken(): Promise<string> { private async getAppToken(
config: {
appName: string;
secret: string;
} = this.getTejaratnoFanavaranConfig(),
): Promise<string> {
try { try {
const requestHeaders: any = { const requestHeaders: any = {
appname: this.APP_NAME, appname: config.appName,
secret: this.SECRET, secret: config.secret,
"Content-Length": "0", "Content-Length": "0",
}; };
delete requestHeaders["Content-Type"]; delete requestHeaders["Content-Type"];
@@ -3494,12 +3677,18 @@ export class ClaimRequestManagementService {
/** /**
* Step 2: Login to get authenticationToken * Step 2: Login to get authenticationToken
*/ */
private async login(appToken: string): Promise<string> { private async login(
appToken: string,
config: {
username: string;
password: string;
} = this.getTejaratnoFanavaranConfig(),
): Promise<string> {
try { try {
const requestHeaders: any = { const requestHeaders: any = {
appToken: appToken, appToken: appToken,
userName: this.USERNAME, userName: config.username,
password: this.PASSWORD, password: config.password,
"Content-Length": "0", "Content-Length": "0",
}; };
delete requestHeaders["Content-Type"]; delete requestHeaders["Content-Type"];
@@ -3553,6 +3742,65 @@ export class ClaimRequestManagementService {
} }
} }
private async getPolicyIdFromNationalCode(
nationalCodeOfInsurer: string,
config: {
appName: string;
secret: string;
username: string;
password: string;
corpId: string;
contractId: string;
location: string;
},
logPrefix: string,
): Promise<number | null> {
try {
const appToken = await this.getAppToken(config);
const authenticationToken = await this.login(appToken, config);
const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`;
this.logger.log(
`${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`,
);
const response = await firstValueFrom(
this.httpService.get(policyInquiryUrl, {
headers: {
authenticationToken: authenticationToken,
CorpId: config.corpId,
ContractId: config.contractId,
Location: config.location,
"Content-Type": "application/json",
},
timeout: 15000,
}),
);
if (Array.isArray(response.data) && response.data.length > 0) {
const lastPolicy = response.data[response.data.length - 1];
const policyId = lastPolicy?.PolicyId;
if (policyId !== undefined && policyId !== null) {
this.logger.log(
`${logPrefix} Successfully retrieved PolicyId from last policy entry: ${policyId}`,
);
return policyId;
}
}
this.logger.warn(`${logPrefix} No PolicyId found in API response`);
return null;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "unknown policy inquiry error";
this.logger.warn(
`${logPrefix} Policy inquiry failed; continuing with empty PolicyId: ${errorMessage}`,
);
return null;
}
}
/** /**
* Get PolicyId from external API * Get PolicyId from external API
*/ */
@@ -3600,63 +3848,422 @@ export class ClaimRequestManagementService {
return null; return null;
} }
// Get authenticationToken return await this.getPolicyIdFromNationalCode(
const appToken = await this.getAppToken(); nationalCodeOfInsurer,
const authenticationToken = await this.login(appToken); this.getTejaratnoFanavaranConfig(),
`[Fanavaran Submit] claimRequestId=${claimRequestId}`,
// Call external API to get PolicyId
const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`;
this.logger.log(
`[Fanavaran Submit] Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`,
); );
const response = await firstValueFrom(
this.httpService.get(policyInquiryUrl, {
headers: {
authenticationToken: authenticationToken,
CorpId: this.CORP_ID,
ContractId: this.CONTRACT_ID,
Location: this.LOCATION,
"Content-Type": "application/json",
},
}),
);
if (
Array.isArray(response.data) &&
response.data.length > 0 &&
response.data[0].PolicyId
) {
const policyId = response.data[0].PolicyId;
this.logger.log(
`[Fanavaran Submit] Successfully retrieved PolicyId: ${policyId}`,
);
return policyId;
} else {
this.logger.warn(
`[Fanavaran Submit] No PolicyId found in API response`,
);
return null;
}
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`[Fanavaran Submit] Error getting PolicyId from external API:`, `[Fanavaran Submit] Error getting PolicyId from external API:`,
error, error,
); );
if (isAxiosError(error)) { return null;
const errorMessage =
error.response?.data?.Message ||
error.response?.data?.message ||
error.response?.data ||
error.message ||
"Failed to get PolicyId from external API";
this.logger.error(`[Fanavaran Submit] Error message: ${errorMessage}`);
} }
}
private getNationalCodeOfInsurerForGuiltyPartyV2(
parties: Array<{
role?: PartyRole;
person?: { userId?: Types.ObjectId; nationalCodeOfInsurer?: string };
statement?: { admitsGuilt?: boolean };
}>,
guiltyPartyId?: Types.ObjectId | string | null,
): string | null {
if (!guiltyPartyId) {
return null;
}
const guiltyParty = parties.find(
(party) => party.person?.userId?.toString() === guiltyPartyId.toString(),
);
return guiltyParty?.person?.nationalCodeOfInsurer ?? null;
}
private resolveGuiltyPartyIdV2(
parties: Array<{
role?: PartyRole;
person?: { userId?: Types.ObjectId };
statement?: { admitsGuilt?: boolean };
}>,
decisionGuiltyPartyId?: Types.ObjectId | string | null,
): Types.ObjectId | string | null {
if (decisionGuiltyPartyId) {
return decisionGuiltyPartyId;
}
const admitsGuiltParty = parties.find(
(party) => party.statement?.admitsGuilt && party.person?.userId,
);
if (admitsGuiltParty?.person?.userId) {
return admitsGuiltParty.person.userId;
}
// Legacy-compatible safety fallback: when no explicit guilty marker exists,
// use FIRST party if available (same heuristic used by older flows).
const firstParty = parties.find(
(party) => party.role === PartyRole.FIRST && party.person?.userId,
);
return firstParty?.person?.userId ?? null;
}
/**
* Fanavaran Submit (Parsian / V2) - Map data from claimCases + blameCases
*/
async fanavaranSubmitParsianV2(
claimCaseId: string,
options?: { debug?: boolean },
): Promise<any> {
try {
const debug = {
claimCaseId,
steps: {
claimCaseFound: false,
blameRequestLinked: false,
blameCaseFound: false,
expertDecisionFound: false,
accidentReasonFound: false,
firstPartyFound: false,
firstPartyLocationFound: false,
damageReplyFound: false,
guiltyPartyIdFound: false,
nationalCodeOfInsurerFound: false,
policyInquiryAttempted: false,
policyInquirySucceeded: false,
accidentReasonFallbackFromSnapshot: false,
guiltyPartyFallbackUsed: false,
},
values: {
blameRequestId: null as string | null,
guiltyPartyId: null as string | null,
nationalCodeOfInsurer: null as string | null,
policyId: null as number | null,
estimateAmount: null as number | null,
},
failureReason: null as string | null,
};
const claimCase = await this.claimCaseDbService.findById(claimCaseId);
if (!claimCase) {
throw new NotFoundException("Claim case not found");
}
debug.steps.claimCaseFound = true;
if (!claimCase.blameRequestId) {
debug.failureReason = "claimCase.blameRequestId is missing";
throw new BadRequestException("Blame case not linked to claim case");
}
debug.steps.blameRequestLinked = true;
debug.values.blameRequestId = claimCase.blameRequestId.toString();
const blameCase = await this.blameRequestDbService.findById(
claimCase.blameRequestId,
);
if (!blameCase) {
throw new NotFoundException("Blame case not found");
}
debug.steps.blameCaseFound = true;
const expertDecision = blameCase.expert?.decision;
debug.steps.expertDecisionFound = !!expertDecision;
const fallbackAccidentReason = claimCase.snapshot?.accident?.classification?.accidentReason;
if (!expertDecision?.fields?.accidentReason && fallbackAccidentReason) {
debug.steps.accidentReasonFallbackFromSnapshot = true;
}
const selectedAccidentReason =
expertDecision?.fields?.accidentReason ?? fallbackAccidentReason;
debug.steps.accidentReasonFound = !!selectedAccidentReason;
const firstParty = blameCase.parties?.find(
(party) => party.role === PartyRole.FIRST,
);
debug.steps.firstPartyFound = !!firstParty;
debug.steps.firstPartyLocationFound = !!firstParty?.location;
const activeExpertReply = getActiveV2ExpertReply(
claimCase as unknown as { evaluation?: Record<string, unknown> },
);
const damageParts = activeExpertReply?.parts;
debug.steps.damageReplyFound = !!damageParts?.length;
if (options?.debug) {
debug.values.estimateAmount = damageParts
? this.calculateEstimateAmount(damageParts)
: 0;
}
const payload = await this.buildFanavaranSubmitPayload({
accidentReason: selectedAccidentReason,
createdAt: (blameCase as { createdAt?: Date }).createdAt,
location: firstParty?.location,
damageParts,
defaults: this.PARSIAN_FANAVARAN_DEFAULTS,
logPrefix: `[Fanavaran Parsian V2] claimCaseId=${claimCaseId}`,
resolvePolicyId: async () => {
const guiltyPartyId = this.resolveGuiltyPartyIdV2(
blameCase.parties ?? [],
expertDecision?.guiltyPartyId,
);
if (!expertDecision?.guiltyPartyId && guiltyPartyId) {
debug.steps.guiltyPartyFallbackUsed = true;
}
debug.steps.guiltyPartyIdFound = !!guiltyPartyId;
debug.values.guiltyPartyId = guiltyPartyId
? guiltyPartyId.toString()
: null;
const nationalCodeOfInsurer = this.getNationalCodeOfInsurerForGuiltyPartyV2(
blameCase.parties ?? [],
guiltyPartyId,
);
debug.steps.nationalCodeOfInsurerFound = !!nationalCodeOfInsurer;
debug.values.nationalCodeOfInsurer = nationalCodeOfInsurer;
if (!guiltyPartyId) {
this.logger.warn(
`[Fanavaran Parsian V2] guiltyPartyId not found in expert.decision`,
);
debug.failureReason = "expert.decision.guiltyPartyId is missing";
return null;
}
if (!nationalCodeOfInsurer) {
this.logger.warn(
`[Fanavaran Parsian V2] nationalCodeOfInsurer not found for guiltyPartyId: ${guiltyPartyId}`,
);
debug.failureReason =
"nationalCodeOfInsurer not found for guilty party in blameCase.parties";
return null;
}
debug.steps.policyInquiryAttempted = true;
const policyId = await this.getPolicyIdFromNationalCode(
nationalCodeOfInsurer,
this.PARSIAN_FANAVARAN_CONFIG,
`[Fanavaran Parsian V2] claimCaseId=${claimCaseId}`,
);
debug.values.policyId = policyId;
debug.steps.policyInquirySucceeded = policyId !== null;
if (policyId === null) {
debug.failureReason =
debug.failureReason ??
"policy inquiry returned no PolicyId (timeout, network error, or empty response)";
}
return policyId;
},
});
if (options?.debug) {
return {
payload,
debug,
};
}
return payload;
} catch (error) {
this.logger.error("Error in fanavaranSubmitParsianV2", error);
throw error; throw error;
} }
} }
/**
* Submit Parsian fanavaran data (V2 collections) to external API
*/
async submitToFanavaranParsianV2(claimCaseId: string): Promise<any> {
try {
return await this.executeFanavaranParsianV2Submit(claimCaseId);
} catch (error) {
this.logger.error(
`[Fanavaran Parsian V2] Error submitting to Fanavaran`,
error,
);
throw new BadGatewayException(this.extractFanavaranErrorMessage(error));
}
}
/**
* Auto-submit to Fanavaran when a claim case reaches COMPLETED.
* Never throws — failures are returned as warnings so the main flow continues.
*/
async autoSubmitToFanavaranParsianV2OnClaimCompleted(
claimCaseId: string,
): Promise<FanavaranAutoSubmitResult> {
const logPrefix = `[Fanavaran Parsian V2 Auto] claimCaseId=${claimCaseId}`;
try {
const claimCase = await this.claimCaseDbService.findById(claimCaseId);
if (!claimCase) {
return {
attempted: false,
submitted: false,
warning:
"Claim case not found for Fanavaran auto-submit. Retry manually when available.",
};
}
if (claimCase.claimId != null || claimCase.claimNo != null) {
return {
attempted: false,
submitted: false,
skipped: true,
skipReason: "Already submitted to Fanavaran",
claimId: claimCase.claimId,
claimNo: claimCase.claimNo,
};
}
const fanavaranResponse = await this.executeFanavaranParsianV2Submit(
claimCaseId,
);
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
$push: {
history: {
type: "FANAVARAN_AUTO_SUBMIT_SUCCEEDED",
actor: { actorType: "system" },
timestamp: new Date(),
metadata: {
claimNo: fanavaranResponse?.ClaimNo,
claimId: fanavaranResponse?.Id,
},
},
},
});
return {
attempted: true,
submitted: true,
claimNo: fanavaranResponse?.ClaimNo,
claimId: fanavaranResponse?.Id,
fanavaranResponse,
};
} catch (error) {
const warning = this.extractFanavaranErrorMessage(error);
this.logger.warn(`${logPrefix} Auto-submit failed: ${warning}`);
try {
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
$push: {
history: {
type: "FANAVARAN_AUTO_SUBMIT_FAILED",
actor: { actorType: "system" },
timestamp: new Date(),
metadata: { error: warning },
},
},
});
} catch (historyError) {
this.logger.error(
`${logPrefix} Failed to record Fanavaran auto-submit failure history`,
historyError,
);
}
return {
attempted: true,
submitted: false,
warning: `${warning} Case was not sent to Fanavaran. Retry manually via POST /v2/claim-request-management/fanavaran-submit/parsian/${claimCaseId}.`,
};
}
}
private extractFanavaranErrorMessage(error: unknown): string {
if (isAxiosError(error)) {
return (
error.response?.data?.message ||
error.response?.data?.Message ||
(typeof error.response?.data === "string"
? error.response.data
: undefined) ||
error.message ||
"Failed to submit data to Fanavaran API"
);
}
if (error instanceof HttpException) {
const response = error.getResponse();
if (typeof response === "string") return response;
if (response && typeof response === "object" && "message" in response) {
const message = (response as { message?: string | string[] }).message;
return Array.isArray(message) ? message.join(", ") : String(message);
}
}
if (error instanceof Error) {
return error.message;
}
return "Failed to submit data to Fanavaran API";
}
private async executeFanavaranParsianV2Submit(
claimCaseId: string,
): Promise<any> {
const config = this.PARSIAN_FANAVARAN_CONFIG;
const logPrefix = `[Fanavaran Parsian V2]`;
this.logger.log(
`${logPrefix} Starting submission for claimCaseId: ${claimCaseId}`,
);
const fanavaranData = await this.fanavaranSubmitParsianV2(claimCaseId);
this.logger.log(
`${logPrefix} Mapped data prepared:`,
JSON.stringify(fanavaranData, null, 2),
);
if (
fanavaranData.AccidentCauseId === undefined ||
fanavaranData.AccidentCauseId === null
) {
this.logger.error(
`${logPrefix} CRITICAL: AccidentCauseId is missing or null!`,
);
} else {
this.logger.log(
`${logPrefix} AccidentCauseId validated: ${fanavaranData.AccidentCauseId}`,
);
}
const appToken = await this.getAppToken(config);
const authenticationToken = await this.login(appToken, config);
const response = await firstValueFrom(
this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, {
headers: {
authenticationToken: authenticationToken,
CorpId: config.corpId,
ContractId: config.contractId,
Location: config.location,
"Content-Type": "application/json",
},
}),
);
this.logger.log(`${logPrefix} API Response Status: ${response.status}`);
this.logger.log(
`${logPrefix} API Response Data:`,
JSON.stringify(response.data, null, 2),
);
if (response.data) {
const claimNo = response.data.ClaimNo;
const claimId = response.data.Id;
if (claimNo !== undefined || claimId !== undefined) {
const updateData: any = { $set: {} as Record<string, unknown> };
if (claimNo !== undefined) {
updateData.$set.claimNo = claimNo;
}
if (claimId !== undefined) {
updateData.$set.claimId = claimId;
}
await this.claimCaseDbService.findByIdAndUpdate(
claimCaseId,
updateData,
);
}
}
return response.data;
}
/** /**
* Submit fanavaran data to external API * Submit fanavaran data to external API
*/ */
@@ -6241,6 +6848,7 @@ export class ClaimRequestManagementService {
currentStep?: ClaimWorkflowStep; currentStep?: ClaimWorkflowStep;
accepted: boolean; accepted: boolean;
phase?: "PRICED_PARTS_FOR_FACTORS" | "FINAL_APPROVAL"; phase?: "PRICED_PARTS_FOR_FACTORS" | "FINAL_APPROVAL";
fanavaran?: FanavaranAutoSubmitResult;
}> { }> {
if (!Types.ObjectId.isValid(claimRequestId)) { if (!Types.ObjectId.isValid(claimRequestId)) {
throw new BadRequestException("Invalid claim request id"); throw new BadRequestException("Invalid claim request id");
@@ -6493,14 +7101,26 @@ export class ClaimRequestManagementService {
}, },
}); });
const fanavaran =
await this.autoSubmitToFanavaranParsianV2OnClaimCompleted(claimRequestId);
let message = "Your signature has been recorded. The claim is completed.";
if (fanavaran.submitted) {
message +=
" The claim was sent to Fanavaran successfully.";
} else if (fanavaran.warning) {
message += ` ${fanavaran.warning}`;
}
return { return {
message: "Your signature has been recorded. The claim is completed.", message,
claimRequestId, claimRequestId,
status: ClaimCaseStatus.COMPLETED, status: ClaimCaseStatus.COMPLETED,
claimStatus: ClaimStatus.APPROVED, claimStatus: ClaimStatus.APPROVED,
currentStep: ClaimWorkflowStep.CLAIM_COMPLETED, currentStep: ClaimWorkflowStep.CLAIM_COMPLETED,
accepted: true, accepted: true,
phase: "FINAL_APPROVAL", phase: "FINAL_APPROVAL",
fanavaran,
}; };
} }

View File

@@ -1029,4 +1029,44 @@ Returns status of each item (uploaded/captured or not).
); );
} }
} }
@Get("fanavaran-submit/parsian/:claimCaseId")
@ApiOperation({
summary: "Preview Fanavaran submit body (Parsian / V2)",
description:
"Builds the third-party-car-financial-claims payload from claimCases + blameCases without calling Fanavaran.",
})
@ApiParam({
name: "claimCaseId",
description: "The claim case ID (MongoDB ObjectId)",
})
async fanavaranSubmitParsianV2(
@Param("claimCaseId") claimCaseId: string,
@Query("debug") debug?: string,
) {
return await this.claimRequestManagementService.fanavaranSubmitParsianV2(
claimCaseId,
{
debug: debug === "1" || debug === "true",
},
);
}
@Post("fanavaran-submit/parsian/:claimCaseId")
@ApiOperation({
summary: "Submit claim to Fanavaran (Parsian / V2)",
description:
"Authenticates with Parsian Fanavaran credentials and submits the mapped claimCases + blameCases payload.",
})
@ApiParam({
name: "claimCaseId",
description: "The claim case ID (MongoDB ObjectId)",
})
async submitToFanavaranParsianV2(
@Param("claimCaseId") claimCaseId: string,
) {
return await this.claimRequestManagementService.submitToFanavaranParsianV2(
claimCaseId,
);
}
} }

View File

@@ -10,3 +10,27 @@ export const REPAIR_LINE_AMOUNT_TOMAN = {
/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */ /** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX; export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
/**
* Returns null when the claim v2 total cap is disabled.
*
* Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again.
* Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount.
*/
export function getClaimV2TotalPaymentCapToman(): number | null {
const capEnabled = ENABLED_VALUES.has(
String(process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED ?? "")
.trim()
.toLowerCase(),
);
if (!capEnabled) return null;
const configuredCap = Number(process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN);
return Number.isFinite(configuredCap) && configuredCap > 0
? configuredCap
: CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN;
}

View File

@@ -84,4 +84,26 @@ export class EnvironmentVariables {
@IsOptional() @IsOptional()
HASH_PEPPER?: string; HASH_PEPPER?: string;
// --------------------------------------------------------- // // --------------------------------------------------------- //
@IsOptional()
@IsString({ message: "CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED must be string" })
CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED?: string;
// --------------------------------------------------------- //
@IsOptional()
@IsNumber(
{
allowNaN: false,
allowInfinity: false,
},
{
message: "CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN must be a valid number",
},
)
@IsPositive({
message: "CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN must be a positive number",
})
CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN?: number;
// --------------------------------------------------------- //
} }

View File

@@ -16,6 +16,10 @@ import { distance as stringDistance } from "fastest-levenshtein"; // حتما ن
import { Types } from "mongoose"; import { Types } from "mongoose";
import { lastValueFrom } from "rxjs"; import { lastValueFrom } from "rxjs";
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service"; import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
import {
ClaimRequestManagementService,
FanavaranAutoSubmitResult,
} from "src/claim-request-management/claim-request-management.service";
import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service"; import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service";
import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service"; import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service";
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service"; import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
@@ -135,14 +139,12 @@ import {
} from "src/users/entities/schema/expert-file-activity.schema"; } from "src/users/entities/schema/expert-file-activity.schema";
/** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */ /** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */
import { CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN } from "src/constants/repair-amount-limits"; import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { applyListQueryV2 } from "src/helpers/list-query-v2"; import { applyListQueryV2 } from "src/helpers/list-query-v2";
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher"; import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys"; import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
const CLAIM_V2_TOTAL_PAYMENT_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN;
@Injectable() @Injectable()
export class ExpertClaimService { export class ExpertClaimService {
private readonly logger = new Logger(ExpertClaimService.name); private readonly logger = new Logger(ExpertClaimService.name);
@@ -268,8 +270,22 @@ export class ExpertClaimService {
private readonly userDbService: UserDbService, private readonly userDbService: UserDbService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService, private readonly expertFileActivityDbService: ExpertFileActivityDbService,
private readonly claimSignDbService: ClaimSignDbService, private readonly claimSignDbService: ClaimSignDbService,
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {} ) {}
private appendFanavaranAutoSubmitToMessage(
baseMessage: string,
fanavaran: FanavaranAutoSubmitResult,
): string {
if (fanavaran.submitted) {
return `${baseMessage} The claim was sent to Fanavaran successfully.`;
}
if (fanavaran.warning) {
return `${baseMessage} ${fanavaran.warning}`;
}
return baseMessage;
}
/** /**
* Resolve a `claim-sign` document id to a downloadable file URL. * Resolve a `claim-sign` document id to a downloadable file URL.
* Mirrors the helper used in `ExpertInsurerService` so signature links are * Mirrors the helper used in `ExpertInsurerService` so signature links are
@@ -1539,9 +1555,9 @@ export class ExpertClaimService {
); );
} }
// Validate total price cap (priced lines sum) // Validate total price cap (priced lines sum), when enabled.
if (reply.parts && reply.parts.length > 0) { const priceCap = getClaimV2TotalPaymentCapToman();
const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP; if (priceCap !== null && reply.parts && reply.parts.length > 0) {
let totalPrice = 0; let totalPrice = 0;
for (const part of reply.parts) { for (const part of reply.parts) {
@@ -1570,13 +1586,13 @@ export class ExpertClaimService {
} }
} }
if (totalPrice > PRICE_CAP) { if (totalPrice > priceCap) {
throw new BadRequestException({ throw new BadRequestException({
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`, message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR", error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR", code: "PRICE_CAP_ERROR",
totalPrice: totalPrice, totalPrice: totalPrice,
priceCap: PRICE_CAP, priceCap,
}); });
} }
} }
@@ -2138,7 +2154,7 @@ export class ExpertClaimService {
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION. * Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
* — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature). * — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature).
* — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later). * — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later).
* Total of all repair lines must be ≤ `CLAIM_V2_TOTAL_PAYMENT_CAP` Toman (53_000_000; same as initial expert reply). * When enabled by env, total of all repair lines must be ≤ the claim v2 total payment cap.
* Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`. * Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`.
*/ */
async validateClaimFactorsV2( async validateClaimFactorsV2(
@@ -2273,7 +2289,8 @@ export class ExpertClaimService {
}; };
} }
const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP; const priceCap = getClaimV2TotalPaymentCapToman();
if (priceCap !== null) {
let totalPrice = 0; let totalPrice = 0;
for (const part of updatedReply.parts || []) { for (const part of updatedReply.parts || []) {
const line = this.claimReplyPartLineTotalForCap(part); const line = this.claimReplyPartLineTotalForCap(part);
@@ -2285,14 +2302,15 @@ export class ExpertClaimService {
} }
totalPrice += line; totalPrice += line;
} }
if (totalPrice > PRICE_CAP) { if (totalPrice > priceCap) {
throw new BadRequestException({ throw new BadRequestException({
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`, message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR", error: "PRICE_CAP_ERROR",
totalPrice, totalPrice,
priceCap: PRICE_CAP, priceCap,
}); });
} }
}
const historyActor = { const historyActor = {
actorId: new Types.ObjectId(actor.sub), actorId: new Types.ObjectId(actor.sub),
@@ -2309,13 +2327,20 @@ export class ExpertClaimService {
historyActor, historyActor,
{ replyField }, { replyField },
); );
const fanavaran =
await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted(
claimRequestId,
);
return { return {
message: message: this.appendFanavaranAutoSubmitToMessage(
"Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).", "Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).",
fanavaran,
),
claimRequestId, claimRequestId,
claimStatus: ClaimStatus.APPROVED, claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED, caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "REJECTED_REPRICED_AUTO_COMPLETED", outcome: "REJECTED_REPRICED_AUTO_COMPLETED",
fanavaran,
}; };
} }
@@ -2328,13 +2353,21 @@ export class ExpertClaimService {
{ replyField }, { replyField },
); );
const fanavaran =
await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted(
claimRequestId,
);
return { return {
message: message: this.appendFanavaranAutoSubmitToMessage(
"All factors were approved by the expert. The claim is completed without an additional owner signature.", "All factors were approved by the expert. The claim is completed without an additional owner signature.",
fanavaran,
),
claimRequestId, claimRequestId,
claimStatus: ClaimStatus.APPROVED, claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED, caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "ALL_APPROVED_AUTO_COMPLETED", outcome: "ALL_APPROVED_AUTO_COMPLETED",
fanavaran,
}; };
} }
@@ -2878,21 +2911,23 @@ export class ExpertClaimService {
throw new ForbiddenException("This claim is locked by another expert"); throw new ForbiddenException("This claim is locked by another expert");
} }
// Price cap validation // Price cap validation, when enabled.
const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP; const priceCap = getClaimV2TotalPaymentCapToman();
if (priceCap !== null) {
let totalPrice = 0; let totalPrice = 0;
for (const part of reply.parts || []) { for (const part of reply.parts || []) {
const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0")); const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0"));
if (!isNaN(parsed)) totalPrice += parsed; if (!isNaN(parsed)) totalPrice += parsed;
} }
if (totalPrice > PRICE_CAP) { if (totalPrice > priceCap) {
throw new BadRequestException({ throw new BadRequestException({
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`, message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR", error: "PRICE_CAP_ERROR",
totalPrice, totalPrice,
priceCap: PRICE_CAP, priceCap,
}); });
} }
}
const carTypeSubmit = claim.vehicle?.carType as const carTypeSubmit = claim.vehicle?.carType as
| ClaimVehicleTypeV2 | ClaimVehicleTypeV2