forked from Yara724/api
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:
@@ -360,7 +360,7 @@
|
|||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p class="note" style="margin-top:8px;">
|
<p class="note" style="margin-top:8px;">
|
||||||
ESG wraps every response as <code>{ success: boolean, data: … }</code>. A <code>success=false</code> body is translated to a Persian "استعلام در دسترس نیست" (inquiry unavailable) error.
|
ESG wraps every response as <code>{ success: boolean, data: … }</code>. A <code>success=false</code> body is translated to a contextual Persian error. For example, <code>موردی یافت نشد</code> becomes a plate- or VIN-specific “no matching policy” message; it is not reported as a provider outage.
|
||||||
The offline-inquiry seed check still runs first, before any ESG HTTP call.
|
The offline-inquiry seed check still runs first, before any ESG HTTP call.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -462,4 +462,128 @@ describe("buildInsurerFileReport", () => {
|
|||||||
"پایه یک",
|
"پایه یک",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("localizes participant and recent-transfer inquiry data", () => {
|
||||||
|
const report = buildInsurerFileReport({
|
||||||
|
overview: { publicId: "A00017" },
|
||||||
|
blame: {
|
||||||
|
type: "THIRD_PARTY",
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
role: "FIRST",
|
||||||
|
person: { userId: "guilty", fullName: "مقصر" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "SECOND",
|
||||||
|
person: { userId: "damaged", fullName: "زیاندیده" },
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
participantId: "DRIVER",
|
||||||
|
fullName: "راننده",
|
||||||
|
nationalCode: "0012345678",
|
||||||
|
birthday: "1370/01/01",
|
||||||
|
hasDrivingLicense: true,
|
||||||
|
licenseNumber: "123456789",
|
||||||
|
licenseType: "BASE_2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
participantRoles: {
|
||||||
|
driver: "DRIVER",
|
||||||
|
vehicleOwner: "DRIVER",
|
||||||
|
thirdPartyPolicyholder: "DRIVER",
|
||||||
|
},
|
||||||
|
vehicle: {
|
||||||
|
registrationState: "RECENTLY_TRANSFERRED",
|
||||||
|
previousPlateId: "55ج222ایران33",
|
||||||
|
previousPolicyholderNationalCode: "0098765432",
|
||||||
|
currentPlate: {
|
||||||
|
leftDigits: "44",
|
||||||
|
centerAlphabet: "ب",
|
||||||
|
centerDigits: "111",
|
||||||
|
ir: "22",
|
||||||
|
},
|
||||||
|
inquiry: {
|
||||||
|
plateKind: "PREVIOUS",
|
||||||
|
attempts: [
|
||||||
|
{
|
||||||
|
plateKind: "CURRENT",
|
||||||
|
plate: {
|
||||||
|
leftDigits: "44",
|
||||||
|
centerAlphabet: "ب",
|
||||||
|
centerDigits: "111",
|
||||||
|
ir: "22",
|
||||||
|
},
|
||||||
|
succeeded: false,
|
||||||
|
error: "not found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
plateKind: "PREVIOUS",
|
||||||
|
succeeded: true,
|
||||||
|
usable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expert: { decision: { guiltyPartyId: "guilty" } },
|
||||||
|
},
|
||||||
|
claim: {
|
||||||
|
vehicle: { carType: "SEDAN" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getFieldValue(
|
||||||
|
report,
|
||||||
|
PR.damagedVehicleSection,
|
||||||
|
"خودرو / وضعیت پلاک و مالکیت",
|
||||||
|
),
|
||||||
|
).toBe("انتقال مالکیت اخیر");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.damagedVehicleSection, "خودرو / نوع خودرو"),
|
||||||
|
).toBe("سواری");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.damagedVehicleSection, "خودرو / پلاک قبلی"),
|
||||||
|
).toBe("55ج222ایران33");
|
||||||
|
expect(
|
||||||
|
getFieldValue(
|
||||||
|
report,
|
||||||
|
PR.damagedVehicleSection,
|
||||||
|
"خودرو / پلاک فعلی / دو رقم چپ پلاک",
|
||||||
|
),
|
||||||
|
).toBe("44");
|
||||||
|
|
||||||
|
const driver = getFieldValue(
|
||||||
|
report,
|
||||||
|
PR.damagedParticipantsSection,
|
||||||
|
PR.driverRole,
|
||||||
|
);
|
||||||
|
expect(driver).toContain("گواهینامه دارد: بله");
|
||||||
|
expect(driver).toContain("نوع گواهینامه: پایه دو");
|
||||||
|
expect(
|
||||||
|
getFieldValue(
|
||||||
|
report,
|
||||||
|
PR.damagedVehicleSection,
|
||||||
|
"خودرو / سوابق تلاش استعلام / تلاش ۱ / نوع پلاک استعلامشده",
|
||||||
|
),
|
||||||
|
).toBe("پلاک فعلی");
|
||||||
|
expect(
|
||||||
|
getFieldValue(
|
||||||
|
report,
|
||||||
|
PR.damagedVehicleSection,
|
||||||
|
"خودرو / سوابق تلاش استعلام / تلاش ۱ / خطا",
|
||||||
|
),
|
||||||
|
).toBe("موردی یافت نشد");
|
||||||
|
|
||||||
|
const renderedText = report.sections
|
||||||
|
.flatMap((section) => [
|
||||||
|
section.title,
|
||||||
|
...section.fields.flatMap((field) => [field.label, field.value]),
|
||||||
|
])
|
||||||
|
.join(" ");
|
||||||
|
expect(renderedText).not.toMatch(
|
||||||
|
/RECENTLY_TRANSFERRED|BASE_2|registrationState|previousPlateId|plateKind|not found|CURRENT|PREVIOUS/,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
PR,
|
PR,
|
||||||
persianAccidentCondition,
|
persianAccidentCondition,
|
||||||
persianFieldPath,
|
persianFieldPath,
|
||||||
|
persianReportValue,
|
||||||
persianStatus,
|
persianStatus,
|
||||||
} from "./persian-report-labels";
|
} from "./persian-report-labels";
|
||||||
|
|
||||||
@@ -110,10 +111,13 @@ function firstDefined(...values: unknown[]): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeListValue(value: unknown): string | undefined {
|
function normalizeListValue(value: unknown, path = ""): string | undefined {
|
||||||
if (!Array.isArray(value) || !value.length) return undefined;
|
if (!Array.isArray(value) || !value.length) return undefined;
|
||||||
const items = value
|
const items = value
|
||||||
.map((item) => asString(item) ?? JSON.stringify(item))
|
.map(
|
||||||
|
(item) =>
|
||||||
|
asString(persianReportValue(path, item)) ?? JSON.stringify(item),
|
||||||
|
)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return items.length ? items.join("، ") : undefined;
|
return items.length ? items.join("، ") : undefined;
|
||||||
}
|
}
|
||||||
@@ -124,18 +128,31 @@ function flattenObject(
|
|||||||
depth = 0,
|
depth = 0,
|
||||||
): InsurerFileReportField[] {
|
): InsurerFileReportField[] {
|
||||||
if (obj == null) return [];
|
if (obj == null) return [];
|
||||||
if (depth > 4) {
|
if (depth > 6) {
|
||||||
return [{ label: persianFieldPath(prefix), value: asString(obj) }];
|
return [
|
||||||
|
{
|
||||||
|
label: persianFieldPath(prefix),
|
||||||
|
value: asString(persianReportValue(prefix, obj)),
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
const value = normalizeListValue(obj);
|
if (obj.some((item) => item && typeof item === "object")) {
|
||||||
|
return obj.flatMap((item, index) =>
|
||||||
|
flattenObject(item, `${prefix}.${index + 1}`, depth + 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const value = normalizeListValue(obj, prefix);
|
||||||
return value ? [{ label: persianFieldPath(prefix || "items"), value }] : [];
|
return value ? [{ label: persianFieldPath(prefix || "items"), value }] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof obj !== "object") {
|
if (typeof obj !== "object") {
|
||||||
return [
|
return [
|
||||||
{ label: persianFieldPath(prefix || "value"), value: asString(obj) },
|
{
|
||||||
|
label: persianFieldPath(prefix || "value"),
|
||||||
|
value: asString(persianReportValue(prefix || "value", obj)),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,14 +167,13 @@ function flattenObject(
|
|||||||
if (value === undefined || value === null || value === "") continue;
|
if (value === undefined || value === null || value === "") continue;
|
||||||
|
|
||||||
const path = prefix ? `${prefix}.${key}` : key;
|
const path = prefix ? `${prefix}.${key}` : key;
|
||||||
if (
|
if (typeof value === "object" && !(value instanceof Date)) {
|
||||||
typeof value === "object" &&
|
|
||||||
!Array.isArray(value) &&
|
|
||||||
!(value instanceof Date)
|
|
||||||
) {
|
|
||||||
rows.push(...flattenObject(value, path, depth + 1));
|
rows.push(...flattenObject(value, path, depth + 1));
|
||||||
} else {
|
} else {
|
||||||
rows.push({ label: persianFieldPath(path), value: asString(value) });
|
rows.push({
|
||||||
|
label: persianFieldPath(path),
|
||||||
|
value: asString(persianReportValue(path, value)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,6 +654,17 @@ function buildParticipantRolesSection(
|
|||||||
participant?.licenseNumber
|
participant?.licenseNumber
|
||||||
? `${PR.licenseNumber}: ${asString(participant.licenseNumber)}`
|
? `${PR.licenseNumber}: ${asString(participant.licenseNumber)}`
|
||||||
: undefined,
|
: undefined,
|
||||||
|
participant?.hasDrivingLicense != null
|
||||||
|
? `${PR.hasDrivingLicense}: ${persianStatus(participant.hasDrivingLicense)}`
|
||||||
|
: undefined,
|
||||||
|
participant?.licenseType
|
||||||
|
? `${PR.licenseType}: ${asString(
|
||||||
|
persianReportValue(
|
||||||
|
"participant.licenseType",
|
||||||
|
participant.licenseType,
|
||||||
|
),
|
||||||
|
)}`
|
||||||
|
: undefined,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("، ");
|
.join("، ");
|
||||||
@@ -695,8 +722,12 @@ function buildDriverSection(
|
|||||||
{
|
{
|
||||||
label: PR.licenseType,
|
label: PR.licenseType,
|
||||||
value:
|
value:
|
||||||
licenseType ??
|
asString(
|
||||||
asString(person.licenseType) ??
|
persianReportValue(
|
||||||
|
"participant.licenseType",
|
||||||
|
licenseType ?? person.licenseType,
|
||||||
|
),
|
||||||
|
) ??
|
||||||
(person.driverLicense ? PR.driverLicense : undefined),
|
(person.driverLicense ? PR.driverLicense : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -895,7 +926,7 @@ function evaluationDaghiValue(value: unknown): string | undefined {
|
|||||||
return asString(value);
|
return asString(value);
|
||||||
}
|
}
|
||||||
const daghi = value as ReportRecord;
|
const daghi = value as ReportRecord;
|
||||||
const option = asString(daghi.option);
|
const option = asString(persianReportValue("daghi.option", daghi.option));
|
||||||
const price = formatToman(daghi.price);
|
const price = formatToman(daghi.price);
|
||||||
return [option, price].filter(Boolean).join(" - ") || undefined;
|
return [option, price].filter(Boolean).join(" - ") || undefined;
|
||||||
}
|
}
|
||||||
@@ -915,7 +946,9 @@ function buildEvaluationPartsSection(
|
|||||||
{ label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) },
|
{ label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) },
|
||||||
{
|
{
|
||||||
label: `${prefix} / ${PR.damageType}`,
|
label: `${prefix} / ${PR.damageType}`,
|
||||||
value: asString(part.typeOfDamage),
|
value: asString(
|
||||||
|
persianReportValue("evaluation.part.typeOfDamage", part.typeOfDamage),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ label: `${prefix} / ${PR.partPrice}`, value: formatToman(part.price) },
|
{ label: `${prefix} / ${PR.partPrice}`, value: formatToman(part.price) },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const PR = {
|
|||||||
licenseType: "نوع گواهینامه",
|
licenseType: "نوع گواهینامه",
|
||||||
licenseDate: "تاریخ گواهینامه",
|
licenseDate: "تاریخ گواهینامه",
|
||||||
licenseNumber: "شماره گواهینامه",
|
licenseNumber: "شماره گواهینامه",
|
||||||
|
hasDrivingLicense: "گواهینامه دارد",
|
||||||
driverLicense: "گواهینامه راننده",
|
driverLicense: "گواهینامه راننده",
|
||||||
insuranceCompany: "شرکت بیمه",
|
insuranceCompany: "شرکت بیمه",
|
||||||
policyNumber: "شماره بیمهنامه",
|
policyNumber: "شماره بیمهنامه",
|
||||||
@@ -194,6 +195,31 @@ const KEY_LABELS: Record<string, string> = {
|
|||||||
StatusTypeCode: "کد وضعیت",
|
StatusTypeCode: "کد وضعیت",
|
||||||
label_fa: "برچسب فارسی",
|
label_fa: "برچسب فارسی",
|
||||||
catalogKey: "کلید کاتالوگ",
|
catalogKey: "کلید کاتالوگ",
|
||||||
|
participantId: "شناسه شخص",
|
||||||
|
participants: "اشخاص استعلام",
|
||||||
|
participantRoles: "نقشهای اشخاص",
|
||||||
|
nationalCode: "کد ملی",
|
||||||
|
birthday: "تاریخ تولد",
|
||||||
|
fullName: "نام و نام خانوادگی",
|
||||||
|
phoneNumber: "شماره تلفن",
|
||||||
|
hasDrivingLicense: "گواهینامه دارد",
|
||||||
|
licenseNumber: "شماره گواهینامه",
|
||||||
|
registrationState: "وضعیت پلاک و مالکیت",
|
||||||
|
currentPlate: "پلاک فعلی",
|
||||||
|
previousPlate: "پلاک قبلی",
|
||||||
|
previousPlateId: "پلاک قبلی",
|
||||||
|
previousPolicyholderNationalCode: "کد ملی بیمهگذار پلاک قبلی",
|
||||||
|
vehicleVin: "شماره شاسی (VIN)",
|
||||||
|
driver: "راننده",
|
||||||
|
vehicleOwner: "مالک وسیله نقلیه",
|
||||||
|
thirdPartyPolicyholder: "بیمهگذار شخص ثالث",
|
||||||
|
carBodyPolicyholder: "بیمهگذار بدنه",
|
||||||
|
sameAs: "همان شخص",
|
||||||
|
plateKind: "نوع پلاک استعلامشده",
|
||||||
|
succeeded: "موفق",
|
||||||
|
usable: "قابل استفاده",
|
||||||
|
attempts: "سوابق تلاش استعلام",
|
||||||
|
error: "خطا",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -209,6 +235,76 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
false: "خیر",
|
false: "خیر",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REGISTRATION_STATE_LABELS: Record<string, string> = {
|
||||||
|
CURRENT: "عادی (پلاک فعلی)",
|
||||||
|
RECENTLY_TRANSFERRED: "انتقال مالکیت اخیر",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATE_KIND_LABELS: Record<string, string> = {
|
||||||
|
CURRENT: "پلاک فعلی",
|
||||||
|
PREVIOUS: "پلاک قبلی",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARTICIPANT_ROLE_VALUE_LABELS: Record<string, string> = {
|
||||||
|
DRIVER: "راننده",
|
||||||
|
VEHICLE_OWNER: "مالک وسیله نقلیه",
|
||||||
|
THIRD_PARTY_POLICYHOLDER: "بیمهگذار شخص ثالث",
|
||||||
|
CAR_BODY_POLICYHOLDER: "بیمهگذار بدنه",
|
||||||
|
FIRST: "طرف اول",
|
||||||
|
SECOND: "طرف دوم",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CASE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
THIRD_PARTY: "شخص ثالث",
|
||||||
|
CAR_BODY: "بدنه",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VEHICLE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
SEDAN: "سواری",
|
||||||
|
SUV: "شاسیبلند",
|
||||||
|
HATCHBACK: "هاچبک",
|
||||||
|
PICKUP: "وانت",
|
||||||
|
VAN: "ون",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VALIDITY_LABELS: Record<string, string> = {
|
||||||
|
ACTIVE: "فعال",
|
||||||
|
INACTIVE: "غیرفعال",
|
||||||
|
VALID: "معتبر",
|
||||||
|
INVALID: "نامعتبر",
|
||||||
|
EXPIRED: "منقضیشده",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAMAGE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
REPAIR: "تعمیر",
|
||||||
|
CHANGE: "تعویض",
|
||||||
|
REPLACE: "تعویض",
|
||||||
|
};
|
||||||
|
|
||||||
|
const INQUIRY_ERROR_LABELS: Record<string, string> = {
|
||||||
|
NOT_FOUND: "موردی یافت نشد",
|
||||||
|
NO_RECORD_FOUND: "موردی یافت نشد",
|
||||||
|
TIMEOUT: "مهلت پاسخ استعلام به پایان رسید",
|
||||||
|
REQUEST_FAILED: "استعلام ناموفق بود",
|
||||||
|
FAILED: "استعلام ناموفق بود",
|
||||||
|
UNAVAILABLE: "سرویس استعلام در دسترس نیست",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LICENSE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
"1": "پایه یک",
|
||||||
|
"2": "پایه دو",
|
||||||
|
"3": "پایه سه",
|
||||||
|
BASE_1: "پایه یک",
|
||||||
|
BASE1: "پایه یک",
|
||||||
|
GRADE_1: "پایه یک",
|
||||||
|
BASE_2: "پایه دو",
|
||||||
|
BASE2: "پایه دو",
|
||||||
|
GRADE_2: "پایه دو",
|
||||||
|
BASE_3: "پایه سه",
|
||||||
|
BASE3: "پایه سه",
|
||||||
|
GRADE_3: "پایه سه",
|
||||||
|
};
|
||||||
|
|
||||||
const WEATHER_LABELS: Record<string, string> = {
|
const WEATHER_LABELS: Record<string, string> = {
|
||||||
clear: "صاف",
|
clear: "صاف",
|
||||||
sunny: "صاف",
|
sunny: "صاف",
|
||||||
@@ -255,9 +351,39 @@ export function persianFieldPath(path: string): string {
|
|||||||
return `بیمه / ${translatedLast}`;
|
return `بیمه / ${translatedLast}`;
|
||||||
}
|
}
|
||||||
if (parts[0] === "claim" && parts[1] === "vehicle") {
|
if (parts[0] === "claim" && parts[1] === "vehicle") {
|
||||||
|
const attemptIndex = parts.indexOf("attempts");
|
||||||
|
if (attemptIndex >= 0) {
|
||||||
|
const attemptNumber = Number(parts[attemptIndex + 1]);
|
||||||
|
const attemptLabel = Number.isFinite(attemptNumber)
|
||||||
|
? `تلاش ${attemptNumber.toLocaleString("fa-IR")}`
|
||||||
|
: "تلاش استعلام";
|
||||||
|
const plateLabel = parts.includes("plate") ? " / پلاک" : "";
|
||||||
|
return `خودرو / سوابق تلاش استعلام / ${attemptLabel}${plateLabel} / ${translatedLast}`;
|
||||||
|
}
|
||||||
|
if (parts.includes("currentPlate")) {
|
||||||
|
return `خودرو / پلاک فعلی / ${translatedLast}`;
|
||||||
|
}
|
||||||
|
if (parts.includes("previousPlate")) {
|
||||||
|
return `خودرو / پلاک قبلی / ${translatedLast}`;
|
||||||
|
}
|
||||||
return `خودرو / ${translatedLast}`;
|
return `خودرو / ${translatedLast}`;
|
||||||
}
|
}
|
||||||
if (parts[0] === "party" && parts[1] === "vehicle") {
|
if (parts[0] === "party" && parts[1] === "vehicle") {
|
||||||
|
const attemptIndex = parts.indexOf("attempts");
|
||||||
|
if (attemptIndex >= 0) {
|
||||||
|
const attemptNumber = Number(parts[attemptIndex + 1]);
|
||||||
|
const attemptLabel = Number.isFinite(attemptNumber)
|
||||||
|
? `تلاش ${attemptNumber.toLocaleString("fa-IR")}`
|
||||||
|
: "تلاش استعلام";
|
||||||
|
const plateLabel = parts.includes("plate") ? " / پلاک" : "";
|
||||||
|
return `خودرو / سوابق تلاش استعلام / ${attemptLabel}${plateLabel} / ${translatedLast}`;
|
||||||
|
}
|
||||||
|
if (parts.includes("currentPlate")) {
|
||||||
|
return `خودرو / پلاک فعلی / ${translatedLast}`;
|
||||||
|
}
|
||||||
|
if (parts.includes("previousPlate")) {
|
||||||
|
return `خودرو / پلاک قبلی / ${translatedLast}`;
|
||||||
|
}
|
||||||
return `خودرو / ${translatedLast}`;
|
return `خودرو / ${translatedLast}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +393,51 @@ export function persianFieldPath(path: string): string {
|
|||||||
return translated.join(" / ");
|
return translated.join(" / ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localize only known domain tokens. Free-form provider values, names,
|
||||||
|
* identifiers, plates and VINs are deliberately preserved verbatim.
|
||||||
|
*/
|
||||||
|
export function persianReportValue(
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
): unknown {
|
||||||
|
if (value === undefined || value === null || value === "") return value;
|
||||||
|
if (typeof value === "boolean") return value ? "بله" : "خیر";
|
||||||
|
if (typeof value !== "string" && typeof value !== "number") return value;
|
||||||
|
|
||||||
|
const raw = String(value).trim();
|
||||||
|
const key = raw.toUpperCase().replace(/[\s-]+/g, "_");
|
||||||
|
const field = path.split(".").filter(Boolean).pop() ?? "";
|
||||||
|
|
||||||
|
if (field === "licenseType" || field === "LicenseType") {
|
||||||
|
return LICENSE_TYPE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (field === "registrationState") {
|
||||||
|
return REGISTRATION_STATE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (field === "plateKind") return PLATE_KIND_LABELS[key] ?? raw;
|
||||||
|
if (field === "role" || field.endsWith("Role")) {
|
||||||
|
return PARTICIPANT_ROLE_VALUE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (field === "blameRequestType" || field === "fileType") {
|
||||||
|
return CASE_TYPE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(field === "carType" || field === "type") &&
|
||||||
|
path.toLowerCase().includes("vehicle")
|
||||||
|
) {
|
||||||
|
return VEHICLE_TYPE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (field === "typeOfDamage") return DAMAGE_TYPE_LABELS[key] ?? raw;
|
||||||
|
if (field === "error") return INQUIRY_ERROR_LABELS[key] ?? raw;
|
||||||
|
if (/status$/i.test(field)) {
|
||||||
|
return STATUS_LABELS[raw] ?? VALIDITY_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw === "true" || raw === "false") return STATUS_LABELS[raw];
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
export function persianStatus(value: unknown): string | undefined {
|
export function persianStatus(value: unknown): string | undefined {
|
||||||
if (value === undefined || value === null || value === "") return undefined;
|
if (value === undefined || value === null || value === "") return undefined;
|
||||||
const key = String(value);
|
const key = String(value);
|
||||||
|
|||||||
@@ -101,7 +101,12 @@ import {
|
|||||||
ClaimListItemV2Dto,
|
ClaimListItemV2Dto,
|
||||||
} from "./dto/my-claims-v2.dto";
|
} from "./dto/my-claims-v2.dto";
|
||||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
import {
|
||||||
|
applyListQueryV2,
|
||||||
|
isInListDateRange,
|
||||||
|
parseListDateRange,
|
||||||
|
} from "src/helpers/list-query-v2";
|
||||||
|
import { resolveUnifiedFileStatus } from "src/helpers/unified-file-status";
|
||||||
import { partyPersonMatchesUser } from "src/helpers/iran-mobile";
|
import { partyPersonMatchesUser } from "src/helpers/iran-mobile";
|
||||||
import {
|
import {
|
||||||
buildBlamePartyAccessOrConditions,
|
buildBlamePartyAccessOrConditions,
|
||||||
@@ -206,6 +211,7 @@ import {
|
|||||||
partLookupKey,
|
partLookupKey,
|
||||||
resolvePartCaptureIndex,
|
resolvePartCaptureIndex,
|
||||||
} from "src/helpers/outer-damage-parts";
|
} from "src/helpers/outer-damage-parts";
|
||||||
|
import { serializeDamagedPartSelectionHistory } from "src/helpers/claim-damaged-part-audit";
|
||||||
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
||||||
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
|
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
|
||||||
|
|
||||||
@@ -12421,7 +12427,7 @@ export class ClaimRequestManagementService {
|
|||||||
blameIdsForList.length > 0
|
blameIdsForList.length > 0
|
||||||
? ((await this.blameRequestDbService.find(
|
? ((await this.blameRequestDbService.find(
|
||||||
{ _id: { $in: blameIdsForList.map((id) => new Types.ObjectId(id)) } },
|
{ _id: { $in: blameIdsForList.map((id) => new Types.ObjectId(id)) } },
|
||||||
{ lean: true, select: "type creationMethod" },
|
{ lean: true, select: "type creationMethod status" },
|
||||||
)) as any[])
|
)) as any[])
|
||||||
: [];
|
: [];
|
||||||
const blameByIdForList = new Map<string, any>(
|
const blameByIdForList = new Map<string, any>(
|
||||||
@@ -12443,16 +12449,37 @@ export class ClaimRequestManagementService {
|
|||||||
blameRequestId: c.blameRequestId?.toString(),
|
blameRequestId: c.blameRequestId?.toString(),
|
||||||
blameType: blameForItem?.type ?? undefined,
|
blameType: blameForItem?.type ?? undefined,
|
||||||
creationMethod: blameForItem?.creationMethod ?? undefined,
|
creationMethod: blameForItem?.creationMethod ?? undefined,
|
||||||
|
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||||
|
blameStatus: blameForItem?.status,
|
||||||
|
claimStatus: c.status,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}) as ClaimListItemV2Dto[];
|
}) as ClaimListItemV2Dto[];
|
||||||
|
|
||||||
|
let filtered = list;
|
||||||
|
if (query.unifiedStatus) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(row) => row.unifiedFileStatus === query.unifiedStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { fromDate, toDate } = parseListDateRange(
|
||||||
|
query.startDate,
|
||||||
|
query.endDate,
|
||||||
|
);
|
||||||
|
if (fromDate || toDate) {
|
||||||
|
filtered = filtered.filter((row) =>
|
||||||
|
isInListDateRange(row.createdAt, fromDate, toDate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const paged = applyListQueryV2(
|
const paged = applyListQueryV2(
|
||||||
list,
|
filtered,
|
||||||
{
|
{
|
||||||
publicId: (r) => r.publicId,
|
publicId: (r) => r.publicId,
|
||||||
createdAt: (r) => r.createdAt,
|
createdAt: (r) => r.createdAt,
|
||||||
requestNo: (r) => r.requestNo,
|
requestNo: (r) => r.requestNo,
|
||||||
status: (r) => r.status,
|
status: (r) => r.unifiedFileStatus ?? r.status,
|
||||||
|
fileType: (r) => r.blameType,
|
||||||
searchExtras: (r) =>
|
searchExtras: (r) =>
|
||||||
[
|
[
|
||||||
r.claimRequestId,
|
r.claimRequestId,
|
||||||
@@ -12588,6 +12615,10 @@ export class ClaimRequestManagementService {
|
|||||||
catalogLikeKeyFromPart,
|
catalogLikeKeyFromPart,
|
||||||
buildFileLink,
|
buildFileLink,
|
||||||
});
|
});
|
||||||
|
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
|
||||||
|
history: (claim.damage as any)?.partSelectionHistory,
|
||||||
|
currentSelectedParts: selectedNormDetails,
|
||||||
|
});
|
||||||
|
|
||||||
const er = claim.evaluation?.damageExpertResend;
|
const er = claim.evaluation?.damageExpertResend;
|
||||||
const expertResend =
|
const expertResend =
|
||||||
@@ -12671,6 +12702,7 @@ export class ClaimRequestManagementService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
carAngles,
|
carAngles,
|
||||||
damagedParts,
|
damagedParts,
|
||||||
|
damagedPartsHistory,
|
||||||
expertResend,
|
expertResend,
|
||||||
fanavaran: fanavaranClaimReferences(claim),
|
fanavaran: fanavaranClaimReferences(claim),
|
||||||
evaluation: mappedEvaluation
|
evaluation: mappedEvaluation
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get My Claims (V2)",
|
summary: "Get My Claims (V2)",
|
||||||
description:
|
description:
|
||||||
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
|
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType`, `startDate`, `endDate`.",
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -204,6 +204,33 @@ export class ClaimDetailsV2ResponseDto {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Append-only expert damaged-part revisions. Removed rows retain their original capture URL.',
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
revisionId: { type: 'string' },
|
||||||
|
changedAt: { type: 'string', format: 'date-time' },
|
||||||
|
changedBy: { type: 'object' },
|
||||||
|
removedParts: { type: 'array', items: { type: 'object' } },
|
||||||
|
addedParts: { type: 'array', items: { type: 'object' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
damagedPartsHistory?: Array<{
|
||||||
|
revisionId: string;
|
||||||
|
changedAt: Date | string;
|
||||||
|
changedBy: {
|
||||||
|
actorId: string;
|
||||||
|
actorName?: string;
|
||||||
|
actorType: string;
|
||||||
|
};
|
||||||
|
removedParts: Array<Record<string, unknown>>;
|
||||||
|
addedParts: Array<Record<string, unknown>>;
|
||||||
|
}>;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
|
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export class ClaimListItemV2Dto {
|
|||||||
example: 'IN_PERSON',
|
example: 'IN_PERSON',
|
||||||
})
|
})
|
||||||
creationMethod?: string;
|
creationMethod?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Calculated combined blame and claim lifecycle status',
|
||||||
|
example: 'WAITING_FOR_DAMAGE_EXPERT',
|
||||||
|
})
|
||||||
|
unifiedFileStatus?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetMyClaimsV2ResponseDto {
|
export class GetMyClaimsV2ResponseDto {
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ export class ClaimDamageSelection {
|
|||||||
@Prop({ type: [String], default: [] })
|
@Prop({ type: [String], default: [] })
|
||||||
otherParts?: string[];
|
otherParts?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only expert edit revisions. Each revision preserves the prior
|
||||||
|
* selection and capture metadata for removed parts before the live arrays
|
||||||
|
* are replaced. Mixed keeps legacy/free-text part shapes readable.
|
||||||
|
*/
|
||||||
|
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||||
|
partSelectionHistory?: unknown[];
|
||||||
|
|
||||||
|
/** Parts introduced by an expert across revisions (used for origin labels). */
|
||||||
|
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||||
|
expertAddedParts?: unknown[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy fields - kept for backward compatibility
|
* Legacy fields - kept for backward compatibility
|
||||||
*/
|
*/
|
||||||
|
|||||||
51
src/common/utils/inquiry-error.spec.ts
Normal file
51
src/common/utils/inquiry-error.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
getInquiryErrorMessage,
|
||||||
|
isInquiryFailurePayload,
|
||||||
|
} from "./inquiry-error";
|
||||||
|
|
||||||
|
describe("inquiry error messages", () => {
|
||||||
|
it("turns ESG not-found responses into a contextual plate message", () => {
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
{ success: false, message: "موردی یافت نشد" },
|
||||||
|
"thirdPartyPlate",
|
||||||
|
),
|
||||||
|
).toBe("بیمهنامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes VIN and car-body not-found failures", () => {
|
||||||
|
expect(getInquiryErrorMessage({ status: 404 }, "thirdPartyVin")).toContain(
|
||||||
|
"شماره شاسی (VIN)",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
new Error("No active policy found"),
|
||||||
|
"carBodyPlate",
|
||||||
|
),
|
||||||
|
).toBe("بیمهنامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a specific Persian provider message", () => {
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
{ message: "کد ملی واردشده صحیح نیست" },
|
||||||
|
"personalIdentity",
|
||||||
|
),
|
||||||
|
).toBe("کد ملی واردشده صحیح نیست");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose transport or authentication details", () => {
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
{ response: { status: 401 }, message: "ESG authentication failed" },
|
||||||
|
"thirdPartyPlate",
|
||||||
|
),
|
||||||
|
).toBe("سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes provider failure envelopes", () => {
|
||||||
|
expect(isInquiryFailurePayload({ success: false })).toBe(true);
|
||||||
|
expect(isInquiryFailurePayload({ HasError: true })).toBe(true);
|
||||||
|
expect(isInquiryFailurePayload({ success: true, data: {} })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
220
src/common/utils/inquiry-error.ts
Normal file
220
src/common/utils/inquiry-error.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
export type InquiryErrorContext =
|
||||||
|
| "thirdPartyPlate"
|
||||||
|
| "thirdPartyVin"
|
||||||
|
| "carBodyPlate"
|
||||||
|
| "carBodyVin"
|
||||||
|
| "personalIdentity"
|
||||||
|
| "drivingLicense"
|
||||||
|
| "carOwnership"
|
||||||
|
| "sheba"
|
||||||
|
| "generic";
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
const NOT_FOUND_MESSAGES: Record<InquiryErrorContext, string> = {
|
||||||
|
thirdPartyPlate: "بیمهنامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||||
|
thirdPartyVin:
|
||||||
|
"بیمهنامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
|
||||||
|
carBodyPlate: "بیمهنامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||||
|
carBodyVin:
|
||||||
|
"بیمهنامه بدنه فعالی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
|
||||||
|
personalIdentity: "اطلاعات هویتی مطابق کد ملی و تاریخ تولد واردشده یافت نشد.",
|
||||||
|
drivingLicense:
|
||||||
|
"گواهینامهای مطابق کد ملی و شماره گواهینامه واردشده یافت نشد.",
|
||||||
|
carOwnership: "مالکیتی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||||
|
sheba: "اطلاعاتی مطابق شماره شبا و کد ملی واردشده یافت نشد.",
|
||||||
|
generic: "موردی مطابق اطلاعات واردشده یافت نشد.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const INVALID_MESSAGES: Record<InquiryErrorContext, string> = {
|
||||||
|
thirdPartyPlate: "پلاک یا کد ملی واردشده برای استعلام شخص ثالث معتبر نیست.",
|
||||||
|
thirdPartyVin:
|
||||||
|
"شماره شاسی (VIN) یا کد ملی واردشده برای استعلام شخص ثالث معتبر نیست.",
|
||||||
|
carBodyPlate: "پلاک یا کد ملی واردشده برای استعلام بیمه بدنه معتبر نیست.",
|
||||||
|
carBodyVin:
|
||||||
|
"شماره شاسی (VIN) یا کد ملی واردشده برای استعلام بیمه بدنه معتبر نیست.",
|
||||||
|
personalIdentity: "کد ملی یا تاریخ تولد واردشده معتبر نیست.",
|
||||||
|
drivingLicense: "کد ملی یا شماره گواهینامه واردشده معتبر نیست.",
|
||||||
|
carOwnership: "پلاک یا کد ملی واردشده برای استعلام مالکیت معتبر نیست.",
|
||||||
|
sheba: "شماره شبا یا کد ملی واردشده معتبر نیست.",
|
||||||
|
generic: "اطلاعات ارسالشده برای استعلام معتبر نیست.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): UnknownRecord | undefined =>
|
||||||
|
value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as UnknownRecord)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const cleanMessage = (value: unknown): string => {
|
||||||
|
if (typeof value === "string") return value.trim();
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("، ");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
function errorRecords(error: unknown): UnknownRecord[] {
|
||||||
|
const root = asRecord(error);
|
||||||
|
if (!root) return [];
|
||||||
|
|
||||||
|
const response = asRecord(root.response);
|
||||||
|
const responseData = asRecord(response?.data);
|
||||||
|
const data = asRecord(root.data);
|
||||||
|
const nestedError = asRecord(root.Error) ?? asRecord(root.error);
|
||||||
|
const responseError =
|
||||||
|
asRecord(responseData?.Error) ?? asRecord(responseData?.error);
|
||||||
|
|
||||||
|
return [root, responseData, data, nestedError, responseError].filter(
|
||||||
|
(item): item is UnknownRecord => !!item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inquiryErrorStatus(error: unknown): number | undefined {
|
||||||
|
for (const record of errorRecords(error)) {
|
||||||
|
const response = asRecord(record.response);
|
||||||
|
const value =
|
||||||
|
record.statusCode ?? record.status ?? response?.status ?? record.code;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 100 && parsed <= 599) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractInquiryProviderMessage(error: unknown): string {
|
||||||
|
for (const record of errorRecords(error)) {
|
||||||
|
for (const key of ["message", "Message", "detail", "title"] as const) {
|
||||||
|
const message = cleanMessage(record[key]);
|
||||||
|
if (message) return message;
|
||||||
|
}
|
||||||
|
const scalarData = cleanMessage(record.data);
|
||||||
|
if (scalarData) return scalarData;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message.trim() : cleanMessage(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInquiryFailurePayload(value: unknown): boolean {
|
||||||
|
const root = asRecord(value);
|
||||||
|
if (!root) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
root.success === false ||
|
||||||
|
root.isSuccess === false ||
|
||||||
|
root.IsSuccess === false ||
|
||||||
|
root.IsSucceed === false ||
|
||||||
|
root.ReturnValue === false ||
|
||||||
|
root.HasError === true ||
|
||||||
|
root.hasError === true ||
|
||||||
|
root.Error != null ||
|
||||||
|
root.error != null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInquiryTimeout(error: unknown): boolean {
|
||||||
|
const root = asRecord(error);
|
||||||
|
const code = String(root?.code ?? "").toUpperCase();
|
||||||
|
const message = extractInquiryProviderMessage(error);
|
||||||
|
return (
|
||||||
|
["ECONNABORTED", "ETIMEDOUT", "ESOCKETTIMEDOUT"].includes(code) ||
|
||||||
|
/timeout|timed out|مهلت|زمان.*پایان/i.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPersian = (value: string): boolean => /[\u0600-\u06ff]/.test(value);
|
||||||
|
|
||||||
|
const isNotFound = (error: unknown, message: string): boolean => {
|
||||||
|
const root = asRecord(error);
|
||||||
|
const responseData = asRecord(asRecord(root?.response)?.data);
|
||||||
|
const code = String(root?.code ?? responseData?.code ?? "").toUpperCase();
|
||||||
|
return (
|
||||||
|
inquiryErrorStatus(error) === 404 ||
|
||||||
|
["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY", "RECORD_NOT_FOUND"].includes(
|
||||||
|
code,
|
||||||
|
) ||
|
||||||
|
/\bnot[ -]?found\b|\bno (?:active |relevant )?(?:record|policy|item)\b|record\.not\.found|موردی یافت نشد|یافت نشد|پیدا نشد|فاقد بیمه(?:| )?نامه/i.test(
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInvalidInput = (message: string): boolean =>
|
||||||
|
/invalid|malformed|required|must contain|bad request|نامعتبر|الزامی|وارد نشده|صحیح نیست/i.test(
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isUnavailable = (error: unknown, message: string): boolean => {
|
||||||
|
const root = asRecord(error);
|
||||||
|
const code = String(root?.code ?? "").toUpperCase();
|
||||||
|
const status = inquiryErrorStatus(error);
|
||||||
|
return (
|
||||||
|
isInquiryTimeout(error) ||
|
||||||
|
["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ERR_NETWORK"].includes(code) ||
|
||||||
|
status === 401 ||
|
||||||
|
status === 502 ||
|
||||||
|
status === 503 ||
|
||||||
|
status === 504 ||
|
||||||
|
/offline|unavailable|connection|socket|network|authentication|credentials|empty response|در دسترس نیست|عدم دسترسی/i.test(
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts provider and transport failures into a stable, user-facing Persian
|
||||||
|
* message. Provider details remain in server logs; raw English or technical
|
||||||
|
* messages are never returned to clients.
|
||||||
|
*/
|
||||||
|
export function getInquiryErrorMessage(
|
||||||
|
error: unknown,
|
||||||
|
context: InquiryErrorContext = "generic",
|
||||||
|
): string {
|
||||||
|
const providerMessage = extractInquiryProviderMessage(error);
|
||||||
|
|
||||||
|
if (isNotFound(error, providerMessage)) return NOT_FOUND_MESSAGES[context];
|
||||||
|
|
||||||
|
if (
|
||||||
|
context === "carOwnership" &&
|
||||||
|
/not the owner|مالک.*نیست/i.test(providerMessage)
|
||||||
|
) {
|
||||||
|
return "پلاک واردشده متعلق به کد ملی واردشده نیست.";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
context === "sheba" &&
|
||||||
|
/does not match|تطابق ندارد/i.test(providerMessage)
|
||||||
|
) {
|
||||||
|
return "شماره شبا متعلق به کد ملی واردشده نیست.";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
context === "drivingLicense" &&
|
||||||
|
/not valid|نامعتبر/i.test(providerMessage)
|
||||||
|
) {
|
||||||
|
return "گواهینامه واردشده معتبر نیست.";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/policy insurer does not match|بیمه.*متعلق.*نیست/i.test(providerMessage)
|
||||||
|
) {
|
||||||
|
return "بیمهنامه یافتشده متعلق به شرکت بیمه این سامانه نیست.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInquiryTimeout(error)) {
|
||||||
|
return "زمان پاسخگویی سرویس استعلام به پایان رسید. لطفاً دوباره تلاش کنید.";
|
||||||
|
}
|
||||||
|
if (isUnavailable(error, providerMessage)) {
|
||||||
|
return "سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// A provider's specific Persian validation/business message is already safe
|
||||||
|
// and more useful than replacing it with a broad local validation message.
|
||||||
|
if (providerMessage && hasPersian(providerMessage)) return providerMessage;
|
||||||
|
|
||||||
|
if (isInvalidInput(providerMessage) || inquiryErrorStatus(error) === 422) {
|
||||||
|
return INVALID_MESSAGES[context];
|
||||||
|
}
|
||||||
|
|
||||||
|
return "انجام استعلام با خطا مواجه شد. لطفاً دوباره تلاش کنید.";
|
||||||
|
}
|
||||||
@@ -131,6 +131,33 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
url?: string;
|
url?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Append-only expert damaged-part revisions. Removed rows retain their original capture URL even after the authoritative selection changes.",
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
revisionId: { type: "string" },
|
||||||
|
changedAt: { type: "string", format: "date-time" },
|
||||||
|
changedBy: { type: "object" },
|
||||||
|
removedParts: { type: "array", items: { type: "object" } },
|
||||||
|
addedParts: { type: "array", items: { type: "object" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
damagedPartsHistory?: Array<{
|
||||||
|
revisionId: string;
|
||||||
|
changedAt: Date | string;
|
||||||
|
changedBy: {
|
||||||
|
actorId: string;
|
||||||
|
actorName?: string;
|
||||||
|
actorType: string;
|
||||||
|
};
|
||||||
|
removedParts: Array<Record<string, unknown>>;
|
||||||
|
addedParts: Array<Record<string, unknown>>;
|
||||||
|
}>;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
"True when user uploaded all required factors and the case awaits expert approve/reject.",
|
"True when user uploaded all required factors and the case awaits expert approve/reject.",
|
||||||
|
|||||||
@@ -90,4 +90,21 @@ describe("SubmitExpertReplyV2Dto", () => {
|
|||||||
|
|
||||||
expect(await validate(dto)).toHaveLength(0);
|
expect(await validate(dto)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts a grouped Persian current car price as a string", async () => {
|
||||||
|
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
|
||||||
|
carPrice: "۱٬۲۵۰٬۰۰۰٬۰۰۰",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
partId: 201,
|
||||||
|
typeOfDamage: TypeOfDamage.Repair,
|
||||||
|
salary: "1000000",
|
||||||
|
totalPayment: "1000000",
|
||||||
|
factorNeeded: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await validate(dto)).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -122,8 +122,9 @@ export class SubmitExpertReplyV2Dto {
|
|||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '1_000_000_000',
|
example: '1000000000',
|
||||||
description: "Today's car price",
|
description:
|
||||||
|
"Current vehicle price in Rial. Required for car-body claims and forbidden for third-party claims. This is independent from evaluation.priceDrop.carPrice.",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsMoneyAmountString()
|
@IsMoneyAmountString()
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { BadRequestException } from "@nestjs/common";
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { Types } from "mongoose";
|
||||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
|
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
|
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
|
||||||
|
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
import { ExpertClaimService } from "./expert-claim.service";
|
import { ExpertClaimService } from "./expert-claim.service";
|
||||||
|
|
||||||
|
const V2_EXPERT_ID = "66ec0e480e321873c0900001";
|
||||||
|
|
||||||
const blankPricingReply = {
|
const blankPricingReply = {
|
||||||
description: "Damage assessment",
|
description: "Damage assessment",
|
||||||
parts: [
|
parts: [
|
||||||
@@ -26,7 +30,144 @@ function createService() {
|
|||||||
) as ExpertClaimService;
|
) as ExpertClaimService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupSuccessfulV2Submit(blameType: BlameRequestType) {
|
||||||
|
const service = createService() as any;
|
||||||
|
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const claim = {
|
||||||
|
_id: "66ec0e480e321873c0900002",
|
||||||
|
publicId: "CLM-1",
|
||||||
|
blameRequestId: "66ec0e480e321873c0900003",
|
||||||
|
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||||
|
workflow: { locked: true, lockedBy: { actorId: V2_EXPERT_ID } },
|
||||||
|
vehicle: { carType: "sedan" },
|
||||||
|
damage: {
|
||||||
|
selectedParts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "front",
|
||||||
|
side: "",
|
||||||
|
label_fa: "جلو کامل",
|
||||||
|
catalogKey: "1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
service.claimCaseDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue(claim),
|
||||||
|
findByIdAndUpdate,
|
||||||
|
};
|
||||||
|
service.blameRequestDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
type: blameType,
|
||||||
|
expertInitiated: true,
|
||||||
|
creationMethod: "IN_PERSON",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.recordClaimExpertActivity = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
return { service, findByIdAndUpdate };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validV2Reply = {
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
partId: 1,
|
||||||
|
typeOfDamage: TypeOfDamage.Repair,
|
||||||
|
salary: "1000000",
|
||||||
|
totalPayment: "1000000",
|
||||||
|
factorNeeded: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
describe("ExpertClaimService expert-reply pricing", () => {
|
describe("ExpertClaimService expert-reply pricing", () => {
|
||||||
|
it("requires the current car price for a car-body expert reply", async () => {
|
||||||
|
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
|
||||||
|
BlameRequestType.CAR_BODY,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.submitExpertReplyV2("v2-claim", validV2Reply, {
|
||||||
|
sub: V2_EXPERT_ID,
|
||||||
|
fullName: "Expert One",
|
||||||
|
role: RoleEnum.FIELD_EXPERT,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
code: "CAR_PRICE_REQUIRED",
|
||||||
|
field: "carPrice",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes and persists the current car price on car-body replies", async () => {
|
||||||
|
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
|
||||||
|
BlameRequestType.CAR_BODY,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.submitExpertReplyV2(
|
||||||
|
"v2-claim",
|
||||||
|
{ ...validV2Reply, carPrice: "۱٬۲۵۰٬۰۰۰٬۰۰۰" },
|
||||||
|
{
|
||||||
|
sub: V2_EXPERT_ID,
|
||||||
|
fullName: "Expert One",
|
||||||
|
role: RoleEnum.FIELD_EXPERT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(findByIdAndUpdate.mock.calls[0][1]).toMatchObject({
|
||||||
|
"vehicle.price": "1250000000",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the current car price on third-party replies", async () => {
|
||||||
|
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
|
||||||
|
BlameRequestType.THIRD_PARTY,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.submitExpertReplyV2(
|
||||||
|
"v2-claim",
|
||||||
|
{ ...validV2Reply, carPrice: "1250000000" },
|
||||||
|
{
|
||||||
|
sub: V2_EXPERT_ID,
|
||||||
|
fullName: "Expert One",
|
||||||
|
role: RoleEnum.FIELD_EXPERT,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
code: "CAR_PRICE_NOT_ALLOWED",
|
||||||
|
field: "carPrice",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite vehicle.price on third-party replies", async () => {
|
||||||
|
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
|
||||||
|
BlameRequestType.THIRD_PARTY,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.submitExpertReplyV2("v2-claim", validV2Reply, {
|
||||||
|
sub: V2_EXPERT_ID,
|
||||||
|
fullName: "Expert One",
|
||||||
|
role: RoleEnum.FIELD_EXPERT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(findByIdAndUpdate.mock.calls[0][1]).not.toHaveProperty(
|
||||||
|
"vehicle.price",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("allows a repair line without daghi and removes a stray daghi payload", () => {
|
it("allows a repair line without daghi and removes a stray daghi payload", () => {
|
||||||
const service = createService() as any;
|
const service = createService() as any;
|
||||||
|
|
||||||
@@ -105,18 +246,27 @@ describe("ExpertClaimService expert-reply pricing", () => {
|
|||||||
const findByIdAndUpdate = jest.fn();
|
const findByIdAndUpdate = jest.fn();
|
||||||
service.claimCaseDbService = {
|
service.claimCaseDbService = {
|
||||||
findById: jest.fn().mockResolvedValue({
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
blameRequestId: "blame-1",
|
||||||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||||
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
|
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
|
||||||
}),
|
}),
|
||||||
findByIdAndUpdate,
|
findByIdAndUpdate,
|
||||||
};
|
};
|
||||||
|
service.blameRequestDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
|
||||||
|
};
|
||||||
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.submitExpertReplyV2("v2-claim", blankPricingReply, {
|
service.submitExpertReplyV2(
|
||||||
sub: "expert-1",
|
"v2-claim",
|
||||||
role: RoleEnum.FIELD_EXPERT,
|
{ ...blankPricingReply, carPrice: "1000000" },
|
||||||
}),
|
{
|
||||||
|
sub: "expert-1",
|
||||||
|
role: RoleEnum.FIELD_EXPERT,
|
||||||
|
},
|
||||||
|
),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
||||||
@@ -127,18 +277,24 @@ describe("ExpertClaimService expert-reply pricing", () => {
|
|||||||
const findByIdAndUpdate = jest.fn();
|
const findByIdAndUpdate = jest.fn();
|
||||||
service.claimCaseDbService = {
|
service.claimCaseDbService = {
|
||||||
findById: jest.fn().mockResolvedValue({
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
blameRequestId: "blame-1",
|
||||||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||||
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
|
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
|
||||||
}),
|
}),
|
||||||
findByIdAndUpdate,
|
findByIdAndUpdate,
|
||||||
};
|
};
|
||||||
|
service.blameRequestDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
|
||||||
|
};
|
||||||
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.submitExpertReplyV2(
|
service.submitExpertReplyV2(
|
||||||
"v2-claim",
|
"v2-claim",
|
||||||
{
|
{
|
||||||
description: "Damage assessment",
|
description: "Damage assessment",
|
||||||
|
carPrice: "1000000",
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
partId: 201,
|
partId: 201,
|
||||||
@@ -163,3 +319,132 @@ describe("ExpertClaimService expert-reply pricing", () => {
|
|||||||
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ExpertClaimService damaged-part audit", () => {
|
||||||
|
it("archives removed capture evidence before replacing the live arrays", async () => {
|
||||||
|
const service = createService() as any;
|
||||||
|
const expertId = "66ec0e480e321873c0900001";
|
||||||
|
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.claimCaseDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
_id: "66ec0e480e321873c0900002",
|
||||||
|
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||||
|
workflow: { locked: true, lockedBy: { actorId: expertId } },
|
||||||
|
vehicle: { carType: "SEDAN" },
|
||||||
|
damage: {
|
||||||
|
selectedParts: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
name: "hood",
|
||||||
|
side: "front",
|
||||||
|
label_fa: "کاپوت",
|
||||||
|
catalogKey: "front_hood",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 202,
|
||||||
|
name: "door",
|
||||||
|
side: "left",
|
||||||
|
label_fa: "درب چپ",
|
||||||
|
catalogKey: "left_door",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
damagedParts: [
|
||||||
|
{ path: "claims/hood.jpg", fileName: "hood.jpg" },
|
||||||
|
{ path: "claims/door.jpg", fileName: "door.jpg" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
findByIdAndUpdate,
|
||||||
|
};
|
||||||
|
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.snapshotDamageExpert = jest.fn().mockResolvedValue({
|
||||||
|
firstName: "Expert",
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.updateClaimDamagedPartsV2(
|
||||||
|
"66ec0e480e321873c0900002",
|
||||||
|
{
|
||||||
|
selectedParts: [
|
||||||
|
{
|
||||||
|
id: 202,
|
||||||
|
name: "door",
|
||||||
|
side: "left",
|
||||||
|
label_fa: "درب چپ",
|
||||||
|
catalogKey: "left_door",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sub: expertId,
|
||||||
|
fullName: "Expert One",
|
||||||
|
role: RoleEnum.FIELD_EXPERT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
const update = findByIdAndUpdate.mock.calls[0][1];
|
||||||
|
expect(update.$set["media.damagedParts"]).toEqual([
|
||||||
|
expect.objectContaining({ path: "claims/door.jpg" }),
|
||||||
|
]);
|
||||||
|
expect(update.$push["damage.partSelectionHistory"]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
changedBy: expect.objectContaining({ actorId: expertId }),
|
||||||
|
removedParts: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 101,
|
||||||
|
capture: expect.objectContaining({ path: "claims/hood.jpg" }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ExpertClaimService FileReviewer assignment", () => {
|
||||||
|
it("lets the assigned reviewer reopen a file while it is still awaiting FileReviewer work", async () => {
|
||||||
|
const service = createService() as any;
|
||||||
|
const reviewerId = new Types.ObjectId();
|
||||||
|
const clientId = new Types.ObjectId();
|
||||||
|
const blameId = new Types.ObjectId();
|
||||||
|
const claimId = new Types.ObjectId();
|
||||||
|
|
||||||
|
service.expireClaimWorkflowLockV2IfStale = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.claimCaseDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
_id: claimId,
|
||||||
|
blameRequestId: blameId,
|
||||||
|
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
service.blameRequestDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
_id: blameId,
|
||||||
|
type: BlameRequestType.CAR_BODY,
|
||||||
|
status: "WAITING_FOR_FILE_REVIEWER",
|
||||||
|
assignedFileReviewerId: reviewerId,
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
role: "FIRST",
|
||||||
|
person: { clientId },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
service.assertExpertActorOnClaim = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.assignClaimForReviewV2(String(claimId), {
|
||||||
|
sub: String(reviewerId),
|
||||||
|
role: RoleEnum.FILE_REVIEWER,
|
||||||
|
clientKey: String(clientId),
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
success: true,
|
||||||
|
status: "already_assigned_to_you",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.assertExpertActorOnClaim).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ import {
|
|||||||
} from "src/helpers/outer-damage-parts";
|
} from "src/helpers/outer-damage-parts";
|
||||||
import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend";
|
import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend";
|
||||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
|
import {
|
||||||
|
buildDamagedPartSelectionRevision,
|
||||||
|
serializeDamagedPartSelectionHistory,
|
||||||
|
} from "src/helpers/claim-damaged-part-audit";
|
||||||
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
||||||
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
|
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
|
||||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||||
@@ -172,6 +176,10 @@ import {
|
|||||||
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
|
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
|
||||||
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
|
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
|
||||||
import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing";
|
import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing";
|
||||||
|
import {
|
||||||
|
normalizeMoneyAmountString,
|
||||||
|
parseMoneyAmountToman,
|
||||||
|
} from "src/utils/unicode-digits";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpertClaimService {
|
export class ExpertClaimService {
|
||||||
@@ -2719,9 +2727,14 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
|
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
|
||||||
if (assignedReviewerId && assignedReviewerId === actor.sub) {
|
if (assignedReviewerId && assignedReviewerId === actor.sub) {
|
||||||
// Reviewer already assigned (e.g. after a FileMaker rejection that reset
|
// Phase 1 is intentionally idempotent. The linked claim can still be
|
||||||
// blame back to WAITING_FOR_FILE_REVIEWER) — skip Phase 1 and fall
|
// in a data-capture status here, so falling through to the damage
|
||||||
// through to the damage-expert workflow lock below.
|
// assessment status gate would incorrectly reject the same reviewer.
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: "already_assigned_to_you",
|
||||||
|
message: "You have already taken this file.",
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
// Phase 1: first-time blame assignment
|
// Phase 1: first-time blame assignment
|
||||||
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
|
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
|
||||||
@@ -3263,8 +3276,6 @@ export class ExpertClaimService {
|
|||||||
) {
|
) {
|
||||||
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
const blame = await this.blameRequestDbService.findById(claim.blameRequestId);
|
|
||||||
|
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
this.expertReplySubmissionError(
|
this.expertReplySubmissionError(
|
||||||
@@ -3274,6 +3285,18 @@ export class ExpertClaimService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blame = await this.blameRequestDbService.findById(
|
||||||
|
claim.blameRequestId,
|
||||||
|
);
|
||||||
|
if (!blame) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
this.expertReplySubmissionError(
|
||||||
|
"پرونده تعیین خسارت مرتبط یافت نشد.",
|
||||||
|
"BLAME_NOT_FOUND",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.assertExpertActorOnClaim(claim, actor);
|
await this.assertExpertActorOnClaim(claim, actor);
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
@@ -3304,12 +3327,33 @@ export class ExpertClaimService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reply.carPrice && blame.type !== BlameRequestType.CAR_BODY) {
|
const carPriceWasProvided = reply.carPrice != null;
|
||||||
throw new ForbiddenException("قیمت روز خودرو فقط در پرونده های بدنه باید بررسی شود")
|
let normalizedCurrentCarPrice: string | undefined;
|
||||||
}
|
|
||||||
|
|
||||||
if (blame.type === BlameRequestType.THIRD_PARTY && reply.carPrice) {
|
if (blame.type === BlameRequestType.CAR_BODY) {
|
||||||
throw new ForbiddenException("قیمت روز خودرو فقط در پرونده های بدنه باید بررسی شود")
|
const parsedCurrentCarPrice = parseMoneyAmountToman(reply.carPrice);
|
||||||
|
if (
|
||||||
|
parsedCurrentCarPrice == null ||
|
||||||
|
!Number.isSafeInteger(parsedCurrentCarPrice) ||
|
||||||
|
parsedCurrentCarPrice <= 0
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
this.expertReplySubmissionError(
|
||||||
|
"قیمت روز خودرو در پرونده بدنه الزامی است و باید مبلغی صحیح و بیشتر از صفر باشد.",
|
||||||
|
"CAR_PRICE_REQUIRED",
|
||||||
|
{ field: "carPrice" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
normalizedCurrentCarPrice = normalizeMoneyAmountString(reply.carPrice!);
|
||||||
|
} else if (carPriceWasProvided) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
this.expertReplySubmissionError(
|
||||||
|
"قیمت روز خودرو فقط برای پرونده بدنه قابل ثبت است.",
|
||||||
|
"CAR_PRICE_NOT_ALLOWED",
|
||||||
|
{ field: "carPrice" },
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pricingValidationError = getExpertReplyPricingValidationError(
|
const pricingValidationError = getExpertReplyPricingValidationError(
|
||||||
@@ -3540,7 +3584,9 @@ export class ExpertClaimService {
|
|||||||
"evaluation.ownerInsurerApproval": "",
|
"evaluation.ownerInsurerApproval": "",
|
||||||
"evaluation.ownerPricedPartsApproval": "",
|
"evaluation.ownerPricedPartsApproval": "",
|
||||||
},
|
},
|
||||||
"vehicle.price": reply.carPrice,
|
...(normalizedCurrentCarPrice
|
||||||
|
? { "vehicle.price": normalizedCurrentCarPrice }
|
||||||
|
: {}),
|
||||||
"workflow.currentStep": currentStep,
|
"workflow.currentStep": currentStep,
|
||||||
"workflow.nextStep": nextWorkflowStep,
|
"workflow.nextStep": nextWorkflowStep,
|
||||||
[`evaluation.${replyField}`]: replyPayload,
|
[`evaluation.${replyField}`]: replyPayload,
|
||||||
@@ -5050,6 +5096,10 @@ export class ExpertClaimService {
|
|||||||
buildFileLink,
|
buildFileLink,
|
||||||
resolveStoredFileUrl,
|
resolveStoredFileUrl,
|
||||||
});
|
});
|
||||||
|
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
|
||||||
|
history: (claim.damage as any)?.partSelectionHistory,
|
||||||
|
currentSelectedParts: selectedNormExpert,
|
||||||
|
});
|
||||||
|
|
||||||
// Vehicle payload — fall back to blame inquiry if claim vehicle is sparse
|
// Vehicle payload — fall back to blame inquiry if claim vehicle is sparse
|
||||||
let vehiclePayload = claim.vehicle as any;
|
let vehiclePayload = claim.vehicle as any;
|
||||||
@@ -5229,6 +5279,7 @@ export class ExpertClaimService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
carAngles,
|
carAngles,
|
||||||
damagedParts,
|
damagedParts,
|
||||||
|
damagedPartsHistory,
|
||||||
awaitingFactorValidation: isFactorValidationPending,
|
awaitingFactorValidation: isFactorValidationPending,
|
||||||
requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval,
|
requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval,
|
||||||
fileMakerRejectionCount: (claim as any).fileMakerRejectionCount ?? 0,
|
fileMakerRejectionCount: (claim as any).fileMakerRejectionCount ?? 0,
|
||||||
@@ -5606,33 +5657,58 @@ export class ExpertClaimService {
|
|||||||
);
|
);
|
||||||
const mergedExpertAdded = [...existingExpertAdded, ...expertAddedToAppend];
|
const mergedExpertAdded = [...existingExpertAdded, ...expertAddedToAppend];
|
||||||
|
|
||||||
|
const changedAt = new Date();
|
||||||
|
const partSelectionRevision = buildDamagedPartSelectionRevision({
|
||||||
|
revisionId: new Types.ObjectId().toString(),
|
||||||
|
changedAt,
|
||||||
|
changedBy: {
|
||||||
|
actorId: actor.sub,
|
||||||
|
actorName: actor.fullName,
|
||||||
|
actorType: "damage_expert",
|
||||||
|
},
|
||||||
|
expertProfileSnapshot: damagedPartsEditSnapshot,
|
||||||
|
previousParts: previousNorm,
|
||||||
|
selectedParts: nextNorm,
|
||||||
|
previousMedia: prevMedia,
|
||||||
|
});
|
||||||
|
|
||||||
const $set: Record<string, unknown> = {
|
const $set: Record<string, unknown> = {
|
||||||
"damage.selectedParts": nextNorm,
|
"damage.selectedParts": nextNorm,
|
||||||
"media.damagedParts": nextMedia,
|
"media.damagedParts": nextMedia,
|
||||||
"damage.expertAddedParts": mergedExpertAdded,
|
"damage.expertAddedParts": mergedExpertAdded,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
const push: Record<string, unknown> = {
|
||||||
$set,
|
history: {
|
||||||
$push: {
|
type: "EXPERT_DAMAGED_PARTS_UPDATED",
|
||||||
history: {
|
actor: {
|
||||||
type: "EXPERT_DAMAGED_PARTS_UPDATED",
|
actorId: new Types.ObjectId(actor.sub),
|
||||||
actor: {
|
actorName: actor.fullName,
|
||||||
actorId: new Types.ObjectId(actor.sub),
|
actorType: "damage_expert",
|
||||||
actorName: actor.fullName,
|
},
|
||||||
actorType: "damage_expert",
|
timestamp: changedAt,
|
||||||
},
|
metadata: {
|
||||||
timestamp: new Date(),
|
previousSelectedParts: previous,
|
||||||
metadata: {
|
selectedParts: nextNorm,
|
||||||
previousSelectedParts: previous,
|
expertAddedParts: mergedExpertAdded,
|
||||||
selectedParts: nextNorm,
|
...(partSelectionRevision && {
|
||||||
expertAddedParts: mergedExpertAdded,
|
partSelectionRevisionId: partSelectionRevision.revisionId,
|
||||||
...(damagedPartsEditSnapshot && {
|
removedParts: partSelectionRevision.removedParts,
|
||||||
expertProfileSnapshot: damagedPartsEditSnapshot,
|
addedParts: partSelectionRevision.addedParts,
|
||||||
}),
|
}),
|
||||||
},
|
...(damagedPartsEditSnapshot && {
|
||||||
|
expertProfileSnapshot: damagedPartsEditSnapshot,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
if (partSelectionRevision) {
|
||||||
|
push["damage.partSelectionHistory"] = partSelectionRevision;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set,
|
||||||
|
$push: push,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -5640,6 +5716,7 @@ export class ExpertClaimService {
|
|||||||
selectedParts: nextNorm,
|
selectedParts: nextNorm,
|
||||||
previousSelectedParts: previous,
|
previousSelectedParts: previous,
|
||||||
expertAddedParts: mergedExpertAdded,
|
expertAddedParts: mergedExpertAdded,
|
||||||
|
partSelectionRevision,
|
||||||
message: "Damaged parts updated successfully.",
|
message: "Damaged parts updated successfully.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import {
|
|||||||
localizeTimelineMetadata,
|
localizeTimelineMetadata,
|
||||||
} from "./helper/timeline-fa-labels";
|
} from "./helper/timeline-fa-labels";
|
||||||
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
|
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
|
||||||
|
import { serializeDamagedPartSelectionHistory } from "src/helpers/claim-damaged-part-audit";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpertInsurerService {
|
export class ExpertInsurerService {
|
||||||
@@ -524,6 +525,8 @@ export class ExpertInsurerService {
|
|||||||
const d = { ...(damage as Record<string, unknown>) };
|
const d = { ...(damage as Record<string, unknown>) };
|
||||||
delete d.selectedOuterParts;
|
delete d.selectedOuterParts;
|
||||||
delete d.selectedPartIds;
|
delete d.selectedPartIds;
|
||||||
|
// Expose the stable, URL-enriched contract instead of raw audit storage.
|
||||||
|
delete d.partSelectionHistory;
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,6 +680,10 @@ export class ExpertInsurerService {
|
|||||||
catalogLikeKeyFromPart,
|
catalogLikeKeyFromPart,
|
||||||
buildFileLink,
|
buildFileLink,
|
||||||
});
|
});
|
||||||
|
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
|
||||||
|
history: (claim as any).damage?.partSelectionHistory,
|
||||||
|
currentSelectedParts: selectedNorm,
|
||||||
|
});
|
||||||
|
|
||||||
// Then add damagedParts to the return object
|
// Then add damagedParts to the return object
|
||||||
const requiredDocs = claim.requiredDocuments as any;
|
const requiredDocs = claim.requiredDocuments as any;
|
||||||
@@ -797,6 +804,7 @@ export class ExpertInsurerService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
carAngles,
|
carAngles,
|
||||||
damagedParts,
|
damagedParts,
|
||||||
|
damagedPartsHistory,
|
||||||
videoCapture,
|
videoCapture,
|
||||||
evaluation: evaluationEnriched,
|
evaluation: evaluationEnriched,
|
||||||
userRating: claim.userRating,
|
userRating: claim.userRating,
|
||||||
|
|||||||
72
src/helpers/claim-damaged-part-audit.spec.ts
Normal file
72
src/helpers/claim-damaged-part-audit.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
buildDamagedPartSelectionRevision,
|
||||||
|
serializeDamagedPartSelectionHistory,
|
||||||
|
} from "./claim-damaged-part-audit";
|
||||||
|
|
||||||
|
describe("damaged-part selection audit", () => {
|
||||||
|
const hood = {
|
||||||
|
id: 101,
|
||||||
|
name: "hood",
|
||||||
|
side: "front",
|
||||||
|
label_fa: "کاپوت",
|
||||||
|
catalogKey: "front_hood",
|
||||||
|
};
|
||||||
|
const door = {
|
||||||
|
id: 202,
|
||||||
|
name: "door",
|
||||||
|
side: "left",
|
||||||
|
label_fa: "درب چپ",
|
||||||
|
catalogKey: "left_door",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("keeps the removed part capture and serializes it for panel display", () => {
|
||||||
|
const revision = buildDamagedPartSelectionRevision({
|
||||||
|
revisionId: "revision-1",
|
||||||
|
changedAt: new Date("2026-09-18T10:00:00.000Z"),
|
||||||
|
changedBy: {
|
||||||
|
actorId: "expert-1",
|
||||||
|
actorName: "Expert One",
|
||||||
|
actorType: "damage_expert",
|
||||||
|
},
|
||||||
|
previousParts: [hood, door],
|
||||||
|
selectedParts: [door],
|
||||||
|
previousMedia: [
|
||||||
|
{ path: "claims/hood.jpg", fileName: "hood.jpg" },
|
||||||
|
{ path: "claims/door.jpg", fileName: "door.jpg" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(revision?.removedParts).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 101,
|
||||||
|
capture: expect.objectContaining({ path: "claims/hood.jpg" }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const serialized = serializeDamagedPartSelectionHistory({
|
||||||
|
history: [revision],
|
||||||
|
currentSelectedParts: [door],
|
||||||
|
});
|
||||||
|
expect(serialized[0].removedParts[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 101,
|
||||||
|
captured: true,
|
||||||
|
currentlySelected: false,
|
||||||
|
url: expect.stringContaining("claims/hood.jpg"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create a revision for an unchanged or order-only submission", () => {
|
||||||
|
expect(
|
||||||
|
buildDamagedPartSelectionRevision({
|
||||||
|
revisionId: "revision-2",
|
||||||
|
changedAt: new Date(),
|
||||||
|
changedBy: { actorId: "expert-1", actorType: "damage_expert" },
|
||||||
|
previousParts: [hood, door],
|
||||||
|
selectedParts: [door, hood],
|
||||||
|
previousMedia: [],
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
170
src/helpers/claim-damaged-part-audit.ts
Normal file
170
src/helpers/claim-damaged-part-audit.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { resolveStoredFileUrl } from "src/helpers/urlCreator";
|
||||||
|
import {
|
||||||
|
coerceDamagedPartsMediaToArray,
|
||||||
|
partLookupKey,
|
||||||
|
type DamageSelectedPartV2,
|
||||||
|
} from "src/helpers/outer-damage-parts";
|
||||||
|
|
||||||
|
export interface DamagedPartAuditActor {
|
||||||
|
actorId: string;
|
||||||
|
actorName?: string;
|
||||||
|
actorType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredDamagedPartAuditRow extends DamageSelectedPartV2 {
|
||||||
|
capture?: {
|
||||||
|
path?: string;
|
||||||
|
fileName?: string;
|
||||||
|
url?: string;
|
||||||
|
capturedAt?: Date | string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredDamagedPartSelectionRevision {
|
||||||
|
revisionId: string;
|
||||||
|
changedAt: Date;
|
||||||
|
changedBy: DamagedPartAuditActor;
|
||||||
|
expertProfileSnapshot?: unknown;
|
||||||
|
previousSelectedParts: DamageSelectedPartV2[];
|
||||||
|
selectedParts: DamageSelectedPartV2[];
|
||||||
|
removedParts: StoredDamagedPartAuditRow[];
|
||||||
|
addedParts: StoredDamagedPartAuditRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DamagedPartSelectionHistoryRow {
|
||||||
|
revisionId: string;
|
||||||
|
changedAt: Date | string;
|
||||||
|
changedBy: DamagedPartAuditActor;
|
||||||
|
expertProfileSnapshot?: unknown;
|
||||||
|
removedParts: Array<
|
||||||
|
DamageSelectedPartV2 & {
|
||||||
|
partId: number | null;
|
||||||
|
captured: boolean;
|
||||||
|
url?: string;
|
||||||
|
fileName?: string;
|
||||||
|
capturedAt?: Date | string;
|
||||||
|
currentlySelected: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
addedParts: Array<
|
||||||
|
DamageSelectedPartV2 & {
|
||||||
|
partId: number | null;
|
||||||
|
currentlySelected: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainCapture(row: unknown): StoredDamagedPartAuditRow["capture"] {
|
||||||
|
if (!row || typeof row !== "object") return undefined;
|
||||||
|
const source = row as Record<string, unknown>;
|
||||||
|
const capture = {
|
||||||
|
...(source.path ? { path: String(source.path) } : {}),
|
||||||
|
...(source.fileName ? { fileName: String(source.fileName) } : {}),
|
||||||
|
...(source.url ? { url: String(source.url) } : {}),
|
||||||
|
...(source.capturedAt
|
||||||
|
? { capturedAt: source.capturedAt as Date | string }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
return Object.keys(capture).length > 0 ? capture : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds one immutable audit revision before the live selected-parts/media arrays
|
||||||
|
* are replaced. Returns null for a semantic no-op (including order-only changes).
|
||||||
|
*/
|
||||||
|
export function buildDamagedPartSelectionRevision(params: {
|
||||||
|
revisionId: string;
|
||||||
|
changedAt: Date;
|
||||||
|
changedBy: DamagedPartAuditActor;
|
||||||
|
expertProfileSnapshot?: unknown;
|
||||||
|
previousParts: DamageSelectedPartV2[];
|
||||||
|
selectedParts: DamageSelectedPartV2[];
|
||||||
|
previousMedia: unknown;
|
||||||
|
}): StoredDamagedPartSelectionRevision | null {
|
||||||
|
const previousKeys = new Set(params.previousParts.map(partLookupKey));
|
||||||
|
const selectedKeys = new Set(params.selectedParts.map(partLookupKey));
|
||||||
|
const mediaRows = coerceDamagedPartsMediaToArray(
|
||||||
|
params.previousMedia,
|
||||||
|
params.previousParts,
|
||||||
|
);
|
||||||
|
|
||||||
|
const removedParts = params.previousParts.flatMap((part, index) => {
|
||||||
|
if (selectedKeys.has(partLookupKey(part))) return [];
|
||||||
|
const capture = plainCapture(mediaRows[index]);
|
||||||
|
return [{ ...part, ...(capture ? { capture } : {}) }];
|
||||||
|
});
|
||||||
|
const addedParts = params.selectedParts
|
||||||
|
.filter((part) => !previousKeys.has(partLookupKey(part)))
|
||||||
|
.map((part) => ({ ...part }));
|
||||||
|
|
||||||
|
if (removedParts.length === 0 && addedParts.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
revisionId: params.revisionId,
|
||||||
|
changedAt: params.changedAt,
|
||||||
|
changedBy: params.changedBy,
|
||||||
|
...(params.expertProfileSnapshot
|
||||||
|
? { expertProfileSnapshot: params.expertProfileSnapshot }
|
||||||
|
: {}),
|
||||||
|
previousSelectedParts: params.previousParts.map((part) => ({ ...part })),
|
||||||
|
selectedParts: params.selectedParts.map((part) => ({ ...part })),
|
||||||
|
removedParts,
|
||||||
|
addedParts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts stored revisions into a stable, URL-enriched panel contract. */
|
||||||
|
export function serializeDamagedPartSelectionHistory(params: {
|
||||||
|
history: unknown;
|
||||||
|
currentSelectedParts: DamageSelectedPartV2[];
|
||||||
|
}): DamagedPartSelectionHistoryRow[] {
|
||||||
|
if (!Array.isArray(params.history)) return [];
|
||||||
|
const currentKeys = new Set(params.currentSelectedParts.map(partLookupKey));
|
||||||
|
|
||||||
|
return params.history
|
||||||
|
.filter((revision) => revision && typeof revision === "object")
|
||||||
|
.map((revision: any) => ({
|
||||||
|
revisionId: String(revision.revisionId ?? ""),
|
||||||
|
changedAt: revision.changedAt,
|
||||||
|
changedBy: {
|
||||||
|
actorId: String(revision.changedBy?.actorId ?? ""),
|
||||||
|
actorName: revision.changedBy?.actorName,
|
||||||
|
actorType: String(revision.changedBy?.actorType ?? "damage_expert"),
|
||||||
|
},
|
||||||
|
...(revision.expertProfileSnapshot
|
||||||
|
? { expertProfileSnapshot: revision.expertProfileSnapshot }
|
||||||
|
: {}),
|
||||||
|
removedParts: (Array.isArray(revision.removedParts)
|
||||||
|
? revision.removedParts
|
||||||
|
: []
|
||||||
|
).map((part: StoredDamagedPartAuditRow) => ({
|
||||||
|
id: part.id ?? null,
|
||||||
|
partId: part.id ?? null,
|
||||||
|
name: part.name,
|
||||||
|
side: part.side,
|
||||||
|
label_fa: part.label_fa,
|
||||||
|
...(part.catalogKey ? { catalogKey: part.catalogKey } : {}),
|
||||||
|
captured: !!part.capture,
|
||||||
|
...(part.capture?.fileName ? { fileName: part.capture.fileName } : {}),
|
||||||
|
...(part.capture?.capturedAt
|
||||||
|
? { capturedAt: part.capture.capturedAt }
|
||||||
|
: {}),
|
||||||
|
...(resolveStoredFileUrl(part.capture)
|
||||||
|
? { url: resolveStoredFileUrl(part.capture) }
|
||||||
|
: {}),
|
||||||
|
currentlySelected: currentKeys.has(partLookupKey(part)),
|
||||||
|
})),
|
||||||
|
addedParts: (Array.isArray(revision.addedParts)
|
||||||
|
? revision.addedParts
|
||||||
|
: []
|
||||||
|
).map((part: DamageSelectedPartV2) => ({
|
||||||
|
...part,
|
||||||
|
partId: part.id ?? null,
|
||||||
|
currentlySelected: currentKeys.has(partLookupKey(part)),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(revision) =>
|
||||||
|
revision.removedParts.length > 0 || revision.addedParts.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -110,12 +110,42 @@ for (const [seg, key] of Object.entries(SEGMENT_ALIASES)) {
|
|||||||
NORM_TO_PART_KEY.set(seg, key);
|
NORM_TO_PART_KEY.set(seg, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PERSIAN_LABEL_ALIASES: Array<[string, string]> = [
|
||||||
|
["گلگیر عقب", "backFender"],
|
||||||
|
["درب عقب", "backDoor"],
|
||||||
|
["درب جلو", "frontDoor"],
|
||||||
|
["گلگیر جلو", "frontFender"],
|
||||||
|
["سپر جلو", "frontBumper"],
|
||||||
|
["سپر عقب", "frontBumper"],
|
||||||
|
["درب موتور", "Hood"],
|
||||||
|
["کاپوت", "Hood"],
|
||||||
|
["درب صندوق", "Trunk"],
|
||||||
|
["صندوق عقب", "Trunk"],
|
||||||
|
["سقف", "Roof"],
|
||||||
|
["کلاف", "coil"],
|
||||||
|
["ستون", "column"],
|
||||||
|
["سینی جلو", "frontTray"],
|
||||||
|
["سینی عقب", "backTray"],
|
||||||
|
["شاسی جلو", "frontChassis"],
|
||||||
|
["شاسی عقب", "backChassis"],
|
||||||
|
["رکاب", "carFootrest"],
|
||||||
|
["کف اتاق", "carFloor"],
|
||||||
|
];
|
||||||
|
|
||||||
export function normalizePriceDropKey(str: string): string {
|
export function normalizePriceDropKey(str: string): string {
|
||||||
return String(str ?? "")
|
return String(str ?? "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]/g, "");
|
.replace(/[^a-z0-9]/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePersianLabel(str: string): string {
|
||||||
|
return String(str ?? "")
|
||||||
|
.replace(/[يى]/g, "ی")
|
||||||
|
.replace(/ك/g, "ک")
|
||||||
|
.replace(/[\u200c\s()\-_]/g, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function parsePriceDropNumber(input: number | string): number {
|
export function parsePriceDropNumber(input: number | string): number {
|
||||||
if (typeof input === "number" && Number.isFinite(input)) return input;
|
if (typeof input === "number" && Number.isFinite(input)) return input;
|
||||||
return Number(
|
return Number(
|
||||||
@@ -191,6 +221,7 @@ export function buildPriceDropCatalogForApi(): Array<{
|
|||||||
export function resolvePriceDropPartKeyFromDamagePart(part: {
|
export function resolvePriceDropPartKeyFromDamagePart(part: {
|
||||||
name?: string;
|
name?: string;
|
||||||
catalogKey?: string;
|
catalogKey?: string;
|
||||||
|
label_fa?: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
if (part.catalogKey) {
|
if (part.catalogKey) {
|
||||||
@@ -205,6 +236,11 @@ export function resolvePriceDropPartKeyFromDamagePart(part: {
|
|||||||
const hit = NORM_TO_PART_KEY.get(norm);
|
const hit = NORM_TO_PART_KEY.get(norm);
|
||||||
if (hit && PRICE_DROP_PART_TABLE[hit]) return hit;
|
if (hit && PRICE_DROP_PART_TABLE[hit]) return hit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedLabel = normalizePersianLabel(part.label_fa ?? part.name ?? "");
|
||||||
|
for (const [label, key] of PERSIAN_LABEL_ALIASES) {
|
||||||
|
if (normalizedLabel.includes(normalizePersianLabel(label))) return key;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,21 +128,21 @@ describe("getExpertReplyPricingValidationError", () => {
|
|||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["price", "99,999", "parts[0].price"],
|
["price", "99,999", "parts[0].price"],
|
||||||
["salary", "10,000,000,001", "parts[0].salary"],
|
["salary", "100,000,000,001", "parts[0].salary"],
|
||||||
["totalPayment", "99,999", "parts[0].totalPayment"],
|
["totalPayment", "99,999", "parts[0].totalPayment"],
|
||||||
["daghi.price", "10,000,000,001", "parts[0].daghi.price"],
|
["daghi.price", "100,000,000,001", "parts[0].daghi.price"],
|
||||||
])(
|
])(
|
||||||
"enforces the amount range for %s",
|
"enforces the amount range for %s",
|
||||||
(field, invalidValue, expectedField) => {
|
(field, invalidValue, expectedField) => {
|
||||||
const part = {
|
const part = {
|
||||||
partId: 201,
|
partId: 201,
|
||||||
typeOfDamage: TypeOfDamage.Change,
|
typeOfDamage: TypeOfDamage.Change,
|
||||||
price: "100000",
|
price: "1000000",
|
||||||
salary: "100000",
|
salary: "1000000",
|
||||||
totalPayment: "100000",
|
totalPayment: "1000000",
|
||||||
daghi: {
|
daghi: {
|
||||||
option: DaghiOption.RECYCLED_PARTS_VALUE,
|
option: DaghiOption.RECYCLED_PARTS_VALUE,
|
||||||
price: "100000",
|
price: "1000000",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (field === "daghi.price") part.daghi.price = invalidValue;
|
if (field === "daghi.price") part.daghi.price = invalidValue;
|
||||||
|
|||||||
@@ -43,36 +43,36 @@ describe("resolveSelectedPartByPartId", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves catalog id from outer catalog when not on claim", () => {
|
it("resolves catalog id from outer catalog when not on claim", () => {
|
||||||
const hit = resolveCatalogPartByPartId(201, ClaimVehicleTypeV2.HATCHBACK);
|
const hit = resolveCatalogPartByPartId(36, ClaimVehicleTypeV2.HATCHBACK);
|
||||||
expect(hit?.id).toBe(201);
|
expect(hit?.id).toBe(36);
|
||||||
expect(hit?.side).toBe("left");
|
expect(hit?.side).toBe("");
|
||||||
expect(hit?.catalogKey).toBe("left_backfender");
|
expect(hit?.catalogKey).toBe("36");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolvePartForExpertReply uses catalog for new expert line", () => {
|
it("resolvePartForExpertReply uses catalog for new expert line", () => {
|
||||||
const hit = resolvePartForExpertReply(
|
const hit = resolvePartForExpertReply(
|
||||||
201,
|
36,
|
||||||
[],
|
[],
|
||||||
ClaimVehicleTypeV2.HATCHBACK,
|
ClaimVehicleTypeV2.HATCHBACK,
|
||||||
);
|
);
|
||||||
expect(hit?.id).toBe(201);
|
expect(hit?.id).toBe(36);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sanitize fixes Persian side and re-hydrates from catalog id", () => {
|
it("sanitize fixes Persian side and re-hydrates from catalog id", () => {
|
||||||
const fixed = sanitizeDamageSelectedPartV2(
|
const fixed = sanitizeDamageSelectedPartV2(
|
||||||
{
|
{
|
||||||
id: 201,
|
id: 36,
|
||||||
name: "گلگیر عقب (چپ)",
|
name: "گلگير عقب سمت راننده",
|
||||||
side: "چپ",
|
side: "چپ",
|
||||||
label_fa: "",
|
label_fa: "",
|
||||||
catalogKey: "چپ",
|
catalogKey: "چپ",
|
||||||
},
|
},
|
||||||
ClaimVehicleTypeV2.HATCHBACK,
|
ClaimVehicleTypeV2.HATCHBACK,
|
||||||
);
|
);
|
||||||
expect(fixed.side).toBe("left");
|
expect(fixed.side).toBe("");
|
||||||
expect(fixed.name).toBe("backfender");
|
expect(fixed.name).toBe("36");
|
||||||
expect(fixed.catalogKey).toBe("left_backfender");
|
expect(fixed.catalogKey).toBe("36");
|
||||||
expect(catalogPartIdFromSelectedPart(fixed)).toBe(201);
|
expect(catalogPartIdFromSelectedPart(fixed)).toBe(36);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("internal parts have null catalog partId", () => {
|
it("internal parts have null catalog partId", () => {
|
||||||
@@ -90,30 +90,30 @@ describe("resolveSelectedPartByPartId", () => {
|
|||||||
const fixed = sanitizeDamageSelectedPartV2(
|
const fixed = sanitizeDamageSelectedPartV2(
|
||||||
{
|
{
|
||||||
id: null,
|
id: null,
|
||||||
name: "گلگیر عقب (چپ)",
|
name: "گلگير عقب سمت راننده",
|
||||||
side: "internal",
|
side: "internal",
|
||||||
label_fa: "گلگیر عقب (چپ)",
|
label_fa: "گلگير عقب سمت راننده",
|
||||||
},
|
},
|
||||||
ClaimVehicleTypeV2.HATCHBACK,
|
ClaimVehicleTypeV2.HATCHBACK,
|
||||||
);
|
);
|
||||||
expect(fixed.id).toBe(201);
|
expect(fixed.id).toBe(36);
|
||||||
expect(fixed.side).toBe("left");
|
expect(fixed.side).toBe("");
|
||||||
expect(fixed.name).toBe("backfender");
|
expect(fixed.name).toBe("36");
|
||||||
expect(fixed.catalogKey).toBe("left_backfender");
|
expect(fixed.catalogKey).toBe("36");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("price drop resolves catalog id when claim row is corrupt", () => {
|
it("price drop resolves catalog id when claim row is corrupt", () => {
|
||||||
const corrupt: DamageSelectedPartV2[] = [
|
const corrupt: DamageSelectedPartV2[] = [
|
||||||
{
|
{
|
||||||
id: null,
|
id: null,
|
||||||
name: "گلگیر عقب (چپ)",
|
name: "گلگير عقب سمت راننده",
|
||||||
side: "internal",
|
side: "internal",
|
||||||
label_fa: "گلگیر عقب (چپ)",
|
label_fa: "گلگير عقب سمت راننده",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
|
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
|
||||||
corrupt,
|
corrupt,
|
||||||
[{ partId: 201, severity: "Minor" }],
|
[{ partId: 36, severity: "Minor" }],
|
||||||
ClaimVehicleTypeV2.HATCHBACK,
|
ClaimVehicleTypeV2.HATCHBACK,
|
||||||
);
|
);
|
||||||
expect(errors).toHaveLength(0);
|
expect(errors).toHaveLength(0);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { ProfileController } from "./profile.controller";
|
import { ProfileController } from "./profile.controller";
|
||||||
import { ProfileService } from "./profile.service";
|
import { ProfileService } from "./profile.service";
|
||||||
|
import { PlatesService } from "src/plates/plates.service";
|
||||||
|
import { JwtService } from "@nestjs/jwt";
|
||||||
|
|
||||||
describe("ProfileController", () => {
|
describe("ProfileController", () => {
|
||||||
let controller: ProfileController;
|
let controller: ProfileController;
|
||||||
@@ -8,7 +10,11 @@ describe("ProfileController", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [ProfileController],
|
controllers: [ProfileController],
|
||||||
providers: [ProfileService],
|
providers: [
|
||||||
|
{ provide: ProfileService, useValue: {} },
|
||||||
|
{ provide: PlatesService, useValue: {} },
|
||||||
|
{ provide: JwtService, useValue: {} },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<ProfileController>(ProfileController);
|
controller = module.get<ProfileController>(ProfileController);
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { ProfileService } from "./profile.service";
|
import { ProfileService } from "./profile.service";
|
||||||
|
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||||
|
|
||||||
describe("ProfileService", () => {
|
describe("ProfileService", () => {
|
||||||
let service: ProfileService;
|
let service: ProfileService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [ProfileService],
|
providers: [
|
||||||
|
ProfileService,
|
||||||
|
{
|
||||||
|
provide: UserDbService,
|
||||||
|
useValue: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
findOneAndUpdate: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<ProfileService>(ProfileService);
|
service = module.get<ProfileService>(ProfileService);
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
|
|||||||
import { CreationMethod } from "./entities/schema/request-management.schema";
|
import { CreationMethod } from "./entities/schema/request-management.schema";
|
||||||
import { PartyRole } from "./entities/schema/partyRole.enum";
|
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||||
import { RequestManagementService } from "./request-management.service";
|
import { RequestManagementService } from "./request-management.service";
|
||||||
|
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||||
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
|
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||||
|
|
||||||
describe("RequestManagementService V4 FileMaker workflow", () => {
|
describe("RequestManagementService V4 FileMaker workflow", () => {
|
||||||
it("persists FIRST_INITIAL_FORM after the first party OTP is verified", async () => {
|
it("persists FIRST_INITIAL_FORM after the first party OTP is verified", async () => {
|
||||||
@@ -216,4 +219,138 @@ describe("RequestManagementService V4 FileMaker workflow", () => {
|
|||||||
WorkflowStep.FIRST_VIDEO,
|
WorkflowStep.FIRST_VIDEO,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
"resumes V4/V5 FileMaker work from the linked claim during partial document upload (approval=%s)",
|
||||||
|
async (requiresFileMakerApproval) => {
|
||||||
|
const fileMakerId = new Types.ObjectId();
|
||||||
|
const blameId = new Types.ObjectId();
|
||||||
|
const claimId = new Types.ObjectId();
|
||||||
|
const request = {
|
||||||
|
_id: blameId,
|
||||||
|
publicId: "BL-FILE-MAKER-RESUME",
|
||||||
|
requestNo: "BL-RESUME",
|
||||||
|
type: BlameRequestType.THIRD_PARTY,
|
||||||
|
status: CaseStatus.OPEN,
|
||||||
|
isMadeByFileMaker: true,
|
||||||
|
initiatedByFieldExpertId: fileMakerId,
|
||||||
|
requiresFileMakerApproval,
|
||||||
|
parties: [],
|
||||||
|
workflow: {
|
||||||
|
currentStep: WorkflowStep.SECOND_COMPLETED,
|
||||||
|
nextStep: WorkflowStep.WAITING_FOR_GUILT_DECISION,
|
||||||
|
completedSteps: [
|
||||||
|
WorkflowStep.FIRST_COMPLETED,
|
||||||
|
WorkflowStep.SECOND_COMPLETED,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const claim = {
|
||||||
|
_id: claimId,
|
||||||
|
blameRequestId: blameId,
|
||||||
|
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
|
workflow: {
|
||||||
|
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||||
|
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||||
|
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service =
|
||||||
|
new (RequestManagementService as any)() as RequestManagementService;
|
||||||
|
(service as any).blameRequestDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue(request),
|
||||||
|
find: jest.fn().mockResolvedValue([request]),
|
||||||
|
};
|
||||||
|
(service as any).claimCaseDbService = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(claim),
|
||||||
|
find: jest.fn().mockResolvedValue([claim]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const reopened = await service.getMyFileMakerFileDetail(
|
||||||
|
{ sub: String(fileMakerId), role: RoleEnum.FILE_MAKER },
|
||||||
|
String(blameId),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reopened.status).toBe(CaseStatus.OPEN);
|
||||||
|
expect(reopened.claimStatus).toBe(
|
||||||
|
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
|
);
|
||||||
|
expect(reopened.fileMakerResume).toEqual({
|
||||||
|
action: "UPLOAD_REQUIRED_DOCUMENTS",
|
||||||
|
entity: "CLAIM",
|
||||||
|
entityId: String(claimId),
|
||||||
|
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
|
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||||
|
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = await service.getMyFileMakerFiles({
|
||||||
|
sub: String(fileMakerId),
|
||||||
|
role: RoleEnum.FILE_MAKER,
|
||||||
|
});
|
||||||
|
expect(list.list[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
linkedClaimId: String(claimId),
|
||||||
|
claimStatus: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
|
fileMakerResume: expect.objectContaining({
|
||||||
|
action: "UPLOAD_REQUIRED_DOCUMENTS",
|
||||||
|
entityId: String(claimId),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("does not leave the blame narrative before all required signatures", async () => {
|
||||||
|
const fileMakerId = new Types.ObjectId();
|
||||||
|
const blameId = new Types.ObjectId();
|
||||||
|
const claimId = new Types.ObjectId();
|
||||||
|
const request = {
|
||||||
|
_id: blameId,
|
||||||
|
publicId: "BL-FILE-MAKER-NARRATIVE",
|
||||||
|
requestNo: "BL-NARRATIVE",
|
||||||
|
type: BlameRequestType.THIRD_PARTY,
|
||||||
|
status: CaseStatus.OPEN,
|
||||||
|
isMadeByFileMaker: true,
|
||||||
|
initiatedByFieldExpertId: fileMakerId,
|
||||||
|
parties: [],
|
||||||
|
workflow: {
|
||||||
|
currentStep: WorkflowStep.FIRST_DESCRIPTION,
|
||||||
|
nextStep: WorkflowStep.FIRST_SIGN,
|
||||||
|
completedSteps: [WorkflowStep.FIRST_VOICE],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const claim = {
|
||||||
|
_id: claimId,
|
||||||
|
blameRequestId: blameId,
|
||||||
|
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
|
workflow: {
|
||||||
|
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||||
|
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||||
|
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service =
|
||||||
|
new (RequestManagementService as any)() as RequestManagementService;
|
||||||
|
(service as any).blameRequestDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue(request),
|
||||||
|
};
|
||||||
|
(service as any).claimCaseDbService = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(claim),
|
||||||
|
};
|
||||||
|
|
||||||
|
const reopened = await service.getMyFileMakerFileDetail(
|
||||||
|
{ sub: String(fileMakerId), role: RoleEnum.FILE_MAKER },
|
||||||
|
String(blameId),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reopened.fileMakerResume).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "CONTINUE_BLAME",
|
||||||
|
entity: "BLAME",
|
||||||
|
entityId: String(blameId),
|
||||||
|
currentStep: WorkflowStep.FIRST_DESCRIPTION,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
Put,
|
Put,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -44,6 +45,7 @@ import {
|
|||||||
UploadRequiredDocumentV2ResponseDto,
|
UploadRequiredDocumentV2ResponseDto,
|
||||||
} from "src/claim-request-management/dto/upload-document-v2.dto";
|
} from "src/claim-request-management/dto/upload-document-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
||||||
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V4 FileMaker flow — first half of the split blame workflow.
|
* V4 FileMaker flow — first half of the split blame workflow.
|
||||||
@@ -96,10 +98,13 @@ export class FileMakerBlameV4Controller {
|
|||||||
@Get("my-files")
|
@Get("my-files")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List all blame files created by this FileMaker",
|
summary: "List all blame files created by this FileMaker",
|
||||||
description: "Returns all V4 FileMaker blame files initiated by the authenticated FileMaker.",
|
description: "Returns V4 FileMaker blame files using the shared search, sort, filter, and pagination query contract.",
|
||||||
})
|
})
|
||||||
async getMyFiles(@CurrentUser() fileMaker: any) {
|
async getMyFiles(
|
||||||
return this.requestManagementService.getMyFileMakerFiles(fileMaker);
|
@CurrentUser() fileMaker: any,
|
||||||
|
@Query() query: ListQueryV2Dto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.getMyFileMakerFiles(fileMaker, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("my-files/:requestId")
|
@Get("my-files/:requestId")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
Put,
|
Put,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -44,6 +45,7 @@ import {
|
|||||||
UploadRequiredDocumentV2ResponseDto,
|
UploadRequiredDocumentV2ResponseDto,
|
||||||
} from "src/claim-request-management/dto/upload-document-v2.dto";
|
} from "src/claim-request-management/dto/upload-document-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
||||||
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V5 FileMaker flow — identical to V4 but under the /v5/ prefix.
|
* V5 FileMaker flow — identical to V4 but under the /v5/ prefix.
|
||||||
@@ -95,10 +97,13 @@ export class FileMakerBlameV5Controller {
|
|||||||
@Get("my-files")
|
@Get("my-files")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List all blame files created by this FileMaker",
|
summary: "List all blame files created by this FileMaker",
|
||||||
description: "Returns all V5 FileMaker blame files initiated by the authenticated FileMaker.",
|
description: "Returns V5 FileMaker blame files using the shared search, sort, filter, and pagination query contract.",
|
||||||
})
|
})
|
||||||
async getMyFiles(@CurrentUser() fileMaker: any) {
|
async getMyFiles(
|
||||||
return this.requestManagementService.getMyFileMakerFiles(fileMaker);
|
@CurrentUser() fileMaker: any,
|
||||||
|
@Query() query: ListQueryV2Dto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.getMyFileMakerFiles(fileMaker, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("my-files/:requestId")
|
@Get("my-files/:requestId")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
CapturePartV2Dto,
|
CapturePartV2Dto,
|
||||||
CapturePartV2ResponseDto,
|
CapturePartV2ResponseDto,
|
||||||
} from "src/claim-request-management/dto/capture-part-v2.dto";
|
} from "src/claim-request-management/dto/capture-part-v2.dto";
|
||||||
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,10 +90,16 @@ export class FileReviewerBlameV4Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List available and assigned FileMaker blame files",
|
summary: "List available and assigned FileMaker blame files",
|
||||||
description:
|
description:
|
||||||
"Returns V4 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
|
"Returns V4 FileMaker blame files in this reviewer's insurer using the shared search, sort, filter, and pagination query contract: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
|
||||||
})
|
})
|
||||||
async getMyFiles(@CurrentUser() fileReviewer: any) {
|
async getMyFiles(
|
||||||
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
|
@CurrentUser() fileReviewer: any,
|
||||||
|
@Query() query: ListQueryV2Dto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.getMyFileReviewerFiles(
|
||||||
|
fileReviewer,
|
||||||
|
query,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("my-files/:requestId")
|
@Get("my-files/:requestId")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
CapturePartV2ResponseDto,
|
CapturePartV2ResponseDto,
|
||||||
} from "src/claim-request-management/dto/capture-part-v2.dto";
|
} from "src/claim-request-management/dto/capture-part-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
||||||
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V5 FileReviewer flow — same as V4 except after the damage expert completes
|
* V5 FileReviewer flow — same as V4 except after the damage expert completes
|
||||||
@@ -86,10 +88,16 @@ export class FileReviewerBlameV5Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List available and assigned FileMaker blame files",
|
summary: "List available and assigned FileMaker blame files",
|
||||||
description:
|
description:
|
||||||
"Returns V5 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
|
"Returns V5 FileMaker blame files in this reviewer's insurer using the shared search, sort, filter, and pagination query contract: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
|
||||||
})
|
})
|
||||||
async getMyFiles(@CurrentUser() fileReviewer: any) {
|
async getMyFiles(
|
||||||
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
|
@CurrentUser() fileReviewer: any,
|
||||||
|
@Query() query: ListQueryV2Dto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.getMyFileReviewerFiles(
|
||||||
|
fileReviewer,
|
||||||
|
query,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("my-files/:requestId")
|
@Get("my-files/:requestId")
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ describe("inquiry participant resolver", () => {
|
|||||||
},
|
},
|
||||||
vin: "NAAM01E15HK123456",
|
vin: "NAAM01E15HK123456",
|
||||||
}),
|
}),
|
||||||
).toThrow("previousPolicyholderNationalCode");
|
).toThrow("کد ملی بیمهگذار قبلی");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults an omitted registration state to CURRENT", () => {
|
it("defaults an omitted registration state to CURRENT", () => {
|
||||||
@@ -102,7 +102,7 @@ describe("inquiry participant resolver", () => {
|
|||||||
},
|
},
|
||||||
vin: "TOO-SHORT",
|
vin: "TOO-SHORT",
|
||||||
}),
|
}),
|
||||||
).toThrow("vehicle.vin must contain exactly 17 characters.");
|
).toThrow("شماره شاسی (VIN) باید دقیقاً ۱۷ کاراکتر باشد.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an incomplete current plate", () => {
|
it("rejects an incomplete current plate", () => {
|
||||||
@@ -115,7 +115,7 @@ describe("inquiry participant resolver", () => {
|
|||||||
ir: "22",
|
ir: "22",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toThrow("vehicle.currentPlate.centerDigits is required.");
|
).toThrow("سه رقم میانی پلاک در پلاک فعلی الزامی است.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid vehicle choice values", () => {
|
it("rejects invalid vehicle choice values", () => {
|
||||||
@@ -129,7 +129,7 @@ describe("inquiry participant resolver", () => {
|
|||||||
ir: "22",
|
ir: "22",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toThrow("vehicle.registrationState must be CURRENT or RECENTLY_TRANSFERRED.");
|
).toThrow("وضعیت پلاک خودرو باید «فعلی» یا «تازه تعویضشده» باشد.");
|
||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
resolveInquiryVehicle({
|
resolveInquiryVehicle({
|
||||||
@@ -141,7 +141,7 @@ describe("inquiry participant resolver", () => {
|
|||||||
},
|
},
|
||||||
isNewCar: "false" as any,
|
isNewCar: "false" as any,
|
||||||
}),
|
}),
|
||||||
).toThrow("vehicle.isNewCar must be a boolean.");
|
).toThrow("وضعیت صفر بودن خودرو نامعتبر است.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a previous policyholder national code for a current registration", () => {
|
it("rejects a previous policyholder national code for a current registration", () => {
|
||||||
@@ -647,7 +647,7 @@ describe("inquiry participant resolver", () => {
|
|||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, input as any),
|
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, input as any),
|
||||||
).toThrow("THIRD_PARTY_POLICYHOLDER does not support the unknown option.");
|
).toThrow("ثبت بیمهگذار شخص ثالث بهصورت نامشخص امکانپذیر نیست.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips the removed unknown field from historical participant output", () => {
|
it("strips the removed unknown field from historical participant output", () => {
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ export interface InquirySubjects {
|
|||||||
driverNationalCode: 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
|
* The single routing seam for external inquiries. Policy checks belong to
|
||||||
* their policyholder, Sheba belongs to the vehicle owner, and licence data
|
* their policyholder, Sheba belongs to the vehicle owner, and licence data
|
||||||
@@ -70,7 +84,7 @@ export function resolveInquirySubjects(
|
|||||||
): InquirySubjects {
|
): InquirySubjects {
|
||||||
if (!submission.vehicleOwner) {
|
if (!submission.vehicleOwner) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Vehicle owner identity is required for Sheba validation.",
|
"اطلاعات هویتی مالک خودرو برای استعلام شبا الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -102,7 +116,11 @@ function assertCompleteInquiryPlate(
|
|||||||
"ir",
|
"ir",
|
||||||
] as const) {
|
] as const) {
|
||||||
if (plate?.[field] == null || String(plate[field]).trim() === "") {
|
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 {
|
): ResolvedInquiryParticipant {
|
||||||
if (Object.prototype.hasOwnProperty.call(input, "phoneNumber")) {
|
if (Object.prototype.hasOwnProperty.call(input, "phoneNumber")) {
|
||||||
throw new BadRequestException(
|
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 nationalCode = String(input.nationalCode ?? "").trim();
|
||||||
const birthday = String(input.birthday ?? "").trim();
|
const birthday = String(input.birthday ?? "").trim();
|
||||||
if (!nationalCode || !birthday) {
|
if (!nationalCode || !birthday) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${role} requires nationalCode and birthday.`,
|
`کد ملی و تاریخ تولد ${PARTICIPANT_ROLE_LABELS[role]} الزامی است.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
role === InquiryParticipantRole.DRIVER &&
|
role === InquiryParticipantRole.DRIVER &&
|
||||||
typeof input.hasDrivingLicense !== "boolean"
|
typeof input.hasDrivingLicense !== "boolean"
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException("DRIVER requires hasDrivingLicense.");
|
throw new BadRequestException("وضعیت داشتن گواهینامه راننده الزامی است.");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
role === InquiryParticipantRole.DRIVER &&
|
role === InquiryParticipantRole.DRIVER &&
|
||||||
@@ -194,7 +212,7 @@ function requiredIdentity(
|
|||||||
!String(input.licenseType ?? "").trim())
|
!String(input.licenseType ?? "").trim())
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"DRIVER requires licenseNumber and licenseType when hasDrivingLicense is true.",
|
"شماره و نوع گواهینامه برای راننده دارای گواهینامه الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -220,7 +238,7 @@ export function resolveInquiryParticipants(
|
|||||||
);
|
);
|
||||||
if (!hasRoleCompleteInput) {
|
if (!hasRoleCompleteInput) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"driver, vehicleOwner, and thirdPartyPolicyholder are required in the structured inquiry format.",
|
"اطلاعات راننده، مالک خودرو و بیمهگذار شخص ثالث برای استعلام الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -228,7 +246,7 @@ export function resolveInquiryParticipants(
|
|||||||
input.carBodyPolicyholder != null
|
input.carBodyPolicyholder != null
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
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 (existing) return existing;
|
||||||
if (resolving.has(role)) {
|
if (resolving.has(role)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Participant sameAs references cannot be circular.",
|
"ارتباط اشخاص یکسان در اطلاعات استعلام نامعتبر است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const value = input[ROLE_FIELDS[role]] as
|
const value = input[ROLE_FIELDS[role]] as
|
||||||
| InquiryParticipantInputDto
|
| InquiryParticipantInputDto
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!value) throw new BadRequestException(`${role} is required.`);
|
if (!value) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`اطلاعات ${PARTICIPANT_ROLE_LABELS[role]} الزامی است.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
resolving.add(role);
|
resolving.add(role);
|
||||||
let participantId: string;
|
let participantId: string;
|
||||||
if (Object.prototype.hasOwnProperty.call(value, "unknown")) {
|
if (Object.prototype.hasOwnProperty.call(value, "unknown")) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${role} does not support the unknown option.`,
|
`ثبت ${PARTICIPANT_ROLE_LABELS[role]} بهصورت نامشخص امکانپذیر نیست.`,
|
||||||
);
|
);
|
||||||
} else if (value.sameAs) {
|
} else if (value.sameAs) {
|
||||||
const hasPersonSpecificFields = Object.entries(value).some(
|
const hasPersonSpecificFields = Object.entries(value).some(
|
||||||
@@ -269,7 +291,7 @@ export function resolveInquiryParticipants(
|
|||||||
);
|
);
|
||||||
if (hasPersonSpecificFields) {
|
if (hasPersonSpecificFields) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${role} must contain either sameAs or identity fields, not both.`,
|
`برای ${PARTICIPANT_ROLE_LABELS[role]} باید فقط ارتباط با شخص دیگر یا اطلاعات هویتی مستقل ارسال شود.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
participantId = resolveRole(value.sameAs);
|
participantId = resolveRole(value.sameAs);
|
||||||
@@ -280,7 +302,7 @@ export function resolveInquiryParticipants(
|
|||||||
);
|
);
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${role} duplicates an existing nationalCode; use sameAs instead.`,
|
`کد ملی ${PARTICIPANT_ROLE_LABELS[role]} تکراری است؛ ارتباط با شخص ثبتشده را انتخاب کنید.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
participants.set(participant.participantId, participant);
|
participants.set(participant.participantId, participant);
|
||||||
@@ -317,29 +339,29 @@ export function resolveInquiryVehicle(
|
|||||||
input: InquiryVehicleInputDto,
|
input: InquiryVehicleInputDto,
|
||||||
): ResolvedInquiryVehicle {
|
): ResolvedInquiryVehicle {
|
||||||
if (!input) {
|
if (!input) {
|
||||||
throw new BadRequestException("vehicle is required.");
|
throw new BadRequestException("اطلاعات خودرو برای استعلام الزامی است.");
|
||||||
}
|
}
|
||||||
const registrationState =
|
const registrationState =
|
||||||
input.registrationState ?? VehicleRegistrationState.CURRENT;
|
input.registrationState ?? VehicleRegistrationState.CURRENT;
|
||||||
if (!Object.values(VehicleRegistrationState).includes(registrationState)) {
|
if (!Object.values(VehicleRegistrationState).includes(registrationState)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"vehicle.registrationState must be CURRENT or RECENTLY_TRANSFERRED.",
|
"وضعیت پلاک خودرو باید «فعلی» یا «تازه تعویضشده» باشد.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (input.isNewCar != null && typeof input.isNewCar !== "boolean") {
|
if (input.isNewCar != null && typeof input.isNewCar !== "boolean") {
|
||||||
throw new BadRequestException("vehicle.isNewCar must be a boolean.");
|
throw new BadRequestException("وضعیت صفر بودن خودرو نامعتبر است.");
|
||||||
}
|
}
|
||||||
const previousPolicyholderNationalCode = String(
|
const previousPolicyholderNationalCode = String(
|
||||||
input.previousPolicyholderNationalCode ?? "",
|
input.previousPolicyholderNationalCode ?? "",
|
||||||
).trim();
|
).trim();
|
||||||
if (!input.currentPlate) {
|
if (!input.currentPlate) {
|
||||||
throw new BadRequestException("vehicle.currentPlate is required.");
|
throw new BadRequestException("پلاک فعلی خودرو برای استعلام الزامی است.");
|
||||||
}
|
}
|
||||||
assertCompleteInquiryPlate(input.currentPlate, "vehicle.currentPlate");
|
assertCompleteInquiryPlate(input.currentPlate, "vehicle.currentPlate");
|
||||||
const vin = String(input.vin ?? "").trim();
|
const vin = String(input.vin ?? "").trim();
|
||||||
if (vin && vin.length !== 17) {
|
if (vin && vin.length !== 17) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"vehicle.vin must contain exactly 17 characters.",
|
"شماره شاسی (VIN) باید دقیقاً ۱۷ کاراکتر باشد.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -347,7 +369,7 @@ export function resolveInquiryVehicle(
|
|||||||
(!input.previousPlate || !vin || !previousPolicyholderNationalCode)
|
(!input.previousPlate || !vin || !previousPolicyholderNationalCode)
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"RECENTLY_TRANSFERRED requires previousPlate, vin, and previousPolicyholderNationalCode.",
|
"برای خودروی تازه تعویضپلاکشده، پلاک قبلی، شماره شاسی (VIN) و کد ملی بیمهگذار قبلی الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -355,7 +377,7 @@ export function resolveInquiryVehicle(
|
|||||||
(input.previousPlate || input.previousPolicyholderNationalCode != null)
|
(input.previousPlate || input.previousPolicyholderNationalCode != null)
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"previousPlate and previousPolicyholderNationalCode are only allowed for RECENTLY_TRANSFERRED vehicles.",
|
"پلاک و کد ملی بیمهگذار قبلی فقط برای خودروی تازه تعویضپلاکشده قابل ثبت است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (input.previousPlate) {
|
if (input.previousPlate) {
|
||||||
@@ -442,7 +464,7 @@ function assertStructuredInquiryInput(input: Record<string, any>): void {
|
|||||||
);
|
);
|
||||||
if (legacyFields.length > 0) {
|
if (legacyFields.length > 0) {
|
||||||
throw new BadRequestException(
|
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);
|
.filter(Boolean);
|
||||||
if (!expected || !candidates.includes(expected)) {
|
if (!expected || !candidates.includes(expected)) {
|
||||||
throw new BadRequestException(
|
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;
|
if (!isLast) continue;
|
||||||
const error = new BadRequestException(
|
const error = new BadRequestException(
|
||||||
"No current usable policy was found for the submitted vehicle identifiers.",
|
"بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
|
||||||
) as BadRequestException & { attempts?: typeof attempts };
|
) as BadRequestException & { attempts?: typeof attempts };
|
||||||
error.attempts = attempts;
|
error.attempts = attempts;
|
||||||
throw error;
|
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>>(
|
export function normalizeInquirySubmission<T extends Record<string, any>>(
|
||||||
@@ -610,7 +637,7 @@ export function normalizeInquirySubmission<T extends Record<string, any>>(
|
|||||||
);
|
);
|
||||||
if (!driver || !thirdPartyPolicyholder) {
|
if (!driver || !thirdPartyPolicyholder) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Driver and third-party policyholder identities are required.",
|
"اطلاعات هویتی راننده و بیمهگذار شخص ثالث الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const vehicleOwner = participantForRole(
|
const vehicleOwner = participantForRole(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ReinquiryInquiriesResponseDto,
|
ReinquiryInquiriesResponseDto,
|
||||||
ReinquiryPartyResultDto,
|
ReinquiryPartyResultDto,
|
||||||
} from "./dto/reinquiry-inquiries.dto";
|
} from "./dto/reinquiry-inquiries.dto";
|
||||||
|
import { getInquiryErrorMessage } from "src/common/utils/inquiry-error";
|
||||||
|
|
||||||
type PlateParts = {
|
type PlateParts = {
|
||||||
leftDigits: number;
|
leftDigits: number;
|
||||||
@@ -48,7 +49,7 @@ export class InquiryRefreshService {
|
|||||||
|
|
||||||
if (!body.publicId && !body.blameRequestId && limit === 0) {
|
if (!body.publicId && !body.blameRequestId && limit === 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Provide publicId, blameRequestId, or limit for bulk refresh.",
|
"برای اجرای مجدد استعلام، شناسه عمومی پرونده، شناسه پرونده یا تعداد پروندهها را وارد کنید.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,14 +59,16 @@ export class InquiryRefreshService {
|
|||||||
if (body.publicId) filter.publicId = body.publicId;
|
if (body.publicId) filter.publicId = body.publicId;
|
||||||
if (body.blameRequestId) {
|
if (body.blameRequestId) {
|
||||||
if (!Types.ObjectId.isValid(body.blameRequestId)) {
|
if (!Types.ObjectId.isValid(body.blameRequestId)) {
|
||||||
throw new BadRequestException("Invalid blameRequestId");
|
throw new BadRequestException("شناسه پرونده معتبر نیست.");
|
||||||
}
|
}
|
||||||
filter._id = new Types.ObjectId(body.blameRequestId);
|
filter._id = new Types.ObjectId(body.blameRequestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let docs = await this.blameRequestDbService.find(filter, { lean: true });
|
let docs = await this.blameRequestDbService.find(filter, { lean: true });
|
||||||
if (!docs.length) {
|
if (!docs.length) {
|
||||||
throw new NotFoundException("No matching blame cases found");
|
throw new NotFoundException(
|
||||||
|
"پرونده تقصیر مطابق اطلاعات واردشده یافت نشد.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
docs = limit > 0 ? docs.slice(0, limit) : docs;
|
docs = limit > 0 ? docs.slice(0, limit) : docs;
|
||||||
|
|
||||||
@@ -111,8 +114,8 @@ export class InquiryRefreshService {
|
|||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
partyResults.push({
|
partyResults.push({
|
||||||
role,
|
role,
|
||||||
thirdParty: { ok: false, message: "party not found" },
|
thirdParty: { ok: false, message: "طرف پرونده یافت نشد." },
|
||||||
person: { ok: false, message: "party not found" },
|
person: { ok: false, message: "طرف پرونده یافت نشد." },
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -150,7 +153,9 @@ export class InquiryRefreshService {
|
|||||||
blameRequestId: new Types.ObjectId(String(doc._id)),
|
blameRequestId: new Types.ObjectId(String(doc._id)),
|
||||||
});
|
});
|
||||||
claimsUpdated = linkedClaims.length;
|
claimsUpdated = linkedClaims.length;
|
||||||
this.logger.log(`[dry-run] ${label} would update blame + ${claimsUpdated} claim(s)`);
|
this.logger.log(
|
||||||
|
`[dry-run] ${label} would update blame + ${claimsUpdated} claim(s)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -208,9 +213,7 @@ export class InquiryRefreshService {
|
|||||||
plateId: party?.vehicle?.plateId,
|
plateId: party?.vehicle?.plateId,
|
||||||
...(plate ? { plate } : {}),
|
...(plate ? { plate } : {}),
|
||||||
...(nationalCode ? { nationalCode } : {}),
|
...(nationalCode ? { nationalCode } : {}),
|
||||||
...(birthDate !== null && birthDate !== undefined
|
...(birthDate !== null && birthDate !== undefined ? { birthDate } : {}),
|
||||||
? { birthDate }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
@@ -223,8 +226,8 @@ export class InquiryRefreshService {
|
|||||||
result.thirdParty = {
|
result.thirdParty = {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: !plate
|
message: !plate
|
||||||
? "plate not found on party"
|
? "پلاک برای این طرف پرونده ثبت نشده است."
|
||||||
: "nationalCodeOfInsurer/nationalCodeOfDriver missing",
|
: "کد ملی برای این طرف پرونده ثبت نشده است.",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
await this.waitForRateLimit();
|
await this.waitForRateLimit();
|
||||||
@@ -249,7 +252,9 @@ export class InquiryRefreshService {
|
|||||||
inquiriesChanged = true;
|
inquiriesChanged = true;
|
||||||
result.thirdParty = {
|
result.thirdParty = {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: inquiry.mapped.Error.Message || "third-party inquiry error",
|
message:
|
||||||
|
inquiry.mapped.Error.Message ||
|
||||||
|
getInquiryErrorMessage(inquiry.mapped, "thirdPartyPlate"),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
nextParty = this.applyThirdPartyToParty(
|
nextParty = this.applyThirdPartyToParty(
|
||||||
@@ -292,11 +297,18 @@ export class InquiryRefreshService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.recordPartyInquiry(inquiries, "thirdParty", role, false, {}, error);
|
this.recordPartyInquiry(
|
||||||
|
inquiries,
|
||||||
|
"thirdParty",
|
||||||
|
role,
|
||||||
|
false,
|
||||||
|
{},
|
||||||
|
error,
|
||||||
|
);
|
||||||
inquiriesChanged = true;
|
inquiriesChanged = true;
|
||||||
result.thirdParty = {
|
result.thirdParty = {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: error?.message || String(error),
|
message: getInquiryErrorMessage(error, "thirdPartyPlate"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,8 +317,8 @@ export class InquiryRefreshService {
|
|||||||
result.person = {
|
result.person = {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: !nationalCode
|
message: !nationalCode
|
||||||
? "nationalCodeOfInsurer/nationalCodeOfDriver missing"
|
? "کد ملی برای این طرف پرونده ثبت نشده است."
|
||||||
: "insurerBirthday/driverBirthday missing",
|
: "تاریخ تولد برای این طرف پرونده ثبت نشده است.",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
await this.waitForRateLimit();
|
await this.waitForRateLimit();
|
||||||
@@ -333,7 +345,7 @@ export class InquiryRefreshService {
|
|||||||
inquiriesChanged = true;
|
inquiriesChanged = true;
|
||||||
result.person = {
|
result.person = {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: error?.message || String(error),
|
message: getInquiryErrorMessage(error, "personalIdentity"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -477,12 +489,14 @@ export class InquiryRefreshService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const blameDoc = await this.blameRequestDbService.findById(blameId);
|
const blameDoc = await this.blameRequestDbService.findById(blameId);
|
||||||
if (!blameDoc) {
|
if (!blameDoc) {
|
||||||
throw new NotFoundException(`Blame case ${blameId} not found`);
|
throw new NotFoundException("پرونده تقصیر یافت نشد.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const role of roles) {
|
for (const role of roles) {
|
||||||
const memParty = updatedParties.find((party) => party?.role === role);
|
const memParty = updatedParties.find((party) => party?.role === role);
|
||||||
const docIdx = blameDoc.parties.findIndex((party) => party?.role === role);
|
const docIdx = blameDoc.parties.findIndex(
|
||||||
|
(party) => party?.role === role,
|
||||||
|
);
|
||||||
if (!memParty || docIdx === -1) continue;
|
if (!memParty || docIdx === -1) continue;
|
||||||
|
|
||||||
const party = blameDoc.parties[docIdx];
|
const party = blameDoc.parties[docIdx];
|
||||||
@@ -514,7 +528,9 @@ export class InquiryRefreshService {
|
|||||||
party.insurance.company = memParty.insurance.company;
|
party.insurance.company = memParty.insurance.company;
|
||||||
}
|
}
|
||||||
if (memParty.insurance.financialCeiling !== undefined) {
|
if (memParty.insurance.financialCeiling !== undefined) {
|
||||||
party.insurance.financialCeiling = String(memParty.insurance.financialCeiling);
|
party.insurance.financialCeiling = String(
|
||||||
|
memParty.insurance.financialCeiling,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (memParty.insurance.startDate !== undefined) {
|
if (memParty.insurance.startDate !== undefined) {
|
||||||
party.insurance.startDate = memParty.insurance.startDate;
|
party.insurance.startDate = memParty.insurance.startDate;
|
||||||
@@ -541,7 +557,8 @@ export class InquiryRefreshService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const inquiryPatch: Record<string, unknown> = {};
|
const inquiryPatch: Record<string, unknown> = {};
|
||||||
if (inquiries.thirdParty) inquiryPatch["inquiries.thirdParty"] = inquiries.thirdParty;
|
if (inquiries.thirdParty)
|
||||||
|
inquiryPatch["inquiries.thirdParty"] = inquiries.thirdParty;
|
||||||
if (inquiries.person) inquiryPatch["inquiries.person"] = inquiries.person;
|
if (inquiries.person) inquiryPatch["inquiries.person"] = inquiries.person;
|
||||||
if (!Object.keys(inquiryPatch).length) return 0;
|
if (!Object.keys(inquiryPatch).length) return 0;
|
||||||
|
|
||||||
@@ -590,14 +607,16 @@ export class InquiryRefreshService {
|
|||||||
|
|
||||||
private normalizeInquiryError(error: any): Record<string, unknown> {
|
private normalizeInquiryError(error: any): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
message: error?.message || String(error),
|
message: getInquiryErrorMessage(error, "generic"),
|
||||||
status: error?.status ?? error?.response?.status,
|
status: error?.status ?? error?.response?.status,
|
||||||
data: error?.data ?? error?.response?.data,
|
data: error?.data ?? error?.response?.data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolvePartyPlate(party: Record<string, any>): PlateParts | null {
|
private resolvePartyPlate(party: Record<string, any>): PlateParts | null {
|
||||||
const fromPlateId = this.parsePlateFromCompactString(party?.vehicle?.plateId);
|
const fromPlateId = this.parsePlateFromCompactString(
|
||||||
|
party?.vehicle?.plateId,
|
||||||
|
);
|
||||||
if (fromPlateId) return fromPlateId;
|
if (fromPlateId) return fromPlateId;
|
||||||
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@@ -608,14 +627,24 @@ export class InquiryRefreshService {
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const leftDigits = this.firstPresent(candidate.Plk1, candidate.platePartOne);
|
const leftDigits = this.firstPresent(
|
||||||
|
candidate.Plk1,
|
||||||
|
candidate.platePartOne,
|
||||||
|
);
|
||||||
const centerAlphabet = this.firstPresent(
|
const centerAlphabet = this.firstPresent(
|
||||||
candidate.plateLetterid,
|
candidate.plateLetterid,
|
||||||
candidate.plateLetterId,
|
candidate.plateLetterId,
|
||||||
candidate.plateLetterTitle,
|
candidate.plateLetterTitle,
|
||||||
);
|
);
|
||||||
const centerDigits = this.firstPresent(candidate.Plk3, candidate.platePartThree);
|
const centerDigits = this.firstPresent(
|
||||||
const ir = this.firstPresent(candidate.PlkSrl, candidate.plkSrl, candidate.plateSerialNumber);
|
candidate.Plk3,
|
||||||
|
candidate.platePartThree,
|
||||||
|
);
|
||||||
|
const ir = this.firstPresent(
|
||||||
|
candidate.PlkSrl,
|
||||||
|
candidate.plkSrl,
|
||||||
|
candidate.plateSerialNumber,
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
leftDigits !== undefined &&
|
leftDigits !== undefined &&
|
||||||
@@ -623,7 +652,9 @@ export class InquiryRefreshService {
|
|||||||
centerDigits !== undefined &&
|
centerDigits !== undefined &&
|
||||||
ir !== undefined
|
ir !== undefined
|
||||||
) {
|
) {
|
||||||
const plateLetter = this.plateNormalizer.normalizePlateText(String(centerAlphabet));
|
const plateLetter = this.plateNormalizer.normalizePlateText(
|
||||||
|
String(centerAlphabet),
|
||||||
|
);
|
||||||
const parsed: PlateParts = {
|
const parsed: PlateParts = {
|
||||||
leftDigits: Number(leftDigits),
|
leftDigits: Number(leftDigits),
|
||||||
centerAlphabet: plateLetter,
|
centerAlphabet: plateLetter,
|
||||||
@@ -653,7 +684,9 @@ export class InquiryRefreshService {
|
|||||||
const ir = Number(irRaw);
|
const ir = Number(irRaw);
|
||||||
const leftDigits = Number(leftRaw);
|
const leftDigits = Number(leftRaw);
|
||||||
const centerDigits = Number(centerRaw);
|
const centerDigits = Number(centerRaw);
|
||||||
const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || ""));
|
const centerAlphabet = this.plateNormalizer.normalizePlateText(
|
||||||
|
String(alphaRaw || ""),
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(ir) ||
|
!Number.isFinite(ir) ||
|
||||||
!Number.isFinite(leftDigits) ||
|
!Number.isFinite(leftDigits) ||
|
||||||
@@ -685,6 +718,8 @@ export class InquiryRefreshService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private firstPresent(...values: unknown[]): unknown {
|
private firstPresent(...values: unknown[]): unknown {
|
||||||
return values.find((value) => value !== undefined && value !== null && value !== "");
|
return values.find(
|
||||||
|
(value) => value !== undefined && value !== null && value !== "",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,8 @@ describe("damaged-party inquiry requirements", () => {
|
|||||||
const service = getService();
|
const service = getService();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
(service as any).validateShebaV3(
|
(service as any).validateShebaV3(undefined, "0012345678", "client-id"),
|
||||||
undefined,
|
).rejects.toThrow("شماره شبا برای طرف زیاندیده الزامی است.");
|
||||||
"0012345678",
|
|
||||||
"client-id",
|
|
||||||
),
|
|
||||||
).rejects.toThrow("sheba is required for the damaged party.");
|
|
||||||
expect(
|
expect(
|
||||||
(service as any).sandHubService.getShebaValidation,
|
(service as any).sandHubService.getShebaValidation,
|
||||||
).not.toHaveBeenCalled();
|
).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
|||||||
publicId: "BLM-OPEN",
|
publicId: "BLM-OPEN",
|
||||||
type: "THIRD_PARTY",
|
type: "THIRD_PARTY",
|
||||||
status: "WAITING_FOR_FILE_REVIEWER",
|
status: "WAITING_FOR_FILE_REVIEWER",
|
||||||
|
createdAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||||
isMadeByFileMaker: true,
|
isMadeByFileMaker: true,
|
||||||
expertInitiated: true,
|
expertInitiated: true,
|
||||||
creationMethod: "IN_PERSON",
|
creationMethod: "IN_PERSON",
|
||||||
@@ -34,6 +35,9 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
|||||||
undefined,
|
undefined,
|
||||||
blameRequestDbService,
|
blameRequestDbService,
|
||||||
) as RequestManagementService;
|
) as RequestManagementService;
|
||||||
|
(service as any).claimCaseDbService = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
return { service, blameRequestDbService };
|
return { service, blameRequestDbService };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +50,7 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
|||||||
clientKey: String(clientId),
|
clientKey: String(clientId),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result.list).toEqual([
|
||||||
expect.objectContaining({ _id: sealedFile._id, publicId: "BLM-OPEN" }),
|
expect.objectContaining({ _id: sealedFile._id, publicId: "BLM-OPEN" }),
|
||||||
]);
|
]);
|
||||||
expect(blameRequestDbService.find).toHaveBeenCalledWith(
|
expect(blameRequestDbService.find).toHaveBeenCalledWith(
|
||||||
@@ -82,7 +86,34 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
|||||||
clientKey: String(clientId),
|
clientKey: String(clientId),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
expect(result.list).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts and paginates the reviewer inbox with the shared list contract", async () => {
|
||||||
|
const olderFile = {
|
||||||
|
...sealedFile,
|
||||||
|
_id: new Types.ObjectId(),
|
||||||
|
publicId: "BLM-OLDER",
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
const { service } = createService([olderFile, sealedFile]);
|
||||||
|
|
||||||
|
const result = await service.getMyFileReviewerFiles(
|
||||||
|
{
|
||||||
|
sub: String(reviewerId),
|
||||||
|
role: RoleEnum.FILE_REVIEWER,
|
||||||
|
clientKey: String(clientId),
|
||||||
|
},
|
||||||
|
{ page: 1, limit: 1, sortBy: "createdAt", sortOrder: "desc" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({ total: 2, page: 1, limit: 1, totalPages: 2 }),
|
||||||
|
);
|
||||||
|
expect(result.list).toHaveLength(1);
|
||||||
|
expect(result.list[0]).toEqual(
|
||||||
|
expect.objectContaining({ publicId: "BLM-OPEN" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not expose an open file's details to a reviewer from another tenant", async () => {
|
it("does not expose an open file's details to a reviewer from another tenant", async () => {
|
||||||
|
|||||||
@@ -60,9 +60,14 @@ import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.
|
|||||||
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
|
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
|
||||||
import { isOtpExpiryActive } from "src/helpers/user-otp-expiry";
|
import { isOtpExpiryActive } from "src/helpers/user-otp-expiry";
|
||||||
import { parseIranLocalDateTime } from "src/helpers/iran-datetime";
|
import { parseIranLocalDateTime } from "src/helpers/iran-datetime";
|
||||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
import {
|
||||||
|
applyListQueryV2,
|
||||||
|
isInListDateRange,
|
||||||
|
parseListDateRange,
|
||||||
|
} from "src/helpers/list-query-v2";
|
||||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
import { GetUserBlameListV2ResponseDto } from "src/request-management/dto/blame-list-user-v2.dto";
|
import { GetUserBlameListV2ResponseDto } from "src/request-management/dto/blame-list-user-v2.dto";
|
||||||
|
import { resolveUnifiedFileStatus } from "src/helpers/unified-file-status";
|
||||||
import { AutoCloseRequestService } from "src/utils/cron/cron.service";
|
import { AutoCloseRequestService } from "src/utils/cron/cron.service";
|
||||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||||
import {
|
import {
|
||||||
@@ -138,6 +143,9 @@ import {
|
|||||||
runPlateInquiryWithFallback,
|
runPlateInquiryWithFallback,
|
||||||
sanitizeStoredInquiryParticipants,
|
sanitizeStoredInquiryParticipants,
|
||||||
} from "./inquiry-participant-resolver";
|
} from "./inquiry-participant-resolver";
|
||||||
|
import {
|
||||||
|
getInquiryErrorMessage,
|
||||||
|
} from "src/common/utils/inquiry-error";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD.
|
* Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD.
|
||||||
@@ -154,6 +162,18 @@ function formatJalaliCompact(
|
|||||||
return String(raw);
|
return String(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileMakerResumeProjection = {
|
||||||
|
action:
|
||||||
|
| "CONTINUE_BLAME"
|
||||||
|
| "UPLOAD_REQUIRED_DOCUMENTS"
|
||||||
|
| "WAIT_FOR_FILE_REVIEWER";
|
||||||
|
entity: "BLAME" | "CLAIM";
|
||||||
|
entityId: string;
|
||||||
|
status: string;
|
||||||
|
currentStep: string;
|
||||||
|
nextStep?: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RequestManagementService {
|
export class RequestManagementService {
|
||||||
private readonly logger = new Logger(RequestManagementService.name);
|
private readonly logger = new Logger(RequestManagementService.name);
|
||||||
@@ -183,10 +203,16 @@ export class RequestManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Keep eligibility rejections distinct from an unavailable CAR_BODY provider. */
|
/** Keep eligibility rejections distinct from an unavailable CAR_BODY provider. */
|
||||||
private throwCarBodyInquiryFailure(err: unknown): never {
|
private throwCarBodyInquiryFailure(
|
||||||
if (err instanceof ForbiddenException) throw err;
|
err: unknown,
|
||||||
|
context: "carBodyPlate" | "carBodyVin" = "carBodyPlate",
|
||||||
|
): never {
|
||||||
|
const message = getInquiryErrorMessage(err, context);
|
||||||
|
if (err instanceof ForbiddenException) {
|
||||||
|
throw new ForbiddenException(message);
|
||||||
|
}
|
||||||
|
|
||||||
throw new HttpException("Car body inquiry failed", HttpStatus.BAD_REQUEST);
|
throw new HttpException(message, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,7 +226,7 @@ export class RequestManagementService {
|
|||||||
const configuredCode = Number(process.env.CLIENT_ID);
|
const configuredCode = Number(process.env.CLIENT_ID);
|
||||||
if (!Number.isFinite(configuredCode)) {
|
if (!Number.isFinite(configuredCode)) {
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
"CLIENT_ID must be configured to save CAR_BODY policy ownership.",
|
"تنظیمات شرکت بیمه برای ذخیره بیمهنامه بدنه کامل نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +246,7 @@ export class RequestManagementService {
|
|||||||
const clientId = (client as any)?._id ?? (client as any)?._doc?._id;
|
const clientId = (client as any)?._id ?? (client as any)?._doc?._id;
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
"Configured CAR_BODY insurer client could not be resolved.",
|
"شرکت بیمه تنظیمشده برای بیمهنامه بدنه قابل شناسایی نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return clientId;
|
return clientId;
|
||||||
@@ -394,7 +420,7 @@ export class RequestManagementService {
|
|||||||
const policyholderNationalCode = subjects.carBodyPolicyNationalCode;
|
const policyholderNationalCode = subjects.carBodyPolicyNationalCode;
|
||||||
if (!policyholderNationalCode) {
|
if (!policyholderNationalCode) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Car-body policyholder identity is required for a CAR_BODY inquiry.",
|
"اطلاعات بیمهگذار برای استعلام بیمه بدنه الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const result = await runPlateInquiryWithFallback({
|
const result = await runPlateInquiryWithFallback({
|
||||||
@@ -434,7 +460,7 @@ export class RequestManagementService {
|
|||||||
).trim();
|
).trim();
|
||||||
if (!chassis) {
|
if (!chassis) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"vehicle.vin is required for a VIN/chassis inquiry.",
|
"شماره شاسی (VIN) برای استعلام خودرو الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const subjects = resolveInquirySubjects(submission);
|
const subjects = resolveInquirySubjects(submission);
|
||||||
@@ -462,7 +488,7 @@ export class RequestManagementService {
|
|||||||
for (const participant of participants) {
|
for (const participant of participants) {
|
||||||
if (!participant.nationalCode || !participant.birthday) {
|
if (!participant.nationalCode || !participant.birthday) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${participant.participantId} requires nationalCode and birthday for personal inquiry.`,
|
"کد ملی و تاریخ تولد برای استعلام هویت الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -486,7 +512,7 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
await this.persistBlameInquiryAudit(req);
|
await this.persistBlameInquiryAudit(req);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${participant.participantId} personal identity inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`,
|
getInquiryErrorMessage(error, "personalIdentity"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -512,7 +538,7 @@ export class RequestManagementService {
|
|||||||
const plate = submission.vehicle?.currentPlate ?? submission.dto.plate;
|
const plate = submission.vehicle?.currentPlate ?? submission.dto.plate;
|
||||||
if (!plate) {
|
if (!plate) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Current plate is required for vehicle ownership inquiry.",
|
"پلاک فعلی برای استعلام مالکیت خودرو الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -536,7 +562,7 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
await this.persistBlameInquiryAudit(req);
|
await this.persistBlameInquiryAudit(req);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Vehicle ownership inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`,
|
getInquiryErrorMessage(error, "carOwnership"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,7 +584,7 @@ export class RequestManagementService {
|
|||||||
}
|
}
|
||||||
if (!submission.driver.licenseNumber) {
|
if (!submission.driver.licenseNumber) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Driver licence number is required when the driver has a licence.",
|
"شماره گواهینامه راننده برای استعلام گواهینامه الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -582,7 +608,7 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
await this.persistBlameInquiryAudit(req);
|
await this.persistBlameInquiryAudit(req);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Driver licence inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`,
|
getInquiryErrorMessage(error, "drivingLicense"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -683,7 +709,7 @@ export class RequestManagementService {
|
|||||||
private normalizeInquiryError(err: any): any {
|
private normalizeInquiryError(err: any): any {
|
||||||
if (!err) return undefined;
|
if (!err) return undefined;
|
||||||
return {
|
return {
|
||||||
message: err?.message || String(err),
|
message: getInquiryErrorMessage(err, "generic"),
|
||||||
status: err?.response?.status,
|
status: err?.response?.status,
|
||||||
data: err?.response?.data,
|
data: err?.response?.data,
|
||||||
...(Array.isArray(err?.attempts) ? { attempts: err.attempts } : {}),
|
...(Array.isArray(err?.attempts) ? { attempts: err.attempts } : {}),
|
||||||
@@ -1745,7 +1771,7 @@ export class RequestManagementService {
|
|||||||
body.insurerLicense === body.driverLicense)
|
body.insurerLicense === body.driverLicense)
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Insurer and Driver should be two different persons in this mode.",
|
"در این حالت، بیمهگذار و راننده باید دو شخص متفاوت باشند.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (body.driverIsInsurer === true) {
|
} else if (body.driverIsInsurer === true) {
|
||||||
@@ -1757,7 +1783,7 @@ export class RequestManagementService {
|
|||||||
String(body.driverBirthday) === String(body.insurerBirthday);
|
String(body.driverBirthday) === String(body.insurerBirthday);
|
||||||
if (!sameNat || !sameLic || !sameBirthday) {
|
if (!sameNat || !sameLic || !sameBirthday) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"When driverIsInsurer is true, insurer and driver data must be the same.",
|
"وقتی راننده همان بیمهگذار است، اطلاعات راننده و بیمهگذار باید یکسان باشد.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1831,7 +1857,10 @@ export class RequestManagementService {
|
|||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
await this.persistBlameInquiryAudit(req);
|
await this.persistBlameInquiryAudit(req);
|
||||||
throw new HttpException("Inquiry failed", HttpStatus.BAD_REQUEST);
|
throw new HttpException(
|
||||||
|
getInquiryErrorMessage(err, "thirdPartyPlate"),
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inquiryMapped?.Error) {
|
if (inquiryMapped?.Error) {
|
||||||
@@ -1847,7 +1876,8 @@ export class RequestManagementService {
|
|||||||
});
|
});
|
||||||
await this.persistBlameInquiryAudit(req);
|
await this.persistBlameInquiryAudit(req);
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
inquiryMapped.Error.Message || "Inquiry returned error",
|
inquiryMapped.Error.Message ||
|
||||||
|
getInquiryErrorMessage(inquiryMapped, "thirdPartyPlate"),
|
||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1875,7 +1905,7 @@ export class RequestManagementService {
|
|||||||
const clientName = inquiryMapped?.CompanyName;
|
const clientName = inquiryMapped?.CompanyName;
|
||||||
if (!clientName) {
|
if (!clientName) {
|
||||||
const error = new BadRequestException(
|
const error = new BadRequestException(
|
||||||
`CompanyName missing from inquiry response`,
|
"پاسخ استعلام بیمهنامه ناقص است و نام شرکت بیمه در آن وجود ندارد.",
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(
|
this.recordPartyCaseInquiryStatus(
|
||||||
req,
|
req,
|
||||||
@@ -1903,7 +1933,7 @@ export class RequestManagementService {
|
|||||||
: null;
|
: null;
|
||||||
if (clientName && !client) {
|
if (clientName && !client) {
|
||||||
const error = new BadRequestException(
|
const error = new BadRequestException(
|
||||||
`CompanyCode missing or invalid in inquiry response`,
|
"پاسخ استعلام بیمهنامه ناقص است و شرکت بیمه قابل شناسایی نیست.",
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(
|
this.recordPartyCaseInquiryStatus(
|
||||||
req,
|
req,
|
||||||
@@ -2188,7 +2218,7 @@ export class RequestManagementService {
|
|||||||
body.insurerLicense === body.driverLicense)
|
body.insurerLicense === body.driverLicense)
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Insurer and Driver should be two different persons in this mode.",
|
"در این حالت، بیمهگذار و راننده باید دو شخص متفاوت باشند.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (body.driverIsInsurer === true) {
|
} else if (body.driverIsInsurer === true) {
|
||||||
@@ -2200,7 +2230,7 @@ export class RequestManagementService {
|
|||||||
String(body.driverBirthday) === String(body.insurerBirthday);
|
String(body.driverBirthday) === String(body.insurerBirthday);
|
||||||
if (!sameNat || !sameLic || !sameBirthday) {
|
if (!sameNat || !sameLic || !sameBirthday) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"When driverIsInsurer is true, insurer and driver data must be the same.",
|
"وقتی راننده همان بیمهگذار است، اطلاعات راننده و بیمهگذار باید یکسان باشد.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2261,7 +2291,10 @@ export class RequestManagementService {
|
|||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
await this.persistBlameInquiryAudit(req);
|
await this.persistBlameInquiryAudit(req);
|
||||||
throw new HttpException("VIN inquiry failed", HttpStatus.BAD_REQUEST);
|
throw new HttpException(
|
||||||
|
getInquiryErrorMessage(err, "thirdPartyVin"),
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inquiryMapped?.Error) {
|
if (inquiryMapped?.Error) {
|
||||||
@@ -2275,7 +2308,8 @@ export class RequestManagementService {
|
|||||||
});
|
});
|
||||||
await this.persistBlameInquiryAudit(req);
|
await this.persistBlameInquiryAudit(req);
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
inquiryMapped.Error.Message || "VIN inquiry returned error",
|
inquiryMapped.Error.Message ||
|
||||||
|
getInquiryErrorMessage(inquiryMapped, "thirdPartyVin"),
|
||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2303,7 +2337,7 @@ export class RequestManagementService {
|
|||||||
const clientName = inquiryMapped?.CompanyName;
|
const clientName = inquiryMapped?.CompanyName;
|
||||||
if (!clientName) {
|
if (!clientName) {
|
||||||
const error = new BadRequestException(
|
const error = new BadRequestException(
|
||||||
"CompanyName missing from VIN inquiry response",
|
"پاسخ استعلام VIN ناقص است و نام شرکت بیمه در آن وجود ندارد.",
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(
|
this.recordPartyCaseInquiryStatus(
|
||||||
req,
|
req,
|
||||||
@@ -2329,7 +2363,7 @@ export class RequestManagementService {
|
|||||||
: null;
|
: null;
|
||||||
if (clientName && !client) {
|
if (clientName && !client) {
|
||||||
const error = new BadRequestException(
|
const error = new BadRequestException(
|
||||||
"CompanyCode missing or invalid in VIN inquiry response",
|
"پاسخ استعلام VIN ناقص است و شرکت بیمه قابل شناسایی نیست.",
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(
|
this.recordPartyCaseInquiryStatus(
|
||||||
req,
|
req,
|
||||||
@@ -2459,7 +2493,7 @@ export class RequestManagementService {
|
|||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
await this.persistBlameInquiryAudit(req);
|
await this.persistBlameInquiryAudit(req);
|
||||||
this.throwCarBodyInquiryFailure(err);
|
this.throwCarBodyInquiryFailure(err, "carBodyVin");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3472,7 +3506,10 @@ export class RequestManagementService {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new HttpException("Client not found", HttpStatus.CONFLICT);
|
throw new HttpException(
|
||||||
|
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const partyDetails =
|
const partyDetails =
|
||||||
@@ -3642,7 +3679,7 @@ export class RequestManagementService {
|
|||||||
this.logger.error(er);
|
this.logger.error(er);
|
||||||
if (er instanceof HttpException) throw er;
|
if (er instanceof HttpException) throw er;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
"Failed to update request with plate details.",
|
"ذخیره اطلاعات پلاک و بیمهنامه انجام نشد.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3670,7 +3707,7 @@ export class RequestManagementService {
|
|||||||
body.driverIsInsurer === false
|
body.driverIsInsurer === false
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Insurer and Driver should be two different persons in this mode.",
|
"در این حالت، بیمهگذار و راننده باید دو شخص متفاوت باشند.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3703,7 +3740,7 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
if (isSameNationalCode || isSamePlate) {
|
if (isSameNationalCode || isSamePlate) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
"The plate and national code for the second party cannot be the same as the first party.",
|
"پلاک و کد ملی طرف دوم نمیتواند با طرف اول یکسان باشد.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6466,6 +6503,20 @@ export class RequestManagementService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const requestIds = requests.map((request: any) => request._id);
|
||||||
|
const claimsForStatus =
|
||||||
|
requestIds.length > 0
|
||||||
|
? ((await this.claimCaseDbService.find(
|
||||||
|
{ blameRequestId: { $in: requestIds } },
|
||||||
|
{ lean: true, select: "blameRequestId status" },
|
||||||
|
)) as any[])
|
||||||
|
: [];
|
||||||
|
const claimStatusByBlameId = new Map<string, string>(
|
||||||
|
claimsForStatus
|
||||||
|
.filter((claim) => claim?.blameRequestId && claim?.status)
|
||||||
|
.map((claim) => [String(claim.blameRequestId), claim.status]),
|
||||||
|
);
|
||||||
|
|
||||||
const enriched = requests.map((req: any) => {
|
const enriched = requests.map((req: any) => {
|
||||||
const isInitiator =
|
const isInitiator =
|
||||||
(user?.role === RoleEnum.FIELD_EXPERT &&
|
(user?.role === RoleEnum.FIELD_EXPERT &&
|
||||||
@@ -6487,17 +6538,44 @@ export class RequestManagementService {
|
|||||||
...obj,
|
...obj,
|
||||||
userSide: party?.role ?? null,
|
userSide: party?.role ?? null,
|
||||||
initiatedByMe: isInitiator,
|
initiatedByMe: isInitiator,
|
||||||
|
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||||
|
blameStatus: req.status,
|
||||||
|
claimStatus: claimStatusByBlameId.get(String(req._id)),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let filtered = enriched;
|
||||||
|
if (query.unifiedStatus) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(row) => row.unifiedFileStatus === query.unifiedStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { fromDate, toDate } = parseListDateRange(
|
||||||
|
query.startDate,
|
||||||
|
query.endDate,
|
||||||
|
);
|
||||||
|
if (fromDate || toDate) {
|
||||||
|
filtered = filtered.filter((row) =>
|
||||||
|
isInListDateRange(row.createdAt, fromDate, toDate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const paged = applyListQueryV2(
|
const paged = applyListQueryV2(
|
||||||
enriched,
|
filtered,
|
||||||
{
|
{
|
||||||
publicId: (r) => String((r as { publicId?: string }).publicId ?? ""),
|
publicId: (r) => String((r as { publicId?: string }).publicId ?? ""),
|
||||||
createdAt: (r) => (r as { createdAt?: Date }).createdAt,
|
createdAt: (r) => (r as { createdAt?: Date }).createdAt,
|
||||||
requestNo: (r) =>
|
requestNo: (r) =>
|
||||||
String((r as { requestNo?: string }).requestNo ?? ""),
|
String((r as { requestNo?: string }).requestNo ?? ""),
|
||||||
status: (r) => String((r as { status?: string }).status ?? ""),
|
status: (r) =>
|
||||||
|
String(
|
||||||
|
(r as { unifiedFileStatus?: string; status?: string })
|
||||||
|
.unifiedFileStatus ??
|
||||||
|
(r as { status?: string }).status ??
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
fileType: (r) => (r as { type?: string }).type,
|
||||||
searchExtras: (r) => {
|
searchExtras: (r) => {
|
||||||
const row = r as {
|
const row = r as {
|
||||||
blameStatus?: string;
|
blameStatus?: string;
|
||||||
@@ -7004,8 +7082,9 @@ export class RequestManagementService {
|
|||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
await (req as any).save();
|
await (req as any).save();
|
||||||
throw new InternalServerErrorException(
|
if (e instanceof HttpException) throw e;
|
||||||
"Failed to process plate information.",
|
throw new BadRequestException(
|
||||||
|
getInquiryErrorMessage(e, "thirdPartyPlate"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7020,7 +7099,7 @@ export class RequestManagementService {
|
|||||||
: await this.clientService.findOne({ clientName });
|
: await this.clientService.findOne({ clientName });
|
||||||
if (!client) {
|
if (!client) {
|
||||||
const error = new NotFoundException(
|
const error = new NotFoundException(
|
||||||
`Client not found for company: ${clientName}`,
|
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(
|
this.recordPartyCaseInquiryStatus(
|
||||||
req,
|
req,
|
||||||
@@ -7345,7 +7424,7 @@ export class RequestManagementService {
|
|||||||
: null;
|
: null;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
const error = new NotFoundException(
|
const error = new NotFoundException(
|
||||||
`Client not found for company: ${clientName}`,
|
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(
|
this.recordPartyCaseInquiryStatus(
|
||||||
req,
|
req,
|
||||||
@@ -8104,7 +8183,7 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
const error = new NotFoundException(
|
const error = new NotFoundException(
|
||||||
`Client not found for company: ${clientName}`,
|
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
|
||||||
);
|
);
|
||||||
await this.persistLegacyInquiryAudit(
|
await this.persistLegacyInquiryAudit(
|
||||||
requestId,
|
requestId,
|
||||||
@@ -8188,8 +8267,8 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
this.logger.error("Error processing first party plate:", plateError);
|
this.logger.error("Error processing first party plate:", plateError);
|
||||||
if (plateError instanceof HttpException) throw plateError;
|
if (plateError instanceof HttpException) throw plateError;
|
||||||
throw new InternalServerErrorException(
|
throw new BadRequestException(
|
||||||
"Failed to process first party plate information",
|
getInquiryErrorMessage(plateError, "thirdPartyPlate"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8241,7 +8320,7 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
const error = new NotFoundException(
|
const error = new NotFoundException(
|
||||||
`Client not found for company: ${clientName}${companyCode ? ` (code: ${companyCode})` : ""}`,
|
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
|
||||||
);
|
);
|
||||||
await this.persistLegacyInquiryAudit(
|
await this.persistLegacyInquiryAudit(
|
||||||
requestId,
|
requestId,
|
||||||
@@ -8326,8 +8405,9 @@ export class RequestManagementService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.logger.error("Error processing second party plate:", plateError);
|
this.logger.error("Error processing second party plate:", plateError);
|
||||||
throw new InternalServerErrorException(
|
if (plateError instanceof HttpException) throw plateError;
|
||||||
"Failed to process second party plate information",
|
throw new BadRequestException(
|
||||||
|
getInquiryErrorMessage(plateError, "thirdPartyPlate"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8584,7 +8664,7 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
const error = new NotFoundException(
|
const error = new NotFoundException(
|
||||||
`Client not found for company: ${clientName}`,
|
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
|
||||||
);
|
);
|
||||||
await this.persistLegacyInquiryAudit(
|
await this.persistLegacyInquiryAudit(
|
||||||
requestId,
|
requestId,
|
||||||
@@ -8710,9 +8790,8 @@ export class RequestManagementService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.logger.error("Error processing first party plate:", plateError);
|
this.logger.error("Error processing first party plate:", plateError);
|
||||||
throw new InternalServerErrorException(
|
if (plateError instanceof HttpException) throw plateError;
|
||||||
"Failed to process first party plate information",
|
throw new BadRequestException(getInquiryErrorMessage(plateError));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For CAR_BODY: Create expertSubmitReply
|
// For CAR_BODY: Create expertSubmitReply
|
||||||
@@ -9850,14 +9929,14 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
await this.persistBlameInquiryAudit(req);
|
await this.persistBlameInquiryAudit(req);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${roleLabel} party plate inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
|
getInquiryErrorMessage(err, "thirdPartyPlate"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inquiryMapped?.Error) {
|
if (inquiryMapped?.Error) {
|
||||||
const error = new BadRequestException(
|
const error = new BadRequestException(
|
||||||
inquiryMapped.Error.Message ||
|
inquiryMapped.Error.Message ||
|
||||||
`${roleLabel} party plate inquiry returned an error`,
|
getInquiryErrorMessage(inquiryMapped, "thirdPartyPlate"),
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(
|
this.recordPartyCaseInquiryStatus(
|
||||||
req,
|
req,
|
||||||
@@ -9881,7 +9960,7 @@ export class RequestManagementService {
|
|||||||
const companyCode = inquiryMapped?.CompanyCode;
|
const companyCode = inquiryMapped?.CompanyCode;
|
||||||
if (!clientName) {
|
if (!clientName) {
|
||||||
const error = new BadRequestException(
|
const error = new BadRequestException(
|
||||||
`CompanyName missing from ${roleLabel} party inquiry response`,
|
"پاسخ استعلام بیمهنامه ناقص است و نام شرکت بیمه در آن وجود ندارد.",
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(
|
this.recordPartyCaseInquiryStatus(
|
||||||
req,
|
req,
|
||||||
@@ -9908,7 +9987,7 @@ export class RequestManagementService {
|
|||||||
: null;
|
: null;
|
||||||
if (clientName && !client) {
|
if (clientName && !client) {
|
||||||
const error = new BadRequestException(
|
const error = new BadRequestException(
|
||||||
`CompanyCode missing or invalid in ${roleLabel} party inquiry response`,
|
"پاسخ استعلام بیمهنامه ناقص است و شرکت بیمه قابل شناسایی نیست.",
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(
|
this.recordPartyCaseInquiryStatus(
|
||||||
req,
|
req,
|
||||||
@@ -10128,7 +10207,7 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
await this.persistBlameInquiryAudit(req);
|
await this.persistBlameInquiryAudit(req);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
|
getInquiryErrorMessage(err, "drivingLicense"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10143,7 +10222,7 @@ export class RequestManagementService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!String(sheba ?? "").trim()) {
|
if (!String(sheba ?? "").trim()) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"sheba is required for the damaged party.",
|
"شماره شبا برای طرف زیاندیده الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.sandHubService.getShebaValidation(
|
await this.sandHubService.getShebaValidation(
|
||||||
@@ -10764,14 +10843,14 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
await this.persistBlameInquiryAudit(req);
|
await this.persistBlameInquiryAudit(req);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${roleLabel} party VIN inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
|
getInquiryErrorMessage(err, "thirdPartyVin"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inquiryMapped?.Error) {
|
if (inquiryMapped?.Error) {
|
||||||
const error = new BadRequestException(
|
const error = new BadRequestException(
|
||||||
inquiryMapped.Error.Message ||
|
inquiryMapped.Error.Message ||
|
||||||
`${roleLabel} party VIN inquiry returned an error`,
|
getInquiryErrorMessage(inquiryMapped, "thirdPartyVin"),
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(
|
this.recordPartyCaseInquiryStatus(
|
||||||
req,
|
req,
|
||||||
@@ -10793,7 +10872,7 @@ export class RequestManagementService {
|
|||||||
const companyCode = inquiryMapped?.CompanyCode;
|
const companyCode = inquiryMapped?.CompanyCode;
|
||||||
if (!clientName) {
|
if (!clientName) {
|
||||||
const error = new BadRequestException(
|
const error = new BadRequestException(
|
||||||
`CompanyName missing from ${roleLabel} party VIN inquiry response`,
|
"پاسخ استعلام VIN ناقص است و نام شرکت بیمه در آن وجود ندارد.",
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(
|
this.recordPartyCaseInquiryStatus(
|
||||||
req,
|
req,
|
||||||
@@ -10818,7 +10897,7 @@ export class RequestManagementService {
|
|||||||
: null;
|
: null;
|
||||||
if (clientName && !client) {
|
if (clientName && !client) {
|
||||||
const error = new BadRequestException(
|
const error = new BadRequestException(
|
||||||
`CompanyCode missing or invalid in ${roleLabel} party VIN inquiry response`,
|
"پاسخ استعلام VIN ناقص است و شرکت بیمه قابل شناسایی نیست.",
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(
|
this.recordPartyCaseInquiryStatus(
|
||||||
req,
|
req,
|
||||||
@@ -10970,7 +11049,7 @@ export class RequestManagementService {
|
|||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
await this.persistBlameInquiryAudit(req);
|
await this.persistBlameInquiryAudit(req);
|
||||||
this.throwCarBodyInquiryFailure(err);
|
this.throwCarBodyInquiryFailure(err, "carBodyVin");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11045,7 +11124,7 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
await this.persistBlameInquiryAudit(req);
|
await this.persistBlameInquiryAudit(req);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
|
getInquiryErrorMessage(err, "drivingLicense"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12756,27 +12835,124 @@ export class RequestManagementService {
|
|||||||
return { ...workflow, completedSteps };
|
return { ...workflow, completedSteps };
|
||||||
}
|
}
|
||||||
|
|
||||||
// /**
|
/**
|
||||||
// * V4/V5 dirty bridge: FileMaker FE resumes from blame `status`, but pre-capture
|
* FileMaker owns a cross-aggregate workflow: the party narrative is stored on
|
||||||
// * document upload lives on the claim (`UPLOADING_REQUIRED_DOCUMENTS`) while blame
|
* the blame case, while required-document progress is stored on its linked
|
||||||
// * is still at FIRST/SECOND_COMPLETED. Mirror claim status into `status` only for
|
* claim. Expose the authoritative aggregate to resume instead of overloading
|
||||||
// * that phase so leave/re-enter can continue; keep real blame status as
|
* either record's status with the other record's state.
|
||||||
// * `blameCaseStatus`. Remove once FE keys off `claimStatus` / a unified resume pointer.
|
*/
|
||||||
// */
|
private fileMakerResumeProjection(
|
||||||
// private fileMakerStatusForResume(
|
file: any,
|
||||||
// blameStatus: unknown,
|
claim?: any,
|
||||||
// claimStatus: unknown,
|
): FileMakerResumeProjection {
|
||||||
// ): { status: unknown; blameCaseStatus?: unknown } {
|
const blameWorkflow = this.fileMakerWorkflowProjection(file);
|
||||||
// if (claimStatus === ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS) {
|
const narrativeTerminalStep =
|
||||||
// return {
|
file?.type === BlameRequestType.CAR_BODY
|
||||||
// status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
? WorkflowStep.FIRST_COMPLETED
|
||||||
// blameCaseStatus: blameStatus,
|
: WorkflowStep.SECOND_COMPLETED;
|
||||||
// };
|
const narrativeComplete =
|
||||||
// }
|
blameWorkflow.currentStep === narrativeTerminalStep ||
|
||||||
// return { status: blameStatus };
|
(blameWorkflow.completedSteps ?? []).includes(narrativeTerminalStep);
|
||||||
// }
|
const claimWorkflow = claim?.workflow ?? {};
|
||||||
|
|
||||||
async getMyFileMakerFiles(fileMaker: any): Promise<any[]> {
|
if (
|
||||||
|
narrativeComplete &&
|
||||||
|
claim &&
|
||||||
|
claim.status === ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS &&
|
||||||
|
claimWorkflow.currentStep === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
action: "UPLOAD_REQUIRED_DOCUMENTS",
|
||||||
|
entity: "CLAIM",
|
||||||
|
entityId: String(claim._id),
|
||||||
|
status: claim.status,
|
||||||
|
currentStep: claimWorkflow.currentStep,
|
||||||
|
nextStep: claimWorkflow.nextStep,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
narrativeComplete &&
|
||||||
|
claim &&
|
||||||
|
(claim.status === ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER ||
|
||||||
|
file?.status === CaseStatus.WAITING_FOR_FILE_REVIEWER)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
action: "WAIT_FOR_FILE_REVIEWER",
|
||||||
|
entity: "CLAIM",
|
||||||
|
entityId: String(claim._id),
|
||||||
|
status: claim.status,
|
||||||
|
currentStep: claimWorkflow.currentStep,
|
||||||
|
nextStep: claimWorkflow.nextStep,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: "CONTINUE_BLAME",
|
||||||
|
entity: "BLAME",
|
||||||
|
entityId: String(file._id),
|
||||||
|
status: file.status,
|
||||||
|
currentStep: blameWorkflow.currentStep,
|
||||||
|
nextStep: blameWorkflow.nextStep,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private paginateUserFacingFiles(
|
||||||
|
rows: any[],
|
||||||
|
query: ListQueryV2Dto,
|
||||||
|
): GetUserBlameListV2ResponseDto {
|
||||||
|
let filtered = rows;
|
||||||
|
if (query.unifiedStatus) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(row) => row.unifiedFileStatus === query.unifiedStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fromDate, toDate } = parseListDateRange(
|
||||||
|
query.startDate,
|
||||||
|
query.endDate,
|
||||||
|
);
|
||||||
|
if (fromDate || toDate) {
|
||||||
|
filtered = filtered.filter((row) =>
|
||||||
|
isInListDateRange(row.createdAt, fromDate, toDate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paged = applyListQueryV2(
|
||||||
|
filtered,
|
||||||
|
{
|
||||||
|
publicId: (row) => String(row.publicId ?? ""),
|
||||||
|
createdAt: (row) => row.createdAt,
|
||||||
|
requestNo: (row) => String(row.requestNo ?? ""),
|
||||||
|
status: (row) => String(row.unifiedFileStatus ?? row.status ?? ""),
|
||||||
|
fileType: (row) => row.type,
|
||||||
|
searchExtras: (row) =>
|
||||||
|
[
|
||||||
|
row._id,
|
||||||
|
row.blameStatus,
|
||||||
|
row.claimStatus,
|
||||||
|
row.workflow?.currentStep,
|
||||||
|
row.claimWorkflow?.currentStep,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(String),
|
||||||
|
},
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: paged.list,
|
||||||
|
total: paged.total,
|
||||||
|
page: paged.page,
|
||||||
|
limit: paged.limit,
|
||||||
|
totalPages: paged.totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyFileMakerFiles(
|
||||||
|
fileMaker: any,
|
||||||
|
query: ListQueryV2Dto = {},
|
||||||
|
): Promise<GetUserBlameListV2ResponseDto> {
|
||||||
if (fileMaker?.role !== RoleEnum.FILE_MAKER) {
|
if (fileMaker?.role !== RoleEnum.FILE_MAKER) {
|
||||||
throw new ForbiddenException("Only FileMakers can use this endpoint.");
|
throw new ForbiddenException("Only FileMakers can use this endpoint.");
|
||||||
}
|
}
|
||||||
@@ -12785,26 +12961,27 @@ export class RequestManagementService {
|
|||||||
isMadeByFileMaker: true,
|
isMadeByFileMaker: true,
|
||||||
initiatedByFieldExpertId: makerId,
|
initiatedByFieldExpertId: makerId,
|
||||||
});
|
});
|
||||||
// const blameIds = (files || []).map((f: any) => f._id).filter(Boolean);
|
const blameIds = (files || []).map((f: any) => f._id).filter(Boolean);
|
||||||
// const claims =
|
const claims =
|
||||||
// blameIds.length > 0
|
blameIds.length > 0
|
||||||
// ? await this.claimCaseDbService.find(
|
? await this.claimCaseDbService.find(
|
||||||
// { blameRequestId: { $in: blameIds } },
|
{ blameRequestId: { $in: blameIds } },
|
||||||
// { lean: true, select: "blameRequestId status" },
|
{
|
||||||
// )
|
lean: true,
|
||||||
// : [];
|
select: "blameRequestId status workflow",
|
||||||
// const claimStatusByBlameId = new Map<string, unknown>();
|
},
|
||||||
// for (const c of claims as any[]) {
|
)
|
||||||
// const blameId = c?.blameRequestId != null ? String(c.blameRequestId) : "";
|
: [];
|
||||||
// if (blameId) claimStatusByBlameId.set(blameId, c.status);
|
const claimByBlameId = new Map<string, any>();
|
||||||
// }
|
for (const claim of claims as any[]) {
|
||||||
|
const blameId =
|
||||||
|
claim?.blameRequestId != null ? String(claim.blameRequestId) : "";
|
||||||
|
if (blameId) claimByBlameId.set(blameId, claim);
|
||||||
|
}
|
||||||
|
|
||||||
return (files || []).map((f: any) => {
|
const list = (files || []).map((f: any) => {
|
||||||
const workflow = this.fileMakerWorkflowProjection(f);
|
const workflow = this.fileMakerWorkflowProjection(f);
|
||||||
// const resume = this.fileMakerStatusForResume(
|
const claim = claimByBlameId.get(String(f._id));
|
||||||
// f.status,
|
|
||||||
// claimStatusByBlameId.get(String(f._id)),
|
|
||||||
// );
|
|
||||||
return {
|
return {
|
||||||
_id: f._id,
|
_id: f._id,
|
||||||
publicId: f.publicId,
|
publicId: f.publicId,
|
||||||
@@ -12817,11 +12994,21 @@ export class RequestManagementService {
|
|||||||
nextStep: workflow.nextStep,
|
nextStep: workflow.nextStep,
|
||||||
completedSteps: workflow.completedSteps,
|
completedSteps: workflow.completedSteps,
|
||||||
},
|
},
|
||||||
|
linkedClaimId: claim?._id ? String(claim._id) : null,
|
||||||
|
claimStatus: claim?.status,
|
||||||
|
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||||
|
blameStatus: f.status,
|
||||||
|
claimStatus: claim?.status,
|
||||||
|
}),
|
||||||
|
claimWorkflow: claim?.workflow,
|
||||||
|
fileMakerResume: this.fileMakerResumeProjection(f, claim),
|
||||||
requiresFileMakerApproval: f.requiresFileMakerApproval,
|
requiresFileMakerApproval: f.requiresFileMakerApproval,
|
||||||
createdAt: f.createdAt,
|
createdAt: f.createdAt,
|
||||||
updatedAt: f.updatedAt,
|
updatedAt: f.updatedAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return this.paginateUserFacingFiles(list, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMyFileMakerFileDetail(
|
async getMyFileMakerFileDetail(
|
||||||
@@ -12855,6 +13042,7 @@ export class RequestManagementService {
|
|||||||
: claim
|
: claim
|
||||||
? { ...(claim as any) }
|
? { ...(claim as any) }
|
||||||
: null;
|
: null;
|
||||||
|
const fileMakerResume = this.fileMakerResumeProjection(plain, claimPlain);
|
||||||
return {
|
return {
|
||||||
_id: plain._id,
|
_id: plain._id,
|
||||||
publicId: plain.publicId,
|
publicId: plain.publicId,
|
||||||
@@ -12907,6 +13095,7 @@ export class RequestManagementService {
|
|||||||
hasSigned: p.confirmation != null,
|
hasSigned: p.confirmation != null,
|
||||||
})),
|
})),
|
||||||
linkedClaimId: claimPlain ? String(claimPlain._id) : null,
|
linkedClaimId: claimPlain ? String(claimPlain._id) : null,
|
||||||
|
fileMakerResume,
|
||||||
...(claimPlain
|
...(claimPlain
|
||||||
? {
|
? {
|
||||||
claimStatus: claimPlain.status,
|
claimStatus: claimPlain.status,
|
||||||
@@ -12933,7 +13122,10 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
// ─── FileReviewer file list / detail (V4 + V5) ─────────────────────────────
|
// ─── FileReviewer file list / detail (V4 + V5) ─────────────────────────────
|
||||||
|
|
||||||
async getMyFileReviewerFiles(fileReviewer: any): Promise<any[]> {
|
async getMyFileReviewerFiles(
|
||||||
|
fileReviewer: any,
|
||||||
|
query: ListQueryV2Dto = {},
|
||||||
|
): Promise<GetUserBlameListV2ResponseDto> {
|
||||||
if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) {
|
if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) {
|
||||||
throw new ForbiddenException("Only FileReviewers can use this endpoint.");
|
throw new ForbiddenException("Only FileReviewers can use this endpoint.");
|
||||||
}
|
}
|
||||||
@@ -12971,13 +13163,35 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return visibleFiles.map((f: any) => ({
|
const visibleBlameIds = visibleFiles
|
||||||
|
.map((f: any) => f._id)
|
||||||
|
.filter(Boolean);
|
||||||
|
const claims =
|
||||||
|
visibleBlameIds.length > 0
|
||||||
|
? await this.claimCaseDbService.find(
|
||||||
|
{ blameRequestId: { $in: visibleBlameIds } },
|
||||||
|
{ lean: true, select: "blameRequestId status" },
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const claimByBlameId = new Map<string, any>();
|
||||||
|
for (const claim of claims as any[]) {
|
||||||
|
if (claim?.blameRequestId) {
|
||||||
|
claimByBlameId.set(String(claim.blameRequestId), claim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = visibleFiles.map((f: any) => ({
|
||||||
_id: f._id,
|
_id: f._id,
|
||||||
publicId: f.publicId,
|
publicId: f.publicId,
|
||||||
requestNo: f.requestNo,
|
requestNo: f.requestNo,
|
||||||
type: f.type,
|
type: f.type,
|
||||||
status: f.status,
|
status: f.status,
|
||||||
blameStatus: f.blameStatus,
|
blameStatus: f.blameStatus,
|
||||||
|
claimStatus: claimByBlameId.get(String(f._id))?.status,
|
||||||
|
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||||
|
blameStatus: f.status,
|
||||||
|
claimStatus: claimByBlameId.get(String(f._id))?.status,
|
||||||
|
}),
|
||||||
workflow: {
|
workflow: {
|
||||||
currentStep: f.workflow?.currentStep,
|
currentStep: f.workflow?.currentStep,
|
||||||
nextStep: f.workflow?.nextStep,
|
nextStep: f.workflow?.nextStep,
|
||||||
@@ -12987,6 +13201,8 @@ export class RequestManagementService {
|
|||||||
createdAt: f.createdAt,
|
createdAt: f.createdAt,
|
||||||
updatedAt: f.updatedAt,
|
updatedAt: f.updatedAt,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return this.paginateUserFacingFiles(list, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMyFileReviewerFileDetail(
|
async getMyFileReviewerFileDetail(
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export class RequestManagementV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List my blame requests (V2)",
|
summary: "List my blame requests (V2)",
|
||||||
description:
|
description:
|
||||||
"Party-owned blame files, or files initiated by the current FIELD_EXPERT / REGISTRAR. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.",
|
"Party-owned blame files, or files initiated by the current FIELD_EXPERT / REGISTRAR. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType`, `startDate`, `endDate`.",
|
||||||
})
|
})
|
||||||
async getAllBlameRequestsV2(
|
async getAllBlameRequestsV2(
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SandHubService } from "./sand-hub.service";
|
import { SandHubService } from "./sand-hub.service";
|
||||||
import {
|
import {
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service";
|
import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service";
|
||||||
@@ -58,7 +59,9 @@ describe("SandHubService inquiry mocks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps disabled-live third-party mock policy usable", async () => {
|
it("keeps disabled-live third-party mock policy usable", async () => {
|
||||||
const result = await service.getTejaratBlockInquiry(userDetail);
|
const result = await service.getTejaratBlockInquiry(userDetail, {
|
||||||
|
enforceDeploymentClientMatch: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(isMappedPolicyCurrent(result.mapped)).toBe(true);
|
expect(isMappedPolicyCurrent(result.mapped)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -121,6 +124,17 @@ describe("SandHubService inquiry mocks", () => {
|
|||||||
expect(httpService.post).not.toHaveBeenCalled();
|
expect(httpService.post).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns a contextual Persian error when no car-body policy matches", async () => {
|
||||||
|
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||||
|
lookupsService.findLastProcessedCarPolicy.mockRejectedValue(
|
||||||
|
new NotFoundException("No active Fanavaran car-body policy was found"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.getCarBodyInquiry(userDetail)).rejects.toThrow(
|
||||||
|
"بیمهنامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not let an offline third-party seed override a live ESG inquiry", async () => {
|
it("does not let an offline third-party seed override a live ESG inquiry", async () => {
|
||||||
process.env.CLIENT_ID = "8";
|
process.env.CLIENT_ID = "8";
|
||||||
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||||
@@ -129,12 +143,10 @@ describe("SandHubService inquiry mocks", () => {
|
|||||||
raw: { mocked: true },
|
raw: { mocked: true },
|
||||||
mapped: { PrntPlcyCmpDocNo: "MOCK-POLICY" },
|
mapped: { PrntPlcyCmpDocNo: "MOCK-POLICY" },
|
||||||
});
|
});
|
||||||
const esg = jest
|
const esg = jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
|
||||||
.spyOn(service as any, "makeEsgRequest")
|
success: true,
|
||||||
.mockResolvedValue({
|
data: { PrntCmpDocNo: "REAL-ESG-POLICY" },
|
||||||
success: true,
|
});
|
||||||
data: { PrntCmpDocNo: "REAL-ESG-POLICY" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.getTejaratBlockInquiry(userDetail);
|
const result = await service.getTejaratBlockInquiry(userDetail);
|
||||||
|
|
||||||
@@ -143,6 +155,40 @@ describe("SandHubService inquiry mocks", () => {
|
|||||||
expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY");
|
expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves ESG not-found semantics as a Persian plate-specific error", async () => {
|
||||||
|
process.env.CLIENT_ID = "8";
|
||||||
|
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||||
|
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
message: "موردی یافت نشد",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getTejaratBlockInquiry(userDetail, {
|
||||||
|
enforceDeploymentClientMatch: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.mapped.Error.Message).toBe(
|
||||||
|
"بیمهنامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a VIN-specific message for the same ESG not-found response", async () => {
|
||||||
|
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||||
|
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
message: "موردی یافت نشد",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getPolicyByChassisInquiry({
|
||||||
|
nationalCode: "0012345678",
|
||||||
|
chassis: "NAAR03HFFRDE07024",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.mapped.Error.Message).toBe(
|
||||||
|
"بیمهنامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a guilty third-party policy issued by another insurer", async () => {
|
it("rejects a guilty third-party policy issued by another insurer", async () => {
|
||||||
process.env.CLIENT_ID = "15";
|
process.env.CLIENT_ID = "15";
|
||||||
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
|||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
import { mapEsgCarBodyPolicyToInquiry } from "./esg-car-body-inquiry.mapper";
|
import { mapEsgCarBodyPolicyToInquiry } from "./esg-car-body-inquiry.mapper";
|
||||||
import type { Plates } from "src/Types&Enums/plate.interface";
|
import type { Plates } from "src/Types&Enums/plate.interface";
|
||||||
|
import {
|
||||||
|
getInquiryErrorMessage,
|
||||||
|
inquiryErrorStatus,
|
||||||
|
isInquiryFailurePayload,
|
||||||
|
isInquiryTimeout,
|
||||||
|
type InquiryErrorContext,
|
||||||
|
} from "src/common/utils/inquiry-error";
|
||||||
|
|
||||||
type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & {
|
type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & {
|
||||||
plate: Plates | string;
|
plate: Plates | string;
|
||||||
@@ -31,9 +38,6 @@ type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SandHubService {
|
export class SandHubService {
|
||||||
private static readonly ESG_INQUIRY_UNAVAILABLE_MESSAGE =
|
|
||||||
"استعلام در دسترس نیست";
|
|
||||||
|
|
||||||
private readonly logger = new Logger(SandHubService.name);
|
private readonly logger = new Logger(SandHubService.name);
|
||||||
private loginToken: string | null = null;
|
private loginToken: string | null = null;
|
||||||
private tokenExpiry: Date | null = null;
|
private tokenExpiry: Date | null = null;
|
||||||
@@ -90,6 +94,30 @@ export class SandHubService {
|
|||||||
return resolveFanavaranClientKey() === "parsian";
|
return resolveFanavaranClientKey() === "parsian";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private inquiryContext(type: ExternalInquiryType): InquiryErrorContext {
|
||||||
|
if (type === "vinChassis") return "thirdPartyVin";
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private throwInquiryError(
|
||||||
|
error: unknown,
|
||||||
|
context: InquiryErrorContext,
|
||||||
|
): never {
|
||||||
|
const message = getInquiryErrorMessage(error, context);
|
||||||
|
const status = inquiryErrorStatus(error);
|
||||||
|
|
||||||
|
if (error instanceof ForbiddenException || status === 403) {
|
||||||
|
throw new ForbiddenException(message);
|
||||||
|
}
|
||||||
|
if (isInquiryTimeout(error) || status === 504) {
|
||||||
|
throw new GatewayTimeoutException(message);
|
||||||
|
}
|
||||||
|
if ([400, 404, 409, 422].includes(status ?? 0)) {
|
||||||
|
throw new BadRequestException(message);
|
||||||
|
}
|
||||||
|
throw new ServiceUnavailableException(message);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A case may proceed only when the policy belongs to the insurer served by
|
* A case may proceed only when the policy belongs to the insurer served by
|
||||||
* this deployment. Callers opt in for the guilty/first-party policy only.
|
* this deployment. Callers opt in for the guilty/first-party policy only.
|
||||||
@@ -101,7 +129,7 @@ export class SandHubService {
|
|||||||
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
|
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
|
||||||
if (!expectedClientCode) {
|
if (!expectedClientCode) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
"CLIENT_ID must be configured before insurance eligibility can be checked.",
|
"تنظیمات شرکت بیمه برای بررسی اعتبار بیمهنامه کامل نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +139,7 @@ export class SandHubService {
|
|||||||
if (actualClientCode === expectedClientCode) return;
|
if (actualClientCode === expectedClientCode) return;
|
||||||
|
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`${insuranceLine} policy insurer does not match this deployment.`,
|
"بیمهنامه یافتشده متعلق به شرکت بیمه این سامانه نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,13 +162,13 @@ export class SandHubService {
|
|||||||
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
|
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
|
||||||
if (!expectedClientCode) {
|
if (!expectedClientCode) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
"CLIENT_ID must be configured before insurance eligibility can be checked.",
|
"تنظیمات شرکت بیمه برای بررسی اعتبار بیمهنامه کامل نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (expectedClientCode === "8") return;
|
if (expectedClientCode === "8") return;
|
||||||
|
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"CAR_BODY policy insurer does not match this deployment.",
|
"بیمهنامه یافتشده متعلق به شرکت بیمه این سامانه نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +454,7 @@ export class SandHubService {
|
|||||||
this.logger.error("Failed to login to SandHub:", er.message);
|
this.logger.error("Failed to login to SandHub:", er.message);
|
||||||
this.loginToken = null;
|
this.loginToken = null;
|
||||||
this.tokenExpiry = null;
|
this.tokenExpiry = null;
|
||||||
throw new UnauthorizedException("SandHub authentication failed");
|
throw new UnauthorizedException("احراز هویت سرویس استعلام انجام نشد.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,7 +474,7 @@ export class SandHubService {
|
|||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
"Tejarat inquiry credentials are not configured (TEJARAT_INQUIRY_EMAIL/TEJARAT_INQUIRY_PASSWORD)",
|
"اطلاعات اتصال به سرویس استعلام تجارت نو تنظیم نشده است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,12 +514,18 @@ export class SandHubService {
|
|||||||
);
|
);
|
||||||
this.tejaratAccessToken = null;
|
this.tejaratAccessToken = null;
|
||||||
this.tejaratTokenExpiry = null;
|
this.tejaratTokenExpiry = null;
|
||||||
throw new UnauthorizedException("Tejarat inquiry authentication failed");
|
throw new UnauthorizedException(
|
||||||
|
"احراز هویت سرویس استعلام تجارت نو انجام نشد.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getEsgAccessToken(): Promise<string> {
|
private async getEsgAccessToken(): Promise<string> {
|
||||||
if (this.esgAccessToken && this.esgTokenExpiry && this.esgTokenExpiry > new Date()) {
|
if (
|
||||||
|
this.esgAccessToken &&
|
||||||
|
this.esgTokenExpiry &&
|
||||||
|
this.esgTokenExpiry > new Date()
|
||||||
|
) {
|
||||||
return this.esgAccessToken;
|
return this.esgAccessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,7 +535,7 @@ export class SandHubService {
|
|||||||
|
|
||||||
if (!baseUrl || !username || !password) {
|
if (!baseUrl || !username || !password) {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
"ESG credentials are not configured (ESG_URL/ESG_USERNAME/ESG_PASSWORD)",
|
"اطلاعات اتصال به سرویس استعلام ESG تنظیم نشده است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +573,9 @@ export class SandHubService {
|
|||||||
this.logger.error("Failed to login to ESG inquiry:", er?.message || er);
|
this.logger.error("Failed to login to ESG inquiry:", er?.message || er);
|
||||||
this.esgAccessToken = null;
|
this.esgAccessToken = null;
|
||||||
this.esgTokenExpiry = null;
|
this.esgTokenExpiry = null;
|
||||||
throw new UnauthorizedException("ESG inquiry authentication failed");
|
throw new UnauthorizedException(
|
||||||
|
"احراز هویت سرویس استعلام ESG انجام نشد.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,24 +639,30 @@ export class SandHubService {
|
|||||||
this.esgTokenExpiry = null;
|
this.esgTokenExpiry = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([400, 404, 409, 422].includes(status)) {
|
||||||
|
this.throwInquiryError(err, this.inquiryContext(inquiryType));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === maxRetries - 1) {
|
||||||
|
this.throwInquiryError(err, this.inquiryContext(inquiryType));
|
||||||
|
}
|
||||||
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
|
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
if (attempt === maxRetries - 1) throw err;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapEsgPolicyByPlateToOldFormat(raw: any): any {
|
private mapEsgPolicyByPlateToOldFormat(
|
||||||
|
raw: any,
|
||||||
|
context: "thirdPartyPlate" | "thirdPartyVin" = "thirdPartyPlate",
|
||||||
|
): any {
|
||||||
if (!raw) return raw;
|
if (!raw) return raw;
|
||||||
|
|
||||||
if (raw?.success === false) {
|
if (isInquiryFailurePayload(raw)) {
|
||||||
this.logger.warn(
|
this.logger.warn("ESG policy inquiry returned a failure payload", raw);
|
||||||
"ESG policyByPlate inquiry returned success=false",
|
|
||||||
raw,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
Error: {
|
Error: {
|
||||||
Message: SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
|
Message: getInquiryErrorMessage(raw, context),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -670,7 +712,8 @@ export class SandHubService {
|
|||||||
): string | null {
|
): string | null {
|
||||||
if (input === null || input === undefined) return null;
|
if (input === null || input === undefined) return null;
|
||||||
|
|
||||||
const raw = typeof input === "number" ? String(input) : String(input).trim();
|
const raw =
|
||||||
|
typeof input === "number" ? String(input) : String(input).trim();
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|
||||||
let year = 0;
|
let year = 0;
|
||||||
@@ -700,7 +743,9 @@ export class SandHubService {
|
|||||||
return `${year}-${mm}-${dd}`;
|
return `${year}-${mm}-${dd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultMockPersonInquiry(nationalCode: string): Record<string, unknown> {
|
private getDefaultMockPersonInquiry(
|
||||||
|
nationalCode: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
firstName: "نام",
|
firstName: "نام",
|
||||||
lastName: "خانوادگی",
|
lastName: "خانوادگی",
|
||||||
@@ -711,10 +756,10 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mapEsgPersonInquiryToOldFormat(raw: any): Record<string, unknown> {
|
private mapEsgPersonInquiryToOldFormat(raw: any): Record<string, unknown> {
|
||||||
if (raw?.success === false) {
|
if (isInquiryFailurePayload(raw)) {
|
||||||
this.logger.warn("ESG person inquiry returned success=false", raw);
|
this.logger.warn("ESG person inquiry returned success=false", raw);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
|
getInquiryErrorMessage(raw, "personalIdentity"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,11 +781,9 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mapEsgShebaInquiryToOldFormat(raw: any): Record<string, unknown> {
|
private mapEsgShebaInquiryToOldFormat(raw: any): Record<string, unknown> {
|
||||||
if (raw?.success === false) {
|
if (isInquiryFailurePayload(raw)) {
|
||||||
this.logger.warn("ESG sheba inquiry returned success=false", raw);
|
this.logger.warn("ESG sheba inquiry returned success=false", raw);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(getInquiryErrorMessage(raw, "sheba"));
|
||||||
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = raw?.data ?? {};
|
const data = raw?.data ?? {};
|
||||||
@@ -800,11 +843,15 @@ export class SandHubService {
|
|||||||
this.tejaratTokenExpiry = null;
|
this.tejaratTokenExpiry = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([400, 404, 409, 422].includes(status)) {
|
||||||
|
this.throwInquiryError(err, this.inquiryContext(inquiryType));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === maxRetries - 1) {
|
||||||
|
this.throwInquiryError(err, this.inquiryContext(inquiryType));
|
||||||
|
}
|
||||||
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
|
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
if (attempt === maxRetries - 1) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -883,8 +930,13 @@ export class SandHubService {
|
|||||||
`[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`,
|
`[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
|
const mapped = this.mapEsgPolicyByPlateToOldFormat(
|
||||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
raw,
|
||||||
|
"thirdPartyPlate",
|
||||||
|
);
|
||||||
|
if (!mapped?.Error) {
|
||||||
|
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||||
|
}
|
||||||
return { raw, mapped };
|
return { raw, mapped };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,8 +964,16 @@ export class SandHubService {
|
|||||||
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
|
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const mapped = this.mapNewApiResponseToOldFormat(raw);
|
const mapped = isInquiryFailurePayload(raw)
|
||||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
? {
|
||||||
|
Error: {
|
||||||
|
Message: getInquiryErrorMessage(raw, "thirdPartyPlate"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: this.mapNewApiResponseToOldFormat(raw);
|
||||||
|
if (!mapped?.Error) {
|
||||||
|
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||||
|
}
|
||||||
return { raw, mapped };
|
return { raw, mapped };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,13 +1022,15 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isVinInquiry) {
|
if (isVinInquiry) {
|
||||||
const raw = await this.lookupsService.findLastProcessedCarPolicy(
|
let raw: any;
|
||||||
"car-body",
|
try {
|
||||||
{
|
raw = await this.lookupsService.findLastProcessedCarPolicy("car-body", {
|
||||||
nationalCode: String(userDetail.nationalCodeOfInsurer),
|
nationalCode: String(userDetail.nationalCodeOfInsurer),
|
||||||
vin: plateOrVin,
|
vin: plateOrVin,
|
||||||
},
|
});
|
||||||
);
|
} catch (error) {
|
||||||
|
this.throwInquiryError(error, "carBodyVin");
|
||||||
|
}
|
||||||
if (useParsianCarBodyLookup) {
|
if (useParsianCarBodyLookup) {
|
||||||
this.assertParsianCarBodyLookupMatchesDeployment();
|
this.assertParsianCarBodyLookupMatchesDeployment();
|
||||||
}
|
}
|
||||||
@@ -1000,10 +1062,15 @@ export class SandHubService {
|
|||||||
plaqueRight: String(plateOrVin.centerDigits),
|
plaqueRight: String(plateOrVin.centerDigits),
|
||||||
plaqueSerial: String(plateOrVin.ir),
|
plaqueSerial: String(plateOrVin.ir),
|
||||||
};
|
};
|
||||||
const raw = await this.lookupsService.findLastProcessedCarPolicy(
|
let raw: any;
|
||||||
"car-body",
|
try {
|
||||||
query,
|
raw = await this.lookupsService.findLastProcessedCarPolicy(
|
||||||
);
|
"car-body",
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.throwInquiryError(error, "carBodyPlate");
|
||||||
|
}
|
||||||
this.assertParsianCarBodyLookupMatchesDeployment();
|
this.assertParsianCarBodyLookupMatchesDeployment();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1046,6 +1113,11 @@ export class SandHubService {
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isInquiryFailurePayload(raw)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
getInquiryErrorMessage(raw, "carBodyPlate"),
|
||||||
|
);
|
||||||
|
}
|
||||||
const mapped = this.mapCarBodyInquiryResponse(raw);
|
const mapped = this.mapCarBodyInquiryResponse(raw);
|
||||||
return { raw, mapped };
|
return { raw, mapped };
|
||||||
}
|
}
|
||||||
@@ -1114,7 +1186,6 @@ export class SandHubService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`).
|
* ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`).
|
||||||
*
|
*
|
||||||
@@ -1130,10 +1201,10 @@ export class SandHubService {
|
|||||||
options?: SandHubInquiryOptions,
|
options?: SandHubInquiryOptions,
|
||||||
): Promise<{ raw: any; mapped: any }> {
|
): Promise<{ raw: any; mapped: any }> {
|
||||||
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
||||||
const requestUrl = `${baseUrl}/inquiry/carByChassis`;
|
const requestUrl = `${baseUrl}/inquiry/policyByChassis`;
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
nationalCode: String(identity.nationalCode),
|
nationalCode: String(identity.nationalCode),
|
||||||
chassisNo: String(identity.chassis),
|
chassis: String(identity.chassis),
|
||||||
};
|
};
|
||||||
|
|
||||||
const live = await this.isInquiryLive("vinChassis", options);
|
const live = await this.isInquiryLive("vinChassis", options);
|
||||||
@@ -1144,8 +1215,10 @@ export class SandHubService {
|
|||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`[MOCK] getPolicyByChassisInquiry nationalCode=${identity.nationalCode} chassis=${identity.chassis}`,
|
`[MOCK] getPolicyByChassisInquiry nationalCode=${identity.nationalCode} chassis=${identity.chassis}`,
|
||||||
);
|
);
|
||||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
|
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
|
||||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
if (!mapped?.Error) {
|
||||||
|
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||||
|
}
|
||||||
return { raw, mapped };
|
return { raw, mapped };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1155,12 +1228,13 @@ export class SandHubService {
|
|||||||
"vinChassis",
|
"vinChassis",
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
|
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
|
||||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
if (!mapped?.Error) {
|
||||||
|
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||||
|
}
|
||||||
return { raw, mapped };
|
return { raw, mapped };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async makeSandHubRequest(
|
private async makeSandHubRequest(
|
||||||
url: string,
|
url: string,
|
||||||
payload: any,
|
payload: any,
|
||||||
@@ -1205,7 +1279,7 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new BadGatewayException(
|
throw new BadGatewayException(
|
||||||
"Failed to fetch data from SandHub after multiple retries",
|
"سرویس استعلام پس از چند تلاش پاسخ نداد. لطفاً کمی بعد دوباره تلاش کنید.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1229,11 +1303,16 @@ export class SandHubService {
|
|||||||
// Pattern: {centerDigits}{centerLetter(s)}{leftDigits}<space>{ir}
|
// Pattern: {centerDigits}{centerLetter(s)}{leftDigits}<space>{ir}
|
||||||
const m = plk.trim().match(/^(\d+)([^\d\s]+)(\d+)\s+(\d+)$/);
|
const m = plk.trim().match(/^(\d+)([^\d\s]+)(\d+)\s+(\d+)$/);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const Plk3 = parseInt(m[1], 10); // center digits
|
const Plk3 = parseInt(m[1], 10); // center digits
|
||||||
const Plk2 = this.plateNormalizer.normalizePlateText(m[2]);
|
const Plk2 = this.plateNormalizer.normalizePlateText(m[2]);
|
||||||
const Plk1 = parseInt(m[3], 10); // left digits
|
const Plk1 = parseInt(m[3], 10); // left digits
|
||||||
const PlkSrl = parseInt(m[4], 10); // IR region code
|
const PlkSrl = parseInt(m[4], 10); // IR region code
|
||||||
if (!Number.isFinite(Plk3) || !Number.isFinite(Plk1) || !Number.isFinite(PlkSrl) || !Plk2) {
|
if (
|
||||||
|
!Number.isFinite(Plk3) ||
|
||||||
|
!Number.isFinite(Plk1) ||
|
||||||
|
!Number.isFinite(PlkSrl) ||
|
||||||
|
!Plk2
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { Plk1, Plk2, Plk3, PlkSrl };
|
return { Plk1, Plk2, Plk3, PlkSrl };
|
||||||
@@ -1245,7 +1324,12 @@ export class SandHubService {
|
|||||||
// If the response carries a `plk` plate string (VIN inquiry) but lacks the
|
// If the response carries a `plk` plate string (VIN inquiry) but lacks the
|
||||||
// individual Plk1/Plk2/Plk3/PlkSrl fields, parse and inject them so that
|
// individual Plk1/Plk2/Plk3/PlkSrl fields, parse and inject them so that
|
||||||
// all downstream plate-handling code works identically to the plate flow.
|
// all downstream plate-handling code works identically to the plate flow.
|
||||||
let plkParts: { Plk1: number; Plk2: string; Plk3: number; PlkSrl: number } | null = null;
|
let plkParts: {
|
||||||
|
Plk1: number;
|
||||||
|
Plk2: string;
|
||||||
|
Plk3: number;
|
||||||
|
PlkSrl: number;
|
||||||
|
} | null = null;
|
||||||
if (
|
if (
|
||||||
newResponse.plk &&
|
newResponse.plk &&
|
||||||
newResponse.Plk1 == null &&
|
newResponse.Plk1 == null &&
|
||||||
@@ -1258,13 +1342,15 @@ export class SandHubService {
|
|||||||
// Map the new field names to the old field names
|
// Map the new field names to the old field names
|
||||||
return {
|
return {
|
||||||
...newResponse,
|
...newResponse,
|
||||||
...(plkParts ? {
|
...(plkParts
|
||||||
Plk1: plkParts.Plk1,
|
? {
|
||||||
Plk2: plkParts.Plk2,
|
Plk1: plkParts.Plk1,
|
||||||
Plk3: plkParts.Plk3,
|
Plk2: plkParts.Plk2,
|
||||||
PlkSrl: plkParts.PlkSrl,
|
Plk3: plkParts.Plk3,
|
||||||
plateLetterid: plkParts.Plk2,
|
PlkSrl: plkParts.PlkSrl,
|
||||||
} : {}),
|
plateLetterid: plkParts.Plk2,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
// Company information
|
// Company information
|
||||||
CompanyCode: newResponse.companyId || newResponse.CompanyCode,
|
CompanyCode: newResponse.companyId || newResponse.CompanyCode,
|
||||||
CompanyName: newResponse.companyPersianName || newResponse.CompanyName,
|
CompanyName: newResponse.companyPersianName || newResponse.CompanyName,
|
||||||
@@ -1401,7 +1487,7 @@ export class SandHubService {
|
|||||||
) {
|
) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
throw new Error(err);
|
this.throwInquiryError(err, "thirdPartyPlate");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1420,7 +1506,7 @@ export class SandHubService {
|
|||||||
const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate);
|
const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate);
|
||||||
if (!jalaliBirthDate) {
|
if (!jalaliBirthDate) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13781124 or "1378-11-24").`,
|
"تاریخ تولد واردشده برای استعلام هویت معتبر نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,7 +1540,7 @@ export class SandHubService {
|
|||||||
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
|
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
|
||||||
if (!gregorianBirthdate) {
|
if (!gregorianBirthdate) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13770624 or "1377-06-24").`,
|
"تاریخ تولد واردشده برای استعلام هویت معتبر نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1472,7 +1558,7 @@ export class SandHubService {
|
|||||||
|
|
||||||
if (response?.message?.includes("err.record.not.found")) {
|
if (response?.message?.includes("err.record.not.found")) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
"Personal inquiry failed: Record not found for the given national code and birth date.",
|
getInquiryErrorMessage(response, "personalIdentity"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -1483,7 +1569,7 @@ export class SandHubService {
|
|||||||
) {
|
) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
throw new Error(`Error in finding personal inquiry: ${err}`);
|
this.throwInquiryError(err, "personalIdentity");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1512,22 +1598,13 @@ export class SandHubService {
|
|||||||
|
|
||||||
if (response?.data?.IsSucceed === false) {
|
if (response?.data?.IsSucceed === false) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
"Driving license check failed: The license is not valid or could not be found.",
|
"گواهینامهای مطابق کد ملی و شماره گواهینامه واردشده یافت نشد.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
this.throwInquiryError(error, "drivingLicense");
|
||||||
error instanceof BadGatewayException &&
|
|
||||||
error.message.includes("multiple retries")
|
|
||||||
) {
|
|
||||||
throw new BadGatewayException(
|
|
||||||
`Driving license check failed after multiple retries. The service may be down.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// For all other errors (like 400, 404, etc.), re-throw them as-is.
|
|
||||||
throw new Error(`Error in finding driving license: ${error}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1563,13 +1640,13 @@ export class SandHubService {
|
|||||||
response,
|
response,
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Ownership validation failed: The provided national ID is not the owner of this vehicle.",
|
"پلاک واردشده متعلق به کد ملی واردشده نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Error in finding car ownership: ${err}`);
|
this.throwInquiryError(err, "carOwnership");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1619,7 +1696,7 @@ export class SandHubService {
|
|||||||
response,
|
response,
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.",
|
"شماره شبا متعلق به کد ملی واردشده نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1647,7 +1724,7 @@ export class SandHubService {
|
|||||||
response,
|
response,
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.",
|
"شماره شبا متعلق به کد ملی واردشده نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,7 +1733,7 @@ export class SandHubService {
|
|||||||
if (err instanceof BadRequestException) {
|
if (err instanceof BadRequestException) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
throw new Error(`Error in matching sheba validation: ${err}`);
|
this.throwInquiryError(err, "sheba");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1672,9 +1749,7 @@ export class SandHubService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (err.response.status === 400) {
|
if (err.response.status === 400) {
|
||||||
throw new BadGatewayException(
|
throw new BadGatewayException(getInquiryErrorMessage(err, "generic"));
|
||||||
`SandHub rejected the request with a 400 Bad Request. Details: ${JSON.stringify(err.response.data)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
@@ -1685,23 +1760,24 @@ export class SandHubService {
|
|||||||
|
|
||||||
if (err.message === "EMPTY_RESPONSE") {
|
if (err.message === "EMPTY_RESPONSE") {
|
||||||
throw new BadGatewayException(
|
throw new BadGatewayException(
|
||||||
"SandHub is offline or returned an empty response",
|
"سرویس استعلام پاسخی برنگرداند. لطفاً دوباره تلاش کنید.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (err.code === "ECONNABORTED") {
|
if (err.code === "ECONNABORTED") {
|
||||||
throw new GatewayTimeoutException("SandHub request timed out");
|
throw new GatewayTimeoutException(
|
||||||
|
"زمان پاسخگویی سرویس استعلام به پایان رسید. لطفاً دوباره تلاش کنید.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (err.code === "ECONNRESET" || err.message.includes("socket hang up")) {
|
if (err.code === "ECONNRESET" || err.message.includes("socket hang up")) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
"SandHub connection was reset or closed unexpectedly",
|
"ارتباط با سرویس استعلام قطع شد. لطفاً دوباره تلاش کنید.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This final check is for when all retries have failed for a retryable error.
|
// This final check is for when all retries have failed for a retryable error.
|
||||||
if (attempt >= maxRetries) {
|
if (attempt >= maxRetries - 1) {
|
||||||
throw new BadGatewayException(
|
throw new BadGatewayException(
|
||||||
"Failed to fetch data from SandHub after multiple retries",
|
"سرویس استعلام پس از چند تلاش پاسخ نداد. لطفاً کمی بعد دوباره تلاش کنید.",
|
||||||
err.message,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user