car body policy and sales extended

This commit is contained in:
2026-09-05 18:37:42 +03:30
parent eb072ef080
commit eb636bdbdd
10 changed files with 1097 additions and 14 deletions

View File

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

View File

@@ -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,
};
}

View File

@@ -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 },

View File

@@ -78,9 +78,13 @@ export class FanavaranLookupService {
async fetchFromFanavaran(
clientKey: FanavaranClientKey,
url: string,
options?: { contractIdOverride?: string },
): Promise<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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(

View File

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

View File

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

View File

@@ -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<string, unknown>;
vehicle: Record<string, unknown> | null;
};
/** Same Fanavaran middle-letter codes used on claim submit. */
export const FANAVARAN_PLATE_LETTER_CODE: Record<string, number> = {
الف: 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<string, string> = {
"۰": "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<string, unknown> | null {
if (!value || typeof value !== "object") {
return null;
}
if (Array.isArray(value)) {
return asObjectRecord(value[0]);
}
return value as Record<string, unknown>;
}
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<string, unknown> | 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<string, unknown>,
): 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<string, unknown> | 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<string, unknown> | 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 } : {}),
};
}

View File

@@ -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",

View File

@@ -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<string, jest.Mock>) {
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);
});
});

View File

@@ -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<string, unknown> | 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<string, unknown> | 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<any> {
if (!policy || typeof policy !== "object") {
return policy;
@@ -222,13 +414,26 @@ export class LookupsService {
async bodyPolicyById(policyId: number): Promise<unknown> {
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<unknown> {
async vehicleById(vehicleId: number, versionNo?: number): Promise<unknown> {
const clientKey = this.activeClientKey();
return this.fanavaranLookupService.vehicleById(clientKey, vehicleId, versionNo);
return this.fanavaranLookupService.vehicleById(
clientKey,
vehicleId,
versionNo,
);
}
async customerById(customerId: number): Promise<unknown> {