Merge pull request 'fanavaran stages matching data is double checked and more controlled +' (#318) from s.hajizadeh/yara724api:main into main

Reviewed-on: Yara724/api#318
This commit is contained in:
2026-09-15 15:39:38 +03:30
6 changed files with 1016 additions and 117 deletions

View File

@@ -36,6 +36,16 @@ import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.se
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service";
import { fanavaranClaimReferences } from "./fanavaran-claim-references";
import {
fanavaranClaimProductFromBlameType,
fanavaranClaimsBaseUrl,
fanavaranInsuranceLineIdForProduct,
fanavaranPartyInquirySources,
isFanavaranSubmitSupportedBlameType,
pickCarBodyPolicyNationalCode,
pickStoredCarBodyPolicyId,
type FanavaranClaimProduct,
} from "./fanavaran-claim-product";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { CreateClaimFromBlameResponseDto } from "./dto/create-claim-v2.dto";
import {
@@ -181,6 +191,7 @@ import {
fanavaranSubmitPath,
getFanavaranClientProfile,
resolveFanavaranClientKey,
resolveFanavaranProductContractId,
} from "src/core/config/fanavaran-client.config";
import { FanavaranAuditService } from "src/fanavaran/fanavaran-audit.service";
import { FanavaranAuthService } from "src/fanavaran/fanavaran-auth.service";
@@ -216,14 +227,21 @@ import {
} from "./fanavaran-other-people";
import {
asObjectRecord,
asVehicleInquiryRows,
canReuseCachedFanavaranVehicleIds,
collectPartyVinCandidates,
completePlaqueParts,
fanavaranIdOrConfigDefault,
fanavaranPayloadMatchesDamagedIdentity,
fanavaranVehicleMatchesDamagedIdentity,
normalizeVin,
parseFanavaranId,
pickFanavaranPlaqueLookupFields,
pickFanavaranVehicleFromInquiry,
pickVehicleId,
pickVinFromPartyVehicle,
selectFanavaranVehicleForDamageCase,
type FanavaranDamagedVehicleIdentity,
} from "src/lookups/fanavaran-last-car-policy";
export interface FanavaranAutoSubmitResult {
@@ -358,9 +376,8 @@ const FANAVARAN_PLATE_LETTER_CODE: Record<string, number> = {
export class ClaimRequestManagementService {
private readonly logger = new Logger(ClaimRequestManagementService.name);
// Business submit URL (auth lives in FanavaranAuthService)
private readonly FANAVARAN_SUBMIT_URL =
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/third-party-car-financial-claims";
// Legacy Tejaratno third-party URL. V2 stages use fanavaranClaimsBaseUrl(product).
private readonly FANAVARAN_SUBMIT_URL = fanavaranClaimsBaseUrl("third-party");
// Authentication credentials
private readonly APP_NAME = "fanhab";
@@ -3932,29 +3949,47 @@ export class ClaimRequestManagementService {
}
/**
* Damaged cars often store chassis on vehicle.vin. Try every VIN-like id
* until Fanavaran returns PlaqueKindId (required on GEN.12).
* Damaged-car VIN inquiry: keep only rows that match VIN/plate/chassis.
* Then GET that VehicleId so PlaqueKindId is the current version, not a
* leftover row that merely had some PlaqueKindId.
*/
private async fetchFanavaranVehicleByVinCandidates(
clientKey: FanavaranClientKey,
vins: string[],
identity: FanavaranDamagedVehicleIdentity,
product: FanavaranClaimProduct = "third-party",
): Promise<Record<string, unknown> | null> {
let fallback: Record<string, unknown> | null = null;
const requestOptions = {
contractIdOverride: resolveFanavaranProductContractId(clientKey, product),
};
let matched: Record<string, unknown> | null = null;
for (const vin of vins) {
try {
const vehicle = await this.fetchFanavaranVehicleByVin(clientKey, vin);
if (!vehicle) {
const inquired = await this.fanavaranLookupService.inquiryByVin(
clientKey,
vin,
requestOptions,
);
const rows = asVehicleInquiryRows(inquired);
if (rows.length === 0) {
this.logger.warn(
`[buildFanavaranDamageCasePayload] VIN inquiry for ${vin} returned no vehicle`,
);
continue;
}
const plaqueKindId = pickFanavaranPlaqueLookupFields(vehicle).kindId;
const selected = selectFanavaranVehicleForDamageCase(rows, identity);
if (!selected) {
this.logger.warn(
`[buildFanavaranDamageCasePayload] VIN inquiry ${vin} returned ${rows.length} row(s) but none matched damaged VIN/plate/chassis`,
);
continue;
}
const plaqueKindId = pickFanavaranPlaqueLookupFields(selected).kindId;
this.logger.log(
`[buildFanavaranDamageCasePayload] VIN inquiry ${vin} VehicleId=${pickVehicleId(vehicle) ?? "none"} VehicleKindId=${parseFanavaranId(vehicle.VehicleKindId) ?? "none"} UsedId=${parseFanavaranId(vehicle.UsedId) ?? "none"} PlaqueKindId=${plaqueKindId ?? "none"}`,
`[buildFanavaranDamageCasePayload] VIN inquiry ${vin} matched VehicleId=${pickVehicleId(selected) ?? "none"} VehicleKindId=${parseFanavaranId(selected.VehicleKindId) ?? "none"} UsedId=${parseFanavaranId(selected.UsedId) ?? "none"} PlaqueKindId=${plaqueKindId ?? "none"} VersionNo=${parseFanavaranId(selected.VersionNo) ?? "none"}`,
);
if (plaqueKindId != null) return vehicle;
if (!fallback) fallback = vehicle;
matched = selected;
break;
} catch (error) {
this.logger.warn(
`[buildFanavaranDamageCasePayload] VIN inquiry failed for ${vin}: ${
@@ -3963,7 +3998,43 @@ export class ClaimRequestManagementService {
);
}
}
return fallback;
if (!matched) return null;
const vehicleId = pickVehicleId(matched);
const versionNo = parseFanavaranId(matched.VersionNo);
if (vehicleId == null) return matched;
try {
const fetched = asObjectRecord(
await this.fanavaranLookupService.vehicleById(
clientKey,
vehicleId,
versionNo ?? undefined,
requestOptions,
),
);
if (
fetched &&
fanavaranVehicleMatchesDamagedIdentity(fetched, identity)
) {
this.logger.log(
`[buildFanavaranDamageCasePayload] vehicle GET ${vehicleId} confirmed identity PlaqueKindId=${pickFanavaranPlaqueLookupFields(fetched).kindId ?? "none"} VehicleKindId=${parseFanavaranId(fetched.VehicleKindId) ?? "none"}`,
);
return fetched;
}
if (fetched) {
this.logger.warn(
`[buildFanavaranDamageCasePayload] vehicle GET ${vehicleId} did not match damaged identity; keeping VIN-inquiry row`,
);
}
} catch (error) {
this.logger.warn(
`[buildFanavaranDamageCasePayload] vehicle GET failed for VehicleId=${vehicleId}: ${
error instanceof Error ? error.message : error
}`,
);
}
return matched;
}
/**
@@ -3972,13 +4043,27 @@ export class ClaimRequestManagementService {
private async resolveAccidentVehicleUsedFromPolicy(
clientKey: FanavaranClientKey,
policyId: number,
product: FanavaranClaimProduct = "third-party",
): Promise<{ usedId: number | null; vehicleKindId: number | null }> {
try {
const policy = asObjectRecord(
await this.fanavaranLookupService.thirdPartyPolicyById(
const requestOptions = {
contractIdOverride: resolveFanavaranProductContractId(
clientKey,
policyId,
product,
),
};
const policy = asObjectRecord(
product === "car-body"
? await this.fanavaranLookupService.bodyPolicyById(
clientKey,
policyId,
requestOptions,
)
: await this.fanavaranLookupService.thirdPartyPolicyById(
clientKey,
policyId,
requestOptions,
),
);
const vehicleId = pickVehicleId(policy?.VehicleId ?? policy?.vehicleId);
if (vehicleId === null) {
@@ -3992,6 +4077,7 @@ export class ClaimRequestManagementService {
clientKey,
vehicleId,
versionNo ?? undefined,
requestOptions,
),
);
this.logger.log(
@@ -4013,6 +4099,7 @@ export class ClaimRequestManagementService {
parties: unknown[];
guiltyPartyId?: unknown;
policyId?: number | null;
product?: FanavaranClaimProduct;
}): Promise<{
usedId: number | null;
vehicleKindId: number | null;
@@ -4038,6 +4125,7 @@ export class ClaimRequestManagementService {
const fromPolicy = await this.resolveAccidentVehicleUsedFromPolicy(
input.clientKey,
input.policyId,
input.product ?? "third-party",
);
if (fromPolicy.usedId != null) {
return { ...fromPolicy, source: "policy-vehicle" };
@@ -4144,14 +4232,9 @@ export class ClaimRequestManagementService {
const person = damagedParty?.person ?? {};
const vehicle = damagedParty?.vehicle ?? {};
const insurance = damagedParty?.insurance ?? {};
// Prefer nested inquiry (ESG/Tejarat) on the damaged party — not flattened insurance.*
const inquiryRawPayload =
vehicle?.inquiry?.raw?.data != null &&
typeof vehicle.inquiry.raw.data === "object" &&
!Array.isArray(vehicle.inquiry.raw.data)
? vehicle.inquiry.raw.data
: (vehicle?.inquiry?.raw ?? {});
const inquiryMapped = vehicle?.inquiry?.mapped ?? inquiryRawPayload ?? {};
const product = fanavaranClaimProductFromBlameType(input.blameCase?.type);
const { mapped: inquiryMapped, raw: inquiryRawPayload } =
fanavaranPartyInquirySources(product, damagedParty);
const inquiryRaw = inquiryRawPayload ?? {};
const plate =
@@ -4213,20 +4296,38 @@ export class ClaimRequestManagementService {
]);
const builtYearFromInquiry = builtYearRaw ? Number(builtYearRaw) || null : null;
const damagedVinCandidates: string[] = [];
const seenVins = new Set<string>();
for (const value of [
...collectPartyVinCandidates(damagedParty),
const damagedVin =
this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [
"VIN",
"vin",
"VinNumberField",
]),
this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [
"ShsNum",
"ChassisNumberField",
"shsNam",
]),
]) ?? collectPartyVinCandidates(damagedParty)[0] ??
null;
const damagedChassis = this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [
"ShsNum",
"ChassisNumberField",
"shsNam",
]);
const damagedIdentity: FanavaranDamagedVehicleIdentity = {
vin: damagedVin,
chassis: damagedChassis,
plaque: plate
? completePlaqueParts({
plaqueLeft: String(plate.leftDigits),
plaqueLetter: plate.centerAlphabet,
plaqueRight: String(plate.centerDigits),
plaqueSerial: String(plate.ir),
})
: null,
policyCINumber,
};
const damagedVinCandidates: string[] = [];
const seenVins = new Set<string>();
for (const value of [
...collectPartyVinCandidates(damagedParty),
damagedVin,
damagedChassis,
]) {
const vin = normalizeVin(value);
if (!vin || seenVins.has(vin)) continue;
@@ -4236,29 +4337,46 @@ export class ClaimRequestManagementService {
const cachedDamage =
(input.claimCase as any)?.fanavaranSync?.damageCase ?? {};
const lastDamagePayload = asObjectRecord(cachedDamage.lastPayload);
const cacheMatchesIdentity = fanavaranPayloadMatchesDamagedIdentity(
lastDamagePayload,
damagedIdentity,
);
const canReuseCachedPlaque =
cacheMatchesIdentity &&
canReuseCachedFanavaranVehicleIds(cachedDamage.plaqueKindSource);
const canReuseCachedVehicleKind =
cacheMatchesIdentity &&
canReuseCachedFanavaranVehicleIds(cachedDamage.vehicleKindSource);
this.logger.log(
`[buildFanavaranDamageCasePayload] VIN candidates=${damagedVinCandidates.join(",") || "none"}`,
`[buildFanavaranDamageCasePayload] identity VIN=${damagedIdentity.vin ?? "none"} chassis=${damagedIdentity.chassis ?? "none"} plate=${damagedIdentity.plaque ? `${damagedIdentity.plaque.right}${damagedIdentity.plaque.letter}${damagedIdentity.plaque.left}/${damagedIdentity.plaque.serial}` : "none"} CI=${damagedIdentity.policyCINumber ?? "none"} VIN candidates=${damagedVinCandidates.join(",") || "none"}`,
);
const apiVehicle = await this.fetchFanavaranVehicleByVinCandidates(
input.clientKey,
damagedVinCandidates,
damagedIdentity,
product,
);
const apiPlaque = pickFanavaranPlaqueLookupFields(apiVehicle);
const inquiryPlaque = pickFanavaranPlaqueLookupFields(
asObjectRecord(inquiryMapped) ?? asObjectRecord(inquiryRaw),
);
const inquiryVehicle =
asObjectRecord(inquiryMapped) ?? asObjectRecord(inquiryRaw);
const inquiryPlaque = fanavaranVehicleMatchesDamagedIdentity(
inquiryVehicle,
damagedIdentity,
)
? pickFanavaranPlaqueLookupFields(inquiryVehicle)
: pickFanavaranPlaqueLookupFields(null);
const plaqueKindFromApi = this.firstPositiveFanavaranId(
apiPlaque.kindId,
inquiryPlaque.kindId,
cachedDamage.plaqueKindId,
lastDamagePayload?.PlaqueKindId,
canReuseCachedPlaque ? cachedDamage.plaqueKindId : null,
canReuseCachedPlaque ? lastDamagePayload?.PlaqueKindId : null,
);
const plaqueSampleFromApi = this.firstPositiveFanavaranId(
apiPlaque.sampleId,
inquiryPlaque.sampleId,
cachedDamage.plaqueSampleId,
lastDamagePayload?.PlaqueSampleId,
canReuseCachedPlaque ? cachedDamage.plaqueSampleId : null,
canReuseCachedPlaque ? lastDamagePayload?.PlaqueSampleId : null,
);
const plaqueKindId = fanavaranIdOrConfigDefault(
plaqueKindFromApi,
@@ -4268,6 +4386,10 @@ export class ClaimRequestManagementService {
plaqueSampleFromApi,
input.defaults.PlaqueSampleId,
);
const plaqueKindSource =
plaqueKindFromApi != null ? "vehicle-inquiry" : "config-default";
const plaqueSampleSource =
plaqueSampleFromApi != null ? "vehicle-inquiry" : "config-default";
if (plaqueKindFromApi == null) {
this.logger.log(
`[buildFanavaranDamageCasePayload] PlaqueKindId API miss; using config default ${plaqueKindId}`,
@@ -4284,14 +4406,21 @@ export class ClaimRequestManagementService {
const carType = input.claimCase?.vehicle?.carType as string | undefined;
let vehicleKindId = this.firstPositiveFanavaranId(
apiVehicle?.VehicleKindId,
cachedDamage.vehicleKindId,
lastDamagePayload?.VehicleKindId,
canReuseCachedVehicleKind ? cachedDamage.vehicleKindId : null,
canReuseCachedVehicleKind ? lastDamagePayload?.VehicleKindId : null,
);
let vehicleKindSource: "vehicle-inquiry" | "car-type-lookup" | null =
parseFanavaranId(apiVehicle?.VehicleKindId) != null
? "vehicle-inquiry"
: canReuseCachedVehicleKind
? "vehicle-inquiry"
: null;
if (vehicleKindId == null) {
vehicleKindId = await this.resolveVehicleKindId(
input.clientKey,
carType,
);
if (vehicleKindId != null) vehicleKindSource = "car-type-lookup";
}
// Prefer claim-level cache, then party.person.fanavaranDriverId, then live inquiry, then last payload
@@ -4359,49 +4488,37 @@ export class ClaimRequestManagementService {
}
const cachedUsedId =
cachedDamage.accidentVehicleUsedId != null
cacheMatchesIdentity && cachedDamage.accidentVehicleUsedId != null
? Number(cachedDamage.accidentVehicleUsedId)
: (input.claimCase as any)?.fanavaranSync?.baseClaim
?.accidentVehicleUsedId != null
? Number(
(input.claimCase as any).fanavaranSync.baseClaim
.accidentVehicleUsedId,
)
: null;
const cachedUsedSource =
cachedDamage.accidentVehicleUsedSource ??
(input.claimCase as any)?.fanavaranSync?.baseClaim
?.accidentVehicleUsedSource;
: null;
const cachedUsedSource = cachedDamage.accidentVehicleUsedSource;
const canReuseApiUsedId =
cachedUsedId != null &&
cachedUsedId > 0 &&
(cachedUsedSource === "vin-inquiry" ||
cachedUsedSource === "policy-vehicle");
cachedUsedSource === "vehicle-inquiry";
let accidentVehicleUsedId: number | null = canReuseApiUsedId
? cachedUsedId
: null;
let usedSource: "vin-inquiry" | "policy-vehicle" | null = canReuseApiUsedId
? cachedUsedSource
: null;
if (accidentVehicleUsedId == null) {
const resolved = await this.resolveAccidentVehicleUsedFromApis({
clientKey: input.clientKey,
parties: input.blameCase?.parties ?? [],
guiltyPartyId: this.resolveGuiltyPartyIdV2(
input.blameCase?.parties ?? [],
input.blameCase?.expert?.decision?.guiltyPartyId,
),
policyId: (input.claimCase as any)?.fanavaranSync?.baseClaim?.policyId,
});
accidentVehicleUsedId = resolved.usedId;
usedSource = resolved.source;
let usedSource: "vin-inquiry" | "policy-vehicle" | "vehicle-inquiry" | null =
canReuseApiUsedId ? "vehicle-inquiry" : null;
if (accidentVehicleUsedId == null && apiVehicle) {
const fromDamagedVehicle =
await this.resolveAccidentVehicleUsedFromVehicleRecord(
input.clientKey,
apiVehicle,
);
accidentVehicleUsedId = fromDamagedVehicle.usedId;
if (accidentVehicleUsedId != null) usedSource = "vehicle-inquiry";
}
if (accidentVehicleUsedId == null) {
accidentVehicleUsedId = await this.resolveAccidentVehicleUsedFromVehicleKind(
input.clientKey,
vehicleKindId,
null,
parseFanavaranId(apiVehicle?.UsedId),
);
if (accidentVehicleUsedId != null && usedSource == null) {
usedSource = apiVehicle ? "vehicle-inquiry" : null;
}
}
if (accidentVehicleUsedId == null) {
accidentVehicleUsedId = input.defaults.AccidentVehicleUsedId;
@@ -4482,10 +4599,6 @@ export class ClaimRequestManagementService {
const persistedPayload = this.keepPreviousPositiveIds(lastDamagePayload, payload, [
"DriverId",
"PlaqueKindId",
"PlaqueSampleId",
"VehicleKindId",
"AccidentVehicleUsedId",
"InsuranceCorpId",
]);
@@ -4502,14 +4615,16 @@ export class ClaimRequestManagementService {
? { "fanavaranSync.damageCase.otherPersonId": otherPersonId }
: {}),
...(vehicleKindId != null
? { "fanavaranSync.damageCase.vehicleKindId": vehicleKindId }
: {}),
...(plaqueKindId != null
? { "fanavaranSync.damageCase.plaqueKindId": plaqueKindId }
: {}),
...(plaqueSampleId != null
? { "fanavaranSync.damageCase.plaqueSampleId": plaqueSampleId }
? {
"fanavaranSync.damageCase.vehicleKindId": vehicleKindId,
"fanavaranSync.damageCase.vehicleKindSource":
vehicleKindSource ?? "car-type-lookup",
}
: {}),
"fanavaranSync.damageCase.plaqueKindId": plaqueKindId,
"fanavaranSync.damageCase.plaqueKindSource": plaqueKindSource,
"fanavaranSync.damageCase.plaqueSampleId": plaqueSampleId,
"fanavaranSync.damageCase.plaqueSampleSource": plaqueSampleSource,
...(accidentVehicleUsedId != null
? {
"fanavaranSync.damageCase.accidentVehicleUsedId":
@@ -4720,6 +4835,31 @@ export class ClaimRequestManagementService {
return resolveFanavaranClientKey();
}
private async resolveFanavaranClaimProduct(
claimCaseId?: string,
blameCase?: { type?: string } | null,
): Promise<FanavaranClaimProduct> {
if (blameCase?.type) {
return fanavaranClaimProductFromBlameType(blameCase.type);
}
if (!claimCaseId) return "third-party";
const claimCase = await this.claimCaseDbService.findById(claimCaseId);
if (!claimCase?.blameRequestId) return "third-party";
const blame = await this.blameRequestDbService.findById(
String(claimCase.blameRequestId),
);
return fanavaranClaimProductFromBlameType(blame?.type);
}
private async fanavaranClaimsUrlForClaim(
claimCaseId?: string,
blameCase?: { type?: string } | null,
): Promise<string> {
return fanavaranClaimsBaseUrl(
await this.resolveFanavaranClaimProduct(claimCaseId, blameCase),
);
}
private async getFanavaranAuthHeaders(
clientKey: FanavaranClientKey,
auditSession?: FanavaranAuditSession,
@@ -4729,9 +4869,11 @@ export class ClaimRequestManagementService {
const locationOverride = caseId
? await this.resolveFanavaranLocationForClaim(caseId, clientKey)
: undefined;
const product = await this.resolveFanavaranClaimProduct(caseId);
return this.fanavaranAuthService.getRequestHeaders(clientKey, {
auditSession,
locationOverride,
contractIdOverride: resolveFanavaranProductContractId(clientKey, product),
});
}
@@ -4793,21 +4935,31 @@ export class ClaimRequestManagementService {
},
logPrefix: string,
auditSession?: FanavaranAuditSession,
options?: { clientKey?: FanavaranClientKey; claimCaseId?: string },
options?: {
clientKey?: FanavaranClientKey;
claimCaseId?: string;
product?: FanavaranClaimProduct;
insuranceLineId?: number;
},
): Promise<number | null> {
const clientKey =
options?.clientKey ?? this.resolveClientKeyFromAuthConfig(config);
const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`;
const product =
options?.product ??
(await this.resolveFanavaranClaimProduct(options?.claimCaseId));
const insuranceLineId =
options?.insuranceLineId ?? fanavaranInsuranceLineIdForProduct(product);
const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=${insuranceLineId}&NationalCode=${nationalCodeOfInsurer}`;
const startedAt = Date.now();
const requestMeta = {
corpId: config.corpId,
contractId: config.contractId,
contractId: resolveFanavaranProductContractId(clientKey, product),
location: config.location,
nationalCode: this.fanavaranAuditService.maskNationalCode(
nationalCodeOfInsurer,
),
InsuranceLineId: 5,
InsuranceLineId: insuranceLineId,
};
if (auditSession) {
@@ -5268,6 +5420,13 @@ export class ClaimRequestManagementService {
: [];
if (parties.length === 0) return null;
if (
blameCase?.type === BlameRequestType.CAR_BODY ||
blameCase?.type === "CAR_BODY"
) {
return resolveDamagedPartyRow(blameCase) ?? parties[0] ?? null;
}
const ownerId = claimCase?.owner?.userId;
if (ownerId) {
const byOwner = parties.find(
@@ -5474,15 +5633,69 @@ export class ClaimRequestManagementService {
(claimCase as any)?.fanavaranSync?.baseClaim?.lastPayload?.PolicyId ??
null;
const accidentAt =
blameCase.type === BlameRequestType.CAR_BODY
? ((firstParty as { statement?: { accidentDate?: Date } })?.statement
?.accidentDate ??
(blameCase as { createdAt?: Date }).createdAt)
: (blameCase as { createdAt?: Date }).createdAt;
const payload = await this.buildFanavaranSubmitPayload({
accidentReason: selectedAccidentReason,
createdAt: (blameCase as { createdAt?: Date }).createdAt,
createdAt: accidentAt,
location: firstParty?.location,
damageParts,
defaults: profile.defaults,
logPrefix,
requirePolicyId,
resolvePolicyId: async () => {
const product = fanavaranClaimProductFromBlameType(blameCase.type);
if (cachedPolicyId != null && !forceRefreshPolicy) {
debug.steps.policyIdFromCache = true;
debug.values.policyId = Number(cachedPolicyId);
return Number(cachedPolicyId);
}
if (product === "car-body") {
const storedPolicyId = pickStoredCarBodyPolicyId(blameCase);
if (storedPolicyId != null) {
debug.values.policyId = storedPolicyId;
debug.steps.policyInquirySucceeded = true;
return storedPolicyId;
}
const nationalCodeOfInsurer =
pickCarBodyPolicyNationalCode(blameCase);
debug.steps.nationalCodeOfInsurerFound = !!nationalCodeOfInsurer;
debug.values.nationalCodeOfInsurer = nationalCodeOfInsurer;
if (!nationalCodeOfInsurer) {
this.logger.warn(
`${logPrefix} car-body policyholder national code is missing`,
);
debug.failureReason =
"car-body policyholder national code is missing";
return null;
}
debug.steps.policyInquiryAttempted = true;
const policyId = await this.getPolicyIdFromNationalCode(
nationalCodeOfInsurer,
profile.auth,
logPrefix,
auditSession,
{ clientKey, claimCaseId, product: "car-body" },
);
debug.values.policyId = policyId;
debug.steps.policyInquirySucceeded = policyId !== null;
if (policyId === null) {
debug.failureReason =
debug.failureReason ??
"car-body policy inquiry returned no PolicyId (timeout, network error, empty response, or backoff)";
}
return policyId;
}
const guiltyPartyId = this.resolveGuiltyPartyIdV2(
blameCase.parties ?? [],
expertDecision?.guiltyPartyId,
@@ -5519,20 +5732,13 @@ export class ClaimRequestManagementService {
return null;
}
if (cachedPolicyId != null && !forceRefreshPolicy) {
debug.steps.policyIdFromCache = true;
debug.values.policyId = Number(cachedPolicyId);
// Silent reuse — no Fanavaran HTTP and no audit noise on warm preview.
return Number(cachedPolicyId);
}
debug.steps.policyInquiryAttempted = true;
const policyId = await this.getPolicyIdFromNationalCode(
nationalCodeOfInsurer,
profile.auth,
logPrefix,
auditSession,
{ clientKey, claimCaseId },
{ clientKey, claimCaseId, product: "third-party" },
);
debug.values.policyId = policyId;
debug.steps.policyInquirySucceeded = policyId !== null;
@@ -5545,6 +5751,17 @@ export class ClaimRequestManagementService {
},
});
const carBodyAccidentTime = (
firstParty as { statement?: { accidentTime?: string } } | undefined
)?.statement?.accidentTime;
if (
blameCase.type === BlameRequestType.CAR_BODY &&
typeof carBodyAccidentTime === "string" &&
carBodyAccidentTime.trim()
) {
payload.AccidentTime = carBodyAccidentTime.trim();
}
const cachedUsedId =
forceRefreshUsedId || forceRefreshPolicy
? null
@@ -5575,6 +5792,7 @@ export class ClaimRequestManagementService {
guiltyPartyId: guiltyPartyIdForVin,
policyId:
typeof payload.PolicyId === "number" ? payload.PolicyId : null,
product: fanavaranClaimProductFromBlameType(blameCase.type),
});
if (resolved.usedId != null) {
payload.AccidentVehicleUsedId = resolved.usedId;
@@ -5687,9 +5905,9 @@ export class ClaimRequestManagementService {
if (!blameCase) {
throw new NotFoundException("Blame case not found");
}
if (blameCase.type !== BlameRequestType.THIRD_PARTY) {
if (!isFanavaranSubmitSupportedBlameType(blameCase.type)) {
throw new BadRequestException(
"Fanavaran damage-case submit only applies to THIRD_PARTY claims",
"Fanavaran damage-case submit only applies to THIRD_PARTY and CAR_BODY claims",
);
}
@@ -5717,7 +5935,7 @@ export class ClaimRequestManagementService {
claimId: claimCase.claimId ?? null,
dmgCaseId: claimCase.dmgCaseId ?? null,
submitUrl: claimCase.claimId
? `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/dmg-cases`
? `${fanavaranClaimsBaseUrl(fanavaranClaimProductFromBlameType(blameCase.type))}/${claimCase.claimId}/dmg-cases`
: null,
warning: warnings.length ? warnings.join(" ") : undefined,
payload,
@@ -5881,6 +6099,7 @@ export class ClaimRequestManagementService {
): Promise<any> {
const { claimCase, candidates } =
await this.collectFanavaranAttachmentCandidates(claimCaseId, clientKey);
const claimsUrl = await this.fanavaranClaimsUrlForClaim(claimCaseId);
return {
clientKey,
@@ -5889,7 +6108,7 @@ export class ClaimRequestManagementService {
claimNo: claimCase.claimNo ?? null,
dmgCaseId: claimCase.dmgCaseId ?? null,
submitUrl: claimCase.claimId
? `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/files`
? `${claimsUrl}/${claimCase.claimId}/files`
: null,
warning: claimCase.claimId
? undefined
@@ -5984,7 +6203,7 @@ export class ClaimRequestManagementService {
};
}
const url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/files`;
const url = `${await this.fanavaranClaimsUrlForClaim(claimCaseId)}/${claimCase.claimId}/files`;
const fileTypeId = profile.defaults.ClaimFileTypeId;
const logPrefix = `[Fanavaran ${clientKey} V2 Attachment Batch] claimCaseId=${claimCaseId}`;
@@ -6143,7 +6362,7 @@ export class ClaimRequestManagementService {
};
}
url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/files`;
url = `${await this.fanavaranClaimsUrlForClaim(claimCaseId)}/${claimCase.claimId}/files`;
content = {
FileName: fileName,
FileTypeId: profile.defaults.ClaimFileTypeId,
@@ -6636,7 +6855,7 @@ export class ClaimRequestManagementService {
expertiseId: claimCase.expertiseId ?? null,
replyKey,
submitUrl: claimCase.claimId
? `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/expertise`
? `${await this.fanavaranClaimsUrlForClaim(claimCaseId)}/${claimCase.claimId}/expertise`
: null,
ready: warnings.length === 0,
warnings: Array.from(new Set(warnings)),
@@ -6721,7 +6940,7 @@ export class ClaimRequestManagementService {
}
);
}
const url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/expertise`;
const url = `${await this.fanavaranClaimsUrlForClaim(claimCaseId)}/${claimCase.claimId}/expertise`;
const startedAt = Date.now();
const auditSession: FanavaranAuditSession = {
trackingCode: this.fanavaranAuditService.generateTrackingCode(),
@@ -6938,7 +7157,7 @@ export class ClaimRequestManagementService {
fanavaran: FanavaranAutoSubmitResult,
): string {
if (fanavaran.submitted) {
return `${baseMessage} The initial third-party case was sent to Fanavaran successfully.`;
return `${baseMessage} The initial Fanavaran case was sent successfully.`;
}
if (fanavaran.warning) {
return `${baseMessage} ${fanavaran.warning}`;
@@ -6964,8 +7183,8 @@ export class ClaimRequestManagementService {
return "Blame case not found";
}
if (blameCase.type !== BlameRequestType.THIRD_PARTY) {
return "Fanavaran third-party financial submit only applies to THIRD_PARTY claims";
if (!isFanavaranSubmitSupportedBlameType(blameCase.type)) {
return "Fanavaran submit only applies to THIRD_PARTY and CAR_BODY claims";
}
return null;
@@ -7197,9 +7416,9 @@ export class ClaimRequestManagementService {
if (!blameCase) {
throw new NotFoundException("Blame case not found");
}
if (blameCase.type !== BlameRequestType.THIRD_PARTY) {
if (!isFanavaranSubmitSupportedBlameType(blameCase.type)) {
throw new BadRequestException(
"Fanavaran damage-case submit only applies to THIRD_PARTY claims",
"Fanavaran damage-case submit only applies to THIRD_PARTY and CAR_BODY claims",
);
}
@@ -7215,7 +7434,7 @@ export class ClaimRequestManagementService {
// Guardrail: never send empty/null licence fields even if manual overrides arrive.
payload.LicenceNo = this.ensureFanavaranNonEmptyLicenceNo(payload.LicenceNo);
const url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/dmg-cases`;
const url = `${fanavaranClaimsBaseUrl(fanavaranClaimProductFromBlameType(blameCase.type))}/${claimCase.claimId}/dmg-cases`;
this.logger.log(
`[executeFanavaranDamageCaseSubmit] claimCaseId=${claimCaseId} claimId=${claimCase.claimId} payload.DriverId=${payload.DriverId ?? "NULL"} payload keys=${Object.keys(payload).join(", ")}`,
);
@@ -7439,7 +7658,7 @@ export class ClaimRequestManagementService {
}
/**
* Auto-submit the minimum third-party Fanavaran claim as soon as the local
* Auto-submit the minimum Fanavaran claim as soon as the local
* claim case exists. This preserves progress in Fanavaran even if parties stop
* later in the damage-assessment flow.
*/
@@ -8046,6 +8265,7 @@ export class ClaimRequestManagementService {
auditSession,
claimCaseId,
);
const submitUrl = await this.fanavaranClaimsUrlForClaim(claimCaseId);
const requestHeaders = {
...headers,
@@ -8055,7 +8275,7 @@ export class ClaimRequestManagementService {
session: auditSession,
step: FanavaranAuditStep.SUBMIT_CLAIM,
status: FanavaranAuditStatus.STARTED,
requestUrl: this.FANAVARAN_SUBMIT_URL,
requestUrl: submitUrl,
requestMethod: "POST",
requestHeaders,
requestBody: fanavaranData,
@@ -8065,7 +8285,7 @@ export class ClaimRequestManagementService {
try {
const response = await firstValueFrom(
this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, {
this.httpService.post(submitUrl, fanavaranData, {
headers: requestHeaders,
}),
);
@@ -8086,7 +8306,7 @@ export class ClaimRequestManagementService {
session: auditSession,
step: FanavaranAuditStep.SUBMIT_CLAIM,
status: FanavaranAuditStatus.SUCCESS,
requestUrl: this.FANAVARAN_SUBMIT_URL,
requestUrl: submitUrl,
requestMethod: "POST",
httpStatus: response.status,
requestHeaders: exchange.requestHeaders,
@@ -8138,7 +8358,7 @@ export class ClaimRequestManagementService {
session: auditSession,
step: FanavaranAuditStep.SUBMIT_CLAIM,
status: FanavaranAuditStatus.FAILURE,
requestUrl: this.FANAVARAN_SUBMIT_URL,
requestUrl: submitUrl,
requestMethod: "POST",
httpStatus: exchange.httpStatus,
requestHeaders: exchange.requestHeaders,

View File

@@ -97,14 +97,26 @@ export class FanavaranSyncStage {
@Prop({ type: Number })
vehicleKindId?: number;
/** How VehicleKindId was resolved: vehicle-inquiry | car-type-lookup */
@Prop({ type: String })
vehicleKindSource?: string;
/** Cached Fanavaran PlaqueKindId from VIN inquiry / vehicle GET. */
@Prop({ type: Number })
plaqueKindId?: number;
/** How PlaqueKindId was resolved: vehicle-inquiry | config-default */
@Prop({ type: String })
plaqueKindSource?: string;
/** Cached Fanavaran PlaqueSampleId from VIN inquiry / vehicle GET. */
@Prop({ type: Number })
plaqueSampleId?: number;
/** How PlaqueSampleId was resolved: vehicle-inquiry | config-default */
@Prop({ type: String })
plaqueSampleSource?: string;
/**
* Cached Fanavaran AccidentVehicleUsedId, resolved from VIN inquiry / vehicle GET
* UsedId (lookup-filter safe). Not the Mongo tenant default.

View File

@@ -0,0 +1,133 @@
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import {
fanavaranClaimProductFromBlameType,
fanavaranClaimsBaseUrl,
fanavaranPartyInquirySources,
isFanavaranSubmitSupportedBlameType,
pickCarBodyPolicyNationalCode,
pickStoredCarBodyPolicyId,
} from "./fanavaran-claim-product";
describe("fanavaran claim product", () => {
it("maps CAR_BODY blame files onto the hull claims resource", () => {
expect(fanavaranClaimProductFromBlameType(BlameRequestType.CAR_BODY)).toBe(
"car-body",
);
expect(fanavaranClaimsBaseUrl("car-body")).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims",
);
});
it("keeps THIRD_PARTY on the financial third-party claims resource", () => {
expect(
fanavaranClaimProductFromBlameType(BlameRequestType.THIRD_PARTY),
).toBe("third-party");
expect(fanavaranClaimsBaseUrl("third-party")).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/third-party-car-financial-claims",
);
});
it("allows Fanavaran submit for both car products", () => {
expect(isFanavaranSubmitSupportedBlameType("THIRD_PARTY")).toBe(true);
expect(isFanavaranSubmitSupportedBlameType("CAR_BODY")).toBe(true);
expect(isFanavaranSubmitSupportedBlameType("OTHER")).toBe(false);
});
it("reuses the car-body inquiry PolicyId without another policy list call", () => {
const policyId = pickStoredCarBodyPolicyId({
parties: [
{
role: PartyRole.FIRST,
insurance: { carBodyInsurance: { policyId: 15292336 } },
},
],
});
expect(policyId).toBe(15292336);
});
it("reads PolicyId from nested car-body inquiry raw when the flat field is missing", () => {
const policyId = pickStoredCarBodyPolicyId({
parties: [
{
role: PartyRole.FIRST,
vehicle: {
inquiry: {
carBody: {
mapped: {},
raw: { policyId: "15292336", policy: { PolicyId: 99 } },
},
},
},
},
],
});
expect(policyId).toBe(15292336);
});
it("uses the first-party car-body policyholder national code", () => {
const nationalCode = pickCarBodyPolicyNationalCode({
parties: [
{
role: PartyRole.FIRST,
person: { nationalCodeOfInsurer: "0012345678" },
insurance: {
carBodyInsurance: { ownerNationalCode: "0098765432" },
},
},
],
});
expect(nationalCode).toBe("0012345678");
});
it("flattens car-body inquiry fields onto the damage-case aliases", () => {
const { mapped } = fanavaranPartyInquirySources("car-body", {
insurance: {
carBodyInsurance: {
policyNumber: "70019846985",
chassisNumber: "IRNKAEK4150012345",
},
},
vehicle: {
inquiry: {
carBody: {
mapped: {
policyId: 15292336,
policyNumber: "70019846985",
chassisNumber: "IRNKAEK4150012345",
motorNumber: "M123",
vin: "IRNKAEK4150012345",
StartDate: "1405/05/18",
EndDate: "1406/05/18",
builtYear: 1402,
platePartOne: "29",
plateLetterTitle: "د",
platePartThree: "782",
plateSerialNumber: "44",
},
raw: {
policy: { CINumber: "70019846985" },
vehicle: { ChassisNo: "IRNKAEK4150012345" },
},
},
},
},
});
expect(mapped.policyId).toBe(15292336);
expect(mapped.PrntCmpDocNo).toBe("70019846985");
expect(mapped.ShsNum).toBe("IRNKAEK4150012345");
expect(mapped.MtrNum).toBe("M123");
expect(mapped.VIN).toBe("IRNKAEK4150012345");
expect(mapped.HBgnDte).toBe("1405/05/18");
expect(mapped.HEndDte).toBe("1406/05/18");
expect(mapped.PrdDte).toBe(1402);
expect(mapped.plk1).toBe("29");
expect(mapped.plk2).toBe("د");
expect(mapped.plk3).toBe("782");
expect(mapped.plksrl).toBe("44");
});
});

View File

@@ -0,0 +1,227 @@
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import {
FANAVARAN_CAR_BODY_LINE_ID,
FANAVARAN_THIRD_PARTY_LINE_ID,
asObjectRecord,
parseFanavaranId,
type FanavaranCarPolicyProduct,
} from "src/lookups/fanavaran-last-car-policy";
export type FanavaranClaimProduct = FanavaranCarPolicyProduct;
const FANAVARAN_CLAIMS_HOST =
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car";
export function fanavaranClaimProductFromBlameType(
type?: string | null,
): FanavaranClaimProduct {
return type === BlameRequestType.CAR_BODY || type === "CAR_BODY"
? "car-body"
: "third-party";
}
export function isFanavaranSubmitSupportedBlameType(
type?: string | null,
): boolean {
return (
type === BlameRequestType.THIRD_PARTY ||
type === "THIRD_PARTY" ||
type === BlameRequestType.CAR_BODY ||
type === "CAR_BODY"
);
}
export function fanavaranClaimsResourcePath(
product: FanavaranClaimProduct,
): string {
return product === "car-body"
? "vehicle-hull-claims"
: "third-party-car-financial-claims";
}
export function fanavaranClaimsBaseUrl(
product: FanavaranClaimProduct,
): string {
return `${FANAVARAN_CLAIMS_HOST}/${fanavaranClaimsResourcePath(product)}`;
}
export function fanavaranInsuranceLineIdForProduct(
product: FanavaranClaimProduct,
): number {
return product === "car-body"
? FANAVARAN_CAR_BODY_LINE_ID
: FANAVARAN_THIRD_PARTY_LINE_ID;
}
type BlamePartyForCarBodyPolicy = {
role?: string;
person?: { nationalCodeOfInsurer?: unknown };
insurance?: {
carBodyInsurance?: {
policyId?: unknown;
ownerNationalCode?: unknown;
insurerNationalCode?: unknown;
policyNumber?: unknown;
chassisNumber?: unknown;
motorNumber?: unknown;
vin?: unknown;
startDate?: unknown;
endDate?: unknown;
};
};
vehicle?: {
inquiry?: {
mapped?: Record<string, unknown>;
raw?: Record<string, unknown> | { data?: Record<string, unknown> };
carBody?: {
mapped?: Record<string, unknown>;
raw?: Record<string, unknown> & {
policyId?: unknown;
policy?: Record<string, unknown>;
vehicle?: Record<string, unknown>;
};
};
};
};
};
function firstCarBodyParty(
blame?: { parties?: BlamePartyForCarBodyPolicy[] } | null,
): BlamePartyForCarBodyPolicy | null {
const parties = blame?.parties ?? [];
return (
parties.find((party) => party?.role === PartyRole.FIRST) ??
parties[0] ??
null
);
}
function nonEmptyText(value: unknown): string | null {
if (value == null) return null;
const text = String(value).trim();
return text ? text : null;
}
export function pickStoredCarBodyPolicyId(
blame?: { parties?: BlamePartyForCarBodyPolicy[] } | null,
): number | null {
const party = firstCarBodyParty(blame);
const carBody = party?.vehicle?.inquiry?.carBody;
const raw = asObjectRecord(carBody?.raw) ?? {};
const mapped = asObjectRecord(carBody?.mapped) ?? {};
const rawPolicy = asObjectRecord(raw.policy);
return parseFanavaranId(
party?.insurance?.carBodyInsurance?.policyId ??
mapped.policyId ??
raw.policyId ??
rawPolicy?.PolicyId,
);
}
export function pickCarBodyPolicyNationalCode(
blame?: { parties?: BlamePartyForCarBodyPolicy[] } | null,
): string | null {
const party = firstCarBodyParty(blame);
return (
nonEmptyText(party?.person?.nationalCodeOfInsurer) ??
nonEmptyText(party?.insurance?.carBodyInsurance?.ownerNationalCode) ??
nonEmptyText(party?.insurance?.carBodyInsurance?.insurerNationalCode)
);
}
export function fanavaranPartyInquirySources(
product: FanavaranClaimProduct,
party?: BlamePartyForCarBodyPolicy | null,
): {
mapped: Record<string, any>;
raw: Record<string, any>;
} {
const inquiry = party?.vehicle?.inquiry ?? {};
if (product !== "car-body") {
const mapped = asObjectRecord(inquiry.mapped) ?? {};
const rawValue = inquiry.raw as
| Record<string, unknown>
| { data?: Record<string, unknown> }
| undefined;
const raw =
asObjectRecord(
rawValue &&
typeof rawValue === "object" &&
"data" in rawValue &&
rawValue.data != null &&
typeof rawValue.data === "object" &&
!Array.isArray(rawValue.data)
? rawValue.data
: rawValue,
) ?? {};
return { mapped, raw };
}
const carBody = inquiry.carBody ?? {};
const mapped = asObjectRecord(carBody.mapped) ?? {};
const raw = asObjectRecord(carBody.raw) ?? {};
const policy = asObjectRecord(raw.policy) ?? {};
const vehicle = asObjectRecord(raw.vehicle) ?? {};
const insurance = party?.insurance?.carBodyInsurance ?? {};
const policyNumber =
mapped.policyNumber ??
insurance.policyNumber ??
policy.CINumber ??
policy.PolicyNo;
const chassisNumber =
mapped.chassisNumber ?? insurance.chassisNumber ?? vehicle.ChassisNo;
const motorNumber =
mapped.motorNumber ?? insurance.motorNumber ?? vehicle.MotorNo;
const vin = mapped.vin ?? insurance.vin ?? vehicle.VIN ?? vehicle.ChassisNo;
const beginDate =
mapped.StartDate ?? mapped.startDate ?? insurance.startDate ?? policy.BeginDate;
const endDate =
mapped.EndDate ?? mapped.endDate ?? insurance.endDate ?? policy.EndDate;
const builtYear = mapped.builtYear ?? vehicle.BuiltYear;
const plaque = asObjectRecord(vehicle.plaque) ?? {};
const plk1 =
mapped.platePartOne ?? plaque.leftTwoDigits ?? vehicle.PlaqueLeftNo;
const plk2 =
mapped.plateLetterTitle ?? plaque.serialLetter;
const plk3 =
mapped.platePartThree ?? plaque.threeDigits ?? vehicle.PlaqueRightNo;
const plksrl =
mapped.plateSerialNumber ??
plaque.rightTwoDigits ??
vehicle.PlaqueSerial;
return {
mapped: {
...mapped,
...insurance,
policyId:
mapped.policyId ??
insurance.policyId ??
raw.policyId ??
policy.PolicyId,
PrntCmpDocNo: policyNumber,
PlcyUnqCod: policy.PolicyUnqCod ?? mapped.policyNumber,
ShsNum: chassisNumber,
MtrNum: motorNumber,
VIN: vin,
HBgnDte: beginDate,
HEndDte: endDate,
PrdDte: builtYear,
plk1,
Plk1: plk1,
plk2,
Plk2: plk2,
plk3,
Plk3: plk3,
plksrl,
PlkSrl: plksrl,
},
raw: {
...policy,
...vehicle,
...raw,
},
};
}

View File

@@ -1,6 +1,9 @@
import {
canReuseCachedFanavaranVehicleIds,
collectPartyVinCandidates,
fanavaranIdOrConfigDefault,
fanavaranPayloadMatchesDamagedIdentity,
fanavaranVehicleMatchesDamagedIdentity,
filterPoliciesByLine,
insuranceLineLabel,
parseLastCarPolicyInput,
@@ -10,6 +13,7 @@ import {
pickVinFromPartyVehicle,
plaqueLetterFromMiddleCode,
plaqueMatchesVehicle,
selectFanavaranVehicleForDamageCase,
selectLastAmongCarMatches,
toAppPlaque,
vehicleMatchesCar,
@@ -311,4 +315,154 @@ describe("fanavaran last car policy", () => {
it("returns null when there are no matches", () => {
expect(selectLastAmongCarMatches([], today)).toBeNull();
});
const damagedIdentity = {
vin: "IRPC941V2BD798656",
chassis: "NAS431100E5798656",
plaque: { left: "59", letter: "ی", right: "419", serial: "78" },
policyCINumber: "16040446424",
};
const culpritVehicle = {
Id: 475449,
VIN: "IRFC891V7D2301065",
ChassisNo: "NAAP41FD5BJ301065",
VehicleKindId: 6953,
VersionNo: 3,
PlaqueKindId: 8,
PlaqueSampleId: 10,
PlaqueLeftNo: "56",
PlaqueMiddleCodeId: 5,
PlaqueRightNo: "394",
PlaqueSerial: "66",
PlaqueNo: "394د56",
};
it("does not treat the culprit vehicle as the damaged car", () => {
expect(
fanavaranVehicleMatchesDamagedIdentity(culpritVehicle, damagedIdentity),
).toBe(false);
expect(
selectFanavaranVehicleForDamageCase(
[
culpritVehicle,
{ Id: 9, VIN: "OTHER", PlaqueKindId: 15, PlaqueSampleId: 24 },
],
damagedIdentity,
),
).toBeNull();
});
it("ignores a PlaqueKindId 15 row that does not share VIN or plate", () => {
expect(
selectFanavaranVehicleForDamageCase(
[
{ Id: 1, VIN: "IRPC941V2BD798656" },
{
Id: 2,
VIN: "IRFC901V119493683",
PlaqueKindId: 15,
PlaqueSampleId: 24,
PlaqueLeftNo: "62",
PlaqueMiddleCodeId: 14,
PlaqueRightNo: "278",
PlaqueSerial: "50",
},
],
damagedIdentity,
),
).toEqual({ Id: 1, VIN: "IRPC941V2BD798656" });
});
it("requires plate agreement when the inquiry row has plaque parts", () => {
expect(
selectFanavaranVehicleForDamageCase(
[
{
Id: 1,
VIN: "IRPC941V2BD798656",
PlaqueKindId: 15,
PlaqueLeftNo: "56",
PlaqueMiddleCodeId: 5,
PlaqueRightNo: "394",
PlaqueSerial: "66",
},
],
damagedIdentity,
),
).toBeNull();
});
it("picks the newest matching version of the damaged VIN and plate", () => {
expect(
selectFanavaranVehicleForDamageCase(
[
{
Id: 10,
VIN: "IRPC941V2BD798656",
ChassisNo: "NAS431100E5798656",
VersionNo: 1,
PlaqueKindId: 15,
PlaqueSampleId: 24,
PlaqueLeftNo: "59",
PlaqueMiddleCodeId: 16,
PlaqueRightNo: "419",
PlaqueSerial: "78",
},
{
Id: 10,
VIN: "IRPC941V2BD798656",
ChassisNo: "NAS431100E5798656",
VersionNo: 3,
PlaqueKindId: 8,
PlaqueSampleId: 10,
PlaqueLeftNo: "59",
PlaqueMiddleCodeId: 16,
PlaqueRightNo: "419",
PlaqueSerial: "78",
},
],
damagedIdentity,
)?.PlaqueKindId,
).toBe(8);
});
it("rejects cached payload ids from a different VIN, plate, or CI number", () => {
expect(
fanavaranPayloadMatchesDamagedIdentity(
{
VIN: "IRFC891V7D2301065",
ChassisNo: "NAAP41FD5BJ301065",
PolicyCINumber: "16018466143",
PlaqueKindId: 8,
PlaqueLeftNo: "56",
PlaqueMiddleCodeId: 5,
PlaqueRightNo: "394",
PlaqueSerial: "66",
},
damagedIdentity,
),
).toBe(false);
expect(canReuseCachedFanavaranVehicleIds("config-default")).toBe(false);
expect(canReuseCachedFanavaranVehicleIds("vehicle-inquiry")).toBe(true);
expect(canReuseCachedFanavaranVehicleIds(undefined)).toBe(false);
});
it("accepts cache only when VIN, plate, and CI number all agree", () => {
expect(
fanavaranPayloadMatchesDamagedIdentity(
{
VIN: "IRPC941V2BD798656",
ChassisNo: "NAS431100E5798656",
PolicyCINumber: "16040446424",
PlaqueLeftNo: "59",
PlaqueMiddleCodeId: 16,
PlaqueRightNo: "419",
PlaqueSerial: "78",
PlaqueKindId: 8,
},
damagedIdentity,
),
).toBe(true);
});
});

View File

@@ -262,6 +262,159 @@ export function pickFanavaranVehicleFromInquiry(
return asObjectRecord(inquired);
}
export type FanavaranDamagedVehicleIdentity = {
vin?: string | null;
chassis?: string | null;
plaque?: FanavaranPlaqueParts | null;
policyCINumber?: string | null;
vehicleKindId?: number | null;
};
export function asVehicleInquiryRows(
inquired: unknown,
): Record<string, unknown>[] {
if (Array.isArray(inquired)) {
return inquired
.map((row) => asObjectRecord(row))
.filter((row): row is Record<string, unknown> => row != null);
}
const one = asObjectRecord(inquired);
return one ? [one] : [];
}
export function normalizePolicyCINumber(value: unknown): string {
return toEnglishDigits(value).replace(/\s+/g, "").trim();
}
function vehicleHasPlaqueParts(vehicle: Record<string, unknown>): boolean {
return Boolean(
asPlaqueText(vehicle.PlaqueLeftNo ?? vehicle.plaqueLeftNo) &&
(parseFanavaranId(
vehicle.PlaqueMiddleCodeId ?? vehicle.plaqueMiddleCodeId,
) != null ||
String(vehicle.PlaqueLetter ?? vehicle.plaqueLetter ?? "").trim()) &&
asPlaqueText(vehicle.PlaqueRightNo ?? vehicle.plaqueRightNo) &&
asPlaqueText(vehicle.PlaqueSerial ?? vehicle.plaqueSerial),
);
}
function vinEquals(left: unknown, right: unknown): boolean {
const a = normalizeVin(left);
const b = normalizeVin(right);
return Boolean(a && b && a === b);
}
/**
* Same physical car: VIN and plaque must not contradict the damaged-party
* inquiry. A row that only "has PlaqueKindId" is not enough.
*/
export function fanavaranVehicleMatchesDamagedIdentity(
vehicle: Record<string, unknown> | null,
identity: FanavaranDamagedVehicleIdentity,
): boolean {
if (!vehicle) return false;
const identityVin = normalizeVin(identity.vin);
const identityChassis = normalizeVin(identity.chassis);
const vehicleVin = pickVehicleVin(vehicle);
const vehicleChassis = normalizeVin(
vehicle.ChassisNo ?? vehicle.chassisNo ?? vehicle.ShsNum,
);
const vinMatched = Boolean(
identityVin &&
(vinEquals(vehicleVin, identityVin) ||
vinEquals(vehicleChassis, identityVin)),
);
const chassisMatched = Boolean(
identityChassis &&
(vinEquals(vehicleVin, identityChassis) ||
vinEquals(vehicleChassis, identityChassis)),
);
if (identityVin && vehicleVin && !vinMatched && !chassisMatched) {
return false;
}
const plaqueMatched = Boolean(
identity.plaque && plaqueMatchesVehicle(identity.plaque, vehicle),
);
if (identity.plaque && vehicleHasPlaqueParts(vehicle) && !plaqueMatched) {
return false;
}
return vinMatched || chassisMatched || plaqueMatched;
}
export function fanavaranPayloadMatchesDamagedIdentity(
payload: Record<string, unknown> | null,
identity: FanavaranDamagedVehicleIdentity,
): boolean {
if (!payload) return false;
const payloadVin = normalizeVin(payload.VIN ?? payload.vin);
const identityVin = normalizeVin(identity.vin);
if (payloadVin && identityVin && payloadVin !== identityVin) return false;
const payloadChassis = normalizeVin(
payload.ChassisNo ?? payload.chassisNo,
);
const identityChassis = normalizeVin(identity.chassis);
if (payloadChassis && identityChassis && payloadChassis !== identityChassis) {
return false;
}
const payloadCI = normalizePolicyCINumber(payload.PolicyCINumber);
const identityCI = normalizePolicyCINumber(identity.policyCINumber);
if (payloadCI && identityCI && payloadCI !== identityCI) return false;
if (identity.plaque && vehicleHasPlaqueParts(payload)) {
if (!plaqueMatchesVehicle(identity.plaque, payload)) return false;
}
const vinMatched = Boolean(payloadVin && identityVin && payloadVin === identityVin);
const chassisMatched = Boolean(
payloadChassis && identityChassis && payloadChassis === identityChassis,
);
const ciMatched = Boolean(payloadCI && identityCI && payloadCI === identityCI);
const plaqueMatched = Boolean(
identity.plaque && plaqueMatchesVehicle(identity.plaque, payload),
);
return vinMatched || chassisMatched || ciMatched || plaqueMatched;
}
export function canReuseCachedFanavaranVehicleIds(source: unknown): boolean {
return source === "vehicle-inquiry" || source === "vin-inquiry" || source === "vehicle-get";
}
/**
* Pick the VIN-inquiry / vehicle-GET row that is the damaged car.
* Newest VersionNo wins among matches; PlaqueKindId is a tie-break only.
*/
export function selectFanavaranVehicleForDamageCase(
inquired: unknown,
identity: FanavaranDamagedVehicleIdentity,
): Record<string, unknown> | null {
const matches = asVehicleInquiryRows(inquired).filter((row) =>
fanavaranVehicleMatchesDamagedIdentity(row, identity),
);
if (matches.length === 0) return null;
return matches.reduce((current, candidate) => {
const currentVersion = parseFanavaranId(current.VersionNo) ?? 0;
const candidateVersion = parseFanavaranId(candidate.VersionNo) ?? 0;
if (candidateVersion !== currentVersion) {
return candidateVersion > currentVersion ? candidate : current;
}
const currentHasKind =
pickFanavaranPlaqueLookupFields(current).kindId != null;
const candidateHasKind =
pickFanavaranPlaqueLookupFields(candidate).kindId != null;
if (candidateHasKind && !currentHasKind) return candidate;
return current;
});
}
export function pickVinFromPartyVehicle(party: {
vehicle?: { vin?: unknown; inquiry?: unknown };
} | null | undefined): string | null {