From fc1c117b93b3f41118b781333a0c5c8b4c10b88c Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Sat, 26 Sep 2026 16:42:21 +0330 Subject: [PATCH] Map Fanavaran people and licences by party role; allow unlisted base-info lookups. Resolve driver, owner, and policyholder from participants when they differ, prefer real licence / driverLicenseDate over dummies, and fall back unknown /lookups/fanavaran/{name} to car/base-info/{name} (including vehicle-groups). Co-authored-by: Cursor --- .../claim-request-management.service.ts | 175 +++++++++++--- .../fanavaran-claim-product.spec.ts | 23 ++ .../fanavaran-claim-product.ts | 4 + .../fanavaran-driver-inquiry.ts | 45 +++- .../fanavaran-party-roles.spec.ts | 125 ++++++++++ .../fanavaran-party-roles.ts | 217 ++++++++++++++++++ src/fanavaran/fanavaran-lookup.config.ts | 33 +++ src/lookups/lookups.controller.ts | 9 +- src/lookups/lookups.service.ts | 3 +- 9 files changed, 590 insertions(+), 44 deletions(-) create mode 100644 src/claim-request-management/fanavaran-party-roles.spec.ts create mode 100644 src/claim-request-management/fanavaran-party-roles.ts diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 5ccb1ab..7f12196 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -262,7 +262,16 @@ import { pickPersonBirthday, pickPersonNationalCode, selectFanavaranDriverId, + selectFanavaranOwnerId, } from "./fanavaran-driver-inquiry"; +import { + resolveDriverFromParty, + resolveDriverIsOwner, + resolveFanavaranLicenceIssuDate, + resolveFanavaranLicenceNo, + resolveOwnerFromParty, + resolvePolicyholderFromParty, +} from "./fanavaran-party-roles"; import { buildFanavaranOtherPeoplePayload, pickFanavaranRecordId, @@ -3676,23 +3685,23 @@ export class ClaimRequestManagementService { /** * Resolve licence number to send to Fanavaran. * Preference order: - * - use the first non-empty value from DB (driverLicense/insurerLicense) - * - else use the configured 10-digit dummy + * - driver participant / person.driverLicense (and insurerLicense as legacy) + * - else the configured 10-digit dummy */ private resolveFanavaranLicenceNoFromParty(input: { driverIsInsurer?: boolean; driverLicense?: unknown; insurerLicense?: unknown; + /** Optional already-resolved digits from participants. */ + resolvedLicenseNo?: string | null; }): string { - const { driverIsInsurer, driverLicense, insurerLicense } = input; + const fromResolved = this.extractNonEmptyLicenceDigits( + input.resolvedLicenseNo, + ); + if (fromResolved) return fromResolved; - // When driverIsInsurer === true, UI usually mirrors insurer/driver values, - // but DB can still miss one of the two fields — prefer both. - const candidates = driverIsInsurer - ? [driverLicense, insurerLicense] - : [driverLicense, insurerLicense]; - - for (const c of candidates) { + const { driverLicense, insurerLicense } = input; + for (const c of [driverLicense, insurerLicense]) { const digits = this.extractNonEmptyLicenceDigits(c); if (digits) return digits; } @@ -4298,6 +4307,36 @@ export class ClaimRequestManagementService { } } + /** Best-effort OwnerId from parties inquiry (no GEN.44 create). */ + private async resolveOwnerFanavaranId( + clientKey: FanavaranClientKey, + nationalCode: string | null | undefined, + birthday: string | number | null | undefined, + ): Promise { + if (!nationalCode) return null; + const parsed = this.parseJalaliBirthday(birthday); + if (!parsed) return null; + try { + const response = await this.fanavaranLookupService.inquiryByUniqueIdentifier( + clientKey, + { + nationalCode, + birthYear: parsed.year, + birthMonth: parsed.month, + birthDay: parsed.day, + }, + ); + return selectFanavaranOwnerId(response); + } catch (error) { + this.logger.warn( + `[resolveOwnerFanavaranId] inquiry failed for nationalCode=${nationalCode}: ${ + error instanceof Error ? error.message : error + }`, + ); + return null; + } + } + /** GEN.03 hull: TotalPremium + parties inquiry (AccidentCulpritId) before hull field whitelist. */ private async enrichCarBodyHullBaseClaimPayload(input: { clientKey: FanavaranClientKey; @@ -4357,13 +4396,14 @@ export class ClaimRequestManagementService { } | undefined; + const driverIdentity = resolveDriverFromParty(input.firstParty as any); const nationalCode = + driverIdentity.nationalCode ?? pickPersonNationalCode(person) ?? pickCarBodyPolicyNationalCode( input.blameCase as { parties?: unknown[] }, ); - const birthday = pickPersonBirthday(person); - const driverIsInsurer = person?.driverIsInsurer ?? true; + const birthday = driverIdentity.birthday ?? pickPersonBirthday(person); let accidentCulpritId: number | null = null; if (nationalCode && birthday) { @@ -4371,7 +4411,7 @@ export class ClaimRequestManagementService { input.clientKey, nationalCode, birthday, - driverIsInsurer, + false, ); } if (accidentCulpritId == null) { @@ -4386,6 +4426,13 @@ export class ClaimRequestManagementService { input.payload.AccidentCulpritId = accidentCulpritId; } + input.payload.CulpritLicenceNo = resolveFanavaranLicenceNo( + driverIdentity, + FANAVARAN_DUMMY_LICENCE_NO, + ); + input.payload.CulpritLicenceIssuDate = + resolveFanavaranLicenceIssuDate(driverIdentity); + input.payload.CustomerFaultPercent = FANAVARAN_DEFAULT_HULL_CUSTOMER_FAULT_PERCENT; @@ -4619,6 +4666,9 @@ export class ClaimRequestManagementService { const person = damagedParty?.person ?? {}; const vehicle = damagedParty?.vehicle ?? {}; const insurance = damagedParty?.insurance ?? {}; + const driverIdentity = resolveDriverFromParty(damagedParty); + const ownerIdentity = resolveOwnerFromParty(damagedParty); + const driverIsOwner = resolveDriverIsOwner(damagedParty); const product = fanavaranClaimProductFromBlameType(input.blameCase?.type); const { mapped: inquiryMapped, raw: inquiryRawPayload } = fanavaranPartyInquirySources(product, damagedParty); @@ -4630,12 +4680,14 @@ export class ClaimRequestManagementService { this.logger.log( `[buildFanavaranDamageCasePayload] damagedParty resolved: userId=${damagedParty?.person?.userId ?? "NONE"}, ` + - `nationalCode=${pickPersonNationalCode(person) ?? "MISSING"}, ` + + `driverNationalCode=${driverIdentity.nationalCode ?? "MISSING"}, ` + + `ownerNationalCode=${ownerIdentity.nationalCode ?? "MISSING"}, ` + `nationalCodeOfDriver=${person.nationalCodeOfDriver ?? "MISSING"}, ` + `nationalCodeOfInsurer=${person.nationalCodeOfInsurer ?? "MISSING"}, ` + - `driverBirthday=${pickPersonBirthday(person) ?? "MISSING"}, ` + - `driverIsInsurer=${person.driverIsInsurer ?? "MISSING"}, ` + - `driverLicense=${person.driverLicense ?? "MISSING"}`, + `driverBirthday=${driverIdentity.birthday ?? "MISSING"}, ` + + `driverIsOwner=${driverIsOwner}, ` + + `driverLicense=${driverIdentity.licenseNo ?? person.driverLicense ?? "MISSING"}, ` + + `driverLicenseDate=${driverIdentity.licenseIssuDate ?? "MISSING"}`, ); if (!damagedParty) { this.logger.warn( @@ -4761,12 +4813,23 @@ export class ClaimRequestManagementService { if (vehicleKindId != null) vehicleKindSource = "car-type-lookup"; } - let driverFanavaranId = await this.resolveDriverFanavaranIdFromBlameCase( - input.clientKey, - input.blameCase?.parties ?? [], - person, - input.claimCase, - ); + let driverFanavaranId: number | null = null; + if (driverIdentity.nationalCode && driverIdentity.birthday) { + driverFanavaranId = await this.resolveDriverFanavaranId( + input.clientKey, + driverIdentity.nationalCode, + driverIdentity.birthday, + false, + ); + } + if (driverFanavaranId == null) { + driverFanavaranId = await this.resolveDriverFanavaranIdFromBlameCase( + input.clientKey, + input.blameCase?.parties ?? [], + person, + input.claimCase, + ); + } let otherPersonId = this.firstPositiveFanavaranId(cachedDamage.otherPersonId); if (driverFanavaranId) { @@ -4781,7 +4844,13 @@ export class ClaimRequestManagementService { } else if (input.registerMissingPerson) { const registered = await this.registerFanavaranOtherPerson({ clientKey: input.clientKey, - person, + person: { + ...person, + nationalCodeOfDriver: + driverIdentity.nationalCode ?? person.nationalCodeOfDriver, + driverBirthday: driverIdentity.birthday ?? person.driverBirthday, + driverIsInsurer: false, + }, claimCaseId: input.claimCase?._id ? String(input.claimCase._id) : undefined, @@ -4792,6 +4861,21 @@ export class ClaimRequestManagementService { } } + let ownerFanavaranId: number | null = null; + if ( + ownerIdentity.nationalCode && + ownerIdentity.nationalCode === driverIdentity.nationalCode && + driverFanavaranId != null + ) { + ownerFanavaranId = driverFanavaranId; + } else if (ownerIdentity.nationalCode && ownerIdentity.birthday) { + ownerFanavaranId = await this.resolveOwnerFanavaranId( + input.clientKey, + ownerIdentity.nationalCode, + ownerIdentity.birthday, + ); + } + if (driverFanavaranId && input.blameCase?._id && damagedParty) { const partyIndex = (input.blameCase.parties ?? []).findIndex( (p: any) => p === damagedParty || @@ -4844,7 +4928,7 @@ export class ClaimRequestManagementService { } this.logger.log( - `[buildFanavaranDamageCasePayload] Final DriverId=${driverFanavaranId ?? "NULL"} for nationalCode=${pickPersonNationalCode(person) ?? "MISSING"}, ` + + `[buildFanavaranDamageCasePayload] Final DriverId=${driverFanavaranId ?? "NULL"} OwnerId=${ownerFanavaranId ?? "NULL"} for driverNationalCode=${driverIdentity.nationalCode ?? "MISSING"}, ` + `PolicyNo=${policyNo ?? "NULL"}, PolicyCINumber=${policyCINumber ?? "NULL"}`, ); @@ -4866,16 +4950,17 @@ export class ClaimRequestManagementService { EstimateAmount: FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT, FaultPercent: input.defaults.FaultPercent, InsuranceCorpId: insuranceCorpId, - DriverIsOwner: person.driverIsInsurer ? 1 : input.defaults.DriverIsOwner, + DriverIsOwner: driverIsOwner ? 1 : 0, LicenceCityId: null, LicenceCountryId: null, LicenceForeignCityName: null, - LicenceIssuDate: person.driverBirthday ?? "1394/10/13", + LicenceIssuDate: resolveFanavaranLicenceIssuDate(driverIdentity), // Must never be empty/null; prefer stored DB value, else use dummy. LicenceNo: this.resolveFanavaranLicenceNoFromParty({ driverIsInsurer: person.driverIsInsurer, driverLicense: person.driverLicense, insurerLicense: person.insurerLicense, + resolvedLicenseNo: driverIdentity.licenseNo, }), LicenceTypeId: input.defaults.CulpritLicenceTypeId, MotorNo: @@ -4884,7 +4969,7 @@ export class ClaimRequestManagementService { "EngineNumberField", "mtrnum", ]) ?? (typeof apiVehicle?.MotorNo === "string" ? apiVehicle.MotorNo : null), - OwnerId: null, + OwnerId: ownerFanavaranId, PlaqueCityId: apiPlaque.cityId, PlaqueKindId: plaqueKindId, PlaqueLeftNo: @@ -5819,6 +5904,8 @@ export class ClaimRequestManagementService { parties: Array<{ role?: PartyRole; person?: { userId?: Types.ObjectId; nationalCodeOfInsurer?: string }; + participants?: Array>; + participantRoles?: Record; statement?: { admitsGuilt?: boolean }; }>, guiltyPartyId?: Types.ObjectId | string | null, @@ -5830,8 +5917,13 @@ export class ClaimRequestManagementService { const guiltyParty = parties.find( (party) => party.person?.userId?.toString() === guiltyPartyId.toString(), ); + if (!guiltyParty) return null; - return guiltyParty?.person?.nationalCodeOfInsurer ?? null; + return ( + resolvePolicyholderFromParty(guiltyParty, "third-party").nationalCode ?? + guiltyParty.person?.nationalCodeOfInsurer ?? + null + ); } private resolveGuiltyPartyIdV2( @@ -6110,6 +6202,31 @@ export class ClaimRequestManagementService { }, }); + // GEN.03 CulpritLicence*: guilty-party DRIVER for THIRD_PARTY, FIRST-party DRIVER for CAR_BODY. + { + const culpritParty = + blameCase.type === BlameRequestType.CAR_BODY + ? firstParty + : (() => { + const guiltyPartyId = this.resolveGuiltyPartyIdV2( + blameCase.parties ?? [], + expertDecision?.guiltyPartyId, + ); + return (blameCase.parties ?? []).find( + (party: any) => + String(party?.person?.userId ?? "") === + String(guiltyPartyId ?? ""), + ); + })(); + const culpritDriver = resolveDriverFromParty(culpritParty as any); + payload.CulpritLicenceNo = resolveFanavaranLicenceNo( + culpritDriver, + FANAVARAN_DUMMY_LICENCE_NO, + ); + payload.CulpritLicenceIssuDate = + resolveFanavaranLicenceIssuDate(culpritDriver); + } + const carBodyAccidentTime = ( firstParty as { statement?: { accidentTime?: string } } | undefined )?.statement?.accidentTime; diff --git a/src/claim-request-management/fanavaran-claim-product.spec.ts b/src/claim-request-management/fanavaran-claim-product.spec.ts index 60aa0c9..9cb7219 100644 --- a/src/claim-request-management/fanavaran-claim-product.spec.ts +++ b/src/claim-request-management/fanavaran-claim-product.spec.ts @@ -107,6 +107,29 @@ describe("fanavaran claim product", () => { expect(nationalCode).toBe("0012345678"); }); + it("prefers CAR_BODY_POLICYHOLDER participant over person.nationalCodeOfInsurer", () => { + const nationalCode = pickCarBodyPolicyNationalCode({ + parties: [ + { + role: PartyRole.FIRST, + person: { nationalCodeOfInsurer: "0012345678" }, + participants: [ + { + participantId: "CAR_BODY_POLICYHOLDER", + nationalCode: "0499370899", + birthday: "1368/05/20", + }, + ], + participantRoles: { + carBodyPolicyholder: "CAR_BODY_POLICYHOLDER", + }, + }, + ], + }); + + expect(nationalCode).toBe("0499370899"); + }); + it("flattens car-body inquiry fields onto the damage-case aliases", () => { const { mapped } = fanavaranPartyInquirySources("car-body", { insurance: { diff --git a/src/claim-request-management/fanavaran-claim-product.ts b/src/claim-request-management/fanavaran-claim-product.ts index 8f6d4b9..82e5113 100644 --- a/src/claim-request-management/fanavaran-claim-product.ts +++ b/src/claim-request-management/fanavaran-claim-product.ts @@ -7,6 +7,7 @@ import { parseFanavaranId, type FanavaranCarPolicyProduct, } from "src/lookups/fanavaran-last-car-policy"; +import { resolvePolicyholderFromParty } from "./fanavaran-party-roles"; export type FanavaranClaimProduct = FanavaranCarPolicyProduct; @@ -101,6 +102,8 @@ export function fanavaranInsuranceLineIdForProduct( type BlamePartyForCarBodyPolicy = { role?: string; person?: { nationalCodeOfInsurer?: unknown }; + participants?: Array>; + participantRoles?: Record; insurance?: { carBodyInsurance?: { policyId?: unknown; @@ -168,6 +171,7 @@ export function pickCarBodyPolicyNationalCode( ): string | null { const party = firstCarBodyParty(blame); return ( + resolvePolicyholderFromParty(party, "car-body").nationalCode ?? nonEmptyText(party?.person?.nationalCodeOfInsurer) ?? nonEmptyText(party?.insurance?.carBodyInsurance?.ownerNationalCode) ?? nonEmptyText(party?.insurance?.carBodyInsurance?.insurerNationalCode) diff --git a/src/claim-request-management/fanavaran-driver-inquiry.ts b/src/claim-request-management/fanavaran-driver-inquiry.ts index ad97453..1edcb14 100644 --- a/src/claim-request-management/fanavaran-driver-inquiry.ts +++ b/src/claim-request-management/fanavaran-driver-inquiry.ts @@ -34,6 +34,21 @@ export function asPartyInquiryRows(value: unknown): FanavaranPartyInquiryRow[] { return []; } +function selectFanavaranIdByRoles( + rows: unknown, + preferredRoles: number[], +): number | null { + const parties = asPartyInquiryRows(rows); + if (parties.length === 0) return null; + + for (const roleId of preferredRoles) { + const match = parties.find((row) => asPositiveId(row.RoleId) === roleId); + if (match) return asPositiveId(match.Id); + } + + return asPositiveId(parties[0].Id); +} + /** * DriverId for GEN.12 زیان‌دیده. Prefer insurer (161) or driver (166) * based on driverIsInsurer, then any returned party id. @@ -42,19 +57,27 @@ export function selectFanavaranDriverId( rows: unknown, driverIsInsurer: boolean | undefined, ): number | null { - const parties = asPartyInquiryRows(rows); - if (parties.length === 0) return null; - const preferredRoles = driverIsInsurer - ? [FANAVARAN_INSURER_ROLE_ID, FANAVARAN_DRIVER_ROLE_ID, FANAVARAN_PLAQUE_OWNER_ROLE_ID] - : [FANAVARAN_DRIVER_ROLE_ID, FANAVARAN_INSURER_ROLE_ID, FANAVARAN_PLAQUE_OWNER_ROLE_ID]; + ? [ + FANAVARAN_INSURER_ROLE_ID, + FANAVARAN_DRIVER_ROLE_ID, + FANAVARAN_PLAQUE_OWNER_ROLE_ID, + ] + : [ + FANAVARAN_DRIVER_ROLE_ID, + FANAVARAN_INSURER_ROLE_ID, + FANAVARAN_PLAQUE_OWNER_ROLE_ID, + ]; + return selectFanavaranIdByRoles(rows, preferredRoles); +} - for (const roleId of preferredRoles) { - const match = parties.find((row) => asPositiveId(row.RoleId) === roleId); - if (match) return asPositiveId(match.Id); - } - - return asPositiveId(parties[0].Id); +/** OwnerId: prefer plaque-owner role (163), then insurer / driver. */ +export function selectFanavaranOwnerId(rows: unknown): number | null { + return selectFanavaranIdByRoles(rows, [ + FANAVARAN_PLAQUE_OWNER_ROLE_ID, + FANAVARAN_INSURER_ROLE_ID, + FANAVARAN_DRIVER_ROLE_ID, + ]); } export function normalizeNationalCode(value: unknown): string | null { diff --git a/src/claim-request-management/fanavaran-party-roles.spec.ts b/src/claim-request-management/fanavaran-party-roles.spec.ts new file mode 100644 index 0000000..2924564 --- /dev/null +++ b/src/claim-request-management/fanavaran-party-roles.spec.ts @@ -0,0 +1,125 @@ +import { InquiryParticipantRole } from "src/common/dto/inquiry-participants.dto"; +import { + FANAVARAN_DUMMY_LICENCE_ISSU_DATE, + formatFanavaranLicenceIssuDate, + resolveDriverFromParty, + resolveDriverIsOwner, + resolveFanavaranLicenceIssuDate, + resolveFanavaranLicenceNo, + resolveOwnerFromParty, + resolvePolicyholderFromParty, +} from "./fanavaran-party-roles"; + +describe("fanavaran-party-roles", () => { + const partyWithDistinctRoles = { + participants: [ + { + participantId: "DRIVER", + nationalCode: "0012345678", + birthday: "1370/01/15", + licenseNumber: "1234567890", + licenseType: "1", + driverLicenseDate: "1398/06/01", + }, + { + participantId: "VEHICLE_OWNER", + nationalCode: "0499370899", + birthday: "1368/05/20", + }, + { + participantId: "THIRD_PARTY_POLICYHOLDER", + nationalCode: "0080086519", + birthday: "1364/06/28", + }, + ], + participantRoles: { + driver: "DRIVER", + vehicleOwner: "VEHICLE_OWNER", + thirdPartyPolicyholder: "THIRD_PARTY_POLICYHOLDER", + }, + person: { + nationalCodeOfDriver: "0012345678", + nationalCodeOfInsurer: "0080086519", + driverLicense: "1234567890", + driverIsInsurer: false, + }, + }; + + it("resolves driver licence and driverLicenseDate from participants", () => { + const driver = resolveDriverFromParty(partyWithDistinctRoles); + expect(driver.nationalCode).toBe("0012345678"); + expect(driver.licenseNo).toBe("1234567890"); + expect(driver.licenseIssuDate).toBe("1398/06/01"); + expect(resolveFanavaranLicenceNo(driver, "9705463515")).toBe("1234567890"); + expect(resolveFanavaranLicenceIssuDate(driver)).toBe("1398/06/01"); + }); + + it("falls back to dummy licence issue date when driverLicenseDate is missing", () => { + const driver = resolveDriverFromParty({ + participants: [ + { + participantId: InquiryParticipantRole.DRIVER, + nationalCode: "0012345678", + birthday: "1370/01/15", + licenseNumber: "999", + }, + ], + participantRoles: { driver: InquiryParticipantRole.DRIVER }, + }); + expect(driver.licenseIssuDate).toBeNull(); + expect(resolveFanavaranLicenceIssuDate(driver)).toBe( + FANAVARAN_DUMMY_LICENCE_ISSU_DATE, + ); + }); + + it("marks DriverIsOwner false when driver and owner differ", () => { + expect(resolveDriverIsOwner(partyWithDistinctRoles)).toBe(false); + }); + + it("marks DriverIsOwner true when driver and owner share a national code", () => { + expect( + resolveDriverIsOwner({ + participants: [ + { + participantId: "SAME", + nationalCode: "0012345678", + birthday: "1370/01/15", + }, + ], + participantRoles: { + driver: "SAME", + vehicleOwner: "SAME", + thirdPartyPolicyholder: "SAME", + }, + }), + ).toBe(true); + }); + + it("resolves owner and policyholder independently of the driver", () => { + expect(resolveOwnerFromParty(partyWithDistinctRoles).nationalCode).toBe( + "0499370899", + ); + expect( + resolvePolicyholderFromParty(partyWithDistinctRoles, "third-party") + .nationalCode, + ).toBe("0080086519"); + }); + + it("formats compact Jalali driverLicenseDate", () => { + expect(formatFanavaranLicenceIssuDate(13980601)).toBe("1398/06/01"); + }); + + it("falls back to legacy person.driverLicense when participants are absent", () => { + const driver = resolveDriverFromParty({ + person: { + nationalCodeOfDriver: "0011111111", + driverBirthday: "13700101", + driverLicense: "5555555555", + driverLicenseDate: "1390/01/01", + }, + }); + expect(driver.nationalCode).toBe("0011111111"); + expect(driver.licenseNo).toBe("5555555555"); + expect(driver.licenseIssuDate).toBe("1390/01/01"); + }); +}); diff --git a/src/claim-request-management/fanavaran-party-roles.ts b/src/claim-request-management/fanavaran-party-roles.ts new file mode 100644 index 0000000..31d6235 --- /dev/null +++ b/src/claim-request-management/fanavaran-party-roles.ts @@ -0,0 +1,217 @@ +import { InquiryParticipantRole } from "src/common/dto/inquiry-participants.dto"; +import { participantForStoredPartyRole } from "src/request-management/inquiry-participant-resolver"; +import { + normalizeNationalCode, + parseJalaliDateParts, +} from "./fanavaran-driver-inquiry"; +import type { FanavaranClaimProduct } from "./fanavaran-claim-product"; + +/** Fanavaran dummy licence issue date when the user did not supply one. */ +export const FANAVARAN_DUMMY_LICENCE_ISSU_DATE = "1394/10/13"; + +export type FanavaranRoleIdentity = { + nationalCode: string | null; + birthday: string | number | null; + /** Digits-only licence number when known. */ + licenseNo: string | null; + licenseType: string | null; + /** + * Jalali `YYYY/MM/DD` from `driverLicenseDate` when present. + * Not a persisted model field — read opportunistically from party/person/participant. + */ + licenseIssuDate: string | null; +}; + +type PartyLike = { + participants?: Array>; + participantRoles?: Record; + person?: Record | null; +}; + +function nonEmptyText(value: unknown): string | null { + if (value == null) return null; + const text = String(value).trim(); + if (!text || text.toLowerCase() === "null" || text.toLowerCase() === "undefined") { + return null; + } + return text; +} + +/** Digits-only licence number, or null when empty / non-digit. */ +export function extractLicenceDigits(value: unknown): string | null { + const text = nonEmptyText(value); + if (!text) return null; + const digits = text.replace(/[^\d]/g, ""); + return digits || null; +} + +/** + * Normalize a stored `driverLicenseDate` (or similar) to Fanavaran Jalali `YYYY/MM/DD`. + * Returns null when missing / unparseable so callers can fall back to the dummy. + */ +export function formatFanavaranLicenceIssuDate(value: unknown): string | null { + const parsed = parseJalaliDateParts(value); + if (!parsed) return null; + const month = String(parsed.month).padStart(2, "0"); + const day = String(parsed.day).padStart(2, "0"); + return `${parsed.year}/${month}/${day}`; +} + +function readDriverLicenseDate( + ...sources: Array | null | undefined> +): string | null { + for (const source of sources) { + if (!source) continue; + const formatted = formatFanavaranLicenceIssuDate( + source.driverLicenseDate ?? source.licenseDate ?? source.licenceIssuDate, + ); + if (formatted) return formatted; + } + return null; +} + +function fromParticipant( + participant: Record | undefined, +): FanavaranRoleIdentity { + if (!participant) { + return { + nationalCode: null, + birthday: null, + licenseNo: null, + licenseType: null, + licenseIssuDate: null, + }; + } + return { + nationalCode: normalizeNationalCode(participant.nationalCode), + birthday: nonEmptyText(participant.birthday) ?? participant.birthday ?? null, + licenseNo: extractLicenceDigits(participant.licenseNumber), + licenseType: nonEmptyText(participant.licenseType), + licenseIssuDate: readDriverLicenseDate(participant), + }; +} + +/** + * Damaged/guilty party DRIVER: participants first, then legacy person.* fields. + * Licence number prefers real user values; callers fall back to the Fanavaran dummy. + */ +export function resolveDriverFromParty(party: PartyLike | null | undefined): FanavaranRoleIdentity { + const fromRole = fromParticipant( + participantForStoredPartyRole( + party ?? {}, + InquiryParticipantRole.DRIVER, + ) as Record | undefined, + ); + const person = party?.person ?? undefined; + + return { + nationalCode: + fromRole.nationalCode ?? + normalizeNationalCode(person?.nationalCodeOfDriver) ?? + normalizeNationalCode(person?.nationalCode), + birthday: + (fromRole.birthday != null && String(fromRole.birthday).trim() !== "" + ? fromRole.birthday + : null) ?? + nonEmptyText(person?.driverBirthday) ?? + nonEmptyText(person?.birthday) ?? + person?.driverBirthday ?? + person?.birthday ?? + null, + licenseNo: + fromRole.licenseNo ?? + extractLicenceDigits(person?.driverLicense) ?? + extractLicenceDigits(person?.insurerLicense), + licenseType: fromRole.licenseType ?? nonEmptyText(person?.licenseType), + licenseIssuDate: + fromRole.licenseIssuDate ?? readDriverLicenseDate(person), + }; +} + +/** Vehicle owner for OwnerId / DriverIsOwner / Sheba national-code routing. */ +export function resolveOwnerFromParty(party: PartyLike | null | undefined): FanavaranRoleIdentity { + const fromRole = fromParticipant( + participantForStoredPartyRole( + party ?? {}, + InquiryParticipantRole.VEHICLE_OWNER, + ) as Record | undefined, + ); + if (fromRole.nationalCode || fromRole.birthday) { + return fromRole; + } + // Legacy parties had no dedicated owner fields. + return { + nationalCode: null, + birthday: null, + licenseNo: null, + licenseType: null, + licenseIssuDate: null, + }; +} + +/** + * Policyholder national code for GEN.03 PolicyId inquiry: + * THIRD_PARTY → third-party policyholder; CAR_BODY → car-body policyholder. + */ +export function resolvePolicyholderFromParty( + party: PartyLike | null | undefined, + product: FanavaranClaimProduct, +): FanavaranRoleIdentity { + const role = + product === "car-body" + ? InquiryParticipantRole.CAR_BODY_POLICYHOLDER + : InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER; + const fromRole = fromParticipant( + participantForStoredPartyRole(party ?? {}, role) as + | Record + | undefined, + ); + const person = party?.person ?? undefined; + + return { + nationalCode: + fromRole.nationalCode ?? + normalizeNationalCode(person?.nationalCodeOfInsurer) ?? + normalizeNationalCode(person?.nationalCode), + birthday: + (fromRole.birthday != null && String(fromRole.birthday).trim() !== "" + ? fromRole.birthday + : null) ?? + nonEmptyText(person?.insurerBirthday) ?? + person?.insurerBirthday ?? + null, + licenseNo: + fromRole.licenseNo ?? extractLicenceDigits(person?.insurerLicense), + licenseType: fromRole.licenseType ?? null, + licenseIssuDate: fromRole.licenseIssuDate, + }; +} + +/** + * GEN.12 `DriverIsOwner`: driver national code equals vehicle-owner national code. + * Falls back to legacy `person.driverIsInsurer` when owner role is absent. + */ +export function resolveDriverIsOwner(party: PartyLike | null | undefined): boolean { + const driver = resolveDriverFromParty(party); + const owner = resolveOwnerFromParty(party); + if (driver.nationalCode && owner.nationalCode) { + return driver.nationalCode === owner.nationalCode; + } + return party?.person?.driverIsInsurer === true; +} + +/** Licence number for Fanavaran; never empty — uses `dummy` when user value is missing. */ +export function resolveFanavaranLicenceNo( + identity: Pick, + dummy: string, +): string { + return identity.licenseNo || dummy; +} + +/** Licence issue date for Fanavaran; uses dummy when `driverLicenseDate` is absent. */ +export function resolveFanavaranLicenceIssuDate( + identity: Pick, + dummy: string = FANAVARAN_DUMMY_LICENCE_ISSU_DATE, +): string { + return identity.licenseIssuDate || dummy; +} diff --git a/src/fanavaran/fanavaran-lookup.config.ts b/src/fanavaran/fanavaran-lookup.config.ts index 1f1c5b6..28822ce 100644 --- a/src/fanavaran/fanavaran-lookup.config.ts +++ b/src/fanavaran/fanavaran-lookup.config.ts @@ -172,8 +172,41 @@ export const FANAVARAN_REMOTE_LOOKUPS: FanavaranRemoteLookupDefinition[] = [ url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/cii-validation-status`, cacheFile: "cii-validation-status.json", }, + { + name: "vehicle-groups", + url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/vehicle-groups`, + cacheFile: "vehicle-groups.json", + }, ]; +/** Safe Fanavaran lookup slug: lowercase segments separated by hyphens. */ +const FANAVARAN_LOOKUP_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * Resolve a remote lookup definition. + * Known names use the catalogue; unknown slugs fall back to + * `car/base-info/{lookupName}` so `/lookups/fanavaran/{name}` works without + * a dedicated route or config entry (e.g. vehicle-groups). + */ +export function resolveFanavaranRemoteLookup( + name: string, +): FanavaranRemoteLookupDefinition | null { + const configured = FANAVARAN_REMOTE_LOOKUPS.find((item) => item.name === name); + if (configured) { + return configured; + } + + if (!FANAVARAN_LOOKUP_NAME_PATTERN.test(name)) { + return null; + } + + return { + name, + url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/${name}`, + cacheFile: `${name}.json`, + }; +} + export const TEJARAT_STATIC_ACCIDENT_FILES = { accidentReason: "ACCIDENT_REASON.json", accidentWay: "ACCIDENT_WAY.json", diff --git a/src/lookups/lookups.controller.ts b/src/lookups/lookups.controller.ts index 22b88a6..32448ef 100644 --- a/src/lookups/lookups.controller.ts +++ b/src/lookups/lookups.controller.ts @@ -582,7 +582,8 @@ export class LookupsController { @ApiOperation({ summary: "List configured Fanavaran remote lookups", description: - "Returns the lookup names available through /lookups/fanavaran/{lookupName}.", + "Returns catalogue entries for /lookups/fanavaran/{lookupName}. " + + "Unlisted car/base-info slugs still work via the same route (fallback).", }) async listFanavaranRemoteLookups() { return this.lookupsService.listFanavaranRemoteLookups().map((lookup) => ({ @@ -596,12 +597,14 @@ export class LookupsController { @ApiOperation({ summary: "Fetch a Fanavaran remote lookup by name", description: - "Generic cached Fanavaran lookup fetcher for configured lookup names, including GEN.08 expertise lookups.", + "Generic cached Fanavaran lookup fetcher. Configured names use their catalogue URL; " + + "unlisted slugs (e.g. vehicle-groups) fall back to car/base-info/{lookupName}.", }) @ApiParam({ name: "lookupName", description: - "Configured Fanavaran lookup name, for example inspection-place, drop-amount-status, car-components, accident-level, expert-status", + "Fanavaran lookup slug, for example vehicle-groups, inspection-place, car-components. " + + "Unconfigured names are fetched from car/base-info/{lookupName}.", }) @ApiOkResponse({ description: "Returns cached or live Fanavaran lookup data", diff --git a/src/lookups/lookups.service.ts b/src/lookups/lookups.service.ts index 55dbe98..2847fa3 100644 --- a/src/lookups/lookups.service.ts +++ b/src/lookups/lookups.service.ts @@ -10,6 +10,7 @@ import { } from "src/core/config/fanavaran-client.config"; import { FANAVARAN_REMOTE_LOOKUPS, + resolveFanavaranRemoteLookup, type FanavaranRemoteLookupDefinition, TEJARAT_STATIC_ACCIDENT_FILES, } from "src/fanavaran/fanavaran-lookup.config"; @@ -66,7 +67,7 @@ export class LookupsService { } private findRemoteLookup(name: string) { - const lookup = FANAVARAN_REMOTE_LOOKUPS.find((item) => item.name === name); + const lookup = resolveFanavaranRemoteLookup(name); if (!lookup) { throw new NotFoundException(`Unknown Fanavaran remote lookup: ${name}`); }