diff --git a/docs/expert-claim-reply-submit-v2-frontend.fa.md b/docs/expert-claim-reply-submit-v2-frontend.fa.md index a4c452c..6d6ecf5 100644 --- a/docs/expert-claim-reply-submit-v2-frontend.fa.md +++ b/docs/expert-claim-reply-submit-v2-frontend.fa.md @@ -56,7 +56,7 @@ Content-Type: application/json | `typeOfDamage` | بله | دقیقاً یکی از `"تعمیر"` یا `"تعویض"`. | | `price` | برای `"تعویض"` بله؛ برای `"تعمیر"` اختیاری | در صورت ارسال، مبلغ بین 100,000 تا 10,000,000,000 تومان. | | `salary` | بله | مبلغ بین 100,000 تا 10,000,000,000 تومان. | -| `totalPayment` | بله | مبلغ بین 100,000 تا 10,000,000,000 تومان. مجموع این فیلدها در کل پرونده نباید از 53,000,000 تومان بیشتر شود. | +| `totalPayment` | بله | مبلغ بین 1,000,000 تا 100,000,000,000 ریال. فقط در جریان V1، مجموع این فیلدها در کل پرونده نباید از 530,000,000 ریال بیشتر شود. | | `factorNeeded` | بله | مقدار Boolean واقعی (`true` یا `false`)؛ رشته ارسال نکنید. | | `daghi` | برای `"تعویض"` بله | برای `"تعمیر"` لازم نیست و در ثبت نهایی حذف می‌شود. | | `daghi.option` | در صورت وجود `daghi` بله | یکی از `"ارزش لوازم بازیافتی"`، `"تحویل داغی"`، `"فاقد ارزش"` یا `"با احتساب داغی"`. | @@ -66,7 +66,7 @@ Content-Type: application/json نکته‌ها: - مقدار `0` برای هیچ مبلغ ارسالی این endpoint معتبر نیست؛ حداقل مبلغ 100,000 تومان است. -- محدودیت 10 میلیارد مربوط به **هر فیلد مبلغ** است؛ سقف 53 میلیون مربوط به **مجموع `totalPayment` تمام ردیف‌ها** است و همچنان اعمال می‌شود. +- محدودیت هر فیلد مبلغ مستقل است؛ سقف 530 میلیون ریال فقط برای **مجموع `totalPayment` تمام ردیف‌های پرونده V1** اعمال می‌شود. جریان‌های V2 تا V6 سقف مجموع ندارند. - فیلدهای ناشناخته در body حذف می‌شوند. فرانت‌اند نباید برای انتقال داده به آن‌ها تکیه کند. ## پاسخ موفق @@ -152,15 +152,15 @@ Content-Type: application/json ```json { "statusCode": 400, - "message": "مجموع مبلغ قطعات (۵۴٬۰۰۰٬۰۰۰) از سقف مجاز (۵۳٬۰۰۰٬۰۰۰) تومان بیشتر است.", + "message": "مجموع مبلغ قطعات (۵۴۰٬۰۰۰٬۰۰۰) از سقف مجاز (۵۳۰٬۰۰۰٬۰۰۰) ریال بیشتر است.", "error": "PRICE_CAP_ERROR", "code": "PRICE_CAP_ERROR", - "totalPrice": 54000000, - "priceCap": 53000000 + "totalPrice": 540000000, + "priceCap": 530000000 } ``` -این خطا به یک ردیف مشخص وصل نیست. آن را در بالای جدول قیمت‌ها نمایش دهید و در صورت نیاز از `priceCap` برای پیام UI استفاده کنید. +این خطا فقط برای پرونده‌های V1 رخ می‌دهد و به یک ردیف مشخص وصل نیست. آن را در بالای جدول قیمت‌ها نمایش دهید و از `priceCap` جزئیات پرونده/خطا برای پیام UI استفاده کنید. مقدار `priceCap: null` یعنی پرونده سقف مجموع ندارد. ### 4. خطاهای قواعد پرونده و workflow @@ -225,4 +225,4 @@ function applySubmitError(body: ApiErrorBody) { } ``` -قبل از ارسال، فرانت‌اند می‌تواند همین بازه مبلغ را برای تجربه کاربری بهتر بررسی کند؛ با این حال اعتبار نهایی همیشه پاسخ API است. برای جلوگیری از خطای سقف مجموع، جمع `totalPayment` ردیف‌ها را نیز پیش از ارسال محاسبه و نمایش دهید. +قبل از ارسال، فرانت‌اند می‌تواند همین بازه مبلغ را برای تجربه کاربری بهتر بررسی کند؛ با این حال اعتبار نهایی همیشه پاسخ API است. جمع `totalPayment` را فقط وقتی `priceCap` جزئیات پرونده عدد است با سقف مقایسه کنید؛ `null` یعنی V2 تا V6 و بدون سقف مجموع. diff --git a/docs/external-integrations-reference.fa.html b/docs/external-integrations-reference.fa.html index e3e32af..6a07fda 100644 --- a/docs/external-integrations-reference.fa.html +++ b/docs/external-integrations-reference.fa.html @@ -159,6 +159,7 @@
برای هر پرس‌وجوی مبتنی بر پلاک:
diff --git a/docs/external-integrations-reference.html b/docs/external-integrations-reference.html index 73daada..8c7eefd 100644 --- a/docs/external-integrations-reference.html +++ b/docs/external-integrations-reference.html @@ -154,6 +154,7 @@
For every plate-based block inquiry:
diff --git a/docs/inquiry-participants-frontend-guide.fa.md b/docs/inquiry-participants-frontend-guide.fa.md index d1fa90f..68ee806 100644 --- a/docs/inquiry-participants-frontend-guide.fa.md +++ b/docs/inquiry-participants-frontend-guide.fa.md @@ -157,7 +157,7 @@ } ``` -سیستم ابتدا پلاک فعلی را با کد ملی بیمه‌گذار فعلیِ مرتبط با نوع بیمه استعلام می‌کند. اگر نتیجه ناموجود، منقضی یا فاقد بیمه‌نامه مرتبط باشد، پلاک قبلی را با `previousPolicyholderNationalCode` امتحان می‌کند. نتیجه پلاک قبلی فقط در صورت تطبیق VIN پذیرفته می‌شود؛ پلاک قبلی هرگز جایگزین پلاک فعلی نمی‌شود. +اطلاعات انتقال اخیر فقط به‌عنوان متادیتای پرونده ذخیره می‌شوند. در route پلاک، سیستم فقط `currentPlate` را با کد ملی بیمه‌گذار نهاییِ مرتبط با نوع بیمه استعلام می‌کند و هیچ fallbackای به `previousPlate` یا `previousPolicyholderNationalCode` ندارد. در route شماره شاسی نیز فقط `vehicle.vin` با همان بیمه‌گذار نهایی استعلام می‌شود. حتی در route مربوط به VIN، آبجکت `vehicle` از قرارداد مشترک استفاده می‌کند و `currentPlate` در قرارداد فعلی الزامی است. مقدار VIN در `vehicle.vin` قرار می‌گیرد، نه در فیلد سطح بالای `vin`. diff --git a/docs/inquiry-participants-proposal.fa.md b/docs/inquiry-participants-proposal.fa.md index c31ce78..e8cd0b7 100644 --- a/docs/inquiry-participants-proposal.fa.md +++ b/docs/inquiry-participants-proposal.fa.md @@ -79,9 +79,9 @@ } ``` -مقدار پیش‌فرض `registrationState` برابر `CURRENT` است و برای این مسیر استثنایی مقدار `RECENTLY_TRANSFERRED` استفاده می‌شود. در انتقال اخیر، `previousPlate`، `previousPolicyholderNationalCode` و `vin` الزامی‌اند؛ فیلدهای مربوط به پلاک قبلی در حالت عادی `CURRENT` نباید ارسال شوند. پلاک فعلی همچنان شناسه اصلی خودرو است. هماهنگ‌کننده استعلام ابتدا پلاک فعلی را با کد ملی بیمه‌گذار فعلی بررسی می‌کند و اگر نتیجه ناموجود، قدیمی یا فاقد بیمه‌نامه مرتبط بود، پلاک قبلی را با `previousPolicyholderNationalCode` استعلام می‌کند. +مقدار پیش‌فرض `registrationState` برابر `CURRENT` است و برای این مسیر استثنایی مقدار `RECENTLY_TRANSFERRED` استفاده می‌شود. در انتقال اخیر، `previousPlate`، `previousPolicyholderNationalCode` و `vin` الزامی‌اند؛ فیلدهای مربوط به پلاک قبلی در حالت عادی `CURRENT` نباید ارسال شوند. این اطلاعات انتقال فقط به‌عنوان متادیتای پرونده نگه‌داری می‌شوند و پلاک فعلی همچنان شناسه اصلی خودرو است. -پیش از پذیرش نتیجه پلاک قبلی، بک‌اند باید یکسان بودن VIN/شماره شاسی را بررسی کند. در صورت مغایرت، انتخاب خودکار متوقف و اصلاح اطلاعات یا بررسی دستی الزامی شود. هر دو پلاک و تمام تلاش‌های استعلام برای ممیزی نگه‌داری شوند، اما پلاک قبلی هیچ‌گاه نباید روی پلاک فعلی نوشته شود. +در route پلاک، هماهنگ‌کننده دقیقاً یک استعلام بیمه انجام می‌دهد: `currentPlate` همراه با بیمه‌گذار نهایی همان نوع بیمه. در route شماره شاسی نیز `vehicle.vin` با همان بیمه‌گذار نهایی ارسال می‌شود. `previousPlate` هیچ‌گاه استعلام نمی‌شود، `previousPolicyholderNationalCode` به ارائه‌دهنده استعلام ارسال نمی‌شود و VIN برای انتخاب نتیجه پلاک قبلی به کار نمی‌رود. ## ترتیب پیشنهادی فرم @@ -102,7 +102,7 @@ - شخص نهایی هر نقش را برگرداند؛ - هویت درست را به استعلام مرتبط بدهد: گواهینامه ← راننده، مالکیت و تطبیق شبا ← مالک خودرو، بیمه شخص ثالث با پلاک/VIN ← بیمه‌گذار شخص ثالث، بیمه بدنه با پلاک/VIN ← بیمه‌گذار بدنه؛ - شبا را در استعلام شخص مطالبه‌کننده خسارت (`SECOND` زیان‌دیده در `THIRD_PARTY` و طرف اول در `CAR_BODY`) الزامی کند و با کد ملی مالک خودرو اعتبارسنجی کند؛ در استعلام `FIRST` مقصر پرونده ثالث شبا دریافت نمی‌شود؛ -- بر اساس یک قاعده مشخص، پلاک فعلی یا قبلی را انتخاب و نتیجه پلاک قبلی را با VIN/شماره شاسی تطبیق دهد؛ +- فقط پلاک فعلی یا VIN ارسال‌شده را با بیمه‌گذار نهایی همان نوع بیمه استعلام کند؛ متادیتای انتقال قبلی نباید مسیریابی استعلام را تغییر دهد؛ - استعلام هویت را برای هر شخص یکتا فقط یک بار اجرا کند؛ - اشخاص نرمال‌شده و نقش‌های آن‌ها را در `Party` مربوط ذخیره کند. - `participants` و `participantRoles` ذخیره‌شده را بدون حذف اطلاعات در جزئیات پرونده پنل‌های کارشناسی و پرونده خسارت متصل نمایش دهد تا اطلاعات راننده و سایر نقش‌ها برای بررسی در دسترس بماند. diff --git a/docs/inquiry-participants-proposal.md b/docs/inquiry-participants-proposal.md index fb0dee3..183070e 100644 --- a/docs/inquiry-participants-proposal.md +++ b/docs/inquiry-participants-proposal.md @@ -79,9 +79,9 @@ Participant roles and vehicle identifiers are separate concerns. When a vehicle } ``` -`registrationState` is `CURRENT` by default or `RECENTLY_TRANSFERRED` for this exceptional path. `previousPlate`, `previousPolicyholderNationalCode`, and `vin` are required when `registrationState=RECENTLY_TRANSFERRED`; the previous-plate fields are forbidden for the normal `CURRENT` path. The current plate remains the vehicle's primary identifier. The inquiry orchestrator queries the current plate with the current policyholder's national code first, then automatically tries the previous plate with `previousPolicyholderNationalCode` when the current result is missing, stale, or does not find the relevant policy. +`registrationState` is `CURRENT` by default or `RECENTLY_TRANSFERRED` for this exceptional path. `previousPlate`, `previousPolicyholderNationalCode`, and `vin` are required when `registrationState=RECENTLY_TRANSFERRED`; the previous-plate fields are forbidden for the normal `CURRENT` path. These transfer fields are retained only as case metadata, and the current plate remains the vehicle's primary identifier. -Before accepting a previous-plate result, the backend must correlate it to the same VIN/chassis. A mismatch must stop automatic selection and require correction or manual review. Both identifiers and every attempted inquiry should be retained for audit, but a previous plate must never overwrite the current plate. +For a plate route, the inquiry orchestrator performs exactly one policy lookup: `currentPlate` with the resolved policyholder for that policy type. For a VIN route, it uses `vehicle.vin` with the same resolved policyholder. It never queries `previousPlate`, never sends `previousPolicyholderNationalCode` to an inquiry provider, and does not use VIN to select a previous-plate result. ## Suggested UI sequence @@ -102,7 +102,7 @@ Create one shared participant resolver used by every inquiry route. Its interfac - return the resolved person for each role; - route the correct identity to each inquiry: driver licence → Driver, ownership and Sheba validation → Vehicle Owner, third-party policy by plate/VIN → Third-party Policyholder, car-body policy by plate/VIN → Car-body Policyholder; - require Sheba in the claimant inquiry (`THIRD_PARTY` damaged/SECOND party and `CAR_BODY` first party) and validate it against the resolved Vehicle Owner; the `THIRD_PARTY` guilty/FIRST inquiry does not collect Sheba; -- choose the current or previous plate deterministically and verify previous-plate results against VIN/chassis; +- query only the submitted current plate or VIN with the resolved policyholder for that policy type; previous-transfer metadata must not affect inquiry routing; - run personal identity inquiry once per distinct person; - persist normalized participants and role assignments on the relevant `Party`. - expose the persisted `participants` and `participantRoles` unchanged in expert-facing blame and linked-claim details so driver and other role data remain available for review. diff --git a/docs/panel-roles-reference.fa.html b/docs/panel-roles-reference.fa.html index b23c284..831a321 100644 --- a/docs/panel-roles-reference.fa.html +++ b/docs/panel-roles-reference.fa.html @@ -370,14 +370,14 @@ - + - + - + diff --git a/docs/panel-roles-reference.html b/docs/panel-roles-reference.html index adedc62..0c490e9 100644 --- a/docs/panel-roles-reference.html +++ b/docs/panel-roles-reference.html @@ -363,14 +363,14 @@
متدمسیرتوضیح
GETv2/expert-claim/requestsفهرست خسارت‌ها در صف WAITING_FOR_DAMAGE_EXPERT + صف اعتبارسنجی فاکتور. پارامترها: search، sortBy، page، limit، unifiedStatus، fileType.
GETv2/expert-claim/request/:claimRequestIdجزئیات کامل خسارت: قطعات آسیب‌دیده، تصاویر گرفته‌شده، اسناد، priceDrop، داده طرف بلیم، آدرس‌های ویدیو.
GETv2/expert-claim/request/:claimRequestIdجزئیات کامل خسارت، به‌همراه priceCap مؤثر: ۵۳۰٬۰۰۰٬۰۰۰ ریال برای V1 و null برای V2 تا V6 یا وقتی سقف غیرفعال است.
POSTv2/expert-claim/assign/:claimRequestIdقفل خسارت برای این کارشناس. بازمی‌گرداند: assigned، already_assigned_to_you، یا ۴۰۹.
GETv2/expert-claim/request/:claimRequestId/price-dropمحتوای کاهش قیمت: برچسب‌های شدت، کاتالوگ ضریب، قطعات آسیب‌دیده + نگاشت، سال پیشنهادی خودرو از استعلام تقصیر.
PUTv2/expert-claim/request/:claimRequestId/price-dropمحاسبه و ذخیره کاهش قیمت: قیمت خودرو × ضریب سال × مجموع ضرایب ÷ ۴۰۰.
PUTv2/expert-claim/reply/submit/:claimRequestIdارسال پاسخ ارزیابی خسارت (لیست قطعات قیمت‌گذاری‌شده، داغی، branchId). سقف: کل ≤ ۵۳،۰۰۰،۰۰۰ تومان. بسته به پرچم‌های factorNeeded، خسارت را به owner-sign، mixed-factors-pending، یا صف اعتبارسنجی فاکتور منتقل می‌کند.
PUTv2/expert-claim/reply/submit/:claimRequestIdارسال پاسخ ارزیابی خسارت (لیست قطعات قیمت‌گذاری‌شده، داغی، branchId). فقط V1: کل ≤ ۵۳۰٬۰۰۰٬۰۰۰ ریال؛ V2 تا V6 بدون سقف مجموع هستند.
PUTv2/expert-claim/reply/resend/:claimRequestIdدرخواست از کاربر برای ارسال مجدد اسناد/عکس‌ها. یک ارسال مجدد در هر چرخه خسارت؛ در صورت تکمیل قبلی ۴۲۲ برمی‌گرداند.
PATCHv2/expert-claim/:claimRequestId/visitدرخواست از کاربر برای مراجعه حضوری. خسارت را آزاد می‌کند، وضعیت claimStatus را به NEEDS_REVISION تنظیم می‌کند.
PATCHv2/expert-claim/validate-factors/:claimRequestIdاعتبارسنجی فاکتورهای تعمیرگاه بارگذاری‌شده. تأیید یا رد هر خط فاکتور با totalPayment. سقف برای تمام خطوط اعمال می‌شود (≤ ۵۳،۰۰۰،۰۰۰ تومان). پس از تصمیم‌گیری درباره تمام خطوط، به‌صورت خودکار تکمیل می‌شود.
PATCHv2/expert-claim/validate-factors/:claimRequestIdاعتبارسنجی فاکتورهای تعمیرگاه بارگذاری‌شده. سقف ۵۳۰٬۰۰۰٬۰۰۰ ریال تمام خطوط فقط برای V1 اعمال می‌شود؛ V2 تا V6 بدون سقف هستند.
PATCHv2/expert-claim/request/:claimRequestId/damaged-partsویرایش قطعات آسیب‌دیده انتخاب‌شده در حالی که خسارت توسط این کارشناس قفل است (EXPERT_REVIEWING).
GETv2/expert-claim/outer-parts-catalogکاتالوگ قطعات بیرونی خودرو فناوران (مشترک با جریان کاربر).
GETv2/expert-claim/inner-parts-catalogJSON ثابت کاتالوگ قطعات داخلی خودرو.
- + - + - + diff --git a/src/common/utils/inquiry-error.spec.ts b/src/common/utils/inquiry-error.spec.ts index 4ea2920..cce24da 100644 --- a/src/common/utils/inquiry-error.spec.ts +++ b/src/common/utils/inquiry-error.spec.ts @@ -4,13 +4,63 @@ import { } from "./inquiry-error"; describe("inquiry error messages", () => { - it("turns ESG not-found responses into a contextual plate message", () => { + it("preserves a Persian ESG not-found response", () => { expect( getInquiryErrorMessage( { success: false, message: "موردی یافت نشد" }, "thirdPartyPlate", ), - ).toBe("بیمه‌نامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد."); + ).toBe("موردی یافت نشد"); + }); + + it.each([ + ["RECORD_NOT_FOUND", "رکوردی یافت نشد", "Provider request failed"], + [ + "INQUIRY_NO_MATCH", + "نتیجه‌ای مطابق با اطلاعات وارد شده یافت نشد", + "Inquiry returned no matching result", + ], + ])( + "prefers ESG messageFa for %s over the technical message", + (code, messageFa, message) => { + expect( + getInquiryErrorMessage( + { + error: { + code, + message, + messageFa, + providerMessage: message, + providerCode: code, + }, + attemptSummary: { + attempts: [{ code, message, messageFa }], + }, + }, + "thirdPartyPlate", + ), + ).toBe(messageFa); + }, + ); + + it("finds messageFa inside an HTTP response envelope", () => { + expect( + getInquiryErrorMessage( + { + response: { + status: 404, + data: { + error: { + code: "RECORD_NOT_FOUND", + message: "Provider request failed", + messageFa: "رکوردی یافت نشد", + }, + }, + }, + }, + "thirdPartyPlate", + ), + ).toBe("رکوردی یافت نشد"); }); it("distinguishes VIN and car-body not-found failures", () => { diff --git a/src/common/utils/inquiry-error.ts b/src/common/utils/inquiry-error.ts index 2f08ee8..08ce261 100644 --- a/src/common/utils/inquiry-error.ts +++ b/src/common/utils/inquiry-error.ts @@ -87,7 +87,25 @@ export function inquiryErrorStatus(error: unknown): number | undefined { } export function extractInquiryProviderMessage(error: unknown): string { - for (const record of errorRecords(error)) { + const records = errorRecords(error); + + // The normalized ESG/Parsian envelope carries the safe user-facing text in + // messageFa while `message` and `providerMessage` may remain technical. + // Search every envelope level for that explicit Persian field before + // considering generic message fields on an outer object. + for (const record of records) { + for (const key of [ + "messageFa", + "MessageFa", + "messageFA", + "persianMessage", + ] as const) { + const message = cleanMessage(record[key]); + if (message) return message; + } + } + + for (const record of records) { for (const key of ["message", "Message", "detail", "title"] as const) { const message = cleanMessage(record[key]); if (message) return message; @@ -128,13 +146,21 @@ export function isInquiryTimeout(error: unknown): boolean { 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(); + const codes = errorRecords(error).flatMap((record) => + [record.code, record.providerCode] + .map((code) => String(code ?? "").toUpperCase()) + .filter(Boolean), + ); return ( inquiryErrorStatus(error) === 404 || - ["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY", "RECORD_NOT_FOUND"].includes( - code, + codes.some((code) => + [ + "NOT_FOUND", + "POLICY_NOT_FOUND", + "NO_POLICY", + "RECORD_NOT_FOUND", + "INQUIRY_NO_MATCH", + ].includes(code), ) || /\bnot[ -]?found\b|\bno (?:active |relevant )?(?:record|policy|item)\b|record\.not\.found|موردی یافت نشد|یافت نشد|پیدا نشد|فاقد بیمه(?:‌| )?نامه/i.test( message, @@ -175,6 +201,11 @@ export function getInquiryErrorMessage( ): string { const providerMessage = extractInquiryProviderMessage(error); + // Persian text supplied by the provider is already the intended client + // message. Preserve it verbatim instead of replacing it with a local + // contextual fallback such as "inquiry not found". + if (providerMessage && hasPersian(providerMessage)) return providerMessage; + if (isNotFound(error, providerMessage)) return NOT_FOUND_MESSAGES[context]; if ( @@ -208,10 +239,6 @@ export function getInquiryErrorMessage( 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]; } diff --git a/src/constants/repair-amount-limits.ts b/src/constants/repair-amount-limits.ts index a9941eb..56d5665 100644 --- a/src/constants/repair-amount-limits.ts +++ b/src/constants/repair-amount-limits.ts @@ -8,13 +8,13 @@ export const REPAIR_LINE_AMOUNT_TOMAN = { // IT IS RIAL FROM NOW ON MAX: 530_000_000, } as const; -/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */ +/** Max sum of all priced + factor lines in one V1 expert reply / validation (Rial). */ export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX; const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]); /** - * Returns null when the claim v2 total cap is disabled. + * Returns null when the V1 claim total cap is disabled. * * Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again. * Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount. diff --git a/src/expert-blame/expert-blame.participants.spec.ts b/src/expert-blame/expert-blame.participants.spec.ts new file mode 100644 index 0000000..8ca8b4d --- /dev/null +++ b/src/expert-blame/expert-blame.participants.spec.ts @@ -0,0 +1,66 @@ +import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; +import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; +import { ExpertBlameService } from "./expert-blame.service"; + +describe("ExpertBlameService participant detail contract", () => { + it("returns normalized participants and their role assignments from the blame party", async () => { + const service = new (ExpertBlameService as any)( + ...new Array(13).fill(undefined), + ) as any; + const expertId = "66ec0e480e321873c0900001"; + + service.expireBlameCaseWorkflowLockV2IfStale = jest + .fn() + .mockResolvedValue(undefined); + service.blameRequestDbService = { + findByIdWithoutHistory: jest.fn().mockResolvedValue({ + _id: "66ec0e480e321873c0900002", + type: BlameRequestType.THIRD_PARTY, + status: CaseStatus.WAITING_FOR_EXPERT, + expertInitiated: true, + initiatedByFieldExpertId: expertId, + workflow: {}, + parties: [ + { + role: "FIRST", + person: { fullName: "Legacy Party Name" }, + participants: [ + { + participantId: "PERSON_1", + nationalCode: "0012345678", + birthday: "1370/01/01", + unknown: true, + }, + ], + participantRoles: { + driver: "PERSON_1", + vehicleOwner: "PERSON_1", + thirdPartyPolicyholder: "PERSON_1", + }, + vehicle: { inquiry: { raw: { large: true } } }, + }, + ], + createdAt: new Date("2026-09-19T00:00:00.000Z"), + updatedAt: new Date("2026-09-19T00:00:00.000Z"), + }), + }; + + const result = await service.findOneV2("blame-1", { sub: expertId }); + const party = (result.parties as any[])[0]; + + expect(party.participants).toEqual([ + { + participantId: "PERSON_1", + nationalCode: "0012345678", + birthday: "1370/01/01", + }, + ]); + expect(party.participantRoles).toEqual({ + driver: "PERSON_1", + vehicleOwner: "PERSON_1", + thirdPartyPolicyholder: "PERSON_1", + }); + expect(party.person.fullName).toBe("Legacy Party Name"); + expect(party.vehicle.inquiry).toBeUndefined(); + }); +}); diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index b682124..e840672 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -91,6 +91,7 @@ import { ExpertFileActivityType, ExpertFileKind, } from "src/users/entities/schema/expert-file-activity.schema"; +import { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver"; interface CheckedRequestEntry { CheckedRequest?: { @@ -1250,8 +1251,17 @@ export class ExpertBlameService { doc.createdAtFormatted = `${createdDate} ${createdTime}`; doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`; + // Keep the normalized blame-party participant contract authoritative for + // every expert detail response. Legacy person fields remain for old files, + // but new UIs must read participants + participantRoles from the same party. + doc.parties = parties.map((party: Record) => ({ + ...party, + participants: sanitizeStoredInquiryParticipants(party.participants), + participantRoles: party.participantRoles, + })); + // Strip heavy SandHub inquiry blob - for (const party of parties as Array<{ + for (const party of doc.parties as Array<{ vehicle?: Record; }>) { if ( diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts index 2b827e7..8221ce6 100644 --- a/src/expert-claim/dto/claim-detail-v2.dto.ts +++ b/src/expert-claim/dto/claim-detail-v2.dto.ts @@ -53,11 +53,19 @@ export class ClaimDetailV2ResponseDto { blameRequestType?: BlameRequestType; @ApiPropertyOptional({ - description: "How the blame file was initiated: IN_PERSON or LINK", + description: "How the blame file was initiated: NORMAL, IN_PERSON, or LINK", example: "IN_PERSON", }) creationMethod?: string; + @ApiProperty({ + nullable: true, + description: + "Maximum expert-reply total for V1 user-created files. Null for V2-V6 flows or when the cap is disabled.", + example: 530000000, + }) + priceCap: number | null; + @ApiPropertyOptional({ description: "CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)", @@ -278,7 +286,7 @@ export class ClaimDetailV2ResponseDto { @ApiPropertyOptional({ description: - "Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.", + "Linked blame case (`blameCases`), same shape as expert-blame detail. Each party exposes its authoritative normalized `participants` and `participantRoles`, plus video/voice URLs, workflow, expert, and formatted dates.", }) blameCase?: Record; diff --git a/src/expert-claim/expert-claim.service.spec.ts b/src/expert-claim/expert-claim.service.spec.ts index 96e443c..8ab91c8 100644 --- a/src/expert-claim/expert-claim.service.spec.ts +++ b/src/expert-claim/expert-claim.service.spec.ts @@ -168,6 +168,88 @@ describe("ExpertClaimService expert-reply pricing", () => { ); }); + it("enforces the configured total-payment cap for a V1 user-created file", async () => { + const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED; + process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true"; + + try { + const { service, findByIdAndUpdate } = setupSuccessfulV2Submit( + BlameRequestType.THIRD_PARTY, + ); + (service as any).blameRequestDbService.findById.mockResolvedValue({ + type: BlameRequestType.THIRD_PARTY, + creationMethod: "NORMAL", + }); + + await expect( + service.submitExpertReplyV2( + "v1-claim", + { + ...validV2Reply, + parts: [ + { + ...validV2Reply.parts[0], + salary: "1000000", + totalPayment: "600000000", + }, + ], + }, + { + sub: V2_EXPERT_ID, + fullName: "Expert One", + role: RoleEnum.FIELD_EXPERT, + }, + ), + ).rejects.toMatchObject({ response: { code: "PRICE_CAP_ERROR" } }); + + expect(findByIdAndUpdate).not.toHaveBeenCalled(); + } finally { + if (previousEnabled === undefined) { + delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED; + } else { + process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled; + } + } + }); + + it("does not enforce the V1 cap for an expert-initiated flow", async () => { + const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED; + process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true"; + + try { + const { service, findByIdAndUpdate } = setupSuccessfulV2Submit( + BlameRequestType.THIRD_PARTY, + ); + + await service.submitExpertReplyV2( + "v3-claim", + { + ...validV2Reply, + parts: [ + { + ...validV2Reply.parts[0], + salary: "1000000", + totalPayment: "600000000", + }, + ], + }, + { + sub: V2_EXPERT_ID, + fullName: "Expert One", + role: RoleEnum.FIELD_EXPERT, + }, + ); + + expect(findByIdAndUpdate).toHaveBeenCalledTimes(1); + } finally { + if (previousEnabled === undefined) { + delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED; + } else { + process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled; + } + } + }); + it("allows a repair line without daghi and removes a stray daghi payload", () => { const service = createService() as any; diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 2d1d9aa..40e6753 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -156,7 +156,7 @@ import { ExpertFileKind, } from "src/users/entities/schema/expert-file-activity.schema"; -/** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */ +/** Configured V1 maximum sum of line `totalPayment` across the claim (Rial). */ import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { @@ -180,6 +180,7 @@ import { normalizeMoneyAmountString, parseMoneyAmountToman, } from "src/utils/unicode-digits"; +import { claimPriceCapAppliesToBlame } from "src/helpers/claim-price-cap"; @Injectable() export class ExpertClaimService { @@ -1694,8 +1695,13 @@ export class ExpertClaimService { throw new BadRequestException(pricingValidationError); } - // Validate total price cap (priced lines sum), when enabled. - const priceCap = getClaimV2TotalPaymentCapToman(); + // The total cap is a V1-only rule. Legacy claims embed their blame file. + const configuredPriceCap = getClaimV2TotalPaymentCapToman(); + const priceCap = + configuredPriceCap !== null && + claimPriceCapAppliesToBlame(request.blameFile) + ? configuredPriceCap + : null; if (priceCap !== null && reply.parts && reply.parts.length > 0) { let totalPrice = 0; @@ -1727,7 +1733,7 @@ export class ExpertClaimService { if (totalPrice > priceCap) { throw new BadRequestException({ - message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`, + message: `You have reached the maximum acceptable total price (Rial). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`, error: "PRICE_CAP_ERROR", code: "PRICE_CAP_ERROR", totalPrice: totalPrice, @@ -2293,7 +2299,8 @@ export class ExpertClaimService { * Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION. * — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature). * — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later). - * When enabled by env, total of all repair lines must be ≤ the claim v2 total payment cap. + * For V1 user-created files, when enabled by env, the total of all repair + * lines must be no greater than the configured total-payment cap. * Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`. */ async validateClaimFactorsV2( @@ -2433,7 +2440,12 @@ export class ExpertClaimService { }; } - const priceCap = getClaimV2TotalPaymentCapToman(); + const configuredPriceCap = getClaimV2TotalPaymentCapToman(); + const priceCap = + configuredPriceCap !== null && + claimPriceCapAppliesToBlame(await this.loadBlameForClaim(claim)) + ? configuredPriceCap + : null; if (priceCap !== null) { let totalPrice = 0; for (const part of updatedReply.parts || []) { @@ -2448,7 +2460,7 @@ export class ExpertClaimService { } if (totalPrice > priceCap) { throw new BadRequestException({ - message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`, + message: `You have reached the maximum acceptable total price (Rial). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`, error: "PRICE_CAP_ERROR", totalPrice, priceCap, @@ -3259,7 +3271,7 @@ export class ExpertClaimService { * - Claim must exist * - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub) * - Must be in EXPERT_REVIEWING status - * - Total payment across all parts must not exceed 53,000,000 (same cap as factor validation totals) + * - V1 only: total payment across all parts must not exceed the configured cap * - Each part must include `daghi` (option + conditional price) like V1 * * On success: @@ -3364,8 +3376,12 @@ export class ExpertClaimService { throw new BadRequestException(pricingValidationError); } - // Price cap validation, when enabled. - const priceCap = getClaimV2TotalPaymentCapToman(); + // The configured total cap is enforced only for V1 user-created files. + const configuredPriceCap = getClaimV2TotalPaymentCapToman(); + const priceCap = + configuredPriceCap !== null && claimPriceCapAppliesToBlame(blame) + ? configuredPriceCap + : null; if (priceCap !== null) { let totalPrice = 0; for (const part of reply.parts || []) { @@ -3376,7 +3392,7 @@ export class ExpertClaimService { } if (totalPrice > priceCap) { throw new BadRequestException({ - message: `مجموع مبلغ قطعات (${totalPrice.toLocaleString("fa-IR")}) از سقف مجاز (${priceCap.toLocaleString("fa-IR")}) تومان بیشتر است.`, + message: `مجموع مبلغ قطعات (${totalPrice.toLocaleString("fa-IR")}) از سقف مجاز (${priceCap.toLocaleString("fa-IR")}) ریال بیشتر است.`, error: "PRICE_CAP_ERROR", code: "PRICE_CAP_ERROR", totalPrice, @@ -5125,7 +5141,11 @@ export class ExpertClaimService { claim.blameRequestId ? this.blameRequestDbService.find( { _id: new Types.ObjectId(claim.blameRequestId.toString()) }, - { lean: true, select: "type parties expert.decision" }, + { + lean: true, + select: + "type parties expert.decision creationMethod expertInitiated registrarInitiated callCenterInitiated initiatedByFieldExpertId initiatedByRegistrarId initiatedByCallCenterId", + }, ) : Promise.resolve([]), ]); @@ -5160,6 +5180,11 @@ export class ExpertClaimService { const blameFileContext = blameLean ? this.blameFileContextForExpert(blameLean) : {}; + const configuredPriceCap = getClaimV2TotalPaymentCapToman(); + const priceCap = + configuredPriceCap !== null && claimPriceCapAppliesToBlame(linkedBlame) + ? configuredPriceCap + : null; let videoCapture: ClaimDetailV2ResponseDto["videoCapture"] = undefined; if (videoCaptureRow) { @@ -5269,6 +5294,7 @@ export class ExpertClaimService { ? this.sanitizeVehicleInquiryForApi(vehiclePayload) : undefined, ...blameFileContext, + priceCap, blameRequestId: claim.blameRequestId?.toString(), blameRequestNo: claim.blameRequestNo, money: moneyPayload, diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index fcfdb99..6e41a1c 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -268,7 +268,7 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "Submit expert damage assessment reply", description: - "**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 100,000–10,000,000,000 **Toman**. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 **Toman** (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" + + "**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 1,000,000–100,000,000,000 **Rial**. **V1-only cap:** for user-created V1 files, sum of line `totalPayment` values ≤ 530,000,000 **Rial** (same limit as factor-validation totals across priced + factor lines). V2–V6 files are uncapped. Claim detail exposes the effective `priceCap` (`null` when uncapped). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" + "**Frontend routing by `ClaimCaseStatus` (`status`):**\n" + "- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" + "- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" + @@ -353,7 +353,7 @@ export class ExpertClaimV2Controller { "**Response:** `claimStatus` = `ClaimStatus` (e.g. APPROVED). `caseStatus` = `ClaimCaseStatus` (e.g. COMPLETED vs insurer-review) — they are not interchangeable.\n\n" + "**Preconditions:** `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`), `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" + "**Decisions:** each factor line gets `APPROVED` or `REJECTED`. **Every** decided line must include expert-entered `totalPayment` **or** both `price` and `salary` (factor photos are not read for amounts).\n\n" + - "**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000 Toman**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" + + "**V1-only cap (when every factor line is decided):** for user-created V1 files, sum of **all** reply lines (priced parts + factor lines) must be ≤ **530,000,000 Rial**; otherwise `PRICE_CAP_ERROR` is returned. V2–V6 files are uncapped.\n\n" + "**Outcomes:**\n" + "- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature. V5 instead waits for FileMaker approval.\n" + "- **Any rejected (repriced):** same completion behavior for now (V5 waits for FileMaker approval).\n" + diff --git a/src/helpers/claim-price-cap.spec.ts b/src/helpers/claim-price-cap.spec.ts new file mode 100644 index 0000000..20f8eb4 --- /dev/null +++ b/src/helpers/claim-price-cap.spec.ts @@ -0,0 +1,25 @@ +import { CreationMethod } from "src/request-management/entities/schema/request-management.schema"; +import { claimPriceCapAppliesToBlame } from "./claim-price-cap"; + +describe("claimPriceCapAppliesToBlame", () => { + it.each([ + [{ creationMethod: CreationMethod.NORMAL }], + [{}], + ])("applies to V1 user-created files (%p)", (blame) => { + expect(claimPriceCapAppliesToBlame(blame)).toBe(true); + }); + + it.each([ + [{ creationMethod: CreationMethod.LINK, expertInitiated: true }], + [{ creationMethod: CreationMethod.IN_PERSON, expertInitiated: true }], + [{ creationMethod: CreationMethod.IN_PERSON, registrarInitiated: true }], + [{ creationMethod: CreationMethod.LINK, callCenterInitiated: true }], + [{ creationMethod: CreationMethod.NORMAL, initiatedByCallCenterId: "agent" }], + ])("does not apply to non-V1 files (%p)", (blame) => { + expect(claimPriceCapAppliesToBlame(blame)).toBe(false); + }); + + it("does not apply when the linked blame origin is unavailable", () => { + expect(claimPriceCapAppliesToBlame(null)).toBe(false); + }); +}); diff --git a/src/helpers/claim-price-cap.ts b/src/helpers/claim-price-cap.ts new file mode 100644 index 0000000..ecf41a2 --- /dev/null +++ b/src/helpers/claim-price-cap.ts @@ -0,0 +1,39 @@ +import { CreationMethod } from "src/request-management/entities/schema/request-management.schema"; + +export type ClaimPriceCapBlameOrigin = { + creationMethod?: CreationMethod | string | null; + expertInitiated?: boolean | null; + registrarInitiated?: boolean | null; + callCenterInitiated?: boolean | null; + initiatedByFieldExpertId?: unknown; + initiatedByRegistrarId?: unknown; + initiatedByCallCenterId?: unknown; +}; + +/** + * The total-payment cap is a V1 business rule. V1 files are created directly + * by a user (`NORMAL`); expert/registrar LINK or IN_PERSON files and V6 + * call-center files must not inherit it. Missing creationMethod is treated as + * NORMAL for older user-created records, while initiator markers take + * precedence so legacy non-V1 records cannot be misclassified. + */ +export function claimPriceCapAppliesToBlame( + blame?: ClaimPriceCapBlameOrigin | null, +): boolean { + if (!blame) return false; + + const hasNonV1Initiator = + blame.expertInitiated === true || + blame.registrarInitiated === true || + blame.callCenterInitiated === true || + blame.initiatedByFieldExpertId != null || + blame.initiatedByRegistrarId != null || + blame.initiatedByCallCenterId != null; + + if (hasNonV1Initiator) return false; + + return ( + blame.creationMethod == null || + blame.creationMethod === CreationMethod.NORMAL + ); +} diff --git a/src/request-management/inquiry-participant-resolver.spec.ts b/src/request-management/inquiry-participant-resolver.spec.ts index 20553b9..b807e18 100644 --- a/src/request-management/inquiry-participant-resolver.spec.ts +++ b/src/request-management/inquiry-participant-resolver.spec.ts @@ -5,7 +5,6 @@ import { VehicleRegistrationState, } from "src/common/dto/inquiry-participants.dto"; import { - assertPreviousPlateInquiryMatchesVin, isMappedPolicyCurrent, normalizeInquirySubmission, participantForRole, @@ -13,9 +12,8 @@ import { resolveInquiryParticipants, resolveInquirySubjects, resolveInquiryVehicle, - runPlateInquiryWithFallback, + runCurrentPlateInquiry, sanitizeStoredInquiryParticipants, - vehiclePlateCandidates, } from "./inquiry-participant-resolver"; describe("inquiry participant resolver", () => { @@ -309,42 +307,6 @@ describe("inquiry participant resolver", () => { ).toBe("0022222222"); }); - it("orders the current plate before the previous-plate fallback", () => { - const currentPlate = { - leftDigits: "44", - centerAlphabet: "ب", - centerDigits: "111", - ir: "22", - }; - const previousPlate = { - leftDigits: "55", - centerAlphabet: "ج", - centerDigits: "222", - ir: "33", - }; - - expect( - vehiclePlateCandidates({ - registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED, - currentPlate, - previousPlate, - vin: "NAAM01E15HK123456", - previousPolicyholderNationalCode: "0098765432", - }), - ).toEqual([ - { kind: "CURRENT", plate: currentPlate }, - { kind: "PREVIOUS", plate: previousPlate }, - ]); - }); - - it("rejects a previous-plate result for another chassis", () => { - expect(() => - assertPreviousPlateInquiryMatchesVin("NAAM01E15HK123456", { - VinNumberField: "DIFFERENTVIN00001", - }), - ).toThrow(BadRequestException); - }); - it("requires the driver's licence status in the new contract", () => { expect(() => resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, { @@ -357,58 +319,36 @@ describe("inquiry participant resolver", () => { ).toThrow(BadRequestException); }); - it("falls back to the previous plate and accepts only a matching VIN", async () => { + it("runs only one inquiry for the current plate", async () => { const currentPlate = { leftDigits: "44", centerAlphabet: "ب", centerDigits: "111", ir: "22", }; - const previousPlate = { - leftDigits: "55", - centerAlphabet: "ج", - centerDigits: "222", - ir: "33", - }; - const query = jest - .fn() - .mockRejectedValueOnce(new Error("not found")) - .mockResolvedValueOnce({ - mapped: { VinNumberField: "NAAM01E15HK123456", CompanyName: "پارسیان" }, - }); - - const result = await runPlateInquiryWithFallback<{ - mapped: { VinNumberField?: string; CompanyName?: string }; - }>({ - vehicle: { - registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED, - currentPlate, - previousPlate, - vin: "NAAM01E15HK123456", - previousPolicyholderNationalCode: "0098765432", - }, - fallbackCurrentPlate: currentPlate, - query, - isUsable: (value) => !!value.mapped.CompanyName, - mappedValue: (value) => value.mapped, + const query = jest.fn().mockResolvedValue({ + mapped: { CompanyName: "پارسیان" }, }); - expect(query).toHaveBeenCalledTimes(2); - expect(query).toHaveBeenNthCalledWith(1, currentPlate, "CURRENT"); - expect(query).toHaveBeenNthCalledWith(2, previousPlate, "PREVIOUS"); - expect(result.plateKind).toBe("PREVIOUS"); + const result = await runCurrentPlateInquiry<{ + mapped: { CompanyName?: string }; + }>({ + currentPlate, + vin: "NAAM01E15HK123456", + query, + isUsable: (value) => !!value.mapped.CompanyName, + }); + + expect(query).toHaveBeenCalledTimes(1); + expect(query).toHaveBeenCalledWith(currentPlate); + expect(result.plateKind).toBe("CURRENT"); expect(result.attempts).toMatchObject([ - { plateKind: "CURRENT", succeeded: false, error: "not found" }, - { plateKind: "PREVIOUS", succeeded: true, usable: true }, + { plateKind: "CURRENT", succeeded: true, usable: true }, ]); expect(result.attempts[0]).toMatchObject({ plate: currentPlate, vin: "NAAM01E15HK123456", }); - expect(result.attempts[1]).toMatchObject({ - plate: previousPlate, - vin: "NAAM01E15HK123456", - }); }); it("rejects a car-body policyholder on a third-party case", () => { @@ -510,95 +450,72 @@ describe("inquiry participant resolver", () => { ); }); - it("falls back from a stale current policy to a current previous-plate policy", async () => { + it("rejects a stale current policy without trying another plate", async () => { const currentPlate = { leftDigits: "44", centerAlphabet: "ب", centerDigits: "111", ir: "22", }; - const previousPlate = { - leftDigits: "55", - centerAlphabet: "ج", - centerDigits: "222", - ir: "33", - }; - const query = jest - .fn() - .mockResolvedValueOnce({ - mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" }, - }) - .mockResolvedValueOnce({ - mapped: { - CompanyName: "پارسیان", - EndDate: "1406/01/01", - VinNumberField: "NAAM01E15HK123456", - }, - }); + const query = jest.fn().mockResolvedValue({ + mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" }, + }); - const result = await runPlateInquiryWithFallback<{ - mapped: Record; - }>({ - vehicle: resolveInquiryVehicle({ - registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED, + await expect( + runCurrentPlateInquiry<{ mapped: Record }>({ currentPlate, - previousPlate, - vin: "NAAM01E15HK123456", - previousPolicyholderNationalCode: "0098765432", + query, + isUsable: (value) => + !!value.mapped.CompanyName && + isMappedPolicyCurrent(value.mapped, "2026-09-13"), }), - fallbackCurrentPlate: currentPlate, - query, - isUsable: (value) => - !!value.mapped.CompanyName && - isMappedPolicyCurrent(value.mapped, "2026-09-13"), - mappedValue: (value) => value.mapped, - }); - - expect(result.plateKind).toBe("PREVIOUS"); - expect(result.attempts[0]).toMatchObject({ - plateKind: "CURRENT", - succeeded: true, - usable: false, + ).rejects.toMatchObject({ + attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }], }); + expect(query).toHaveBeenCalledTimes(1); }); - it("rejects the result and retains audit attempts when every plate is unusable", async () => { + it("retains the current-plate audit attempt when the result is unusable", async () => { const currentPlate = { leftDigits: "44", centerAlphabet: "ب", centerDigits: "111", ir: "22", }; - const previousPlate = { - leftDigits: "55", - centerAlphabet: "ج", - centerDigits: "222", - ir: "33", - }; await expect( - runPlateInquiryWithFallback({ - vehicle: resolveInquiryVehicle({ - registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED, - currentPlate, - previousPlate, - vin: "NAAM01E15HK123456", - previousPolicyholderNationalCode: "0098765432", - }), - fallbackCurrentPlate: currentPlate, + runCurrentPlateInquiry({ + currentPlate, + vin: "NAAM01E15HK123456", query: async () => ({ mapped: { CompanyName: "پارسیان" } }), isUsable: () => false, - mappedValue: (value) => value.mapped, }), ).rejects.toMatchObject({ - attempts: [ - { plateKind: "CURRENT", succeeded: true, usable: false }, - { plateKind: "PREVIOUS", succeeded: true, usable: false }, - ], + attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }], }); }); - it("does not use the previous plate after a transport or provider outage", async () => { + it("preserves a mapped provider message when the current result is unusable", async () => { + const currentPlate = { + leftDigits: "44", + centerAlphabet: "ب", + centerDigits: "111", + ir: "22", + }; + + await expect( + runCurrentPlateInquiry({ + currentPlate, + query: async () => ({ + mapped: { Error: { Message: "رکوردی یافت نشد" } }, + }), + isUsable: () => false, + errorMessage: (value) => value.mapped.Error.Message, + }), + ).rejects.toThrow("رکوردی یافت نشد"); + }); + + it("retains one failed current-plate attempt after a provider outage", async () => { const currentPlate = { leftDigits: "44", centerAlphabet: "ب", @@ -612,23 +529,11 @@ describe("inquiry participant resolver", () => { ); await expect( - runPlateInquiryWithFallback({ - vehicle: resolveInquiryVehicle({ - registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED, - currentPlate, - previousPlate: { - leftDigits: "55", - centerAlphabet: "ج", - centerDigits: "222", - ir: "33", - }, - vin: "NAAM01E15HK123456", - previousPolicyholderNationalCode: "0098765432", - }), - fallbackCurrentPlate: currentPlate, + runCurrentPlateInquiry({ + currentPlate, + vin: "NAAM01E15HK123456", query, isUsable: () => false, - mappedValue: () => ({}), }), ).rejects.toThrow("upstream timeout"); expect(query).toHaveBeenCalledTimes(1); diff --git a/src/request-management/inquiry-participant-resolver.ts b/src/request-management/inquiry-participant-resolver.ts index 550a352..de9e8e0 100644 --- a/src/request-management/inquiry-participant-resolver.ts +++ b/src/request-management/inquiry-participant-resolver.ts @@ -393,26 +393,6 @@ export function resolveInquiryVehicle( }; } -export function vehiclePlateCandidates(input?: ResolvedInquiryVehicle): Array<{ - kind: "CURRENT" | "PREVIOUS"; - plate: InquiryVehicleInputDto["currentPlate"]; -}> { - if (!input) return []; - return [ - { kind: "CURRENT" as const, plate: input.currentPlate }, - ...(input.registrationState === - VehicleRegistrationState.RECENTLY_TRANSFERRED && input.previousPlate - ? [{ kind: "PREVIOUS" as const, plate: input.previousPlate }] - : []), - ]; -} - -function normalizeVehicleSerial(value: unknown): string { - return String(value ?? "") - .toUpperCase() - .replace(/[^A-Z0-9]/g, ""); -} - /** A dated result is usable only while the returned policy has not expired. */ export function isMappedPolicyCurrent( mapped: Record, @@ -429,19 +409,6 @@ export function isMappedPolicyCurrent( return endDate != null && endDate >= todayGregorian; } -function normalizePlateForComparison( - plate: InquiryVehicleInputDto["currentPlate"], -): string { - return [ - plate?.ir, - plate?.leftDigits, - plate?.centerAlphabet, - plate?.centerDigits, - ] - .map((part) => String(part ?? "").trim()) - .join("|"); -} - const LEGACY_INQUIRY_FIELDS = [ "nationalCodeOfDriver", "driverBirthday", @@ -469,63 +436,22 @@ function assertStructuredInquiryInput(input: Record): void { } } -export function assertPreviousPlateInquiryMatchesVin( - expectedVin: string, - mapped: Record, -): void { - const expected = normalizeVehicleSerial(expectedVin); - const candidates = [ - mapped?.VinNumberField, - mapped?.vin, - mapped?.VIN, - mapped?.ChassisNumberField, - mapped?.chassisNumber, - mapped?.ChassisNo, - mapped?.vehicle?.VIN, - mapped?.vehicle?.ChassisNo, - ] - .map(normalizeVehicleSerial) - .filter(Boolean); - if (!expected || !candidates.includes(expected)) { - throw new BadRequestException( - "نتیجه استعلام پلاک قبلی با شماره شاسی (VIN) واردشده مطابقت ندارد و پرونده نیازمند بررسی دستی است.", - ); - } -} - -export function isPolicyNotFoundError(error: unknown): boolean { - const candidate = error as Record | null; - const status = candidate?.status ?? candidate?.response?.status; - if (Number(status) === 404) return true; - const code = String( - candidate?.code ?? candidate?.response?.data?.code ?? "", - ).toUpperCase(); - if (["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY"].includes(code)) { - return true; - } - const message = String( - candidate?.message ?? candidate?.response?.data?.message ?? error ?? "", - ); - return /\bnot[ -]?found\b|\bno (?:relevant )?policy\b|یافت نشد|فاقد بیمه(?:نامه)?/i.test( - message, - ); -} - -export async function runPlateInquiryWithFallback(options: { - vehicle?: ResolvedInquiryVehicle; - fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"]; - query: ( - plate: InquiryVehicleInputDto["currentPlate"], - plateKind: "CURRENT" | "PREVIOUS", - ) => Promise; +/** + * Run a policy inquiry only for the submitted current plate. Recent-transfer + * data is retained as case metadata, but must never trigger an inquiry for a + * previous plate or a previous policyholder. + */ +export async function runCurrentPlateInquiry(options: { + currentPlate: InquiryVehicleInputDto["currentPlate"]; + vin?: string; + query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise; isUsable: (value: T) => boolean; - mappedValue: (value: T) => Record; - shouldFallbackOnError?: (error: unknown) => boolean; + errorMessage?: (value: T) => string | undefined; }): Promise<{ value: T; - plateKind: "CURRENT" | "PREVIOUS"; + plateKind: "CURRENT"; attempts: Array<{ - plateKind: "CURRENT" | "PREVIOUS"; + plateKind: "CURRENT"; plate: InquiryVehicleInputDto["currentPlate"]; vin?: string; succeeded: boolean; @@ -533,12 +459,8 @@ export async function runPlateInquiryWithFallback(options: { error?: string; }>; }> { - const candidates = options.vehicle - ? vehiclePlateCandidates(options.vehicle) - : [{ kind: "CURRENT" as const, plate: options.fallbackCurrentPlate }]; - let lastError: unknown; const attempts: Array<{ - plateKind: "CURRENT" | "PREVIOUS"; + plateKind: "CURRENT"; plate: InquiryVehicleInputDto["currentPlate"]; vin?: string; succeeded: boolean; @@ -546,80 +468,51 @@ export async function runPlateInquiryWithFallback(options: { error?: string; }> = []; - for (let index = 0; index < candidates.length; index += 1) { - const candidate = candidates[index]; - const isLast = index === candidates.length - 1; - try { - const value = await options.query(candidate.plate, candidate.kind); - const usable = options.isUsable(value); - if (!usable) { - attempts.push({ - plateKind: candidate.kind, - plate: candidate.plate, - ...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), - succeeded: true, - usable: false, - }); - if (!isLast) continue; - const error = new BadRequestException( - "بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.", - ) as BadRequestException & { attempts?: typeof attempts }; - error.attempts = attempts; - throw error; - } - if (candidate.kind === "PREVIOUS" && usable) { - assertPreviousPlateInquiryMatchesVin( - options.vehicle!.vin!, - options.mappedValue(value), - ); - } + try { + const value = await options.query(options.currentPlate); + const usable = options.isUsable(value); + if (!usable) { attempts.push({ - plateKind: candidate.kind, - plate: candidate.plate, - ...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), + plateKind: "CURRENT", + plate: options.currentPlate, + ...(options.vin ? { vin: options.vin } : {}), succeeded: true, - usable: true, + usable: false, }); - return { value, plateKind: candidate.kind, attempts }; - } catch (error) { - lastError = error; - const alreadyRecorded = - typeof error === "object" && - error != null && - Array.isArray((error as { attempts?: unknown }).attempts); - if (!alreadyRecorded) { - attempts.push({ - plateKind: candidate.kind, - plate: candidate.plate, - ...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), - succeeded: false, - error: error instanceof Error ? error.message : String(error), - }); - } - if ( - !isLast && - !(options.shouldFallbackOnError ?? isPolicyNotFoundError)(error) - ) { - if (typeof error === "object" && error != null) { - (error as { attempts?: typeof attempts }).attempts = attempts; - } - throw error; - } - if (isLast) { - if (typeof error === "object" && error != null) { - (error as { attempts?: typeof attempts }).attempts = attempts; - } - throw error; - } + const error = new BadRequestException( + options.errorMessage?.(value) || + "بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.", + ) as BadRequestException & { attempts?: typeof attempts }; + error.attempts = attempts; + throw error; } + attempts.push({ + plateKind: "CURRENT", + plate: options.currentPlate, + ...(options.vin ? { vin: options.vin } : {}), + succeeded: true, + usable: true, + }); + return { value, plateKind: "CURRENT", attempts }; + } catch (error) { + const alreadyRecorded = + typeof error === "object" && + error != null && + Array.isArray((error as { attempts?: unknown }).attempts); + if (!alreadyRecorded) { + attempts.push({ + plateKind: "CURRENT", + plate: options.currentPlate, + ...(options.vin ? { vin: options.vin } : {}), + succeeded: false, + error: error instanceof Error ? error.message : String(error), + }); + } + if (typeof error === "object" && error != null) { + (error as { attempts?: typeof attempts }).attempts = attempts; + } + throw error; } - - throw ( - lastError ?? - new BadRequestException( - "برای هیچ‌یک از پلاک‌های ثبت‌شده نتیجه معتبری یافت نشد.", - ) - ); } export function normalizeInquirySubmission>( diff --git a/src/request-management/request-management.policyholder-inquiry.spec.ts b/src/request-management/request-management.policyholder-inquiry.spec.ts new file mode 100644 index 0000000..3682edc --- /dev/null +++ b/src/request-management/request-management.policyholder-inquiry.spec.ts @@ -0,0 +1,166 @@ +import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; +import { RequestManagementService } from "./request-management.service"; + +describe("RequestManagementService policyholder inquiry routing", () => { + const currentPlate = { + leftDigits: "44", + centerAlphabet: "ب", + centerDigits: "111", + ir: "22", + }; + const previousPlate = { + leftDigits: "55", + centerAlphabet: "ج", + centerDigits: "222", + ir: "33", + }; + const vehicle = { + registrationState: "RECENTLY_TRANSFERRED", + currentPlate, + previousPlate, + previousPolicyholderNationalCode: "0098765432", + vin: "NAAM01E15HK123456", + }; + + function participantInput(caseType: BlameRequestType) { + return { + driver: { + nationalCode: "0011111111", + birthday: "1370/01/01", + hasDrivingLicense: false, + }, + vehicleOwner: { sameAs: "DRIVER" }, + thirdPartyPolicyholder: { + nationalCode: "0022222222", + birthday: "1360/02/02", + }, + ...(caseType === BlameRequestType.CAR_BODY + ? { + carBodyPolicyholder: { + nationalCode: "0033333333", + birthday: "1350/03/03", + }, + } + : {}), + vehicle, + }; + } + + it("does not fall back from the current plate for third-party insurance", async () => { + const service = Object.create(RequestManagementService.prototype) as any; + service.sandHubService = { + getTejaratBlockInquiry: jest + .fn() + .mockResolvedValueOnce({ raw: {}, mapped: {} }) + .mockResolvedValueOnce({ + raw: {}, + mapped: { CompanyName: "پارسیان", VinNumberField: vehicle.vin }, + }), + }; + const submission = service.normalizeInquiryInput( + BlameRequestType.THIRD_PARTY, + participantInput(BlameRequestType.THIRD_PARTY), + ); + + await expect(service.getThirdPartyPlateInquiry(submission)).rejects.toThrow( + "بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.", + ); + + expect(service.sandHubService.getTejaratBlockInquiry).toHaveBeenCalledTimes( + 1, + ); + expect(service.sandHubService.getTejaratBlockInquiry).toHaveBeenCalledWith( + expect.objectContaining({ + plate: currentPlate, + nationalCodeOfInsurer: "0022222222", + }), + undefined, + ); + }); + + it("propagates the mapped ESG Persian error for third-party insurance", async () => { + const service = Object.create(RequestManagementService.prototype) as any; + service.sandHubService = { + getTejaratBlockInquiry: jest.fn().mockResolvedValue({ + raw: { + success: false, + error: { + code: "RECORD_NOT_FOUND", + message: "Provider request failed", + messageFa: "رکوردی یافت نشد", + }, + }, + mapped: { Error: { Message: "رکوردی یافت نشد" } }, + }), + }; + const submission = service.normalizeInquiryInput( + BlameRequestType.THIRD_PARTY, + participantInput(BlameRequestType.THIRD_PARTY), + ); + + await expect( + service.getThirdPartyPlateInquiry(submission), + ).rejects.toThrow("رکوردی یافت نشد"); + expect(service.sandHubService.getTejaratBlockInquiry).toHaveBeenCalledTimes( + 1, + ); + }); + + it("does not fall back from the current plate for car-body insurance", async () => { + const service = Object.create(RequestManagementService.prototype) as any; + service.sandHubService = { + getCarBodyInquiry: jest + .fn() + .mockResolvedValueOnce({ raw: {}, mapped: {} }) + .mockResolvedValueOnce({ + raw: {}, + mapped: { policyNumber: "BODY-1", VinNumberField: vehicle.vin }, + }), + }; + const submission = service.normalizeInquiryInput( + BlameRequestType.CAR_BODY, + participantInput(BlameRequestType.CAR_BODY), + ); + + await expect(service.getCarBodyPlateInquiry(submission)).rejects.toThrow( + "بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.", + ); + + expect(service.sandHubService.getCarBodyInquiry).toHaveBeenCalledTimes(1); + expect(service.sandHubService.getCarBodyInquiry).toHaveBeenCalledWith( + expect.objectContaining({ + plate: currentPlate, + nationalCodeOfInsurer: "0033333333", + }), + ); + }); + + it("queries VIN with the third-party policyholder", async () => { + const service = Object.create(RequestManagementService.prototype) as any; + service.sandHubService = { + getPolicyByChassisInquiry: jest.fn().mockResolvedValue({ + raw: {}, + mapped: { CompanyName: "پارسیان" }, + }), + }; + const submission = service.normalizeInquiryInput( + BlameRequestType.THIRD_PARTY, + participantInput(BlameRequestType.THIRD_PARTY), + ); + + await service.getThirdPartyVinInquiry(submission); + + expect( + service.sandHubService.getPolicyByChassisInquiry, + ).toHaveBeenCalledTimes(1); + expect( + service.sandHubService.getPolicyByChassisInquiry, + ).toHaveBeenCalledWith( + { + nationalCode: "0022222222", + chassis: vehicle.vin, + }, + undefined, + ); + }); +}); diff --git a/src/request-management/request-management.previous-policyholder.spec.ts b/src/request-management/request-management.previous-policyholder.spec.ts deleted file mode 100644 index 53874d5..0000000 --- a/src/request-management/request-management.previous-policyholder.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; -import { RequestManagementService } from "./request-management.service"; - -describe("RequestManagementService previous policyholder routing", () => { - const currentPlate = { - leftDigits: "44", - centerAlphabet: "ب", - centerDigits: "111", - ir: "22", - }; - const previousPlate = { - leftDigits: "55", - centerAlphabet: "ج", - centerDigits: "222", - ir: "33", - }; - const vehicle = { - registrationState: "RECENTLY_TRANSFERRED", - currentPlate, - previousPlate, - previousPolicyholderNationalCode: "0098765432", - vin: "NAAM01E15HK123456", - }; - - function participantInput(caseType: BlameRequestType) { - return { - driver: { - nationalCode: "0011111111", - birthday: "1370/01/01", - hasDrivingLicense: false, - }, - vehicleOwner: { sameAs: "DRIVER" }, - thirdPartyPolicyholder: { - nationalCode: "0022222222", - birthday: "1360/02/02", - }, - ...(caseType === BlameRequestType.CAR_BODY - ? { - carBodyPolicyholder: { - nationalCode: "0033333333", - birthday: "1350/03/03", - }, - } - : {}), - vehicle, - }; - } - - it("uses the previous code only for the previous-plate third-party lookup", async () => { - const service = Object.create(RequestManagementService.prototype) as any; - service.sandHubService = { - getTejaratBlockInquiry: jest - .fn() - .mockResolvedValueOnce({ raw: {}, mapped: {} }) - .mockResolvedValueOnce({ - raw: {}, - mapped: { - CompanyName: "پارسیان", - VinNumberField: vehicle.vin, - }, - }), - }; - const submission = service.normalizeInquiryInput( - BlameRequestType.THIRD_PARTY, - participantInput(BlameRequestType.THIRD_PARTY), - ); - - const result = await service.getThirdPartyPlateInquiry(submission); - - expect(result.plateKind).toBe("PREVIOUS"); - expect( - service.sandHubService.getTejaratBlockInquiry, - ).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - plate: currentPlate, - nationalCodeOfInsurer: "0022222222", - }), - undefined, - ); - expect( - service.sandHubService.getTejaratBlockInquiry, - ).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - plate: previousPlate, - nationalCodeOfInsurer: "0098765432", - }), - undefined, - ); - }); - - it("uses the previous code only for the previous-plate car-body lookup", async () => { - const service = Object.create(RequestManagementService.prototype) as any; - service.sandHubService = { - getCarBodyInquiry: jest - .fn() - .mockResolvedValueOnce({ raw: {}, mapped: {} }) - .mockResolvedValueOnce({ - raw: {}, - mapped: { - policyNumber: "BODY-1", - VinNumberField: vehicle.vin, - }, - }), - }; - const submission = service.normalizeInquiryInput( - BlameRequestType.CAR_BODY, - participantInput(BlameRequestType.CAR_BODY), - ); - - const result = await service.getCarBodyPlateInquiry(submission); - - expect(result.plateKind).toBe("PREVIOUS"); - expect(service.sandHubService.getCarBodyInquiry).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - plate: currentPlate, - nationalCodeOfInsurer: "0033333333", - }), - ); - expect(service.sandHubService.getCarBodyInquiry).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - plate: previousPlate, - nationalCodeOfInsurer: "0098765432", - }), - ); - }); -}); diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index c3db7bf..e856449 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -140,13 +140,20 @@ import { NormalizedInquirySubmission, normalizeInquirySubmission, resolveInquirySubjects, - runPlateInquiryWithFallback, + runCurrentPlateInquiry, sanitizeStoredInquiryParticipants, } from "./inquiry-participant-resolver"; import { getInquiryErrorMessage, } from "src/common/utils/inquiry-error"; +function mappedInquiryErrorMessage(value: any): string | undefined { + const message = value?.mapped?.Error?.Message; + return typeof message === "string" && message.trim() + ? message.trim() + : undefined; +} + /** * Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD. * Returns the original value as a string if it cannot be parsed. @@ -386,17 +393,14 @@ export class RequestManagementService { options?: Record, ): Promise { const subjects = resolveInquirySubjects(submission); - const result = await runPlateInquiryWithFallback({ - vehicle: submission.vehicle, - fallbackCurrentPlate: submission.dto.plate, - query: (plate, plateKind) => + const result = await runCurrentPlateInquiry({ + currentPlate: submission.vehicle?.currentPlate ?? submission.dto.plate, + vin: submission.vehicle?.vin, + query: (plate) => this.sandHubService.getTejaratBlockInquiry( { plate: plate as any, - nationalCodeOfInsurer: - plateKind === "PREVIOUS" - ? submission.vehicle!.previousPolicyholderNationalCode! - : subjects.thirdPartyPolicyNationalCode, + nationalCodeOfInsurer: subjects.thirdPartyPolicyNationalCode, }, options, ), @@ -404,7 +408,7 @@ export class RequestManagementService { !value?.mapped?.Error && !!value?.mapped?.CompanyName && isMappedPolicyCurrent(value.mapped), - mappedValue: (value) => value?.mapped ?? {}, + errorMessage: mappedInquiryErrorMessage, }); return { ...result.value, @@ -423,15 +427,12 @@ export class RequestManagementService { "اطلاعات بیمه‌گذار برای استعلام بیمه بدنه الزامی است.", ); } - const result = await runPlateInquiryWithFallback({ - vehicle: submission.vehicle, - fallbackCurrentPlate: submission.dto.plate, - query: (plate, plateKind) => + const result = await runCurrentPlateInquiry({ + currentPlate: submission.vehicle?.currentPlate ?? submission.dto.plate, + vin: submission.vehicle?.vin, + query: (plate) => this.sandHubService.getCarBodyInquiry({ - nationalCodeOfInsurer: - plateKind === "PREVIOUS" - ? submission.vehicle!.previousPolicyholderNationalCode! - : policyholderNationalCode, + nationalCodeOfInsurer: policyholderNationalCode, plate: plate as any, }), isUsable: (value) => @@ -442,7 +443,7 @@ export class RequestManagementService { value.mapped.CompanyName || value.mapped.companyId ), - mappedValue: (value) => value?.mapped ?? {}, + errorMessage: mappedInquiryErrorMessage, }); return { ...result.value, diff --git a/src/sand-hub/sand-hub.service.spec.ts b/src/sand-hub/sand-hub.service.spec.ts index ef7b743..1ad9b20 100644 --- a/src/sand-hub/sand-hub.service.spec.ts +++ b/src/sand-hub/sand-hub.service.spec.ts @@ -155,28 +155,38 @@ 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 () => { + it("preserves ESG messageFa for a plate inquiry", async () => { process.env.CLIENT_ID = "8"; externalInquirySettings.isInquiryLive.mockResolvedValue(true); jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({ success: false, - message: "موردی یافت نشد", + error: { + code: "RECORD_NOT_FOUND", + message: "Provider request failed", + messageFa: "رکوردی یافت نشد", + providerMessage: "err.record.not.found", + providerCode: "RECORD_NOT_FOUND", + }, }); const result = await service.getTejaratBlockInquiry(userDetail, { enforceDeploymentClientMatch: true, }); - expect(result.mapped.Error.Message).toBe( - "بیمه‌نامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.", - ); + expect(result.mapped.Error.Message).toBe("رکوردی یافت نشد"); }); - it("uses a VIN-specific message for the same ESG not-found response", async () => { + it("preserves ESG messageFa for a VIN inquiry", async () => { externalInquirySettings.isInquiryLive.mockResolvedValue(true); jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({ success: false, - message: "موردی یافت نشد", + error: { + code: "INQUIRY_NO_MATCH", + message: "Inquiry returned no matching result", + messageFa: "نتیجه‌ای مطابق با اطلاعات وارد شده یافت نشد", + providerMessage: "Inquiry returned no matching result", + providerCode: "INQUIRY_NO_MATCH", + }, }); const result = await service.getPolicyByChassisInquiry({ @@ -185,7 +195,7 @@ describe("SandHubService inquiry mocks", () => { }); expect(result.mapped.Error.Message).toBe( - "بیمه‌نامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.", + "نتیجه‌ای مطابق با اطلاعات وارد شده یافت نشد", ); });
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. Completed claims include Fanavaran claimNo / claimId when available.
GETv2/expert-claim/request/:claimRequestIdFull claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs, and the effective priceCap (530,000,000 Rial for V1; null for V2–V6 or when disabled). 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.
PUTv2/expert-claim/reply/submit/:claimRequestIdSubmit damage assessment reply (priced parts list and daghi). daghi.branchId is used only with the تحویل داغی option. Cap: total ≤ 53 000 000 Toman. A priced-only claim completes immediately; factor claims continue through factor collection/validation. No final owner signature or automatic Fanavaran submission.
PUTv2/expert-claim/reply/submit/:claimRequestIdSubmit damage assessment reply (priced parts list and daghi). daghi.branchId is used only with the تحویل داغی option. V1 only: total ≤ 530,000,000 Rial; V2–V6 are uncapped. A priced-only claim completes immediately; factor claims continue through factor collection/validation. No final owner signature or automatic Fanavaran submission.
PUTv2/expert-claim/reply/resend/:claimRequestIdRequest user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled.
PATCHv2/expert-claim/:claimRequestId/visitAsk user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION.
PATCHv2/expert-claim/validate-factors/:claimRequestIdValidate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. Cap applies across all lines (≤ 53 000 000 Toman). Auto-completes when all lines are decided.
PATCHv2/expert-claim/validate-factors/:claimRequestIdValidate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. The 530,000,000 Rial all-lines cap applies only to V1. Auto-completes when all lines are decided.
PATCHv2/expert-claim/request/:claimRequestId/damaged-partsEdit selected damaged parts while the claim is locked by this expert (EXPERT_REVIEWING).
GETv2/expert-claim/outer-parts-catalogFanavaran outer car-components catalog (shared with user flow).
GETv2/expert-claim/inner-parts-catalogStatic inner car-parts catalog JSON.