Merge pull request 'fix: query policies only for current holder' (#327) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#327
This commit is contained in:
2026-09-19 12:13:54 +03:30
25 changed files with 704 additions and 524 deletions

View File

@@ -56,7 +56,7 @@ Content-Type: application/json
| `typeOfDamage` | بله | دقیقاً یکی از `"تعمیر"` یا `"تعویض"`. | | `typeOfDamage` | بله | دقیقاً یکی از `"تعمیر"` یا `"تعویض"`. |
| `price` | برای `"تعویض"` بله؛ برای `"تعمیر"` اختیاری | در صورت ارسال، مبلغ بین 100,000 تا 10,000,000,000 تومان. | | `price` | برای `"تعویض"` بله؛ برای `"تعمیر"` اختیاری | در صورت ارسال، مبلغ بین 100,000 تا 10,000,000,000 تومان. |
| `salary` | بله | مبلغ بین 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`)؛ رشته ارسال نکنید. | | `factorNeeded` | بله | مقدار Boolean واقعی (`true` یا `false`)؛ رشته ارسال نکنید. |
| `daghi` | برای `"تعویض"` بله | برای `"تعمیر"` لازم نیست و در ثبت نهایی حذف می‌شود. | | `daghi` | برای `"تعویض"` بله | برای `"تعمیر"` لازم نیست و در ثبت نهایی حذف می‌شود. |
| `daghi.option` | در صورت وجود `daghi` بله | یکی از `"ارزش لوازم بازیافتی"`، `"تحویل داغی"`، `"فاقد ارزش"` یا `"با احتساب داغی"`. | | `daghi.option` | در صورت وجود `daghi` بله | یکی از `"ارزش لوازم بازیافتی"`، `"تحویل داغی"`، `"فاقد ارزش"` یا `"با احتساب داغی"`. |
@@ -66,7 +66,7 @@ Content-Type: application/json
نکته‌ها: نکته‌ها:
- مقدار `0` برای هیچ مبلغ ارسالی این endpoint معتبر نیست؛ حداقل مبلغ 100,000 تومان است. - مقدار `0` برای هیچ مبلغ ارسالی این endpoint معتبر نیست؛ حداقل مبلغ 100,000 تومان است.
- محدودیت 10 میلیارد مربوط به **هر فیلد مبلغ** است؛ سقف 53 میلیون مربوط به **مجموع `totalPayment` تمام ردیف‌ها** است و همچنان اعمال می‌شود. - محدودیت هر فیلد مبلغ مستقل است؛ سقف 530 میلیون ریال فقط برای **مجموع `totalPayment` تمام ردیف‌های پرونده V1** اعمال می‌شود. جریان‌های V2 تا V6 سقف مجموع ندارند.
- فیلدهای ناشناخته در body حذف می‌شوند. فرانت‌اند نباید برای انتقال داده به آن‌ها تکیه کند. - فیلدهای ناشناخته در body حذف می‌شوند. فرانت‌اند نباید برای انتقال داده به آن‌ها تکیه کند.
## پاسخ موفق ## پاسخ موفق
@@ -152,15 +152,15 @@ Content-Type: application/json
```json ```json
{ {
"statusCode": 400, "statusCode": 400,
"message": "مجموع مبلغ قطعات (۵۴٬۰۰۰٬۰۰۰) از سقف مجاز (۵۳٬۰۰۰٬۰۰۰) تومان بیشتر است.", "message": "مجموع مبلغ قطعات (۵۴۰٬۰۰۰٬۰۰۰) از سقف مجاز (۵۳۰٬۰۰۰٬۰۰۰) ریال بیشتر است.",
"error": "PRICE_CAP_ERROR", "error": "PRICE_CAP_ERROR",
"code": "PRICE_CAP_ERROR", "code": "PRICE_CAP_ERROR",
"totalPrice": 54000000, "totalPrice": 540000000,
"priceCap": 53000000 "priceCap": 530000000
} }
``` ```
این خطا به یک ردیف مشخص وصل نیست. آن را در بالای جدول قیمت‌ها نمایش دهید و در صورت نیاز از `priceCap` برای پیام UI استفاده کنید. این خطا فقط برای پرونده‌های V1 رخ می‌دهد و به یک ردیف مشخص وصل نیست. آن را در بالای جدول قیمت‌ها نمایش دهید و از `priceCap` جزئیات پرونده/خطا برای پیام UI استفاده کنید. مقدار `priceCap: null` یعنی پرونده سقف مجموع ندارد.
### 4. خطاهای قواعد پرونده و workflow ### 4. خطاهای قواعد پرونده و workflow
@@ -225,4 +225,4 @@ function applySubmitError(body: ApiErrorBody) {
} }
``` ```
قبل از ارسال، فرانت‌اند می‌تواند همین بازه مبلغ را برای تجربه کاربری بهتر بررسی کند؛ با این حال اعتبار نهایی همیشه پاسخ API است. برای جلوگیری از خطای سقف مجموع، جمع `totalPayment` ردیف‌ها را نیز پیش از ارسال محاسبه و نمایش دهید. قبل از ارسال، فرانت‌اند می‌تواند همین بازه مبلغ را برای تجربه کاربری بهتر بررسی کند؛ با این حال اعتبار نهایی همیشه پاسخ API است. جمع `totalPayment` را فقط وقتی `priceCap` جزئیات پرونده عدد است با سقف مقایسه کنید؛ `null` یعنی V2 تا V6 و بدون سقف مجموع.

View File

@@ -159,6 +159,7 @@
<div class="decision-tree"> <div class="decision-tree">
<strong>برای هر پرس‌وجوی مبتنی بر پلاک:</strong> <strong>برای هر پرس‌وجوی مبتنی بر پلاک:</strong>
<ul> <ul>
<li>درخواست همیشه با پلاک فعلی ارسال‌شده و بیمه‌گذار نهایی همان نوع بیمه انجام می‌شود. متادیتای انتقال اخیر هیچ استعلامی برای پلاک یا بیمه‌گذار قبلی ایجاد نمی‌کند.</li>
<li>۱. بررسی داده‌های آفلاین (MongoDB) — اگر داده مطابق یافت شد، آن را برگردانده و تمام HTTP را رد کن.</li> <li>۱. بررسی داده‌های آفلاین (MongoDB) — اگر داده مطابق یافت شد، آن را برگردانده و تمام HTTP را رد کن.</li>
<li>۲. اگر <code>CLIENT_ID=8</code> (تنانت پارسیان/ESG) → مسیریابی به <strong>ESG</strong> <code>/inquiry/policyByPlate</code> یا <code>/inquiry/policyByChassis</code>.</li> <li>۲. اگر <code>CLIENT_ID=8</code> (تنانت پارسیان/ESG) → مسیریابی به <strong>ESG</strong> <code>/inquiry/policyByPlate</code> یا <code>/inquiry/policyByChassis</code>.</li>
<li>۳. در غیر این صورت → مسیریابی به <strong>پرس‌وجوی تجارت</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) یا <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li> <li>۳. در غیر این صورت → مسیریابی به <strong>پرس‌وجوی تجارت</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) یا <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>
@@ -363,7 +364,7 @@
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>اعتبارسنجی شبا / حساب بانکی.</td></tr> <tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>اعتبارسنجی شبا / حساب بانکی.</td></tr>
</table> </table>
<p class="note" style="margin-top:8px;"> <p class="note" style="margin-top:8px;">
ESG هر پاسخ را به صورت <code>{ success: boolean, data: … }</code> می‌پیچد. یک بدنه <code>success=false</code> به یک خطای فارسی "استعلام در دسترس نیست" ترجمه می‌شود. ESG هر پاسخ را به صورت <code>{ success: boolean, data: … }</code> می‌پیچد. در envelope نرمال‌شده خطا، بک‌اند مقدار <code>error.messageFa</code> را بدون تغییر به فراخواننده برمی‌گرداند؛ فیلدهای فنی مانند <code>message</code>، <code>providerMessage</code> و <code>providerCode</code> برای ثبت لاگ و دسته‌بندی حفظ می‌شوند. خطای کسب‌وکاری «یافت نشد» به‌عنوان قطعی سرویس گزارش نمی‌شود.
بررسی داده آفلاین-پرس‌وجو هنوز ابتدا اجرا می‌شود، قبل از هر فراخوانی HTTP ESG. بررسی داده آفلاین-پرس‌وجو هنوز ابتدا اجرا می‌شود، قبل از هر فراخوانی HTTP ESG.
</p> </p>
</div> </div>

View File

@@ -154,6 +154,7 @@
<div class="decision-tree"> <div class="decision-tree">
<strong>For every plate-based block inquiry:</strong> <strong>For every plate-based block inquiry:</strong>
<ul> <ul>
<li>The request always uses the submitted current plate and the resolved policyholder for that policy type. Recent-transfer metadata never triggers a previous-plate or previous-policyholder lookup.</li>
<li>1. Check offline-inquiry seeds (MongoDB) — if a matching seed exists, return it and skip all HTTP.</li> <li>1. Check offline-inquiry seeds (MongoDB) — if a matching seed exists, return it and skip all HTTP.</li>
<li>2. If <code>CLIENT_ID=8</code> (Parsian/ESG tenant) → route to <strong>ESG</strong> <code>/inquiry/policyByPlate</code> or <code>/inquiry/policyByChassis</code>.</li> <li>2. If <code>CLIENT_ID=8</code> (Parsian/ESG tenant) → route to <strong>ESG</strong> <code>/inquiry/policyByPlate</code> or <code>/inquiry/policyByChassis</code>.</li>
<li>3. Otherwise → route to <strong>Tejarat inquiry</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) or <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li> <li>3. Otherwise → route to <strong>Tejarat inquiry</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) or <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>
@@ -360,7 +361,7 @@
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr> <tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr>
</table> </table>
<p class="note" style="margin-top:8px;"> <p class="note" style="margin-top:8px;">
ESG wraps every response as <code>{ success: boolean, data: … }</code>. A <code>success=false</code> body is translated to a contextual Persian error. For example, <code>موردی یافت نشد</code> becomes a plate- or VIN-specific “no matching policy” message; it is not reported as a provider outage. ESG wraps every response as <code>{ success: boolean, data: … }</code>. For normalized error envelopes, the backend returns <code>error.messageFa</code> unchanged to the caller; technical fields such as <code>message</code>, <code>providerMessage</code>, and <code>providerCode</code> remain available for logging and classification. A business-level not-found response is not reported as a provider outage.
The offline-inquiry seed check still runs first, before any ESG HTTP call. The offline-inquiry seed check still runs first, before any ESG HTTP call.
</p> </p>
</div> </div>

View File

@@ -157,7 +157,7 @@
} }
``` ```
سیستم ابتدا پلاک فعلی را با کد ملی بیمه‌گذار فعلیِ مرتبط با نوع بیمه استعلام می‌کند. اگر نتیجه ناموجود، منقضی یا فاقد بیمه‌نامه مرتبط باشد، پلاک قبلی را با `previousPolicyholderNationalCode` امتحان می‌کند. نتیجه پلاک قبلی فقط در صورت تطبیق VIN پذیرفته می‌شود؛ پلاک قبلی هرگز جایگزین پلاک فعلی نمی‌شود. اطلاعات انتقال اخیر فقط به‌عنوان متادیتای پرونده ذخیره می‌شوند. در route پلاک، سیستم فقط `currentPlate` را با کد ملی بیمه‌گذار نهاییِ مرتبط با نوع بیمه استعلام می‌کند و هیچ fallbackای به `previousPlate` یا `previousPolicyholderNationalCode` ندارد. در route شماره شاسی نیز فقط `vehicle.vin` با همان بیمه‌گذار نهایی استعلام می‌شود.
حتی در route مربوط به VIN، آبجکت `vehicle` از قرارداد مشترک استفاده می‌کند و `currentPlate` در قرارداد فعلی الزامی است. مقدار VIN در `vehicle.vin` قرار می‌گیرد، نه در فیلد سطح بالای `vin`. حتی در route مربوط به VIN، آبجکت `vehicle` از قرارداد مشترک استفاده می‌کند و `currentPlate` در قرارداد فعلی الزامی است. مقدار VIN در `vehicle.vin` قرار می‌گیرد، نه در فیلد سطح بالای `vin`.

View File

@@ -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 ← بیمه‌گذار بدنه؛ - هویت درست را به استعلام مرتبط بدهد: گواهینامه ← راننده، مالکیت و تطبیق شبا ← مالک خودرو، بیمه شخص ثالث با پلاک/VIN ← بیمه‌گذار شخص ثالث، بیمه بدنه با پلاک/VIN ← بیمه‌گذار بدنه؛
- شبا را در استعلام شخص مطالبه‌کننده خسارت (`SECOND` زیان‌دیده در `THIRD_PARTY` و طرف اول در `CAR_BODY`) الزامی کند و با کد ملی مالک خودرو اعتبارسنجی کند؛ در استعلام `FIRST` مقصر پرونده ثالث شبا دریافت نمی‌شود؛ - شبا را در استعلام شخص مطالبه‌کننده خسارت (`SECOND` زیان‌دیده در `THIRD_PARTY` و طرف اول در `CAR_BODY`) الزامی کند و با کد ملی مالک خودرو اعتبارسنجی کند؛ در استعلام `FIRST` مقصر پرونده ثالث شبا دریافت نمی‌شود؛
- بر اساس یک قاعده مشخص، پلاک فعلی یا قبلی را انتخاب و نتیجه پلاک قبلی را با VIN/شماره شاسی تطبیق دهد؛ - فقط پلاک فعلی یا VIN ارسال‌شده را با بیمه‌گذار نهایی همان نوع بیمه استعلام کند؛ متادیتای انتقال قبلی نباید مسیریابی استعلام را تغییر دهد؛
- استعلام هویت را برای هر شخص یکتا فقط یک بار اجرا کند؛ - استعلام هویت را برای هر شخص یکتا فقط یک بار اجرا کند؛
- اشخاص نرمال‌شده و نقش‌های آن‌ها را در `Party` مربوط ذخیره کند. - اشخاص نرمال‌شده و نقش‌های آن‌ها را در `Party` مربوط ذخیره کند.
- `participants` و `participantRoles` ذخیره‌شده را بدون حذف اطلاعات در جزئیات پرونده پنل‌های کارشناسی و پرونده خسارت متصل نمایش دهد تا اطلاعات راننده و سایر نقش‌ها برای بررسی در دسترس بماند. - `participants` و `participantRoles` ذخیره‌شده را بدون حذف اطلاعات در جزئیات پرونده پنل‌های کارشناسی و پرونده خسارت متصل نمایش دهد تا اطلاعات راننده و سایر نقش‌ها برای بررسی در دسترس بماند.

View File

@@ -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 ## 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; - 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; - 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; - 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; - run personal identity inquiry once per distinct person;
- persist normalized participants and role assignments on the relevant `Party`. - 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. - 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.

View File

@@ -370,14 +370,14 @@
<table> <table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr> <tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>فهرست خسارت‌ها در صف <code>WAITING_FOR_DAMAGE_EXPERT</code> + صف اعتبارسنجی فاکتور. پارامترها: search، sortBy، page، limit، unifiedStatus، fileType.</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>فهرست خسارت‌ها در صف <code>WAITING_FOR_DAMAGE_EXPERT</code> + صف اعتبارسنجی فاکتور. پارامترها: search، sortBy، page، limit، unifiedStatus، fileType.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>جزئیات کامل خسارت: قطعات آسیب‌دیده، تصاویر گرفته‌شده، اسناد، priceDrop، داده طرف بلیم، آدرس‌های ویدیو.</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>جزئیات کامل خسارت، به‌همراه <code>priceCap</code> مؤثر: ۵۳۰٬۰۰۰٬۰۰۰ ریال برای V1 و <code>null</code> برای V2 تا V6 یا وقتی سقف غیرفعال است.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>قفل خسارت برای این کارشناس. بازمی‌گرداند: <code>assigned</code>، <code>already_assigned_to_you</code>، یا ۴۰۹.</td></tr> <tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>قفل خسارت برای این کارشناس. بازمی‌گرداند: <code>assigned</code>، <code>already_assigned_to_you</code>، یا ۴۰۹.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>محتوای کاهش قیمت: برچسب‌های شدت، کاتالوگ ضریب، قطعات آسیب‌دیده + نگاشت، سال پیشنهادی خودرو از استعلام تقصیر.</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>محتوای کاهش قیمت: برچسب‌های شدت، کاتالوگ ضریب، قطعات آسیب‌دیده + نگاشت، سال پیشنهادی خودرو از استعلام تقصیر.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>محاسبه و ذخیره کاهش قیمت: قیمت خودرو × ضریب سال × مجموع ضرایب ÷ ۴۰۰.</td></tr> <tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>محاسبه و ذخیره کاهش قیمت: قیمت خودرو × ضریب سال × مجموع ضرایب ÷ ۴۰۰.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>ارسال پاسخ ارزیابی خسارت (لیست قطعات قیمت‌گذاری‌شده، داغی، branchId). سقف: کل ≤ ۵۳،۰۰۰،۰۰۰ تومان. بسته به پرچم‌های factorNeeded، خسارت را به owner-sign، mixed-factors-pending، یا صف اعتبارسنجی فاکتور منتقل می‌کند.</td></tr> <tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>ارسال پاسخ ارزیابی خسارت (لیست قطعات قیمت‌گذاری‌شده، داغی، branchId). فقط V1: کل ≤ ۵۳۰٬۰۰۰٬۰۰۰ ریال؛ V2 تا V6 بدون سقف مجموع هستند.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>درخواست از کاربر برای ارسال مجدد اسناد/عکس‌ها. یک ارسال مجدد در هر چرخه خسارت؛ در صورت تکمیل قبلی ۴۲۲ برمی‌گرداند.</td></tr> <tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>درخواست از کاربر برای ارسال مجدد اسناد/عکس‌ها. یک ارسال مجدد در هر چرخه خسارت؛ در صورت تکمیل قبلی ۴۲۲ برمی‌گرداند.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>درخواست از کاربر برای مراجعه حضوری. خسارت را آزاد می‌کند، وضعیت claimStatus را به NEEDS_REVISION تنظیم می‌کند.</td></tr> <tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>درخواست از کاربر برای مراجعه حضوری. خسارت را آزاد می‌کند، وضعیت claimStatus را به NEEDS_REVISION تنظیم می‌کند.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>اعتبارسنجی فاکتورهای تعمیرگاه بارگذاری‌شده. تأیید یا رد هر خط فاکتور با totalPayment. سقف برای تمام خطوط اعمال می‌شود (≤ ۵۳،۰۰۰،۰۰۰ تومان). پس از تصمیم‌گیری درباره تمام خطوط، به‌صورت خودکار تکمیل می‌شود.</td></tr> <tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>اعتبارسنجی فاکتورهای تعمیرگاه بارگذاری‌شده. سقف ۵۳۰٬۰۰۰٬۰۰۰ ریال تمام خطوط فقط برای V1 اعمال می‌شود؛ V2 تا V6 بدون سقف هستند.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/request/:claimRequestId/damaged-parts</code></td><td>ویرایش قطعات آسیب‌دیده انتخاب‌شده در حالی که خسارت توسط این کارشناس قفل است (EXPERT_REVIEWING).</td></tr> <tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/request/:claimRequestId/damaged-parts</code></td><td>ویرایش قطعات آسیب‌دیده انتخاب‌شده در حالی که خسارت توسط این کارشناس قفل است (EXPERT_REVIEWING).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/outer-parts-catalog</code></td><td>کاتالوگ قطعات بیرونی خودرو فناوران (مشترک با جریان کاربر).</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/outer-parts-catalog</code></td><td>کاتالوگ قطعات بیرونی خودرو فناوران (مشترک با جریان کاربر).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/inner-parts-catalog</code></td><td>JSON ثابت کاتالوگ قطعات داخلی خودرو.</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/inner-parts-catalog</code></td><td>JSON ثابت کاتالوگ قطعات داخلی خودرو.</td></tr>

View File

@@ -363,14 +363,14 @@
<table> <table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr> <tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>List claims in <code>WAITING_FOR_DAMAGE_EXPERT</code> queue + factor-validation queue. Query: search, sortBy, page, limit, unifiedStatus, fileType.</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>List claims in <code>WAITING_FOR_DAMAGE_EXPERT</code> queue + factor-validation queue. Query: search, sortBy, page, limit, unifiedStatus, fileType.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs. Completed claims include Fanavaran claimNo / claimId when available.</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs, and the effective <code>priceCap</code> (530,000,000 Rial for V1; <code>null</code> for V2–V6 or when disabled). Completed claims include Fanavaran claimNo / claimId when available.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>Lock claim to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409.</td></tr> <tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>Lock claim to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Price-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry.</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Price-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Calculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400.</td></tr> <tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Calculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>Submit damage assessment reply (priced parts list and daghi). <code>daghi.branchId</code> is used only with the <code>تحویل داغی</code> 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.</td></tr> <tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>Submit damage assessment reply (priced parts list and daghi). <code>daghi.branchId</code> is used only with the <code>تحویل داغی</code> 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.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>Request user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled.</td></tr> <tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>Request user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>Ask user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION.</td></tr> <tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>Ask user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>Validate 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.</td></tr> <tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>Validate 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.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/request/:claimRequestId/damaged-parts</code></td><td>Edit selected damaged parts while the claim is locked by this expert (EXPERT_REVIEWING).</td></tr> <tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/request/:claimRequestId/damaged-parts</code></td><td>Edit selected damaged parts while the claim is locked by this expert (EXPERT_REVIEWING).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/outer-parts-catalog</code></td><td>Fanavaran outer car-components catalog (shared with user flow).</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/outer-parts-catalog</code></td><td>Fanavaran outer car-components catalog (shared with user flow).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/inner-parts-catalog</code></td><td>Static inner car-parts catalog JSON.</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/inner-parts-catalog</code></td><td>Static inner car-parts catalog JSON.</td></tr>

View File

@@ -4,13 +4,63 @@ import {
} from "./inquiry-error"; } from "./inquiry-error";
describe("inquiry error messages", () => { describe("inquiry error messages", () => {
it("turns ESG not-found responses into a contextual plate message", () => { it("preserves a Persian ESG not-found response", () => {
expect( expect(
getInquiryErrorMessage( getInquiryErrorMessage(
{ success: false, message: "موردی یافت نشد" }, { success: false, message: "موردی یافت نشد" },
"thirdPartyPlate", "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", () => { it("distinguishes VIN and car-body not-found failures", () => {

View File

@@ -87,7 +87,25 @@ export function inquiryErrorStatus(error: unknown): number | undefined {
} }
export function extractInquiryProviderMessage(error: unknown): string { 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) { for (const key of ["message", "Message", "detail", "title"] as const) {
const message = cleanMessage(record[key]); const message = cleanMessage(record[key]);
if (message) return message; if (message) return message;
@@ -128,13 +146,21 @@ export function isInquiryTimeout(error: unknown): boolean {
const hasPersian = (value: string): boolean => /[\u0600-\u06ff]/.test(value); const hasPersian = (value: string): boolean => /[\u0600-\u06ff]/.test(value);
const isNotFound = (error: unknown, message: string): boolean => { const isNotFound = (error: unknown, message: string): boolean => {
const root = asRecord(error); const codes = errorRecords(error).flatMap((record) =>
const responseData = asRecord(asRecord(root?.response)?.data); [record.code, record.providerCode]
const code = String(root?.code ?? responseData?.code ?? "").toUpperCase(); .map((code) => String(code ?? "").toUpperCase())
.filter(Boolean),
);
return ( return (
inquiryErrorStatus(error) === 404 || inquiryErrorStatus(error) === 404 ||
["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY", "RECORD_NOT_FOUND"].includes( codes.some((code) =>
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( /\bnot[ -]?found\b|\bno (?:active |relevant )?(?:record|policy|item)\b|record\.not\.found|موردی یافت نشد|یافت نشد|پیدا نشد|فاقد بیمه(?:‌| )?نامه/i.test(
message, message,
@@ -175,6 +201,11 @@ export function getInquiryErrorMessage(
): string { ): string {
const providerMessage = extractInquiryProviderMessage(error); 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 (isNotFound(error, providerMessage)) return NOT_FOUND_MESSAGES[context];
if ( if (
@@ -208,10 +239,6 @@ export function getInquiryErrorMessage(
return "سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید."; 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) { if (isInvalidInput(providerMessage) || inquiryErrorStatus(error) === 422) {
return INVALID_MESSAGES[context]; return INVALID_MESSAGES[context];
} }

View File

@@ -8,13 +8,13 @@ export const REPAIR_LINE_AMOUNT_TOMAN = { // IT IS RIAL FROM NOW ON
MAX: 530_000_000, MAX: 530_000_000,
} as const; } 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; export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]); 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. * 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. * Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount.

View File

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

View File

@@ -91,6 +91,7 @@ import {
ExpertFileActivityType, ExpertFileActivityType,
ExpertFileKind, ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema"; } from "src/users/entities/schema/expert-file-activity.schema";
import { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver";
interface CheckedRequestEntry { interface CheckedRequestEntry {
CheckedRequest?: { CheckedRequest?: {
@@ -1250,8 +1251,17 @@ export class ExpertBlameService {
doc.createdAtFormatted = `${createdDate} ${createdTime}`; doc.createdAtFormatted = `${createdDate} ${createdTime}`;
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`; 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<string, unknown>) => ({
...party,
participants: sanitizeStoredInquiryParticipants(party.participants),
participantRoles: party.participantRoles,
}));
// Strip heavy SandHub inquiry blob // Strip heavy SandHub inquiry blob
for (const party of parties as Array<{ for (const party of doc.parties as Array<{
vehicle?: Record<string, unknown>; vehicle?: Record<string, unknown>;
}>) { }>) {
if ( if (

View File

@@ -53,11 +53,19 @@ export class ClaimDetailV2ResponseDto {
blameRequestType?: BlameRequestType; blameRequestType?: BlameRequestType;
@ApiPropertyOptional({ @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", example: "IN_PERSON",
}) })
creationMethod?: string; 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({ @ApiPropertyOptional({
description: description:
"CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)", "CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)",
@@ -278,7 +286,7 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 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<string, unknown>; blameCase?: Record<string, unknown>;

View File

@@ -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", () => { it("allows a repair line without daghi and removes a stray daghi payload", () => {
const service = createService() as any; const service = createService() as any;

View File

@@ -156,7 +156,7 @@ import {
ExpertFileKind, ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema"; } 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 { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { import {
@@ -180,6 +180,7 @@ import {
normalizeMoneyAmountString, normalizeMoneyAmountString,
parseMoneyAmountToman, parseMoneyAmountToman,
} from "src/utils/unicode-digits"; } from "src/utils/unicode-digits";
import { claimPriceCapAppliesToBlame } from "src/helpers/claim-price-cap";
@Injectable() @Injectable()
export class ExpertClaimService { export class ExpertClaimService {
@@ -1694,8 +1695,13 @@ export class ExpertClaimService {
throw new BadRequestException(pricingValidationError); throw new BadRequestException(pricingValidationError);
} }
// Validate total price cap (priced lines sum), when enabled. // The total cap is a V1-only rule. Legacy claims embed their blame file.
const priceCap = getClaimV2TotalPaymentCapToman(); const configuredPriceCap = getClaimV2TotalPaymentCapToman();
const priceCap =
configuredPriceCap !== null &&
claimPriceCapAppliesToBlame(request.blameFile)
? configuredPriceCap
: null;
if (priceCap !== null && reply.parts && reply.parts.length > 0) { if (priceCap !== null && reply.parts && reply.parts.length > 0) {
let totalPrice = 0; let totalPrice = 0;
@@ -1727,7 +1733,7 @@ export class ExpertClaimService {
if (totalPrice > priceCap) { if (totalPrice > priceCap) {
throw new BadRequestException({ 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", error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR", code: "PRICE_CAP_ERROR",
totalPrice: totalPrice, totalPrice: totalPrice,
@@ -2293,7 +2299,8 @@ export class ExpertClaimService {
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION. * 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). * — 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). * — 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`. * Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`.
*/ */
async validateClaimFactorsV2( 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) { if (priceCap !== null) {
let totalPrice = 0; let totalPrice = 0;
for (const part of updatedReply.parts || []) { for (const part of updatedReply.parts || []) {
@@ -2448,7 +2460,7 @@ export class ExpertClaimService {
} }
if (totalPrice > priceCap) { if (totalPrice > priceCap) {
throw new BadRequestException({ 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", error: "PRICE_CAP_ERROR",
totalPrice, totalPrice,
priceCap, priceCap,
@@ -3259,7 +3271,7 @@ export class ExpertClaimService {
* - Claim must exist * - Claim must exist
* - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub) * - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub)
* - Must be in EXPERT_REVIEWING status * - 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 * - Each part must include `daghi` (option + conditional price) like V1
* *
* On success: * On success:
@@ -3364,8 +3376,12 @@ export class ExpertClaimService {
throw new BadRequestException(pricingValidationError); throw new BadRequestException(pricingValidationError);
} }
// Price cap validation, when enabled. // The configured total cap is enforced only for V1 user-created files.
const priceCap = getClaimV2TotalPaymentCapToman(); const configuredPriceCap = getClaimV2TotalPaymentCapToman();
const priceCap =
configuredPriceCap !== null && claimPriceCapAppliesToBlame(blame)
? configuredPriceCap
: null;
if (priceCap !== null) { if (priceCap !== null) {
let totalPrice = 0; let totalPrice = 0;
for (const part of reply.parts || []) { for (const part of reply.parts || []) {
@@ -3376,7 +3392,7 @@ export class ExpertClaimService {
} }
if (totalPrice > priceCap) { if (totalPrice > priceCap) {
throw new BadRequestException({ 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", error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR", code: "PRICE_CAP_ERROR",
totalPrice, totalPrice,
@@ -5125,7 +5141,11 @@ export class ExpertClaimService {
claim.blameRequestId claim.blameRequestId
? this.blameRequestDbService.find( ? this.blameRequestDbService.find(
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) }, { _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([]), : Promise.resolve([]),
]); ]);
@@ -5160,6 +5180,11 @@ export class ExpertClaimService {
const blameFileContext = blameLean const blameFileContext = blameLean
? this.blameFileContextForExpert(blameLean) ? this.blameFileContextForExpert(blameLean)
: {}; : {};
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
const priceCap =
configuredPriceCap !== null && claimPriceCapAppliesToBlame(linkedBlame)
? configuredPriceCap
: null;
let videoCapture: ClaimDetailV2ResponseDto["videoCapture"] = undefined; let videoCapture: ClaimDetailV2ResponseDto["videoCapture"] = undefined;
if (videoCaptureRow) { if (videoCaptureRow) {
@@ -5269,6 +5294,7 @@ export class ExpertClaimService {
? this.sanitizeVehicleInquiryForApi(vehiclePayload) ? this.sanitizeVehicleInquiryForApi(vehiclePayload)
: undefined, : undefined,
...blameFileContext, ...blameFileContext,
priceCap,
blameRequestId: claim.blameRequestId?.toString(), blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo, blameRequestNo: claim.blameRequestNo,
money: moneyPayload, money: moneyPayload,

View File

@@ -268,7 +268,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Submit expert damage assessment reply", summary: "Submit expert damage assessment reply",
description: 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" + "**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" + "- **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" + "- **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" + "**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" + "**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" + "**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" + "**Outcomes:**\n" +
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature. V5 instead waits for FileMaker approval.\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" + "- **Any rejected (repriced):** same completion behavior for now (V5 waits for FileMaker approval).\n" +

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import {
VehicleRegistrationState, VehicleRegistrationState,
} from "src/common/dto/inquiry-participants.dto"; } from "src/common/dto/inquiry-participants.dto";
import { import {
assertPreviousPlateInquiryMatchesVin,
isMappedPolicyCurrent, isMappedPolicyCurrent,
normalizeInquirySubmission, normalizeInquirySubmission,
participantForRole, participantForRole,
@@ -13,9 +12,8 @@ import {
resolveInquiryParticipants, resolveInquiryParticipants,
resolveInquirySubjects, resolveInquirySubjects,
resolveInquiryVehicle, resolveInquiryVehicle,
runPlateInquiryWithFallback, runCurrentPlateInquiry,
sanitizeStoredInquiryParticipants, sanitizeStoredInquiryParticipants,
vehiclePlateCandidates,
} from "./inquiry-participant-resolver"; } from "./inquiry-participant-resolver";
describe("inquiry participant resolver", () => { describe("inquiry participant resolver", () => {
@@ -309,42 +307,6 @@ describe("inquiry participant resolver", () => {
).toBe("0022222222"); ).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", () => { it("requires the driver's licence status in the new contract", () => {
expect(() => expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, { resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
@@ -357,58 +319,36 @@ describe("inquiry participant resolver", () => {
).toThrow(BadRequestException); ).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 = { const currentPlate = {
leftDigits: "44", leftDigits: "44",
centerAlphabet: "ب", centerAlphabet: "ب",
centerDigits: "111", centerDigits: "111",
ir: "22", ir: "22",
}; };
const previousPlate = { const query = jest.fn().mockResolvedValue({
leftDigits: "55", mapped: { CompanyName: "پارسیان" },
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
const query = jest
.fn()
.mockRejectedValueOnce(new Error("not found"))
.mockResolvedValueOnce({
mapped: { VinNumberField: "NAAM01E15HK123456", CompanyName: "پارسیان" },
}); });
const result = await runPlateInquiryWithFallback<{ const result = await runCurrentPlateInquiry<{
mapped: { VinNumberField?: string; CompanyName?: string }; mapped: { CompanyName?: string };
}>({ }>({
vehicle: {
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate, currentPlate,
previousPlate,
vin: "NAAM01E15HK123456", vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
},
fallbackCurrentPlate: currentPlate,
query, query,
isUsable: (value) => !!value.mapped.CompanyName, isUsable: (value) => !!value.mapped.CompanyName,
mappedValue: (value) => value.mapped,
}); });
expect(query).toHaveBeenCalledTimes(2); expect(query).toHaveBeenCalledTimes(1);
expect(query).toHaveBeenNthCalledWith(1, currentPlate, "CURRENT"); expect(query).toHaveBeenCalledWith(currentPlate);
expect(query).toHaveBeenNthCalledWith(2, previousPlate, "PREVIOUS"); expect(result.plateKind).toBe("CURRENT");
expect(result.plateKind).toBe("PREVIOUS");
expect(result.attempts).toMatchObject([ expect(result.attempts).toMatchObject([
{ plateKind: "CURRENT", succeeded: false, error: "not found" }, { plateKind: "CURRENT", succeeded: true, usable: true },
{ plateKind: "PREVIOUS", succeeded: true, usable: true },
]); ]);
expect(result.attempts[0]).toMatchObject({ expect(result.attempts[0]).toMatchObject({
plate: currentPlate, plate: currentPlate,
vin: "NAAM01E15HK123456", vin: "NAAM01E15HK123456",
}); });
expect(result.attempts[1]).toMatchObject({
plate: previousPlate,
vin: "NAAM01E15HK123456",
});
}); });
it("rejects a car-body policyholder on a third-party case", () => { 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 = { const currentPlate = {
leftDigits: "44", leftDigits: "44",
centerAlphabet: "ب", centerAlphabet: "ب",
centerDigits: "111", centerDigits: "111",
ir: "22", ir: "22",
}; };
const previousPlate = { const query = jest.fn().mockResolvedValue({
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
const query = jest
.fn()
.mockResolvedValueOnce({
mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" }, mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" },
})
.mockResolvedValueOnce({
mapped: {
CompanyName: "پارسیان",
EndDate: "1406/01/01",
VinNumberField: "NAAM01E15HK123456",
},
}); });
const result = await runPlateInquiryWithFallback<{ await expect(
mapped: Record<string, any>; runCurrentPlateInquiry<{ mapped: Record<string, any> }>({
}>({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate, currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
}),
fallbackCurrentPlate: currentPlate,
query, query,
isUsable: (value) => isUsable: (value) =>
!!value.mapped.CompanyName && !!value.mapped.CompanyName &&
isMappedPolicyCurrent(value.mapped, "2026-09-13"), isMappedPolicyCurrent(value.mapped, "2026-09-13"),
mappedValue: (value) => value.mapped, }),
).rejects.toMatchObject({
attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
});
expect(query).toHaveBeenCalledTimes(1);
}); });
expect(result.plateKind).toBe("PREVIOUS"); it("retains the current-plate audit attempt when the result is unusable", async () => {
expect(result.attempts[0]).toMatchObject({
plateKind: "CURRENT",
succeeded: true,
usable: false,
});
});
it("rejects the result and retains audit attempts when every plate is unusable", async () => {
const currentPlate = { const currentPlate = {
leftDigits: "44", leftDigits: "44",
centerAlphabet: "ب", centerAlphabet: "ب",
centerDigits: "111", centerDigits: "111",
ir: "22", ir: "22",
}; };
const previousPlate = {
leftDigits: "55", await expect(
centerAlphabet: "ج", runCurrentPlateInquiry({
centerDigits: "222", currentPlate,
ir: "33", vin: "NAAM01E15HK123456",
query: async () => ({ mapped: { CompanyName: "پارسیان" } }),
isUsable: () => false,
}),
).rejects.toMatchObject({
attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
});
});
it("preserves a mapped provider message when the current result is unusable", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
}; };
await expect( await expect(
runPlateInquiryWithFallback({ runCurrentPlateInquiry({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate, currentPlate,
previousPlate, query: async () => ({
vin: "NAAM01E15HK123456", mapped: { Error: { Message: "رکوردی یافت نشد" } },
previousPolicyholderNationalCode: "0098765432",
}), }),
fallbackCurrentPlate: currentPlate,
query: async () => ({ mapped: { CompanyName: "پارسیان" } }),
isUsable: () => false, isUsable: () => false,
mappedValue: (value) => value.mapped, errorMessage: (value) => value.mapped.Error.Message,
}), }),
).rejects.toMatchObject({ ).rejects.toThrow("رکوردی یافت نشد");
attempts: [
{ plateKind: "CURRENT", succeeded: true, usable: false },
{ plateKind: "PREVIOUS", succeeded: true, usable: false },
],
});
}); });
it("does not use the previous plate after a transport or provider outage", async () => { it("retains one failed current-plate attempt after a provider outage", async () => {
const currentPlate = { const currentPlate = {
leftDigits: "44", leftDigits: "44",
centerAlphabet: "ب", centerAlphabet: "ب",
@@ -612,23 +529,11 @@ describe("inquiry participant resolver", () => {
); );
await expect( await expect(
runPlateInquiryWithFallback({ runCurrentPlateInquiry({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate, currentPlate,
previousPlate: {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
},
vin: "NAAM01E15HK123456", vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
}),
fallbackCurrentPlate: currentPlate,
query, query,
isUsable: () => false, isUsable: () => false,
mappedValue: () => ({}),
}), }),
).rejects.toThrow("upstream timeout"); ).rejects.toThrow("upstream timeout");
expect(query).toHaveBeenCalledTimes(1); expect(query).toHaveBeenCalledTimes(1);

View File

@@ -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. */ /** A dated result is usable only while the returned policy has not expired. */
export function isMappedPolicyCurrent( export function isMappedPolicyCurrent(
mapped: Record<string, any>, mapped: Record<string, any>,
@@ -429,19 +409,6 @@ export function isMappedPolicyCurrent(
return endDate != null && endDate >= todayGregorian; 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 = [ const LEGACY_INQUIRY_FIELDS = [
"nationalCodeOfDriver", "nationalCodeOfDriver",
"driverBirthday", "driverBirthday",
@@ -469,63 +436,22 @@ function assertStructuredInquiryInput(input: Record<string, any>): void {
} }
} }
export function assertPreviousPlateInquiryMatchesVin( /**
expectedVin: string, * Run a policy inquiry only for the submitted current plate. Recent-transfer
mapped: Record<string, any>, * data is retained as case metadata, but must never trigger an inquiry for a
): void { * previous plate or a previous policyholder.
const expected = normalizeVehicleSerial(expectedVin); */
const candidates = [ export async function runCurrentPlateInquiry<T>(options: {
mapped?.VinNumberField, currentPlate: InquiryVehicleInputDto["currentPlate"];
mapped?.vin, vin?: string;
mapped?.VIN, query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise<T>;
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<string, any> | 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<T>(options: {
vehicle?: ResolvedInquiryVehicle;
fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"];
query: (
plate: InquiryVehicleInputDto["currentPlate"],
plateKind: "CURRENT" | "PREVIOUS",
) => Promise<T>;
isUsable: (value: T) => boolean; isUsable: (value: T) => boolean;
mappedValue: (value: T) => Record<string, any>; errorMessage?: (value: T) => string | undefined;
shouldFallbackOnError?: (error: unknown) => boolean;
}): Promise<{ }): Promise<{
value: T; value: T;
plateKind: "CURRENT" | "PREVIOUS"; plateKind: "CURRENT";
attempts: Array<{ attempts: Array<{
plateKind: "CURRENT" | "PREVIOUS"; plateKind: "CURRENT";
plate: InquiryVehicleInputDto["currentPlate"]; plate: InquiryVehicleInputDto["currentPlate"];
vin?: string; vin?: string;
succeeded: boolean; succeeded: boolean;
@@ -533,12 +459,8 @@ export async function runPlateInquiryWithFallback<T>(options: {
error?: string; error?: string;
}>; }>;
}> { }> {
const candidates = options.vehicle
? vehiclePlateCandidates(options.vehicle)
: [{ kind: "CURRENT" as const, plate: options.fallbackCurrentPlate }];
let lastError: unknown;
const attempts: Array<{ const attempts: Array<{
plateKind: "CURRENT" | "PREVIOUS"; plateKind: "CURRENT";
plate: InquiryVehicleInputDto["currentPlate"]; plate: InquiryVehicleInputDto["currentPlate"];
vin?: string; vin?: string;
succeeded: boolean; succeeded: boolean;
@@ -546,80 +468,51 @@ export async function runPlateInquiryWithFallback<T>(options: {
error?: string; error?: string;
}> = []; }> = [];
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
const isLast = index === candidates.length - 1;
try { try {
const value = await options.query(candidate.plate, candidate.kind); const value = await options.query(options.currentPlate);
const usable = options.isUsable(value); const usable = options.isUsable(value);
if (!usable) { if (!usable) {
attempts.push({ attempts.push({
plateKind: candidate.kind, plateKind: "CURRENT",
plate: candidate.plate, plate: options.currentPlate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), ...(options.vin ? { vin: options.vin } : {}),
succeeded: true, succeeded: true,
usable: false, usable: false,
}); });
if (!isLast) continue;
const error = new BadRequestException( const error = new BadRequestException(
options.errorMessage?.(value) ||
"بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.", "بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
) as BadRequestException & { attempts?: typeof attempts }; ) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts; error.attempts = attempts;
throw error; throw error;
} }
if (candidate.kind === "PREVIOUS" && usable) {
assertPreviousPlateInquiryMatchesVin(
options.vehicle!.vin!,
options.mappedValue(value),
);
}
attempts.push({ attempts.push({
plateKind: candidate.kind, plateKind: "CURRENT",
plate: candidate.plate, plate: options.currentPlate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), ...(options.vin ? { vin: options.vin } : {}),
succeeded: true, succeeded: true,
usable: true, usable: true,
}); });
return { value, plateKind: candidate.kind, attempts }; return { value, plateKind: "CURRENT", attempts };
} catch (error) { } catch (error) {
lastError = error;
const alreadyRecorded = const alreadyRecorded =
typeof error === "object" && typeof error === "object" &&
error != null && error != null &&
Array.isArray((error as { attempts?: unknown }).attempts); Array.isArray((error as { attempts?: unknown }).attempts);
if (!alreadyRecorded) { if (!alreadyRecorded) {
attempts.push({ attempts.push({
plateKind: candidate.kind, plateKind: "CURRENT",
plate: candidate.plate, plate: options.currentPlate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), ...(options.vin ? { vin: options.vin } : {}),
succeeded: false, succeeded: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}); });
} }
if (
!isLast &&
!(options.shouldFallbackOnError ?? isPolicyNotFoundError)(error)
) {
if (typeof error === "object" && error != null) { if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts; (error as { attempts?: typeof attempts }).attempts = attempts;
} }
throw error; throw error;
} }
if (isLast) {
if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts;
}
throw error;
}
}
}
throw (
lastError ??
new BadRequestException(
"برای هیچ‌یک از پلاک‌های ثبت‌شده نتیجه معتبری یافت نشد.",
)
);
} }
export function normalizeInquirySubmission<T extends Record<string, any>>( export function normalizeInquirySubmission<T extends Record<string, any>>(

View File

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

View File

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

View File

@@ -140,13 +140,20 @@ import {
NormalizedInquirySubmission, NormalizedInquirySubmission,
normalizeInquirySubmission, normalizeInquirySubmission,
resolveInquirySubjects, resolveInquirySubjects,
runPlateInquiryWithFallback, runCurrentPlateInquiry,
sanitizeStoredInquiryParticipants, sanitizeStoredInquiryParticipants,
} from "./inquiry-participant-resolver"; } from "./inquiry-participant-resolver";
import { import {
getInquiryErrorMessage, getInquiryErrorMessage,
} from "src/common/utils/inquiry-error"; } 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. * 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. * Returns the original value as a string if it cannot be parsed.
@@ -386,17 +393,14 @@ export class RequestManagementService {
options?: Record<string, any>, options?: Record<string, any>,
): Promise<any> { ): Promise<any> {
const subjects = resolveInquirySubjects(submission); const subjects = resolveInquirySubjects(submission);
const result = await runPlateInquiryWithFallback({ const result = await runCurrentPlateInquiry({
vehicle: submission.vehicle, currentPlate: submission.vehicle?.currentPlate ?? submission.dto.plate,
fallbackCurrentPlate: submission.dto.plate, vin: submission.vehicle?.vin,
query: (plate, plateKind) => query: (plate) =>
this.sandHubService.getTejaratBlockInquiry( this.sandHubService.getTejaratBlockInquiry(
{ {
plate: plate as any, plate: plate as any,
nationalCodeOfInsurer: nationalCodeOfInsurer: subjects.thirdPartyPolicyNationalCode,
plateKind === "PREVIOUS"
? submission.vehicle!.previousPolicyholderNationalCode!
: subjects.thirdPartyPolicyNationalCode,
}, },
options, options,
), ),
@@ -404,7 +408,7 @@ export class RequestManagementService {
!value?.mapped?.Error && !value?.mapped?.Error &&
!!value?.mapped?.CompanyName && !!value?.mapped?.CompanyName &&
isMappedPolicyCurrent(value.mapped), isMappedPolicyCurrent(value.mapped),
mappedValue: (value) => value?.mapped ?? {}, errorMessage: mappedInquiryErrorMessage,
}); });
return { return {
...result.value, ...result.value,
@@ -423,15 +427,12 @@ export class RequestManagementService {
"اطلاعات بیمه‌گذار برای استعلام بیمه بدنه الزامی است.", "اطلاعات بیمه‌گذار برای استعلام بیمه بدنه الزامی است.",
); );
} }
const result = await runPlateInquiryWithFallback({ const result = await runCurrentPlateInquiry({
vehicle: submission.vehicle, currentPlate: submission.vehicle?.currentPlate ?? submission.dto.plate,
fallbackCurrentPlate: submission.dto.plate, vin: submission.vehicle?.vin,
query: (plate, plateKind) => query: (plate) =>
this.sandHubService.getCarBodyInquiry({ this.sandHubService.getCarBodyInquiry({
nationalCodeOfInsurer: nationalCodeOfInsurer: policyholderNationalCode,
plateKind === "PREVIOUS"
? submission.vehicle!.previousPolicyholderNationalCode!
: policyholderNationalCode,
plate: plate as any, plate: plate as any,
}), }),
isUsable: (value) => isUsable: (value) =>
@@ -442,7 +443,7 @@ export class RequestManagementService {
value.mapped.CompanyName || value.mapped.CompanyName ||
value.mapped.companyId value.mapped.companyId
), ),
mappedValue: (value) => value?.mapped ?? {}, errorMessage: mappedInquiryErrorMessage,
}); });
return { return {
...result.value, ...result.value,

View File

@@ -155,28 +155,38 @@ describe("SandHubService inquiry mocks", () => {
expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY"); expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY");
}); });
it("preserves ESG not-found semantics as a Persian plate-specific error", async () => { it("preserves ESG messageFa for a plate inquiry", async () => {
process.env.CLIENT_ID = "8"; process.env.CLIENT_ID = "8";
externalInquirySettings.isInquiryLive.mockResolvedValue(true); externalInquirySettings.isInquiryLive.mockResolvedValue(true);
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({ jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: false, 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, { const result = await service.getTejaratBlockInquiry(userDetail, {
enforceDeploymentClientMatch: true, 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); externalInquirySettings.isInquiryLive.mockResolvedValue(true);
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({ jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: false, 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({ const result = await service.getPolicyByChassisInquiry({
@@ -185,7 +195,7 @@ describe("SandHubService inquiry mocks", () => {
}); });
expect(result.mapped.Error.Message).toBe( expect(result.mapped.Error.Message).toBe(
"بیمه‌نامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.", "نتیجه‌ای مطابق با اطلاعات وارد شده یافت نشد",
); );
}); });