Files
yara724api/src/request-management/inquiry-participant-resolver.ts
2026-09-14 14:27:23 +03:30

659 lines
21 KiB
TypeScript

import { BadRequestException } from "@nestjs/common";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
import { gregorianDateInIran } from "src/helpers/iran-datetime";
import {
InquiryParticipantFieldsDto,
InquiryParticipantInputDto,
InquiryParticipantRole,
InquiryVehicleInputDto,
VehicleRegistrationState,
} from "src/common/dto/inquiry-participants.dto";
export interface ResolvedInquiryParticipant {
participantId: string;
nationalCode: string;
birthday: string;
fullName?: string;
hasDrivingLicense?: boolean;
licenseNumber?: string;
licenseType?: string;
}
export interface InquiryParticipantRoleAssignments {
driver: string;
vehicleOwner?: string;
thirdPartyPolicyholder: string;
carBodyPolicyholder?: string;
}
export interface ResolvedInquiryParticipants {
participants: ResolvedInquiryParticipant[];
roles: InquiryParticipantRoleAssignments;
legacy: boolean;
}
export interface NormalizedInquirySubmission<T extends Record<string, any>> {
dto: T & {
nationalCodeOfDriver: string;
driverBirthday: string;
driverLicense?: string;
licenseType?: string;
userNoCertificate?: boolean;
nationalCodeOfInsurer: string;
insurerBirthday: string;
driverIsInsurer: boolean;
insurerLicense?: string;
};
participants: ResolvedInquiryParticipants;
driver: ResolvedInquiryParticipant;
vehicleOwner?: ResolvedInquiryParticipant;
thirdPartyPolicyholder: ResolvedInquiryParticipant;
carBodyPolicyholder?: ResolvedInquiryParticipant;
vehicle?: ResolvedInquiryVehicle;
}
export interface InquirySubjects {
thirdPartyPolicyNationalCode: string;
carBodyPolicyNationalCode?: string;
shebaNationalCode: string;
driverNationalCode: string;
}
/**
* The single routing seam for external inquiries. Policy checks belong to
* their policyholder, Sheba belongs to the vehicle owner, and licence data
* belongs to the driver even when those roles resolve to different people.
*/
export function resolveInquirySubjects(
submission: NormalizedInquirySubmission<Record<string, any>>,
): InquirySubjects {
if (!submission.vehicleOwner) {
throw new BadRequestException(
"Vehicle owner identity is required for Sheba validation.",
);
}
return {
thirdPartyPolicyNationalCode:
submission.thirdPartyPolicyholder.nationalCode,
...(submission.carBodyPolicyholder
? {
carBodyPolicyNationalCode:
submission.carBodyPolicyholder.nationalCode,
}
: {}),
shebaNationalCode: submission.vehicleOwner.nationalCode,
driverNationalCode: submission.driver.nationalCode,
};
}
export type ResolvedInquiryVehicle = InquiryVehicleInputDto & {
registrationState: VehicleRegistrationState;
};
function assertCompleteInquiryPlate(
plate: InquiryVehicleInputDto["currentPlate"],
path: "vehicle.currentPlate" | "vehicle.previousPlate",
): void {
for (const field of [
"leftDigits",
"centerAlphabet",
"centerDigits",
"ir",
] as const) {
if (plate?.[field] == null || String(plate[field]).trim() === "") {
throw new BadRequestException(`${path}.${field} is required.`);
}
}
}
export function participantForRole(
resolved: ResolvedInquiryParticipants,
role: InquiryParticipantRole,
): ResolvedInquiryParticipant | undefined {
const roleField = ROLE_FIELDS[
role
] as keyof InquiryParticipantRoleAssignments;
const participantId = resolved.roles[roleField];
return resolved.participants.find(
(participant) => participant.participantId === participantId,
);
}
/** Resolve a participant from the role links persisted on a blame party. */
export function participantForStoredPartyRole(
party: {
participants?: Array<Record<string, any>>;
participantRoles?: Partial<InquiryParticipantRoleAssignments>;
},
role: InquiryParticipantRole,
): Record<string, any> | undefined {
const roleField = ROLE_FIELDS[
role
] as keyof InquiryParticipantRoleAssignments;
const participantId = party.participantRoles?.[roleField];
if (!participantId || !Array.isArray(party.participants)) return undefined;
return party.participants.find(
(participant) => participant?.participantId === participantId,
);
}
/** Remove fields retired from the participant contract from historical records. */
export function sanitizeStoredInquiryParticipants(
participants: unknown,
): Array<Record<string, any>> | undefined {
if (!Array.isArray(participants)) return undefined;
return participants.map((participant) => {
const plain =
participant &&
typeof participant === "object" &&
typeof (participant as { toObject?: unknown }).toObject === "function"
? (participant as { toObject: () => Record<string, any> }).toObject()
: { ...(participant as Record<string, any>) };
delete plain.unknown;
return plain;
});
}
const ROLE_FIELDS: Record<
InquiryParticipantRole,
keyof InquiryParticipantFieldsDto
> = {
[InquiryParticipantRole.DRIVER]: "driver",
[InquiryParticipantRole.VEHICLE_OWNER]: "vehicleOwner",
[InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER]: "thirdPartyPolicyholder",
[InquiryParticipantRole.CAR_BODY_POLICYHOLDER]: "carBodyPolicyholder",
};
function requiredIdentity(
role: InquiryParticipantRole,
input: InquiryParticipantInputDto,
): ResolvedInquiryParticipant {
if (Object.prototype.hasOwnProperty.call(input, "phoneNumber")) {
throw new BadRequestException(
`${role} does not accept phoneNumber; phone numbers are collected separately from inquiry identity.`,
);
}
const nationalCode = String(input.nationalCode ?? "").trim();
const birthday = String(input.birthday ?? "").trim();
if (!nationalCode || !birthday) {
throw new BadRequestException(
`${role} requires nationalCode and birthday.`,
);
}
if (
role === InquiryParticipantRole.DRIVER &&
typeof input.hasDrivingLicense !== "boolean"
) {
throw new BadRequestException("DRIVER requires hasDrivingLicense.");
}
if (
role === InquiryParticipantRole.DRIVER &&
input.hasDrivingLicense === true &&
(!String(input.licenseNumber ?? "").trim() ||
!String(input.licenseType ?? "").trim())
) {
throw new BadRequestException(
"DRIVER requires licenseNumber and licenseType when hasDrivingLicense is true.",
);
}
return {
participantId: role,
nationalCode,
birthday,
...(input.fullName ? { fullName: input.fullName } : {}),
...(input.hasDrivingLicense != null
? { hasDrivingLicense: input.hasDrivingLicense }
: {}),
...(input.licenseNumber ? { licenseNumber: input.licenseNumber } : {}),
...(input.licenseType ? { licenseType: input.licenseType } : {}),
};
}
export function resolveInquiryParticipants(
caseType: BlameRequestType,
input: Partial<InquiryParticipantFieldsDto> & Record<string, any>,
): ResolvedInquiryParticipants {
assertStructuredInquiryInput(input);
const hasRoleCompleteInput = Object.values(ROLE_FIELDS).some(
(field) => input[field] != null,
);
if (!hasRoleCompleteInput) {
throw new BadRequestException(
"driver, vehicleOwner, and thirdPartyPolicyholder are required in the structured inquiry format.",
);
}
if (
caseType === BlameRequestType.THIRD_PARTY &&
input.carBodyPolicyholder != null
) {
throw new BadRequestException(
"CAR_BODY_POLICYHOLDER is not allowed for a THIRD_PARTY case.",
);
}
const requiredRoles = [
InquiryParticipantRole.DRIVER,
InquiryParticipantRole.VEHICLE_OWNER,
InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER,
...(caseType === BlameRequestType.CAR_BODY
? [InquiryParticipantRole.CAR_BODY_POLICYHOLDER]
: []),
];
const participants = new Map<string, ResolvedInquiryParticipant>();
const resolvedRoleIds = new Map<InquiryParticipantRole, string>();
const resolving = new Set<InquiryParticipantRole>();
const resolveRole = (role: InquiryParticipantRole): string => {
const existing = resolvedRoleIds.get(role);
if (existing) return existing;
if (resolving.has(role)) {
throw new BadRequestException(
"Participant sameAs references cannot be circular.",
);
}
const value = input[ROLE_FIELDS[role]] as
| InquiryParticipantInputDto
| undefined;
if (!value) throw new BadRequestException(`${role} is required.`);
resolving.add(role);
let participantId: string;
if (Object.prototype.hasOwnProperty.call(value, "unknown")) {
throw new BadRequestException(
`${role} does not support the unknown option.`,
);
} else if (value.sameAs) {
const hasPersonSpecificFields = Object.entries(value).some(
([field, fieldValue]) => field !== "sameAs" && fieldValue != null,
);
if (hasPersonSpecificFields) {
throw new BadRequestException(
`${role} must contain either sameAs or identity fields, not both.`,
);
}
participantId = resolveRole(value.sameAs);
} else {
const participant = requiredIdentity(role, value);
const duplicate = [...participants.values()].find(
(item) => item.nationalCode === participant.nationalCode,
);
if (duplicate) {
throw new BadRequestException(
`${role} duplicates an existing nationalCode; use sameAs instead.`,
);
}
participants.set(participant.participantId, participant);
participantId = participant.participantId;
}
resolving.delete(role);
resolvedRoleIds.set(role, participantId);
return participantId;
};
for (const role of requiredRoles) resolveRole(role);
return {
participants: [...participants.values()],
roles: {
driver: resolvedRoleIds.get(InquiryParticipantRole.DRIVER)!,
vehicleOwner: resolvedRoleIds.get(InquiryParticipantRole.VEHICLE_OWNER)!,
thirdPartyPolicyholder: resolvedRoleIds.get(
InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER,
)!,
...(caseType === BlameRequestType.CAR_BODY
? {
carBodyPolicyholder: resolvedRoleIds.get(
InquiryParticipantRole.CAR_BODY_POLICYHOLDER,
)!,
}
: {}),
},
legacy: false,
};
}
export function resolveInquiryVehicle(
input: InquiryVehicleInputDto,
): ResolvedInquiryVehicle {
if (!input) {
throw new BadRequestException("vehicle is required.");
}
const registrationState =
input.registrationState ?? VehicleRegistrationState.CURRENT;
if (!Object.values(VehicleRegistrationState).includes(registrationState)) {
throw new BadRequestException(
"vehicle.registrationState must be CURRENT or RECENTLY_TRANSFERRED.",
);
}
if (input.isNewCar != null && typeof input.isNewCar !== "boolean") {
throw new BadRequestException("vehicle.isNewCar must be a boolean.");
}
const previousPolicyholderNationalCode = String(
input.previousPolicyholderNationalCode ?? "",
).trim();
if (!input.currentPlate) {
throw new BadRequestException("vehicle.currentPlate is required.");
}
assertCompleteInquiryPlate(input.currentPlate, "vehicle.currentPlate");
const vin = String(input.vin ?? "").trim();
if (vin && vin.length !== 17) {
throw new BadRequestException(
"vehicle.vin must contain exactly 17 characters.",
);
}
if (
registrationState === VehicleRegistrationState.RECENTLY_TRANSFERRED &&
(!input.previousPlate || !vin || !previousPolicyholderNationalCode)
) {
throw new BadRequestException(
"RECENTLY_TRANSFERRED requires previousPlate, vin, and previousPolicyholderNationalCode.",
);
}
if (
registrationState !== VehicleRegistrationState.RECENTLY_TRANSFERRED &&
(input.previousPlate || input.previousPolicyholderNationalCode != null)
) {
throw new BadRequestException(
"previousPlate and previousPolicyholderNationalCode are only allowed for RECENTLY_TRANSFERRED vehicles.",
);
}
if (input.previousPlate) {
assertCompleteInquiryPlate(input.previousPlate, "vehicle.previousPlate");
}
return {
...input,
registrationState,
...(vin ? { vin } : {}),
...(previousPolicyholderNationalCode
? { previousPolicyholderNationalCode }
: {}),
};
}
export function vehiclePlateCandidates(input?: ResolvedInquiryVehicle): Array<{
kind: "CURRENT" | "PREVIOUS";
plate: InquiryVehicleInputDto["currentPlate"];
}> {
if (!input) return [];
return [
{ kind: "CURRENT" as const, plate: input.currentPlate },
...(input.registrationState ===
VehicleRegistrationState.RECENTLY_TRANSFERRED && input.previousPlate
? [{ kind: "PREVIOUS" as const, plate: input.previousPlate }]
: []),
];
}
function normalizeVehicleSerial(value: unknown): string {
return String(value ?? "")
.toUpperCase()
.replace(/[^A-Z0-9]/g, "");
}
/** A dated result is usable only while the returned policy has not expired. */
export function isMappedPolicyCurrent(
mapped: Record<string, any>,
todayGregorian: string = gregorianDateInIran(new Date()),
): boolean {
const rawEndDate =
mapped?.EndDate ??
mapped?.HEndDte ??
mapped?.persianEndDate ??
mapped?.PolicyEndDate ??
mapped?.endDate;
if (rawEndDate == null || String(rawEndDate).trim() === "") return true;
const endDate = jalaliToGregorianDate(rawEndDate);
return endDate != null && endDate >= todayGregorian;
}
function normalizePlateForComparison(
plate: InquiryVehicleInputDto["currentPlate"],
): string {
return [
plate?.ir,
plate?.leftDigits,
plate?.centerAlphabet,
plate?.centerDigits,
]
.map((part) => String(part ?? "").trim())
.join("|");
}
const LEGACY_INQUIRY_FIELDS = [
"nationalCodeOfDriver",
"driverBirthday",
"driverLicense",
"licenseType",
"nationalCodeOfInsurer",
"insurerBirthday",
"insurerLicense",
"driverIsInsurer",
"userNoCertificate",
"plate",
"plateId",
"vin",
"isNewCar",
] as const;
function assertStructuredInquiryInput(input: Record<string, any>): void {
const legacyFields = LEGACY_INQUIRY_FIELDS.filter((field) =>
Object.prototype.hasOwnProperty.call(input, field),
);
if (legacyFields.length > 0) {
throw new BadRequestException(
`Legacy inquiry fields are not accepted: ${legacyFields.join(", ")}. Use driver, vehicleOwner, thirdPartyPolicyholder, carBodyPolicyholder, and vehicle.`,
);
}
}
export function assertPreviousPlateInquiryMatchesVin(
expectedVin: string,
mapped: Record<string, any>,
): void {
const expected = normalizeVehicleSerial(expectedVin);
const candidates = [
mapped?.VinNumberField,
mapped?.vin,
mapped?.VIN,
mapped?.ChassisNumberField,
mapped?.chassisNumber,
mapped?.ChassisNo,
mapped?.vehicle?.VIN,
mapped?.vehicle?.ChassisNo,
]
.map(normalizeVehicleSerial)
.filter(Boolean);
if (!expected || !candidates.includes(expected)) {
throw new BadRequestException(
"Previous-plate inquiry does not match the submitted VIN/chassis; manual review is required.",
);
}
}
export function isPolicyNotFoundError(error: unknown): boolean {
const candidate = error as Record<string, any> | null;
const status = candidate?.status ?? candidate?.response?.status;
if (Number(status) === 404) return true;
const code = String(
candidate?.code ?? candidate?.response?.data?.code ?? "",
).toUpperCase();
if (["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY"].includes(code)) {
return true;
}
const message = String(
candidate?.message ?? candidate?.response?.data?.message ?? error ?? "",
);
return /\bnot[ -]?found\b|\bno (?:relevant )?policy\b|یافت نشد|فاقد بیمه(?:نامه)?/i.test(
message,
);
}
export async function runPlateInquiryWithFallback<T>(options: {
vehicle?: ResolvedInquiryVehicle;
fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"];
query: (
plate: InquiryVehicleInputDto["currentPlate"],
plateKind: "CURRENT" | "PREVIOUS",
) => Promise<T>;
isUsable: (value: T) => boolean;
mappedValue: (value: T) => Record<string, any>;
shouldFallbackOnError?: (error: unknown) => boolean;
}): Promise<{
value: T;
plateKind: "CURRENT" | "PREVIOUS";
attempts: Array<{
plateKind: "CURRENT" | "PREVIOUS";
plate: InquiryVehicleInputDto["currentPlate"];
vin?: string;
succeeded: boolean;
usable?: boolean;
error?: string;
}>;
}> {
const candidates = options.vehicle
? vehiclePlateCandidates(options.vehicle)
: [{ kind: "CURRENT" as const, plate: options.fallbackCurrentPlate }];
let lastError: unknown;
const attempts: Array<{
plateKind: "CURRENT" | "PREVIOUS";
plate: InquiryVehicleInputDto["currentPlate"];
vin?: string;
succeeded: boolean;
usable?: boolean;
error?: string;
}> = [];
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
const isLast = index === candidates.length - 1;
try {
const value = await options.query(candidate.plate, candidate.kind);
const usable = options.isUsable(value);
if (!usable) {
attempts.push({
plateKind: candidate.kind,
plate: candidate.plate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
succeeded: true,
usable: false,
});
if (!isLast) continue;
const error = new BadRequestException(
"No current usable policy was found for the submitted vehicle identifiers.",
) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts;
throw error;
}
if (candidate.kind === "PREVIOUS" && usable) {
assertPreviousPlateInquiryMatchesVin(
options.vehicle!.vin!,
options.mappedValue(value),
);
}
attempts.push({
plateKind: candidate.kind,
plate: candidate.plate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
succeeded: true,
usable: true,
});
return { value, plateKind: candidate.kind, attempts };
} catch (error) {
lastError = error;
const alreadyRecorded =
typeof error === "object" &&
error != null &&
Array.isArray((error as { attempts?: unknown }).attempts);
if (!alreadyRecorded) {
attempts.push({
plateKind: candidate.kind,
plate: candidate.plate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
succeeded: false,
error: error instanceof Error ? error.message : String(error),
});
}
if (
!isLast &&
!(options.shouldFallbackOnError ?? isPolicyNotFoundError)(error)
) {
if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts;
}
throw error;
}
if (isLast) {
if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts;
}
throw error;
}
}
}
throw lastError ?? new BadRequestException("Inquiry failed for all plates.");
}
export function normalizeInquirySubmission<T extends Record<string, any>>(
caseType: BlameRequestType,
input: T,
): NormalizedInquirySubmission<T> {
const participants = resolveInquiryParticipants(caseType, input as any);
const driver = participantForRole(
participants,
InquiryParticipantRole.DRIVER,
);
const thirdPartyPolicyholder = participantForRole(
participants,
InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER,
);
if (!driver || !thirdPartyPolicyholder) {
throw new BadRequestException(
"Driver and third-party policyholder identities are required.",
);
}
const vehicleOwner = participantForRole(
participants,
InquiryParticipantRole.VEHICLE_OWNER,
);
const carBodyPolicyholder = participantForRole(
participants,
InquiryParticipantRole.CAR_BODY_POLICYHOLDER,
);
const vehicle = resolveInquiryVehicle(
input.vehicle as InquiryVehicleInputDto,
);
const sameDriverAndPolicyholder =
participants.roles.driver === participants.roles.thirdPartyPolicyholder;
return {
dto: {
...input,
plate: vehicle.currentPlate,
vin: vehicle.vin,
nationalCodeOfDriver: driver.nationalCode,
driverBirthday: driver.birthday,
driverLicense: driver.licenseNumber,
licenseType: driver.licenseType,
userNoCertificate:
driver.hasDrivingLicense == null
? undefined
: !driver.hasDrivingLicense,
nationalCodeOfInsurer: thirdPartyPolicyholder.nationalCode,
insurerBirthday: thirdPartyPolicyholder.birthday,
driverIsInsurer: sameDriverAndPolicyholder,
insurerLicense: sameDriverAndPolicyholder
? driver.licenseNumber
: undefined,
isNewCar: vehicle.isNewCar,
} as NormalizedInquirySubmission<T>["dto"],
participants,
driver,
vehicleOwner,
thirdPartyPolicyholder,
carBodyPolicyholder,
vehicle,
};
}