forked from Yara724/api
Compare commits
28 Commits
a5f534817b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac185c861 | ||
| f07e510c5f | |||
|
|
8b65e43c60 | ||
| 9dd0ec0c00 | |||
|
|
e06178f804 | ||
| a9adad3c1b | |||
| bbe5b7b77b | |||
| 1e13bb3a3c | |||
| a8d8df574b | |||
| cf07a33951 | |||
| 6f8a6fed69 | |||
| dbf46cfdda | |||
|
|
4ef53f2cc9 | ||
| b4c1d7bd5f | |||
|
|
4cd6e0ecb3 | ||
|
|
8071215803 | ||
| cd36a0f2d4 | |||
| ffaeb50c61 | |||
| 8afc1fabc4 | |||
| 1d9ef5e3d2 | |||
| be06bfe1b5 | |||
| 85576e7c5e | |||
|
|
406b139b3d | ||
|
|
45e0ad883a | ||
| 51e1d495a9 | |||
| 7d1a50db7b | |||
| a84d83a135 | |||
| 631c9bce16 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -186,4 +186,3 @@ dist
|
|||||||
*.sh
|
*.sh
|
||||||
!scripts/fanavaran-flow-test.sh
|
!scripts/fanavaran-flow-test.sh
|
||||||
!scripts/fanavaran-auth.sh
|
!scripts/fanavaran-auth.sh
|
||||||
*.json
|
|
||||||
BIN
docs/Yara724 flows.pdf
Normal file
BIN
docs/Yara724 flows.pdf
Normal file
Binary file not shown.
@@ -461,6 +461,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Optional document shared by every claim flow</div>
|
||||||
|
<p class="step-note">
|
||||||
|
Every V1–V6 document-upload step also offers
|
||||||
|
<code>accident_sketch</code> (کروکی). It is optional for both
|
||||||
|
<code>THIRD_PARTY</code> and <code>CAR_BODY</code>, may be omitted without
|
||||||
|
blocking progression, and is returned in expert and insurer case details when uploaded.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
<h2>Flow V1 / V2 — User-Initiated Blame & Claim</h2>
|
<h2>Flow V1 / V2 — User-Initiated Blame & Claim</h2>
|
||||||
<p class="section-intro">
|
<p class="section-intro">
|
||||||
|
|||||||
@@ -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 و بدون سقف مجموع.
|
||||||
|
|||||||
@@ -433,6 +433,23 @@ curl -X POST "$ESG_URL/inquiry/policyByPlate" \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Car By Chassis (Two-Factor VIN Inquiry)
|
||||||
|
|
||||||
|
Use the two-factor chassis route when the backend must match both the VIN and
|
||||||
|
the resolved third-party policyholder. Do not send `nationalCode` to the
|
||||||
|
one-factor `policyByChassis` route.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST "$ESG_URL/inquiry/carByChassis" \
|
||||||
|
-H "Authorization: Bearer $ESG_ACCESS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
--data '{
|
||||||
|
"nationalCode": "0012345678",
|
||||||
|
"chassisNo": "NAAR03HFFRDE07024"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
### Person Inquiry
|
### Person Inquiry
|
||||||
|
|
||||||
ESG expects Jalali birth date, normalized as `YYYY-MM-DD`.
|
ESG expects Jalali birth date, normalized as `YYYY-MM-DD`.
|
||||||
|
|||||||
@@ -159,8 +159,9 @@
|
|||||||
<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> و برای VIN/شاسی به مسیر دوعاملی <code>/inquiry/carByChassis</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>
|
||||||
<li>۴. اگر <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (پیشفرض) → پاسخ mock برگردانده شود به جای انجام فراخوانیهای HTTP.</li>
|
<li>۴. اگر <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (پیشفرض) → پاسخ mock برگردانده شود به جای انجام فراخوانیهای HTTP.</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -207,7 +208,7 @@
|
|||||||
<div class="card card-blue">
|
<div class="card card-blue">
|
||||||
<h3>پروتکل ارسال خسارت (۴ مرحله)</h3>
|
<h3>پروتکل ارسال خسارت (۴ مرحله)</h3>
|
||||||
<div class="flow-box">
|
<div class="flow-box">
|
||||||
<div class="flow-step"><div class="flow-num">۱</div><div class="flow-text"><strong>خسارت پایه (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. دادههای مالک، راننده، بیمه، وسیله نقلیه و تصادف را ارسال میکند. یک <code>claimId</code> و <code>claimNo</code> فناوران برمیگرداند. پیامک با هر دو شناسه برای مالک ارسال میشود.</div></div>
|
<div class="flow-step"><div class="flow-num">۱</div><div class="flow-text"><strong>خسارت پایه (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. دادههای مالک، راننده، بیمه، وسیله نقلیه و تصادف را ارسال میکند. یک <code>claimId</code> و <code>claimNo</code> فناوران برمیگرداند که برای نمایش در پنل ذخیره میشوند.</div></div>
|
||||||
<div class="flow-step"><div class="flow-num">۲</div><div class="flow-text"><strong>موارد خسارت (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. یک ورودی به ازای هر قطعه آسیبدیده با شناسه کامپوننت، شدت و قیمت. سقف: کل ≤ ۵۳،۰۰۰،۰۰۰ تومان.</div></div>
|
<div class="flow-step"><div class="flow-num">۲</div><div class="flow-text"><strong>موارد خسارت (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. یک ورودی به ازای هر قطعه آسیبدیده با شناسه کامپوننت، شدت و قیمت. سقف: کل ≤ ۵۳،۰۰۰،۰۰۰ تومان.</div></div>
|
||||||
<div class="flow-step"><div class="flow-num">۳</div><div class="flow-text"><strong>پیوستها (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. اسناد، تصاویر car-capture و ویدیوها که با شناسه فایل ارجاع داده شدهاند.</div></div>
|
<div class="flow-step"><div class="flow-num">۳</div><div class="flow-text"><strong>پیوستها (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. اسناد، تصاویر car-capture و ویدیوها که با شناسه فایل ارجاع داده شدهاند.</div></div>
|
||||||
<div class="flow-step"><div class="flow-num">۴</div><div class="flow-text"><strong>کارشناسی (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. متادیتای ارزیابی کارشناس (نقش کارشناس، تاریخ، نتیجه). ارسال را نهایی میکند.</div></div>
|
<div class="flow-step"><div class="flow-num">۴</div><div class="flow-text"><strong>کارشناسی (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. متادیتای ارزیابی کارشناس (نقش کارشناس، تاریخ، نتیجه). ارسال را نهایی میکند.</div></div>
|
||||||
@@ -358,12 +359,12 @@
|
|||||||
<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 post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>جستجوی بیمهنامه مبتنی بر پلاک (THIRD_PARTY). بدنه: <code>nationalCode</code>، <code>plk1–plk4</code>. پاسخ قبل از ذخیره به فرمت قدیمی تجارت نگاشت میشود.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>جستجوی بیمهنامه مبتنی بر پلاک (THIRD_PARTY). بدنه: <code>nationalCode</code>، <code>plk1–plk4</code>. پاسخ قبل از ذخیره به فرمت قدیمی تجارت نگاشت میشود.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByChassis</code></td><td>جایگزین مبتنی بر VIN/شاسی برای پرسوجوی پلاک. توسط اندپوینتهای <code>run-inquiries-vin</code> فراخوانی میشود. از جستجوی شاسی ESG استفاده میکند (نه مسیر SandHub). بدنه: <code>nationalCode</code>، <code>chassis</code>.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/carByChassis</code></td><td>جایگزین دوعاملی VIN/شاسی برای استعلام پلاک. توسط اندپوینتهای <code>run-inquiries-vin</code> فراخوانی میشود. بدنه: <code>nationalCode</code>، <code>chassisNo</code>. مسیر تکعاملی <code>policyByChassis</code> استفاده نمیشود، چون فیلد <code>nationalCode</code> را نمیپذیرد.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>بررسی هویت شخصی. بدنه: <code>nationalCode</code>، <code>birthDate</code> (جلالی، نه میلادی).</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>بررسی هویت شخصی. بدنه: <code>nationalCode</code>، <code>birthDate</code> (جلالی، نه میلادی).</td></tr>
|
||||||
<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>
|
||||||
@@ -414,7 +415,7 @@
|
|||||||
<tr><td><code>token</code> = "تصادف"/"خسارت"، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس</td><td>کارشناس یک فایل تقصیر یا خسارت را قفل میکند</td><td><code>yara-expert-lock</code></td></tr>
|
<tr><td><code>token</code> = "تصادف"/"خسارت"، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس</td><td>کارشناس یک فایل تقصیر یا خسارت را قفل میکند</td><td><code>yara-expert-lock</code></td></tr>
|
||||||
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = لینک</td><td>کارشناس درخواست ارسال مجدد اسناد میدهد</td><td><code>yara-resend-documents</code></td></tr>
|
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = لینک</td><td>کارشناس درخواست ارسال مجدد اسناد میدهد</td><td><code>yara-resend-documents</code></td></tr>
|
||||||
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس، <code>token10</code> = لینک</td><td>طرف مطلع میشود که ارزیابی خسارت کارشناس را امضا کند</td><td><code>yara-signature</code></td></tr>
|
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس، <code>token10</code> = لینک</td><td>طرف مطلع میشود که ارزیابی خسارت کارشناس را امضا کند</td><td><code>yara-signature</code></td></tr>
|
||||||
<tr><td><code>token</code> = publicId، <code>token2</code> = claimId فناوران، <code>token3</code> = claimNo فناوران</td><td>ارسال فناوران تأیید شد — با شماره و شناسه خسارت فناوران برای مالک خسارت ارسال میشود</td><td><code>yara-fanavaran-claim</code></td></tr>
|
<tr><td><code>token</code> = publicId، <code>token2</code> = claimId فناوران، <code>token3</code> = claimNo فناوران</td><td>قالب قدیمی نگهداری شده است؛ ارسال خودکار پس از آخرین مرحله فناوران غیرفعال است</td><td><code>yara-fanavaran-claim</code></td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p class="note" style="margin-top:8px;">
|
<p class="note" style="margin-top:8px;">
|
||||||
تمام فراخوانیهای پیامک fire-and-forget هستند — هرگز throw نمیکنند. شکستها log میشوند اما جریان اصلی را مسدود نمیکنند.
|
تمام فراخوانیهای پیامک fire-and-forget هستند — هرگز throw نمیکنند. شکستها log میشوند اما جریان اصلی را مسدود نمیکنند.
|
||||||
|
|||||||
@@ -154,8 +154,9 @@
|
|||||||
<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> for plates or the two-factor <code>/inquiry/carByChassis</code> for VIN/chassis inquiries.</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>
|
||||||
<li>4. If <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (default) → return mock response instead of making HTTP calls.</li>
|
<li>4. If <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (default) → return mock response instead of making HTTP calls.</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -203,7 +204,7 @@
|
|||||||
<div class="card card-blue">
|
<div class="card card-blue">
|
||||||
<h3>Claim submission protocol (4 steps)</h3>
|
<h3>Claim submission protocol (4 steps)</h3>
|
||||||
<div class="flow-box">
|
<div class="flow-box">
|
||||||
<div class="flow-step"><div class="flow-num">1</div><div class="flow-text"><strong>Base claim (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. Sends owner, driver, insurance, vehicle, and accident data. Returns a Fanavaran <code>claimId</code> and <code>claimNo</code>. SMS is sent to the owner with both identifiers.</div></div>
|
<div class="flow-step"><div class="flow-num">1</div><div class="flow-text"><strong>Base claim (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. Sends owner, driver, insurance, vehicle, and accident data. Returns a Fanavaran <code>claimId</code> and <code>claimNo</code>, which are persisted for panel display.</div></div>
|
||||||
<div class="flow-step"><div class="flow-num">2</div><div class="flow-text"><strong>Damage cases (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. One entry per damaged part with component ID, severity, and price. Cap: total ≤ 53 000 000 Toman.</div></div>
|
<div class="flow-step"><div class="flow-num">2</div><div class="flow-text"><strong>Damage cases (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. One entry per damaged part with component ID, severity, and price. Cap: total ≤ 53 000 000 Toman.</div></div>
|
||||||
<div class="flow-step"><div class="flow-num">3</div><div class="flow-text"><strong>Attachments (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. Documents, car-capture images, and videos referenced by file ID.</div></div>
|
<div class="flow-step"><div class="flow-num">3</div><div class="flow-text"><strong>Attachments (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. Documents, car-capture images, and videos referenced by file ID.</div></div>
|
||||||
<div class="flow-step"><div class="flow-num">4</div><div class="flow-text"><strong>Expertise (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. Expert assessment metadata (expert role, date, result). Finalises the submission.</div></div>
|
<div class="flow-step"><div class="flow-num">4</div><div class="flow-text"><strong>Expertise (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. Expert assessment metadata (expert role, date, result). Finalises the submission.</div></div>
|
||||||
@@ -355,12 +356,12 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
|
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>Plate-based policy lookup (THIRD_PARTY). Body: <code>nationalCode</code>, <code>plk1–plk4</code>. Response is mapped to the old Tejarat format before being stored.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>Plate-based policy lookup (THIRD_PARTY). Body: <code>nationalCode</code>, <code>plk1–plk4</code>. Response is mapped to the old Tejarat format before being stored.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByChassis</code></td><td>VIN/chassis-based alternative to plate inquiry. Called by <code>run-inquiries-vin</code> endpoints. Uses ESG chassis lookup (not the SandHub path). Body: <code>nationalCode</code>, <code>chassis</code>.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/carByChassis</code></td><td>Two-factor VIN/chassis alternative to the plate inquiry. Called by <code>run-inquiries-vin</code> endpoints. Body: <code>nationalCode</code>, <code>chassisNo</code>. The one-factor <code>policyByChassis</code> route is not used because it rejects <code>nationalCode</code>.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>Personal identity check. Body: <code>nationalCode</code>, <code>birthDate</code> (Jalali, NOT Gregorian).</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>Personal identity check. Body: <code>nationalCode</code>, <code>birthDate</code> (Jalali, NOT Gregorian).</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p class="note" style="margin-top:8px;">
|
<p class="note" style="margin-top:8px;">
|
||||||
ESG wraps every response as <code>{ success: boolean, data: … }</code>. A <code>success=false</code> body is translated to a Persian "استعلام در دسترس نیست" (inquiry unavailable) error.
|
ESG wraps every response as <code>{ success: boolean, data: … }</code>. 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>
|
||||||
@@ -411,7 +412,7 @@
|
|||||||
<tr><td><code>yara-expert-lock</code></td><td>Expert locks a blame or claim file</td><td><code>token</code> = "تصادف"/"خسارت", <code>token2</code> = publicId, <code>token3</code> = expert surname</td></tr>
|
<tr><td><code>yara-expert-lock</code></td><td>Expert locks a blame or claim file</td><td><code>token</code> = "تصادف"/"خسارت", <code>token2</code> = publicId, <code>token3</code> = expert surname</td></tr>
|
||||||
<tr><td><code>yara-resend-documents</code></td><td>Expert requests document resend</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = link</td></tr>
|
<tr><td><code>yara-resend-documents</code></td><td>Expert requests document resend</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = link</td></tr>
|
||||||
<tr><td><code>yara-signature</code></td><td>Party notified to sign the expert's damage assessment</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = expert surname, <code>token10</code> = link</td></tr>
|
<tr><td><code>yara-signature</code></td><td>Party notified to sign the expert's damage assessment</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = expert surname, <code>token10</code> = link</td></tr>
|
||||||
<tr><td><code>yara-fanavaran-claim</code></td><td>Fanavaran submission confirmed — sent to claim owner with Fanavaran claim number and ID</td><td><code>token</code> = publicId, <code>token2</code> = Fanavaran claimId, <code>token3</code> = Fanavaran claimNo</td></tr>
|
<tr><td><code>yara-fanavaran-claim</code></td><td>Retained legacy template; automatic dispatch after the final Fanavaran stage is disabled</td><td><code>token</code> = publicId, <code>token2</code> = Fanavaran claimId, <code>token3</code> = Fanavaran claimNo</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p class="note" style="margin-top:8px;">
|
<p class="note" style="margin-top:8px;">
|
||||||
All SMS calls are fire-and-forget — they never throw. Failures are logged but do not block the main flow.
|
All SMS calls are fire-and-forget — they never throw. Failures are logged but do not block the main flow.
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ Requires `claimId` + `dmgCaseId` (soft-ensures earlier stages).
|
|||||||
| `Id` | `claimCases.expertiseId` |
|
| `Id` | `claimCases.expertiseId` |
|
||||||
|
|
||||||
History: `FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED`.
|
History: `FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED`.
|
||||||
SMS owner via `SmsOrchestrationService` (deduped `fanavaranSync.expertise.smsNotifiedAt`).
|
No owner SMS is sent after expertise; the returned identifier is persisted only.
|
||||||
Proven Parsian: `expertiseId=403144`, `ClaimExpertId=29`.
|
Proven Parsian: `expertiseId=403144`, `ClaimExpertId=29`.
|
||||||
|
|
||||||
Manual: `POST /v2/fanavaran/{client}/claim-cases/{id}/expertise/submit`.
|
Manual: `POST /v2/fanavaran/{client}/claim-cases/{id}/expertise/submit`.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Start local claim
|
|||||||
→ Upload docs/images locally
|
→ Upload docs/images locally
|
||||||
→ GEN.07 attachments (per file, best-effort)
|
→ GEN.07 attachments (per file, best-effort)
|
||||||
→ Expert pricing ready
|
→ Expert pricing ready
|
||||||
→ GEN.08 expertise → store expertiseId → SMS owner
|
→ GEN.08 expertise → store expertiseId (no SMS)
|
||||||
→ End (local completion independent of Fanavaran success)
|
→ End (local completion independent of Fanavaran success)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ sequenceDiagram
|
|||||||
YARA->>FV: POST .../expertise (GEN.08)
|
YARA->>FV: POST .../expertise (GEN.08)
|
||||||
FV-->>YARA: ExpertiseId
|
FV-->>YARA: ExpertiseId
|
||||||
YARA->>DB: expertiseId, history SUCCESS
|
YARA->>DB: expertiseId, history SUCCESS
|
||||||
YARA->>YARA: SMS owner (deduped)
|
Note over YARA: Post-expertise owner SMS is disabled
|
||||||
```
|
```
|
||||||
|
|
||||||
## Flow diagram (stages + soft-ensure)
|
## Flow diagram (stages + soft-ensure)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ source: fanavaran-module-docs
|
|||||||
| Tenant config | boot + System Settings | `fanavaran-client-config.service.ts`, `system-settings.*` |
|
| Tenant config | boot + System Settings | `fanavaran-client-config.service.ts`, `system-settings.*` |
|
||||||
| Orchestration | `ClaimRequestManagementModule` | `claim-request-management.service.ts` |
|
| Orchestration | `ClaimRequestManagementModule` | `claim-request-management.service.ts` |
|
||||||
| Policy select | same | `fanavaran-policy-selection.ts` |
|
| Policy select | same | `fanavaran-policy-selection.ts` |
|
||||||
| SMS after expertise | `SmsOrchestrationModule` | `sms-orchestration.service.ts` |
|
| SMS after expertise | Disabled by product decision | No message is dispatched |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ source: fanavaran-module-docs
|
|||||||
| Send | GEN.03 JSON + business headers |
|
| Send | GEN.03 JSON + business headers |
|
||||||
| Receive | `Id`, `ClaimNo` |
|
| Receive | `Id`, `ClaimNo` |
|
||||||
| Store | `claimId`, `claimNo`, `fanavaranSync.baseClaim.*`, history |
|
| Store | `claimId`, `claimNo`, `fanavaranSync.baseClaim.*`, history |
|
||||||
| Consumers | Damage/expertise soft-ensure; UI claim detail; SMS |
|
| Consumers | Damage/expertise soft-ensure; UI claim detail |
|
||||||
|
|
||||||
### C. Damage case
|
### C. Damage case
|
||||||
|
|
||||||
|
|||||||
@@ -69,4 +69,4 @@ Per HTTP step:
|
|||||||
|
|
||||||
## SMS
|
## SMS
|
||||||
|
|
||||||
After successful expertise (last Fanavaran stage), owner SMS may be sent once (`fanavaranSync.expertise.smsNotifiedAt`). Failure to SMS should not roll back Fanavaran expertise submit; treat as separate notification concern. Claims already notified at base claim (`fanavaranSync.baseClaim.smsNotifiedAt`) are not re-notified.
|
The post-expertise owner SMS is disabled. A successful final Fanavaran response only persists the returned `expertiseId` and synchronization state; it does not dispatch a notification.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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` ذخیرهشده را بدون حذف اطلاعات در جزئیات پرونده پنلهای کارشناسی و پرونده خسارت متصل نمایش دهد تا اطلاعات راننده و سایر نقشها برای بررسی در دسترس بماند.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.</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>
|
||||||
@@ -462,7 +462,7 @@
|
|||||||
<tr><td><span class="method post">POST</span></td><td><code>run-inquiries/:id</code> / <code>run-inquiries-vin/:id</code></td><td>Run plate or VIN inquiry. First call = guilty (+ auto-creates claim). Second call = damaged (THIRD_PARTY only).</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>run-inquiries/:id</code> / <code>run-inquiries-vin/:id</code></td><td>Run plate or VIN inquiry. First call = guilty (+ auto-creates claim). Second call = damaged (THIRD_PARTY only).</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>add-detail-location/:id</code> / <code>add-detail-description/:id</code> / <code>upload-voice/:id</code></td><td>Add location, description, and voice for current party (partyRole param selects FIRST/SECOND).</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>add-detail-location/:id</code> / <code>add-detail-description/:id</code> / <code>upload-voice/:id</code></td><td>Add location, description, and voice for current party (partyRole param selects FIRST/SECOND).</td></tr>
|
||||||
<tr><td><span class="method put">PUT</span></td><td><code>sign/:id</code></td><td>Upload party signature (partyRole=FIRST then SECOND). After second signature, file is sealed.</td></tr>
|
<tr><td><span class="method put">PUT</span></td><td><code>sign/:id</code></td><td>Upload party signature (partyRole=FIRST then SECOND). After second signature, file is sealed.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload licences / car cards against the auto-created claim.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload licences / car cards and the optional <code>accident_sketch</code> (کروکی) against the auto-created claim. The sketch never gates completion.</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (phases: pre-capture docs vs damaged parts + chassis/engine).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (phases: pre-capture docs vs damaged parts + chassis/engine).</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -498,7 +498,7 @@
|
|||||||
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>Get the auto-created claim ID (from FileMaker's guilty-party inquiry).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>Get the auto-created claim ID (from FileMaker's guilty-party inquiry).</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:requestId</code></td><td>Step 1 (FileReviewer): save accident fields (accidentWay, accidentReason, accidentType).</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:requestId</code></td><td>Step 1 (FileReviewer): save accident fields (accidentWay, accidentReason, accidentType).</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (pre-capture docs phase vs capture-parts phase).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (pre-capture docs phase vs capture-parts phase).</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload chassis / engine / metal-plate documents.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload chassis / engine / metal-plate documents; <code>accident_sketch</code> (کروکی) remains optional and does not affect completion.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>Select outer (body) damaged parts.</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>Select outer (body) damaged parts.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>Select other (non-body) damaged parts.</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>Select other (non-body) damaged parts.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>Capture part photos + angles for each selected damaged part.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>Capture part photos + angles for each selected damaged part.</td></tr>
|
||||||
|
|||||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -8796,9 +8796,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.3.1",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||||
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
|
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export enum ClaimRequiredDocumentType {
|
export enum ClaimRequiredDocumentType {
|
||||||
|
/** Optional police accident sketch (Persian: کروکی). Never gates claim progress. */
|
||||||
|
ACCIDENT_SKETCH = "accident_sketch",
|
||||||
|
|
||||||
// Car green card
|
// Car green card
|
||||||
CAR_GREEN_CARD = "car_green_card",
|
CAR_GREEN_CARD = "car_green_card",
|
||||||
CAR_CERTIFICATE = "car_certificate",
|
CAR_CERTIFICATE = "car_certificate",
|
||||||
@@ -34,4 +37,3 @@ export enum CarAngle {
|
|||||||
LEFT = "left",
|
LEFT = "left",
|
||||||
RIGHT = "right",
|
RIGHT = "right",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -462,4 +462,128 @@ describe("buildInsurerFileReport", () => {
|
|||||||
"پایه یک",
|
"پایه یک",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("localizes participant and recent-transfer inquiry data", () => {
|
||||||
|
const report = buildInsurerFileReport({
|
||||||
|
overview: { publicId: "A00017" },
|
||||||
|
blame: {
|
||||||
|
type: "THIRD_PARTY",
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
role: "FIRST",
|
||||||
|
person: { userId: "guilty", fullName: "مقصر" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "SECOND",
|
||||||
|
person: { userId: "damaged", fullName: "زیاندیده" },
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
participantId: "DRIVER",
|
||||||
|
fullName: "راننده",
|
||||||
|
nationalCode: "0012345678",
|
||||||
|
birthday: "1370/01/01",
|
||||||
|
hasDrivingLicense: true,
|
||||||
|
licenseNumber: "123456789",
|
||||||
|
licenseType: "BASE_2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
participantRoles: {
|
||||||
|
driver: "DRIVER",
|
||||||
|
vehicleOwner: "DRIVER",
|
||||||
|
thirdPartyPolicyholder: "DRIVER",
|
||||||
|
},
|
||||||
|
vehicle: {
|
||||||
|
registrationState: "RECENTLY_TRANSFERRED",
|
||||||
|
previousPlateId: "55ج222ایران33",
|
||||||
|
previousPolicyholderNationalCode: "0098765432",
|
||||||
|
currentPlate: {
|
||||||
|
leftDigits: "44",
|
||||||
|
centerAlphabet: "ب",
|
||||||
|
centerDigits: "111",
|
||||||
|
ir: "22",
|
||||||
|
},
|
||||||
|
inquiry: {
|
||||||
|
plateKind: "PREVIOUS",
|
||||||
|
attempts: [
|
||||||
|
{
|
||||||
|
plateKind: "CURRENT",
|
||||||
|
plate: {
|
||||||
|
leftDigits: "44",
|
||||||
|
centerAlphabet: "ب",
|
||||||
|
centerDigits: "111",
|
||||||
|
ir: "22",
|
||||||
|
},
|
||||||
|
succeeded: false,
|
||||||
|
error: "not found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
plateKind: "PREVIOUS",
|
||||||
|
succeeded: true,
|
||||||
|
usable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expert: { decision: { guiltyPartyId: "guilty" } },
|
||||||
|
},
|
||||||
|
claim: {
|
||||||
|
vehicle: { carType: "SEDAN" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getFieldValue(
|
||||||
|
report,
|
||||||
|
PR.damagedVehicleSection,
|
||||||
|
"خودرو / وضعیت پلاک و مالکیت",
|
||||||
|
),
|
||||||
|
).toBe("انتقال مالکیت اخیر");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.damagedVehicleSection, "خودرو / نوع خودرو"),
|
||||||
|
).toBe("سواری");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.damagedVehicleSection, "خودرو / پلاک قبلی"),
|
||||||
|
).toBe("55ج222ایران33");
|
||||||
|
expect(
|
||||||
|
getFieldValue(
|
||||||
|
report,
|
||||||
|
PR.damagedVehicleSection,
|
||||||
|
"خودرو / پلاک فعلی / دو رقم چپ پلاک",
|
||||||
|
),
|
||||||
|
).toBe("44");
|
||||||
|
|
||||||
|
const driver = getFieldValue(
|
||||||
|
report,
|
||||||
|
PR.damagedParticipantsSection,
|
||||||
|
PR.driverRole,
|
||||||
|
);
|
||||||
|
expect(driver).toContain("گواهینامه دارد: بله");
|
||||||
|
expect(driver).toContain("نوع گواهینامه: پایه دو");
|
||||||
|
expect(
|
||||||
|
getFieldValue(
|
||||||
|
report,
|
||||||
|
PR.damagedVehicleSection,
|
||||||
|
"خودرو / سوابق تلاش استعلام / تلاش ۱ / نوع پلاک استعلامشده",
|
||||||
|
),
|
||||||
|
).toBe("پلاک فعلی");
|
||||||
|
expect(
|
||||||
|
getFieldValue(
|
||||||
|
report,
|
||||||
|
PR.damagedVehicleSection,
|
||||||
|
"خودرو / سوابق تلاش استعلام / تلاش ۱ / خطا",
|
||||||
|
),
|
||||||
|
).toBe("موردی یافت نشد");
|
||||||
|
|
||||||
|
const renderedText = report.sections
|
||||||
|
.flatMap((section) => [
|
||||||
|
section.title,
|
||||||
|
...section.fields.flatMap((field) => [field.label, field.value]),
|
||||||
|
])
|
||||||
|
.join(" ");
|
||||||
|
expect(renderedText).not.toMatch(
|
||||||
|
/RECENTLY_TRANSFERRED|BASE_2|registrationState|previousPlateId|plateKind|not found|CURRENT|PREVIOUS/,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
PR,
|
PR,
|
||||||
persianAccidentCondition,
|
persianAccidentCondition,
|
||||||
persianFieldPath,
|
persianFieldPath,
|
||||||
|
persianReportValue,
|
||||||
persianStatus,
|
persianStatus,
|
||||||
} from "./persian-report-labels";
|
} from "./persian-report-labels";
|
||||||
|
|
||||||
@@ -110,10 +111,13 @@ function firstDefined(...values: unknown[]): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeListValue(value: unknown): string | undefined {
|
function normalizeListValue(value: unknown, path = ""): string | undefined {
|
||||||
if (!Array.isArray(value) || !value.length) return undefined;
|
if (!Array.isArray(value) || !value.length) return undefined;
|
||||||
const items = value
|
const items = value
|
||||||
.map((item) => asString(item) ?? JSON.stringify(item))
|
.map(
|
||||||
|
(item) =>
|
||||||
|
asString(persianReportValue(path, item)) ?? JSON.stringify(item),
|
||||||
|
)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return items.length ? items.join("، ") : undefined;
|
return items.length ? items.join("، ") : undefined;
|
||||||
}
|
}
|
||||||
@@ -124,18 +128,31 @@ function flattenObject(
|
|||||||
depth = 0,
|
depth = 0,
|
||||||
): InsurerFileReportField[] {
|
): InsurerFileReportField[] {
|
||||||
if (obj == null) return [];
|
if (obj == null) return [];
|
||||||
if (depth > 4) {
|
if (depth > 6) {
|
||||||
return [{ label: persianFieldPath(prefix), value: asString(obj) }];
|
return [
|
||||||
|
{
|
||||||
|
label: persianFieldPath(prefix),
|
||||||
|
value: asString(persianReportValue(prefix, obj)),
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
const value = normalizeListValue(obj);
|
if (obj.some((item) => item && typeof item === "object")) {
|
||||||
|
return obj.flatMap((item, index) =>
|
||||||
|
flattenObject(item, `${prefix}.${index + 1}`, depth + 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const value = normalizeListValue(obj, prefix);
|
||||||
return value ? [{ label: persianFieldPath(prefix || "items"), value }] : [];
|
return value ? [{ label: persianFieldPath(prefix || "items"), value }] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof obj !== "object") {
|
if (typeof obj !== "object") {
|
||||||
return [
|
return [
|
||||||
{ label: persianFieldPath(prefix || "value"), value: asString(obj) },
|
{
|
||||||
|
label: persianFieldPath(prefix || "value"),
|
||||||
|
value: asString(persianReportValue(prefix || "value", obj)),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,14 +167,13 @@ function flattenObject(
|
|||||||
if (value === undefined || value === null || value === "") continue;
|
if (value === undefined || value === null || value === "") continue;
|
||||||
|
|
||||||
const path = prefix ? `${prefix}.${key}` : key;
|
const path = prefix ? `${prefix}.${key}` : key;
|
||||||
if (
|
if (typeof value === "object" && !(value instanceof Date)) {
|
||||||
typeof value === "object" &&
|
|
||||||
!Array.isArray(value) &&
|
|
||||||
!(value instanceof Date)
|
|
||||||
) {
|
|
||||||
rows.push(...flattenObject(value, path, depth + 1));
|
rows.push(...flattenObject(value, path, depth + 1));
|
||||||
} else {
|
} else {
|
||||||
rows.push({ label: persianFieldPath(path), value: asString(value) });
|
rows.push({
|
||||||
|
label: persianFieldPath(path),
|
||||||
|
value: asString(persianReportValue(path, value)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,6 +654,17 @@ function buildParticipantRolesSection(
|
|||||||
participant?.licenseNumber
|
participant?.licenseNumber
|
||||||
? `${PR.licenseNumber}: ${asString(participant.licenseNumber)}`
|
? `${PR.licenseNumber}: ${asString(participant.licenseNumber)}`
|
||||||
: undefined,
|
: undefined,
|
||||||
|
participant?.hasDrivingLicense != null
|
||||||
|
? `${PR.hasDrivingLicense}: ${persianStatus(participant.hasDrivingLicense)}`
|
||||||
|
: undefined,
|
||||||
|
participant?.licenseType
|
||||||
|
? `${PR.licenseType}: ${asString(
|
||||||
|
persianReportValue(
|
||||||
|
"participant.licenseType",
|
||||||
|
participant.licenseType,
|
||||||
|
),
|
||||||
|
)}`
|
||||||
|
: undefined,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("، ");
|
.join("، ");
|
||||||
@@ -695,8 +722,12 @@ function buildDriverSection(
|
|||||||
{
|
{
|
||||||
label: PR.licenseType,
|
label: PR.licenseType,
|
||||||
value:
|
value:
|
||||||
licenseType ??
|
asString(
|
||||||
asString(person.licenseType) ??
|
persianReportValue(
|
||||||
|
"participant.licenseType",
|
||||||
|
licenseType ?? person.licenseType,
|
||||||
|
),
|
||||||
|
) ??
|
||||||
(person.driverLicense ? PR.driverLicense : undefined),
|
(person.driverLicense ? PR.driverLicense : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -895,7 +926,7 @@ function evaluationDaghiValue(value: unknown): string | undefined {
|
|||||||
return asString(value);
|
return asString(value);
|
||||||
}
|
}
|
||||||
const daghi = value as ReportRecord;
|
const daghi = value as ReportRecord;
|
||||||
const option = asString(daghi.option);
|
const option = asString(persianReportValue("daghi.option", daghi.option));
|
||||||
const price = formatToman(daghi.price);
|
const price = formatToman(daghi.price);
|
||||||
return [option, price].filter(Boolean).join(" - ") || undefined;
|
return [option, price].filter(Boolean).join(" - ") || undefined;
|
||||||
}
|
}
|
||||||
@@ -915,7 +946,9 @@ function buildEvaluationPartsSection(
|
|||||||
{ label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) },
|
{ label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) },
|
||||||
{
|
{
|
||||||
label: `${prefix} / ${PR.damageType}`,
|
label: `${prefix} / ${PR.damageType}`,
|
||||||
value: asString(part.typeOfDamage),
|
value: asString(
|
||||||
|
persianReportValue("evaluation.part.typeOfDamage", part.typeOfDamage),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ label: `${prefix} / ${PR.partPrice}`, value: formatToman(part.price) },
|
{ label: `${prefix} / ${PR.partPrice}`, value: formatToman(part.price) },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const PR = {
|
|||||||
licenseType: "نوع گواهینامه",
|
licenseType: "نوع گواهینامه",
|
||||||
licenseDate: "تاریخ گواهینامه",
|
licenseDate: "تاریخ گواهینامه",
|
||||||
licenseNumber: "شماره گواهینامه",
|
licenseNumber: "شماره گواهینامه",
|
||||||
|
hasDrivingLicense: "گواهینامه دارد",
|
||||||
driverLicense: "گواهینامه راننده",
|
driverLicense: "گواهینامه راننده",
|
||||||
insuranceCompany: "شرکت بیمه",
|
insuranceCompany: "شرکت بیمه",
|
||||||
policyNumber: "شماره بیمهنامه",
|
policyNumber: "شماره بیمهنامه",
|
||||||
@@ -194,6 +195,31 @@ const KEY_LABELS: Record<string, string> = {
|
|||||||
StatusTypeCode: "کد وضعیت",
|
StatusTypeCode: "کد وضعیت",
|
||||||
label_fa: "برچسب فارسی",
|
label_fa: "برچسب فارسی",
|
||||||
catalogKey: "کلید کاتالوگ",
|
catalogKey: "کلید کاتالوگ",
|
||||||
|
participantId: "شناسه شخص",
|
||||||
|
participants: "اشخاص استعلام",
|
||||||
|
participantRoles: "نقشهای اشخاص",
|
||||||
|
nationalCode: "کد ملی",
|
||||||
|
birthday: "تاریخ تولد",
|
||||||
|
fullName: "نام و نام خانوادگی",
|
||||||
|
phoneNumber: "شماره تلفن",
|
||||||
|
hasDrivingLicense: "گواهینامه دارد",
|
||||||
|
licenseNumber: "شماره گواهینامه",
|
||||||
|
registrationState: "وضعیت پلاک و مالکیت",
|
||||||
|
currentPlate: "پلاک فعلی",
|
||||||
|
previousPlate: "پلاک قبلی",
|
||||||
|
previousPlateId: "پلاک قبلی",
|
||||||
|
previousPolicyholderNationalCode: "کد ملی بیمهگذار پلاک قبلی",
|
||||||
|
vehicleVin: "شماره شاسی (VIN)",
|
||||||
|
driver: "راننده",
|
||||||
|
vehicleOwner: "مالک وسیله نقلیه",
|
||||||
|
thirdPartyPolicyholder: "بیمهگذار شخص ثالث",
|
||||||
|
carBodyPolicyholder: "بیمهگذار بدنه",
|
||||||
|
sameAs: "همان شخص",
|
||||||
|
plateKind: "نوع پلاک استعلامشده",
|
||||||
|
succeeded: "موفق",
|
||||||
|
usable: "قابل استفاده",
|
||||||
|
attempts: "سوابق تلاش استعلام",
|
||||||
|
error: "خطا",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -209,6 +235,76 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
false: "خیر",
|
false: "خیر",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REGISTRATION_STATE_LABELS: Record<string, string> = {
|
||||||
|
CURRENT: "عادی (پلاک فعلی)",
|
||||||
|
RECENTLY_TRANSFERRED: "انتقال مالکیت اخیر",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATE_KIND_LABELS: Record<string, string> = {
|
||||||
|
CURRENT: "پلاک فعلی",
|
||||||
|
PREVIOUS: "پلاک قبلی",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARTICIPANT_ROLE_VALUE_LABELS: Record<string, string> = {
|
||||||
|
DRIVER: "راننده",
|
||||||
|
VEHICLE_OWNER: "مالک وسیله نقلیه",
|
||||||
|
THIRD_PARTY_POLICYHOLDER: "بیمهگذار شخص ثالث",
|
||||||
|
CAR_BODY_POLICYHOLDER: "بیمهگذار بدنه",
|
||||||
|
FIRST: "طرف اول",
|
||||||
|
SECOND: "طرف دوم",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CASE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
THIRD_PARTY: "شخص ثالث",
|
||||||
|
CAR_BODY: "بدنه",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VEHICLE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
SEDAN: "سواری",
|
||||||
|
SUV: "شاسیبلند",
|
||||||
|
HATCHBACK: "هاچبک",
|
||||||
|
PICKUP: "وانت",
|
||||||
|
VAN: "ون",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VALIDITY_LABELS: Record<string, string> = {
|
||||||
|
ACTIVE: "فعال",
|
||||||
|
INACTIVE: "غیرفعال",
|
||||||
|
VALID: "معتبر",
|
||||||
|
INVALID: "نامعتبر",
|
||||||
|
EXPIRED: "منقضیشده",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAMAGE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
REPAIR: "تعمیر",
|
||||||
|
CHANGE: "تعویض",
|
||||||
|
REPLACE: "تعویض",
|
||||||
|
};
|
||||||
|
|
||||||
|
const INQUIRY_ERROR_LABELS: Record<string, string> = {
|
||||||
|
NOT_FOUND: "موردی یافت نشد",
|
||||||
|
NO_RECORD_FOUND: "موردی یافت نشد",
|
||||||
|
TIMEOUT: "مهلت پاسخ استعلام به پایان رسید",
|
||||||
|
REQUEST_FAILED: "استعلام ناموفق بود",
|
||||||
|
FAILED: "استعلام ناموفق بود",
|
||||||
|
UNAVAILABLE: "سرویس استعلام در دسترس نیست",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LICENSE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
"1": "پایه یک",
|
||||||
|
"2": "پایه دو",
|
||||||
|
"3": "پایه سه",
|
||||||
|
BASE_1: "پایه یک",
|
||||||
|
BASE1: "پایه یک",
|
||||||
|
GRADE_1: "پایه یک",
|
||||||
|
BASE_2: "پایه دو",
|
||||||
|
BASE2: "پایه دو",
|
||||||
|
GRADE_2: "پایه دو",
|
||||||
|
BASE_3: "پایه سه",
|
||||||
|
BASE3: "پایه سه",
|
||||||
|
GRADE_3: "پایه سه",
|
||||||
|
};
|
||||||
|
|
||||||
const WEATHER_LABELS: Record<string, string> = {
|
const WEATHER_LABELS: Record<string, string> = {
|
||||||
clear: "صاف",
|
clear: "صاف",
|
||||||
sunny: "صاف",
|
sunny: "صاف",
|
||||||
@@ -255,9 +351,39 @@ export function persianFieldPath(path: string): string {
|
|||||||
return `بیمه / ${translatedLast}`;
|
return `بیمه / ${translatedLast}`;
|
||||||
}
|
}
|
||||||
if (parts[0] === "claim" && parts[1] === "vehicle") {
|
if (parts[0] === "claim" && parts[1] === "vehicle") {
|
||||||
|
const attemptIndex = parts.indexOf("attempts");
|
||||||
|
if (attemptIndex >= 0) {
|
||||||
|
const attemptNumber = Number(parts[attemptIndex + 1]);
|
||||||
|
const attemptLabel = Number.isFinite(attemptNumber)
|
||||||
|
? `تلاش ${attemptNumber.toLocaleString("fa-IR")}`
|
||||||
|
: "تلاش استعلام";
|
||||||
|
const plateLabel = parts.includes("plate") ? " / پلاک" : "";
|
||||||
|
return `خودرو / سوابق تلاش استعلام / ${attemptLabel}${plateLabel} / ${translatedLast}`;
|
||||||
|
}
|
||||||
|
if (parts.includes("currentPlate")) {
|
||||||
|
return `خودرو / پلاک فعلی / ${translatedLast}`;
|
||||||
|
}
|
||||||
|
if (parts.includes("previousPlate")) {
|
||||||
|
return `خودرو / پلاک قبلی / ${translatedLast}`;
|
||||||
|
}
|
||||||
return `خودرو / ${translatedLast}`;
|
return `خودرو / ${translatedLast}`;
|
||||||
}
|
}
|
||||||
if (parts[0] === "party" && parts[1] === "vehicle") {
|
if (parts[0] === "party" && parts[1] === "vehicle") {
|
||||||
|
const attemptIndex = parts.indexOf("attempts");
|
||||||
|
if (attemptIndex >= 0) {
|
||||||
|
const attemptNumber = Number(parts[attemptIndex + 1]);
|
||||||
|
const attemptLabel = Number.isFinite(attemptNumber)
|
||||||
|
? `تلاش ${attemptNumber.toLocaleString("fa-IR")}`
|
||||||
|
: "تلاش استعلام";
|
||||||
|
const plateLabel = parts.includes("plate") ? " / پلاک" : "";
|
||||||
|
return `خودرو / سوابق تلاش استعلام / ${attemptLabel}${plateLabel} / ${translatedLast}`;
|
||||||
|
}
|
||||||
|
if (parts.includes("currentPlate")) {
|
||||||
|
return `خودرو / پلاک فعلی / ${translatedLast}`;
|
||||||
|
}
|
||||||
|
if (parts.includes("previousPlate")) {
|
||||||
|
return `خودرو / پلاک قبلی / ${translatedLast}`;
|
||||||
|
}
|
||||||
return `خودرو / ${translatedLast}`;
|
return `خودرو / ${translatedLast}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +393,51 @@ export function persianFieldPath(path: string): string {
|
|||||||
return translated.join(" / ");
|
return translated.join(" / ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localize only known domain tokens. Free-form provider values, names,
|
||||||
|
* identifiers, plates and VINs are deliberately preserved verbatim.
|
||||||
|
*/
|
||||||
|
export function persianReportValue(
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
): unknown {
|
||||||
|
if (value === undefined || value === null || value === "") return value;
|
||||||
|
if (typeof value === "boolean") return value ? "بله" : "خیر";
|
||||||
|
if (typeof value !== "string" && typeof value !== "number") return value;
|
||||||
|
|
||||||
|
const raw = String(value).trim();
|
||||||
|
const key = raw.toUpperCase().replace(/[\s-]+/g, "_");
|
||||||
|
const field = path.split(".").filter(Boolean).pop() ?? "";
|
||||||
|
|
||||||
|
if (field === "licenseType" || field === "LicenseType") {
|
||||||
|
return LICENSE_TYPE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (field === "registrationState") {
|
||||||
|
return REGISTRATION_STATE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (field === "plateKind") return PLATE_KIND_LABELS[key] ?? raw;
|
||||||
|
if (field === "role" || field.endsWith("Role")) {
|
||||||
|
return PARTICIPANT_ROLE_VALUE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (field === "blameRequestType" || field === "fileType") {
|
||||||
|
return CASE_TYPE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(field === "carType" || field === "type") &&
|
||||||
|
path.toLowerCase().includes("vehicle")
|
||||||
|
) {
|
||||||
|
return VEHICLE_TYPE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (field === "typeOfDamage") return DAMAGE_TYPE_LABELS[key] ?? raw;
|
||||||
|
if (field === "error") return INQUIRY_ERROR_LABELS[key] ?? raw;
|
||||||
|
if (/status$/i.test(field)) {
|
||||||
|
return STATUS_LABELS[raw] ?? VALIDITY_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw === "true" || raw === "false") return STATUS_LABELS[raw];
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
export function persianStatus(value: unknown): string | undefined {
|
export function persianStatus(value: unknown): string | undefined {
|
||||||
if (value === undefined || value === null || value === "") return undefined;
|
if (value === undefined || value === null || value === "") return undefined;
|
||||||
const key = String(value);
|
const key = String(value);
|
||||||
|
|||||||
45
src/claim-request-management/claim-optional-document.spec.ts
Normal file
45
src/claim-request-management/claim-optional-document.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||||
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
|
|
||||||
|
describe("optional accident sketch", () => {
|
||||||
|
const service = Object.create(
|
||||||
|
ClaimRequestManagementService.prototype,
|
||||||
|
) as ClaimRequestManagementService;
|
||||||
|
|
||||||
|
it("is excluded from legacy required-document completion", () => {
|
||||||
|
const requiredTypes = (service as any).getRequiredDocumentTypes({
|
||||||
|
blameFile: { type: "THIRD_PARTY" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requiredTypes).not.toContain(
|
||||||
|
ClaimRequiredDocumentType.ACCIDENT_SKETCH,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not gate V2 owner-document completion", () => {
|
||||||
|
const mandatoryKeys = (service as any).requiredDocumentKeysV2(false);
|
||||||
|
const requiredDocuments = Object.fromEntries(
|
||||||
|
[...mandatoryKeys, ClaimRequiredDocumentType.CAR_GREEN_CARD].map(
|
||||||
|
(key) => [key, { uploaded: true }],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(service as any).allV2OwnerDocumentsComplete(
|
||||||
|
{ requiredDocuments },
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
requiredDocuments[ClaimRequiredDocumentType.ACCIDENT_SKETCH] = {
|
||||||
|
uploaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(service as any).allV2OwnerDocumentsComplete(
|
||||||
|
{ requiredDocuments },
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,7 +58,6 @@ import { MediaPolicyModule } from "src/media-policy/media-policy.module";
|
|||||||
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
|
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
|
||||||
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
||||||
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
||||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -77,7 +76,6 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
|
|||||||
SandHubModule,
|
SandHubModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
MediaPolicyModule,
|
MediaPolicyModule,
|
||||||
SmsOrchestrationModule,
|
|
||||||
JwtModule.register({}),
|
JwtModule.register({}),
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ import {
|
|||||||
} from "./fanavaran-claim-product";
|
} from "./fanavaran-claim-product";
|
||||||
import {
|
import {
|
||||||
actualPremiumFromHullPolicyRecord,
|
actualPremiumFromHullPolicyRecord,
|
||||||
|
authorityCulpritIdFromDetectionRows,
|
||||||
customerIdFromHullPolicyRecord,
|
customerIdFromHullPolicyRecord,
|
||||||
FANAVARAN_DEFAULT_HULL_CULPRIT_TYPE_ID,
|
FANAVARAN_DEFAULT_HULL_AUTHORITY_CULPRIT_ID,
|
||||||
|
FANAVARAN_DEFAULT_HULL_CUSTOMER_FAULT_PERCENT,
|
||||||
fanavaranHullNestedClaimId,
|
fanavaranHullNestedClaimId,
|
||||||
toFanavaranHullBaseClaimPayload,
|
toFanavaranHullBaseClaimPayload,
|
||||||
} from "./fanavaran-hull-base-claim";
|
} from "./fanavaran-hull-base-claim";
|
||||||
@@ -64,15 +66,15 @@ import {
|
|||||||
toFanavaranHullExpertiseDmgSection,
|
toFanavaranHullExpertiseDmgSection,
|
||||||
toFanavaranHullExpertisePayload,
|
toFanavaranHullExpertisePayload,
|
||||||
vehicleCurrentValueFromHullPolicyRecord,
|
vehicleCurrentValueFromHullPolicyRecord,
|
||||||
|
FANAVARAN_DEFAULT_HULL_REPAIR_DURATION,
|
||||||
} from "./fanavaran-hull-expertise";
|
} from "./fanavaran-hull-expertise";
|
||||||
import {
|
import {
|
||||||
asHullDmgAccessoryRows,
|
|
||||||
FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID,
|
FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID,
|
||||||
FANAVARAN_DEFAULT_HULL_DMG_KIND_ID,
|
FANAVARAN_DEFAULT_HULL_DMG_KIND_ID,
|
||||||
|
FANAVARAN_DEFAULT_HULL_VEHICLE_ACCESSORY_ID,
|
||||||
FANAVARAN_HULL_DMG_COST_KIND_CAPTION,
|
FANAVARAN_HULL_DMG_COST_KIND_CAPTION,
|
||||||
FANAVARAN_HULL_DMG_KIND_CAPTION,
|
FANAVARAN_HULL_DMG_KIND_CAPTION,
|
||||||
findLookupIdByCaption,
|
findLookupIdByCaption,
|
||||||
resolveVehicleHullAccessoryId,
|
|
||||||
} from "./fanavaran-hull-expertise-lookups";
|
} from "./fanavaran-hull-expertise-lookups";
|
||||||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
import { CreateClaimFromBlameResponseDto } from "./dto/create-claim-v2.dto";
|
import { CreateClaimFromBlameResponseDto } from "./dto/create-claim-v2.dto";
|
||||||
@@ -101,7 +103,12 @@ import {
|
|||||||
ClaimListItemV2Dto,
|
ClaimListItemV2Dto,
|
||||||
} from "./dto/my-claims-v2.dto";
|
} from "./dto/my-claims-v2.dto";
|
||||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
import {
|
||||||
|
applyListQueryV2,
|
||||||
|
isInListDateRange,
|
||||||
|
parseListDateRange,
|
||||||
|
} from "src/helpers/list-query-v2";
|
||||||
|
import { resolveUnifiedFileStatus } from "src/helpers/unified-file-status";
|
||||||
import { partyPersonMatchesUser } from "src/helpers/iran-mobile";
|
import { partyPersonMatchesUser } from "src/helpers/iran-mobile";
|
||||||
import {
|
import {
|
||||||
buildBlamePartyAccessOrConditions,
|
buildBlamePartyAccessOrConditions,
|
||||||
@@ -206,6 +213,7 @@ import {
|
|||||||
partLookupKey,
|
partLookupKey,
|
||||||
resolvePartCaptureIndex,
|
resolvePartCaptureIndex,
|
||||||
} from "src/helpers/outer-damage-parts";
|
} from "src/helpers/outer-damage-parts";
|
||||||
|
import { serializeDamagedPartSelectionHistory } from "src/helpers/claim-damaged-part-audit";
|
||||||
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
||||||
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
|
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
|
||||||
|
|
||||||
@@ -241,7 +249,6 @@ import {
|
|||||||
FanavaranAuditStatus,
|
FanavaranAuditStatus,
|
||||||
FanavaranAuditStep,
|
FanavaranAuditStep,
|
||||||
} from "src/fanavaran/schema/fanavaran-audit-log.schema";
|
} from "src/fanavaran/schema/fanavaran-audit-log.schema";
|
||||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
|
||||||
import { selectLatestActiveFanavaranPolicy } from "./fanavaran-policy-selection";
|
import { selectLatestActiveFanavaranPolicy } from "./fanavaran-policy-selection";
|
||||||
import {
|
import {
|
||||||
selectAccidentVehicleUsedId,
|
selectAccidentVehicleUsedId,
|
||||||
@@ -479,7 +486,6 @@ export class ClaimRequestManagementService {
|
|||||||
private readonly fanavaranAuditService: FanavaranAuditService,
|
private readonly fanavaranAuditService: FanavaranAuditService,
|
||||||
private readonly fanavaranAuthService: FanavaranAuthService,
|
private readonly fanavaranAuthService: FanavaranAuthService,
|
||||||
private readonly fanavaranLookupService: FanavaranLookupService,
|
private readonly fanavaranLookupService: FanavaranLookupService,
|
||||||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
|
||||||
private readonly fileMakerDbService: FileMakerDbService,
|
private readonly fileMakerDbService: FileMakerDbService,
|
||||||
private readonly fileReviewerDbService: FileReviewerDbService,
|
private readonly fileReviewerDbService: FileReviewerDbService,
|
||||||
private readonly fieldExpertDbService: FieldExpertDbService,
|
private readonly fieldExpertDbService: FieldExpertDbService,
|
||||||
@@ -1220,6 +1226,7 @@ export class ClaimRequestManagementService {
|
|||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
expert: any,
|
expert: any,
|
||||||
dto: {
|
dto: {
|
||||||
|
selectedPartIds?: number[];
|
||||||
carPartDamage?: CarDamagePartDto;
|
carPartDamage?: CarDamagePartDto;
|
||||||
sheba?: string;
|
sheba?: string;
|
||||||
nationalCodeOfInsurer?: string;
|
nationalCodeOfInsurer?: string;
|
||||||
@@ -1256,12 +1263,34 @@ export class ClaimRequestManagementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.carPartDamage) {
|
if (dto.selectedPartIds?.length || dto.carPartDamage) {
|
||||||
if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) {
|
if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) {
|
||||||
throw new BadRequestException("Car part damage is already set.");
|
throw new BadRequestException("Car part damage is already set.");
|
||||||
}
|
}
|
||||||
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage);
|
let trueItems: Array<Record<string, unknown>>;
|
||||||
const trueItems = this.findTrueItems(dto.carPartDamage);
|
if (dto.selectedPartIds?.length) {
|
||||||
|
const liveCatalog = await this.getLiveFanavaranCatalogItems();
|
||||||
|
const byId = new Map<number, OuterPartCatalogItem>(
|
||||||
|
liveCatalog.map((part) => [part.id, part]),
|
||||||
|
);
|
||||||
|
trueItems = dto.selectedPartIds.map((id) => {
|
||||||
|
const item = byId.get(id);
|
||||||
|
if (!item) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid outer part id: ${id}. Use GET outer-parts-catalog to see valid IDs.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
side: item.side || "carDamage",
|
||||||
|
part: item.titleFa,
|
||||||
|
id: item.id,
|
||||||
|
label_fa: item.titleFa,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage!);
|
||||||
|
trueItems = this.findTrueItems(dto.carPartDamage!);
|
||||||
|
}
|
||||||
const claimDoc = (await this.claimDbService.findOne(
|
const claimDoc = (await this.claimDbService.findOne(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
)) as any;
|
)) as any;
|
||||||
@@ -1296,6 +1325,7 @@ export class ClaimRequestManagementService {
|
|||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
expert: any,
|
expert: any,
|
||||||
dto: {
|
dto: {
|
||||||
|
selectedPartIds?: number[];
|
||||||
carPartDamage?: CarDamagePartDto;
|
carPartDamage?: CarDamagePartDto;
|
||||||
sheba?: string;
|
sheba?: string;
|
||||||
nationalCodeOfInsurer?: string;
|
nationalCodeOfInsurer?: string;
|
||||||
@@ -1307,7 +1337,7 @@ export class ClaimRequestManagementService {
|
|||||||
|
|
||||||
let working = claimCase;
|
let working = claimCase;
|
||||||
|
|
||||||
if (dto.carPartDamage) {
|
if (dto.selectedPartIds?.length || dto.carPartDamage) {
|
||||||
if (
|
if (
|
||||||
working.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS
|
working.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS
|
||||||
) {
|
) {
|
||||||
@@ -1321,20 +1351,41 @@ export class ClaimRequestManagementService {
|
|||||||
) {
|
) {
|
||||||
throw new BadRequestException("Car part damage is already set.");
|
throw new BadRequestException("Car part damage is already set.");
|
||||||
}
|
}
|
||||||
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage);
|
let selectedPartObjs: DamageSelectedPartV2[];
|
||||||
const selectedSlugStrings = this.carDamagePartDtoToOuterPartSlugs(
|
if (dto.selectedPartIds?.length) {
|
||||||
dto.carPartDamage,
|
const liveCatalog = await this.getLiveFanavaranCatalogItems();
|
||||||
);
|
const byId = new Map<number, OuterPartCatalogItem>(
|
||||||
if (!selectedSlugStrings.length) {
|
liveCatalog.map((part) => [part.id, part]),
|
||||||
throw new BadRequestException(
|
);
|
||||||
"No outer damaged parts could be derived from carPartDamage. Check part selections.",
|
const selectedItems = dto.selectedPartIds.map((id) => {
|
||||||
|
const item = byId.get(id);
|
||||||
|
if (!item) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid outer part id: ${id}. Use GET outer-parts-catalog to see valid IDs.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
selectedPartObjs = selectedItems.map((part) =>
|
||||||
|
catalogItemToSelectedPart(part, liveCatalog),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Backward compatibility for older expert-panel clients.
|
||||||
|
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage!);
|
||||||
|
const selectedSlugStrings = this.carDamagePartDtoToOuterPartSlugs(
|
||||||
|
dto.carPartDamage!,
|
||||||
|
);
|
||||||
|
if (!selectedSlugStrings.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"No outer damaged parts could be derived from carPartDamage. Check part selections.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
selectedPartObjs = normalizeDamageSelectedParts(
|
||||||
|
selectedSlugStrings,
|
||||||
|
working.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const selectedPartObjs = normalizeDamageSelectedParts(
|
|
||||||
selectedSlugStrings,
|
|
||||||
working.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const damagedPartsStubs = selectedPartObjs.map((p) => ({
|
const damagedPartsStubs = selectedPartObjs.map((p) => ({
|
||||||
id: p.id ?? undefined,
|
id: p.id ?? undefined,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
@@ -1588,7 +1639,9 @@ export class ClaimRequestManagementService {
|
|||||||
private getRequiredDocumentTypes(
|
private getRequiredDocumentTypes(
|
||||||
claimRequest: any,
|
claimRequest: any,
|
||||||
): ClaimRequiredDocumentType[] {
|
): ClaimRequiredDocumentType[] {
|
||||||
const allTypes = Object.values(ClaimRequiredDocumentType);
|
const allTypes = Object.values(ClaimRequiredDocumentType).filter(
|
||||||
|
(type) => type !== ClaimRequiredDocumentType.ACCIDENT_SKETCH,
|
||||||
|
);
|
||||||
|
|
||||||
// Check if it's CAR_BODY type
|
// Check if it's CAR_BODY type
|
||||||
const isCarBody = claimRequest.blameFile?.type === "CAR_BODY";
|
const isCarBody = claimRequest.blameFile?.type === "CAR_BODY";
|
||||||
@@ -4253,6 +4306,7 @@ export class ClaimRequestManagementService {
|
|||||||
/** Blame `Party` row (FIRST party for CAR_BODY); person shape is read via pickPerson* helpers. */
|
/** Blame `Party` row (FIRST party for CAR_BODY); person shape is read via pickPerson* helpers. */
|
||||||
firstParty?: { person?: unknown };
|
firstParty?: { person?: unknown };
|
||||||
logPrefix: string;
|
logPrefix: string;
|
||||||
|
claimCaseId?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const policyId = parseFanavaranId(input.payload.PolicyId);
|
const policyId = parseFanavaranId(input.payload.PolicyId);
|
||||||
if (policyId == null) return;
|
if (policyId == null) return;
|
||||||
@@ -4284,6 +4338,13 @@ export class ClaimRequestManagementService {
|
|||||||
input.payload.ActualPremium = actualPremium;
|
input.payload.ActualPremium = actualPremium;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const vehicleValue = vehicleCurrentValueFromHullPolicyRecord(policyRecord);
|
||||||
|
if (vehicleValue != null && input.claimCaseId) {
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(input.claimCaseId, {
|
||||||
|
$set: { "vehicle.carPrice": vehicleValue },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const person = input.firstParty?.person as
|
const person = input.firstParty?.person as
|
||||||
| {
|
| {
|
||||||
driverIsInsurer?: boolean;
|
driverIsInsurer?: boolean;
|
||||||
@@ -4325,14 +4386,25 @@ export class ClaimRequestManagementService {
|
|||||||
input.payload.AccidentCulpritId = accidentCulpritId;
|
input.payload.AccidentCulpritId = accidentCulpritId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const culpritTypeId =
|
input.payload.CustomerFaultPercent =
|
||||||
parseFanavaranId(input.payload.CulpritTypeId) ??
|
FANAVARAN_DEFAULT_HULL_CUSTOMER_FAULT_PERCENT;
|
||||||
FANAVARAN_DEFAULT_HULL_CULPRIT_TYPE_ID;
|
|
||||||
if (
|
if (parseFanavaranId(input.payload.AuthorityCulpritId) == null) {
|
||||||
input.payload.CustomerFaultPercent == null &&
|
try {
|
||||||
culpritTypeId === 300
|
const authorityRows = await this.getFanavaranLookupRows(
|
||||||
) {
|
input.clientKey,
|
||||||
input.payload.CustomerFaultPercent = 100;
|
"detection-accident-culprits",
|
||||||
|
);
|
||||||
|
input.payload.AuthorityCulpritId =
|
||||||
|
authorityCulpritIdFromDetectionRows(authorityRows);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`${input.logPrefix} detection-accident-culprits lookup failed; using default AuthorityCulpritId=${FANAVARAN_DEFAULT_HULL_AUTHORITY_CULPRIT_ID}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
input.payload.AuthorityCulpritId =
|
||||||
|
FANAVARAN_DEFAULT_HULL_AUTHORITY_CULPRIT_ID;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4410,7 +4482,7 @@ export class ClaimRequestManagementService {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [dmgKindRows, dmgCostKindRows, policyRecord, accessoriesRaw] =
|
const [dmgKindRows, dmgCostKindRows, policyRecord] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.getFanavaranLookupRows(input.clientKey, "vehicle-hull-dmg-kind"),
|
this.getFanavaranLookupRows(input.clientKey, "vehicle-hull-dmg-kind"),
|
||||||
this.getFanavaranLookupRows(
|
this.getFanavaranLookupRows(
|
||||||
@@ -4424,13 +4496,6 @@ export class ClaimRequestManagementService {
|
|||||||
contractOptions,
|
contractOptions,
|
||||||
)
|
)
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
policyId != null
|
|
||||||
? this.fanavaranLookupService.vehicleHullDmgAccessoriesByPolicyId(
|
|
||||||
input.clientKey,
|
|
||||||
policyId,
|
|
||||||
contractOptions,
|
|
||||||
)
|
|
||||||
: Promise.resolve([]),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const dmgKindId =
|
const dmgKindId =
|
||||||
@@ -4442,13 +4507,6 @@ export class ClaimRequestManagementService {
|
|||||||
FANAVARAN_HULL_DMG_COST_KIND_CAPTION,
|
FANAVARAN_HULL_DMG_COST_KIND_CAPTION,
|
||||||
) ?? FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID;
|
) ?? FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID;
|
||||||
|
|
||||||
const accessories = asHullDmgAccessoryRows(accessoriesRaw);
|
|
||||||
if (policyId != null && accessories.length === 0) {
|
|
||||||
input.warnings.push(
|
|
||||||
`No hull dmg-accessories for policy ${policyId}; VehicleHullAccessoryId may be missing.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let vehicle = pickHullVehicleIdentity(input.blame);
|
let vehicle = pickHullVehicleIdentity(input.blame);
|
||||||
let colorId: number | null = null;
|
let colorId: number | null = null;
|
||||||
const policy = asObjectRecord(policyRecord);
|
const policy = asObjectRecord(policyRecord);
|
||||||
@@ -4492,16 +4550,6 @@ export class ClaimRequestManagementService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hullSections = input.parts.map((part: any) => {
|
const hullSections = input.parts.map((part: any) => {
|
||||||
const accessoryKindId = parseCatalogPartIdInput(part?.partId);
|
|
||||||
const vehicleHullAccessoryId = resolveVehicleHullAccessoryId(
|
|
||||||
accessories,
|
|
||||||
accessoryKindId,
|
|
||||||
);
|
|
||||||
if (vehicleHullAccessoryId == null) {
|
|
||||||
input.warnings.push(
|
|
||||||
`No VehicleHullAccessoryId for part "${this.fanavaranPartLabel(part)}" (AccessoryKindId=${accessoryKindId ?? "none"}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return toFanavaranHullExpertiseDmgSection({
|
return toFanavaranHullExpertiseDmgSection({
|
||||||
partId: part?.partId,
|
partId: part?.partId,
|
||||||
desc: this.fanavaranPartLabel(part),
|
desc: this.fanavaranPartLabel(part),
|
||||||
@@ -4511,20 +4559,20 @@ export class ClaimRequestManagementService {
|
|||||||
this.parseFanavaranMoney(part?.salary),
|
this.parseFanavaranMoney(part?.salary),
|
||||||
dmgKindId,
|
dmgKindId,
|
||||||
dmgCostKindId,
|
dmgCostKindId,
|
||||||
vehicleHullAccessoryId,
|
vehicleHullAccessoryId: FANAVARAN_DEFAULT_HULL_VEHICLE_ACCESSORY_ID,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let vehicleCurrentValue =
|
let vehicleCurrentValue =
|
||||||
this.parseFanavaranMoney(input.priceDropCarPrice) || null;
|
this.parseFanavaranMoney(input.claimCase?.vehicle?.carPrice) ||
|
||||||
|
this.parseFanavaranMoney(input.claimCase?.vehicle?.price) ||
|
||||||
|
this.parseFanavaranMoney(input.priceDropCarPrice) ||
|
||||||
|
null;
|
||||||
if (vehicleCurrentValue == null) {
|
if (vehicleCurrentValue == null) {
|
||||||
vehicleCurrentValue = vehicleCurrentValueFromHullPolicyRecord(policy);
|
vehicleCurrentValue = vehicleCurrentValueFromHullPolicyRecord(policy);
|
||||||
}
|
}
|
||||||
|
|
||||||
const repairDuration =
|
const repairDuration = FANAVARAN_DEFAULT_HULL_REPAIR_DURATION;
|
||||||
typeof input.profile.defaults.HullExpertiseRepairDuration === "number"
|
|
||||||
? input.profile.defaults.HullExpertiseRepairDuration
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
replyKey: input.replyKey,
|
replyKey: input.replyKey,
|
||||||
@@ -6126,7 +6174,9 @@ export class ClaimRequestManagementService {
|
|||||||
blameCase,
|
blameCase,
|
||||||
firstParty,
|
firstParty,
|
||||||
logPrefix,
|
logPrefix,
|
||||||
|
claimCaseId,
|
||||||
});
|
});
|
||||||
|
delete payload.IsLicenseReplacement;
|
||||||
const hull = toFanavaranHullBaseClaimPayload(payload);
|
const hull = toFanavaranHullBaseClaimPayload(payload);
|
||||||
for (const key of Object.keys(payload)) delete payload[key];
|
for (const key of Object.keys(payload)) delete payload[key];
|
||||||
Object.assign(payload, hull);
|
Object.assign(payload, hull);
|
||||||
@@ -7363,13 +7413,7 @@ export class ClaimRequestManagementService {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[executeFanavaranExpertiseSubmit] Already have expertiseId=${claimCase.expertiseId}; skipping Fanavaran POST`,
|
`[executeFanavaranExpertiseSubmit] Already have expertiseId=${claimCase.expertiseId}; skipping Fanavaran POST`,
|
||||||
);
|
);
|
||||||
// Catch-up / deduped: SMS may still be pending if expertise predated this notify.
|
// Product decision: completing the final Fanavaran stage must not send SMS.
|
||||||
await this.notifyClaimOwnerFanavaranClaimRegistered({
|
|
||||||
claimCaseId,
|
|
||||||
claimId: claimCase.claimId,
|
|
||||||
claimNo: claimCase.claimNo,
|
|
||||||
logPrefix: `[Fanavaran ${clientKey} V2 Expertise]`,
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
(claimCase as any)?.fanavaranSync?.expertise?.response ?? {
|
(claimCase as any)?.fanavaranSync?.expertise?.response ?? {
|
||||||
Id: claimCase.expertiseId,
|
Id: claimCase.expertiseId,
|
||||||
@@ -7445,13 +7489,7 @@ export class ClaimRequestManagementService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Best-effort: SMS after last Fanavaran stage (expertise) succeeds
|
// Product decision: completing the final Fanavaran stage must not send SMS.
|
||||||
await this.notifyClaimOwnerFanavaranClaimRegistered({
|
|
||||||
claimCaseId,
|
|
||||||
claimId: claimCase.claimId,
|
|
||||||
claimNo: claimCase.claimNo,
|
|
||||||
logPrefix: `[Fanavaran ${clientKey} V2 Expertise]`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -7489,12 +7527,7 @@ export class ClaimRequestManagementService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (claimCase.expertiseId != null) {
|
if (claimCase.expertiseId != null) {
|
||||||
await this.notifyClaimOwnerFanavaranClaimRegistered({
|
// Product decision: an already-completed Fanavaran stage must not send SMS.
|
||||||
claimCaseId,
|
|
||||||
claimId: claimCase.claimId,
|
|
||||||
claimNo: claimCase.claimNo,
|
|
||||||
logPrefix,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
attempted: false,
|
attempted: false,
|
||||||
submitted: false,
|
submitted: false,
|
||||||
@@ -7840,7 +7873,18 @@ export class ClaimRequestManagementService {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Fanavaran hull sync] PUT ${url} keys=${Object.keys(hull).join(",")}`,
|
`[Fanavaran hull sync] PUT ${url} keys=${Object.keys(hull).join(",")}`,
|
||||||
);
|
);
|
||||||
await this.putFanavaranJson(url, hull, clientKey, undefined, claimCaseId);
|
try {
|
||||||
|
await this.putFanavaranJson(url, hull, clientKey, undefined, claimCaseId);
|
||||||
|
} catch (error) {
|
||||||
|
// Parsian bonyan often has no PUT route on vehicle-hull-claims/{Id}
|
||||||
|
// ("No HTTP resource was found.../vehicle-hull-claims/{Id}"). Do not
|
||||||
|
// block GEN.05 files / GEN.06 expertise on that.
|
||||||
|
this.logger.warn(
|
||||||
|
`[Fanavaran hull sync] PUT ${url} failed; continuing with nested stages: ${
|
||||||
|
error instanceof Error ? error.message : error
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async postFanavaranMultipart(
|
private async postFanavaranMultipart(
|
||||||
@@ -8663,111 +8707,6 @@ export class ClaimRequestManagementService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Phone for the claim owner / damaged party (same recipient as other claim SMS).
|
|
||||||
*/
|
|
||||||
private async resolveFanavaranClaimOwnerPhone(
|
|
||||||
claimCase: any,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
const notifyUserId =
|
|
||||||
claimCase?.damagedPartyUserId ?? claimCase?.owner?.userId;
|
|
||||||
if (!notifyUserId) return undefined;
|
|
||||||
const ownerUserId = String(notifyUserId);
|
|
||||||
|
|
||||||
if (claimCase.blameRequestId) {
|
|
||||||
const blame = await this.blameRequestDbService.findById(
|
|
||||||
String(claimCase.blameRequestId),
|
|
||||||
);
|
|
||||||
const ownerParty = (blame?.parties || []).find(
|
|
||||||
(p: any) =>
|
|
||||||
p?.person?.userId && String(p.person.userId) === ownerUserId,
|
|
||||||
);
|
|
||||||
const fromParty = ownerParty?.person?.phoneNumber;
|
|
||||||
if (typeof fromParty === "string" && fromParty.trim()) {
|
|
||||||
return fromParty.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.userDbService.findOne({
|
|
||||||
_id: new Types.ObjectId(ownerUserId),
|
|
||||||
});
|
|
||||||
const mobile = user?.mobile;
|
|
||||||
if (typeof mobile === "string" && mobile.trim()) return mobile.trim();
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* After Fanavaran expertise (last stage) succeeds, SMS the claim owner with
|
|
||||||
* publicId + claimId + ClaimNo. Uses SmsOrchestrationService → same provider
|
|
||||||
* as login for this deployment (`SMS` / `SMS_PROVIDER`). Never throws.
|
|
||||||
*/
|
|
||||||
private async notifyClaimOwnerFanavaranClaimRegistered(input: {
|
|
||||||
claimCaseId: string;
|
|
||||||
claimId?: number | string | null;
|
|
||||||
claimNo?: number | string | null;
|
|
||||||
logPrefix: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (input.claimId == null && input.claimNo == null) {
|
|
||||||
this.logger.warn(
|
|
||||||
`${input.logPrefix} Skip Fanavaran SMS: no claimId/ClaimNo on response`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const claimCase = await this.claimCaseDbService.findById(
|
|
||||||
input.claimCaseId,
|
|
||||||
);
|
|
||||||
if (!claimCase) return;
|
|
||||||
|
|
||||||
const sync = (claimCase as any)?.fanavaranSync;
|
|
||||||
// Dedup: expertise stage, or older base-claim notify from before the move.
|
|
||||||
if (sync?.expertise?.smsNotifiedAt || sync?.baseClaim?.smsNotifiedAt) {
|
|
||||||
this.logger.log(
|
|
||||||
`${input.logPrefix} Fanavaran SMS already sent; skipping duplicate`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const phone = await this.resolveFanavaranClaimOwnerPhone(claimCase);
|
|
||||||
if (!phone) {
|
|
||||||
this.logger.warn(
|
|
||||||
`${input.logPrefix} Skip Fanavaran SMS: no phone for claim owner`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicId =
|
|
||||||
typeof claimCase.publicId === "string" && claimCase.publicId.trim()
|
|
||||||
? claimCase.publicId.trim()
|
|
||||||
: String(input.claimCaseId);
|
|
||||||
|
|
||||||
const sent =
|
|
||||||
await this.smsOrchestrationService.sendFanavaranClaimRegisteredNotice({
|
|
||||||
receptor: phone,
|
|
||||||
publicId,
|
|
||||||
claimNo: input.claimNo ?? claimCase.claimNo,
|
|
||||||
claimId: input.claimId ?? claimCase.claimId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sent) {
|
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(input.claimCaseId, {
|
|
||||||
$set: {
|
|
||||||
"fanavaranSync.expertise.smsNotifiedAt": new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.logger.log(
|
|
||||||
`${input.logPrefix} Fanavaran claim SMS sent to claim owner phone=${phone} publicId=${publicId} claimNo=${input.claimNo ?? claimCase.claimNo ?? "-"} claimId=${input.claimId ?? claimCase.claimId ?? "-"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`${input.logPrefix} Fanavaran claim SMS failed (non-fatal)`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async executeFanavaranV2Submit(
|
private async executeFanavaranV2Submit(
|
||||||
claimCaseId: string,
|
claimCaseId: string,
|
||||||
clientKey: FanavaranClientKey,
|
clientKey: FanavaranClientKey,
|
||||||
@@ -9410,6 +9349,9 @@ export class ClaimRequestManagementService {
|
|||||||
blameRequestId: new Types.ObjectId(blameRequestId),
|
blameRequestId: new Types.ObjectId(blameRequestId),
|
||||||
blameRequestNo: blameRequest.requestNo,
|
blameRequestNo: blameRequest.requestNo,
|
||||||
initiatedByFieldExpertId: new Types.ObjectId(expert.sub),
|
initiatedByFieldExpertId: new Types.ObjectId(expert.sub),
|
||||||
|
...((blameRequest as any).branchId
|
||||||
|
? { branchId: new Types.ObjectId(String((blameRequest as any).branchId)) }
|
||||||
|
: {}),
|
||||||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||||||
claimStatus: ClaimStatus.PENDING,
|
claimStatus: ClaimStatus.PENDING,
|
||||||
inquiries: (blameRequest as any).inquiries ?? {},
|
inquiries: (blameRequest as any).inquiries ?? {},
|
||||||
@@ -10392,7 +10334,8 @@ export class ClaimRequestManagementService {
|
|||||||
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
|
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required documents: CAR_BODY needs 7 (damaged + green card); THIRD_PARTY needs 13 (add guilty_*)
|
// Required documents vary by claim type. The accident sketch is offered
|
||||||
|
// in every flow, but is excluded from every completion calculation.
|
||||||
const blameRequest = claimCase.blameRequestId
|
const blameRequest = claimCase.blameRequestId
|
||||||
? await this.blameRequestDbService.findById(
|
? await this.blameRequestDbService.findById(
|
||||||
claimCase.blameRequestId.toString(),
|
claimCase.blameRequestId.toString(),
|
||||||
@@ -10400,53 +10343,68 @@ export class ClaimRequestManagementService {
|
|||||||
: null;
|
: null;
|
||||||
const isCarBody = blameRequest?.type === BlameRequestType.CAR_BODY;
|
const isCarBody = blameRequest?.type === BlameRequestType.CAR_BODY;
|
||||||
const requiredDocsDefinition = [
|
const requiredDocsDefinition = [
|
||||||
|
{
|
||||||
|
key: ClaimRequiredDocumentType.ACCIDENT_SKETCH,
|
||||||
|
label_fa: "کروکی",
|
||||||
|
label_en: "Accident Sketch",
|
||||||
|
category: "general",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "damaged_driving_license_front",
|
key: "damaged_driving_license_front",
|
||||||
label_fa: "گواهینامه طرف آسیبدیده - جلو",
|
label_fa: "گواهینامه طرف آسیبدیده - جلو",
|
||||||
label_en: "Damaged Party License - Front",
|
label_en: "Damaged Party License - Front",
|
||||||
category: "damaged_party",
|
category: "damaged_party",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "damaged_driving_license_back",
|
key: "damaged_driving_license_back",
|
||||||
label_fa: "گواهینامه طرف آسیبدیده - پشت",
|
label_fa: "گواهینامه طرف آسیبدیده - پشت",
|
||||||
label_en: "Damaged Party License - Back",
|
label_en: "Damaged Party License - Back",
|
||||||
category: "damaged_party",
|
category: "damaged_party",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "damaged_chassis_number",
|
key: "damaged_chassis_number",
|
||||||
label_fa: "شماره شاسی",
|
label_fa: "شماره شاسی",
|
||||||
label_en: "Chassis Number",
|
label_en: "Chassis Number",
|
||||||
category: "damaged_party",
|
category: "damaged_party",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "damaged_engine_photo",
|
key: "damaged_engine_photo",
|
||||||
label_fa: "عکس موتور",
|
label_fa: "عکس موتور",
|
||||||
label_en: "Engine Photo",
|
label_en: "Engine Photo",
|
||||||
category: "damaged_party",
|
category: "damaged_party",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "damaged_car_card_front",
|
key: "damaged_car_card_front",
|
||||||
label_fa: "کارت خودرو آسیبدیده - جلو",
|
label_fa: "کارت خودرو آسیبدیده - جلو",
|
||||||
label_en: "Damaged Car Card - Front",
|
label_en: "Damaged Car Card - Front",
|
||||||
category: "damaged_party",
|
category: "damaged_party",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "damaged_car_card_back",
|
key: "damaged_car_card_back",
|
||||||
label_fa: "کارت خودرو آسیبدیده - پشت",
|
label_fa: "کارت خودرو آسیبدیده - پشت",
|
||||||
label_en: "Damaged Car Card - Back",
|
label_en: "Damaged Car Card - Back",
|
||||||
category: "damaged_party",
|
category: "damaged_party",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "damaged_metal_plate",
|
key: "damaged_metal_plate",
|
||||||
label_fa: "پلاک فلزی آسیبدیده",
|
label_fa: "پلاک فلزی آسیبدیده",
|
||||||
label_en: "Damaged Metal Plate",
|
label_en: "Damaged Metal Plate",
|
||||||
category: "damaged_party",
|
category: "damaged_party",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "car_green_card",
|
key: "car_green_card",
|
||||||
label_fa: "کارت سبز خودرو",
|
label_fa: "کارت سبز خودرو",
|
||||||
label_en: "Car Green Card",
|
label_en: "Car Green Card",
|
||||||
category: "damaged_party",
|
category: "damaged_party",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
...(isCarBody
|
...(isCarBody
|
||||||
? []
|
? []
|
||||||
@@ -10456,30 +10414,35 @@ export class ClaimRequestManagementService {
|
|||||||
label_fa: "گواهینامه طرف مقصر - جلو",
|
label_fa: "گواهینامه طرف مقصر - جلو",
|
||||||
label_en: "Guilty Party License - Front",
|
label_en: "Guilty Party License - Front",
|
||||||
category: "guilty_party",
|
category: "guilty_party",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "guilty_driving_license_back",
|
key: "guilty_driving_license_back",
|
||||||
label_fa: "گواهینامه طرف مقصر - پشت",
|
label_fa: "گواهینامه طرف مقصر - پشت",
|
||||||
label_en: "Guilty Party License - Back",
|
label_en: "Guilty Party License - Back",
|
||||||
category: "guilty_party",
|
category: "guilty_party",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "guilty_car_card_front",
|
key: "guilty_car_card_front",
|
||||||
label_fa: "کارت خودرو مقصر - جلو",
|
label_fa: "کارت خودرو مقصر - جلو",
|
||||||
label_en: "Guilty Car Card - Front",
|
label_en: "Guilty Car Card - Front",
|
||||||
category: "guilty_party",
|
category: "guilty_party",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "guilty_car_card_back",
|
key: "guilty_car_card_back",
|
||||||
label_fa: "کارت خودرو مقصر - پشت",
|
label_fa: "کارت خودرو مقصر - پشت",
|
||||||
label_en: "Guilty Car Card - Back",
|
label_en: "Guilty Car Card - Back",
|
||||||
category: "guilty_party",
|
category: "guilty_party",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "guilty_metal_plate",
|
key: "guilty_metal_plate",
|
||||||
label_fa: "پلاک فلزی مقصر",
|
label_fa: "پلاک فلزی مقصر",
|
||||||
label_en: "Guilty Metal Plate",
|
label_en: "Guilty Metal Plate",
|
||||||
category: "guilty_party",
|
category: "guilty_party",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
@@ -10491,6 +10454,7 @@ export class ClaimRequestManagementService {
|
|||||||
label_fa: doc.label_fa,
|
label_fa: doc.label_fa,
|
||||||
label_en: doc.label_en,
|
label_en: doc.label_en,
|
||||||
category: doc.category,
|
category: doc.category,
|
||||||
|
required: doc.required,
|
||||||
uploaded: docData?.uploaded || false,
|
uploaded: docData?.uploaded || false,
|
||||||
preferUploadDuringCapture: isCapturePhaseDamagedPartyDocKey(doc.key),
|
preferUploadDuringCapture: isCapturePhaseDamagedPartyDocKey(doc.key),
|
||||||
};
|
};
|
||||||
@@ -10553,7 +10517,10 @@ export class ClaimRequestManagementService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Calculate progress
|
// Calculate progress
|
||||||
const documentsUploaded = requiredDocuments.filter(
|
const requiredDocumentItems = requiredDocuments.filter(
|
||||||
|
(d) => d.required,
|
||||||
|
);
|
||||||
|
const documentsUploaded = requiredDocumentItems.filter(
|
||||||
(d) => d.uploaded,
|
(d) => d.uploaded,
|
||||||
).length;
|
).length;
|
||||||
const anglesCaptured = carAngles.filter((a) => a.captured).length;
|
const anglesCaptured = carAngles.filter((a) => a.captured).length;
|
||||||
@@ -10571,7 +10538,7 @@ export class ClaimRequestManagementService {
|
|||||||
captureProgress.anglesTotal - captureProgress.anglesCaptured,
|
captureProgress.anglesTotal - captureProgress.anglesCaptured,
|
||||||
);
|
);
|
||||||
const postCaptureDocsRemaining = requiredDocuments.filter(
|
const postCaptureDocsRemaining = requiredDocuments.filter(
|
||||||
(d) => !d.preferUploadDuringCapture && !d.uploaded,
|
(d) => d.required && !d.preferUploadDuringCapture && !d.uploaded,
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const totalRemaining =
|
const totalRemaining =
|
||||||
@@ -10591,7 +10558,7 @@ export class ClaimRequestManagementService {
|
|||||||
totalRemaining,
|
totalRemaining,
|
||||||
progress: {
|
progress: {
|
||||||
documentsUploaded,
|
documentsUploaded,
|
||||||
documentsTotal: requiredDocuments.length,
|
documentsTotal: requiredDocumentItems.length,
|
||||||
anglesCaptured,
|
anglesCaptured,
|
||||||
anglesTotal: carAngles.length,
|
anglesTotal: carAngles.length,
|
||||||
partsCaptured,
|
partsCaptured,
|
||||||
@@ -10754,6 +10721,8 @@ export class ClaimRequestManagementService {
|
|||||||
|
|
||||||
const step = claimCase.workflow?.currentStep;
|
const step = claimCase.workflow?.currentStep;
|
||||||
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
||||||
|
const isOptionalDocument =
|
||||||
|
body.documentKey === ClaimRequiredDocumentType.ACCIDENT_SKETCH;
|
||||||
const isCapturePhaseDocUpload =
|
const isCapturePhaseDocUpload =
|
||||||
step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES &&
|
step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES &&
|
||||||
(isCapturePhaseDamagedPartyDocKey(body.documentKey) ||
|
(isCapturePhaseDamagedPartyDocKey(body.documentKey) ||
|
||||||
@@ -10784,7 +10753,9 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
const allowedInitialUploadStep =
|
const allowedInitialUploadStep =
|
||||||
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS ||
|
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS ||
|
||||||
isCapturePhaseDocUpload;
|
isCapturePhaseDocUpload ||
|
||||||
|
(isOptionalDocument &&
|
||||||
|
step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE);
|
||||||
if (!allowedInitialUploadStep) {
|
if (!allowedInitialUploadStep) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} (capture-phase documents only), or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`,
|
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} (capture-phase documents only), or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`,
|
||||||
@@ -10903,7 +10874,7 @@ export class ClaimRequestManagementService {
|
|||||||
let remaining = 0;
|
let remaining = 0;
|
||||||
let captureStepCompletedOnThisUpload = false;
|
let captureStepCompletedOnThisUpload = false;
|
||||||
|
|
||||||
if (!isResendUpload) {
|
if (!isResendUpload && !isOptionalDocument) {
|
||||||
if (isCapturePhaseDocUpload) {
|
if (isCapturePhaseDocUpload) {
|
||||||
const afterThis = (k: string) =>
|
const afterThis = (k: string) =>
|
||||||
k === body.documentKey ||
|
k === body.documentKey ||
|
||||||
@@ -11041,6 +11012,28 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isResendUpload && isOptionalDocument) {
|
||||||
|
allDocumentsUploaded = options?.v3InPersonFlow
|
||||||
|
? this.allV3PreCaptureDocumentsComplete(
|
||||||
|
claimCase,
|
||||||
|
isCarBodyUpload,
|
||||||
|
undefined,
|
||||||
|
options?.skipMetalPlate,
|
||||||
|
)
|
||||||
|
: this.allV2OwnerDocumentsComplete(claimCase, isCarBodyUpload);
|
||||||
|
remaining = options?.v3InPersonFlow
|
||||||
|
? this.countRemainingV3PreCaptureDocuments(
|
||||||
|
claimCase,
|
||||||
|
isCarBodyUpload,
|
||||||
|
undefined,
|
||||||
|
options?.skipMetalPlate,
|
||||||
|
)
|
||||||
|
: this.countRemainingV2OwnerDocuments(
|
||||||
|
claimCase,
|
||||||
|
isCarBodyUpload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Build the final MongoDB update — all operators explicit and clean
|
// Build the final MongoDB update — all operators explicit and clean
|
||||||
const updatePayload: Record<string, unknown> = { $set };
|
const updatePayload: Record<string, unknown> = { $set };
|
||||||
|
|
||||||
@@ -11145,7 +11138,12 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-submit Fanavaran attachments when all documents are uploaded
|
// Auto-submit Fanavaran attachments when all documents are uploaded
|
||||||
if (allDocumentsUploaded && !isResendUpload && !options?.v3InPersonFlow) {
|
if (
|
||||||
|
allDocumentsUploaded &&
|
||||||
|
!isOptionalDocument &&
|
||||||
|
!isResendUpload &&
|
||||||
|
!options?.v3InPersonFlow
|
||||||
|
) {
|
||||||
this.submitFanavaranAttachmentsV2(claimRequestId, resolveFanavaranClientKey()).catch(
|
this.submitFanavaranAttachmentsV2(claimRequestId, resolveFanavaranClientKey()).catch(
|
||||||
(err) => this.logger.warn(`Fanavaran attachments auto-submit failed: ${err?.message}`),
|
(err) => this.logger.warn(`Fanavaran attachments auto-submit failed: ${err?.message}`),
|
||||||
);
|
);
|
||||||
@@ -11185,7 +11183,9 @@ export class ClaimRequestManagementService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = isCapturePhaseDocUpload
|
const message = isOptionalDocument
|
||||||
|
? "Optional accident sketch uploaded successfully."
|
||||||
|
: isCapturePhaseDocUpload
|
||||||
? captureStepCompletedOnThisUpload
|
? captureStepCompletedOnThisUpload
|
||||||
? options?.v3InPersonFlow
|
? options?.v3InPersonFlow
|
||||||
? "Capture-phase documents and all damage captures are complete. Upload the walk-around video (car-capture) to finalise."
|
? "Capture-phase documents and all damage captures are complete. Upload the walk-around video (car-capture) to finalise."
|
||||||
@@ -11208,7 +11208,9 @@ export class ClaimRequestManagementService {
|
|||||||
documentKey: body.documentKey,
|
documentKey: body.documentKey,
|
||||||
fileUrl,
|
fileUrl,
|
||||||
allDocumentsUploaded,
|
allDocumentsUploaded,
|
||||||
currentStep: isCapturePhaseDocUpload
|
currentStep: isOptionalDocument
|
||||||
|
? step || ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||||||
|
: isCapturePhaseDocUpload
|
||||||
? captureStepCompletedOnThisUpload
|
? captureStepCompletedOnThisUpload
|
||||||
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||||||
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES
|
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES
|
||||||
@@ -12421,7 +12423,7 @@ export class ClaimRequestManagementService {
|
|||||||
blameIdsForList.length > 0
|
blameIdsForList.length > 0
|
||||||
? ((await this.blameRequestDbService.find(
|
? ((await this.blameRequestDbService.find(
|
||||||
{ _id: { $in: blameIdsForList.map((id) => new Types.ObjectId(id)) } },
|
{ _id: { $in: blameIdsForList.map((id) => new Types.ObjectId(id)) } },
|
||||||
{ lean: true, select: "type creationMethod" },
|
{ lean: true, select: "type creationMethod status" },
|
||||||
)) as any[])
|
)) as any[])
|
||||||
: [];
|
: [];
|
||||||
const blameByIdForList = new Map<string, any>(
|
const blameByIdForList = new Map<string, any>(
|
||||||
@@ -12443,16 +12445,37 @@ export class ClaimRequestManagementService {
|
|||||||
blameRequestId: c.blameRequestId?.toString(),
|
blameRequestId: c.blameRequestId?.toString(),
|
||||||
blameType: blameForItem?.type ?? undefined,
|
blameType: blameForItem?.type ?? undefined,
|
||||||
creationMethod: blameForItem?.creationMethod ?? undefined,
|
creationMethod: blameForItem?.creationMethod ?? undefined,
|
||||||
|
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||||
|
blameStatus: blameForItem?.status,
|
||||||
|
claimStatus: c.status,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}) as ClaimListItemV2Dto[];
|
}) as ClaimListItemV2Dto[];
|
||||||
|
|
||||||
|
let filtered = list;
|
||||||
|
if (query.unifiedStatus) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(row) => row.unifiedFileStatus === query.unifiedStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { fromDate, toDate } = parseListDateRange(
|
||||||
|
query.startDate,
|
||||||
|
query.endDate,
|
||||||
|
);
|
||||||
|
if (fromDate || toDate) {
|
||||||
|
filtered = filtered.filter((row) =>
|
||||||
|
isInListDateRange(row.createdAt, fromDate, toDate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const paged = applyListQueryV2(
|
const paged = applyListQueryV2(
|
||||||
list,
|
filtered,
|
||||||
{
|
{
|
||||||
publicId: (r) => r.publicId,
|
publicId: (r) => r.publicId,
|
||||||
createdAt: (r) => r.createdAt,
|
createdAt: (r) => r.createdAt,
|
||||||
requestNo: (r) => r.requestNo,
|
requestNo: (r) => r.requestNo,
|
||||||
status: (r) => r.status,
|
status: (r) => r.unifiedFileStatus ?? r.status,
|
||||||
|
fileType: (r) => r.blameType,
|
||||||
searchExtras: (r) =>
|
searchExtras: (r) =>
|
||||||
[
|
[
|
||||||
r.claimRequestId,
|
r.claimRequestId,
|
||||||
@@ -12588,6 +12611,10 @@ export class ClaimRequestManagementService {
|
|||||||
catalogLikeKeyFromPart,
|
catalogLikeKeyFromPart,
|
||||||
buildFileLink,
|
buildFileLink,
|
||||||
});
|
});
|
||||||
|
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
|
||||||
|
history: (claim.damage as any)?.partSelectionHistory,
|
||||||
|
currentSelectedParts: selectedNormDetails,
|
||||||
|
});
|
||||||
|
|
||||||
const er = claim.evaluation?.damageExpertResend;
|
const er = claim.evaluation?.damageExpertResend;
|
||||||
const expertResend =
|
const expertResend =
|
||||||
@@ -12671,6 +12698,7 @@ export class ClaimRequestManagementService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
carAngles,
|
carAngles,
|
||||||
damagedParts,
|
damagedParts,
|
||||||
|
damagedPartsHistory,
|
||||||
expertResend,
|
expertResend,
|
||||||
fanavaran: fanavaranClaimReferences(claim),
|
fanavaran: fanavaranClaimReferences(claim),
|
||||||
evaluation: mappedEvaluation
|
evaluation: mappedEvaluation
|
||||||
@@ -12939,7 +12967,10 @@ export class ClaimRequestManagementService {
|
|||||||
const preCaptureDocs = base.requiredDocuments.filter(
|
const preCaptureDocs = base.requiredDocuments.filter(
|
||||||
(d) => !d.preferUploadDuringCapture,
|
(d) => !d.preferUploadDuringCapture,
|
||||||
);
|
);
|
||||||
const remaining = preCaptureDocs.filter((d) => !d.uploaded).length;
|
const requiredPreCaptureDocs = preCaptureDocs.filter((d) => d.required);
|
||||||
|
const remaining = requiredPreCaptureDocs.filter(
|
||||||
|
(d) => !d.uploaded,
|
||||||
|
).length;
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
requiredDocuments: preCaptureDocs,
|
requiredDocuments: preCaptureDocs,
|
||||||
@@ -12951,8 +12982,9 @@ export class ClaimRequestManagementService {
|
|||||||
totalRemaining: remaining,
|
totalRemaining: remaining,
|
||||||
progress: {
|
progress: {
|
||||||
...base.progress,
|
...base.progress,
|
||||||
documentsTotal: preCaptureDocs.length,
|
documentsTotal: requiredPreCaptureDocs.length,
|
||||||
documentsUploaded: preCaptureDocs.filter((d) => d.uploaded).length,
|
documentsUploaded: requiredPreCaptureDocs.filter((d) => d.uploaded)
|
||||||
|
.length,
|
||||||
partsCaptured: 0,
|
partsCaptured: 0,
|
||||||
partsTotal: 0,
|
partsTotal: 0,
|
||||||
anglesCaptured: 0,
|
anglesCaptured: 0,
|
||||||
@@ -13007,6 +13039,7 @@ export class ClaimRequestManagementService {
|
|||||||
label_fa: "تصویر نقطه آسیبدیده مقصر",
|
label_fa: "تصویر نقطه آسیبدیده مقصر",
|
||||||
label_en: "Guilty Car Damaged Area",
|
label_en: "Guilty Car Damaged Area",
|
||||||
category: "guilty_party",
|
category: "guilty_party",
|
||||||
|
required: true,
|
||||||
uploaded: guiltyAreaUploaded,
|
uploaded: guiltyAreaUploaded,
|
||||||
preferUploadDuringCapture: true,
|
preferUploadDuringCapture: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get My Claims (V2)",
|
summary: "Get My Claims (V2)",
|
||||||
description:
|
description:
|
||||||
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
|
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType`, `startDate`, `endDate`.",
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -801,6 +801,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
|
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
|
||||||
|
|
||||||
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
|
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
|
||||||
|
- accident_sketch (optional; never blocks workflow completion)
|
||||||
- damaged_driving_license_front/back
|
- damaged_driving_license_front/back
|
||||||
- damaged_chassis_number, damaged_engine_photo
|
- damaged_chassis_number, damaged_engine_photo
|
||||||
- damaged_car_card_front/back, damaged_metal_plate
|
- damaged_car_card_front/back, damaged_metal_plate
|
||||||
@@ -836,6 +837,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
"guilty_car_card_front",
|
"guilty_car_card_front",
|
||||||
"guilty_car_card_back",
|
"guilty_car_card_back",
|
||||||
"guilty_metal_plate",
|
"guilty_metal_plate",
|
||||||
|
"accident_sketch",
|
||||||
],
|
],
|
||||||
example: "damaged_driving_license_front",
|
example: "damaged_driving_license_front",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ export class RequiredDocumentItem {
|
|||||||
example: true,
|
example: true,
|
||||||
})
|
})
|
||||||
preferUploadDuringCapture?: boolean;
|
preferUploadDuringCapture?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'Whether this document is mandatory for workflow completion. Optional documents are uploadable but never included in remaining/progress counts.',
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
required: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -204,6 +204,33 @@ export class ClaimDetailsV2ResponseDto {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Append-only expert damaged-part revisions. Removed rows retain their original capture URL.',
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
revisionId: { type: 'string' },
|
||||||
|
changedAt: { type: 'string', format: 'date-time' },
|
||||||
|
changedBy: { type: 'object' },
|
||||||
|
removedParts: { type: 'array', items: { type: 'object' } },
|
||||||
|
addedParts: { type: 'array', items: { type: 'object' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
damagedPartsHistory?: Array<{
|
||||||
|
revisionId: string;
|
||||||
|
changedAt: Date | string;
|
||||||
|
changedBy: {
|
||||||
|
actorId: string;
|
||||||
|
actorName?: string;
|
||||||
|
actorType: string;
|
||||||
|
};
|
||||||
|
removedParts: Array<Record<string, unknown>>;
|
||||||
|
addedParts: Array<Record<string, unknown>>;
|
||||||
|
}>;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
|
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export class ClaimListItemV2Dto {
|
|||||||
example: 'IN_PERSON',
|
example: 'IN_PERSON',
|
||||||
})
|
})
|
||||||
creationMethod?: string;
|
creationMethod?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Calculated combined blame and claim lifecycle status',
|
||||||
|
example: 'WAITING_FOR_DAMAGE_EXPERT',
|
||||||
|
})
|
||||||
|
unifiedFileStatus?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetMyClaimsV2ResponseDto {
|
export class GetMyClaimsV2ResponseDto {
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ export class ClaimDamageSelection {
|
|||||||
@Prop({ type: [String], default: [] })
|
@Prop({ type: [String], default: [] })
|
||||||
otherParts?: string[];
|
otherParts?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only expert edit revisions. Each revision preserves the prior
|
||||||
|
* selection and capture metadata for removed parts before the live arrays
|
||||||
|
* are replaced. Mixed keeps legacy/free-text part shapes readable.
|
||||||
|
*/
|
||||||
|
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||||
|
partSelectionHistory?: unknown[];
|
||||||
|
|
||||||
|
/** Parts introduced by an expert across revisions (used for origin labels). */
|
||||||
|
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||||
|
expertAddedParts?: unknown[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy fields - kept for backward compatibility
|
* Legacy fields - kept for backward compatibility
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export class ClaimVehicleSnapshot {
|
|||||||
|
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
price?: string;
|
price?: string;
|
||||||
|
|
||||||
|
/** Vehicle value in Rial from hull policy VehicleValue or expert assessment. */
|
||||||
|
@Prop({ type: Number })
|
||||||
|
carPrice?: number;
|
||||||
}
|
}
|
||||||
export const ClaimVehicleSnapshotSchema =
|
export const ClaimVehicleSnapshotSchema =
|
||||||
SchemaFactory.createForClass(ClaimVehicleSnapshot);
|
SchemaFactory.createForClass(ClaimVehicleSnapshot);
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export class FanavaranSyncStage {
|
|||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
lastPayloadBuiltAt?: Date;
|
lastPayloadBuiltAt?: Date;
|
||||||
|
|
||||||
/** When we SMS'd the claim owner about Fanavaran claimId/ClaimNo (after expertise). */
|
/** Legacy marker from the retired post-expertise Fanavaran SMS flow. */
|
||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
smsNotifiedAt?: Date;
|
smsNotifiedAt?: Date;
|
||||||
|
|
||||||
@@ -239,6 +239,10 @@ export class ClaimCase {
|
|||||||
@Prop({ type: Types.ObjectId, index: true })
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
initiatedByFieldExpertId?: Types.ObjectId;
|
initiatedByFieldExpertId?: Types.ObjectId;
|
||||||
|
|
||||||
|
/** Branch snapshot inherited from the originating expert-created blame. */
|
||||||
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
|
branchId?: Types.ObjectId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The damaged party's userId, resolved from the blame at claim-creation time.
|
* The damaged party's userId, resolved from the blame at claim-creation time.
|
||||||
* Stored here so view/list access does not require an extra blame lookup.
|
* Stored here so view/list access does not require an extra blame lookup.
|
||||||
|
|||||||
@@ -416,6 +416,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
"guilty_car_card_front",
|
"guilty_car_card_front",
|
||||||
"guilty_car_card_back",
|
"guilty_car_card_back",
|
||||||
"guilty_metal_plate",
|
"guilty_metal_plate",
|
||||||
|
"accident_sketch",
|
||||||
],
|
],
|
||||||
example: "damaged_driving_license_front",
|
example: "damaged_driving_license_front",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ describe("fanavaran hull base claim", () => {
|
|||||||
expect(fanavaranHullNestedClaimId({ ClaimNo: 2268 })).toBeNull();
|
expect(fanavaranHullNestedClaimId({ ClaimNo: 2268 })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps IsLicenseReplaced null even when ثالث IsLicenseReplacement is 0", () => {
|
||||||
|
const hull = toFanavaranHullBaseClaimPayload({
|
||||||
|
PolicyId: 1,
|
||||||
|
IsLicenseReplacement: 0,
|
||||||
|
});
|
||||||
|
expect(hull.IsLicenseReplaced).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("strips ثالث-only fields from a live hull create echo", () => {
|
it("strips ثالث-only fields from a live hull create echo", () => {
|
||||||
const hull = toFanavaranHullBaseClaimPayload({
|
const hull = toFanavaranHullBaseClaimPayload({
|
||||||
AccidentCauseId: 6,
|
AccidentCauseId: 6,
|
||||||
@@ -68,7 +76,7 @@ describe("fanavaran hull base claim", () => {
|
|||||||
expect(hull.IsLicenseReplaced).toBeNull();
|
expect(hull.IsLicenseReplaced).toBeNull();
|
||||||
expect(hull.CostSeparationToDmgSections).toBe(0);
|
expect(hull.CostSeparationToDmgSections).toBe(0);
|
||||||
expect(hull.IsOwnerChanged).toBe(0);
|
expect(hull.IsOwnerChanged).toBe(0);
|
||||||
expect(hull.CulpritTypeId).toBe(301);
|
expect(hull.CulpritTypeId).toBe(300);
|
||||||
expect(hull.DamagedCount).toBeUndefined();
|
expect(hull.DamagedCount).toBeUndefined();
|
||||||
expect(hull.HasOtherCulprit).toBeUndefined();
|
expect(hull.HasOtherCulprit).toBeUndefined();
|
||||||
expect(hull.CompensationReferenceId).toBeUndefined();
|
expect(hull.CompensationReferenceId).toBeUndefined();
|
||||||
|
|||||||
@@ -51,7 +51,17 @@ export const FANAVARAN_DEFAULT_HULL_ACCIDENT_TYPE_ID = 2;
|
|||||||
export const FANAVARAN_HULL_ANS_NO = 0;
|
export const FANAVARAN_HULL_ANS_NO = 0;
|
||||||
|
|
||||||
/** GEN.03 hull culprit type when third-party is at fault. Lookup: vehicle-hull-accident-culprit-type */
|
/** GEN.03 hull culprit type when third-party is at fault. Lookup: vehicle-hull-accident-culprit-type */
|
||||||
export const FANAVARAN_DEFAULT_HULL_CULPRIT_TYPE_ID = 301;
|
export const FANAVARAN_DEFAULT_HULL_CULPRIT_TYPE_ID = 300;
|
||||||
|
|
||||||
|
/** GEN.03 required: درصد تقصير بيمه گذار. Proven Parsian submit uses 100. */
|
||||||
|
export const FANAVARAN_DEFAULT_HULL_CUSTOMER_FAULT_PERCENT = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GEN.03 required: مرجع تعيين مقصر.
|
||||||
|
* Lookup: GET /lookups/detection-accident-culprits. Fallback is بیمهگذار.
|
||||||
|
*/
|
||||||
|
export const FANAVARAN_DEFAULT_HULL_AUTHORITY_CULPRIT_ID = 1;
|
||||||
|
export const FANAVARAN_HULL_AUTHORITY_CULPRIT_CAPTION = "بيمه گذار";
|
||||||
|
|
||||||
/** GEN.03 ActualPremium from GET car/vehicle-hull-policies/{PolicyId} → TotalPremium. */
|
/** GEN.03 ActualPremium from GET car/vehicle-hull-policies/{PolicyId} → TotalPremium. */
|
||||||
export function actualPremiumFromHullPolicyRecord(
|
export function actualPremiumFromHullPolicyRecord(
|
||||||
@@ -63,6 +73,41 @@ export function actualPremiumFromHullPolicyRecord(
|
|||||||
return Number.isFinite(n) && n >= 0 ? n : null;
|
return Number.isFinite(n) && n >= 0 ? n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHullCaption(value: unknown): string {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replace(/ي/g, "ی")
|
||||||
|
.replace(/ك/g, "ک")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AuthorityCulpritId from GET /lookups/detection-accident-culprits. */
|
||||||
|
export function authorityCulpritIdFromDetectionRows(
|
||||||
|
rows: unknown,
|
||||||
|
): number {
|
||||||
|
const list = Array.isArray(rows) ? rows : [];
|
||||||
|
const wanted = normalizeHullCaption(
|
||||||
|
FANAVARAN_HULL_AUTHORITY_CULPRIT_CAPTION,
|
||||||
|
);
|
||||||
|
for (const row of list) {
|
||||||
|
if (!row || typeof row !== "object") continue;
|
||||||
|
const caption = normalizeHullCaption(
|
||||||
|
(row as { Caption?: unknown }).Caption,
|
||||||
|
);
|
||||||
|
if (!caption.includes(wanted)) continue;
|
||||||
|
const raw = (row as { Id?: unknown }).Id;
|
||||||
|
const n = typeof raw === "number" ? raw : Number(raw);
|
||||||
|
if (Number.isFinite(n) && n > 0) return n;
|
||||||
|
}
|
||||||
|
for (const row of list) {
|
||||||
|
if (!row || typeof row !== "object") continue;
|
||||||
|
const raw = (row as { Id?: unknown }).Id;
|
||||||
|
const n = typeof raw === "number" ? raw : Number(raw);
|
||||||
|
if (Number.isFinite(n) && n > 0) return n;
|
||||||
|
}
|
||||||
|
return FANAVARAN_DEFAULT_HULL_AUTHORITY_CULPRIT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fallback AccidentCulpritId when parties inquiry is unavailable. */
|
/** Fallback AccidentCulpritId when parties inquiry is unavailable. */
|
||||||
export function customerIdFromHullPolicyRecord(
|
export function customerIdFromHullPolicyRecord(
|
||||||
policy: Record<string, unknown> | null | undefined,
|
policy: Record<string, unknown> | null | undefined,
|
||||||
@@ -79,8 +124,8 @@ export function toFanavaranHullBaseClaimPayload(
|
|||||||
const next: Record<string, unknown> = {};
|
const next: Record<string, unknown> = {};
|
||||||
for (const key of FANAVARAN_HULL_BASE_CLAIM_KEYS) {
|
for (const key of FANAVARAN_HULL_BASE_CLAIM_KEYS) {
|
||||||
if (key === "IsLicenseReplaced") {
|
if (key === "IsLicenseReplaced") {
|
||||||
next[key] =
|
// GEN.03: must be empty/null. Do not map ثالث IsLicenseReplacement (default 0).
|
||||||
built.IsLicenseReplaced ?? built.IsLicenseReplacement ?? null;
|
next[key] = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (key === "AccidentTypeId") {
|
if (key === "AccidentTypeId") {
|
||||||
@@ -108,15 +153,14 @@ export function toFanavaranHullBaseClaimPayload(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (key === "CustomerFaultPercent") {
|
if (key === "CustomerFaultPercent") {
|
||||||
const culpritType =
|
next[key] =
|
||||||
built.CulpritTypeId ?? FANAVARAN_DEFAULT_HULL_CULPRIT_TYPE_ID;
|
built.CustomerFaultPercent ??
|
||||||
if (built.CustomerFaultPercent != null) {
|
FANAVARAN_DEFAULT_HULL_CUSTOMER_FAULT_PERCENT;
|
||||||
next[key] = built.CustomerFaultPercent;
|
continue;
|
||||||
} else if (culpritType === 300) {
|
}
|
||||||
next[key] = 100;
|
if (key === "AuthorityCulpritId") {
|
||||||
} else {
|
next[key] =
|
||||||
next[key] = null;
|
built.AuthorityCulpritId ?? FANAVARAN_DEFAULT_HULL_AUTHORITY_CULPRIT_ID;
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
next[key] = Object.prototype.hasOwnProperty.call(built, key)
|
next[key] = Object.prototype.hasOwnProperty.call(built, key)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export const FANAVARAN_HULL_DMG_COST_KIND_CAPTION = "مجموع لوازم";
|
|||||||
export const FANAVARAN_DEFAULT_HULL_DMG_KIND_ID = 5485;
|
export const FANAVARAN_DEFAULT_HULL_DMG_KIND_ID = 5485;
|
||||||
/** Fallback when lookup fetch fails (`vehicle-hull-dmg-cost-kinds` Id). */
|
/** Fallback when lookup fetch fails (`vehicle-hull-dmg-cost-kinds` Id). */
|
||||||
export const FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID = 1;
|
export const FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID = 1;
|
||||||
|
/** GEN.06 factory (فابریک) accessory row. Used for every hull DmgSection. */
|
||||||
|
export const FANAVARAN_DEFAULT_HULL_VEHICLE_ACCESSORY_ID = 3043330;
|
||||||
|
|
||||||
export type FanavaranHullDmgAccessoryRow = {
|
export type FanavaranHullDmgAccessoryRow = {
|
||||||
Id?: unknown;
|
Id?: unknown;
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ export {
|
|||||||
FANAVARAN_HULL_DMG_KIND_CAPTION,
|
FANAVARAN_HULL_DMG_KIND_CAPTION,
|
||||||
} from "./fanavaran-hull-expertise-lookups";
|
} from "./fanavaran-hull-expertise-lookups";
|
||||||
|
|
||||||
|
/** GEN.06 RepairDuration in days for every hull expertise. */
|
||||||
|
export const FANAVARAN_DEFAULT_HULL_REPAIR_DURATION = 3;
|
||||||
|
|
||||||
/** GEN.06 VehicleCurrentValue from GET car/vehicle-hull-policies/{PolicyId} → VehicleValue. */
|
/** GEN.06 VehicleCurrentValue from GET car/vehicle-hull-policies/{PolicyId} → VehicleValue. */
|
||||||
export function vehicleCurrentValueFromHullPolicyRecord(
|
export function vehicleCurrentValueFromHullPolicyRecord(
|
||||||
policy: Record<string, unknown> | null | undefined,
|
policy: Record<string, unknown> | null | undefined,
|
||||||
@@ -117,7 +120,8 @@ export function toFanavaranHullExpertisePayload(input: {
|
|||||||
ComponentReplacementCost: input.componentReplacementCost,
|
ComponentReplacementCost: input.componentReplacementCost,
|
||||||
WasteValue: input.wasteValue,
|
WasteValue: input.wasteValue,
|
||||||
CarryAndRescueCost: null,
|
CarryAndRescueCost: null,
|
||||||
RepairDuration: input.repairDuration ?? null,
|
RepairDuration:
|
||||||
|
input.repairDuration ?? FANAVARAN_DEFAULT_HULL_REPAIR_DURATION,
|
||||||
VehicleCurrentValue: input.vehicleCurrentValue,
|
VehicleCurrentValue: input.vehicleCurrentValue,
|
||||||
WreckHighestValue: null,
|
WreckHighestValue: null,
|
||||||
InspectionDeduction: null,
|
InspectionDeduction: null,
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
"guilty_car_card_front",
|
"guilty_car_card_front",
|
||||||
"guilty_car_card_back",
|
"guilty_car_card_back",
|
||||||
"guilty_metal_plate",
|
"guilty_metal_plate",
|
||||||
|
"accident_sketch",
|
||||||
],
|
],
|
||||||
example: "damaged_driving_license_front",
|
example: "damaged_driving_license_front",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`). Required for the VIN initial-form path.",
|
"ESG two-factor VIN/chassis inquiry (`/inquiry/carByChassis`). Required for the VIN initial-form path.",
|
||||||
example: false,
|
example: false,
|
||||||
})
|
})
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
101
src/common/utils/inquiry-error.spec.ts
Normal file
101
src/common/utils/inquiry-error.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
getInquiryErrorMessage,
|
||||||
|
isInquiryFailurePayload,
|
||||||
|
} from "./inquiry-error";
|
||||||
|
|
||||||
|
describe("inquiry error messages", () => {
|
||||||
|
it("preserves a Persian ESG not-found response", () => {
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
{ success: false, message: "موردی یافت نشد" },
|
||||||
|
"thirdPartyPlate",
|
||||||
|
),
|
||||||
|
).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", () => {
|
||||||
|
expect(getInquiryErrorMessage({ status: 404 }, "thirdPartyVin")).toContain(
|
||||||
|
"شماره شاسی (VIN)",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
new Error("No active policy found"),
|
||||||
|
"carBodyPlate",
|
||||||
|
),
|
||||||
|
).toBe("بیمهنامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a specific Persian provider message", () => {
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
{ message: "کد ملی واردشده صحیح نیست" },
|
||||||
|
"personalIdentity",
|
||||||
|
),
|
||||||
|
).toBe("کد ملی واردشده صحیح نیست");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose transport or authentication details", () => {
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
{ response: { status: 401 }, message: "ESG authentication failed" },
|
||||||
|
"thirdPartyPlate",
|
||||||
|
),
|
||||||
|
).toBe("سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes provider failure envelopes", () => {
|
||||||
|
expect(isInquiryFailurePayload({ success: false })).toBe(true);
|
||||||
|
expect(isInquiryFailurePayload({ HasError: true })).toBe(true);
|
||||||
|
expect(isInquiryFailurePayload({ success: true, data: {} })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
247
src/common/utils/inquiry-error.ts
Normal file
247
src/common/utils/inquiry-error.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
export type InquiryErrorContext =
|
||||||
|
| "thirdPartyPlate"
|
||||||
|
| "thirdPartyVin"
|
||||||
|
| "carBodyPlate"
|
||||||
|
| "carBodyVin"
|
||||||
|
| "personalIdentity"
|
||||||
|
| "drivingLicense"
|
||||||
|
| "carOwnership"
|
||||||
|
| "sheba"
|
||||||
|
| "generic";
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
const NOT_FOUND_MESSAGES: Record<InquiryErrorContext, string> = {
|
||||||
|
thirdPartyPlate: "بیمهنامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||||
|
thirdPartyVin:
|
||||||
|
"بیمهنامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
|
||||||
|
carBodyPlate: "بیمهنامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||||
|
carBodyVin:
|
||||||
|
"بیمهنامه بدنه فعالی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
|
||||||
|
personalIdentity: "اطلاعات هویتی مطابق کد ملی و تاریخ تولد واردشده یافت نشد.",
|
||||||
|
drivingLicense:
|
||||||
|
"گواهینامهای مطابق کد ملی و شماره گواهینامه واردشده یافت نشد.",
|
||||||
|
carOwnership: "مالکیتی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||||
|
sheba: "اطلاعاتی مطابق شماره شبا و کد ملی واردشده یافت نشد.",
|
||||||
|
generic: "موردی مطابق اطلاعات واردشده یافت نشد.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const INVALID_MESSAGES: Record<InquiryErrorContext, string> = {
|
||||||
|
thirdPartyPlate: "پلاک یا کد ملی واردشده برای استعلام شخص ثالث معتبر نیست.",
|
||||||
|
thirdPartyVin:
|
||||||
|
"شماره شاسی (VIN) یا کد ملی واردشده برای استعلام شخص ثالث معتبر نیست.",
|
||||||
|
carBodyPlate: "پلاک یا کد ملی واردشده برای استعلام بیمه بدنه معتبر نیست.",
|
||||||
|
carBodyVin:
|
||||||
|
"شماره شاسی (VIN) یا کد ملی واردشده برای استعلام بیمه بدنه معتبر نیست.",
|
||||||
|
personalIdentity: "کد ملی یا تاریخ تولد واردشده معتبر نیست.",
|
||||||
|
drivingLicense: "کد ملی یا شماره گواهینامه واردشده معتبر نیست.",
|
||||||
|
carOwnership: "پلاک یا کد ملی واردشده برای استعلام مالکیت معتبر نیست.",
|
||||||
|
sheba: "شماره شبا یا کد ملی واردشده معتبر نیست.",
|
||||||
|
generic: "اطلاعات ارسالشده برای استعلام معتبر نیست.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): UnknownRecord | undefined =>
|
||||||
|
value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as UnknownRecord)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const cleanMessage = (value: unknown): string => {
|
||||||
|
if (typeof value === "string") return value.trim();
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("، ");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
function errorRecords(error: unknown): UnknownRecord[] {
|
||||||
|
const root = asRecord(error);
|
||||||
|
if (!root) return [];
|
||||||
|
|
||||||
|
const response = asRecord(root.response);
|
||||||
|
const responseData = asRecord(response?.data);
|
||||||
|
const data = asRecord(root.data);
|
||||||
|
const nestedError = asRecord(root.Error) ?? asRecord(root.error);
|
||||||
|
const responseError =
|
||||||
|
asRecord(responseData?.Error) ?? asRecord(responseData?.error);
|
||||||
|
|
||||||
|
return [root, responseData, data, nestedError, responseError].filter(
|
||||||
|
(item): item is UnknownRecord => !!item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inquiryErrorStatus(error: unknown): number | undefined {
|
||||||
|
for (const record of errorRecords(error)) {
|
||||||
|
const response = asRecord(record.response);
|
||||||
|
const value =
|
||||||
|
record.statusCode ?? record.status ?? response?.status ?? record.code;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 100 && parsed <= 599) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractInquiryProviderMessage(error: unknown): string {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const scalarData = cleanMessage(record.data);
|
||||||
|
if (scalarData) return scalarData;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message.trim() : cleanMessage(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInquiryFailurePayload(value: unknown): boolean {
|
||||||
|
const root = asRecord(value);
|
||||||
|
if (!root) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
root.success === false ||
|
||||||
|
root.isSuccess === false ||
|
||||||
|
root.IsSuccess === false ||
|
||||||
|
root.IsSucceed === false ||
|
||||||
|
root.ReturnValue === false ||
|
||||||
|
root.HasError === true ||
|
||||||
|
root.hasError === true ||
|
||||||
|
root.Error != null ||
|
||||||
|
root.error != null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInquiryTimeout(error: unknown): boolean {
|
||||||
|
const root = asRecord(error);
|
||||||
|
const code = String(root?.code ?? "").toUpperCase();
|
||||||
|
const message = extractInquiryProviderMessage(error);
|
||||||
|
return (
|
||||||
|
["ECONNABORTED", "ETIMEDOUT", "ESOCKETTIMEDOUT"].includes(code) ||
|
||||||
|
/timeout|timed out|مهلت|زمان.*پایان/i.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPersian = (value: string): boolean => /[\u0600-\u06ff]/.test(value);
|
||||||
|
|
||||||
|
const isNotFound = (error: unknown, message: string): boolean => {
|
||||||
|
const codes = errorRecords(error).flatMap((record) =>
|
||||||
|
[record.code, record.providerCode]
|
||||||
|
.map((code) => String(code ?? "").toUpperCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
inquiryErrorStatus(error) === 404 ||
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInvalidInput = (message: string): boolean =>
|
||||||
|
/invalid|malformed|required|must contain|bad request|نامعتبر|الزامی|وارد نشده|صحیح نیست/i.test(
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isUnavailable = (error: unknown, message: string): boolean => {
|
||||||
|
const root = asRecord(error);
|
||||||
|
const code = String(root?.code ?? "").toUpperCase();
|
||||||
|
const status = inquiryErrorStatus(error);
|
||||||
|
return (
|
||||||
|
isInquiryTimeout(error) ||
|
||||||
|
["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ERR_NETWORK"].includes(code) ||
|
||||||
|
status === 401 ||
|
||||||
|
status === 502 ||
|
||||||
|
status === 503 ||
|
||||||
|
status === 504 ||
|
||||||
|
/offline|unavailable|connection|socket|network|authentication|credentials|empty response|در دسترس نیست|عدم دسترسی/i.test(
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts provider and transport failures into a stable, user-facing Persian
|
||||||
|
* message. Provider details remain in server logs; raw English or technical
|
||||||
|
* messages are never returned to clients.
|
||||||
|
*/
|
||||||
|
export function getInquiryErrorMessage(
|
||||||
|
error: unknown,
|
||||||
|
context: InquiryErrorContext = "generic",
|
||||||
|
): string {
|
||||||
|
const providerMessage = extractInquiryProviderMessage(error);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
context === "carOwnership" &&
|
||||||
|
/not the owner|مالک.*نیست/i.test(providerMessage)
|
||||||
|
) {
|
||||||
|
return "پلاک واردشده متعلق به کد ملی واردشده نیست.";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
context === "sheba" &&
|
||||||
|
/does not match|تطابق ندارد/i.test(providerMessage)
|
||||||
|
) {
|
||||||
|
return "شماره شبا متعلق به کد ملی واردشده نیست.";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
context === "drivingLicense" &&
|
||||||
|
/not valid|نامعتبر/i.test(providerMessage)
|
||||||
|
) {
|
||||||
|
return "گواهینامه واردشده معتبر نیست.";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/policy insurer does not match|بیمه.*متعلق.*نیست/i.test(providerMessage)
|
||||||
|
) {
|
||||||
|
return "بیمهنامه یافتشده متعلق به شرکت بیمه این سامانه نیست.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInquiryTimeout(error)) {
|
||||||
|
return "زمان پاسخگویی سرویس استعلام به پایان رسید. لطفاً دوباره تلاش کنید.";
|
||||||
|
}
|
||||||
|
if (isUnavailable(error, providerMessage)) {
|
||||||
|
return "سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInvalidInput(providerMessage) || inquiryErrorStatus(error) === 422) {
|
||||||
|
return INVALID_MESSAGES[context];
|
||||||
|
}
|
||||||
|
|
||||||
|
return "انجام استعلام با خطا مواجه شد. لطفاً دوباره تلاش کنید.";
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
66
src/expert-blame/expert-blame.participants.spec.ts
Normal file
66
src/expert-blame/expert-blame.participants.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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?: {
|
||||||
@@ -613,9 +614,24 @@ export class ExpertBlameService {
|
|||||||
)) as Record<string, unknown>[];
|
)) as Record<string, unknown>[];
|
||||||
|
|
||||||
// Scope to this reviewer's insurance company via blame party clientId
|
// Scope to this reviewer's insurance company via blame party clientId
|
||||||
const visibleCases = allSealed.filter((doc) =>
|
const tenantVisibleCases = allSealed.filter((doc) =>
|
||||||
blameCaseTouchesClient(doc, clientKey),
|
blameCaseTouchesClient(doc, clientKey),
|
||||||
);
|
);
|
||||||
|
const visibleCases = (
|
||||||
|
await Promise.all(
|
||||||
|
tenantVisibleCases.map(async (doc) => {
|
||||||
|
try {
|
||||||
|
await this.requestManagementService.assertFileReviewerBranchAccess(
|
||||||
|
doc,
|
||||||
|
actor.sub,
|
||||||
|
);
|
||||||
|
return doc;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter((doc): doc is Record<string, unknown> => doc != null);
|
||||||
|
|
||||||
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
|
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
|
||||||
return pagedResult;
|
return pagedResult;
|
||||||
@@ -1250,8 +1266,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 (
|
||||||
|
|||||||
@@ -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`)",
|
||||||
@@ -131,6 +139,33 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
url?: string;
|
url?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Append-only expert damaged-part revisions. Removed rows retain their original capture URL even after the authoritative selection changes.",
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
revisionId: { type: "string" },
|
||||||
|
changedAt: { type: "string", format: "date-time" },
|
||||||
|
changedBy: { type: "object" },
|
||||||
|
removedParts: { type: "array", items: { type: "object" } },
|
||||||
|
addedParts: { type: "array", items: { type: "object" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
damagedPartsHistory?: Array<{
|
||||||
|
revisionId: string;
|
||||||
|
changedAt: Date | string;
|
||||||
|
changedBy: {
|
||||||
|
actorId: string;
|
||||||
|
actorName?: string;
|
||||||
|
actorType: string;
|
||||||
|
};
|
||||||
|
removedParts: Array<Record<string, unknown>>;
|
||||||
|
addedParts: Array<Record<string, unknown>>;
|
||||||
|
}>;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
"True when user uploaded all required factors and the case awaits expert approve/reject.",
|
"True when user uploaded all required factors and the case awaits expert approve/reject.",
|
||||||
@@ -251,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>;
|
||||||
|
|
||||||
|
|||||||
@@ -90,4 +90,21 @@ describe("SubmitExpertReplyV2Dto", () => {
|
|||||||
|
|
||||||
expect(await validate(dto)).toHaveLength(0);
|
expect(await validate(dto)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts a grouped Persian current car price as a string", async () => {
|
||||||
|
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
|
||||||
|
carPrice: "۱٬۲۵۰٬۰۰۰٬۰۰۰",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
partId: 201,
|
||||||
|
typeOfDamage: TypeOfDamage.Repair,
|
||||||
|
salary: "1000000",
|
||||||
|
totalPayment: "1000000",
|
||||||
|
factorNeeded: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await validate(dto)).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -122,8 +122,9 @@ export class SubmitExpertReplyV2Dto {
|
|||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '1_000_000_000',
|
example: '1000000000',
|
||||||
description: "Today's car price",
|
description:
|
||||||
|
"Current vehicle price in Rial. Required for car-body claims and forbidden for third-party claims. This is independent from evaluation.priceDrop.carPrice.",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsMoneyAmountString()
|
@IsMoneyAmountString()
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { BadRequestException } from "@nestjs/common";
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { Types } from "mongoose";
|
||||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
|
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
|
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
|
||||||
|
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
import { ExpertClaimService } from "./expert-claim.service";
|
import { ExpertClaimService } from "./expert-claim.service";
|
||||||
|
|
||||||
|
const V2_EXPERT_ID = "66ec0e480e321873c0900001";
|
||||||
|
|
||||||
const blankPricingReply = {
|
const blankPricingReply = {
|
||||||
description: "Damage assessment",
|
description: "Damage assessment",
|
||||||
parts: [
|
parts: [
|
||||||
@@ -26,7 +30,227 @@ function createService() {
|
|||||||
) as ExpertClaimService;
|
) as ExpertClaimService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupSuccessfulV2Submit(blameType: BlameRequestType) {
|
||||||
|
const service = createService() as any;
|
||||||
|
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const claim = {
|
||||||
|
_id: "66ec0e480e321873c0900002",
|
||||||
|
publicId: "CLM-1",
|
||||||
|
blameRequestId: "66ec0e480e321873c0900003",
|
||||||
|
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||||
|
workflow: { locked: true, lockedBy: { actorId: V2_EXPERT_ID } },
|
||||||
|
vehicle: { carType: "sedan" },
|
||||||
|
damage: {
|
||||||
|
selectedParts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "front",
|
||||||
|
side: "",
|
||||||
|
label_fa: "جلو کامل",
|
||||||
|
catalogKey: "1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
service.claimCaseDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue(claim),
|
||||||
|
findByIdAndUpdate,
|
||||||
|
};
|
||||||
|
service.blameRequestDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
type: blameType,
|
||||||
|
expertInitiated: true,
|
||||||
|
creationMethod: "IN_PERSON",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.recordClaimExpertActivity = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
return { service, findByIdAndUpdate };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validV2Reply = {
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
partId: 1,
|
||||||
|
typeOfDamage: TypeOfDamage.Repair,
|
||||||
|
salary: "1000000",
|
||||||
|
totalPayment: "1000000",
|
||||||
|
factorNeeded: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
describe("ExpertClaimService expert-reply pricing", () => {
|
describe("ExpertClaimService expert-reply pricing", () => {
|
||||||
|
it("requires the current car price for a car-body expert reply", async () => {
|
||||||
|
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
|
||||||
|
BlameRequestType.CAR_BODY,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.submitExpertReplyV2("v2-claim", validV2Reply, {
|
||||||
|
sub: V2_EXPERT_ID,
|
||||||
|
fullName: "Expert One",
|
||||||
|
role: RoleEnum.FIELD_EXPERT,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
code: "CAR_PRICE_REQUIRED",
|
||||||
|
field: "carPrice",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes and persists the current car price on car-body replies", async () => {
|
||||||
|
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
|
||||||
|
BlameRequestType.CAR_BODY,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.submitExpertReplyV2(
|
||||||
|
"v2-claim",
|
||||||
|
{ ...validV2Reply, carPrice: "۱٬۲۵۰٬۰۰۰٬۰۰۰" },
|
||||||
|
{
|
||||||
|
sub: V2_EXPERT_ID,
|
||||||
|
fullName: "Expert One",
|
||||||
|
role: RoleEnum.FIELD_EXPERT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(findByIdAndUpdate.mock.calls[0][1]).toMatchObject({
|
||||||
|
"vehicle.price": "1250000000",
|
||||||
|
"vehicle.carPrice": 1250000000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the current car price on third-party replies", async () => {
|
||||||
|
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
|
||||||
|
BlameRequestType.THIRD_PARTY,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.submitExpertReplyV2(
|
||||||
|
"v2-claim",
|
||||||
|
{ ...validV2Reply, carPrice: "1250000000" },
|
||||||
|
{
|
||||||
|
sub: V2_EXPERT_ID,
|
||||||
|
fullName: "Expert One",
|
||||||
|
role: RoleEnum.FIELD_EXPERT,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
code: "CAR_PRICE_NOT_ALLOWED",
|
||||||
|
field: "carPrice",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite vehicle.price on third-party replies", async () => {
|
||||||
|
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
|
||||||
|
BlameRequestType.THIRD_PARTY,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.submitExpertReplyV2("v2-claim", validV2Reply, {
|
||||||
|
sub: V2_EXPERT_ID,
|
||||||
|
fullName: "Expert One",
|
||||||
|
role: RoleEnum.FIELD_EXPERT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(findByIdAndUpdate.mock.calls[0][1]).not.toHaveProperty(
|
||||||
|
"vehicle.price",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("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;
|
||||||
|
|
||||||
@@ -105,18 +329,27 @@ describe("ExpertClaimService expert-reply pricing", () => {
|
|||||||
const findByIdAndUpdate = jest.fn();
|
const findByIdAndUpdate = jest.fn();
|
||||||
service.claimCaseDbService = {
|
service.claimCaseDbService = {
|
||||||
findById: jest.fn().mockResolvedValue({
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
blameRequestId: "blame-1",
|
||||||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||||
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
|
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
|
||||||
}),
|
}),
|
||||||
findByIdAndUpdate,
|
findByIdAndUpdate,
|
||||||
};
|
};
|
||||||
|
service.blameRequestDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
|
||||||
|
};
|
||||||
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.submitExpertReplyV2("v2-claim", blankPricingReply, {
|
service.submitExpertReplyV2(
|
||||||
sub: "expert-1",
|
"v2-claim",
|
||||||
role: RoleEnum.FIELD_EXPERT,
|
{ ...blankPricingReply, carPrice: "1000000" },
|
||||||
}),
|
{
|
||||||
|
sub: "expert-1",
|
||||||
|
role: RoleEnum.FIELD_EXPERT,
|
||||||
|
},
|
||||||
|
),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
||||||
@@ -127,18 +360,24 @@ describe("ExpertClaimService expert-reply pricing", () => {
|
|||||||
const findByIdAndUpdate = jest.fn();
|
const findByIdAndUpdate = jest.fn();
|
||||||
service.claimCaseDbService = {
|
service.claimCaseDbService = {
|
||||||
findById: jest.fn().mockResolvedValue({
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
blameRequestId: "blame-1",
|
||||||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||||
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
|
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
|
||||||
}),
|
}),
|
||||||
findByIdAndUpdate,
|
findByIdAndUpdate,
|
||||||
};
|
};
|
||||||
|
service.blameRequestDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
|
||||||
|
};
|
||||||
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.submitExpertReplyV2(
|
service.submitExpertReplyV2(
|
||||||
"v2-claim",
|
"v2-claim",
|
||||||
{
|
{
|
||||||
description: "Damage assessment",
|
description: "Damage assessment",
|
||||||
|
carPrice: "1000000",
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
partId: 201,
|
partId: 201,
|
||||||
@@ -163,3 +402,160 @@ describe("ExpertClaimService expert-reply pricing", () => {
|
|||||||
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ExpertClaimService damaged-part audit", () => {
|
||||||
|
it("archives removed capture evidence before replacing the live arrays", async () => {
|
||||||
|
const service = createService() as any;
|
||||||
|
const expertId = "66ec0e480e321873c0900001";
|
||||||
|
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.claimCaseDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
_id: "66ec0e480e321873c0900002",
|
||||||
|
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||||
|
workflow: { locked: true, lockedBy: { actorId: expertId } },
|
||||||
|
vehicle: { carType: "SEDAN" },
|
||||||
|
damage: {
|
||||||
|
selectedParts: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
name: "hood",
|
||||||
|
side: "front",
|
||||||
|
label_fa: "کاپوت",
|
||||||
|
catalogKey: "front_hood",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 202,
|
||||||
|
name: "door",
|
||||||
|
side: "left",
|
||||||
|
label_fa: "درب چپ",
|
||||||
|
catalogKey: "left_door",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
damagedParts: [
|
||||||
|
{ path: "claims/hood.jpg", fileName: "hood.jpg" },
|
||||||
|
{ path: "claims/door.jpg", fileName: "door.jpg" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
findByIdAndUpdate,
|
||||||
|
};
|
||||||
|
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.snapshotDamageExpert = jest.fn().mockResolvedValue({
|
||||||
|
firstName: "Expert",
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.updateClaimDamagedPartsV2(
|
||||||
|
"66ec0e480e321873c0900002",
|
||||||
|
{
|
||||||
|
selectedParts: [
|
||||||
|
{
|
||||||
|
id: 202,
|
||||||
|
name: "door",
|
||||||
|
side: "left",
|
||||||
|
label_fa: "درب چپ",
|
||||||
|
catalogKey: "left_door",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sub: expertId,
|
||||||
|
fullName: "Expert One",
|
||||||
|
role: RoleEnum.FIELD_EXPERT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
const update = findByIdAndUpdate.mock.calls[0][1];
|
||||||
|
expect(update.$set["media.damagedParts"]).toEqual([
|
||||||
|
expect.objectContaining({ path: "claims/door.jpg" }),
|
||||||
|
]);
|
||||||
|
expect(update.$push["damage.partSelectionHistory"]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
changedBy: expect.objectContaining({ actorId: expertId }),
|
||||||
|
removedParts: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 101,
|
||||||
|
capture: expect.objectContaining({ path: "claims/hood.jpg" }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ExpertClaimService FileReviewer assignment", () => {
|
||||||
|
it("lets the assigned reviewer reopen a file while it is still awaiting FileReviewer work", async () => {
|
||||||
|
const service = createService() as any;
|
||||||
|
const reviewerId = new Types.ObjectId();
|
||||||
|
const clientId = new Types.ObjectId();
|
||||||
|
const blameId = new Types.ObjectId();
|
||||||
|
const claimId = new Types.ObjectId();
|
||||||
|
|
||||||
|
service.expireClaimWorkflowLockV2IfStale = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.claimCaseDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
_id: claimId,
|
||||||
|
blameRequestId: blameId,
|
||||||
|
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
service.blameRequestDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
_id: blameId,
|
||||||
|
type: BlameRequestType.CAR_BODY,
|
||||||
|
status: "WAITING_FOR_FILE_REVIEWER",
|
||||||
|
assignedFileReviewerId: reviewerId,
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
role: "FIRST",
|
||||||
|
person: { clientId },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
service.fanavaranLocationService = {
|
||||||
|
assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
service.assertExpertActorOnClaim = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.assignClaimForReviewV2(String(claimId), {
|
||||||
|
sub: String(reviewerId),
|
||||||
|
role: RoleEnum.FILE_REVIEWER,
|
||||||
|
clientKey: String(clientId),
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
success: true,
|
||||||
|
status: "already_assigned_to_you",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.assertExpertActorOnClaim).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ExpertClaimService branch-scoped claim views", () => {
|
||||||
|
it("shows branch-scoped claims only to damage experts in that branch", async () => {
|
||||||
|
const service = createService() as any;
|
||||||
|
const branchId = new Types.ObjectId();
|
||||||
|
const otherBranchId = new Types.ObjectId();
|
||||||
|
service.damageExpertDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({ branchId }),
|
||||||
|
};
|
||||||
|
service.blameRequestDbService = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const inBranch = { _id: "in-branch", branchId };
|
||||||
|
const otherBranch = { _id: "other-branch", branchId: otherBranchId };
|
||||||
|
const legacyUnscoped = { _id: "legacy-unscoped" };
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.filterDamageExpertClaimsByBranch(
|
||||||
|
[inBranch, otherBranch, legacyUnscoped],
|
||||||
|
{ sub: V2_EXPERT_ID, role: RoleEnum.DAMAGE_EXPERT },
|
||||||
|
),
|
||||||
|
).resolves.toEqual([inBranch, legacyUnscoped]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ import {
|
|||||||
} from "src/helpers/outer-damage-parts";
|
} from "src/helpers/outer-damage-parts";
|
||||||
import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend";
|
import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend";
|
||||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
|
import {
|
||||||
|
buildDamagedPartSelectionRevision,
|
||||||
|
serializeDamagedPartSelectionHistory,
|
||||||
|
} from "src/helpers/claim-damaged-part-audit";
|
||||||
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
||||||
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
|
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
|
||||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||||
@@ -152,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 {
|
||||||
@@ -172,6 +176,11 @@ import {
|
|||||||
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
|
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
|
||||||
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
|
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
|
||||||
import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing";
|
import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing";
|
||||||
|
import {
|
||||||
|
normalizeMoneyAmountString,
|
||||||
|
parseMoneyAmountToman,
|
||||||
|
} from "src/utils/unicode-digits";
|
||||||
|
import { claimPriceCapAppliesToBlame } from "src/helpers/claim-price-cap";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpertClaimService {
|
export class ExpertClaimService {
|
||||||
@@ -409,14 +418,145 @@ export class ExpertClaimService {
|
|||||||
claim: any,
|
claim: any,
|
||||||
actor: any,
|
actor: any,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const blame = await this.loadBlameForClaim(claim);
|
||||||
// FILE_REVIEWER: by the time we reach this method the caller has already
|
// FILE_REVIEWER: by the time we reach this method the caller has already
|
||||||
// verified that actor.sub === blame.assignedFileReviewerId (Phase 2 of
|
// verified that actor.sub === blame.assignedFileReviewerId (Phase 2 of
|
||||||
// assignClaimForReviewV2). The generic tenant-scope check would incorrectly
|
// assignClaimForReviewV2). The generic tenant-scope check would incorrectly
|
||||||
// reject them because initiatedByFieldExpertId belongs to the FileMaker, not
|
// reject them because initiatedByFieldExpertId belongs to the FileMaker, not
|
||||||
// the reviewer. Skip it — the assignment check is the access proof.
|
// the reviewer. Skip it — the assignment check is the access proof.
|
||||||
if ((actor as any).role === RoleEnum.FILE_REVIEWER) return;
|
if ((actor as any).role === RoleEnum.FILE_REVIEWER) {
|
||||||
const blame = await this.loadBlameForClaim(claim);
|
await this.assertActorBranchAccess(claim, actor, blame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
assertClaimCaseForExpertActor(claim, actor, blame);
|
assertClaimCaseForExpertActor(claim, actor, blame);
|
||||||
|
await this.assertActorBranchAccess(claim, actor, blame);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertActorBranchAccess(
|
||||||
|
claim: any,
|
||||||
|
actor: any,
|
||||||
|
blame?: any,
|
||||||
|
): Promise<void> {
|
||||||
|
const caseBranchId = claim?.branchId
|
||||||
|
? String(claim.branchId)
|
||||||
|
: blame?.branchId
|
||||||
|
? String(blame.branchId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (actor?.role === RoleEnum.FILE_REVIEWER) {
|
||||||
|
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: blame?.initiatedByFieldExpertId
|
||||||
|
? String(blame.initiatedByFieldExpertId)
|
||||||
|
: null,
|
||||||
|
fileReviewerId: String(actor.sub),
|
||||||
|
caseBranchId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor?.role === RoleEnum.FILE_MAKER) {
|
||||||
|
const actorBranchId = await this.fanavaranLocationService.fileMakerBranchId(
|
||||||
|
String(actor.sub),
|
||||||
|
);
|
||||||
|
if (!actorBranchId || (caseBranchId && caseBranchId !== actorBranchId)) {
|
||||||
|
throw new ForbiddenException("This file belongs to another branch.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor?.role === RoleEnum.FIELD_EXPERT) return;
|
||||||
|
|
||||||
|
const damageExpert = await this.damageExpertDbService.findById(
|
||||||
|
String(actor.sub),
|
||||||
|
);
|
||||||
|
const actorBranchId = (damageExpert as any)?.branchId
|
||||||
|
? String((damageExpert as any).branchId)
|
||||||
|
: undefined;
|
||||||
|
if (!actorBranchId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"DamageExpert account is not assigned to a branch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Ordinary user-created legacy claims have no branch snapshot. Preserve
|
||||||
|
// their existing tenant queue; branch-created claims must match exactly.
|
||||||
|
if (caseBranchId && caseBranchId !== actorBranchId) {
|
||||||
|
throw new ForbiddenException("This file belongs to another branch.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async filterDamageExpertClaimsByBranch(
|
||||||
|
claims: any[],
|
||||||
|
actor: any,
|
||||||
|
): Promise<any[]> {
|
||||||
|
const damageExpert = await this.damageExpertDbService.findById(
|
||||||
|
String(actor.sub),
|
||||||
|
);
|
||||||
|
const actorBranchId = (damageExpert as any)?.branchId
|
||||||
|
? String((damageExpert as any).branchId)
|
||||||
|
: undefined;
|
||||||
|
if (!actorBranchId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"DamageExpert account is not assigned to a branch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingBranchBlameIds = [
|
||||||
|
...new Set(
|
||||||
|
claims
|
||||||
|
.filter((claim) => !claim?.branchId && claim?.blameRequestId)
|
||||||
|
.map((claim) => String(claim.blameRequestId)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const blames = missingBranchBlameIds.length
|
||||||
|
? ((await this.blameRequestDbService.find(
|
||||||
|
{
|
||||||
|
_id: {
|
||||||
|
$in: missingBranchBlameIds.map((id) => new Types.ObjectId(id)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lean: true,
|
||||||
|
select:
|
||||||
|
"_id branchId isMadeByFileMaker initiatedByFieldExpertId",
|
||||||
|
},
|
||||||
|
)) as any[])
|
||||||
|
: [];
|
||||||
|
const blameById = new Map(
|
||||||
|
blames.map((blame) => [String(blame._id), blame] as const),
|
||||||
|
);
|
||||||
|
const makerBranchById = new Map<string, string | undefined>();
|
||||||
|
await Promise.all(
|
||||||
|
[
|
||||||
|
...new Set(
|
||||||
|
blames
|
||||||
|
.filter(
|
||||||
|
(blame) =>
|
||||||
|
blame.isMadeByFileMaker &&
|
||||||
|
!blame.branchId &&
|
||||||
|
blame.initiatedByFieldExpertId,
|
||||||
|
)
|
||||||
|
.map((blame) => String(blame.initiatedByFieldExpertId)),
|
||||||
|
),
|
||||||
|
].map(async (makerId) => {
|
||||||
|
makerBranchById.set(
|
||||||
|
makerId,
|
||||||
|
await this.fanavaranLocationService.fileMakerBranchId(makerId),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return claims.filter((claim) => {
|
||||||
|
let caseBranchId = claim?.branchId ? String(claim.branchId) : undefined;
|
||||||
|
if (!caseBranchId && claim?.blameRequestId) {
|
||||||
|
const blame = blameById.get(String(claim.blameRequestId));
|
||||||
|
caseBranchId = blame?.branchId
|
||||||
|
? String(blame.branchId)
|
||||||
|
: blame?.isMadeByFileMaker && blame?.initiatedByFieldExpertId
|
||||||
|
? makerBranchById.get(String(blame.initiatedByFieldExpertId))
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
return !caseBranchId || caseBranchId === actorBranchId;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fieldExpertOwnsClaim(
|
private async fieldExpertOwnsClaim(
|
||||||
@@ -1686,8 +1826,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;
|
||||||
|
|
||||||
@@ -1719,7 +1864,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,
|
||||||
@@ -2285,7 +2430,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(
|
||||||
@@ -2425,7 +2571,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 || []) {
|
||||||
@@ -2440,7 +2591,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,
|
||||||
@@ -2580,6 +2731,15 @@ export class ExpertClaimService {
|
|||||||
"This file does not belong to your organization.",
|
"This file does not belong to your organization.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: (blame as any)?.initiatedByFieldExpertId
|
||||||
|
? String((blame as any).initiatedByFieldExpertId)
|
||||||
|
: null,
|
||||||
|
fileReviewerId: actor.sub,
|
||||||
|
caseBranchId: (blame as any)?.branchId
|
||||||
|
? String((blame as any).branchId)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
!(blame as any).isMadeByFileMaker ||
|
!(blame as any).isMadeByFileMaker ||
|
||||||
!(blame as any).expertInitiated ||
|
!(blame as any).expertInitiated ||
|
||||||
@@ -2712,6 +2872,15 @@ export class ExpertClaimService {
|
|||||||
"This file does not belong to your organization.",
|
"This file does not belong to your organization.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: (reviewerBlame as any).initiatedByFieldExpertId
|
||||||
|
? String((reviewerBlame as any).initiatedByFieldExpertId)
|
||||||
|
: null,
|
||||||
|
fileReviewerId: actor.sub,
|
||||||
|
caseBranchId: (reviewerBlame as any).branchId
|
||||||
|
? String((reviewerBlame as any).branchId)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId
|
const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId
|
||||||
? String((reviewerBlame as any).assignedFileReviewerId)
|
? String((reviewerBlame as any).assignedFileReviewerId)
|
||||||
: null;
|
: null;
|
||||||
@@ -2719,9 +2888,14 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
|
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
|
||||||
if (assignedReviewerId && assignedReviewerId === actor.sub) {
|
if (assignedReviewerId && assignedReviewerId === actor.sub) {
|
||||||
// Reviewer already assigned (e.g. after a FileMaker rejection that reset
|
// Phase 1 is intentionally idempotent. The linked claim can still be
|
||||||
// blame back to WAITING_FOR_FILE_REVIEWER) — skip Phase 1 and fall
|
// in a data-capture status here, so falling through to the damage
|
||||||
// through to the damage-expert workflow lock below.
|
// assessment status gate would incorrectly reject the same reviewer.
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: "already_assigned_to_you",
|
||||||
|
message: "You have already taken this file.",
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
// Phase 1: first-time blame assignment
|
// Phase 1: first-time blame assignment
|
||||||
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
|
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
|
||||||
@@ -3246,7 +3420,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:
|
||||||
@@ -3263,8 +3437,6 @@ export class ExpertClaimService {
|
|||||||
) {
|
) {
|
||||||
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
const blame = await this.blameRequestDbService.findById(claim.blameRequestId);
|
|
||||||
|
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
this.expertReplySubmissionError(
|
this.expertReplySubmissionError(
|
||||||
@@ -3274,6 +3446,18 @@ export class ExpertClaimService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blame = await this.blameRequestDbService.findById(
|
||||||
|
claim.blameRequestId,
|
||||||
|
);
|
||||||
|
if (!blame) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
this.expertReplySubmissionError(
|
||||||
|
"پرونده تعیین خسارت مرتبط یافت نشد.",
|
||||||
|
"BLAME_NOT_FOUND",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.assertExpertActorOnClaim(claim, actor);
|
await this.assertExpertActorOnClaim(claim, actor);
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
@@ -3304,12 +3488,34 @@ export class ExpertClaimService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reply.carPrice && blame.type !== BlameRequestType.CAR_BODY) {
|
const carPriceWasProvided = reply.carPrice != null;
|
||||||
throw new ForbiddenException("قیمت روز خودرو فقط در پرونده های بدنه باید بررسی شود")
|
let normalizedCurrentCarPrice: string | undefined;
|
||||||
}
|
let parsedCurrentCarPrice: number | undefined;
|
||||||
|
|
||||||
if (blame.type === BlameRequestType.THIRD_PARTY && reply.carPrice) {
|
if (blame.type === BlameRequestType.CAR_BODY) {
|
||||||
throw new ForbiddenException("قیمت روز خودرو فقط در پرونده های بدنه باید بررسی شود")
|
parsedCurrentCarPrice = parseMoneyAmountToman(reply.carPrice) ?? undefined;
|
||||||
|
if (
|
||||||
|
parsedCurrentCarPrice == null ||
|
||||||
|
!Number.isSafeInteger(parsedCurrentCarPrice) ||
|
||||||
|
parsedCurrentCarPrice <= 0
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
this.expertReplySubmissionError(
|
||||||
|
"قیمت روز خودرو در پرونده بدنه الزامی است و باید مبلغی صحیح و بیشتر از صفر باشد.",
|
||||||
|
"CAR_PRICE_REQUIRED",
|
||||||
|
{ field: "carPrice" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
normalizedCurrentCarPrice = normalizeMoneyAmountString(reply.carPrice!);
|
||||||
|
} else if (carPriceWasProvided) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
this.expertReplySubmissionError(
|
||||||
|
"قیمت روز خودرو فقط برای پرونده بدنه قابل ثبت است.",
|
||||||
|
"CAR_PRICE_NOT_ALLOWED",
|
||||||
|
{ field: "carPrice" },
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pricingValidationError = getExpertReplyPricingValidationError(
|
const pricingValidationError = getExpertReplyPricingValidationError(
|
||||||
@@ -3320,8 +3526,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 || []) {
|
||||||
@@ -3332,7 +3542,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,
|
||||||
@@ -3540,7 +3750,12 @@ export class ExpertClaimService {
|
|||||||
"evaluation.ownerInsurerApproval": "",
|
"evaluation.ownerInsurerApproval": "",
|
||||||
"evaluation.ownerPricedPartsApproval": "",
|
"evaluation.ownerPricedPartsApproval": "",
|
||||||
},
|
},
|
||||||
"vehicle.price": reply.carPrice,
|
...(normalizedCurrentCarPrice && parsedCurrentCarPrice != null
|
||||||
|
? {
|
||||||
|
"vehicle.price": normalizedCurrentCarPrice,
|
||||||
|
"vehicle.carPrice": parsedCurrentCarPrice,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
"workflow.currentStep": currentStep,
|
"workflow.currentStep": currentStep,
|
||||||
"workflow.nextStep": nextWorkflowStep,
|
"workflow.nextStep": nextWorkflowStep,
|
||||||
[`evaluation.${replyField}`]: replyPayload,
|
[`evaluation.${replyField}`]: replyPayload,
|
||||||
@@ -4055,9 +4270,13 @@ export class ExpertClaimService {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const filtered = (claims as any[]).filter((c) =>
|
const tenantFiltered = (claims as any[]).filter((c) =>
|
||||||
claimCaseTouchesClient(c, clientKey),
|
claimCaseTouchesClient(c, clientKey),
|
||||||
);
|
);
|
||||||
|
const filtered = await this.filterDamageExpertClaimsByBranch(
|
||||||
|
tenantFiltered,
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
// Reconcile stale locks — if no decision was made, also clear assignedForReviewBy
|
// Reconcile stale locks — if no decision was made, also clear assignedForReviewBy
|
||||||
const staleLockToReconcile = filtered.filter(
|
const staleLockToReconcile = filtered.filter(
|
||||||
@@ -4323,7 +4542,7 @@ export class ExpertClaimService {
|
|||||||
{
|
{
|
||||||
lean: true,
|
lean: true,
|
||||||
select:
|
select:
|
||||||
"_id type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval",
|
"_id branchId initiatedByFieldExpertId type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval",
|
||||||
},
|
},
|
||||||
)) as any[];
|
)) as any[];
|
||||||
|
|
||||||
@@ -4332,9 +4551,31 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Scope to this reviewer's insurer via blame party clientId
|
// Scope to this reviewer's insurer via blame party clientId
|
||||||
const scopedBlames = blames.filter((b) =>
|
const tenantScopedBlames = blames.filter((b) =>
|
||||||
blameCaseTouchesClient(b, clientKey),
|
blameCaseTouchesClient(b, clientKey),
|
||||||
);
|
);
|
||||||
|
const scopedBlames = (
|
||||||
|
await Promise.all(
|
||||||
|
tenantScopedBlames.map(async (blame) => {
|
||||||
|
try {
|
||||||
|
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible(
|
||||||
|
{
|
||||||
|
fileMakerId: blame.initiatedByFieldExpertId
|
||||||
|
? String(blame.initiatedByFieldExpertId)
|
||||||
|
: null,
|
||||||
|
fileReviewerId: actor.sub,
|
||||||
|
caseBranchId: blame.branchId
|
||||||
|
? String(blame.branchId)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return blame;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter((blame): blame is any => blame != null);
|
||||||
|
|
||||||
if (scopedBlames.length === 0) {
|
if (scopedBlames.length === 0) {
|
||||||
return this.paginateClaimListV2([], query);
|
return this.paginateClaimListV2([], query);
|
||||||
@@ -4412,10 +4653,25 @@ export class ExpertClaimService {
|
|||||||
): Promise<GetClaimListV2ResponseDto> {
|
): Promise<GetClaimListV2ResponseDto> {
|
||||||
const makerOid = new Types.ObjectId(actor.sub);
|
const makerOid = new Types.ObjectId(actor.sub);
|
||||||
|
|
||||||
const makerBlames = (await this.blameRequestDbService.find(
|
const ownMakerBlames = (await this.blameRequestDbService.find(
|
||||||
{ isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid },
|
{ isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid },
|
||||||
{ lean: true, select: "_id type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval" },
|
{ lean: true, select: "_id branchId type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval" },
|
||||||
)) as any[];
|
)) as any[];
|
||||||
|
const hasBranchScopedFiles = ownMakerBlames.some(
|
||||||
|
(blame) => !!blame.branchId,
|
||||||
|
);
|
||||||
|
const makerBranchId = hasBranchScopedFiles
|
||||||
|
? await this.fanavaranLocationService.fileMakerBranchId(actor.sub)
|
||||||
|
: undefined;
|
||||||
|
if (hasBranchScopedFiles && !makerBranchId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"FileMaker account is not assigned to a branch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const makerBlames = ownMakerBlames.filter(
|
||||||
|
(blame) =>
|
||||||
|
!blame.branchId || String(blame.branchId) === makerBranchId,
|
||||||
|
);
|
||||||
|
|
||||||
if (makerBlames.length === 0) {
|
if (makerBlames.length === 0) {
|
||||||
return this.paginateClaimListV2([], query);
|
return this.paginateClaimListV2([], query);
|
||||||
@@ -4854,6 +5110,7 @@ export class ExpertClaimService {
|
|||||||
if (actor.role !== RoleEnum.FILE_REVIEWER) {
|
if (actor.role !== RoleEnum.FILE_REVIEWER) {
|
||||||
assertClaimCaseForExpertActor(claim, actor, linkedBlame);
|
assertClaimCaseForExpertActor(claim, actor, linkedBlame);
|
||||||
}
|
}
|
||||||
|
await this.assertActorBranchAccess(claim, actor, linkedBlame);
|
||||||
|
|
||||||
// Variables used both in the gate block and in the detail-build section below
|
// Variables used both in the gate block and in the detail-build section below
|
||||||
const isDamageExpertPhase =
|
const isDamageExpertPhase =
|
||||||
@@ -5050,6 +5307,10 @@ export class ExpertClaimService {
|
|||||||
buildFileLink,
|
buildFileLink,
|
||||||
resolveStoredFileUrl,
|
resolveStoredFileUrl,
|
||||||
});
|
});
|
||||||
|
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
|
||||||
|
history: (claim.damage as any)?.partSelectionHistory,
|
||||||
|
currentSelectedParts: selectedNormExpert,
|
||||||
|
});
|
||||||
|
|
||||||
// Vehicle payload — fall back to blame inquiry if claim vehicle is sparse
|
// Vehicle payload — fall back to blame inquiry if claim vehicle is sparse
|
||||||
let vehiclePayload = claim.vehicle as any;
|
let vehiclePayload = claim.vehicle as any;
|
||||||
@@ -5075,7 +5336,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([]),
|
||||||
]);
|
]);
|
||||||
@@ -5110,6 +5375,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) {
|
||||||
@@ -5219,6 +5489,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,
|
||||||
@@ -5229,6 +5500,7 @@ export class ExpertClaimService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
carAngles,
|
carAngles,
|
||||||
damagedParts,
|
damagedParts,
|
||||||
|
damagedPartsHistory,
|
||||||
awaitingFactorValidation: isFactorValidationPending,
|
awaitingFactorValidation: isFactorValidationPending,
|
||||||
requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval,
|
requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval,
|
||||||
fileMakerRejectionCount: (claim as any).fileMakerRejectionCount ?? 0,
|
fileMakerRejectionCount: (claim as any).fileMakerRejectionCount ?? 0,
|
||||||
@@ -5606,33 +5878,58 @@ export class ExpertClaimService {
|
|||||||
);
|
);
|
||||||
const mergedExpertAdded = [...existingExpertAdded, ...expertAddedToAppend];
|
const mergedExpertAdded = [...existingExpertAdded, ...expertAddedToAppend];
|
||||||
|
|
||||||
|
const changedAt = new Date();
|
||||||
|
const partSelectionRevision = buildDamagedPartSelectionRevision({
|
||||||
|
revisionId: new Types.ObjectId().toString(),
|
||||||
|
changedAt,
|
||||||
|
changedBy: {
|
||||||
|
actorId: actor.sub,
|
||||||
|
actorName: actor.fullName,
|
||||||
|
actorType: "damage_expert",
|
||||||
|
},
|
||||||
|
expertProfileSnapshot: damagedPartsEditSnapshot,
|
||||||
|
previousParts: previousNorm,
|
||||||
|
selectedParts: nextNorm,
|
||||||
|
previousMedia: prevMedia,
|
||||||
|
});
|
||||||
|
|
||||||
const $set: Record<string, unknown> = {
|
const $set: Record<string, unknown> = {
|
||||||
"damage.selectedParts": nextNorm,
|
"damage.selectedParts": nextNorm,
|
||||||
"media.damagedParts": nextMedia,
|
"media.damagedParts": nextMedia,
|
||||||
"damage.expertAddedParts": mergedExpertAdded,
|
"damage.expertAddedParts": mergedExpertAdded,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
const push: Record<string, unknown> = {
|
||||||
$set,
|
history: {
|
||||||
$push: {
|
type: "EXPERT_DAMAGED_PARTS_UPDATED",
|
||||||
history: {
|
actor: {
|
||||||
type: "EXPERT_DAMAGED_PARTS_UPDATED",
|
actorId: new Types.ObjectId(actor.sub),
|
||||||
actor: {
|
actorName: actor.fullName,
|
||||||
actorId: new Types.ObjectId(actor.sub),
|
actorType: "damage_expert",
|
||||||
actorName: actor.fullName,
|
},
|
||||||
actorType: "damage_expert",
|
timestamp: changedAt,
|
||||||
},
|
metadata: {
|
||||||
timestamp: new Date(),
|
previousSelectedParts: previous,
|
||||||
metadata: {
|
selectedParts: nextNorm,
|
||||||
previousSelectedParts: previous,
|
expertAddedParts: mergedExpertAdded,
|
||||||
selectedParts: nextNorm,
|
...(partSelectionRevision && {
|
||||||
expertAddedParts: mergedExpertAdded,
|
partSelectionRevisionId: partSelectionRevision.revisionId,
|
||||||
...(damagedPartsEditSnapshot && {
|
removedParts: partSelectionRevision.removedParts,
|
||||||
expertProfileSnapshot: damagedPartsEditSnapshot,
|
addedParts: partSelectionRevision.addedParts,
|
||||||
}),
|
}),
|
||||||
},
|
...(damagedPartsEditSnapshot && {
|
||||||
|
expertProfileSnapshot: damagedPartsEditSnapshot,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
if (partSelectionRevision) {
|
||||||
|
push["damage.partSelectionHistory"] = partSelectionRevision;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set,
|
||||||
|
$push: push,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -5640,6 +5937,7 @@ export class ExpertClaimService {
|
|||||||
selectedParts: nextNorm,
|
selectedParts: nextNorm,
|
||||||
previousSelectedParts: previous,
|
previousSelectedParts: previous,
|
||||||
expertAddedParts: mergedExpertAdded,
|
expertAddedParts: mergedExpertAdded,
|
||||||
|
partSelectionRevision,
|
||||||
message: "Damaged parts updated successfully.",
|
message: "Damaged parts updated successfully.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" +
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ExpertInsurerService } from "./expert-insurer.service";
|
import { ExpertInsurerService } from "./expert-insurer.service";
|
||||||
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
|
|
||||||
describe("insurer expert file summaries", () => {
|
describe("insurer expert file summaries", () => {
|
||||||
const service = Object.create(
|
const service = Object.create(
|
||||||
@@ -49,4 +50,25 @@ describe("insurer expert file summaries", () => {
|
|||||||
|
|
||||||
expect(summary.reviewDurationMinutes).toBe(0);
|
expect(summary.reviewDurationMinutes).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes the four Fanavaran claim references in insurer claim detail", async () => {
|
||||||
|
const detail = await (service as any).buildInsurerClaimDetail(
|
||||||
|
{
|
||||||
|
_id: "6aa50ce70545fc1906433c86",
|
||||||
|
status: ClaimCaseStatus.COMPLETED,
|
||||||
|
claimId: 101,
|
||||||
|
claimNo: 102,
|
||||||
|
dmgCaseId: 103,
|
||||||
|
expertiseId: 104,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(detail.fanavaran).toEqual({
|
||||||
|
claimId: 101,
|
||||||
|
claimNo: 102,
|
||||||
|
dmgCaseId: 103,
|
||||||
|
expertiseId: 104,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ import {
|
|||||||
localizeTimelineMetadata,
|
localizeTimelineMetadata,
|
||||||
} from "./helper/timeline-fa-labels";
|
} from "./helper/timeline-fa-labels";
|
||||||
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
|
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
|
||||||
|
import { serializeDamagedPartSelectionHistory } from "src/helpers/claim-damaged-part-audit";
|
||||||
|
import { fanavaranClaimReferences } from "src/claim-request-management/fanavaran-claim-references";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpertInsurerService {
|
export class ExpertInsurerService {
|
||||||
@@ -524,6 +526,8 @@ export class ExpertInsurerService {
|
|||||||
const d = { ...(damage as Record<string, unknown>) };
|
const d = { ...(damage as Record<string, unknown>) };
|
||||||
delete d.selectedOuterParts;
|
delete d.selectedOuterParts;
|
||||||
delete d.selectedPartIds;
|
delete d.selectedPartIds;
|
||||||
|
// Expose the stable, URL-enriched contract instead of raw audit storage.
|
||||||
|
delete d.partSelectionHistory;
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,6 +681,10 @@ export class ExpertInsurerService {
|
|||||||
catalogLikeKeyFromPart,
|
catalogLikeKeyFromPart,
|
||||||
buildFileLink,
|
buildFileLink,
|
||||||
});
|
});
|
||||||
|
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
|
||||||
|
history: (claim as any).damage?.partSelectionHistory,
|
||||||
|
currentSelectedParts: selectedNorm,
|
||||||
|
});
|
||||||
|
|
||||||
// Then add damagedParts to the return object
|
// Then add damagedParts to the return object
|
||||||
const requiredDocs = claim.requiredDocuments as any;
|
const requiredDocs = claim.requiredDocuments as any;
|
||||||
@@ -779,6 +787,7 @@ export class ExpertInsurerService {
|
|||||||
publicId: claim.publicId,
|
publicId: claim.publicId,
|
||||||
status: claim.status,
|
status: claim.status,
|
||||||
claimStatus: claim.claimStatus,
|
claimStatus: claim.claimStatus,
|
||||||
|
fanavaran: fanavaranClaimReferences(claim),
|
||||||
blameDocumentResendPending: claim.blameDocumentResendPending,
|
blameDocumentResendPending: claim.blameDocumentResendPending,
|
||||||
workflow: claim.workflow,
|
workflow: claim.workflow,
|
||||||
owner: ownerOut,
|
owner: ownerOut,
|
||||||
@@ -797,6 +806,7 @@ export class ExpertInsurerService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
carAngles,
|
carAngles,
|
||||||
damagedParts,
|
damagedParts,
|
||||||
|
damagedPartsHistory,
|
||||||
videoCapture,
|
videoCapture,
|
||||||
evaluation: evaluationEnriched,
|
evaluation: evaluationEnriched,
|
||||||
userRating: claim.userRating,
|
userRating: claim.userRating,
|
||||||
|
|||||||
55
src/fanavaran/fanavaran-location.service.spec.ts
Normal file
55
src/fanavaran/fanavaran-location.service.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { ForbiddenException } from "@nestjs/common";
|
||||||
|
import { FanavaranLocationService } from "./fanavaran-location.service";
|
||||||
|
|
||||||
|
describe("FanavaranLocationService branch scope", () => {
|
||||||
|
const makerId = "maker-1";
|
||||||
|
const reviewerId = "reviewer-1";
|
||||||
|
|
||||||
|
const createService = (makerBranch?: string, reviewerBranch?: string) =>
|
||||||
|
new FanavaranLocationService(
|
||||||
|
{
|
||||||
|
findById: jest.fn().mockResolvedValue(
|
||||||
|
makerBranch ? { branchId: makerBranch } : {},
|
||||||
|
),
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
findById: jest.fn().mockResolvedValue(
|
||||||
|
reviewerBranch ? { branchId: reviewerBranch } : {},
|
||||||
|
),
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
it("allows a reviewer to view a file from the same branch", async () => {
|
||||||
|
const service = createService("branch-1", "branch-1");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: makerId,
|
||||||
|
fileReviewerId: reviewerId,
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a reviewer from another branch", async () => {
|
||||||
|
const service = createService("branch-1", "branch-2");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: makerId,
|
||||||
|
fileReviewerId: reviewerId,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the immutable case branch snapshot", async () => {
|
||||||
|
const service = createService("old-branch", "branch-2");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: makerId,
|
||||||
|
fileReviewerId: reviewerId,
|
||||||
|
caseBranchId: "branch-2",
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -113,6 +113,54 @@ export class FanavaranLocationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fileMakerBranchId(
|
||||||
|
fileMakerId: string | null | undefined,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!fileMakerId) return undefined;
|
||||||
|
const doc = await this.fileMakerDbService.findById(String(fileMakerId));
|
||||||
|
const branchId = (doc as any)?.branchId;
|
||||||
|
return branchId ? String(branchId) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fileReviewerBranchId(
|
||||||
|
fileReviewerId: string | null | undefined,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!fileReviewerId) return undefined;
|
||||||
|
const doc = await this.fileReviewerDbService.findById(
|
||||||
|
String(fileReviewerId),
|
||||||
|
);
|
||||||
|
const branchId = (doc as any)?.branchId;
|
||||||
|
return branchId ? String(branchId) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V4/V5 case visibility is branch-scoped by the FileMaker who created it.
|
||||||
|
* Missing branch assignments are denied instead of widening visibility.
|
||||||
|
*/
|
||||||
|
async assertMakerReviewerBranchCompatible(input: {
|
||||||
|
fileMakerId?: string | null;
|
||||||
|
fileReviewerId?: string | null;
|
||||||
|
caseBranchId?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const reviewerBranchId = await this.fileReviewerBranchId(
|
||||||
|
input.fileReviewerId,
|
||||||
|
);
|
||||||
|
const caseBranchId =
|
||||||
|
(input.caseBranchId ? String(input.caseBranchId) : undefined) ??
|
||||||
|
(await this.fileMakerBranchId(input.fileMakerId));
|
||||||
|
|
||||||
|
if (!reviewerBranchId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"FileReviewer account is not assigned to a branch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!caseBranchId || caseBranchId !== reviewerBranchId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"This file belongs to another branch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async loadPrimaryLocationId(
|
private async loadPrimaryLocationId(
|
||||||
userId: string | null | undefined,
|
userId: string | null | undefined,
|
||||||
kind: "maker" | "reviewer",
|
kind: "maker" | "reviewer",
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export const FANAVARAN_REMOTE_LOOKUPS: FanavaranRemoteLookupDefinition[] = [
|
|||||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/vehicle-hull-accident-culprit-type`,
|
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/vehicle-hull-accident-culprit-type`,
|
||||||
cacheFile: "vehicle-hull-accident-culprit-type.json",
|
cacheFile: "vehicle-hull-accident-culprit-type.json",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "detection-accident-culprits",
|
||||||
|
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/detection-accident-culprits`,
|
||||||
|
cacheFile: "detection-accident-culprits.json",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "vehicle-hull-dmg-kind",
|
name: "vehicle-hull-dmg-kind",
|
||||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/vehicle-hull-dmg-kind`,
|
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/vehicle-hull-dmg-kind`,
|
||||||
|
|||||||
72
src/helpers/claim-damaged-part-audit.spec.ts
Normal file
72
src/helpers/claim-damaged-part-audit.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
buildDamagedPartSelectionRevision,
|
||||||
|
serializeDamagedPartSelectionHistory,
|
||||||
|
} from "./claim-damaged-part-audit";
|
||||||
|
|
||||||
|
describe("damaged-part selection audit", () => {
|
||||||
|
const hood = {
|
||||||
|
id: 101,
|
||||||
|
name: "hood",
|
||||||
|
side: "front",
|
||||||
|
label_fa: "کاپوت",
|
||||||
|
catalogKey: "front_hood",
|
||||||
|
};
|
||||||
|
const door = {
|
||||||
|
id: 202,
|
||||||
|
name: "door",
|
||||||
|
side: "left",
|
||||||
|
label_fa: "درب چپ",
|
||||||
|
catalogKey: "left_door",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("keeps the removed part capture and serializes it for panel display", () => {
|
||||||
|
const revision = buildDamagedPartSelectionRevision({
|
||||||
|
revisionId: "revision-1",
|
||||||
|
changedAt: new Date("2026-09-18T10:00:00.000Z"),
|
||||||
|
changedBy: {
|
||||||
|
actorId: "expert-1",
|
||||||
|
actorName: "Expert One",
|
||||||
|
actorType: "damage_expert",
|
||||||
|
},
|
||||||
|
previousParts: [hood, door],
|
||||||
|
selectedParts: [door],
|
||||||
|
previousMedia: [
|
||||||
|
{ path: "claims/hood.jpg", fileName: "hood.jpg" },
|
||||||
|
{ path: "claims/door.jpg", fileName: "door.jpg" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(revision?.removedParts).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 101,
|
||||||
|
capture: expect.objectContaining({ path: "claims/hood.jpg" }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const serialized = serializeDamagedPartSelectionHistory({
|
||||||
|
history: [revision],
|
||||||
|
currentSelectedParts: [door],
|
||||||
|
});
|
||||||
|
expect(serialized[0].removedParts[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 101,
|
||||||
|
captured: true,
|
||||||
|
currentlySelected: false,
|
||||||
|
url: expect.stringContaining("claims/hood.jpg"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create a revision for an unchanged or order-only submission", () => {
|
||||||
|
expect(
|
||||||
|
buildDamagedPartSelectionRevision({
|
||||||
|
revisionId: "revision-2",
|
||||||
|
changedAt: new Date(),
|
||||||
|
changedBy: { actorId: "expert-1", actorType: "damage_expert" },
|
||||||
|
previousParts: [hood, door],
|
||||||
|
selectedParts: [door, hood],
|
||||||
|
previousMedia: [],
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
170
src/helpers/claim-damaged-part-audit.ts
Normal file
170
src/helpers/claim-damaged-part-audit.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { resolveStoredFileUrl } from "src/helpers/urlCreator";
|
||||||
|
import {
|
||||||
|
coerceDamagedPartsMediaToArray,
|
||||||
|
partLookupKey,
|
||||||
|
type DamageSelectedPartV2,
|
||||||
|
} from "src/helpers/outer-damage-parts";
|
||||||
|
|
||||||
|
export interface DamagedPartAuditActor {
|
||||||
|
actorId: string;
|
||||||
|
actorName?: string;
|
||||||
|
actorType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredDamagedPartAuditRow extends DamageSelectedPartV2 {
|
||||||
|
capture?: {
|
||||||
|
path?: string;
|
||||||
|
fileName?: string;
|
||||||
|
url?: string;
|
||||||
|
capturedAt?: Date | string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredDamagedPartSelectionRevision {
|
||||||
|
revisionId: string;
|
||||||
|
changedAt: Date;
|
||||||
|
changedBy: DamagedPartAuditActor;
|
||||||
|
expertProfileSnapshot?: unknown;
|
||||||
|
previousSelectedParts: DamageSelectedPartV2[];
|
||||||
|
selectedParts: DamageSelectedPartV2[];
|
||||||
|
removedParts: StoredDamagedPartAuditRow[];
|
||||||
|
addedParts: StoredDamagedPartAuditRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DamagedPartSelectionHistoryRow {
|
||||||
|
revisionId: string;
|
||||||
|
changedAt: Date | string;
|
||||||
|
changedBy: DamagedPartAuditActor;
|
||||||
|
expertProfileSnapshot?: unknown;
|
||||||
|
removedParts: Array<
|
||||||
|
DamageSelectedPartV2 & {
|
||||||
|
partId: number | null;
|
||||||
|
captured: boolean;
|
||||||
|
url?: string;
|
||||||
|
fileName?: string;
|
||||||
|
capturedAt?: Date | string;
|
||||||
|
currentlySelected: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
addedParts: Array<
|
||||||
|
DamageSelectedPartV2 & {
|
||||||
|
partId: number | null;
|
||||||
|
currentlySelected: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainCapture(row: unknown): StoredDamagedPartAuditRow["capture"] {
|
||||||
|
if (!row || typeof row !== "object") return undefined;
|
||||||
|
const source = row as Record<string, unknown>;
|
||||||
|
const capture = {
|
||||||
|
...(source.path ? { path: String(source.path) } : {}),
|
||||||
|
...(source.fileName ? { fileName: String(source.fileName) } : {}),
|
||||||
|
...(source.url ? { url: String(source.url) } : {}),
|
||||||
|
...(source.capturedAt
|
||||||
|
? { capturedAt: source.capturedAt as Date | string }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
return Object.keys(capture).length > 0 ? capture : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds one immutable audit revision before the live selected-parts/media arrays
|
||||||
|
* are replaced. Returns null for a semantic no-op (including order-only changes).
|
||||||
|
*/
|
||||||
|
export function buildDamagedPartSelectionRevision(params: {
|
||||||
|
revisionId: string;
|
||||||
|
changedAt: Date;
|
||||||
|
changedBy: DamagedPartAuditActor;
|
||||||
|
expertProfileSnapshot?: unknown;
|
||||||
|
previousParts: DamageSelectedPartV2[];
|
||||||
|
selectedParts: DamageSelectedPartV2[];
|
||||||
|
previousMedia: unknown;
|
||||||
|
}): StoredDamagedPartSelectionRevision | null {
|
||||||
|
const previousKeys = new Set(params.previousParts.map(partLookupKey));
|
||||||
|
const selectedKeys = new Set(params.selectedParts.map(partLookupKey));
|
||||||
|
const mediaRows = coerceDamagedPartsMediaToArray(
|
||||||
|
params.previousMedia,
|
||||||
|
params.previousParts,
|
||||||
|
);
|
||||||
|
|
||||||
|
const removedParts = params.previousParts.flatMap((part, index) => {
|
||||||
|
if (selectedKeys.has(partLookupKey(part))) return [];
|
||||||
|
const capture = plainCapture(mediaRows[index]);
|
||||||
|
return [{ ...part, ...(capture ? { capture } : {}) }];
|
||||||
|
});
|
||||||
|
const addedParts = params.selectedParts
|
||||||
|
.filter((part) => !previousKeys.has(partLookupKey(part)))
|
||||||
|
.map((part) => ({ ...part }));
|
||||||
|
|
||||||
|
if (removedParts.length === 0 && addedParts.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
revisionId: params.revisionId,
|
||||||
|
changedAt: params.changedAt,
|
||||||
|
changedBy: params.changedBy,
|
||||||
|
...(params.expertProfileSnapshot
|
||||||
|
? { expertProfileSnapshot: params.expertProfileSnapshot }
|
||||||
|
: {}),
|
||||||
|
previousSelectedParts: params.previousParts.map((part) => ({ ...part })),
|
||||||
|
selectedParts: params.selectedParts.map((part) => ({ ...part })),
|
||||||
|
removedParts,
|
||||||
|
addedParts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts stored revisions into a stable, URL-enriched panel contract. */
|
||||||
|
export function serializeDamagedPartSelectionHistory(params: {
|
||||||
|
history: unknown;
|
||||||
|
currentSelectedParts: DamageSelectedPartV2[];
|
||||||
|
}): DamagedPartSelectionHistoryRow[] {
|
||||||
|
if (!Array.isArray(params.history)) return [];
|
||||||
|
const currentKeys = new Set(params.currentSelectedParts.map(partLookupKey));
|
||||||
|
|
||||||
|
return params.history
|
||||||
|
.filter((revision) => revision && typeof revision === "object")
|
||||||
|
.map((revision: any) => ({
|
||||||
|
revisionId: String(revision.revisionId ?? ""),
|
||||||
|
changedAt: revision.changedAt,
|
||||||
|
changedBy: {
|
||||||
|
actorId: String(revision.changedBy?.actorId ?? ""),
|
||||||
|
actorName: revision.changedBy?.actorName,
|
||||||
|
actorType: String(revision.changedBy?.actorType ?? "damage_expert"),
|
||||||
|
},
|
||||||
|
...(revision.expertProfileSnapshot
|
||||||
|
? { expertProfileSnapshot: revision.expertProfileSnapshot }
|
||||||
|
: {}),
|
||||||
|
removedParts: (Array.isArray(revision.removedParts)
|
||||||
|
? revision.removedParts
|
||||||
|
: []
|
||||||
|
).map((part: StoredDamagedPartAuditRow) => ({
|
||||||
|
id: part.id ?? null,
|
||||||
|
partId: part.id ?? null,
|
||||||
|
name: part.name,
|
||||||
|
side: part.side,
|
||||||
|
label_fa: part.label_fa,
|
||||||
|
...(part.catalogKey ? { catalogKey: part.catalogKey } : {}),
|
||||||
|
captured: !!part.capture,
|
||||||
|
...(part.capture?.fileName ? { fileName: part.capture.fileName } : {}),
|
||||||
|
...(part.capture?.capturedAt
|
||||||
|
? { capturedAt: part.capture.capturedAt }
|
||||||
|
: {}),
|
||||||
|
...(resolveStoredFileUrl(part.capture)
|
||||||
|
? { url: resolveStoredFileUrl(part.capture) }
|
||||||
|
: {}),
|
||||||
|
currentlySelected: currentKeys.has(partLookupKey(part)),
|
||||||
|
})),
|
||||||
|
addedParts: (Array.isArray(revision.addedParts)
|
||||||
|
? revision.addedParts
|
||||||
|
: []
|
||||||
|
).map((part: DamageSelectedPartV2) => ({
|
||||||
|
...part,
|
||||||
|
partId: part.id ?? null,
|
||||||
|
currentlySelected: currentKeys.has(partLookupKey(part)),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(revision) =>
|
||||||
|
revision.removedParts.length > 0 || revision.addedParts.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/helpers/claim-price-cap.spec.ts
Normal file
25
src/helpers/claim-price-cap.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
src/helpers/claim-price-cap.ts
Normal file
39
src/helpers/claim-price-cap.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -110,12 +110,42 @@ for (const [seg, key] of Object.entries(SEGMENT_ALIASES)) {
|
|||||||
NORM_TO_PART_KEY.set(seg, key);
|
NORM_TO_PART_KEY.set(seg, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PERSIAN_LABEL_ALIASES: Array<[string, string]> = [
|
||||||
|
["گلگیر عقب", "backFender"],
|
||||||
|
["درب عقب", "backDoor"],
|
||||||
|
["درب جلو", "frontDoor"],
|
||||||
|
["گلگیر جلو", "frontFender"],
|
||||||
|
["سپر جلو", "frontBumper"],
|
||||||
|
["سپر عقب", "frontBumper"],
|
||||||
|
["درب موتور", "Hood"],
|
||||||
|
["کاپوت", "Hood"],
|
||||||
|
["درب صندوق", "Trunk"],
|
||||||
|
["صندوق عقب", "Trunk"],
|
||||||
|
["سقف", "Roof"],
|
||||||
|
["کلاف", "coil"],
|
||||||
|
["ستون", "column"],
|
||||||
|
["سینی جلو", "frontTray"],
|
||||||
|
["سینی عقب", "backTray"],
|
||||||
|
["شاسی جلو", "frontChassis"],
|
||||||
|
["شاسی عقب", "backChassis"],
|
||||||
|
["رکاب", "carFootrest"],
|
||||||
|
["کف اتاق", "carFloor"],
|
||||||
|
];
|
||||||
|
|
||||||
export function normalizePriceDropKey(str: string): string {
|
export function normalizePriceDropKey(str: string): string {
|
||||||
return String(str ?? "")
|
return String(str ?? "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]/g, "");
|
.replace(/[^a-z0-9]/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePersianLabel(str: string): string {
|
||||||
|
return String(str ?? "")
|
||||||
|
.replace(/[يى]/g, "ی")
|
||||||
|
.replace(/ك/g, "ک")
|
||||||
|
.replace(/[\u200c\s()\-_]/g, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function parsePriceDropNumber(input: number | string): number {
|
export function parsePriceDropNumber(input: number | string): number {
|
||||||
if (typeof input === "number" && Number.isFinite(input)) return input;
|
if (typeof input === "number" && Number.isFinite(input)) return input;
|
||||||
return Number(
|
return Number(
|
||||||
@@ -191,6 +221,7 @@ export function buildPriceDropCatalogForApi(): Array<{
|
|||||||
export function resolvePriceDropPartKeyFromDamagePart(part: {
|
export function resolvePriceDropPartKeyFromDamagePart(part: {
|
||||||
name?: string;
|
name?: string;
|
||||||
catalogKey?: string;
|
catalogKey?: string;
|
||||||
|
label_fa?: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
if (part.catalogKey) {
|
if (part.catalogKey) {
|
||||||
@@ -205,6 +236,11 @@ export function resolvePriceDropPartKeyFromDamagePart(part: {
|
|||||||
const hit = NORM_TO_PART_KEY.get(norm);
|
const hit = NORM_TO_PART_KEY.get(norm);
|
||||||
if (hit && PRICE_DROP_PART_TABLE[hit]) return hit;
|
if (hit && PRICE_DROP_PART_TABLE[hit]) return hit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedLabel = normalizePersianLabel(part.label_fa ?? part.name ?? "");
|
||||||
|
for (const [label, key] of PERSIAN_LABEL_ALIASES) {
|
||||||
|
if (normalizedLabel.includes(normalizePersianLabel(label))) return key;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,21 +128,21 @@ describe("getExpertReplyPricingValidationError", () => {
|
|||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["price", "99,999", "parts[0].price"],
|
["price", "99,999", "parts[0].price"],
|
||||||
["salary", "10,000,000,001", "parts[0].salary"],
|
["salary", "100,000,000,001", "parts[0].salary"],
|
||||||
["totalPayment", "99,999", "parts[0].totalPayment"],
|
["totalPayment", "99,999", "parts[0].totalPayment"],
|
||||||
["daghi.price", "10,000,000,001", "parts[0].daghi.price"],
|
["daghi.price", "100,000,000,001", "parts[0].daghi.price"],
|
||||||
])(
|
])(
|
||||||
"enforces the amount range for %s",
|
"enforces the amount range for %s",
|
||||||
(field, invalidValue, expectedField) => {
|
(field, invalidValue, expectedField) => {
|
||||||
const part = {
|
const part = {
|
||||||
partId: 201,
|
partId: 201,
|
||||||
typeOfDamage: TypeOfDamage.Change,
|
typeOfDamage: TypeOfDamage.Change,
|
||||||
price: "100000",
|
price: "1000000",
|
||||||
salary: "100000",
|
salary: "1000000",
|
||||||
totalPayment: "100000",
|
totalPayment: "1000000",
|
||||||
daghi: {
|
daghi: {
|
||||||
option: DaghiOption.RECYCLED_PARTS_VALUE,
|
option: DaghiOption.RECYCLED_PARTS_VALUE,
|
||||||
price: "100000",
|
price: "1000000",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (field === "daghi.price") part.daghi.price = invalidValue;
|
if (field === "daghi.price") part.daghi.price = invalidValue;
|
||||||
|
|||||||
@@ -18,9 +18,22 @@ describe("parseIranLocalDateTime", () => {
|
|||||||
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies a separately supplied time to an explicit-offset date", () => {
|
||||||
|
const instant = parseIranLocalDateTime(
|
||||||
|
"2025-05-16T00:00:00.000Z",
|
||||||
|
"18:28",
|
||||||
|
);
|
||||||
|
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
it("combines Date calendar day in Iran with accidentTime", () => {
|
it("combines Date calendar day in Iran with accidentTime", () => {
|
||||||
const dateOnly = new Date("2025-05-16T00:00:00.000Z");
|
const dateOnly = new Date("2025-05-16T00:00:00.000Z");
|
||||||
const instant = parseIranLocalDateTime(dateOnly, "18:28");
|
const instant = parseIranLocalDateTime(dateOnly, "18:28");
|
||||||
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects out-of-range hours and minutes", () => {
|
||||||
|
expect(parseIranLocalDateTime("2025-05-16", "24:00")).toBeNull();
|
||||||
|
expect(parseIranLocalDateTime("2025-05-16", "12:60")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function normalizeTimeToHms(time: string): string | null {
|
|||||||
const hh = m[1].padStart(2, "0");
|
const hh = m[1].padStart(2, "0");
|
||||||
const mm = m[2];
|
const mm = m[2];
|
||||||
const ss = m[3] ?? "00";
|
const ss = m[3] ?? "00";
|
||||||
|
if (Number(hh) > 23 || Number(mm) > 59 || Number(ss) > 59) return null;
|
||||||
return `${hh}:${mm}:${ss}`;
|
return `${hh}:${mm}:${ss}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +57,12 @@ export function parseIranLocalDateTime(
|
|||||||
|
|
||||||
if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(raw)) {
|
if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(raw)) {
|
||||||
const d = new Date(raw);
|
const d = new Date(raw);
|
||||||
return Number.isNaN(d.getTime()) ? null : d;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
const cleanTime = (time ?? "").trim();
|
||||||
|
if (!cleanTime) return d;
|
||||||
|
const timeHms = normalizeTimeToHms(cleanTime);
|
||||||
|
if (!timeHms) return null;
|
||||||
|
return parseIranLocalIso(gregorianDateInIran(d), timeHms);
|
||||||
}
|
}
|
||||||
|
|
||||||
const naive = raw.match(NAIVE_ISO_DATETIME_RE);
|
const naive = raw.match(NAIVE_ISO_DATETIME_RE);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
|||||||
import { buildCoefficientsFromPartSeverities } from "./claim-price-drop";
|
import { buildCoefficientsFromPartSeverities } from "./claim-price-drop";
|
||||||
import {
|
import {
|
||||||
catalogPartIdFromSelectedPart,
|
catalogPartIdFromSelectedPart,
|
||||||
|
normalizeDamageSelectedParts,
|
||||||
resolveCatalogPartByPartId,
|
resolveCatalogPartByPartId,
|
||||||
resolvePartForExpertReply,
|
resolvePartForExpertReply,
|
||||||
resolveSelectedPartByPartId,
|
resolveSelectedPartByPartId,
|
||||||
@@ -43,36 +44,36 @@ describe("resolveSelectedPartByPartId", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves catalog id from outer catalog when not on claim", () => {
|
it("resolves catalog id from outer catalog when not on claim", () => {
|
||||||
const hit = resolveCatalogPartByPartId(201, ClaimVehicleTypeV2.HATCHBACK);
|
const hit = resolveCatalogPartByPartId(36, ClaimVehicleTypeV2.HATCHBACK);
|
||||||
expect(hit?.id).toBe(201);
|
expect(hit?.id).toBe(36);
|
||||||
expect(hit?.side).toBe("left");
|
expect(hit?.side).toBe("");
|
||||||
expect(hit?.catalogKey).toBe("left_backfender");
|
expect(hit?.catalogKey).toBe("36");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolvePartForExpertReply uses catalog for new expert line", () => {
|
it("resolvePartForExpertReply uses catalog for new expert line", () => {
|
||||||
const hit = resolvePartForExpertReply(
|
const hit = resolvePartForExpertReply(
|
||||||
201,
|
36,
|
||||||
[],
|
[],
|
||||||
ClaimVehicleTypeV2.HATCHBACK,
|
ClaimVehicleTypeV2.HATCHBACK,
|
||||||
);
|
);
|
||||||
expect(hit?.id).toBe(201);
|
expect(hit?.id).toBe(36);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sanitize fixes Persian side and re-hydrates from catalog id", () => {
|
it("sanitize fixes Persian side and re-hydrates from catalog id", () => {
|
||||||
const fixed = sanitizeDamageSelectedPartV2(
|
const fixed = sanitizeDamageSelectedPartV2(
|
||||||
{
|
{
|
||||||
id: 201,
|
id: 36,
|
||||||
name: "گلگیر عقب (چپ)",
|
name: "گلگير عقب سمت راننده",
|
||||||
side: "چپ",
|
side: "چپ",
|
||||||
label_fa: "",
|
label_fa: "",
|
||||||
catalogKey: "چپ",
|
catalogKey: "چپ",
|
||||||
},
|
},
|
||||||
ClaimVehicleTypeV2.HATCHBACK,
|
ClaimVehicleTypeV2.HATCHBACK,
|
||||||
);
|
);
|
||||||
expect(fixed.side).toBe("left");
|
expect(fixed.side).toBe("");
|
||||||
expect(fixed.name).toBe("backfender");
|
expect(fixed.name).toBe("36");
|
||||||
expect(fixed.catalogKey).toBe("left_backfender");
|
expect(fixed.catalogKey).toBe("36");
|
||||||
expect(catalogPartIdFromSelectedPart(fixed)).toBe(201);
|
expect(catalogPartIdFromSelectedPart(fixed)).toBe(36);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("internal parts have null catalog partId", () => {
|
it("internal parts have null catalog partId", () => {
|
||||||
@@ -86,34 +87,59 @@ describe("resolveSelectedPartByPartId", () => {
|
|||||||
expect(catalogPartIdFromSelectedPart(row)).toBeNull();
|
expect(catalogPartIdFromSelectedPart(row)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves a live Fanavaran id that is newer than the static snapshot", () => {
|
||||||
|
const [row] = normalizeDamageSelectedParts(
|
||||||
|
[{
|
||||||
|
id: 96,
|
||||||
|
name: "96",
|
||||||
|
side: "",
|
||||||
|
label_fa: "تیوتر",
|
||||||
|
catalogKey: "96",
|
||||||
|
}],
|
||||||
|
ClaimVehicleTypeV2.SEDAN,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(row).toEqual({
|
||||||
|
id: 96,
|
||||||
|
name: "96",
|
||||||
|
side: "",
|
||||||
|
label_fa: "تیوتر",
|
||||||
|
catalogKey: "96",
|
||||||
|
});
|
||||||
|
expect(catalogPartIdFromSelectedPart(row)).toBe(96);
|
||||||
|
expect(resolvePartForExpertReply(96, [row], ClaimVehicleTypeV2.SEDAN)).toBe(
|
||||||
|
row,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("recovers outer part wrongly stored as internal with Persian label", () => {
|
it("recovers outer part wrongly stored as internal with Persian label", () => {
|
||||||
const fixed = sanitizeDamageSelectedPartV2(
|
const fixed = sanitizeDamageSelectedPartV2(
|
||||||
{
|
{
|
||||||
id: null,
|
id: null,
|
||||||
name: "گلگیر عقب (چپ)",
|
name: "گلگير عقب سمت راننده",
|
||||||
side: "internal",
|
side: "internal",
|
||||||
label_fa: "گلگیر عقب (چپ)",
|
label_fa: "گلگير عقب سمت راننده",
|
||||||
},
|
},
|
||||||
ClaimVehicleTypeV2.HATCHBACK,
|
ClaimVehicleTypeV2.HATCHBACK,
|
||||||
);
|
);
|
||||||
expect(fixed.id).toBe(201);
|
expect(fixed.id).toBe(36);
|
||||||
expect(fixed.side).toBe("left");
|
expect(fixed.side).toBe("");
|
||||||
expect(fixed.name).toBe("backfender");
|
expect(fixed.name).toBe("36");
|
||||||
expect(fixed.catalogKey).toBe("left_backfender");
|
expect(fixed.catalogKey).toBe("36");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("price drop resolves catalog id when claim row is corrupt", () => {
|
it("price drop resolves catalog id when claim row is corrupt", () => {
|
||||||
const corrupt: DamageSelectedPartV2[] = [
|
const corrupt: DamageSelectedPartV2[] = [
|
||||||
{
|
{
|
||||||
id: null,
|
id: null,
|
||||||
name: "گلگیر عقب (چپ)",
|
name: "گلگير عقب سمت راننده",
|
||||||
side: "internal",
|
side: "internal",
|
||||||
label_fa: "گلگیر عقب (چپ)",
|
label_fa: "گلگير عقب سمت راننده",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
|
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
|
||||||
corrupt,
|
corrupt,
|
||||||
[{ partId: 201, severity: "Minor" }],
|
[{ partId: 36, severity: "Minor" }],
|
||||||
ClaimVehicleTypeV2.HATCHBACK,
|
ClaimVehicleTypeV2.HATCHBACK,
|
||||||
);
|
);
|
||||||
expect(errors).toHaveLength(0);
|
expect(errors).toHaveLength(0);
|
||||||
|
|||||||
@@ -466,6 +466,28 @@ export function sanitizeDamageSelectedPartV2(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fanavaran serves this catalog live and can add ids that do not exist in
|
||||||
|
// our fallback snapshot yet. Live rows are persisted with their numeric id
|
||||||
|
// repeated as `name`/`catalogKey` and an empty side. Preserve that identity
|
||||||
|
// instead of downgrading the row to an internal free-text part. Otherwise a
|
||||||
|
// newly-added Fanavaran part (for example id 96) disappears from the expert
|
||||||
|
// reply payload and later fails with PART_NOT_ON_CLAIM.
|
||||||
|
const numericCatalogIdentity =
|
||||||
|
id != null &&
|
||||||
|
Number.isFinite(id) &&
|
||||||
|
Number(id) > 0 &&
|
||||||
|
sideTrim === "" &&
|
||||||
|
(nameTrim === String(id) || String(catalogKey ?? "").trim() === String(id));
|
||||||
|
if (numericCatalogIdentity) {
|
||||||
|
return {
|
||||||
|
id: Number(id),
|
||||||
|
name: String(id),
|
||||||
|
side: "",
|
||||||
|
label_fa: labelTrim || nameTrim || String(id),
|
||||||
|
catalogKey: String(id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (isInternalDamageSide(side)) {
|
if (isInternalDamageSide(side)) {
|
||||||
return {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
|
|||||||
@@ -64,6 +64,20 @@ export class LookupsController {
|
|||||||
return await this.lookupsService.getVehicleHullAccidentCulpritType();
|
return await this.lookupsService.getVehicleHullAccidentCulpritType();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("detection-accident-culprits")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Fanavaran GEN.03 hull authority that determined the culprit",
|
||||||
|
description:
|
||||||
|
"Returns values for base-claim field AuthorityCulpritId from car/base-info/detection-accident-culprits.",
|
||||||
|
})
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: "Returns Fanavaran detection-accident-culprits rows",
|
||||||
|
schema: { type: "array", items: { type: "object" } },
|
||||||
|
})
|
||||||
|
async getDetectionAccidentCulprits() {
|
||||||
|
return await this.lookupsService.getDetectionAccidentCulprits();
|
||||||
|
}
|
||||||
|
|
||||||
@Get("vehicle-hull-dmg-kind")
|
@Get("vehicle-hull-dmg-kind")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Fanavaran GEN.06 hull damage kind lookup",
|
summary: "Fanavaran GEN.06 hull damage kind lookup",
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ export class LookupsService {
|
|||||||
return await this.getClientRemoteLookup("vehicle-hull-accident-culprit-type");
|
return await this.getClientRemoteLookup("vehicle-hull-accident-culprit-type");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDetectionAccidentCulprits(): Promise<any> {
|
||||||
|
return await this.getClientRemoteLookup("detection-accident-culprits");
|
||||||
|
}
|
||||||
|
|
||||||
async getVehicleHullDmgKind(): Promise<any> {
|
async getVehicleHullDmgKind(): Promise<any> {
|
||||||
return await this.getClientRemoteLookup("vehicle-hull-dmg-kind");
|
return await this.getClientRemoteLookup("vehicle-hull-dmg-kind");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { ProfileController } from "./profile.controller";
|
import { ProfileController } from "./profile.controller";
|
||||||
import { ProfileService } from "./profile.service";
|
import { ProfileService } from "./profile.service";
|
||||||
|
import { PlatesService } from "src/plates/plates.service";
|
||||||
|
import { JwtService } from "@nestjs/jwt";
|
||||||
|
|
||||||
describe("ProfileController", () => {
|
describe("ProfileController", () => {
|
||||||
let controller: ProfileController;
|
let controller: ProfileController;
|
||||||
@@ -8,7 +10,11 @@ describe("ProfileController", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [ProfileController],
|
controllers: [ProfileController],
|
||||||
providers: [ProfileService],
|
providers: [
|
||||||
|
{ provide: ProfileService, useValue: {} },
|
||||||
|
{ provide: PlatesService, useValue: {} },
|
||||||
|
{ provide: JwtService, useValue: {} },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<ProfileController>(ProfileController);
|
controller = module.get<ProfileController>(ProfileController);
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { ProfileService } from "./profile.service";
|
import { ProfileService } from "./profile.service";
|
||||||
|
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||||
|
|
||||||
describe("ProfileService", () => {
|
describe("ProfileService", () => {
|
||||||
let service: ProfileService;
|
let service: ProfileService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [ProfileService],
|
providers: [
|
||||||
|
ProfileService,
|
||||||
|
{
|
||||||
|
provide: UserDbService,
|
||||||
|
useValue: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
findOneAndUpdate: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<ProfileService>(ProfileService);
|
service = module.get<ProfileService>(ProfileService);
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export class CallCenterBlameV6Controller {
|
|||||||
description:
|
description:
|
||||||
"VIN alternative to `run-inquiry`. " +
|
"VIN alternative to `run-inquiry`. " +
|
||||||
"The agent supplies the chassis number and personal data collected from the caller. " +
|
"The agent supplies the chassis number and personal data collected from the caller. " +
|
||||||
"ESG chassis lookup (`policyByChassis`) is executed and the result is stored on the " +
|
"ESG two-factor chassis lookup (`carByChassis`) is executed and the result is stored on the " +
|
||||||
"blame document under `vehicle.vin` (plateId is left empty). " +
|
"blame document under `vehicle.vin` (plateId is left empty). " +
|
||||||
"Identical eligibility guards and insurer-company validation as the plate variant. " +
|
"Identical eligibility guards and insurer-company validation as the plate variant. " +
|
||||||
"After this call, proceed to `send-link` exactly as in the plate flow.",
|
"After this call, proceed to `send-link` exactly as in the plate flow.",
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
} from "class-validator";
|
||||||
import { CarDamagePartDto } from "src/claim-request-management/dto/car-part.dto";
|
import { CarDamagePartDto } from "src/claim-request-management/dto/car-part.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6,6 +13,19 @@ import { CarDamagePartDto } from "src/claim-request-management/dto/car-part.dto"
|
|||||||
* All fields optional so expert can submit in one or two steps (e.g. car parts first, then sheba/other).
|
* All fields optional so expert can submit in one or two steps (e.g. car parts first, then sheba/other).
|
||||||
*/
|
*/
|
||||||
export class ExpertCompleteClaimDataDto {
|
export class ExpertCompleteClaimDataDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Selected damaged-part IDs from the live Fanavaran car-components catalog.",
|
||||||
|
example: [9, 10, 30],
|
||||||
|
type: [Number],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: "selectedPartIds must be an array" })
|
||||||
|
@ArrayMinSize(1, { message: "At least one part ID must be selected" })
|
||||||
|
@ArrayUnique({ message: "Duplicate part IDs are not allowed" })
|
||||||
|
@IsInt({ each: true, message: "Each selected part ID must be an integer" })
|
||||||
|
selectedPartIds?: number[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: "Car part damage selection (same as selectCarPartDamage). Required for first claim data step.",
|
description: "Car part damage selection (same as selectCarPartDamage). Required for first claim data step.",
|
||||||
type: CarDamagePartDto,
|
type: CarDamagePartDto,
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export class BlameRequest {
|
|||||||
@Prop({ type: Types.ObjectId })
|
@Prop({ type: Types.ObjectId })
|
||||||
initiatedByFieldExpertId?: Types.ObjectId;
|
initiatedByFieldExpertId?: Types.ObjectId;
|
||||||
|
|
||||||
|
/** Branch snapshot of the expert/FileMaker who created this file. */
|
||||||
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
|
branchId?: Types.ObjectId;
|
||||||
|
|
||||||
/** True when this blame was created by a registrar. */
|
/** True when this blame was created by a registrar. */
|
||||||
@Prop({ default: false })
|
@Prop({ default: false })
|
||||||
registrarInitiated?: boolean;
|
registrarInitiated?: boolean;
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
|
|||||||
import { CreationMethod } from "./entities/schema/request-management.schema";
|
import { CreationMethod } from "./entities/schema/request-management.schema";
|
||||||
import { PartyRole } from "./entities/schema/partyRole.enum";
|
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||||
import { RequestManagementService } from "./request-management.service";
|
import { RequestManagementService } from "./request-management.service";
|
||||||
|
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||||
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
|
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||||
|
|
||||||
describe("RequestManagementService V4 FileMaker workflow", () => {
|
describe("RequestManagementService V4 FileMaker workflow", () => {
|
||||||
it("persists FIRST_INITIAL_FORM after the first party OTP is verified", async () => {
|
it("persists FIRST_INITIAL_FORM after the first party OTP is verified", async () => {
|
||||||
@@ -216,4 +219,138 @@ describe("RequestManagementService V4 FileMaker workflow", () => {
|
|||||||
WorkflowStep.FIRST_VIDEO,
|
WorkflowStep.FIRST_VIDEO,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
"resumes V4/V5 FileMaker work from the linked claim during partial document upload (approval=%s)",
|
||||||
|
async (requiresFileMakerApproval) => {
|
||||||
|
const fileMakerId = new Types.ObjectId();
|
||||||
|
const blameId = new Types.ObjectId();
|
||||||
|
const claimId = new Types.ObjectId();
|
||||||
|
const request = {
|
||||||
|
_id: blameId,
|
||||||
|
publicId: "BL-FILE-MAKER-RESUME",
|
||||||
|
requestNo: "BL-RESUME",
|
||||||
|
type: BlameRequestType.THIRD_PARTY,
|
||||||
|
status: CaseStatus.OPEN,
|
||||||
|
isMadeByFileMaker: true,
|
||||||
|
initiatedByFieldExpertId: fileMakerId,
|
||||||
|
requiresFileMakerApproval,
|
||||||
|
parties: [],
|
||||||
|
workflow: {
|
||||||
|
currentStep: WorkflowStep.SECOND_COMPLETED,
|
||||||
|
nextStep: WorkflowStep.WAITING_FOR_GUILT_DECISION,
|
||||||
|
completedSteps: [
|
||||||
|
WorkflowStep.FIRST_COMPLETED,
|
||||||
|
WorkflowStep.SECOND_COMPLETED,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const claim = {
|
||||||
|
_id: claimId,
|
||||||
|
blameRequestId: blameId,
|
||||||
|
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
|
workflow: {
|
||||||
|
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||||
|
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||||
|
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service =
|
||||||
|
new (RequestManagementService as any)() as RequestManagementService;
|
||||||
|
(service as any).blameRequestDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue(request),
|
||||||
|
find: jest.fn().mockResolvedValue([request]),
|
||||||
|
};
|
||||||
|
(service as any).claimCaseDbService = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(claim),
|
||||||
|
find: jest.fn().mockResolvedValue([claim]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const reopened = await service.getMyFileMakerFileDetail(
|
||||||
|
{ sub: String(fileMakerId), role: RoleEnum.FILE_MAKER },
|
||||||
|
String(blameId),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reopened.status).toBe(CaseStatus.OPEN);
|
||||||
|
expect(reopened.claimStatus).toBe(
|
||||||
|
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
|
);
|
||||||
|
expect(reopened.fileMakerResume).toEqual({
|
||||||
|
action: "UPLOAD_REQUIRED_DOCUMENTS",
|
||||||
|
entity: "CLAIM",
|
||||||
|
entityId: String(claimId),
|
||||||
|
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
|
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||||
|
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = await service.getMyFileMakerFiles({
|
||||||
|
sub: String(fileMakerId),
|
||||||
|
role: RoleEnum.FILE_MAKER,
|
||||||
|
});
|
||||||
|
expect(list.list[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
linkedClaimId: String(claimId),
|
||||||
|
claimStatus: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
|
fileMakerResume: expect.objectContaining({
|
||||||
|
action: "UPLOAD_REQUIRED_DOCUMENTS",
|
||||||
|
entityId: String(claimId),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("does not leave the blame narrative before all required signatures", async () => {
|
||||||
|
const fileMakerId = new Types.ObjectId();
|
||||||
|
const blameId = new Types.ObjectId();
|
||||||
|
const claimId = new Types.ObjectId();
|
||||||
|
const request = {
|
||||||
|
_id: blameId,
|
||||||
|
publicId: "BL-FILE-MAKER-NARRATIVE",
|
||||||
|
requestNo: "BL-NARRATIVE",
|
||||||
|
type: BlameRequestType.THIRD_PARTY,
|
||||||
|
status: CaseStatus.OPEN,
|
||||||
|
isMadeByFileMaker: true,
|
||||||
|
initiatedByFieldExpertId: fileMakerId,
|
||||||
|
parties: [],
|
||||||
|
workflow: {
|
||||||
|
currentStep: WorkflowStep.FIRST_DESCRIPTION,
|
||||||
|
nextStep: WorkflowStep.FIRST_SIGN,
|
||||||
|
completedSteps: [WorkflowStep.FIRST_VOICE],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const claim = {
|
||||||
|
_id: claimId,
|
||||||
|
blameRequestId: blameId,
|
||||||
|
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
|
workflow: {
|
||||||
|
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||||
|
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||||
|
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service =
|
||||||
|
new (RequestManagementService as any)() as RequestManagementService;
|
||||||
|
(service as any).blameRequestDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue(request),
|
||||||
|
};
|
||||||
|
(service as any).claimCaseDbService = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(claim),
|
||||||
|
};
|
||||||
|
|
||||||
|
const reopened = await service.getMyFileMakerFileDetail(
|
||||||
|
{ sub: String(fileMakerId), role: RoleEnum.FILE_MAKER },
|
||||||
|
String(blameId),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reopened.fileMakerResume).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "CONTINUE_BLAME",
|
||||||
|
entity: "BLAME",
|
||||||
|
entityId: String(blameId),
|
||||||
|
currentStep: WorkflowStep.FIRST_DESCRIPTION,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
Put,
|
Put,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -44,6 +45,7 @@ import {
|
|||||||
UploadRequiredDocumentV2ResponseDto,
|
UploadRequiredDocumentV2ResponseDto,
|
||||||
} from "src/claim-request-management/dto/upload-document-v2.dto";
|
} from "src/claim-request-management/dto/upload-document-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
||||||
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V4 FileMaker flow — first half of the split blame workflow.
|
* V4 FileMaker flow — first half of the split blame workflow.
|
||||||
@@ -96,10 +98,13 @@ export class FileMakerBlameV4Controller {
|
|||||||
@Get("my-files")
|
@Get("my-files")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List all blame files created by this FileMaker",
|
summary: "List all blame files created by this FileMaker",
|
||||||
description: "Returns all V4 FileMaker blame files initiated by the authenticated FileMaker.",
|
description: "Returns V4 FileMaker blame files using the shared search, sort, filter, and pagination query contract.",
|
||||||
})
|
})
|
||||||
async getMyFiles(@CurrentUser() fileMaker: any) {
|
async getMyFiles(
|
||||||
return this.requestManagementService.getMyFileMakerFiles(fileMaker);
|
@CurrentUser() fileMaker: any,
|
||||||
|
@Query() query: ListQueryV2Dto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.getMyFileMakerFiles(fileMaker, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("my-files/:requestId")
|
@Get("my-files/:requestId")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
Put,
|
Put,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -44,6 +45,7 @@ import {
|
|||||||
UploadRequiredDocumentV2ResponseDto,
|
UploadRequiredDocumentV2ResponseDto,
|
||||||
} from "src/claim-request-management/dto/upload-document-v2.dto";
|
} from "src/claim-request-management/dto/upload-document-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
||||||
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V5 FileMaker flow — identical to V4 but under the /v5/ prefix.
|
* V5 FileMaker flow — identical to V4 but under the /v5/ prefix.
|
||||||
@@ -95,10 +97,13 @@ export class FileMakerBlameV5Controller {
|
|||||||
@Get("my-files")
|
@Get("my-files")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List all blame files created by this FileMaker",
|
summary: "List all blame files created by this FileMaker",
|
||||||
description: "Returns all V5 FileMaker blame files initiated by the authenticated FileMaker.",
|
description: "Returns V5 FileMaker blame files using the shared search, sort, filter, and pagination query contract.",
|
||||||
})
|
})
|
||||||
async getMyFiles(@CurrentUser() fileMaker: any) {
|
async getMyFiles(
|
||||||
return this.requestManagementService.getMyFileMakerFiles(fileMaker);
|
@CurrentUser() fileMaker: any,
|
||||||
|
@Query() query: ListQueryV2Dto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.getMyFileMakerFiles(fileMaker, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("my-files/:requestId")
|
@Get("my-files/:requestId")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
CapturePartV2Dto,
|
CapturePartV2Dto,
|
||||||
CapturePartV2ResponseDto,
|
CapturePartV2ResponseDto,
|
||||||
} from "src/claim-request-management/dto/capture-part-v2.dto";
|
} from "src/claim-request-management/dto/capture-part-v2.dto";
|
||||||
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,10 +90,16 @@ export class FileReviewerBlameV4Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List available and assigned FileMaker blame files",
|
summary: "List available and assigned FileMaker blame files",
|
||||||
description:
|
description:
|
||||||
"Returns V4 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
|
"Returns V4 FileMaker blame files in this reviewer's insurer using the shared search, sort, filter, and pagination query contract: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
|
||||||
})
|
})
|
||||||
async getMyFiles(@CurrentUser() fileReviewer: any) {
|
async getMyFiles(
|
||||||
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
|
@CurrentUser() fileReviewer: any,
|
||||||
|
@Query() query: ListQueryV2Dto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.getMyFileReviewerFiles(
|
||||||
|
fileReviewer,
|
||||||
|
query,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("my-files/:requestId")
|
@Get("my-files/:requestId")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
CapturePartV2ResponseDto,
|
CapturePartV2ResponseDto,
|
||||||
} from "src/claim-request-management/dto/capture-part-v2.dto";
|
} from "src/claim-request-management/dto/capture-part-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
||||||
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V5 FileReviewer flow — same as V4 except after the damage expert completes
|
* V5 FileReviewer flow — same as V4 except after the damage expert completes
|
||||||
@@ -86,10 +88,16 @@ export class FileReviewerBlameV5Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List available and assigned FileMaker blame files",
|
summary: "List available and assigned FileMaker blame files",
|
||||||
description:
|
description:
|
||||||
"Returns V5 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
|
"Returns V5 FileMaker blame files in this reviewer's insurer using the shared search, sort, filter, and pagination query contract: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
|
||||||
})
|
})
|
||||||
async getMyFiles(@CurrentUser() fileReviewer: any) {
|
async getMyFiles(
|
||||||
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
|
@CurrentUser() fileReviewer: any,
|
||||||
|
@Query() query: ListQueryV2Dto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.getMyFileReviewerFiles(
|
||||||
|
fileReviewer,
|
||||||
|
query,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("my-files/:requestId")
|
@Get("my-files/:requestId")
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
@@ -74,7 +72,7 @@ describe("inquiry participant resolver", () => {
|
|||||||
},
|
},
|
||||||
vin: "NAAM01E15HK123456",
|
vin: "NAAM01E15HK123456",
|
||||||
}),
|
}),
|
||||||
).toThrow("previousPolicyholderNationalCode");
|
).toThrow("کد ملی بیمهگذار قبلی");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults an omitted registration state to CURRENT", () => {
|
it("defaults an omitted registration state to CURRENT", () => {
|
||||||
@@ -102,7 +100,7 @@ describe("inquiry participant resolver", () => {
|
|||||||
},
|
},
|
||||||
vin: "TOO-SHORT",
|
vin: "TOO-SHORT",
|
||||||
}),
|
}),
|
||||||
).toThrow("vehicle.vin must contain exactly 17 characters.");
|
).toThrow("شماره شاسی (VIN) باید دقیقاً ۱۷ کاراکتر باشد.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an incomplete current plate", () => {
|
it("rejects an incomplete current plate", () => {
|
||||||
@@ -115,7 +113,7 @@ describe("inquiry participant resolver", () => {
|
|||||||
ir: "22",
|
ir: "22",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toThrow("vehicle.currentPlate.centerDigits is required.");
|
).toThrow("سه رقم میانی پلاک در پلاک فعلی الزامی است.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid vehicle choice values", () => {
|
it("rejects invalid vehicle choice values", () => {
|
||||||
@@ -129,7 +127,7 @@ describe("inquiry participant resolver", () => {
|
|||||||
ir: "22",
|
ir: "22",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toThrow("vehicle.registrationState must be CURRENT or RECENTLY_TRANSFERRED.");
|
).toThrow("وضعیت پلاک خودرو باید «فعلی» یا «تازه تعویضشده» باشد.");
|
||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
resolveInquiryVehicle({
|
resolveInquiryVehicle({
|
||||||
@@ -141,7 +139,7 @@ describe("inquiry participant resolver", () => {
|
|||||||
},
|
},
|
||||||
isNewCar: "false" as any,
|
isNewCar: "false" as any,
|
||||||
}),
|
}),
|
||||||
).toThrow("vehicle.isNewCar must be a boolean.");
|
).toThrow("وضعیت صفر بودن خودرو نامعتبر است.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a previous policyholder national code for a current registration", () => {
|
it("rejects a previous policyholder national code for a current registration", () => {
|
||||||
@@ -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<{
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(query).toHaveBeenCalledTimes(2);
|
const result = await runCurrentPlateInquiry<{
|
||||||
expect(query).toHaveBeenNthCalledWith(1, currentPlate, "CURRENT");
|
mapped: { CompanyName?: string };
|
||||||
expect(query).toHaveBeenNthCalledWith(2, previousPlate, "PREVIOUS");
|
}>({
|
||||||
expect(result.plateKind).toBe("PREVIOUS");
|
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([
|
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",
|
mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" },
|
||||||
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 result = await runPlateInquiryWithFallback<{
|
await expect(
|
||||||
mapped: Record<string, any>;
|
runCurrentPlateInquiry<{ mapped: Record<string, any> }>({
|
||||||
}>({
|
|
||||||
vehicle: resolveInquiryVehicle({
|
|
||||||
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
|
|
||||||
currentPlate,
|
currentPlate,
|
||||||
previousPlate,
|
query,
|
||||||
vin: "NAAM01E15HK123456",
|
isUsable: (value) =>
|
||||||
previousPolicyholderNationalCode: "0098765432",
|
!!value.mapped.CompanyName &&
|
||||||
|
isMappedPolicyCurrent(value.mapped, "2026-09-13"),
|
||||||
}),
|
}),
|
||||||
fallbackCurrentPlate: currentPlate,
|
).rejects.toMatchObject({
|
||||||
query,
|
attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
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 = {
|
const currentPlate = {
|
||||||
leftDigits: "44",
|
leftDigits: "44",
|
||||||
centerAlphabet: "ب",
|
centerAlphabet: "ب",
|
||||||
centerDigits: "111",
|
centerDigits: "111",
|
||||||
ir: "22",
|
ir: "22",
|
||||||
};
|
};
|
||||||
const previousPlate = {
|
|
||||||
leftDigits: "55",
|
|
||||||
centerAlphabet: "ج",
|
|
||||||
centerDigits: "222",
|
|
||||||
ir: "33",
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runPlateInquiryWithFallback({
|
runCurrentPlateInquiry({
|
||||||
vehicle: resolveInquiryVehicle({
|
currentPlate,
|
||||||
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
|
vin: "NAAM01E15HK123456",
|
||||||
currentPlate,
|
|
||||||
previousPlate,
|
|
||||||
vin: "NAAM01E15HK123456",
|
|
||||||
previousPolicyholderNationalCode: "0098765432",
|
|
||||||
}),
|
|
||||||
fallbackCurrentPlate: currentPlate,
|
|
||||||
query: async () => ({ mapped: { CompanyName: "پارسیان" } }),
|
query: async () => ({ mapped: { CompanyName: "پارسیان" } }),
|
||||||
isUsable: () => false,
|
isUsable: () => false,
|
||||||
mappedValue: (value) => value.mapped,
|
|
||||||
}),
|
}),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
attempts: [
|
attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
|
||||||
{ 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("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 = {
|
const currentPlate = {
|
||||||
leftDigits: "44",
|
leftDigits: "44",
|
||||||
centerAlphabet: "ب",
|
centerAlphabet: "ب",
|
||||||
@@ -612,23 +529,11 @@ describe("inquiry participant resolver", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runPlateInquiryWithFallback({
|
runCurrentPlateInquiry({
|
||||||
vehicle: resolveInquiryVehicle({
|
currentPlate,
|
||||||
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
|
vin: "NAAM01E15HK123456",
|
||||||
currentPlate,
|
|
||||||
previousPlate: {
|
|
||||||
leftDigits: "55",
|
|
||||||
centerAlphabet: "ج",
|
|
||||||
centerDigits: "222",
|
|
||||||
ir: "33",
|
|
||||||
},
|
|
||||||
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);
|
||||||
@@ -647,7 +552,7 @@ describe("inquiry participant resolver", () => {
|
|||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, input as any),
|
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, input as any),
|
||||||
).toThrow("THIRD_PARTY_POLICYHOLDER does not support the unknown option.");
|
).toThrow("ثبت بیمهگذار شخص ثالث بهصورت نامشخص امکانپذیر نیست.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips the removed unknown field from historical participant output", () => {
|
it("strips the removed unknown field from historical participant output", () => {
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ export interface InquirySubjects {
|
|||||||
driverNationalCode: string;
|
driverNationalCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PARTICIPANT_ROLE_LABELS: Record<InquiryParticipantRole, string> = {
|
||||||
|
[InquiryParticipantRole.DRIVER]: "راننده",
|
||||||
|
[InquiryParticipantRole.VEHICLE_OWNER]: "مالک خودرو",
|
||||||
|
[InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER]: "بیمهگذار شخص ثالث",
|
||||||
|
[InquiryParticipantRole.CAR_BODY_POLICYHOLDER]: "بیمهگذار بدنه",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATE_FIELD_LABELS = {
|
||||||
|
leftDigits: "دو رقم سمت چپ پلاک",
|
||||||
|
centerAlphabet: "حرف پلاک",
|
||||||
|
centerDigits: "سه رقم میانی پلاک",
|
||||||
|
ir: "کد ایران پلاک",
|
||||||
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single routing seam for external inquiries. Policy checks belong to
|
* The single routing seam for external inquiries. Policy checks belong to
|
||||||
* their policyholder, Sheba belongs to the vehicle owner, and licence data
|
* their policyholder, Sheba belongs to the vehicle owner, and licence data
|
||||||
@@ -70,7 +84,7 @@ export function resolveInquirySubjects(
|
|||||||
): InquirySubjects {
|
): InquirySubjects {
|
||||||
if (!submission.vehicleOwner) {
|
if (!submission.vehicleOwner) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Vehicle owner identity is required for Sheba validation.",
|
"اطلاعات هویتی مالک خودرو برای استعلام شبا الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -102,7 +116,11 @@ function assertCompleteInquiryPlate(
|
|||||||
"ir",
|
"ir",
|
||||||
] as const) {
|
] as const) {
|
||||||
if (plate?.[field] == null || String(plate[field]).trim() === "") {
|
if (plate?.[field] == null || String(plate[field]).trim() === "") {
|
||||||
throw new BadRequestException(`${path}.${field} is required.`);
|
const plateLabel =
|
||||||
|
path === "vehicle.currentPlate" ? "پلاک فعلی" : "پلاک قبلی";
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${PLATE_FIELD_LABELS[field]} در ${plateLabel} الزامی است.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,21 +189,21 @@ function requiredIdentity(
|
|||||||
): ResolvedInquiryParticipant {
|
): ResolvedInquiryParticipant {
|
||||||
if (Object.prototype.hasOwnProperty.call(input, "phoneNumber")) {
|
if (Object.prototype.hasOwnProperty.call(input, "phoneNumber")) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${role} does not accept phoneNumber; phone numbers are collected separately from inquiry identity.`,
|
`شماره همراه ${PARTICIPANT_ROLE_LABELS[role]} باید جدا از اطلاعات هویتی استعلام ارسال شود.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const nationalCode = String(input.nationalCode ?? "").trim();
|
const nationalCode = String(input.nationalCode ?? "").trim();
|
||||||
const birthday = String(input.birthday ?? "").trim();
|
const birthday = String(input.birthday ?? "").trim();
|
||||||
if (!nationalCode || !birthday) {
|
if (!nationalCode || !birthday) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${role} requires nationalCode and birthday.`,
|
`کد ملی و تاریخ تولد ${PARTICIPANT_ROLE_LABELS[role]} الزامی است.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
role === InquiryParticipantRole.DRIVER &&
|
role === InquiryParticipantRole.DRIVER &&
|
||||||
typeof input.hasDrivingLicense !== "boolean"
|
typeof input.hasDrivingLicense !== "boolean"
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException("DRIVER requires hasDrivingLicense.");
|
throw new BadRequestException("وضعیت داشتن گواهینامه راننده الزامی است.");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
role === InquiryParticipantRole.DRIVER &&
|
role === InquiryParticipantRole.DRIVER &&
|
||||||
@@ -194,7 +212,7 @@ function requiredIdentity(
|
|||||||
!String(input.licenseType ?? "").trim())
|
!String(input.licenseType ?? "").trim())
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"DRIVER requires licenseNumber and licenseType when hasDrivingLicense is true.",
|
"شماره و نوع گواهینامه برای راننده دارای گواهینامه الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -220,7 +238,7 @@ export function resolveInquiryParticipants(
|
|||||||
);
|
);
|
||||||
if (!hasRoleCompleteInput) {
|
if (!hasRoleCompleteInput) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"driver, vehicleOwner, and thirdPartyPolicyholder are required in the structured inquiry format.",
|
"اطلاعات راننده، مالک خودرو و بیمهگذار شخص ثالث برای استعلام الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -228,7 +246,7 @@ export function resolveInquiryParticipants(
|
|||||||
input.carBodyPolicyholder != null
|
input.carBodyPolicyholder != null
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"CAR_BODY_POLICYHOLDER is not allowed for a THIRD_PARTY case.",
|
"بیمهگذار بدنه برای پرونده شخص ثالث قابل ثبت نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,19 +267,23 @@ export function resolveInquiryParticipants(
|
|||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
if (resolving.has(role)) {
|
if (resolving.has(role)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Participant sameAs references cannot be circular.",
|
"ارتباط اشخاص یکسان در اطلاعات استعلام نامعتبر است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const value = input[ROLE_FIELDS[role]] as
|
const value = input[ROLE_FIELDS[role]] as
|
||||||
| InquiryParticipantInputDto
|
| InquiryParticipantInputDto
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!value) throw new BadRequestException(`${role} is required.`);
|
if (!value) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`اطلاعات ${PARTICIPANT_ROLE_LABELS[role]} الزامی است.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
resolving.add(role);
|
resolving.add(role);
|
||||||
let participantId: string;
|
let participantId: string;
|
||||||
if (Object.prototype.hasOwnProperty.call(value, "unknown")) {
|
if (Object.prototype.hasOwnProperty.call(value, "unknown")) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${role} does not support the unknown option.`,
|
`ثبت ${PARTICIPANT_ROLE_LABELS[role]} بهصورت نامشخص امکانپذیر نیست.`,
|
||||||
);
|
);
|
||||||
} else if (value.sameAs) {
|
} else if (value.sameAs) {
|
||||||
const hasPersonSpecificFields = Object.entries(value).some(
|
const hasPersonSpecificFields = Object.entries(value).some(
|
||||||
@@ -269,7 +291,7 @@ export function resolveInquiryParticipants(
|
|||||||
);
|
);
|
||||||
if (hasPersonSpecificFields) {
|
if (hasPersonSpecificFields) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${role} must contain either sameAs or identity fields, not both.`,
|
`برای ${PARTICIPANT_ROLE_LABELS[role]} باید فقط ارتباط با شخص دیگر یا اطلاعات هویتی مستقل ارسال شود.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
participantId = resolveRole(value.sameAs);
|
participantId = resolveRole(value.sameAs);
|
||||||
@@ -280,7 +302,7 @@ export function resolveInquiryParticipants(
|
|||||||
);
|
);
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${role} duplicates an existing nationalCode; use sameAs instead.`,
|
`کد ملی ${PARTICIPANT_ROLE_LABELS[role]} تکراری است؛ ارتباط با شخص ثبتشده را انتخاب کنید.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
participants.set(participant.participantId, participant);
|
participants.set(participant.participantId, participant);
|
||||||
@@ -317,29 +339,29 @@ export function resolveInquiryVehicle(
|
|||||||
input: InquiryVehicleInputDto,
|
input: InquiryVehicleInputDto,
|
||||||
): ResolvedInquiryVehicle {
|
): ResolvedInquiryVehicle {
|
||||||
if (!input) {
|
if (!input) {
|
||||||
throw new BadRequestException("vehicle is required.");
|
throw new BadRequestException("اطلاعات خودرو برای استعلام الزامی است.");
|
||||||
}
|
}
|
||||||
const registrationState =
|
const registrationState =
|
||||||
input.registrationState ?? VehicleRegistrationState.CURRENT;
|
input.registrationState ?? VehicleRegistrationState.CURRENT;
|
||||||
if (!Object.values(VehicleRegistrationState).includes(registrationState)) {
|
if (!Object.values(VehicleRegistrationState).includes(registrationState)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"vehicle.registrationState must be CURRENT or RECENTLY_TRANSFERRED.",
|
"وضعیت پلاک خودرو باید «فعلی» یا «تازه تعویضشده» باشد.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (input.isNewCar != null && typeof input.isNewCar !== "boolean") {
|
if (input.isNewCar != null && typeof input.isNewCar !== "boolean") {
|
||||||
throw new BadRequestException("vehicle.isNewCar must be a boolean.");
|
throw new BadRequestException("وضعیت صفر بودن خودرو نامعتبر است.");
|
||||||
}
|
}
|
||||||
const previousPolicyholderNationalCode = String(
|
const previousPolicyholderNationalCode = String(
|
||||||
input.previousPolicyholderNationalCode ?? "",
|
input.previousPolicyholderNationalCode ?? "",
|
||||||
).trim();
|
).trim();
|
||||||
if (!input.currentPlate) {
|
if (!input.currentPlate) {
|
||||||
throw new BadRequestException("vehicle.currentPlate is required.");
|
throw new BadRequestException("پلاک فعلی خودرو برای استعلام الزامی است.");
|
||||||
}
|
}
|
||||||
assertCompleteInquiryPlate(input.currentPlate, "vehicle.currentPlate");
|
assertCompleteInquiryPlate(input.currentPlate, "vehicle.currentPlate");
|
||||||
const vin = String(input.vin ?? "").trim();
|
const vin = String(input.vin ?? "").trim();
|
||||||
if (vin && vin.length !== 17) {
|
if (vin && vin.length !== 17) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"vehicle.vin must contain exactly 17 characters.",
|
"شماره شاسی (VIN) باید دقیقاً ۱۷ کاراکتر باشد.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -347,7 +369,7 @@ export function resolveInquiryVehicle(
|
|||||||
(!input.previousPlate || !vin || !previousPolicyholderNationalCode)
|
(!input.previousPlate || !vin || !previousPolicyholderNationalCode)
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"RECENTLY_TRANSFERRED requires previousPlate, vin, and previousPolicyholderNationalCode.",
|
"برای خودروی تازه تعویضپلاکشده، پلاک قبلی، شماره شاسی (VIN) و کد ملی بیمهگذار قبلی الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -355,7 +377,7 @@ export function resolveInquiryVehicle(
|
|||||||
(input.previousPlate || input.previousPolicyholderNationalCode != null)
|
(input.previousPlate || input.previousPolicyholderNationalCode != null)
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"previousPlate and previousPolicyholderNationalCode are only allowed for RECENTLY_TRANSFERRED vehicles.",
|
"پلاک و کد ملی بیمهگذار قبلی فقط برای خودروی تازه تعویضپلاکشده قابل ثبت است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (input.previousPlate) {
|
if (input.previousPlate) {
|
||||||
@@ -371,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>,
|
||||||
@@ -407,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",
|
||||||
@@ -442,68 +431,27 @@ function assertStructuredInquiryInput(input: Record<string, any>): void {
|
|||||||
);
|
);
|
||||||
if (legacyFields.length > 0) {
|
if (legacyFields.length > 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Legacy inquiry fields are not accepted: ${legacyFields.join(", ")}. Use driver, vehicleOwner, thirdPartyPolicyholder, carBodyPolicyholder, and vehicle.`,
|
"ساختار قدیمی اطلاعات استعلام پذیرفته نمیشود؛ اطلاعات اشخاص و خودرو را در بخشهای جدید ارسال کنید.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
|
||||||
"Previous-plate inquiry does not match the submitted VIN/chassis; manual review is required.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -511,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;
|
||||||
@@ -524,75 +468,51 @@ export async function runPlateInquiryWithFallback<T>(options: {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (let index = 0; index < candidates.length; index += 1) {
|
try {
|
||||||
const candidate = candidates[index];
|
const value = await options.query(options.currentPlate);
|
||||||
const isLast = index === candidates.length - 1;
|
const usable = options.isUsable(value);
|
||||||
try {
|
if (!usable) {
|
||||||
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(
|
|
||||||
"No current usable policy was found for the submitted vehicle identifiers.",
|
|
||||||
) as BadRequestException & { attempts?: typeof attempts };
|
|
||||||
error.attempts = attempts;
|
|
||||||
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: false,
|
||||||
});
|
});
|
||||||
return { value, plateKind: candidate.kind, attempts };
|
const error = new BadRequestException(
|
||||||
} catch (error) {
|
options.errorMessage?.(value) ||
|
||||||
lastError = error;
|
"بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
|
||||||
const alreadyRecorded =
|
) as BadRequestException & { attempts?: typeof attempts };
|
||||||
typeof error === "object" &&
|
error.attempts = attempts;
|
||||||
error != null &&
|
throw error;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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("Inquiry failed for all plates.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeInquirySubmission<T extends Record<string, any>>(
|
export function normalizeInquirySubmission<T extends Record<string, any>>(
|
||||||
@@ -610,7 +530,7 @@ export function normalizeInquirySubmission<T extends Record<string, any>>(
|
|||||||
);
|
);
|
||||||
if (!driver || !thirdPartyPolicyholder) {
|
if (!driver || !thirdPartyPolicyholder) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Driver and third-party policyholder identities are required.",
|
"اطلاعات هویتی راننده و بیمهگذار شخص ثالث الزامی است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const vehicleOwner = participantForRole(
|
const vehicleOwner = participantForRole(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ReinquiryInquiriesResponseDto,
|
ReinquiryInquiriesResponseDto,
|
||||||
ReinquiryPartyResultDto,
|
ReinquiryPartyResultDto,
|
||||||
} from "./dto/reinquiry-inquiries.dto";
|
} from "./dto/reinquiry-inquiries.dto";
|
||||||
|
import { getInquiryErrorMessage } from "src/common/utils/inquiry-error";
|
||||||
|
|
||||||
type PlateParts = {
|
type PlateParts = {
|
||||||
leftDigits: number;
|
leftDigits: number;
|
||||||
@@ -48,7 +49,7 @@ export class InquiryRefreshService {
|
|||||||
|
|
||||||
if (!body.publicId && !body.blameRequestId && limit === 0) {
|
if (!body.publicId && !body.blameRequestId && limit === 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Provide publicId, blameRequestId, or limit for bulk refresh.",
|
"برای اجرای مجدد استعلام، شناسه عمومی پرونده، شناسه پرونده یا تعداد پروندهها را وارد کنید.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,14 +59,16 @@ export class InquiryRefreshService {
|
|||||||
if (body.publicId) filter.publicId = body.publicId;
|
if (body.publicId) filter.publicId = body.publicId;
|
||||||
if (body.blameRequestId) {
|
if (body.blameRequestId) {
|
||||||
if (!Types.ObjectId.isValid(body.blameRequestId)) {
|
if (!Types.ObjectId.isValid(body.blameRequestId)) {
|
||||||
throw new BadRequestException("Invalid blameRequestId");
|
throw new BadRequestException("شناسه پرونده معتبر نیست.");
|
||||||
}
|
}
|
||||||
filter._id = new Types.ObjectId(body.blameRequestId);
|
filter._id = new Types.ObjectId(body.blameRequestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let docs = await this.blameRequestDbService.find(filter, { lean: true });
|
let docs = await this.blameRequestDbService.find(filter, { lean: true });
|
||||||
if (!docs.length) {
|
if (!docs.length) {
|
||||||
throw new NotFoundException("No matching blame cases found");
|
throw new NotFoundException(
|
||||||
|
"پرونده تقصیر مطابق اطلاعات واردشده یافت نشد.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
docs = limit > 0 ? docs.slice(0, limit) : docs;
|
docs = limit > 0 ? docs.slice(0, limit) : docs;
|
||||||
|
|
||||||
@@ -111,8 +114,8 @@ export class InquiryRefreshService {
|
|||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
partyResults.push({
|
partyResults.push({
|
||||||
role,
|
role,
|
||||||
thirdParty: { ok: false, message: "party not found" },
|
thirdParty: { ok: false, message: "طرف پرونده یافت نشد." },
|
||||||
person: { ok: false, message: "party not found" },
|
person: { ok: false, message: "طرف پرونده یافت نشد." },
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -150,7 +153,9 @@ export class InquiryRefreshService {
|
|||||||
blameRequestId: new Types.ObjectId(String(doc._id)),
|
blameRequestId: new Types.ObjectId(String(doc._id)),
|
||||||
});
|
});
|
||||||
claimsUpdated = linkedClaims.length;
|
claimsUpdated = linkedClaims.length;
|
||||||
this.logger.log(`[dry-run] ${label} would update blame + ${claimsUpdated} claim(s)`);
|
this.logger.log(
|
||||||
|
`[dry-run] ${label} would update blame + ${claimsUpdated} claim(s)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -208,9 +213,7 @@ export class InquiryRefreshService {
|
|||||||
plateId: party?.vehicle?.plateId,
|
plateId: party?.vehicle?.plateId,
|
||||||
...(plate ? { plate } : {}),
|
...(plate ? { plate } : {}),
|
||||||
...(nationalCode ? { nationalCode } : {}),
|
...(nationalCode ? { nationalCode } : {}),
|
||||||
...(birthDate !== null && birthDate !== undefined
|
...(birthDate !== null && birthDate !== undefined ? { birthDate } : {}),
|
||||||
? { birthDate }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
@@ -223,8 +226,8 @@ export class InquiryRefreshService {
|
|||||||
result.thirdParty = {
|
result.thirdParty = {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: !plate
|
message: !plate
|
||||||
? "plate not found on party"
|
? "پلاک برای این طرف پرونده ثبت نشده است."
|
||||||
: "nationalCodeOfInsurer/nationalCodeOfDriver missing",
|
: "کد ملی برای این طرف پرونده ثبت نشده است.",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
await this.waitForRateLimit();
|
await this.waitForRateLimit();
|
||||||
@@ -249,7 +252,9 @@ export class InquiryRefreshService {
|
|||||||
inquiriesChanged = true;
|
inquiriesChanged = true;
|
||||||
result.thirdParty = {
|
result.thirdParty = {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: inquiry.mapped.Error.Message || "third-party inquiry error",
|
message:
|
||||||
|
inquiry.mapped.Error.Message ||
|
||||||
|
getInquiryErrorMessage(inquiry.mapped, "thirdPartyPlate"),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
nextParty = this.applyThirdPartyToParty(
|
nextParty = this.applyThirdPartyToParty(
|
||||||
@@ -292,11 +297,18 @@ export class InquiryRefreshService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.recordPartyInquiry(inquiries, "thirdParty", role, false, {}, error);
|
this.recordPartyInquiry(
|
||||||
|
inquiries,
|
||||||
|
"thirdParty",
|
||||||
|
role,
|
||||||
|
false,
|
||||||
|
{},
|
||||||
|
error,
|
||||||
|
);
|
||||||
inquiriesChanged = true;
|
inquiriesChanged = true;
|
||||||
result.thirdParty = {
|
result.thirdParty = {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: error?.message || String(error),
|
message: getInquiryErrorMessage(error, "thirdPartyPlate"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,8 +317,8 @@ export class InquiryRefreshService {
|
|||||||
result.person = {
|
result.person = {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: !nationalCode
|
message: !nationalCode
|
||||||
? "nationalCodeOfInsurer/nationalCodeOfDriver missing"
|
? "کد ملی برای این طرف پرونده ثبت نشده است."
|
||||||
: "insurerBirthday/driverBirthday missing",
|
: "تاریخ تولد برای این طرف پرونده ثبت نشده است.",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
await this.waitForRateLimit();
|
await this.waitForRateLimit();
|
||||||
@@ -333,7 +345,7 @@ export class InquiryRefreshService {
|
|||||||
inquiriesChanged = true;
|
inquiriesChanged = true;
|
||||||
result.person = {
|
result.person = {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: error?.message || String(error),
|
message: getInquiryErrorMessage(error, "personalIdentity"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -477,12 +489,14 @@ export class InquiryRefreshService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const blameDoc = await this.blameRequestDbService.findById(blameId);
|
const blameDoc = await this.blameRequestDbService.findById(blameId);
|
||||||
if (!blameDoc) {
|
if (!blameDoc) {
|
||||||
throw new NotFoundException(`Blame case ${blameId} not found`);
|
throw new NotFoundException("پرونده تقصیر یافت نشد.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const role of roles) {
|
for (const role of roles) {
|
||||||
const memParty = updatedParties.find((party) => party?.role === role);
|
const memParty = updatedParties.find((party) => party?.role === role);
|
||||||
const docIdx = blameDoc.parties.findIndex((party) => party?.role === role);
|
const docIdx = blameDoc.parties.findIndex(
|
||||||
|
(party) => party?.role === role,
|
||||||
|
);
|
||||||
if (!memParty || docIdx === -1) continue;
|
if (!memParty || docIdx === -1) continue;
|
||||||
|
|
||||||
const party = blameDoc.parties[docIdx];
|
const party = blameDoc.parties[docIdx];
|
||||||
@@ -514,7 +528,9 @@ export class InquiryRefreshService {
|
|||||||
party.insurance.company = memParty.insurance.company;
|
party.insurance.company = memParty.insurance.company;
|
||||||
}
|
}
|
||||||
if (memParty.insurance.financialCeiling !== undefined) {
|
if (memParty.insurance.financialCeiling !== undefined) {
|
||||||
party.insurance.financialCeiling = String(memParty.insurance.financialCeiling);
|
party.insurance.financialCeiling = String(
|
||||||
|
memParty.insurance.financialCeiling,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (memParty.insurance.startDate !== undefined) {
|
if (memParty.insurance.startDate !== undefined) {
|
||||||
party.insurance.startDate = memParty.insurance.startDate;
|
party.insurance.startDate = memParty.insurance.startDate;
|
||||||
@@ -541,7 +557,8 @@ export class InquiryRefreshService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const inquiryPatch: Record<string, unknown> = {};
|
const inquiryPatch: Record<string, unknown> = {};
|
||||||
if (inquiries.thirdParty) inquiryPatch["inquiries.thirdParty"] = inquiries.thirdParty;
|
if (inquiries.thirdParty)
|
||||||
|
inquiryPatch["inquiries.thirdParty"] = inquiries.thirdParty;
|
||||||
if (inquiries.person) inquiryPatch["inquiries.person"] = inquiries.person;
|
if (inquiries.person) inquiryPatch["inquiries.person"] = inquiries.person;
|
||||||
if (!Object.keys(inquiryPatch).length) return 0;
|
if (!Object.keys(inquiryPatch).length) return 0;
|
||||||
|
|
||||||
@@ -590,14 +607,16 @@ export class InquiryRefreshService {
|
|||||||
|
|
||||||
private normalizeInquiryError(error: any): Record<string, unknown> {
|
private normalizeInquiryError(error: any): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
message: error?.message || String(error),
|
message: getInquiryErrorMessage(error, "generic"),
|
||||||
status: error?.status ?? error?.response?.status,
|
status: error?.status ?? error?.response?.status,
|
||||||
data: error?.data ?? error?.response?.data,
|
data: error?.data ?? error?.response?.data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolvePartyPlate(party: Record<string, any>): PlateParts | null {
|
private resolvePartyPlate(party: Record<string, any>): PlateParts | null {
|
||||||
const fromPlateId = this.parsePlateFromCompactString(party?.vehicle?.plateId);
|
const fromPlateId = this.parsePlateFromCompactString(
|
||||||
|
party?.vehicle?.plateId,
|
||||||
|
);
|
||||||
if (fromPlateId) return fromPlateId;
|
if (fromPlateId) return fromPlateId;
|
||||||
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@@ -608,14 +627,24 @@ export class InquiryRefreshService {
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const leftDigits = this.firstPresent(candidate.Plk1, candidate.platePartOne);
|
const leftDigits = this.firstPresent(
|
||||||
|
candidate.Plk1,
|
||||||
|
candidate.platePartOne,
|
||||||
|
);
|
||||||
const centerAlphabet = this.firstPresent(
|
const centerAlphabet = this.firstPresent(
|
||||||
candidate.plateLetterid,
|
candidate.plateLetterid,
|
||||||
candidate.plateLetterId,
|
candidate.plateLetterId,
|
||||||
candidate.plateLetterTitle,
|
candidate.plateLetterTitle,
|
||||||
);
|
);
|
||||||
const centerDigits = this.firstPresent(candidate.Plk3, candidate.platePartThree);
|
const centerDigits = this.firstPresent(
|
||||||
const ir = this.firstPresent(candidate.PlkSrl, candidate.plkSrl, candidate.plateSerialNumber);
|
candidate.Plk3,
|
||||||
|
candidate.platePartThree,
|
||||||
|
);
|
||||||
|
const ir = this.firstPresent(
|
||||||
|
candidate.PlkSrl,
|
||||||
|
candidate.plkSrl,
|
||||||
|
candidate.plateSerialNumber,
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
leftDigits !== undefined &&
|
leftDigits !== undefined &&
|
||||||
@@ -623,7 +652,9 @@ export class InquiryRefreshService {
|
|||||||
centerDigits !== undefined &&
|
centerDigits !== undefined &&
|
||||||
ir !== undefined
|
ir !== undefined
|
||||||
) {
|
) {
|
||||||
const plateLetter = this.plateNormalizer.normalizePlateText(String(centerAlphabet));
|
const plateLetter = this.plateNormalizer.normalizePlateText(
|
||||||
|
String(centerAlphabet),
|
||||||
|
);
|
||||||
const parsed: PlateParts = {
|
const parsed: PlateParts = {
|
||||||
leftDigits: Number(leftDigits),
|
leftDigits: Number(leftDigits),
|
||||||
centerAlphabet: plateLetter,
|
centerAlphabet: plateLetter,
|
||||||
@@ -653,7 +684,9 @@ export class InquiryRefreshService {
|
|||||||
const ir = Number(irRaw);
|
const ir = Number(irRaw);
|
||||||
const leftDigits = Number(leftRaw);
|
const leftDigits = Number(leftRaw);
|
||||||
const centerDigits = Number(centerRaw);
|
const centerDigits = Number(centerRaw);
|
||||||
const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || ""));
|
const centerAlphabet = this.plateNormalizer.normalizePlateText(
|
||||||
|
String(alphaRaw || ""),
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(ir) ||
|
!Number.isFinite(ir) ||
|
||||||
!Number.isFinite(leftDigits) ||
|
!Number.isFinite(leftDigits) ||
|
||||||
@@ -685,6 +718,8 @@ export class InquiryRefreshService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private firstPresent(...values: unknown[]): unknown {
|
private firstPresent(...values: unknown[]): unknown {
|
||||||
return values.find((value) => value !== undefined && value !== null && value !== "");
|
return values.find(
|
||||||
|
(value) => value !== undefined && value !== null && value !== "",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,8 @@ describe("damaged-party inquiry requirements", () => {
|
|||||||
const service = getService();
|
const service = getService();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
(service as any).validateShebaV3(
|
(service as any).validateShebaV3(undefined, "0012345678", "client-id"),
|
||||||
undefined,
|
).rejects.toThrow("شماره شبا برای طرف زیاندیده الزامی است.");
|
||||||
"0012345678",
|
|
||||||
"client-id",
|
|
||||||
),
|
|
||||||
).rejects.toThrow("sheba is required for the damaged party.");
|
|
||||||
expect(
|
expect(
|
||||||
(service as any).sandHubService.getShebaValidation,
|
(service as any).sandHubService.getShebaValidation,
|
||||||
).not.toHaveBeenCalled();
|
).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
|||||||
publicId: "BLM-OPEN",
|
publicId: "BLM-OPEN",
|
||||||
type: "THIRD_PARTY",
|
type: "THIRD_PARTY",
|
||||||
status: "WAITING_FOR_FILE_REVIEWER",
|
status: "WAITING_FOR_FILE_REVIEWER",
|
||||||
|
createdAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||||
isMadeByFileMaker: true,
|
isMadeByFileMaker: true,
|
||||||
expertInitiated: true,
|
expertInitiated: true,
|
||||||
creationMethod: "IN_PERSON",
|
creationMethod: "IN_PERSON",
|
||||||
@@ -34,6 +35,12 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
|||||||
undefined,
|
undefined,
|
||||||
blameRequestDbService,
|
blameRequestDbService,
|
||||||
) as RequestManagementService;
|
) as RequestManagementService;
|
||||||
|
(service as any).claimCaseDbService = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
(service as any).fanavaranLocationService = {
|
||||||
|
assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
return { service, blameRequestDbService };
|
return { service, blameRequestDbService };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +53,7 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
|||||||
clientKey: String(clientId),
|
clientKey: String(clientId),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result.list).toEqual([
|
||||||
expect.objectContaining({ _id: sealedFile._id, publicId: "BLM-OPEN" }),
|
expect.objectContaining({ _id: sealedFile._id, publicId: "BLM-OPEN" }),
|
||||||
]);
|
]);
|
||||||
expect(blameRequestDbService.find).toHaveBeenCalledWith(
|
expect(blameRequestDbService.find).toHaveBeenCalledWith(
|
||||||
@@ -82,7 +89,50 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
|||||||
clientKey: String(clientId),
|
clientKey: String(clientId),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
expect(result.list).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not list an open file from another branch", async () => {
|
||||||
|
const { service } = createService([sealedFile]);
|
||||||
|
(service as any).fanavaranLocationService
|
||||||
|
.assertMakerReviewerBranchCompatible.mockRejectedValue(
|
||||||
|
new Error("This file belongs to another branch."),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getMyFileReviewerFiles({
|
||||||
|
sub: String(reviewerId),
|
||||||
|
role: RoleEnum.FILE_REVIEWER,
|
||||||
|
clientKey: String(clientId),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.list).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts and paginates the reviewer inbox with the shared list contract", async () => {
|
||||||
|
const olderFile = {
|
||||||
|
...sealedFile,
|
||||||
|
_id: new Types.ObjectId(),
|
||||||
|
publicId: "BLM-OLDER",
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
const { service } = createService([olderFile, sealedFile]);
|
||||||
|
|
||||||
|
const result = await service.getMyFileReviewerFiles(
|
||||||
|
{
|
||||||
|
sub: String(reviewerId),
|
||||||
|
role: RoleEnum.FILE_REVIEWER,
|
||||||
|
clientKey: String(clientId),
|
||||||
|
},
|
||||||
|
{ page: 1, limit: 1, sortBy: "createdAt", sortOrder: "desc" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({ total: 2, page: 1, limit: 1, totalPages: 2 }),
|
||||||
|
);
|
||||||
|
expect(result.list).toHaveLength(1);
|
||||||
|
expect(result.list[0]).toEqual(
|
||||||
|
expect.objectContaining({ publicId: "BLM-OPEN" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not expose an open file's details to a reviewer from another tenant", async () => {
|
it("does not expose an open file's details to a reviewer from another tenant", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
|
import { PartyRole } from "./entities/schema/partyRole.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 = {
|
||||||
|
getCarByChassisInquiry: jest.fn().mockResolvedValue({
|
||||||
|
raw: {},
|
||||||
|
mapped: { CompanyName: "پارسیان" },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const submission = service.normalizeInquiryInput(
|
||||||
|
BlameRequestType.THIRD_PARTY,
|
||||||
|
participantInput(BlameRequestType.THIRD_PARTY),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.getThirdPartyVinInquiry(submission);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
service.sandHubService.getCarByChassisInquiry,
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
service.sandHubService.getCarByChassisInquiry,
|
||||||
|
).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
nationalCode: "0022222222",
|
||||||
|
chassisNo: vehicle.vin,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs both third-party and car-body inquiries for a car-body VIN", async () => {
|
||||||
|
const service = Object.create(RequestManagementService.prototype) as any;
|
||||||
|
service.getThirdPartyVinInquiry = jest.fn().mockResolvedValue({
|
||||||
|
raw: { success: true },
|
||||||
|
mapped: {
|
||||||
|
CompanyCode: "8",
|
||||||
|
CompanyName: "بیمه پارسیان",
|
||||||
|
PrntPlcyCmpDocNo: "TP-1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
service.sandHubService = {
|
||||||
|
getCarBodyInquiry: jest.fn().mockResolvedValue({
|
||||||
|
source: "ESG_CAR_BODY_VIN_INQUIRY",
|
||||||
|
raw: { policyId: 123 },
|
||||||
|
mapped: {
|
||||||
|
policyNumber: "BODY-1",
|
||||||
|
companyId: "8",
|
||||||
|
CompanyName: "بیمه پارسیان",
|
||||||
|
VinNumberField: vehicle.vin,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
service.clientService = {
|
||||||
|
findOrCreateClientByCompanyCode: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ _id: "client-third-party" }),
|
||||||
|
};
|
||||||
|
service.resolveCarBodyPolicyClientId = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue("client-car-body");
|
||||||
|
service.runParticipantPersonalInquiries = jest.fn().mockResolvedValue(undefined);
|
||||||
|
service.runVehicleOwnershipInquiry = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const req: any = {
|
||||||
|
_id: "car-body-vin-request",
|
||||||
|
type: BlameRequestType.CAR_BODY,
|
||||||
|
inquiries: {},
|
||||||
|
};
|
||||||
|
const party: any = { person: {}, vehicle: {}, insurance: {} };
|
||||||
|
|
||||||
|
await service.runPartyInquiriesVinV3Internal(
|
||||||
|
req,
|
||||||
|
participantInput(BlameRequestType.CAR_BODY),
|
||||||
|
PartyRole.FIRST,
|
||||||
|
party,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(service.getThirdPartyVinInquiry).toHaveBeenCalledTimes(1);
|
||||||
|
expect(service.sandHubService.getCarBodyInquiry).toHaveBeenCalledWith({
|
||||||
|
nationalCodeOfInsurer: "0033333333",
|
||||||
|
plate: vehicle.vin,
|
||||||
|
});
|
||||||
|
expect(req.inquiries.thirdParty.has).toBe(true);
|
||||||
|
expect(req.inquiries.carBody.has).toBe(true);
|
||||||
|
expect(party.insurance.policyNumber).toBe("TP-1");
|
||||||
|
expect(party.insurance.carBodyInsurance.policyNumber).toBe("BODY-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,7 @@ export class RequestManagementV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List my blame requests (V2)",
|
summary: "List my blame requests (V2)",
|
||||||
description:
|
description:
|
||||||
"Party-owned blame files, or files initiated by the current FIELD_EXPERT / REGISTRAR. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.",
|
"Party-owned blame files, or files initiated by the current FIELD_EXPERT / REGISTRAR. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType`, `startDate`, `endDate`.",
|
||||||
})
|
})
|
||||||
async getAllBlameRequestsV2(
|
async getAllBlameRequestsV2(
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SandHubService } from "./sand-hub.service";
|
import { SandHubService } from "./sand-hub.service";
|
||||||
import {
|
import {
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service";
|
import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service";
|
||||||
@@ -58,7 +59,9 @@ describe("SandHubService inquiry mocks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps disabled-live third-party mock policy usable", async () => {
|
it("keeps disabled-live third-party mock policy usable", async () => {
|
||||||
const result = await service.getTejaratBlockInquiry(userDetail);
|
const result = await service.getTejaratBlockInquiry(userDetail, {
|
||||||
|
enforceDeploymentClientMatch: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(isMappedPolicyCurrent(result.mapped)).toBe(true);
|
expect(isMappedPolicyCurrent(result.mapped)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -121,6 +124,17 @@ describe("SandHubService inquiry mocks", () => {
|
|||||||
expect(httpService.post).not.toHaveBeenCalled();
|
expect(httpService.post).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns a contextual Persian error when no car-body policy matches", async () => {
|
||||||
|
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||||
|
lookupsService.findLastProcessedCarPolicy.mockRejectedValue(
|
||||||
|
new NotFoundException("No active Fanavaran car-body policy was found"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.getCarBodyInquiry(userDetail)).rejects.toThrow(
|
||||||
|
"بیمهنامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not let an offline third-party seed override a live ESG inquiry", async () => {
|
it("does not let an offline third-party seed override a live ESG inquiry", async () => {
|
||||||
process.env.CLIENT_ID = "8";
|
process.env.CLIENT_ID = "8";
|
||||||
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||||
@@ -129,12 +143,10 @@ describe("SandHubService inquiry mocks", () => {
|
|||||||
raw: { mocked: true },
|
raw: { mocked: true },
|
||||||
mapped: { PrntPlcyCmpDocNo: "MOCK-POLICY" },
|
mapped: { PrntPlcyCmpDocNo: "MOCK-POLICY" },
|
||||||
});
|
});
|
||||||
const esg = jest
|
const esg = jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
|
||||||
.spyOn(service as any, "makeEsgRequest")
|
success: true,
|
||||||
.mockResolvedValue({
|
data: { PrntCmpDocNo: "REAL-ESG-POLICY" },
|
||||||
success: true,
|
});
|
||||||
data: { PrntCmpDocNo: "REAL-ESG-POLICY" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.getTejaratBlockInquiry(userDetail);
|
const result = await service.getTejaratBlockInquiry(userDetail);
|
||||||
|
|
||||||
@@ -143,6 +155,50 @@ describe("SandHubService inquiry mocks", () => {
|
|||||||
expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY");
|
expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
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("رکوردی یافت نشد");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves ESG messageFa for a VIN inquiry", async () => {
|
||||||
|
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||||
|
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
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.getCarByChassisInquiry({
|
||||||
|
nationalCode: "0012345678",
|
||||||
|
chassisNo: "NAAR03HFFRDE07024",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.mapped.Error.Message).toBe(
|
||||||
|
"نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a guilty third-party policy issued by another insurer", async () => {
|
it("rejects a guilty third-party policy issued by another insurer", async () => {
|
||||||
process.env.CLIENT_ID = "15";
|
process.env.CLIENT_ID = "15";
|
||||||
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||||
@@ -183,10 +239,10 @@ describe("SandHubService inquiry mocks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getPolicyByChassisInquiry(
|
service.getCarByChassisInquiry(
|
||||||
{
|
{
|
||||||
nationalCode: "1234567890",
|
nationalCode: "1234567890",
|
||||||
chassis: "NAAR03HFFRDE07024",
|
chassisNo: "NAAR03HFFRDE07024",
|
||||||
},
|
},
|
||||||
{ enforceDeploymentClientMatch: true },
|
{ enforceDeploymentClientMatch: true },
|
||||||
),
|
),
|
||||||
@@ -200,16 +256,16 @@ describe("SandHubService inquiry mocks", () => {
|
|||||||
data: { CmpCod: "8", CmpNam: "بیمه پارسیان" },
|
data: { CmpCod: "8", CmpNam: "بیمه پارسیان" },
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.getPolicyByChassisInquiry({
|
await service.getCarByChassisInquiry({
|
||||||
nationalCode: "0012345678",
|
nationalCode: "0012345678",
|
||||||
chassis: "NAAR03HFFRDE07024",
|
chassisNo: "NAAR03HFFRDE07024",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(esg).toHaveBeenCalledWith(
|
expect(esg).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("/inquiry/policyByChassis"),
|
expect.stringContaining("/inquiry/carByChassis"),
|
||||||
{
|
{
|
||||||
nationalCode: "0012345678",
|
nationalCode: "0012345678",
|
||||||
chassis: "NAAR03HFFRDE07024",
|
chassisNo: "NAAR03HFFRDE07024",
|
||||||
},
|
},
|
||||||
"vinChassis",
|
"vinChassis",
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
|||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
import { mapEsgCarBodyPolicyToInquiry } from "./esg-car-body-inquiry.mapper";
|
import { mapEsgCarBodyPolicyToInquiry } from "./esg-car-body-inquiry.mapper";
|
||||||
import type { Plates } from "src/Types&Enums/plate.interface";
|
import type { Plates } from "src/Types&Enums/plate.interface";
|
||||||
|
import {
|
||||||
|
getInquiryErrorMessage,
|
||||||
|
inquiryErrorStatus,
|
||||||
|
isInquiryFailurePayload,
|
||||||
|
isInquiryTimeout,
|
||||||
|
type InquiryErrorContext,
|
||||||
|
} from "src/common/utils/inquiry-error";
|
||||||
|
|
||||||
type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & {
|
type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & {
|
||||||
plate: Plates | string;
|
plate: Plates | string;
|
||||||
@@ -31,9 +38,6 @@ type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SandHubService {
|
export class SandHubService {
|
||||||
private static readonly ESG_INQUIRY_UNAVAILABLE_MESSAGE =
|
|
||||||
"استعلام در دسترس نیست";
|
|
||||||
|
|
||||||
private readonly logger = new Logger(SandHubService.name);
|
private readonly logger = new Logger(SandHubService.name);
|
||||||
private loginToken: string | null = null;
|
private loginToken: string | null = null;
|
||||||
private tokenExpiry: Date | null = null;
|
private tokenExpiry: Date | null = null;
|
||||||
@@ -90,6 +94,30 @@ export class SandHubService {
|
|||||||
return resolveFanavaranClientKey() === "parsian";
|
return resolveFanavaranClientKey() === "parsian";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private inquiryContext(type: ExternalInquiryType): InquiryErrorContext {
|
||||||
|
if (type === "vinChassis") return "thirdPartyVin";
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private throwInquiryError(
|
||||||
|
error: unknown,
|
||||||
|
context: InquiryErrorContext,
|
||||||
|
): never {
|
||||||
|
const message = getInquiryErrorMessage(error, context);
|
||||||
|
const status = inquiryErrorStatus(error);
|
||||||
|
|
||||||
|
if (error instanceof ForbiddenException || status === 403) {
|
||||||
|
throw new ForbiddenException(message);
|
||||||
|
}
|
||||||
|
if (isInquiryTimeout(error) || status === 504) {
|
||||||
|
throw new GatewayTimeoutException(message);
|
||||||
|
}
|
||||||
|
if ([400, 404, 409, 422].includes(status ?? 0)) {
|
||||||
|
throw new BadRequestException(message);
|
||||||
|
}
|
||||||
|
throw new ServiceUnavailableException(message);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A case may proceed only when the policy belongs to the insurer served by
|
* A case may proceed only when the policy belongs to the insurer served by
|
||||||
* this deployment. Callers opt in for the guilty/first-party policy only.
|
* this deployment. Callers opt in for the guilty/first-party policy only.
|
||||||
@@ -101,7 +129,7 @@ export class SandHubService {
|
|||||||
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
|
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
|
||||||
if (!expectedClientCode) {
|
if (!expectedClientCode) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
"CLIENT_ID must be configured before insurance eligibility can be checked.",
|
"تنظیمات شرکت بیمه برای بررسی اعتبار بیمهنامه کامل نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +139,7 @@ export class SandHubService {
|
|||||||
if (actualClientCode === expectedClientCode) return;
|
if (actualClientCode === expectedClientCode) return;
|
||||||
|
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`${insuranceLine} policy insurer does not match this deployment.`,
|
"بیمهنامه یافتشده متعلق به شرکت بیمه این سامانه نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,13 +162,13 @@ export class SandHubService {
|
|||||||
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
|
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
|
||||||
if (!expectedClientCode) {
|
if (!expectedClientCode) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
"CLIENT_ID must be configured before insurance eligibility can be checked.",
|
"تنظیمات شرکت بیمه برای بررسی اعتبار بیمهنامه کامل نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (expectedClientCode === "8") return;
|
if (expectedClientCode === "8") return;
|
||||||
|
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"CAR_BODY policy insurer does not match this deployment.",
|
"بیمهنامه یافتشده متعلق به شرکت بیمه این سامانه نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +454,7 @@ export class SandHubService {
|
|||||||
this.logger.error("Failed to login to SandHub:", er.message);
|
this.logger.error("Failed to login to SandHub:", er.message);
|
||||||
this.loginToken = null;
|
this.loginToken = null;
|
||||||
this.tokenExpiry = null;
|
this.tokenExpiry = null;
|
||||||
throw new UnauthorizedException("SandHub authentication failed");
|
throw new UnauthorizedException("احراز هویت سرویس استعلام انجام نشد.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,7 +474,7 @@ export class SandHubService {
|
|||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
"Tejarat inquiry credentials are not configured (TEJARAT_INQUIRY_EMAIL/TEJARAT_INQUIRY_PASSWORD)",
|
"اطلاعات اتصال به سرویس استعلام تجارت نو تنظیم نشده است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,12 +514,18 @@ export class SandHubService {
|
|||||||
);
|
);
|
||||||
this.tejaratAccessToken = null;
|
this.tejaratAccessToken = null;
|
||||||
this.tejaratTokenExpiry = null;
|
this.tejaratTokenExpiry = null;
|
||||||
throw new UnauthorizedException("Tejarat inquiry authentication failed");
|
throw new UnauthorizedException(
|
||||||
|
"احراز هویت سرویس استعلام تجارت نو انجام نشد.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getEsgAccessToken(): Promise<string> {
|
private async getEsgAccessToken(): Promise<string> {
|
||||||
if (this.esgAccessToken && this.esgTokenExpiry && this.esgTokenExpiry > new Date()) {
|
if (
|
||||||
|
this.esgAccessToken &&
|
||||||
|
this.esgTokenExpiry &&
|
||||||
|
this.esgTokenExpiry > new Date()
|
||||||
|
) {
|
||||||
return this.esgAccessToken;
|
return this.esgAccessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,7 +535,7 @@ export class SandHubService {
|
|||||||
|
|
||||||
if (!baseUrl || !username || !password) {
|
if (!baseUrl || !username || !password) {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
"ESG credentials are not configured (ESG_URL/ESG_USERNAME/ESG_PASSWORD)",
|
"اطلاعات اتصال به سرویس استعلام ESG تنظیم نشده است.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +573,9 @@ export class SandHubService {
|
|||||||
this.logger.error("Failed to login to ESG inquiry:", er?.message || er);
|
this.logger.error("Failed to login to ESG inquiry:", er?.message || er);
|
||||||
this.esgAccessToken = null;
|
this.esgAccessToken = null;
|
||||||
this.esgTokenExpiry = null;
|
this.esgTokenExpiry = null;
|
||||||
throw new UnauthorizedException("ESG inquiry authentication failed");
|
throw new UnauthorizedException(
|
||||||
|
"احراز هویت سرویس استعلام ESG انجام نشد.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,24 +639,30 @@ export class SandHubService {
|
|||||||
this.esgTokenExpiry = null;
|
this.esgTokenExpiry = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([400, 404, 409, 422].includes(status)) {
|
||||||
|
this.throwInquiryError(err, this.inquiryContext(inquiryType));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === maxRetries - 1) {
|
||||||
|
this.throwInquiryError(err, this.inquiryContext(inquiryType));
|
||||||
|
}
|
||||||
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
|
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
if (attempt === maxRetries - 1) throw err;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapEsgPolicyByPlateToOldFormat(raw: any): any {
|
private mapEsgPolicyByPlateToOldFormat(
|
||||||
|
raw: any,
|
||||||
|
context: "thirdPartyPlate" | "thirdPartyVin" = "thirdPartyPlate",
|
||||||
|
): any {
|
||||||
if (!raw) return raw;
|
if (!raw) return raw;
|
||||||
|
|
||||||
if (raw?.success === false) {
|
if (isInquiryFailurePayload(raw)) {
|
||||||
this.logger.warn(
|
this.logger.warn("ESG policy inquiry returned a failure payload", raw);
|
||||||
"ESG policyByPlate inquiry returned success=false",
|
|
||||||
raw,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
Error: {
|
Error: {
|
||||||
Message: SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
|
Message: getInquiryErrorMessage(raw, context),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -670,7 +712,8 @@ export class SandHubService {
|
|||||||
): string | null {
|
): string | null {
|
||||||
if (input === null || input === undefined) return null;
|
if (input === null || input === undefined) return null;
|
||||||
|
|
||||||
const raw = typeof input === "number" ? String(input) : String(input).trim();
|
const raw =
|
||||||
|
typeof input === "number" ? String(input) : String(input).trim();
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|
||||||
let year = 0;
|
let year = 0;
|
||||||
@@ -700,7 +743,9 @@ export class SandHubService {
|
|||||||
return `${year}-${mm}-${dd}`;
|
return `${year}-${mm}-${dd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultMockPersonInquiry(nationalCode: string): Record<string, unknown> {
|
private getDefaultMockPersonInquiry(
|
||||||
|
nationalCode: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
firstName: "نام",
|
firstName: "نام",
|
||||||
lastName: "خانوادگی",
|
lastName: "خانوادگی",
|
||||||
@@ -711,10 +756,10 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mapEsgPersonInquiryToOldFormat(raw: any): Record<string, unknown> {
|
private mapEsgPersonInquiryToOldFormat(raw: any): Record<string, unknown> {
|
||||||
if (raw?.success === false) {
|
if (isInquiryFailurePayload(raw)) {
|
||||||
this.logger.warn("ESG person inquiry returned success=false", raw);
|
this.logger.warn("ESG person inquiry returned success=false", raw);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
|
getInquiryErrorMessage(raw, "personalIdentity"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,11 +781,9 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mapEsgShebaInquiryToOldFormat(raw: any): Record<string, unknown> {
|
private mapEsgShebaInquiryToOldFormat(raw: any): Record<string, unknown> {
|
||||||
if (raw?.success === false) {
|
if (isInquiryFailurePayload(raw)) {
|
||||||
this.logger.warn("ESG sheba inquiry returned success=false", raw);
|
this.logger.warn("ESG sheba inquiry returned success=false", raw);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(getInquiryErrorMessage(raw, "sheba"));
|
||||||
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = raw?.data ?? {};
|
const data = raw?.data ?? {};
|
||||||
@@ -800,11 +843,15 @@ export class SandHubService {
|
|||||||
this.tejaratTokenExpiry = null;
|
this.tejaratTokenExpiry = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([400, 404, 409, 422].includes(status)) {
|
||||||
|
this.throwInquiryError(err, this.inquiryContext(inquiryType));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === maxRetries - 1) {
|
||||||
|
this.throwInquiryError(err, this.inquiryContext(inquiryType));
|
||||||
|
}
|
||||||
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
|
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
if (attempt === maxRetries - 1) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -883,8 +930,13 @@ export class SandHubService {
|
|||||||
`[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`,
|
`[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
|
const mapped = this.mapEsgPolicyByPlateToOldFormat(
|
||||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
raw,
|
||||||
|
"thirdPartyPlate",
|
||||||
|
);
|
||||||
|
if (!mapped?.Error) {
|
||||||
|
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||||
|
}
|
||||||
return { raw, mapped };
|
return { raw, mapped };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,8 +964,16 @@ export class SandHubService {
|
|||||||
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
|
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const mapped = this.mapNewApiResponseToOldFormat(raw);
|
const mapped = isInquiryFailurePayload(raw)
|
||||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
? {
|
||||||
|
Error: {
|
||||||
|
Message: getInquiryErrorMessage(raw, "thirdPartyPlate"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: this.mapNewApiResponseToOldFormat(raw);
|
||||||
|
if (!mapped?.Error) {
|
||||||
|
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||||
|
}
|
||||||
return { raw, mapped };
|
return { raw, mapped };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,13 +1022,15 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isVinInquiry) {
|
if (isVinInquiry) {
|
||||||
const raw = await this.lookupsService.findLastProcessedCarPolicy(
|
let raw: any;
|
||||||
"car-body",
|
try {
|
||||||
{
|
raw = await this.lookupsService.findLastProcessedCarPolicy("car-body", {
|
||||||
nationalCode: String(userDetail.nationalCodeOfInsurer),
|
nationalCode: String(userDetail.nationalCodeOfInsurer),
|
||||||
vin: plateOrVin,
|
vin: plateOrVin,
|
||||||
},
|
});
|
||||||
);
|
} catch (error) {
|
||||||
|
this.throwInquiryError(error, "carBodyVin");
|
||||||
|
}
|
||||||
if (useParsianCarBodyLookup) {
|
if (useParsianCarBodyLookup) {
|
||||||
this.assertParsianCarBodyLookupMatchesDeployment();
|
this.assertParsianCarBodyLookupMatchesDeployment();
|
||||||
}
|
}
|
||||||
@@ -1000,10 +1062,15 @@ export class SandHubService {
|
|||||||
plaqueRight: String(plateOrVin.centerDigits),
|
plaqueRight: String(plateOrVin.centerDigits),
|
||||||
plaqueSerial: String(plateOrVin.ir),
|
plaqueSerial: String(plateOrVin.ir),
|
||||||
};
|
};
|
||||||
const raw = await this.lookupsService.findLastProcessedCarPolicy(
|
let raw: any;
|
||||||
"car-body",
|
try {
|
||||||
query,
|
raw = await this.lookupsService.findLastProcessedCarPolicy(
|
||||||
);
|
"car-body",
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.throwInquiryError(error, "carBodyPlate");
|
||||||
|
}
|
||||||
this.assertParsianCarBodyLookupMatchesDeployment();
|
this.assertParsianCarBodyLookupMatchesDeployment();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1046,6 +1113,11 @@ export class SandHubService {
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isInquiryFailurePayload(raw)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
getInquiryErrorMessage(raw, "carBodyPlate"),
|
||||||
|
);
|
||||||
|
}
|
||||||
const mapped = this.mapCarBodyInquiryResponse(raw);
|
const mapped = this.mapCarBodyInquiryResponse(raw);
|
||||||
return { raw, mapped };
|
return { raw, mapped };
|
||||||
}
|
}
|
||||||
@@ -1114,9 +1186,8 @@ export class SandHubService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`).
|
* ESG two-factor VIN/chassis inquiry (`/inquiry/carByChassis`).
|
||||||
*
|
*
|
||||||
* When `vinChassis` inquiry is disabled (mock mode) the response shape mirrors
|
* When `vinChassis` inquiry is disabled (mock mode) the response shape mirrors
|
||||||
* `buildMockPlateInquiryRaw` so the downstream mapper (`mapEsgPolicyByPlateToOldFormat`)
|
* `buildMockPlateInquiryRaw` so the downstream mapper (`mapEsgPolicyByPlateToOldFormat`)
|
||||||
@@ -1125,15 +1196,15 @@ export class SandHubService {
|
|||||||
* @param identity - policyholder national code and 17-character VIN/chassis
|
* @param identity - policyholder national code and 17-character VIN/chassis
|
||||||
* @param options - optional per-tenant client scope
|
* @param options - optional per-tenant client scope
|
||||||
*/
|
*/
|
||||||
async getPolicyByChassisInquiry(
|
async getCarByChassisInquiry(
|
||||||
identity: { nationalCode: string; chassis: string },
|
identity: { nationalCode: string; chassisNo: string },
|
||||||
options?: SandHubInquiryOptions,
|
options?: SandHubInquiryOptions,
|
||||||
): Promise<{ raw: any; mapped: any }> {
|
): Promise<{ raw: any; mapped: any }> {
|
||||||
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
||||||
const requestUrl = `${baseUrl}/inquiry/carByChassis`;
|
const requestUrl = `${baseUrl}/inquiry/carByChassis`;
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
nationalCode: String(identity.nationalCode),
|
nationalCode: String(identity.nationalCode),
|
||||||
chassisNo: String(identity.chassis),
|
chassisNo: String(identity.chassisNo),
|
||||||
};
|
};
|
||||||
|
|
||||||
const live = await this.isInquiryLive("vinChassis", options);
|
const live = await this.isInquiryLive("vinChassis", options);
|
||||||
@@ -1142,10 +1213,12 @@ export class SandHubService {
|
|||||||
const ctx = await this.mockCompanyContext(options);
|
const ctx = await this.mockCompanyContext(options);
|
||||||
const raw = this.buildMockPlateInquiryRaw(ctx);
|
const raw = this.buildMockPlateInquiryRaw(ctx);
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`[MOCK] getPolicyByChassisInquiry nationalCode=${identity.nationalCode} chassis=${identity.chassis}`,
|
`[MOCK] getCarByChassisInquiry nationalCode=${identity.nationalCode} chassisNo=${identity.chassisNo}`,
|
||||||
);
|
);
|
||||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
|
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
|
||||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
if (!mapped?.Error) {
|
||||||
|
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||||
|
}
|
||||||
return { raw, mapped };
|
return { raw, mapped };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1155,12 +1228,13 @@ export class SandHubService {
|
|||||||
"vinChassis",
|
"vinChassis",
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
|
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
|
||||||
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
if (!mapped?.Error) {
|
||||||
|
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
|
||||||
|
}
|
||||||
return { raw, mapped };
|
return { raw, mapped };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async makeSandHubRequest(
|
private async makeSandHubRequest(
|
||||||
url: string,
|
url: string,
|
||||||
payload: any,
|
payload: any,
|
||||||
@@ -1205,7 +1279,7 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new BadGatewayException(
|
throw new BadGatewayException(
|
||||||
"Failed to fetch data from SandHub after multiple retries",
|
"سرویس استعلام پس از چند تلاش پاسخ نداد. لطفاً کمی بعد دوباره تلاش کنید.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1229,11 +1303,16 @@ export class SandHubService {
|
|||||||
// Pattern: {centerDigits}{centerLetter(s)}{leftDigits}<space>{ir}
|
// Pattern: {centerDigits}{centerLetter(s)}{leftDigits}<space>{ir}
|
||||||
const m = plk.trim().match(/^(\d+)([^\d\s]+)(\d+)\s+(\d+)$/);
|
const m = plk.trim().match(/^(\d+)([^\d\s]+)(\d+)\s+(\d+)$/);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const Plk3 = parseInt(m[1], 10); // center digits
|
const Plk3 = parseInt(m[1], 10); // center digits
|
||||||
const Plk2 = this.plateNormalizer.normalizePlateText(m[2]);
|
const Plk2 = this.plateNormalizer.normalizePlateText(m[2]);
|
||||||
const Plk1 = parseInt(m[3], 10); // left digits
|
const Plk1 = parseInt(m[3], 10); // left digits
|
||||||
const PlkSrl = parseInt(m[4], 10); // IR region code
|
const PlkSrl = parseInt(m[4], 10); // IR region code
|
||||||
if (!Number.isFinite(Plk3) || !Number.isFinite(Plk1) || !Number.isFinite(PlkSrl) || !Plk2) {
|
if (
|
||||||
|
!Number.isFinite(Plk3) ||
|
||||||
|
!Number.isFinite(Plk1) ||
|
||||||
|
!Number.isFinite(PlkSrl) ||
|
||||||
|
!Plk2
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { Plk1, Plk2, Plk3, PlkSrl };
|
return { Plk1, Plk2, Plk3, PlkSrl };
|
||||||
@@ -1245,7 +1324,12 @@ export class SandHubService {
|
|||||||
// If the response carries a `plk` plate string (VIN inquiry) but lacks the
|
// If the response carries a `plk` plate string (VIN inquiry) but lacks the
|
||||||
// individual Plk1/Plk2/Plk3/PlkSrl fields, parse and inject them so that
|
// individual Plk1/Plk2/Plk3/PlkSrl fields, parse and inject them so that
|
||||||
// all downstream plate-handling code works identically to the plate flow.
|
// all downstream plate-handling code works identically to the plate flow.
|
||||||
let plkParts: { Plk1: number; Plk2: string; Plk3: number; PlkSrl: number } | null = null;
|
let plkParts: {
|
||||||
|
Plk1: number;
|
||||||
|
Plk2: string;
|
||||||
|
Plk3: number;
|
||||||
|
PlkSrl: number;
|
||||||
|
} | null = null;
|
||||||
if (
|
if (
|
||||||
newResponse.plk &&
|
newResponse.plk &&
|
||||||
newResponse.Plk1 == null &&
|
newResponse.Plk1 == null &&
|
||||||
@@ -1258,13 +1342,15 @@ export class SandHubService {
|
|||||||
// Map the new field names to the old field names
|
// Map the new field names to the old field names
|
||||||
return {
|
return {
|
||||||
...newResponse,
|
...newResponse,
|
||||||
...(plkParts ? {
|
...(plkParts
|
||||||
Plk1: plkParts.Plk1,
|
? {
|
||||||
Plk2: plkParts.Plk2,
|
Plk1: plkParts.Plk1,
|
||||||
Plk3: plkParts.Plk3,
|
Plk2: plkParts.Plk2,
|
||||||
PlkSrl: plkParts.PlkSrl,
|
Plk3: plkParts.Plk3,
|
||||||
plateLetterid: plkParts.Plk2,
|
PlkSrl: plkParts.PlkSrl,
|
||||||
} : {}),
|
plateLetterid: plkParts.Plk2,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
// Company information
|
// Company information
|
||||||
CompanyCode: newResponse.companyId || newResponse.CompanyCode,
|
CompanyCode: newResponse.companyId || newResponse.CompanyCode,
|
||||||
CompanyName: newResponse.companyPersianName || newResponse.CompanyName,
|
CompanyName: newResponse.companyPersianName || newResponse.CompanyName,
|
||||||
@@ -1401,7 +1487,7 @@ export class SandHubService {
|
|||||||
) {
|
) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
throw new Error(err);
|
this.throwInquiryError(err, "thirdPartyPlate");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1420,7 +1506,7 @@ export class SandHubService {
|
|||||||
const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate);
|
const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate);
|
||||||
if (!jalaliBirthDate) {
|
if (!jalaliBirthDate) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13781124 or "1378-11-24").`,
|
"تاریخ تولد واردشده برای استعلام هویت معتبر نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,7 +1540,7 @@ export class SandHubService {
|
|||||||
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
|
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
|
||||||
if (!gregorianBirthdate) {
|
if (!gregorianBirthdate) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13770624 or "1377-06-24").`,
|
"تاریخ تولد واردشده برای استعلام هویت معتبر نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1472,7 +1558,7 @@ export class SandHubService {
|
|||||||
|
|
||||||
if (response?.message?.includes("err.record.not.found")) {
|
if (response?.message?.includes("err.record.not.found")) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
"Personal inquiry failed: Record not found for the given national code and birth date.",
|
getInquiryErrorMessage(response, "personalIdentity"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -1483,7 +1569,7 @@ export class SandHubService {
|
|||||||
) {
|
) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
throw new Error(`Error in finding personal inquiry: ${err}`);
|
this.throwInquiryError(err, "personalIdentity");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1512,22 +1598,13 @@ export class SandHubService {
|
|||||||
|
|
||||||
if (response?.data?.IsSucceed === false) {
|
if (response?.data?.IsSucceed === false) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
"Driving license check failed: The license is not valid or could not be found.",
|
"گواهینامهای مطابق کد ملی و شماره گواهینامه واردشده یافت نشد.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
this.throwInquiryError(error, "drivingLicense");
|
||||||
error instanceof BadGatewayException &&
|
|
||||||
error.message.includes("multiple retries")
|
|
||||||
) {
|
|
||||||
throw new BadGatewayException(
|
|
||||||
`Driving license check failed after multiple retries. The service may be down.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// For all other errors (like 400, 404, etc.), re-throw them as-is.
|
|
||||||
throw new Error(`Error in finding driving license: ${error}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1563,13 +1640,13 @@ export class SandHubService {
|
|||||||
response,
|
response,
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Ownership validation failed: The provided national ID is not the owner of this vehicle.",
|
"پلاک واردشده متعلق به کد ملی واردشده نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Error in finding car ownership: ${err}`);
|
this.throwInquiryError(err, "carOwnership");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1619,7 +1696,7 @@ export class SandHubService {
|
|||||||
response,
|
response,
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.",
|
"شماره شبا متعلق به کد ملی واردشده نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1647,7 +1724,7 @@ export class SandHubService {
|
|||||||
response,
|
response,
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.",
|
"شماره شبا متعلق به کد ملی واردشده نیست.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,7 +1733,7 @@ export class SandHubService {
|
|||||||
if (err instanceof BadRequestException) {
|
if (err instanceof BadRequestException) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
throw new Error(`Error in matching sheba validation: ${err}`);
|
this.throwInquiryError(err, "sheba");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1672,9 +1749,7 @@ export class SandHubService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (err.response.status === 400) {
|
if (err.response.status === 400) {
|
||||||
throw new BadGatewayException(
|
throw new BadGatewayException(getInquiryErrorMessage(err, "generic"));
|
||||||
`SandHub rejected the request with a 400 Bad Request. Details: ${JSON.stringify(err.response.data)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
@@ -1685,23 +1760,24 @@ export class SandHubService {
|
|||||||
|
|
||||||
if (err.message === "EMPTY_RESPONSE") {
|
if (err.message === "EMPTY_RESPONSE") {
|
||||||
throw new BadGatewayException(
|
throw new BadGatewayException(
|
||||||
"SandHub is offline or returned an empty response",
|
"سرویس استعلام پاسخی برنگرداند. لطفاً دوباره تلاش کنید.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (err.code === "ECONNABORTED") {
|
if (err.code === "ECONNABORTED") {
|
||||||
throw new GatewayTimeoutException("SandHub request timed out");
|
throw new GatewayTimeoutException(
|
||||||
|
"زمان پاسخگویی سرویس استعلام به پایان رسید. لطفاً دوباره تلاش کنید.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (err.code === "ECONNRESET" || err.message.includes("socket hang up")) {
|
if (err.code === "ECONNRESET" || err.message.includes("socket hang up")) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
"SandHub connection was reset or closed unexpectedly",
|
"ارتباط با سرویس استعلام قطع شد. لطفاً دوباره تلاش کنید.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This final check is for when all retries have failed for a retryable error.
|
// This final check is for when all retries have failed for a retryable error.
|
||||||
if (attempt >= maxRetries) {
|
if (attempt >= maxRetries - 1) {
|
||||||
throw new BadGatewayException(
|
throw new BadGatewayException(
|
||||||
"Failed to fetch data from SandHub after multiple retries",
|
"سرویس استعلام پس از چند تلاش پاسخ نداد. لطفاً کمی بعد دوباره تلاش کنید.",
|
||||||
err.message,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user