Files
yara724api/src/request-management/inquiry-participant-resolver.ts
2026-09-19 12:11:43 +03:30

579 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
const PARTICIPANT_ROLE_LABELS: Record<InquiryParticipantRole, string> = {
[InquiryParticipantRole.DRIVER]: "راننده",
[InquiryParticipantRole.VEHICLE_OWNER]: "مالک خودرو",
[InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER]: "بیمه‌گذار شخص ثالث",
[InquiryParticipantRole.CAR_BODY_POLICYHOLDER]: "بیمه‌گذار بدنه",
};
const PLATE_FIELD_LABELS = {
leftDigits: "دو رقم سمت چپ پلاک",
centerAlphabet: "حرف پلاک",
centerDigits: "سه رقم میانی پلاک",
ir: "کد ایران پلاک",
} as const;
/**
* 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(
"اطلاعات هویتی مالک خودرو برای استعلام شبا الزامی است.",
);
}
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() === "") {
const plateLabel =
path === "vehicle.currentPlate" ? "پلاک فعلی" : "پلاک قبلی";
throw new BadRequestException(
`${PLATE_FIELD_LABELS[field]} در ${plateLabel} الزامی است.`,
);
}
}
}
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(
`شماره همراه ${PARTICIPANT_ROLE_LABELS[role]} باید جدا از اطلاعات هویتی استعلام ارسال شود.`,
);
}
const nationalCode = String(input.nationalCode ?? "").trim();
const birthday = String(input.birthday ?? "").trim();
if (!nationalCode || !birthday) {
throw new BadRequestException(
`کد ملی و تاریخ تولد ${PARTICIPANT_ROLE_LABELS[role]} الزامی است.`,
);
}
if (
role === InquiryParticipantRole.DRIVER &&
typeof input.hasDrivingLicense !== "boolean"
) {
throw new BadRequestException("وضعیت داشتن گواهینامه راننده الزامی است.");
}
if (
role === InquiryParticipantRole.DRIVER &&
input.hasDrivingLicense === true &&
(!String(input.licenseNumber ?? "").trim() ||
!String(input.licenseType ?? "").trim())
) {
throw new BadRequestException(
"شماره و نوع گواهینامه برای راننده دارای گواهینامه الزامی است.",
);
}
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(
"اطلاعات راننده، مالک خودرو و بیمه‌گذار شخص ثالث برای استعلام الزامی است.",
);
}
if (
caseType === BlameRequestType.THIRD_PARTY &&
input.carBodyPolicyholder != null
) {
throw new BadRequestException(
"بیمه‌گذار بدنه برای پرونده شخص ثالث قابل ثبت نیست.",
);
}
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(
"ارتباط اشخاص یکسان در اطلاعات استعلام نامعتبر است.",
);
}
const value = input[ROLE_FIELDS[role]] as
| InquiryParticipantInputDto
| undefined;
if (!value) {
throw new BadRequestException(
`اطلاعات ${PARTICIPANT_ROLE_LABELS[role]} الزامی است.`,
);
}
resolving.add(role);
let participantId: string;
if (Object.prototype.hasOwnProperty.call(value, "unknown")) {
throw new BadRequestException(
`ثبت ${PARTICIPANT_ROLE_LABELS[role]} به‌صورت نامشخص امکان‌پذیر نیست.`,
);
} else if (value.sameAs) {
const hasPersonSpecificFields = Object.entries(value).some(
([field, fieldValue]) => field !== "sameAs" && fieldValue != null,
);
if (hasPersonSpecificFields) {
throw new BadRequestException(
`برای ${PARTICIPANT_ROLE_LABELS[role]} باید فقط ارتباط با شخص دیگر یا اطلاعات هویتی مستقل ارسال شود.`,
);
}
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(
`کد ملی ${PARTICIPANT_ROLE_LABELS[role]} تکراری است؛ ارتباط با شخص ثبت‌شده را انتخاب کنید.`,
);
}
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("اطلاعات خودرو برای استعلام الزامی است.");
}
const registrationState =
input.registrationState ?? VehicleRegistrationState.CURRENT;
if (!Object.values(VehicleRegistrationState).includes(registrationState)) {
throw new BadRequestException(
"وضعیت پلاک خودرو باید «فعلی» یا «تازه تعویض‌شده» باشد.",
);
}
if (input.isNewCar != null && typeof input.isNewCar !== "boolean") {
throw new BadRequestException("وضعیت صفر بودن خودرو نامعتبر است.");
}
const previousPolicyholderNationalCode = String(
input.previousPolicyholderNationalCode ?? "",
).trim();
if (!input.currentPlate) {
throw new BadRequestException("پلاک فعلی خودرو برای استعلام الزامی است.");
}
assertCompleteInquiryPlate(input.currentPlate, "vehicle.currentPlate");
const vin = String(input.vin ?? "").trim();
if (vin && vin.length !== 17) {
throw new BadRequestException(
"شماره شاسی (VIN) باید دقیقاً ۱۷ کاراکتر باشد.",
);
}
if (
registrationState === VehicleRegistrationState.RECENTLY_TRANSFERRED &&
(!input.previousPlate || !vin || !previousPolicyholderNationalCode)
) {
throw new BadRequestException(
"برای خودروی تازه تعویض‌پلاک‌شده، پلاک قبلی، شماره شاسی (VIN) و کد ملی بیمه‌گذار قبلی الزامی است.",
);
}
if (
registrationState !== VehicleRegistrationState.RECENTLY_TRANSFERRED &&
(input.previousPlate || input.previousPolicyholderNationalCode != null)
) {
throw new BadRequestException(
"پلاک و کد ملی بیمه‌گذار قبلی فقط برای خودروی تازه تعویض‌پلاک‌شده قابل ثبت است.",
);
}
if (input.previousPlate) {
assertCompleteInquiryPlate(input.previousPlate, "vehicle.previousPlate");
}
return {
...input,
registrationState,
...(vin ? { vin } : {}),
...(previousPolicyholderNationalCode
? { previousPolicyholderNationalCode }
: {}),
};
}
/** 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;
}
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(
"ساختار قدیمی اطلاعات استعلام پذیرفته نمی‌شود؛ اطلاعات اشخاص و خودرو را در بخش‌های جدید ارسال کنید.",
);
}
}
/**
* Run a policy inquiry only for the submitted current plate. Recent-transfer
* data is retained as case metadata, but must never trigger an inquiry for a
* previous plate or a previous policyholder.
*/
export async function runCurrentPlateInquiry<T>(options: {
currentPlate: InquiryVehicleInputDto["currentPlate"];
vin?: string;
query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise<T>;
isUsable: (value: T) => boolean;
errorMessage?: (value: T) => string | undefined;
}): Promise<{
value: T;
plateKind: "CURRENT";
attempts: Array<{
plateKind: "CURRENT";
plate: InquiryVehicleInputDto["currentPlate"];
vin?: string;
succeeded: boolean;
usable?: boolean;
error?: string;
}>;
}> {
const attempts: Array<{
plateKind: "CURRENT";
plate: InquiryVehicleInputDto["currentPlate"];
vin?: string;
succeeded: boolean;
usable?: boolean;
error?: string;
}> = [];
try {
const value = await options.query(options.currentPlate);
const usable = options.isUsable(value);
if (!usable) {
attempts.push({
plateKind: "CURRENT",
plate: options.currentPlate,
...(options.vin ? { vin: options.vin } : {}),
succeeded: true,
usable: false,
});
const error = new BadRequestException(
options.errorMessage?.(value) ||
"بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts;
throw error;
}
attempts.push({
plateKind: "CURRENT",
plate: options.currentPlate,
...(options.vin ? { vin: options.vin } : {}),
succeeded: true,
usable: true,
});
return { value, plateKind: "CURRENT", attempts };
} catch (error) {
const alreadyRecorded =
typeof error === "object" &&
error != null &&
Array.isArray((error as { attempts?: unknown }).attempts);
if (!alreadyRecorded) {
attempts.push({
plateKind: "CURRENT",
plate: options.currentPlate,
...(options.vin ? { vin: options.vin } : {}),
succeeded: false,
error: error instanceof Error ? error.message : String(error),
});
}
if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts;
}
throw error;
}
}
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(
"اطلاعات هویتی راننده و بیمه‌گذار شخص ثالث الزامی است.",
);
}
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,
};
}