Compare commits

..

38 Commits

Author SHA1 Message Date
985c28ed6b docs 2026-09-21 10:05:49 +03:30
9dd0ec0c00 Merge pull request 'fix claim validation and expert branch scoping' (#335) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#335
2026-09-20 15:03:22 +03:30
SepehrYahyaee
e06178f804 fix claim validation and expert branch scoping 2026-09-20 15:00:46 +03:30
a9adad3c1b Merge pull request 'VehicleHullAccessoryId' (#334) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#334
2026-09-20 12:32:48 +03:30
bbe5b7b77b VehicleHullAccessoryId 2026-09-20 12:31:39 +03:30
1e13bb3a3c Merge pull request 'Repair Duration' (#333) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#333
2026-09-20 12:07:11 +03:30
a8d8df574b Repair Duration 2026-09-20 12:06:20 +03:30
cf07a33951 Merge pull request 'hat PUT is now best-effort. If it 502s, YARA logs it and continues with POST …/vehicle-hull-claims/5032516/expertise.' (#332) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#332
2026-09-20 11:07:02 +03:30
6f8a6fed69 hat PUT is now best-effort. If it 502s, YARA logs it and continues with POST …/vehicle-hull-claims/5032516/expertise. 2026-09-20 11:05:07 +03:30
dbf46cfdda Merge pull request 'fix ESG chassis inquiry payload' (#331) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#331
2026-09-19 16:51:31 +03:30
SepehrYahyaee
4ef53f2cc9 fix ESG chassis inquiry payload 2026-09-19 16:50:34 +03:30
b4c1d7bd5f Merge pull request 'main' (#330) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#330
2026-09-19 16:12:46 +03:30
SepehrYahyaee
4cd6e0ecb3 test: align hull culprit default 2026-09-19 16:09:50 +03:30
SepehrYahyaee
8071215803 fix inquiry integration and insurer case details 2026-09-19 16:08:28 +03:30
cd36a0f2d4 Merge pull request 'main' (#329) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#329
2026-09-19 15:38:51 +03:30
ffaeb50c61 merge upstream 2026-09-19 15:38:13 +03:30
8afc1fabc4 fuck 2026-09-19 15:38:00 +03:30
1d9ef5e3d2 Merge pull request 'update the culprit licenses chnged and ActualPremium' (#328) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#328
2026-09-19 13:10:09 +03:30
be06bfe1b5 update the culprit licenses chnged and ActualPremium 2026-09-19 13:09:28 +03:30
85576e7c5e Merge pull request 'fix: query policies only for current holder' (#327) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#327
2026-09-19 12:13:54 +03:30
SepehrYahyaee
406b139b3d fix: align inquiry errors and expert review rules 2026-09-19 12:11:43 +03:30
SepehrYahyaee
45e0ad883a fix: query policies only for current holder 2026-09-19 11:26:40 +03:30
51e1d495a9 Merge pull request 'main' (#326) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#326
2026-09-18 16:11:29 +03:30
7d1a50db7b fix: harden claim review and inquiry workflows
Preserve damage history and current vehicle price, restore depreciation mapping, normalize inquiry/report output, and support resumable expert review with paginated case retrieval.
2026-09-18 16:04:33 +03:30
a84d83a135 Fixed dependencies 2026-09-17 12:26:58 +03:30
631c9bce16 Docs 2026-09-17 12:26:42 +03:30
a5f534817b Merge pull request 'main' (#325) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#325
2026-09-17 08:59:07 +03:30
ebfb4385de Use FileMaker and FileReviewer Fanavaran expert ids on V4/V5 flows.
Block file create and review when the matching third-party or car-body code is missing, and override GEN.03/GEN.06 ClaimExpertId from those profiles instead of tenant defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 16:26:58 +03:30
acb5d2a682 car body implemented 2026-09-16 16:05:54 +03:30
a174332d81 Merge pull request 'Rollback uploadDocument resume, added carPrice' (#324) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#324
2026-09-16 15:41:17 +03:30
SepehrYahyaee
c9e273fc4e Rollback uploadDocument step resume 2026-09-16 15:37:27 +03:30
SepehrYahyaee
ecf69cc332 Change Car_BODY fanavaran default client code and id to parsian 2026-09-16 14:16:44 +03:30
31f8cabf80 Merge pull request 'Fixed uploadDocuments status bug, Fixed image deletion bug' (#323) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#323
2026-09-16 12:52:29 +03:30
SepehrYahyaee
fee0bcb5be Fixed images being removed, fixed v4/v5 wrong status on uploadDocument 2026-09-16 12:50:12 +03:30
SepehrYahyaee
dc30518a7f Completed DOCS for participants 2026-09-16 10:24:22 +03:30
8173764913 Merge pull request 'main' (#322) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#322
2026-09-15 17:48:40 +03:30
c72cb265f1 merge upstream 2026-09-15 17:48:20 +03:30
b2ff3f574b Merge pull request 'main' (#321) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#321
2026-09-15 17:07:37 +03:30
94 changed files with 6387 additions and 1118 deletions

1
.gitignore vendored
View File

@@ -186,4 +186,3 @@ dist
*.sh
!scripts/fanavaran-flow-test.sh
!scripts/fanavaran-auth.sh
*.json

BIN
docs/Yara724 flows.pdf Normal file

Binary file not shown.

View File

@@ -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 و بدون سقف مجموع.

View File

@@ -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`.

View File

@@ -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 می‌شوند اما جریان اصلی را مسدود نمی‌کنند.

View File

@@ -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 Persian "استعلام در دسترس نیست" (inquiry unavailable) error.
ESG wraps every response as <code>{ success: boolean, data: … }</code>. For normalized error envelopes, the backend returns <code>error.messageFa</code> unchanged to the caller; technical fields such as <code>message</code>, <code>providerMessage</code>, and <code>providerCode</code> remain available for logging and classification. A business-level not-found response is not reported as a provider outage.
The offline-inquiry seed check still runs first, before any ESG HTTP call.
</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.

View File

@@ -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`.

View File

@@ -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}` |

View File

@@ -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 |

View File

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

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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.

View 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
```

View 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`.

View 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.

View 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`.

View File

@@ -2,25 +2,6 @@
این سند قرارداد نهایی فرانت‌اند برای مرحله استعلام است. از این به بعد اطلاعات اشخاص و خودرو باید با ساختار نقش‌محور زیر ارسال شود. فیلدهای تخت قدیمی مانند `nationalCodeOfDriver` و `nationalCodeOfInsurer` دیگر ورودی معتبر نیستند.
## پاسخ کوتاه درباره `unknown`
`unknown` فقط برای یک حالت استثنایی لازم است: وقتی در مرحله طرف زیان‌دیده یک پرونده `THIRD_PARTY`، هویت بیمه‌گذار شخص ثالث واقعاً مشخص نیست.
```json
{
"thirdPartyPolicyholder": { "unknown": true }
}
```
قواعد آن:
- فقط برای طرف زیان‌دیده (`SECOND`) مجاز است؛ برای طرف مقصر (`FIRST`) خطا برمی‌گردد.
- فقط برای `thirdPartyPolicyholder` مجاز است؛ برای راننده، مالک یا بیمه‌گذار بدنه مجاز نیست.
- وقتی `unknown: true` ارسال می‌شود، هیچ فیلد هویتی دیگری در همان آبجکت نفرستید.
- در استعلام شخص ثالث با پلاک یا VIN، استعلام بیمه‌گذار عمداً skip می‌شود و سیستم نباید کد ملی راننده یا مالک را جایگزین کند.
- استعلام‌های راننده، مالکیت خودرو و اطلاعات افراد شناخته‌شده همچنان اجرا می‌شوند.
- اگر هویت بیمه‌گذار مشخص است، اصلاً از `unknown` استفاده نکنید و اطلاعات واقعی یا `sameAs` را بفرستید.
## ساختار کلی درخواست
برای پرونده `THIRD_PARTY`:
@@ -113,7 +94,6 @@
| `birthday` | تاریخ تولد جلالی | برای شخص جدید الزامی |
| `fullName` | نام نمایشی شخص | اختیاری |
| `sameAs` | اتصال این نقش به نقش دیگر | به‌جای اطلاعات شخص جدید |
| `unknown` | نامشخص بودن بیمه‌گذار ثالث | فقط `SECOND` در `THIRD_PARTY` |
| `hasDrivingLicense` | داشتن گواهینامه راننده | برای نقش راننده الزامی |
| `licenseNumber` | شماره گواهینامه | اگر `hasDrivingLicense=true` الزامی |
| `licenseType` | نوع گواهینامه | اگر `hasDrivingLicense=true` الزامی |
@@ -136,7 +116,8 @@
| `vehicle.registrationState` | وضعیت ثبت رسمی خودرو | `CURRENT` یا `RECENTLY_TRANSFERRED`؛ پیش‌فرض `CURRENT` |
| `vehicle.currentPlate` | پلاک رسمی فعلی و شناسه اصلی خودرو | الزامی |
| `vehicle.previousPlate` | پلاک قبلی در انتقال اخیر | فقط در `RECENTLY_TRANSFERRED` |
| `vehicle.vin` | شماره شاسی/VIN | در انتقال اخیر الزامی؛ حداکثر ۱۷ کاراکتر |
| `vehicle.previousPolicyholderNationalCode` | کد ملی بیمه‌گذار مربوط به پلاک قبلی | فقط در `RECENTLY_TRANSFERRED` و الزامی |
| `vehicle.vin` | شماره شاسی/VIN | در انتقال اخیر الزامی؛ در صورت ارسال دقیقاً ۱۷ کاراکتر |
| `vehicle.isNewCar` | نو بودن خودرو | اختیاری |
اجزای پلاک:
@@ -170,19 +151,20 @@
"centerDigits": "222",
"ir": "33"
},
"previousPolicyholderNationalCode": "0098765432",
"vin": "NAAM01E15HK123456"
}
}
```
سیستم ابتدا پلاک فعلی را استعلام می‌کند. اگر نتیجه ناموجود، منقضی یا فاقد بیمه‌نامه مرتبط باشد، پلاک قبلی را امتحان می‌کند. نتیجه پلاک قبلی فقط در صورت تطبیق VIN پذیرفته می‌شود؛ پلاک قبلی هرگز جایگزین پلاک فعلی نمی‌شود.
اطلاعات انتقال اخیر فقط به‌عنوان متادیتای پرونده ذخیره می‌شوند. در route پلاک، سیستم فقط `currentPlate` را با کد ملی بیمه‌گذار نهاییِ مرتبط با نوع بیمه استعلام می‌کند و هیچ fallbackای به `previousPlate` یا `previousPolicyholderNationalCode` ندارد. در route شماره شاسی نیز فقط `vehicle.vin` با همان بیمه‌گذار نهایی استعلام می‌شود.
حتی در route مربوط به VIN، آبجکت `vehicle` از قرارداد مشترک استفاده می‌کند و `currentPlate` در قرارداد فعلی الزامی است. مقدار VIN در `vehicle.vin` قرار می‌گیرد، نه در فیلد سطح بالای `vin`.
## ترتیب پیشنهادی نمایش فرم
1. پلاک فعلی و وضعیت انتقال خودرو را بگیرید.
2. اگر انتقال اخیر بود، پلاک قبلی و VIN را بگیرید.
2. اگر انتقال اخیر بود، پلاک قبلی، کد ملی بیمه‌گذار پلاک قبلی و VIN را بگیرید.
3. اطلاعات راننده و وضعیت گواهینامه را بگیرید.
4. بپرسید مالک خودرو همان راننده است یا شخص دیگری؛ در حالت یکسان از `sameAs` استفاده کنید.
5. بیمه‌گذار شخص ثالث را از بین راننده، مالک یا شخص دیگر انتخاب کنید.
@@ -199,7 +181,19 @@
| کارشناس/پرونده‌ساز V3 تا V5 | `.../run-inquiries/:requestId` | `.../run-inquiries-vin/:requestId` |
| مرکز تماس V6 | `/v6/call-center-blame/run-inquiry/:requestId` | `/v6/call-center-blame/run-inquiry-vin/:requestId` |
در جریان‌های V3 تا V5، فراخوان اول برای طرف مقصر (`FIRST`) و فراخوان دوم، فقط در `THIRD_PARTY`، برای طرف زیان‌دیده (`SECOND`) است. همین تفاوت تعیین می‌کند که `unknown` مجاز است یا نه.
در جریان‌های V3 تا V5، فراخوان اول برای طرف مقصر (`FIRST`) و فراخوان دوم، فقط در `THIRD_PARTY`، برای طرف زیان‌دیده (`SECOND`) است. اطلاعات بیمه‌گذار برای هر دو طرف الزامی است.
## تفاوت اطلاعات مقصر و زیان‌دیده
ساختار نقش‌محور اشخاص و خودرو برای هر دو طرف یکسان است، اما ترتیب و قواعد کسب‌وکار آن‌ها تفاوت دارد:
| پرونده و طرف | نقش‌ها و رفتار |
| --- | --- |
| `THIRD_PARTY / FIRST` (مقصر) | راننده، مالک و بیمه‌گذار شخص ثالثِ خودروی مقصر ارسال می‌شوند. شبا در این فراخوان لازم نیست. بیمه‌نامه مقصر باید متعلق به شرکت بیمه همین سامانه باشد. |
| `THIRD_PARTY / SECOND` (زیان‌دیده) | راننده، مالک و بیمه‌گذار شخص ثالثِ خودروی زیان‌دیده ارسال می‌شوند. این مرحله فقط بعد از امضای مقصر و احراز OTP زیان‌دیده اجرا می‌شود. `sheba` الزامی است و با کد ملی `vehicleOwner` اعتبارسنجی می‌شود. بیمه‌گذار زیان‌دیده نیز همیشه باید مشخص باشد. |
| `CAR_BODY / FIRST` (بیمه‌گذار/زیان‌دیده بدنه) | هر چهار نقش راننده، مالک، بیمه‌گذار شخص ثالث و بیمه‌گذار بدنه ارسال می‌شوند. `sheba` در همین فراخوان الزامی است و با کد ملی `vehicleOwner` اعتبارسنجی می‌شود. استعلام بدنه با کد ملی `carBodyPolicyholder` انجام می‌شود. |
در V2 و V6 که شبا در مرحله جداگانه از کاربر دریافت می‌شود، شبا داخل درخواست استعلام مقصر ارسال نمی‌شود؛ بک‌اند هنگام مرحله بانکی آن را با کد ملی مالک ذخیره‌شده تطبیق می‌دهد.
## فیلدهایی که نباید ارسال شوند
@@ -220,6 +214,7 @@ plateId
vin // در سطح بالا؛ مقدار صحیح داخل vehicle.vin است
isNewCar // در سطح بالا؛ مقدار صحیح داخل vehicle.isNewCar است
phoneNumber // در participantها
unknown // حذف شده؛ بیمه‌گذار همیشه باید مشخص باشد
```
## خطاهای رایج فرانت‌اند
@@ -227,9 +222,10 @@ phoneNumber // در participantها
- ارسال `vehicleOwner` به‌صورت خالی؛ باید شخص جدید یا `sameAs` باشد.
- استفاده از `sameAs` همراه با `nationalCode` یا `birthday`.
- ارسال `carBodyPolicyholder` برای `THIRD_PARTY`.
- ارسال `unknown` برای طرف مقصر یا برای نقشی غیر از بیمه‌گذار ثالث.
- ارسال `unknown` برای هر نقش؛ این فیلد دیگر پذیرفته نمی‌شود.
- فرستادن `previousPlate` بدون `registrationState=RECENTLY_TRANSFERRED`.
- فرستادن `RECENTLY_TRANSFERRED` بدون `previousPlate` یا `vin`.
- فرستادن `RECENTLY_TRANSFERRED` بدون `previousPlate`، `previousPolicyholderNationalCode` یا `vin`.
- فرستادن `previousPolicyholderNationalCode` برای خودروی دارای وضعیت `CURRENT`.
- قرار دادن VIN یا پلاک در سطح بالای body.
- ارسال شماره تلفن در آبجکت شخص.
- تکرار کد ملی راننده یا بیمه‌گذار در مرحله شبا؛ تطبیق شبا همیشه با مالک خودرو انجام می‌شود.

View File

@@ -8,7 +8,7 @@
فیلدهای نقش‌ها و آبجکت الزامی `vehicle` که در ادامه آمده‌اند، مستقیماً در body تمام درخواست‌های استعلام فعلی پذیرفته می‌شوند. این تغییر شامل فرم اولیه کاربر و mirror کارشناس/ثبت‌کننده در V2، جریان کارشناس V3، جریان‌های پرونده‌ساز V4/V5، مرکز تماس V6 و مسیرهای تک‌درخواستی حضوری است. routeهای پلاک و VIN از قوانین مشترک اشخاص استفاده می‌کنند. فیلدهای تخت راننده/بیمه‌گذار و شماره تلفن، ورودی استعلام نیستند.
پاسخ‌ها و جزئیات پرونده برای نقش‌های عملیاتی، در صورت وجود داده، فیلدهای نرمال‌شده `participants`، `participantRoles`، `vehicle.registrationState` و `vehicle.previousPlateId` را نمایش می‌دهند.
پاسخ‌ها و جزئیات پرونده برای نقش‌های عملیاتی، در صورت وجود داده، فیلدهای نرمال‌شده `participants`، `participantRoles`، `vehicle.registrationState`، `vehicle.previousPlateId` و `vehicle.previousPolicyholderNationalCode` را نمایش می‌دهند.
## مسئله
@@ -49,7 +49,7 @@
فیلد `carBodyPolicyholder` برای `THIRD_PARTY` مجاز نیست و برای `CAR_BODY` الزامی است. هر نقش باید فقط یکی از دو حالت «اطلاعات شخص» یا `sameAs` را داشته باشد. بک‌اند این ورودی را به فهرست اشخاص یکتا و اتصال نقش‌ها به آن‌ها تبدیل می‌کند.
فقط برای طرف زیان‌دیده در پرونده `THIRD_PARTY` می‌توان نامشخص بودن بیمه‌گذار را به‌صورت صریح با `"thirdPartyPolicyholder": { "unknown": true }` ارسال کرد. این حالت برای طرف مقصر پذیرفته نمی‌شود. استعلام بیمه شخص ثالث با پلاک یا VIN ــ چون هر دو به کد ملی بیمه‌گذار نیاز دارند ــ به‌شکل قابل‌ممیزی «عمداً اجرا نشد» ثبت می‌شود و بک‌اند نباید کد ملی راننده یا مالک را جایگزین کند. اعتبارسنجی راننده و مالک و استعلام‌های هویت، مالکیت و گواهینامه همچنان انجام می‌شوند.
هر بیمه‌گذار باید با اطلاعات هویتی یا `sameAs` به یک شخص مشخص متصل شود. گزینه حذف‌شده `unknown` برای هیچ نقشی پذیرفته نمی‌شود؛ بنابراین استعلام بیمه به‌دلیل نامشخص بودن هویت بیمه‌گذار رد یا عمداً اجرا‌نشده ثبت نمی‌شود.
برای راننده، `hasDrivingLicense` الزامی است. اگر مقدار آن `true` باشد، هر دو فیلد `licenseNumber` و `licenseType` نیز الزامی هستند؛ اگر مقدار آن `false` باشد، استعلام گواهینامه عمداً اجرا نمی‌شود.
@@ -73,18 +73,19 @@
"centerDigits": "222",
"ir": "33"
},
"previousPolicyholderNationalCode": "0098765432",
"vin": "NAAM01E15HK123456"
}
}
```
مقدار پیش‌فرض `registrationState` برابر `CURRENT` است و برای این مسیر استثنایی مقدار `RECENTLY_TRANSFERRED` استفاده می‌شود. `previousPlate` فقط در حالت انتقال اخیر الزامی است. پلاک فعلی همچنان شناسه اصلی خودرو است. هماهنگ‌کننده استعلام باید ابتدا پلاک فعلی را بررسی کند و اگر نتیجه ناموجود، قدیمی یا فاقد بیمه‌نامه مرتبط بود، پلاک قبلی را به‌صورت خودکار استعلام کند.
مقدار پیش‌فرض `registrationState` برابر `CURRENT` است و برای این مسیر استثنایی مقدار `RECENTLY_TRANSFERRED` استفاده می‌شود. در انتقال اخیر، `previousPlate`، `previousPolicyholderNationalCode` و `vin` الزامی‌اند؛ فیلدهای مربوط به پلاک قبلی در حالت عادی `CURRENT` نباید ارسال شوند. این اطلاعات انتقال فقط به‌عنوان متادیتای پرونده نگه‌داری می‌شوند و پلاک فعلی همچنان شناسه اصلی خودرو است.
پیش از پذیرش نتیجه پلاک قبلی، بک‌اند باید یکسان بودن VIN/شماره شاسی را بررسی کند. در صورت مغایرت، انتخاب خودکار متوقف و اصلاح اطلاعات یا بررسی دستی الزامی شود. هر دو پلاک و تمام تلاش‌های استعلام برای ممیزی نگه‌داری شوند، اما پلاک قبلی هیچ‌گاه نباید روی پلاک فعلی نوشته شود.
در route پلاک، هماهنگ‌کننده دقیقاً یک استعلام بیمه انجام می‌دهد: `currentPlate` همراه با بیمه‌گذار نهایی همان نوع بیمه. در route شماره شاسی نیز `vehicle.vin` با همان بیمه‌گذار نهایی ارسال می‌شود. `previousPlate` هیچ‌گاه استعلام نمی‌شود، `previousPolicyholderNationalCode` به ارائه‌دهنده استعلام ارسال نمی‌شود و VIN برای انتخاب نتیجه پلاک قبلی به کار نمی‌رود.
## ترتیب پیشنهادی فرم
1. پلاک فعلی دریافت و درباره انتقال مالکیت اخیر پرسیده شود. در صورت انتقال اخیر، پلاک قبلی و VIN/شماره شاسی نیز دریافت شوند.
1. پلاک فعلی دریافت و درباره انتقال مالکیت اخیر پرسیده شود. در صورت انتقال اخیر، پلاک قبلی، کد ملی بیمه‌گذار مربوط به پلاک قبلی و VIN/شماره شاسی نیز دریافت شوند.
2. اطلاعات هویتی و گواهینامه راننده دریافت شود.
3. پرسیده شود آیا مالک خودرو همان راننده است؛ فقط در صورت تفاوت، اطلاعات مالک دریافت شود.
4. برای بیمه‌گذار شخص ثالث یکی از «راننده»، «مالک» یا «شخص دیگر» انتخاب شود؛ فقط برای شخص دیگر فرم جدید نمایش داده شود.
@@ -100,7 +101,8 @@
- نقش‌های لازم را بر اساس نوع پرونده اعتبارسنجی و ارجاع‌های نامعتبر یا حلقوی `sameAs` را رد کند؛
- شخص نهایی هر نقش را برگرداند؛
- هویت درست را به استعلام مرتبط بدهد: گواهینامه ← راننده، مالکیت و تطبیق شبا ← مالک خودرو، بیمه شخص ثالث با پلاک/VIN ← بیمه‌گذار شخص ثالث، بیمه بدنه با پلاک/VIN ← بیمه‌گذار بدنه؛
- بر اساس یک قاعده مشخص، پلاک فعلی یا قبلی را انتخاب و نتیجه پلاک قبلی را با VIN/شماره شاسی تطبیق دهد؛
- شبا را در استعلام شخص مطالبه‌کننده خسارت (`SECOND` زیان‌دیده در `THIRD_PARTY` و طرف اول در `CAR_BODY`) الزامی کند و با کد ملی مالک خودرو اعتبارسنجی کند؛ در استعلام `FIRST` مقصر پرونده ثالث شبا دریافت نمی‌شود؛
- فقط پلاک فعلی یا VIN ارسال‌شده را با بیمه‌گذار نهایی همان نوع بیمه استعلام کند؛ متادیتای انتقال قبلی نباید مسیریابی استعلام را تغییر دهد؛
- استعلام هویت را برای هر شخص یکتا فقط یک بار اجرا کند؛
- اشخاص نرمال‌شده و نقش‌های آن‌ها را در `Party` مربوط ذخیره کند.
- `participants` و `participantRoles` ذخیره‌شده را بدون حذف اطلاعات در جزئیات پرونده پنل‌های کارشناسی و پرونده خسارت متصل نمایش دهد تا اطلاعات راننده و سایر نقش‌ها برای بررسی در دسترس بماند.

View File

@@ -8,7 +8,7 @@ Persian version: [inquiry-participants-proposal.fa.md](./inquiry-participants-pr
The role fields and required `vehicle` object shown below are accepted directly in every existing inquiry request body. This covers V2 user and expert/registrar mirror initial forms, V3 expert flow, V4/V5 FileMaker flows, V6 call-center flow, and the one-shot in-person completion paths. Plate and VIN routes share the same participant rules. Flat driver/insurer fields and phone numbers are not inquiry inputs.
Responses and file-detail views for operational actors expose normalized `participants`, `participantRoles`, `vehicle.registrationState`, and `vehicle.previousPlateId` where available.
Responses and file-detail views for operational actors expose normalized `participants`, `participantRoles`, `vehicle.registrationState`, `vehicle.previousPlateId`, and `vehicle.previousPolicyholderNationalCode` where available.
## Problem
@@ -49,7 +49,7 @@ Use explicit role references instead:
`carBodyPolicyholder` is forbidden for `THIRD_PARTY` and required for `CAR_BODY`. A role is either a new person's identity or a `sameAs` reference, never both. The backend should normalize this input into unique participants plus role assignments.
For the damaged party of a `THIRD_PARTY` case only, the policyholder can be explicitly unresolved with `"thirdPartyPolicyholder": { "unknown": true }`. This is not accepted for the guilty party. Third-party-policy inquiry by either plate or VIN is recorded as intentionally skipped because both routes require the policyholder national code; the backend must not substitute the driver or owner. The driver, owner, personal, ownership, and licence rules remain enforced.
Every policyholder must resolve to a known participant through identity fields or `sameAs`. The removed `unknown` option is rejected for every role, so policy inquiries are never skipped because a policyholder identity is missing.
For Driver, `hasDrivingLicense` is required. When it is `true`, both `licenseNumber` and `licenseType` are required; when it is `false`, the licence inquiry is intentionally skipped.
@@ -73,18 +73,19 @@ Participant roles and vehicle identifiers are separate concerns. When a vehicle
"centerDigits": "222",
"ir": "33"
},
"previousPolicyholderNationalCode": "0098765432",
"vin": "NAAM01E15HK123456"
}
}
```
`registrationState` is `CURRENT` by default or `RECENTLY_TRANSFERRED` for this exceptional path. `previousPlate` is required only when `registrationState=RECENTLY_TRANSFERRED`. The current plate remains the vehicle's primary identifier. The inquiry orchestrator should query the current plate first and automatically try the previous plate 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
1. Collect the current plate and ask whether the vehicle was recently transferred. If yes, collect the previous plate and VIN/chassis.
1. Collect the current plate and ask whether the vehicle was recently transferred. If yes, collect the previous plate, its policyholder's national code, and VIN/chassis.
2. Collect driver identity and licence details.
3. Ask whether the vehicle owner is the driver; collect owner identity only when different.
4. Ask whether the third-party policyholder is the driver, the owner, or another person; collect identity only for “another person”.
@@ -100,7 +101,8 @@ Create one shared participant resolver used by every inquiry route. Its interfac
- validate required roles by case type and reject circular/invalid `sameAs` references;
- 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;
- choose the current or previous plate deterministically and verify previous-plate results against VIN/chassis;
- 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;
- 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.

View File

@@ -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>

View File

@@ -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.</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>

6
package-lock.json generated
View File

@@ -8796,9 +8796,9 @@
}
},
"node_modules/ip-address": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"

View File

@@ -462,4 +462,128 @@ describe("buildInsurerFileReport", () => {
"پایه یک",
);
});
it("localizes participant and recent-transfer inquiry data", () => {
const report = buildInsurerFileReport({
overview: { publicId: "A00017" },
blame: {
type: "THIRD_PARTY",
parties: [
{
role: "FIRST",
person: { userId: "guilty", fullName: "مقصر" },
},
{
role: "SECOND",
person: { userId: "damaged", fullName: "زیان‌دیده" },
participants: [
{
participantId: "DRIVER",
fullName: "راننده",
nationalCode: "0012345678",
birthday: "1370/01/01",
hasDrivingLicense: true,
licenseNumber: "123456789",
licenseType: "BASE_2",
},
],
participantRoles: {
driver: "DRIVER",
vehicleOwner: "DRIVER",
thirdPartyPolicyholder: "DRIVER",
},
vehicle: {
registrationState: "RECENTLY_TRANSFERRED",
previousPlateId: "55ج222ایران33",
previousPolicyholderNationalCode: "0098765432",
currentPlate: {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
},
inquiry: {
plateKind: "PREVIOUS",
attempts: [
{
plateKind: "CURRENT",
plate: {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
},
succeeded: false,
error: "not found",
},
{
plateKind: "PREVIOUS",
succeeded: true,
usable: true,
},
],
},
},
},
],
expert: { decision: { guiltyPartyId: "guilty" } },
},
claim: {
vehicle: { carType: "SEDAN" },
},
});
expect(
getFieldValue(
report,
PR.damagedVehicleSection,
"خودرو / وضعیت پلاک و مالکیت",
),
).toBe("انتقال مالکیت اخیر");
expect(
getFieldValue(report, PR.damagedVehicleSection, "خودرو / نوع خودرو"),
).toBe("سواری");
expect(
getFieldValue(report, PR.damagedVehicleSection, "خودرو / پلاک قبلی"),
).toBe("55ج222ایران33");
expect(
getFieldValue(
report,
PR.damagedVehicleSection,
"خودرو / پلاک فعلی / دو رقم چپ پلاک",
),
).toBe("44");
const driver = getFieldValue(
report,
PR.damagedParticipantsSection,
PR.driverRole,
);
expect(driver).toContain("گواهینامه دارد: بله");
expect(driver).toContain("نوع گواهینامه: پایه دو");
expect(
getFieldValue(
report,
PR.damagedVehicleSection,
"خودرو / سوابق تلاش استعلام / تلاش ۱ / نوع پلاک استعلام‌شده",
),
).toBe("پلاک فعلی");
expect(
getFieldValue(
report,
PR.damagedVehicleSection,
"خودرو / سوابق تلاش استعلام / تلاش ۱ / خطا",
),
).toBe("موردی یافت نشد");
const renderedText = report.sections
.flatMap((section) => [
section.title,
...section.fields.flatMap((field) => [field.label, field.value]),
])
.join(" ");
expect(renderedText).not.toMatch(
/RECENTLY_TRANSFERRED|BASE_2|registrationState|previousPlateId|plateKind|not found|CURRENT|PREVIOUS/,
);
});
});

View File

@@ -14,6 +14,7 @@ import {
PR,
persianAccidentCondition,
persianFieldPath,
persianReportValue,
persianStatus,
} from "./persian-report-labels";
@@ -110,10 +111,13 @@ function firstDefined(...values: unknown[]): string | undefined {
return undefined;
}
function normalizeListValue(value: unknown): string | undefined {
function normalizeListValue(value: unknown, path = ""): string | undefined {
if (!Array.isArray(value) || !value.length) return undefined;
const items = value
.map((item) => asString(item) ?? JSON.stringify(item))
.map(
(item) =>
asString(persianReportValue(path, item)) ?? JSON.stringify(item),
)
.filter(Boolean);
return items.length ? items.join("، ") : undefined;
}
@@ -124,18 +128,31 @@ function flattenObject(
depth = 0,
): InsurerFileReportField[] {
if (obj == null) return [];
if (depth > 4) {
return [{ label: persianFieldPath(prefix), value: asString(obj) }];
if (depth > 6) {
return [
{
label: persianFieldPath(prefix),
value: asString(persianReportValue(prefix, obj)),
},
];
}
if (Array.isArray(obj)) {
const value = normalizeListValue(obj);
if (obj.some((item) => item && typeof item === "object")) {
return obj.flatMap((item, index) =>
flattenObject(item, `${prefix}.${index + 1}`, depth + 1),
);
}
const value = normalizeListValue(obj, prefix);
return value ? [{ label: persianFieldPath(prefix || "items"), value }] : [];
}
if (typeof obj !== "object") {
return [
{ label: persianFieldPath(prefix || "value"), value: asString(obj) },
{
label: persianFieldPath(prefix || "value"),
value: asString(persianReportValue(prefix || "value", obj)),
},
];
}
@@ -150,14 +167,13 @@ function flattenObject(
if (value === undefined || value === null || value === "") continue;
const path = prefix ? `${prefix}.${key}` : key;
if (
typeof value === "object" &&
!Array.isArray(value) &&
!(value instanceof Date)
) {
if (typeof value === "object" && !(value instanceof Date)) {
rows.push(...flattenObject(value, path, depth + 1));
} else {
rows.push({ label: persianFieldPath(path), value: asString(value) });
rows.push({
label: persianFieldPath(path),
value: asString(persianReportValue(path, value)),
});
}
}
@@ -638,6 +654,17 @@ function buildParticipantRolesSection(
participant?.licenseNumber
? `${PR.licenseNumber}: ${asString(participant.licenseNumber)}`
: undefined,
participant?.hasDrivingLicense != null
? `${PR.hasDrivingLicense}: ${persianStatus(participant.hasDrivingLicense)}`
: undefined,
participant?.licenseType
? `${PR.licenseType}: ${asString(
persianReportValue(
"participant.licenseType",
participant.licenseType,
),
)}`
: undefined,
]
.filter(Boolean)
.join("، ");
@@ -695,8 +722,12 @@ function buildDriverSection(
{
label: PR.licenseType,
value:
licenseType ??
asString(person.licenseType) ??
asString(
persianReportValue(
"participant.licenseType",
licenseType ?? person.licenseType,
),
) ??
(person.driverLicense ? PR.driverLicense : undefined),
},
{
@@ -895,7 +926,7 @@ function evaluationDaghiValue(value: unknown): string | undefined {
return asString(value);
}
const daghi = value as ReportRecord;
const option = asString(daghi.option);
const option = asString(persianReportValue("daghi.option", daghi.option));
const price = formatToman(daghi.price);
return [option, price].filter(Boolean).join(" - ") || undefined;
}
@@ -915,7 +946,9 @@ function buildEvaluationPartsSection(
{ label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) },
{
label: `${prefix} / ${PR.damageType}`,
value: asString(part.typeOfDamage),
value: asString(
persianReportValue("evaluation.part.typeOfDamage", part.typeOfDamage),
),
},
{ label: `${prefix} / ${PR.partPrice}`, value: formatToman(part.price) },
{

View File

@@ -29,6 +29,7 @@ export const PR = {
licenseType: "نوع گواهینامه",
licenseDate: "تاریخ گواهینامه",
licenseNumber: "شماره گواهینامه",
hasDrivingLicense: "گواهینامه دارد",
driverLicense: "گواهینامه راننده",
insuranceCompany: "شرکت بیمه",
policyNumber: "شماره بیمه‌نامه",
@@ -194,6 +195,31 @@ const KEY_LABELS: Record<string, string> = {
StatusTypeCode: "کد وضعیت",
label_fa: "برچسب فارسی",
catalogKey: "کلید کاتالوگ",
participantId: "شناسه شخص",
participants: "اشخاص استعلام",
participantRoles: "نقش‌های اشخاص",
nationalCode: "کد ملی",
birthday: "تاریخ تولد",
fullName: "نام و نام خانوادگی",
phoneNumber: "شماره تلفن",
hasDrivingLicense: "گواهینامه دارد",
licenseNumber: "شماره گواهینامه",
registrationState: "وضعیت پلاک و مالکیت",
currentPlate: "پلاک فعلی",
previousPlate: "پلاک قبلی",
previousPlateId: "پلاک قبلی",
previousPolicyholderNationalCode: "کد ملی بیمه‌گذار پلاک قبلی",
vehicleVin: "شماره شاسی (VIN)",
driver: "راننده",
vehicleOwner: "مالک وسیله نقلیه",
thirdPartyPolicyholder: "بیمه‌گذار شخص ثالث",
carBodyPolicyholder: "بیمه‌گذار بدنه",
sameAs: "همان شخص",
plateKind: "نوع پلاک استعلام‌شده",
succeeded: "موفق",
usable: "قابل استفاده",
attempts: "سوابق تلاش استعلام",
error: "خطا",
};
const STATUS_LABELS: Record<string, string> = {
@@ -209,6 +235,76 @@ const STATUS_LABELS: Record<string, string> = {
false: "خیر",
};
const REGISTRATION_STATE_LABELS: Record<string, string> = {
CURRENT: "عادی (پلاک فعلی)",
RECENTLY_TRANSFERRED: "انتقال مالکیت اخیر",
};
const PLATE_KIND_LABELS: Record<string, string> = {
CURRENT: "پلاک فعلی",
PREVIOUS: "پلاک قبلی",
};
const PARTICIPANT_ROLE_VALUE_LABELS: Record<string, string> = {
DRIVER: "راننده",
VEHICLE_OWNER: "مالک وسیله نقلیه",
THIRD_PARTY_POLICYHOLDER: "بیمه‌گذار شخص ثالث",
CAR_BODY_POLICYHOLDER: "بیمه‌گذار بدنه",
FIRST: "طرف اول",
SECOND: "طرف دوم",
};
const CASE_TYPE_LABELS: Record<string, string> = {
THIRD_PARTY: "شخص ثالث",
CAR_BODY: "بدنه",
};
const VEHICLE_TYPE_LABELS: Record<string, string> = {
SEDAN: "سواری",
SUV: "شاسی‌بلند",
HATCHBACK: "هاچ‌بک",
PICKUP: "وانت",
VAN: "ون",
};
const VALIDITY_LABELS: Record<string, string> = {
ACTIVE: "فعال",
INACTIVE: "غیرفعال",
VALID: "معتبر",
INVALID: "نامعتبر",
EXPIRED: "منقضی‌شده",
};
const DAMAGE_TYPE_LABELS: Record<string, string> = {
REPAIR: "تعمیر",
CHANGE: "تعویض",
REPLACE: "تعویض",
};
const INQUIRY_ERROR_LABELS: Record<string, string> = {
NOT_FOUND: "موردی یافت نشد",
NO_RECORD_FOUND: "موردی یافت نشد",
TIMEOUT: "مهلت پاسخ استعلام به پایان رسید",
REQUEST_FAILED: "استعلام ناموفق بود",
FAILED: "استعلام ناموفق بود",
UNAVAILABLE: "سرویس استعلام در دسترس نیست",
};
const LICENSE_TYPE_LABELS: Record<string, string> = {
"1": "پایه یک",
"2": "پایه دو",
"3": "پایه سه",
BASE_1: "پایه یک",
BASE1: "پایه یک",
GRADE_1: "پایه یک",
BASE_2: "پایه دو",
BASE2: "پایه دو",
GRADE_2: "پایه دو",
BASE_3: "پایه سه",
BASE3: "پایه سه",
GRADE_3: "پایه سه",
};
const WEATHER_LABELS: Record<string, string> = {
clear: "صاف",
sunny: "صاف",
@@ -255,9 +351,39 @@ export function persianFieldPath(path: string): string {
return `بیمه / ${translatedLast}`;
}
if (parts[0] === "claim" && parts[1] === "vehicle") {
const attemptIndex = parts.indexOf("attempts");
if (attemptIndex >= 0) {
const attemptNumber = Number(parts[attemptIndex + 1]);
const attemptLabel = Number.isFinite(attemptNumber)
? `تلاش ${attemptNumber.toLocaleString("fa-IR")}`
: "تلاش استعلام";
const plateLabel = parts.includes("plate") ? " / پلاک" : "";
return `خودرو / سوابق تلاش استعلام / ${attemptLabel}${plateLabel} / ${translatedLast}`;
}
if (parts.includes("currentPlate")) {
return `خودرو / پلاک فعلی / ${translatedLast}`;
}
if (parts.includes("previousPlate")) {
return `خودرو / پلاک قبلی / ${translatedLast}`;
}
return `خودرو / ${translatedLast}`;
}
if (parts[0] === "party" && parts[1] === "vehicle") {
const attemptIndex = parts.indexOf("attempts");
if (attemptIndex >= 0) {
const attemptNumber = Number(parts[attemptIndex + 1]);
const attemptLabel = Number.isFinite(attemptNumber)
? `تلاش ${attemptNumber.toLocaleString("fa-IR")}`
: "تلاش استعلام";
const plateLabel = parts.includes("plate") ? " / پلاک" : "";
return `خودرو / سوابق تلاش استعلام / ${attemptLabel}${plateLabel} / ${translatedLast}`;
}
if (parts.includes("currentPlate")) {
return `خودرو / پلاک فعلی / ${translatedLast}`;
}
if (parts.includes("previousPlate")) {
return `خودرو / پلاک قبلی / ${translatedLast}`;
}
return `خودرو / ${translatedLast}`;
}
@@ -267,6 +393,51 @@ export function persianFieldPath(path: string): string {
return translated.join(" / ");
}
/**
* Localize only known domain tokens. Free-form provider values, names,
* identifiers, plates and VINs are deliberately preserved verbatim.
*/
export function persianReportValue(
path: string,
value: unknown,
): unknown {
if (value === undefined || value === null || value === "") return value;
if (typeof value === "boolean") return value ? "بله" : "خیر";
if (typeof value !== "string" && typeof value !== "number") return value;
const raw = String(value).trim();
const key = raw.toUpperCase().replace(/[\s-]+/g, "_");
const field = path.split(".").filter(Boolean).pop() ?? "";
if (field === "licenseType" || field === "LicenseType") {
return LICENSE_TYPE_LABELS[key] ?? raw;
}
if (field === "registrationState") {
return REGISTRATION_STATE_LABELS[key] ?? raw;
}
if (field === "plateKind") return PLATE_KIND_LABELS[key] ?? raw;
if (field === "role" || field.endsWith("Role")) {
return PARTICIPANT_ROLE_VALUE_LABELS[key] ?? raw;
}
if (field === "blameRequestType" || field === "fileType") {
return CASE_TYPE_LABELS[key] ?? raw;
}
if (
(field === "carType" || field === "type") &&
path.toLowerCase().includes("vehicle")
) {
return VEHICLE_TYPE_LABELS[key] ?? raw;
}
if (field === "typeOfDamage") return DAMAGE_TYPE_LABELS[key] ?? raw;
if (field === "error") return INQUIRY_ERROR_LABELS[key] ?? raw;
if (/status$/i.test(field)) {
return STATUS_LABELS[raw] ?? VALIDITY_LABELS[key] ?? raw;
}
if (raw === "true" || raw === "false") return STATUS_LABELS[raw];
return raw;
}
export function persianStatus(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
const key = String(value);

View File

@@ -0,0 +1,113 @@
/**
* Regression: concurrent capturePart writes must not clobber sibling slots.
*
* capturePartV2 used to `$set` the entire `media.damagedParts` array from a
* stale read. Parallel uploads (common while Fanavaran attachment submit keeps
* the HTTP request open) made the last writer win — Fanavaran still saw each
* file on disk and could return errors, while Mongo was missing captures.
*
* Required strategy: per-index `$set` (`media.damagedParts.N`), matching
* `media.carAngles.<key>`.
*/
describe("capture-part media.damagedParts write strategies", () => {
type Row = { path?: string; fileName?: string; name?: string };
type Claim = { media: { damagedParts: Row[] } };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Legacy (buggy) strategy: replace the entire array from a stale read. */
async function writeWholeArray(
store: { claim: Claim },
index: number,
capture: Row,
readDelayMs: number,
) {
const snapshot = structuredClone(store.claim);
await sleep(readDelayMs);
const next = snapshot.media.damagedParts.map((row) => ({ ...row }));
while (next.length <= index) next.push({});
next[index] = { ...next[index], ...capture };
store.claim = {
...store.claim,
media: { ...store.claim.media, damagedParts: next },
};
}
/** Required strategy: set only the target index (Mongo $set media.damagedParts.N). */
async function writeSingleIndex(
store: { claim: Claim },
index: number,
capture: Row,
readDelayMs: number,
) {
await sleep(readDelayMs);
const next = store.claim.media.damagedParts.map((row) => ({ ...row }));
while (next.length <= index) next.push({});
next[index] = { ...next[index], ...capture };
store.claim.media.damagedParts[index] = next[index];
}
it("documents that whole-array replace loses a concurrent capture", async () => {
const store: { claim: Claim } = {
claim: {
media: {
damagedParts: [{ name: "hood" }, { name: "front_bumper" }],
},
},
};
await Promise.all([
writeWholeArray(
store,
0,
{ path: "files/claim-captures/hood.jpg", fileName: "hood.jpg" },
30,
),
writeWholeArray(
store,
1,
{
path: "files/claim-captures/bumper.jpg",
fileName: "bumper.jpg",
},
10,
),
]);
expect(store.claim.media.damagedParts.map((r) => r.path).filter(Boolean))
.toHaveLength(1);
});
it("keeps both concurrent captures with per-index writes", async () => {
const store: { claim: Claim } = {
claim: {
media: {
damagedParts: [{ name: "hood" }, { name: "front_bumper" }],
},
},
};
await Promise.all([
writeSingleIndex(
store,
0,
{ path: "files/claim-captures/hood.jpg", fileName: "hood.jpg" },
30,
),
writeSingleIndex(
store,
1,
{
path: "files/claim-captures/bumper.jpg",
fileName: "bumper.jpg",
},
10,
),
]);
expect(store.claim.media.damagedParts.map((r) => r.path)).toEqual([
"files/claim-captures/hood.jpg",
"files/claim-captures/bumper.jpg",
]);
});
});

View File

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

View File

@@ -83,7 +83,7 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({
summary: "Get My Claims (V2)",
description:
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType`, `startDate`, `endDate`.",
})
@ApiResponse({
status: 200,

View File

@@ -204,6 +204,33 @@ export class ClaimDetailsV2ResponseDto {
fileName?: string;
}>;
@ApiPropertyOptional({
description:
'Append-only expert damaged-part revisions. Removed rows retain their original capture URL.',
type: 'array',
items: {
type: 'object',
properties: {
revisionId: { type: 'string' },
changedAt: { type: 'string', format: 'date-time' },
changedBy: { type: 'object' },
removedParts: { type: 'array', items: { type: 'object' } },
addedParts: { type: 'array', items: { type: 'object' } },
},
},
})
damagedPartsHistory?: Array<{
revisionId: string;
changedAt: Date | string;
changedBy: {
actorId: string;
actorName?: string;
actorType: string;
};
removedParts: Array<Record<string, unknown>>;
addedParts: Array<Record<string, unknown>>;
}>;
@ApiPropertyOptional({
description:
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',

View File

@@ -40,6 +40,12 @@ export class ClaimListItemV2Dto {
example: 'IN_PERSON',
})
creationMethod?: string;
@ApiPropertyOptional({
description: 'Calculated combined blame and claim lifecycle status',
example: 'WAITING_FOR_DAMAGE_EXPERT',
})
unifiedFileStatus?: string;
}
export class GetMyClaimsV2ResponseDto {

View File

@@ -18,6 +18,18 @@ export class ClaimDamageSelection {
@Prop({ type: [String], default: [] })
otherParts?: string[];
/**
* Append-only expert edit revisions. Each revision preserves the prior
* selection and capture metadata for removed parts before the live arrays
* are replaced. Mixed keeps legacy/free-text part shapes readable.
*/
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
partSelectionHistory?: unknown[];
/** Parts introduced by an expert across revisions (used for origin labels). */
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
expertAddedParts?: unknown[];
/**
* Legacy fields - kept for backward compatibility
*/

View File

@@ -36,6 +36,13 @@ export class ClaimVehicleSnapshot {
@Prop({ type: ClaimPlateSchema })
plate?: ClaimPlate;
@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);

View File

@@ -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.

View File

@@ -0,0 +1,87 @@
import {
actualPremiumFromHullPolicyRecord,
customerIdFromHullPolicyRecord,
FANAVARAN_DEFAULT_HULL_ACCIDENT_TYPE_ID,
fanavaranHullNestedClaimId,
toFanavaranHullBaseClaimPayload,
} from "./fanavaran-hull-base-claim";
describe("fanavaran hull base claim", () => {
it("reads ActualPremium and CustomerId from hull policy GET", () => {
expect(
actualPremiumFromHullPolicyRecord({ TotalPremium: 21613552 }),
).toBe(21613552);
expect(customerIdFromHullPolicyRecord({ CustomerId: 1230091 })).toBe(
1230091,
);
});
it("defaults CustomerFaultPercent to 100 when CulpritTypeId is 300", () => {
const hull = toFanavaranHullBaseClaimPayload({
PolicyId: 1,
CulpritTypeId: 300,
});
expect(hull.CustomerFaultPercent).toBe(100);
});
it("uses GEN.03 Id for nested files/expertise, not ClaimNo", () => {
expect(
fanavaranHullNestedClaimId({ Id: 5023617, ClaimNo: 2268 }),
).toBe(5023617);
expect(
fanavaranHullNestedClaimId({ claimId: 5023617, claimNo: 2268 }),
).toBe(5023617);
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,
AccidentCityId: 701,
AccidentDate: "1405/06/24",
AccidentLocationAddress: "استان تهران شهر تهران",
AccidentReportTypeId: 155,
AccidentTime: "10:33",
AccidentVehicleUsedId: 1,
ActualPremium: 57270462,
AnnouncementDate: "1405/06/24",
ClaimExpertId: 154,
ClaimNo: 2268,
CompensationReferenceId: 167,
CulpritLicenceNo: "9705463515",
CulpritTypeId: 337,
CustomerFaultPercent: 100,
DamagedCount: 1,
EstimateAmount: 16000000,
HasOtherCulprit: 0,
Id: 5023617,
IsFatalAccident: 0,
IsLicenseReplacement: null,
IsPlaqueChanged: 0,
PolicyId: 13764408,
PreviousPolicyEndDate: "1404/10/23",
SanhabVersion: 6,
});
expect(hull.PolicyId).toBe(13764408);
expect(hull.AccidentTypeId).toBe(FANAVARAN_DEFAULT_HULL_ACCIDENT_TYPE_ID);
expect(hull.IsLicenseReplaced).toBeNull();
expect(hull.CostSeparationToDmgSections).toBe(0);
expect(hull.IsOwnerChanged).toBe(0);
expect(hull.CulpritTypeId).toBe(300);
expect(hull.DamagedCount).toBeUndefined();
expect(hull.HasOtherCulprit).toBeUndefined();
expect(hull.CompensationReferenceId).toBeUndefined();
expect(hull.SanhabVersion).toBeUndefined();
expect(hull.Id).toBeUndefined();
expect(hull.ClaimNo).toBeUndefined();
});
});

View File

@@ -0,0 +1,184 @@
/** GEN.03 VHUD input fields. Do not send ثالث-only keys (DamagedCount, HasOtherCulprit, plaques, …). */
export const FANAVARAN_HULL_BASE_CLAIM_KEYS = [
"ArchiveNo",
"AccidentDate",
"AccidentTime",
"AnnouncementDate",
"AccidentLocationAddress",
"ClaimExpertId",
"EntryDate",
"CulpritLicenceNo",
"CulpritLicenceIssuDate",
"CulpritLicenceForeignCityName",
"PoliceReportSeri",
"PoliceReportSerial",
"PoliceReportDesc",
"CustomerFaultPercent",
"CulpritLevelTwoLicenceIssuDate",
"ActualPremium",
"EstimateAmount",
"TrackingCode",
"CourtArchiveNo",
"PlaqueReplacementDate",
"StatusChangeDate",
"ClaimCompletionDate",
"DmgAssessorFirstCreationTime",
"PolicyId",
"IsSurplusArticleEighthLaw",
"AccidentCityId",
"AccidentCauseId",
"AccidentTypeId",
"GlassBreakReasonId",
"CulpritTypeId",
"AuthorityCulpritId",
"AccidentCulpritId",
"CulpritLicenceTypeId",
"IsLicenseReplaced",
"CulpritLicenceCountryId",
"CulpritLicenceCityId",
"AccidentReportTypeId",
"PoliceOfficerId",
"IsAccidentOutOfBorder",
"AccidentVehicleUsedId",
"IsOwnerChanged",
"CostSeparationToDmgSections",
] as const;
/** GEN.03 default: تصادف(حادثه). Lookup: GET /lookups/vehicle-hull-accident-types */
export const FANAVARAN_DEFAULT_HULL_ACCIDENT_TYPE_ID = 2;
/** GEN.03 ans (0=خیر). Used for CostSeparationToDmgSections, IsOwnerChanged, IsLicenseReplaced. */
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 = 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(
policy: Record<string, unknown> | null | undefined,
): number | null {
if (!policy) return null;
const raw = policy.TotalPremium ?? policy.totalPremium;
const n = typeof raw === "number" ? raw : Number(raw);
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,
): number | null {
if (!policy) return null;
const raw = policy.CustomerId ?? policy.customerId;
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isFinite(n) && n > 0 ? n : null;
}
export function toFanavaranHullBaseClaimPayload(
built: Record<string, unknown>,
): Record<string, unknown> {
const next: Record<string, unknown> = {};
for (const key of FANAVARAN_HULL_BASE_CLAIM_KEYS) {
if (key === "IsLicenseReplaced") {
// GEN.03: must be empty/null. Do not map ثالث IsLicenseReplacement (default 0).
next[key] = null;
continue;
}
if (key === "AccidentTypeId") {
next[key] =
built.AccidentTypeId ?? FANAVARAN_DEFAULT_HULL_ACCIDENT_TYPE_ID;
continue;
}
if (key === "CostSeparationToDmgSections") {
next[key] =
built.CostSeparationToDmgSections ?? FANAVARAN_HULL_ANS_NO;
continue;
}
if (key === "IsOwnerChanged") {
next[key] = built.IsOwnerChanged ?? FANAVARAN_HULL_ANS_NO;
continue;
}
if (key === "CulpritTypeId") {
const raw = built.CulpritTypeId;
// ثالث profile default 337 is not in vehicle-hull-accident-culprit-type.
if (raw == null || raw === 337) {
next[key] = FANAVARAN_DEFAULT_HULL_CULPRIT_TYPE_ID;
} else {
next[key] = raw;
}
continue;
}
if (key === "CustomerFaultPercent") {
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)
? built[key]
: null;
}
return next;
}
/** Nested hull files/expertise use GEN.03 `Id` (کد رایانه), never `ClaimNo` (شماره پرونده). */
export function fanavaranHullNestedClaimId(input: {
Id?: unknown;
ClaimNo?: unknown;
claimId?: unknown;
claimNo?: unknown;
}): number | null {
const id = input.claimId ?? input.Id;
if (typeof id === "number" && Number.isFinite(id) && id > 0) return id;
if (typeof id === "string" && /^\d+$/.test(id.trim())) return Number(id);
return null;
}

View File

@@ -0,0 +1,78 @@
import { parseFanavaranId } from "src/lookups/fanavaran-last-car-policy";
export const FANAVARAN_HULL_DMG_KIND_CAPTION = "تخریب";
export const FANAVARAN_HULL_DMG_COST_KIND_CAPTION = "مجموع لوازم";
/** Fallback when lookup fetch fails (Parsian `vehicle-hull-dmg-kind`). */
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;
AccessoryKindId?: unknown;
AccessoryDesc?: unknown;
};
export function asLookupRows(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) return [];
return value.filter(
(row): row is Record<string, unknown> =>
!!row && typeof row === "object" && !Array.isArray(row),
);
}
export function asHullDmgAccessoryRows(value: unknown): FanavaranHullDmgAccessoryRow[] {
return asLookupRows(value) as FanavaranHullDmgAccessoryRow[];
}
export function findLookupIdByCaption(
rows: unknown,
caption: string,
): number | null {
const target = caption.trim();
for (const row of asLookupRows(rows)) {
if (String(row.Caption ?? "").trim() !== target) continue;
const id = parseFanavaranId(row.Id);
if (id != null) return id;
}
return null;
}
function isFactoryDefaultAccessoryRow(row: FanavaranHullDmgAccessoryRow): boolean {
const desc = String(row.AccessoryDesc ?? "").trim();
return (
desc.includes("کليه قطعات فابريک") ||
desc.includes("کلیه قطعات فابریک") ||
desc.includes("کليه قطعات") ||
desc.includes("فابريک")
);
}
/**
* GEN.06 VehicleHullAccessoryId from policy dmg-accessories.
* 1) Match AccessoryKindId to car-components part id.
* 2) Else factory bundle row (app default outer parts map here on Parsian).
*/
export function resolveVehicleHullAccessoryId(
accessories: FanavaranHullDmgAccessoryRow[],
accessoryKindId: number | null,
): number | null {
if (accessories.length === 0) return null;
if (accessoryKindId != null) {
const exact = accessories.find(
(row) => parseFanavaranId(row.AccessoryKindId) === accessoryKindId,
);
const exactId = parseFanavaranId(exact?.Id);
if (exactId != null) return exactId;
}
const factory = accessories.find(isFactoryDefaultAccessoryRow);
const factoryId = parseFanavaranId(factory?.Id);
if (factoryId != null) return factoryId;
return parseFanavaranId(accessories[0]?.Id);
}

View File

@@ -5,9 +5,16 @@ import {
pickHullVehicleIdentity,
toFanavaranHullExpertiseDmgSection,
toFanavaranHullExpertisePayload,
vehicleCurrentValueFromHullPolicyRecord,
} from "./fanavaran-hull-expertise";
describe("fanavaran hull expertise", () => {
it("reads VehicleCurrentValue from hull policy VehicleValue", () => {
expect(
vehicleCurrentValueFromHullPolicyRecord({ VehicleValue: 11000000000 }),
).toBe(11000000000);
});
it("maps car-body inquiry identity onto GEN.06 vehicle fields", () => {
const vehicle = pickHullVehicleIdentity({
parties: [
@@ -71,6 +78,9 @@ describe("fanavaran hull expertise", () => {
desc: "bumper",
wasteValue: 0,
amount: 3000,
dmgKindId: 5485,
dmgCostKindId: 1,
vehicleHullAccessoryId: 3043330,
}),
],
});
@@ -89,10 +99,10 @@ describe("fanavaran hull expertise", () => {
Desc: "bumper",
WasteValue: 0,
AccessoryKindId: 9,
DmgKindId: null,
VehicleHullAccessoryId: null,
DmgKindId: 5485,
VehicleHullAccessoryId: 3043330,
DmgSectionCosts: [
{ Caption: "bumper", Amount: 3000, DmgCostKindId: null },
{ Caption: "bumper", Amount: 3000, DmgCostKindId: 1 },
],
},
]);
@@ -122,12 +132,18 @@ describe("fanavaran hull expertise", () => {
desc: "سپر جلو",
wasteValue: 0,
amount: 11000000,
dmgKindId: 5485,
dmgCostKindId: 1,
vehicleHullAccessoryId: 3043330,
}),
toFanavaranHullExpertiseDmgSection({
partId: 11,
desc: "آينه سمت راننده",
wasteValue: 0,
amount: 5000000,
dmgKindId: 5485,
dmgCostKindId: 1,
vehicleHullAccessoryId: 3043330,
}),
],
});

View File

@@ -13,6 +13,29 @@ export type FanavaranHullVehicleIdentity = {
builtYear: number | null;
};
export function mergeHullVehicleIdentityFromFanavaranVehicle(
base: FanavaranHullVehicleIdentity,
vehicle: Record<string, unknown> | null | undefined,
): FanavaranHullVehicleIdentity {
if (!vehicle) return base;
const readText = (key: string, fallback: string | null) => {
const raw = vehicle[key];
if (raw == null || String(raw).trim() === "") return fallback;
return String(raw).trim();
};
return {
motorNo: readText("MotorNo", base.motorNo),
chassisNo: readText("ChassisNo", base.chassisNo),
vin: readText("VIN", base.vin),
plaqueNo: readText("PlaqueNo", base.plaqueNo),
plaqueSerial: readText("PlaqueSerial", base.plaqueSerial),
builtYear:
parseFanavaranId(vehicle.BuiltYear) ??
parseFanavaranId(vehicle.builtYear) ??
base.builtYear,
};
}
export function pickHullVehicleIdentity(
blame?: { parties?: unknown[] } | null,
): FanavaranHullVehicleIdentity {
@@ -48,6 +71,27 @@ function text(value: unknown): string | null {
return next ? next : null;
}
export {
FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID,
FANAVARAN_DEFAULT_HULL_DMG_KIND_ID,
FANAVARAN_HULL_DMG_COST_KIND_CAPTION,
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,
): number | null {
if (!policy) return null;
const raw =
policy.VehicleValue ?? policy.vehicleValue ?? policy.VehicleCurrentValue;
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isFinite(n) && n > 0 ? n : null;
}
export function toFanavaranHullExpertisePayload(input: {
claimExpertId: number;
dmgAssessmentDate: string;
@@ -58,6 +102,8 @@ export function toFanavaranHullExpertisePayload(input: {
dropAmount: number;
vehicleCurrentValue: number | null;
vehicle: FanavaranHullVehicleIdentity;
colorId?: number | null;
repairDuration?: number | null;
dmgSections: Record<string, unknown>[];
}): Record<string, unknown> {
return {
@@ -74,7 +120,8 @@ export function toFanavaranHullExpertisePayload(input: {
ComponentReplacementCost: input.componentReplacementCost,
WasteValue: input.wasteValue,
CarryAndRescueCost: null,
RepairDuration: null,
RepairDuration:
input.repairDuration ?? FANAVARAN_DEFAULT_HULL_REPAIR_DURATION,
VehicleCurrentValue: input.vehicleCurrentValue,
WreckHighestValue: null,
InspectionDeduction: null,
@@ -82,7 +129,7 @@ export function toFanavaranHullExpertisePayload(input: {
WentDistanceByExpert: null,
IsDestruction: null,
DropAmount: input.dropAmount,
ColorId: null,
ColorId: input.colorId ?? null,
PlaqueDesignId: null,
PlaqueCityId: null,
AccidentPercent: null,
@@ -96,19 +143,22 @@ export function toFanavaranHullExpertiseDmgSection(input: {
desc: string;
wasteValue: number;
amount: number;
dmgKindId: number;
dmgCostKindId: number;
vehicleHullAccessoryId: number | null;
}): Record<string, unknown> {
return {
Count: 1,
Desc: input.desc,
WasteValue: input.wasteValue,
AccessoryKindId: parseFanavaranId(input.partId),
DmgKindId: null,
VehicleHullAccessoryId: null,
DmgKindId: input.dmgKindId,
VehicleHullAccessoryId: input.vehicleHullAccessoryId,
DmgSectionCosts: [
{
Caption: input.desc,
Amount: input.amount,
DmgCostKindId: null,
DmgCostKindId: input.dmgCostKindId,
},
],
};

View File

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

View File

@@ -0,0 +1,101 @@
import {
getInquiryErrorMessage,
isInquiryFailurePayload,
} from "./inquiry-error";
describe("inquiry error messages", () => {
it("preserves a Persian ESG not-found response", () => {
expect(
getInquiryErrorMessage(
{ success: false, message: "موردی یافت نشد" },
"thirdPartyPlate",
),
).toBe("موردی یافت نشد");
});
it.each([
["RECORD_NOT_FOUND", "رکوردی یافت نشد", "Provider request failed"],
[
"INQUIRY_NO_MATCH",
"نتیجه‌ای مطابق با اطلاعات وارد شده یافت نشد",
"Inquiry returned no matching result",
],
])(
"prefers ESG messageFa for %s over the technical message",
(code, messageFa, message) => {
expect(
getInquiryErrorMessage(
{
error: {
code,
message,
messageFa,
providerMessage: message,
providerCode: code,
},
attemptSummary: {
attempts: [{ code, message, messageFa }],
},
},
"thirdPartyPlate",
),
).toBe(messageFa);
},
);
it("finds messageFa inside an HTTP response envelope", () => {
expect(
getInquiryErrorMessage(
{
response: {
status: 404,
data: {
error: {
code: "RECORD_NOT_FOUND",
message: "Provider request failed",
messageFa: "رکوردی یافت نشد",
},
},
},
},
"thirdPartyPlate",
),
).toBe("رکوردی یافت نشد");
});
it("distinguishes VIN and car-body not-found failures", () => {
expect(getInquiryErrorMessage({ status: 404 }, "thirdPartyVin")).toContain(
"شماره شاسی (VIN)",
);
expect(
getInquiryErrorMessage(
new Error("No active policy found"),
"carBodyPlate",
),
).toBe("بیمه‌نامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.");
});
it("preserves a specific Persian provider message", () => {
expect(
getInquiryErrorMessage(
{ message: "کد ملی واردشده صحیح نیست" },
"personalIdentity",
),
).toBe("کد ملی واردشده صحیح نیست");
});
it("does not expose transport or authentication details", () => {
expect(
getInquiryErrorMessage(
{ response: { status: 401 }, message: "ESG authentication failed" },
"thirdPartyPlate",
),
).toBe("سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.");
});
it("recognizes provider failure envelopes", () => {
expect(isInquiryFailurePayload({ success: false })).toBe(true);
expect(isInquiryFailurePayload({ HasError: true })).toBe(true);
expect(isInquiryFailurePayload({ success: true, data: {} })).toBe(false);
});
});

View File

@@ -0,0 +1,247 @@
export type InquiryErrorContext =
| "thirdPartyPlate"
| "thirdPartyVin"
| "carBodyPlate"
| "carBodyVin"
| "personalIdentity"
| "drivingLicense"
| "carOwnership"
| "sheba"
| "generic";
type UnknownRecord = Record<string, unknown>;
const NOT_FOUND_MESSAGES: Record<InquiryErrorContext, string> = {
thirdPartyPlate: "بیمه‌نامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.",
thirdPartyVin:
"بیمه‌نامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
carBodyPlate: "بیمه‌نامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.",
carBodyVin:
"بیمه‌نامه بدنه فعالی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
personalIdentity: "اطلاعات هویتی مطابق کد ملی و تاریخ تولد واردشده یافت نشد.",
drivingLicense:
"گواهینامه‌ای مطابق کد ملی و شماره گواهینامه واردشده یافت نشد.",
carOwnership: "مالکیتی مطابق پلاک و کد ملی واردشده یافت نشد.",
sheba: "اطلاعاتی مطابق شماره شبا و کد ملی واردشده یافت نشد.",
generic: "موردی مطابق اطلاعات واردشده یافت نشد.",
};
const INVALID_MESSAGES: Record<InquiryErrorContext, string> = {
thirdPartyPlate: "پلاک یا کد ملی واردشده برای استعلام شخص ثالث معتبر نیست.",
thirdPartyVin:
"شماره شاسی (VIN) یا کد ملی واردشده برای استعلام شخص ثالث معتبر نیست.",
carBodyPlate: "پلاک یا کد ملی واردشده برای استعلام بیمه بدنه معتبر نیست.",
carBodyVin:
"شماره شاسی (VIN) یا کد ملی واردشده برای استعلام بیمه بدنه معتبر نیست.",
personalIdentity: "کد ملی یا تاریخ تولد واردشده معتبر نیست.",
drivingLicense: "کد ملی یا شماره گواهینامه واردشده معتبر نیست.",
carOwnership: "پلاک یا کد ملی واردشده برای استعلام مالکیت معتبر نیست.",
sheba: "شماره شبا یا کد ملی واردشده معتبر نیست.",
generic: "اطلاعات ارسال‌شده برای استعلام معتبر نیست.",
};
const asRecord = (value: unknown): UnknownRecord | undefined =>
value && typeof value === "object" && !Array.isArray(value)
? (value as UnknownRecord)
: undefined;
const cleanMessage = (value: unknown): string => {
if (typeof value === "string") return value.trim();
if (Array.isArray(value)) {
return value
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean)
.join("، ");
}
return "";
};
function errorRecords(error: unknown): UnknownRecord[] {
const root = asRecord(error);
if (!root) return [];
const response = asRecord(root.response);
const responseData = asRecord(response?.data);
const data = asRecord(root.data);
const nestedError = asRecord(root.Error) ?? asRecord(root.error);
const responseError =
asRecord(responseData?.Error) ?? asRecord(responseData?.error);
return [root, responseData, data, nestedError, responseError].filter(
(item): item is UnknownRecord => !!item,
);
}
export function inquiryErrorStatus(error: unknown): number | undefined {
for (const record of errorRecords(error)) {
const response = asRecord(record.response);
const value =
record.statusCode ?? record.status ?? response?.status ?? record.code;
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed >= 100 && parsed <= 599) {
return parsed;
}
}
return undefined;
}
export function extractInquiryProviderMessage(error: unknown): string {
const records = errorRecords(error);
// The normalized ESG/Parsian envelope carries the safe user-facing text in
// messageFa while `message` and `providerMessage` may remain technical.
// Search every envelope level for that explicit Persian field before
// considering generic message fields on an outer object.
for (const record of records) {
for (const key of [
"messageFa",
"MessageFa",
"messageFA",
"persianMessage",
] as const) {
const message = cleanMessage(record[key]);
if (message) return message;
}
}
for (const record of records) {
for (const key of ["message", "Message", "detail", "title"] as const) {
const message = cleanMessage(record[key]);
if (message) return message;
}
const scalarData = cleanMessage(record.data);
if (scalarData) return scalarData;
}
return error instanceof Error ? error.message.trim() : cleanMessage(error);
}
export function isInquiryFailurePayload(value: unknown): boolean {
const root = asRecord(value);
if (!root) return false;
return (
root.success === false ||
root.isSuccess === false ||
root.IsSuccess === false ||
root.IsSucceed === false ||
root.ReturnValue === false ||
root.HasError === true ||
root.hasError === true ||
root.Error != null ||
root.error != null
);
}
export function isInquiryTimeout(error: unknown): boolean {
const root = asRecord(error);
const code = String(root?.code ?? "").toUpperCase();
const message = extractInquiryProviderMessage(error);
return (
["ECONNABORTED", "ETIMEDOUT", "ESOCKETTIMEDOUT"].includes(code) ||
/timeout|timed out|مهلت|زمان.*پایان/i.test(message)
);
}
const hasPersian = (value: string): boolean => /[\u0600-\u06ff]/.test(value);
const isNotFound = (error: unknown, message: string): boolean => {
const codes = errorRecords(error).flatMap((record) =>
[record.code, record.providerCode]
.map((code) => String(code ?? "").toUpperCase())
.filter(Boolean),
);
return (
inquiryErrorStatus(error) === 404 ||
codes.some((code) =>
[
"NOT_FOUND",
"POLICY_NOT_FOUND",
"NO_POLICY",
"RECORD_NOT_FOUND",
"INQUIRY_NO_MATCH",
].includes(code),
) ||
/\bnot[ -]?found\b|\bno (?:active |relevant )?(?:record|policy|item)\b|record\.not\.found|موردی یافت نشد|یافت نشد|پیدا نشد|فاقد بیمه(?:‌| )?نامه/i.test(
message,
)
);
};
const isInvalidInput = (message: string): boolean =>
/invalid|malformed|required|must contain|bad request|نامعتبر|الزامی|وارد نشده|صحیح نیست/i.test(
message,
);
const isUnavailable = (error: unknown, message: string): boolean => {
const root = asRecord(error);
const code = String(root?.code ?? "").toUpperCase();
const status = inquiryErrorStatus(error);
return (
isInquiryTimeout(error) ||
["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ERR_NETWORK"].includes(code) ||
status === 401 ||
status === 502 ||
status === 503 ||
status === 504 ||
/offline|unavailable|connection|socket|network|authentication|credentials|empty response|در دسترس نیست|عدم دسترسی/i.test(
message,
)
);
};
/**
* Converts provider and transport failures into a stable, user-facing Persian
* message. Provider details remain in server logs; raw English or technical
* messages are never returned to clients.
*/
export function getInquiryErrorMessage(
error: unknown,
context: InquiryErrorContext = "generic",
): string {
const providerMessage = extractInquiryProviderMessage(error);
// Persian text supplied by the provider is already the intended client
// message. Preserve it verbatim instead of replacing it with a local
// contextual fallback such as "inquiry not found".
if (providerMessage && hasPersian(providerMessage)) return providerMessage;
if (isNotFound(error, providerMessage)) return NOT_FOUND_MESSAGES[context];
if (
context === "carOwnership" &&
/not the owner|مالک.*نیست/i.test(providerMessage)
) {
return "پلاک واردشده متعلق به کد ملی واردشده نیست.";
}
if (
context === "sheba" &&
/does not match|تطابق ندارد/i.test(providerMessage)
) {
return "شماره شبا متعلق به کد ملی واردشده نیست.";
}
if (
context === "drivingLicense" &&
/not valid|نامعتبر/i.test(providerMessage)
) {
return "گواهینامه واردشده معتبر نیست.";
}
if (
/policy insurer does not match|بیمه.*متعلق.*نیست/i.test(providerMessage)
) {
return "بیمه‌نامه یافت‌شده متعلق به شرکت بیمه این سامانه نیست.";
}
if (isInquiryTimeout(error)) {
return "زمان پاسخ‌گویی سرویس استعلام به پایان رسید. لطفاً دوباره تلاش کنید.";
}
if (isUnavailable(error, providerMessage)) {
return "سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.";
}
if (isInvalidInput(providerMessage) || inquiryErrorStatus(error) === 422) {
return INVALID_MESSAGES[context];
}
return "انجام استعلام با خطا مواجه شد. لطفاً دوباره تلاش کنید.";
}

View File

@@ -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.

View File

@@ -57,6 +57,8 @@ export interface FanavaranPayloadDefaults {
* (Parsian: 29; Tejaratno proven: 2709).
*/
ExpertiseClaimExpertId: number;
/** GEN.06 hull RepairDuration when configured per tenant (optional). */
HullExpertiseRepairDuration?: number | null;
CompensationReferenceId: number;
CulpritLicenceTypeId: number;
CulpritTypeId: number;

View File

@@ -0,0 +1,66 @@
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ExpertBlameService } from "./expert-blame.service";
describe("ExpertBlameService participant detail contract", () => {
it("returns normalized participants and their role assignments from the blame party", async () => {
const service = new (ExpertBlameService as any)(
...new Array(13).fill(undefined),
) as any;
const expertId = "66ec0e480e321873c0900001";
service.expireBlameCaseWorkflowLockV2IfStale = jest
.fn()
.mockResolvedValue(undefined);
service.blameRequestDbService = {
findByIdWithoutHistory: jest.fn().mockResolvedValue({
_id: "66ec0e480e321873c0900002",
type: BlameRequestType.THIRD_PARTY,
status: CaseStatus.WAITING_FOR_EXPERT,
expertInitiated: true,
initiatedByFieldExpertId: expertId,
workflow: {},
parties: [
{
role: "FIRST",
person: { fullName: "Legacy Party Name" },
participants: [
{
participantId: "PERSON_1",
nationalCode: "0012345678",
birthday: "1370/01/01",
unknown: true,
},
],
participantRoles: {
driver: "PERSON_1",
vehicleOwner: "PERSON_1",
thirdPartyPolicyholder: "PERSON_1",
},
vehicle: { inquiry: { raw: { large: true } } },
},
],
createdAt: new Date("2026-09-19T00:00:00.000Z"),
updatedAt: new Date("2026-09-19T00:00:00.000Z"),
}),
};
const result = await service.findOneV2("blame-1", { sub: expertId });
const party = (result.parties as any[])[0];
expect(party.participants).toEqual([
{
participantId: "PERSON_1",
nationalCode: "0012345678",
birthday: "1370/01/01",
},
]);
expect(party.participantRoles).toEqual({
driver: "PERSON_1",
vehicleOwner: "PERSON_1",
thirdPartyPolicyholder: "PERSON_1",
});
expect(party.person.fullName).toBe("Legacy Party Name");
expect(party.vehicle.inquiry).toBeUndefined();
});
});

View File

@@ -91,6 +91,7 @@ import {
ExpertFileActivityType,
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 (

View File

@@ -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`)",
@@ -131,6 +139,33 @@ export class ClaimDetailV2ResponseDto {
url?: string;
}>;
@ApiPropertyOptional({
description:
"Append-only expert damaged-part revisions. Removed rows retain their original capture URL even after the authoritative selection changes.",
type: "array",
items: {
type: "object",
properties: {
revisionId: { type: "string" },
changedAt: { type: "string", format: "date-time" },
changedBy: { type: "object" },
removedParts: { type: "array", items: { type: "object" } },
addedParts: { type: "array", items: { type: "object" } },
},
},
})
damagedPartsHistory?: Array<{
revisionId: string;
changedAt: Date | string;
changedBy: {
actorId: string;
actorName?: string;
actorType: string;
};
removedParts: Array<Record<string, unknown>>;
addedParts: Array<Record<string, unknown>>;
}>;
@ApiPropertyOptional({
description:
"True when user uploaded all required factors and the case awaits expert approve/reject.",
@@ -251,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>;

View File

@@ -90,4 +90,21 @@ describe("SubmitExpertReplyV2Dto", () => {
expect(await validate(dto)).toHaveLength(0);
});
it("accepts a grouped Persian current car price as a string", async () => {
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
carPrice: "۱٬۲۵۰٬۰۰۰٬۰۰۰",
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
salary: "1000000",
totalPayment: "1000000",
factorNeeded: false,
},
],
});
expect(await validate(dto)).toHaveLength(0);
});
});

View File

@@ -121,6 +121,15 @@ export class SubmitExpertReplyV2Dto {
@IsString()
description?: string;
@ApiPropertyOptional({
example: '1000000000',
description:
"Current vehicle price in Rial. Required for car-body claims and forbidden for third-party claims. This is independent from evaluation.priceDrop.carPrice.",
})
@IsOptional()
@IsMoneyAmountString()
carPrice?: string;
@ApiProperty({
type: [PartPricingV2Dto],
description:

View File

@@ -1,10 +1,14 @@
import { BadRequestException } from "@nestjs/common";
import { Types } from "mongoose";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { ExpertClaimService } from "./expert-claim.service";
const V2_EXPERT_ID = "66ec0e480e321873c0900001";
const blankPricingReply = {
description: "Damage assessment",
parts: [
@@ -26,7 +30,227 @@ function createService() {
) as ExpertClaimService;
}
function setupSuccessfulV2Submit(blameType: BlameRequestType) {
const service = createService() as any;
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
const claim = {
_id: "66ec0e480e321873c0900002",
publicId: "CLM-1",
blameRequestId: "66ec0e480e321873c0900003",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: V2_EXPERT_ID } },
vehicle: { carType: "sedan" },
damage: {
selectedParts: [
{
id: 1,
name: "front",
side: "",
label_fa: "جلو کامل",
catalogKey: "1",
},
],
},
};
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue(claim),
findByIdAndUpdate,
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({
type: blameType,
expertInitiated: true,
creationMethod: "IN_PERSON",
}),
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
service.recordClaimExpertActivity = jest.fn().mockResolvedValue(undefined);
return { service, findByIdAndUpdate };
}
const validV2Reply = {
parts: [
{
partId: 1,
typeOfDamage: TypeOfDamage.Repair,
salary: "1000000",
totalPayment: "1000000",
factorNeeded: false,
},
],
};
describe("ExpertClaimService expert-reply pricing", () => {
it("requires the current car price for a car-body expert reply", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.CAR_BODY,
);
await expect(
service.submitExpertReplyV2("v2-claim", validV2Reply, {
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
}),
).rejects.toMatchObject({
response: {
code: "CAR_PRICE_REQUIRED",
field: "carPrice",
},
});
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
it("normalizes and persists the current car price on car-body replies", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.CAR_BODY,
);
await service.submitExpertReplyV2(
"v2-claim",
{ ...validV2Reply, carPrice: "۱٬۲۵۰٬۰۰۰٬۰۰۰" },
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
);
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
expect(findByIdAndUpdate.mock.calls[0][1]).toMatchObject({
"vehicle.price": "1250000000",
"vehicle.carPrice": 1250000000,
});
});
it("rejects the current car price on third-party replies", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
await expect(
service.submitExpertReplyV2(
"v2-claim",
{ ...validV2Reply, carPrice: "1250000000" },
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
),
).rejects.toMatchObject({
response: {
code: "CAR_PRICE_NOT_ALLOWED",
field: "carPrice",
},
});
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
it("does not overwrite vehicle.price on third-party replies", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
await service.submitExpertReplyV2("v2-claim", validV2Reply, {
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
});
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
expect(findByIdAndUpdate.mock.calls[0][1]).not.toHaveProperty(
"vehicle.price",
);
});
it("enforces the configured total-payment cap for a V1 user-created file", async () => {
const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true";
try {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
(service as any).blameRequestDbService.findById.mockResolvedValue({
type: BlameRequestType.THIRD_PARTY,
creationMethod: "NORMAL",
});
await expect(
service.submitExpertReplyV2(
"v1-claim",
{
...validV2Reply,
parts: [
{
...validV2Reply.parts[0],
salary: "1000000",
totalPayment: "600000000",
},
],
},
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
),
).rejects.toMatchObject({ response: { code: "PRICE_CAP_ERROR" } });
expect(findByIdAndUpdate).not.toHaveBeenCalled();
} finally {
if (previousEnabled === undefined) {
delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
} else {
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled;
}
}
});
it("does not enforce the V1 cap for an expert-initiated flow", async () => {
const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true";
try {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
await service.submitExpertReplyV2(
"v3-claim",
{
...validV2Reply,
parts: [
{
...validV2Reply.parts[0],
salary: "1000000",
totalPayment: "600000000",
},
],
},
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
);
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
} finally {
if (previousEnabled === undefined) {
delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
} else {
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled;
}
}
});
it("allows a repair line without daghi and removes a stray daghi payload", () => {
const service = createService() as any;
@@ -105,18 +329,27 @@ describe("ExpertClaimService expert-reply pricing", () => {
const findByIdAndUpdate = jest.fn();
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
blameRequestId: "blame-1",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
}),
findByIdAndUpdate,
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
await expect(
service.submitExpertReplyV2("v2-claim", blankPricingReply, {
sub: "expert-1",
role: RoleEnum.FIELD_EXPERT,
}),
service.submitExpertReplyV2(
"v2-claim",
{ ...blankPricingReply, carPrice: "1000000" },
{
sub: "expert-1",
role: RoleEnum.FIELD_EXPERT,
},
),
).rejects.toBeInstanceOf(BadRequestException);
expect(findByIdAndUpdate).not.toHaveBeenCalled();
@@ -127,18 +360,24 @@ describe("ExpertClaimService expert-reply pricing", () => {
const findByIdAndUpdate = jest.fn();
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
blameRequestId: "blame-1",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
}),
findByIdAndUpdate,
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
await expect(
service.submitExpertReplyV2(
"v2-claim",
{
description: "Damage assessment",
carPrice: "1000000",
parts: [
{
partId: 201,
@@ -163,3 +402,160 @@ describe("ExpertClaimService expert-reply pricing", () => {
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
});
describe("ExpertClaimService damaged-part audit", () => {
it("archives removed capture evidence before replacing the live arrays", async () => {
const service = createService() as any;
const expertId = "66ec0e480e321873c0900001";
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
_id: "66ec0e480e321873c0900002",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: expertId } },
vehicle: { carType: "SEDAN" },
damage: {
selectedParts: [
{
id: 101,
name: "hood",
side: "front",
label_fa: "کاپوت",
catalogKey: "front_hood",
},
{
id: 202,
name: "door",
side: "left",
label_fa: "درب چپ",
catalogKey: "left_door",
},
],
},
media: {
damagedParts: [
{ path: "claims/hood.jpg", fileName: "hood.jpg" },
{ path: "claims/door.jpg", fileName: "door.jpg" },
],
},
}),
findByIdAndUpdate,
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue({
firstName: "Expert",
});
await service.updateClaimDamagedPartsV2(
"66ec0e480e321873c0900002",
{
selectedParts: [
{
id: 202,
name: "door",
side: "left",
label_fa: "درب چپ",
catalogKey: "left_door",
},
],
},
{
sub: expertId,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
);
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
const update = findByIdAndUpdate.mock.calls[0][1];
expect(update.$set["media.damagedParts"]).toEqual([
expect.objectContaining({ path: "claims/door.jpg" }),
]);
expect(update.$push["damage.partSelectionHistory"]).toEqual(
expect.objectContaining({
changedBy: expect.objectContaining({ actorId: expertId }),
removedParts: [
expect.objectContaining({
id: 101,
capture: expect.objectContaining({ path: "claims/hood.jpg" }),
}),
],
}),
);
});
});
describe("ExpertClaimService FileReviewer assignment", () => {
it("lets the assigned reviewer reopen a file while it is still awaiting FileReviewer work", async () => {
const service = createService() as any;
const reviewerId = new Types.ObjectId();
const clientId = new Types.ObjectId();
const blameId = new Types.ObjectId();
const claimId = new Types.ObjectId();
service.expireClaimWorkflowLockV2IfStale = jest.fn().mockResolvedValue(undefined);
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
_id: claimId,
blameRequestId: blameId,
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
}),
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({
_id: blameId,
type: BlameRequestType.CAR_BODY,
status: "WAITING_FOR_FILE_REVIEWER",
assignedFileReviewerId: reviewerId,
parties: [
{
role: "FIRST",
person: { clientId },
},
],
}),
};
service.fanavaranLocationService = {
assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined),
};
service.assertExpertActorOnClaim = jest.fn();
await expect(
service.assignClaimForReviewV2(String(claimId), {
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
}),
).resolves.toMatchObject({
success: true,
status: "already_assigned_to_you",
});
expect(service.assertExpertActorOnClaim).not.toHaveBeenCalled();
});
});
describe("ExpertClaimService branch-scoped claim views", () => {
it("shows branch-scoped claims only to damage experts in that branch", async () => {
const service = createService() as any;
const branchId = new Types.ObjectId();
const otherBranchId = new Types.ObjectId();
service.damageExpertDbService = {
findById: jest.fn().mockResolvedValue({ branchId }),
};
service.blameRequestDbService = {
find: jest.fn().mockResolvedValue([]),
};
const inBranch = { _id: "in-branch", branchId };
const otherBranch = { _id: "other-branch", branchId: otherBranchId };
const legacyUnscoped = { _id: "legacy-unscoped" };
await expect(
service.filterDamageExpertClaimsByBranch(
[inBranch, otherBranch, legacyUnscoped],
{ sub: V2_EXPERT_ID, role: RoleEnum.DAMAGE_EXPERT },
),
).resolves.toEqual([inBranch, legacyUnscoped]);
});
});

View File

@@ -143,6 +143,10 @@ import {
} from "src/helpers/outer-damage-parts";
import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import {
buildDamagedPartSelectionRevision,
serializeDamagedPartSelectionHistory,
} from "src/helpers/claim-damaged-part-audit";
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
@@ -152,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 {
@@ -172,6 +176,11 @@ import {
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing";
import {
normalizeMoneyAmountString,
parseMoneyAmountToman,
} from "src/utils/unicode-digits";
import { claimPriceCapAppliesToBlame } from "src/helpers/claim-price-cap";
@Injectable()
export class ExpertClaimService {
@@ -409,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(
@@ -1686,8 +1826,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;
@@ -1719,7 +1864,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,
@@ -2285,7 +2430,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(
@@ -2425,7 +2571,12 @@ export class ExpertClaimService {
};
}
const priceCap = getClaimV2TotalPaymentCapToman();
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
const priceCap =
configuredPriceCap !== null &&
claimPriceCapAppliesToBlame(await this.loadBlameForClaim(claim))
? configuredPriceCap
: null;
if (priceCap !== null) {
let totalPrice = 0;
for (const part of updatedReply.parts || []) {
@@ -2440,7 +2591,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,
@@ -2580,6 +2731,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 ||
@@ -2712,6 +2872,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;
@@ -2719,9 +2888,14 @@ export class ExpertClaimService {
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
if (assignedReviewerId && assignedReviewerId === actor.sub) {
// Reviewer already assigned (e.g. after a FileMaker rejection that reset
// blame back to WAITING_FOR_FILE_REVIEWER) — skip Phase 1 and fall
// through to the damage-expert workflow lock below.
// Phase 1 is intentionally idempotent. The linked claim can still be
// in a data-capture status here, so falling through to the damage
// assessment status gate would incorrectly reject the same reviewer.
return {
success: true,
status: "already_assigned_to_you",
message: "You have already taken this file.",
};
} else {
// Phase 1: first-time blame assignment
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
@@ -3246,7 +3420,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:
@@ -3263,7 +3437,6 @@ export class ExpertClaimService {
) {
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException(
this.expertReplySubmissionError(
@@ -3273,6 +3446,18 @@ export class ExpertClaimService {
);
}
const blame = await this.blameRequestDbService.findById(
claim.blameRequestId,
);
if (!blame) {
throw new NotFoundException(
this.expertReplySubmissionError(
"پرونده تعیین خسارت مرتبط یافت نشد.",
"BLAME_NOT_FOUND",
),
);
}
await this.assertExpertActorOnClaim(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
@@ -3303,6 +3488,36 @@ export class ExpertClaimService {
);
}
const carPriceWasProvided = reply.carPrice != null;
let normalizedCurrentCarPrice: string | undefined;
let parsedCurrentCarPrice: number | undefined;
if (blame.type === BlameRequestType.CAR_BODY) {
parsedCurrentCarPrice = parseMoneyAmountToman(reply.carPrice) ?? undefined;
if (
parsedCurrentCarPrice == null ||
!Number.isSafeInteger(parsedCurrentCarPrice) ||
parsedCurrentCarPrice <= 0
) {
throw new BadRequestException(
this.expertReplySubmissionError(
"قیمت روز خودرو در پرونده بدنه الزامی است و باید مبلغی صحیح و بیشتر از صفر باشد.",
"CAR_PRICE_REQUIRED",
{ field: "carPrice" },
),
);
}
normalizedCurrentCarPrice = normalizeMoneyAmountString(reply.carPrice!);
} else if (carPriceWasProvided) {
throw new ForbiddenException(
this.expertReplySubmissionError(
"قیمت روز خودرو فقط برای پرونده بدنه قابل ثبت است.",
"CAR_PRICE_NOT_ALLOWED",
{ field: "carPrice" },
),
);
}
const pricingValidationError = getExpertReplyPricingValidationError(
reply.parts,
{ locale: "fa", enforceAmountBounds: true },
@@ -3311,8 +3526,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 || []) {
@@ -3323,7 +3542,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,
@@ -3531,6 +3750,12 @@ export class ExpertClaimService {
"evaluation.ownerInsurerApproval": "",
"evaluation.ownerPricedPartsApproval": "",
},
...(normalizedCurrentCarPrice && parsedCurrentCarPrice != null
? {
"vehicle.price": normalizedCurrentCarPrice,
"vehicle.carPrice": parsedCurrentCarPrice,
}
: {}),
"workflow.currentStep": currentStep,
"workflow.nextStep": nextWorkflowStep,
[`evaluation.${replyField}`]: replyPayload,
@@ -4045,9 +4270,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(
@@ -4313,7 +4542,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[];
@@ -4322,9 +4551,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);
@@ -4402,10 +4653,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);
@@ -4844,6 +5110,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 =
@@ -5040,6 +5307,10 @@ export class ExpertClaimService {
buildFileLink,
resolveStoredFileUrl,
});
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
history: (claim.damage as any)?.partSelectionHistory,
currentSelectedParts: selectedNormExpert,
});
// Vehicle payload — fall back to blame inquiry if claim vehicle is sparse
let vehiclePayload = claim.vehicle as any;
@@ -5065,7 +5336,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([]),
]);
@@ -5100,6 +5375,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) {
@@ -5209,6 +5489,7 @@ export class ExpertClaimService {
? this.sanitizeVehicleInquiryForApi(vehiclePayload)
: undefined,
...blameFileContext,
priceCap,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
money: moneyPayload,
@@ -5219,6 +5500,7 @@ export class ExpertClaimService {
: undefined,
carAngles,
damagedParts,
damagedPartsHistory,
awaitingFactorValidation: isFactorValidationPending,
requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval,
fileMakerRejectionCount: (claim as any).fileMakerRejectionCount ?? 0,
@@ -5596,33 +5878,58 @@ export class ExpertClaimService {
);
const mergedExpertAdded = [...existingExpertAdded, ...expertAddedToAppend];
const changedAt = new Date();
const partSelectionRevision = buildDamagedPartSelectionRevision({
revisionId: new Types.ObjectId().toString(),
changedAt,
changedBy: {
actorId: actor.sub,
actorName: actor.fullName,
actorType: "damage_expert",
},
expertProfileSnapshot: damagedPartsEditSnapshot,
previousParts: previousNorm,
selectedParts: nextNorm,
previousMedia: prevMedia,
});
const $set: Record<string, unknown> = {
"damage.selectedParts": nextNorm,
"media.damagedParts": nextMedia,
"damage.expertAddedParts": mergedExpertAdded,
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set,
$push: {
history: {
type: "EXPERT_DAMAGED_PARTS_UPDATED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: new Date(),
metadata: {
previousSelectedParts: previous,
selectedParts: nextNorm,
expertAddedParts: mergedExpertAdded,
...(damagedPartsEditSnapshot && {
expertProfileSnapshot: damagedPartsEditSnapshot,
}),
},
const push: Record<string, unknown> = {
history: {
type: "EXPERT_DAMAGED_PARTS_UPDATED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: changedAt,
metadata: {
previousSelectedParts: previous,
selectedParts: nextNorm,
expertAddedParts: mergedExpertAdded,
...(partSelectionRevision && {
partSelectionRevisionId: partSelectionRevision.revisionId,
removedParts: partSelectionRevision.removedParts,
addedParts: partSelectionRevision.addedParts,
}),
...(damagedPartsEditSnapshot && {
expertProfileSnapshot: damagedPartsEditSnapshot,
}),
},
},
};
if (partSelectionRevision) {
push["damage.partSelectionHistory"] = partSelectionRevision;
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set,
$push: push,
});
return {
@@ -5630,6 +5937,7 @@ export class ExpertClaimService {
selectedParts: nextNorm,
previousSelectedParts: previous,
expertAddedParts: mergedExpertAdded,
partSelectionRevision,
message: "Damaged parts updated successfully.",
};
}

View File

@@ -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" +

View File

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

View File

@@ -85,6 +85,8 @@ import {
localizeTimelineMetadata,
} from "./helper/timeline-fa-labels";
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
import { serializeDamagedPartSelectionHistory } from "src/helpers/claim-damaged-part-audit";
import { fanavaranClaimReferences } from "src/claim-request-management/fanavaran-claim-references";
@Injectable()
export class ExpertInsurerService {
@@ -524,6 +526,8 @@ export class ExpertInsurerService {
const d = { ...(damage as Record<string, unknown>) };
delete d.selectedOuterParts;
delete d.selectedPartIds;
// Expose the stable, URL-enriched contract instead of raw audit storage.
delete d.partSelectionHistory;
return d;
}
@@ -677,6 +681,10 @@ export class ExpertInsurerService {
catalogLikeKeyFromPart,
buildFileLink,
});
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
history: (claim as any).damage?.partSelectionHistory,
currentSelectedParts: selectedNorm,
});
// Then add damagedParts to the return object
const requiredDocs = claim.requiredDocuments as any;
@@ -779,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,
@@ -797,6 +806,7 @@ export class ExpertInsurerService {
: undefined,
carAngles,
damagedParts,
damagedPartsHistory,
videoCapture,
evaluation: evaluationEnriched,
userRating: claim.userRating,

View File

@@ -0,0 +1,57 @@
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import {
assertFileMakerCanCreateBlameType,
assertFileReviewerCanReviewBlameType,
resolveFileMakerStageOneExpertId,
resolveFileReviewerExpertiseExpertId,
} from "./fanavaran-file-role-expert-ids";
describe("fanavaran-file-role-expert-ids", () => {
it("resolves stage-1 and expertise ids by blame type", () => {
expect(
resolveFileMakerStageOneExpertId(
{
ThirdPartyClaimExpertId: "154",
CarBodyClaimExpertId: "29",
},
BlameRequestType.THIRD_PARTY,
),
).toBe(154);
expect(
resolveFileMakerStageOneExpertId(
{
ThirdPartyClaimExpertId: "154",
CarBodyClaimExpertId: "29",
},
BlameRequestType.CAR_BODY,
),
).toBe(29);
expect(
resolveFileReviewerExpertiseExpertId(
{
ThirdPartyExpertiseClaim: "2709",
CarBodyExpertiseClaim: "15",
},
BlameRequestType.CAR_BODY,
),
).toBe(15);
});
it("blocks file maker create when the product code is missing", () => {
expect(() =>
assertFileMakerCanCreateBlameType(
{ ThirdPartyClaimExpertId: "154" },
BlameRequestType.CAR_BODY,
),
).toThrow(/stage-1 claim expert id/);
});
it("blocks file reviewer when the product code is missing", () => {
expect(() =>
assertFileReviewerCanReviewBlameType(
{ CarBodyExpertiseClaim: "15" },
BlameRequestType.THIRD_PARTY,
),
).toThrow(/expertise claim expert id/);
});
});

View File

@@ -0,0 +1,90 @@
import { BadRequestException } from "@nestjs/common";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { parseFanavaranId } from "src/lookups/fanavaran-last-car-policy";
export type FileMakerFanavaranExpertCodes = {
ThirdPartyClaimExpertId?: string;
CarBodyClaimExpertId?: string;
};
export type FileReviewerFanavaranExpertCodes = {
ThirdPartyExpertiseClaim?: string;
CarBodyExpertiseClaim?: string;
};
export function parseFanavaranExpertId(raw?: string | null): number | null {
if (raw == null) return null;
const trimmed = String(raw).trim();
if (!trimmed) return null;
return parseFanavaranId(trimmed) ?? parseFanavaranId(Number(trimmed));
}
export function fileMakerStageOneExpertCode(
fileMaker: FileMakerFanavaranExpertCodes,
blameType: BlameRequestType,
): string | undefined {
return blameType === BlameRequestType.CAR_BODY
? fileMaker.CarBodyClaimExpertId
: fileMaker.ThirdPartyClaimExpertId;
}
export function fileReviewerExpertiseExpertCode(
fileReviewer: FileReviewerFanavaranExpertCodes,
blameType: BlameRequestType,
): string | undefined {
return blameType === BlameRequestType.CAR_BODY
? fileReviewer.CarBodyExpertiseClaim
: fileReviewer.ThirdPartyExpertiseClaim;
}
function blameTypeLabel(blameType: BlameRequestType): string {
return blameType === BlameRequestType.CAR_BODY
? "car body (بدنه)"
: "third party (ثالث)";
}
export function assertFileMakerCanCreateBlameType(
fileMaker: FileMakerFanavaranExpertCodes,
blameType: BlameRequestType,
): void {
const raw = fileMakerStageOneExpertCode(fileMaker, blameType);
if (parseFanavaranExpertId(raw) != null) {
return;
}
throw new BadRequestException({
code: "FILE_MAKER_FANAVARAN_EXPERT_CODE_MISSING",
message: `Your FileMaker profile has no Fanavaran stage-1 claim expert id for ${blameTypeLabel(blameType)} files. You cannot create this file type.`,
});
}
export function assertFileReviewerCanReviewBlameType(
fileReviewer: FileReviewerFanavaranExpertCodes,
blameType: BlameRequestType,
): void {
const raw = fileReviewerExpertiseExpertCode(fileReviewer, blameType);
if (parseFanavaranExpertId(raw) != null) {
return;
}
throw new BadRequestException({
code: "FILE_REVIEWER_FANAVARAN_EXPERT_CODE_MISSING",
message: `Your FileReviewer profile has no Fanavaran expertise claim expert id for ${blameTypeLabel(blameType)} files. You cannot review this file type.`,
});
}
export function resolveFileMakerStageOneExpertId(
fileMaker: FileMakerFanavaranExpertCodes,
blameType: BlameRequestType,
): number | null {
return parseFanavaranExpertId(
fileMakerStageOneExpertCode(fileMaker, blameType),
);
}
export function resolveFileReviewerExpertiseExpertId(
fileReviewer: FileReviewerFanavaranExpertCodes,
blameType: BlameRequestType,
): number | null {
return parseFanavaranExpertId(
fileReviewerExpertiseExpertCode(fileReviewer, blameType),
);
}

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

View File

@@ -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",

View File

@@ -16,6 +16,31 @@ export const FANAVARAN_REMOTE_LOOKUPS: FanavaranRemoteLookupDefinition[] = [
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/accident-causes`,
cacheFile: "accident-causes.json",
},
{
name: "vehicle-hull-accident-types",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/vehicle-hull-accident-types`,
cacheFile: "vehicle-hull-accident-types.json",
},
{
name: "vehicle-hull-accident-culprit-type",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/vehicle-hull-accident-culprit-type`,
cacheFile: "vehicle-hull-accident-culprit-type.json",
},
{
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`,
cacheFile: "vehicle-hull-dmg-kind.json",
},
{
name: "vehicle-hull-dmg-cost-kinds",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/vehicle-hull-dmg-cost-kinds`,
cacheFile: "vehicle-hull-dmg-cost-kinds.json",
},
{
name: "accident-report-type",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/accident-report-type`,

View File

@@ -277,6 +277,16 @@ export class FanavaranLookupService {
return this.fetchFromFanavaran(clientKey, url, options);
}
/** GEN.06 VehicleHullAccessoryId — requires hull PolicyId (کد رایانه بیمه‌نامه). */
async vehicleHullDmgAccessoriesByPolicyId(
clientKey: FanavaranClientKey,
policyId: number,
options?: { contractIdOverride?: string },
): Promise<unknown> {
const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicle-hull-policies/${policyId}/dmg-accessories`;
return this.fetchFromFanavaran(clientKey, url, options);
}
async vehicleById(
clientKey: FanavaranClientKey,
vehicleId: number,

View File

@@ -0,0 +1,72 @@
import {
buildDamagedPartSelectionRevision,
serializeDamagedPartSelectionHistory,
} from "./claim-damaged-part-audit";
describe("damaged-part selection audit", () => {
const hood = {
id: 101,
name: "hood",
side: "front",
label_fa: "کاپوت",
catalogKey: "front_hood",
};
const door = {
id: 202,
name: "door",
side: "left",
label_fa: "درب چپ",
catalogKey: "left_door",
};
it("keeps the removed part capture and serializes it for panel display", () => {
const revision = buildDamagedPartSelectionRevision({
revisionId: "revision-1",
changedAt: new Date("2026-09-18T10:00:00.000Z"),
changedBy: {
actorId: "expert-1",
actorName: "Expert One",
actorType: "damage_expert",
},
previousParts: [hood, door],
selectedParts: [door],
previousMedia: [
{ path: "claims/hood.jpg", fileName: "hood.jpg" },
{ path: "claims/door.jpg", fileName: "door.jpg" },
],
});
expect(revision?.removedParts).toEqual([
expect.objectContaining({
id: 101,
capture: expect.objectContaining({ path: "claims/hood.jpg" }),
}),
]);
const serialized = serializeDamagedPartSelectionHistory({
history: [revision],
currentSelectedParts: [door],
});
expect(serialized[0].removedParts[0]).toEqual(
expect.objectContaining({
id: 101,
captured: true,
currentlySelected: false,
url: expect.stringContaining("claims/hood.jpg"),
}),
);
});
it("does not create a revision for an unchanged or order-only submission", () => {
expect(
buildDamagedPartSelectionRevision({
revisionId: "revision-2",
changedAt: new Date(),
changedBy: { actorId: "expert-1", actorType: "damage_expert" },
previousParts: [hood, door],
selectedParts: [door, hood],
previousMedia: [],
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,170 @@
import { resolveStoredFileUrl } from "src/helpers/urlCreator";
import {
coerceDamagedPartsMediaToArray,
partLookupKey,
type DamageSelectedPartV2,
} from "src/helpers/outer-damage-parts";
export interface DamagedPartAuditActor {
actorId: string;
actorName?: string;
actorType: string;
}
export interface StoredDamagedPartAuditRow extends DamageSelectedPartV2 {
capture?: {
path?: string;
fileName?: string;
url?: string;
capturedAt?: Date | string;
};
}
export interface StoredDamagedPartSelectionRevision {
revisionId: string;
changedAt: Date;
changedBy: DamagedPartAuditActor;
expertProfileSnapshot?: unknown;
previousSelectedParts: DamageSelectedPartV2[];
selectedParts: DamageSelectedPartV2[];
removedParts: StoredDamagedPartAuditRow[];
addedParts: StoredDamagedPartAuditRow[];
}
export interface DamagedPartSelectionHistoryRow {
revisionId: string;
changedAt: Date | string;
changedBy: DamagedPartAuditActor;
expertProfileSnapshot?: unknown;
removedParts: Array<
DamageSelectedPartV2 & {
partId: number | null;
captured: boolean;
url?: string;
fileName?: string;
capturedAt?: Date | string;
currentlySelected: boolean;
}
>;
addedParts: Array<
DamageSelectedPartV2 & {
partId: number | null;
currentlySelected: boolean;
}
>;
}
function plainCapture(row: unknown): StoredDamagedPartAuditRow["capture"] {
if (!row || typeof row !== "object") return undefined;
const source = row as Record<string, unknown>;
const capture = {
...(source.path ? { path: String(source.path) } : {}),
...(source.fileName ? { fileName: String(source.fileName) } : {}),
...(source.url ? { url: String(source.url) } : {}),
...(source.capturedAt
? { capturedAt: source.capturedAt as Date | string }
: {}),
};
return Object.keys(capture).length > 0 ? capture : undefined;
}
/**
* Builds one immutable audit revision before the live selected-parts/media arrays
* are replaced. Returns null for a semantic no-op (including order-only changes).
*/
export function buildDamagedPartSelectionRevision(params: {
revisionId: string;
changedAt: Date;
changedBy: DamagedPartAuditActor;
expertProfileSnapshot?: unknown;
previousParts: DamageSelectedPartV2[];
selectedParts: DamageSelectedPartV2[];
previousMedia: unknown;
}): StoredDamagedPartSelectionRevision | null {
const previousKeys = new Set(params.previousParts.map(partLookupKey));
const selectedKeys = new Set(params.selectedParts.map(partLookupKey));
const mediaRows = coerceDamagedPartsMediaToArray(
params.previousMedia,
params.previousParts,
);
const removedParts = params.previousParts.flatMap((part, index) => {
if (selectedKeys.has(partLookupKey(part))) return [];
const capture = plainCapture(mediaRows[index]);
return [{ ...part, ...(capture ? { capture } : {}) }];
});
const addedParts = params.selectedParts
.filter((part) => !previousKeys.has(partLookupKey(part)))
.map((part) => ({ ...part }));
if (removedParts.length === 0 && addedParts.length === 0) return null;
return {
revisionId: params.revisionId,
changedAt: params.changedAt,
changedBy: params.changedBy,
...(params.expertProfileSnapshot
? { expertProfileSnapshot: params.expertProfileSnapshot }
: {}),
previousSelectedParts: params.previousParts.map((part) => ({ ...part })),
selectedParts: params.selectedParts.map((part) => ({ ...part })),
removedParts,
addedParts,
};
}
/** Converts stored revisions into a stable, URL-enriched panel contract. */
export function serializeDamagedPartSelectionHistory(params: {
history: unknown;
currentSelectedParts: DamageSelectedPartV2[];
}): DamagedPartSelectionHistoryRow[] {
if (!Array.isArray(params.history)) return [];
const currentKeys = new Set(params.currentSelectedParts.map(partLookupKey));
return params.history
.filter((revision) => revision && typeof revision === "object")
.map((revision: any) => ({
revisionId: String(revision.revisionId ?? ""),
changedAt: revision.changedAt,
changedBy: {
actorId: String(revision.changedBy?.actorId ?? ""),
actorName: revision.changedBy?.actorName,
actorType: String(revision.changedBy?.actorType ?? "damage_expert"),
},
...(revision.expertProfileSnapshot
? { expertProfileSnapshot: revision.expertProfileSnapshot }
: {}),
removedParts: (Array.isArray(revision.removedParts)
? revision.removedParts
: []
).map((part: StoredDamagedPartAuditRow) => ({
id: part.id ?? null,
partId: part.id ?? null,
name: part.name,
side: part.side,
label_fa: part.label_fa,
...(part.catalogKey ? { catalogKey: part.catalogKey } : {}),
captured: !!part.capture,
...(part.capture?.fileName ? { fileName: part.capture.fileName } : {}),
...(part.capture?.capturedAt
? { capturedAt: part.capture.capturedAt }
: {}),
...(resolveStoredFileUrl(part.capture)
? { url: resolveStoredFileUrl(part.capture) }
: {}),
currentlySelected: currentKeys.has(partLookupKey(part)),
})),
addedParts: (Array.isArray(revision.addedParts)
? revision.addedParts
: []
).map((part: DamageSelectedPartV2) => ({
...part,
partId: part.id ?? null,
currentlySelected: currentKeys.has(partLookupKey(part)),
})),
}))
.filter(
(revision) =>
revision.removedParts.length > 0 || revision.addedParts.length > 0,
);
}

View File

@@ -0,0 +1,25 @@
import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
import { claimPriceCapAppliesToBlame } from "./claim-price-cap";
describe("claimPriceCapAppliesToBlame", () => {
it.each([
[{ creationMethod: CreationMethod.NORMAL }],
[{}],
])("applies to V1 user-created files (%p)", (blame) => {
expect(claimPriceCapAppliesToBlame(blame)).toBe(true);
});
it.each([
[{ creationMethod: CreationMethod.LINK, expertInitiated: true }],
[{ creationMethod: CreationMethod.IN_PERSON, expertInitiated: true }],
[{ creationMethod: CreationMethod.IN_PERSON, registrarInitiated: true }],
[{ creationMethod: CreationMethod.LINK, callCenterInitiated: true }],
[{ creationMethod: CreationMethod.NORMAL, initiatedByCallCenterId: "agent" }],
])("does not apply to non-V1 files (%p)", (blame) => {
expect(claimPriceCapAppliesToBlame(blame)).toBe(false);
});
it("does not apply when the linked blame origin is unavailable", () => {
expect(claimPriceCapAppliesToBlame(null)).toBe(false);
});
});

View File

@@ -0,0 +1,39 @@
import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
export type ClaimPriceCapBlameOrigin = {
creationMethod?: CreationMethod | string | null;
expertInitiated?: boolean | null;
registrarInitiated?: boolean | null;
callCenterInitiated?: boolean | null;
initiatedByFieldExpertId?: unknown;
initiatedByRegistrarId?: unknown;
initiatedByCallCenterId?: unknown;
};
/**
* The total-payment cap is a V1 business rule. V1 files are created directly
* by a user (`NORMAL`); expert/registrar LINK or IN_PERSON files and V6
* call-center files must not inherit it. Missing creationMethod is treated as
* NORMAL for older user-created records, while initiator markers take
* precedence so legacy non-V1 records cannot be misclassified.
*/
export function claimPriceCapAppliesToBlame(
blame?: ClaimPriceCapBlameOrigin | null,
): boolean {
if (!blame) return false;
const hasNonV1Initiator =
blame.expertInitiated === true ||
blame.registrarInitiated === true ||
blame.callCenterInitiated === true ||
blame.initiatedByFieldExpertId != null ||
blame.initiatedByRegistrarId != null ||
blame.initiatedByCallCenterId != null;
if (hasNonV1Initiator) return false;
return (
blame.creationMethod == null ||
blame.creationMethod === CreationMethod.NORMAL
);
}

View File

@@ -110,12 +110,42 @@ for (const [seg, key] of Object.entries(SEGMENT_ALIASES)) {
NORM_TO_PART_KEY.set(seg, key);
}
const PERSIAN_LABEL_ALIASES: Array<[string, string]> = [
["گلگیر عقب", "backFender"],
["درب عقب", "backDoor"],
["درب جلو", "frontDoor"],
["گلگیر جلو", "frontFender"],
["سپر جلو", "frontBumper"],
["سپر عقب", "frontBumper"],
["درب موتور", "Hood"],
["کاپوت", "Hood"],
["درب صندوق", "Trunk"],
["صندوق عقب", "Trunk"],
["سقف", "Roof"],
["کلاف", "coil"],
["ستون", "column"],
["سینی جلو", "frontTray"],
["سینی عقب", "backTray"],
["شاسی جلو", "frontChassis"],
["شاسی عقب", "backChassis"],
["رکاب", "carFootrest"],
["کف اتاق", "carFloor"],
];
export function normalizePriceDropKey(str: string): string {
return String(str ?? "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
function normalizePersianLabel(str: string): string {
return String(str ?? "")
.replace(/[يى]/g, "ی")
.replace(/ك/g, "ک")
.replace(/[\u200c\s()\-_]/g, "")
.trim();
}
export function parsePriceDropNumber(input: number | string): number {
if (typeof input === "number" && Number.isFinite(input)) return input;
return Number(
@@ -191,6 +221,7 @@ export function buildPriceDropCatalogForApi(): Array<{
export function resolvePriceDropPartKeyFromDamagePart(part: {
name?: string;
catalogKey?: string;
label_fa?: string;
}): string | null {
const candidates: string[] = [];
if (part.catalogKey) {
@@ -205,6 +236,11 @@ export function resolvePriceDropPartKeyFromDamagePart(part: {
const hit = NORM_TO_PART_KEY.get(norm);
if (hit && PRICE_DROP_PART_TABLE[hit]) return hit;
}
const normalizedLabel = normalizePersianLabel(part.label_fa ?? part.name ?? "");
for (const [label, key] of PERSIAN_LABEL_ALIASES) {
if (normalizedLabel.includes(normalizePersianLabel(label))) return key;
}
return null;
}

View File

@@ -128,21 +128,21 @@ describe("getExpertReplyPricingValidationError", () => {
it.each([
["price", "99,999", "parts[0].price"],
["salary", "10,000,000,001", "parts[0].salary"],
["salary", "100,000,000,001", "parts[0].salary"],
["totalPayment", "99,999", "parts[0].totalPayment"],
["daghi.price", "10,000,000,001", "parts[0].daghi.price"],
["daghi.price", "100,000,000,001", "parts[0].daghi.price"],
])(
"enforces the amount range for %s",
(field, invalidValue, expectedField) => {
const part = {
partId: 201,
typeOfDamage: TypeOfDamage.Change,
price: "100000",
salary: "100000",
totalPayment: "100000",
price: "1000000",
salary: "1000000",
totalPayment: "1000000",
daghi: {
option: DaghiOption.RECYCLED_PARTS_VALUE,
price: "100000",
price: "1000000",
},
};
if (field === "daghi.price") part.daghi.price = invalidValue;

View File

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

View File

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

View File

@@ -43,36 +43,36 @@ describe("resolveSelectedPartByPartId", () => {
});
it("resolves catalog id from outer catalog when not on claim", () => {
const hit = resolveCatalogPartByPartId(201, ClaimVehicleTypeV2.HATCHBACK);
expect(hit?.id).toBe(201);
expect(hit?.side).toBe("left");
expect(hit?.catalogKey).toBe("left_backfender");
const hit = resolveCatalogPartByPartId(36, ClaimVehicleTypeV2.HATCHBACK);
expect(hit?.id).toBe(36);
expect(hit?.side).toBe("");
expect(hit?.catalogKey).toBe("36");
});
it("resolvePartForExpertReply uses catalog for new expert line", () => {
const hit = resolvePartForExpertReply(
201,
36,
[],
ClaimVehicleTypeV2.HATCHBACK,
);
expect(hit?.id).toBe(201);
expect(hit?.id).toBe(36);
});
it("sanitize fixes Persian side and re-hydrates from catalog id", () => {
const fixed = sanitizeDamageSelectedPartV2(
{
id: 201,
name: "گلگیر عقب (چپ)",
id: 36,
name: "گلگير عقب سمت راننده",
side: "چپ",
label_fa: "",
catalogKey: "چپ",
},
ClaimVehicleTypeV2.HATCHBACK,
);
expect(fixed.side).toBe("left");
expect(fixed.name).toBe("backfender");
expect(fixed.catalogKey).toBe("left_backfender");
expect(catalogPartIdFromSelectedPart(fixed)).toBe(201);
expect(fixed.side).toBe("");
expect(fixed.name).toBe("36");
expect(fixed.catalogKey).toBe("36");
expect(catalogPartIdFromSelectedPart(fixed)).toBe(36);
});
it("internal parts have null catalog partId", () => {
@@ -90,30 +90,30 @@ describe("resolveSelectedPartByPartId", () => {
const fixed = sanitizeDamageSelectedPartV2(
{
id: null,
name: "گلگیر عقب (چپ)",
name: "گلگير عقب سمت راننده",
side: "internal",
label_fa: "گلگیر عقب (چپ)",
label_fa: "گلگير عقب سمت راننده",
},
ClaimVehicleTypeV2.HATCHBACK,
);
expect(fixed.id).toBe(201);
expect(fixed.side).toBe("left");
expect(fixed.name).toBe("backfender");
expect(fixed.catalogKey).toBe("left_backfender");
expect(fixed.id).toBe(36);
expect(fixed.side).toBe("");
expect(fixed.name).toBe("36");
expect(fixed.catalogKey).toBe("36");
});
it("price drop resolves catalog id when claim row is corrupt", () => {
const corrupt: DamageSelectedPartV2[] = [
{
id: null,
name: "گلگیر عقب (چپ)",
name: "گلگير عقب سمت راننده",
side: "internal",
label_fa: "گلگیر عقب (چپ)",
label_fa: "گلگير عقب سمت راننده",
},
];
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
corrupt,
[{ partId: 201, severity: "Minor" }],
[{ partId: 36, severity: "Minor" }],
ClaimVehicleTypeV2.HATCHBACK,
);
expect(errors).toHaveLength(0);

View File

@@ -36,6 +36,99 @@ export class LookupsController {
return await this.lookupsService.getAccidentCauses();
}
@Get("vehicle-hull-accident-types")
@ApiOperation({
summary: "Fanavaran GEN.03 hull accident type lookup",
description:
"Returns values for base-claim field AccidentTypeId from car/base-info/vehicle-hull-accident-types (not the Tejarat static accident-type list).",
})
@ApiOkResponse({
description: "Returns Fanavaran vehicle hull accident types",
schema: { type: "array", items: { type: "object" } },
})
async getVehicleHullAccidentTypes() {
return await this.lookupsService.getVehicleHullAccidentTypes();
}
@Get("vehicle-hull-accident-culprit-type")
@ApiOperation({
summary: "Fanavaran GEN.03 hull culprit type lookup",
description:
"Returns values for base-claim field CulpritTypeId from car/code-list/vehicle-hull-accident-culprit-type (not accident-culprit-type used for ثالث).",
})
@ApiOkResponse({
description: "Returns Fanavaran vehicle hull accident culprit types",
schema: { type: "array", items: { type: "object" } },
})
async getVehicleHullAccidentCulpritType() {
return await this.lookupsService.getVehicleHullAccidentCulpritType();
}
@Get("detection-accident-culprits")
@ApiOperation({
summary: "Fanavaran GEN.03 hull authority that determined the culprit",
description:
"Returns values for base-claim field AuthorityCulpritId from car/base-info/detection-accident-culprits.",
})
@ApiOkResponse({
description: "Returns Fanavaran detection-accident-culprits rows",
schema: { type: "array", items: { type: "object" } },
})
async getDetectionAccidentCulprits() {
return await this.lookupsService.getDetectionAccidentCulprits();
}
@Get("vehicle-hull-dmg-kind")
@ApiOperation({
summary: "Fanavaran GEN.06 hull damage kind lookup",
description:
"Returns values for DmgSections[].DmgKindId from car/code-list/vehicle-hull-dmg-kind.",
})
@ApiOkResponse({
description: "Returns Fanavaran vehicle hull damage kinds",
schema: { type: "array", items: { type: "object" } },
})
async getVehicleHullDmgKind() {
return await this.lookupsService.getVehicleHullDmgKind();
}
@Get("vehicle-hull-dmg-cost-kinds")
@ApiOperation({
summary: "Fanavaran GEN.06 hull damage cost kind lookup",
description:
"Returns values for DmgSections[].DmgSectionCosts[].DmgCostKindId from car/base-info/vehicle-hull-dmg-cost-kinds.",
})
@ApiOkResponse({
description: "Returns Fanavaran vehicle hull damage cost kinds",
schema: { type: "array", items: { type: "object" } },
})
async getVehicleHullDmgCostKinds() {
return await this.lookupsService.getVehicleHullDmgCostKinds();
}
@Get("vehicle-hull-dmg-accessories/:policyId")
@ApiOperation({
summary: "Fanavaran GEN.06 hull policy damage accessories",
description:
"Returns rows for DmgSections[].VehicleHullAccessoryId from car/vehicle-hull-policies/{policyId}/dmg-accessories. PolicyId is required (Fanavaran: کد رایانه ریسورس).",
})
@ApiParam({
name: "policyId",
description: "Fanavaran hull policy Id (same as GEN.03 PolicyId)",
example: 13764610,
})
@ApiOkResponse({
description: "Returns Fanavaran vehicle hull damage accessories for the policy",
schema: { type: "array", items: { type: "object" } },
})
async getVehicleHullDmgAccessoriesByPolicyId(
@Param("policyId", ParseIntPipe) policyId: number,
) {
return await this.lookupsService.getVehicleHullDmgAccessoriesByPolicyId(
policyId,
);
}
@Get("accident-report-type")
@ApiOkResponse({
description: "Returns accident report type lookup data",

View File

@@ -106,6 +106,40 @@ export class LookupsService {
return await this.getClientRemoteLookup("accident-causes");
}
async getVehicleHullAccidentTypes(): Promise<any> {
return await this.getClientRemoteLookup("vehicle-hull-accident-types");
}
async getVehicleHullAccidentCulpritType(): Promise<any> {
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");
}
async getVehicleHullDmgCostKinds(): Promise<any> {
return await this.getClientRemoteLookup("vehicle-hull-dmg-cost-kinds");
}
async getVehicleHullDmgAccessoriesByPolicyId(policyId: number): Promise<unknown> {
const clientKey = this.activeClientKey();
return this.fanavaranLookupService.vehicleHullDmgAccessoriesByPolicyId(
clientKey,
policyId,
{
contractIdOverride: resolveFanavaranProductContractId(
clientKey,
"car-body",
),
},
);
}
async getAccidentReportType(): Promise<any> {
return await this.getClientRemoteLookup("accident-report-type");
}

View File

@@ -1,6 +1,8 @@
import { Test, TestingModule } from "@nestjs/testing";
import { ProfileController } from "./profile.controller";
import { ProfileService } from "./profile.service";
import { PlatesService } from "src/plates/plates.service";
import { JwtService } from "@nestjs/jwt";
describe("ProfileController", () => {
let controller: ProfileController;
@@ -8,7 +10,11 @@ describe("ProfileController", () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ProfileController],
providers: [ProfileService],
providers: [
{ provide: ProfileService, useValue: {} },
{ provide: PlatesService, useValue: {} },
{ provide: JwtService, useValue: {} },
],
}).compile();
controller = module.get<ProfileController>(ProfileController);

View File

@@ -1,12 +1,22 @@
import { Test, TestingModule } from "@nestjs/testing";
import { ProfileService } from "./profile.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service";
describe("ProfileService", () => {
let service: ProfileService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ProfileService],
providers: [
ProfileService,
{
provide: UserDbService,
useValue: {
findOne: jest.fn(),
findOneAndUpdate: jest.fn(),
},
},
],
}).compile();
service = module.get<ProfileService>(ProfileService);

View File

@@ -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.",

View File

@@ -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,

View File

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

View File

@@ -5,6 +5,9 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
import { CreationMethod } from "./entities/schema/request-management.schema";
import { PartyRole } from "./entities/schema/partyRole.enum";
import { RequestManagementService } from "./request-management.service";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
describe("RequestManagementService V4 FileMaker workflow", () => {
it("persists FIRST_INITIAL_FORM after the first party OTP is verified", async () => {
@@ -216,4 +219,138 @@ describe("RequestManagementService V4 FileMaker workflow", () => {
WorkflowStep.FIRST_VIDEO,
);
});
it.each([false, true])(
"resumes V4/V5 FileMaker work from the linked claim during partial document upload (approval=%s)",
async (requiresFileMakerApproval) => {
const fileMakerId = new Types.ObjectId();
const blameId = new Types.ObjectId();
const claimId = new Types.ObjectId();
const request = {
_id: blameId,
publicId: "BL-FILE-MAKER-RESUME",
requestNo: "BL-RESUME",
type: BlameRequestType.THIRD_PARTY,
status: CaseStatus.OPEN,
isMadeByFileMaker: true,
initiatedByFieldExpertId: fileMakerId,
requiresFileMakerApproval,
parties: [],
workflow: {
currentStep: WorkflowStep.SECOND_COMPLETED,
nextStep: WorkflowStep.WAITING_FOR_GUILT_DECISION,
completedSteps: [
WorkflowStep.FIRST_COMPLETED,
WorkflowStep.SECOND_COMPLETED,
],
},
};
const claim = {
_id: claimId,
blameRequestId: blameId,
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
workflow: {
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
},
};
const service =
new (RequestManagementService as any)() as RequestManagementService;
(service as any).blameRequestDbService = {
findById: jest.fn().mockResolvedValue(request),
find: jest.fn().mockResolvedValue([request]),
};
(service as any).claimCaseDbService = {
findOne: jest.fn().mockResolvedValue(claim),
find: jest.fn().mockResolvedValue([claim]),
};
const reopened = await service.getMyFileMakerFileDetail(
{ sub: String(fileMakerId), role: RoleEnum.FILE_MAKER },
String(blameId),
);
expect(reopened.status).toBe(CaseStatus.OPEN);
expect(reopened.claimStatus).toBe(
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
);
expect(reopened.fileMakerResume).toEqual({
action: "UPLOAD_REQUIRED_DOCUMENTS",
entity: "CLAIM",
entityId: String(claimId),
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
});
const list = await service.getMyFileMakerFiles({
sub: String(fileMakerId),
role: RoleEnum.FILE_MAKER,
});
expect(list.list[0]).toEqual(
expect.objectContaining({
linkedClaimId: String(claimId),
claimStatus: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
fileMakerResume: expect.objectContaining({
action: "UPLOAD_REQUIRED_DOCUMENTS",
entityId: String(claimId),
}),
}),
);
},
);
it("does not leave the blame narrative before all required signatures", async () => {
const fileMakerId = new Types.ObjectId();
const blameId = new Types.ObjectId();
const claimId = new Types.ObjectId();
const request = {
_id: blameId,
publicId: "BL-FILE-MAKER-NARRATIVE",
requestNo: "BL-NARRATIVE",
type: BlameRequestType.THIRD_PARTY,
status: CaseStatus.OPEN,
isMadeByFileMaker: true,
initiatedByFieldExpertId: fileMakerId,
parties: [],
workflow: {
currentStep: WorkflowStep.FIRST_DESCRIPTION,
nextStep: WorkflowStep.FIRST_SIGN,
completedSteps: [WorkflowStep.FIRST_VOICE],
},
};
const claim = {
_id: claimId,
blameRequestId: blameId,
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
workflow: {
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
},
};
const service =
new (RequestManagementService as any)() as RequestManagementService;
(service as any).blameRequestDbService = {
findById: jest.fn().mockResolvedValue(request),
};
(service as any).claimCaseDbService = {
findOne: jest.fn().mockResolvedValue(claim),
};
const reopened = await service.getMyFileMakerFileDetail(
{ sub: String(fileMakerId), role: RoleEnum.FILE_MAKER },
String(blameId),
);
expect(reopened.fileMakerResume).toEqual(
expect.objectContaining({
action: "CONTINUE_BLAME",
entity: "BLAME",
entityId: String(blameId),
currentStep: WorkflowStep.FIRST_DESCRIPTION,
}),
);
});
});

View File

@@ -5,6 +5,7 @@ import {
Get,
Param,
Post,
Query,
Put,
UploadedFile,
UseGuards,
@@ -44,6 +45,7 @@ import {
UploadRequiredDocumentV2ResponseDto,
} from "src/claim-request-management/dto/upload-document-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
/**
* V4 FileMaker flow — first half of the split blame workflow.
@@ -96,10 +98,13 @@ export class FileMakerBlameV4Controller {
@Get("my-files")
@ApiOperation({
summary: "List all blame files created by this FileMaker",
description: "Returns all V4 FileMaker blame files initiated by the authenticated FileMaker.",
description: "Returns V4 FileMaker blame files using the shared search, sort, filter, and pagination query contract.",
})
async getMyFiles(@CurrentUser() fileMaker: any) {
return this.requestManagementService.getMyFileMakerFiles(fileMaker);
async getMyFiles(
@CurrentUser() fileMaker: any,
@Query() query: ListQueryV2Dto,
) {
return this.requestManagementService.getMyFileMakerFiles(fileMaker, query);
}
@Get("my-files/:requestId")

View File

@@ -5,6 +5,7 @@ import {
Get,
Param,
Post,
Query,
Put,
UploadedFile,
UseGuards,
@@ -44,6 +45,7 @@ import {
UploadRequiredDocumentV2ResponseDto,
} from "src/claim-request-management/dto/upload-document-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
/**
* V5 FileMaker flow — identical to V4 but under the /v5/ prefix.
@@ -95,10 +97,13 @@ export class FileMakerBlameV5Controller {
@Get("my-files")
@ApiOperation({
summary: "List all blame files created by this FileMaker",
description: "Returns all V5 FileMaker blame files initiated by the authenticated FileMaker.",
description: "Returns V5 FileMaker blame files using the shared search, sort, filter, and pagination query contract.",
})
async getMyFiles(@CurrentUser() fileMaker: any) {
return this.requestManagementService.getMyFileMakerFiles(fileMaker);
async getMyFiles(
@CurrentUser() fileMaker: any,
@Query() query: ListQueryV2Dto,
) {
return this.requestManagementService.getMyFileMakerFiles(fileMaker, query);
}
@Get("my-files/:requestId")

View File

@@ -10,6 +10,7 @@ import {
Patch,
Post,
Put,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
@@ -48,6 +49,7 @@ import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
} from "src/claim-request-management/dto/capture-part-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
/**
@@ -88,10 +90,16 @@ export class FileReviewerBlameV4Controller {
@ApiOperation({
summary: "List available and assigned FileMaker blame files",
description:
"Returns V4 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
"Returns V4 FileMaker blame files in this reviewer's insurer using the shared search, sort, filter, and pagination query contract: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
})
async getMyFiles(@CurrentUser() fileReviewer: any) {
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
async getMyFiles(
@CurrentUser() fileReviewer: any,
@Query() query: ListQueryV2Dto,
) {
return this.requestManagementService.getMyFileReviewerFiles(
fileReviewer,
query,
);
}
@Get("my-files/:requestId")

View File

@@ -9,6 +9,7 @@ import {
Patch,
Post,
Put,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
@@ -48,6 +49,7 @@ import {
CapturePartV2ResponseDto,
} from "src/claim-request-management/dto/capture-part-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
/**
* V5 FileReviewer flow — same as V4 except after the damage expert completes
@@ -86,10 +88,16 @@ export class FileReviewerBlameV5Controller {
@ApiOperation({
summary: "List available and assigned FileMaker blame files",
description:
"Returns V5 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
"Returns V5 FileMaker blame files in this reviewer's insurer using the shared search, sort, filter, and pagination query contract: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
})
async getMyFiles(@CurrentUser() fileReviewer: any) {
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
async getMyFiles(
@CurrentUser() fileReviewer: any,
@Query() query: ListQueryV2Dto,
) {
return this.requestManagementService.getMyFileReviewerFiles(
fileReviewer,
query,
);
}
@Get("my-files/:requestId")

View File

@@ -5,7 +5,6 @@ import {
VehicleRegistrationState,
} from "src/common/dto/inquiry-participants.dto";
import {
assertPreviousPlateInquiryMatchesVin,
isMappedPolicyCurrent,
normalizeInquirySubmission,
participantForRole,
@@ -13,9 +12,8 @@ import {
resolveInquiryParticipants,
resolveInquirySubjects,
resolveInquiryVehicle,
runPlateInquiryWithFallback,
runCurrentPlateInquiry,
sanitizeStoredInquiryParticipants,
vehiclePlateCandidates,
} from "./inquiry-participant-resolver";
describe("inquiry participant resolver", () => {
@@ -74,7 +72,7 @@ describe("inquiry participant resolver", () => {
},
vin: "NAAM01E15HK123456",
}),
).toThrow("previousPolicyholderNationalCode");
).toThrow("کد ملی بیمه‌گذار قبلی");
});
it("defaults an omitted registration state to CURRENT", () => {
@@ -102,7 +100,7 @@ describe("inquiry participant resolver", () => {
},
vin: "TOO-SHORT",
}),
).toThrow("vehicle.vin must contain exactly 17 characters.");
).toThrow("شماره شاسی (VIN) باید دقیقاً ۱۷ کاراکتر باشد.");
});
it("rejects an incomplete current plate", () => {
@@ -115,7 +113,7 @@ describe("inquiry participant resolver", () => {
ir: "22",
},
}),
).toThrow("vehicle.currentPlate.centerDigits is required.");
).toThrow("سه رقم میانی پلاک در پلاک فعلی الزامی است.");
});
it("rejects invalid vehicle choice values", () => {
@@ -129,7 +127,7 @@ describe("inquiry participant resolver", () => {
ir: "22",
},
}),
).toThrow("vehicle.registrationState must be CURRENT or RECENTLY_TRANSFERRED.");
).toThrow("وضعیت پلاک خودرو باید «فعلی» یا «تازه تعویض‌شده» باشد.");
expect(() =>
resolveInquiryVehicle({
@@ -141,7 +139,7 @@ describe("inquiry participant resolver", () => {
},
isNewCar: "false" as any,
}),
).toThrow("vehicle.isNewCar must be a boolean.");
).toThrow("وضعیت صفر بودن خودرو نامعتبر است.");
});
it("rejects a previous policyholder national code for a current registration", () => {
@@ -309,42 +307,6 @@ describe("inquiry participant resolver", () => {
).toBe("0022222222");
});
it("orders the current plate before the previous-plate fallback", () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
expect(
vehiclePlateCandidates({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
}),
).toEqual([
{ kind: "CURRENT", plate: currentPlate },
{ kind: "PREVIOUS", plate: previousPlate },
]);
});
it("rejects a previous-plate result for another chassis", () => {
expect(() =>
assertPreviousPlateInquiryMatchesVin("NAAM01E15HK123456", {
VinNumberField: "DIFFERENTVIN00001",
}),
).toThrow(BadRequestException);
});
it("requires the driver's licence status in the new contract", () => {
expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
@@ -357,58 +319,36 @@ describe("inquiry participant resolver", () => {
).toThrow(BadRequestException);
});
it("falls back to the previous plate and accepts only a matching VIN", async () => {
it("runs only one inquiry for the current plate", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
const query = jest
.fn()
.mockRejectedValueOnce(new Error("not found"))
.mockResolvedValueOnce({
mapped: { VinNumberField: "NAAM01E15HK123456", CompanyName: "پارسیان" },
});
const result = await runPlateInquiryWithFallback<{
mapped: { VinNumberField?: string; CompanyName?: string };
}>({
vehicle: {
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
},
fallbackCurrentPlate: currentPlate,
query,
isUsable: (value) => !!value.mapped.CompanyName,
mappedValue: (value) => value.mapped,
const query = jest.fn().mockResolvedValue({
mapped: { CompanyName: "پارسیان" },
});
expect(query).toHaveBeenCalledTimes(2);
expect(query).toHaveBeenNthCalledWith(1, currentPlate, "CURRENT");
expect(query).toHaveBeenNthCalledWith(2, previousPlate, "PREVIOUS");
expect(result.plateKind).toBe("PREVIOUS");
const result = await runCurrentPlateInquiry<{
mapped: { CompanyName?: string };
}>({
currentPlate,
vin: "NAAM01E15HK123456",
query,
isUsable: (value) => !!value.mapped.CompanyName,
});
expect(query).toHaveBeenCalledTimes(1);
expect(query).toHaveBeenCalledWith(currentPlate);
expect(result.plateKind).toBe("CURRENT");
expect(result.attempts).toMatchObject([
{ plateKind: "CURRENT", succeeded: false, error: "not found" },
{ plateKind: "PREVIOUS", succeeded: true, usable: true },
{ plateKind: "CURRENT", succeeded: true, usable: true },
]);
expect(result.attempts[0]).toMatchObject({
plate: currentPlate,
vin: "NAAM01E15HK123456",
});
expect(result.attempts[1]).toMatchObject({
plate: previousPlate,
vin: "NAAM01E15HK123456",
});
});
it("rejects a car-body policyholder on a third-party case", () => {
@@ -510,95 +450,72 @@ describe("inquiry participant resolver", () => {
);
});
it("falls back from a stale current policy to a current previous-plate policy", async () => {
it("rejects a stale current policy without trying another plate", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
const query = jest
.fn()
.mockResolvedValueOnce({
mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" },
})
.mockResolvedValueOnce({
mapped: {
CompanyName: "پارسیان",
EndDate: "1406/01/01",
VinNumberField: "NAAM01E15HK123456",
},
});
const query = jest.fn().mockResolvedValue({
mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" },
});
const result = await runPlateInquiryWithFallback<{
mapped: Record<string, any>;
}>({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
await expect(
runCurrentPlateInquiry<{ mapped: Record<string, any> }>({
currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
query,
isUsable: (value) =>
!!value.mapped.CompanyName &&
isMappedPolicyCurrent(value.mapped, "2026-09-13"),
}),
fallbackCurrentPlate: currentPlate,
query,
isUsable: (value) =>
!!value.mapped.CompanyName &&
isMappedPolicyCurrent(value.mapped, "2026-09-13"),
mappedValue: (value) => value.mapped,
});
expect(result.plateKind).toBe("PREVIOUS");
expect(result.attempts[0]).toMatchObject({
plateKind: "CURRENT",
succeeded: true,
usable: false,
).rejects.toMatchObject({
attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
});
expect(query).toHaveBeenCalledTimes(1);
});
it("rejects the result and retains audit attempts when every plate is unusable", async () => {
it("retains the current-plate audit attempt when the result is unusable", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
await expect(
runPlateInquiryWithFallback({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
}),
fallbackCurrentPlate: currentPlate,
runCurrentPlateInquiry({
currentPlate,
vin: "NAAM01E15HK123456",
query: async () => ({ mapped: { CompanyName: "پارسیان" } }),
isUsable: () => false,
mappedValue: (value) => value.mapped,
}),
).rejects.toMatchObject({
attempts: [
{ plateKind: "CURRENT", succeeded: true, usable: false },
{ plateKind: "PREVIOUS", succeeded: true, usable: false },
],
attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
});
});
it("does not use the previous plate after a transport or provider outage", async () => {
it("preserves a mapped provider message when the current result is unusable", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
await expect(
runCurrentPlateInquiry({
currentPlate,
query: async () => ({
mapped: { Error: { Message: "رکوردی یافت نشد" } },
}),
isUsable: () => false,
errorMessage: (value) => value.mapped.Error.Message,
}),
).rejects.toThrow("رکوردی یافت نشد");
});
it("retains one failed current-plate attempt after a provider outage", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
@@ -612,23 +529,11 @@ describe("inquiry participant resolver", () => {
);
await expect(
runPlateInquiryWithFallback({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate,
previousPlate: {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
},
vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
}),
fallbackCurrentPlate: currentPlate,
runCurrentPlateInquiry({
currentPlate,
vin: "NAAM01E15HK123456",
query,
isUsable: () => false,
mappedValue: () => ({}),
}),
).rejects.toThrow("upstream timeout");
expect(query).toHaveBeenCalledTimes(1);
@@ -647,7 +552,7 @@ describe("inquiry participant resolver", () => {
expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, input as any),
).toThrow("THIRD_PARTY_POLICYHOLDER does not support the unknown option.");
).toThrow("ثبت بیمه‌گذار شخص ثالث به‌صورت نامشخص امکان‌پذیر نیست.");
});
it("strips the removed unknown field from historical participant output", () => {

View File

@@ -60,6 +60,20 @@ export interface InquirySubjects {
driverNationalCode: string;
}
const PARTICIPANT_ROLE_LABELS: Record<InquiryParticipantRole, string> = {
[InquiryParticipantRole.DRIVER]: "راننده",
[InquiryParticipantRole.VEHICLE_OWNER]: "مالک خودرو",
[InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER]: "بیمه‌گذار شخص ثالث",
[InquiryParticipantRole.CAR_BODY_POLICYHOLDER]: "بیمه‌گذار بدنه",
};
const PLATE_FIELD_LABELS = {
leftDigits: "دو رقم سمت چپ پلاک",
centerAlphabet: "حرف پلاک",
centerDigits: "سه رقم میانی پلاک",
ir: "کد ایران پلاک",
} as const;
/**
* The single routing seam for external inquiries. Policy checks belong to
* their policyholder, Sheba belongs to the vehicle owner, and licence data
@@ -70,7 +84,7 @@ export function resolveInquirySubjects(
): InquirySubjects {
if (!submission.vehicleOwner) {
throw new BadRequestException(
"Vehicle owner identity is required for Sheba validation.",
"اطلاعات هویتی مالک خودرو برای استعلام شبا الزامی است.",
);
}
return {
@@ -102,7 +116,11 @@ function assertCompleteInquiryPlate(
"ir",
] as const) {
if (plate?.[field] == null || String(plate[field]).trim() === "") {
throw new BadRequestException(`${path}.${field} is required.`);
const plateLabel =
path === "vehicle.currentPlate" ? "پلاک فعلی" : "پلاک قبلی";
throw new BadRequestException(
`${PLATE_FIELD_LABELS[field]} در ${plateLabel} الزامی است.`,
);
}
}
}
@@ -171,21 +189,21 @@ function requiredIdentity(
): ResolvedInquiryParticipant {
if (Object.prototype.hasOwnProperty.call(input, "phoneNumber")) {
throw new BadRequestException(
`${role} does not accept phoneNumber; phone numbers are collected separately from inquiry identity.`,
`شماره همراه ${PARTICIPANT_ROLE_LABELS[role]} باید جدا از اطلاعات هویتی استعلام ارسال شود.`,
);
}
const nationalCode = String(input.nationalCode ?? "").trim();
const birthday = String(input.birthday ?? "").trim();
if (!nationalCode || !birthday) {
throw new BadRequestException(
`${role} requires nationalCode and birthday.`,
`کد ملی و تاریخ تولد ${PARTICIPANT_ROLE_LABELS[role]} الزامی است.`,
);
}
if (
role === InquiryParticipantRole.DRIVER &&
typeof input.hasDrivingLicense !== "boolean"
) {
throw new BadRequestException("DRIVER requires hasDrivingLicense.");
throw new BadRequestException("وضعیت داشتن گواهینامه راننده الزامی است.");
}
if (
role === InquiryParticipantRole.DRIVER &&
@@ -194,7 +212,7 @@ function requiredIdentity(
!String(input.licenseType ?? "").trim())
) {
throw new BadRequestException(
"DRIVER requires licenseNumber and licenseType when hasDrivingLicense is true.",
"شماره و نوع گواهینامه برای راننده دارای گواهینامه الزامی است.",
);
}
return {
@@ -220,7 +238,7 @@ export function resolveInquiryParticipants(
);
if (!hasRoleCompleteInput) {
throw new BadRequestException(
"driver, vehicleOwner, and thirdPartyPolicyholder are required in the structured inquiry format.",
"اطلاعات راننده، مالک خودرو و بیمه‌گذار شخص ثالث برای استعلام الزامی است.",
);
}
if (
@@ -228,7 +246,7 @@ export function resolveInquiryParticipants(
input.carBodyPolicyholder != null
) {
throw new BadRequestException(
"CAR_BODY_POLICYHOLDER is not allowed for a THIRD_PARTY case.",
"بیمه‌گذار بدنه برای پرونده شخص ثالث قابل ثبت نیست.",
);
}
@@ -249,19 +267,23 @@ export function resolveInquiryParticipants(
if (existing) return existing;
if (resolving.has(role)) {
throw new BadRequestException(
"Participant sameAs references cannot be circular.",
"ارتباط اشخاص یکسان در اطلاعات استعلام نامعتبر است.",
);
}
const value = input[ROLE_FIELDS[role]] as
| InquiryParticipantInputDto
| undefined;
if (!value) throw new BadRequestException(`${role} is required.`);
if (!value) {
throw new BadRequestException(
`اطلاعات ${PARTICIPANT_ROLE_LABELS[role]} الزامی است.`,
);
}
resolving.add(role);
let participantId: string;
if (Object.prototype.hasOwnProperty.call(value, "unknown")) {
throw new BadRequestException(
`${role} does not support the unknown option.`,
`ثبت ${PARTICIPANT_ROLE_LABELS[role]} به‌صورت نامشخص امکان‌پذیر نیست.`,
);
} else if (value.sameAs) {
const hasPersonSpecificFields = Object.entries(value).some(
@@ -269,7 +291,7 @@ export function resolveInquiryParticipants(
);
if (hasPersonSpecificFields) {
throw new BadRequestException(
`${role} must contain either sameAs or identity fields, not both.`,
`برای ${PARTICIPANT_ROLE_LABELS[role]} باید فقط ارتباط با شخص دیگر یا اطلاعات هویتی مستقل ارسال شود.`,
);
}
participantId = resolveRole(value.sameAs);
@@ -280,7 +302,7 @@ export function resolveInquiryParticipants(
);
if (duplicate) {
throw new BadRequestException(
`${role} duplicates an existing nationalCode; use sameAs instead.`,
`کد ملی ${PARTICIPANT_ROLE_LABELS[role]} تکراری است؛ ارتباط با شخص ثبت‌شده را انتخاب کنید.`,
);
}
participants.set(participant.participantId, participant);
@@ -317,29 +339,29 @@ export function resolveInquiryVehicle(
input: InquiryVehicleInputDto,
): ResolvedInquiryVehicle {
if (!input) {
throw new BadRequestException("vehicle is required.");
throw new BadRequestException("اطلاعات خودرو برای استعلام الزامی است.");
}
const registrationState =
input.registrationState ?? VehicleRegistrationState.CURRENT;
if (!Object.values(VehicleRegistrationState).includes(registrationState)) {
throw new BadRequestException(
"vehicle.registrationState must be CURRENT or RECENTLY_TRANSFERRED.",
"وضعیت پلاک خودرو باید «فعلی» یا «تازه تعویض‌شده» باشد.",
);
}
if (input.isNewCar != null && typeof input.isNewCar !== "boolean") {
throw new BadRequestException("vehicle.isNewCar must be a boolean.");
throw new BadRequestException("وضعیت صفر بودن خودرو نامعتبر است.");
}
const previousPolicyholderNationalCode = String(
input.previousPolicyholderNationalCode ?? "",
).trim();
if (!input.currentPlate) {
throw new BadRequestException("vehicle.currentPlate is required.");
throw new BadRequestException("پلاک فعلی خودرو برای استعلام الزامی است.");
}
assertCompleteInquiryPlate(input.currentPlate, "vehicle.currentPlate");
const vin = String(input.vin ?? "").trim();
if (vin && vin.length !== 17) {
throw new BadRequestException(
"vehicle.vin must contain exactly 17 characters.",
"شماره شاسی (VIN) باید دقیقاً ۱۷ کاراکتر باشد.",
);
}
if (
@@ -347,7 +369,7 @@ export function resolveInquiryVehicle(
(!input.previousPlate || !vin || !previousPolicyholderNationalCode)
) {
throw new BadRequestException(
"RECENTLY_TRANSFERRED requires previousPlate, vin, and previousPolicyholderNationalCode.",
"برای خودروی تازه تعویض‌پلاک‌شده، پلاک قبلی، شماره شاسی (VIN) و کد ملی بیمه‌گذار قبلی الزامی است.",
);
}
if (
@@ -355,7 +377,7 @@ export function resolveInquiryVehicle(
(input.previousPlate || input.previousPolicyholderNationalCode != null)
) {
throw new BadRequestException(
"previousPlate and previousPolicyholderNationalCode are only allowed for RECENTLY_TRANSFERRED vehicles.",
"پلاک و کد ملی بیمه‌گذار قبلی فقط برای خودروی تازه تعویض‌پلاک‌شده قابل ثبت است.",
);
}
if (input.previousPlate) {
@@ -371,26 +393,6 @@ export function resolveInquiryVehicle(
};
}
export function vehiclePlateCandidates(input?: ResolvedInquiryVehicle): Array<{
kind: "CURRENT" | "PREVIOUS";
plate: InquiryVehicleInputDto["currentPlate"];
}> {
if (!input) return [];
return [
{ kind: "CURRENT" as const, plate: input.currentPlate },
...(input.registrationState ===
VehicleRegistrationState.RECENTLY_TRANSFERRED && input.previousPlate
? [{ kind: "PREVIOUS" as const, plate: input.previousPlate }]
: []),
];
}
function normalizeVehicleSerial(value: unknown): string {
return String(value ?? "")
.toUpperCase()
.replace(/[^A-Z0-9]/g, "");
}
/** A dated result is usable only while the returned policy has not expired. */
export function isMappedPolicyCurrent(
mapped: Record<string, any>,
@@ -407,19 +409,6 @@ export function isMappedPolicyCurrent(
return endDate != null && endDate >= todayGregorian;
}
function normalizePlateForComparison(
plate: InquiryVehicleInputDto["currentPlate"],
): string {
return [
plate?.ir,
plate?.leftDigits,
plate?.centerAlphabet,
plate?.centerDigits,
]
.map((part) => String(part ?? "").trim())
.join("|");
}
const LEGACY_INQUIRY_FIELDS = [
"nationalCodeOfDriver",
"driverBirthday",
@@ -442,68 +431,27 @@ function assertStructuredInquiryInput(input: Record<string, any>): void {
);
if (legacyFields.length > 0) {
throw new BadRequestException(
`Legacy inquiry fields are not accepted: ${legacyFields.join(", ")}. Use driver, vehicleOwner, thirdPartyPolicyholder, carBodyPolicyholder, and vehicle.`,
"ساختار قدیمی اطلاعات استعلام پذیرفته نمی‌شود؛ اطلاعات اشخاص و خودرو را در بخش‌های جدید ارسال کنید.",
);
}
}
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(
"Previous-plate inquiry does not match the submitted VIN/chassis; manual review is required.",
);
}
}
export function isPolicyNotFoundError(error: unknown): boolean {
const candidate = error as Record<string, any> | null;
const status = candidate?.status ?? candidate?.response?.status;
if (Number(status) === 404) return true;
const code = String(
candidate?.code ?? candidate?.response?.data?.code ?? "",
).toUpperCase();
if (["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY"].includes(code)) {
return true;
}
const message = String(
candidate?.message ?? candidate?.response?.data?.message ?? error ?? "",
);
return /\bnot[ -]?found\b|\bno (?:relevant )?policy\b|یافت نشد|فاقد بیمه(?:نامه)?/i.test(
message,
);
}
export async function runPlateInquiryWithFallback<T>(options: {
vehicle?: ResolvedInquiryVehicle;
fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"];
query: (
plate: InquiryVehicleInputDto["currentPlate"],
plateKind: "CURRENT" | "PREVIOUS",
) => Promise<T>;
/**
* 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;
@@ -511,12 +459,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;
@@ -524,75 +468,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 usable = options.isUsable(value);
if (!usable) {
attempts.push({
plateKind: candidate.kind,
plate: candidate.plate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
succeeded: true,
usable: false,
});
if (!isLast) continue;
const error = new BadRequestException(
"No current usable policy was found for the submitted vehicle identifiers.",
) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts;
throw error;
}
if (candidate.kind === "PREVIOUS" && usable) {
assertPreviousPlateInquiryMatchesVin(
options.vehicle!.vin!,
options.mappedValue(value),
);
}
try {
const value = await options.query(options.currentPlate);
const usable = options.isUsable(value);
if (!usable) {
attempts.push({
plateKind: candidate.kind,
plate: candidate.plate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
plateKind: "CURRENT",
plate: options.currentPlate,
...(options.vin ? { vin: options.vin } : {}),
succeeded: true,
usable: true,
usable: false,
});
return { value, plateKind: candidate.kind, attempts };
} catch (error) {
lastError = error;
const alreadyRecorded =
typeof error === "object" &&
error != null &&
Array.isArray((error as { attempts?: unknown }).attempts);
if (!alreadyRecorded) {
attempts.push({
plateKind: candidate.kind,
plate: candidate.plate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
succeeded: false,
error: error instanceof Error ? error.message : String(error),
});
}
if (
!isLast &&
!(options.shouldFallbackOnError ?? isPolicyNotFoundError)(error)
) {
if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts;
}
throw error;
}
if (isLast) {
if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts;
}
throw error;
}
const error = new BadRequestException(
options.errorMessage?.(value) ||
"بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts;
throw error;
}
attempts.push({
plateKind: "CURRENT",
plate: options.currentPlate,
...(options.vin ? { vin: options.vin } : {}),
succeeded: true,
usable: true,
});
return { value, plateKind: "CURRENT", attempts };
} catch (error) {
const alreadyRecorded =
typeof error === "object" &&
error != null &&
Array.isArray((error as { attempts?: unknown }).attempts);
if (!alreadyRecorded) {
attempts.push({
plateKind: "CURRENT",
plate: options.currentPlate,
...(options.vin ? { vin: options.vin } : {}),
succeeded: false,
error: error instanceof Error ? error.message : String(error),
});
}
if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts;
}
throw error;
}
throw lastError ?? new BadRequestException("Inquiry failed for all plates.");
}
export function normalizeInquirySubmission<T extends Record<string, any>>(
@@ -610,7 +530,7 @@ export function normalizeInquirySubmission<T extends Record<string, any>>(
);
if (!driver || !thirdPartyPolicyholder) {
throw new BadRequestException(
"Driver and third-party policyholder identities are required.",
"اطلاعات هویتی راننده و بیمه‌گذار شخص ثالث الزامی است.",
);
}
const vehicleOwner = participantForRole(

View File

@@ -17,6 +17,7 @@ import {
ReinquiryInquiriesResponseDto,
ReinquiryPartyResultDto,
} from "./dto/reinquiry-inquiries.dto";
import { getInquiryErrorMessage } from "src/common/utils/inquiry-error";
type PlateParts = {
leftDigits: number;
@@ -48,7 +49,7 @@ export class InquiryRefreshService {
if (!body.publicId && !body.blameRequestId && limit === 0) {
throw new BadRequestException(
"Provide publicId, blameRequestId, or limit for bulk refresh.",
"برای اجرای مجدد استعلام، شناسه عمومی پرونده، شناسه پرونده یا تعداد پرونده‌ها را وارد کنید.",
);
}
@@ -58,14 +59,16 @@ export class InquiryRefreshService {
if (body.publicId) filter.publicId = body.publicId;
if (body.blameRequestId) {
if (!Types.ObjectId.isValid(body.blameRequestId)) {
throw new BadRequestException("Invalid blameRequestId");
throw new BadRequestException("شناسه پرونده معتبر نیست.");
}
filter._id = new Types.ObjectId(body.blameRequestId);
}
let docs = await this.blameRequestDbService.find(filter, { lean: true });
if (!docs.length) {
throw new NotFoundException("No matching blame cases found");
throw new NotFoundException(
"پرونده تقصیر مطابق اطلاعات واردشده یافت نشد.",
);
}
docs = limit > 0 ? docs.slice(0, limit) : docs;
@@ -111,8 +114,8 @@ export class InquiryRefreshService {
if (index === -1) {
partyResults.push({
role,
thirdParty: { ok: false, message: "party not found" },
person: { ok: false, message: "party not found" },
thirdParty: { ok: false, message: "طرف پرونده یافت نشد." },
person: { ok: false, message: "طرف پرونده یافت نشد." },
});
continue;
}
@@ -150,7 +153,9 @@ export class InquiryRefreshService {
blameRequestId: new Types.ObjectId(String(doc._id)),
});
claimsUpdated = linkedClaims.length;
this.logger.log(`[dry-run] ${label} would update blame + ${claimsUpdated} claim(s)`);
this.logger.log(
`[dry-run] ${label} would update blame + ${claimsUpdated} claim(s)`,
);
}
return {
@@ -208,9 +213,7 @@ export class InquiryRefreshService {
plateId: party?.vehicle?.plateId,
...(plate ? { plate } : {}),
...(nationalCode ? { nationalCode } : {}),
...(birthDate !== null && birthDate !== undefined
? { birthDate }
: {}),
...(birthDate !== null && birthDate !== undefined ? { birthDate } : {}),
};
if (dryRun) {
@@ -223,8 +226,8 @@ export class InquiryRefreshService {
result.thirdParty = {
ok: false,
message: !plate
? "plate not found on party"
: "nationalCodeOfInsurer/nationalCodeOfDriver missing",
? "پلاک برای این طرف پرونده ثبت نشده است."
: "کد ملی برای این طرف پرونده ثبت نشده است.",
};
} else {
await this.waitForRateLimit();
@@ -249,7 +252,9 @@ export class InquiryRefreshService {
inquiriesChanged = true;
result.thirdParty = {
ok: false,
message: inquiry.mapped.Error.Message || "third-party inquiry error",
message:
inquiry.mapped.Error.Message ||
getInquiryErrorMessage(inquiry.mapped, "thirdPartyPlate"),
};
} else {
nextParty = this.applyThirdPartyToParty(
@@ -292,11 +297,18 @@ export class InquiryRefreshService {
}
}
} catch (error: any) {
this.recordPartyInquiry(inquiries, "thirdParty", role, false, {}, error);
this.recordPartyInquiry(
inquiries,
"thirdParty",
role,
false,
{},
error,
);
inquiriesChanged = true;
result.thirdParty = {
ok: false,
message: error?.message || String(error),
message: getInquiryErrorMessage(error, "thirdPartyPlate"),
};
}
}
@@ -305,8 +317,8 @@ export class InquiryRefreshService {
result.person = {
ok: false,
message: !nationalCode
? "nationalCodeOfInsurer/nationalCodeOfDriver missing"
: "insurerBirthday/driverBirthday missing",
? "کد ملی برای این طرف پرونده ثبت نشده است."
: "تاریخ تولد برای این طرف پرونده ثبت نشده است.",
};
} else {
await this.waitForRateLimit();
@@ -333,7 +345,7 @@ export class InquiryRefreshService {
inquiriesChanged = true;
result.person = {
ok: false,
message: error?.message || String(error),
message: getInquiryErrorMessage(error, "personalIdentity"),
};
}
}
@@ -477,12 +489,14 @@ export class InquiryRefreshService {
): Promise<void> {
const blameDoc = await this.blameRequestDbService.findById(blameId);
if (!blameDoc) {
throw new NotFoundException(`Blame case ${blameId} not found`);
throw new NotFoundException("پرونده تقصیر یافت نشد.");
}
for (const role of roles) {
const memParty = updatedParties.find((party) => party?.role === role);
const docIdx = blameDoc.parties.findIndex((party) => party?.role === role);
const docIdx = blameDoc.parties.findIndex(
(party) => party?.role === role,
);
if (!memParty || docIdx === -1) continue;
const party = blameDoc.parties[docIdx];
@@ -514,7 +528,9 @@ export class InquiryRefreshService {
party.insurance.company = memParty.insurance.company;
}
if (memParty.insurance.financialCeiling !== undefined) {
party.insurance.financialCeiling = String(memParty.insurance.financialCeiling);
party.insurance.financialCeiling = String(
memParty.insurance.financialCeiling,
);
}
if (memParty.insurance.startDate !== undefined) {
party.insurance.startDate = memParty.insurance.startDate;
@@ -541,7 +557,8 @@ export class InquiryRefreshService {
});
const inquiryPatch: Record<string, unknown> = {};
if (inquiries.thirdParty) inquiryPatch["inquiries.thirdParty"] = inquiries.thirdParty;
if (inquiries.thirdParty)
inquiryPatch["inquiries.thirdParty"] = inquiries.thirdParty;
if (inquiries.person) inquiryPatch["inquiries.person"] = inquiries.person;
if (!Object.keys(inquiryPatch).length) return 0;
@@ -590,14 +607,16 @@ export class InquiryRefreshService {
private normalizeInquiryError(error: any): Record<string, unknown> {
return {
message: error?.message || String(error),
message: getInquiryErrorMessage(error, "generic"),
status: error?.status ?? error?.response?.status,
data: error?.data ?? error?.response?.data,
};
}
private resolvePartyPlate(party: Record<string, any>): PlateParts | null {
const fromPlateId = this.parsePlateFromCompactString(party?.vehicle?.plateId);
const fromPlateId = this.parsePlateFromCompactString(
party?.vehicle?.plateId,
);
if (fromPlateId) return fromPlateId;
const candidates = [
@@ -608,14 +627,24 @@ export class InquiryRefreshService {
].filter(Boolean);
for (const candidate of candidates) {
const leftDigits = this.firstPresent(candidate.Plk1, candidate.platePartOne);
const leftDigits = this.firstPresent(
candidate.Plk1,
candidate.platePartOne,
);
const centerAlphabet = this.firstPresent(
candidate.plateLetterid,
candidate.plateLetterId,
candidate.plateLetterTitle,
);
const centerDigits = this.firstPresent(candidate.Plk3, candidate.platePartThree);
const ir = this.firstPresent(candidate.PlkSrl, candidate.plkSrl, candidate.plateSerialNumber);
const centerDigits = this.firstPresent(
candidate.Plk3,
candidate.platePartThree,
);
const ir = this.firstPresent(
candidate.PlkSrl,
candidate.plkSrl,
candidate.plateSerialNumber,
);
if (
leftDigits !== undefined &&
@@ -623,7 +652,9 @@ export class InquiryRefreshService {
centerDigits !== undefined &&
ir !== undefined
) {
const plateLetter = this.plateNormalizer.normalizePlateText(String(centerAlphabet));
const plateLetter = this.plateNormalizer.normalizePlateText(
String(centerAlphabet),
);
const parsed: PlateParts = {
leftDigits: Number(leftDigits),
centerAlphabet: plateLetter,
@@ -653,7 +684,9 @@ export class InquiryRefreshService {
const ir = Number(irRaw);
const leftDigits = Number(leftRaw);
const centerDigits = Number(centerRaw);
const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || ""));
const centerAlphabet = this.plateNormalizer.normalizePlateText(
String(alphaRaw || ""),
);
if (
!Number.isFinite(ir) ||
!Number.isFinite(leftDigits) ||
@@ -685,6 +718,8 @@ export class InquiryRefreshService {
}
private firstPresent(...values: unknown[]): unknown {
return values.find((value) => value !== undefined && value !== null && value !== "");
return values.find(
(value) => value !== undefined && value !== null && value !== "",
);
}
}

View File

@@ -15,12 +15,8 @@ describe("damaged-party inquiry requirements", () => {
const service = getService();
await expect(
(service as any).validateShebaV3(
undefined,
"0012345678",
"client-id",
),
).rejects.toThrow("sheba is required for the damaged party.");
(service as any).validateShebaV3(undefined, "0012345678", "client-id"),
).rejects.toThrow("شماره شبا برای طرف زیان‌دیده الزامی است.");
expect(
(service as any).sandHubService.getShebaValidation,
).not.toHaveBeenCalled();

View File

@@ -14,6 +14,7 @@ describe("RequestManagementService FileReviewer inbox", () => {
publicId: "BLM-OPEN",
type: "THIRD_PARTY",
status: "WAITING_FOR_FILE_REVIEWER",
createdAt: new Date("2026-01-02T00:00:00.000Z"),
isMadeByFileMaker: true,
expertInitiated: true,
creationMethod: "IN_PERSON",
@@ -34,6 +35,12 @@ describe("RequestManagementService FileReviewer inbox", () => {
undefined,
blameRequestDbService,
) as RequestManagementService;
(service as any).claimCaseDbService = {
find: jest.fn().mockResolvedValue([]),
};
(service as any).fanavaranLocationService = {
assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined),
};
return { service, blameRequestDbService };
}
@@ -46,7 +53,7 @@ describe("RequestManagementService FileReviewer inbox", () => {
clientKey: String(clientId),
});
expect(result).toEqual([
expect(result.list).toEqual([
expect.objectContaining({ _id: sealedFile._id, publicId: "BLM-OPEN" }),
]);
expect(blameRequestDbService.find).toHaveBeenCalledWith(
@@ -82,7 +89,50 @@ describe("RequestManagementService FileReviewer inbox", () => {
clientKey: String(clientId),
});
expect(result).toEqual([]);
expect(result.list).toEqual([]);
});
it("does not list an open file from another branch", async () => {
const { service } = createService([sealedFile]);
(service as any).fanavaranLocationService
.assertMakerReviewerBranchCompatible.mockRejectedValue(
new Error("This file belongs to another branch."),
);
const result = await service.getMyFileReviewerFiles({
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
});
expect(result.list).toEqual([]);
});
it("sorts and paginates the reviewer inbox with the shared list contract", async () => {
const olderFile = {
...sealedFile,
_id: new Types.ObjectId(),
publicId: "BLM-OLDER",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
};
const { service } = createService([olderFile, sealedFile]);
const result = await service.getMyFileReviewerFiles(
{
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
},
{ page: 1, limit: 1, sortBy: "createdAt", sortOrder: "desc" },
);
expect(result).toEqual(
expect.objectContaining({ total: 2, page: 1, limit: 1, totalPages: 2 }),
);
expect(result.list).toHaveLength(1);
expect(result.list[0]).toEqual(
expect.objectContaining({ publicId: "BLM-OPEN" }),
);
});
it("does not expose an open file's details to a reviewer from another tenant", async () => {

View File

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

View File

@@ -1,130 +0,0 @@
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { RequestManagementService } from "./request-management.service";
describe("RequestManagementService previous policyholder routing", () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
const vehicle = {
registrationState: "RECENTLY_TRANSFERRED",
currentPlate,
previousPlate,
previousPolicyholderNationalCode: "0098765432",
vin: "NAAM01E15HK123456",
};
function participantInput(caseType: BlameRequestType) {
return {
driver: {
nationalCode: "0011111111",
birthday: "1370/01/01",
hasDrivingLicense: false,
},
vehicleOwner: { sameAs: "DRIVER" },
thirdPartyPolicyholder: {
nationalCode: "0022222222",
birthday: "1360/02/02",
},
...(caseType === BlameRequestType.CAR_BODY
? {
carBodyPolicyholder: {
nationalCode: "0033333333",
birthday: "1350/03/03",
},
}
: {}),
vehicle,
};
}
it("uses the previous code only for the previous-plate third-party lookup", async () => {
const service = Object.create(RequestManagementService.prototype) as any;
service.sandHubService = {
getTejaratBlockInquiry: jest
.fn()
.mockResolvedValueOnce({ raw: {}, mapped: {} })
.mockResolvedValueOnce({
raw: {},
mapped: {
CompanyName: "پارسیان",
VinNumberField: vehicle.vin,
},
}),
};
const submission = service.normalizeInquiryInput(
BlameRequestType.THIRD_PARTY,
participantInput(BlameRequestType.THIRD_PARTY),
);
const result = await service.getThirdPartyPlateInquiry(submission);
expect(result.plateKind).toBe("PREVIOUS");
expect(
service.sandHubService.getTejaratBlockInquiry,
).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
plate: currentPlate,
nationalCodeOfInsurer: "0022222222",
}),
undefined,
);
expect(
service.sandHubService.getTejaratBlockInquiry,
).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
plate: previousPlate,
nationalCodeOfInsurer: "0098765432",
}),
undefined,
);
});
it("uses the previous code only for the previous-plate car-body lookup", async () => {
const service = Object.create(RequestManagementService.prototype) as any;
service.sandHubService = {
getCarBodyInquiry: jest
.fn()
.mockResolvedValueOnce({ raw: {}, mapped: {} })
.mockResolvedValueOnce({
raw: {},
mapped: {
policyNumber: "BODY-1",
VinNumberField: vehicle.vin,
},
}),
};
const submission = service.normalizeInquiryInput(
BlameRequestType.CAR_BODY,
participantInput(BlameRequestType.CAR_BODY),
);
const result = await service.getCarBodyPlateInquiry(submission);
expect(result.plateKind).toBe("PREVIOUS");
expect(service.sandHubService.getCarBodyInquiry).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
plate: currentPlate,
nationalCodeOfInsurer: "0033333333",
}),
);
expect(service.sandHubService.getCarBodyInquiry).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
plate: previousPlate,
nationalCodeOfInsurer: "0098765432",
}),
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -82,7 +82,7 @@ export class RequestManagementV2Controller {
@ApiOperation({
summary: "List my blame requests (V2)",
description:
"Party-owned blame files, or files initiated by the current FIELD_EXPERT / REGISTRAR. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.",
"Party-owned blame files, or files initiated by the current FIELD_EXPERT / REGISTRAR. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType`, `startDate`, `endDate`.",
})
async getAllBlameRequestsV2(
@CurrentUser() user: any,

View File

@@ -28,8 +28,8 @@ export function mapEsgCarBodyPolicyToInquiry(
// Provider responses do not currently include an insurer company identity.
companyId: policy.CompanyId ?? null,
CompanyCode: policy.CompanyId ?? null,
CompanyName: policy.CompanyName ?? null,
CompanyCode: policy.CompanyId ?? "8",
CompanyName: policy.CompanyName ?? "بیمه پارسیان",
// The lookup's customer is the policyholder/owner, not the insurer.
insurerName: null,

View File

@@ -1,6 +1,7 @@
import { SandHubService } from "./sand-hub.service";
import {
ForbiddenException,
NotFoundException,
ServiceUnavailableException,
} from "@nestjs/common";
import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service";
@@ -58,7 +59,9 @@ describe("SandHubService inquiry mocks", () => {
});
it("keeps disabled-live third-party mock policy usable", async () => {
const result = await service.getTejaratBlockInquiry(userDetail);
const result = await service.getTejaratBlockInquiry(userDetail, {
enforceDeploymentClientMatch: true,
});
expect(isMappedPolicyCurrent(result.mapped)).toBe(true);
});
@@ -121,6 +124,17 @@ describe("SandHubService inquiry mocks", () => {
expect(httpService.post).not.toHaveBeenCalled();
});
it("returns a contextual Persian error when no car-body policy matches", async () => {
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
lookupsService.findLastProcessedCarPolicy.mockRejectedValue(
new NotFoundException("No active Fanavaran car-body policy was found"),
);
await expect(service.getCarBodyInquiry(userDetail)).rejects.toThrow(
"بیمه‌نامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.",
);
});
it("does not let an offline third-party seed override a live ESG inquiry", async () => {
process.env.CLIENT_ID = "8";
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
@@ -129,12 +143,10 @@ describe("SandHubService inquiry mocks", () => {
raw: { mocked: true },
mapped: { PrntPlcyCmpDocNo: "MOCK-POLICY" },
});
const esg = jest
.spyOn(service as any, "makeEsgRequest")
.mockResolvedValue({
success: true,
data: { PrntCmpDocNo: "REAL-ESG-POLICY" },
});
const esg = jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: true,
data: { PrntCmpDocNo: "REAL-ESG-POLICY" },
});
const result = await service.getTejaratBlockInquiry(userDetail);
@@ -143,6 +155,50 @@ describe("SandHubService inquiry mocks", () => {
expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY");
});
it("preserves ESG messageFa for a plate inquiry", async () => {
process.env.CLIENT_ID = "8";
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: false,
error: {
code: "RECORD_NOT_FOUND",
message: "Provider request failed",
messageFa: "رکوردی یافت نشد",
providerMessage: "err.record.not.found",
providerCode: "RECORD_NOT_FOUND",
},
});
const result = await service.getTejaratBlockInquiry(userDetail, {
enforceDeploymentClientMatch: true,
});
expect(result.mapped.Error.Message).toBe("رکوردی یافت نشد");
});
it("preserves ESG messageFa for a VIN inquiry", async () => {
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: false,
error: {
code: "INQUIRY_NO_MATCH",
message: "Inquiry returned no matching result",
messageFa: "نتیجه‌ای مطابق با اطلاعات وارد شده یافت نشد",
providerMessage: "Inquiry returned no matching result",
providerCode: "INQUIRY_NO_MATCH",
},
});
const result = await service.getCarByChassisInquiry({
nationalCode: "0012345678",
chassisNo: "NAAR03HFFRDE07024",
});
expect(result.mapped.Error.Message).toBe(
"نتیجه‌ای مطابق با اطلاعات وارد شده یافت نشد",
);
});
it("rejects a guilty third-party policy issued by another insurer", async () => {
process.env.CLIENT_ID = "15";
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
@@ -183,10 +239,10 @@ describe("SandHubService inquiry mocks", () => {
});
await expect(
service.getPolicyByChassisInquiry(
service.getCarByChassisInquiry(
{
nationalCode: "1234567890",
chassis: "NAAR03HFFRDE07024",
chassisNo: "NAAR03HFFRDE07024",
},
{ enforceDeploymentClientMatch: true },
),
@@ -200,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,

View File

@@ -24,6 +24,13 @@ import { jalaliToGregorianDate } from "src/helpers/date-jalali";
import { firstValueFrom } from "rxjs";
import { mapEsgCarBodyPolicyToInquiry } from "./esg-car-body-inquiry.mapper";
import type { Plates } from "src/Types&Enums/plate.interface";
import {
getInquiryErrorMessage,
inquiryErrorStatus,
isInquiryFailurePayload,
isInquiryTimeout,
type InquiryErrorContext,
} from "src/common/utils/inquiry-error";
type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & {
plate: Plates | string;
@@ -31,9 +38,6 @@ type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & {
@Injectable()
export class SandHubService {
private static readonly ESG_INQUIRY_UNAVAILABLE_MESSAGE =
"استعلام در دسترس نیست";
private readonly logger = new Logger(SandHubService.name);
private loginToken: string | null = null;
private tokenExpiry: Date | null = null;
@@ -90,6 +94,30 @@ export class SandHubService {
return resolveFanavaranClientKey() === "parsian";
}
private inquiryContext(type: ExternalInquiryType): InquiryErrorContext {
if (type === "vinChassis") return "thirdPartyVin";
return type;
}
private throwInquiryError(
error: unknown,
context: InquiryErrorContext,
): never {
const message = getInquiryErrorMessage(error, context);
const status = inquiryErrorStatus(error);
if (error instanceof ForbiddenException || status === 403) {
throw new ForbiddenException(message);
}
if (isInquiryTimeout(error) || status === 504) {
throw new GatewayTimeoutException(message);
}
if ([400, 404, 409, 422].includes(status ?? 0)) {
throw new BadRequestException(message);
}
throw new ServiceUnavailableException(message);
}
/**
* A case may proceed only when the policy belongs to the insurer served by
* this deployment. Callers opt in for the guilty/first-party policy only.
@@ -101,7 +129,7 @@ export class SandHubService {
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
if (!expectedClientCode) {
throw new ServiceUnavailableException(
"CLIENT_ID must be configured before insurance eligibility can be checked.",
"تنظیمات شرکت بیمه برای بررسی اعتبار بیمه‌نامه کامل نیست.",
);
}
@@ -111,7 +139,7 @@ export class SandHubService {
if (actualClientCode === expectedClientCode) return;
throw new ForbiddenException(
`${insuranceLine} policy insurer does not match this deployment.`,
"بیمه‌نامه یافت‌شده متعلق به شرکت بیمه این سامانه نیست.",
);
}
@@ -134,13 +162,13 @@ export class SandHubService {
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
if (!expectedClientCode) {
throw new ServiceUnavailableException(
"CLIENT_ID must be configured before insurance eligibility can be checked.",
"تنظیمات شرکت بیمه برای بررسی اعتبار بیمه‌نامه کامل نیست.",
);
}
if (expectedClientCode === "8") return;
throw new ForbiddenException(
"CAR_BODY policy insurer does not match this deployment.",
"بیمه‌نامه یافت‌شده متعلق به شرکت بیمه این سامانه نیست.",
);
}
@@ -426,7 +454,7 @@ export class SandHubService {
this.logger.error("Failed to login to SandHub:", er.message);
this.loginToken = null;
this.tokenExpiry = null;
throw new UnauthorizedException("SandHub authentication failed");
throw new UnauthorizedException("احراز هویت سرویس استعلام انجام نشد.");
}
}
@@ -446,7 +474,7 @@ export class SandHubService {
if (!email || !password) {
throw new UnauthorizedException(
"Tejarat inquiry credentials are not configured (TEJARAT_INQUIRY_EMAIL/TEJARAT_INQUIRY_PASSWORD)",
"اطلاعات اتصال به سرویس استعلام تجارت نو تنظیم نشده است.",
);
}
@@ -486,12 +514,18 @@ export class SandHubService {
);
this.tejaratAccessToken = null;
this.tejaratTokenExpiry = null;
throw new UnauthorizedException("Tejarat inquiry authentication failed");
throw new UnauthorizedException(
"احراز هویت سرویس استعلام تجارت نو انجام نشد.",
);
}
}
private async getEsgAccessToken(): Promise<string> {
if (this.esgAccessToken && this.esgTokenExpiry && this.esgTokenExpiry > new Date()) {
if (
this.esgAccessToken &&
this.esgTokenExpiry &&
this.esgTokenExpiry > new Date()
) {
return this.esgAccessToken;
}
@@ -501,7 +535,7 @@ export class SandHubService {
if (!baseUrl || !username || !password) {
throw new UnauthorizedException(
"ESG credentials are not configured (ESG_URL/ESG_USERNAME/ESG_PASSWORD)",
"اطلاعات اتصال به سرویس استعلام ESG تنظیم نشده است.",
);
}
@@ -539,7 +573,9 @@ export class SandHubService {
this.logger.error("Failed to login to ESG inquiry:", er?.message || er);
this.esgAccessToken = null;
this.esgTokenExpiry = null;
throw new UnauthorizedException("ESG inquiry authentication failed");
throw new UnauthorizedException(
"احراز هویت سرویس استعلام ESG انجام نشد.",
);
}
}
@@ -603,24 +639,30 @@ export class SandHubService {
this.esgTokenExpiry = null;
}
if ([400, 404, 409, 422].includes(status)) {
this.throwInquiryError(err, this.inquiryContext(inquiryType));
}
if (attempt === maxRetries - 1) {
this.throwInquiryError(err, this.inquiryContext(inquiryType));
}
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
if (attempt === maxRetries - 1) throw err;
}
}
}
private mapEsgPolicyByPlateToOldFormat(raw: any): any {
private mapEsgPolicyByPlateToOldFormat(
raw: any,
context: "thirdPartyPlate" | "thirdPartyVin" = "thirdPartyPlate",
): any {
if (!raw) return raw;
if (raw?.success === false) {
this.logger.warn(
"ESG policyByPlate inquiry returned success=false",
raw,
);
if (isInquiryFailurePayload(raw)) {
this.logger.warn("ESG policy inquiry returned a failure payload", raw);
return {
Error: {
Message: SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
Message: getInquiryErrorMessage(raw, context),
},
};
}
@@ -670,7 +712,8 @@ export class SandHubService {
): string | null {
if (input === null || input === undefined) return null;
const raw = typeof input === "number" ? String(input) : String(input).trim();
const raw =
typeof input === "number" ? String(input) : String(input).trim();
if (!raw) return null;
let year = 0;
@@ -700,7 +743,9 @@ export class SandHubService {
return `${year}-${mm}-${dd}`;
}
private getDefaultMockPersonInquiry(nationalCode: string): Record<string, unknown> {
private getDefaultMockPersonInquiry(
nationalCode: string,
): Record<string, unknown> {
return {
firstName: "نام",
lastName: "خانوادگی",
@@ -711,10 +756,10 @@ export class SandHubService {
}
private mapEsgPersonInquiryToOldFormat(raw: any): Record<string, unknown> {
if (raw?.success === false) {
if (isInquiryFailurePayload(raw)) {
this.logger.warn("ESG person inquiry returned success=false", raw);
throw new BadRequestException(
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
getInquiryErrorMessage(raw, "personalIdentity"),
);
}
@@ -736,11 +781,9 @@ export class SandHubService {
}
private mapEsgShebaInquiryToOldFormat(raw: any): Record<string, unknown> {
if (raw?.success === false) {
if (isInquiryFailurePayload(raw)) {
this.logger.warn("ESG sheba inquiry returned success=false", raw);
throw new BadRequestException(
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
);
throw new BadRequestException(getInquiryErrorMessage(raw, "sheba"));
}
const data = raw?.data ?? {};
@@ -800,11 +843,15 @@ export class SandHubService {
this.tejaratTokenExpiry = null;
}
if ([400, 404, 409, 422].includes(status)) {
this.throwInquiryError(err, this.inquiryContext(inquiryType));
}
if (attempt === maxRetries - 1) {
this.throwInquiryError(err, this.inquiryContext(inquiryType));
}
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
if (attempt === maxRetries - 1) {
throw err;
}
}
}
}
@@ -883,8 +930,13 @@ export class SandHubService {
`[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`,
);
}
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
const mapped = this.mapEsgPolicyByPlateToOldFormat(
raw,
"thirdPartyPlate",
);
if (!mapped?.Error) {
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
}
return { raw, mapped };
}
@@ -912,8 +964,16 @@ export class SandHubService {
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
);
}
const mapped = this.mapNewApiResponseToOldFormat(raw);
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
const mapped = isInquiryFailurePayload(raw)
? {
Error: {
Message: getInquiryErrorMessage(raw, "thirdPartyPlate"),
},
}
: this.mapNewApiResponseToOldFormat(raw);
if (!mapped?.Error) {
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
}
return { raw, mapped };
}
@@ -962,13 +1022,15 @@ export class SandHubService {
}
if (isVinInquiry) {
const raw = await this.lookupsService.findLastProcessedCarPolicy(
"car-body",
{
let raw: any;
try {
raw = await this.lookupsService.findLastProcessedCarPolicy("car-body", {
nationalCode: String(userDetail.nationalCodeOfInsurer),
vin: plateOrVin,
},
);
});
} catch (error) {
this.throwInquiryError(error, "carBodyVin");
}
if (useParsianCarBodyLookup) {
this.assertParsianCarBodyLookupMatchesDeployment();
}
@@ -1000,10 +1062,15 @@ export class SandHubService {
plaqueRight: String(plateOrVin.centerDigits),
plaqueSerial: String(plateOrVin.ir),
};
const raw = await this.lookupsService.findLastProcessedCarPolicy(
"car-body",
query,
);
let raw: any;
try {
raw = await this.lookupsService.findLastProcessedCarPolicy(
"car-body",
query,
);
} catch (error) {
this.throwInquiryError(error, "carBodyPlate");
}
this.assertParsianCarBodyLookupMatchesDeployment();
return {
@@ -1046,6 +1113,11 @@ export class SandHubService {
options,
);
if (isInquiryFailurePayload(raw)) {
throw new BadRequestException(
getInquiryErrorMessage(raw, "carBodyPlate"),
);
}
const mapped = this.mapCarBodyInquiryResponse(raw);
return { raw, mapped };
}
@@ -1114,9 +1186,8 @@ export class SandHubService {
};
}
/**
* ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`).
* ESG two-factor VIN/chassis inquiry (`/inquiry/carByChassis`).
*
* When `vinChassis` inquiry is disabled (mock mode) the response shape mirrors
* `buildMockPlateInquiryRaw` so the downstream mapper (`mapEsgPolicyByPlateToOldFormat`)
@@ -1125,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/carByChassis`;
const requestPayload = {
nationalCode: String(identity.nationalCode),
chassisNo: String(identity.chassis),
chassisNo: String(identity.chassisNo),
};
const live = await this.isInquiryLive("vinChassis", options);
@@ -1142,10 +1213,12 @@ 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);
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
if (!mapped?.Error) {
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
}
return { raw, mapped };
}
@@ -1155,12 +1228,13 @@ export class SandHubService {
"vinChassis",
options,
);
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
if (!mapped?.Error) {
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
}
return { raw, mapped };
}
private async makeSandHubRequest(
url: string,
payload: any,
@@ -1205,7 +1279,7 @@ export class SandHubService {
}
}
throw new BadGatewayException(
"Failed to fetch data from SandHub after multiple retries",
"سرویس استعلام پس از چند تلاش پاسخ نداد. لطفاً کمی بعد دوباره تلاش کنید.",
);
}
@@ -1229,11 +1303,16 @@ export class SandHubService {
// Pattern: {centerDigits}{centerLetter(s)}{leftDigits}<space>{ir}
const m = plk.trim().match(/^(\d+)([^\d\s]+)(\d+)\s+(\d+)$/);
if (!m) return null;
const Plk3 = parseInt(m[1], 10); // center digits
const Plk3 = parseInt(m[1], 10); // center digits
const Plk2 = this.plateNormalizer.normalizePlateText(m[2]);
const Plk1 = parseInt(m[3], 10); // left digits
const Plk1 = parseInt(m[3], 10); // left digits
const PlkSrl = parseInt(m[4], 10); // IR region code
if (!Number.isFinite(Plk3) || !Number.isFinite(Plk1) || !Number.isFinite(PlkSrl) || !Plk2) {
if (
!Number.isFinite(Plk3) ||
!Number.isFinite(Plk1) ||
!Number.isFinite(PlkSrl) ||
!Plk2
) {
return null;
}
return { Plk1, Plk2, Plk3, PlkSrl };
@@ -1245,7 +1324,12 @@ export class SandHubService {
// If the response carries a `plk` plate string (VIN inquiry) but lacks the
// individual Plk1/Plk2/Plk3/PlkSrl fields, parse and inject them so that
// all downstream plate-handling code works identically to the plate flow.
let plkParts: { Plk1: number; Plk2: string; Plk3: number; PlkSrl: number } | null = null;
let plkParts: {
Plk1: number;
Plk2: string;
Plk3: number;
PlkSrl: number;
} | null = null;
if (
newResponse.plk &&
newResponse.Plk1 == null &&
@@ -1258,13 +1342,15 @@ export class SandHubService {
// Map the new field names to the old field names
return {
...newResponse,
...(plkParts ? {
Plk1: plkParts.Plk1,
Plk2: plkParts.Plk2,
Plk3: plkParts.Plk3,
PlkSrl: plkParts.PlkSrl,
plateLetterid: plkParts.Plk2,
} : {}),
...(plkParts
? {
Plk1: plkParts.Plk1,
Plk2: plkParts.Plk2,
Plk3: plkParts.Plk3,
PlkSrl: plkParts.PlkSrl,
plateLetterid: plkParts.Plk2,
}
: {}),
// Company information
CompanyCode: newResponse.companyId || newResponse.CompanyCode,
CompanyName: newResponse.companyPersianName || newResponse.CompanyName,
@@ -1401,7 +1487,7 @@ export class SandHubService {
) {
throw err;
}
throw new Error(err);
this.throwInquiryError(err, "thirdPartyPlate");
}
}
@@ -1420,7 +1506,7 @@ export class SandHubService {
const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate);
if (!jalaliBirthDate) {
throw new BadRequestException(
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13781124 or "1378-11-24").`,
"تاریخ تولد واردشده برای استعلام هویت معتبر نیست.",
);
}
@@ -1454,7 +1540,7 @@ export class SandHubService {
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
if (!gregorianBirthdate) {
throw new BadRequestException(
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13770624 or "1377-06-24").`,
"تاریخ تولد واردشده برای استعلام هویت معتبر نیست.",
);
}
@@ -1472,7 +1558,7 @@ export class SandHubService {
if (response?.message?.includes("err.record.not.found")) {
throw new NotFoundException(
"Personal inquiry failed: Record not found for the given national code and birth date.",
getInquiryErrorMessage(response, "personalIdentity"),
);
}
return response.data;
@@ -1483,7 +1569,7 @@ export class SandHubService {
) {
throw err;
}
throw new Error(`Error in finding personal inquiry: ${err}`);
this.throwInquiryError(err, "personalIdentity");
}
}
@@ -1512,22 +1598,13 @@ export class SandHubService {
if (response?.data?.IsSucceed === false) {
throw new NotFoundException(
"Driving license check failed: The license is not valid or could not be found.",
"گواهینامه‌ای مطابق کد ملی و شماره گواهینامه واردشده یافت نشد.",
);
}
return response.data;
} catch (error) {
if (
error instanceof BadGatewayException &&
error.message.includes("multiple retries")
) {
throw new BadGatewayException(
`Driving license check failed after multiple retries. The service may be down.`,
);
}
// For all other errors (like 400, 404, etc.), re-throw them as-is.
throw new Error(`Error in finding driving license: ${error}`);
this.throwInquiryError(error, "drivingLicense");
}
}
@@ -1563,13 +1640,13 @@ export class SandHubService {
response,
);
throw new BadRequestException(
"Ownership validation failed: The provided national ID is not the owner of this vehicle.",
"پلاک واردشده متعلق به کد ملی واردشده نیست.",
);
}
return response;
} catch (err) {
throw new Error(`Error in finding car ownership: ${err}`);
this.throwInquiryError(err, "carOwnership");
}
}
@@ -1619,7 +1696,7 @@ export class SandHubService {
response,
);
throw new BadRequestException(
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.",
"شماره شبا متعلق به کد ملی واردشده نیست.",
);
}
@@ -1647,7 +1724,7 @@ export class SandHubService {
response,
);
throw new BadRequestException(
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.",
"شماره شبا متعلق به کد ملی واردشده نیست.",
);
}
@@ -1656,7 +1733,7 @@ export class SandHubService {
if (err instanceof BadRequestException) {
throw err;
}
throw new Error(`Error in matching sheba validation: ${err}`);
this.throwInquiryError(err, "sheba");
}
}
@@ -1672,9 +1749,7 @@ export class SandHubService {
);
if (err.response.status === 400) {
throw new BadGatewayException(
`SandHub rejected the request with a 400 Bad Request. Details: ${JSON.stringify(err.response.data)}`,
);
throw new BadGatewayException(getInquiryErrorMessage(err, "generic"));
}
} else {
this.logger.error(
@@ -1685,23 +1760,24 @@ export class SandHubService {
if (err.message === "EMPTY_RESPONSE") {
throw new BadGatewayException(
"SandHub is offline or returned an empty response",
"سرویس استعلام پاسخی برنگرداند. لطفاً دوباره تلاش کنید.",
);
}
if (err.code === "ECONNABORTED") {
throw new GatewayTimeoutException("SandHub request timed out");
throw new GatewayTimeoutException(
"زمان پاسخ‌گویی سرویس استعلام به پایان رسید. لطفاً دوباره تلاش کنید.",
);
}
if (err.code === "ECONNRESET" || err.message.includes("socket hang up")) {
throw new ServiceUnavailableException(
"SandHub connection was reset or closed unexpectedly",
"ارتباط با سرویس استعلام قطع شد. لطفاً دوباره تلاش کنید.",
);
}
// This final check is for when all retries have failed for a retryable error.
if (attempt >= maxRetries) {
if (attempt >= maxRetries - 1) {
throw new BadGatewayException(
"Failed to fetch data from SandHub after multiple retries",
err.message,
"سرویس استعلام پس از چند تلاش پاسخ نداد. لطفاً کمی بعد دوباره تلاش کنید.",
);
}
}