fix: harden claim review and inquiry workflows

Preserve damage history and current vehicle price, restore depreciation mapping, normalize inquiry/report output, and support resumable expert review with paginated case retrieval.
This commit is contained in:
2026-09-18 16:04:33 +03:30
parent a84d83a135
commit 7d1a50db7b
38 changed files with 2345 additions and 370 deletions

View File

@@ -60,6 +60,20 @@ export interface InquirySubjects {
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
@@ -70,7 +84,7 @@ export function resolveInquirySubjects(
): InquirySubjects {
if (!submission.vehicleOwner) {
throw new BadRequestException(
"Vehicle owner identity is required for Sheba validation.",
"اطلاعات هویتی مالک خودرو برای استعلام شبا الزامی است.",
);
}
return {
@@ -102,7 +116,11 @@ function assertCompleteInquiryPlate(
"ir",
] as const) {
if (plate?.[field] == null || String(plate[field]).trim() === "") {
throw new BadRequestException(`${path}.${field} is required.`);
const plateLabel =
path === "vehicle.currentPlate" ? "پلاک فعلی" : "پلاک قبلی";
throw new BadRequestException(
`${PLATE_FIELD_LABELS[field]} در ${plateLabel} الزامی است.`,
);
}
}
}
@@ -171,21 +189,21 @@ function requiredIdentity(
): ResolvedInquiryParticipant {
if (Object.prototype.hasOwnProperty.call(input, "phoneNumber")) {
throw new BadRequestException(
`${role} does not accept phoneNumber; phone numbers are collected separately from inquiry identity.`,
`شماره همراه ${PARTICIPANT_ROLE_LABELS[role]} باید جدا از اطلاعات هویتی استعلام ارسال شود.`,
);
}
const nationalCode = String(input.nationalCode ?? "").trim();
const birthday = String(input.birthday ?? "").trim();
if (!nationalCode || !birthday) {
throw new BadRequestException(
`${role} requires nationalCode and birthday.`,
`کد ملی و تاریخ تولد ${PARTICIPANT_ROLE_LABELS[role]} الزامی است.`,
);
}
if (
role === InquiryParticipantRole.DRIVER &&
typeof input.hasDrivingLicense !== "boolean"
) {
throw new BadRequestException("DRIVER requires hasDrivingLicense.");
throw new BadRequestException("وضعیت داشتن گواهینامه راننده الزامی است.");
}
if (
role === InquiryParticipantRole.DRIVER &&
@@ -194,7 +212,7 @@ function requiredIdentity(
!String(input.licenseType ?? "").trim())
) {
throw new BadRequestException(
"DRIVER requires licenseNumber and licenseType when hasDrivingLicense is true.",
"شماره و نوع گواهینامه برای راننده دارای گواهینامه الزامی است.",
);
}
return {
@@ -220,7 +238,7 @@ export function resolveInquiryParticipants(
);
if (!hasRoleCompleteInput) {
throw new BadRequestException(
"driver, vehicleOwner, and thirdPartyPolicyholder are required in the structured inquiry format.",
"اطلاعات راننده، مالک خودرو و بیمه‌گذار شخص ثالث برای استعلام الزامی است.",
);
}
if (
@@ -228,7 +246,7 @@ export function resolveInquiryParticipants(
input.carBodyPolicyholder != null
) {
throw new BadRequestException(
"CAR_BODY_POLICYHOLDER is not allowed for a THIRD_PARTY case.",
"بیمه‌گذار بدنه برای پرونده شخص ثالث قابل ثبت نیست.",
);
}
@@ -249,19 +267,23 @@ export function resolveInquiryParticipants(
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.`);
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(
`${role} does not support the unknown option.`,
`ثبت ${PARTICIPANT_ROLE_LABELS[role]} به‌صورت نامشخص امکان‌پذیر نیست.`,
);
} else if (value.sameAs) {
const hasPersonSpecificFields = Object.entries(value).some(
@@ -269,7 +291,7 @@ export function resolveInquiryParticipants(
);
if (hasPersonSpecificFields) {
throw new BadRequestException(
`${role} must contain either sameAs or identity fields, not both.`,
`برای ${PARTICIPANT_ROLE_LABELS[role]} باید فقط ارتباط با شخص دیگر یا اطلاعات هویتی مستقل ارسال شود.`,
);
}
participantId = resolveRole(value.sameAs);
@@ -280,7 +302,7 @@ export function resolveInquiryParticipants(
);
if (duplicate) {
throw new BadRequestException(
`${role} duplicates an existing nationalCode; use sameAs instead.`,
`کد ملی ${PARTICIPANT_ROLE_LABELS[role]} تکراری است؛ ارتباط با شخص ثبت‌شده را انتخاب کنید.`,
);
}
participants.set(participant.participantId, participant);
@@ -317,29 +339,29 @@ export function resolveInquiryVehicle(
input: InquiryVehicleInputDto,
): ResolvedInquiryVehicle {
if (!input) {
throw new BadRequestException("vehicle is required.");
throw new BadRequestException("اطلاعات خودرو برای استعلام الزامی است.");
}
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.");
throw new BadRequestException("وضعیت صفر بودن خودرو نامعتبر است.");
}
const previousPolicyholderNationalCode = String(
input.previousPolicyholderNationalCode ?? "",
).trim();
if (!input.currentPlate) {
throw new BadRequestException("vehicle.currentPlate is required.");
throw new BadRequestException("پلاک فعلی خودرو برای استعلام الزامی است.");
}
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.",
"شماره شاسی (VIN) باید دقیقاً ۱۷ کاراکتر باشد.",
);
}
if (
@@ -347,7 +369,7 @@ export function resolveInquiryVehicle(
(!input.previousPlate || !vin || !previousPolicyholderNationalCode)
) {
throw new BadRequestException(
"RECENTLY_TRANSFERRED requires previousPlate, vin, and previousPolicyholderNationalCode.",
"برای خودروی تازه تعویض‌پلاک‌شده، پلاک قبلی، شماره شاسی (VIN) و کد ملی بیمه‌گذار قبلی الزامی است.",
);
}
if (
@@ -355,7 +377,7 @@ export function resolveInquiryVehicle(
(input.previousPlate || input.previousPolicyholderNationalCode != null)
) {
throw new BadRequestException(
"previousPlate and previousPolicyholderNationalCode are only allowed for RECENTLY_TRANSFERRED vehicles.",
"پلاک و کد ملی بیمه‌گذار قبلی فقط برای خودروی تازه تعویض‌پلاک‌شده قابل ثبت است.",
);
}
if (input.previousPlate) {
@@ -442,7 +464,7 @@ function assertStructuredInquiryInput(input: Record<string, any>): void {
);
if (legacyFields.length > 0) {
throw new BadRequestException(
`Legacy inquiry fields are not accepted: ${legacyFields.join(", ")}. Use driver, vehicleOwner, thirdPartyPolicyholder, carBodyPolicyholder, and vehicle.`,
"ساختار قدیمی اطلاعات استعلام پذیرفته نمی‌شود؛ اطلاعات اشخاص و خودرو را در بخش‌های جدید ارسال کنید.",
);
}
}
@@ -466,7 +488,7 @@ export function assertPreviousPlateInquiryMatchesVin(
.filter(Boolean);
if (!expected || !candidates.includes(expected)) {
throw new BadRequestException(
"Previous-plate inquiry does not match the submitted VIN/chassis; manual review is required.",
"نتیجه استعلام پلاک قبلی با شماره شاسی (VIN) واردشده مطابقت ندارد و پرونده نیازمند بررسی دستی است.",
);
}
}
@@ -540,7 +562,7 @@ export async function runPlateInquiryWithFallback<T>(options: {
});
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;
@@ -592,7 +614,12 @@ export async function runPlateInquiryWithFallback<T>(options: {
}
}
throw lastError ?? new BadRequestException("Inquiry failed for all plates.");
throw (
lastError ??
new BadRequestException(
"برای هیچ‌یک از پلاک‌های ثبت‌شده نتیجه معتبری یافت نشد.",
)
);
}
export function normalizeInquirySubmission<T extends Record<string, any>>(
@@ -610,7 +637,7 @@ export function normalizeInquirySubmission<T extends Record<string, any>>(
);
if (!driver || !thirdPartyPolicyholder) {
throw new BadRequestException(
"Driver and third-party policyholder identities are required.",
"اطلاعات هویتی راننده و بیمه‌گذار شخص ثالث الزامی است.",
);
}
const vehicleOwner = participantForRole(