diff --git a/docs/Yara724 flows.pdf b/docs/Yara724 flows.pdf new file mode 100644 index 0000000..79168c9 Binary files /dev/null and b/docs/Yara724 flows.pdf differ 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/shebaSheba / 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/docs/panel-roles-reference.html b/docs/panel-roles-reference.html index 52b6a45..adedc62 100644 --- a/docs/panel-roles-reference.html +++ b/docs/panel-roles-reference.html @@ -363,7 +363,7 @@ - + diff --git a/package-lock.json b/package-lock.json index ef29116..abcfbc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8796,9 +8796,9 @@ } }, "node_modules/ip-address": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", - "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" 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, + "سرویس استعلام پس از چند تلاش پاسخ نداد. لطفاً کمی بعد دوباره تلاش کنید.", ); } }
MethodRouteWhat it does
GETv2/expert-claim/requestsList claims in WAITING_FOR_DAMAGE_EXPERT queue + factor-validation queue. Query: search, sortBy, page, limit, unifiedStatus, fileType.
GETv2/expert-claim/request/:claimRequestIdFull claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs.
GETv2/expert-claim/request/:claimRequestIdFull claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs. Completed claims include Fanavaran claimNo / claimId when available.
POSTv2/expert-claim/assign/:claimRequestIdLock claim to this expert. Returns assigned, already_assigned_to_you, or 409.
GETv2/expert-claim/request/:claimRequestId/price-dropPrice-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry.
PUTv2/expert-claim/request/:claimRequestId/price-dropCalculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400.