import { jalaliToGregorianDate } from "src/helpers/date-jalali"; import { gregorianDateInIran } from "src/helpers/iran-datetime"; import { normalizePlateText } from "src/utils/plate-normalizer/plate-normalizer.service"; export const FANAVARAN_THIRD_PARTY_LINE_ID = 5; export const FANAVARAN_CAR_BODY_LINE_ID = 4; export type FanavaranCarPolicyProduct = "third-party" | "car-body"; export type FanavaranPlaqueParts = { left: string; letter: string; right: string; serial: string; }; export type FanavaranCarMatchInput = { vin?: string; plaque?: FanavaranPlaqueParts; }; export type FanavaranPolicyListRow = { PolicyId?: unknown; InsuranceLineId?: unknown; BeginDate?: unknown; EndDate?: unknown; }; export type HydratedFanavaranCarPolicy = { policyId: number; beginDate?: unknown; endDate?: unknown; vehicleId?: number | null; policy: Record; vehicle: Record | null; }; /** Same Fanavaran middle-letter codes used on claim submit. */ export const FANAVARAN_PLATE_LETTER_CODE: Record = { الف: 1, ب: 2, پ: 3, ج: 4, د: 5, س: 6, ص: 7, ط: 8, ع: 9, ق: 10, ل: 11, م: 12, ن: 13, و: 14, ه: 15, ی: 16, ک: 17, ژ: 18, ت: 19, ث: 20, ز: 21, ش: 22, ف: 23, گ: 24, }; const PERSIAN_AND_ARABIC_DIGITS: Record = { "۰": "0", "۱": "1", "۲": "2", "۳": "3", "۴": "4", "۵": "5", "۶": "6", "۷": "7", "۸": "8", "۹": "9", "٠": "0", "١": "1", "٢": "2", "٣": "3", "٤": "4", "٥": "5", "٦": "6", "٧": "7", "٨": "8", "٩": "9", }; export function insuranceLineIdForProduct( product: FanavaranCarPolicyProduct, ): number { return product === "car-body" ? FANAVARAN_CAR_BODY_LINE_ID : FANAVARAN_THIRD_PARTY_LINE_ID; } export function insuranceLineLabel( insuranceLineId: number, ): "CAR_BODY" | "THIRD_PARTY" | null { if (insuranceLineId === FANAVARAN_CAR_BODY_LINE_ID) return "CAR_BODY"; if (insuranceLineId === FANAVARAN_THIRD_PARTY_LINE_ID) return "THIRD_PARTY"; return null; } export function plaqueLetterFromMiddleCode(code: unknown): string | null { const id = parseFanavaranId(code); if (id === null) return null; const found = Object.entries(FANAVARAN_PLATE_LETTER_CODE).find( ([, value]) => value === id, ); return found?.[0] ?? null; } export type AppPlaque = { leftTwoDigits: string; serialLetter: string; threeDigits: string; rightTwoDigits: string; }; export function toAppPlaque( vehicle: Record | null, ): AppPlaque | null { if (!vehicle) return null; const leftTwoDigits = normalizePlaquePart( vehicle.PlaqueLeftNo ?? vehicle.plaqueLeftNo, ); const threeDigits = normalizePlaquePart( vehicle.PlaqueRightNo ?? vehicle.plaqueRightNo, ); const rightTwoDigits = normalizePlaquePart( vehicle.PlaqueSerial ?? vehicle.plaqueSerial, ); const serialLetter = plaqueLetterFromMiddleCode( vehicle.PlaqueMiddleCodeId ?? vehicle.plaqueMiddleCodeId, ); if (!leftTwoDigits || !serialLetter || !threeDigits || !rightTwoDigits) { return null; } return { leftTwoDigits, serialLetter, threeDigits, rightTwoDigits }; } export function toEnglishDigits(value: unknown): string { return String(value ?? "") .split("") .map((char) => PERSIAN_AND_ARABIC_DIGITS[char] ?? char) .join(""); } export function normalizeVin(value: unknown): string { return toEnglishDigits(value).trim().toUpperCase(); } export function normalizePlaquePart(value: unknown): string { return toEnglishDigits(value).replace(/\s+/g, "").trim(); } export function parseFanavaranId(value: unknown): number | null { if (value === null || value === undefined) return null; if (typeof value === "string" && value.trim() === "") return null; const id = Number(toEnglishDigits(value).trim()); return Number.isFinite(id) && id > 0 ? id : null; } /** Live Fanavaran / cache id wins; tenant `fanavaranClientConfigs.defaults` if the API returned nothing. */ export function fanavaranIdOrConfigDefault( resolved: unknown, configDefault: number, ): number { return parseFanavaranId(resolved) ?? configDefault; } export function asObjectRecord(value: unknown): Record | null { if (!value || typeof value !== "object") { return null; } if (Array.isArray(value)) { return asObjectRecord(value[0]); } return value as Record; } export function asPolicyList(data: unknown): FanavaranPolicyListRow[] { if (Array.isArray(data)) { return data.filter( (row): row is FanavaranPolicyListRow => !!row && typeof row === "object" && !Array.isArray(row), ); } const record = asObjectRecord(data); if (!record) return []; for (const key of ["value", "Value", "items", "Items", "data", "Data"]) { const nested = record[key]; if (Array.isArray(nested)) { return asPolicyList(nested); } } return []; } export function filterPoliciesByLine( policies: unknown, insuranceLineId: number, ): FanavaranPolicyListRow[] { return asPolicyList(policies).filter((row) => { const line = parseFanavaranId(row.InsuranceLineId); return line === null || line === insuranceLineId; }); } export function sortPoliciesNewestEndDateFirst( policies: FanavaranPolicyListRow[], ): FanavaranPolicyListRow[] { return [...policies].sort((left, right) => { const leftEnd = toGregorianDate(left.EndDate) ?? ""; const rightEnd = toGregorianDate(right.EndDate) ?? ""; return rightEnd.localeCompare(leftEnd); }); } export function toGregorianDate(value: unknown): string | null { return jalaliToGregorianDate(toEnglishDigits(value).trim() || null); } export function pickVehicleVin(vehicle: Record | null): string { if (!vehicle) return ""; for (const key of ["VIN", "Vin", "VinNo", "vin", "ChassisNo", "chassisNo"]) { const vin = normalizeVin(vehicle[key]); if (vin) return vin; } return ""; } export function pickVehicleId(source: unknown): number | null { const record = asObjectRecord(source); if (!record) return parseFanavaranId(source); for (const key of ["VehicleId", "vehicleId", "Id", "id"]) { const id = parseFanavaranId(record[key]); if (id !== null) return id; } return null; } /** Prefer the inquiry row whose Id matches the policy VehicleId. */ export function pickFanavaranVehicleFromInquiry( inquired: unknown, vehicleId?: number | null, ): Record | null { if (Array.isArray(inquired)) { if (vehicleId != null) { const match = inquired.find((row) => pickVehicleId(row) === vehicleId); if (match) return asObjectRecord(match); } const withPlaqueKind = inquired.find( (row) => parseFanavaranId(asObjectRecord(row)?.PlaqueKindId) != null || parseFanavaranId(asObjectRecord(row)?.plaqueKindId) != null, ); if (withPlaqueKind) return asObjectRecord(withPlaqueKind); return asObjectRecord(inquired[0]); } 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"; } /** Proven Parsian national (چهار تکه) plaque codebook ids. */ export const FANAVARAN_NATIONAL_PLAQUE_KIND_ID = 8; export const FANAVARAN_NATIONAL_PLAQUE_SAMPLE_ID = 10; export const FANAVARAN_DAMAGE_MANUAL_OVERRIDE_KEYS = [ "Desc", "EstimateAmount", "LicenceNo", "LicenceIssuDate", "DriverIsOwner", ] as const; export const FANAVARAN_EXPERTISE_MANUAL_OVERRIDE_KEYS = [ "RepairWage", "ComponentReplacementCost", "WasteValue", "DmgAssessmentDate", "InspectionTime", "DamagedVehicleCurrentPrice", "DropAmountAdditionsDeductions", ] as const; export const FANAVARAN_BASE_MANUAL_OVERRIDE_KEYS = [ "EstimateAmount", "CulpritLicenceNo", "CulpritLicenceIssuDate", "AccidentLocationAddress", "AccidentDate", "AnnouncementDate", "DocReceivedDate", "AccidentTime", "PoliceReportDesc", "PoliceReportSeri", "PoliceReportSerial", ] as const; /** * Swagger POST body is optional user-entry tweaks only. * Inquiry / lookup-filter ids always come from the live rebuilt payload. */ export function applyFanavaranManualPayloadOverrides( built: Record, body: Record | null | undefined, keys: readonly string[], ): Record { if (!body) return built; const next = { ...built }; for (const key of keys) { if (!Object.prototype.hasOwnProperty.call(body, key)) continue; if (body[key] === undefined) continue; next[key] = body[key]; } return next; } /** * Plaque kind/sample only from a matched Fanavaran vehicle record. * CII/ESG inquiry, unsourced cache, and tenant defaults of 15 must not win. * National 4-part plates fall back to 8/10 (lookup-filter safe on Parsian). */ export function resolveDamageCasePlaqueLookupIds(matchedVehicle: Record< string, unknown > | null): { kindId: number; sampleId: number; source: "vehicle-inquiry" | "national-plate-default"; } { const plaque = pickFanavaranPlaqueLookupFields(matchedVehicle); if (plaque.kindId != null) { return { kindId: plaque.kindId, sampleId: plaque.sampleId ?? FANAVARAN_NATIONAL_PLAQUE_SAMPLE_ID, source: "vehicle-inquiry", }; } return { kindId: FANAVARAN_NATIONAL_PLAQUE_KIND_ID, sampleId: FANAVARAN_NATIONAL_PLAQUE_SAMPLE_ID, source: "national-plate-default", }; } /** * 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 { return collectPartyVinCandidates(party)[0] ?? null; } /** * VIN first (mapped/raw VIN fields), then party.vehicle.vin, then chassis. * Damaged cars often store chassis on `vehicle.vin` which must not win over the real VIN. */ export function collectPartyVinCandidates(party: { vehicle?: { vin?: unknown; inquiry?: unknown }; } | null | undefined): string[] { const inquiry = asObjectRecord(party?.vehicle?.inquiry); const mapped = asObjectRecord(inquiry?.mapped); const rawData = asObjectRecord( asObjectRecord(inquiry?.raw)?.data ?? inquiry?.raw ?? inquiry, ); const values = [ mapped?.VIN, mapped?.Vin, mapped?.vin, mapped?.VinNumberField, rawData?.VIN, rawData?.Vin, rawData?.vin, rawData?.VinNumberField, party?.vehicle?.vin, mapped?.ChassisNo, mapped?.chassisNo, mapped?.ShsNum, mapped?.shsNam, mapped?.ChassisNumberField, rawData?.ChassisNo, rawData?.chassisNo, rawData?.ShsNum, rawData?.shsNam, rawData?.ChassisNumberField, ]; const unique: string[] = []; const seen = new Set(); for (const value of values) { const vin = normalizeVin(value); if (!vin || seen.has(vin)) continue; seen.add(vin); unique.push(vin); } return unique; } export type FanavaranPlaqueLookupFields = { kindId: number | null; sampleId: number | null; cityId: number | null; leftNo: string | null; middleCodeId: number | null; rightNo: string | null; serial: string | null; plaqueNo: string | null; }; function asPlaqueText(value: unknown): string | null { const text = normalizePlaquePart(value); return text || null; } /** Plaque lookup-filter fields from VIN inquiry / vehicle GET. */ export function pickFanavaranPlaqueLookupFields( vehicle: Record | null, ): FanavaranPlaqueLookupFields { return { kindId: parseFanavaranId(vehicle?.PlaqueKindId ?? vehicle?.plaqueKindId), sampleId: parseFanavaranId( vehicle?.PlaqueSampleId ?? vehicle?.plaqueSampleId, ), cityId: parseFanavaranId(vehicle?.PlaqueCityId ?? vehicle?.plaqueCityId), leftNo: asPlaqueText(vehicle?.PlaqueLeftNo ?? vehicle?.plaqueLeftNo), middleCodeId: parseFanavaranId( vehicle?.PlaqueMiddleCodeId ?? vehicle?.plaqueMiddleCodeId, ), rightNo: asPlaqueText(vehicle?.PlaqueRightNo ?? vehicle?.plaqueRightNo), serial: asPlaqueText(vehicle?.PlaqueSerial ?? vehicle?.plaqueSerial), plaqueNo: asPlaqueText(vehicle?.PlaqueNo ?? vehicle?.plaqueNo), }; } function plaqueNumberEquals(left: unknown, right: unknown): boolean { const a = normalizePlaquePart(left); const b = normalizePlaquePart(right); if (!a || !b) return false; const aNum = Number(a); const bNum = Number(b); if (Number.isFinite(aNum) && Number.isFinite(bNum)) { return aNum === bNum; } return a === b; } function plaqueLetterEquals( inputLetter: string, vehicle: Record, ): boolean { const normalizedInput = normalizePlateText(toEnglishDigits(inputLetter).trim()); if (!normalizedInput) return false; const vehicleLetterRaw = [ vehicle.PlaqueMiddleCodeCaption, vehicle.PlaqueLetter, vehicle.MiddleCodeCaption, vehicle.PlaqueMiddleCode, ] .map((value) => normalizePlateText(String(value ?? "").trim())) .find((value) => value && Number.isNaN(Number(value))); if (vehicleLetterRaw && normalizePlateText(vehicleLetterRaw) === normalizedInput) { return true; } const inputCode = FANAVARAN_PLATE_LETTER_CODE[normalizedInput] ?? (Number.isFinite(Number(normalizedInput)) ? Number(normalizedInput) : null); const vehicleCode = parseFanavaranId( vehicle.PlaqueMiddleCodeId ?? vehicle.plaqueMiddleCodeId, ); if (inputCode !== null && vehicleCode !== null) { return inputCode === vehicleCode; } return false; } export function plaqueMatchesVehicle( plaque: FanavaranPlaqueParts, vehicle: Record | null, ): boolean { if (!vehicle) return false; return ( plaqueNumberEquals(plaque.left, vehicle.PlaqueLeftNo ?? vehicle.plaqueLeftNo) && plaqueLetterEquals(plaque.letter, vehicle) && plaqueNumberEquals( plaque.right, vehicle.PlaqueRightNo ?? vehicle.plaqueRightNo, ) && plaqueNumberEquals( plaque.serial, vehicle.PlaqueSerial ?? vehicle.plaqueSerial, ) ); } export function vehicleMatchesCar( vehicle: Record | null, input: FanavaranCarMatchInput, vinVehicleId?: number | null, ): boolean { const vin = normalizeVin(input.vin); if (vin) { const vehicleId = pickVehicleId(vehicle); if ( vinVehicleId != null && vehicleId != null && vinVehicleId === vehicleId ) { return true; } return pickVehicleVin(vehicle) === vin; } if (input.plaque) { return plaqueMatchesVehicle(input.plaque, vehicle); } return false; } export function isPolicyActiveOn( policy: { beginDate?: unknown; endDate?: unknown }, todayGregorian: string, ): boolean { const begin = toGregorianDate(policy.beginDate); const end = toGregorianDate(policy.endDate); if (!end) return false; if (end < todayGregorian) return false; if (begin && begin > todayGregorian) return false; return true; } /** * Same car, several renewals: keep that car only. * Active (EndDate overlaps today) → newest BeginDate. * Else → latest EndDate. */ export function selectLastAmongCarMatches( matches: HydratedFanavaranCarPolicy[], todayGregorian: string = gregorianDateInIran(new Date()), ): HydratedFanavaranCarPolicy | null { if (matches.length === 0) return null; const active = matches.filter((match) => isPolicyActiveOn(match, todayGregorian)); const pool = active.length > 0 ? active : matches; const useBeginDate = active.length > 0; return pool.reduce((current, candidate) => { const currentKey = useBeginDate ? toGregorianDate(current.beginDate) ?? "" : toGregorianDate(current.endDate) ?? ""; const candidateKey = useBeginDate ? toGregorianDate(candidate.beginDate) ?? "" : toGregorianDate(candidate.endDate) ?? ""; return candidateKey > currentKey ? candidate : current; }); } export function completePlaqueParts(input: { plaqueLeft?: string; plaqueLetter?: string; plaqueRight?: string; plaqueSerial?: string; }): FanavaranPlaqueParts | null { const left = normalizePlaquePart(input.plaqueLeft); const letter = normalizePlateText(toEnglishDigits(input.plaqueLetter).trim()); const right = normalizePlaquePart(input.plaqueRight); const serial = normalizePlaquePart(input.plaqueSerial); if (!left || !letter || !right || !serial) { return null; } return { left, letter, right, serial }; } export function parseLastCarPolicyInput(query: { nationalCode?: string; vin?: string; plaqueLeft?: string; plaqueLetter?: string; plaqueRight?: string; plaqueSerial?: string; }): { nationalCode: string; vin?: string; plaque?: FanavaranPlaqueParts; } | { error: string } { const nationalCode = toEnglishDigits(query.nationalCode).replace(/\D/g, ""); if (!nationalCode) { return { error: "nationalCode is required" }; } const vin = normalizeVin(query.vin); const plaque = completePlaqueParts(query); const anyPlaquePart = [ query.plaqueLeft, query.plaqueLetter, query.plaqueRight, query.plaqueSerial, ].some((part) => String(part ?? "").trim() !== ""); if (!vin && !plaque) { if (anyPlaquePart) { return { error: "plaque requires all four parts: plaqueLeft, plaqueLetter, plaqueRight, plaqueSerial", }; } return { error: "vin and/or a complete plaque is required" }; } return { nationalCode, ...(vin ? { vin } : {}), ...(plaque ? { plaque } : {}), }; }