diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 4529090..e5a9640 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -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 = { 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 | null> { - let fallback: Record | null = null; + const requestOptions = { + contractIdOverride: resolveFanavaranProductContractId(clientKey, product), + }; + let matched: Record | 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(); - 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(); + 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 { + 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 { + 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 { 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 { 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, diff --git a/src/claim-request-management/entites/schema/claim-cases.schema.ts b/src/claim-request-management/entites/schema/claim-cases.schema.ts index c9b9fc7..3f733e0 100644 --- a/src/claim-request-management/entites/schema/claim-cases.schema.ts +++ b/src/claim-request-management/entites/schema/claim-cases.schema.ts @@ -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. diff --git a/src/claim-request-management/fanavaran-claim-product.spec.ts b/src/claim-request-management/fanavaran-claim-product.spec.ts new file mode 100644 index 0000000..3ea89fb --- /dev/null +++ b/src/claim-request-management/fanavaran-claim-product.spec.ts @@ -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"); + }); +}); diff --git a/src/claim-request-management/fanavaran-claim-product.ts b/src/claim-request-management/fanavaran-claim-product.ts new file mode 100644 index 0000000..f6b71ba --- /dev/null +++ b/src/claim-request-management/fanavaran-claim-product.ts @@ -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; + raw?: Record | { data?: Record }; + carBody?: { + mapped?: Record; + raw?: Record & { + policyId?: unknown; + policy?: Record; + vehicle?: Record; + }; + }; + }; + }; +}; + +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; + raw: Record; +} { + const inquiry = party?.vehicle?.inquiry ?? {}; + if (product !== "car-body") { + const mapped = asObjectRecord(inquiry.mapped) ?? {}; + const rawValue = inquiry.raw as + | Record + | { data?: Record } + | 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, + }, + }; +} diff --git a/src/lookups/fanavaran-last-car-policy.spec.ts b/src/lookups/fanavaran-last-car-policy.spec.ts index d191623..424b1ed 100644 --- a/src/lookups/fanavaran-last-car-policy.spec.ts +++ b/src/lookups/fanavaran-last-car-policy.spec.ts @@ -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); + }); }); diff --git a/src/lookups/fanavaran-last-car-policy.ts b/src/lookups/fanavaran-last-car-policy.ts index 1459652..1a937fa 100644 --- a/src/lookups/fanavaran-last-car-policy.ts +++ b/src/lookups/fanavaran-last-car-policy.ts @@ -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[] { + if (Array.isArray(inquired)) { + return inquired + .map((row) => asObjectRecord(row)) + .filter((row): row is Record => 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): 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 | 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 | 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 | 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 {