forked from Yara724/api
Compare commits
34 Commits
51e1d495a9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c422c9a43 | |||
|
|
c7c7709f0f | ||
| aaf0df1cc0 | |||
|
|
54d90b1c1b | ||
|
|
b758346af1 | ||
| 1a959ce8f6 | |||
| fc1c117b93 | |||
| 4f8fe04380 | |||
| 73ed37852b | |||
|
|
8ac185c861 | ||
| f07e510c5f | |||
|
|
8b65e43c60 | ||
| 985c28ed6b | |||
| 9dd0ec0c00 | |||
|
|
e06178f804 | ||
| a9adad3c1b | |||
| bbe5b7b77b | |||
| 1e13bb3a3c | |||
| a8d8df574b | |||
| cf07a33951 | |||
| 6f8a6fed69 | |||
| dbf46cfdda | |||
|
|
4ef53f2cc9 | ||
| b4c1d7bd5f | |||
|
|
4cd6e0ecb3 | ||
|
|
8071215803 | ||
| cd36a0f2d4 | |||
| ffaeb50c61 | |||
| 8afc1fabc4 | |||
| 1d9ef5e3d2 | |||
| be06bfe1b5 | |||
| 85576e7c5e | |||
|
|
406b139b3d | ||
|
|
45e0ad883a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -186,4 +186,3 @@ dist
|
||||
*.sh
|
||||
!scripts/fanavaran-flow-test.sh
|
||||
!scripts/fanavaran-auth.sh
|
||||
*.json
|
||||
@@ -461,6 +461,16 @@
|
||||
</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>
|
||||
<p class="section-intro">
|
||||
|
||||
@@ -56,7 +56,7 @@ Content-Type: application/json
|
||||
| `typeOfDamage` | بله | دقیقاً یکی از `"تعمیر"` یا `"تعویض"`. |
|
||||
| `price` | برای `"تعویض"` بله؛ برای `"تعمیر"` اختیاری | در صورت ارسال، مبلغ بین 100,000 تا 10,000,000,000 تومان. |
|
||||
| `salary` | بله | مبلغ بین 100,000 تا 10,000,000,000 تومان. |
|
||||
| `totalPayment` | بله | مبلغ بین 100,000 تا 10,000,000,000 تومان. مجموع این فیلدها در کل پرونده نباید از 53,000,000 تومان بیشتر شود. |
|
||||
| `totalPayment` | بله | مبلغ بین 1,000,000 تا 100,000,000,000 ریال. فقط در جریان V1، مجموع این فیلدها در کل پرونده نباید از 530,000,000 ریال بیشتر شود. |
|
||||
| `factorNeeded` | بله | مقدار Boolean واقعی (`true` یا `false`)؛ رشته ارسال نکنید. |
|
||||
| `daghi` | برای `"تعویض"` بله | برای `"تعمیر"` لازم نیست و در ثبت نهایی حذف میشود. |
|
||||
| `daghi.option` | در صورت وجود `daghi` بله | یکی از `"ارزش لوازم بازیافتی"`، `"تحویل داغی"`، `"فاقد ارزش"` یا `"با احتساب داغی"`. |
|
||||
@@ -66,7 +66,7 @@ Content-Type: application/json
|
||||
نکتهها:
|
||||
|
||||
- مقدار `0` برای هیچ مبلغ ارسالی این endpoint معتبر نیست؛ حداقل مبلغ 100,000 تومان است.
|
||||
- محدودیت 10 میلیارد مربوط به **هر فیلد مبلغ** است؛ سقف 53 میلیون مربوط به **مجموع `totalPayment` تمام ردیفها** است و همچنان اعمال میشود.
|
||||
- محدودیت هر فیلد مبلغ مستقل است؛ سقف 530 میلیون ریال فقط برای **مجموع `totalPayment` تمام ردیفهای پرونده V1** اعمال میشود. جریانهای V2 تا V6 سقف مجموع ندارند.
|
||||
- فیلدهای ناشناخته در body حذف میشوند. فرانتاند نباید برای انتقال داده به آنها تکیه کند.
|
||||
|
||||
## پاسخ موفق
|
||||
@@ -152,15 +152,15 @@ Content-Type: application/json
|
||||
```json
|
||||
{
|
||||
"statusCode": 400,
|
||||
"message": "مجموع مبلغ قطعات (۵۴٬۰۰۰٬۰۰۰) از سقف مجاز (۵۳٬۰۰۰٬۰۰۰) تومان بیشتر است.",
|
||||
"message": "مجموع مبلغ قطعات (۵۴۰٬۰۰۰٬۰۰۰) از سقف مجاز (۵۳۰٬۰۰۰٬۰۰۰) ریال بیشتر است.",
|
||||
"error": "PRICE_CAP_ERROR",
|
||||
"code": "PRICE_CAP_ERROR",
|
||||
"totalPrice": 54000000,
|
||||
"priceCap": 53000000
|
||||
"totalPrice": 540000000,
|
||||
"priceCap": 530000000
|
||||
}
|
||||
```
|
||||
|
||||
این خطا به یک ردیف مشخص وصل نیست. آن را در بالای جدول قیمتها نمایش دهید و در صورت نیاز از `priceCap` برای پیام UI استفاده کنید.
|
||||
این خطا فقط برای پروندههای V1 رخ میدهد و به یک ردیف مشخص وصل نیست. آن را در بالای جدول قیمتها نمایش دهید و از `priceCap` جزئیات پرونده/خطا برای پیام UI استفاده کنید. مقدار `priceCap: null` یعنی پرونده سقف مجموع ندارد.
|
||||
|
||||
### 4. خطاهای قواعد پرونده و workflow
|
||||
|
||||
@@ -225,4 +225,4 @@ function applySubmitError(body: ApiErrorBody) {
|
||||
}
|
||||
```
|
||||
|
||||
قبل از ارسال، فرانتاند میتواند همین بازه مبلغ را برای تجربه کاربری بهتر بررسی کند؛ با این حال اعتبار نهایی همیشه پاسخ API است. برای جلوگیری از خطای سقف مجموع، جمع `totalPayment` ردیفها را نیز پیش از ارسال محاسبه و نمایش دهید.
|
||||
قبل از ارسال، فرانتاند میتواند همین بازه مبلغ را برای تجربه کاربری بهتر بررسی کند؛ با این حال اعتبار نهایی همیشه پاسخ API است. جمع `totalPayment` را فقط وقتی `priceCap` جزئیات پرونده عدد است با سقف مقایسه کنید؛ `null` یعنی V2 تا V6 و بدون سقف مجموع.
|
||||
|
||||
@@ -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
|
||||
|
||||
ESG expects Jalali birth date, normalized as `YYYY-MM-DD`.
|
||||
|
||||
@@ -159,8 +159,9 @@
|
||||
<div class="decision-tree">
|
||||
<strong>برای هر پرسوجوی مبتنی بر پلاک:</strong>
|
||||
<ul>
|
||||
<li>درخواست همیشه با پلاک فعلی ارسالشده و بیمهگذار نهایی همان نوع بیمه انجام میشود. متادیتای انتقال اخیر هیچ استعلامی برای پلاک یا بیمهگذار قبلی ایجاد نمیکند.</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>۴. اگر <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (پیشفرض) → پاسخ mock برگردانده شود به جای انجام فراخوانیهای HTTP.</li>
|
||||
</ul>
|
||||
@@ -207,7 +208,7 @@
|
||||
<div class="card card-blue">
|
||||
<h3>پروتکل ارسال خسارت (۴ مرحله)</h3>
|
||||
<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.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>
|
||||
@@ -358,12 +359,12 @@
|
||||
<table>
|
||||
<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/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/sheba</code></td><td>اعتبارسنجی شبا / حساب بانکی.</td></tr>
|
||||
</table>
|
||||
<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.
|
||||
</p>
|
||||
</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-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> = 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>
|
||||
<p class="note" style="margin-top:8px;">
|
||||
تمام فراخوانیهای پیامک fire-and-forget هستند — هرگز throw نمیکنند. شکستها log میشوند اما جریان اصلی را مسدود نمیکنند.
|
||||
|
||||
@@ -154,8 +154,9 @@
|
||||
<div class="decision-tree">
|
||||
<strong>For every plate-based block inquiry:</strong>
|
||||
<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>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>4. If <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (default) → return mock response instead of making HTTP calls.</li>
|
||||
</ul>
|
||||
@@ -203,7 +204,7 @@
|
||||
<div class="card card-blue">
|
||||
<h3>Claim submission protocol (4 steps)</h3>
|
||||
<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">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>
|
||||
@@ -355,12 +356,12 @@
|
||||
<table>
|
||||
<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/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/sheba</code></td><td>Sheba / bank account validation.</td></tr>
|
||||
</table>
|
||||
<p class="note" style="margin-top:8px;">
|
||||
ESG wraps every response as <code>{ success: boolean, data: … }</code>. A <code>success=false</code> body is translated to a contextual Persian error. For example, <code>موردی یافت نشد</code> becomes a plate- or VIN-specific “no matching policy” message; it is not reported as a provider outage.
|
||||
ESG wraps every response as <code>{ success: boolean, data: … }</code>. For normalized error envelopes, the backend returns <code>error.messageFa</code> unchanged to the caller; technical fields such as <code>message</code>, <code>providerMessage</code>, and <code>providerCode</code> remain available for logging and classification. A business-level not-found response is not reported as a provider outage.
|
||||
The offline-inquiry seed check still runs first, before any ESG HTTP call.
|
||||
</p>
|
||||
</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-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-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>
|
||||
<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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
last_updated: 2026-08-08
|
||||
tags: [fanavaran, api, write, gen03, gen07, gen08, gen12]
|
||||
tags: [fanavaran, api, write, gen03, gen07, gen08, gen12, gen44]
|
||||
source: fanavaran-module-docs
|
||||
---
|
||||
|
||||
@@ -115,7 +115,8 @@ Full shape: see skill reference sample and `buildFanavaranSubmitPayload` / `appl
|
||||
|
||||
### Validation (YARA)
|
||||
|
||||
- Only `THIRD_PARTY` claims (not `CAR_BODY`)
|
||||
- `THIRD_PARTY` → `third-party-car-financial-claims`; `CAR_BODY` → `vehicle-hull-claims`
|
||||
- Hull skips `dmg-cases` (resource does not exist)
|
||||
- `PolicyId` required on submit (`requirePolicyId: true`)
|
||||
- Skip if local `claimId` / `claimNo` already set
|
||||
- `EstimateAmount` normalized to positive
|
||||
@@ -215,6 +216,26 @@ After local `SELECT_OUTER_PARTS` — before image upload.
|
||||
|
||||
Manual: `POST /v2/fanavaran/{client}/claim-cases/{id}/damage-case/submit`.
|
||||
|
||||
If `DriverId` is still missing, submit registers the damaged party with GEN.44 first (see below). Preview only warns.
|
||||
|
||||
---
|
||||
|
||||
## 4.1 GEN.44 — Other people (unknown person)
|
||||
|
||||
Doc: `CMN.GENR.APIH.GEN.44`
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| نام عملیات | ثبت سایر اشخاص |
|
||||
| Endpoint | `POST /Api/BimeApi/v2.0/common/other-people` |
|
||||
| Method | `POST` |
|
||||
|
||||
Also: `GET/PUT .../common/other-people/{Id}`. Local GET: `GET /lookups/other-people/:personId`.
|
||||
|
||||
Used when parties inquiry has no row for the damaged party. Iranian create needs national code + Jalali birth date; Fanavaran fills name/gender from ثبت احوال. Lookup ids: `CityId`, `GenderId`, `IsIranian` (`ans`), `PersonKindId`, `NationalityId`, `MaritalStatus`.
|
||||
|
||||
YARA: `registerFanavaranOtherPerson` during damage-case submit. Store `Id` as `driverId` / `otherPersonId`. Best-effort; do not create on preview; do not block the local claim flow.
|
||||
|
||||
---
|
||||
|
||||
## 5. GEN.07 — Attachments
|
||||
@@ -308,7 +329,7 @@ Requires `claimId` + `dmgCaseId` (soft-ensures earlier stages).
|
||||
| `Id` | `claimCases.expertiseId` |
|
||||
|
||||
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`.
|
||||
|
||||
Manual: `POST /v2/fanavaran/{client}/claim-cases/{id}/expertise/submit`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
last_updated: 2026-08-09
|
||||
last_updated: 2026-09-14
|
||||
tags: [fanavaran, api, read, lookups]
|
||||
source: fanavaran-module-docs
|
||||
---
|
||||
@@ -66,6 +66,10 @@ Used when building GEN.12 to resolve `DriverId`.
|
||||
|
||||
Prefer cache before live call.
|
||||
|
||||
If inquiry returns no person, damage-case **submit** registers them with GEN.44 (`POST /common/other-people`). See [01-write-apis.md](./01-write-apis.md) and the skill reference `other-people.md`. Preview only warns; it does not create.
|
||||
|
||||
Local GET: `GET /lookups/inquiry-by-unique-identifier` and `GET /lookups/other-people/:personId`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Remote lookup catalogue
|
||||
@@ -80,6 +84,7 @@ Base: `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0`
|
||||
| dmg-pay-method | `/car/code-list/dmg-pay-method` | `GET /lookups/dmg-pay-method` | Pay method |
|
||||
| driving-licence-types | `/car/base-info/driving-licence-types` | `GET /lookups/driving-licence-types` | Licence type |
|
||||
| accident-culprit-type | `/car/code-list/accident-culprit-type` | `GET /lookups/accident-culprit-type` | Culprit type |
|
||||
| detection-accident-culprits | `/car/base-info/detection-accident-culprits` | `GET /lookups/detection-accident-culprits` | GEN.03 `AuthorityCulpritId` |
|
||||
| inspection-place | `/car/code-list/inspection-place` | `GET /lookups/inspection-place` | GEN.08 |
|
||||
| drop-amount-status | `/car/code-list/drop-amount-status` | `GET /lookups/drop-amount-status` | GEN.08 |
|
||||
| car-components | `/car/base-info/car-components` | `GET /lookups/car-components` | `DmgSectionId` |
|
||||
@@ -95,6 +100,12 @@ Base: `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0`
|
||||
| dmg-history-status | `/car/code-list/dmg-case-history-status` | `GET /lookups/dmg-history-status` | GEN.12 |
|
||||
| used-place | `/car/code-list/used-place` | `GET /lookups/used-place` | Used place |
|
||||
| dmg-business-line | `/car/code-list/dmg-business-line` | `GET /lookups/dmg-business-line` | Business line |
|
||||
| gender | `/common/code-list/gender` | `GET /lookups/gender` | GEN.44 `GenderId` |
|
||||
| marital-status | `/common/code-list/marital-status` | `GET /lookups/marital-status` | GEN.44 `MaritalStatus` |
|
||||
| ans | `/common/code-list/ans` | `GET /lookups/ans` | GEN.44 `IsIranian` / yes-no |
|
||||
| countries | `/common/base-info/countries` | `GET /lookups/countries` | GEN.44 `NationalityId` |
|
||||
| person-kind | `/common/code-list/person-kind` | `GET /lookups/person-kind` | GEN.44 `PersonKindId` |
|
||||
| cii-validation-status | `/common/code-list/cii-validation-status` | `GET /lookups/cii-validation-status` | GEN.44 CII status |
|
||||
|
||||
### Generic accessor
|
||||
|
||||
@@ -121,6 +132,7 @@ Auth: Bearer + `AuthGuard`. Active tenant from `FANAVARAN_CLIENT` / `CLIENT_ID`.
|
||||
| `GET /lookups/dmg-pay-method` | `/car/code-list/dmg-pay-method` |
|
||||
| `GET /lookups/driving-licence-types` | `/car/base-info/driving-licence-types` |
|
||||
| `GET /lookups/accident-culprit-type` | `/car/code-list/accident-culprit-type` |
|
||||
| `GET /lookups/detection-accident-culprits` | `/car/base-info/detection-accident-culprits` |
|
||||
| `GET /lookups/inspection-place` | `/car/code-list/inspection-place` |
|
||||
| `GET /lookups/drop-amount-status` | `/car/code-list/drop-amount-status` |
|
||||
| `GET /lookups/car-components` | `/car/base-info/car-components` |
|
||||
@@ -135,6 +147,12 @@ Auth: Bearer + `AuthGuard`. Active tenant from `FANAVARAN_CLIENT` / `CLIENT_ID`.
|
||||
| `GET /lookups/dmg-history-status` | `/car/code-list/dmg-case-history-status` |
|
||||
| `GET /lookups/used-place` | `/car/code-list/used-place` |
|
||||
| `GET /lookups/dmg-business-line` | `/car/code-list/dmg-business-line` |
|
||||
| `GET /lookups/gender` | `/common/code-list/gender` |
|
||||
| `GET /lookups/marital-status` | `/common/code-list/marital-status` |
|
||||
| `GET /lookups/ans` | `/common/code-list/ans` |
|
||||
| `GET /lookups/countries` | `/common/base-info/countries` |
|
||||
| `GET /lookups/person-kind` | `/common/code-list/person-kind` |
|
||||
| `GET /lookups/cii-validation-status` | `/common/code-list/cii-validation-status` |
|
||||
| `GET /lookups/fanavaran` | catalogue metadata (names + URLs) |
|
||||
| `GET /lookups/fanavaran/{lookupName}` | any configured name (incl. `insurance-corp`) |
|
||||
|
||||
@@ -143,7 +161,11 @@ Auth: Bearer + `AuthGuard`. Active tenant from `FANAVARAN_CLIENT` / `CLIENT_ID`.
|
||||
| Nest route | Fanavaran path / notes |
|
||||
|------------|------------------------|
|
||||
| `GET /lookups/inquiry-by-vin?vin=` | `/car/vehicles/inquiry-by-vin` |
|
||||
| `GET /lookups/inquiry-by-unique-identifier?nationalCode=&birthday=` | `/common/parties/inquiry-by-unique-identifier` |
|
||||
| `GET /lookups/other-people/:personId` | `/common/other-people/{Id}` (GEN.44) |
|
||||
| `GET /lookups/my-policies?nationalCode=&insuranceLineId=` | `/common/Policies/inquiry-my-policies` |
|
||||
| `GET /lookups/processed-third-party-policy?nationalCode=&vin=&plaque…` | Last matching ثالث policy for that car + vehicle |
|
||||
| `GET /lookups/processed-body-policy?nationalCode=&vin=&plaque…` | Last matching بدنه policy for that car + vehicle |
|
||||
| `GET /lookups/third-party-policy/:policyId` | `/car/third-party-car-policies/{id}` |
|
||||
| `GET /lookups/body-policy/:policyId` | `/car/vehicle-hull-policies/{id}` |
|
||||
|
||||
|
||||
@@ -87,8 +87,8 @@ File: `claim-request-management.service.ts`
|
||||
|
||||
| Name | Relevant values | Fanavaran use |
|
||||
|------|-----------------|---------------|
|
||||
| Claim type | `THIRD_PARTY` only | Fanavaran path enabled |
|
||||
| | `CAR_BODY` | **Not** submitted to Fanavaran |
|
||||
| Claim type | `THIRD_PARTY` | `third-party-car-financial-claims` (base → dmg-cases → files → expertise) |
|
||||
| | `CAR_BODY` | `vehicle-hull-claims` (base → files → expertise; **no dmg-cases**) |
|
||||
| `ClaimCaseStatus` | local workflow | Triggers stages indirectly |
|
||||
| History event types | `FANAVARAN_*_SUCCEEDED/FAILED` | Audit trail on claim |
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Start local claim
|
||||
→ Upload docs/images locally
|
||||
→ GEN.07 attachments (per file, best-effort)
|
||||
→ Expert pricing ready
|
||||
→ GEN.08 expertise → store expertiseId → SMS owner
|
||||
→ GEN.08 expertise → store expertiseId (no SMS)
|
||||
→ End (local completion independent of Fanavaran success)
|
||||
```
|
||||
|
||||
@@ -60,6 +60,12 @@ sequenceDiagram
|
||||
User->>YARA: SELECT_OUTER_PARTS
|
||||
YARA->>YARA: autoSubmitFanavaranDamageCase...
|
||||
Note over YARA: soft-ensure base if missing
|
||||
alt DriverId missing
|
||||
YARA->>FV: GET parties/inquiry-by-unique-identifier
|
||||
FV-->>YARA: empty
|
||||
YARA->>FV: POST common/other-people (GEN.44)
|
||||
FV-->>YARA: person Id
|
||||
end
|
||||
YARA->>FV: POST .../dmg-cases (GEN.12)
|
||||
FV-->>YARA: DmgCaseId
|
||||
YARA->>DB: dmgCaseId
|
||||
@@ -76,7 +82,7 @@ sequenceDiagram
|
||||
YARA->>FV: POST .../expertise (GEN.08)
|
||||
FV-->>YARA: ExpertiseId
|
||||
YARA->>DB: expertiseId, history SUCCESS
|
||||
YARA->>YARA: SMS owner (deduped)
|
||||
Note over YARA: Post-expertise owner SMS is disabled
|
||||
```
|
||||
|
||||
## 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.*` |
|
||||
| Orchestration | `ClaimRequestManagementModule` | `claim-request-management.service.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 |
|
||||
| Receive | `Id`, `ClaimNo` |
|
||||
| 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
|
||||
|
||||
@@ -133,8 +133,9 @@ Expert reply path calls expertise auto-submit (~10576).
|
||||
| `claimCases.dmgCaseId` | GEN.12 |
|
||||
| `claimCases.expertiseId` | GEN.08 |
|
||||
| `claimCases.fanavaranSync.*` | All |
|
||||
| `claimCases.fanavaranSync.damageCase.otherPersonId` | GEN.44 |
|
||||
| `claimCases.history[]` | Success/fail events |
|
||||
| `blameCases.parties[].person.fanavaranDriverId` | GEN.12 driver resolve |
|
||||
| `blameCases.parties[].person.fanavaranDriverId` | GEN.12 driver resolve / GEN.44 |
|
||||
| `fanavaranAuditLogs` | Every real HTTP step |
|
||||
| `fanavaranAuthTokens` | Auth cache |
|
||||
| `fanavaranClientConfigs` | Tenant profiles |
|
||||
|
||||
@@ -28,7 +28,7 @@ source: fanavaran-module-docs
|
||||
| YARA | Fanavaran |
|
||||
|------|-----------|
|
||||
| `damage.selectedParts` labels | `Desc` (`/` joined) |
|
||||
| Party `nationalCodeOfDriver` + birthday (+ insurer flag) → inquiry | `DriverId` |
|
||||
| Party `nationalCodeOfDriver` + birthday (+ insurer flag) → inquiry, else GEN.44 create | `DriverId` |
|
||||
| `claimCase.vehicle.carType` → vehicle-kinds | `VehicleKindId` |
|
||||
| `INSURANCE_CORP_ID` caption → insurance-corp | `InsuranceCorpId` |
|
||||
| Inquiry `ShsNum` / Chassis* | `ChassisNo` |
|
||||
@@ -43,6 +43,20 @@ source: fanavaran-module-docs
|
||||
| Tenant defaults | `DmgCaseTypeId`, `DmgHistoryStatus`, `PlaqueKindId`, `PlaqueSampleId`, `FaultPercent`, `AccidentVehicleUsedId`, `LicenceTypeId` |
|
||||
| Provisional | `EstimateAmount` = 1000 |
|
||||
|
||||
## GEN.44 — Other people
|
||||
|
||||
| YARA | Fanavaran |
|
||||
|------|-----------|
|
||||
| Party national code | `NationalCode` |
|
||||
| Party birthday (Jalali) | `BirthYear` / `BirthMonth` / `BirthDay` |
|
||||
| `fullName` split | `Name` / `LastName` (registry may overwrite) |
|
||||
| Party phone / user mobile | `Mobile` |
|
||||
| User address / city / gender | `Address`, `CityId`, `GenderId` |
|
||||
| `ans` lookup بله | `IsIranian` |
|
||||
| `person-kind` حقیقی | `PersonKindId` |
|
||||
|
||||
Create only on GEN.12 submit when inquiry missed. Cache `otherPersonId` + `driverId`.
|
||||
|
||||
## GEN.07 — Attachments
|
||||
|
||||
| YARA | Fanavaran |
|
||||
@@ -83,7 +97,7 @@ source: fanavaran-module-docs
|
||||
|
||||
| YARA | Note |
|
||||
|------|------|
|
||||
| `CAR_BODY` claims | No Fanavaran submit |
|
||||
| `CAR_BODY` dmg-cases | Hull has no GEN.12 resource; skip that stage |
|
||||
| Videos | Not uploaded via GEN.07 in current code |
|
||||
| Full police report fields | Often null unless later enriched |
|
||||
|
||||
|
||||
@@ -69,4 +69,4 @@ Per HTTP step:
|
||||
|
||||
## 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.
|
||||
|
||||
442
docs/fanavaran/car-body-curl-flow.md
Normal file
442
docs/fanavaran/car-body-curl-flow.md
Normal file
@@ -0,0 +1,442 @@
|
||||
---
|
||||
last_updated: 2026-09-16
|
||||
tags: [fanavaran, car-body, hull, curl, vhud, parsian]
|
||||
source: fanavaran-apis skill + VHUD GEN docs
|
||||
---
|
||||
|
||||
# Car-body (بدنه) Fanavaran — manual curl flow
|
||||
|
||||
Direct Fanavaran BimeApi calls for **vehicle-hull-claims** (VHUD GEN.03 / GEN.05 / GEN.06).
|
||||
Use this when debugging production without YARA, or to compare with YARA preview payloads.
|
||||
|
||||
Credentials below match **`PARSIAN_FANAVARAN_CONFIG`** in `claim-request-management.service.ts` and `scripts/fanavaran-auth.sh parsian`. Tokens (`APP_TOKEN`, `AUTH_TOKEN`) change daily — run auth curls first or `./scripts/fanavaran-auth.sh parsian` then `source files/fanavaran-auth/parsian/tokens.env`.
|
||||
|
||||
## Base URLs
|
||||
|
||||
| Purpose | URL |
|
||||
|---------|-----|
|
||||
| Auth | `https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/...` |
|
||||
| Business (BimeApi v2 car) | `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/...` |
|
||||
|
||||
## Variables (set after each step)
|
||||
|
||||
```bash
|
||||
# Parsian (ready values)
|
||||
export FANAVARAN_APPNAME='ParsianService'
|
||||
export FANAVARAN_SECRET='P@r30@n$erv!ce'
|
||||
export FANAVARAN_USERNAME='ParsianServiceUser'
|
||||
export FANAVARAN_PASSWORD='P@r30@n123'
|
||||
export FANAVARAN_CORP_ID='543'
|
||||
export FANAVARAN_CONTRACT_ID='28' # confirm hull ContractId with Fanavaran if files fail
|
||||
export FANAVARAN_LOCATION='210050'
|
||||
|
||||
export APP_TOKEN='' # from GetAppToken response header apptoken
|
||||
export AUTH_TOKEN='' # from Login response header/body
|
||||
|
||||
export HULL_CLAIM_ID='5023617' # GEN.03 response Id — کد رایانه (NOT ClaimNo); replace after POST
|
||||
export HULL_CLAIM_NO='2268' # display only
|
||||
export EXPERTISE_ID='' # GEN.06 response Id
|
||||
```
|
||||
|
||||
### One-shot auth (Parsian) — copy/paste
|
||||
|
||||
```bash
|
||||
curl -sS -D /tmp/fanavaran-apptoken.headers -o /tmp/fanavaran-apptoken.body \
|
||||
-X POST 'https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken' \
|
||||
-H 'appname: ParsianService' \
|
||||
-H 'secret: P@r30@n$erv!ce' \
|
||||
-H 'Content-Length: 0'
|
||||
|
||||
export APP_TOKEN="$(awk -F': ' 'tolower($1)=="apptoken"{gsub(/\r/,"",$2);print $2;exit}' /tmp/fanavaran-apptoken.headers)"
|
||||
|
||||
curl -sS -D /tmp/fanavaran-login.headers -o /tmp/fanavaran-login.body \
|
||||
-X POST 'https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login' \
|
||||
-H "appToken: ${APP_TOKEN}" \
|
||||
-H 'userName: ParsianServiceUser' \
|
||||
-H 'password: P@r30@n123' \
|
||||
-H 'Content-Length: 0'
|
||||
|
||||
export AUTH_TOKEN="$(awk -F': ' 'tolower($1)=="authenticationtoken"{gsub(/\r/,"",$2);print $2;exit}' /tmp/fanavaran-login.headers)"
|
||||
echo "APP_TOKEN=${APP_TOKEN}"
|
||||
echo "AUTH_TOKEN=${AUTH_TOKEN}"
|
||||
```
|
||||
|
||||
Or from repo root: `./scripts/fanavaran-auth.sh parsian && source files/fanavaran-auth/parsian/tokens.env` (uses `AUTHENTICATION_TOKEN` — alias `export AUTH_TOKEN="$AUTHENTICATION_TOKEN"`).
|
||||
|
||||
### Id vs ClaimNo (critical)
|
||||
|
||||
| Field | Persian | Use in URL? |
|
||||
|-------|---------|-------------|
|
||||
| **`Id`** | کد رایانه پرونده | **Yes** — `/vehicle-hull-claims/{Id}/files` and `/expertise` |
|
||||
| **`ClaimNo`** | شماره پرونده | **No** — do not put `2268` in the path |
|
||||
|
||||
Example: `Id=5023617`, `ClaimNo=2268` → use **`5023617`** everywhere below.
|
||||
|
||||
---
|
||||
|
||||
## Auth — GetAppToken
|
||||
|
||||
**Doc:** API.GENR (EIT Authentication)
|
||||
|
||||
```bash
|
||||
curl -sS -D /tmp/fanavaran-apptoken.headers -o /tmp/fanavaran-apptoken.body \
|
||||
-X POST 'https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken' \
|
||||
-H 'appname: ParsianService' \
|
||||
-H 'secret: P@r30@n$erv!ce' \
|
||||
-H 'Content-Length: 0'
|
||||
```
|
||||
|
||||
Read **`apptoken`** (or `appToken`) from response **headers**:
|
||||
|
||||
```bash
|
||||
grep -i apptoken /tmp/fanavaran-apptoken.headers
|
||||
export APP_TOKEN='<paste apptoken header value>'
|
||||
```
|
||||
|
||||
Body is usually empty. No JSON body on this call.
|
||||
|
||||
---
|
||||
|
||||
## Auth — Login
|
||||
|
||||
```bash
|
||||
curl -sS -D /tmp/fanavaran-login.headers -o /tmp/fanavaran-login.body \
|
||||
-X POST 'https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login' \
|
||||
-H "appToken: ${APP_TOKEN}" \
|
||||
-H 'userName: ParsianServiceUser' \
|
||||
-H 'password: P@r30@n123' \
|
||||
-H 'Content-Length: 0'
|
||||
```
|
||||
|
||||
Read **`authenticationToken`** from response header or JSON body:
|
||||
|
||||
```bash
|
||||
grep -i authenticationtoken /tmp/fanavaran-login.headers
|
||||
# or
|
||||
cat /tmp/fanavaran-login.body
|
||||
export AUTH_TOKEN='<paste token>'
|
||||
```
|
||||
|
||||
Token is typically valid until **Asia/Tehran midnight** (same as YARA `FanavaranAuthService`).
|
||||
|
||||
---
|
||||
|
||||
## Business headers (all stages below)
|
||||
|
||||
Every BimeApi call needs:
|
||||
|
||||
```text
|
||||
authenticationToken: <AUTH_TOKEN>
|
||||
CorpId: <FANAVARAN_CORP_ID>
|
||||
ContractId: <FANAVARAN_CONTRACT_ID>
|
||||
Location: <FANAVARAN_LOCATION>
|
||||
Content-Type: application/json # except multipart files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## YARA preview (copy JSON bodies from production)
|
||||
|
||||
Before curling Fanavaran directly, get the exact payload from Parsian production YARA (same tenant auth as above):
|
||||
|
||||
| Stage | YARA preview (Parsian) |
|
||||
|-------|-------------------------|
|
||||
| 1 Base claim | `GET /v2/fanavaran/parsian/claim-cases/{claimCaseId}/base-claim/preview` |
|
||||
| 2 Damage case | **N/A for CAR_BODY** — skipped |
|
||||
| 3 Attachments | `GET /v2/fanavaran/parsian/claim-cases/{claimCaseId}/attachments/preview` |
|
||||
| 4 Expertise | `GET /v2/fanavaran/parsian/claim-cases/{claimCaseId}/expertise/preview` |
|
||||
|
||||
Expert UI BFF (if used): `POST /car-damage/expert/api/fanavaran` with `{"claimCaseId":"..."}` runs remaining stages via the app.
|
||||
|
||||
Save preview `payload` into a file, e.g. `stage1-body.json`, then use `@stage1-body.json` in curl.
|
||||
Example hull-only GEN.03 body (proven Parsian case): [`stage1-hull-body.json`](./stage1-hull-body.json).
|
||||
|
||||
**Enrichment before POST (YARA does this automatically):**
|
||||
|
||||
| Field | Source |
|
||||
|-------|--------|
|
||||
| `ActualPremium` | `GET /lookups/body-policy/{PolicyId}` → `TotalPremium` |
|
||||
| `AccidentCulpritId` | `GET /lookups/inquiry-by-unique-identifier?nationalCode=…&birthday=…` → `driverId` (fallback: policy `CustomerId`) |
|
||||
| `CustomerFaultPercent` | **100** (required; درصد تقصير بيمه گذار) |
|
||||
| `AuthorityCulpritId` | `GET /lookups/detection-accident-culprits` → بیمهگذار (required; مرجع تعيين مقصر) |
|
||||
|
||||
Success example: `Id=5026065`, `ClaimNo=1747` — use **`5026065`** for files/expertise.
|
||||
|
||||
---
|
||||
|
||||
## Stage 1 — GEN.03 Create hull base claim
|
||||
|
||||
**Doc:** CAR.VHUD.APIH.GEN.03
|
||||
**Method:** `POST`
|
||||
**URL:** `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims`
|
||||
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
'https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims' \
|
||||
-H "authenticationToken: ${AUTH_TOKEN}" \
|
||||
-H "CorpId: ${FANAVARAN_CORP_ID}" \
|
||||
-H "ContractId: ${FANAVARAN_CONTRACT_ID}" \
|
||||
-H "Location: ${FANAVARAN_LOCATION}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @stage1-body.json
|
||||
```
|
||||
|
||||
**Body:** paste from YARA **base-claim preview** `payload`. Must be **hull GEN.03 shape** (include `AccidentTypeId`, `IsLicenseReplaced`, `CostSeparationToDmgSections`, `IsOwnerChanged`; do **not** send ثالث-only fields like `DamagedCount`, `HasOtherCulprit`, `CompensationReferenceId`, plaque block on base).
|
||||
|
||||
**GEN.03 required lookups (Parsian live):**
|
||||
|
||||
| Field | Lookup | Typical collision value |
|
||||
|-------|--------|-------------------------|
|
||||
| `AccidentTypeId` | `GET /lookups/vehicle-hull-accident-types` | **2** = تصادف(حادثه) |
|
||||
| `CostSeparationToDmgSections` | `GET /lookups/ans` | **0** = خیر |
|
||||
| `IsOwnerChanged` | `GET /lookups/ans` | **0** = خیر |
|
||||
| `IsLicenseReplaced` | — | **`null`** (must be empty; do not send `0` or ثالث `IsLicenseReplacement`) |
|
||||
| `CulpritTypeId` | `GET /lookups/vehicle-hull-accident-culprit-type` | **301** = حادثه مقصر شناخته شده دارد (not ثالث `337`) |
|
||||
| `CustomerFaultPercent` | — | **100** |
|
||||
| `AuthorityCulpritId` | `GET /lookups/detection-accident-culprits` | بیمهگذار (`Id` from lookup, fallback **1**) |
|
||||
|
||||
Fanavaran direct: `car/base-info/vehicle-hull-accident-types`, `common/code-list/ans`, `car/code-list/vehicle-hull-accident-culprit-type`.
|
||||
|
||||
**Success:** JSON includes at least:
|
||||
|
||||
```json
|
||||
{ "Id": 5023617, "ClaimNo": 2268, ... }
|
||||
```
|
||||
|
||||
```bash
|
||||
export HULL_CLAIM_ID='5023617' # use Id
|
||||
export HULL_CLAIM_NO='2268' # do not use in nested URLs
|
||||
```
|
||||
|
||||
### Optional — GET base claim (verify Id exists)
|
||||
|
||||
```bash
|
||||
curl -sS -X GET \
|
||||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}" \
|
||||
-H "authenticationToken: ${AUTH_TOKEN}" \
|
||||
-H "CorpId: ${FANAVARAN_CORP_ID}" \
|
||||
-H "ContractId: ${FANAVARAN_CONTRACT_ID}" \
|
||||
-H "Location: ${FANAVARAN_LOCATION}"
|
||||
```
|
||||
|
||||
### Optional — PUT hull shape (if files/expertise say پرونده خسارت بدنه یافت نشد)
|
||||
|
||||
If stage 1 was created with a wrong (ثالث-shaped) body, **PUT** the same hull GEN.03 field set onto the existing `Id` (body = preview payload after hull mapping):
|
||||
|
||||
```bash
|
||||
curl -sS -X PUT \
|
||||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}" \
|
||||
-H "authenticationToken: ${AUTH_TOKEN}" \
|
||||
-H "CorpId: ${FANAVARAN_CORP_ID}" \
|
||||
-H "ContractId: ${FANAVARAN_CONTRACT_ID}" \
|
||||
-H "Location: ${FANAVARAN_LOCATION}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @stage1-body-hull.json
|
||||
```
|
||||
|
||||
YARA does this automatically before attachments/expertise when product is CAR_BODY.
|
||||
|
||||
---
|
||||
|
||||
## Stage 2 — Damage case (ثالث GEN.12) — **does not exist for hull**
|
||||
|
||||
**Do not call:**
|
||||
|
||||
```text
|
||||
POST .../vehicle-hull-claims/{id}/dmg-cases ❌ Feature:url(...) not found
|
||||
```
|
||||
|
||||
Third-party only:
|
||||
|
||||
```text
|
||||
POST .../third-party-car-financial-claims/{id}/dmg-cases
|
||||
```
|
||||
|
||||
For car-body YARA: damage-case preview/submit **returns skipped**. Flow is **1 → 3 → 4**.
|
||||
|
||||
---
|
||||
|
||||
## Stage 3 — GEN.05 Attachments (one file per request)
|
||||
|
||||
**Doc:** CAR.VHUD.APIH.GEN.05
|
||||
**Method:** `POST`
|
||||
**URL:** `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/{HULL_CLAIM_ID}/files`
|
||||
|
||||
**Parsian `FileTypeId`:** `63` (سایر مدارک خسارت) — proven on ثالث; verify in `file-types` lookup for hull if rejected.
|
||||
|
||||
### JSON metadata (multipart part)
|
||||
|
||||
YARA sends this as multipart field **`Param`** (`application/json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"FileName": "photo_example.jpg",
|
||||
"FileTypeId": 63,
|
||||
"ClaimId": 5023617
|
||||
}
|
||||
```
|
||||
|
||||
`FileName` must **exactly** match the uploaded file name. `ClaimId` = same as URL `{HULL_CLAIM_ID}` (`Id`, not `ClaimNo`).
|
||||
|
||||
### curl (matches YARA `postFanavaranMultipart`: Param + Param1)
|
||||
|
||||
```bash
|
||||
export FILE_PATH='/path/to/photo_example.jpg'
|
||||
export FILE_NAME='photo_example.jpg'
|
||||
|
||||
curl -sS -X POST \
|
||||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}/files" \
|
||||
-H "authenticationToken: ${AUTH_TOKEN}" \
|
||||
-H "CorpId: ${FANAVARAN_CORP_ID}" \
|
||||
-H "ContractId: ${FANAVARAN_CONTRACT_ID}" \
|
||||
-H "Location: ${FANAVARAN_LOCATION}" \
|
||||
-F "Param={\"FileName\":\"${FILE_NAME}\",\"FileTypeId\":63,\"ClaimId\":${HULL_CLAIM_ID}};type=application/json" \
|
||||
-F "Param1=@${FILE_PATH};filename=${FILE_NAME}"
|
||||
```
|
||||
|
||||
Repeat once per image (YARA does not batch hull uploads).
|
||||
|
||||
**Success example:** `{ "Id": 2008737, "Status": 2003, ... }`
|
||||
|
||||
### Optional — list files (GET)
|
||||
|
||||
```bash
|
||||
curl -sS -X GET \
|
||||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}/files" \
|
||||
-H "authenticationToken: ${AUTH_TOKEN}" \
|
||||
-H "CorpId: ${FANAVARAN_CORP_ID}" \
|
||||
-H "ContractId: ${FANAVARAN_CONTRACT_ID}" \
|
||||
-H "Location: ${FANAVARAN_LOCATION}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stage 4 — GEN.06 Expertise
|
||||
|
||||
**Doc:** CAR.VHUD.APIH.GEN.06
|
||||
**Method:** `POST`
|
||||
**URL:** `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/{HULL_CLAIM_ID}/expertise`
|
||||
|
||||
**Body:** paste from YARA **expertise preview** `payload` (GEN.06 — **not** ثالث GEN.08).
|
||||
|
||||
| Field | Source (YARA) |
|
||||
|-------|----------------|
|
||||
| `VehicleCurrentValue` | `evaluation.priceDrop.carPrice` if set; else **`GET /lookups/body-policy/{PolicyId}` → `VehicleValue`** |
|
||||
| `DmgSections[].DmgKindId` | **`5485`** (تخریب) from **`GET /lookups/vehicle-hull-dmg-kind`** |
|
||||
| `DmgSectionCosts[].DmgCostKindId` | **`1`** (Id for «مجموع لوازم») from **`GET /lookups/vehicle-hull-dmg-cost-kinds`** — not `DmgCostCategoryId` |
|
||||
|
||||
Hull expects fields such as:
|
||||
|
||||
- `ClaimExpertId` (Parsian assessor: **29** = `ExpertiseClaimExpertId`)
|
||||
- `Wage`, `ComponentReplacementCost`, `WasteValue`, `DropAmount`, `VehicleCurrentValue`
|
||||
- `MotorNo`, `ChassisNo`, `VIN`, `PlaqueNo`, `PlaqueSerial`, `BuiltYear`
|
||||
- `DmgSections[]` with `Count`, `Desc`, `AccessoryKindId`, `DmgSectionCosts[]`
|
||||
- **No** `DmgCaseId`, `InspectionPlaceId`, `DropAmountStatus`, `RepairWage`
|
||||
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}/expertise" \
|
||||
-H "authenticationToken: ${AUTH_TOKEN}" \
|
||||
-H "CorpId: ${FANAVARAN_CORP_ID}" \
|
||||
-H "ContractId: ${FANAVARAN_CONTRACT_ID}" \
|
||||
-H "Location: ${FANAVARAN_LOCATION}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @stage4-expertise-body.json
|
||||
```
|
||||
|
||||
**Success:** `{ "Id": 71927, "ClaimId": 78776, ... }` → store as `expertiseId`.
|
||||
|
||||
---
|
||||
|
||||
## Stage map (YARA vs Fanavaran)
|
||||
|
||||
| YARA / user stage | Fanavaran doc | HTTP | Exists for بدنه? |
|
||||
|-------------------|---------------|------|------------------|
|
||||
| Auth | GetAppToken + Login | POST | Yes |
|
||||
| 1 Base claim | GEN.03 | POST `/vehicle-hull-claims` | Yes |
|
||||
| 1b Sync hull body | GEN.03 | PUT `/vehicle-hull-claims/{Id}` | Yes (fix row) |
|
||||
| 2 Damage case | GEN.12 (ثالث) | POST `.../dmg-cases` | **No — skip** |
|
||||
| 3 Attachments | GEN.05 | POST `.../{Id}/files` | Yes |
|
||||
| 4 Expertise | GEN.06 | POST `.../{Id}/expertise` | Yes |
|
||||
|
||||
## Not used for normal collision car-body
|
||||
|
||||
| Doc | URL |
|
||||
|-----|-----|
|
||||
| GEN.01 bulk import | POST `/vehicle-hull-import-claims` |
|
||||
| GEN.02 import files | POST `/vehicle-hull-import-claims/{PrvId}/files` |
|
||||
| GEN.04 culprits | POST `.../{Id}/culprits` |
|
||||
|
||||
## Related docs
|
||||
|
||||
- Skill summary: `.agents/skills/fanavaran-apis/references/car-body-cases.md`
|
||||
- VHUD extracts: `.agents/skills/fanavaran-apis/references/vehicle-hull/`
|
||||
- Local auth script: `scripts/fanavaran-auth.sh parsian`
|
||||
|
||||
---
|
||||
|
||||
## Parsian — full flow script (after auth)
|
||||
|
||||
Run the **One-shot auth** block above first. Save YARA preview JSON to `stage1-body.json` and `stage4-expertise-body.json`. Set `FILE_PATH` / `FILE_NAME` for stage 3.
|
||||
|
||||
```bash
|
||||
# Stage 1 — POST hull base claim
|
||||
curl -sS -X POST \
|
||||
'https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims' \
|
||||
-H "authenticationToken: ${AUTH_TOKEN}" \
|
||||
-H 'CorpId: 543' \
|
||||
-H 'ContractId: 28' \
|
||||
-H 'Location: 210050' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @stage1-body.json
|
||||
|
||||
# Stage 1 optional — GET
|
||||
curl -sS -X GET \
|
||||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}" \
|
||||
-H "authenticationToken: ${AUTH_TOKEN}" \
|
||||
-H 'CorpId: 543' \
|
||||
-H 'ContractId: 28' \
|
||||
-H 'Location: 210050'
|
||||
|
||||
# Stage 1 optional — PUT hull sync
|
||||
curl -sS -X PUT \
|
||||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}" \
|
||||
-H "authenticationToken: ${AUTH_TOKEN}" \
|
||||
-H 'CorpId: 543' \
|
||||
-H 'ContractId: 28' \
|
||||
-H 'Location: 210050' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @stage1-body-hull.json
|
||||
|
||||
# Stage 2 — (skip — no dmg-cases for hull)
|
||||
|
||||
# Stage 3 — POST one file
|
||||
FILE_PATH='/path/to/photo.jpg'
|
||||
FILE_NAME='photo.jpg'
|
||||
curl -sS -X POST \
|
||||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}/files" \
|
||||
-H "authenticationToken: ${AUTH_TOKEN}" \
|
||||
-H 'CorpId: 543' \
|
||||
-H 'ContractId: 28' \
|
||||
-H 'Location: 210050' \
|
||||
-F "Param={\"FileName\":\"${FILE_NAME}\",\"FileTypeId\":63,\"ClaimId\":${HULL_CLAIM_ID}};type=application/json" \
|
||||
-F "Param1=@${FILE_PATH};filename=${FILE_NAME}"
|
||||
|
||||
# Stage 3 optional — GET files list
|
||||
curl -sS -X GET \
|
||||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}/files" \
|
||||
-H "authenticationToken: ${AUTH_TOKEN}" \
|
||||
-H 'CorpId: 543' \
|
||||
-H 'ContractId: 28' \
|
||||
-H 'Location: 210050'
|
||||
|
||||
# Stage 4 — POST expertise
|
||||
curl -sS -X POST \
|
||||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}/expertise" \
|
||||
-H "authenticationToken: ${AUTH_TOKEN}" \
|
||||
-H 'CorpId: 543' \
|
||||
-H 'ContractId: 28' \
|
||||
-H 'Location: 210050' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @stage4-expertise-body.json
|
||||
```
|
||||
154
docs/fanavaran/car-body-stage-fields.md
Normal file
154
docs/fanavaran/car-body-stage-fields.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Car-body (بدنه) — Fanavaran stage bodies (current YARA)
|
||||
|
||||
Snapshot of **what hull submit actually sends** after `toFanavaranHullBaseClaimPayload` / `toFanavaranHullExpertisePayload`.
|
||||
|
||||
Staged flow (VHUD):
|
||||
|
||||
1. Base: `POST /car/vehicle-hull-claims` (GEN.03)
|
||||
2. **No dmg-cases** (`vehicle-hull-claims/{id}/dmg-cases` does not exist)
|
||||
3. Attachments: `POST …/{Id}/files` (GEN.05)
|
||||
4. Expertise: `POST …/{Id}/expertise` (GEN.06)
|
||||
|
||||
Use hull `ContractId` (`hullContractId`, else tenant `contractId`). Nested URLs use GEN.03 **`Id`**, never `ClaimNo`.
|
||||
|
||||
A PUT to `/vehicle-hull-claims/{Id}` may run before files/expertise; Parsian often has **no PUT route**. That PUT is best-effort and must not block GEN.05/06.
|
||||
|
||||
**Send rule:** send the **hull whitelist only**. Do not send ثالث-only keys (`DamagedCount`, `HasOtherCulprit`, `CompensationReferenceId`, `DocReceivedDate`, `IsLicenseReplacement`, plaque block on base, …).
|
||||
|
||||
**Source tags:** same as `third-party-stage-fields.md` (`system` / `lookup` / `inquiry` / `user` / `staff` / `derived`).
|
||||
|
||||
---
|
||||
|
||||
## Stage 1 — Hull base claim (GEN.03)
|
||||
|
||||
Built by `buildFanavaranSubmitPayload` then `enrichCarBodyHullBaseClaimPayload` then `toFanavaranHullBaseClaimPayload`.
|
||||
|
||||
**Fanavaran has rejected as required (must not be null):**
|
||||
`CustomerFaultPercent`, `AuthorityCulpritId`, `AccidentTypeId`, `CostSeparationToDmgSections`, `IsOwnerChanged`, `PolicyId`, `ClaimExpertId`, dates/time, `CulpritTypeId`.
|
||||
|
||||
**Must be null:** `IsLicenseReplaced` (error if `0` / mapped from ثالث `IsLicenseReplacement`).
|
||||
|
||||
| Field | Persian | Type | Null? | Current value / source |
|
||||
|-------|---------|------|-------|------------------------|
|
||||
| PolicyId | شناسه بیمهنامه بدنه | number | **no** | **inquiry** first-party hull policy (`processed-body-policy` / `body-policy/{id}` / stored inquiry `policyId`) |
|
||||
| ClaimExpertId | کارشناس مسئول پرونده | number | **no** | **staff** FileMaker `CarBodyClaimExpertId` on V4/V5, else tenant |
|
||||
| AccidentDate | تاریخ حادثه | string Jalali | **no** | **user** first-party `statement.accidentDate`, else createdAt |
|
||||
| AccidentTime | ساعت حادثه | string `HH:mm` | **no** | **user** `statement.accidentTime` if set |
|
||||
| AnnouncementDate | تاریخ اعلام | string Jalali | **no** | **derived** = AccidentDate |
|
||||
| AccidentLocationAddress | نشانی محل حادثه | string | **no** | **system** `"استان تهران شهر تهران"` |
|
||||
| AccidentCauseId | علت حادثه | number | **no** | **system** `6` · lookup `accident-causes` |
|
||||
| AccidentCityId | شهر حادثه | number | **no** | **system** `701` · lookup `cities` |
|
||||
| AccidentReportTypeId | نحوه اعلام | number | **no** | **system** `155` · lookup `accident-report-type` |
|
||||
| AccidentTypeId | نوع حادثه بدنه | number | **no** | **system** `2` تصادف(حادثه) · **lookup** `GET /lookups/vehicle-hull-accident-types` |
|
||||
| CulpritTypeId | نوع مقصر بدنه | number | **no** | **system** `300` (ثالث `337` remapped) · **lookup** `GET /lookups/vehicle-hull-accident-culprit-type` |
|
||||
| AuthorityCulpritId | مرجع تعیین مقصر | number | **no** | **lookup** `GET /lookups/detection-accident-culprits` (caption بیمهگذار, fallback `1`) |
|
||||
| AccidentCulpritId | شناسه مقصر فناوران | number | preferred | **inquiry** parties `inquiry-by-unique-identifier`, fallback hull policy `CustomerId` |
|
||||
| CustomerFaultPercent | درصد تقصیر بیمهگذار | number | **no** | **system** `100` |
|
||||
| CostSeparationToDmgSections | تفکیک هزینه به نقاط | number (ans) | **no** | **system** `0` خیر · lookup `ans` |
|
||||
| IsOwnerChanged | مالک تغییر یافته؟ | number (ans) | **no** | **system** `0` · lookup `ans` |
|
||||
| IsLicenseReplaced | گواهینامه تعویضی است؟ | number | **must be null** | never copy ثالث `IsLicenseReplacement` |
|
||||
| ActualPremium | حق بیمه واقعی | number | preferred | **inquiry** hull policy `TotalPremium` via `GET /lookups/body-policy/{PolicyId}` |
|
||||
| EstimateAmount | برآورد | number | **no** | **user** expert prices / provisional |
|
||||
| CulpritLicenceNo | شماره گواهینامه | string | **no** | dummy / **user** |
|
||||
| CulpritLicenceIssuDate | تاریخ صدور گواهینامه | string Jalali | **no** | **system** `"1394/10/13"` |
|
||||
| CulpritLicenceTypeId | نوع گواهینامه | number | sent | tenant `2` · lookup `driving-licence-types` |
|
||||
| AccidentVehicleUsedId | کاربری خودرو | number | **no** | **inquiry** VIN/policy, else tenant |
|
||||
| PoliceOfficerId | افسر | number | sent | **system** `1` |
|
||||
| IsAccidentOutOfBorder | خارج از مرز | number | sent | **system** `0` |
|
||||
| ArchiveNo | بایگانی | string | yes | null |
|
||||
| EntryDate | تاریخ ورود | string | yes | null |
|
||||
| CulpritLicenceForeignCityName | شهر خارجی گواهینامه | string | yes | null |
|
||||
| PoliceReportSeri / Serial / Desc | کروکی | string | yes | null until **user** |
|
||||
| CulpritLevelTwoLicenceIssuDate | صدور گواهینامه پایه ۲ | string | yes | null |
|
||||
| TrackingCode | کد پیگیری | string | yes | null |
|
||||
| CourtArchiveNo | بایگانی دادگاه | string | yes | null |
|
||||
| PlaqueReplacementDate | تاریخ تعویض پلاک | string | yes | null |
|
||||
| StatusChangeDate | تغییر وضعیت | string | yes | null |
|
||||
| ClaimCompletionDate | تکمیل پرونده | string | yes | null |
|
||||
| DmgAssessorFirstCreationTime | زمان ارزیاب | string | yes | null |
|
||||
| IsSurplusArticleEighthLaw | مازاد ماده ۸ | number | yes | null |
|
||||
| GlassBreakReasonId | علت شکست شیشه | number | yes | null |
|
||||
| CulpritLicenceCountryId / CityId | محل صدور گواهینامه | number | yes | null |
|
||||
|
||||
Also persisted locally (not a GEN.03 field): `claimCase.vehicle.carPrice` from hull policy **`VehicleValue`**.
|
||||
|
||||
**Inquiries at this stage**
|
||||
|
||||
- Hull policy: `GET /lookups/body-policy/{PolicyId}` → `TotalPremium`, `CustomerId`, `VehicleValue`, `VehicleId`
|
||||
- Parties: `GET /lookups/inquiry-by-unique-identifier`
|
||||
- Optional VIN / vehicle used-id
|
||||
|
||||
---
|
||||
|
||||
## Stage 2 — Damage case
|
||||
|
||||
**Skipped.** YARA returns `fanavaranSync.damageCase.status = skipped`. Do not POST `dmg-cases`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 3 — Attachments (GEN.05)
|
||||
|
||||
Multipart `Param` + `Param1`. Proven `FileTypeId=63`.
|
||||
|
||||
| Field | Persian | Type | Null? | Source |
|
||||
|-------|---------|------|-------|--------|
|
||||
| FileName | نام فایل | string | **no** | **user** (exact match to binary) |
|
||||
| FileTypeId | نوع مدرک | number | **no** | **lookup** `file-types` / tenant `ClaimFileTypeId` |
|
||||
| ClaimId | کد رایانه پرونده بدنه | number | **no** | stage 1 `Id` |
|
||||
|
||||
---
|
||||
|
||||
## Stage 4 — Expertise (GEN.06)
|
||||
|
||||
Not ثالث GEN.08: no `DmgCaseId`, `InspectionPlaceId`, `RepairWage` on the root. Uses `Wage` + vehicle identity.
|
||||
|
||||
`ClaimExpertId` = FileReviewer `CarBodyExpertiseClaim` on V4/V5, else tenant `ExpertiseClaimExpertId`.
|
||||
|
||||
| Field | Persian | Type | Null? | Current source |
|
||||
|-------|---------|------|-------|----------------|
|
||||
| ClaimExpertId | کارشناس ارزیاب بدنه | number | **no** | **staff** / tenant |
|
||||
| DmgAssessmentDate | تاریخ بازدید | string Jalali | **no** | **derived** expert `submittedAt` |
|
||||
| InspectionTime | ساعت بازدید | string | **no** | **derived** |
|
||||
| MotorNo | شماره موتور | string | preferred | **inquiry** hull vehicle / party mapped `MtrNum` |
|
||||
| ChassisNo | شماره شاسی | string | preferred | **inquiry** `ShsNum` |
|
||||
| BuiltYear | سال ساخت | number | preferred | **inquiry** `BuiltYear` / `PrdDte` |
|
||||
| VIN | VIN | string | preferred | **inquiry** |
|
||||
| PlaqueNo | پلاک رشتهای | string | preferred | **inquiry** plk1+letter+plk3 |
|
||||
| PlaqueSerial | سریال پلاک (ایران) | string | preferred | **inquiry** |
|
||||
| ColorId | رنگ | number | preferred | **inquiry** `GET` vehicle by policy `VehicleId` |
|
||||
| Wage | اجرت کل | number | **no** | **user** sum `salary` |
|
||||
| ComponentReplacementCost | قطعه کل | number | **no** | **user** sum `price` |
|
||||
| WasteValue | داغی کل | number | **no** | **user** daghi |
|
||||
| RepairDuration | مدت تعمیر (روز) | number | **no** | **system** `3` |
|
||||
| VehicleCurrentValue | ارزش روز خودرو | number | preferred | **user** `vehicle.carPrice` / `vehicle.price`, else **inquiry** policy `VehicleValue` |
|
||||
| DropAmount | افت قیمت | number | **no** | **user** price-drop total or `0` |
|
||||
| CarryAndRescueCost | حمل و نجات | number | yes | null |
|
||||
| WreckHighestValue | بالاترین ارزش لاشه | number | yes | null |
|
||||
| InspectionDeduction | کسر بازدید | number | yes | null |
|
||||
| DmgAndWasteDesc | شرح خسارت و داغی | string | yes | null |
|
||||
| WentDistanceByExpert | مسافت | number | yes | null |
|
||||
| IsDestruction | اسقاط؟ | number | yes | null |
|
||||
| PlaqueDesignId | طرح پلاک | number | yes | null |
|
||||
| PlaqueCityId | شهر پلاک | number | yes | null |
|
||||
| AccidentPercent | درصد حادثه | number | yes | null |
|
||||
| TheftCases | موارد سرقت | array | empty `[]` | system |
|
||||
| DmgSections[].Count | تعداد | number | **no** | **system** `1` |
|
||||
| DmgSections[].Desc | شرح قطعه | string | **no** | **user** part label |
|
||||
| DmgSections[].WasteValue | داغی ردیف | number | **no** | **user** |
|
||||
| DmgSections[].AccessoryKindId | نوع قطعه کاتالوگ | number | preferred | **user** `partId` · lookup `car-components` |
|
||||
| DmgSections[].DmgKindId | نوع آسیب بدنه | number | **no** | **lookup** `vehicle-hull-dmg-kind` caption تخریب, fallback `5485` |
|
||||
| DmgSections[].VehicleHullAccessoryId | شناسه لوازم بدنه | number | **no** | **system** `3043330` (فابریک) for **every** row |
|
||||
| DmgSections[].DmgSectionCosts[].Caption | عنوان هزینه | string | **no** | **user** label |
|
||||
| DmgSections[].DmgSectionCosts[].Amount | مبلغ (قطعه+اجرت) | number | **no** | **user** |
|
||||
| DmgSections[].DmgSectionCosts[].DmgCostKindId | نوع هزینه | number | **no** | **lookup** `vehicle-hull-dmg-cost-kinds` caption مجموع لوازم, fallback `1` |
|
||||
|
||||
**Lookups used:** `vehicle-hull-dmg-kind`, `vehicle-hull-dmg-cost-kinds`, `detection-accident-culprits`, `vehicle-hull-accident-types`, `vehicle-hull-accident-culprit-type`, `ans`, optional `vehicle-hull-policies/{id}/dmg-accessories` (no longer used for AccessoryId).
|
||||
|
||||
**Inquiries:** `body-policy/{PolicyId}`, `vehicleById` (color + identity), parties inquiry, VIN/plate inquiry on blame.
|
||||
|
||||
---
|
||||
|
||||
## YARA preview / submit
|
||||
|
||||
Same routes as ثالث (`/base-claim`, `/attachments`, `/expertise`).
|
||||
`/damage-case` preview/submit **skips** for `CAR_BODY`.
|
||||
201
docs/fanavaran/flow-design-user-vs-inquiry.md
Normal file
201
docs/fanavaran/flow-design-user-vs-inquiry.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Fanavaran intake design — user vs inquiry (proposal)
|
||||
|
||||
This file is a **proposed product design**, not a description of every current default. Use it to decide which YARA screens should collect data and which calls should stay automatic.
|
||||
|
||||
Two intake channels only:
|
||||
|
||||
1. **Inquiries & lookups** (Fanavaran / ESG / SandHub — no extra typing when they succeed)
|
||||
2. **User input** (owner, expert, file-maker, file-reviewer)
|
||||
|
||||
Persian titles match Fanavaran field captions where known. Types are what we send on the wire.
|
||||
|
||||
Related: [third-party-stage-fields.md](./third-party-stage-fields.md), [car-body-stage-fields.md](./car-body-stage-fields.md).
|
||||
|
||||
---
|
||||
|
||||
## Shared identity (both products)
|
||||
|
||||
Collect once on the blame file. Inquiries reuse these keys.
|
||||
|
||||
| Persian | Field / storage | Type | Channel | Why |
|
||||
|---------|-----------------|------|---------|-----|
|
||||
| کد ملی بیمهگذار | `nationalCodeOfInsurer` | string(10) | **user** | Policy inquiry key |
|
||||
| تاریخ تولد (جلالی) | `birthday` | string `yyyy/mm/dd` or `yyyyMMdd` | **user** | Parties inquiry + GEN.44 |
|
||||
| کد ملی راننده | `nationalCodeOfDriver` | string(10) | **user** if `driverIsInsurer=false` | Parties inquiry |
|
||||
| راننده همان بیمهگذار است؟ | `driverIsInsurer` | boolean | **user** | Chooses which national code / GEN.44 |
|
||||
| شماره موبایل | phone | string | **user** | OTP / SMS, not Fanavaran |
|
||||
| VIN / شماره شاسی | vin, chassis | string | **inquiry first**, **user** if miss | VIN inquiry, vehicle GET |
|
||||
| شماره موتور | motorNo | string | **inquiry**, else **user** | GEN.06 / GEN.12 |
|
||||
| پلاک (۲ رقم، حرف، ۳ رقم، ایران) | plate parts | string/number | **user** (photo/OCR later) then **inquiry** confirm | Plaque + policy match |
|
||||
| سال ساخت | builtYear | number | **inquiry**, else **user** | GEN.12 / GEN.06 |
|
||||
| نوع بدنه خودرو (سواری/شاسیبلند/…) | `vehicle.carType` | enum string | **user** | Fallback `VehicleKindId` lookup |
|
||||
|
||||
**Auto after identity**
|
||||
|
||||
| Persian | Fanavaran field | Type | Call |
|
||||
|---------|-----------------|------|------|
|
||||
| شناسه بیمهنامه ثالث | `PolicyId` (ثالث) | number | `my-policies` / `processed-third-party-policy` |
|
||||
| شناسه بیمهنامه بدنه | `PolicyId` (بدنه) | number | `processed-body-policy` / `body-policy/{id}` |
|
||||
| شناسه شخص فناوران | `DriverId` / `AccidentCulpritId` | number | `inquiry-by-unique-identifier` → else GEN.44 |
|
||||
| شناسه مشتری بیمهنامه | `CustomerId` | number | hull policy GET |
|
||||
| حق بیمه | `ActualPremium` | number | hull `TotalPremium` |
|
||||
| ارزش روز خودرو (بیمهنامه) | `VehicleCurrentValue` seed | number | hull `VehicleValue` → `vehicle.carPrice` |
|
||||
| رنگ | `ColorId` | number | vehicle GET |
|
||||
| نوع کاربری | `AccidentVehicleUsedId` | number | vehicle `UsedId` + `vehicle-use-types` |
|
||||
| نوع خودرو فناوران | `VehicleKindId` | number | VIN `VehicleKindId` or `vehicle-kinds` |
|
||||
| مشخصات پلاک فناوران | PlaqueKindId / SampleId / … | number/string | VIN / vehicle GET |
|
||||
|
||||
---
|
||||
|
||||
## Accident facts (both) — mostly user
|
||||
|
||||
| Persian | Field | Type | Channel |
|
||||
|---------|-------|------|---------|
|
||||
| تاریخ حادثه | AccidentDate | Jalali date | **user** |
|
||||
| ساعت حادثه | AccidentTime | `HH:mm` | **user** |
|
||||
| نشانی محل حادثه | AccidentLocationAddress | string | **user** (today hardcoded تهران) |
|
||||
| شهر محل حادثه | AccidentCityId | number | **user** pick from `cities` (today `701`) |
|
||||
| علت حادثه | AccidentCauseId | number | **user** pick from `accident-causes` (today `6`) |
|
||||
| نحوه اعلام حادثه | AccidentReportTypeId | number | **user** or default from `accident-report-type` |
|
||||
| کروکی — سری / سریال / شرح | PoliceReport* | string | **user** optional; null ok |
|
||||
| خارج از مرز؟ | IsAccidentOutOfBorder | ans number | **user** default خیر |
|
||||
|
||||
---
|
||||
|
||||
## Third-party only — proposed screens
|
||||
|
||||
### File create (مقصر + زیاندیده)
|
||||
|
||||
| Persian | Field | Type | Channel |
|
||||
|---------|-------|------|---------|
|
||||
| نقش طرفین | FIRST / SECOND | enum | **user** |
|
||||
| مقصر کیست | guiltyPartyId | id | **user** expert decision |
|
||||
| نوع مقصر | CulpritTypeId | number | **user** or default `accident-culprit-type` |
|
||||
| شماره گواهینامه مقصر | CulpritLicenceNo | string | **user** (stop dummy when captured) |
|
||||
| نوع گواهینامه مقصر | CulpritLicenceTypeId | number | **user** from `driving-licence-types` |
|
||||
| تاریخ صدور گواهینامه | CulpritLicenceIssuDate | Jalali date | **user** |
|
||||
| گواهینامه تعویضی؟ | IsLicenseReplacement | ans | **user** default خیر |
|
||||
| قطعات آسیبدیده زیاندیده | selectedParts | catalog ids | **user** · `car-components` |
|
||||
| نوع خودرو زیاندیده | carType | enum | **user** |
|
||||
|
||||
### Expert pricing (ثالث)
|
||||
|
||||
| Persian | Field | Type | Channel |
|
||||
|---------|-------|------|---------|
|
||||
| قیمت قطعه | parts[].price | money | **user** |
|
||||
| اجرت | parts[].salary | money | **user** |
|
||||
| داغی | parts[].daghi | money/object | **user** |
|
||||
| شدت آسیب | AccidentLevel / typeOfDamage | number | **user** · `accident-level` |
|
||||
| افت قیمت — دارد/ندارد + مبلغ | DropAmount* | number | **user** · `drop-amount-status` |
|
||||
| محل بازدید | InspectionPlaceId | number | **user** or branch default · `inspection-place` |
|
||||
| قیمت روز خودرو (اختیاری ثالث) | DamagedVehicleCurrentPrice | money | **user** |
|
||||
|
||||
### Staff (V4/V5)
|
||||
|
||||
| Persian | Field | Type | Channel |
|
||||
|---------|-------|------|---------|
|
||||
| کد کارشناس پرونده ثالث | FileMaker.ThirdPartyClaimExpertId | string/number | **staff admin** |
|
||||
| کد ارزیاب ثالث | FileReviewer.ThirdPartyExpertiseClaim | string/number | **staff admin** |
|
||||
|
||||
Do not ask the owner for these.
|
||||
|
||||
---
|
||||
|
||||
## Car-body only — proposed screens
|
||||
|
||||
Hull is **first party**. One vehicle, one policy.
|
||||
|
||||
### File create
|
||||
|
||||
| Persian | Field | Type | Channel |
|
||||
|---------|-------|------|---------|
|
||||
| نوع حادثه بدنه | AccidentTypeId | number | **user** from `vehicle-hull-accident-types` (default تصادف = 2) |
|
||||
| نوع مقصر بدنه | CulpritTypeId | number | **user** from `vehicle-hull-accident-culprit-type` (default بیمهگذار = 300) |
|
||||
| مرجع تعیین مقصر | AuthorityCulpritId | number | **user** from `detection-accident-culprits` (default بیمهگذار) |
|
||||
| درصد تقصیر بیمهگذار | CustomerFaultPercent | number 0–100 | **user** if not 100%; today always 100 |
|
||||
| تفکیک هزینه به نقاط | CostSeparationToDmgSections | ans | **user** default خیر |
|
||||
| مالک تغییر یافته؟ | IsOwnerChanged | ans | **user** default خیر |
|
||||
| گواهینامه تعویضی؟ | IsLicenseReplaced | ans or empty | **must stay empty/null** unless Fanavaran later requires a value |
|
||||
| قطعات آسیبدیده | selectedParts | catalog ids | **user** |
|
||||
|
||||
### Expert pricing (بدنه)
|
||||
|
||||
| Persian | Field | Type | Channel |
|
||||
|---------|-------|------|---------|
|
||||
| قیمت قطعه / اجرت / داغی | price, salary, daghi | money | **user** |
|
||||
| قیمت روز خودرو | vehicle.carPrice | money | **user** required; seed from policy `VehicleValue` |
|
||||
| افت قیمت | DropAmount / priceDrop.total | money | **user** |
|
||||
| مدت تعمیر (روز) | RepairDuration | number | **system** `3` unless product wants **user** |
|
||||
| نوع آسیب ردیف | DmgKindId | number | default تخریب; optional **user** · `vehicle-hull-dmg-kind` |
|
||||
| نوع هزینه ردیف | DmgCostKindId | number | default مجموع لوازم · `vehicle-hull-dmg-cost-kinds` |
|
||||
| لوازم فابریک | VehicleHullAccessoryId | number | **system** `3043330` (do not ask user) |
|
||||
|
||||
### Staff (V4/V5)
|
||||
|
||||
| Persian | Field | Type | Channel |
|
||||
|---------|-------|------|---------|
|
||||
| کد کارشناس پرونده بدنه | FileMaker.CarBodyClaimExpertId | string/number | **staff admin** |
|
||||
| کد ارزیاب بدنه | FileReviewer.CarBodyExpertiseClaim | string/number | **staff admin** |
|
||||
|
||||
If the matching code is missing, YARA must not create/review that product line.
|
||||
|
||||
---
|
||||
|
||||
## Documents (both) — user files
|
||||
|
||||
| Persian | Typical key | Channel |
|
||||
|---------|-------------|---------|
|
||||
| کارت ملی / گواهینامه / کارت خودرو / پلاک فلزی | requiredDocuments | **user** photos |
|
||||
| عکس زاویه خودرو / قطعه | damage + car-angle captures | **user** |
|
||||
| فاکتور تعمیر | factor images | **user** when `factorNeeded` |
|
||||
|
||||
`FileTypeId` is **lookup** (`file-types`), not typed by the owner.
|
||||
|
||||
---
|
||||
|
||||
## Lookups catalogue (for UI dropdowns)
|
||||
|
||||
Expose these as select lists; store the numeric `Id`.
|
||||
|
||||
| Persian UI | Lookup route | Used on |
|
||||
|------------|--------------|---------|
|
||||
| علت حادثه | `/lookups/accident-causes` | GEN.03 |
|
||||
| نوع اعلام حادثه | `/lookups/accident-report-type` | GEN.03 |
|
||||
| شهر | `/lookups/cities` | GEN.03 city |
|
||||
| بله/خیر | `/lookups/ans` | hull flags |
|
||||
| نوع مقصر ثالث | `/lookups/accident-culprit-type` | ثالث GEN.03 |
|
||||
| نوع مقصر بدنه | `/lookups/vehicle-hull-accident-culprit-type` | بدنه GEN.03 |
|
||||
| نوع حادثه بدنه | `/lookups/vehicle-hull-accident-types` | بدنه GEN.03 |
|
||||
| مرجع تعیین مقصر | `/lookups/detection-accident-culprits` | بدنه GEN.03 |
|
||||
| نوع گواهینامه | `/lookups/driving-licence-types` | GEN.03 / GEN.12 |
|
||||
| کاربری خودرو | `/lookups/vehicle-use-types` | GEN.03 / GEN.12 |
|
||||
| نوع خودرو | `/lookups/vehicle-kinds` | GEN.12 |
|
||||
| قطعات | `/lookups/car-components` | parts / DmgSectionId |
|
||||
| شدت آسیب | `/lookups/accident-level` | ثالث expertise |
|
||||
| محل بازدید | `/lookups/inspection-place` | ثالث expertise |
|
||||
| وضعیت افت | `/lookups/drop-amount-status` | ثالث expertise |
|
||||
| نوع آسیب بدنه | `/lookups/vehicle-hull-dmg-kind` | بدنه expertise |
|
||||
| نوع هزینه بدنه | `/lookups/vehicle-hull-dmg-cost-kinds` | بدنه expertise |
|
||||
| نوع فایل | `/lookups/file-types` | attachments |
|
||||
| جنسیت / تأهل / تابعیت / نوع شخص | gender, marital-status, countries, person-kind, ans | GEN.44 only if inquiry misses |
|
||||
|
||||
Inquiry helpers (not dropdowns): `inquiry-by-vin`, `inquiry-by-unique-identifier`, `my-policies`, `processed-third-party-policy`, `processed-body-policy`, `third-party-policy/:id`, `body-policy/:id`.
|
||||
|
||||
---
|
||||
|
||||
## Suggested flow (what to collect, in order)
|
||||
|
||||
```text
|
||||
1. User: phone + national code + birthday + plate (and VIN if known)
|
||||
2. Inquiry: policy + vehicle + person Id
|
||||
3. If person missing → GEN.44 using user national code + birthday (submit time)
|
||||
4. User: accident date/time/address + (بدنه) accident type / culprit authority
|
||||
5. User: damaged parts
|
||||
6. Expert: prices, daghi, carPrice (بدنه)
|
||||
7. System: ClaimExpertId / expertise ClaimExpertId from staff profiles
|
||||
8. System: POST stages in order (ثالث 1→2→3→4, بدنه 1→3→4)
|
||||
```
|
||||
|
||||
**Do not ask the user for:** Fanavaran `PolicyId`, `DriverId`, `CustomerId`, `ColorId`, `VehicleKindId`, `VehicleHullAccessoryId` (فابریک), `ActualPremium`, `FileTypeId`, expert numeric ids.
|
||||
|
||||
**Stop using dummies when the screen exists:** `CulpritLicenceNo`, `CulpritLicenceIssuDate`, `AccidentLocationAddress`, `AccidentCityId`, `AccidentCauseId`. Until then, current defaults keep Fanavaran from rejecting the create.
|
||||
199
docs/fanavaran/third-party-stage-fields.md
Normal file
199
docs/fanavaran/third-party-stage-fields.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Third-party (ثالث) — Fanavaran stage bodies (current YARA)
|
||||
|
||||
Snapshot of **what `ClaimRequestManagementService` actually sends** for `THIRD_PARTY` as of this version.
|
||||
|
||||
- Base: `POST /car/third-party-car-financial-claims` (GEN.03)
|
||||
- Damage case: `POST …/{claimId}/dmg-cases` (GEN.12)
|
||||
- Attachments: `POST …/{claimId}/files` (GEN.07, multipart)
|
||||
- Expertise: `POST …/{claimId}/expertise` (GEN.08)
|
||||
|
||||
**Send rule:** keep keys in the JSON even when the value is `null`. Fanavaran treats omitted keys differently from explicit `null`.
|
||||
|
||||
**Source columns**
|
||||
|
||||
| Tag | Meaning |
|
||||
|-----|---------|
|
||||
| **system** | Hardcoded YARA default / tenant `fanavaranClientConfigs` |
|
||||
| **lookup** | Fanavaran codebook (`GET /lookups/…`) |
|
||||
| **inquiry** | Live Fanavaran inquiry (policies, VIN, parties, vehicle GET) |
|
||||
| **user** | Collected in YARA UI (owner, expert, file-maker) |
|
||||
| **staff** | FileMaker `ThirdPartyClaimExpertId` / FileReviewer `ThirdPartyExpertiseClaim` on V4/V5; else tenant defaults |
|
||||
| **derived** | Computed from other fields (dates, sums) |
|
||||
|
||||
Parsian tenant defaults used below: `ClaimExpertId=154`, `ExpertiseClaimExpertId=29`, `ClaimFileTypeId=63`, `AccidentCityId=701`, `AccidentReportTypeId=155`, `CulpritTypeId=337`, `CulpritLicenceTypeId=2`, `CompensationReferenceId=167`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 1 — Base claim (GEN.03)
|
||||
|
||||
**Must send (non-null):** `PolicyId`, `ClaimExpertId`, `AccidentDate`, `AnnouncementDate`, `AccidentTime`, `AccidentCauseId`, `AccidentCityId`, `AccidentReportTypeId`, `CulpritTypeId`, `CulpritLicenceTypeId`, `CulpritLicenceNo`, `CulpritLicenceIssuDate`, `DamagedCount`, `EstimateAmount` (> 0), `AccidentLocationAddress`, `HasOtherCulprit`, `IsFatalAccident`, `IsAccidentOutOfBorder`, `IsPlaqueChanged`, `IsLicenseMatchWithVehicleKind`, `IsLicenseReplacement`, `PoliceOfficerId`, `AccidentVehicleUsedId`.
|
||||
|
||||
**May be null** (and currently are, unless noted): everything else in the table.
|
||||
|
||||
| Field | Persian | Type | Null? | Current source | Notes |
|
||||
|-------|---------|------|-------|----------------|-------|
|
||||
| PolicyId | شناسه بیمهنامه مقصر | number | **no** | **inquiry** `GET /lookups/my-policies` (guilty national code, line ثالث) | Cached on `fanavaranSync.baseClaim.policyId` |
|
||||
| ClaimExpertId | کارشناس مسئول پرونده | number | **no** | **staff** FileMaker `ThirdPartyClaimExpertId` on V4/V5, else tenant | GEN.03 role, not expertise |
|
||||
| AccidentDate | تاریخ حادثه | string (Jalali `yyyy/mm/dd`) | **no** | **user** accident datetime, else blame `createdAt` | |
|
||||
| AnnouncementDate | تاریخ اعلام | string Jalali | **no** | **derived** = AccidentDate | |
|
||||
| DocReceivedDate | تاریخ وصول مدارک | string Jalali | **no** | **derived** = AccidentDate | Third-party only (not on hull whitelist) |
|
||||
| AccidentTime | ساعت حادثه | string `HH:mm` | **no** | **user** / **derived** from createdAt | |
|
||||
| AccidentLocationAddress | نشانی محل حادثه | string | **no** | **system** `"استان تهران شهر تهران"` | Should later be **user** |
|
||||
| AccidentCauseId | علت حادثه | number | **no** | **system** `6` | Lookup: `GET /lookups/accident-causes`. Expert `accidentReason.fanavaran` exists but is not yet wired as the sent id |
|
||||
| AccidentCityId | شهر محل حادثه | number | **no** | **system** `701` تهران | Lookup: `GET /lookups/cities` |
|
||||
| AccidentReportTypeId | نحوه اعلام حادثه | number | **no** | **system** `155` | Lookup: `GET /lookups/accident-report-type` |
|
||||
| AccidentVehicleUsedId | نوع کاربری خودرو | number | **no** | **inquiry** VIN / policy vehicle `UsedId`, else tenant `1` | Lookup: `GET /lookups/vehicle-use-types` |
|
||||
| CulpritTypeId | نوع مقصر | number | **no** | **system** `337` | Lookup: `GET /lookups/accident-culprit-type` (ثالث, not hull) |
|
||||
| CulpritLicenceTypeId | نوع گواهینامه مقصر | number | **no** | **system** `2` | Lookup: `GET /lookups/driving-licence-types` |
|
||||
| CulpritLicenceNo | شماره گواهینامه مقصر | string | **no** | **user** party licence if stored, else dummy `9705463515` | Must never be empty |
|
||||
| CulpritLicenceIssuDate | تاریخ صدور گواهینامه | string Jalali | **no** | **system** `"1394/10/13"` | Should later be **user** |
|
||||
| EstimateAmount | مبلغ برآورد | number | **no** | **user** expert part prices, else provisional `1000` | Must be > 0 |
|
||||
| CompensationReferenceId | مرجع جبران | number | **no** | **system** `167` | Tenant default |
|
||||
| DamagedCount | تعداد زیاندیدگان | number | **no** | **system** `1` | |
|
||||
| HasOtherCulprit | مقصر دیگر دارد؟ | number (ans) | **no** | **system** `0` | Lookup `GET /lookups/ans` |
|
||||
| IsFatalAccident | فوت دارد؟ | number | **no** | **system** `0` | |
|
||||
| IsAccidentOutOfBorder | خارج از مرز؟ | number | **no** | **system** `0` | |
|
||||
| IsPlaqueChanged | پلاک تعویضی؟ | number | **no** | **system** `0` | |
|
||||
| IsLicenseMatchWithVehicleKind | گواهینامه متناسب؟ | number | **no** | **system** `1` | |
|
||||
| IsLicenseReplacement | گواهینامه تعویضی؟ | number | **no** | **system** `0` | Third-party uses this name (hull uses `IsLicenseReplaced` and must be null) |
|
||||
| PoliceOfficerId | افسر پلیس | number | **no** | **system** `1` | |
|
||||
| ActualPremium | حق بیمه واقعی | number | yes | unused on ثالث create | |
|
||||
| ArchiveNo | شماره بایگانی | string | yes | — | |
|
||||
| AuthorityCulpritId | مرجع تعیین مقصر | number | yes | **null** on ثالث | Hull requires this |
|
||||
| AccidentCulpritId | شناسه مقصر در فناوران | number | yes | **null** on ثالث | Hull fills from parties inquiry |
|
||||
| ClaimCompletionDate | تاریخ تکمیل | string | yes | — | |
|
||||
| CostSeparationToDmgSections | تفکیک هزینه به نقاط | number | yes | **null** on ثالث | Hull sends `0` |
|
||||
| CouponNo | شماره کوپن | string | yes | — | |
|
||||
| CourtArchiveNo | شماره بایگانی دادگاه | string | yes | — | |
|
||||
| CustomerFaultPercent | درصد تقصیر بیمهگذار | number | yes | **null** on ثالث | Hull sends `100` |
|
||||
| CulpritLicenceCityId | شهر صدور گواهینامه | number | yes | Lookup `cities` | |
|
||||
| CulpritLicenceCountryId | کشور صدور گواهینامه | number | yes | Lookup `countries` | |
|
||||
| CulpritLicenceForeignCityName | شهر خارجی صدور | string | yes | — | |
|
||||
| DmgAssessorFirstCreationTime | زمان ایجاد ارزیاب | string | yes | — | |
|
||||
| EntryDate | تاریخ ورود | string | yes | — | |
|
||||
| GlassBreakReasonId | علت شکست شیشه | number | yes | — | |
|
||||
| IsOwnerChanged | مالک تغییر کرده؟ | number | yes | **null** | Hull sends `0` |
|
||||
| IsSurplusArticleEighthLaw | مازاد ماده ۸ | number | yes | — | |
|
||||
| PlaqueCityId … PlaqueSerial | پلاک (بلوک پایه) | mixed | yes | **null** on GEN.03 ثالث | Plaque belongs on GEN.12 |
|
||||
| PoliceReportDesc / Seri / Serial | مشخصات کروکی | string | yes | **user** later | |
|
||||
| PreviousPolicyEndDate | پایان بیمهنامه قبلی | string | empty `""` | **system** | |
|
||||
| StatusChangeDate | تاریخ تغییر وضعیت | string | yes | — | |
|
||||
| TrackingCode | کد پیگیری | string | yes | — | |
|
||||
| UnknownCulpritCauseId | علت مقصر ناشناس | number | yes | — | |
|
||||
|
||||
**Inquiries used at this stage**
|
||||
|
||||
- `GET /lookups/my-policies?nationalCode=&insuranceLineId=` (ثالث)
|
||||
- Optional VIN → `AccidentVehicleUsedId`
|
||||
|
||||
**User input already needed (even if some are still defaulted)**
|
||||
|
||||
- Guilty party national code (to find PolicyId)
|
||||
- Accident date/time
|
||||
- Expert priced parts (EstimateAmount) when available
|
||||
|
||||
---
|
||||
|
||||
## Stage 2 — Damage case (GEN.12)
|
||||
|
||||
Nested under Fanavaran `claimId` (`Id` from stage 1, never `ClaimNo`).
|
||||
|
||||
**Must send (non-null in proven Parsian):** `DriverId`, `VehicleKindId`, `AccidentVehicleUsedId`, `DmgCaseTypeId`, `DmgHistoryStatus`, `FaultPercent`, `InsuranceCorpId`, `LicenceNo`, `LicenceTypeId`, `LicenceIssuDate`, `EstimateAmount`, `Desc`, plaque block (`PlaqueKindId`, `PlaqueSampleId`, left/middle/right/serial), `BeginDate`/`EndDate` when inquiry has them.
|
||||
|
||||
If `DriverId` is missing, **submit** (not preview) registers GEN.44 `POST /common/other-people` and stores `otherPersonId`.
|
||||
|
||||
| Field | Persian | Type | Null? | Current source |
|
||||
|-------|---------|------|-------|----------------|
|
||||
| DriverId | شناسه راننده زیاندیده در فناوران | number | **no** on submit | **inquiry** `GET /lookups/inquiry-by-unique-identifier` (national code + Jalali birthday). Fallback **GEN.44** create |
|
||||
| OwnerId | شناسه مالک | number | yes | currently `null` |
|
||||
| VehicleKindId | نوع خودرو | number | **no** | **inquiry** VIN vehicle `VehicleKindId`, else **lookup** `GET /lookups/vehicle-kinds` matched to `claim.vehicle.carType` |
|
||||
| AccidentVehicleUsedId | کاربری | number | **no** | **inquiry** vehicle `UsedId` / use-types, else tenant |
|
||||
| VIN | شماره شاسی ۱۷ رقمی | string | preferred | **inquiry** plate/VIN inquiry |
|
||||
| ChassisNo | شماره شاسی | string | preferred | **inquiry** |
|
||||
| MotorNo | شماره موتور | string | preferred | **inquiry** |
|
||||
| BuiltYear | سال ساخت | number | preferred | **inquiry** VIN / mapped `PrdDte` |
|
||||
| PlaqueKindId | نوع پلاک | number | **no** | **inquiry** vehicle GET, else national-plate default |
|
||||
| PlaqueSampleId | نمونه پلاک | number | **no** | same |
|
||||
| PlaqueLeftNo | دو رقم سمت چپ | string | **no** | **user** plate / **inquiry** |
|
||||
| PlaqueMiddleCodeId | حرف پلاک (کد فناوران) | number | **no** | **derived** from plate letter |
|
||||
| PlaqueRightNo | سه رقم | string | **no** | **user** / **inquiry** |
|
||||
| PlaqueSerial | ایران | string | **no** | **user** / **inquiry** |
|
||||
| PlaqueNo | پلاک رشتهای | string | preferred | **derived** |
|
||||
| PlaqueCityId | شهر پلاک | number | yes | **inquiry** vehicle |
|
||||
| PolicyNo | شماره چاپی بیمهنامه زیاندیده | string | preferred | **inquiry** ESG/Tejarat mapped |
|
||||
| PolicyCINumber | کد یکتای بیمهنامه | string | preferred | **inquiry** `PlcyUnqCod` |
|
||||
| BeginDate | شروع پوشش | string Jalali | preferred | **inquiry** |
|
||||
| EndDate | پایان پوشش | string Jalali | preferred | **inquiry** |
|
||||
| PreviousPolicyEndDate | پایان بیمهنامه قبلی | string | empty ok | **derived** from EndDate |
|
||||
| InsuranceCorpId | شرکت بیمه زیاندیده | number | **no** | **lookup** insurance-corp for tenant |
|
||||
| DmgCaseTypeId | نوع مورد خسارت | number | **no** | **system** `175` · lookup `dmg-case-type` |
|
||||
| DmgHistoryStatus | وضعیت سابقه خسارت | number | **no** | **system** `5214` · lookup `dmg-history-status` |
|
||||
| FaultPercent | درصد تقصیر | number | **no** | **system** `100` |
|
||||
| DriverIsOwner | راننده همان مالک؟ | number | **no** | **user** `driverIsInsurer` → 1/0 |
|
||||
| LicenceNo | شماره گواهینامه زیاندیده | string | **no** | **user**, else dummy |
|
||||
| LicenceTypeId | نوع گواهینامه | number | **no** | **system** same as CulpritLicenceTypeId |
|
||||
| LicenceIssuDate | تاریخ صدور | string Jalali | **no** | **user** birthday used as stand-in, else dummy date |
|
||||
| LicenceCityId / CountryId / ForeignCityName | محل صدور گواهینامه | mixed | yes | currently null |
|
||||
| EstimateAmount | برآورد مورد خسارت | number | **no** | **system** provisional constant |
|
||||
| Desc | شرح قطعات آسیبدیده | string | **no** | **user** selected outer parts labels joined with `/` |
|
||||
| DmgCaseId | (response) | number | — | store as local `dmgCaseId` |
|
||||
|
||||
**Lookups:** `vehicle-kinds`, `vehicle-use-types`, `dmg-case-type`, `dmg-history-status`, `insurance-corp`, `file-types` (later), `driving-licence-types`, `ans`.
|
||||
|
||||
**Inquiries:** parties by national code + birthday; VIN inquiry; optional vehicle GET; plate inquiry on blame.
|
||||
|
||||
---
|
||||
|
||||
## Stage 3 — Attachments (GEN.07)
|
||||
|
||||
Multipart: field `Param` = JSON, field `Param1` = file bytes. One file per request. Parsian `FileTypeId=63` (سایر مدارک خسارت).
|
||||
|
||||
| Field | Persian | Type | Null? | Current source |
|
||||
|-------|---------|------|-------|----------------|
|
||||
| FileName | نام فایل | string | **no** | **user** upload name (must match binary name) |
|
||||
| FileTypeId | نوع مدرک | number | **no** | **lookup** `GET /lookups/file-types` via tenant `ClaimFileTypeId` |
|
||||
| Files[].FileName / FileTypeId | تکرار متادیتا | object | **no** | same |
|
||||
| ClaimId | (hull only on Param) | number | hull yes | third-party URL already has claimId |
|
||||
|
||||
Files themselves are **user** (documents, damage photos, factors).
|
||||
|
||||
---
|
||||
|
||||
## Stage 4 — Expertise (GEN.08)
|
||||
|
||||
Requires local `dmgCaseId`. `ClaimExpertId` here is the **assessor** role (Parsian `29`), from FileReviewer `ThirdPartyExpertiseClaim` on V4/V5.
|
||||
|
||||
| Field | Persian | Type | Null? | Current source |
|
||||
|-------|---------|------|-------|----------------|
|
||||
| DmgCaseId | شناسه مورد خسارت | number | **no** | stage 2 response |
|
||||
| ClaimExpertId | کارشناس ارزیاب | number | **no** | **staff** / tenant `ExpertiseClaimExpertId` |
|
||||
| DmgAssessmentDate | تاریخ بازدید | string Jalali | **no** | **derived** expert reply `submittedAt` |
|
||||
| InspectionTime | ساعت بازدید | string `HH:mm` | **no** | **derived** |
|
||||
| InspectionPlaceId | محل بازدید | number | **no** | **system** `282` · lookup `GET /lookups/inspection-place` |
|
||||
| RepairWage | اجرت کل | number | **no** | **user** sum of part `salary` |
|
||||
| ComponentReplacementCost | قطعه کل | number | **no** | **user** sum of part `price` |
|
||||
| WasteValue | داغی کل | number | **no** | **user** daghi prices |
|
||||
| DropAmountStatus | وضعیت افت | number | **no** | **system** `5458` · lookup `drop-amount-status` |
|
||||
| DropAmountAdditionsDeductions | مبلغ افت | number | **no** | **user** `evaluation.priceDrop.total` or `0` |
|
||||
| ComponentReplacementTaxAndToll | مالیات قطعه | number | **no** | **system** `0` |
|
||||
| DamagedVehicleCurrentPrice | قیمت روز خودرو | number | yes | **user** price-drop `carPrice` |
|
||||
| WentDistanceByExpert | مسافت کارشناس | number | yes | currently `null` |
|
||||
| DmgSections[].DmgSectionId | شناسه نقطه آسیب | number | **no** | **user** catalog `partId` · lookup `car-components` |
|
||||
| DmgSections[].AccidentLevel | شدت آسیب | number | **no** | **user** `typeOfDamage` or default `5456` · lookup `accident-level` |
|
||||
| DmgSections[].Desc | شرح | string | **no** | **user** |
|
||||
| DmgSections[].ComponentReplacementCost | قیمت قطعه | number | **no** | **user** |
|
||||
| DmgSections[].RepairWage | اجرت | number | **no** | **user** |
|
||||
| DmgSections[].WasteValue | داغی | number | **no** | **user** |
|
||||
|
||||
---
|
||||
|
||||
## YARA preview / submit
|
||||
|
||||
| Stage | Preview | Submit |
|
||||
|-------|---------|--------|
|
||||
| 1 | `GET /v2/fanavaran/{client}/claim-cases/{id}/base-claim/preview` | `POST …/base-claim/submit` |
|
||||
| 2 | `GET …/damage-case/preview` | `POST …/damage-case/submit` |
|
||||
| 3 | `GET …/attachments/preview` | `POST …/attachments/submit` |
|
||||
| 4 | `GET …/expertise/preview` | `POST …/expertise/submit` |
|
||||
|
||||
Nested Fanavaran URLs always use stored `claimId` (کد رایانه), never `claimNo`.
|
||||
@@ -157,7 +157,7 @@
|
||||
}
|
||||
```
|
||||
|
||||
سیستم ابتدا پلاک فعلی را با کد ملی بیمهگذار فعلیِ مرتبط با نوع بیمه استعلام میکند. اگر نتیجه ناموجود، منقضی یا فاقد بیمهنامه مرتبط باشد، پلاک قبلی را با `previousPolicyholderNationalCode` امتحان میکند. نتیجه پلاک قبلی فقط در صورت تطبیق VIN پذیرفته میشود؛ پلاک قبلی هرگز جایگزین پلاک فعلی نمیشود.
|
||||
اطلاعات انتقال اخیر فقط بهعنوان متادیتای پرونده ذخیره میشوند. در route پلاک، سیستم فقط `currentPlate` را با کد ملی بیمهگذار نهاییِ مرتبط با نوع بیمه استعلام میکند و هیچ fallbackای به `previousPlate` یا `previousPolicyholderNationalCode` ندارد. در route شماره شاسی نیز فقط `vehicle.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 ← بیمهگذار بدنه؛
|
||||
- شبا را در استعلام شخص مطالبهکننده خسارت (`SECOND` زیاندیده در `THIRD_PARTY` و طرف اول در `CAR_BODY`) الزامی کند و با کد ملی مالک خودرو اعتبارسنجی کند؛ در استعلام `FIRST` مقصر پرونده ثالث شبا دریافت نمیشود؛
|
||||
- بر اساس یک قاعده مشخص، پلاک فعلی یا قبلی را انتخاب و نتیجه پلاک قبلی را با VIN/شماره شاسی تطبیق دهد؛
|
||||
- فقط پلاک فعلی یا VIN ارسالشده را با بیمهگذار نهایی همان نوع بیمه استعلام کند؛ متادیتای انتقال قبلی نباید مسیریابی استعلام را تغییر دهد؛
|
||||
- استعلام هویت را برای هر شخص یکتا فقط یک بار اجرا کند؛
|
||||
- اشخاص نرمالشده و نقشهای آنها را در `Party` مربوط ذخیره کند.
|
||||
- `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
|
||||
|
||||
@@ -102,7 +102,7 @@ Create one shared participant resolver used by every inquiry route. Its interfac
|
||||
- return the resolved person for each role;
|
||||
- route the correct identity to each inquiry: driver licence → Driver, ownership and Sheba validation → Vehicle Owner, third-party policy by plate/VIN → Third-party Policyholder, car-body policy by plate/VIN → Car-body Policyholder;
|
||||
- require Sheba in the claimant inquiry (`THIRD_PARTY` damaged/SECOND party and `CAR_BODY` first party) and validate it against the resolved Vehicle Owner; the `THIRD_PARTY` guilty/FIRST inquiry does not collect Sheba;
|
||||
- choose the current or previous plate deterministically and verify previous-plate results against VIN/chassis;
|
||||
- query only the submitted current plate or VIN with the resolved policyholder for that policy type; previous-transfer metadata must not affect inquiry routing;
|
||||
- run personal identity inquiry once per distinct person;
|
||||
- persist normalized participants and role assignments on the relevant `Party`.
|
||||
- expose the persisted `participants` and `participantRoles` unchanged in expert-facing blame and linked-claim details so driver and other role data remain available for review.
|
||||
|
||||
@@ -370,14 +370,14 @@
|
||||
<table>
|
||||
<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/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 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/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 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 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>
|
||||
|
||||
@@ -363,14 +363,14 @@
|
||||
<table>
|
||||
<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/request/:claimRequestId</code></td><td>Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs. Completed claims include Fanavaran claimNo / claimId when available.</td></tr>
|
||||
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs, and the effective <code>priceCap</code> (530,000,000 Rial for V1; <code>null</code> for V2–V6 or when disabled). Completed claims include Fanavaran claimNo / claimId when available.</td></tr>
|
||||
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>Lock claim to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409.</td></tr>
|
||||
<tr><td><span class="method 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/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 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 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>
|
||||
@@ -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>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 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>
|
||||
</table>
|
||||
</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 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 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-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>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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_CERTIFICATE = "car_certificate",
|
||||
@@ -34,4 +37,3 @@ export enum CarAngle {
|
||||
LEFT = "left",
|
||||
RIGHT = "right",
|
||||
}
|
||||
|
||||
|
||||
@@ -483,6 +483,7 @@ describe("buildInsurerFileReport", () => {
|
||||
nationalCode: "0012345678",
|
||||
birthday: "1370/01/01",
|
||||
hasDrivingLicense: true,
|
||||
driverLicenseDate: "1400/01/01",
|
||||
licenseNumber: "123456789",
|
||||
licenseType: "BASE_2",
|
||||
},
|
||||
|
||||
@@ -732,7 +732,10 @@ function buildDriverSection(
|
||||
},
|
||||
{
|
||||
label: PR.licenseDate,
|
||||
value: licenseDate ?? asString(person.driverLicense),
|
||||
value:
|
||||
asString(person.driverLicenseDate) ||
|
||||
licenseDate ||
|
||||
asString(person.driverLicense),
|
||||
},
|
||||
{ label: PR.phone, value: asString(person.phoneNumber) },
|
||||
{ label: PR.nationalCode, value: asString(person.nationalCodeOfDriver) },
|
||||
|
||||
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 { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
||||
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -77,7 +76,6 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
|
||||
SandHubModule,
|
||||
ClientModule,
|
||||
MediaPolicyModule,
|
||||
SmsOrchestrationModule,
|
||||
JwtModule.register({}),
|
||||
MongooseModule.forFeature([
|
||||
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -801,6 +801,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
**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):
|
||||
- accident_sketch (optional; never blocks workflow completion)
|
||||
- damaged_driving_license_front/back
|
||||
- damaged_chassis_number, damaged_engine_photo
|
||||
- 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_back",
|
||||
"guilty_metal_plate",
|
||||
"accident_sketch",
|
||||
],
|
||||
example: "damaged_driving_license_front",
|
||||
},
|
||||
|
||||
@@ -42,6 +42,13 @@ export class RequiredDocumentItem {
|
||||
example: true,
|
||||
})
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,10 @@ export class ClaimVehicleSnapshot {
|
||||
|
||||
@Prop({ type: String })
|
||||
price?: string;
|
||||
|
||||
/** Vehicle value in Rial from hull policy VehicleValue or expert assessment. */
|
||||
@Prop({ type: Number })
|
||||
carPrice?: number;
|
||||
}
|
||||
export const ClaimVehicleSnapshotSchema =
|
||||
SchemaFactory.createForClass(ClaimVehicleSnapshot);
|
||||
|
||||
@@ -142,7 +142,7 @@ export class FanavaranSyncStage {
|
||||
@Prop({ type: 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 })
|
||||
smsNotifiedAt?: Date;
|
||||
|
||||
@@ -239,6 +239,10 @@ export class ClaimCase {
|
||||
@Prop({ type: Types.ObjectId, index: true })
|
||||
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.
|
||||
* 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_back",
|
||||
"guilty_metal_plate",
|
||||
"accident_sketch",
|
||||
],
|
||||
example: "damaged_driving_license_front",
|
||||
},
|
||||
|
||||
@@ -107,6 +107,29 @@ describe("fanavaran claim product", () => {
|
||||
expect(nationalCode).toBe("0012345678");
|
||||
});
|
||||
|
||||
it("prefers CAR_BODY_POLICYHOLDER participant over person.nationalCodeOfInsurer", () => {
|
||||
const nationalCode = pickCarBodyPolicyNationalCode({
|
||||
parties: [
|
||||
{
|
||||
role: PartyRole.FIRST,
|
||||
person: { nationalCodeOfInsurer: "0012345678" },
|
||||
participants: [
|
||||
{
|
||||
participantId: "CAR_BODY_POLICYHOLDER",
|
||||
nationalCode: "0499370899",
|
||||
birthday: "1368/05/20",
|
||||
},
|
||||
],
|
||||
participantRoles: {
|
||||
carBodyPolicyholder: "CAR_BODY_POLICYHOLDER",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(nationalCode).toBe("0499370899");
|
||||
});
|
||||
|
||||
it("flattens car-body inquiry fields onto the damage-case aliases", () => {
|
||||
const { mapped } = fanavaranPartyInquirySources("car-body", {
|
||||
insurance: {
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
parseFanavaranId,
|
||||
type FanavaranCarPolicyProduct,
|
||||
} from "src/lookups/fanavaran-last-car-policy";
|
||||
import { resolvePolicyholderFromParty } from "./fanavaran-party-roles";
|
||||
import type { InquiryParticipantRoleAssignments } from "src/request-management/inquiry-participant-resolver";
|
||||
|
||||
export type FanavaranClaimProduct = FanavaranCarPolicyProduct;
|
||||
|
||||
@@ -101,6 +103,8 @@ export function fanavaranInsuranceLineIdForProduct(
|
||||
type BlamePartyForCarBodyPolicy = {
|
||||
role?: string;
|
||||
person?: { nationalCodeOfInsurer?: unknown };
|
||||
participants?: Array<Record<string, any>>;
|
||||
participantRoles?: Partial<InquiryParticipantRoleAssignments>;
|
||||
insurance?: {
|
||||
carBodyInsurance?: {
|
||||
policyId?: unknown;
|
||||
@@ -168,6 +172,7 @@ export function pickCarBodyPolicyNationalCode(
|
||||
): string | null {
|
||||
const party = firstCarBodyParty(blame);
|
||||
return (
|
||||
resolvePolicyholderFromParty(party, "car-body").nationalCode ??
|
||||
nonEmptyText(party?.person?.nationalCodeOfInsurer) ??
|
||||
nonEmptyText(party?.insurance?.carBodyInsurance?.ownerNationalCode) ??
|
||||
nonEmptyText(party?.insurance?.carBodyInsurance?.insurerNationalCode)
|
||||
|
||||
@@ -34,6 +34,21 @@ export function asPartyInquiryRows(value: unknown): FanavaranPartyInquiryRow[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function selectFanavaranIdByRoles(
|
||||
rows: unknown,
|
||||
preferredRoles: number[],
|
||||
): number | null {
|
||||
const parties = asPartyInquiryRows(rows);
|
||||
if (parties.length === 0) return null;
|
||||
|
||||
for (const roleId of preferredRoles) {
|
||||
const match = parties.find((row) => asPositiveId(row.RoleId) === roleId);
|
||||
if (match) return asPositiveId(match.Id);
|
||||
}
|
||||
|
||||
return asPositiveId(parties[0].Id);
|
||||
}
|
||||
|
||||
/**
|
||||
* DriverId for GEN.12 زیاندیده. Prefer insurer (161) or driver (166)
|
||||
* based on driverIsInsurer, then any returned party id.
|
||||
@@ -42,19 +57,27 @@ export function selectFanavaranDriverId(
|
||||
rows: unknown,
|
||||
driverIsInsurer: boolean | undefined,
|
||||
): number | null {
|
||||
const parties = asPartyInquiryRows(rows);
|
||||
if (parties.length === 0) return null;
|
||||
|
||||
const preferredRoles = driverIsInsurer
|
||||
? [FANAVARAN_INSURER_ROLE_ID, FANAVARAN_DRIVER_ROLE_ID, FANAVARAN_PLAQUE_OWNER_ROLE_ID]
|
||||
: [FANAVARAN_DRIVER_ROLE_ID, FANAVARAN_INSURER_ROLE_ID, FANAVARAN_PLAQUE_OWNER_ROLE_ID];
|
||||
|
||||
for (const roleId of preferredRoles) {
|
||||
const match = parties.find((row) => asPositiveId(row.RoleId) === roleId);
|
||||
if (match) return asPositiveId(match.Id);
|
||||
? [
|
||||
FANAVARAN_INSURER_ROLE_ID,
|
||||
FANAVARAN_DRIVER_ROLE_ID,
|
||||
FANAVARAN_PLAQUE_OWNER_ROLE_ID,
|
||||
]
|
||||
: [
|
||||
FANAVARAN_DRIVER_ROLE_ID,
|
||||
FANAVARAN_INSURER_ROLE_ID,
|
||||
FANAVARAN_PLAQUE_OWNER_ROLE_ID,
|
||||
];
|
||||
return selectFanavaranIdByRoles(rows, preferredRoles);
|
||||
}
|
||||
|
||||
return asPositiveId(parties[0].Id);
|
||||
/** OwnerId: prefer plaque-owner role (163), then insurer / driver. */
|
||||
export function selectFanavaranOwnerId(rows: unknown): number | null {
|
||||
return selectFanavaranIdByRoles(rows, [
|
||||
FANAVARAN_PLAQUE_OWNER_ROLE_ID,
|
||||
FANAVARAN_INSURER_ROLE_ID,
|
||||
FANAVARAN_DRIVER_ROLE_ID,
|
||||
]);
|
||||
}
|
||||
|
||||
export function normalizeNationalCode(value: unknown): string | null {
|
||||
|
||||
@@ -34,6 +34,14 @@ describe("fanavaran hull base claim", () => {
|
||||
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", () => {
|
||||
const hull = toFanavaranHullBaseClaimPayload({
|
||||
AccidentCauseId: 6,
|
||||
@@ -68,7 +76,7 @@ describe("fanavaran hull base claim", () => {
|
||||
expect(hull.IsLicenseReplaced).toBeNull();
|
||||
expect(hull.CostSeparationToDmgSections).toBe(0);
|
||||
expect(hull.IsOwnerChanged).toBe(0);
|
||||
expect(hull.CulpritTypeId).toBe(301);
|
||||
expect(hull.CulpritTypeId).toBe(300);
|
||||
expect(hull.DamagedCount).toBeUndefined();
|
||||
expect(hull.HasOtherCulprit).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;
|
||||
|
||||
/** 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. */
|
||||
export function actualPremiumFromHullPolicyRecord(
|
||||
@@ -63,6 +73,41 @@ export function actualPremiumFromHullPolicyRecord(
|
||||
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. */
|
||||
export function customerIdFromHullPolicyRecord(
|
||||
policy: Record<string, unknown> | null | undefined,
|
||||
@@ -79,8 +124,8 @@ export function toFanavaranHullBaseClaimPayload(
|
||||
const next: Record<string, unknown> = {};
|
||||
for (const key of FANAVARAN_HULL_BASE_CLAIM_KEYS) {
|
||||
if (key === "IsLicenseReplaced") {
|
||||
next[key] =
|
||||
built.IsLicenseReplaced ?? built.IsLicenseReplacement ?? null;
|
||||
// GEN.03: must be empty/null. Do not map ثالث IsLicenseReplacement (default 0).
|
||||
next[key] = null;
|
||||
continue;
|
||||
}
|
||||
if (key === "AccidentTypeId") {
|
||||
@@ -108,15 +153,14 @@ export function toFanavaranHullBaseClaimPayload(
|
||||
continue;
|
||||
}
|
||||
if (key === "CustomerFaultPercent") {
|
||||
const culpritType =
|
||||
built.CulpritTypeId ?? FANAVARAN_DEFAULT_HULL_CULPRIT_TYPE_ID;
|
||||
if (built.CustomerFaultPercent != null) {
|
||||
next[key] = built.CustomerFaultPercent;
|
||||
} else if (culpritType === 300) {
|
||||
next[key] = 100;
|
||||
} else {
|
||||
next[key] = null;
|
||||
next[key] =
|
||||
built.CustomerFaultPercent ??
|
||||
FANAVARAN_DEFAULT_HULL_CUSTOMER_FAULT_PERCENT;
|
||||
continue;
|
||||
}
|
||||
if (key === "AuthorityCulpritId") {
|
||||
next[key] =
|
||||
built.AuthorityCulpritId ?? FANAVARAN_DEFAULT_HULL_AUTHORITY_CULPRIT_ID;
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
/** Fallback when lookup fetch fails (`vehicle-hull-dmg-cost-kinds` Id). */
|
||||
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 = {
|
||||
Id?: unknown;
|
||||
|
||||
@@ -78,6 +78,9 @@ export {
|
||||
FANAVARAN_HULL_DMG_KIND_CAPTION,
|
||||
} 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. */
|
||||
export function vehicleCurrentValueFromHullPolicyRecord(
|
||||
policy: Record<string, unknown> | null | undefined,
|
||||
@@ -117,7 +120,8 @@ export function toFanavaranHullExpertisePayload(input: {
|
||||
ComponentReplacementCost: input.componentReplacementCost,
|
||||
WasteValue: input.wasteValue,
|
||||
CarryAndRescueCost: null,
|
||||
RepairDuration: input.repairDuration ?? null,
|
||||
RepairDuration:
|
||||
input.repairDuration ?? FANAVARAN_DEFAULT_HULL_REPAIR_DURATION,
|
||||
VehicleCurrentValue: input.vehicleCurrentValue,
|
||||
WreckHighestValue: null,
|
||||
InspectionDeduction: null,
|
||||
|
||||
125
src/claim-request-management/fanavaran-party-roles.spec.ts
Normal file
125
src/claim-request-management/fanavaran-party-roles.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { InquiryParticipantRole } from "src/common/dto/inquiry-participants.dto";
|
||||
import {
|
||||
FANAVARAN_DUMMY_LICENCE_ISSU_DATE,
|
||||
formatFanavaranLicenceIssuDate,
|
||||
resolveDriverFromParty,
|
||||
resolveDriverIsOwner,
|
||||
resolveFanavaranLicenceIssuDate,
|
||||
resolveFanavaranLicenceNo,
|
||||
resolveOwnerFromParty,
|
||||
resolvePolicyholderFromParty,
|
||||
} from "./fanavaran-party-roles";
|
||||
|
||||
describe("fanavaran-party-roles", () => {
|
||||
const partyWithDistinctRoles = {
|
||||
participants: [
|
||||
{
|
||||
participantId: "DRIVER",
|
||||
nationalCode: "0012345678",
|
||||
birthday: "1370/01/15",
|
||||
licenseNumber: "1234567890",
|
||||
licenseType: "1",
|
||||
driverLicenseDate: "1398/06/01",
|
||||
},
|
||||
{
|
||||
participantId: "VEHICLE_OWNER",
|
||||
nationalCode: "0499370899",
|
||||
birthday: "1368/05/20",
|
||||
},
|
||||
{
|
||||
participantId: "THIRD_PARTY_POLICYHOLDER",
|
||||
nationalCode: "0080086519",
|
||||
birthday: "1364/06/28",
|
||||
},
|
||||
],
|
||||
participantRoles: {
|
||||
driver: "DRIVER",
|
||||
vehicleOwner: "VEHICLE_OWNER",
|
||||
thirdPartyPolicyholder: "THIRD_PARTY_POLICYHOLDER",
|
||||
},
|
||||
person: {
|
||||
nationalCodeOfDriver: "0012345678",
|
||||
nationalCodeOfInsurer: "0080086519",
|
||||
driverLicense: "1234567890",
|
||||
driverIsInsurer: false,
|
||||
},
|
||||
};
|
||||
|
||||
it("resolves driver licence and driverLicenseDate from participants", () => {
|
||||
const driver = resolveDriverFromParty(partyWithDistinctRoles);
|
||||
expect(driver.nationalCode).toBe("0012345678");
|
||||
expect(driver.licenseNo).toBe("1234567890");
|
||||
expect(driver.licenseIssuDate).toBe("1398/06/01");
|
||||
expect(resolveFanavaranLicenceNo(driver, "9705463515")).toBe("1234567890");
|
||||
expect(resolveFanavaranLicenceIssuDate(driver)).toBe("1398/06/01");
|
||||
});
|
||||
|
||||
it("falls back to dummy licence issue date when driverLicenseDate is missing", () => {
|
||||
const driver = resolveDriverFromParty({
|
||||
participants: [
|
||||
{
|
||||
participantId: InquiryParticipantRole.DRIVER,
|
||||
nationalCode: "0012345678",
|
||||
birthday: "1370/01/15",
|
||||
licenseNumber: "999",
|
||||
},
|
||||
],
|
||||
participantRoles: { driver: InquiryParticipantRole.DRIVER },
|
||||
});
|
||||
expect(driver.licenseIssuDate).toBeNull();
|
||||
expect(resolveFanavaranLicenceIssuDate(driver)).toBe(
|
||||
FANAVARAN_DUMMY_LICENCE_ISSU_DATE,
|
||||
);
|
||||
});
|
||||
|
||||
it("marks DriverIsOwner false when driver and owner differ", () => {
|
||||
expect(resolveDriverIsOwner(partyWithDistinctRoles)).toBe(false);
|
||||
});
|
||||
|
||||
it("marks DriverIsOwner true when driver and owner share a national code", () => {
|
||||
expect(
|
||||
resolveDriverIsOwner({
|
||||
participants: [
|
||||
{
|
||||
participantId: "SAME",
|
||||
nationalCode: "0012345678",
|
||||
birthday: "1370/01/15",
|
||||
},
|
||||
],
|
||||
participantRoles: {
|
||||
driver: "SAME",
|
||||
vehicleOwner: "SAME",
|
||||
thirdPartyPolicyholder: "SAME",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves owner and policyholder independently of the driver", () => {
|
||||
expect(resolveOwnerFromParty(partyWithDistinctRoles).nationalCode).toBe(
|
||||
"0499370899",
|
||||
);
|
||||
expect(
|
||||
resolvePolicyholderFromParty(partyWithDistinctRoles, "third-party")
|
||||
.nationalCode,
|
||||
).toBe("0080086519");
|
||||
});
|
||||
|
||||
it("formats compact Jalali driverLicenseDate", () => {
|
||||
expect(formatFanavaranLicenceIssuDate(13980601)).toBe("1398/06/01");
|
||||
});
|
||||
|
||||
it("falls back to legacy person.driverLicense when participants are absent", () => {
|
||||
const driver = resolveDriverFromParty({
|
||||
person: {
|
||||
nationalCodeOfDriver: "0011111111",
|
||||
driverBirthday: "13700101",
|
||||
driverLicense: "5555555555",
|
||||
driverLicenseDate: "1390/01/01",
|
||||
},
|
||||
});
|
||||
expect(driver.nationalCode).toBe("0011111111");
|
||||
expect(driver.licenseNo).toBe("5555555555");
|
||||
expect(driver.licenseIssuDate).toBe("1390/01/01");
|
||||
});
|
||||
});
|
||||
218
src/claim-request-management/fanavaran-party-roles.ts
Normal file
218
src/claim-request-management/fanavaran-party-roles.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { InquiryParticipantRole } from "src/common/dto/inquiry-participants.dto";
|
||||
import type { InquiryParticipantRoleAssignments } from "src/request-management/inquiry-participant-resolver";
|
||||
import { participantForStoredPartyRole } from "src/request-management/inquiry-participant-resolver";
|
||||
import {
|
||||
normalizeNationalCode,
|
||||
parseJalaliDateParts,
|
||||
} from "./fanavaran-driver-inquiry";
|
||||
import type { FanavaranClaimProduct } from "./fanavaran-claim-product";
|
||||
|
||||
/** Fanavaran dummy licence issue date when the user did not supply one. */
|
||||
export const FANAVARAN_DUMMY_LICENCE_ISSU_DATE = "1394/10/13";
|
||||
|
||||
export type FanavaranRoleIdentity = {
|
||||
nationalCode: string | null;
|
||||
birthday: string | number | null;
|
||||
/** Digits-only licence number when known. */
|
||||
licenseNo: string | null;
|
||||
licenseType: string | null;
|
||||
/**
|
||||
* Jalali `YYYY/MM/DD` from `driverLicenseDate` when present.
|
||||
* Not a persisted model field — read opportunistically from party/person/participant.
|
||||
*/
|
||||
licenseIssuDate: string | null;
|
||||
};
|
||||
|
||||
type PartyLike = {
|
||||
participants?: Array<Record<string, any>>;
|
||||
participantRoles?: Partial<InquiryParticipantRoleAssignments>;
|
||||
person?: Record<string, any> | null;
|
||||
};
|
||||
|
||||
function nonEmptyText(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const text = String(value).trim();
|
||||
if (!text || text.toLowerCase() === "null" || text.toLowerCase() === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Digits-only licence number, or null when empty / non-digit. */
|
||||
export function extractLicenceDigits(value: unknown): string | null {
|
||||
const text = nonEmptyText(value);
|
||||
if (!text) return null;
|
||||
const digits = text.replace(/[^\d]/g, "");
|
||||
return digits || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a stored `driverLicenseDate` (or similar) to Fanavaran Jalali `YYYY/MM/DD`.
|
||||
* Returns null when missing / unparseable so callers can fall back to the dummy.
|
||||
*/
|
||||
export function formatFanavaranLicenceIssuDate(value: unknown): string | null {
|
||||
const parsed = parseJalaliDateParts(value);
|
||||
if (!parsed) return null;
|
||||
const month = String(parsed.month).padStart(2, "0");
|
||||
const day = String(parsed.day).padStart(2, "0");
|
||||
return `${parsed.year}/${month}/${day}`;
|
||||
}
|
||||
|
||||
function readDriverLicenseDate(
|
||||
...sources: Array<Record<string, any> | null | undefined>
|
||||
): string | null {
|
||||
for (const source of sources) {
|
||||
if (!source) continue;
|
||||
const formatted = formatFanavaranLicenceIssuDate(
|
||||
source.driverLicenseDate ?? source.licenseDate ?? source.licenceIssuDate,
|
||||
);
|
||||
if (formatted) return formatted;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fromParticipant(
|
||||
participant: Record<string, any> | undefined,
|
||||
): FanavaranRoleIdentity {
|
||||
if (!participant) {
|
||||
return {
|
||||
nationalCode: null,
|
||||
birthday: null,
|
||||
licenseNo: null,
|
||||
licenseType: null,
|
||||
licenseIssuDate: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
nationalCode: normalizeNationalCode(participant.nationalCode),
|
||||
birthday: nonEmptyText(participant.birthday) ?? participant.birthday ?? null,
|
||||
licenseNo: extractLicenceDigits(participant.licenseNumber),
|
||||
licenseType: nonEmptyText(participant.licenseType),
|
||||
licenseIssuDate: readDriverLicenseDate(participant),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Damaged/guilty party DRIVER: participants first, then legacy person.* fields.
|
||||
* Licence number prefers real user values; callers fall back to the Fanavaran dummy.
|
||||
*/
|
||||
export function resolveDriverFromParty(party: PartyLike | null | undefined): FanavaranRoleIdentity {
|
||||
const fromRole = fromParticipant(
|
||||
participantForStoredPartyRole(
|
||||
party ?? {},
|
||||
InquiryParticipantRole.DRIVER,
|
||||
) as Record<string, any> | undefined,
|
||||
);
|
||||
const person = party?.person ?? undefined;
|
||||
|
||||
return {
|
||||
nationalCode:
|
||||
fromRole.nationalCode ??
|
||||
normalizeNationalCode(person?.nationalCodeOfDriver) ??
|
||||
normalizeNationalCode(person?.nationalCode),
|
||||
birthday:
|
||||
(fromRole.birthday != null && String(fromRole.birthday).trim() !== ""
|
||||
? fromRole.birthday
|
||||
: null) ??
|
||||
nonEmptyText(person?.driverBirthday) ??
|
||||
nonEmptyText(person?.birthday) ??
|
||||
person?.driverBirthday ??
|
||||
person?.birthday ??
|
||||
null,
|
||||
licenseNo:
|
||||
fromRole.licenseNo ??
|
||||
extractLicenceDigits(person?.driverLicense) ??
|
||||
extractLicenceDigits(person?.insurerLicense),
|
||||
licenseType: fromRole.licenseType ?? nonEmptyText(person?.licenseType),
|
||||
licenseIssuDate:
|
||||
fromRole.licenseIssuDate ?? readDriverLicenseDate(person),
|
||||
};
|
||||
}
|
||||
|
||||
/** Vehicle owner for OwnerId / DriverIsOwner / Sheba national-code routing. */
|
||||
export function resolveOwnerFromParty(party: PartyLike | null | undefined): FanavaranRoleIdentity {
|
||||
const fromRole = fromParticipant(
|
||||
participantForStoredPartyRole(
|
||||
party ?? {},
|
||||
InquiryParticipantRole.VEHICLE_OWNER,
|
||||
) as Record<string, any> | undefined,
|
||||
);
|
||||
if (fromRole.nationalCode || fromRole.birthday) {
|
||||
return fromRole;
|
||||
}
|
||||
// Legacy parties had no dedicated owner fields.
|
||||
return {
|
||||
nationalCode: null,
|
||||
birthday: null,
|
||||
licenseNo: null,
|
||||
licenseType: null,
|
||||
licenseIssuDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Policyholder national code for GEN.03 PolicyId inquiry:
|
||||
* THIRD_PARTY → third-party policyholder; CAR_BODY → car-body policyholder.
|
||||
*/
|
||||
export function resolvePolicyholderFromParty(
|
||||
party: PartyLike | null | undefined,
|
||||
product: FanavaranClaimProduct,
|
||||
): FanavaranRoleIdentity {
|
||||
const role =
|
||||
product === "car-body"
|
||||
? InquiryParticipantRole.CAR_BODY_POLICYHOLDER
|
||||
: InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER;
|
||||
const fromRole = fromParticipant(
|
||||
participantForStoredPartyRole(party ?? {}, role) as
|
||||
| Record<string, any>
|
||||
| undefined,
|
||||
);
|
||||
const person = party?.person ?? undefined;
|
||||
|
||||
return {
|
||||
nationalCode:
|
||||
fromRole.nationalCode ??
|
||||
normalizeNationalCode(person?.nationalCodeOfInsurer) ??
|
||||
normalizeNationalCode(person?.nationalCode),
|
||||
birthday:
|
||||
(fromRole.birthday != null && String(fromRole.birthday).trim() !== ""
|
||||
? fromRole.birthday
|
||||
: null) ??
|
||||
nonEmptyText(person?.insurerBirthday) ??
|
||||
person?.insurerBirthday ??
|
||||
null,
|
||||
licenseNo:
|
||||
fromRole.licenseNo ?? extractLicenceDigits(person?.insurerLicense),
|
||||
licenseType: fromRole.licenseType ?? null,
|
||||
licenseIssuDate: fromRole.licenseIssuDate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN.12 `DriverIsOwner`: driver national code equals vehicle-owner national code.
|
||||
* Falls back to legacy `person.driverIsInsurer` when owner role is absent.
|
||||
*/
|
||||
export function resolveDriverIsOwner(party: PartyLike | null | undefined): boolean {
|
||||
const driver = resolveDriverFromParty(party);
|
||||
const owner = resolveOwnerFromParty(party);
|
||||
if (driver.nationalCode && owner.nationalCode) {
|
||||
return driver.nationalCode === owner.nationalCode;
|
||||
}
|
||||
return party?.person?.driverIsInsurer === true;
|
||||
}
|
||||
|
||||
/** Licence number for Fanavaran; never empty — uses `dummy` when user value is missing. */
|
||||
export function resolveFanavaranLicenceNo(
|
||||
identity: Pick<FanavaranRoleIdentity, "licenseNo">,
|
||||
dummy: string,
|
||||
): string {
|
||||
return identity.licenseNo || dummy;
|
||||
}
|
||||
|
||||
/** Licence issue date for Fanavaran; uses dummy when `driverLicenseDate` is absent. */
|
||||
export function resolveFanavaranLicenceIssuDate(
|
||||
identity: Pick<FanavaranRoleIdentity, "licenseIssuDate">,
|
||||
dummy: string = FANAVARAN_DUMMY_LICENCE_ISSU_DATE,
|
||||
): string {
|
||||
return identity.licenseIssuDate || dummy;
|
||||
}
|
||||
@@ -373,6 +373,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
"guilty_car_card_front",
|
||||
"guilty_car_card_back",
|
||||
"guilty_metal_plate",
|
||||
"accident_sketch",
|
||||
],
|
||||
example: "damaged_driving_license_front",
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
|
||||
|
||||
@ApiProperty({
|
||||
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,
|
||||
})
|
||||
@IsBoolean()
|
||||
|
||||
@@ -81,6 +81,14 @@ export class InquiryParticipantInputDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
licenseType?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Driver licence issue date. Required only for a driver who has a licence; Jalali YYYY/MM/DD is recommended.",
|
||||
example: "1400/01/15",
|
||||
})
|
||||
@IsOptional()
|
||||
driverLicenseDate?: string | number;
|
||||
}
|
||||
|
||||
export class InquiryVehicleInputDto {
|
||||
@@ -168,6 +176,9 @@ export class InquiryParticipantFieldsDto {
|
||||
@ApiHideProperty()
|
||||
driverLicense?: string;
|
||||
|
||||
@ApiHideProperty()
|
||||
driverLicenseDate?: string;
|
||||
|
||||
@ApiHideProperty()
|
||||
licenseType?: string;
|
||||
|
||||
|
||||
@@ -86,6 +86,26 @@ export class ListQueryV2Dto {
|
||||
@MaxLength(64)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Filter cases by a phone number found on any party.",
|
||||
example: "09121234567",
|
||||
maxLength: 32,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Filter cases by a national code found on any party.",
|
||||
example: "0012345678",
|
||||
maxLength: 32,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
nationalCode?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: UNIFIED_FILE_STATUS_KEYS,
|
||||
description:
|
||||
|
||||
@@ -4,13 +4,63 @@ import {
|
||||
} from "./inquiry-error";
|
||||
|
||||
describe("inquiry error messages", () => {
|
||||
it("turns ESG not-found responses into a contextual plate message", () => {
|
||||
it("preserves a Persian ESG not-found response", () => {
|
||||
expect(
|
||||
getInquiryErrorMessage(
|
||||
{ success: false, message: "موردی یافت نشد" },
|
||||
"thirdPartyPlate",
|
||||
),
|
||||
).toBe("بیمهنامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.");
|
||||
).toBe("موردی یافت نشد");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["RECORD_NOT_FOUND", "رکوردی یافت نشد", "Provider request failed"],
|
||||
[
|
||||
"INQUIRY_NO_MATCH",
|
||||
"نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
|
||||
"Inquiry returned no matching result",
|
||||
],
|
||||
])(
|
||||
"prefers ESG messageFa for %s over the technical message",
|
||||
(code, messageFa, message) => {
|
||||
expect(
|
||||
getInquiryErrorMessage(
|
||||
{
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
messageFa,
|
||||
providerMessage: message,
|
||||
providerCode: code,
|
||||
},
|
||||
attemptSummary: {
|
||||
attempts: [{ code, message, messageFa }],
|
||||
},
|
||||
},
|
||||
"thirdPartyPlate",
|
||||
),
|
||||
).toBe(messageFa);
|
||||
},
|
||||
);
|
||||
|
||||
it("finds messageFa inside an HTTP response envelope", () => {
|
||||
expect(
|
||||
getInquiryErrorMessage(
|
||||
{
|
||||
response: {
|
||||
status: 404,
|
||||
data: {
|
||||
error: {
|
||||
code: "RECORD_NOT_FOUND",
|
||||
message: "Provider request failed",
|
||||
messageFa: "رکوردی یافت نشد",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"thirdPartyPlate",
|
||||
),
|
||||
).toBe("رکوردی یافت نشد");
|
||||
});
|
||||
|
||||
it("distinguishes VIN and car-body not-found failures", () => {
|
||||
|
||||
@@ -87,7 +87,25 @@ export function inquiryErrorStatus(error: unknown): number | undefined {
|
||||
}
|
||||
|
||||
export function extractInquiryProviderMessage(error: unknown): string {
|
||||
for (const record of errorRecords(error)) {
|
||||
const records = errorRecords(error);
|
||||
|
||||
// The normalized ESG/Parsian envelope carries the safe user-facing text in
|
||||
// messageFa while `message` and `providerMessage` may remain technical.
|
||||
// Search every envelope level for that explicit Persian field before
|
||||
// considering generic message fields on an outer object.
|
||||
for (const record of records) {
|
||||
for (const key of [
|
||||
"messageFa",
|
||||
"MessageFa",
|
||||
"messageFA",
|
||||
"persianMessage",
|
||||
] as const) {
|
||||
const message = cleanMessage(record[key]);
|
||||
if (message) return message;
|
||||
}
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
for (const key of ["message", "Message", "detail", "title"] as const) {
|
||||
const message = cleanMessage(record[key]);
|
||||
if (message) return message;
|
||||
@@ -128,13 +146,21 @@ export function isInquiryTimeout(error: unknown): boolean {
|
||||
const hasPersian = (value: string): boolean => /[\u0600-\u06ff]/.test(value);
|
||||
|
||||
const isNotFound = (error: unknown, message: string): boolean => {
|
||||
const root = asRecord(error);
|
||||
const responseData = asRecord(asRecord(root?.response)?.data);
|
||||
const code = String(root?.code ?? responseData?.code ?? "").toUpperCase();
|
||||
const codes = errorRecords(error).flatMap((record) =>
|
||||
[record.code, record.providerCode]
|
||||
.map((code) => String(code ?? "").toUpperCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
return (
|
||||
inquiryErrorStatus(error) === 404 ||
|
||||
["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY", "RECORD_NOT_FOUND"].includes(
|
||||
code,
|
||||
codes.some((code) =>
|
||||
[
|
||||
"NOT_FOUND",
|
||||
"POLICY_NOT_FOUND",
|
||||
"NO_POLICY",
|
||||
"RECORD_NOT_FOUND",
|
||||
"INQUIRY_NO_MATCH",
|
||||
].includes(code),
|
||||
) ||
|
||||
/\bnot[ -]?found\b|\bno (?:active |relevant )?(?:record|policy|item)\b|record\.not\.found|موردی یافت نشد|یافت نشد|پیدا نشد|فاقد بیمه(?:| )?نامه/i.test(
|
||||
message,
|
||||
@@ -175,6 +201,11 @@ export function getInquiryErrorMessage(
|
||||
): string {
|
||||
const providerMessage = extractInquiryProviderMessage(error);
|
||||
|
||||
// Persian text supplied by the provider is already the intended client
|
||||
// message. Preserve it verbatim instead of replacing it with a local
|
||||
// contextual fallback such as "inquiry not found".
|
||||
if (providerMessage && hasPersian(providerMessage)) return providerMessage;
|
||||
|
||||
if (isNotFound(error, providerMessage)) return NOT_FOUND_MESSAGES[context];
|
||||
|
||||
if (
|
||||
@@ -208,10 +239,6 @@ export function getInquiryErrorMessage(
|
||||
return "سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.";
|
||||
}
|
||||
|
||||
// A provider's specific Persian validation/business message is already safe
|
||||
// and more useful than replacing it with a broad local validation message.
|
||||
if (providerMessage && hasPersian(providerMessage)) return providerMessage;
|
||||
|
||||
if (isInvalidInput(providerMessage) || inquiryErrorStatus(error) === 422) {
|
||||
return INVALID_MESSAGES[context];
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ export const REPAIR_LINE_AMOUNT_TOMAN = { // IT IS RIAL FROM NOW ON
|
||||
MAX: 530_000_000,
|
||||
} as const;
|
||||
|
||||
/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
|
||||
/** Max sum of all priced + factor lines in one V1 expert reply / validation (Rial). */
|
||||
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
|
||||
|
||||
const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
|
||||
|
||||
/**
|
||||
* Returns null when the claim v2 total cap is disabled.
|
||||
* Returns null when the V1 claim total cap is disabled.
|
||||
*
|
||||
* Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again.
|
||||
* Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount.
|
||||
|
||||
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,
|
||||
ExpertFileKind,
|
||||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver";
|
||||
|
||||
interface CheckedRequestEntry {
|
||||
CheckedRequest?: {
|
||||
@@ -613,9 +614,24 @@ export class ExpertBlameService {
|
||||
)) as Record<string, unknown>[];
|
||||
|
||||
// Scope to this reviewer's insurance company via blame party clientId
|
||||
const visibleCases = allSealed.filter((doc) =>
|
||||
const tenantVisibleCases = allSealed.filter((doc) =>
|
||||
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);
|
||||
return pagedResult;
|
||||
@@ -1250,8 +1266,17 @@ export class ExpertBlameService {
|
||||
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
|
||||
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
|
||||
|
||||
// Keep the normalized blame-party participant contract authoritative for
|
||||
// every expert detail response. Legacy person fields remain for old files,
|
||||
// but new UIs must read participants + participantRoles from the same party.
|
||||
doc.parties = parties.map((party: Record<string, unknown>) => ({
|
||||
...party,
|
||||
participants: sanitizeStoredInquiryParticipants(party.participants),
|
||||
participantRoles: party.participantRoles,
|
||||
}));
|
||||
|
||||
// Strip heavy SandHub inquiry blob
|
||||
for (const party of parties as Array<{
|
||||
for (const party of doc.parties as Array<{
|
||||
vehicle?: Record<string, unknown>;
|
||||
}>) {
|
||||
if (
|
||||
|
||||
@@ -53,11 +53,19 @@ export class ClaimDetailV2ResponseDto {
|
||||
blameRequestType?: BlameRequestType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "How the blame file was initiated: IN_PERSON or LINK",
|
||||
description: "How the blame file was initiated: NORMAL, IN_PERSON, or LINK",
|
||||
example: "IN_PERSON",
|
||||
})
|
||||
creationMethod?: string;
|
||||
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
description:
|
||||
"Maximum expert-reply total for V1 user-created files. Null for V2-V6 flows or when the cap is disabled.",
|
||||
example: 530000000,
|
||||
})
|
||||
priceCap: number | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)",
|
||||
@@ -278,7 +286,7 @@ export class ClaimDetailV2ResponseDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.",
|
||||
"Linked blame case (`blameCases`), same shape as expert-blame detail. Each party exposes its authoritative normalized `participants` and `participantRoles`, plus video/voice URLs, workflow, expert, and formatted dates.",
|
||||
})
|
||||
blameCase?: Record<string, unknown>;
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ describe("ExpertClaimService expert-reply pricing", () => {
|
||||
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(findByIdAndUpdate.mock.calls[0][1]).toMatchObject({
|
||||
"vehicle.price": "1250000000",
|
||||
"vehicle.carPrice": 1250000000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,6 +169,88 @@ describe("ExpertClaimService expert-reply pricing", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces the configured total-payment cap for a V1 user-created file", async () => {
|
||||
const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
|
||||
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true";
|
||||
|
||||
try {
|
||||
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
|
||||
BlameRequestType.THIRD_PARTY,
|
||||
);
|
||||
(service as any).blameRequestDbService.findById.mockResolvedValue({
|
||||
type: BlameRequestType.THIRD_PARTY,
|
||||
creationMethod: "NORMAL",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.submitExpertReplyV2(
|
||||
"v1-claim",
|
||||
{
|
||||
...validV2Reply,
|
||||
parts: [
|
||||
{
|
||||
...validV2Reply.parts[0],
|
||||
salary: "1000000",
|
||||
totalPayment: "600000000",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sub: V2_EXPERT_ID,
|
||||
fullName: "Expert One",
|
||||
role: RoleEnum.FIELD_EXPERT,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ response: { code: "PRICE_CAP_ERROR" } });
|
||||
|
||||
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previousEnabled === undefined) {
|
||||
delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
|
||||
} else {
|
||||
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not enforce the V1 cap for an expert-initiated flow", async () => {
|
||||
const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
|
||||
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true";
|
||||
|
||||
try {
|
||||
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
|
||||
BlameRequestType.THIRD_PARTY,
|
||||
);
|
||||
|
||||
await service.submitExpertReplyV2(
|
||||
"v3-claim",
|
||||
{
|
||||
...validV2Reply,
|
||||
parts: [
|
||||
{
|
||||
...validV2Reply.parts[0],
|
||||
salary: "1000000",
|
||||
totalPayment: "600000000",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sub: V2_EXPERT_ID,
|
||||
fullName: "Expert One",
|
||||
role: RoleEnum.FIELD_EXPERT,
|
||||
},
|
||||
);
|
||||
|
||||
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
if (previousEnabled === undefined) {
|
||||
delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
|
||||
} else {
|
||||
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("allows a repair line without daghi and removes a stray daghi payload", () => {
|
||||
const service = createService() as any;
|
||||
|
||||
@@ -432,6 +515,9 @@ describe("ExpertClaimService FileReviewer assignment", () => {
|
||||
],
|
||||
}),
|
||||
};
|
||||
service.fanavaranLocationService = {
|
||||
assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
service.assertExpertActorOnClaim = jest.fn();
|
||||
|
||||
await expect(
|
||||
@@ -448,3 +534,28 @@ describe("ExpertClaimService FileReviewer assignment", () => {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,7 +156,7 @@ import {
|
||||
ExpertFileKind,
|
||||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||
|
||||
/** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */
|
||||
/** Configured V1 maximum sum of line `totalPayment` across the claim (Rial). */
|
||||
import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import {
|
||||
@@ -180,6 +180,7 @@ import {
|
||||
normalizeMoneyAmountString,
|
||||
parseMoneyAmountToman,
|
||||
} from "src/utils/unicode-digits";
|
||||
import { claimPriceCapAppliesToBlame } from "src/helpers/claim-price-cap";
|
||||
|
||||
@Injectable()
|
||||
export class ExpertClaimService {
|
||||
@@ -417,14 +418,145 @@ export class ExpertClaimService {
|
||||
claim: any,
|
||||
actor: any,
|
||||
): Promise<void> {
|
||||
const blame = await this.loadBlameForClaim(claim);
|
||||
// FILE_REVIEWER: by the time we reach this method the caller has already
|
||||
// verified that actor.sub === blame.assignedFileReviewerId (Phase 2 of
|
||||
// assignClaimForReviewV2). The generic tenant-scope check would incorrectly
|
||||
// reject them because initiatedByFieldExpertId belongs to the FileMaker, not
|
||||
// the reviewer. Skip it — the assignment check is the access proof.
|
||||
if ((actor as any).role === RoleEnum.FILE_REVIEWER) return;
|
||||
const blame = await this.loadBlameForClaim(claim);
|
||||
if ((actor as any).role === RoleEnum.FILE_REVIEWER) {
|
||||
await this.assertActorBranchAccess(claim, actor, blame);
|
||||
return;
|
||||
}
|
||||
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(
|
||||
@@ -646,6 +778,7 @@ export class ExpertClaimService {
|
||||
creationMethod?: string;
|
||||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||||
blameStatus?: string;
|
||||
partyIdentities?: Array<Record<string, unknown>>;
|
||||
} {
|
||||
if (!blame?.type) return {};
|
||||
const blameRequestType = blame.type as BlameRequestType;
|
||||
@@ -654,12 +787,30 @@ export class ExpertClaimService {
|
||||
creationMethod?: string;
|
||||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||||
blameStatus?: string;
|
||||
partyIdentities?: Array<Record<string, unknown>>;
|
||||
} = { blameRequestType };
|
||||
if (blame.creationMethod) out.creationMethod = blame.creationMethod;
|
||||
out.blameStatus = blame.blameStatus;
|
||||
if (blameRequestType !== BlameRequestType.CAR_BODY) return out;
|
||||
|
||||
const parties = blame.parties;
|
||||
if (Array.isArray(parties) && parties.length > 0) {
|
||||
out.partyIdentities = parties.map((party: any) => ({
|
||||
phoneNumber: party?.person?.phoneNumber,
|
||||
nationalCode: party?.person?.nationalCode,
|
||||
nationalCodeOfInsurer: party?.person?.nationalCodeOfInsurer,
|
||||
nationalCodeOfDriver: party?.person?.nationalCodeOfDriver,
|
||||
participants: (
|
||||
Array.isArray(party?.participants)
|
||||
? party.participants
|
||||
: party?.inquiryParticipants
|
||||
)?.map((participant: any) => ({
|
||||
phoneNumber: participant?.phoneNumber,
|
||||
nationalCode: participant?.nationalCode,
|
||||
nationalCodeOfInsurer: participant?.nationalCodeOfInsurer,
|
||||
nationalCodeOfDriver: participant?.nationalCodeOfDriver,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
if (blameRequestType !== BlameRequestType.CAR_BODY) return out;
|
||||
if (!Array.isArray(parties) || parties.length === 0) return out;
|
||||
|
||||
const first =
|
||||
@@ -1694,8 +1845,13 @@ export class ExpertClaimService {
|
||||
throw new BadRequestException(pricingValidationError);
|
||||
}
|
||||
|
||||
// Validate total price cap (priced lines sum), when enabled.
|
||||
const priceCap = getClaimV2TotalPaymentCapToman();
|
||||
// The total cap is a V1-only rule. Legacy claims embed their blame file.
|
||||
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
|
||||
const priceCap =
|
||||
configuredPriceCap !== null &&
|
||||
claimPriceCapAppliesToBlame(request.blameFile)
|
||||
? configuredPriceCap
|
||||
: null;
|
||||
if (priceCap !== null && reply.parts && reply.parts.length > 0) {
|
||||
let totalPrice = 0;
|
||||
|
||||
@@ -1727,7 +1883,7 @@ export class ExpertClaimService {
|
||||
|
||||
if (totalPrice > priceCap) {
|
||||
throw new BadRequestException({
|
||||
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
|
||||
message: `You have reached the maximum acceptable total price (Rial). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
|
||||
error: "PRICE_CAP_ERROR",
|
||||
code: "PRICE_CAP_ERROR",
|
||||
totalPrice: totalPrice,
|
||||
@@ -2293,7 +2449,8 @@ export class ExpertClaimService {
|
||||
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
|
||||
* — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature).
|
||||
* — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later).
|
||||
* When enabled by env, total of all repair lines must be ≤ the claim v2 total payment cap.
|
||||
* For V1 user-created files, when enabled by env, the total of all repair
|
||||
* lines must be no greater than the configured total-payment cap.
|
||||
* Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`.
|
||||
*/
|
||||
async validateClaimFactorsV2(
|
||||
@@ -2433,7 +2590,12 @@ export class ExpertClaimService {
|
||||
};
|
||||
}
|
||||
|
||||
const priceCap = getClaimV2TotalPaymentCapToman();
|
||||
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
|
||||
const priceCap =
|
||||
configuredPriceCap !== null &&
|
||||
claimPriceCapAppliesToBlame(await this.loadBlameForClaim(claim))
|
||||
? configuredPriceCap
|
||||
: null;
|
||||
if (priceCap !== null) {
|
||||
let totalPrice = 0;
|
||||
for (const part of updatedReply.parts || []) {
|
||||
@@ -2448,7 +2610,7 @@ export class ExpertClaimService {
|
||||
}
|
||||
if (totalPrice > priceCap) {
|
||||
throw new BadRequestException({
|
||||
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
|
||||
message: `You have reached the maximum acceptable total price (Rial). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
|
||||
error: "PRICE_CAP_ERROR",
|
||||
totalPrice,
|
||||
priceCap,
|
||||
@@ -2588,6 +2750,15 @@ export class ExpertClaimService {
|
||||
"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 (
|
||||
!(blame as any).isMadeByFileMaker ||
|
||||
!(blame as any).expertInitiated ||
|
||||
@@ -2720,6 +2891,15 @@ export class ExpertClaimService {
|
||||
"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
|
||||
? String((reviewerBlame as any).assignedFileReviewerId)
|
||||
: null;
|
||||
@@ -3259,7 +3439,7 @@ export class ExpertClaimService {
|
||||
* - Claim must exist
|
||||
* - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub)
|
||||
* - Must be in EXPERT_REVIEWING status
|
||||
* - Total payment across all parts must not exceed 53,000,000 (same cap as factor validation totals)
|
||||
* - V1 only: total payment across all parts must not exceed the configured cap
|
||||
* - Each part must include `daghi` (option + conditional price) like V1
|
||||
*
|
||||
* On success:
|
||||
@@ -3329,9 +3509,10 @@ export class ExpertClaimService {
|
||||
|
||||
const carPriceWasProvided = reply.carPrice != null;
|
||||
let normalizedCurrentCarPrice: string | undefined;
|
||||
let parsedCurrentCarPrice: number | undefined;
|
||||
|
||||
if (blame.type === BlameRequestType.CAR_BODY) {
|
||||
const parsedCurrentCarPrice = parseMoneyAmountToman(reply.carPrice);
|
||||
parsedCurrentCarPrice = parseMoneyAmountToman(reply.carPrice) ?? undefined;
|
||||
if (
|
||||
parsedCurrentCarPrice == null ||
|
||||
!Number.isSafeInteger(parsedCurrentCarPrice) ||
|
||||
@@ -3364,8 +3545,12 @@ export class ExpertClaimService {
|
||||
throw new BadRequestException(pricingValidationError);
|
||||
}
|
||||
|
||||
// Price cap validation, when enabled.
|
||||
const priceCap = getClaimV2TotalPaymentCapToman();
|
||||
// The configured total cap is enforced only for V1 user-created files.
|
||||
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
|
||||
const priceCap =
|
||||
configuredPriceCap !== null && claimPriceCapAppliesToBlame(blame)
|
||||
? configuredPriceCap
|
||||
: null;
|
||||
if (priceCap !== null) {
|
||||
let totalPrice = 0;
|
||||
for (const part of reply.parts || []) {
|
||||
@@ -3376,7 +3561,7 @@ export class ExpertClaimService {
|
||||
}
|
||||
if (totalPrice > priceCap) {
|
||||
throw new BadRequestException({
|
||||
message: `مجموع مبلغ قطعات (${totalPrice.toLocaleString("fa-IR")}) از سقف مجاز (${priceCap.toLocaleString("fa-IR")}) تومان بیشتر است.`,
|
||||
message: `مجموع مبلغ قطعات (${totalPrice.toLocaleString("fa-IR")}) از سقف مجاز (${priceCap.toLocaleString("fa-IR")}) ریال بیشتر است.`,
|
||||
error: "PRICE_CAP_ERROR",
|
||||
code: "PRICE_CAP_ERROR",
|
||||
totalPrice,
|
||||
@@ -3584,8 +3769,11 @@ export class ExpertClaimService {
|
||||
"evaluation.ownerInsurerApproval": "",
|
||||
"evaluation.ownerPricedPartsApproval": "",
|
||||
},
|
||||
...(normalizedCurrentCarPrice
|
||||
? { "vehicle.price": normalizedCurrentCarPrice }
|
||||
...(normalizedCurrentCarPrice && parsedCurrentCarPrice != null
|
||||
? {
|
||||
"vehicle.price": normalizedCurrentCarPrice,
|
||||
"vehicle.carPrice": parsedCurrentCarPrice,
|
||||
}
|
||||
: {}),
|
||||
"workflow.currentStep": currentStep,
|
||||
"workflow.nextStep": nextWorkflowStep,
|
||||
@@ -4101,9 +4289,13 @@ export class ExpertClaimService {
|
||||
],
|
||||
});
|
||||
|
||||
const filtered = (claims as any[]).filter((c) =>
|
||||
const tenantFiltered = (claims as any[]).filter((c) =>
|
||||
claimCaseTouchesClient(c, clientKey),
|
||||
);
|
||||
const filtered = await this.filterDamageExpertClaimsByBranch(
|
||||
tenantFiltered,
|
||||
actor,
|
||||
);
|
||||
|
||||
// Reconcile stale locks — if no decision was made, also clear assignedForReviewBy
|
||||
const staleLockToReconcile = filtered.filter(
|
||||
@@ -4220,6 +4412,8 @@ export class ExpertClaimService {
|
||||
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
|
||||
: undefined,
|
||||
...fileCtx,
|
||||
partyIdentities:
|
||||
fileCtx.partyIdentities ?? c.parties ?? c.snapshot?.parties,
|
||||
blameRequestId: c.blameRequestId?.toString(),
|
||||
createdAt: c.createdAt,
|
||||
awaitingFactorValidation,
|
||||
@@ -4313,6 +4507,8 @@ export class ExpertClaimService {
|
||||
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
|
||||
: undefined,
|
||||
...fileCtx,
|
||||
partyIdentities:
|
||||
fileCtx.partyIdentities ?? c.parties ?? c.snapshot?.parties,
|
||||
blameRequestId: c.blameRequestId?.toString(),
|
||||
createdAt: c.createdAt,
|
||||
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
||||
@@ -4369,7 +4565,7 @@ export class ExpertClaimService {
|
||||
{
|
||||
lean: true,
|
||||
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[];
|
||||
|
||||
@@ -4378,9 +4574,31 @@ export class ExpertClaimService {
|
||||
}
|
||||
|
||||
// Scope to this reviewer's insurer via blame party clientId
|
||||
const scopedBlames = blames.filter((b) =>
|
||||
const tenantScopedBlames = blames.filter((b) =>
|
||||
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) {
|
||||
return this.paginateClaimListV2([], query);
|
||||
@@ -4430,6 +4648,8 @@ export class ExpertClaimService {
|
||||
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
|
||||
: undefined,
|
||||
...fileCtx,
|
||||
partyIdentities:
|
||||
fileCtx.partyIdentities ?? c.parties ?? c.snapshot?.parties,
|
||||
blameRequestId: c.blameRequestId?.toString(),
|
||||
createdAt: c.createdAt,
|
||||
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
||||
@@ -4458,10 +4678,25 @@ export class ExpertClaimService {
|
||||
): Promise<GetClaimListV2ResponseDto> {
|
||||
const makerOid = new Types.ObjectId(actor.sub);
|
||||
|
||||
const makerBlames = (await this.blameRequestDbService.find(
|
||||
const ownMakerBlames = (await this.blameRequestDbService.find(
|
||||
{ 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[];
|
||||
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) {
|
||||
return this.paginateClaimListV2([], query);
|
||||
@@ -4510,6 +4745,8 @@ export class ExpertClaimService {
|
||||
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
|
||||
: undefined,
|
||||
...fileCtx,
|
||||
partyIdentities:
|
||||
fileCtx.partyIdentities ?? c.parties ?? c.snapshot?.parties,
|
||||
blameRequestId: c.blameRequestId?.toString(),
|
||||
createdAt: c.createdAt,
|
||||
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
||||
@@ -4900,6 +5137,7 @@ export class ExpertClaimService {
|
||||
if (actor.role !== RoleEnum.FILE_REVIEWER) {
|
||||
assertClaimCaseForExpertActor(claim, actor, linkedBlame);
|
||||
}
|
||||
await this.assertActorBranchAccess(claim, actor, linkedBlame);
|
||||
|
||||
// Variables used both in the gate block and in the detail-build section below
|
||||
const isDamageExpertPhase =
|
||||
@@ -5125,7 +5363,11 @@ export class ExpertClaimService {
|
||||
claim.blameRequestId
|
||||
? this.blameRequestDbService.find(
|
||||
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
|
||||
{ lean: true, select: "type parties expert.decision" },
|
||||
{
|
||||
lean: true,
|
||||
select:
|
||||
"type parties expert.decision creationMethod expertInitiated registrarInitiated callCenterInitiated initiatedByFieldExpertId initiatedByRegistrarId initiatedByCallCenterId",
|
||||
},
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
@@ -5160,6 +5402,11 @@ export class ExpertClaimService {
|
||||
const blameFileContext = blameLean
|
||||
? this.blameFileContextForExpert(blameLean)
|
||||
: {};
|
||||
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
|
||||
const priceCap =
|
||||
configuredPriceCap !== null && claimPriceCapAppliesToBlame(linkedBlame)
|
||||
? configuredPriceCap
|
||||
: null;
|
||||
|
||||
let videoCapture: ClaimDetailV2ResponseDto["videoCapture"] = undefined;
|
||||
if (videoCaptureRow) {
|
||||
@@ -5269,6 +5516,7 @@ export class ExpertClaimService {
|
||||
? this.sanitizeVehicleInquiryForApi(vehiclePayload)
|
||||
: undefined,
|
||||
...blameFileContext,
|
||||
priceCap,
|
||||
blameRequestId: claim.blameRequestId?.toString(),
|
||||
blameRequestNo: claim.blameRequestNo,
|
||||
money: moneyPayload,
|
||||
|
||||
@@ -268,7 +268,7 @@ export class ExpertClaimV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "Submit expert damage assessment reply",
|
||||
description:
|
||||
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 100,000–10,000,000,000 **Toman**. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 **Toman** (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
|
||||
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 1,000,000–100,000,000,000 **Rial**. **V1-only cap:** for user-created V1 files, sum of line `totalPayment` values ≤ 530,000,000 **Rial** (same limit as factor-validation totals across priced + factor lines). V2–V6 files are uncapped. Claim detail exposes the effective `priceCap` (`null` when uncapped). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
|
||||
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
|
||||
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
|
||||
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
|
||||
@@ -353,7 +353,7 @@ export class ExpertClaimV2Controller {
|
||||
"**Response:** `claimStatus` = `ClaimStatus` (e.g. APPROVED). `caseStatus` = `ClaimCaseStatus` (e.g. COMPLETED vs insurer-review) — they are not interchangeable.\n\n" +
|
||||
"**Preconditions:** `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`), `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" +
|
||||
"**Decisions:** each factor line gets `APPROVED` or `REJECTED`. **Every** decided line must include expert-entered `totalPayment` **or** both `price` and `salary` (factor photos are not read for amounts).\n\n" +
|
||||
"**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000 Toman**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" +
|
||||
"**V1-only cap (when every factor line is decided):** for user-created V1 files, sum of **all** reply lines (priced parts + factor lines) must be ≤ **530,000,000 Rial**; otherwise `PRICE_CAP_ERROR` is returned. V2–V6 files are uncapped.\n\n" +
|
||||
"**Outcomes:**\n" +
|
||||
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature. V5 instead waits for FileMaker approval.\n" +
|
||||
"- **Any rejected (repriced):** same completion behavior for now (V5 waits for FileMaker approval).\n" +
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ExpertInsurerService } from "./expert-insurer.service";
|
||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||
|
||||
describe("insurer expert file summaries", () => {
|
||||
const service = Object.create(
|
||||
@@ -49,4 +50,25 @@ describe("insurer expert file summaries", () => {
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
} from "./helper/timeline-fa-labels";
|
||||
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
|
||||
import { serializeDamagedPartSelectionHistory } from "src/helpers/claim-damaged-part-audit";
|
||||
import { fanavaranClaimReferences } from "src/claim-request-management/fanavaran-claim-references";
|
||||
|
||||
@Injectable()
|
||||
export class ExpertInsurerService {
|
||||
@@ -786,6 +787,7 @@ export class ExpertInsurerService {
|
||||
publicId: claim.publicId,
|
||||
status: claim.status,
|
||||
claimStatus: claim.claimStatus,
|
||||
fanavaran: fanavaranClaimReferences(claim),
|
||||
blameDocumentResendPending: claim.blameDocumentResendPending,
|
||||
workflow: claim.workflow,
|
||||
owner: ownerOut,
|
||||
|
||||
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(
|
||||
userId: string | null | undefined,
|
||||
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`,
|
||||
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",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/vehicle-hull-dmg-kind`,
|
||||
@@ -167,8 +172,41 @@ export const FANAVARAN_REMOTE_LOOKUPS: FanavaranRemoteLookupDefinition[] = [
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/cii-validation-status`,
|
||||
cacheFile: "cii-validation-status.json",
|
||||
},
|
||||
{
|
||||
name: "vehicle-groups",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/vehicle-groups`,
|
||||
cacheFile: "vehicle-groups.json",
|
||||
},
|
||||
];
|
||||
|
||||
/** Safe Fanavaran lookup slug: lowercase segments separated by hyphens. */
|
||||
const FANAVARAN_LOOKUP_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
/**
|
||||
* Resolve a remote lookup definition.
|
||||
* Known names use the catalogue; unknown slugs fall back to
|
||||
* `car/base-info/{lookupName}` so `/lookups/fanavaran/{name}` works without
|
||||
* a dedicated route or config entry (e.g. vehicle-groups).
|
||||
*/
|
||||
export function resolveFanavaranRemoteLookup(
|
||||
name: string,
|
||||
): FanavaranRemoteLookupDefinition | null {
|
||||
const configured = FANAVARAN_REMOTE_LOOKUPS.find((item) => item.name === name);
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
if (!FANAVARAN_LOOKUP_NAME_PATTERN.test(name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/${name}`,
|
||||
cacheFile: `${name}.json`,
|
||||
};
|
||||
}
|
||||
|
||||
export const TEJARAT_STATIC_ACCIDENT_FILES = {
|
||||
accidentReason: "ACCIDENT_REASON.json",
|
||||
accidentWay: "ACCIDENT_WAY.json",
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -18,9 +18,22 @@ describe("parseIranLocalDateTime", () => {
|
||||
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", () => {
|
||||
const dateOnly = new Date("2025-05-16T00:00:00.000Z");
|
||||
const instant = parseIranLocalDateTime(dateOnly, "18:28");
|
||||
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 mm = m[2];
|
||||
const ss = m[3] ?? "00";
|
||||
if (Number(hh) > 23 || Number(mm) > 59 || Number(ss) > 59) return null;
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
@@ -56,7 +57,12 @@ export function parseIranLocalDateTime(
|
||||
|
||||
if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(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);
|
||||
|
||||
@@ -26,6 +26,61 @@ describe("applyListQueryV2", () => {
|
||||
expect(r.list[0].publicId).toBe("A200");
|
||||
});
|
||||
|
||||
it("filters nested party identities and normalizes Persian digits", () => {
|
||||
const identityRows = [
|
||||
{
|
||||
publicId: "PHONE",
|
||||
createdAt: "2026-01-01",
|
||||
status: "OPEN",
|
||||
parties: [
|
||||
{
|
||||
person: {
|
||||
phoneNumber: "+98 912 123 4567",
|
||||
nationalCodeOfDriver: "۰۰۱۲۳۴۵۶۷۸",
|
||||
},
|
||||
participants: [
|
||||
{
|
||||
phoneNumber: "0913-765-4321",
|
||||
nationalCode: "۰۰۹۸۷۶۵۴۳۲۱",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
publicId: "OTHER",
|
||||
createdAt: "2026-01-02",
|
||||
status: "OPEN",
|
||||
parties: [{ person: { phoneNumber: "09351234567", nationalCode: "1111111111" } }],
|
||||
},
|
||||
];
|
||||
const identityGetters = {
|
||||
publicId: (r: (typeof identityRows)[0]) => r.publicId,
|
||||
createdAt: (r: (typeof identityRows)[0]) => r.createdAt,
|
||||
status: (r: (typeof identityRows)[0]) => r.status,
|
||||
};
|
||||
|
||||
const byPhone = applyListQueryV2(identityRows, identityGetters, {
|
||||
phoneNumber: "۰۹۱۲۱۲۳۴۵۶۷",
|
||||
});
|
||||
expect(byPhone.list.map((row) => row.publicId)).toEqual(["PHONE"]);
|
||||
|
||||
const byNationalCode = applyListQueryV2(identityRows, identityGetters, {
|
||||
nationalCode: "0012345678",
|
||||
});
|
||||
expect(byNationalCode.list.map((row) => row.publicId)).toEqual(["PHONE"]);
|
||||
|
||||
const byParticipantPhone = applyListQueryV2(identityRows, identityGetters, {
|
||||
phoneNumber: "09137654321",
|
||||
});
|
||||
expect(byParticipantPhone.list.map((row) => row.publicId)).toEqual(["PHONE"]);
|
||||
|
||||
const byParticipantNationalCode = applyListQueryV2(identityRows, identityGetters, {
|
||||
nationalCode: "۰۰۹۸۷۶۵۴۳۲۱",
|
||||
});
|
||||
expect(byParticipantNationalCode.list.map((row) => row.publicId)).toEqual(["PHONE"]);
|
||||
});
|
||||
|
||||
it("sorts by publicId ascending", () => {
|
||||
const r = applyListQueryV2(rows, getters, {
|
||||
sortBy: "publicId",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { normalizeIranMobile } from "src/helpers/iran-mobile";
|
||||
|
||||
const MAX_LIMIT = 100;
|
||||
const DEFAULT_LIMIT_WHEN_PAGE_SET = 20;
|
||||
@@ -53,6 +54,53 @@ function normalizeSearch(raw?: string): string | undefined {
|
||||
return t || undefined;
|
||||
}
|
||||
|
||||
function normalizeIdentityValue(raw?: unknown): string {
|
||||
return String(raw ?? "")
|
||||
.trim()
|
||||
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
|
||||
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)))
|
||||
.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
function isPhoneKey(key: string): boolean {
|
||||
return /phone|mobile|tel/i.test(key);
|
||||
}
|
||||
|
||||
function isNationalCodeKey(key: string): boolean {
|
||||
return /national[_-]?(code|id)|code[_-]?mell?i|mell?i[_-]?code/i.test(key);
|
||||
}
|
||||
|
||||
function normalizePhoneSearchValue(raw: unknown): string {
|
||||
const digits = normalizeIdentityValue(raw);
|
||||
return normalizeIranMobile(digits) ?? digits;
|
||||
}
|
||||
|
||||
function collectIdentityValues(
|
||||
value: unknown,
|
||||
matchKey: (key: string) => boolean,
|
||||
): string[] {
|
||||
const values: string[] = [];
|
||||
const seen = new WeakSet<object>();
|
||||
const visit = (current: unknown, depth: number) => {
|
||||
if (current == null || depth > 8 || typeof current !== "object") return;
|
||||
const object = current as Record<string, unknown>;
|
||||
if (seen.has(object)) return;
|
||||
seen.add(object);
|
||||
for (const [key, child] of Object.entries(object)) {
|
||||
if (
|
||||
matchKey(key) &&
|
||||
(typeof child === "string" || typeof child === "number")
|
||||
) {
|
||||
const normalized = normalizeIdentityValue(child);
|
||||
if (normalized) values.push(normalized);
|
||||
}
|
||||
if (child && typeof child === "object") visit(child, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(value, 0);
|
||||
return values;
|
||||
}
|
||||
|
||||
function toTime(v: Date | string | number | undefined): number {
|
||||
if (v == null) return 0;
|
||||
const n = v instanceof Date ? v.getTime() : new Date(v as string | number).getTime();
|
||||
@@ -75,12 +123,28 @@ export function applyListQueryV2<T>(
|
||||
totalPages?: number;
|
||||
} {
|
||||
const search = normalizeSearch(query.search);
|
||||
const phoneNumber = normalizePhoneSearchValue(query.phoneNumber);
|
||||
const nationalCode = normalizeIdentityValue(query.nationalCode);
|
||||
let filtered = rows;
|
||||
if (query.fileType && getters.fileType) {
|
||||
filtered = filtered.filter(
|
||||
(row) => getters.fileType!(row) === query.fileType,
|
||||
);
|
||||
}
|
||||
if (phoneNumber) {
|
||||
filtered = filtered.filter((row) =>
|
||||
collectIdentityValues(row, isPhoneKey).some((value) =>
|
||||
normalizePhoneSearchValue(value).includes(phoneNumber),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (nationalCode) {
|
||||
filtered = filtered.filter((row) =>
|
||||
collectIdentityValues(row, isNationalCodeKey).some((value) =>
|
||||
value.includes(nationalCode),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (search) {
|
||||
filtered = filtered.filter((row) => {
|
||||
const chunks: string[] = [];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
import { buildCoefficientsFromPartSeverities } from "./claim-price-drop";
|
||||
import {
|
||||
catalogPartIdFromSelectedPart,
|
||||
normalizeDamageSelectedParts,
|
||||
resolveCatalogPartByPartId,
|
||||
resolvePartForExpertReply,
|
||||
resolveSelectedPartByPartId,
|
||||
@@ -86,6 +87,31 @@ describe("resolveSelectedPartByPartId", () => {
|
||||
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", () => {
|
||||
const fixed = sanitizeDamageSelectedPartV2(
|
||||
{
|
||||
|
||||
@@ -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)) {
|
||||
return {
|
||||
id: null,
|
||||
|
||||
@@ -64,6 +64,20 @@ export class LookupsController {
|
||||
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")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran GEN.06 hull damage kind lookup",
|
||||
@@ -568,7 +582,8 @@ export class LookupsController {
|
||||
@ApiOperation({
|
||||
summary: "List configured Fanavaran remote lookups",
|
||||
description:
|
||||
"Returns the lookup names available through /lookups/fanavaran/{lookupName}.",
|
||||
"Returns catalogue entries for /lookups/fanavaran/{lookupName}. " +
|
||||
"Unlisted car/base-info slugs still work via the same route (fallback).",
|
||||
})
|
||||
async listFanavaranRemoteLookups() {
|
||||
return this.lookupsService.listFanavaranRemoteLookups().map((lookup) => ({
|
||||
@@ -582,12 +597,14 @@ export class LookupsController {
|
||||
@ApiOperation({
|
||||
summary: "Fetch a Fanavaran remote lookup by name",
|
||||
description:
|
||||
"Generic cached Fanavaran lookup fetcher for configured lookup names, including GEN.08 expertise lookups.",
|
||||
"Generic cached Fanavaran lookup fetcher. Configured names use their catalogue URL; " +
|
||||
"unlisted slugs (e.g. vehicle-groups) fall back to car/base-info/{lookupName}.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "lookupName",
|
||||
description:
|
||||
"Configured Fanavaran lookup name, for example inspection-place, drop-amount-status, car-components, accident-level, expert-status",
|
||||
"Fanavaran lookup slug, for example vehicle-groups, inspection-place, car-components. " +
|
||||
"Unconfigured names are fetched from car/base-info/{lookupName}.",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns cached or live Fanavaran lookup data",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "src/core/config/fanavaran-client.config";
|
||||
import {
|
||||
FANAVARAN_REMOTE_LOOKUPS,
|
||||
resolveFanavaranRemoteLookup,
|
||||
type FanavaranRemoteLookupDefinition,
|
||||
TEJARAT_STATIC_ACCIDENT_FILES,
|
||||
} from "src/fanavaran/fanavaran-lookup.config";
|
||||
@@ -66,7 +67,7 @@ export class LookupsService {
|
||||
}
|
||||
|
||||
private findRemoteLookup(name: string) {
|
||||
const lookup = FANAVARAN_REMOTE_LOOKUPS.find((item) => item.name === name);
|
||||
const lookup = resolveFanavaranRemoteLookup(name);
|
||||
if (!lookup) {
|
||||
throw new NotFoundException(`Unknown Fanavaran remote lookup: ${name}`);
|
||||
}
|
||||
@@ -114,6 +115,10 @@ export class LookupsService {
|
||||
return await this.getClientRemoteLookup("vehicle-hull-accident-culprit-type");
|
||||
}
|
||||
|
||||
async getDetectionAccidentCulprits(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("detection-accident-culprits");
|
||||
}
|
||||
|
||||
async getVehicleHullDmgKind(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("vehicle-hull-dmg-kind");
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export class CallCenterBlameV6Controller {
|
||||
description:
|
||||
"VIN alternative to `run-inquiry`. " +
|
||||
"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). " +
|
||||
"Identical eligibility guards and insurer-company validation as the plate variant. " +
|
||||
"After this call, proceed to `send-link` exactly as in the plate flow.",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
import { Types } from "mongoose";
|
||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
|
||||
@@ -51,8 +51,13 @@ export class FirstPartyFileDto {
|
||||
}
|
||||
|
||||
export class DescriptionDto {
|
||||
@ApiProperty({ description: "Accident description (required for all types)" })
|
||||
desc: string;
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Accident description. Optional for THIRD_PARTY; CAR_BODY requires at least 100 non-whitespace characters.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
desc?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||
@@ -94,10 +99,13 @@ export class DescriptionDto {
|
||||
* front-end can display them consistently regardless of blame type.
|
||||
*/
|
||||
export class DescriptionV4Dto {
|
||||
@ApiProperty({ description: "Accident description" })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Accident description. Optional for THIRD_PARTY; CAR_BODY requires at least 100 non-whitespace characters.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
desc: string;
|
||||
desc?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -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).
|
||||
*/
|
||||
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({
|
||||
description: "Car part damage selection (same as selectCarPartDamage). Required for first claim data step.",
|
||||
type: CarDamagePartDto,
|
||||
|
||||
@@ -94,6 +94,10 @@ export class BlameRequest {
|
||||
@Prop({ type: 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. */
|
||||
@Prop({ default: false })
|
||||
registrarInitiated?: boolean;
|
||||
|
||||
@@ -23,6 +23,7 @@ export class Person {
|
||||
@Prop() nationalCodeOfDriver?: string;
|
||||
@Prop() insurerLicense?: string;
|
||||
@Prop() driverLicense?: string;
|
||||
@Prop() driverLicenseDate?: string;
|
||||
/** Driving licence type code from the Fanavaran lookup (GET /lookups/driving-licence-types). */
|
||||
@Prop() licenseType?: string;
|
||||
|
||||
@@ -66,6 +67,7 @@ export class InquiryParticipant {
|
||||
@Prop({ type: Boolean }) hasDrivingLicense?: boolean;
|
||||
@Prop() licenseNumber?: string;
|
||||
@Prop() licenseType?: string;
|
||||
@Prop() driverLicenseDate?: string;
|
||||
}
|
||||
export const InquiryParticipantSchema =
|
||||
SchemaFactory.createForClass(InquiryParticipant);
|
||||
|
||||
@@ -53,6 +53,7 @@ export class CarDetail {
|
||||
insurerBirthday?: string;
|
||||
|
||||
driverBirthday?: string | null;
|
||||
driverLicenseDate?: string;
|
||||
}
|
||||
|
||||
export class InsuranceDetail {
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("inquiry participant persistence", () => {
|
||||
nationalCode: "0022324224",
|
||||
birthday: "1378/04/26",
|
||||
hasDrivingLicense: true,
|
||||
driverLicenseDate: "1400/01/01",
|
||||
licenseNumber: "1001011111",
|
||||
licenseType: "10",
|
||||
},
|
||||
@@ -60,6 +61,9 @@ describe("inquiry participant persistence", () => {
|
||||
expect(request.validateSync()).toBeUndefined();
|
||||
expect(request.parties[0].person.insurerBirthday).toBe("1378/06/27");
|
||||
expect(request.parties[0].person.driverBirthday).toBe("1378/04/26");
|
||||
expect(request.parties[0].participants?.[0].driverLicenseDate).toBe(
|
||||
"1400/01/01",
|
||||
);
|
||||
});
|
||||
|
||||
it("recasts populated party subdocuments during an inquiry-audit update", () => {
|
||||
@@ -89,6 +93,7 @@ describe("inquiry participant persistence", () => {
|
||||
nationalCode: "0022324224",
|
||||
birthday: "1378/04/26",
|
||||
hasDrivingLicense: true,
|
||||
driverLicenseDate: "1400/01/01",
|
||||
licenseNumber: "1001011111",
|
||||
licenseType: "10",
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
VehicleRegistrationState,
|
||||
} from "src/common/dto/inquiry-participants.dto";
|
||||
import {
|
||||
assertPreviousPlateInquiryMatchesVin,
|
||||
isMappedPolicyCurrent,
|
||||
normalizeInquirySubmission,
|
||||
participantForRole,
|
||||
@@ -13,18 +12,62 @@ import {
|
||||
resolveInquiryParticipants,
|
||||
resolveInquirySubjects,
|
||||
resolveInquiryVehicle,
|
||||
runPlateInquiryWithFallback,
|
||||
runCurrentPlateInquiry,
|
||||
sanitizeStoredInquiryParticipants,
|
||||
vehiclePlateCandidates,
|
||||
validateDriverLicenseDate,
|
||||
} from "./inquiry-participant-resolver";
|
||||
|
||||
describe("inquiry participant resolver", () => {
|
||||
it("accepts a driver licence date within the ten-year window", () => {
|
||||
expect(validateDriverLicenseDate("2016-09-26", "2026-09-26")).toBe(
|
||||
"2016-09-26",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects future and older-than-ten-year driver licence dates", () => {
|
||||
expect(() =>
|
||||
validateDriverLicenseDate("2026-09-27", "2026-09-26"),
|
||||
).toThrow("نمیتواند در آینده");
|
||||
expect(() =>
|
||||
validateDriverLicenseDate("2016-09-25", "2026-09-26"),
|
||||
).toThrow("بیشتر از ۱۰ سال قبل");
|
||||
});
|
||||
|
||||
it("requires the date only for a licensed driver", () => {
|
||||
expect(() =>
|
||||
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
|
||||
driver: {
|
||||
nationalCode: "0012345678",
|
||||
birthday: "1370/01/01",
|
||||
hasDrivingLicense: true,
|
||||
licenseNumber: "123456789",
|
||||
licenseType: "1",
|
||||
},
|
||||
vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER },
|
||||
thirdPartyPolicyholder: { sameAs: InquiryParticipantRole.DRIVER },
|
||||
}),
|
||||
).toThrow("تاریخ صدور گواهینامه");
|
||||
|
||||
expect(() =>
|
||||
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
|
||||
driver: {
|
||||
nationalCode: "0012345678",
|
||||
birthday: "1370/01/01",
|
||||
hasDrivingLicense: false,
|
||||
},
|
||||
vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER },
|
||||
thirdPartyPolicyholder: { sameAs: InquiryParticipantRole.DRIVER },
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("links several roles to one explicitly shared person", () => {
|
||||
const resolved = resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
|
||||
driver: {
|
||||
nationalCode: "0012345678",
|
||||
birthday: "1370/01/01",
|
||||
hasDrivingLicense: true,
|
||||
driverLicenseDate: "1400/01/01",
|
||||
licenseNumber: "123456789",
|
||||
licenseType: "1",
|
||||
},
|
||||
@@ -233,6 +276,7 @@ describe("inquiry participant resolver", () => {
|
||||
nationalCode: "0012345678",
|
||||
birthday: "1370/01/01",
|
||||
hasDrivingLicense: true,
|
||||
driverLicenseDate: "1400/01/01",
|
||||
licenseNumber: "D-1",
|
||||
licenseType: "1",
|
||||
},
|
||||
@@ -309,42 +353,6 @@ describe("inquiry participant resolver", () => {
|
||||
).toBe("0022222222");
|
||||
});
|
||||
|
||||
it("orders the current plate before the previous-plate fallback", () => {
|
||||
const currentPlate = {
|
||||
leftDigits: "44",
|
||||
centerAlphabet: "ب",
|
||||
centerDigits: "111",
|
||||
ir: "22",
|
||||
};
|
||||
const previousPlate = {
|
||||
leftDigits: "55",
|
||||
centerAlphabet: "ج",
|
||||
centerDigits: "222",
|
||||
ir: "33",
|
||||
};
|
||||
|
||||
expect(
|
||||
vehiclePlateCandidates({
|
||||
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
|
||||
currentPlate,
|
||||
previousPlate,
|
||||
vin: "NAAM01E15HK123456",
|
||||
previousPolicyholderNationalCode: "0098765432",
|
||||
}),
|
||||
).toEqual([
|
||||
{ kind: "CURRENT", plate: currentPlate },
|
||||
{ kind: "PREVIOUS", plate: previousPlate },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a previous-plate result for another chassis", () => {
|
||||
expect(() =>
|
||||
assertPreviousPlateInquiryMatchesVin("NAAM01E15HK123456", {
|
||||
VinNumberField: "DIFFERENTVIN00001",
|
||||
}),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("requires the driver's licence status in the new contract", () => {
|
||||
expect(() =>
|
||||
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
|
||||
@@ -357,58 +365,36 @@ describe("inquiry participant resolver", () => {
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("falls back to the previous plate and accepts only a matching VIN", async () => {
|
||||
it("runs only one inquiry for the current plate", async () => {
|
||||
const currentPlate = {
|
||||
leftDigits: "44",
|
||||
centerAlphabet: "ب",
|
||||
centerDigits: "111",
|
||||
ir: "22",
|
||||
};
|
||||
const previousPlate = {
|
||||
leftDigits: "55",
|
||||
centerAlphabet: "ج",
|
||||
centerDigits: "222",
|
||||
ir: "33",
|
||||
};
|
||||
const query = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("not found"))
|
||||
.mockResolvedValueOnce({
|
||||
mapped: { VinNumberField: "NAAM01E15HK123456", CompanyName: "پارسیان" },
|
||||
const query = jest.fn().mockResolvedValue({
|
||||
mapped: { CompanyName: "پارسیان" },
|
||||
});
|
||||
|
||||
const result = await runPlateInquiryWithFallback<{
|
||||
mapped: { VinNumberField?: string; CompanyName?: string };
|
||||
const result = await runCurrentPlateInquiry<{
|
||||
mapped: { 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);
|
||||
expect(query).toHaveBeenNthCalledWith(1, currentPlate, "CURRENT");
|
||||
expect(query).toHaveBeenNthCalledWith(2, previousPlate, "PREVIOUS");
|
||||
expect(result.plateKind).toBe("PREVIOUS");
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
expect(query).toHaveBeenCalledWith(currentPlate);
|
||||
expect(result.plateKind).toBe("CURRENT");
|
||||
expect(result.attempts).toMatchObject([
|
||||
{ plateKind: "CURRENT", succeeded: false, error: "not found" },
|
||||
{ plateKind: "PREVIOUS", succeeded: true, usable: true },
|
||||
{ plateKind: "CURRENT", succeeded: true, usable: true },
|
||||
]);
|
||||
expect(result.attempts[0]).toMatchObject({
|
||||
plate: currentPlate,
|
||||
vin: "NAAM01E15HK123456",
|
||||
});
|
||||
expect(result.attempts[1]).toMatchObject({
|
||||
plate: previousPlate,
|
||||
vin: "NAAM01E15HK123456",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a car-body policyholder on a third-party case", () => {
|
||||
@@ -510,95 +496,72 @@ describe("inquiry participant resolver", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back from a stale current policy to a current previous-plate policy", async () => {
|
||||
it("rejects a stale current policy without trying another plate", async () => {
|
||||
const currentPlate = {
|
||||
leftDigits: "44",
|
||||
centerAlphabet: "ب",
|
||||
centerDigits: "111",
|
||||
ir: "22",
|
||||
};
|
||||
const previousPlate = {
|
||||
leftDigits: "55",
|
||||
centerAlphabet: "ج",
|
||||
centerDigits: "222",
|
||||
ir: "33",
|
||||
};
|
||||
const query = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
const query = jest.fn().mockResolvedValue({
|
||||
mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
mapped: {
|
||||
CompanyName: "پارسیان",
|
||||
EndDate: "1406/01/01",
|
||||
VinNumberField: "NAAM01E15HK123456",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runPlateInquiryWithFallback<{
|
||||
mapped: Record<string, any>;
|
||||
}>({
|
||||
vehicle: resolveInquiryVehicle({
|
||||
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
|
||||
await expect(
|
||||
runCurrentPlateInquiry<{ mapped: Record<string, any> }>({
|
||||
currentPlate,
|
||||
previousPlate,
|
||||
vin: "NAAM01E15HK123456",
|
||||
previousPolicyholderNationalCode: "0098765432",
|
||||
}),
|
||||
fallbackCurrentPlate: currentPlate,
|
||||
query,
|
||||
isUsable: (value) =>
|
||||
!!value.mapped.CompanyName &&
|
||||
isMappedPolicyCurrent(value.mapped, "2026-09-13"),
|
||||
mappedValue: (value) => value.mapped,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
|
||||
});
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(result.plateKind).toBe("PREVIOUS");
|
||||
expect(result.attempts[0]).toMatchObject({
|
||||
plateKind: "CURRENT",
|
||||
succeeded: true,
|
||||
usable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects the result and retains audit attempts when every plate is unusable", async () => {
|
||||
it("retains the current-plate audit attempt when the result is unusable", async () => {
|
||||
const currentPlate = {
|
||||
leftDigits: "44",
|
||||
centerAlphabet: "ب",
|
||||
centerDigits: "111",
|
||||
ir: "22",
|
||||
};
|
||||
const previousPlate = {
|
||||
leftDigits: "55",
|
||||
centerAlphabet: "ج",
|
||||
centerDigits: "222",
|
||||
ir: "33",
|
||||
|
||||
await expect(
|
||||
runCurrentPlateInquiry({
|
||||
currentPlate,
|
||||
vin: "NAAM01E15HK123456",
|
||||
query: async () => ({ mapped: { CompanyName: "پارسیان" } }),
|
||||
isUsable: () => false,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a mapped provider message when the current result is unusable", async () => {
|
||||
const currentPlate = {
|
||||
leftDigits: "44",
|
||||
centerAlphabet: "ب",
|
||||
centerDigits: "111",
|
||||
ir: "22",
|
||||
};
|
||||
|
||||
await expect(
|
||||
runPlateInquiryWithFallback({
|
||||
vehicle: resolveInquiryVehicle({
|
||||
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
|
||||
runCurrentPlateInquiry({
|
||||
currentPlate,
|
||||
previousPlate,
|
||||
vin: "NAAM01E15HK123456",
|
||||
previousPolicyholderNationalCode: "0098765432",
|
||||
query: async () => ({
|
||||
mapped: { Error: { Message: "رکوردی یافت نشد" } },
|
||||
}),
|
||||
fallbackCurrentPlate: currentPlate,
|
||||
query: async () => ({ mapped: { CompanyName: "پارسیان" } }),
|
||||
isUsable: () => false,
|
||||
mappedValue: (value) => value.mapped,
|
||||
errorMessage: (value) => value.mapped.Error.Message,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
attempts: [
|
||||
{ plateKind: "CURRENT", succeeded: true, usable: false },
|
||||
{ plateKind: "PREVIOUS", succeeded: true, usable: false },
|
||||
],
|
||||
});
|
||||
).rejects.toThrow("رکوردی یافت نشد");
|
||||
});
|
||||
|
||||
it("does not use the previous plate after a transport or provider outage", async () => {
|
||||
it("retains one failed current-plate attempt after a provider outage", async () => {
|
||||
const currentPlate = {
|
||||
leftDigits: "44",
|
||||
centerAlphabet: "ب",
|
||||
@@ -612,23 +575,11 @@ describe("inquiry participant resolver", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
runPlateInquiryWithFallback({
|
||||
vehicle: resolveInquiryVehicle({
|
||||
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
|
||||
runCurrentPlateInquiry({
|
||||
currentPlate,
|
||||
previousPlate: {
|
||||
leftDigits: "55",
|
||||
centerAlphabet: "ج",
|
||||
centerDigits: "222",
|
||||
ir: "33",
|
||||
},
|
||||
vin: "NAAM01E15HK123456",
|
||||
previousPolicyholderNationalCode: "0098765432",
|
||||
}),
|
||||
fallbackCurrentPlate: currentPlate,
|
||||
query,
|
||||
isUsable: () => false,
|
||||
mappedValue: () => ({}),
|
||||
}),
|
||||
).rejects.toThrow("upstream timeout");
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ResolvedInquiryParticipant {
|
||||
hasDrivingLicense?: boolean;
|
||||
licenseNumber?: string;
|
||||
licenseType?: string;
|
||||
driverLicenseDate?: string;
|
||||
}
|
||||
|
||||
export interface InquiryParticipantRoleAssignments {
|
||||
@@ -38,6 +39,7 @@ export interface NormalizedInquirySubmission<T extends Record<string, any>> {
|
||||
nationalCodeOfDriver: string;
|
||||
driverBirthday: string;
|
||||
driverLicense?: string;
|
||||
driverLicenseDate?: string;
|
||||
licenseType?: string;
|
||||
userNoCertificate?: boolean;
|
||||
nationalCodeOfInsurer: string;
|
||||
@@ -215,6 +217,29 @@ function requiredIdentity(
|
||||
"شماره و نوع گواهینامه برای راننده دارای گواهینامه الزامی است.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
role !== InquiryParticipantRole.DRIVER &&
|
||||
Object.prototype.hasOwnProperty.call(input, "driverLicenseDate")
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"تاریخ صدور گواهینامه فقط برای راننده قابل ثبت است.",
|
||||
);
|
||||
}
|
||||
const driverLicenseDate =
|
||||
role === InquiryParticipantRole.DRIVER && input.hasDrivingLicense === true
|
||||
? validateDriverLicenseDate(input.driverLicenseDate)
|
||||
: undefined;
|
||||
if (
|
||||
role === InquiryParticipantRole.DRIVER &&
|
||||
input.hasDrivingLicense === false &&
|
||||
Object.prototype.hasOwnProperty.call(input, "driverLicenseDate") &&
|
||||
input.driverLicenseDate != null &&
|
||||
String(input.driverLicenseDate).trim() !== ""
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"تاریخ صدور گواهینامه برای راننده فاقد گواهینامه قابل ثبت نیست.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
participantId: role,
|
||||
nationalCode,
|
||||
@@ -225,9 +250,38 @@ function requiredIdentity(
|
||||
: {}),
|
||||
...(input.licenseNumber ? { licenseNumber: input.licenseNumber } : {}),
|
||||
...(input.licenseType ? { licenseType: input.licenseType } : {}),
|
||||
...(driverLicenseDate ? { driverLicenseDate } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate a driver licence issue date against the current Iran date. */
|
||||
export function validateDriverLicenseDate(
|
||||
value: unknown,
|
||||
todayGregorian: string = gregorianDateInIran(new Date()),
|
||||
): string {
|
||||
const raw = String(value ?? "").trim();
|
||||
const parsed = jalaliToGregorianDate(raw);
|
||||
if (!parsed) {
|
||||
throw new BadRequestException("تاریخ صدور گواهینامه راننده نامعتبر است.");
|
||||
}
|
||||
const today = jalaliToGregorianDate(todayGregorian) ?? todayGregorian;
|
||||
const todayDate = new Date(`${today}T00:00:00Z`);
|
||||
const minimumDate = new Date(todayDate);
|
||||
minimumDate.setUTCFullYear(minimumDate.getUTCFullYear() - 10);
|
||||
const minimum = minimumDate.toISOString().slice(0, 10);
|
||||
if (parsed > today) {
|
||||
throw new BadRequestException(
|
||||
"تاریخ صدور گواهینامه راننده نمیتواند در آینده باشد.",
|
||||
);
|
||||
}
|
||||
if (parsed < minimum) {
|
||||
throw new BadRequestException(
|
||||
"تاریخ صدور گواهینامه راننده نباید بیشتر از ۱۰ سال قبل باشد.",
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function resolveInquiryParticipants(
|
||||
caseType: BlameRequestType,
|
||||
input: Partial<InquiryParticipantFieldsDto> & Record<string, any>,
|
||||
@@ -393,26 +447,6 @@ export function resolveInquiryVehicle(
|
||||
};
|
||||
}
|
||||
|
||||
export function vehiclePlateCandidates(input?: ResolvedInquiryVehicle): Array<{
|
||||
kind: "CURRENT" | "PREVIOUS";
|
||||
plate: InquiryVehicleInputDto["currentPlate"];
|
||||
}> {
|
||||
if (!input) return [];
|
||||
return [
|
||||
{ kind: "CURRENT" as const, plate: input.currentPlate },
|
||||
...(input.registrationState ===
|
||||
VehicleRegistrationState.RECENTLY_TRANSFERRED && input.previousPlate
|
||||
? [{ kind: "PREVIOUS" as const, plate: input.previousPlate }]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeVehicleSerial(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]/g, "");
|
||||
}
|
||||
|
||||
/** A dated result is usable only while the returned policy has not expired. */
|
||||
export function isMappedPolicyCurrent(
|
||||
mapped: Record<string, any>,
|
||||
@@ -429,23 +463,11 @@ export function isMappedPolicyCurrent(
|
||||
return endDate != null && endDate >= todayGregorian;
|
||||
}
|
||||
|
||||
function normalizePlateForComparison(
|
||||
plate: InquiryVehicleInputDto["currentPlate"],
|
||||
): string {
|
||||
return [
|
||||
plate?.ir,
|
||||
plate?.leftDigits,
|
||||
plate?.centerAlphabet,
|
||||
plate?.centerDigits,
|
||||
]
|
||||
.map((part) => String(part ?? "").trim())
|
||||
.join("|");
|
||||
}
|
||||
|
||||
const LEGACY_INQUIRY_FIELDS = [
|
||||
"nationalCodeOfDriver",
|
||||
"driverBirthday",
|
||||
"driverLicense",
|
||||
"driverLicenseDate",
|
||||
"licenseType",
|
||||
"nationalCodeOfInsurer",
|
||||
"insurerBirthday",
|
||||
@@ -469,63 +491,22 @@ function assertStructuredInquiryInput(input: Record<string, any>): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPreviousPlateInquiryMatchesVin(
|
||||
expectedVin: string,
|
||||
mapped: Record<string, any>,
|
||||
): void {
|
||||
const expected = normalizeVehicleSerial(expectedVin);
|
||||
const candidates = [
|
||||
mapped?.VinNumberField,
|
||||
mapped?.vin,
|
||||
mapped?.VIN,
|
||||
mapped?.ChassisNumberField,
|
||||
mapped?.chassisNumber,
|
||||
mapped?.ChassisNo,
|
||||
mapped?.vehicle?.VIN,
|
||||
mapped?.vehicle?.ChassisNo,
|
||||
]
|
||||
.map(normalizeVehicleSerial)
|
||||
.filter(Boolean);
|
||||
if (!expected || !candidates.includes(expected)) {
|
||||
throw new BadRequestException(
|
||||
"نتیجه استعلام پلاک قبلی با شماره شاسی (VIN) واردشده مطابقت ندارد و پرونده نیازمند بررسی دستی است.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function isPolicyNotFoundError(error: unknown): boolean {
|
||||
const candidate = error as Record<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>;
|
||||
/**
|
||||
* Run a policy inquiry only for the submitted current plate. Recent-transfer
|
||||
* data is retained as case metadata, but must never trigger an inquiry for a
|
||||
* previous plate or a previous policyholder.
|
||||
*/
|
||||
export async function runCurrentPlateInquiry<T>(options: {
|
||||
currentPlate: InquiryVehicleInputDto["currentPlate"];
|
||||
vin?: string;
|
||||
query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise<T>;
|
||||
isUsable: (value: T) => boolean;
|
||||
mappedValue: (value: T) => Record<string, any>;
|
||||
shouldFallbackOnError?: (error: unknown) => boolean;
|
||||
errorMessage?: (value: T) => string | undefined;
|
||||
}): Promise<{
|
||||
value: T;
|
||||
plateKind: "CURRENT" | "PREVIOUS";
|
||||
plateKind: "CURRENT";
|
||||
attempts: Array<{
|
||||
plateKind: "CURRENT" | "PREVIOUS";
|
||||
plateKind: "CURRENT";
|
||||
plate: InquiryVehicleInputDto["currentPlate"];
|
||||
vin?: string;
|
||||
succeeded: boolean;
|
||||
@@ -533,12 +514,8 @@ export async function runPlateInquiryWithFallback<T>(options: {
|
||||
error?: string;
|
||||
}>;
|
||||
}> {
|
||||
const candidates = options.vehicle
|
||||
? vehiclePlateCandidates(options.vehicle)
|
||||
: [{ kind: "CURRENT" as const, plate: options.fallbackCurrentPlate }];
|
||||
let lastError: unknown;
|
||||
const attempts: Array<{
|
||||
plateKind: "CURRENT" | "PREVIOUS";
|
||||
plateKind: "CURRENT";
|
||||
plate: InquiryVehicleInputDto["currentPlate"];
|
||||
vin?: string;
|
||||
succeeded: boolean;
|
||||
@@ -546,80 +523,51 @@ export async function runPlateInquiryWithFallback<T>(options: {
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (let index = 0; index < candidates.length; index += 1) {
|
||||
const candidate = candidates[index];
|
||||
const isLast = index === candidates.length - 1;
|
||||
try {
|
||||
const value = await options.query(candidate.plate, candidate.kind);
|
||||
const value = await options.query(options.currentPlate);
|
||||
const usable = options.isUsable(value);
|
||||
if (!usable) {
|
||||
attempts.push({
|
||||
plateKind: candidate.kind,
|
||||
plate: candidate.plate,
|
||||
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
|
||||
plateKind: "CURRENT",
|
||||
plate: options.currentPlate,
|
||||
...(options.vin ? { vin: options.vin } : {}),
|
||||
succeeded: true,
|
||||
usable: false,
|
||||
});
|
||||
if (!isLast) continue;
|
||||
const error = new BadRequestException(
|
||||
options.errorMessage?.(value) ||
|
||||
"بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
|
||||
) as BadRequestException & { attempts?: typeof attempts };
|
||||
error.attempts = attempts;
|
||||
throw error;
|
||||
}
|
||||
if (candidate.kind === "PREVIOUS" && usable) {
|
||||
assertPreviousPlateInquiryMatchesVin(
|
||||
options.vehicle!.vin!,
|
||||
options.mappedValue(value),
|
||||
);
|
||||
}
|
||||
attempts.push({
|
||||
plateKind: candidate.kind,
|
||||
plate: candidate.plate,
|
||||
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
|
||||
plateKind: "CURRENT",
|
||||
plate: options.currentPlate,
|
||||
...(options.vin ? { vin: options.vin } : {}),
|
||||
succeeded: true,
|
||||
usable: true,
|
||||
});
|
||||
return { value, plateKind: candidate.kind, attempts };
|
||||
return { value, plateKind: "CURRENT", attempts };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const alreadyRecorded =
|
||||
typeof error === "object" &&
|
||||
error != null &&
|
||||
Array.isArray((error as { attempts?: unknown }).attempts);
|
||||
if (!alreadyRecorded) {
|
||||
attempts.push({
|
||||
plateKind: candidate.kind,
|
||||
plate: candidate.plate,
|
||||
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
|
||||
plateKind: "CURRENT",
|
||||
plate: options.currentPlate,
|
||||
...(options.vin ? { vin: options.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw (
|
||||
lastError ??
|
||||
new BadRequestException(
|
||||
"برای هیچیک از پلاکهای ثبتشده نتیجه معتبری یافت نشد.",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeInquirySubmission<T extends Record<string, any>>(
|
||||
@@ -662,6 +610,7 @@ export function normalizeInquirySubmission<T extends Record<string, any>>(
|
||||
nationalCodeOfDriver: driver.nationalCode,
|
||||
driverBirthday: driver.birthday,
|
||||
driverLicense: driver.licenseNumber,
|
||||
driverLicenseDate: driver.driverLicenseDate,
|
||||
licenseType: driver.licenseType,
|
||||
userNoCertificate:
|
||||
driver.hasDrivingLicense == null
|
||||
|
||||
@@ -38,6 +38,9 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
||||
(service as any).claimCaseDbService = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
(service as any).fanavaranLocationService = {
|
||||
assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return { service, blameRequestDbService };
|
||||
}
|
||||
|
||||
@@ -89,6 +92,22 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,7 @@ import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.
|
||||
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
|
||||
import { isOtpExpiryActive } from "src/helpers/user-otp-expiry";
|
||||
import { parseIranLocalDateTime } from "src/helpers/iran-datetime";
|
||||
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
||||
import {
|
||||
applyListQueryV2,
|
||||
isInListDateRange,
|
||||
@@ -140,13 +141,20 @@ import {
|
||||
NormalizedInquirySubmission,
|
||||
normalizeInquirySubmission,
|
||||
resolveInquirySubjects,
|
||||
runPlateInquiryWithFallback,
|
||||
runCurrentPlateInquiry,
|
||||
sanitizeStoredInquiryParticipants,
|
||||
} from "./inquiry-participant-resolver";
|
||||
import {
|
||||
getInquiryErrorMessage,
|
||||
} from "src/common/utils/inquiry-error";
|
||||
|
||||
function mappedInquiryErrorMessage(value: any): string | undefined {
|
||||
const message = value?.mapped?.Error?.Message;
|
||||
return typeof message === "string" && message.trim()
|
||||
? message.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD.
|
||||
* Returns the original value as a string if it cannot be parsed.
|
||||
@@ -203,6 +211,19 @@ export class RequestManagementService {
|
||||
}
|
||||
|
||||
/** Keep eligibility rejections distinct from an unavailable CAR_BODY provider. */
|
||||
private assertCarBodyDescriptionMinimum(
|
||||
requestType: BlameRequestType | string | undefined,
|
||||
description: unknown,
|
||||
): void {
|
||||
if (requestType !== BlameRequestType.CAR_BODY) return;
|
||||
const length = String(description ?? "").trim().length;
|
||||
if (length < 100) {
|
||||
throw new BadRequestException(
|
||||
"CAR_BODY description must contain at least 100 non-whitespace characters.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private throwCarBodyInquiryFailure(
|
||||
err: unknown,
|
||||
context: "carBodyPlate" | "carBodyVin" = "carBodyPlate",
|
||||
@@ -386,17 +407,14 @@ export class RequestManagementService {
|
||||
options?: Record<string, any>,
|
||||
): Promise<any> {
|
||||
const subjects = resolveInquirySubjects(submission);
|
||||
const result = await runPlateInquiryWithFallback({
|
||||
vehicle: submission.vehicle,
|
||||
fallbackCurrentPlate: submission.dto.plate,
|
||||
query: (plate, plateKind) =>
|
||||
const result = await runCurrentPlateInquiry({
|
||||
currentPlate: submission.vehicle?.currentPlate ?? submission.dto.plate,
|
||||
vin: submission.vehicle?.vin,
|
||||
query: (plate) =>
|
||||
this.sandHubService.getTejaratBlockInquiry(
|
||||
{
|
||||
plate: plate as any,
|
||||
nationalCodeOfInsurer:
|
||||
plateKind === "PREVIOUS"
|
||||
? submission.vehicle!.previousPolicyholderNationalCode!
|
||||
: subjects.thirdPartyPolicyNationalCode,
|
||||
nationalCodeOfInsurer: subjects.thirdPartyPolicyNationalCode,
|
||||
},
|
||||
options,
|
||||
),
|
||||
@@ -404,7 +422,7 @@ export class RequestManagementService {
|
||||
!value?.mapped?.Error &&
|
||||
!!value?.mapped?.CompanyName &&
|
||||
isMappedPolicyCurrent(value.mapped),
|
||||
mappedValue: (value) => value?.mapped ?? {},
|
||||
errorMessage: mappedInquiryErrorMessage,
|
||||
});
|
||||
return {
|
||||
...result.value,
|
||||
@@ -423,15 +441,12 @@ export class RequestManagementService {
|
||||
"اطلاعات بیمهگذار برای استعلام بیمه بدنه الزامی است.",
|
||||
);
|
||||
}
|
||||
const result = await runPlateInquiryWithFallback({
|
||||
vehicle: submission.vehicle,
|
||||
fallbackCurrentPlate: submission.dto.plate,
|
||||
query: (plate, plateKind) =>
|
||||
const result = await runCurrentPlateInquiry({
|
||||
currentPlate: submission.vehicle?.currentPlate ?? submission.dto.plate,
|
||||
vin: submission.vehicle?.vin,
|
||||
query: (plate) =>
|
||||
this.sandHubService.getCarBodyInquiry({
|
||||
nationalCodeOfInsurer:
|
||||
plateKind === "PREVIOUS"
|
||||
? submission.vehicle!.previousPolicyholderNationalCode!
|
||||
: policyholderNationalCode,
|
||||
nationalCodeOfInsurer: policyholderNationalCode,
|
||||
plate: plate as any,
|
||||
}),
|
||||
isUsable: (value) =>
|
||||
@@ -442,7 +457,7 @@ export class RequestManagementService {
|
||||
value.mapped.CompanyName ||
|
||||
value.mapped.companyId
|
||||
),
|
||||
mappedValue: (value) => value?.mapped ?? {},
|
||||
errorMessage: mappedInquiryErrorMessage,
|
||||
});
|
||||
return {
|
||||
...result.value,
|
||||
@@ -464,10 +479,10 @@ export class RequestManagementService {
|
||||
);
|
||||
}
|
||||
const subjects = resolveInquirySubjects(submission);
|
||||
const result = await this.sandHubService.getPolicyByChassisInquiry(
|
||||
const result = await this.sandHubService.getCarByChassisInquiry(
|
||||
{
|
||||
nationalCode: subjects.thirdPartyPolicyNationalCode,
|
||||
chassis,
|
||||
chassisNo: chassis,
|
||||
},
|
||||
options,
|
||||
);
|
||||
@@ -1124,6 +1139,22 @@ export class RequestManagementService {
|
||||
private readonly fileReviewerDbService: FileReviewerDbService,
|
||||
) {}
|
||||
|
||||
async assertFileReviewerBranchAccess(
|
||||
file: {
|
||||
initiatedByFieldExpertId?: unknown;
|
||||
branchId?: unknown;
|
||||
},
|
||||
fileReviewerId: string,
|
||||
): Promise<void> {
|
||||
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
|
||||
fileMakerId: file?.initiatedByFieldExpertId
|
||||
? String(file.initiatedByFieldExpertId)
|
||||
: null,
|
||||
fileReviewerId,
|
||||
caseBranchId: file?.branchId ? String(file.branchId) : null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject CAR_BODY submissions whose accident is older than the per-client
|
||||
* window (see `ClientService.getCarBodyAccidentMaxAgeDays`). The check is
|
||||
@@ -1170,7 +1201,8 @@ export class RequestManagementService {
|
||||
if (ageMs < 0) {
|
||||
throw new BadRequestException({
|
||||
code: "CAR_BODY_ACCIDENT_DATE_IN_FUTURE",
|
||||
message: "Accident date cannot be in the future.",
|
||||
message: "Accident date and time cannot be in the future.",
|
||||
messageFa: "تاریخ و ساعت حادثه نمیتواند در آینده باشد.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1201,7 +1233,47 @@ export class RequestManagementService {
|
||||
date: Date | string,
|
||||
time?: string,
|
||||
): Date | null {
|
||||
return parseIranLocalDateTime(date, time);
|
||||
const normalizedDate =
|
||||
typeof date === "string" ? jalaliToGregorianDate(date) ?? date : date;
|
||||
return parseIranLocalDateTime(normalizedDate, time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal write guard for accident timestamps. UI restrictions are only a
|
||||
* convenience; every endpoint that persists accidentDate/accidentTime must
|
||||
* reject an invalid or future Iran-local timestamp as well.
|
||||
*/
|
||||
private assertAccidentDateTimeNotInFuture(params: {
|
||||
accidentDate: Date | string | null | undefined;
|
||||
accidentTime?: string | null;
|
||||
}): void {
|
||||
const { accidentDate, accidentTime } = params;
|
||||
if (accidentDate == null && !accidentTime) return;
|
||||
|
||||
if (accidentDate == null || !String(accidentTime ?? "").trim()) {
|
||||
throw new BadRequestException({
|
||||
code: "ACCIDENT_DATE_TIME_INVALID",
|
||||
message: "A valid accident date and time are required together.",
|
||||
messageFa: "تاریخ و ساعت معتبر حادثه باید با هم وارد شوند.",
|
||||
});
|
||||
}
|
||||
|
||||
const instant = this.parseAccidentInstant(accidentDate, accidentTime!);
|
||||
if (!instant || Number.isNaN(instant.getTime())) {
|
||||
throw new BadRequestException({
|
||||
code: "ACCIDENT_DATE_TIME_INVALID",
|
||||
message: "Invalid accident date or time.",
|
||||
messageFa: "تاریخ یا ساعت حادثه معتبر نیست.",
|
||||
});
|
||||
}
|
||||
|
||||
if (instant.getTime() > Date.now()) {
|
||||
throw new BadRequestException({
|
||||
code: "ACCIDENT_DATE_TIME_IN_FUTURE",
|
||||
message: "Accident date and time cannot be in the future.",
|
||||
messageFa: "تاریخ و ساعت حادثه نمیتواند در آینده باشد.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1962,6 +2034,7 @@ export class RequestManagementService {
|
||||
party.person.nationalCodeOfDriver = body.nationalCodeOfDriver;
|
||||
party.person.insurerLicense = body.insurerLicense;
|
||||
party.person.driverLicense = body.driverLicense;
|
||||
party.person.driverLicenseDate = body.driverLicenseDate;
|
||||
party.person.driverIsInsurer = body.driverIsInsurer;
|
||||
party.person.isNewCar = body.isNewCar;
|
||||
party.person.userNoCertificate = body.userNoCertificate;
|
||||
@@ -2141,7 +2214,7 @@ export class RequestManagementService {
|
||||
* V2 initial-form submitted with a VIN/chassis number instead of a plate.
|
||||
*
|
||||
* Performs:
|
||||
* 1. ESG `/inquiry/policyByChassis` (or its mock when `vinChassis` is off)
|
||||
* 1. ESG two-factor `/inquiry/carByChassis` (or its mock when `vinChassis` is off)
|
||||
* 2. Personal identity inquiry (same as the plate path)
|
||||
*
|
||||
* The inquiry result is mapped through the same `mapEsgPolicyByPlateToOldFormat`
|
||||
@@ -2235,7 +2308,7 @@ export class RequestManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- External inquiry: ESG policyByChassis ----
|
||||
// ---- External inquiry: ESG two-factor carByChassis ----
|
||||
const inquiryClientId = party.person?.clientId
|
||||
? String(party.person.clientId)
|
||||
: undefined;
|
||||
@@ -2255,10 +2328,10 @@ export class RequestManagementService {
|
||||
inquiryRaw = inquiry.raw;
|
||||
inquiryMapped = inquiry.mapped;
|
||||
this.logger.log(
|
||||
`[ESG] policyByChassis raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`,
|
||||
`[ESG] carByChassis raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`,
|
||||
);
|
||||
this.logger.log(
|
||||
`[ESG] policyByChassis mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`,
|
||||
`[ESG] carByChassis mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`,
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(
|
||||
req,
|
||||
@@ -2273,11 +2346,11 @@ export class RequestManagementService {
|
||||
);
|
||||
} catch (err: any) {
|
||||
this.logger.error(
|
||||
`[ESG] policyByChassis failed for request=${req._id}: ${err?.message || err}`,
|
||||
`[ESG] carByChassis failed for request=${req._id}: ${err?.message || err}`,
|
||||
);
|
||||
if (err?.response) {
|
||||
this.logger.error(
|
||||
`[ESG] policyByChassis response for request=${req._id}: status=${
|
||||
`[ESG] carByChassis response for request=${req._id}: status=${
|
||||
err.response.status
|
||||
}, data=${JSON.stringify(err.response.data)}`,
|
||||
);
|
||||
@@ -2299,7 +2372,7 @@ export class RequestManagementService {
|
||||
|
||||
if (inquiryMapped?.Error) {
|
||||
this.logger.warn(
|
||||
`[ESG] policyByChassis error for request=${req._id}: ${JSON.stringify(inquiryMapped.Error)}`,
|
||||
`[ESG] carByChassis error for request=${req._id}: ${JSON.stringify(inquiryMapped.Error)}`,
|
||||
);
|
||||
this.recordPartyCaseInquiryStatus(req, "thirdParty", role, false, {
|
||||
source: "ESG_VIN_INQUIRY",
|
||||
@@ -2389,6 +2462,7 @@ export class RequestManagementService {
|
||||
party.person.nationalCodeOfDriver = body.nationalCodeOfDriver;
|
||||
party.person.insurerLicense = body.insurerLicense;
|
||||
party.person.driverLicense = body.driverLicense;
|
||||
party.person.driverLicenseDate = body.driverLicenseDate;
|
||||
party.person.driverIsInsurer = body.driverIsInsurer;
|
||||
party.person.isNewCar = body.isNewCar;
|
||||
party.person.userNoCertificate = body.userNoCertificate;
|
||||
@@ -2758,6 +2832,8 @@ export class RequestManagementService {
|
||||
this.isBlameOnBehalfActor(req, user),
|
||||
);
|
||||
|
||||
this.assertCarBodyDescriptionMinimum(req.type, body.desc);
|
||||
|
||||
// Set userId for party if not already set (important for second party)
|
||||
if (!party.person) party.person = {} as any;
|
||||
if (
|
||||
@@ -4061,6 +4137,8 @@ export class RequestManagementService {
|
||||
throw new NotFoundException("Request not found");
|
||||
}
|
||||
|
||||
this.assertCarBodyDescriptionMinimum(request.type, body.desc);
|
||||
|
||||
// --- First Party Flow ---
|
||||
if (request.firstPartyDetails.firstPartyId == user.sub) {
|
||||
// Build base update payload with proper MongoDB operators
|
||||
@@ -4105,6 +4183,10 @@ export class RequestManagementService {
|
||||
|
||||
// Add CAR_BODY specific fields if type is CAR_BODY
|
||||
if (request.type === "CAR_BODY") {
|
||||
this.assertAccidentDateTimeNotInFuture({
|
||||
accidentDate: body.accidentDate,
|
||||
accidentTime: body.accidentTime,
|
||||
});
|
||||
if (body.accidentDate) {
|
||||
updatePayload.$set["firstPartyDetails.firstPartyFile.accidentDate"] =
|
||||
body.accidentDate;
|
||||
@@ -5377,6 +5459,15 @@ export class RequestManagementService {
|
||||
throw new ForbiddenException("FileReviewer account not found.");
|
||||
}
|
||||
assertFileReviewerCanReviewBlameType(fileReviewer, req.type);
|
||||
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
|
||||
fileMakerId: req?.initiatedByFieldExpertId
|
||||
? String(req.initiatedByFieldExpertId)
|
||||
: null,
|
||||
fileReviewerId: String(expert.sub),
|
||||
caseBranchId: (req as any)?.branchId
|
||||
? String((req as any).branchId)
|
||||
: null,
|
||||
});
|
||||
|
||||
if (!assignedId) {
|
||||
// Atomically claim — ignore if another reviewer won the race (they would have
|
||||
@@ -5686,12 +5777,19 @@ export class RequestManagementService {
|
||||
}
|
||||
|
||||
const isFileMakerRole = (expert as any)?.role === RoleEnum.FILE_MAKER;
|
||||
let expertBranchId: Types.ObjectId | undefined;
|
||||
if (isFileMakerRole) {
|
||||
const fileMaker = await this.fileMakerDbService.findById(String(expert.sub));
|
||||
if (!fileMaker) {
|
||||
throw new ForbiddenException("FileMaker account not found.");
|
||||
}
|
||||
assertFileMakerCanCreateBlameType(fileMaker, type);
|
||||
if (!fileMaker.branchId) {
|
||||
throw new ForbiddenException(
|
||||
"FileMaker account is not assigned to a branch.",
|
||||
);
|
||||
}
|
||||
expertBranchId = new Types.ObjectId(String(fileMaker.branchId));
|
||||
}
|
||||
const created = await this.blameRequestDbService.create({
|
||||
publicId,
|
||||
@@ -5707,6 +5805,7 @@ export class RequestManagementService {
|
||||
history: [],
|
||||
expertInitiated: true,
|
||||
initiatedByFieldExpertId: expertId,
|
||||
...(expertBranchId ? { branchId: expertBranchId } : {}),
|
||||
creationMethod: dto.creationMethod,
|
||||
filledBy:
|
||||
dto.creationMethod === CreationMethod.IN_PERSON
|
||||
@@ -6531,11 +6630,30 @@ export class RequestManagementService {
|
||||
);
|
||||
|
||||
const obj = req.toObject();
|
||||
const partyIdentities = Array.isArray(req.parties)
|
||||
? req.parties.map((entry: any) => ({
|
||||
phoneNumber: entry?.person?.phoneNumber,
|
||||
nationalCode: entry?.person?.nationalCode,
|
||||
nationalCodeOfInsurer: entry?.person?.nationalCodeOfInsurer,
|
||||
nationalCodeOfDriver: entry?.person?.nationalCodeOfDriver,
|
||||
participants: (
|
||||
Array.isArray(entry?.participants)
|
||||
? entry.participants
|
||||
: entry?.inquiryParticipants
|
||||
)?.map((participant: any) => ({
|
||||
phoneNumber: participant?.phoneNumber,
|
||||
nationalCode: participant?.nationalCode,
|
||||
nationalCodeOfInsurer: participant?.nationalCodeOfInsurer,
|
||||
nationalCodeOfDriver: participant?.nationalCodeOfDriver,
|
||||
})),
|
||||
}))
|
||||
: [];
|
||||
|
||||
delete obj.parties;
|
||||
|
||||
return {
|
||||
...obj,
|
||||
partyIdentities,
|
||||
userSide: party?.role ?? null,
|
||||
initiatedByMe: isInitiator,
|
||||
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||
@@ -7011,9 +7129,10 @@ export class RequestManagementService {
|
||||
if (!formData.carBodyForm) {
|
||||
throw new BadRequestException("carBodyForm is required.");
|
||||
}
|
||||
if (!formData?.expertDescription?.desc) {
|
||||
throw new BadRequestException("expertDescription.desc is required.");
|
||||
}
|
||||
this.assertCarBodyDescriptionMinimum(
|
||||
req.type,
|
||||
formData?.expertDescription?.desc,
|
||||
);
|
||||
|
||||
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
|
||||
formData.firstPartyPhoneNumber,
|
||||
@@ -7192,6 +7311,7 @@ export class RequestManagementService {
|
||||
nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver,
|
||||
insurerLicense: firstPartyPlate.insurerLicense,
|
||||
driverLicense: firstPartyPlate.driverLicense,
|
||||
driverLicenseDate: firstPartyPlate.driverLicenseDate,
|
||||
driverIsInsurer: firstPartyPlate.driverIsInsurer,
|
||||
isNewCar: firstPartyPlate.isNewCar,
|
||||
userNoCertificate: firstPartyPlate.userNoCertificate,
|
||||
@@ -7480,6 +7600,7 @@ export class RequestManagementService {
|
||||
nationalCodeOfDriver: plateDto.nationalCodeOfDriver,
|
||||
insurerLicense: plateDto.insurerLicense,
|
||||
driverLicense: plateDto.driverLicense,
|
||||
driverLicenseDate: plateDto.driverLicenseDate,
|
||||
driverIsInsurer: plateDto.driverIsInsurer,
|
||||
isNewCar: plateDto.isNewCar,
|
||||
userNoCertificate: plateDto.userNoCertificate,
|
||||
@@ -8206,6 +8327,7 @@ export class RequestManagementService {
|
||||
carName: sandHubReport?.MapTypNam || sandHubReport?.CarName,
|
||||
insurerBirthday: firstPartyPlate.insurerBirthday,
|
||||
driverBirthday: firstPartyPlate.driverBirthday,
|
||||
driverLicenseDate: firstPartyPlate.driverLicenseDate,
|
||||
carType: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
|
||||
isNewCar: firstPartyPlate.isNewCar,
|
||||
};
|
||||
@@ -8552,6 +8674,16 @@ export class RequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
this.assertCarBodyDescriptionMinimum(
|
||||
request.type,
|
||||
formData.firstPartyDescription?.desc,
|
||||
);
|
||||
|
||||
this.assertAccidentDateTimeNotInFuture({
|
||||
accidentDate: formData.firstPartyDescription?.accidentDate,
|
||||
accidentTime: formData.firstPartyDescription?.accidentTime,
|
||||
});
|
||||
|
||||
try {
|
||||
// Get or create user for first party phone number
|
||||
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
|
||||
@@ -8687,6 +8819,7 @@ export class RequestManagementService {
|
||||
carName: sandHubReport?.MapTypNam || sandHubReport?.CarName,
|
||||
insurerBirthday: firstPartyPlate.insurerBirthday,
|
||||
driverBirthday: firstPartyPlate.driverBirthday,
|
||||
driverLicenseDate: firstPartyPlate.driverLicenseDate,
|
||||
carType: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
|
||||
isNewCar: firstPartyPlate.isNewCar,
|
||||
};
|
||||
@@ -10022,6 +10155,8 @@ export class RequestManagementService {
|
||||
party.person.insurerLicense = partyData.insurerLicense;
|
||||
if (partyData.driverLicense)
|
||||
party.person.driverLicense = partyData.driverLicense;
|
||||
if ((partyData as any).driverLicenseDate)
|
||||
party.person.driverLicenseDate = (partyData as any).driverLicenseDate;
|
||||
if ((partyData as any).licenseType)
|
||||
(party.person as any).licenseType = (partyData as any).licenseType;
|
||||
|
||||
@@ -10644,7 +10779,7 @@ export class RequestManagementService {
|
||||
|
||||
/**
|
||||
* VIN variant of `runInquiriesV3`.
|
||||
* Identical flow, but calls `getPolicyByChassisInquiry` instead of
|
||||
* Identical flow, but calls the two-factor `getCarByChassisInquiry` instead of
|
||||
* `getTejaratBlockInquiry` for the primary policy lookup.
|
||||
*/
|
||||
async runInquiriesVinV3(
|
||||
@@ -10787,7 +10922,7 @@ export class RequestManagementService {
|
||||
|
||||
/**
|
||||
* VIN variant of `runPartyInquiriesV3Internal`.
|
||||
* Calls `getPolicyByChassisInquiry` (ESG chassis lookup) instead of
|
||||
* Calls `getCarByChassisInquiry` (ESG two-factor chassis lookup) instead of
|
||||
* `getTejaratBlockInquiry` (plate-based). All other inquiries (personal,
|
||||
* driving licence, car-body for CAR_BODY) are unchanged.
|
||||
*/
|
||||
@@ -10930,6 +11065,8 @@ export class RequestManagementService {
|
||||
party.person.insurerLicense = partyData.insurerLicense;
|
||||
if (partyData.driverLicense)
|
||||
party.person.driverLicense = partyData.driverLicense;
|
||||
if ((partyData as any).driverLicenseDate)
|
||||
party.person.driverLicenseDate = (partyData as any).driverLicenseDate;
|
||||
if ((partyData as any).licenseType)
|
||||
(party.person as any).licenseType = (partyData as any).licenseType;
|
||||
|
||||
@@ -11692,6 +11829,16 @@ export class RequestManagementService {
|
||||
const role = this.resolvePartyRoleV3(req, partyRole);
|
||||
this.assertBlameV3PartyDetailPhase(req, role);
|
||||
|
||||
// V3/V5 clients can include CAR_BODY accident fields even though this
|
||||
// endpoint only persists the description. Never accept a future timestamp
|
||||
// merely because those extra fields are not part of the V3 statement step.
|
||||
if (body.accidentDate != null || body.accidentTime != null) {
|
||||
this.assertAccidentDateTimeNotInFuture({
|
||||
accidentDate: body.accidentDate,
|
||||
accidentTime: body.accidentTime,
|
||||
});
|
||||
}
|
||||
|
||||
const idx = this.getPartyIndex(req, role);
|
||||
if (idx === -1) throw new BadRequestException(`${role} party not found`);
|
||||
|
||||
@@ -11734,6 +11881,7 @@ export class RequestManagementService {
|
||||
await this.verifyExpertAccessForBlameV2(req, actor);
|
||||
const role = this.resolvePartyRoleV3(req, partyRole);
|
||||
this.assertBlameV3PartyDetailPhase(req, role);
|
||||
this.assertCarBodyDescriptionMinimum(req.type, body.desc);
|
||||
|
||||
// accidentDate and accidentTime are only required for the first (guilty)
|
||||
// party — the second (damaged) party can skip them.
|
||||
@@ -11750,6 +11898,11 @@ export class RequestManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
this.assertAccidentDateTimeNotInFuture({
|
||||
accidentDate: body.accidentDate,
|
||||
accidentTime: body.accidentTime,
|
||||
});
|
||||
|
||||
const idx = this.getPartyIndex(req, role);
|
||||
if (idx === -1) throw new BadRequestException(`${role} party not found`);
|
||||
|
||||
@@ -12957,10 +13110,31 @@ export class RequestManagementService {
|
||||
throw new ForbiddenException("Only FileMakers can use this endpoint.");
|
||||
}
|
||||
const makerId = new Types.ObjectId(fileMaker.sub);
|
||||
const files = await this.blameRequestDbService.find({
|
||||
const ownFiles = await this.blameRequestDbService.find({
|
||||
isMadeByFileMaker: true,
|
||||
initiatedByFieldExpertId: makerId,
|
||||
});
|
||||
const hasBranchScopedFiles = (ownFiles || []).some(
|
||||
(file: any) => !!file.branchId,
|
||||
);
|
||||
let makerBranchId: string | undefined;
|
||||
if (hasBranchScopedFiles) {
|
||||
const fileMakerProfile = await this.fileMakerDbService.findById(
|
||||
String(fileMaker.sub),
|
||||
);
|
||||
makerBranchId = fileMakerProfile?.branchId
|
||||
? String(fileMakerProfile.branchId)
|
||||
: undefined;
|
||||
if (!makerBranchId) {
|
||||
throw new ForbiddenException(
|
||||
"FileMaker account is not assigned to a branch.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const files = (ownFiles || []).filter(
|
||||
(file: any) =>
|
||||
!file.branchId || String(file.branchId) === makerBranchId,
|
||||
);
|
||||
const blameIds = (files || []).map((f: any) => f._id).filter(Boolean);
|
||||
const claims =
|
||||
blameIds.length > 0
|
||||
@@ -13020,6 +13194,17 @@ export class RequestManagementService {
|
||||
}
|
||||
const req = await this.blameRequestDbService.findById(requestId);
|
||||
if (!req) throw new NotFoundException("Blame request not found");
|
||||
if ((req as any).branchId) {
|
||||
const fileMakerProfile = await this.fileMakerDbService.findById(
|
||||
String(fileMaker.sub),
|
||||
);
|
||||
const makerBranchId = fileMakerProfile?.branchId
|
||||
? String(fileMakerProfile.branchId)
|
||||
: undefined;
|
||||
if (!makerBranchId || String((req as any).branchId) !== makerBranchId) {
|
||||
throw new ForbiddenException("This file belongs to another branch.");
|
||||
}
|
||||
}
|
||||
if (
|
||||
!req.isMadeByFileMaker ||
|
||||
String((req as any).initiatedByFieldExpertId) !== String(fileMaker.sub)
|
||||
@@ -13066,6 +13251,7 @@ export class RequestManagementService {
|
||||
driverBirthday: p.person?.driverBirthday,
|
||||
insurerLicense: p.person?.insurerLicense,
|
||||
driverLicense: p.person?.driverLicense,
|
||||
driverLicenseDate: p.person?.driverLicenseDate,
|
||||
userId: p.person?.userId ? String(p.person.userId) : undefined,
|
||||
clientId: p.person?.clientId ? String(p.person.clientId) : undefined,
|
||||
licenseType: p.person?.licenseType ?? undefined,
|
||||
@@ -13148,7 +13334,7 @@ export class RequestManagementService {
|
||||
{ assignedFileReviewerId: reviewerId },
|
||||
],
|
||||
});
|
||||
const visibleFiles = (files || []).filter((file: any) => {
|
||||
const tenantAndAssignmentVisible = (files || []).filter((file: any) => {
|
||||
const assignedReviewerId = file.assignedFileReviewerId
|
||||
? String(file.assignedFileReviewerId)
|
||||
: null;
|
||||
@@ -13162,6 +13348,26 @@ export class RequestManagementService {
|
||||
(isOpen || isAssignedToReviewer)
|
||||
);
|
||||
});
|
||||
const visibleFiles = (
|
||||
await Promise.all(
|
||||
tenantAndAssignmentVisible.map(async (file: any) => {
|
||||
try {
|
||||
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible(
|
||||
{
|
||||
fileMakerId: file.initiatedByFieldExpertId
|
||||
? String(file.initiatedByFieldExpertId)
|
||||
: null,
|
||||
fileReviewerId: String(fileReviewer.sub),
|
||||
caseBranchId: file.branchId ? String(file.branchId) : null,
|
||||
},
|
||||
);
|
||||
return file;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((file): file is any => file != null);
|
||||
|
||||
const visibleBlameIds = visibleFiles
|
||||
.map((f: any) => f._id)
|
||||
@@ -13229,6 +13435,15 @@ export class RequestManagementService {
|
||||
"This file does not belong to your organization.",
|
||||
);
|
||||
}
|
||||
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
|
||||
fileMakerId: (req as any).initiatedByFieldExpertId
|
||||
? String((req as any).initiatedByFieldExpertId)
|
||||
: null,
|
||||
fileReviewerId: String(fileReviewer.sub),
|
||||
caseBranchId: (req as any).branchId
|
||||
? String((req as any).branchId)
|
||||
: null,
|
||||
});
|
||||
const assignedId = (req as any).assignedFileReviewerId
|
||||
? String((req as any).assignedFileReviewerId)
|
||||
: null;
|
||||
@@ -13279,6 +13494,7 @@ export class RequestManagementService {
|
||||
driverBirthday: p.person?.driverBirthday,
|
||||
insurerLicense: p.person?.insurerLicense,
|
||||
driverLicense: p.person?.driverLicense,
|
||||
driverLicenseDate: p.person?.driverLicenseDate,
|
||||
userId: p.person?.userId ? String(p.person.userId) : undefined,
|
||||
clientId: p.person?.clientId ? String(p.person.clientId) : undefined,
|
||||
licenseType: p.person?.licenseType ?? undefined,
|
||||
|
||||
@@ -155,37 +155,47 @@ describe("SandHubService inquiry mocks", () => {
|
||||
expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY");
|
||||
});
|
||||
|
||||
it("preserves ESG not-found semantics as a Persian plate-specific error", async () => {
|
||||
it("preserves ESG messageFa for a plate inquiry", async () => {
|
||||
process.env.CLIENT_ID = "8";
|
||||
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
|
||||
success: false,
|
||||
message: "موردی یافت نشد",
|
||||
error: {
|
||||
code: "RECORD_NOT_FOUND",
|
||||
message: "Provider request failed",
|
||||
messageFa: "رکوردی یافت نشد",
|
||||
providerMessage: "err.record.not.found",
|
||||
providerCode: "RECORD_NOT_FOUND",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.getTejaratBlockInquiry(userDetail, {
|
||||
enforceDeploymentClientMatch: true,
|
||||
});
|
||||
|
||||
expect(result.mapped.Error.Message).toBe(
|
||||
"بیمهنامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||
);
|
||||
expect(result.mapped.Error.Message).toBe("رکوردی یافت نشد");
|
||||
});
|
||||
|
||||
it("uses a VIN-specific message for the same ESG not-found response", async () => {
|
||||
it("preserves ESG messageFa for a VIN inquiry", async () => {
|
||||
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
|
||||
success: false,
|
||||
message: "موردی یافت نشد",
|
||||
error: {
|
||||
code: "INQUIRY_NO_MATCH",
|
||||
message: "Inquiry returned no matching result",
|
||||
messageFa: "نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
|
||||
providerMessage: "Inquiry returned no matching result",
|
||||
providerCode: "INQUIRY_NO_MATCH",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.getPolicyByChassisInquiry({
|
||||
const result = await service.getCarByChassisInquiry({
|
||||
nationalCode: "0012345678",
|
||||
chassis: "NAAR03HFFRDE07024",
|
||||
chassisNo: "NAAR03HFFRDE07024",
|
||||
});
|
||||
|
||||
expect(result.mapped.Error.Message).toBe(
|
||||
"بیمهنامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
|
||||
"نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -229,10 +239,10 @@ describe("SandHubService inquiry mocks", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getPolicyByChassisInquiry(
|
||||
service.getCarByChassisInquiry(
|
||||
{
|
||||
nationalCode: "1234567890",
|
||||
chassis: "NAAR03HFFRDE07024",
|
||||
chassisNo: "NAAR03HFFRDE07024",
|
||||
},
|
||||
{ enforceDeploymentClientMatch: true },
|
||||
),
|
||||
@@ -246,16 +256,16 @@ describe("SandHubService inquiry mocks", () => {
|
||||
data: { CmpCod: "8", CmpNam: "بیمه پارسیان" },
|
||||
});
|
||||
|
||||
await service.getPolicyByChassisInquiry({
|
||||
await service.getCarByChassisInquiry({
|
||||
nationalCode: "0012345678",
|
||||
chassis: "NAAR03HFFRDE07024",
|
||||
chassisNo: "NAAR03HFFRDE07024",
|
||||
});
|
||||
|
||||
expect(esg).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/inquiry/policyByChassis"),
|
||||
expect.stringContaining("/inquiry/carByChassis"),
|
||||
{
|
||||
nationalCode: "0012345678",
|
||||
chassis: "NAAR03HFFRDE07024",
|
||||
chassisNo: "NAAR03HFFRDE07024",
|
||||
},
|
||||
"vinChassis",
|
||||
undefined,
|
||||
|
||||
@@ -1187,7 +1187,7 @@ 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
|
||||
* `buildMockPlateInquiryRaw` so the downstream mapper (`mapEsgPolicyByPlateToOldFormat`)
|
||||
@@ -1196,15 +1196,15 @@ export class SandHubService {
|
||||
* @param identity - policyholder national code and 17-character VIN/chassis
|
||||
* @param options - optional per-tenant client scope
|
||||
*/
|
||||
async getPolicyByChassisInquiry(
|
||||
identity: { nationalCode: string; chassis: string },
|
||||
async getCarByChassisInquiry(
|
||||
identity: { nationalCode: string; chassisNo: string },
|
||||
options?: SandHubInquiryOptions,
|
||||
): Promise<{ raw: any; mapped: any }> {
|
||||
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
||||
const requestUrl = `${baseUrl}/inquiry/policyByChassis`;
|
||||
const requestUrl = `${baseUrl}/inquiry/carByChassis`;
|
||||
const requestPayload = {
|
||||
nationalCode: String(identity.nationalCode),
|
||||
chassis: String(identity.chassis),
|
||||
chassisNo: String(identity.chassisNo),
|
||||
};
|
||||
|
||||
const live = await this.isInquiryLive("vinChassis", options);
|
||||
@@ -1213,7 +1213,7 @@ export class SandHubService {
|
||||
const ctx = await this.mockCompanyContext(options);
|
||||
const raw = this.buildMockPlateInquiryRaw(ctx);
|
||||
this.logger.debug(
|
||||
`[MOCK] getPolicyByChassisInquiry nationalCode=${identity.nationalCode} chassis=${identity.chassis}`,
|
||||
`[MOCK] getCarByChassisInquiry nationalCode=${identity.nationalCode} chassisNo=${identity.chassisNo}`,
|
||||
);
|
||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
|
||||
if (!mapped?.Error) {
|
||||
|
||||
Reference in New Issue
Block a user