Merge pull request 'main' (#326) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#326
This commit is contained in:
2026-09-18 16:11:29 +03:30
41 changed files with 2349 additions and 374 deletions

BIN
docs/Yara724 flows.pdf Normal file

Binary file not shown.

View File

@@ -360,7 +360,7 @@
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr> <tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr>
</table> </table>
<p class="note" style="margin-top:8px;"> <p class="note" style="margin-top:8px;">
ESG wraps every response as <code>{ success: boolean, data: … }</code>. A <code>success=false</code> body is translated to a Persian "استعلام در دسترس نیست" (inquiry unavailable) error. ESG wraps every response as <code>{ success: boolean, data: … }</code>. A <code>success=false</code> body is translated to a contextual Persian error. For example, <code>موردی یافت نشد</code> becomes a plate- or VIN-specific “no matching policy” message; it is not reported as a provider outage.
The offline-inquiry seed check still runs first, before any ESG HTTP call. The offline-inquiry seed check still runs first, before any ESG HTTP call.
</p> </p>
</div> </div>

View File

@@ -363,7 +363,7 @@
<table> <table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr> <tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>List claims in <code>WAITING_FOR_DAMAGE_EXPERT</code> queue + factor-validation queue. Query: search, sortBy, page, limit, unifiedStatus, fileType.</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>List claims in <code>WAITING_FOR_DAMAGE_EXPERT</code> queue + factor-validation queue. Query: search, sortBy, page, limit, unifiedStatus, fileType.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs.</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs. Completed claims include Fanavaran claimNo / claimId when available.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>Lock claim to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409.</td></tr> <tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>Lock claim to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Price-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry.</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Price-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Calculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400.</td></tr> <tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Calculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400.</td></tr>

6
package-lock.json generated
View File

@@ -8796,9 +8796,9 @@
} }
}, },
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.3.1", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"

View File

@@ -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/,
);
});
}); });

View File

@@ -14,6 +14,7 @@ import {
PR, PR,
persianAccidentCondition, persianAccidentCondition,
persianFieldPath, persianFieldPath,
persianReportValue,
persianStatus, persianStatus,
} from "./persian-report-labels"; } from "./persian-report-labels";
@@ -110,10 +111,13 @@ function firstDefined(...values: unknown[]): string | undefined {
return undefined; return undefined;
} }
function normalizeListValue(value: unknown): string | undefined { function normalizeListValue(value: unknown, path = ""): string | undefined {
if (!Array.isArray(value) || !value.length) return undefined; if (!Array.isArray(value) || !value.length) return undefined;
const items = value const items = value
.map((item) => asString(item) ?? JSON.stringify(item)) .map(
(item) =>
asString(persianReportValue(path, item)) ?? JSON.stringify(item),
)
.filter(Boolean); .filter(Boolean);
return items.length ? items.join("، ") : undefined; return items.length ? items.join("، ") : undefined;
} }
@@ -124,18 +128,31 @@ function flattenObject(
depth = 0, depth = 0,
): InsurerFileReportField[] { ): InsurerFileReportField[] {
if (obj == null) return []; if (obj == null) return [];
if (depth > 4) { if (depth > 6) {
return [{ label: persianFieldPath(prefix), value: asString(obj) }]; return [
{
label: persianFieldPath(prefix),
value: asString(persianReportValue(prefix, obj)),
},
];
} }
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
const value = normalizeListValue(obj); if (obj.some((item) => item && typeof item === "object")) {
return obj.flatMap((item, index) =>
flattenObject(item, `${prefix}.${index + 1}`, depth + 1),
);
}
const value = normalizeListValue(obj, prefix);
return value ? [{ label: persianFieldPath(prefix || "items"), value }] : []; return value ? [{ label: persianFieldPath(prefix || "items"), value }] : [];
} }
if (typeof obj !== "object") { if (typeof obj !== "object") {
return [ return [
{ label: persianFieldPath(prefix || "value"), value: asString(obj) }, {
label: persianFieldPath(prefix || "value"),
value: asString(persianReportValue(prefix || "value", obj)),
},
]; ];
} }
@@ -150,14 +167,13 @@ function flattenObject(
if (value === undefined || value === null || value === "") continue; if (value === undefined || value === null || value === "") continue;
const path = prefix ? `${prefix}.${key}` : key; const path = prefix ? `${prefix}.${key}` : key;
if ( if (typeof value === "object" && !(value instanceof Date)) {
typeof value === "object" &&
!Array.isArray(value) &&
!(value instanceof Date)
) {
rows.push(...flattenObject(value, path, depth + 1)); rows.push(...flattenObject(value, path, depth + 1));
} else { } else {
rows.push({ label: persianFieldPath(path), value: asString(value) }); rows.push({
label: persianFieldPath(path),
value: asString(persianReportValue(path, value)),
});
} }
} }
@@ -638,6 +654,17 @@ function buildParticipantRolesSection(
participant?.licenseNumber participant?.licenseNumber
? `${PR.licenseNumber}: ${asString(participant.licenseNumber)}` ? `${PR.licenseNumber}: ${asString(participant.licenseNumber)}`
: undefined, : undefined,
participant?.hasDrivingLicense != null
? `${PR.hasDrivingLicense}: ${persianStatus(participant.hasDrivingLicense)}`
: undefined,
participant?.licenseType
? `${PR.licenseType}: ${asString(
persianReportValue(
"participant.licenseType",
participant.licenseType,
),
)}`
: undefined,
] ]
.filter(Boolean) .filter(Boolean)
.join("، "); .join("، ");
@@ -695,8 +722,12 @@ function buildDriverSection(
{ {
label: PR.licenseType, label: PR.licenseType,
value: value:
licenseType ?? asString(
asString(person.licenseType) ?? persianReportValue(
"participant.licenseType",
licenseType ?? person.licenseType,
),
) ??
(person.driverLicense ? PR.driverLicense : undefined), (person.driverLicense ? PR.driverLicense : undefined),
}, },
{ {
@@ -895,7 +926,7 @@ function evaluationDaghiValue(value: unknown): string | undefined {
return asString(value); return asString(value);
} }
const daghi = value as ReportRecord; const daghi = value as ReportRecord;
const option = asString(daghi.option); const option = asString(persianReportValue("daghi.option", daghi.option));
const price = formatToman(daghi.price); const price = formatToman(daghi.price);
return [option, price].filter(Boolean).join(" - ") || undefined; return [option, price].filter(Boolean).join(" - ") || undefined;
} }
@@ -915,7 +946,9 @@ function buildEvaluationPartsSection(
{ label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) }, { label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) },
{ {
label: `${prefix} / ${PR.damageType}`, label: `${prefix} / ${PR.damageType}`,
value: asString(part.typeOfDamage), value: asString(
persianReportValue("evaluation.part.typeOfDamage", part.typeOfDamage),
),
}, },
{ label: `${prefix} / ${PR.partPrice}`, value: formatToman(part.price) }, { label: `${prefix} / ${PR.partPrice}`, value: formatToman(part.price) },
{ {

View File

@@ -29,6 +29,7 @@ export const PR = {
licenseType: "نوع گواهینامه", licenseType: "نوع گواهینامه",
licenseDate: "تاریخ گواهینامه", licenseDate: "تاریخ گواهینامه",
licenseNumber: "شماره گواهینامه", licenseNumber: "شماره گواهینامه",
hasDrivingLicense: "گواهینامه دارد",
driverLicense: "گواهینامه راننده", driverLicense: "گواهینامه راننده",
insuranceCompany: "شرکت بیمه", insuranceCompany: "شرکت بیمه",
policyNumber: "شماره بیمه‌نامه", policyNumber: "شماره بیمه‌نامه",
@@ -194,6 +195,31 @@ const KEY_LABELS: Record<string, string> = {
StatusTypeCode: "کد وضعیت", StatusTypeCode: "کد وضعیت",
label_fa: "برچسب فارسی", label_fa: "برچسب فارسی",
catalogKey: "کلید کاتالوگ", catalogKey: "کلید کاتالوگ",
participantId: "شناسه شخص",
participants: "اشخاص استعلام",
participantRoles: "نقش‌های اشخاص",
nationalCode: "کد ملی",
birthday: "تاریخ تولد",
fullName: "نام و نام خانوادگی",
phoneNumber: "شماره تلفن",
hasDrivingLicense: "گواهینامه دارد",
licenseNumber: "شماره گواهینامه",
registrationState: "وضعیت پلاک و مالکیت",
currentPlate: "پلاک فعلی",
previousPlate: "پلاک قبلی",
previousPlateId: "پلاک قبلی",
previousPolicyholderNationalCode: "کد ملی بیمه‌گذار پلاک قبلی",
vehicleVin: "شماره شاسی (VIN)",
driver: "راننده",
vehicleOwner: "مالک وسیله نقلیه",
thirdPartyPolicyholder: "بیمه‌گذار شخص ثالث",
carBodyPolicyholder: "بیمه‌گذار بدنه",
sameAs: "همان شخص",
plateKind: "نوع پلاک استعلام‌شده",
succeeded: "موفق",
usable: "قابل استفاده",
attempts: "سوابق تلاش استعلام",
error: "خطا",
}; };
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
@@ -209,6 +235,76 @@ const STATUS_LABELS: Record<string, string> = {
false: "خیر", false: "خیر",
}; };
const REGISTRATION_STATE_LABELS: Record<string, string> = {
CURRENT: "عادی (پلاک فعلی)",
RECENTLY_TRANSFERRED: "انتقال مالکیت اخیر",
};
const PLATE_KIND_LABELS: Record<string, string> = {
CURRENT: "پلاک فعلی",
PREVIOUS: "پلاک قبلی",
};
const PARTICIPANT_ROLE_VALUE_LABELS: Record<string, string> = {
DRIVER: "راننده",
VEHICLE_OWNER: "مالک وسیله نقلیه",
THIRD_PARTY_POLICYHOLDER: "بیمه‌گذار شخص ثالث",
CAR_BODY_POLICYHOLDER: "بیمه‌گذار بدنه",
FIRST: "طرف اول",
SECOND: "طرف دوم",
};
const CASE_TYPE_LABELS: Record<string, string> = {
THIRD_PARTY: "شخص ثالث",
CAR_BODY: "بدنه",
};
const VEHICLE_TYPE_LABELS: Record<string, string> = {
SEDAN: "سواری",
SUV: "شاسی‌بلند",
HATCHBACK: "هاچ‌بک",
PICKUP: "وانت",
VAN: "ون",
};
const VALIDITY_LABELS: Record<string, string> = {
ACTIVE: "فعال",
INACTIVE: "غیرفعال",
VALID: "معتبر",
INVALID: "نامعتبر",
EXPIRED: "منقضی‌شده",
};
const DAMAGE_TYPE_LABELS: Record<string, string> = {
REPAIR: "تعمیر",
CHANGE: "تعویض",
REPLACE: "تعویض",
};
const INQUIRY_ERROR_LABELS: Record<string, string> = {
NOT_FOUND: "موردی یافت نشد",
NO_RECORD_FOUND: "موردی یافت نشد",
TIMEOUT: "مهلت پاسخ استعلام به پایان رسید",
REQUEST_FAILED: "استعلام ناموفق بود",
FAILED: "استعلام ناموفق بود",
UNAVAILABLE: "سرویس استعلام در دسترس نیست",
};
const LICENSE_TYPE_LABELS: Record<string, string> = {
"1": "پایه یک",
"2": "پایه دو",
"3": "پایه سه",
BASE_1: "پایه یک",
BASE1: "پایه یک",
GRADE_1: "پایه یک",
BASE_2: "پایه دو",
BASE2: "پایه دو",
GRADE_2: "پایه دو",
BASE_3: "پایه سه",
BASE3: "پایه سه",
GRADE_3: "پایه سه",
};
const WEATHER_LABELS: Record<string, string> = { const WEATHER_LABELS: Record<string, string> = {
clear: "صاف", clear: "صاف",
sunny: "صاف", sunny: "صاف",
@@ -255,9 +351,39 @@ export function persianFieldPath(path: string): string {
return `بیمه / ${translatedLast}`; return `بیمه / ${translatedLast}`;
} }
if (parts[0] === "claim" && parts[1] === "vehicle") { if (parts[0] === "claim" && parts[1] === "vehicle") {
const attemptIndex = parts.indexOf("attempts");
if (attemptIndex >= 0) {
const attemptNumber = Number(parts[attemptIndex + 1]);
const attemptLabel = Number.isFinite(attemptNumber)
? `تلاش ${attemptNumber.toLocaleString("fa-IR")}`
: "تلاش استعلام";
const plateLabel = parts.includes("plate") ? " / پلاک" : "";
return `خودرو / سوابق تلاش استعلام / ${attemptLabel}${plateLabel} / ${translatedLast}`;
}
if (parts.includes("currentPlate")) {
return `خودرو / پلاک فعلی / ${translatedLast}`;
}
if (parts.includes("previousPlate")) {
return `خودرو / پلاک قبلی / ${translatedLast}`;
}
return `خودرو / ${translatedLast}`; return `خودرو / ${translatedLast}`;
} }
if (parts[0] === "party" && parts[1] === "vehicle") { if (parts[0] === "party" && parts[1] === "vehicle") {
const attemptIndex = parts.indexOf("attempts");
if (attemptIndex >= 0) {
const attemptNumber = Number(parts[attemptIndex + 1]);
const attemptLabel = Number.isFinite(attemptNumber)
? `تلاش ${attemptNumber.toLocaleString("fa-IR")}`
: "تلاش استعلام";
const plateLabel = parts.includes("plate") ? " / پلاک" : "";
return `خودرو / سوابق تلاش استعلام / ${attemptLabel}${plateLabel} / ${translatedLast}`;
}
if (parts.includes("currentPlate")) {
return `خودرو / پلاک فعلی / ${translatedLast}`;
}
if (parts.includes("previousPlate")) {
return `خودرو / پلاک قبلی / ${translatedLast}`;
}
return `خودرو / ${translatedLast}`; return `خودرو / ${translatedLast}`;
} }
@@ -267,6 +393,51 @@ export function persianFieldPath(path: string): string {
return translated.join(" / "); return translated.join(" / ");
} }
/**
* Localize only known domain tokens. Free-form provider values, names,
* identifiers, plates and VINs are deliberately preserved verbatim.
*/
export function persianReportValue(
path: string,
value: unknown,
): unknown {
if (value === undefined || value === null || value === "") return value;
if (typeof value === "boolean") return value ? "بله" : "خیر";
if (typeof value !== "string" && typeof value !== "number") return value;
const raw = String(value).trim();
const key = raw.toUpperCase().replace(/[\s-]+/g, "_");
const field = path.split(".").filter(Boolean).pop() ?? "";
if (field === "licenseType" || field === "LicenseType") {
return LICENSE_TYPE_LABELS[key] ?? raw;
}
if (field === "registrationState") {
return REGISTRATION_STATE_LABELS[key] ?? raw;
}
if (field === "plateKind") return PLATE_KIND_LABELS[key] ?? raw;
if (field === "role" || field.endsWith("Role")) {
return PARTICIPANT_ROLE_VALUE_LABELS[key] ?? raw;
}
if (field === "blameRequestType" || field === "fileType") {
return CASE_TYPE_LABELS[key] ?? raw;
}
if (
(field === "carType" || field === "type") &&
path.toLowerCase().includes("vehicle")
) {
return VEHICLE_TYPE_LABELS[key] ?? raw;
}
if (field === "typeOfDamage") return DAMAGE_TYPE_LABELS[key] ?? raw;
if (field === "error") return INQUIRY_ERROR_LABELS[key] ?? raw;
if (/status$/i.test(field)) {
return STATUS_LABELS[raw] ?? VALIDITY_LABELS[key] ?? raw;
}
if (raw === "true" || raw === "false") return STATUS_LABELS[raw];
return raw;
}
export function persianStatus(value: unknown): string | undefined { export function persianStatus(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined; if (value === undefined || value === null || value === "") return undefined;
const key = String(value); const key = String(value);

View File

@@ -101,7 +101,12 @@ import {
ClaimListItemV2Dto, ClaimListItemV2Dto,
} from "./dto/my-claims-v2.dto"; } from "./dto/my-claims-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { applyListQueryV2 } from "src/helpers/list-query-v2"; import {
applyListQueryV2,
isInListDateRange,
parseListDateRange,
} from "src/helpers/list-query-v2";
import { resolveUnifiedFileStatus } from "src/helpers/unified-file-status";
import { partyPersonMatchesUser } from "src/helpers/iran-mobile"; import { partyPersonMatchesUser } from "src/helpers/iran-mobile";
import { import {
buildBlamePartyAccessOrConditions, buildBlamePartyAccessOrConditions,
@@ -206,6 +211,7 @@ import {
partLookupKey, partLookupKey,
resolvePartCaptureIndex, resolvePartCaptureIndex,
} from "src/helpers/outer-damage-parts"; } from "src/helpers/outer-damage-parts";
import { serializeDamagedPartSelectionHistory } from "src/helpers/claim-damaged-part-audit";
import { normalizeMoneyAmountString } from "src/utils/unicode-digits"; import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service"; import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
@@ -12421,7 +12427,7 @@ export class ClaimRequestManagementService {
blameIdsForList.length > 0 blameIdsForList.length > 0
? ((await this.blameRequestDbService.find( ? ((await this.blameRequestDbService.find(
{ _id: { $in: blameIdsForList.map((id) => new Types.ObjectId(id)) } }, { _id: { $in: blameIdsForList.map((id) => new Types.ObjectId(id)) } },
{ lean: true, select: "type creationMethod" }, { lean: true, select: "type creationMethod status" },
)) as any[]) )) as any[])
: []; : [];
const blameByIdForList = new Map<string, any>( const blameByIdForList = new Map<string, any>(
@@ -12443,16 +12449,37 @@ export class ClaimRequestManagementService {
blameRequestId: c.blameRequestId?.toString(), blameRequestId: c.blameRequestId?.toString(),
blameType: blameForItem?.type ?? undefined, blameType: blameForItem?.type ?? undefined,
creationMethod: blameForItem?.creationMethod ?? undefined, creationMethod: blameForItem?.creationMethod ?? undefined,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: blameForItem?.status,
claimStatus: c.status,
}),
}; };
}) as ClaimListItemV2Dto[]; }) as ClaimListItemV2Dto[];
let filtered = list;
if (query.unifiedStatus) {
filtered = filtered.filter(
(row) => row.unifiedFileStatus === query.unifiedStatus,
);
}
const { fromDate, toDate } = parseListDateRange(
query.startDate,
query.endDate,
);
if (fromDate || toDate) {
filtered = filtered.filter((row) =>
isInListDateRange(row.createdAt, fromDate, toDate),
);
}
const paged = applyListQueryV2( const paged = applyListQueryV2(
list, filtered,
{ {
publicId: (r) => r.publicId, publicId: (r) => r.publicId,
createdAt: (r) => r.createdAt, createdAt: (r) => r.createdAt,
requestNo: (r) => r.requestNo, requestNo: (r) => r.requestNo,
status: (r) => r.status, status: (r) => r.unifiedFileStatus ?? r.status,
fileType: (r) => r.blameType,
searchExtras: (r) => searchExtras: (r) =>
[ [
r.claimRequestId, r.claimRequestId,
@@ -12588,6 +12615,10 @@ export class ClaimRequestManagementService {
catalogLikeKeyFromPart, catalogLikeKeyFromPart,
buildFileLink, buildFileLink,
}); });
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
history: (claim.damage as any)?.partSelectionHistory,
currentSelectedParts: selectedNormDetails,
});
const er = claim.evaluation?.damageExpertResend; const er = claim.evaluation?.damageExpertResend;
const expertResend = const expertResend =
@@ -12671,6 +12702,7 @@ export class ClaimRequestManagementService {
: undefined, : undefined,
carAngles, carAngles,
damagedParts, damagedParts,
damagedPartsHistory,
expertResend, expertResend,
fanavaran: fanavaranClaimReferences(claim), fanavaran: fanavaranClaimReferences(claim),
evaluation: mappedEvaluation evaluation: mappedEvaluation

View File

@@ -83,7 +83,7 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Get My Claims (V2)", summary: "Get My Claims (V2)",
description: description:
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.", "Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType`, `startDate`, `endDate`.",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,

View File

@@ -204,6 +204,33 @@ export class ClaimDetailsV2ResponseDto {
fileName?: string; fileName?: string;
}>; }>;
@ApiPropertyOptional({
description:
'Append-only expert damaged-part revisions. Removed rows retain their original capture URL.',
type: 'array',
items: {
type: 'object',
properties: {
revisionId: { type: 'string' },
changedAt: { type: 'string', format: 'date-time' },
changedBy: { type: 'object' },
removedParts: { type: 'array', items: { type: 'object' } },
addedParts: { type: 'array', items: { type: 'object' } },
},
},
})
damagedPartsHistory?: Array<{
revisionId: string;
changedAt: Date | string;
changedBy: {
actorId: string;
actorName?: string;
actorType: string;
};
removedParts: Array<Record<string, unknown>>;
addedParts: Array<Record<string, unknown>>;
}>;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).', 'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',

View File

@@ -40,6 +40,12 @@ export class ClaimListItemV2Dto {
example: 'IN_PERSON', example: 'IN_PERSON',
}) })
creationMethod?: string; creationMethod?: string;
@ApiPropertyOptional({
description: 'Calculated combined blame and claim lifecycle status',
example: 'WAITING_FOR_DAMAGE_EXPERT',
})
unifiedFileStatus?: string;
} }
export class GetMyClaimsV2ResponseDto { export class GetMyClaimsV2ResponseDto {

View File

@@ -18,6 +18,18 @@ export class ClaimDamageSelection {
@Prop({ type: [String], default: [] }) @Prop({ type: [String], default: [] })
otherParts?: string[]; otherParts?: string[];
/**
* Append-only expert edit revisions. Each revision preserves the prior
* selection and capture metadata for removed parts before the live arrays
* are replaced. Mixed keeps legacy/free-text part shapes readable.
*/
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
partSelectionHistory?: unknown[];
/** Parts introduced by an expert across revisions (used for origin labels). */
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
expertAddedParts?: unknown[];
/** /**
* Legacy fields - kept for backward compatibility * Legacy fields - kept for backward compatibility
*/ */

View 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);
});
});

View 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 "انجام استعلام با خطا مواجه شد. لطفاً دوباره تلاش کنید.";
}

View File

@@ -131,6 +131,33 @@ export class ClaimDetailV2ResponseDto {
url?: string; url?: string;
}>; }>;
@ApiPropertyOptional({
description:
"Append-only expert damaged-part revisions. Removed rows retain their original capture URL even after the authoritative selection changes.",
type: "array",
items: {
type: "object",
properties: {
revisionId: { type: "string" },
changedAt: { type: "string", format: "date-time" },
changedBy: { type: "object" },
removedParts: { type: "array", items: { type: "object" } },
addedParts: { type: "array", items: { type: "object" } },
},
},
})
damagedPartsHistory?: Array<{
revisionId: string;
changedAt: Date | string;
changedBy: {
actorId: string;
actorName?: string;
actorType: string;
};
removedParts: Array<Record<string, unknown>>;
addedParts: Array<Record<string, unknown>>;
}>;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
"True when user uploaded all required factors and the case awaits expert approve/reject.", "True when user uploaded all required factors and the case awaits expert approve/reject.",

View File

@@ -90,4 +90,21 @@ describe("SubmitExpertReplyV2Dto", () => {
expect(await validate(dto)).toHaveLength(0); expect(await validate(dto)).toHaveLength(0);
}); });
it("accepts a grouped Persian current car price as a string", async () => {
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
carPrice: "۱٬۲۵۰٬۰۰۰٬۰۰۰",
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
salary: "1000000",
totalPayment: "1000000",
factorNeeded: false,
},
],
});
expect(await validate(dto)).toHaveLength(0);
});
}); });

View File

@@ -122,8 +122,9 @@ export class SubmitExpertReplyV2Dto {
description?: string; description?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: '1_000_000_000', example: '1000000000',
description: "Today's car price", description:
"Current vehicle price in Rial. Required for car-body claims and forbidden for third-party claims. This is independent from evaluation.priceDrop.carPrice.",
}) })
@IsOptional() @IsOptional()
@IsMoneyAmountString() @IsMoneyAmountString()

View File

@@ -1,10 +1,14 @@
import { BadRequestException } from "@nestjs/common"; import { BadRequestException } from "@nestjs/common";
import { Types } from "mongoose";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum"; import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum"; import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { ExpertClaimService } from "./expert-claim.service"; import { ExpertClaimService } from "./expert-claim.service";
const V2_EXPERT_ID = "66ec0e480e321873c0900001";
const blankPricingReply = { const blankPricingReply = {
description: "Damage assessment", description: "Damage assessment",
parts: [ parts: [
@@ -26,7 +30,144 @@ function createService() {
) as ExpertClaimService; ) as ExpertClaimService;
} }
function setupSuccessfulV2Submit(blameType: BlameRequestType) {
const service = createService() as any;
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
const claim = {
_id: "66ec0e480e321873c0900002",
publicId: "CLM-1",
blameRequestId: "66ec0e480e321873c0900003",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: V2_EXPERT_ID } },
vehicle: { carType: "sedan" },
damage: {
selectedParts: [
{
id: 1,
name: "front",
side: "",
label_fa: "جلو کامل",
catalogKey: "1",
},
],
},
};
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue(claim),
findByIdAndUpdate,
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({
type: blameType,
expertInitiated: true,
creationMethod: "IN_PERSON",
}),
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
service.recordClaimExpertActivity = jest.fn().mockResolvedValue(undefined);
return { service, findByIdAndUpdate };
}
const validV2Reply = {
parts: [
{
partId: 1,
typeOfDamage: TypeOfDamage.Repair,
salary: "1000000",
totalPayment: "1000000",
factorNeeded: false,
},
],
};
describe("ExpertClaimService expert-reply pricing", () => { describe("ExpertClaimService expert-reply pricing", () => {
it("requires the current car price for a car-body expert reply", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.CAR_BODY,
);
await expect(
service.submitExpertReplyV2("v2-claim", validV2Reply, {
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
}),
).rejects.toMatchObject({
response: {
code: "CAR_PRICE_REQUIRED",
field: "carPrice",
},
});
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
it("normalizes and persists the current car price on car-body replies", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.CAR_BODY,
);
await service.submitExpertReplyV2(
"v2-claim",
{ ...validV2Reply, carPrice: "۱٬۲۵۰٬۰۰۰٬۰۰۰" },
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
);
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
expect(findByIdAndUpdate.mock.calls[0][1]).toMatchObject({
"vehicle.price": "1250000000",
});
});
it("rejects the current car price on third-party replies", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
await expect(
service.submitExpertReplyV2(
"v2-claim",
{ ...validV2Reply, carPrice: "1250000000" },
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
),
).rejects.toMatchObject({
response: {
code: "CAR_PRICE_NOT_ALLOWED",
field: "carPrice",
},
});
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
it("does not overwrite vehicle.price on third-party replies", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
await service.submitExpertReplyV2("v2-claim", validV2Reply, {
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
});
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
expect(findByIdAndUpdate.mock.calls[0][1]).not.toHaveProperty(
"vehicle.price",
);
});
it("allows a repair line without daghi and removes a stray daghi payload", () => { it("allows a repair line without daghi and removes a stray daghi payload", () => {
const service = createService() as any; const service = createService() as any;
@@ -105,18 +246,27 @@ describe("ExpertClaimService expert-reply pricing", () => {
const findByIdAndUpdate = jest.fn(); const findByIdAndUpdate = jest.fn();
service.claimCaseDbService = { service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({ findById: jest.fn().mockResolvedValue({
blameRequestId: "blame-1",
status: ClaimCaseStatus.EXPERT_REVIEWING, status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: "expert-1" } }, workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
}), }),
findByIdAndUpdate, findByIdAndUpdate,
}; };
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined); service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
await expect( await expect(
service.submitExpertReplyV2("v2-claim", blankPricingReply, { service.submitExpertReplyV2(
sub: "expert-1", "v2-claim",
role: RoleEnum.FIELD_EXPERT, { ...blankPricingReply, carPrice: "1000000" },
}), {
sub: "expert-1",
role: RoleEnum.FIELD_EXPERT,
},
),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(findByIdAndUpdate).not.toHaveBeenCalled(); expect(findByIdAndUpdate).not.toHaveBeenCalled();
@@ -127,18 +277,24 @@ describe("ExpertClaimService expert-reply pricing", () => {
const findByIdAndUpdate = jest.fn(); const findByIdAndUpdate = jest.fn();
service.claimCaseDbService = { service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({ findById: jest.fn().mockResolvedValue({
blameRequestId: "blame-1",
status: ClaimCaseStatus.EXPERT_REVIEWING, status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: "expert-1" } }, workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
}), }),
findByIdAndUpdate, findByIdAndUpdate,
}; };
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined); service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
await expect( await expect(
service.submitExpertReplyV2( service.submitExpertReplyV2(
"v2-claim", "v2-claim",
{ {
description: "Damage assessment", description: "Damage assessment",
carPrice: "1000000",
parts: [ parts: [
{ {
partId: 201, partId: 201,
@@ -163,3 +319,132 @@ describe("ExpertClaimService expert-reply pricing", () => {
expect(findByIdAndUpdate).not.toHaveBeenCalled(); expect(findByIdAndUpdate).not.toHaveBeenCalled();
}); });
}); });
describe("ExpertClaimService damaged-part audit", () => {
it("archives removed capture evidence before replacing the live arrays", async () => {
const service = createService() as any;
const expertId = "66ec0e480e321873c0900001";
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
_id: "66ec0e480e321873c0900002",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: expertId } },
vehicle: { carType: "SEDAN" },
damage: {
selectedParts: [
{
id: 101,
name: "hood",
side: "front",
label_fa: "کاپوت",
catalogKey: "front_hood",
},
{
id: 202,
name: "door",
side: "left",
label_fa: "درب چپ",
catalogKey: "left_door",
},
],
},
media: {
damagedParts: [
{ path: "claims/hood.jpg", fileName: "hood.jpg" },
{ path: "claims/door.jpg", fileName: "door.jpg" },
],
},
}),
findByIdAndUpdate,
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue({
firstName: "Expert",
});
await service.updateClaimDamagedPartsV2(
"66ec0e480e321873c0900002",
{
selectedParts: [
{
id: 202,
name: "door",
side: "left",
label_fa: "درب چپ",
catalogKey: "left_door",
},
],
},
{
sub: expertId,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
);
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
const update = findByIdAndUpdate.mock.calls[0][1];
expect(update.$set["media.damagedParts"]).toEqual([
expect.objectContaining({ path: "claims/door.jpg" }),
]);
expect(update.$push["damage.partSelectionHistory"]).toEqual(
expect.objectContaining({
changedBy: expect.objectContaining({ actorId: expertId }),
removedParts: [
expect.objectContaining({
id: 101,
capture: expect.objectContaining({ path: "claims/hood.jpg" }),
}),
],
}),
);
});
});
describe("ExpertClaimService FileReviewer assignment", () => {
it("lets the assigned reviewer reopen a file while it is still awaiting FileReviewer work", async () => {
const service = createService() as any;
const reviewerId = new Types.ObjectId();
const clientId = new Types.ObjectId();
const blameId = new Types.ObjectId();
const claimId = new Types.ObjectId();
service.expireClaimWorkflowLockV2IfStale = jest.fn().mockResolvedValue(undefined);
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
_id: claimId,
blameRequestId: blameId,
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
}),
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({
_id: blameId,
type: BlameRequestType.CAR_BODY,
status: "WAITING_FOR_FILE_REVIEWER",
assignedFileReviewerId: reviewerId,
parties: [
{
role: "FIRST",
person: { clientId },
},
],
}),
};
service.assertExpertActorOnClaim = jest.fn();
await expect(
service.assignClaimForReviewV2(String(claimId), {
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
}),
).resolves.toMatchObject({
success: true,
status: "already_assigned_to_you",
});
expect(service.assertExpertActorOnClaim).not.toHaveBeenCalled();
});
});

View File

@@ -143,6 +143,10 @@ import {
} from "src/helpers/outer-damage-parts"; } from "src/helpers/outer-damage-parts";
import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend"; import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import {
buildDamagedPartSelectionRevision,
serializeDamagedPartSelectionHistory,
} from "src/helpers/claim-damaged-part-audit";
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot"; import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema"; import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
@@ -172,6 +176,10 @@ import {
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher"; import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys"; import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing"; import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing";
import {
normalizeMoneyAmountString,
parseMoneyAmountToman,
} from "src/utils/unicode-digits";
@Injectable() @Injectable()
export class ExpertClaimService { export class ExpertClaimService {
@@ -2719,9 +2727,14 @@ export class ExpertClaimService {
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") { if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
if (assignedReviewerId && assignedReviewerId === actor.sub) { if (assignedReviewerId && assignedReviewerId === actor.sub) {
// Reviewer already assigned (e.g. after a FileMaker rejection that reset // Phase 1 is intentionally idempotent. The linked claim can still be
// blame back to WAITING_FOR_FILE_REVIEWER) — skip Phase 1 and fall // in a data-capture status here, so falling through to the damage
// through to the damage-expert workflow lock below. // assessment status gate would incorrectly reject the same reviewer.
return {
success: true,
status: "already_assigned_to_you",
message: "You have already taken this file.",
};
} else { } else {
// Phase 1: first-time blame assignment // Phase 1: first-time blame assignment
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor); return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
@@ -3263,8 +3276,6 @@ export class ExpertClaimService {
) { ) {
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor); if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId);
const blame = await this.blameRequestDbService.findById(claim.blameRequestId);
if (!claim) { if (!claim) {
throw new NotFoundException( throw new NotFoundException(
this.expertReplySubmissionError( this.expertReplySubmissionError(
@@ -3274,6 +3285,18 @@ export class ExpertClaimService {
); );
} }
const blame = await this.blameRequestDbService.findById(
claim.blameRequestId,
);
if (!blame) {
throw new NotFoundException(
this.expertReplySubmissionError(
"پرونده تعیین خسارت مرتبط یافت نشد.",
"BLAME_NOT_FOUND",
),
);
}
await this.assertExpertActorOnClaim(claim, actor); await this.assertExpertActorOnClaim(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
@@ -3304,12 +3327,33 @@ export class ExpertClaimService {
); );
} }
if (reply.carPrice && blame.type !== BlameRequestType.CAR_BODY) { const carPriceWasProvided = reply.carPrice != null;
throw new ForbiddenException("قیمت روز خودرو فقط در پرونده های بدنه باید بررسی شود") let normalizedCurrentCarPrice: string | undefined;
}
if (blame.type === BlameRequestType.THIRD_PARTY && reply.carPrice) { if (blame.type === BlameRequestType.CAR_BODY) {
throw new ForbiddenException("قیمت روز خودرو فقط در پرونده های بدنه باید بررسی شود") const parsedCurrentCarPrice = parseMoneyAmountToman(reply.carPrice);
if (
parsedCurrentCarPrice == null ||
!Number.isSafeInteger(parsedCurrentCarPrice) ||
parsedCurrentCarPrice <= 0
) {
throw new BadRequestException(
this.expertReplySubmissionError(
"قیمت روز خودرو در پرونده بدنه الزامی است و باید مبلغی صحیح و بیشتر از صفر باشد.",
"CAR_PRICE_REQUIRED",
{ field: "carPrice" },
),
);
}
normalizedCurrentCarPrice = normalizeMoneyAmountString(reply.carPrice!);
} else if (carPriceWasProvided) {
throw new ForbiddenException(
this.expertReplySubmissionError(
"قیمت روز خودرو فقط برای پرونده بدنه قابل ثبت است.",
"CAR_PRICE_NOT_ALLOWED",
{ field: "carPrice" },
),
);
} }
const pricingValidationError = getExpertReplyPricingValidationError( const pricingValidationError = getExpertReplyPricingValidationError(
@@ -3540,7 +3584,9 @@ export class ExpertClaimService {
"evaluation.ownerInsurerApproval": "", "evaluation.ownerInsurerApproval": "",
"evaluation.ownerPricedPartsApproval": "", "evaluation.ownerPricedPartsApproval": "",
}, },
"vehicle.price": reply.carPrice, ...(normalizedCurrentCarPrice
? { "vehicle.price": normalizedCurrentCarPrice }
: {}),
"workflow.currentStep": currentStep, "workflow.currentStep": currentStep,
"workflow.nextStep": nextWorkflowStep, "workflow.nextStep": nextWorkflowStep,
[`evaluation.${replyField}`]: replyPayload, [`evaluation.${replyField}`]: replyPayload,
@@ -5050,6 +5096,10 @@ export class ExpertClaimService {
buildFileLink, buildFileLink,
resolveStoredFileUrl, resolveStoredFileUrl,
}); });
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
history: (claim.damage as any)?.partSelectionHistory,
currentSelectedParts: selectedNormExpert,
});
// Vehicle payload — fall back to blame inquiry if claim vehicle is sparse // Vehicle payload — fall back to blame inquiry if claim vehicle is sparse
let vehiclePayload = claim.vehicle as any; let vehiclePayload = claim.vehicle as any;
@@ -5229,6 +5279,7 @@ export class ExpertClaimService {
: undefined, : undefined,
carAngles, carAngles,
damagedParts, damagedParts,
damagedPartsHistory,
awaitingFactorValidation: isFactorValidationPending, awaitingFactorValidation: isFactorValidationPending,
requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval, requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval,
fileMakerRejectionCount: (claim as any).fileMakerRejectionCount ?? 0, fileMakerRejectionCount: (claim as any).fileMakerRejectionCount ?? 0,
@@ -5606,33 +5657,58 @@ export class ExpertClaimService {
); );
const mergedExpertAdded = [...existingExpertAdded, ...expertAddedToAppend]; const mergedExpertAdded = [...existingExpertAdded, ...expertAddedToAppend];
const changedAt = new Date();
const partSelectionRevision = buildDamagedPartSelectionRevision({
revisionId: new Types.ObjectId().toString(),
changedAt,
changedBy: {
actorId: actor.sub,
actorName: actor.fullName,
actorType: "damage_expert",
},
expertProfileSnapshot: damagedPartsEditSnapshot,
previousParts: previousNorm,
selectedParts: nextNorm,
previousMedia: prevMedia,
});
const $set: Record<string, unknown> = { const $set: Record<string, unknown> = {
"damage.selectedParts": nextNorm, "damage.selectedParts": nextNorm,
"media.damagedParts": nextMedia, "media.damagedParts": nextMedia,
"damage.expertAddedParts": mergedExpertAdded, "damage.expertAddedParts": mergedExpertAdded,
}; };
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { const push: Record<string, unknown> = {
$set, history: {
$push: { type: "EXPERT_DAMAGED_PARTS_UPDATED",
history: { actor: {
type: "EXPERT_DAMAGED_PARTS_UPDATED", actorId: new Types.ObjectId(actor.sub),
actor: { actorName: actor.fullName,
actorId: new Types.ObjectId(actor.sub), actorType: "damage_expert",
actorName: actor.fullName, },
actorType: "damage_expert", timestamp: changedAt,
}, metadata: {
timestamp: new Date(), previousSelectedParts: previous,
metadata: { selectedParts: nextNorm,
previousSelectedParts: previous, expertAddedParts: mergedExpertAdded,
selectedParts: nextNorm, ...(partSelectionRevision && {
expertAddedParts: mergedExpertAdded, partSelectionRevisionId: partSelectionRevision.revisionId,
...(damagedPartsEditSnapshot && { removedParts: partSelectionRevision.removedParts,
expertProfileSnapshot: damagedPartsEditSnapshot, addedParts: partSelectionRevision.addedParts,
}), }),
}, ...(damagedPartsEditSnapshot && {
expertProfileSnapshot: damagedPartsEditSnapshot,
}),
}, },
}, },
};
if (partSelectionRevision) {
push["damage.partSelectionHistory"] = partSelectionRevision;
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set,
$push: push,
}); });
return { return {
@@ -5640,6 +5716,7 @@ export class ExpertClaimService {
selectedParts: nextNorm, selectedParts: nextNorm,
previousSelectedParts: previous, previousSelectedParts: previous,
expertAddedParts: mergedExpertAdded, expertAddedParts: mergedExpertAdded,
partSelectionRevision,
message: "Damaged parts updated successfully.", message: "Damaged parts updated successfully.",
}; };
} }

View File

@@ -85,6 +85,7 @@ import {
localizeTimelineMetadata, localizeTimelineMetadata,
} from "./helper/timeline-fa-labels"; } from "./helper/timeline-fa-labels";
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher"; import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
import { serializeDamagedPartSelectionHistory } from "src/helpers/claim-damaged-part-audit";
@Injectable() @Injectable()
export class ExpertInsurerService { export class ExpertInsurerService {
@@ -524,6 +525,8 @@ export class ExpertInsurerService {
const d = { ...(damage as Record<string, unknown>) }; const d = { ...(damage as Record<string, unknown>) };
delete d.selectedOuterParts; delete d.selectedOuterParts;
delete d.selectedPartIds; delete d.selectedPartIds;
// Expose the stable, URL-enriched contract instead of raw audit storage.
delete d.partSelectionHistory;
return d; return d;
} }
@@ -677,6 +680,10 @@ export class ExpertInsurerService {
catalogLikeKeyFromPart, catalogLikeKeyFromPart,
buildFileLink, buildFileLink,
}); });
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
history: (claim as any).damage?.partSelectionHistory,
currentSelectedParts: selectedNorm,
});
// Then add damagedParts to the return object // Then add damagedParts to the return object
const requiredDocs = claim.requiredDocuments as any; const requiredDocs = claim.requiredDocuments as any;
@@ -797,6 +804,7 @@ export class ExpertInsurerService {
: undefined, : undefined,
carAngles, carAngles,
damagedParts, damagedParts,
damagedPartsHistory,
videoCapture, videoCapture,
evaluation: evaluationEnriched, evaluation: evaluationEnriched,
userRating: claim.userRating, userRating: claim.userRating,

View 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();
});
});

View 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,
);
}

View File

@@ -110,12 +110,42 @@ for (const [seg, key] of Object.entries(SEGMENT_ALIASES)) {
NORM_TO_PART_KEY.set(seg, key); NORM_TO_PART_KEY.set(seg, key);
} }
const PERSIAN_LABEL_ALIASES: Array<[string, string]> = [
["گلگیر عقب", "backFender"],
["درب عقب", "backDoor"],
["درب جلو", "frontDoor"],
["گلگیر جلو", "frontFender"],
["سپر جلو", "frontBumper"],
["سپر عقب", "frontBumper"],
["درب موتور", "Hood"],
["کاپوت", "Hood"],
["درب صندوق", "Trunk"],
["صندوق عقب", "Trunk"],
["سقف", "Roof"],
["کلاف", "coil"],
["ستون", "column"],
["سینی جلو", "frontTray"],
["سینی عقب", "backTray"],
["شاسی جلو", "frontChassis"],
["شاسی عقب", "backChassis"],
["رکاب", "carFootrest"],
["کف اتاق", "carFloor"],
];
export function normalizePriceDropKey(str: string): string { export function normalizePriceDropKey(str: string): string {
return String(str ?? "") return String(str ?? "")
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]/g, ""); .replace(/[^a-z0-9]/g, "");
} }
function normalizePersianLabel(str: string): string {
return String(str ?? "")
.replace(/[يى]/g, "ی")
.replace(/ك/g, "ک")
.replace(/[\u200c\s()\-_]/g, "")
.trim();
}
export function parsePriceDropNumber(input: number | string): number { export function parsePriceDropNumber(input: number | string): number {
if (typeof input === "number" && Number.isFinite(input)) return input; if (typeof input === "number" && Number.isFinite(input)) return input;
return Number( return Number(
@@ -191,6 +221,7 @@ export function buildPriceDropCatalogForApi(): Array<{
export function resolvePriceDropPartKeyFromDamagePart(part: { export function resolvePriceDropPartKeyFromDamagePart(part: {
name?: string; name?: string;
catalogKey?: string; catalogKey?: string;
label_fa?: string;
}): string | null { }): string | null {
const candidates: string[] = []; const candidates: string[] = [];
if (part.catalogKey) { if (part.catalogKey) {
@@ -205,6 +236,11 @@ export function resolvePriceDropPartKeyFromDamagePart(part: {
const hit = NORM_TO_PART_KEY.get(norm); const hit = NORM_TO_PART_KEY.get(norm);
if (hit && PRICE_DROP_PART_TABLE[hit]) return hit; if (hit && PRICE_DROP_PART_TABLE[hit]) return hit;
} }
const normalizedLabel = normalizePersianLabel(part.label_fa ?? part.name ?? "");
for (const [label, key] of PERSIAN_LABEL_ALIASES) {
if (normalizedLabel.includes(normalizePersianLabel(label))) return key;
}
return null; return null;
} }

View File

@@ -128,21 +128,21 @@ describe("getExpertReplyPricingValidationError", () => {
it.each([ it.each([
["price", "99,999", "parts[0].price"], ["price", "99,999", "parts[0].price"],
["salary", "10,000,000,001", "parts[0].salary"], ["salary", "100,000,000,001", "parts[0].salary"],
["totalPayment", "99,999", "parts[0].totalPayment"], ["totalPayment", "99,999", "parts[0].totalPayment"],
["daghi.price", "10,000,000,001", "parts[0].daghi.price"], ["daghi.price", "100,000,000,001", "parts[0].daghi.price"],
])( ])(
"enforces the amount range for %s", "enforces the amount range for %s",
(field, invalidValue, expectedField) => { (field, invalidValue, expectedField) => {
const part = { const part = {
partId: 201, partId: 201,
typeOfDamage: TypeOfDamage.Change, typeOfDamage: TypeOfDamage.Change,
price: "100000", price: "1000000",
salary: "100000", salary: "1000000",
totalPayment: "100000", totalPayment: "1000000",
daghi: { daghi: {
option: DaghiOption.RECYCLED_PARTS_VALUE, option: DaghiOption.RECYCLED_PARTS_VALUE,
price: "100000", price: "1000000",
}, },
}; };
if (field === "daghi.price") part.daghi.price = invalidValue; if (field === "daghi.price") part.daghi.price = invalidValue;

View File

@@ -43,36 +43,36 @@ describe("resolveSelectedPartByPartId", () => {
}); });
it("resolves catalog id from outer catalog when not on claim", () => { it("resolves catalog id from outer catalog when not on claim", () => {
const hit = resolveCatalogPartByPartId(201, ClaimVehicleTypeV2.HATCHBACK); const hit = resolveCatalogPartByPartId(36, ClaimVehicleTypeV2.HATCHBACK);
expect(hit?.id).toBe(201); expect(hit?.id).toBe(36);
expect(hit?.side).toBe("left"); expect(hit?.side).toBe("");
expect(hit?.catalogKey).toBe("left_backfender"); expect(hit?.catalogKey).toBe("36");
}); });
it("resolvePartForExpertReply uses catalog for new expert line", () => { it("resolvePartForExpertReply uses catalog for new expert line", () => {
const hit = resolvePartForExpertReply( const hit = resolvePartForExpertReply(
201, 36,
[], [],
ClaimVehicleTypeV2.HATCHBACK, ClaimVehicleTypeV2.HATCHBACK,
); );
expect(hit?.id).toBe(201); expect(hit?.id).toBe(36);
}); });
it("sanitize fixes Persian side and re-hydrates from catalog id", () => { it("sanitize fixes Persian side and re-hydrates from catalog id", () => {
const fixed = sanitizeDamageSelectedPartV2( const fixed = sanitizeDamageSelectedPartV2(
{ {
id: 201, id: 36,
name: "گلگیر عقب (چپ)", name: "گلگير عقب سمت راننده",
side: "چپ", side: "چپ",
label_fa: "", label_fa: "",
catalogKey: "چپ", catalogKey: "چپ",
}, },
ClaimVehicleTypeV2.HATCHBACK, ClaimVehicleTypeV2.HATCHBACK,
); );
expect(fixed.side).toBe("left"); expect(fixed.side).toBe("");
expect(fixed.name).toBe("backfender"); expect(fixed.name).toBe("36");
expect(fixed.catalogKey).toBe("left_backfender"); expect(fixed.catalogKey).toBe("36");
expect(catalogPartIdFromSelectedPart(fixed)).toBe(201); expect(catalogPartIdFromSelectedPart(fixed)).toBe(36);
}); });
it("internal parts have null catalog partId", () => { it("internal parts have null catalog partId", () => {
@@ -90,30 +90,30 @@ describe("resolveSelectedPartByPartId", () => {
const fixed = sanitizeDamageSelectedPartV2( const fixed = sanitizeDamageSelectedPartV2(
{ {
id: null, id: null,
name: "گلگیر عقب (چپ)", name: "گلگير عقب سمت راننده",
side: "internal", side: "internal",
label_fa: "گلگیر عقب (چپ)", label_fa: "گلگير عقب سمت راننده",
}, },
ClaimVehicleTypeV2.HATCHBACK, ClaimVehicleTypeV2.HATCHBACK,
); );
expect(fixed.id).toBe(201); expect(fixed.id).toBe(36);
expect(fixed.side).toBe("left"); expect(fixed.side).toBe("");
expect(fixed.name).toBe("backfender"); expect(fixed.name).toBe("36");
expect(fixed.catalogKey).toBe("left_backfender"); expect(fixed.catalogKey).toBe("36");
}); });
it("price drop resolves catalog id when claim row is corrupt", () => { it("price drop resolves catalog id when claim row is corrupt", () => {
const corrupt: DamageSelectedPartV2[] = [ const corrupt: DamageSelectedPartV2[] = [
{ {
id: null, id: null,
name: "گلگیر عقب (چپ)", name: "گلگير عقب سمت راننده",
side: "internal", side: "internal",
label_fa: "گلگیر عقب (چپ)", label_fa: "گلگير عقب سمت راننده",
}, },
]; ];
const { coefficients, errors } = buildCoefficientsFromPartSeverities( const { coefficients, errors } = buildCoefficientsFromPartSeverities(
corrupt, corrupt,
[{ partId: 201, severity: "Minor" }], [{ partId: 36, severity: "Minor" }],
ClaimVehicleTypeV2.HATCHBACK, ClaimVehicleTypeV2.HATCHBACK,
); );
expect(errors).toHaveLength(0); expect(errors).toHaveLength(0);

View File

@@ -1,6 +1,8 @@
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { ProfileController } from "./profile.controller"; import { ProfileController } from "./profile.controller";
import { ProfileService } from "./profile.service"; import { ProfileService } from "./profile.service";
import { PlatesService } from "src/plates/plates.service";
import { JwtService } from "@nestjs/jwt";
describe("ProfileController", () => { describe("ProfileController", () => {
let controller: ProfileController; let controller: ProfileController;
@@ -8,7 +10,11 @@ describe("ProfileController", () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [ProfileController], controllers: [ProfileController],
providers: [ProfileService], providers: [
{ provide: ProfileService, useValue: {} },
{ provide: PlatesService, useValue: {} },
{ provide: JwtService, useValue: {} },
],
}).compile(); }).compile();
controller = module.get<ProfileController>(ProfileController); controller = module.get<ProfileController>(ProfileController);

View File

@@ -1,12 +1,22 @@
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { ProfileService } from "./profile.service"; import { ProfileService } from "./profile.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service";
describe("ProfileService", () => { describe("ProfileService", () => {
let service: ProfileService; let service: ProfileService;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ProfileService], providers: [
ProfileService,
{
provide: UserDbService,
useValue: {
findOne: jest.fn(),
findOneAndUpdate: jest.fn(),
},
},
],
}).compile(); }).compile();
service = module.get<ProfileService>(ProfileService); service = module.get<ProfileService>(ProfileService);

View File

@@ -5,6 +5,9 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
import { CreationMethod } from "./entities/schema/request-management.schema"; import { CreationMethod } from "./entities/schema/request-management.schema";
import { PartyRole } from "./entities/schema/partyRole.enum"; import { PartyRole } from "./entities/schema/partyRole.enum";
import { RequestManagementService } from "./request-management.service"; import { RequestManagementService } from "./request-management.service";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
describe("RequestManagementService V4 FileMaker workflow", () => { describe("RequestManagementService V4 FileMaker workflow", () => {
it("persists FIRST_INITIAL_FORM after the first party OTP is verified", async () => { it("persists FIRST_INITIAL_FORM after the first party OTP is verified", async () => {
@@ -216,4 +219,138 @@ describe("RequestManagementService V4 FileMaker workflow", () => {
WorkflowStep.FIRST_VIDEO, WorkflowStep.FIRST_VIDEO,
); );
}); });
it.each([false, true])(
"resumes V4/V5 FileMaker work from the linked claim during partial document upload (approval=%s)",
async (requiresFileMakerApproval) => {
const fileMakerId = new Types.ObjectId();
const blameId = new Types.ObjectId();
const claimId = new Types.ObjectId();
const request = {
_id: blameId,
publicId: "BL-FILE-MAKER-RESUME",
requestNo: "BL-RESUME",
type: BlameRequestType.THIRD_PARTY,
status: CaseStatus.OPEN,
isMadeByFileMaker: true,
initiatedByFieldExpertId: fileMakerId,
requiresFileMakerApproval,
parties: [],
workflow: {
currentStep: WorkflowStep.SECOND_COMPLETED,
nextStep: WorkflowStep.WAITING_FOR_GUILT_DECISION,
completedSteps: [
WorkflowStep.FIRST_COMPLETED,
WorkflowStep.SECOND_COMPLETED,
],
},
};
const claim = {
_id: claimId,
blameRequestId: blameId,
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
workflow: {
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
},
};
const service =
new (RequestManagementService as any)() as RequestManagementService;
(service as any).blameRequestDbService = {
findById: jest.fn().mockResolvedValue(request),
find: jest.fn().mockResolvedValue([request]),
};
(service as any).claimCaseDbService = {
findOne: jest.fn().mockResolvedValue(claim),
find: jest.fn().mockResolvedValue([claim]),
};
const reopened = await service.getMyFileMakerFileDetail(
{ sub: String(fileMakerId), role: RoleEnum.FILE_MAKER },
String(blameId),
);
expect(reopened.status).toBe(CaseStatus.OPEN);
expect(reopened.claimStatus).toBe(
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
);
expect(reopened.fileMakerResume).toEqual({
action: "UPLOAD_REQUIRED_DOCUMENTS",
entity: "CLAIM",
entityId: String(claimId),
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
});
const list = await service.getMyFileMakerFiles({
sub: String(fileMakerId),
role: RoleEnum.FILE_MAKER,
});
expect(list.list[0]).toEqual(
expect.objectContaining({
linkedClaimId: String(claimId),
claimStatus: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
fileMakerResume: expect.objectContaining({
action: "UPLOAD_REQUIRED_DOCUMENTS",
entityId: String(claimId),
}),
}),
);
},
);
it("does not leave the blame narrative before all required signatures", async () => {
const fileMakerId = new Types.ObjectId();
const blameId = new Types.ObjectId();
const claimId = new Types.ObjectId();
const request = {
_id: blameId,
publicId: "BL-FILE-MAKER-NARRATIVE",
requestNo: "BL-NARRATIVE",
type: BlameRequestType.THIRD_PARTY,
status: CaseStatus.OPEN,
isMadeByFileMaker: true,
initiatedByFieldExpertId: fileMakerId,
parties: [],
workflow: {
currentStep: WorkflowStep.FIRST_DESCRIPTION,
nextStep: WorkflowStep.FIRST_SIGN,
completedSteps: [WorkflowStep.FIRST_VOICE],
},
};
const claim = {
_id: claimId,
blameRequestId: blameId,
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
workflow: {
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
},
};
const service =
new (RequestManagementService as any)() as RequestManagementService;
(service as any).blameRequestDbService = {
findById: jest.fn().mockResolvedValue(request),
};
(service as any).claimCaseDbService = {
findOne: jest.fn().mockResolvedValue(claim),
};
const reopened = await service.getMyFileMakerFileDetail(
{ sub: String(fileMakerId), role: RoleEnum.FILE_MAKER },
String(blameId),
);
expect(reopened.fileMakerResume).toEqual(
expect.objectContaining({
action: "CONTINUE_BLAME",
entity: "BLAME",
entityId: String(blameId),
currentStep: WorkflowStep.FIRST_DESCRIPTION,
}),
);
});
}); });

View File

@@ -5,6 +5,7 @@ import {
Get, Get,
Param, Param,
Post, Post,
Query,
Put, Put,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
@@ -44,6 +45,7 @@ import {
UploadRequiredDocumentV2ResponseDto, UploadRequiredDocumentV2ResponseDto,
} from "src/claim-request-management/dto/upload-document-v2.dto"; } from "src/claim-request-management/dto/upload-document-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto"; import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
/** /**
* V4 FileMaker flow — first half of the split blame workflow. * V4 FileMaker flow — first half of the split blame workflow.
@@ -96,10 +98,13 @@ export class FileMakerBlameV4Controller {
@Get("my-files") @Get("my-files")
@ApiOperation({ @ApiOperation({
summary: "List all blame files created by this FileMaker", summary: "List all blame files created by this FileMaker",
description: "Returns all V4 FileMaker blame files initiated by the authenticated FileMaker.", description: "Returns V4 FileMaker blame files using the shared search, sort, filter, and pagination query contract.",
}) })
async getMyFiles(@CurrentUser() fileMaker: any) { async getMyFiles(
return this.requestManagementService.getMyFileMakerFiles(fileMaker); @CurrentUser() fileMaker: any,
@Query() query: ListQueryV2Dto,
) {
return this.requestManagementService.getMyFileMakerFiles(fileMaker, query);
} }
@Get("my-files/:requestId") @Get("my-files/:requestId")

View File

@@ -5,6 +5,7 @@ import {
Get, Get,
Param, Param,
Post, Post,
Query,
Put, Put,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
@@ -44,6 +45,7 @@ import {
UploadRequiredDocumentV2ResponseDto, UploadRequiredDocumentV2ResponseDto,
} from "src/claim-request-management/dto/upload-document-v2.dto"; } from "src/claim-request-management/dto/upload-document-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto"; import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
/** /**
* V5 FileMaker flow — identical to V4 but under the /v5/ prefix. * V5 FileMaker flow — identical to V4 but under the /v5/ prefix.
@@ -95,10 +97,13 @@ export class FileMakerBlameV5Controller {
@Get("my-files") @Get("my-files")
@ApiOperation({ @ApiOperation({
summary: "List all blame files created by this FileMaker", summary: "List all blame files created by this FileMaker",
description: "Returns all V5 FileMaker blame files initiated by the authenticated FileMaker.", description: "Returns V5 FileMaker blame files using the shared search, sort, filter, and pagination query contract.",
}) })
async getMyFiles(@CurrentUser() fileMaker: any) { async getMyFiles(
return this.requestManagementService.getMyFileMakerFiles(fileMaker); @CurrentUser() fileMaker: any,
@Query() query: ListQueryV2Dto,
) {
return this.requestManagementService.getMyFileMakerFiles(fileMaker, query);
} }
@Get("my-files/:requestId") @Get("my-files/:requestId")

View File

@@ -10,6 +10,7 @@ import {
Patch, Patch,
Post, Post,
Put, Put,
Query,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@@ -48,6 +49,7 @@ import {
CapturePartV2Dto, CapturePartV2Dto,
CapturePartV2ResponseDto, CapturePartV2ResponseDto,
} from "src/claim-request-management/dto/capture-part-v2.dto"; } from "src/claim-request-management/dto/capture-part-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto"; import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
/** /**
@@ -88,10 +90,16 @@ export class FileReviewerBlameV4Controller {
@ApiOperation({ @ApiOperation({
summary: "List available and assigned FileMaker blame files", summary: "List available and assigned FileMaker blame files",
description: description:
"Returns V4 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.", "Returns V4 FileMaker blame files in this reviewer's insurer using the shared search, sort, filter, and pagination query contract: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
}) })
async getMyFiles(@CurrentUser() fileReviewer: any) { async getMyFiles(
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer); @CurrentUser() fileReviewer: any,
@Query() query: ListQueryV2Dto,
) {
return this.requestManagementService.getMyFileReviewerFiles(
fileReviewer,
query,
);
} }
@Get("my-files/:requestId") @Get("my-files/:requestId")

View File

@@ -9,6 +9,7 @@ import {
Patch, Patch,
Post, Post,
Put, Put,
Query,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@@ -48,6 +49,7 @@ import {
CapturePartV2ResponseDto, CapturePartV2ResponseDto,
} from "src/claim-request-management/dto/capture-part-v2.dto"; } from "src/claim-request-management/dto/capture-part-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto"; import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
/** /**
* V5 FileReviewer flow — same as V4 except after the damage expert completes * V5 FileReviewer flow — same as V4 except after the damage expert completes
@@ -86,10 +88,16 @@ export class FileReviewerBlameV5Controller {
@ApiOperation({ @ApiOperation({
summary: "List available and assigned FileMaker blame files", summary: "List available and assigned FileMaker blame files",
description: description:
"Returns V5 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.", "Returns V5 FileMaker blame files in this reviewer's insurer using the shared search, sort, filter, and pagination query contract: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
}) })
async getMyFiles(@CurrentUser() fileReviewer: any) { async getMyFiles(
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer); @CurrentUser() fileReviewer: any,
@Query() query: ListQueryV2Dto,
) {
return this.requestManagementService.getMyFileReviewerFiles(
fileReviewer,
query,
);
} }
@Get("my-files/:requestId") @Get("my-files/:requestId")

View File

@@ -74,7 +74,7 @@ describe("inquiry participant resolver", () => {
}, },
vin: "NAAM01E15HK123456", vin: "NAAM01E15HK123456",
}), }),
).toThrow("previousPolicyholderNationalCode"); ).toThrow("کد ملی بیمه‌گذار قبلی");
}); });
it("defaults an omitted registration state to CURRENT", () => { it("defaults an omitted registration state to CURRENT", () => {
@@ -102,7 +102,7 @@ describe("inquiry participant resolver", () => {
}, },
vin: "TOO-SHORT", vin: "TOO-SHORT",
}), }),
).toThrow("vehicle.vin must contain exactly 17 characters."); ).toThrow("شماره شاسی (VIN) باید دقیقاً ۱۷ کاراکتر باشد.");
}); });
it("rejects an incomplete current plate", () => { it("rejects an incomplete current plate", () => {
@@ -115,7 +115,7 @@ describe("inquiry participant resolver", () => {
ir: "22", ir: "22",
}, },
}), }),
).toThrow("vehicle.currentPlate.centerDigits is required."); ).toThrow("سه رقم میانی پلاک در پلاک فعلی الزامی است.");
}); });
it("rejects invalid vehicle choice values", () => { it("rejects invalid vehicle choice values", () => {
@@ -129,7 +129,7 @@ describe("inquiry participant resolver", () => {
ir: "22", ir: "22",
}, },
}), }),
).toThrow("vehicle.registrationState must be CURRENT or RECENTLY_TRANSFERRED."); ).toThrow("وضعیت پلاک خودرو باید «فعلی» یا «تازه تعویض‌شده» باشد.");
expect(() => expect(() =>
resolveInquiryVehicle({ resolveInquiryVehicle({
@@ -141,7 +141,7 @@ describe("inquiry participant resolver", () => {
}, },
isNewCar: "false" as any, isNewCar: "false" as any,
}), }),
).toThrow("vehicle.isNewCar must be a boolean."); ).toThrow("وضعیت صفر بودن خودرو نامعتبر است.");
}); });
it("rejects a previous policyholder national code for a current registration", () => { it("rejects a previous policyholder national code for a current registration", () => {
@@ -647,7 +647,7 @@ describe("inquiry participant resolver", () => {
expect(() => expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, input as any), resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, input as any),
).toThrow("THIRD_PARTY_POLICYHOLDER does not support the unknown option."); ).toThrow("ثبت بیمه‌گذار شخص ثالث به‌صورت نامشخص امکان‌پذیر نیست.");
}); });
it("strips the removed unknown field from historical participant output", () => { it("strips the removed unknown field from historical participant output", () => {

View File

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

View File

@@ -17,6 +17,7 @@ import {
ReinquiryInquiriesResponseDto, ReinquiryInquiriesResponseDto,
ReinquiryPartyResultDto, ReinquiryPartyResultDto,
} from "./dto/reinquiry-inquiries.dto"; } from "./dto/reinquiry-inquiries.dto";
import { getInquiryErrorMessage } from "src/common/utils/inquiry-error";
type PlateParts = { type PlateParts = {
leftDigits: number; leftDigits: number;
@@ -48,7 +49,7 @@ export class InquiryRefreshService {
if (!body.publicId && !body.blameRequestId && limit === 0) { if (!body.publicId && !body.blameRequestId && limit === 0) {
throw new BadRequestException( throw new BadRequestException(
"Provide publicId, blameRequestId, or limit for bulk refresh.", "برای اجرای مجدد استعلام، شناسه عمومی پرونده، شناسه پرونده یا تعداد پرونده‌ها را وارد کنید.",
); );
} }
@@ -58,14 +59,16 @@ export class InquiryRefreshService {
if (body.publicId) filter.publicId = body.publicId; if (body.publicId) filter.publicId = body.publicId;
if (body.blameRequestId) { if (body.blameRequestId) {
if (!Types.ObjectId.isValid(body.blameRequestId)) { if (!Types.ObjectId.isValid(body.blameRequestId)) {
throw new BadRequestException("Invalid blameRequestId"); throw new BadRequestException("شناسه پرونده معتبر نیست.");
} }
filter._id = new Types.ObjectId(body.blameRequestId); filter._id = new Types.ObjectId(body.blameRequestId);
} }
let docs = await this.blameRequestDbService.find(filter, { lean: true }); let docs = await this.blameRequestDbService.find(filter, { lean: true });
if (!docs.length) { if (!docs.length) {
throw new NotFoundException("No matching blame cases found"); throw new NotFoundException(
"پرونده تقصیر مطابق اطلاعات واردشده یافت نشد.",
);
} }
docs = limit > 0 ? docs.slice(0, limit) : docs; docs = limit > 0 ? docs.slice(0, limit) : docs;
@@ -111,8 +114,8 @@ export class InquiryRefreshService {
if (index === -1) { if (index === -1) {
partyResults.push({ partyResults.push({
role, role,
thirdParty: { ok: false, message: "party not found" }, thirdParty: { ok: false, message: "طرف پرونده یافت نشد." },
person: { ok: false, message: "party not found" }, person: { ok: false, message: "طرف پرونده یافت نشد." },
}); });
continue; continue;
} }
@@ -150,7 +153,9 @@ export class InquiryRefreshService {
blameRequestId: new Types.ObjectId(String(doc._id)), blameRequestId: new Types.ObjectId(String(doc._id)),
}); });
claimsUpdated = linkedClaims.length; claimsUpdated = linkedClaims.length;
this.logger.log(`[dry-run] ${label} would update blame + ${claimsUpdated} claim(s)`); this.logger.log(
`[dry-run] ${label} would update blame + ${claimsUpdated} claim(s)`,
);
} }
return { return {
@@ -208,9 +213,7 @@ export class InquiryRefreshService {
plateId: party?.vehicle?.plateId, plateId: party?.vehicle?.plateId,
...(plate ? { plate } : {}), ...(plate ? { plate } : {}),
...(nationalCode ? { nationalCode } : {}), ...(nationalCode ? { nationalCode } : {}),
...(birthDate !== null && birthDate !== undefined ...(birthDate !== null && birthDate !== undefined ? { birthDate } : {}),
? { birthDate }
: {}),
}; };
if (dryRun) { if (dryRun) {
@@ -223,8 +226,8 @@ export class InquiryRefreshService {
result.thirdParty = { result.thirdParty = {
ok: false, ok: false,
message: !plate message: !plate
? "plate not found on party" ? "پلاک برای این طرف پرونده ثبت نشده است."
: "nationalCodeOfInsurer/nationalCodeOfDriver missing", : "کد ملی برای این طرف پرونده ثبت نشده است.",
}; };
} else { } else {
await this.waitForRateLimit(); await this.waitForRateLimit();
@@ -249,7 +252,9 @@ export class InquiryRefreshService {
inquiriesChanged = true; inquiriesChanged = true;
result.thirdParty = { result.thirdParty = {
ok: false, ok: false,
message: inquiry.mapped.Error.Message || "third-party inquiry error", message:
inquiry.mapped.Error.Message ||
getInquiryErrorMessage(inquiry.mapped, "thirdPartyPlate"),
}; };
} else { } else {
nextParty = this.applyThirdPartyToParty( nextParty = this.applyThirdPartyToParty(
@@ -292,11 +297,18 @@ export class InquiryRefreshService {
} }
} }
} catch (error: any) { } catch (error: any) {
this.recordPartyInquiry(inquiries, "thirdParty", role, false, {}, error); this.recordPartyInquiry(
inquiries,
"thirdParty",
role,
false,
{},
error,
);
inquiriesChanged = true; inquiriesChanged = true;
result.thirdParty = { result.thirdParty = {
ok: false, ok: false,
message: error?.message || String(error), message: getInquiryErrorMessage(error, "thirdPartyPlate"),
}; };
} }
} }
@@ -305,8 +317,8 @@ export class InquiryRefreshService {
result.person = { result.person = {
ok: false, ok: false,
message: !nationalCode message: !nationalCode
? "nationalCodeOfInsurer/nationalCodeOfDriver missing" ? "کد ملی برای این طرف پرونده ثبت نشده است."
: "insurerBirthday/driverBirthday missing", : "تاریخ تولد برای این طرف پرونده ثبت نشده است.",
}; };
} else { } else {
await this.waitForRateLimit(); await this.waitForRateLimit();
@@ -333,7 +345,7 @@ export class InquiryRefreshService {
inquiriesChanged = true; inquiriesChanged = true;
result.person = { result.person = {
ok: false, ok: false,
message: error?.message || String(error), message: getInquiryErrorMessage(error, "personalIdentity"),
}; };
} }
} }
@@ -477,12 +489,14 @@ export class InquiryRefreshService {
): Promise<void> { ): Promise<void> {
const blameDoc = await this.blameRequestDbService.findById(blameId); const blameDoc = await this.blameRequestDbService.findById(blameId);
if (!blameDoc) { if (!blameDoc) {
throw new NotFoundException(`Blame case ${blameId} not found`); throw new NotFoundException("پرونده تقصیر یافت نشد.");
} }
for (const role of roles) { for (const role of roles) {
const memParty = updatedParties.find((party) => party?.role === role); const memParty = updatedParties.find((party) => party?.role === role);
const docIdx = blameDoc.parties.findIndex((party) => party?.role === role); const docIdx = blameDoc.parties.findIndex(
(party) => party?.role === role,
);
if (!memParty || docIdx === -1) continue; if (!memParty || docIdx === -1) continue;
const party = blameDoc.parties[docIdx]; const party = blameDoc.parties[docIdx];
@@ -514,7 +528,9 @@ export class InquiryRefreshService {
party.insurance.company = memParty.insurance.company; party.insurance.company = memParty.insurance.company;
} }
if (memParty.insurance.financialCeiling !== undefined) { if (memParty.insurance.financialCeiling !== undefined) {
party.insurance.financialCeiling = String(memParty.insurance.financialCeiling); party.insurance.financialCeiling = String(
memParty.insurance.financialCeiling,
);
} }
if (memParty.insurance.startDate !== undefined) { if (memParty.insurance.startDate !== undefined) {
party.insurance.startDate = memParty.insurance.startDate; party.insurance.startDate = memParty.insurance.startDate;
@@ -541,7 +557,8 @@ export class InquiryRefreshService {
}); });
const inquiryPatch: Record<string, unknown> = {}; const inquiryPatch: Record<string, unknown> = {};
if (inquiries.thirdParty) inquiryPatch["inquiries.thirdParty"] = inquiries.thirdParty; if (inquiries.thirdParty)
inquiryPatch["inquiries.thirdParty"] = inquiries.thirdParty;
if (inquiries.person) inquiryPatch["inquiries.person"] = inquiries.person; if (inquiries.person) inquiryPatch["inquiries.person"] = inquiries.person;
if (!Object.keys(inquiryPatch).length) return 0; if (!Object.keys(inquiryPatch).length) return 0;
@@ -590,14 +607,16 @@ export class InquiryRefreshService {
private normalizeInquiryError(error: any): Record<string, unknown> { private normalizeInquiryError(error: any): Record<string, unknown> {
return { return {
message: error?.message || String(error), message: getInquiryErrorMessage(error, "generic"),
status: error?.status ?? error?.response?.status, status: error?.status ?? error?.response?.status,
data: error?.data ?? error?.response?.data, data: error?.data ?? error?.response?.data,
}; };
} }
private resolvePartyPlate(party: Record<string, any>): PlateParts | null { private resolvePartyPlate(party: Record<string, any>): PlateParts | null {
const fromPlateId = this.parsePlateFromCompactString(party?.vehicle?.plateId); const fromPlateId = this.parsePlateFromCompactString(
party?.vehicle?.plateId,
);
if (fromPlateId) return fromPlateId; if (fromPlateId) return fromPlateId;
const candidates = [ const candidates = [
@@ -608,14 +627,24 @@ export class InquiryRefreshService {
].filter(Boolean); ].filter(Boolean);
for (const candidate of candidates) { for (const candidate of candidates) {
const leftDigits = this.firstPresent(candidate.Plk1, candidate.platePartOne); const leftDigits = this.firstPresent(
candidate.Plk1,
candidate.platePartOne,
);
const centerAlphabet = this.firstPresent( const centerAlphabet = this.firstPresent(
candidate.plateLetterid, candidate.plateLetterid,
candidate.plateLetterId, candidate.plateLetterId,
candidate.plateLetterTitle, candidate.plateLetterTitle,
); );
const centerDigits = this.firstPresent(candidate.Plk3, candidate.platePartThree); const centerDigits = this.firstPresent(
const ir = this.firstPresent(candidate.PlkSrl, candidate.plkSrl, candidate.plateSerialNumber); candidate.Plk3,
candidate.platePartThree,
);
const ir = this.firstPresent(
candidate.PlkSrl,
candidate.plkSrl,
candidate.plateSerialNumber,
);
if ( if (
leftDigits !== undefined && leftDigits !== undefined &&
@@ -623,7 +652,9 @@ export class InquiryRefreshService {
centerDigits !== undefined && centerDigits !== undefined &&
ir !== undefined ir !== undefined
) { ) {
const plateLetter = this.plateNormalizer.normalizePlateText(String(centerAlphabet)); const plateLetter = this.plateNormalizer.normalizePlateText(
String(centerAlphabet),
);
const parsed: PlateParts = { const parsed: PlateParts = {
leftDigits: Number(leftDigits), leftDigits: Number(leftDigits),
centerAlphabet: plateLetter, centerAlphabet: plateLetter,
@@ -653,7 +684,9 @@ export class InquiryRefreshService {
const ir = Number(irRaw); const ir = Number(irRaw);
const leftDigits = Number(leftRaw); const leftDigits = Number(leftRaw);
const centerDigits = Number(centerRaw); const centerDigits = Number(centerRaw);
const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || "")); const centerAlphabet = this.plateNormalizer.normalizePlateText(
String(alphaRaw || ""),
);
if ( if (
!Number.isFinite(ir) || !Number.isFinite(ir) ||
!Number.isFinite(leftDigits) || !Number.isFinite(leftDigits) ||
@@ -685,6 +718,8 @@ export class InquiryRefreshService {
} }
private firstPresent(...values: unknown[]): unknown { private firstPresent(...values: unknown[]): unknown {
return values.find((value) => value !== undefined && value !== null && value !== ""); return values.find(
(value) => value !== undefined && value !== null && value !== "",
);
} }
} }

View File

@@ -15,12 +15,8 @@ describe("damaged-party inquiry requirements", () => {
const service = getService(); const service = getService();
await expect( await expect(
(service as any).validateShebaV3( (service as any).validateShebaV3(undefined, "0012345678", "client-id"),
undefined, ).rejects.toThrow("شماره شبا برای طرف زیان‌دیده الزامی است.");
"0012345678",
"client-id",
),
).rejects.toThrow("sheba is required for the damaged party.");
expect( expect(
(service as any).sandHubService.getShebaValidation, (service as any).sandHubService.getShebaValidation,
).not.toHaveBeenCalled(); ).not.toHaveBeenCalled();

View File

@@ -14,6 +14,7 @@ describe("RequestManagementService FileReviewer inbox", () => {
publicId: "BLM-OPEN", publicId: "BLM-OPEN",
type: "THIRD_PARTY", type: "THIRD_PARTY",
status: "WAITING_FOR_FILE_REVIEWER", status: "WAITING_FOR_FILE_REVIEWER",
createdAt: new Date("2026-01-02T00:00:00.000Z"),
isMadeByFileMaker: true, isMadeByFileMaker: true,
expertInitiated: true, expertInitiated: true,
creationMethod: "IN_PERSON", creationMethod: "IN_PERSON",
@@ -34,6 +35,9 @@ describe("RequestManagementService FileReviewer inbox", () => {
undefined, undefined,
blameRequestDbService, blameRequestDbService,
) as RequestManagementService; ) as RequestManagementService;
(service as any).claimCaseDbService = {
find: jest.fn().mockResolvedValue([]),
};
return { service, blameRequestDbService }; return { service, blameRequestDbService };
} }
@@ -46,7 +50,7 @@ describe("RequestManagementService FileReviewer inbox", () => {
clientKey: String(clientId), clientKey: String(clientId),
}); });
expect(result).toEqual([ expect(result.list).toEqual([
expect.objectContaining({ _id: sealedFile._id, publicId: "BLM-OPEN" }), expect.objectContaining({ _id: sealedFile._id, publicId: "BLM-OPEN" }),
]); ]);
expect(blameRequestDbService.find).toHaveBeenCalledWith( expect(blameRequestDbService.find).toHaveBeenCalledWith(
@@ -82,7 +86,34 @@ describe("RequestManagementService FileReviewer inbox", () => {
clientKey: String(clientId), clientKey: String(clientId),
}); });
expect(result).toEqual([]); expect(result.list).toEqual([]);
});
it("sorts and paginates the reviewer inbox with the shared list contract", async () => {
const olderFile = {
...sealedFile,
_id: new Types.ObjectId(),
publicId: "BLM-OLDER",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
};
const { service } = createService([olderFile, sealedFile]);
const result = await service.getMyFileReviewerFiles(
{
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
},
{ page: 1, limit: 1, sortBy: "createdAt", sortOrder: "desc" },
);
expect(result).toEqual(
expect.objectContaining({ total: 2, page: 1, limit: 1, totalPages: 2 }),
);
expect(result.list).toHaveLength(1);
expect(result.list[0]).toEqual(
expect.objectContaining({ publicId: "BLM-OPEN" }),
);
}); });
it("does not expose an open file's details to a reviewer from another tenant", async () => { it("does not expose an open file's details to a reviewer from another tenant", async () => {

View File

@@ -60,9 +60,14 @@ import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service"; import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
import { isOtpExpiryActive } from "src/helpers/user-otp-expiry"; import { isOtpExpiryActive } from "src/helpers/user-otp-expiry";
import { parseIranLocalDateTime } from "src/helpers/iran-datetime"; import { parseIranLocalDateTime } from "src/helpers/iran-datetime";
import { applyListQueryV2 } from "src/helpers/list-query-v2"; import {
applyListQueryV2,
isInListDateRange,
parseListDateRange,
} from "src/helpers/list-query-v2";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { GetUserBlameListV2ResponseDto } from "src/request-management/dto/blame-list-user-v2.dto"; import { GetUserBlameListV2ResponseDto } from "src/request-management/dto/blame-list-user-v2.dto";
import { resolveUnifiedFileStatus } from "src/helpers/unified-file-status";
import { AutoCloseRequestService } from "src/utils/cron/cron.service"; import { AutoCloseRequestService } from "src/utils/cron/cron.service";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import { import {
@@ -138,6 +143,9 @@ import {
runPlateInquiryWithFallback, runPlateInquiryWithFallback,
sanitizeStoredInquiryParticipants, sanitizeStoredInquiryParticipants,
} from "./inquiry-participant-resolver"; } from "./inquiry-participant-resolver";
import {
getInquiryErrorMessage,
} from "src/common/utils/inquiry-error";
/** /**
* Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD. * Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD.
@@ -154,6 +162,18 @@ function formatJalaliCompact(
return String(raw); return String(raw);
} }
type FileMakerResumeProjection = {
action:
| "CONTINUE_BLAME"
| "UPLOAD_REQUIRED_DOCUMENTS"
| "WAIT_FOR_FILE_REVIEWER";
entity: "BLAME" | "CLAIM";
entityId: string;
status: string;
currentStep: string;
nextStep?: string;
};
@Injectable() @Injectable()
export class RequestManagementService { export class RequestManagementService {
private readonly logger = new Logger(RequestManagementService.name); private readonly logger = new Logger(RequestManagementService.name);
@@ -183,10 +203,16 @@ export class RequestManagementService {
} }
/** Keep eligibility rejections distinct from an unavailable CAR_BODY provider. */ /** Keep eligibility rejections distinct from an unavailable CAR_BODY provider. */
private throwCarBodyInquiryFailure(err: unknown): never { private throwCarBodyInquiryFailure(
if (err instanceof ForbiddenException) throw err; err: unknown,
context: "carBodyPlate" | "carBodyVin" = "carBodyPlate",
): never {
const message = getInquiryErrorMessage(err, context);
if (err instanceof ForbiddenException) {
throw new ForbiddenException(message);
}
throw new HttpException("Car body inquiry failed", HttpStatus.BAD_REQUEST); throw new HttpException(message, HttpStatus.BAD_REQUEST);
} }
/** /**
@@ -200,7 +226,7 @@ export class RequestManagementService {
const configuredCode = Number(process.env.CLIENT_ID); const configuredCode = Number(process.env.CLIENT_ID);
if (!Number.isFinite(configuredCode)) { if (!Number.isFinite(configuredCode)) {
throw new InternalServerErrorException( throw new InternalServerErrorException(
"CLIENT_ID must be configured to save CAR_BODY policy ownership.", "تنظیمات شرکت بیمه برای ذخیره بیمه‌نامه بدنه کامل نیست.",
); );
} }
@@ -220,7 +246,7 @@ export class RequestManagementService {
const clientId = (client as any)?._id ?? (client as any)?._doc?._id; const clientId = (client as any)?._id ?? (client as any)?._doc?._id;
if (!clientId) { if (!clientId) {
throw new InternalServerErrorException( throw new InternalServerErrorException(
"Configured CAR_BODY insurer client could not be resolved.", "شرکت بیمه تنظیم‌شده برای بیمه‌نامه بدنه قابل شناسایی نیست.",
); );
} }
return clientId; return clientId;
@@ -394,7 +420,7 @@ export class RequestManagementService {
const policyholderNationalCode = subjects.carBodyPolicyNationalCode; const policyholderNationalCode = subjects.carBodyPolicyNationalCode;
if (!policyholderNationalCode) { if (!policyholderNationalCode) {
throw new BadRequestException( throw new BadRequestException(
"Car-body policyholder identity is required for a CAR_BODY inquiry.", "اطلاعات بیمه‌گذار برای استعلام بیمه بدنه الزامی است.",
); );
} }
const result = await runPlateInquiryWithFallback({ const result = await runPlateInquiryWithFallback({
@@ -434,7 +460,7 @@ export class RequestManagementService {
).trim(); ).trim();
if (!chassis) { if (!chassis) {
throw new BadRequestException( throw new BadRequestException(
"vehicle.vin is required for a VIN/chassis inquiry.", "شماره شاسی (VIN) برای استعلام خودرو الزامی است.",
); );
} }
const subjects = resolveInquirySubjects(submission); const subjects = resolveInquirySubjects(submission);
@@ -462,7 +488,7 @@ export class RequestManagementService {
for (const participant of participants) { for (const participant of participants) {
if (!participant.nationalCode || !participant.birthday) { if (!participant.nationalCode || !participant.birthday) {
throw new BadRequestException( throw new BadRequestException(
`${participant.participantId} requires nationalCode and birthday for personal inquiry.`, "کد ملی و تاریخ تولد برای استعلام هویت الزامی است.",
); );
} }
try { try {
@@ -486,7 +512,7 @@ export class RequestManagementService {
); );
await this.persistBlameInquiryAudit(req); await this.persistBlameInquiryAudit(req);
throw new BadRequestException( throw new BadRequestException(
`${participant.participantId} personal identity inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, getInquiryErrorMessage(error, "personalIdentity"),
); );
} }
} }
@@ -512,7 +538,7 @@ export class RequestManagementService {
const plate = submission.vehicle?.currentPlate ?? submission.dto.plate; const plate = submission.vehicle?.currentPlate ?? submission.dto.plate;
if (!plate) { if (!plate) {
throw new BadRequestException( throw new BadRequestException(
"Current plate is required for vehicle ownership inquiry.", "پلاک فعلی برای استعلام مالکیت خودرو الزامی است.",
); );
} }
try { try {
@@ -536,7 +562,7 @@ export class RequestManagementService {
); );
await this.persistBlameInquiryAudit(req); await this.persistBlameInquiryAudit(req);
throw new BadRequestException( throw new BadRequestException(
`Vehicle ownership inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, getInquiryErrorMessage(error, "carOwnership"),
); );
} }
} }
@@ -558,7 +584,7 @@ export class RequestManagementService {
} }
if (!submission.driver.licenseNumber) { if (!submission.driver.licenseNumber) {
throw new BadRequestException( throw new BadRequestException(
"Driver licence number is required when the driver has a licence.", "شماره گواهینامه راننده برای استعلام گواهینامه الزامی است.",
); );
} }
try { try {
@@ -582,7 +608,7 @@ export class RequestManagementService {
); );
await this.persistBlameInquiryAudit(req); await this.persistBlameInquiryAudit(req);
throw new BadRequestException( throw new BadRequestException(
`Driver licence inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, getInquiryErrorMessage(error, "drivingLicense"),
); );
} }
} }
@@ -683,7 +709,7 @@ export class RequestManagementService {
private normalizeInquiryError(err: any): any { private normalizeInquiryError(err: any): any {
if (!err) return undefined; if (!err) return undefined;
return { return {
message: err?.message || String(err), message: getInquiryErrorMessage(err, "generic"),
status: err?.response?.status, status: err?.response?.status,
data: err?.response?.data, data: err?.response?.data,
...(Array.isArray(err?.attempts) ? { attempts: err.attempts } : {}), ...(Array.isArray(err?.attempts) ? { attempts: err.attempts } : {}),
@@ -1745,7 +1771,7 @@ export class RequestManagementService {
body.insurerLicense === body.driverLicense) body.insurerLicense === body.driverLicense)
) { ) {
throw new BadRequestException( throw new BadRequestException(
"Insurer and Driver should be two different persons in this mode.", "در این حالت، بیمه‌گذار و راننده باید دو شخص متفاوت باشند.",
); );
} }
} else if (body.driverIsInsurer === true) { } else if (body.driverIsInsurer === true) {
@@ -1757,7 +1783,7 @@ export class RequestManagementService {
String(body.driverBirthday) === String(body.insurerBirthday); String(body.driverBirthday) === String(body.insurerBirthday);
if (!sameNat || !sameLic || !sameBirthday) { if (!sameNat || !sameLic || !sameBirthday) {
throw new BadRequestException( throw new BadRequestException(
"When driverIsInsurer is true, insurer and driver data must be the same.", "وقتی راننده همان بیمه‌گذار است، اطلاعات راننده و بیمه‌گذار باید یکسان باشد.",
); );
} }
} }
@@ -1831,7 +1857,10 @@ export class RequestManagementService {
err, err,
); );
await this.persistBlameInquiryAudit(req); await this.persistBlameInquiryAudit(req);
throw new HttpException("Inquiry failed", HttpStatus.BAD_REQUEST); throw new HttpException(
getInquiryErrorMessage(err, "thirdPartyPlate"),
HttpStatus.BAD_REQUEST,
);
} }
if (inquiryMapped?.Error) { if (inquiryMapped?.Error) {
@@ -1847,7 +1876,8 @@ export class RequestManagementService {
}); });
await this.persistBlameInquiryAudit(req); await this.persistBlameInquiryAudit(req);
throw new HttpException( throw new HttpException(
inquiryMapped.Error.Message || "Inquiry returned error", inquiryMapped.Error.Message ||
getInquiryErrorMessage(inquiryMapped, "thirdPartyPlate"),
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
@@ -1875,7 +1905,7 @@ export class RequestManagementService {
const clientName = inquiryMapped?.CompanyName; const clientName = inquiryMapped?.CompanyName;
if (!clientName) { if (!clientName) {
const error = new BadRequestException( const error = new BadRequestException(
`CompanyName missing from inquiry response`, "پاسخ استعلام بیمه‌نامه ناقص است و نام شرکت بیمه در آن وجود ندارد.",
); );
this.recordPartyCaseInquiryStatus( this.recordPartyCaseInquiryStatus(
req, req,
@@ -1903,7 +1933,7 @@ export class RequestManagementService {
: null; : null;
if (clientName && !client) { if (clientName && !client) {
const error = new BadRequestException( const error = new BadRequestException(
`CompanyCode missing or invalid in inquiry response`, "پاسخ استعلام بیمه‌نامه ناقص است و شرکت بیمه قابل شناسایی نیست.",
); );
this.recordPartyCaseInquiryStatus( this.recordPartyCaseInquiryStatus(
req, req,
@@ -2188,7 +2218,7 @@ export class RequestManagementService {
body.insurerLicense === body.driverLicense) body.insurerLicense === body.driverLicense)
) { ) {
throw new BadRequestException( throw new BadRequestException(
"Insurer and Driver should be two different persons in this mode.", "در این حالت، بیمه‌گذار و راننده باید دو شخص متفاوت باشند.",
); );
} }
} else if (body.driverIsInsurer === true) { } else if (body.driverIsInsurer === true) {
@@ -2200,7 +2230,7 @@ export class RequestManagementService {
String(body.driverBirthday) === String(body.insurerBirthday); String(body.driverBirthday) === String(body.insurerBirthday);
if (!sameNat || !sameLic || !sameBirthday) { if (!sameNat || !sameLic || !sameBirthday) {
throw new BadRequestException( throw new BadRequestException(
"When driverIsInsurer is true, insurer and driver data must be the same.", "وقتی راننده همان بیمه‌گذار است، اطلاعات راننده و بیمه‌گذار باید یکسان باشد.",
); );
} }
} }
@@ -2261,7 +2291,10 @@ export class RequestManagementService {
err, err,
); );
await this.persistBlameInquiryAudit(req); await this.persistBlameInquiryAudit(req);
throw new HttpException("VIN inquiry failed", HttpStatus.BAD_REQUEST); throw new HttpException(
getInquiryErrorMessage(err, "thirdPartyVin"),
HttpStatus.BAD_REQUEST,
);
} }
if (inquiryMapped?.Error) { if (inquiryMapped?.Error) {
@@ -2275,7 +2308,8 @@ export class RequestManagementService {
}); });
await this.persistBlameInquiryAudit(req); await this.persistBlameInquiryAudit(req);
throw new HttpException( throw new HttpException(
inquiryMapped.Error.Message || "VIN inquiry returned error", inquiryMapped.Error.Message ||
getInquiryErrorMessage(inquiryMapped, "thirdPartyVin"),
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
@@ -2303,7 +2337,7 @@ export class RequestManagementService {
const clientName = inquiryMapped?.CompanyName; const clientName = inquiryMapped?.CompanyName;
if (!clientName) { if (!clientName) {
const error = new BadRequestException( const error = new BadRequestException(
"CompanyName missing from VIN inquiry response", "پاسخ استعلام VIN ناقص است و نام شرکت بیمه در آن وجود ندارد.",
); );
this.recordPartyCaseInquiryStatus( this.recordPartyCaseInquiryStatus(
req, req,
@@ -2329,7 +2363,7 @@ export class RequestManagementService {
: null; : null;
if (clientName && !client) { if (clientName && !client) {
const error = new BadRequestException( const error = new BadRequestException(
"CompanyCode missing or invalid in VIN inquiry response", "پاسخ استعلام VIN ناقص است و شرکت بیمه قابل شناسایی نیست.",
); );
this.recordPartyCaseInquiryStatus( this.recordPartyCaseInquiryStatus(
req, req,
@@ -2459,7 +2493,7 @@ export class RequestManagementService {
err, err,
); );
await this.persistBlameInquiryAudit(req); await this.persistBlameInquiryAudit(req);
this.throwCarBodyInquiryFailure(err); this.throwCarBodyInquiryFailure(err, "carBodyVin");
} }
} }
@@ -3472,7 +3506,10 @@ export class RequestManagementService {
: null; : null;
if (!client) { if (!client) {
throw new HttpException("Client not found", HttpStatus.CONFLICT); throw new HttpException(
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
HttpStatus.CONFLICT,
);
} }
const partyDetails = const partyDetails =
@@ -3642,7 +3679,7 @@ export class RequestManagementService {
this.logger.error(er); this.logger.error(er);
if (er instanceof HttpException) throw er; if (er instanceof HttpException) throw er;
throw new InternalServerErrorException( throw new InternalServerErrorException(
"Failed to update request with plate details.", "ذخیره اطلاعات پلاک و بیمه‌نامه انجام نشد.",
); );
} }
@@ -3670,7 +3707,7 @@ export class RequestManagementService {
body.driverIsInsurer === false body.driverIsInsurer === false
) { ) {
throw new BadRequestException( throw new BadRequestException(
"Insurer and Driver should be two different persons in this mode.", "در این حالت، بیمه‌گذار و راننده باید دو شخص متفاوت باشند.",
); );
} }
@@ -3703,7 +3740,7 @@ export class RequestManagementService {
if (isSameNationalCode || isSamePlate) { if (isSameNationalCode || isSamePlate) {
throw new ConflictException( throw new ConflictException(
"The plate and national code for the second party cannot be the same as the first party.", "پلاک و کد ملی طرف دوم نمی‌تواند با طرف اول یکسان باشد.",
); );
} }
} }
@@ -6466,6 +6503,20 @@ export class RequestManagementService {
}, },
); );
const requestIds = requests.map((request: any) => request._id);
const claimsForStatus =
requestIds.length > 0
? ((await this.claimCaseDbService.find(
{ blameRequestId: { $in: requestIds } },
{ lean: true, select: "blameRequestId status" },
)) as any[])
: [];
const claimStatusByBlameId = new Map<string, string>(
claimsForStatus
.filter((claim) => claim?.blameRequestId && claim?.status)
.map((claim) => [String(claim.blameRequestId), claim.status]),
);
const enriched = requests.map((req: any) => { const enriched = requests.map((req: any) => {
const isInitiator = const isInitiator =
(user?.role === RoleEnum.FIELD_EXPERT && (user?.role === RoleEnum.FIELD_EXPERT &&
@@ -6487,17 +6538,44 @@ export class RequestManagementService {
...obj, ...obj,
userSide: party?.role ?? null, userSide: party?.role ?? null,
initiatedByMe: isInitiator, initiatedByMe: isInitiator,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: req.status,
claimStatus: claimStatusByBlameId.get(String(req._id)),
}),
}; };
}); });
let filtered = enriched;
if (query.unifiedStatus) {
filtered = filtered.filter(
(row) => row.unifiedFileStatus === query.unifiedStatus,
);
}
const { fromDate, toDate } = parseListDateRange(
query.startDate,
query.endDate,
);
if (fromDate || toDate) {
filtered = filtered.filter((row) =>
isInListDateRange(row.createdAt, fromDate, toDate),
);
}
const paged = applyListQueryV2( const paged = applyListQueryV2(
enriched, filtered,
{ {
publicId: (r) => String((r as { publicId?: string }).publicId ?? ""), publicId: (r) => String((r as { publicId?: string }).publicId ?? ""),
createdAt: (r) => (r as { createdAt?: Date }).createdAt, createdAt: (r) => (r as { createdAt?: Date }).createdAt,
requestNo: (r) => requestNo: (r) =>
String((r as { requestNo?: string }).requestNo ?? ""), String((r as { requestNo?: string }).requestNo ?? ""),
status: (r) => String((r as { status?: string }).status ?? ""), status: (r) =>
String(
(r as { unifiedFileStatus?: string; status?: string })
.unifiedFileStatus ??
(r as { status?: string }).status ??
"",
),
fileType: (r) => (r as { type?: string }).type,
searchExtras: (r) => { searchExtras: (r) => {
const row = r as { const row = r as {
blameStatus?: string; blameStatus?: string;
@@ -7004,8 +7082,9 @@ export class RequestManagementService {
e, e,
); );
await (req as any).save(); await (req as any).save();
throw new InternalServerErrorException( if (e instanceof HttpException) throw e;
"Failed to process plate information.", throw new BadRequestException(
getInquiryErrorMessage(e, "thirdPartyPlate"),
); );
} }
@@ -7020,7 +7099,7 @@ export class RequestManagementService {
: await this.clientService.findOne({ clientName }); : await this.clientService.findOne({ clientName });
if (!client) { if (!client) {
const error = new NotFoundException( const error = new NotFoundException(
`Client not found for company: ${clientName}`, "شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
); );
this.recordPartyCaseInquiryStatus( this.recordPartyCaseInquiryStatus(
req, req,
@@ -7345,7 +7424,7 @@ export class RequestManagementService {
: null; : null;
if (!client) { if (!client) {
const error = new NotFoundException( const error = new NotFoundException(
`Client not found for company: ${clientName}`, "شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
); );
this.recordPartyCaseInquiryStatus( this.recordPartyCaseInquiryStatus(
req, req,
@@ -8104,7 +8183,7 @@ export class RequestManagementService {
if (!client) { if (!client) {
const error = new NotFoundException( const error = new NotFoundException(
`Client not found for company: ${clientName}`, "شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
); );
await this.persistLegacyInquiryAudit( await this.persistLegacyInquiryAudit(
requestId, requestId,
@@ -8188,8 +8267,8 @@ export class RequestManagementService {
); );
this.logger.error("Error processing first party plate:", plateError); this.logger.error("Error processing first party plate:", plateError);
if (plateError instanceof HttpException) throw plateError; if (plateError instanceof HttpException) throw plateError;
throw new InternalServerErrorException( throw new BadRequestException(
"Failed to process first party plate information", getInquiryErrorMessage(plateError, "thirdPartyPlate"),
); );
} }
@@ -8241,7 +8320,7 @@ export class RequestManagementService {
if (!client) { if (!client) {
const error = new NotFoundException( const error = new NotFoundException(
`Client not found for company: ${clientName}${companyCode ? ` (code: ${companyCode})` : ""}`, "شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
); );
await this.persistLegacyInquiryAudit( await this.persistLegacyInquiryAudit(
requestId, requestId,
@@ -8326,8 +8405,9 @@ export class RequestManagementService {
}, },
); );
this.logger.error("Error processing second party plate:", plateError); this.logger.error("Error processing second party plate:", plateError);
throw new InternalServerErrorException( if (plateError instanceof HttpException) throw plateError;
"Failed to process second party plate information", throw new BadRequestException(
getInquiryErrorMessage(plateError, "thirdPartyPlate"),
); );
} }
@@ -8584,7 +8664,7 @@ export class RequestManagementService {
if (!client) { if (!client) {
const error = new NotFoundException( const error = new NotFoundException(
`Client not found for company: ${clientName}`, "شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
); );
await this.persistLegacyInquiryAudit( await this.persistLegacyInquiryAudit(
requestId, requestId,
@@ -8710,9 +8790,8 @@ export class RequestManagementService {
}, },
); );
this.logger.error("Error processing first party plate:", plateError); this.logger.error("Error processing first party plate:", plateError);
throw new InternalServerErrorException( if (plateError instanceof HttpException) throw plateError;
"Failed to process first party plate information", throw new BadRequestException(getInquiryErrorMessage(plateError));
);
} }
// For CAR_BODY: Create expertSubmitReply // For CAR_BODY: Create expertSubmitReply
@@ -9850,14 +9929,14 @@ export class RequestManagementService {
); );
await this.persistBlameInquiryAudit(req); await this.persistBlameInquiryAudit(req);
throw new BadRequestException( throw new BadRequestException(
`${roleLabel} party plate inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, getInquiryErrorMessage(err, "thirdPartyPlate"),
); );
} }
if (inquiryMapped?.Error) { if (inquiryMapped?.Error) {
const error = new BadRequestException( const error = new BadRequestException(
inquiryMapped.Error.Message || inquiryMapped.Error.Message ||
`${roleLabel} party plate inquiry returned an error`, getInquiryErrorMessage(inquiryMapped, "thirdPartyPlate"),
); );
this.recordPartyCaseInquiryStatus( this.recordPartyCaseInquiryStatus(
req, req,
@@ -9881,7 +9960,7 @@ export class RequestManagementService {
const companyCode = inquiryMapped?.CompanyCode; const companyCode = inquiryMapped?.CompanyCode;
if (!clientName) { if (!clientName) {
const error = new BadRequestException( const error = new BadRequestException(
`CompanyName missing from ${roleLabel} party inquiry response`, "پاسخ استعلام بیمه‌نامه ناقص است و نام شرکت بیمه در آن وجود ندارد.",
); );
this.recordPartyCaseInquiryStatus( this.recordPartyCaseInquiryStatus(
req, req,
@@ -9908,7 +9987,7 @@ export class RequestManagementService {
: null; : null;
if (clientName && !client) { if (clientName && !client) {
const error = new BadRequestException( const error = new BadRequestException(
`CompanyCode missing or invalid in ${roleLabel} party inquiry response`, "پاسخ استعلام بیمه‌نامه ناقص است و شرکت بیمه قابل شناسایی نیست.",
); );
this.recordPartyCaseInquiryStatus( this.recordPartyCaseInquiryStatus(
req, req,
@@ -10128,7 +10207,7 @@ export class RequestManagementService {
); );
await this.persistBlameInquiryAudit(req); await this.persistBlameInquiryAudit(req);
throw new BadRequestException( throw new BadRequestException(
`${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, getInquiryErrorMessage(err, "drivingLicense"),
); );
} }
} }
@@ -10143,7 +10222,7 @@ export class RequestManagementService {
): Promise<void> { ): Promise<void> {
if (!String(sheba ?? "").trim()) { if (!String(sheba ?? "").trim()) {
throw new BadRequestException( throw new BadRequestException(
"sheba is required for the damaged party.", "شماره شبا برای طرف زیان‌دیده الزامی است.",
); );
} }
await this.sandHubService.getShebaValidation( await this.sandHubService.getShebaValidation(
@@ -10764,14 +10843,14 @@ export class RequestManagementService {
); );
await this.persistBlameInquiryAudit(req); await this.persistBlameInquiryAudit(req);
throw new BadRequestException( throw new BadRequestException(
`${roleLabel} party VIN inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, getInquiryErrorMessage(err, "thirdPartyVin"),
); );
} }
if (inquiryMapped?.Error) { if (inquiryMapped?.Error) {
const error = new BadRequestException( const error = new BadRequestException(
inquiryMapped.Error.Message || inquiryMapped.Error.Message ||
`${roleLabel} party VIN inquiry returned an error`, getInquiryErrorMessage(inquiryMapped, "thirdPartyVin"),
); );
this.recordPartyCaseInquiryStatus( this.recordPartyCaseInquiryStatus(
req, req,
@@ -10793,7 +10872,7 @@ export class RequestManagementService {
const companyCode = inquiryMapped?.CompanyCode; const companyCode = inquiryMapped?.CompanyCode;
if (!clientName) { if (!clientName) {
const error = new BadRequestException( const error = new BadRequestException(
`CompanyName missing from ${roleLabel} party VIN inquiry response`, "پاسخ استعلام VIN ناقص است و نام شرکت بیمه در آن وجود ندارد.",
); );
this.recordPartyCaseInquiryStatus( this.recordPartyCaseInquiryStatus(
req, req,
@@ -10818,7 +10897,7 @@ export class RequestManagementService {
: null; : null;
if (clientName && !client) { if (clientName && !client) {
const error = new BadRequestException( const error = new BadRequestException(
`CompanyCode missing or invalid in ${roleLabel} party VIN inquiry response`, "پاسخ استعلام VIN ناقص است و شرکت بیمه قابل شناسایی نیست.",
); );
this.recordPartyCaseInquiryStatus( this.recordPartyCaseInquiryStatus(
req, req,
@@ -10970,7 +11049,7 @@ export class RequestManagementService {
err, err,
); );
await this.persistBlameInquiryAudit(req); await this.persistBlameInquiryAudit(req);
this.throwCarBodyInquiryFailure(err); this.throwCarBodyInquiryFailure(err, "carBodyVin");
} }
} }
@@ -11045,7 +11124,7 @@ export class RequestManagementService {
); );
await this.persistBlameInquiryAudit(req); await this.persistBlameInquiryAudit(req);
throw new BadRequestException( throw new BadRequestException(
`${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, getInquiryErrorMessage(err, "drivingLicense"),
); );
} }
} }
@@ -12756,27 +12835,124 @@ export class RequestManagementService {
return { ...workflow, completedSteps }; return { ...workflow, completedSteps };
} }
// /** /**
// * V4/V5 dirty bridge: FileMaker FE resumes from blame `status`, but pre-capture * FileMaker owns a cross-aggregate workflow: the party narrative is stored on
// * document upload lives on the claim (`UPLOADING_REQUIRED_DOCUMENTS`) while blame * the blame case, while required-document progress is stored on its linked
// * is still at FIRST/SECOND_COMPLETED. Mirror claim status into `status` only for * claim. Expose the authoritative aggregate to resume instead of overloading
// * that phase so leave/re-enter can continue; keep real blame status as * either record's status with the other record's state.
// * `blameCaseStatus`. Remove once FE keys off `claimStatus` / a unified resume pointer. */
// */ private fileMakerResumeProjection(
// private fileMakerStatusForResume( file: any,
// blameStatus: unknown, claim?: any,
// claimStatus: unknown, ): FileMakerResumeProjection {
// ): { status: unknown; blameCaseStatus?: unknown } { const blameWorkflow = this.fileMakerWorkflowProjection(file);
// if (claimStatus === ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS) { const narrativeTerminalStep =
// return { file?.type === BlameRequestType.CAR_BODY
// status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, ? WorkflowStep.FIRST_COMPLETED
// blameCaseStatus: blameStatus, : WorkflowStep.SECOND_COMPLETED;
// }; const narrativeComplete =
// } blameWorkflow.currentStep === narrativeTerminalStep ||
// return { status: blameStatus }; (blameWorkflow.completedSteps ?? []).includes(narrativeTerminalStep);
// } const claimWorkflow = claim?.workflow ?? {};
async getMyFileMakerFiles(fileMaker: any): Promise<any[]> { if (
narrativeComplete &&
claim &&
claim.status === ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS &&
claimWorkflow.currentStep === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
) {
return {
action: "UPLOAD_REQUIRED_DOCUMENTS",
entity: "CLAIM",
entityId: String(claim._id),
status: claim.status,
currentStep: claimWorkflow.currentStep,
nextStep: claimWorkflow.nextStep,
};
}
if (
narrativeComplete &&
claim &&
(claim.status === ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER ||
file?.status === CaseStatus.WAITING_FOR_FILE_REVIEWER)
) {
return {
action: "WAIT_FOR_FILE_REVIEWER",
entity: "CLAIM",
entityId: String(claim._id),
status: claim.status,
currentStep: claimWorkflow.currentStep,
nextStep: claimWorkflow.nextStep,
};
}
return {
action: "CONTINUE_BLAME",
entity: "BLAME",
entityId: String(file._id),
status: file.status,
currentStep: blameWorkflow.currentStep,
nextStep: blameWorkflow.nextStep,
};
}
private paginateUserFacingFiles(
rows: any[],
query: ListQueryV2Dto,
): GetUserBlameListV2ResponseDto {
let filtered = rows;
if (query.unifiedStatus) {
filtered = filtered.filter(
(row) => row.unifiedFileStatus === query.unifiedStatus,
);
}
const { fromDate, toDate } = parseListDateRange(
query.startDate,
query.endDate,
);
if (fromDate || toDate) {
filtered = filtered.filter((row) =>
isInListDateRange(row.createdAt, fromDate, toDate),
);
}
const paged = applyListQueryV2(
filtered,
{
publicId: (row) => String(row.publicId ?? ""),
createdAt: (row) => row.createdAt,
requestNo: (row) => String(row.requestNo ?? ""),
status: (row) => String(row.unifiedFileStatus ?? row.status ?? ""),
fileType: (row) => row.type,
searchExtras: (row) =>
[
row._id,
row.blameStatus,
row.claimStatus,
row.workflow?.currentStep,
row.claimWorkflow?.currentStep,
]
.filter(Boolean)
.map(String),
},
query,
);
return {
list: paged.list,
total: paged.total,
page: paged.page,
limit: paged.limit,
totalPages: paged.totalPages,
};
}
async getMyFileMakerFiles(
fileMaker: any,
query: ListQueryV2Dto = {},
): Promise<GetUserBlameListV2ResponseDto> {
if (fileMaker?.role !== RoleEnum.FILE_MAKER) { if (fileMaker?.role !== RoleEnum.FILE_MAKER) {
throw new ForbiddenException("Only FileMakers can use this endpoint."); throw new ForbiddenException("Only FileMakers can use this endpoint.");
} }
@@ -12785,26 +12961,27 @@ export class RequestManagementService {
isMadeByFileMaker: true, isMadeByFileMaker: true,
initiatedByFieldExpertId: makerId, initiatedByFieldExpertId: makerId,
}); });
// const blameIds = (files || []).map((f: any) => f._id).filter(Boolean); const blameIds = (files || []).map((f: any) => f._id).filter(Boolean);
// const claims = const claims =
// blameIds.length > 0 blameIds.length > 0
// ? await this.claimCaseDbService.find( ? await this.claimCaseDbService.find(
// { blameRequestId: { $in: blameIds } }, { blameRequestId: { $in: blameIds } },
// { lean: true, select: "blameRequestId status" }, {
// ) lean: true,
// : []; select: "blameRequestId status workflow",
// const claimStatusByBlameId = new Map<string, unknown>(); },
// for (const c of claims as any[]) { )
// const blameId = c?.blameRequestId != null ? String(c.blameRequestId) : ""; : [];
// if (blameId) claimStatusByBlameId.set(blameId, c.status); const claimByBlameId = new Map<string, any>();
// } for (const claim of claims as any[]) {
const blameId =
claim?.blameRequestId != null ? String(claim.blameRequestId) : "";
if (blameId) claimByBlameId.set(blameId, claim);
}
return (files || []).map((f: any) => { const list = (files || []).map((f: any) => {
const workflow = this.fileMakerWorkflowProjection(f); const workflow = this.fileMakerWorkflowProjection(f);
// const resume = this.fileMakerStatusForResume( const claim = claimByBlameId.get(String(f._id));
// f.status,
// claimStatusByBlameId.get(String(f._id)),
// );
return { return {
_id: f._id, _id: f._id,
publicId: f.publicId, publicId: f.publicId,
@@ -12817,11 +12994,21 @@ export class RequestManagementService {
nextStep: workflow.nextStep, nextStep: workflow.nextStep,
completedSteps: workflow.completedSteps, completedSteps: workflow.completedSteps,
}, },
linkedClaimId: claim?._id ? String(claim._id) : null,
claimStatus: claim?.status,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: f.status,
claimStatus: claim?.status,
}),
claimWorkflow: claim?.workflow,
fileMakerResume: this.fileMakerResumeProjection(f, claim),
requiresFileMakerApproval: f.requiresFileMakerApproval, requiresFileMakerApproval: f.requiresFileMakerApproval,
createdAt: f.createdAt, createdAt: f.createdAt,
updatedAt: f.updatedAt, updatedAt: f.updatedAt,
}; };
}); });
return this.paginateUserFacingFiles(list, query);
} }
async getMyFileMakerFileDetail( async getMyFileMakerFileDetail(
@@ -12855,6 +13042,7 @@ export class RequestManagementService {
: claim : claim
? { ...(claim as any) } ? { ...(claim as any) }
: null; : null;
const fileMakerResume = this.fileMakerResumeProjection(plain, claimPlain);
return { return {
_id: plain._id, _id: plain._id,
publicId: plain.publicId, publicId: plain.publicId,
@@ -12907,6 +13095,7 @@ export class RequestManagementService {
hasSigned: p.confirmation != null, hasSigned: p.confirmation != null,
})), })),
linkedClaimId: claimPlain ? String(claimPlain._id) : null, linkedClaimId: claimPlain ? String(claimPlain._id) : null,
fileMakerResume,
...(claimPlain ...(claimPlain
? { ? {
claimStatus: claimPlain.status, claimStatus: claimPlain.status,
@@ -12933,7 +13122,10 @@ export class RequestManagementService {
// ─── FileReviewer file list / detail (V4 + V5) ───────────────────────────── // ─── FileReviewer file list / detail (V4 + V5) ─────────────────────────────
async getMyFileReviewerFiles(fileReviewer: any): Promise<any[]> { async getMyFileReviewerFiles(
fileReviewer: any,
query: ListQueryV2Dto = {},
): Promise<GetUserBlameListV2ResponseDto> {
if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) { if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) {
throw new ForbiddenException("Only FileReviewers can use this endpoint."); throw new ForbiddenException("Only FileReviewers can use this endpoint.");
} }
@@ -12971,13 +13163,35 @@ export class RequestManagementService {
); );
}); });
return visibleFiles.map((f: any) => ({ const visibleBlameIds = visibleFiles
.map((f: any) => f._id)
.filter(Boolean);
const claims =
visibleBlameIds.length > 0
? await this.claimCaseDbService.find(
{ blameRequestId: { $in: visibleBlameIds } },
{ lean: true, select: "blameRequestId status" },
)
: [];
const claimByBlameId = new Map<string, any>();
for (const claim of claims as any[]) {
if (claim?.blameRequestId) {
claimByBlameId.set(String(claim.blameRequestId), claim);
}
}
const list = visibleFiles.map((f: any) => ({
_id: f._id, _id: f._id,
publicId: f.publicId, publicId: f.publicId,
requestNo: f.requestNo, requestNo: f.requestNo,
type: f.type, type: f.type,
status: f.status, status: f.status,
blameStatus: f.blameStatus, blameStatus: f.blameStatus,
claimStatus: claimByBlameId.get(String(f._id))?.status,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: f.status,
claimStatus: claimByBlameId.get(String(f._id))?.status,
}),
workflow: { workflow: {
currentStep: f.workflow?.currentStep, currentStep: f.workflow?.currentStep,
nextStep: f.workflow?.nextStep, nextStep: f.workflow?.nextStep,
@@ -12987,6 +13201,8 @@ export class RequestManagementService {
createdAt: f.createdAt, createdAt: f.createdAt,
updatedAt: f.updatedAt, updatedAt: f.updatedAt,
})); }));
return this.paginateUserFacingFiles(list, query);
} }
async getMyFileReviewerFileDetail( async getMyFileReviewerFileDetail(

View File

@@ -82,7 +82,7 @@ export class RequestManagementV2Controller {
@ApiOperation({ @ApiOperation({
summary: "List my blame requests (V2)", summary: "List my blame requests (V2)",
description: description:
"Party-owned blame files, or files initiated by the current FIELD_EXPERT / REGISTRAR. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.", "Party-owned blame files, or files initiated by the current FIELD_EXPERT / REGISTRAR. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType`, `startDate`, `endDate`.",
}) })
async getAllBlameRequestsV2( async getAllBlameRequestsV2(
@CurrentUser() user: any, @CurrentUser() user: any,

View File

@@ -1,6 +1,7 @@
import { SandHubService } from "./sand-hub.service"; import { SandHubService } from "./sand-hub.service";
import { import {
ForbiddenException, ForbiddenException,
NotFoundException,
ServiceUnavailableException, ServiceUnavailableException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service"; import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service";
@@ -58,7 +59,9 @@ describe("SandHubService inquiry mocks", () => {
}); });
it("keeps disabled-live third-party mock policy usable", async () => { it("keeps disabled-live third-party mock policy usable", async () => {
const result = await service.getTejaratBlockInquiry(userDetail); const result = await service.getTejaratBlockInquiry(userDetail, {
enforceDeploymentClientMatch: true,
});
expect(isMappedPolicyCurrent(result.mapped)).toBe(true); expect(isMappedPolicyCurrent(result.mapped)).toBe(true);
}); });
@@ -121,6 +124,17 @@ describe("SandHubService inquiry mocks", () => {
expect(httpService.post).not.toHaveBeenCalled(); expect(httpService.post).not.toHaveBeenCalled();
}); });
it("returns a contextual Persian error when no car-body policy matches", async () => {
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
lookupsService.findLastProcessedCarPolicy.mockRejectedValue(
new NotFoundException("No active Fanavaran car-body policy was found"),
);
await expect(service.getCarBodyInquiry(userDetail)).rejects.toThrow(
"بیمه‌نامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.",
);
});
it("does not let an offline third-party seed override a live ESG inquiry", async () => { it("does not let an offline third-party seed override a live ESG inquiry", async () => {
process.env.CLIENT_ID = "8"; process.env.CLIENT_ID = "8";
externalInquirySettings.isInquiryLive.mockResolvedValue(true); externalInquirySettings.isInquiryLive.mockResolvedValue(true);
@@ -129,12 +143,10 @@ describe("SandHubService inquiry mocks", () => {
raw: { mocked: true }, raw: { mocked: true },
mapped: { PrntPlcyCmpDocNo: "MOCK-POLICY" }, mapped: { PrntPlcyCmpDocNo: "MOCK-POLICY" },
}); });
const esg = jest const esg = jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
.spyOn(service as any, "makeEsgRequest") success: true,
.mockResolvedValue({ data: { PrntCmpDocNo: "REAL-ESG-POLICY" },
success: true, });
data: { PrntCmpDocNo: "REAL-ESG-POLICY" },
});
const result = await service.getTejaratBlockInquiry(userDetail); const result = await service.getTejaratBlockInquiry(userDetail);
@@ -143,6 +155,40 @@ describe("SandHubService inquiry mocks", () => {
expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY"); expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY");
}); });
it("preserves ESG not-found semantics as a Persian plate-specific error", async () => {
process.env.CLIENT_ID = "8";
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: false,
message: "موردی یافت نشد",
});
const result = await service.getTejaratBlockInquiry(userDetail, {
enforceDeploymentClientMatch: true,
});
expect(result.mapped.Error.Message).toBe(
"بیمه‌نامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.",
);
});
it("uses a VIN-specific message for the same ESG not-found response", async () => {
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: false,
message: "موردی یافت نشد",
});
const result = await service.getPolicyByChassisInquiry({
nationalCode: "0012345678",
chassis: "NAAR03HFFRDE07024",
});
expect(result.mapped.Error.Message).toBe(
"بیمه‌نامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
);
});
it("rejects a guilty third-party policy issued by another insurer", async () => { it("rejects a guilty third-party policy issued by another insurer", async () => {
process.env.CLIENT_ID = "15"; process.env.CLIENT_ID = "15";
externalInquirySettings.isInquiryLive.mockResolvedValue(true); externalInquirySettings.isInquiryLive.mockResolvedValue(true);

View File

@@ -24,6 +24,13 @@ import { jalaliToGregorianDate } from "src/helpers/date-jalali";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { mapEsgCarBodyPolicyToInquiry } from "./esg-car-body-inquiry.mapper"; import { mapEsgCarBodyPolicyToInquiry } from "./esg-car-body-inquiry.mapper";
import type { Plates } from "src/Types&Enums/plate.interface"; import type { Plates } from "src/Types&Enums/plate.interface";
import {
getInquiryErrorMessage,
inquiryErrorStatus,
isInquiryFailurePayload,
isInquiryTimeout,
type InquiryErrorContext,
} from "src/common/utils/inquiry-error";
type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & { type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & {
plate: Plates | string; plate: Plates | string;
@@ -31,9 +38,6 @@ type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & {
@Injectable() @Injectable()
export class SandHubService { export class SandHubService {
private static readonly ESG_INQUIRY_UNAVAILABLE_MESSAGE =
"استعلام در دسترس نیست";
private readonly logger = new Logger(SandHubService.name); private readonly logger = new Logger(SandHubService.name);
private loginToken: string | null = null; private loginToken: string | null = null;
private tokenExpiry: Date | null = null; private tokenExpiry: Date | null = null;
@@ -90,6 +94,30 @@ export class SandHubService {
return resolveFanavaranClientKey() === "parsian"; return resolveFanavaranClientKey() === "parsian";
} }
private inquiryContext(type: ExternalInquiryType): InquiryErrorContext {
if (type === "vinChassis") return "thirdPartyVin";
return type;
}
private throwInquiryError(
error: unknown,
context: InquiryErrorContext,
): never {
const message = getInquiryErrorMessage(error, context);
const status = inquiryErrorStatus(error);
if (error instanceof ForbiddenException || status === 403) {
throw new ForbiddenException(message);
}
if (isInquiryTimeout(error) || status === 504) {
throw new GatewayTimeoutException(message);
}
if ([400, 404, 409, 422].includes(status ?? 0)) {
throw new BadRequestException(message);
}
throw new ServiceUnavailableException(message);
}
/** /**
* A case may proceed only when the policy belongs to the insurer served by * A case may proceed only when the policy belongs to the insurer served by
* this deployment. Callers opt in for the guilty/first-party policy only. * this deployment. Callers opt in for the guilty/first-party policy only.
@@ -101,7 +129,7 @@ export class SandHubService {
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim(); const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
if (!expectedClientCode) { if (!expectedClientCode) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
"CLIENT_ID must be configured before insurance eligibility can be checked.", "تنظیمات شرکت بیمه برای بررسی اعتبار بیمه‌نامه کامل نیست.",
); );
} }
@@ -111,7 +139,7 @@ export class SandHubService {
if (actualClientCode === expectedClientCode) return; if (actualClientCode === expectedClientCode) return;
throw new ForbiddenException( throw new ForbiddenException(
`${insuranceLine} policy insurer does not match this deployment.`, "بیمه‌نامه یافت‌شده متعلق به شرکت بیمه این سامانه نیست.",
); );
} }
@@ -134,13 +162,13 @@ export class SandHubService {
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim(); const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
if (!expectedClientCode) { if (!expectedClientCode) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
"CLIENT_ID must be configured before insurance eligibility can be checked.", "تنظیمات شرکت بیمه برای بررسی اعتبار بیمه‌نامه کامل نیست.",
); );
} }
if (expectedClientCode === "8") return; if (expectedClientCode === "8") return;
throw new ForbiddenException( throw new ForbiddenException(
"CAR_BODY policy insurer does not match this deployment.", "بیمه‌نامه یافت‌شده متعلق به شرکت بیمه این سامانه نیست.",
); );
} }
@@ -426,7 +454,7 @@ export class SandHubService {
this.logger.error("Failed to login to SandHub:", er.message); this.logger.error("Failed to login to SandHub:", er.message);
this.loginToken = null; this.loginToken = null;
this.tokenExpiry = null; this.tokenExpiry = null;
throw new UnauthorizedException("SandHub authentication failed"); throw new UnauthorizedException("احراز هویت سرویس استعلام انجام نشد.");
} }
} }
@@ -446,7 +474,7 @@ export class SandHubService {
if (!email || !password) { if (!email || !password) {
throw new UnauthorizedException( throw new UnauthorizedException(
"Tejarat inquiry credentials are not configured (TEJARAT_INQUIRY_EMAIL/TEJARAT_INQUIRY_PASSWORD)", "اطلاعات اتصال به سرویس استعلام تجارت نو تنظیم نشده است.",
); );
} }
@@ -486,12 +514,18 @@ export class SandHubService {
); );
this.tejaratAccessToken = null; this.tejaratAccessToken = null;
this.tejaratTokenExpiry = null; this.tejaratTokenExpiry = null;
throw new UnauthorizedException("Tejarat inquiry authentication failed"); throw new UnauthorizedException(
"احراز هویت سرویس استعلام تجارت نو انجام نشد.",
);
} }
} }
private async getEsgAccessToken(): Promise<string> { private async getEsgAccessToken(): Promise<string> {
if (this.esgAccessToken && this.esgTokenExpiry && this.esgTokenExpiry > new Date()) { if (
this.esgAccessToken &&
this.esgTokenExpiry &&
this.esgTokenExpiry > new Date()
) {
return this.esgAccessToken; return this.esgAccessToken;
} }
@@ -501,7 +535,7 @@ export class SandHubService {
if (!baseUrl || !username || !password) { if (!baseUrl || !username || !password) {
throw new UnauthorizedException( throw new UnauthorizedException(
"ESG credentials are not configured (ESG_URL/ESG_USERNAME/ESG_PASSWORD)", "اطلاعات اتصال به سرویس استعلام ESG تنظیم نشده است.",
); );
} }
@@ -539,7 +573,9 @@ export class SandHubService {
this.logger.error("Failed to login to ESG inquiry:", er?.message || er); this.logger.error("Failed to login to ESG inquiry:", er?.message || er);
this.esgAccessToken = null; this.esgAccessToken = null;
this.esgTokenExpiry = null; this.esgTokenExpiry = null;
throw new UnauthorizedException("ESG inquiry authentication failed"); throw new UnauthorizedException(
"احراز هویت سرویس استعلام ESG انجام نشد.",
);
} }
} }
@@ -603,24 +639,30 @@ export class SandHubService {
this.esgTokenExpiry = null; this.esgTokenExpiry = null;
} }
if ([400, 404, 409, 422].includes(status)) {
this.throwInquiryError(err, this.inquiryContext(inquiryType));
}
if (attempt === maxRetries - 1) {
this.throwInquiryError(err, this.inquiryContext(inquiryType));
}
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt); const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
if (attempt === maxRetries - 1) throw err;
} }
} }
} }
private mapEsgPolicyByPlateToOldFormat(raw: any): any { private mapEsgPolicyByPlateToOldFormat(
raw: any,
context: "thirdPartyPlate" | "thirdPartyVin" = "thirdPartyPlate",
): any {
if (!raw) return raw; if (!raw) return raw;
if (raw?.success === false) { if (isInquiryFailurePayload(raw)) {
this.logger.warn( this.logger.warn("ESG policy inquiry returned a failure payload", raw);
"ESG policyByPlate inquiry returned success=false",
raw,
);
return { return {
Error: { Error: {
Message: SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE, Message: getInquiryErrorMessage(raw, context),
}, },
}; };
} }
@@ -670,7 +712,8 @@ export class SandHubService {
): string | null { ): string | null {
if (input === null || input === undefined) return null; if (input === null || input === undefined) return null;
const raw = typeof input === "number" ? String(input) : String(input).trim(); const raw =
typeof input === "number" ? String(input) : String(input).trim();
if (!raw) return null; if (!raw) return null;
let year = 0; let year = 0;
@@ -700,7 +743,9 @@ export class SandHubService {
return `${year}-${mm}-${dd}`; return `${year}-${mm}-${dd}`;
} }
private getDefaultMockPersonInquiry(nationalCode: string): Record<string, unknown> { private getDefaultMockPersonInquiry(
nationalCode: string,
): Record<string, unknown> {
return { return {
firstName: "نام", firstName: "نام",
lastName: "خانوادگی", lastName: "خانوادگی",
@@ -711,10 +756,10 @@ export class SandHubService {
} }
private mapEsgPersonInquiryToOldFormat(raw: any): Record<string, unknown> { private mapEsgPersonInquiryToOldFormat(raw: any): Record<string, unknown> {
if (raw?.success === false) { if (isInquiryFailurePayload(raw)) {
this.logger.warn("ESG person inquiry returned success=false", raw); this.logger.warn("ESG person inquiry returned success=false", raw);
throw new BadRequestException( throw new BadRequestException(
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE, getInquiryErrorMessage(raw, "personalIdentity"),
); );
} }
@@ -736,11 +781,9 @@ export class SandHubService {
} }
private mapEsgShebaInquiryToOldFormat(raw: any): Record<string, unknown> { private mapEsgShebaInquiryToOldFormat(raw: any): Record<string, unknown> {
if (raw?.success === false) { if (isInquiryFailurePayload(raw)) {
this.logger.warn("ESG sheba inquiry returned success=false", raw); this.logger.warn("ESG sheba inquiry returned success=false", raw);
throw new BadRequestException( throw new BadRequestException(getInquiryErrorMessage(raw, "sheba"));
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
);
} }
const data = raw?.data ?? {}; const data = raw?.data ?? {};
@@ -800,11 +843,15 @@ export class SandHubService {
this.tejaratTokenExpiry = null; this.tejaratTokenExpiry = null;
} }
if ([400, 404, 409, 422].includes(status)) {
this.throwInquiryError(err, this.inquiryContext(inquiryType));
}
if (attempt === maxRetries - 1) {
this.throwInquiryError(err, this.inquiryContext(inquiryType));
}
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt); const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
if (attempt === maxRetries - 1) {
throw err;
}
} }
} }
} }
@@ -883,8 +930,13 @@ export class SandHubService {
`[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`, `[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`,
); );
} }
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw); const mapped = this.mapEsgPolicyByPlateToOldFormat(
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options); raw,
"thirdPartyPlate",
);
if (!mapped?.Error) {
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
}
return { raw, mapped }; return { raw, mapped };
} }
@@ -912,8 +964,16 @@ export class SandHubService {
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`, `[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
); );
} }
const mapped = this.mapNewApiResponseToOldFormat(raw); const mapped = isInquiryFailurePayload(raw)
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options); ? {
Error: {
Message: getInquiryErrorMessage(raw, "thirdPartyPlate"),
},
}
: this.mapNewApiResponseToOldFormat(raw);
if (!mapped?.Error) {
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
}
return { raw, mapped }; return { raw, mapped };
} }
@@ -962,13 +1022,15 @@ export class SandHubService {
} }
if (isVinInquiry) { if (isVinInquiry) {
const raw = await this.lookupsService.findLastProcessedCarPolicy( let raw: any;
"car-body", try {
{ raw = await this.lookupsService.findLastProcessedCarPolicy("car-body", {
nationalCode: String(userDetail.nationalCodeOfInsurer), nationalCode: String(userDetail.nationalCodeOfInsurer),
vin: plateOrVin, vin: plateOrVin,
}, });
); } catch (error) {
this.throwInquiryError(error, "carBodyVin");
}
if (useParsianCarBodyLookup) { if (useParsianCarBodyLookup) {
this.assertParsianCarBodyLookupMatchesDeployment(); this.assertParsianCarBodyLookupMatchesDeployment();
} }
@@ -1000,10 +1062,15 @@ export class SandHubService {
plaqueRight: String(plateOrVin.centerDigits), plaqueRight: String(plateOrVin.centerDigits),
plaqueSerial: String(plateOrVin.ir), plaqueSerial: String(plateOrVin.ir),
}; };
const raw = await this.lookupsService.findLastProcessedCarPolicy( let raw: any;
"car-body", try {
query, raw = await this.lookupsService.findLastProcessedCarPolicy(
); "car-body",
query,
);
} catch (error) {
this.throwInquiryError(error, "carBodyPlate");
}
this.assertParsianCarBodyLookupMatchesDeployment(); this.assertParsianCarBodyLookupMatchesDeployment();
return { return {
@@ -1046,6 +1113,11 @@ export class SandHubService {
options, options,
); );
if (isInquiryFailurePayload(raw)) {
throw new BadRequestException(
getInquiryErrorMessage(raw, "carBodyPlate"),
);
}
const mapped = this.mapCarBodyInquiryResponse(raw); const mapped = this.mapCarBodyInquiryResponse(raw);
return { raw, mapped }; return { raw, mapped };
} }
@@ -1114,7 +1186,6 @@ export class SandHubService {
}; };
} }
/** /**
* ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`). * ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`).
* *
@@ -1130,10 +1201,10 @@ export class SandHubService {
options?: SandHubInquiryOptions, options?: SandHubInquiryOptions,
): Promise<{ raw: any; mapped: any }> { ): Promise<{ raw: any; mapped: any }> {
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085"; const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
const requestUrl = `${baseUrl}/inquiry/carByChassis`; const requestUrl = `${baseUrl}/inquiry/policyByChassis`;
const requestPayload = { const requestPayload = {
nationalCode: String(identity.nationalCode), nationalCode: String(identity.nationalCode),
chassisNo: String(identity.chassis), chassis: String(identity.chassis),
}; };
const live = await this.isInquiryLive("vinChassis", options); const live = await this.isInquiryLive("vinChassis", options);
@@ -1144,8 +1215,10 @@ export class SandHubService {
this.logger.debug( this.logger.debug(
`[MOCK] getPolicyByChassisInquiry nationalCode=${identity.nationalCode} chassis=${identity.chassis}`, `[MOCK] getPolicyByChassisInquiry nationalCode=${identity.nationalCode} chassis=${identity.chassis}`,
); );
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw); const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options); if (!mapped?.Error) {
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
}
return { raw, mapped }; return { raw, mapped };
} }
@@ -1155,12 +1228,13 @@ export class SandHubService {
"vinChassis", "vinChassis",
options, options,
); );
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw); const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options); if (!mapped?.Error) {
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
}
return { raw, mapped }; return { raw, mapped };
} }
private async makeSandHubRequest( private async makeSandHubRequest(
url: string, url: string,
payload: any, payload: any,
@@ -1205,7 +1279,7 @@ export class SandHubService {
} }
} }
throw new BadGatewayException( throw new BadGatewayException(
"Failed to fetch data from SandHub after multiple retries", "سرویس استعلام پس از چند تلاش پاسخ نداد. لطفاً کمی بعد دوباره تلاش کنید.",
); );
} }
@@ -1229,11 +1303,16 @@ export class SandHubService {
// Pattern: {centerDigits}{centerLetter(s)}{leftDigits}<space>{ir} // Pattern: {centerDigits}{centerLetter(s)}{leftDigits}<space>{ir}
const m = plk.trim().match(/^(\d+)([^\d\s]+)(\d+)\s+(\d+)$/); const m = plk.trim().match(/^(\d+)([^\d\s]+)(\d+)\s+(\d+)$/);
if (!m) return null; if (!m) return null;
const Plk3 = parseInt(m[1], 10); // center digits const Plk3 = parseInt(m[1], 10); // center digits
const Plk2 = this.plateNormalizer.normalizePlateText(m[2]); const Plk2 = this.plateNormalizer.normalizePlateText(m[2]);
const Plk1 = parseInt(m[3], 10); // left digits const Plk1 = parseInt(m[3], 10); // left digits
const PlkSrl = parseInt(m[4], 10); // IR region code const PlkSrl = parseInt(m[4], 10); // IR region code
if (!Number.isFinite(Plk3) || !Number.isFinite(Plk1) || !Number.isFinite(PlkSrl) || !Plk2) { if (
!Number.isFinite(Plk3) ||
!Number.isFinite(Plk1) ||
!Number.isFinite(PlkSrl) ||
!Plk2
) {
return null; return null;
} }
return { Plk1, Plk2, Plk3, PlkSrl }; return { Plk1, Plk2, Plk3, PlkSrl };
@@ -1245,7 +1324,12 @@ export class SandHubService {
// If the response carries a `plk` plate string (VIN inquiry) but lacks the // If the response carries a `plk` plate string (VIN inquiry) but lacks the
// individual Plk1/Plk2/Plk3/PlkSrl fields, parse and inject them so that // individual Plk1/Plk2/Plk3/PlkSrl fields, parse and inject them so that
// all downstream plate-handling code works identically to the plate flow. // all downstream plate-handling code works identically to the plate flow.
let plkParts: { Plk1: number; Plk2: string; Plk3: number; PlkSrl: number } | null = null; let plkParts: {
Plk1: number;
Plk2: string;
Plk3: number;
PlkSrl: number;
} | null = null;
if ( if (
newResponse.plk && newResponse.plk &&
newResponse.Plk1 == null && newResponse.Plk1 == null &&
@@ -1258,13 +1342,15 @@ export class SandHubService {
// Map the new field names to the old field names // Map the new field names to the old field names
return { return {
...newResponse, ...newResponse,
...(plkParts ? { ...(plkParts
Plk1: plkParts.Plk1, ? {
Plk2: plkParts.Plk2, Plk1: plkParts.Plk1,
Plk3: plkParts.Plk3, Plk2: plkParts.Plk2,
PlkSrl: plkParts.PlkSrl, Plk3: plkParts.Plk3,
plateLetterid: plkParts.Plk2, PlkSrl: plkParts.PlkSrl,
} : {}), plateLetterid: plkParts.Plk2,
}
: {}),
// Company information // Company information
CompanyCode: newResponse.companyId || newResponse.CompanyCode, CompanyCode: newResponse.companyId || newResponse.CompanyCode,
CompanyName: newResponse.companyPersianName || newResponse.CompanyName, CompanyName: newResponse.companyPersianName || newResponse.CompanyName,
@@ -1401,7 +1487,7 @@ export class SandHubService {
) { ) {
throw err; throw err;
} }
throw new Error(err); this.throwInquiryError(err, "thirdPartyPlate");
} }
} }
@@ -1420,7 +1506,7 @@ export class SandHubService {
const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate); const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate);
if (!jalaliBirthDate) { if (!jalaliBirthDate) {
throw new BadRequestException( throw new BadRequestException(
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13781124 or "1378-11-24").`, "تاریخ تولد واردشده برای استعلام هویت معتبر نیست.",
); );
} }
@@ -1454,7 +1540,7 @@ export class SandHubService {
const gregorianBirthdate = jalaliToGregorianDate(birthDate); const gregorianBirthdate = jalaliToGregorianDate(birthDate);
if (!gregorianBirthdate) { if (!gregorianBirthdate) {
throw new BadRequestException( throw new BadRequestException(
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13770624 or "1377-06-24").`, "تاریخ تولد واردشده برای استعلام هویت معتبر نیست.",
); );
} }
@@ -1472,7 +1558,7 @@ export class SandHubService {
if (response?.message?.includes("err.record.not.found")) { if (response?.message?.includes("err.record.not.found")) {
throw new NotFoundException( throw new NotFoundException(
"Personal inquiry failed: Record not found for the given national code and birth date.", getInquiryErrorMessage(response, "personalIdentity"),
); );
} }
return response.data; return response.data;
@@ -1483,7 +1569,7 @@ export class SandHubService {
) { ) {
throw err; throw err;
} }
throw new Error(`Error in finding personal inquiry: ${err}`); this.throwInquiryError(err, "personalIdentity");
} }
} }
@@ -1512,22 +1598,13 @@ export class SandHubService {
if (response?.data?.IsSucceed === false) { if (response?.data?.IsSucceed === false) {
throw new NotFoundException( throw new NotFoundException(
"Driving license check failed: The license is not valid or could not be found.", "گواهینامه‌ای مطابق کد ملی و شماره گواهینامه واردشده یافت نشد.",
); );
} }
return response.data; return response.data;
} catch (error) { } catch (error) {
if ( this.throwInquiryError(error, "drivingLicense");
error instanceof BadGatewayException &&
error.message.includes("multiple retries")
) {
throw new BadGatewayException(
`Driving license check failed after multiple retries. The service may be down.`,
);
}
// For all other errors (like 400, 404, etc.), re-throw them as-is.
throw new Error(`Error in finding driving license: ${error}`);
} }
} }
@@ -1563,13 +1640,13 @@ export class SandHubService {
response, response,
); );
throw new BadRequestException( throw new BadRequestException(
"Ownership validation failed: The provided national ID is not the owner of this vehicle.", "پلاک واردشده متعلق به کد ملی واردشده نیست.",
); );
} }
return response; return response;
} catch (err) { } catch (err) {
throw new Error(`Error in finding car ownership: ${err}`); this.throwInquiryError(err, "carOwnership");
} }
} }
@@ -1619,7 +1696,7 @@ export class SandHubService {
response, response,
); );
throw new BadRequestException( throw new BadRequestException(
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.", "شماره شبا متعلق به کد ملی واردشده نیست.",
); );
} }
@@ -1647,7 +1724,7 @@ export class SandHubService {
response, response,
); );
throw new BadRequestException( throw new BadRequestException(
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.", "شماره شبا متعلق به کد ملی واردشده نیست.",
); );
} }
@@ -1656,7 +1733,7 @@ export class SandHubService {
if (err instanceof BadRequestException) { if (err instanceof BadRequestException) {
throw err; throw err;
} }
throw new Error(`Error in matching sheba validation: ${err}`); this.throwInquiryError(err, "sheba");
} }
} }
@@ -1672,9 +1749,7 @@ export class SandHubService {
); );
if (err.response.status === 400) { if (err.response.status === 400) {
throw new BadGatewayException( throw new BadGatewayException(getInquiryErrorMessage(err, "generic"));
`SandHub rejected the request with a 400 Bad Request. Details: ${JSON.stringify(err.response.data)}`,
);
} }
} else { } else {
this.logger.error( this.logger.error(
@@ -1685,23 +1760,24 @@ export class SandHubService {
if (err.message === "EMPTY_RESPONSE") { if (err.message === "EMPTY_RESPONSE") {
throw new BadGatewayException( throw new BadGatewayException(
"SandHub is offline or returned an empty response", "سرویس استعلام پاسخی برنگرداند. لطفاً دوباره تلاش کنید.",
); );
} }
if (err.code === "ECONNABORTED") { if (err.code === "ECONNABORTED") {
throw new GatewayTimeoutException("SandHub request timed out"); throw new GatewayTimeoutException(
"زمان پاسخ‌گویی سرویس استعلام به پایان رسید. لطفاً دوباره تلاش کنید.",
);
} }
if (err.code === "ECONNRESET" || err.message.includes("socket hang up")) { if (err.code === "ECONNRESET" || err.message.includes("socket hang up")) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
"SandHub connection was reset or closed unexpectedly", "ارتباط با سرویس استعلام قطع شد. لطفاً دوباره تلاش کنید.",
); );
} }
// This final check is for when all retries have failed for a retryable error. // This final check is for when all retries have failed for a retryable error.
if (attempt >= maxRetries) { if (attempt >= maxRetries - 1) {
throw new BadGatewayException( throw new BadGatewayException(
"Failed to fetch data from SandHub after multiple retries", "سرویس استعلام پس از چند تلاش پاسخ نداد. لطفاً کمی بعد دوباره تلاش کنید.",
err.message,
); );
} }
} }