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