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:
@@ -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,
|
||||
persianAccidentCondition,
|
||||
persianFieldPath,
|
||||
persianReportValue,
|
||||
persianStatus,
|
||||
} from "./persian-report-labels";
|
||||
|
||||
@@ -110,10 +111,13 @@ function firstDefined(...values: unknown[]): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeListValue(value: unknown): string | undefined {
|
||||
function normalizeListValue(value: unknown, path = ""): string | undefined {
|
||||
if (!Array.isArray(value) || !value.length) return undefined;
|
||||
const items = value
|
||||
.map((item) => asString(item) ?? JSON.stringify(item))
|
||||
.map(
|
||||
(item) =>
|
||||
asString(persianReportValue(path, item)) ?? JSON.stringify(item),
|
||||
)
|
||||
.filter(Boolean);
|
||||
return items.length ? items.join("، ") : undefined;
|
||||
}
|
||||
@@ -124,18 +128,31 @@ function flattenObject(
|
||||
depth = 0,
|
||||
): InsurerFileReportField[] {
|
||||
if (obj == null) return [];
|
||||
if (depth > 4) {
|
||||
return [{ label: persianFieldPath(prefix), value: asString(obj) }];
|
||||
if (depth > 6) {
|
||||
return [
|
||||
{
|
||||
label: persianFieldPath(prefix),
|
||||
value: asString(persianReportValue(prefix, 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 }] : [];
|
||||
}
|
||||
|
||||
if (typeof obj !== "object") {
|
||||
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;
|
||||
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
!(value instanceof Date)
|
||||
) {
|
||||
if (typeof value === "object" && !(value instanceof Date)) {
|
||||
rows.push(...flattenObject(value, path, depth + 1));
|
||||
} 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
|
||||
? `${PR.licenseNumber}: ${asString(participant.licenseNumber)}`
|
||||
: undefined,
|
||||
participant?.hasDrivingLicense != null
|
||||
? `${PR.hasDrivingLicense}: ${persianStatus(participant.hasDrivingLicense)}`
|
||||
: undefined,
|
||||
participant?.licenseType
|
||||
? `${PR.licenseType}: ${asString(
|
||||
persianReportValue(
|
||||
"participant.licenseType",
|
||||
participant.licenseType,
|
||||
),
|
||||
)}`
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("، ");
|
||||
@@ -695,8 +722,12 @@ function buildDriverSection(
|
||||
{
|
||||
label: PR.licenseType,
|
||||
value:
|
||||
licenseType ??
|
||||
asString(person.licenseType) ??
|
||||
asString(
|
||||
persianReportValue(
|
||||
"participant.licenseType",
|
||||
licenseType ?? person.licenseType,
|
||||
),
|
||||
) ??
|
||||
(person.driverLicense ? PR.driverLicense : undefined),
|
||||
},
|
||||
{
|
||||
@@ -895,7 +926,7 @@ function evaluationDaghiValue(value: unknown): string | undefined {
|
||||
return asString(value);
|
||||
}
|
||||
const daghi = value as ReportRecord;
|
||||
const option = asString(daghi.option);
|
||||
const option = asString(persianReportValue("daghi.option", daghi.option));
|
||||
const price = formatToman(daghi.price);
|
||||
return [option, price].filter(Boolean).join(" - ") || undefined;
|
||||
}
|
||||
@@ -915,7 +946,9 @@ function buildEvaluationPartsSection(
|
||||
{ label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) },
|
||||
{
|
||||
label: `${prefix} / ${PR.damageType}`,
|
||||
value: asString(part.typeOfDamage),
|
||||
value: asString(
|
||||
persianReportValue("evaluation.part.typeOfDamage", part.typeOfDamage),
|
||||
),
|
||||
},
|
||||
{ label: `${prefix} / ${PR.partPrice}`, value: formatToman(part.price) },
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ export const PR = {
|
||||
licenseType: "نوع گواهینامه",
|
||||
licenseDate: "تاریخ گواهینامه",
|
||||
licenseNumber: "شماره گواهینامه",
|
||||
hasDrivingLicense: "گواهینامه دارد",
|
||||
driverLicense: "گواهینامه راننده",
|
||||
insuranceCompany: "شرکت بیمه",
|
||||
policyNumber: "شماره بیمهنامه",
|
||||
@@ -194,6 +195,31 @@ const KEY_LABELS: Record<string, string> = {
|
||||
StatusTypeCode: "کد وضعیت",
|
||||
label_fa: "برچسب فارسی",
|
||||
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> = {
|
||||
@@ -209,6 +235,76 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
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> = {
|
||||
clear: "صاف",
|
||||
sunny: "صاف",
|
||||
@@ -255,9 +351,39 @@ export function persianFieldPath(path: string): string {
|
||||
return `بیمه / ${translatedLast}`;
|
||||
}
|
||||
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}`;
|
||||
}
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -267,6 +393,51 @@ export function persianFieldPath(path: string): string {
|
||||
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 {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const key = String(value);
|
||||
|
||||
@@ -101,7 +101,12 @@ import {
|
||||
ClaimListItemV2Dto,
|
||||
} from "./dto/my-claims-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 {
|
||||
buildBlamePartyAccessOrConditions,
|
||||
@@ -206,6 +211,7 @@ import {
|
||||
partLookupKey,
|
||||
resolvePartCaptureIndex,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
import { serializeDamagedPartSelectionHistory } from "src/helpers/claim-damaged-part-audit";
|
||||
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
||||
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
|
||||
|
||||
@@ -12421,7 +12427,7 @@ export class ClaimRequestManagementService {
|
||||
blameIdsForList.length > 0
|
||||
? ((await this.blameRequestDbService.find(
|
||||
{ _id: { $in: blameIdsForList.map((id) => new Types.ObjectId(id)) } },
|
||||
{ lean: true, select: "type creationMethod" },
|
||||
{ lean: true, select: "type creationMethod status" },
|
||||
)) as any[])
|
||||
: [];
|
||||
const blameByIdForList = new Map<string, any>(
|
||||
@@ -12443,16 +12449,37 @@ export class ClaimRequestManagementService {
|
||||
blameRequestId: c.blameRequestId?.toString(),
|
||||
blameType: blameForItem?.type ?? undefined,
|
||||
creationMethod: blameForItem?.creationMethod ?? undefined,
|
||||
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||
blameStatus: blameForItem?.status,
|
||||
claimStatus: c.status,
|
||||
}),
|
||||
};
|
||||
}) 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(
|
||||
list,
|
||||
filtered,
|
||||
{
|
||||
publicId: (r) => r.publicId,
|
||||
createdAt: (r) => r.createdAt,
|
||||
requestNo: (r) => r.requestNo,
|
||||
status: (r) => r.status,
|
||||
status: (r) => r.unifiedFileStatus ?? r.status,
|
||||
fileType: (r) => r.blameType,
|
||||
searchExtras: (r) =>
|
||||
[
|
||||
r.claimRequestId,
|
||||
@@ -12588,6 +12615,10 @@ export class ClaimRequestManagementService {
|
||||
catalogLikeKeyFromPart,
|
||||
buildFileLink,
|
||||
});
|
||||
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
|
||||
history: (claim.damage as any)?.partSelectionHistory,
|
||||
currentSelectedParts: selectedNormDetails,
|
||||
});
|
||||
|
||||
const er = claim.evaluation?.damageExpertResend;
|
||||
const expertResend =
|
||||
@@ -12671,6 +12702,7 @@ export class ClaimRequestManagementService {
|
||||
: undefined,
|
||||
carAngles,
|
||||
damagedParts,
|
||||
damagedPartsHistory,
|
||||
expertResend,
|
||||
fanavaran: fanavaranClaimReferences(claim),
|
||||
evaluation: mappedEvaluation
|
||||
|
||||
@@ -83,7 +83,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "Get My Claims (V2)",
|
||||
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({
|
||||
status: 200,
|
||||
|
||||
@@ -204,6 +204,33 @@ export class ClaimDetailsV2ResponseDto {
|
||||
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({
|
||||
description:
|
||||
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
|
||||
|
||||
@@ -40,6 +40,12 @@ export class ClaimListItemV2Dto {
|
||||
example: 'IN_PERSON',
|
||||
})
|
||||
creationMethod?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Calculated combined blame and claim lifecycle status',
|
||||
example: 'WAITING_FOR_DAMAGE_EXPERT',
|
||||
})
|
||||
unifiedFileStatus?: string;
|
||||
}
|
||||
|
||||
export class GetMyClaimsV2ResponseDto {
|
||||
|
||||
@@ -18,6 +18,18 @@ export class ClaimDamageSelection {
|
||||
@Prop({ type: [String], default: [] })
|
||||
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
|
||||
*/
|
||||
|
||||
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;
|
||||
}>;
|
||||
|
||||
@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({
|
||||
description:
|
||||
"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);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '1_000_000_000',
|
||||
description: "Today's car price",
|
||||
example: '1000000000',
|
||||
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()
|
||||
@IsMoneyAmountString()
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { Types } from "mongoose";
|
||||
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 { RoleEnum } from "src/Types&Enums/role.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";
|
||||
|
||||
const V2_EXPERT_ID = "66ec0e480e321873c0900001";
|
||||
|
||||
const blankPricingReply = {
|
||||
description: "Damage assessment",
|
||||
parts: [
|
||||
@@ -26,7 +30,144 @@ function createService() {
|
||||
) 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", () => {
|
||||
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", () => {
|
||||
const service = createService() as any;
|
||||
|
||||
@@ -105,18 +246,27 @@ describe("ExpertClaimService expert-reply pricing", () => {
|
||||
const findByIdAndUpdate = jest.fn();
|
||||
service.claimCaseDbService = {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
blameRequestId: "blame-1",
|
||||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
|
||||
}),
|
||||
findByIdAndUpdate,
|
||||
};
|
||||
service.blameRequestDbService = {
|
||||
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
|
||||
};
|
||||
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
||||
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.submitExpertReplyV2("v2-claim", blankPricingReply, {
|
||||
sub: "expert-1",
|
||||
role: RoleEnum.FIELD_EXPERT,
|
||||
}),
|
||||
service.submitExpertReplyV2(
|
||||
"v2-claim",
|
||||
{ ...blankPricingReply, carPrice: "1000000" },
|
||||
{
|
||||
sub: "expert-1",
|
||||
role: RoleEnum.FIELD_EXPERT,
|
||||
},
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
||||
@@ -127,18 +277,24 @@ describe("ExpertClaimService expert-reply pricing", () => {
|
||||
const findByIdAndUpdate = jest.fn();
|
||||
service.claimCaseDbService = {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
blameRequestId: "blame-1",
|
||||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
|
||||
}),
|
||||
findByIdAndUpdate,
|
||||
};
|
||||
service.blameRequestDbService = {
|
||||
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
|
||||
};
|
||||
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
||||
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.submitExpertReplyV2(
|
||||
"v2-claim",
|
||||
{
|
||||
description: "Damage assessment",
|
||||
carPrice: "1000000",
|
||||
parts: [
|
||||
{
|
||||
partId: 201,
|
||||
@@ -163,3 +319,132 @@ describe("ExpertClaimService expert-reply pricing", () => {
|
||||
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";
|
||||
import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend";
|
||||
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 { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
|
||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||
@@ -172,6 +176,10 @@ import {
|
||||
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
|
||||
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
|
||||
import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing";
|
||||
import {
|
||||
normalizeMoneyAmountString,
|
||||
parseMoneyAmountToman,
|
||||
} from "src/utils/unicode-digits";
|
||||
|
||||
@Injectable()
|
||||
export class ExpertClaimService {
|
||||
@@ -2719,9 +2727,14 @@ export class ExpertClaimService {
|
||||
|
||||
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
|
||||
if (assignedReviewerId && assignedReviewerId === actor.sub) {
|
||||
// Reviewer already assigned (e.g. after a FileMaker rejection that reset
|
||||
// blame back to WAITING_FOR_FILE_REVIEWER) — skip Phase 1 and fall
|
||||
// through to the damage-expert workflow lock below.
|
||||
// Phase 1 is intentionally idempotent. The linked claim can still be
|
||||
// in a data-capture status here, so falling through to the damage
|
||||
// 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 {
|
||||
// Phase 1: first-time blame assignment
|
||||
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
|
||||
@@ -3263,8 +3276,6 @@ export class ExpertClaimService {
|
||||
) {
|
||||
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||
const blame = await this.blameRequestDbService.findById(claim.blameRequestId);
|
||||
|
||||
if (!claim) {
|
||||
throw new NotFoundException(
|
||||
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);
|
||||
|
||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||
@@ -3304,12 +3327,33 @@ export class ExpertClaimService {
|
||||
);
|
||||
}
|
||||
|
||||
if (reply.carPrice && blame.type !== BlameRequestType.CAR_BODY) {
|
||||
throw new ForbiddenException("قیمت روز خودرو فقط در پرونده های بدنه باید بررسی شود")
|
||||
}
|
||||
const carPriceWasProvided = reply.carPrice != null;
|
||||
let normalizedCurrentCarPrice: string | undefined;
|
||||
|
||||
if (blame.type === BlameRequestType.THIRD_PARTY && reply.carPrice) {
|
||||
throw new ForbiddenException("قیمت روز خودرو فقط در پرونده های بدنه باید بررسی شود")
|
||||
if (blame.type === BlameRequestType.CAR_BODY) {
|
||||
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(
|
||||
@@ -3540,7 +3584,9 @@ export class ExpertClaimService {
|
||||
"evaluation.ownerInsurerApproval": "",
|
||||
"evaluation.ownerPricedPartsApproval": "",
|
||||
},
|
||||
"vehicle.price": reply.carPrice,
|
||||
...(normalizedCurrentCarPrice
|
||||
? { "vehicle.price": normalizedCurrentCarPrice }
|
||||
: {}),
|
||||
"workflow.currentStep": currentStep,
|
||||
"workflow.nextStep": nextWorkflowStep,
|
||||
[`evaluation.${replyField}`]: replyPayload,
|
||||
@@ -5050,6 +5096,10 @@ export class ExpertClaimService {
|
||||
buildFileLink,
|
||||
resolveStoredFileUrl,
|
||||
});
|
||||
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
|
||||
history: (claim.damage as any)?.partSelectionHistory,
|
||||
currentSelectedParts: selectedNormExpert,
|
||||
});
|
||||
|
||||
// Vehicle payload — fall back to blame inquiry if claim vehicle is sparse
|
||||
let vehiclePayload = claim.vehicle as any;
|
||||
@@ -5229,6 +5279,7 @@ export class ExpertClaimService {
|
||||
: undefined,
|
||||
carAngles,
|
||||
damagedParts,
|
||||
damagedPartsHistory,
|
||||
awaitingFactorValidation: isFactorValidationPending,
|
||||
requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval,
|
||||
fileMakerRejectionCount: (claim as any).fileMakerRejectionCount ?? 0,
|
||||
@@ -5606,33 +5657,58 @@ export class ExpertClaimService {
|
||||
);
|
||||
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> = {
|
||||
"damage.selectedParts": nextNorm,
|
||||
"media.damagedParts": nextMedia,
|
||||
"damage.expertAddedParts": mergedExpertAdded,
|
||||
};
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set,
|
||||
$push: {
|
||||
history: {
|
||||
type: "EXPERT_DAMAGED_PARTS_UPDATED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(actor.sub),
|
||||
actorName: actor.fullName,
|
||||
actorType: "damage_expert",
|
||||
},
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
previousSelectedParts: previous,
|
||||
selectedParts: nextNorm,
|
||||
expertAddedParts: mergedExpertAdded,
|
||||
...(damagedPartsEditSnapshot && {
|
||||
expertProfileSnapshot: damagedPartsEditSnapshot,
|
||||
}),
|
||||
},
|
||||
const push: Record<string, unknown> = {
|
||||
history: {
|
||||
type: "EXPERT_DAMAGED_PARTS_UPDATED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(actor.sub),
|
||||
actorName: actor.fullName,
|
||||
actorType: "damage_expert",
|
||||
},
|
||||
timestamp: changedAt,
|
||||
metadata: {
|
||||
previousSelectedParts: previous,
|
||||
selectedParts: nextNorm,
|
||||
expertAddedParts: mergedExpertAdded,
|
||||
...(partSelectionRevision && {
|
||||
partSelectionRevisionId: partSelectionRevision.revisionId,
|
||||
removedParts: partSelectionRevision.removedParts,
|
||||
addedParts: partSelectionRevision.addedParts,
|
||||
}),
|
||||
...(damagedPartsEditSnapshot && {
|
||||
expertProfileSnapshot: damagedPartsEditSnapshot,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
if (partSelectionRevision) {
|
||||
push["damage.partSelectionHistory"] = partSelectionRevision;
|
||||
}
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set,
|
||||
$push: push,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -5640,6 +5716,7 @@ export class ExpertClaimService {
|
||||
selectedParts: nextNorm,
|
||||
previousSelectedParts: previous,
|
||||
expertAddedParts: mergedExpertAdded,
|
||||
partSelectionRevision,
|
||||
message: "Damaged parts updated successfully.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
localizeTimelineMetadata,
|
||||
} from "./helper/timeline-fa-labels";
|
||||
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
|
||||
import { serializeDamagedPartSelectionHistory } from "src/helpers/claim-damaged-part-audit";
|
||||
|
||||
@Injectable()
|
||||
export class ExpertInsurerService {
|
||||
@@ -524,6 +525,8 @@ export class ExpertInsurerService {
|
||||
const d = { ...(damage as Record<string, unknown>) };
|
||||
delete d.selectedOuterParts;
|
||||
delete d.selectedPartIds;
|
||||
// Expose the stable, URL-enriched contract instead of raw audit storage.
|
||||
delete d.partSelectionHistory;
|
||||
return d;
|
||||
}
|
||||
|
||||
@@ -677,6 +680,10 @@ export class ExpertInsurerService {
|
||||
catalogLikeKeyFromPart,
|
||||
buildFileLink,
|
||||
});
|
||||
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
|
||||
history: (claim as any).damage?.partSelectionHistory,
|
||||
currentSelectedParts: selectedNorm,
|
||||
});
|
||||
|
||||
// Then add damagedParts to the return object
|
||||
const requiredDocs = claim.requiredDocuments as any;
|
||||
@@ -797,6 +804,7 @@ export class ExpertInsurerService {
|
||||
: undefined,
|
||||
carAngles,
|
||||
damagedParts,
|
||||
damagedPartsHistory,
|
||||
videoCapture,
|
||||
evaluation: evaluationEnriched,
|
||||
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);
|
||||
}
|
||||
|
||||
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 {
|
||||
return String(str ?? "")
|
||||
.toLowerCase()
|
||||
.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 {
|
||||
if (typeof input === "number" && Number.isFinite(input)) return input;
|
||||
return Number(
|
||||
@@ -191,6 +221,7 @@ export function buildPriceDropCatalogForApi(): Array<{
|
||||
export function resolvePriceDropPartKeyFromDamagePart(part: {
|
||||
name?: string;
|
||||
catalogKey?: string;
|
||||
label_fa?: string;
|
||||
}): string | null {
|
||||
const candidates: string[] = [];
|
||||
if (part.catalogKey) {
|
||||
@@ -205,6 +236,11 @@ export function resolvePriceDropPartKeyFromDamagePart(part: {
|
||||
const hit = NORM_TO_PART_KEY.get(norm);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -128,21 +128,21 @@ describe("getExpertReplyPricingValidationError", () => {
|
||||
|
||||
it.each([
|
||||
["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"],
|
||||
["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",
|
||||
(field, invalidValue, expectedField) => {
|
||||
const part = {
|
||||
partId: 201,
|
||||
typeOfDamage: TypeOfDamage.Change,
|
||||
price: "100000",
|
||||
salary: "100000",
|
||||
totalPayment: "100000",
|
||||
price: "1000000",
|
||||
salary: "1000000",
|
||||
totalPayment: "1000000",
|
||||
daghi: {
|
||||
option: DaghiOption.RECYCLED_PARTS_VALUE,
|
||||
price: "100000",
|
||||
price: "1000000",
|
||||
},
|
||||
};
|
||||
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", () => {
|
||||
const hit = resolveCatalogPartByPartId(201, ClaimVehicleTypeV2.HATCHBACK);
|
||||
expect(hit?.id).toBe(201);
|
||||
expect(hit?.side).toBe("left");
|
||||
expect(hit?.catalogKey).toBe("left_backfender");
|
||||
const hit = resolveCatalogPartByPartId(36, ClaimVehicleTypeV2.HATCHBACK);
|
||||
expect(hit?.id).toBe(36);
|
||||
expect(hit?.side).toBe("");
|
||||
expect(hit?.catalogKey).toBe("36");
|
||||
});
|
||||
|
||||
it("resolvePartForExpertReply uses catalog for new expert line", () => {
|
||||
const hit = resolvePartForExpertReply(
|
||||
201,
|
||||
36,
|
||||
[],
|
||||
ClaimVehicleTypeV2.HATCHBACK,
|
||||
);
|
||||
expect(hit?.id).toBe(201);
|
||||
expect(hit?.id).toBe(36);
|
||||
});
|
||||
|
||||
it("sanitize fixes Persian side and re-hydrates from catalog id", () => {
|
||||
const fixed = sanitizeDamageSelectedPartV2(
|
||||
{
|
||||
id: 201,
|
||||
name: "گلگیر عقب (چپ)",
|
||||
id: 36,
|
||||
name: "گلگير عقب سمت راننده",
|
||||
side: "چپ",
|
||||
label_fa: "",
|
||||
catalogKey: "چپ",
|
||||
},
|
||||
ClaimVehicleTypeV2.HATCHBACK,
|
||||
);
|
||||
expect(fixed.side).toBe("left");
|
||||
expect(fixed.name).toBe("backfender");
|
||||
expect(fixed.catalogKey).toBe("left_backfender");
|
||||
expect(catalogPartIdFromSelectedPart(fixed)).toBe(201);
|
||||
expect(fixed.side).toBe("");
|
||||
expect(fixed.name).toBe("36");
|
||||
expect(fixed.catalogKey).toBe("36");
|
||||
expect(catalogPartIdFromSelectedPart(fixed)).toBe(36);
|
||||
});
|
||||
|
||||
it("internal parts have null catalog partId", () => {
|
||||
@@ -90,30 +90,30 @@ describe("resolveSelectedPartByPartId", () => {
|
||||
const fixed = sanitizeDamageSelectedPartV2(
|
||||
{
|
||||
id: null,
|
||||
name: "گلگیر عقب (چپ)",
|
||||
name: "گلگير عقب سمت راننده",
|
||||
side: "internal",
|
||||
label_fa: "گلگیر عقب (چپ)",
|
||||
label_fa: "گلگير عقب سمت راننده",
|
||||
},
|
||||
ClaimVehicleTypeV2.HATCHBACK,
|
||||
);
|
||||
expect(fixed.id).toBe(201);
|
||||
expect(fixed.side).toBe("left");
|
||||
expect(fixed.name).toBe("backfender");
|
||||
expect(fixed.catalogKey).toBe("left_backfender");
|
||||
expect(fixed.id).toBe(36);
|
||||
expect(fixed.side).toBe("");
|
||||
expect(fixed.name).toBe("36");
|
||||
expect(fixed.catalogKey).toBe("36");
|
||||
});
|
||||
|
||||
it("price drop resolves catalog id when claim row is corrupt", () => {
|
||||
const corrupt: DamageSelectedPartV2[] = [
|
||||
{
|
||||
id: null,
|
||||
name: "گلگیر عقب (چپ)",
|
||||
name: "گلگير عقب سمت راننده",
|
||||
side: "internal",
|
||||
label_fa: "گلگیر عقب (چپ)",
|
||||
label_fa: "گلگير عقب سمت راننده",
|
||||
},
|
||||
];
|
||||
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
|
||||
corrupt,
|
||||
[{ partId: 201, severity: "Minor" }],
|
||||
[{ partId: 36, severity: "Minor" }],
|
||||
ClaimVehicleTypeV2.HATCHBACK,
|
||||
);
|
||||
expect(errors).toHaveLength(0);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ProfileController } from "./profile.controller";
|
||||
import { ProfileService } from "./profile.service";
|
||||
import { PlatesService } from "src/plates/plates.service";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
|
||||
describe("ProfileController", () => {
|
||||
let controller: ProfileController;
|
||||
@@ -8,7 +10,11 @@ describe("ProfileController", () => {
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ProfileController],
|
||||
providers: [ProfileService],
|
||||
providers: [
|
||||
{ provide: ProfileService, useValue: {} },
|
||||
{ provide: PlatesService, useValue: {} },
|
||||
{ provide: JwtService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<ProfileController>(ProfileController);
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ProfileService } from "./profile.service";
|
||||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||
|
||||
describe("ProfileService", () => {
|
||||
let service: ProfileService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ProfileService],
|
||||
providers: [
|
||||
ProfileService,
|
||||
{
|
||||
provide: UserDbService,
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
findOneAndUpdate: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
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 { PartyRole } from "./entities/schema/partyRole.enum";
|
||||
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", () => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Put,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
UploadRequiredDocumentV2ResponseDto,
|
||||
} from "src/claim-request-management/dto/upload-document-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.
|
||||
@@ -96,10 +98,13 @@ export class FileMakerBlameV4Controller {
|
||||
@Get("my-files")
|
||||
@ApiOperation({
|
||||
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) {
|
||||
return this.requestManagementService.getMyFileMakerFiles(fileMaker);
|
||||
async getMyFiles(
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Query() query: ListQueryV2Dto,
|
||||
) {
|
||||
return this.requestManagementService.getMyFileMakerFiles(fileMaker, query);
|
||||
}
|
||||
|
||||
@Get("my-files/:requestId")
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Put,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
UploadRequiredDocumentV2ResponseDto,
|
||||
} from "src/claim-request-management/dto/upload-document-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.
|
||||
@@ -95,10 +97,13 @@ export class FileMakerBlameV5Controller {
|
||||
@Get("my-files")
|
||||
@ApiOperation({
|
||||
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) {
|
||||
return this.requestManagementService.getMyFileMakerFiles(fileMaker);
|
||||
async getMyFiles(
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Query() query: ListQueryV2Dto,
|
||||
) {
|
||||
return this.requestManagementService.getMyFileMakerFiles(fileMaker, query);
|
||||
}
|
||||
|
||||
@Get("my-files/:requestId")
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
CapturePartV2Dto,
|
||||
CapturePartV2ResponseDto,
|
||||
} 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";
|
||||
|
||||
/**
|
||||
@@ -88,10 +90,16 @@ export class FileReviewerBlameV4Controller {
|
||||
@ApiOperation({
|
||||
summary: "List available and assigned FileMaker blame files",
|
||||
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) {
|
||||
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
|
||||
async getMyFiles(
|
||||
@CurrentUser() fileReviewer: any,
|
||||
@Query() query: ListQueryV2Dto,
|
||||
) {
|
||||
return this.requestManagementService.getMyFileReviewerFiles(
|
||||
fileReviewer,
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("my-files/:requestId")
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
CapturePartV2ResponseDto,
|
||||
} from "src/claim-request-management/dto/capture-part-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
|
||||
@@ -86,10 +88,16 @@ export class FileReviewerBlameV5Controller {
|
||||
@ApiOperation({
|
||||
summary: "List available and assigned FileMaker blame files",
|
||||
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) {
|
||||
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
|
||||
async getMyFiles(
|
||||
@CurrentUser() fileReviewer: any,
|
||||
@Query() query: ListQueryV2Dto,
|
||||
) {
|
||||
return this.requestManagementService.getMyFileReviewerFiles(
|
||||
fileReviewer,
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("my-files/:requestId")
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("inquiry participant resolver", () => {
|
||||
},
|
||||
vin: "NAAM01E15HK123456",
|
||||
}),
|
||||
).toThrow("previousPolicyholderNationalCode");
|
||||
).toThrow("کد ملی بیمهگذار قبلی");
|
||||
});
|
||||
|
||||
it("defaults an omitted registration state to CURRENT", () => {
|
||||
@@ -102,7 +102,7 @@ describe("inquiry participant resolver", () => {
|
||||
},
|
||||
vin: "TOO-SHORT",
|
||||
}),
|
||||
).toThrow("vehicle.vin must contain exactly 17 characters.");
|
||||
).toThrow("شماره شاسی (VIN) باید دقیقاً ۱۷ کاراکتر باشد.");
|
||||
});
|
||||
|
||||
it("rejects an incomplete current plate", () => {
|
||||
@@ -115,7 +115,7 @@ describe("inquiry participant resolver", () => {
|
||||
ir: "22",
|
||||
},
|
||||
}),
|
||||
).toThrow("vehicle.currentPlate.centerDigits is required.");
|
||||
).toThrow("سه رقم میانی پلاک در پلاک فعلی الزامی است.");
|
||||
});
|
||||
|
||||
it("rejects invalid vehicle choice values", () => {
|
||||
@@ -129,7 +129,7 @@ describe("inquiry participant resolver", () => {
|
||||
ir: "22",
|
||||
},
|
||||
}),
|
||||
).toThrow("vehicle.registrationState must be CURRENT or RECENTLY_TRANSFERRED.");
|
||||
).toThrow("وضعیت پلاک خودرو باید «فعلی» یا «تازه تعویضشده» باشد.");
|
||||
|
||||
expect(() =>
|
||||
resolveInquiryVehicle({
|
||||
@@ -141,7 +141,7 @@ describe("inquiry participant resolver", () => {
|
||||
},
|
||||
isNewCar: "false" as any,
|
||||
}),
|
||||
).toThrow("vehicle.isNewCar must be a boolean.");
|
||||
).toThrow("وضعیت صفر بودن خودرو نامعتبر است.");
|
||||
});
|
||||
|
||||
it("rejects a previous policyholder national code for a current registration", () => {
|
||||
@@ -647,7 +647,7 @@ describe("inquiry participant resolver", () => {
|
||||
|
||||
expect(() =>
|
||||
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", () => {
|
||||
|
||||
@@ -60,6 +60,20 @@ export interface InquirySubjects {
|
||||
driverNationalCode: string;
|
||||
}
|
||||
|
||||
const PARTICIPANT_ROLE_LABELS: Record<InquiryParticipantRole, string> = {
|
||||
[InquiryParticipantRole.DRIVER]: "راننده",
|
||||
[InquiryParticipantRole.VEHICLE_OWNER]: "مالک خودرو",
|
||||
[InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER]: "بیمهگذار شخص ثالث",
|
||||
[InquiryParticipantRole.CAR_BODY_POLICYHOLDER]: "بیمهگذار بدنه",
|
||||
};
|
||||
|
||||
const PLATE_FIELD_LABELS = {
|
||||
leftDigits: "دو رقم سمت چپ پلاک",
|
||||
centerAlphabet: "حرف پلاک",
|
||||
centerDigits: "سه رقم میانی پلاک",
|
||||
ir: "کد ایران پلاک",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The single routing seam for external inquiries. Policy checks belong to
|
||||
* their policyholder, Sheba belongs to the vehicle owner, and licence data
|
||||
@@ -70,7 +84,7 @@ export function resolveInquirySubjects(
|
||||
): InquirySubjects {
|
||||
if (!submission.vehicleOwner) {
|
||||
throw new BadRequestException(
|
||||
"Vehicle owner identity is required for Sheba validation.",
|
||||
"اطلاعات هویتی مالک خودرو برای استعلام شبا الزامی است.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
@@ -102,7 +116,11 @@ function assertCompleteInquiryPlate(
|
||||
"ir",
|
||||
] as const) {
|
||||
if (plate?.[field] == null || String(plate[field]).trim() === "") {
|
||||
throw new BadRequestException(`${path}.${field} is required.`);
|
||||
const plateLabel =
|
||||
path === "vehicle.currentPlate" ? "پلاک فعلی" : "پلاک قبلی";
|
||||
throw new BadRequestException(
|
||||
`${PLATE_FIELD_LABELS[field]} در ${plateLabel} الزامی است.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,21 +189,21 @@ function requiredIdentity(
|
||||
): ResolvedInquiryParticipant {
|
||||
if (Object.prototype.hasOwnProperty.call(input, "phoneNumber")) {
|
||||
throw new BadRequestException(
|
||||
`${role} does not accept phoneNumber; phone numbers are collected separately from inquiry identity.`,
|
||||
`شماره همراه ${PARTICIPANT_ROLE_LABELS[role]} باید جدا از اطلاعات هویتی استعلام ارسال شود.`,
|
||||
);
|
||||
}
|
||||
const nationalCode = String(input.nationalCode ?? "").trim();
|
||||
const birthday = String(input.birthday ?? "").trim();
|
||||
if (!nationalCode || !birthday) {
|
||||
throw new BadRequestException(
|
||||
`${role} requires nationalCode and birthday.`,
|
||||
`کد ملی و تاریخ تولد ${PARTICIPANT_ROLE_LABELS[role]} الزامی است.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
role === InquiryParticipantRole.DRIVER &&
|
||||
typeof input.hasDrivingLicense !== "boolean"
|
||||
) {
|
||||
throw new BadRequestException("DRIVER requires hasDrivingLicense.");
|
||||
throw new BadRequestException("وضعیت داشتن گواهینامه راننده الزامی است.");
|
||||
}
|
||||
if (
|
||||
role === InquiryParticipantRole.DRIVER &&
|
||||
@@ -194,7 +212,7 @@ function requiredIdentity(
|
||||
!String(input.licenseType ?? "").trim())
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"DRIVER requires licenseNumber and licenseType when hasDrivingLicense is true.",
|
||||
"شماره و نوع گواهینامه برای راننده دارای گواهینامه الزامی است.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
@@ -220,7 +238,7 @@ export function resolveInquiryParticipants(
|
||||
);
|
||||
if (!hasRoleCompleteInput) {
|
||||
throw new BadRequestException(
|
||||
"driver, vehicleOwner, and thirdPartyPolicyholder are required in the structured inquiry format.",
|
||||
"اطلاعات راننده، مالک خودرو و بیمهگذار شخص ثالث برای استعلام الزامی است.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -228,7 +246,7 @@ export function resolveInquiryParticipants(
|
||||
input.carBodyPolicyholder != null
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"CAR_BODY_POLICYHOLDER is not allowed for a THIRD_PARTY case.",
|
||||
"بیمهگذار بدنه برای پرونده شخص ثالث قابل ثبت نیست.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -249,19 +267,23 @@ export function resolveInquiryParticipants(
|
||||
if (existing) return existing;
|
||||
if (resolving.has(role)) {
|
||||
throw new BadRequestException(
|
||||
"Participant sameAs references cannot be circular.",
|
||||
"ارتباط اشخاص یکسان در اطلاعات استعلام نامعتبر است.",
|
||||
);
|
||||
}
|
||||
const value = input[ROLE_FIELDS[role]] as
|
||||
| InquiryParticipantInputDto
|
||||
| undefined;
|
||||
if (!value) throw new BadRequestException(`${role} is required.`);
|
||||
if (!value) {
|
||||
throw new BadRequestException(
|
||||
`اطلاعات ${PARTICIPANT_ROLE_LABELS[role]} الزامی است.`,
|
||||
);
|
||||
}
|
||||
|
||||
resolving.add(role);
|
||||
let participantId: string;
|
||||
if (Object.prototype.hasOwnProperty.call(value, "unknown")) {
|
||||
throw new BadRequestException(
|
||||
`${role} does not support the unknown option.`,
|
||||
`ثبت ${PARTICIPANT_ROLE_LABELS[role]} بهصورت نامشخص امکانپذیر نیست.`,
|
||||
);
|
||||
} else if (value.sameAs) {
|
||||
const hasPersonSpecificFields = Object.entries(value).some(
|
||||
@@ -269,7 +291,7 @@ export function resolveInquiryParticipants(
|
||||
);
|
||||
if (hasPersonSpecificFields) {
|
||||
throw new BadRequestException(
|
||||
`${role} must contain either sameAs or identity fields, not both.`,
|
||||
`برای ${PARTICIPANT_ROLE_LABELS[role]} باید فقط ارتباط با شخص دیگر یا اطلاعات هویتی مستقل ارسال شود.`,
|
||||
);
|
||||
}
|
||||
participantId = resolveRole(value.sameAs);
|
||||
@@ -280,7 +302,7 @@ export function resolveInquiryParticipants(
|
||||
);
|
||||
if (duplicate) {
|
||||
throw new BadRequestException(
|
||||
`${role} duplicates an existing nationalCode; use sameAs instead.`,
|
||||
`کد ملی ${PARTICIPANT_ROLE_LABELS[role]} تکراری است؛ ارتباط با شخص ثبتشده را انتخاب کنید.`,
|
||||
);
|
||||
}
|
||||
participants.set(participant.participantId, participant);
|
||||
@@ -317,29 +339,29 @@ export function resolveInquiryVehicle(
|
||||
input: InquiryVehicleInputDto,
|
||||
): ResolvedInquiryVehicle {
|
||||
if (!input) {
|
||||
throw new BadRequestException("vehicle is required.");
|
||||
throw new BadRequestException("اطلاعات خودرو برای استعلام الزامی است.");
|
||||
}
|
||||
const registrationState =
|
||||
input.registrationState ?? VehicleRegistrationState.CURRENT;
|
||||
if (!Object.values(VehicleRegistrationState).includes(registrationState)) {
|
||||
throw new BadRequestException(
|
||||
"vehicle.registrationState must be CURRENT or RECENTLY_TRANSFERRED.",
|
||||
"وضعیت پلاک خودرو باید «فعلی» یا «تازه تعویضشده» باشد.",
|
||||
);
|
||||
}
|
||||
if (input.isNewCar != null && typeof input.isNewCar !== "boolean") {
|
||||
throw new BadRequestException("vehicle.isNewCar must be a boolean.");
|
||||
throw new BadRequestException("وضعیت صفر بودن خودرو نامعتبر است.");
|
||||
}
|
||||
const previousPolicyholderNationalCode = String(
|
||||
input.previousPolicyholderNationalCode ?? "",
|
||||
).trim();
|
||||
if (!input.currentPlate) {
|
||||
throw new BadRequestException("vehicle.currentPlate is required.");
|
||||
throw new BadRequestException("پلاک فعلی خودرو برای استعلام الزامی است.");
|
||||
}
|
||||
assertCompleteInquiryPlate(input.currentPlate, "vehicle.currentPlate");
|
||||
const vin = String(input.vin ?? "").trim();
|
||||
if (vin && vin.length !== 17) {
|
||||
throw new BadRequestException(
|
||||
"vehicle.vin must contain exactly 17 characters.",
|
||||
"شماره شاسی (VIN) باید دقیقاً ۱۷ کاراکتر باشد.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -347,7 +369,7 @@ export function resolveInquiryVehicle(
|
||||
(!input.previousPlate || !vin || !previousPolicyholderNationalCode)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"RECENTLY_TRANSFERRED requires previousPlate, vin, and previousPolicyholderNationalCode.",
|
||||
"برای خودروی تازه تعویضپلاکشده، پلاک قبلی، شماره شاسی (VIN) و کد ملی بیمهگذار قبلی الزامی است.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -355,7 +377,7 @@ export function resolveInquiryVehicle(
|
||||
(input.previousPlate || input.previousPolicyholderNationalCode != null)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"previousPlate and previousPolicyholderNationalCode are only allowed for RECENTLY_TRANSFERRED vehicles.",
|
||||
"پلاک و کد ملی بیمهگذار قبلی فقط برای خودروی تازه تعویضپلاکشده قابل ثبت است.",
|
||||
);
|
||||
}
|
||||
if (input.previousPlate) {
|
||||
@@ -442,7 +464,7 @@ function assertStructuredInquiryInput(input: Record<string, any>): void {
|
||||
);
|
||||
if (legacyFields.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Legacy inquiry fields are not accepted: ${legacyFields.join(", ")}. Use driver, vehicleOwner, thirdPartyPolicyholder, carBodyPolicyholder, and vehicle.`,
|
||||
"ساختار قدیمی اطلاعات استعلام پذیرفته نمیشود؛ اطلاعات اشخاص و خودرو را در بخشهای جدید ارسال کنید.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -466,7 +488,7 @@ export function assertPreviousPlateInquiryMatchesVin(
|
||||
.filter(Boolean);
|
||||
if (!expected || !candidates.includes(expected)) {
|
||||
throw new BadRequestException(
|
||||
"Previous-plate inquiry does not match the submitted VIN/chassis; manual review is required.",
|
||||
"نتیجه استعلام پلاک قبلی با شماره شاسی (VIN) واردشده مطابقت ندارد و پرونده نیازمند بررسی دستی است.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -540,7 +562,7 @@ export async function runPlateInquiryWithFallback<T>(options: {
|
||||
});
|
||||
if (!isLast) continue;
|
||||
const error = new BadRequestException(
|
||||
"No current usable policy was found for the submitted vehicle identifiers.",
|
||||
"بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
|
||||
) as BadRequestException & { attempts?: typeof attempts };
|
||||
error.attempts = attempts;
|
||||
throw error;
|
||||
@@ -592,7 +614,12 @@ export async function runPlateInquiryWithFallback<T>(options: {
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new BadRequestException("Inquiry failed for all plates.");
|
||||
throw (
|
||||
lastError ??
|
||||
new BadRequestException(
|
||||
"برای هیچیک از پلاکهای ثبتشده نتیجه معتبری یافت نشد.",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeInquirySubmission<T extends Record<string, any>>(
|
||||
@@ -610,7 +637,7 @@ export function normalizeInquirySubmission<T extends Record<string, any>>(
|
||||
);
|
||||
if (!driver || !thirdPartyPolicyholder) {
|
||||
throw new BadRequestException(
|
||||
"Driver and third-party policyholder identities are required.",
|
||||
"اطلاعات هویتی راننده و بیمهگذار شخص ثالث الزامی است.",
|
||||
);
|
||||
}
|
||||
const vehicleOwner = participantForRole(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ReinquiryInquiriesResponseDto,
|
||||
ReinquiryPartyResultDto,
|
||||
} from "./dto/reinquiry-inquiries.dto";
|
||||
import { getInquiryErrorMessage } from "src/common/utils/inquiry-error";
|
||||
|
||||
type PlateParts = {
|
||||
leftDigits: number;
|
||||
@@ -48,7 +49,7 @@ export class InquiryRefreshService {
|
||||
|
||||
if (!body.publicId && !body.blameRequestId && limit === 0) {
|
||||
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.blameRequestId) {
|
||||
if (!Types.ObjectId.isValid(body.blameRequestId)) {
|
||||
throw new BadRequestException("Invalid blameRequestId");
|
||||
throw new BadRequestException("شناسه پرونده معتبر نیست.");
|
||||
}
|
||||
filter._id = new Types.ObjectId(body.blameRequestId);
|
||||
}
|
||||
|
||||
let docs = await this.blameRequestDbService.find(filter, { lean: true });
|
||||
if (!docs.length) {
|
||||
throw new NotFoundException("No matching blame cases found");
|
||||
throw new NotFoundException(
|
||||
"پرونده تقصیر مطابق اطلاعات واردشده یافت نشد.",
|
||||
);
|
||||
}
|
||||
docs = limit > 0 ? docs.slice(0, limit) : docs;
|
||||
|
||||
@@ -111,8 +114,8 @@ export class InquiryRefreshService {
|
||||
if (index === -1) {
|
||||
partyResults.push({
|
||||
role,
|
||||
thirdParty: { ok: false, message: "party not found" },
|
||||
person: { ok: false, message: "party not found" },
|
||||
thirdParty: { ok: false, message: "طرف پرونده یافت نشد." },
|
||||
person: { ok: false, message: "طرف پرونده یافت نشد." },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -150,7 +153,9 @@ export class InquiryRefreshService {
|
||||
blameRequestId: new Types.ObjectId(String(doc._id)),
|
||||
});
|
||||
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 {
|
||||
@@ -208,9 +213,7 @@ export class InquiryRefreshService {
|
||||
plateId: party?.vehicle?.plateId,
|
||||
...(plate ? { plate } : {}),
|
||||
...(nationalCode ? { nationalCode } : {}),
|
||||
...(birthDate !== null && birthDate !== undefined
|
||||
? { birthDate }
|
||||
: {}),
|
||||
...(birthDate !== null && birthDate !== undefined ? { birthDate } : {}),
|
||||
};
|
||||
|
||||
if (dryRun) {
|
||||
@@ -223,8 +226,8 @@ export class InquiryRefreshService {
|
||||
result.thirdParty = {
|
||||
ok: false,
|
||||
message: !plate
|
||||
? "plate not found on party"
|
||||
: "nationalCodeOfInsurer/nationalCodeOfDriver missing",
|
||||
? "پلاک برای این طرف پرونده ثبت نشده است."
|
||||
: "کد ملی برای این طرف پرونده ثبت نشده است.",
|
||||
};
|
||||
} else {
|
||||
await this.waitForRateLimit();
|
||||
@@ -249,7 +252,9 @@ export class InquiryRefreshService {
|
||||
inquiriesChanged = true;
|
||||
result.thirdParty = {
|
||||
ok: false,
|
||||
message: inquiry.mapped.Error.Message || "third-party inquiry error",
|
||||
message:
|
||||
inquiry.mapped.Error.Message ||
|
||||
getInquiryErrorMessage(inquiry.mapped, "thirdPartyPlate"),
|
||||
};
|
||||
} else {
|
||||
nextParty = this.applyThirdPartyToParty(
|
||||
@@ -292,11 +297,18 @@ export class InquiryRefreshService {
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.recordPartyInquiry(inquiries, "thirdParty", role, false, {}, error);
|
||||
this.recordPartyInquiry(
|
||||
inquiries,
|
||||
"thirdParty",
|
||||
role,
|
||||
false,
|
||||
{},
|
||||
error,
|
||||
);
|
||||
inquiriesChanged = true;
|
||||
result.thirdParty = {
|
||||
ok: false,
|
||||
message: error?.message || String(error),
|
||||
message: getInquiryErrorMessage(error, "thirdPartyPlate"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -305,8 +317,8 @@ export class InquiryRefreshService {
|
||||
result.person = {
|
||||
ok: false,
|
||||
message: !nationalCode
|
||||
? "nationalCodeOfInsurer/nationalCodeOfDriver missing"
|
||||
: "insurerBirthday/driverBirthday missing",
|
||||
? "کد ملی برای این طرف پرونده ثبت نشده است."
|
||||
: "تاریخ تولد برای این طرف پرونده ثبت نشده است.",
|
||||
};
|
||||
} else {
|
||||
await this.waitForRateLimit();
|
||||
@@ -333,7 +345,7 @@ export class InquiryRefreshService {
|
||||
inquiriesChanged = true;
|
||||
result.person = {
|
||||
ok: false,
|
||||
message: error?.message || String(error),
|
||||
message: getInquiryErrorMessage(error, "personalIdentity"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -477,12 +489,14 @@ export class InquiryRefreshService {
|
||||
): Promise<void> {
|
||||
const blameDoc = await this.blameRequestDbService.findById(blameId);
|
||||
if (!blameDoc) {
|
||||
throw new NotFoundException(`Blame case ${blameId} not found`);
|
||||
throw new NotFoundException("پرونده تقصیر یافت نشد.");
|
||||
}
|
||||
|
||||
for (const role of roles) {
|
||||
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;
|
||||
|
||||
const party = blameDoc.parties[docIdx];
|
||||
@@ -514,7 +528,9 @@ export class InquiryRefreshService {
|
||||
party.insurance.company = memParty.insurance.company;
|
||||
}
|
||||
if (memParty.insurance.financialCeiling !== undefined) {
|
||||
party.insurance.financialCeiling = String(memParty.insurance.financialCeiling);
|
||||
party.insurance.financialCeiling = String(
|
||||
memParty.insurance.financialCeiling,
|
||||
);
|
||||
}
|
||||
if (memParty.insurance.startDate !== undefined) {
|
||||
party.insurance.startDate = memParty.insurance.startDate;
|
||||
@@ -541,7 +557,8 @@ export class InquiryRefreshService {
|
||||
});
|
||||
|
||||
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 (!Object.keys(inquiryPatch).length) return 0;
|
||||
|
||||
@@ -590,14 +607,16 @@ export class InquiryRefreshService {
|
||||
|
||||
private normalizeInquiryError(error: any): Record<string, unknown> {
|
||||
return {
|
||||
message: error?.message || String(error),
|
||||
message: getInquiryErrorMessage(error, "generic"),
|
||||
status: error?.status ?? error?.response?.status,
|
||||
data: error?.data ?? error?.response?.data,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const candidates = [
|
||||
@@ -608,14 +627,24 @@ export class InquiryRefreshService {
|
||||
].filter(Boolean);
|
||||
|
||||
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(
|
||||
candidate.plateLetterid,
|
||||
candidate.plateLetterId,
|
||||
candidate.plateLetterTitle,
|
||||
);
|
||||
const centerDigits = this.firstPresent(candidate.Plk3, candidate.platePartThree);
|
||||
const ir = this.firstPresent(candidate.PlkSrl, candidate.plkSrl, candidate.plateSerialNumber);
|
||||
const centerDigits = this.firstPresent(
|
||||
candidate.Plk3,
|
||||
candidate.platePartThree,
|
||||
);
|
||||
const ir = this.firstPresent(
|
||||
candidate.PlkSrl,
|
||||
candidate.plkSrl,
|
||||
candidate.plateSerialNumber,
|
||||
);
|
||||
|
||||
if (
|
||||
leftDigits !== undefined &&
|
||||
@@ -623,7 +652,9 @@ export class InquiryRefreshService {
|
||||
centerDigits !== undefined &&
|
||||
ir !== undefined
|
||||
) {
|
||||
const plateLetter = this.plateNormalizer.normalizePlateText(String(centerAlphabet));
|
||||
const plateLetter = this.plateNormalizer.normalizePlateText(
|
||||
String(centerAlphabet),
|
||||
);
|
||||
const parsed: PlateParts = {
|
||||
leftDigits: Number(leftDigits),
|
||||
centerAlphabet: plateLetter,
|
||||
@@ -653,7 +684,9 @@ export class InquiryRefreshService {
|
||||
const ir = Number(irRaw);
|
||||
const leftDigits = Number(leftRaw);
|
||||
const centerDigits = Number(centerRaw);
|
||||
const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || ""));
|
||||
const centerAlphabet = this.plateNormalizer.normalizePlateText(
|
||||
String(alphaRaw || ""),
|
||||
);
|
||||
if (
|
||||
!Number.isFinite(ir) ||
|
||||
!Number.isFinite(leftDigits) ||
|
||||
@@ -685,6 +718,8 @@ export class InquiryRefreshService {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
await expect(
|
||||
(service as any).validateShebaV3(
|
||||
undefined,
|
||||
"0012345678",
|
||||
"client-id",
|
||||
),
|
||||
).rejects.toThrow("sheba is required for the damaged party.");
|
||||
(service as any).validateShebaV3(undefined, "0012345678", "client-id"),
|
||||
).rejects.toThrow("شماره شبا برای طرف زیاندیده الزامی است.");
|
||||
expect(
|
||||
(service as any).sandHubService.getShebaValidation,
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
||||
publicId: "BLM-OPEN",
|
||||
type: "THIRD_PARTY",
|
||||
status: "WAITING_FOR_FILE_REVIEWER",
|
||||
createdAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
isMadeByFileMaker: true,
|
||||
expertInitiated: true,
|
||||
creationMethod: "IN_PERSON",
|
||||
@@ -34,6 +35,9 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
||||
undefined,
|
||||
blameRequestDbService,
|
||||
) as RequestManagementService;
|
||||
(service as any).claimCaseDbService = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
return { service, blameRequestDbService };
|
||||
}
|
||||
|
||||
@@ -46,7 +50,7 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
||||
clientKey: String(clientId),
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result.list).toEqual([
|
||||
expect.objectContaining({ _id: sealedFile._id, publicId: "BLM-OPEN" }),
|
||||
]);
|
||||
expect(blameRequestDbService.find).toHaveBeenCalledWith(
|
||||
@@ -82,7 +86,34 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
||||
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 () => {
|
||||
|
||||
@@ -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 { isOtpExpiryActive } from "src/helpers/user-otp-expiry";
|
||||
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 { 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 { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||
import {
|
||||
@@ -138,6 +143,9 @@ import {
|
||||
runPlateInquiryWithFallback,
|
||||
sanitizeStoredInquiryParticipants,
|
||||
} 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.
|
||||
@@ -154,6 +162,18 @@ function formatJalaliCompact(
|
||||
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()
|
||||
export class RequestManagementService {
|
||||
private readonly logger = new Logger(RequestManagementService.name);
|
||||
@@ -183,10 +203,16 @@ export class RequestManagementService {
|
||||
}
|
||||
|
||||
/** Keep eligibility rejections distinct from an unavailable CAR_BODY provider. */
|
||||
private throwCarBodyInquiryFailure(err: unknown): never {
|
||||
if (err instanceof ForbiddenException) throw err;
|
||||
private throwCarBodyInquiryFailure(
|
||||
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);
|
||||
if (!Number.isFinite(configuredCode)) {
|
||||
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;
|
||||
if (!clientId) {
|
||||
throw new InternalServerErrorException(
|
||||
"Configured CAR_BODY insurer client could not be resolved.",
|
||||
"شرکت بیمه تنظیمشده برای بیمهنامه بدنه قابل شناسایی نیست.",
|
||||
);
|
||||
}
|
||||
return clientId;
|
||||
@@ -394,7 +420,7 @@ export class RequestManagementService {
|
||||
const policyholderNationalCode = subjects.carBodyPolicyNationalCode;
|
||||
if (!policyholderNationalCode) {
|
||||
throw new BadRequestException(
|
||||
"Car-body policyholder identity is required for a CAR_BODY inquiry.",
|
||||
"اطلاعات بیمهگذار برای استعلام بیمه بدنه الزامی است.",
|
||||
);
|
||||
}
|
||||
const result = await runPlateInquiryWithFallback({
|
||||
@@ -434,7 +460,7 @@ export class RequestManagementService {
|
||||
).trim();
|
||||
if (!chassis) {
|
||||
throw new BadRequestException(
|
||||
"vehicle.vin is required for a VIN/chassis inquiry.",
|
||||
"شماره شاسی (VIN) برای استعلام خودرو الزامی است.",
|
||||
);
|
||||
}
|
||||
const subjects = resolveInquirySubjects(submission);
|
||||
@@ -462,7 +488,7 @@ export class RequestManagementService {
|
||||
for (const participant of participants) {
|
||||
if (!participant.nationalCode || !participant.birthday) {
|
||||
throw new BadRequestException(
|
||||
`${participant.participantId} requires nationalCode and birthday for personal inquiry.`,
|
||||
"کد ملی و تاریخ تولد برای استعلام هویت الزامی است.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
@@ -486,7 +512,7 @@ export class RequestManagementService {
|
||||
);
|
||||
await this.persistBlameInquiryAudit(req);
|
||||
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;
|
||||
if (!plate) {
|
||||
throw new BadRequestException(
|
||||
"Current plate is required for vehicle ownership inquiry.",
|
||||
"پلاک فعلی برای استعلام مالکیت خودرو الزامی است.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
@@ -536,7 +562,7 @@ export class RequestManagementService {
|
||||
);
|
||||
await this.persistBlameInquiryAudit(req);
|
||||
throw new BadRequestException(
|
||||
`Vehicle ownership inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`,
|
||||
getInquiryErrorMessage(error, "carOwnership"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -558,7 +584,7 @@ export class RequestManagementService {
|
||||
}
|
||||
if (!submission.driver.licenseNumber) {
|
||||
throw new BadRequestException(
|
||||
"Driver licence number is required when the driver has a licence.",
|
||||
"شماره گواهینامه راننده برای استعلام گواهینامه الزامی است.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
@@ -582,7 +608,7 @@ export class RequestManagementService {
|
||||
);
|
||||
await this.persistBlameInquiryAudit(req);
|
||||
throw new BadRequestException(
|
||||
`Driver licence inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`,
|
||||
getInquiryErrorMessage(error, "drivingLicense"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -683,7 +709,7 @@ export class RequestManagementService {
|
||||
private normalizeInquiryError(err: any): any {
|
||||
if (!err) return undefined;
|
||||
return {
|
||||
message: err?.message || String(err),
|
||||
message: getInquiryErrorMessage(err, "generic"),
|
||||
status: err?.response?.status,
|
||||
data: err?.response?.data,
|
||||
...(Array.isArray(err?.attempts) ? { attempts: err.attempts } : {}),
|
||||
@@ -1745,7 +1771,7 @@ export class RequestManagementService {
|
||||
body.insurerLicense === body.driverLicense)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Insurer and Driver should be two different persons in this mode.",
|
||||
"در این حالت، بیمهگذار و راننده باید دو شخص متفاوت باشند.",
|
||||
);
|
||||
}
|
||||
} else if (body.driverIsInsurer === true) {
|
||||
@@ -1757,7 +1783,7 @@ export class RequestManagementService {
|
||||
String(body.driverBirthday) === String(body.insurerBirthday);
|
||||
if (!sameNat || !sameLic || !sameBirthday) {
|
||||
throw new BadRequestException(
|
||||
"When driverIsInsurer is true, insurer and driver data must be the same.",
|
||||
"وقتی راننده همان بیمهگذار است، اطلاعات راننده و بیمهگذار باید یکسان باشد.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1831,7 +1857,10 @@ export class RequestManagementService {
|
||||
err,
|
||||
);
|
||||
await this.persistBlameInquiryAudit(req);
|
||||
throw new HttpException("Inquiry failed", HttpStatus.BAD_REQUEST);
|
||||
throw new HttpException(
|
||||
getInquiryErrorMessage(err, "thirdPartyPlate"),
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
if (inquiryMapped?.Error) {
|
||||
@@ -1847,7 +1876,8 @@ export class RequestManagementService {
|
||||
});
|
||||
await this.persistBlameInquiryAudit(req);
|
||||
throw new HttpException(
|
||||
inquiryMapped.Error.Message || "Inquiry returned error",
|
||||
inquiryMapped.Error.Message ||
|
||||
getInquiryErrorMessage(inquiryMapped, "thirdPartyPlate"),
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
@@ -1875,7 +1905,7 @@ export class RequestManagementService {
|
||||
const clientName = inquiryMapped?.CompanyName;
|
||||
if (!clientName) {
|
||||
const error = new BadRequestException(
|
||||
`CompanyName missing from inquiry response`,
|
||||
"پاسخ استعلام بیمهنامه ناقص است و نام شرکت بیمه در آن وجود ندارد.",
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(
|
||||
req,
|
||||
@@ -1903,7 +1933,7 @@ export class RequestManagementService {
|
||||
: null;
|
||||
if (clientName && !client) {
|
||||
const error = new BadRequestException(
|
||||
`CompanyCode missing or invalid in inquiry response`,
|
||||
"پاسخ استعلام بیمهنامه ناقص است و شرکت بیمه قابل شناسایی نیست.",
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(
|
||||
req,
|
||||
@@ -2188,7 +2218,7 @@ export class RequestManagementService {
|
||||
body.insurerLicense === body.driverLicense)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Insurer and Driver should be two different persons in this mode.",
|
||||
"در این حالت، بیمهگذار و راننده باید دو شخص متفاوت باشند.",
|
||||
);
|
||||
}
|
||||
} else if (body.driverIsInsurer === true) {
|
||||
@@ -2200,7 +2230,7 @@ export class RequestManagementService {
|
||||
String(body.driverBirthday) === String(body.insurerBirthday);
|
||||
if (!sameNat || !sameLic || !sameBirthday) {
|
||||
throw new BadRequestException(
|
||||
"When driverIsInsurer is true, insurer and driver data must be the same.",
|
||||
"وقتی راننده همان بیمهگذار است، اطلاعات راننده و بیمهگذار باید یکسان باشد.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2261,7 +2291,10 @@ export class RequestManagementService {
|
||||
err,
|
||||
);
|
||||
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) {
|
||||
@@ -2275,7 +2308,8 @@ export class RequestManagementService {
|
||||
});
|
||||
await this.persistBlameInquiryAudit(req);
|
||||
throw new HttpException(
|
||||
inquiryMapped.Error.Message || "VIN inquiry returned error",
|
||||
inquiryMapped.Error.Message ||
|
||||
getInquiryErrorMessage(inquiryMapped, "thirdPartyVin"),
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
@@ -2303,7 +2337,7 @@ export class RequestManagementService {
|
||||
const clientName = inquiryMapped?.CompanyName;
|
||||
if (!clientName) {
|
||||
const error = new BadRequestException(
|
||||
"CompanyName missing from VIN inquiry response",
|
||||
"پاسخ استعلام VIN ناقص است و نام شرکت بیمه در آن وجود ندارد.",
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(
|
||||
req,
|
||||
@@ -2329,7 +2363,7 @@ export class RequestManagementService {
|
||||
: null;
|
||||
if (clientName && !client) {
|
||||
const error = new BadRequestException(
|
||||
"CompanyCode missing or invalid in VIN inquiry response",
|
||||
"پاسخ استعلام VIN ناقص است و شرکت بیمه قابل شناسایی نیست.",
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(
|
||||
req,
|
||||
@@ -2459,7 +2493,7 @@ export class RequestManagementService {
|
||||
err,
|
||||
);
|
||||
await this.persistBlameInquiryAudit(req);
|
||||
this.throwCarBodyInquiryFailure(err);
|
||||
this.throwCarBodyInquiryFailure(err, "carBodyVin");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3472,7 +3506,10 @@ export class RequestManagementService {
|
||||
: null;
|
||||
|
||||
if (!client) {
|
||||
throw new HttpException("Client not found", HttpStatus.CONFLICT);
|
||||
throw new HttpException(
|
||||
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
const partyDetails =
|
||||
@@ -3642,7 +3679,7 @@ export class RequestManagementService {
|
||||
this.logger.error(er);
|
||||
if (er instanceof HttpException) throw er;
|
||||
throw new InternalServerErrorException(
|
||||
"Failed to update request with plate details.",
|
||||
"ذخیره اطلاعات پلاک و بیمهنامه انجام نشد.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3670,7 +3707,7 @@ export class RequestManagementService {
|
||||
body.driverIsInsurer === false
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Insurer and Driver should be two different persons in this mode.",
|
||||
"در این حالت، بیمهگذار و راننده باید دو شخص متفاوت باشند.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3703,7 +3740,7 @@ export class RequestManagementService {
|
||||
|
||||
if (isSameNationalCode || isSamePlate) {
|
||||
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 isInitiator =
|
||||
(user?.role === RoleEnum.FIELD_EXPERT &&
|
||||
@@ -6487,17 +6538,44 @@ export class RequestManagementService {
|
||||
...obj,
|
||||
userSide: party?.role ?? null,
|
||||
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(
|
||||
enriched,
|
||||
filtered,
|
||||
{
|
||||
publicId: (r) => String((r as { publicId?: string }).publicId ?? ""),
|
||||
createdAt: (r) => (r as { createdAt?: Date }).createdAt,
|
||||
requestNo: (r) =>
|
||||
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) => {
|
||||
const row = r as {
|
||||
blameStatus?: string;
|
||||
@@ -7004,8 +7082,9 @@ export class RequestManagementService {
|
||||
e,
|
||||
);
|
||||
await (req as any).save();
|
||||
throw new InternalServerErrorException(
|
||||
"Failed to process plate information.",
|
||||
if (e instanceof HttpException) throw e;
|
||||
throw new BadRequestException(
|
||||
getInquiryErrorMessage(e, "thirdPartyPlate"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7020,7 +7099,7 @@ export class RequestManagementService {
|
||||
: await this.clientService.findOne({ clientName });
|
||||
if (!client) {
|
||||
const error = new NotFoundException(
|
||||
`Client not found for company: ${clientName}`,
|
||||
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(
|
||||
req,
|
||||
@@ -7345,7 +7424,7 @@ export class RequestManagementService {
|
||||
: null;
|
||||
if (!client) {
|
||||
const error = new NotFoundException(
|
||||
`Client not found for company: ${clientName}`,
|
||||
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(
|
||||
req,
|
||||
@@ -8104,7 +8183,7 @@ export class RequestManagementService {
|
||||
|
||||
if (!client) {
|
||||
const error = new NotFoundException(
|
||||
`Client not found for company: ${clientName}`,
|
||||
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
|
||||
);
|
||||
await this.persistLegacyInquiryAudit(
|
||||
requestId,
|
||||
@@ -8188,8 +8267,8 @@ export class RequestManagementService {
|
||||
);
|
||||
this.logger.error("Error processing first party plate:", plateError);
|
||||
if (plateError instanceof HttpException) throw plateError;
|
||||
throw new InternalServerErrorException(
|
||||
"Failed to process first party plate information",
|
||||
throw new BadRequestException(
|
||||
getInquiryErrorMessage(plateError, "thirdPartyPlate"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8241,7 +8320,7 @@ export class RequestManagementService {
|
||||
|
||||
if (!client) {
|
||||
const error = new NotFoundException(
|
||||
`Client not found for company: ${clientName}${companyCode ? ` (code: ${companyCode})` : ""}`,
|
||||
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
|
||||
);
|
||||
await this.persistLegacyInquiryAudit(
|
||||
requestId,
|
||||
@@ -8326,8 +8405,9 @@ export class RequestManagementService {
|
||||
},
|
||||
);
|
||||
this.logger.error("Error processing second party plate:", plateError);
|
||||
throw new InternalServerErrorException(
|
||||
"Failed to process second party plate information",
|
||||
if (plateError instanceof HttpException) throw plateError;
|
||||
throw new BadRequestException(
|
||||
getInquiryErrorMessage(plateError, "thirdPartyPlate"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8584,7 +8664,7 @@ export class RequestManagementService {
|
||||
|
||||
if (!client) {
|
||||
const error = new NotFoundException(
|
||||
`Client not found for company: ${clientName}`,
|
||||
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
|
||||
);
|
||||
await this.persistLegacyInquiryAudit(
|
||||
requestId,
|
||||
@@ -8710,9 +8790,8 @@ export class RequestManagementService {
|
||||
},
|
||||
);
|
||||
this.logger.error("Error processing first party plate:", plateError);
|
||||
throw new InternalServerErrorException(
|
||||
"Failed to process first party plate information",
|
||||
);
|
||||
if (plateError instanceof HttpException) throw plateError;
|
||||
throw new BadRequestException(getInquiryErrorMessage(plateError));
|
||||
}
|
||||
|
||||
// For CAR_BODY: Create expertSubmitReply
|
||||
@@ -9850,14 +9929,14 @@ export class RequestManagementService {
|
||||
);
|
||||
await this.persistBlameInquiryAudit(req);
|
||||
throw new BadRequestException(
|
||||
`${roleLabel} party plate inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
|
||||
getInquiryErrorMessage(err, "thirdPartyPlate"),
|
||||
);
|
||||
}
|
||||
|
||||
if (inquiryMapped?.Error) {
|
||||
const error = new BadRequestException(
|
||||
inquiryMapped.Error.Message ||
|
||||
`${roleLabel} party plate inquiry returned an error`,
|
||||
getInquiryErrorMessage(inquiryMapped, "thirdPartyPlate"),
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(
|
||||
req,
|
||||
@@ -9881,7 +9960,7 @@ export class RequestManagementService {
|
||||
const companyCode = inquiryMapped?.CompanyCode;
|
||||
if (!clientName) {
|
||||
const error = new BadRequestException(
|
||||
`CompanyName missing from ${roleLabel} party inquiry response`,
|
||||
"پاسخ استعلام بیمهنامه ناقص است و نام شرکت بیمه در آن وجود ندارد.",
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(
|
||||
req,
|
||||
@@ -9908,7 +9987,7 @@ export class RequestManagementService {
|
||||
: null;
|
||||
if (clientName && !client) {
|
||||
const error = new BadRequestException(
|
||||
`CompanyCode missing or invalid in ${roleLabel} party inquiry response`,
|
||||
"پاسخ استعلام بیمهنامه ناقص است و شرکت بیمه قابل شناسایی نیست.",
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(
|
||||
req,
|
||||
@@ -10128,7 +10207,7 @@ export class RequestManagementService {
|
||||
);
|
||||
await this.persistBlameInquiryAudit(req);
|
||||
throw new BadRequestException(
|
||||
`${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
|
||||
getInquiryErrorMessage(err, "drivingLicense"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10143,7 +10222,7 @@ export class RequestManagementService {
|
||||
): Promise<void> {
|
||||
if (!String(sheba ?? "").trim()) {
|
||||
throw new BadRequestException(
|
||||
"sheba is required for the damaged party.",
|
||||
"شماره شبا برای طرف زیاندیده الزامی است.",
|
||||
);
|
||||
}
|
||||
await this.sandHubService.getShebaValidation(
|
||||
@@ -10764,14 +10843,14 @@ export class RequestManagementService {
|
||||
);
|
||||
await this.persistBlameInquiryAudit(req);
|
||||
throw new BadRequestException(
|
||||
`${roleLabel} party VIN inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
|
||||
getInquiryErrorMessage(err, "thirdPartyVin"),
|
||||
);
|
||||
}
|
||||
|
||||
if (inquiryMapped?.Error) {
|
||||
const error = new BadRequestException(
|
||||
inquiryMapped.Error.Message ||
|
||||
`${roleLabel} party VIN inquiry returned an error`,
|
||||
getInquiryErrorMessage(inquiryMapped, "thirdPartyVin"),
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(
|
||||
req,
|
||||
@@ -10793,7 +10872,7 @@ export class RequestManagementService {
|
||||
const companyCode = inquiryMapped?.CompanyCode;
|
||||
if (!clientName) {
|
||||
const error = new BadRequestException(
|
||||
`CompanyName missing from ${roleLabel} party VIN inquiry response`,
|
||||
"پاسخ استعلام VIN ناقص است و نام شرکت بیمه در آن وجود ندارد.",
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(
|
||||
req,
|
||||
@@ -10818,7 +10897,7 @@ export class RequestManagementService {
|
||||
: null;
|
||||
if (clientName && !client) {
|
||||
const error = new BadRequestException(
|
||||
`CompanyCode missing or invalid in ${roleLabel} party VIN inquiry response`,
|
||||
"پاسخ استعلام VIN ناقص است و شرکت بیمه قابل شناسایی نیست.",
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(
|
||||
req,
|
||||
@@ -10970,7 +11049,7 @@ export class RequestManagementService {
|
||||
err,
|
||||
);
|
||||
await this.persistBlameInquiryAudit(req);
|
||||
this.throwCarBodyInquiryFailure(err);
|
||||
this.throwCarBodyInquiryFailure(err, "carBodyVin");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11045,7 +11124,7 @@ export class RequestManagementService {
|
||||
);
|
||||
await this.persistBlameInquiryAudit(req);
|
||||
throw new BadRequestException(
|
||||
`${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
|
||||
getInquiryErrorMessage(err, "drivingLicense"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12756,27 +12835,124 @@ export class RequestManagementService {
|
||||
return { ...workflow, completedSteps };
|
||||
}
|
||||
|
||||
// /**
|
||||
// * V4/V5 dirty bridge: FileMaker FE resumes from blame `status`, but pre-capture
|
||||
// * document upload lives on the claim (`UPLOADING_REQUIRED_DOCUMENTS`) while blame
|
||||
// * is still at FIRST/SECOND_COMPLETED. Mirror claim status into `status` only for
|
||||
// * that phase so leave/re-enter can continue; keep real blame status as
|
||||
// * `blameCaseStatus`. Remove once FE keys off `claimStatus` / a unified resume pointer.
|
||||
// */
|
||||
// private fileMakerStatusForResume(
|
||||
// blameStatus: unknown,
|
||||
// claimStatus: unknown,
|
||||
// ): { status: unknown; blameCaseStatus?: unknown } {
|
||||
// if (claimStatus === ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS) {
|
||||
// return {
|
||||
// status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||
// blameCaseStatus: blameStatus,
|
||||
// };
|
||||
// }
|
||||
// return { status: blameStatus };
|
||||
// }
|
||||
/**
|
||||
* FileMaker owns a cross-aggregate workflow: the party narrative is stored on
|
||||
* the blame case, while required-document progress is stored on its linked
|
||||
* claim. Expose the authoritative aggregate to resume instead of overloading
|
||||
* either record's status with the other record's state.
|
||||
*/
|
||||
private fileMakerResumeProjection(
|
||||
file: any,
|
||||
claim?: any,
|
||||
): FileMakerResumeProjection {
|
||||
const blameWorkflow = this.fileMakerWorkflowProjection(file);
|
||||
const narrativeTerminalStep =
|
||||
file?.type === BlameRequestType.CAR_BODY
|
||||
? WorkflowStep.FIRST_COMPLETED
|
||||
: WorkflowStep.SECOND_COMPLETED;
|
||||
const narrativeComplete =
|
||||
blameWorkflow.currentStep === narrativeTerminalStep ||
|
||||
(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) {
|
||||
throw new ForbiddenException("Only FileMakers can use this endpoint.");
|
||||
}
|
||||
@@ -12785,26 +12961,27 @@ export class RequestManagementService {
|
||||
isMadeByFileMaker: true,
|
||||
initiatedByFieldExpertId: makerId,
|
||||
});
|
||||
// const blameIds = (files || []).map((f: any) => f._id).filter(Boolean);
|
||||
// const claims =
|
||||
// blameIds.length > 0
|
||||
// ? await this.claimCaseDbService.find(
|
||||
// { blameRequestId: { $in: blameIds } },
|
||||
// { lean: true, select: "blameRequestId status" },
|
||||
// )
|
||||
// : [];
|
||||
// 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 blameIds = (files || []).map((f: any) => f._id).filter(Boolean);
|
||||
const claims =
|
||||
blameIds.length > 0
|
||||
? await this.claimCaseDbService.find(
|
||||
{ blameRequestId: { $in: blameIds } },
|
||||
{
|
||||
lean: true,
|
||||
select: "blameRequestId status workflow",
|
||||
},
|
||||
)
|
||||
: [];
|
||||
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 resume = this.fileMakerStatusForResume(
|
||||
// f.status,
|
||||
// claimStatusByBlameId.get(String(f._id)),
|
||||
// );
|
||||
const claim = claimByBlameId.get(String(f._id));
|
||||
return {
|
||||
_id: f._id,
|
||||
publicId: f.publicId,
|
||||
@@ -12817,11 +12994,21 @@ export class RequestManagementService {
|
||||
nextStep: workflow.nextStep,
|
||||
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,
|
||||
createdAt: f.createdAt,
|
||||
updatedAt: f.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
return this.paginateUserFacingFiles(list, query);
|
||||
}
|
||||
|
||||
async getMyFileMakerFileDetail(
|
||||
@@ -12855,6 +13042,7 @@ export class RequestManagementService {
|
||||
: claim
|
||||
? { ...(claim as any) }
|
||||
: null;
|
||||
const fileMakerResume = this.fileMakerResumeProjection(plain, claimPlain);
|
||||
return {
|
||||
_id: plain._id,
|
||||
publicId: plain.publicId,
|
||||
@@ -12907,6 +13095,7 @@ export class RequestManagementService {
|
||||
hasSigned: p.confirmation != null,
|
||||
})),
|
||||
linkedClaimId: claimPlain ? String(claimPlain._id) : null,
|
||||
fileMakerResume,
|
||||
...(claimPlain
|
||||
? {
|
||||
claimStatus: claimPlain.status,
|
||||
@@ -12933,7 +13122,10 @@ export class RequestManagementService {
|
||||
|
||||
// ─── 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) {
|
||||
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,
|
||||
publicId: f.publicId,
|
||||
requestNo: f.requestNo,
|
||||
type: f.type,
|
||||
status: f.status,
|
||||
blameStatus: f.blameStatus,
|
||||
claimStatus: claimByBlameId.get(String(f._id))?.status,
|
||||
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||
blameStatus: f.status,
|
||||
claimStatus: claimByBlameId.get(String(f._id))?.status,
|
||||
}),
|
||||
workflow: {
|
||||
currentStep: f.workflow?.currentStep,
|
||||
nextStep: f.workflow?.nextStep,
|
||||
@@ -12987,6 +13201,8 @@ export class RequestManagementService {
|
||||
createdAt: f.createdAt,
|
||||
updatedAt: f.updatedAt,
|
||||
}));
|
||||
|
||||
return this.paginateUserFacingFiles(list, query);
|
||||
}
|
||||
|
||||
async getMyFileReviewerFileDetail(
|
||||
|
||||
@@ -82,7 +82,7 @@ export class RequestManagementV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "List my blame requests (V2)",
|
||||
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(
|
||||
@CurrentUser() user: any,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SandHubService } from "./sand-hub.service";
|
||||
import {
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
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 () => {
|
||||
const result = await service.getTejaratBlockInquiry(userDetail);
|
||||
const result = await service.getTejaratBlockInquiry(userDetail, {
|
||||
enforceDeploymentClientMatch: true,
|
||||
});
|
||||
|
||||
expect(isMappedPolicyCurrent(result.mapped)).toBe(true);
|
||||
});
|
||||
@@ -121,6 +124,17 @@ describe("SandHubService inquiry mocks", () => {
|
||||
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 () => {
|
||||
process.env.CLIENT_ID = "8";
|
||||
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||
@@ -129,12 +143,10 @@ describe("SandHubService inquiry mocks", () => {
|
||||
raw: { mocked: true },
|
||||
mapped: { PrntPlcyCmpDocNo: "MOCK-POLICY" },
|
||||
});
|
||||
const esg = jest
|
||||
.spyOn(service as any, "makeEsgRequest")
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
data: { PrntCmpDocNo: "REAL-ESG-POLICY" },
|
||||
});
|
||||
const esg = jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
|
||||
success: true,
|
||||
data: { PrntCmpDocNo: "REAL-ESG-POLICY" },
|
||||
});
|
||||
|
||||
const result = await service.getTejaratBlockInquiry(userDetail);
|
||||
|
||||
@@ -143,6 +155,40 @@ describe("SandHubService inquiry mocks", () => {
|
||||
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 () => {
|
||||
process.env.CLIENT_ID = "15";
|
||||
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||
|
||||
@@ -24,6 +24,13 @@ import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { mapEsgCarBodyPolicyToInquiry } from "./esg-car-body-inquiry.mapper";
|
||||
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"> & {
|
||||
plate: Plates | string;
|
||||
@@ -31,9 +38,6 @@ type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & {
|
||||
|
||||
@Injectable()
|
||||
export class SandHubService {
|
||||
private static readonly ESG_INQUIRY_UNAVAILABLE_MESSAGE =
|
||||
"استعلام در دسترس نیست";
|
||||
|
||||
private readonly logger = new Logger(SandHubService.name);
|
||||
private loginToken: string | null = null;
|
||||
private tokenExpiry: Date | null = null;
|
||||
@@ -90,6 +94,30 @@ export class SandHubService {
|
||||
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
|
||||
* 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();
|
||||
if (!expectedClientCode) {
|
||||
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;
|
||||
|
||||
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();
|
||||
if (!expectedClientCode) {
|
||||
throw new ServiceUnavailableException(
|
||||
"CLIENT_ID must be configured before insurance eligibility can be checked.",
|
||||
"تنظیمات شرکت بیمه برای بررسی اعتبار بیمهنامه کامل نیست.",
|
||||
);
|
||||
}
|
||||
if (expectedClientCode === "8") return;
|
||||
|
||||
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.loginToken = null;
|
||||
this.tokenExpiry = null;
|
||||
throw new UnauthorizedException("SandHub authentication failed");
|
||||
throw new UnauthorizedException("احراز هویت سرویس استعلام انجام نشد.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +474,7 @@ export class SandHubService {
|
||||
|
||||
if (!email || !password) {
|
||||
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.tejaratTokenExpiry = null;
|
||||
throw new UnauthorizedException("Tejarat inquiry authentication failed");
|
||||
throw new UnauthorizedException(
|
||||
"احراز هویت سرویس استعلام تجارت نو انجام نشد.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -501,7 +535,7 @@ export class SandHubService {
|
||||
|
||||
if (!baseUrl || !username || !password) {
|
||||
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.esgAccessToken = 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;
|
||||
}
|
||||
|
||||
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);
|
||||
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?.success === false) {
|
||||
this.logger.warn(
|
||||
"ESG policyByPlate inquiry returned success=false",
|
||||
raw,
|
||||
);
|
||||
if (isInquiryFailurePayload(raw)) {
|
||||
this.logger.warn("ESG policy inquiry returned a failure payload", raw);
|
||||
return {
|
||||
Error: {
|
||||
Message: SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
|
||||
Message: getInquiryErrorMessage(raw, context),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -670,7 +712,8 @@ export class SandHubService {
|
||||
): string | 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;
|
||||
|
||||
let year = 0;
|
||||
@@ -700,7 +743,9 @@ export class SandHubService {
|
||||
return `${year}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
private getDefaultMockPersonInquiry(nationalCode: string): Record<string, unknown> {
|
||||
private getDefaultMockPersonInquiry(
|
||||
nationalCode: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
firstName: "نام",
|
||||
lastName: "خانوادگی",
|
||||
@@ -711,10 +756,10 @@ export class SandHubService {
|
||||
}
|
||||
|
||||
private mapEsgPersonInquiryToOldFormat(raw: any): Record<string, unknown> {
|
||||
if (raw?.success === false) {
|
||||
if (isInquiryFailurePayload(raw)) {
|
||||
this.logger.warn("ESG person inquiry returned success=false", raw);
|
||||
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> {
|
||||
if (raw?.success === false) {
|
||||
if (isInquiryFailurePayload(raw)) {
|
||||
this.logger.warn("ESG sheba inquiry returned success=false", raw);
|
||||
throw new BadRequestException(
|
||||
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
|
||||
);
|
||||
throw new BadRequestException(getInquiryErrorMessage(raw, "sheba"));
|
||||
}
|
||||
|
||||
const data = raw?.data ?? {};
|
||||
@@ -800,11 +843,15 @@ export class SandHubService {
|
||||
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);
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
|
||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(
|
||||
raw,
|
||||
"thirdPartyPlate",
|
||||
);
|
||||
if (!mapped?.Error) {
|
||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||
}
|
||||
return { raw, mapped };
|
||||
}
|
||||
|
||||
@@ -912,8 +964,16 @@ export class SandHubService {
|
||||
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||
);
|
||||
}
|
||||
const mapped = this.mapNewApiResponseToOldFormat(raw);
|
||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||
const mapped = isInquiryFailurePayload(raw)
|
||||
? {
|
||||
Error: {
|
||||
Message: getInquiryErrorMessage(raw, "thirdPartyPlate"),
|
||||
},
|
||||
}
|
||||
: this.mapNewApiResponseToOldFormat(raw);
|
||||
if (!mapped?.Error) {
|
||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||
}
|
||||
return { raw, mapped };
|
||||
}
|
||||
|
||||
@@ -962,13 +1022,15 @@ export class SandHubService {
|
||||
}
|
||||
|
||||
if (isVinInquiry) {
|
||||
const raw = await this.lookupsService.findLastProcessedCarPolicy(
|
||||
"car-body",
|
||||
{
|
||||
let raw: any;
|
||||
try {
|
||||
raw = await this.lookupsService.findLastProcessedCarPolicy("car-body", {
|
||||
nationalCode: String(userDetail.nationalCodeOfInsurer),
|
||||
vin: plateOrVin,
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
this.throwInquiryError(error, "carBodyVin");
|
||||
}
|
||||
if (useParsianCarBodyLookup) {
|
||||
this.assertParsianCarBodyLookupMatchesDeployment();
|
||||
}
|
||||
@@ -1000,10 +1062,15 @@ export class SandHubService {
|
||||
plaqueRight: String(plateOrVin.centerDigits),
|
||||
plaqueSerial: String(plateOrVin.ir),
|
||||
};
|
||||
const raw = await this.lookupsService.findLastProcessedCarPolicy(
|
||||
"car-body",
|
||||
query,
|
||||
);
|
||||
let raw: any;
|
||||
try {
|
||||
raw = await this.lookupsService.findLastProcessedCarPolicy(
|
||||
"car-body",
|
||||
query,
|
||||
);
|
||||
} catch (error) {
|
||||
this.throwInquiryError(error, "carBodyPlate");
|
||||
}
|
||||
this.assertParsianCarBodyLookupMatchesDeployment();
|
||||
|
||||
return {
|
||||
@@ -1046,6 +1113,11 @@ export class SandHubService {
|
||||
options,
|
||||
);
|
||||
|
||||
if (isInquiryFailurePayload(raw)) {
|
||||
throw new BadRequestException(
|
||||
getInquiryErrorMessage(raw, "carBodyPlate"),
|
||||
);
|
||||
}
|
||||
const mapped = this.mapCarBodyInquiryResponse(raw);
|
||||
return { raw, mapped };
|
||||
}
|
||||
@@ -1114,7 +1186,6 @@ export class SandHubService {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`).
|
||||
*
|
||||
@@ -1130,10 +1201,10 @@ export class SandHubService {
|
||||
options?: SandHubInquiryOptions,
|
||||
): Promise<{ raw: any; mapped: any }> {
|
||||
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
||||
const requestUrl = `${baseUrl}/inquiry/carByChassis`;
|
||||
const requestUrl = `${baseUrl}/inquiry/policyByChassis`;
|
||||
const requestPayload = {
|
||||
nationalCode: String(identity.nationalCode),
|
||||
chassisNo: String(identity.chassis),
|
||||
chassis: String(identity.chassis),
|
||||
};
|
||||
|
||||
const live = await this.isInquiryLive("vinChassis", options);
|
||||
@@ -1144,8 +1215,10 @@ export class SandHubService {
|
||||
this.logger.debug(
|
||||
`[MOCK] getPolicyByChassisInquiry nationalCode=${identity.nationalCode} chassis=${identity.chassis}`,
|
||||
);
|
||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
|
||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
|
||||
if (!mapped?.Error) {
|
||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||
}
|
||||
return { raw, mapped };
|
||||
}
|
||||
|
||||
@@ -1155,12 +1228,13 @@ export class SandHubService {
|
||||
"vinChassis",
|
||||
options,
|
||||
);
|
||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
|
||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
|
||||
if (!mapped?.Error) {
|
||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||
}
|
||||
return { raw, mapped };
|
||||
}
|
||||
|
||||
|
||||
private async makeSandHubRequest(
|
||||
url: string,
|
||||
payload: any,
|
||||
@@ -1205,7 +1279,7 @@ export class SandHubService {
|
||||
}
|
||||
}
|
||||
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}
|
||||
const m = plk.trim().match(/^(\d+)([^\d\s]+)(\d+)\s+(\d+)$/);
|
||||
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 Plk1 = parseInt(m[3], 10); // left digits
|
||||
const Plk1 = parseInt(m[3], 10); // left digits
|
||||
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 { Plk1, Plk2, Plk3, PlkSrl };
|
||||
@@ -1245,7 +1324,12 @@ export class SandHubService {
|
||||
// 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
|
||||
// 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 (
|
||||
newResponse.plk &&
|
||||
newResponse.Plk1 == null &&
|
||||
@@ -1258,13 +1342,15 @@ export class SandHubService {
|
||||
// Map the new field names to the old field names
|
||||
return {
|
||||
...newResponse,
|
||||
...(plkParts ? {
|
||||
Plk1: plkParts.Plk1,
|
||||
Plk2: plkParts.Plk2,
|
||||
Plk3: plkParts.Plk3,
|
||||
PlkSrl: plkParts.PlkSrl,
|
||||
plateLetterid: plkParts.Plk2,
|
||||
} : {}),
|
||||
...(plkParts
|
||||
? {
|
||||
Plk1: plkParts.Plk1,
|
||||
Plk2: plkParts.Plk2,
|
||||
Plk3: plkParts.Plk3,
|
||||
PlkSrl: plkParts.PlkSrl,
|
||||
plateLetterid: plkParts.Plk2,
|
||||
}
|
||||
: {}),
|
||||
// Company information
|
||||
CompanyCode: newResponse.companyId || newResponse.CompanyCode,
|
||||
CompanyName: newResponse.companyPersianName || newResponse.CompanyName,
|
||||
@@ -1401,7 +1487,7 @@ export class SandHubService {
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error(err);
|
||||
this.throwInquiryError(err, "thirdPartyPlate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1420,7 +1506,7 @@ export class SandHubService {
|
||||
const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate);
|
||||
if (!jalaliBirthDate) {
|
||||
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);
|
||||
if (!gregorianBirthdate) {
|
||||
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")) {
|
||||
throw new NotFoundException(
|
||||
"Personal inquiry failed: Record not found for the given national code and birth date.",
|
||||
getInquiryErrorMessage(response, "personalIdentity"),
|
||||
);
|
||||
}
|
||||
return response.data;
|
||||
@@ -1483,7 +1569,7 @@ export class SandHubService {
|
||||
) {
|
||||
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) {
|
||||
throw new NotFoundException(
|
||||
"Driving license check failed: The license is not valid or could not be found.",
|
||||
"گواهینامهای مطابق کد ملی و شماره گواهینامه واردشده یافت نشد.",
|
||||
);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (
|
||||
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}`);
|
||||
this.throwInquiryError(error, "drivingLicense");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1563,13 +1640,13 @@ export class SandHubService {
|
||||
response,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Ownership validation failed: The provided national ID is not the owner of this vehicle.",
|
||||
"پلاک واردشده متعلق به کد ملی واردشده نیست.",
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
throw new Error(`Error in finding car ownership: ${err}`);
|
||||
this.throwInquiryError(err, "carOwnership");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1619,7 +1696,7 @@ export class SandHubService {
|
||||
response,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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) {
|
||||
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) {
|
||||
throw new BadGatewayException(
|
||||
`SandHub rejected the request with a 400 Bad Request. Details: ${JSON.stringify(err.response.data)}`,
|
||||
);
|
||||
throw new BadGatewayException(getInquiryErrorMessage(err, "generic"));
|
||||
}
|
||||
} else {
|
||||
this.logger.error(
|
||||
@@ -1685,23 +1760,24 @@ export class SandHubService {
|
||||
|
||||
if (err.message === "EMPTY_RESPONSE") {
|
||||
throw new BadGatewayException(
|
||||
"SandHub is offline or returned an empty response",
|
||||
"سرویس استعلام پاسخی برنگرداند. لطفاً دوباره تلاش کنید.",
|
||||
);
|
||||
}
|
||||
if (err.code === "ECONNABORTED") {
|
||||
throw new GatewayTimeoutException("SandHub request timed out");
|
||||
throw new GatewayTimeoutException(
|
||||
"زمان پاسخگویی سرویس استعلام به پایان رسید. لطفاً دوباره تلاش کنید.",
|
||||
);
|
||||
}
|
||||
if (err.code === "ECONNRESET" || err.message.includes("socket hang up")) {
|
||||
throw new ServiceUnavailableException(
|
||||
"SandHub connection was reset or closed unexpectedly",
|
||||
"ارتباط با سرویس استعلام قطع شد. لطفاً دوباره تلاش کنید.",
|
||||
);
|
||||
}
|
||||
|
||||
// This final check is for when all retries have failed for a retryable error.
|
||||
if (attempt >= maxRetries) {
|
||||
if (attempt >= maxRetries - 1) {
|
||||
throw new BadGatewayException(
|
||||
"Failed to fetch data from SandHub after multiple retries",
|
||||
err.message,
|
||||
"سرویس استعلام پس از چند تلاش پاسخ نداد. لطفاً کمی بعد دوباره تلاش کنید.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user