From eb636bdbdd13a3e01ee24b2bb96d7b59b34f905d Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Sat, 5 Sep 2026 18:37:42 +0330 Subject: [PATCH] car body policy and sales extended --- src/core/config/fanavaran-client.config.ts | 18 + src/fanavaran/fanavaran-auth.service.ts | 5 +- .../fanavaran-client-config.service.ts | 3 + src/fanavaran/fanavaran-lookup.service.ts | 26 +- .../schema/fanavaran-client-config.schema.ts | 4 + src/lookups/fanavaran-last-car-policy.spec.ts | 194 +++++++++ src/lookups/fanavaran-last-car-policy.ts | 375 ++++++++++++++++++ src/lookups/lookups.controller.ts | 68 ++++ .../lookups.service.last-car-policy.spec.ts | 203 ++++++++++ src/lookups/lookups.service.ts | 215 +++++++++- 10 files changed, 1097 insertions(+), 14 deletions(-) create mode 100644 src/lookups/fanavaran-last-car-policy.spec.ts create mode 100644 src/lookups/fanavaran-last-car-policy.ts create mode 100644 src/lookups/lookups.service.last-car-policy.spec.ts diff --git a/src/core/config/fanavaran-client.config.ts b/src/core/config/fanavaran-client.config.ts index 9a0aa09..c9403fc 100644 --- a/src/core/config/fanavaran-client.config.ts +++ b/src/core/config/fanavaran-client.config.ts @@ -35,6 +35,11 @@ export interface FanavaranAuthConfig { password: string; corpId: string; contractId: string; + /** + * Vehicle-hull (بدنه) ContractId. Must not be the ثالث contract. + * When unset, body lookups fall back to `contractId`. + */ + hullContractId?: string; location: string; } @@ -209,6 +214,19 @@ export function getFanavaranClientProfile( ); } +/** ثالث uses `contractId`. بدنه uses `hullContractId` when configured. */ +export function resolveFanavaranProductContractId( + clientKey: FanavaranClientKey, + product: "third-party" | "car-body", +): string { + const profile = getFanavaranClientProfile(clientKey); + if (product === "car-body") { + const hull = profile.auth.hullContractId?.trim(); + if (hull) return hull; + } + return profile.auth.contractId; +} + export function listFanavaranClientProfiles(): FanavaranClientProfile[] { return FANAVARAN_CLIENT_KEYS.map((key) => getFanavaranClientProfile(key)); } diff --git a/src/fanavaran/fanavaran-auth.service.ts b/src/fanavaran/fanavaran-auth.service.ts index ef45f63..38ea0ec 100644 --- a/src/fanavaran/fanavaran-auth.service.ts +++ b/src/fanavaran/fanavaran-auth.service.ts @@ -251,6 +251,8 @@ export class FanavaranAuthService { forceRefresh?: boolean; /** Per-request Location override (does not affect token fingerprint). */ locationOverride?: string; + /** Per-request ContractId override (بدنه vs ثالث). Does not affect token fingerprint. */ + contractIdOverride?: string; }, ): Promise<{ authenticationToken: string; @@ -264,10 +266,11 @@ export class FanavaranAuthService { options, ); const locationOverride = options?.locationOverride?.trim(); + const contractIdOverride = options?.contractIdOverride?.trim(); return { authenticationToken, CorpId: profile.auth.corpId, - ContractId: profile.auth.contractId, + ContractId: contractIdOverride || profile.auth.contractId, Location: locationOverride || profile.auth.location, }; } diff --git a/src/fanavaran/fanavaran-client-config.service.ts b/src/fanavaran/fanavaran-client-config.service.ts index 9f43fec..3cc49d2 100644 --- a/src/fanavaran/fanavaran-client-config.service.ts +++ b/src/fanavaran/fanavaran-client-config.service.ts @@ -95,6 +95,9 @@ export class FanavaranClientConfigService implements OnModuleInit { password: doc.auth.password, corpId: doc.auth.corpId, contractId: doc.auth.contractId, + ...(doc.auth.hullContractId + ? { hullContractId: doc.auth.hullContractId } + : {}), location: doc.auth.location, }, defaults: { ...doc.defaults }, diff --git a/src/fanavaran/fanavaran-lookup.service.ts b/src/fanavaran/fanavaran-lookup.service.ts index cb4063f..a6e6263 100644 --- a/src/fanavaran/fanavaran-lookup.service.ts +++ b/src/fanavaran/fanavaran-lookup.service.ts @@ -78,9 +78,13 @@ export class FanavaranLookupService { async fetchFromFanavaran( clientKey: FanavaranClientKey, url: string, + options?: { contractIdOverride?: string }, ): Promise { try { - const headers = await this.fanavaranAuthService.getRequestHeaders(clientKey); + const headers = await this.fanavaranAuthService.getRequestHeaders( + clientKey, + { contractIdOverride: options?.contractIdOverride }, + ); this.logger.log( `[${clientKey}] Calling Fanavaran lookup API: ${url}`, @@ -236,46 +240,52 @@ export class FanavaranLookupService { async inquiryByVin( clientKey: FanavaranClientKey, vin: string, + options?: { contractIdOverride?: string }, ): Promise { const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicles/inquiry-by-vin?vin=${encodeURIComponent(vin)}`; - return this.fetchFromFanavaran(clientKey, url); + return this.fetchFromFanavaran(clientKey, url, options); } async myPolicies( clientKey: FanavaranClientKey, nationalCode: string, insuranceLineId: number = 5, + options?: { contractIdOverride?: string }, ): Promise { const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/Policies/inquiry-my-policies` + `?InsuranceLineId=${insuranceLineId}` + `&NationalCode=${encodeURIComponent(nationalCode)}`; - return this.fetchFromFanavaran(clientKey, url); + return this.fetchFromFanavaran(clientKey, url, options); } async thirdPartyPolicyById( clientKey: FanavaranClientKey, policyId: number, + options?: { contractIdOverride?: string }, ): Promise { const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/third-party-car-policies/${policyId}`; - return this.fetchFromFanavaran(clientKey, url); + return this.fetchFromFanavaran(clientKey, url, options); } async bodyPolicyById( clientKey: FanavaranClientKey, policyId: number, + options?: { contractIdOverride?: string }, ): Promise { const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicle-hull-policies/${policyId}`; - return this.fetchFromFanavaran(clientKey, url); + return this.fetchFromFanavaran(clientKey, url, options); } async vehicleById( clientKey: FanavaranClientKey, vehicleId: number, - versionNo: number = 1, + versionNo?: number, + options?: { contractIdOverride?: string }, ): Promise { - const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicles/${vehicleId}?versionno=${versionNo}`; - return this.fetchFromFanavaran(clientKey, url); + const query = versionNo == null ? "" : `?versionno=${versionNo}`; + const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicles/${vehicleId}${query}`; + return this.fetchFromFanavaran(clientKey, url, options); } async customerById( diff --git a/src/fanavaran/schema/fanavaran-client-config.schema.ts b/src/fanavaran/schema/fanavaran-client-config.schema.ts index b0a2ff3..6abccb5 100644 --- a/src/fanavaran/schema/fanavaran-client-config.schema.ts +++ b/src/fanavaran/schema/fanavaran-client-config.schema.ts @@ -22,6 +22,10 @@ export class FanavaranAuthConfigEmbed { @Prop({ type: String, required: true }) contractId: string; + /** Vehicle-hull (بدنه) ContractId. Optional until the tenant hull line is configured. */ + @Prop({ type: String, required: false }) + hullContractId?: string; + @Prop({ type: String, required: true }) location: string; } diff --git a/src/lookups/fanavaran-last-car-policy.spec.ts b/src/lookups/fanavaran-last-car-policy.spec.ts new file mode 100644 index 0000000..cbeded5 --- /dev/null +++ b/src/lookups/fanavaran-last-car-policy.spec.ts @@ -0,0 +1,194 @@ +import { + filterPoliciesByLine, + parseLastCarPolicyInput, + pickVehicleId, + plaqueMatchesVehicle, + selectLastAmongCarMatches, + vehicleMatchesCar, +} from "./fanavaran-last-car-policy"; + +describe("fanavaran last car policy", () => { + const today = "2026-09-05"; + + it("parses national code and prefers VIN when both identifiers are sent", () => { + const parsed = parseLastCarPolicyInput({ + nationalCode: "۰۰۱۲۳۴۵۶۷۸", + vin: " irnkaek4150012345 ", + plaqueLeft: "12", + plaqueLetter: "ب", + plaqueRight: "345", + plaqueSerial: "67", + }); + + expect(parsed).toEqual({ + nationalCode: "0012345678", + vin: "IRNKAEK4150012345", + plaque: { left: "12", letter: "ب", right: "345", serial: "67" }, + }); + }); + + it("rejects a partial plaque when VIN is missing", () => { + const parsed = parseLastCarPolicyInput({ + nationalCode: "0012345678", + plaqueLeft: "12", + plaqueLetter: "ب", + }); + + expect(parsed).toEqual({ + error: + "plaque requires all four parts: plaqueLeft, plaqueLetter, plaqueRight, plaqueSerial", + }); + }); + + it("keeps only the requested insurance line", () => { + const rows = filterPoliciesByLine( + [ + { PolicyId: 1, InsuranceLineId: 5, EndDate: "1405/01/01" }, + { PolicyId: 2, InsuranceLineId: 4, EndDate: "1405/01/01" }, + { PolicyId: 3, EndDate: "1405/01/01" }, + ], + 5, + ); + + expect(rows.map((row) => row.PolicyId)).toEqual([1, 3]); + }); + + it("reads VehicleId from a one-item inquiry-by-vin array", () => { + expect(pickVehicleId([{ Id: 4118289, VIN: "NAAR03HFFRDE07024" }])).toBe( + 4118289, + ); + }); + + it("matches VIN against ChassisNo when VIN is empty", () => { + expect( + vehicleMatchesCar( + { VIN: null, ChassisNo: "NAAR03HFFRDE07024" }, + { vin: "NAAR03HFFRDE07024" }, + ), + ).toBe(true); + }); + + it("matches by VIN-inquiry VehicleId when GEN.15 VIN is empty", () => { + expect( + vehicleMatchesCar( + { Id: 4118289, VIN: null, ChassisNo: "RDE07024" }, + { vin: "NAAR03HFFRDE07024" }, + 4118289, + ), + ).toBe(true); + }); + + it("matches VIN trim and case-insensitively", () => { + expect( + vehicleMatchesCar( + { VIN: " irnKAEK4150012345 " }, + { vin: "IRNKAEK4150012345" }, + ), + ).toBe(true); + }); + + it("matches plaque by all four parts including letter code", () => { + expect( + plaqueMatchesVehicle( + { left: "12", letter: "ب", right: "345", serial: "67" }, + { + PlaqueLeftNo: "12", + PlaqueMiddleCodeId: 2, + PlaqueRightNo: "345", + PlaqueSerial: "67", + }, + ), + ).toBe(true); + }); + + it("lets VIN win when both VIN and plaque are sent", () => { + expect( + vehicleMatchesCar( + { + VIN: "IRNKAEK4150012345", + PlaqueLeftNo: "99", + PlaqueMiddleCodeId: 1, + PlaqueRightNo: "111", + PlaqueSerial: "22", + }, + { + vin: "IRNKAEK4150012345", + plaque: { left: "12", letter: "ب", right: "345", serial: "67" }, + }, + ), + ).toBe(true); + }); + + it("does not match plaque when any part differs", () => { + expect( + plaqueMatchesVehicle( + { left: "12", letter: "ب", right: "345", serial: "67" }, + { + PlaqueLeftNo: "12", + PlaqueMiddleCodeId: 2, + PlaqueRightNo: "345", + PlaqueSerial: "68", + }, + ), + ).toBe(false); + }); + + it("picks the active renewal with the newest BeginDate", () => { + const selected = selectLastAmongCarMatches( + [ + { + policyId: 10, + beginDate: "1404/06/01", + endDate: "1405/06/01", + policy: {}, + vehicle: null, + }, + { + policyId: 20, + beginDate: "1405/06/02", + endDate: "1406/06/01", + policy: {}, + vehicle: null, + }, + { + policyId: 30, + beginDate: "1403/01/01", + endDate: "1404/01/01", + policy: {}, + vehicle: null, + }, + ], + today, + ); + + expect(selected?.policyId).toBe(20); + }); + + it("falls back to the latest EndDate when no match is active", () => { + const selected = selectLastAmongCarMatches( + [ + { + policyId: 10, + beginDate: "1402/01/01", + endDate: "1403/01/01", + policy: {}, + vehicle: null, + }, + { + policyId: 20, + beginDate: "1403/01/01", + endDate: "1404/06/01", + policy: {}, + vehicle: null, + }, + ], + today, + ); + + expect(selected?.policyId).toBe(20); + }); + + it("returns null when there are no matches", () => { + expect(selectLastAmongCarMatches([], today)).toBeNull(); + }); +}); diff --git a/src/lookups/fanavaran-last-car-policy.ts b/src/lookups/fanavaran-last-car-policy.ts new file mode 100644 index 0000000..74d6707 --- /dev/null +++ b/src/lookups/fanavaran-last-car-policy.ts @@ -0,0 +1,375 @@ +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 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; +} + +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; +} + +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 } : {}), + }; +} diff --git a/src/lookups/lookups.controller.ts b/src/lookups/lookups.controller.ts index 700a1fe..ef58435 100644 --- a/src/lookups/lookups.controller.ts +++ b/src/lookups/lookups.controller.ts @@ -520,6 +520,74 @@ export class LookupsController { ); } + @Get("processed-third-party-policy") + @ApiOperation({ + summary: "Last processed third-party policy for a car", + description: + "Lists Fanavaran policies by national code (InsuranceLineId=5), hydrates each with the third-party policy GET and vehicle GET, matches VIN (preferred) and/or the four plaque parts, then returns that car's last policy. VIN/plaque never open the policy list.", + }) + @ApiQuery({ name: "nationalCode", example: "0012345678" }) + @ApiQuery({ name: "vin", required: false, example: "IRNKAEK4150012345" }) + @ApiQuery({ name: "plaqueLeft", required: false, example: "12" }) + @ApiQuery({ name: "plaqueLetter", required: false, example: "ب" }) + @ApiQuery({ name: "plaqueRight", required: false, example: "345" }) + @ApiQuery({ name: "plaqueSerial", required: false, example: "67" }) + @ApiOkResponse({ + description: "Mapped last third-party policy plus vehicle", + schema: { type: "object" }, + }) + async processedThirdPartyPolicy( + @Query("nationalCode") nationalCode?: string, + @Query("vin") vin?: string, + @Query("plaqueLeft") plaqueLeft?: string, + @Query("plaqueLetter") plaqueLetter?: string, + @Query("plaqueRight") plaqueRight?: string, + @Query("plaqueSerial") plaqueSerial?: string, + ) { + return this.lookupsService.findLastProcessedCarPolicy("third-party", { + nationalCode, + vin, + plaqueLeft, + plaqueLetter, + plaqueRight, + plaqueSerial, + }); + } + + @Get("processed-body-policy") + @ApiOperation({ + summary: "Last processed car-body (hull) policy for a car", + description: + "Same car-matching flow as processed-third-party-policy, using InsuranceLineId=4 and the hull policy GET. Uses hull ContractId when configured.", + }) + @ApiQuery({ name: "nationalCode", example: "0012345678" }) + @ApiQuery({ name: "vin", required: false, example: "IRNKAEK4150012345" }) + @ApiQuery({ name: "plaqueLeft", required: false, example: "12" }) + @ApiQuery({ name: "plaqueLetter", required: false, example: "ب" }) + @ApiQuery({ name: "plaqueRight", required: false, example: "345" }) + @ApiQuery({ name: "plaqueSerial", required: false, example: "67" }) + @ApiOkResponse({ + description: "Mapped last body policy plus vehicle", + schema: { type: "object" }, + }) + async processedBodyPolicy( + @Query("nationalCode") nationalCode?: string, + @Query("vin") vin?: string, + @Query("plaqueLeft") plaqueLeft?: string, + @Query("plaqueLetter") plaqueLetter?: string, + @Query("plaqueRight") plaqueRight?: string, + @Query("plaqueSerial") plaqueSerial?: string, + ) { + return this.lookupsService.findLastProcessedCarPolicy("car-body", { + nationalCode, + vin, + plaqueLeft, + plaqueLetter, + plaqueRight, + plaqueSerial, + }); + } + @Get("third-party-policy/:policyId") @ApiOperation({ summary: "Third-party car policy inquiry by policy ID", diff --git a/src/lookups/lookups.service.last-car-policy.spec.ts b/src/lookups/lookups.service.last-car-policy.spec.ts new file mode 100644 index 0000000..5ee56e9 --- /dev/null +++ b/src/lookups/lookups.service.last-car-policy.spec.ts @@ -0,0 +1,203 @@ +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { LookupsService } from "./lookups.service"; + +describe("LookupsService.findLastProcessedCarPolicy", () => { + const originalClient = process.env.FANAVARAN_CLIENT; + + beforeAll(() => { + process.env.FANAVARAN_CLIENT = "parsian"; + }); + + afterAll(() => { + process.env.FANAVARAN_CLIENT = originalClient; + }); + + function createService(lookup: Record) { + return new LookupsService( + {} as any, + lookup as any, + ); + } + + it("returns the last matching third-party policy and its vehicle", async () => { + const lookup = { + myPolicies: jest.fn().mockResolvedValue([ + { + PolicyId: 10, + InsuranceLineId: 5, + BeginDate: "1403/01/01", + EndDate: "1404/01/01", + }, + { + PolicyId: 20, + InsuranceLineId: 5, + BeginDate: "1405/01/01", + EndDate: "1406/01/01", + }, + { + PolicyId: 99, + InsuranceLineId: 4, + BeginDate: "1405/01/01", + EndDate: "1406/01/01", + }, + ]), + inquiryByVin: jest.fn().mockResolvedValue([{ Id: 500, VIN: "IRNKAEK4150012345" }]), + thirdPartyPolicyById: jest.fn().mockImplementation((_key, policyId) => + Promise.resolve({ + PolicyId: policyId, + VehicleId: policyId === 20 ? 500 : 700, + BeginDate: policyId === 20 ? "1405/01/01" : "1403/01/01", + EndDate: policyId === 20 ? "1406/01/01" : "1404/01/01", + PreviousInsuranceCorpId: 1, + }), + ), + vehicleById: jest.fn().mockImplementation((_key, vehicleId) => + Promise.resolve({ + Id: vehicleId, + VIN: vehicleId === 500 ? "IRNKAEK4150012345" : "OTHERVIN000000000", + }), + ), + getRemoteLookup: jest.fn().mockResolvedValue([]), + }; + + const service = createService(lookup); + const result = await service.findLastProcessedCarPolicy("third-party", { + nationalCode: "0012345678", + vin: "IRNKAEK4150012345", + }); + + expect(result.policyId).toBe(20); + expect(result.product).toBe("third-party"); + expect(result.insuranceLineId).toBe(5); + expect(result.vehicle).toEqual({ + Id: 500, + VIN: "IRNKAEK4150012345", + }); + expect(lookup.thirdPartyPolicyById).toHaveBeenCalled(); + expect(lookup.myPolicies.mock.calls[0][2]).toBe(5); + }); + + it("uses the hull policy GET for car-body", async () => { + const lookup = { + myPolicies: jest.fn().mockResolvedValue([ + { + PolicyId: 44, + InsuranceLineId: 4, + BeginDate: "1405/01/01", + EndDate: "1406/01/01", + }, + ]), + inquiryByVin: jest.fn(), + bodyPolicyById: jest.fn().mockResolvedValue({ + PolicyId: 44, + VehicleId: 8, + BeginDate: "1405/01/01", + EndDate: "1406/01/01", + }), + thirdPartyPolicyById: jest.fn(), + vehicleById: jest.fn().mockResolvedValue({ + Id: 8, + PlaqueLeftNo: "12", + PlaqueMiddleCodeId: 2, + PlaqueRightNo: "345", + PlaqueSerial: "67", + }), + getRemoteLookup: jest.fn().mockResolvedValue([]), + }; + + const service = createService(lookup); + const result = await service.findLastProcessedCarPolicy("car-body", { + nationalCode: "0012345678", + plaqueLeft: "12", + plaqueLetter: "ب", + plaqueRight: "345", + plaqueSerial: "67", + }); + + expect(result.policyId).toBe(44); + expect(result.product).toBe("car-body"); + expect(lookup.bodyPolicyById).toHaveBeenCalled(); + expect(lookup.thirdPartyPolicyById).not.toHaveBeenCalled(); + expect(lookup.inquiryByVin).not.toHaveBeenCalled(); + }); + + it("accepts the policy when VIN inquiry VehicleId matches and GEN.15 VIN is empty", async () => { + const lookup = { + myPolicies: jest.fn().mockResolvedValue([ + { + PolicyId: 15292336, + InsuranceLineId: 4, + BeginDate: "1405/01/01", + EndDate: "1406/01/01", + }, + ]), + inquiryByVin: jest.fn().mockResolvedValue([ + { Id: 4118289, VIN: "NAAR03HFFRDE07024" }, + ]), + bodyPolicyById: jest.fn().mockResolvedValue({ + PolicyId: 15292336, + VehicleId: 4118289, + BeginDate: "1405/01/01", + EndDate: "1406/01/01", + }), + thirdPartyPolicyById: jest.fn(), + vehicleById: jest.fn().mockResolvedValue({ + Id: 4118289, + VIN: null, + ChassisNo: "RDE07024", + }), + getRemoteLookup: jest.fn().mockResolvedValue([]), + }; + + const service = createService(lookup); + const result = await service.findLastProcessedCarPolicy("car-body", { + nationalCode: "4311402422", + vin: "NAAR03HFFRDE07024", + }); + + expect(result.policyId).toBe(15292336); + expect(lookup.vehicleById.mock.calls[0][2]).toBeUndefined(); + }); + + it("does not invent a search when the car does not match", async () => { + const lookup = { + myPolicies: jest.fn().mockResolvedValue([ + { + PolicyId: 10, + InsuranceLineId: 5, + BeginDate: "1405/01/01", + EndDate: "1406/01/01", + }, + ]), + inquiryByVin: jest.fn().mockResolvedValue([{ Id: 99, VIN: "IRNKAEK4150012345" }]), + thirdPartyPolicyById: jest.fn().mockResolvedValue({ + PolicyId: 10, + VehicleId: 1, + BeginDate: "1405/01/01", + EndDate: "1406/01/01", + }), + vehicleById: jest.fn().mockResolvedValue({ + Id: 1, + VIN: "DIFFERENTVIN00001", + }), + getRemoteLookup: jest.fn().mockResolvedValue([]), + }; + + const service = createService(lookup); + await expect( + service.findLastProcessedCarPolicy("third-party", { + nationalCode: "0012345678", + vin: "IRNKAEK4150012345", + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it("rejects missing car identifiers", async () => { + const service = createService({}); + await expect( + service.findLastProcessedCarPolicy("third-party", { + nationalCode: "0012345678", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/src/lookups/lookups.service.ts b/src/lookups/lookups.service.ts index 0115f2f..0ae83d3 100644 --- a/src/lookups/lookups.service.ts +++ b/src/lookups/lookups.service.ts @@ -1,5 +1,13 @@ -import { Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { resolveFanavaranClientKey } from "src/core/config/fanavaran-client.config"; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { + resolveFanavaranClientKey, + resolveFanavaranProductContractId, +} from "src/core/config/fanavaran-client.config"; import { FANAVARAN_REMOTE_LOOKUPS, type FanavaranRemoteLookupDefinition, @@ -7,6 +15,19 @@ import { } from "src/fanavaran/fanavaran-lookup.config"; import { FanavaranLookupService } from "src/fanavaran/fanavaran-lookup.service"; import { LookupDbService } from "./entities/db-service/lookup.db.service"; +import { + asObjectRecord, + filterPoliciesByLine, + insuranceLineIdForProduct, + parseFanavaranId, + parseLastCarPolicyInput, + pickVehicleId, + selectLastAmongCarMatches, + sortPoliciesNewestEndDateFirst, + vehicleMatchesCar, + type FanavaranCarPolicyProduct, + type HydratedFanavaranCarPolicy, +} from "./fanavaran-last-car-policy"; type TejaratAccidentReasonRow = { id: number; @@ -170,6 +191,177 @@ export class LookupsService { ); } + async findLastProcessedCarPolicy( + product: FanavaranCarPolicyProduct, + query: { + nationalCode?: string; + vin?: string; + plaqueLeft?: string; + plaqueLetter?: string; + plaqueRight?: string; + plaqueSerial?: string; + }, + ): Promise<{ + product: FanavaranCarPolicyProduct; + insuranceLineId: number; + policyId: number; + policy: any; + vehicle: Record | null; + }> { + const parsed = parseLastCarPolicyInput(query); + if ("error" in parsed) { + throw new BadRequestException(parsed.error); + } + + const clientKey = this.activeClientKey(); + const insuranceLineId = insuranceLineIdForProduct(product); + const contractIdOverride = resolveFanavaranProductContractId( + clientKey, + product, + ); + const requestOptions = { contractIdOverride }; + + const listed = await this.fanavaranLookupService.myPolicies( + clientKey, + parsed.nationalCode, + insuranceLineId, + requestOptions, + ); + const candidates = sortPoliciesNewestEndDateFirst( + filterPoliciesByLine(listed, insuranceLineId), + ); + + let vinVehicleId: number | null = null; + if (parsed.vin) { + try { + const inquired = await this.fanavaranLookupService.inquiryByVin( + clientKey, + parsed.vin, + requestOptions, + ); + vinVehicleId = pickVehicleId(inquired); + } catch (error) { + this.logger.warn( + `VIN vehicle helper failed; continuing with policy hydrate: ${ + error instanceof Error ? error.message : error + }`, + ); + } + } + + const matches: HydratedFanavaranCarPolicy[] = []; + let hydrationFailures = 0; + + for (const row of candidates) { + const policyId = parseFanavaranId(row.PolicyId); + if (policyId === null) continue; + + let policyRaw: unknown; + try { + policyRaw = + product === "car-body" + ? await this.fanavaranLookupService.bodyPolicyById( + clientKey, + policyId, + requestOptions, + ) + : await this.fanavaranLookupService.thirdPartyPolicyById( + clientKey, + policyId, + requestOptions, + ); + } catch (error) { + hydrationFailures += 1; + this.logger.warn( + `Policy GET failed for PolicyId=${policyId}: ${ + error instanceof Error ? error.message : error + }`, + ); + continue; + } + + const policy = asObjectRecord(policyRaw); + if (!policy) continue; + + const vehicleId = pickVehicleId(policy.VehicleId ?? policy.vehicleId); + if ( + parsed.vin && + vinVehicleId !== null && + vehicleId !== null && + vehicleId !== vinVehicleId + ) { + continue; + } + + let vehicle: Record | null = null; + if (vehicleId !== null) { + try { + vehicle = asObjectRecord( + await this.fanavaranLookupService.vehicleById( + clientKey, + vehicleId, + undefined, + requestOptions, + ), + ); + } catch (error) { + this.logger.warn( + `Vehicle GET failed for VehicleId=${vehicleId} (PolicyId=${policyId}): ${ + error instanceof Error ? error.message : error + }`, + ); + } + } + + const matchedByVinVehicleId = + parsed.vin != null && + vinVehicleId != null && + vehicleId != null && + vinVehicleId === vehicleId; + + if ( + !matchedByVinVehicleId && + !vehicleMatchesCar(vehicle, parsed, vinVehicleId) + ) { + continue; + } + + matches.push({ + policyId, + beginDate: policy.BeginDate ?? row.BeginDate, + endDate: policy.EndDate ?? row.EndDate, + vehicleId, + policy, + vehicle, + }); + } + + const selected = selectLastAmongCarMatches(matches); + if (!selected) { + if (candidates.length === 0) { + throw new NotFoundException( + `No Fanavaran ${product} policy was found for this national code and car.`, + ); + } + if (hydrationFailures === candidates.length) { + throw new NotFoundException( + `Fanavaran ${product} policy details could not be loaded for this national code.`, + ); + } + throw new NotFoundException( + `No Fanavaran ${product} policy of that line was found for this car.`, + ); + } + + return { + product, + insuranceLineId, + policyId: selected.policyId, + policy: await this.mapPolicyDetails(selected.policy), + vehicle: selected.vehicle, + }; + } + async mapPolicyDetails(policy: any): Promise { if (!policy || typeof policy !== "object") { return policy; @@ -222,13 +414,26 @@ export class LookupsService { async bodyPolicyById(policyId: number): Promise { const clientKey = this.activeClientKey(); - const policy = await this.fanavaranLookupService.bodyPolicyById(clientKey, policyId); + const policy = await this.fanavaranLookupService.bodyPolicyById( + clientKey, + policyId, + { + contractIdOverride: resolveFanavaranProductContractId( + clientKey, + "car-body", + ), + }, + ); return this.mapPolicyDetails(policy); } - async vehicleById(vehicleId: number, versionNo: number = 1): Promise { + async vehicleById(vehicleId: number, versionNo?: number): Promise { const clientKey = this.activeClientKey(); - return this.fanavaranLookupService.vehicleById(clientKey, vehicleId, versionNo); + return this.fanavaranLookupService.vehicleById( + clientKey, + vehicleId, + versionNo, + ); } async customerById(customerId: number): Promise {