forked from Shared/esg
feat: add car inquiries with resilience, ownership privacy, and retry visibility
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>
This commit is contained in:
323
src/common/helpers/third-party-car-rules.helper.ts
Normal file
323
src/common/helpers/third-party-car-rules.helper.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user