forked from Shared/esg
Introduce plate, chassis, and third-party car endpoints, a wall-clock inquiry deadline with transport-only retries, and ownership-safe no-match errors so another person's identity never leaks. Co-authored-by: Cursor <cursoragent@cursor.com>
324 lines
9.5 KiB
TypeScript
324 lines
9.5 KiB
TypeScript
import { CarPlateSoapFields } from '../../providers/shared/centinsur-car-policy.client';
|
|
|
|
export interface NormalizedCarPolicy {
|
|
raw: Record<string, string>;
|
|
nationalCode: string;
|
|
plateKey: string;
|
|
chassisNo: string;
|
|
motorNo: string;
|
|
vin: string;
|
|
startDate?: Date;
|
|
endDate?: Date;
|
|
vehicleGroup: string;
|
|
insuranceType: string;
|
|
isZeroKm: boolean;
|
|
isActive: boolean;
|
|
}
|
|
|
|
export interface CarPlateInput extends CarPlateSoapFields {
|
|
nationalCode: string;
|
|
}
|
|
|
|
export function getCurrentJalaliYear(now = new Date()): number {
|
|
const parts = new Intl.DateTimeFormat('en-u-ca-persian', { year: 'numeric' }).formatToParts(now);
|
|
const yearPart = parts.find((part) => part.type === 'year');
|
|
return Number(yearPart?.value ?? new Date().getFullYear() - 621);
|
|
}
|
|
|
|
/** Persian plate letter must be present and not a placeholder. */
|
|
export function isValidPlateLetter(letter: string): boolean {
|
|
const trimmed = letter.trim();
|
|
if (!trimmed || trimmed === '-' || trimmed === '0') {
|
|
return false;
|
|
}
|
|
return /[\u0600-\u06FF]/.test(trimmed);
|
|
}
|
|
|
|
export function nationalIdsMatch(requested: string, fromPolicy: string): boolean {
|
|
return requested.trim().padStart(10, '0') === fromPolicy.trim().padStart(10, '0');
|
|
}
|
|
|
|
export function normalizeSoapCarPolicy(
|
|
policy: Record<string, string>,
|
|
nationalCode: string,
|
|
): NormalizedCarPolicy {
|
|
const plk1 = pickField(policy, ['Plk1', 'PLK1']);
|
|
const plk2 = pickField(policy, ['Plk2', 'PLK2']);
|
|
const plk3 = pickField(policy, ['Plk3', 'PLK3']);
|
|
const plksrl = pickField(policy, ['PlkSrl', 'PlkSRL', 'Plk4']);
|
|
const chassisNo = pickField(policy, ['ChassisNo', 'ShasiNo', 'VIN', 'Vin']);
|
|
const motorNo = pickField(policy, ['MotorNo', 'Motor']);
|
|
const vin = pickField(policy, ['VIN', 'Vin', 'ChassisNo']);
|
|
const vehicleGroup = pickField(policy, [
|
|
'CarGrpCod',
|
|
'VhcleGrpCd',
|
|
'VhcleGrp',
|
|
'KbTyp',
|
|
'VehicleGroup',
|
|
]);
|
|
const insuranceType = pickField(policy, ['InsurTy', 'InsurTyp', 'PlcyTyp', 'InsuranceType']);
|
|
const policyNationalCode =
|
|
pickField(policy, ['NtnlId', 'NationalId', 'NationalCode']) || nationalCode;
|
|
const startDate = parsePolicyDate(
|
|
pickField(policy, [
|
|
'HBgnDte',
|
|
'PlcyBegnDt',
|
|
'BgnDt',
|
|
'PolicyBeginDate',
|
|
'StartDate',
|
|
]),
|
|
);
|
|
const endDate = parsePolicyDate(
|
|
pickField(policy, ['HEndDte', 'PlcyEndDt', 'EndDt', 'PolicyEndDate', 'EndDate']),
|
|
);
|
|
const plateKey = [plk1, plk2, plk3, plksrl].filter(Boolean).join('-');
|
|
const isZeroKm = isZeroKmPlate(plk1, plk2, plk3, plksrl);
|
|
const now = new Date();
|
|
|
|
return {
|
|
raw: policy,
|
|
nationalCode: policyNationalCode,
|
|
plateKey,
|
|
chassisNo,
|
|
motorNo,
|
|
vin,
|
|
startDate,
|
|
endDate,
|
|
vehicleGroup,
|
|
insuranceType,
|
|
isZeroKm,
|
|
isActive: Boolean(startDate && endDate && startDate <= now && endDate >= now),
|
|
};
|
|
}
|
|
|
|
export function isPassengerVehicleGroup(group: string): boolean {
|
|
const normalized = group.trim();
|
|
return normalized === '2' || normalized === '3' || normalized === '02' || normalized === '03';
|
|
}
|
|
|
|
export function overlapsCurrentJalaliYear(
|
|
policy: NormalizedCarPolicy,
|
|
jalaliYear = getCurrentJalaliYear(),
|
|
): boolean {
|
|
if (!policy.startDate || !policy.endDate) {
|
|
return true;
|
|
}
|
|
|
|
const yearStart = jalaliYearStartGregorian(jalaliYear);
|
|
const yearEnd = jalaliYearStartGregorian(jalaliYear + 1);
|
|
return policy.startDate < yearEnd && policy.endDate >= yearStart;
|
|
}
|
|
|
|
export function isAcceptableThirdPartyPolicy(
|
|
policy: NormalizedCarPolicy,
|
|
requestedNationalCode: string,
|
|
): boolean {
|
|
if (!nationalIdsMatch(requestedNationalCode, policy.nationalCode)) {
|
|
return false;
|
|
}
|
|
if (policy.vehicleGroup && !isPassengerVehicleGroup(policy.vehicleGroup)) {
|
|
return false;
|
|
}
|
|
if (!overlapsCurrentJalaliYear(policy)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function sameVehicleIdentity(a: NormalizedCarPolicy, b: NormalizedCarPolicy): boolean {
|
|
const aChassis = normalizeIdentity(a.chassisNo || a.vin);
|
|
const bChassis = normalizeIdentity(b.chassisNo || b.vin);
|
|
if (aChassis && bChassis) {
|
|
return aChassis === bChassis;
|
|
}
|
|
|
|
const aMotor = normalizeIdentity(a.motorNo);
|
|
const bMotor = normalizeIdentity(b.motorNo);
|
|
if (aMotor && bMotor) {
|
|
return aMotor === bMotor;
|
|
}
|
|
|
|
return a.plateKey !== '' && a.plateKey === b.plateKey;
|
|
}
|
|
|
|
export function selectBestThirdPartyPolicy(
|
|
policies: NormalizedCarPolicy[],
|
|
requestedNationalCode: string,
|
|
): NormalizedCarPolicy | null {
|
|
const acceptable = policies.filter((policy) =>
|
|
isAcceptableThirdPartyPolicy(policy, requestedNationalCode),
|
|
);
|
|
if (acceptable.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const active = acceptable.filter((policy) => policy.isActive);
|
|
const pool = active.length > 0 ? active : acceptable;
|
|
|
|
return pool.sort((left, right) => {
|
|
const leftStart = left.startDate?.getTime() ?? 0;
|
|
const rightStart = right.startDate?.getTime() ?? 0;
|
|
return rightStart - leftStart;
|
|
})[0]!;
|
|
}
|
|
|
|
export function mergePlateAndNationalPolicies(
|
|
platePolicy: NormalizedCarPolicy | null,
|
|
nationalPolicy: NormalizedCarPolicy | null,
|
|
): NormalizedCarPolicy | null {
|
|
if (!platePolicy && !nationalPolicy) {
|
|
return null;
|
|
}
|
|
if (!platePolicy) {
|
|
return nationalPolicy;
|
|
}
|
|
if (!nationalPolicy) {
|
|
return platePolicy;
|
|
}
|
|
|
|
if (sameVehicleIdentity(platePolicy, nationalPolicy)) {
|
|
return platePolicy.startDate && nationalPolicy.startDate
|
|
? platePolicy.startDate >= nationalPolicy.startDate
|
|
? platePolicy
|
|
: nationalPolicy
|
|
: platePolicy;
|
|
}
|
|
|
|
if (nationalPolicy.isZeroKm) {
|
|
return nationalPolicy.startDate && platePolicy.startDate
|
|
? nationalPolicy.startDate >= platePolicy.startDate
|
|
? nationalPolicy
|
|
: platePolicy
|
|
: nationalPolicy;
|
|
}
|
|
|
|
return platePolicy;
|
|
}
|
|
|
|
export interface ThirdPartyCarLookup {
|
|
byPlate?: () => Promise<Record<string, string> | null>;
|
|
byNationalCode?: () => Promise<Record<string, string> | null>;
|
|
}
|
|
|
|
export async function resolveThirdPartyCarPolicy(
|
|
input: CarPlateInput,
|
|
lookup: ThirdPartyCarLookup,
|
|
): Promise<{ policy: NormalizedCarPolicy; sources: string[] } | null> {
|
|
const plateLetterValid = isValidPlateLetter(input.plk2);
|
|
const sources: string[] = [];
|
|
|
|
const platePromise =
|
|
plateLetterValid && lookup.byPlate
|
|
? lookup.byPlate().catch(() => null)
|
|
: Promise.resolve(null);
|
|
const nationalPromise = lookup.byNationalCode
|
|
? lookup.byNationalCode().catch(() => null)
|
|
: Promise.resolve(null);
|
|
|
|
// Parallel so dual SOAP inquiries share wall-clock time under the inquiry deadline.
|
|
const [plateRaw, nationalRaw] = await Promise.all([platePromise, nationalPromise]);
|
|
|
|
const platePolicy = plateRaw
|
|
? normalizeSoapCarPolicy(plateRaw, input.nationalCode)
|
|
: null;
|
|
const nationalPolicy = nationalRaw
|
|
? normalizeSoapCarPolicy(nationalRaw, input.nationalCode)
|
|
: null;
|
|
|
|
if (platePolicy) {
|
|
sources.push('plate');
|
|
}
|
|
if (nationalPolicy) {
|
|
sources.push('nationalCode');
|
|
}
|
|
|
|
let merged = mergePlateAndNationalPolicies(
|
|
platePolicy && isAcceptableThirdPartyPolicy(platePolicy, input.nationalCode) ? platePolicy : null,
|
|
nationalPolicy && isAcceptableThirdPartyPolicy(nationalPolicy, input.nationalCode)
|
|
? nationalPolicy
|
|
: null,
|
|
);
|
|
|
|
if (!merged && plateLetterValid && lookup.byPlate && !platePolicy) {
|
|
try {
|
|
const raw = await lookup.byPlate();
|
|
if (raw) {
|
|
const fallback = normalizeSoapCarPolicy(raw, input.nationalCode);
|
|
merged = selectBestThirdPartyPolicy([fallback], input.nationalCode);
|
|
if (merged) {
|
|
sources.push('plateFallback');
|
|
}
|
|
}
|
|
} catch {
|
|
merged = null;
|
|
}
|
|
}
|
|
|
|
if (!merged) {
|
|
return null;
|
|
}
|
|
|
|
return { policy: merged, sources };
|
|
}
|
|
|
|
function pickField(policy: Record<string, string>, keys: string[]): string {
|
|
for (const key of keys) {
|
|
const value = policy[key]?.trim();
|
|
if (value) {
|
|
return value;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function parsePolicyDate(value: string): Date | undefined {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
|
|
// Jalali dates from CentInsur: 1405/05/23
|
|
const jalaliSlash = /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/.exec(trimmed);
|
|
if (jalaliSlash) {
|
|
const year = Number(jalaliSlash[1]);
|
|
const month = Number(jalaliSlash[2]);
|
|
const day = Number(jalaliSlash[3]);
|
|
// Approximate Gregorian for overlap checks (Jalali year + 621, month/day as-is).
|
|
// Good enough for "overlaps current Jalali year" when both ends use the same scheme.
|
|
if (year > 1300 && year < 1600) {
|
|
const date = new Date(year + 621, month - 1, day);
|
|
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
}
|
|
}
|
|
|
|
const compact = trimmed.replace(/[^\d]/g, '');
|
|
if (compact.length === 8) {
|
|
const year = Number(compact.slice(0, 4));
|
|
const month = Number(compact.slice(4, 6));
|
|
const day = Number(compact.slice(6, 8));
|
|
if (year > 1300 && year < 1600) {
|
|
const date = new Date(year + 621, month - 1, day);
|
|
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
}
|
|
const date = new Date(year, month - 1, day);
|
|
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
}
|
|
|
|
const parsed = new Date(trimmed);
|
|
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
}
|
|
|
|
function isZeroKmPlate(plk1: string, plk2: string, plk3: string, plksrl: string): boolean {
|
|
return !plk1.trim() && !plk2.trim() && !plk3.trim() && !plksrl.trim();
|
|
}
|
|
|
|
function normalizeIdentity(value: string): string {
|
|
return value.trim().toUpperCase();
|
|
}
|
|
|
|
function jalaliYearStartGregorian(jalaliYear: number): Date {
|
|
const march = 20;
|
|
const gregorianYear = jalaliYear + 621;
|
|
return new Date(gregorianYear, 2, march);
|
|
}
|