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

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