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 <cursoragent@cursor.com>
This commit is contained in:
2026-09-26 16:42:21 +03:30
parent 4f8fe04380
commit fc1c117b93
9 changed files with 590 additions and 44 deletions

View File

@@ -262,7 +262,16 @@ import {
pickPersonBirthday, pickPersonBirthday,
pickPersonNationalCode, pickPersonNationalCode,
selectFanavaranDriverId, selectFanavaranDriverId,
selectFanavaranOwnerId,
} from "./fanavaran-driver-inquiry"; } from "./fanavaran-driver-inquiry";
import {
resolveDriverFromParty,
resolveDriverIsOwner,
resolveFanavaranLicenceIssuDate,
resolveFanavaranLicenceNo,
resolveOwnerFromParty,
resolvePolicyholderFromParty,
} from "./fanavaran-party-roles";
import { import {
buildFanavaranOtherPeoplePayload, buildFanavaranOtherPeoplePayload,
pickFanavaranRecordId, pickFanavaranRecordId,
@@ -3676,23 +3685,23 @@ export class ClaimRequestManagementService {
/** /**
* Resolve licence number to send to Fanavaran. * Resolve licence number to send to Fanavaran.
* Preference order: * Preference order:
* - use the first non-empty value from DB (driverLicense/insurerLicense) * - driver participant / person.driverLicense (and insurerLicense as legacy)
* - else use the configured 10-digit dummy * - else the configured 10-digit dummy
*/ */
private resolveFanavaranLicenceNoFromParty(input: { private resolveFanavaranLicenceNoFromParty(input: {
driverIsInsurer?: boolean; driverIsInsurer?: boolean;
driverLicense?: unknown; driverLicense?: unknown;
insurerLicense?: unknown; insurerLicense?: unknown;
/** Optional already-resolved digits from participants. */
resolvedLicenseNo?: string | null;
}): string { }): 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, const { driverLicense, insurerLicense } = input;
// but DB can still miss one of the two fields — prefer both. for (const c of [driverLicense, insurerLicense]) {
const candidates = driverIsInsurer
? [driverLicense, insurerLicense]
: [driverLicense, insurerLicense];
for (const c of candidates) {
const digits = this.extractNonEmptyLicenceDigits(c); const digits = this.extractNonEmptyLicenceDigits(c);
if (digits) return digits; 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<number | null> {
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. */ /** GEN.03 hull: TotalPremium + parties inquiry (AccidentCulpritId) before hull field whitelist. */
private async enrichCarBodyHullBaseClaimPayload(input: { private async enrichCarBodyHullBaseClaimPayload(input: {
clientKey: FanavaranClientKey; clientKey: FanavaranClientKey;
@@ -4357,13 +4396,14 @@ export class ClaimRequestManagementService {
} }
| undefined; | undefined;
const driverIdentity = resolveDriverFromParty(input.firstParty as any);
const nationalCode = const nationalCode =
driverIdentity.nationalCode ??
pickPersonNationalCode(person) ?? pickPersonNationalCode(person) ??
pickCarBodyPolicyNationalCode( pickCarBodyPolicyNationalCode(
input.blameCase as { parties?: unknown[] }, input.blameCase as { parties?: unknown[] },
); );
const birthday = pickPersonBirthday(person); const birthday = driverIdentity.birthday ?? pickPersonBirthday(person);
const driverIsInsurer = person?.driverIsInsurer ?? true;
let accidentCulpritId: number | null = null; let accidentCulpritId: number | null = null;
if (nationalCode && birthday) { if (nationalCode && birthday) {
@@ -4371,7 +4411,7 @@ export class ClaimRequestManagementService {
input.clientKey, input.clientKey,
nationalCode, nationalCode,
birthday, birthday,
driverIsInsurer, false,
); );
} }
if (accidentCulpritId == null) { if (accidentCulpritId == null) {
@@ -4386,6 +4426,13 @@ export class ClaimRequestManagementService {
input.payload.AccidentCulpritId = accidentCulpritId; input.payload.AccidentCulpritId = accidentCulpritId;
} }
input.payload.CulpritLicenceNo = resolveFanavaranLicenceNo(
driverIdentity,
FANAVARAN_DUMMY_LICENCE_NO,
);
input.payload.CulpritLicenceIssuDate =
resolveFanavaranLicenceIssuDate(driverIdentity);
input.payload.CustomerFaultPercent = input.payload.CustomerFaultPercent =
FANAVARAN_DEFAULT_HULL_CUSTOMER_FAULT_PERCENT; FANAVARAN_DEFAULT_HULL_CUSTOMER_FAULT_PERCENT;
@@ -4619,6 +4666,9 @@ export class ClaimRequestManagementService {
const person = damagedParty?.person ?? {}; const person = damagedParty?.person ?? {};
const vehicle = damagedParty?.vehicle ?? {}; const vehicle = damagedParty?.vehicle ?? {};
const insurance = damagedParty?.insurance ?? {}; const insurance = damagedParty?.insurance ?? {};
const driverIdentity = resolveDriverFromParty(damagedParty);
const ownerIdentity = resolveOwnerFromParty(damagedParty);
const driverIsOwner = resolveDriverIsOwner(damagedParty);
const product = fanavaranClaimProductFromBlameType(input.blameCase?.type); const product = fanavaranClaimProductFromBlameType(input.blameCase?.type);
const { mapped: inquiryMapped, raw: inquiryRawPayload } = const { mapped: inquiryMapped, raw: inquiryRawPayload } =
fanavaranPartyInquirySources(product, damagedParty); fanavaranPartyInquirySources(product, damagedParty);
@@ -4630,12 +4680,14 @@ export class ClaimRequestManagementService {
this.logger.log( this.logger.log(
`[buildFanavaranDamageCasePayload] damagedParty resolved: userId=${damagedParty?.person?.userId ?? "NONE"}, ` + `[buildFanavaranDamageCasePayload] damagedParty resolved: userId=${damagedParty?.person?.userId ?? "NONE"}, ` +
`nationalCode=${pickPersonNationalCode(person) ?? "MISSING"}, ` + `driverNationalCode=${driverIdentity.nationalCode ?? "MISSING"}, ` +
`ownerNationalCode=${ownerIdentity.nationalCode ?? "MISSING"}, ` +
`nationalCodeOfDriver=${person.nationalCodeOfDriver ?? "MISSING"}, ` + `nationalCodeOfDriver=${person.nationalCodeOfDriver ?? "MISSING"}, ` +
`nationalCodeOfInsurer=${person.nationalCodeOfInsurer ?? "MISSING"}, ` + `nationalCodeOfInsurer=${person.nationalCodeOfInsurer ?? "MISSING"}, ` +
`driverBirthday=${pickPersonBirthday(person) ?? "MISSING"}, ` + `driverBirthday=${driverIdentity.birthday ?? "MISSING"}, ` +
`driverIsInsurer=${person.driverIsInsurer ?? "MISSING"}, ` + `driverIsOwner=${driverIsOwner}, ` +
`driverLicense=${person.driverLicense ?? "MISSING"}`, `driverLicense=${driverIdentity.licenseNo ?? person.driverLicense ?? "MISSING"}, ` +
`driverLicenseDate=${driverIdentity.licenseIssuDate ?? "MISSING"}`,
); );
if (!damagedParty) { if (!damagedParty) {
this.logger.warn( this.logger.warn(
@@ -4761,12 +4813,23 @@ export class ClaimRequestManagementService {
if (vehicleKindId != null) vehicleKindSource = "car-type-lookup"; if (vehicleKindId != null) vehicleKindSource = "car-type-lookup";
} }
let driverFanavaranId = await this.resolveDriverFanavaranIdFromBlameCase( let driverFanavaranId: number | null = null;
input.clientKey, if (driverIdentity.nationalCode && driverIdentity.birthday) {
input.blameCase?.parties ?? [], driverFanavaranId = await this.resolveDriverFanavaranId(
person, input.clientKey,
input.claimCase, 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); let otherPersonId = this.firstPositiveFanavaranId(cachedDamage.otherPersonId);
if (driverFanavaranId) { if (driverFanavaranId) {
@@ -4781,7 +4844,13 @@ export class ClaimRequestManagementService {
} else if (input.registerMissingPerson) { } else if (input.registerMissingPerson) {
const registered = await this.registerFanavaranOtherPerson({ const registered = await this.registerFanavaranOtherPerson({
clientKey: input.clientKey, clientKey: input.clientKey,
person, person: {
...person,
nationalCodeOfDriver:
driverIdentity.nationalCode ?? person.nationalCodeOfDriver,
driverBirthday: driverIdentity.birthday ?? person.driverBirthday,
driverIsInsurer: false,
},
claimCaseId: input.claimCase?._id claimCaseId: input.claimCase?._id
? String(input.claimCase._id) ? String(input.claimCase._id)
: undefined, : 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) { if (driverFanavaranId && input.blameCase?._id && damagedParty) {
const partyIndex = (input.blameCase.parties ?? []).findIndex( const partyIndex = (input.blameCase.parties ?? []).findIndex(
(p: any) => p === damagedParty || (p: any) => p === damagedParty ||
@@ -4844,7 +4928,7 @@ export class ClaimRequestManagementService {
} }
this.logger.log( 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"}`, `PolicyNo=${policyNo ?? "NULL"}, PolicyCINumber=${policyCINumber ?? "NULL"}`,
); );
@@ -4866,16 +4950,17 @@ export class ClaimRequestManagementService {
EstimateAmount: FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT, EstimateAmount: FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT,
FaultPercent: input.defaults.FaultPercent, FaultPercent: input.defaults.FaultPercent,
InsuranceCorpId: insuranceCorpId, InsuranceCorpId: insuranceCorpId,
DriverIsOwner: person.driverIsInsurer ? 1 : input.defaults.DriverIsOwner, DriverIsOwner: driverIsOwner ? 1 : 0,
LicenceCityId: null, LicenceCityId: null,
LicenceCountryId: null, LicenceCountryId: null,
LicenceForeignCityName: null, LicenceForeignCityName: null,
LicenceIssuDate: person.driverBirthday ?? "1394/10/13", LicenceIssuDate: resolveFanavaranLicenceIssuDate(driverIdentity),
// Must never be empty/null; prefer stored DB value, else use dummy. // Must never be empty/null; prefer stored DB value, else use dummy.
LicenceNo: this.resolveFanavaranLicenceNoFromParty({ LicenceNo: this.resolveFanavaranLicenceNoFromParty({
driverIsInsurer: person.driverIsInsurer, driverIsInsurer: person.driverIsInsurer,
driverLicense: person.driverLicense, driverLicense: person.driverLicense,
insurerLicense: person.insurerLicense, insurerLicense: person.insurerLicense,
resolvedLicenseNo: driverIdentity.licenseNo,
}), }),
LicenceTypeId: input.defaults.CulpritLicenceTypeId, LicenceTypeId: input.defaults.CulpritLicenceTypeId,
MotorNo: MotorNo:
@@ -4884,7 +4969,7 @@ export class ClaimRequestManagementService {
"EngineNumberField", "EngineNumberField",
"mtrnum", "mtrnum",
]) ?? (typeof apiVehicle?.MotorNo === "string" ? apiVehicle.MotorNo : null), ]) ?? (typeof apiVehicle?.MotorNo === "string" ? apiVehicle.MotorNo : null),
OwnerId: null, OwnerId: ownerFanavaranId,
PlaqueCityId: apiPlaque.cityId, PlaqueCityId: apiPlaque.cityId,
PlaqueKindId: plaqueKindId, PlaqueKindId: plaqueKindId,
PlaqueLeftNo: PlaqueLeftNo:
@@ -5819,6 +5904,8 @@ export class ClaimRequestManagementService {
parties: Array<{ parties: Array<{
role?: PartyRole; role?: PartyRole;
person?: { userId?: Types.ObjectId; nationalCodeOfInsurer?: string }; person?: { userId?: Types.ObjectId; nationalCodeOfInsurer?: string };
participants?: Array<Record<string, any>>;
participantRoles?: Record<string, string | undefined>;
statement?: { admitsGuilt?: boolean }; statement?: { admitsGuilt?: boolean };
}>, }>,
guiltyPartyId?: Types.ObjectId | string | null, guiltyPartyId?: Types.ObjectId | string | null,
@@ -5830,8 +5917,13 @@ export class ClaimRequestManagementService {
const guiltyParty = parties.find( const guiltyParty = parties.find(
(party) => party.person?.userId?.toString() === guiltyPartyId.toString(), (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( 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 = ( const carBodyAccidentTime = (
firstParty as { statement?: { accidentTime?: string } } | undefined firstParty as { statement?: { accidentTime?: string } } | undefined
)?.statement?.accidentTime; )?.statement?.accidentTime;

View File

@@ -107,6 +107,29 @@ describe("fanavaran claim product", () => {
expect(nationalCode).toBe("0012345678"); 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", () => { it("flattens car-body inquiry fields onto the damage-case aliases", () => {
const { mapped } = fanavaranPartyInquirySources("car-body", { const { mapped } = fanavaranPartyInquirySources("car-body", {
insurance: { insurance: {

View File

@@ -7,6 +7,7 @@ import {
parseFanavaranId, parseFanavaranId,
type FanavaranCarPolicyProduct, type FanavaranCarPolicyProduct,
} from "src/lookups/fanavaran-last-car-policy"; } from "src/lookups/fanavaran-last-car-policy";
import { resolvePolicyholderFromParty } from "./fanavaran-party-roles";
export type FanavaranClaimProduct = FanavaranCarPolicyProduct; export type FanavaranClaimProduct = FanavaranCarPolicyProduct;
@@ -101,6 +102,8 @@ export function fanavaranInsuranceLineIdForProduct(
type BlamePartyForCarBodyPolicy = { type BlamePartyForCarBodyPolicy = {
role?: string; role?: string;
person?: { nationalCodeOfInsurer?: unknown }; person?: { nationalCodeOfInsurer?: unknown };
participants?: Array<Record<string, any>>;
participantRoles?: Record<string, string | undefined>;
insurance?: { insurance?: {
carBodyInsurance?: { carBodyInsurance?: {
policyId?: unknown; policyId?: unknown;
@@ -168,6 +171,7 @@ export function pickCarBodyPolicyNationalCode(
): string | null { ): string | null {
const party = firstCarBodyParty(blame); const party = firstCarBodyParty(blame);
return ( return (
resolvePolicyholderFromParty(party, "car-body").nationalCode ??
nonEmptyText(party?.person?.nationalCodeOfInsurer) ?? nonEmptyText(party?.person?.nationalCodeOfInsurer) ??
nonEmptyText(party?.insurance?.carBodyInsurance?.ownerNationalCode) ?? nonEmptyText(party?.insurance?.carBodyInsurance?.ownerNationalCode) ??
nonEmptyText(party?.insurance?.carBodyInsurance?.insurerNationalCode) nonEmptyText(party?.insurance?.carBodyInsurance?.insurerNationalCode)

View File

@@ -34,6 +34,21 @@ export function asPartyInquiryRows(value: unknown): FanavaranPartyInquiryRow[] {
return []; 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) * DriverId for GEN.12 زیان‌دیده. Prefer insurer (161) or driver (166)
* based on driverIsInsurer, then any returned party id. * based on driverIsInsurer, then any returned party id.
@@ -42,19 +57,27 @@ export function selectFanavaranDriverId(
rows: unknown, rows: unknown,
driverIsInsurer: boolean | undefined, driverIsInsurer: boolean | undefined,
): number | null { ): number | null {
const parties = asPartyInquiryRows(rows);
if (parties.length === 0) return null;
const preferredRoles = driverIsInsurer 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) { /** OwnerId: prefer plaque-owner role (163), then insurer / driver. */
const match = parties.find((row) => asPositiveId(row.RoleId) === roleId); export function selectFanavaranOwnerId(rows: unknown): number | null {
if (match) return asPositiveId(match.Id); return selectFanavaranIdByRoles(rows, [
} FANAVARAN_PLAQUE_OWNER_ROLE_ID,
FANAVARAN_INSURER_ROLE_ID,
return asPositiveId(parties[0].Id); FANAVARAN_DRIVER_ROLE_ID,
]);
} }
export function normalizeNationalCode(value: unknown): string | null { export function normalizeNationalCode(value: unknown): string | null {

View File

@@ -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");
});
});

View File

@@ -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<Record<string, any>>;
participantRoles?: Record<string, string | undefined>;
person?: Record<string, any> | 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<Record<string, any> | 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<string, any> | 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<string, any> | 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<string, any> | 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<string, any>
| 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<FanavaranRoleIdentity, "licenseNo">,
dummy: string,
): string {
return identity.licenseNo || dummy;
}
/** Licence issue date for Fanavaran; uses dummy when `driverLicenseDate` is absent. */
export function resolveFanavaranLicenceIssuDate(
identity: Pick<FanavaranRoleIdentity, "licenseIssuDate">,
dummy: string = FANAVARAN_DUMMY_LICENCE_ISSU_DATE,
): string {
return identity.licenseIssuDate || dummy;
}

View File

@@ -172,8 +172,41 @@ export const FANAVARAN_REMOTE_LOOKUPS: FanavaranRemoteLookupDefinition[] = [
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/cii-validation-status`, url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/cii-validation-status`,
cacheFile: "cii-validation-status.json", 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 = { export const TEJARAT_STATIC_ACCIDENT_FILES = {
accidentReason: "ACCIDENT_REASON.json", accidentReason: "ACCIDENT_REASON.json",
accidentWay: "ACCIDENT_WAY.json", accidentWay: "ACCIDENT_WAY.json",

View File

@@ -582,7 +582,8 @@ export class LookupsController {
@ApiOperation({ @ApiOperation({
summary: "List configured Fanavaran remote lookups", summary: "List configured Fanavaran remote lookups",
description: 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() { async listFanavaranRemoteLookups() {
return this.lookupsService.listFanavaranRemoteLookups().map((lookup) => ({ return this.lookupsService.listFanavaranRemoteLookups().map((lookup) => ({
@@ -596,12 +597,14 @@ export class LookupsController {
@ApiOperation({ @ApiOperation({
summary: "Fetch a Fanavaran remote lookup by name", summary: "Fetch a Fanavaran remote lookup by name",
description: 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({ @ApiParam({
name: "lookupName", name: "lookupName",
description: 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({ @ApiOkResponse({
description: "Returns cached or live Fanavaran lookup data", description: "Returns cached or live Fanavaran lookup data",

View File

@@ -10,6 +10,7 @@ import {
} from "src/core/config/fanavaran-client.config"; } from "src/core/config/fanavaran-client.config";
import { import {
FANAVARAN_REMOTE_LOOKUPS, FANAVARAN_REMOTE_LOOKUPS,
resolveFanavaranRemoteLookup,
type FanavaranRemoteLookupDefinition, type FanavaranRemoteLookupDefinition,
TEJARAT_STATIC_ACCIDENT_FILES, TEJARAT_STATIC_ACCIDENT_FILES,
} from "src/fanavaran/fanavaran-lookup.config"; } from "src/fanavaran/fanavaran-lookup.config";
@@ -66,7 +67,7 @@ export class LookupsService {
} }
private findRemoteLookup(name: string) { private findRemoteLookup(name: string) {
const lookup = FANAVARAN_REMOTE_LOOKUPS.find((item) => item.name === name); const lookup = resolveFanavaranRemoteLookup(name);
if (!lookup) { if (!lookup) {
throw new NotFoundException(`Unknown Fanavaran remote lookup: ${name}`); throw new NotFoundException(`Unknown Fanavaran remote lookup: ${name}`);
} }