forked from Yara724/api
Compare commits
152 Commits
e4ef95a23e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac185c861 | ||
| f07e510c5f | |||
|
|
8b65e43c60 | ||
| 9dd0ec0c00 | |||
|
|
e06178f804 | ||
| a9adad3c1b | |||
| bbe5b7b77b | |||
| 1e13bb3a3c | |||
| a8d8df574b | |||
| cf07a33951 | |||
| 6f8a6fed69 | |||
| dbf46cfdda | |||
|
|
4ef53f2cc9 | ||
| b4c1d7bd5f | |||
|
|
4cd6e0ecb3 | ||
|
|
8071215803 | ||
| cd36a0f2d4 | |||
| ffaeb50c61 | |||
| 8afc1fabc4 | |||
| 1d9ef5e3d2 | |||
| be06bfe1b5 | |||
| 85576e7c5e | |||
|
|
406b139b3d | ||
|
|
45e0ad883a | ||
| 51e1d495a9 | |||
| 7d1a50db7b | |||
| a84d83a135 | |||
| 631c9bce16 | |||
| a5f534817b | |||
| ebfb4385de | |||
| acb5d2a682 | |||
| a174332d81 | |||
|
|
c9e273fc4e | ||
|
|
ecf69cc332 | ||
| 31f8cabf80 | |||
|
|
fee0bcb5be | ||
|
|
dc30518a7f | ||
| 8173764913 | |||
| c72cb265f1 | |||
| 271dc4df2a | |||
| b2ff3f574b | |||
| 769a581a51 | |||
| 466773fb2b | |||
| 37041ba19f | |||
| 0b0a1dfa13 | |||
| 073b4dec38 | |||
|
|
cb4f02918f | ||
| c1f0d041ce | |||
| f9c60a2854 | |||
| 4310ce6398 | |||
|
|
96b56233bf | ||
| d6b5f8f4d4 | |||
|
|
152250a387 | ||
| ada85f9e0c | |||
|
|
e7103a9408 | ||
| af8bec193b | |||
|
|
9930e9ff5b | ||
| b3a298e254 | |||
| b13b0211ad | |||
| 6e5b7cb1fd | |||
|
|
51166a8d0d | ||
| 4f301d1d03 | |||
|
|
1b69da38ff | ||
| 1941013b00 | |||
|
|
98c7ebb83a | ||
| 461afbb6b9 | |||
|
|
6a26811555 | ||
|
|
f9d4b5df9a | ||
|
|
0eaf55e73c | ||
|
|
5b77241f28 | ||
|
|
cc8a96c875 | ||
|
|
5812637a1e | ||
|
|
287978a555 | ||
|
|
fe22e69351 | ||
|
|
b5b18114d8 | ||
|
|
d060b6d9e2 | ||
|
|
c64f23091a | ||
|
|
401ad6a143 | ||
| ed9ef3bac3 | |||
|
|
bc9c217d8a | ||
| 48c8290f9c | |||
|
|
7fd60df1d6 | ||
| 7f50b75c64 | |||
|
|
9e51fdcdaf | ||
| 58e374d502 | |||
|
|
0bab93e983 | ||
|
|
fc37dd78a4 | ||
| 795ecbae96 | |||
|
|
15342e16d8 | ||
| 2dc25100ea | |||
|
|
ebe45646c1 | ||
| bf515f3011 | |||
|
|
55da8188a9 | ||
| 20aa6957e9 | |||
|
|
0e861ff6d1 | ||
| eb6cef1127 | |||
| f98712ba83 | |||
| 3635dc2c10 | |||
|
|
f037450958 | ||
| 503894bf9c | |||
| b9bffdccc1 | |||
| 395e3dbe63 | |||
|
|
9fc7198f40 | ||
| d860e94dee | |||
| b7857a40f5 | |||
| c7cd9e8ce5 | |||
| 6100ac80f3 | |||
|
|
229957e283 | ||
| 0724d66727 | |||
|
|
7e3b308572 | ||
| 4423b7fa25 | |||
|
|
519967855e | ||
| 01346f950c | |||
| 80e112f09b | |||
| 80b71107a1 | |||
| 470f6e0920 | |||
| 584a550ce2 | |||
|
|
8b34e94de3 | ||
| 706bffd1e7 | |||
|
|
ef9768795d | ||
| 4d2501d90c | |||
|
|
c9b5b6f765 | ||
| d52d18a97c | |||
| 02edd0b2ca | |||
| 5f714e1456 | |||
|
|
233a16d96c | ||
| 6ecbe91ede | |||
| eb636bdbdd | |||
| eb072ef080 | |||
|
|
e48dffef89 | ||
| 4f7ee9a0de | |||
|
|
feacad58ca | ||
| 70fa49398d | |||
| 5c2b660600 | |||
| ebfeef9e01 | |||
| cf74d75146 | |||
| f5a60eafb1 | |||
|
|
ae49031c55 | ||
| 23d636416d | |||
|
|
6880de5960 | ||
| 85bd892720 | |||
|
|
a33466025d | ||
| a5d2f5a2b9 | |||
|
|
226b63aefb | ||
| 5203c07154 | |||
|
|
6566b5f112 | ||
| 24a38a6780 | |||
|
|
dc3748ae94 | ||
| fe83c20e7e | |||
|
|
e5a28238e5 | ||
| 8cf7b7b229 | |||
| 5ec40f66dc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -186,4 +186,3 @@ dist
|
|||||||
*.sh
|
*.sh
|
||||||
!scripts/fanavaran-flow-test.sh
|
!scripts/fanavaran-flow-test.sh
|
||||||
!scripts/fanavaran-auth.sh
|
!scripts/fanavaran-auth.sh
|
||||||
*.json
|
|
||||||
BIN
docs/Yara724 flows.pdf
Normal file
BIN
docs/Yara724 flows.pdf
Normal file
Binary file not shown.
103
docs/architecture.md
Normal file
103
docs/architecture.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
docs/
|
||||||
|
│
|
||||||
|
├── README.md
|
||||||
|
│
|
||||||
|
├── architecture/
|
||||||
|
│ ├── overview.md
|
||||||
|
│ ├── principles.md
|
||||||
|
│ ├── layers.md
|
||||||
|
│ ├── modules.md
|
||||||
|
│ ├── workflow-engine.md
|
||||||
|
│ ├── event-driven.md
|
||||||
|
│ ├── database.md
|
||||||
|
│ ├── caching.md
|
||||||
|
│ ├── authentication.md
|
||||||
|
│ ├── authorization.md
|
||||||
|
│ ├── file-storage.md
|
||||||
|
│ ├── id-generation.md
|
||||||
|
│ ├── error-handling.md
|
||||||
|
│ ├── logging.md
|
||||||
|
│ └── diagrams/
|
||||||
|
│
|
||||||
|
├── adr/
|
||||||
|
│ ├── 0001-use-mongodb.md
|
||||||
|
│ ├── 0002-use-workflow-engine.md
|
||||||
|
│ ├── ...
|
||||||
|
│
|
||||||
|
├── engineering/
|
||||||
|
│ ├── structure.md
|
||||||
|
│ ├── coding-style.md
|
||||||
|
│ ├── naming.md
|
||||||
|
│ ├── comments.md
|
||||||
|
│ ├── exceptions.md
|
||||||
|
│ ├── validation.md
|
||||||
|
│ ├── dto-guidelines.md
|
||||||
|
│ ├── repositories.md
|
||||||
|
│ ├── services.md
|
||||||
|
│ ├── controllers.md
|
||||||
|
│ ├── testing.md
|
||||||
|
│ ├── code-review.md
|
||||||
|
│ ├── gitflow.md
|
||||||
|
│ ├── commit-convention.md
|
||||||
|
│ ├── branching.md
|
||||||
|
│ ├── dependency-rules.md
|
||||||
|
│ ├── security.md
|
||||||
|
│ └── performance.md
|
||||||
|
│
|
||||||
|
├── domain/
|
||||||
|
│ ├── glossary.md
|
||||||
|
│ ├── insurance-concepts.md
|
||||||
|
│ ├── entities.md
|
||||||
|
│ ├── events.md
|
||||||
|
│ ├── workflows.md
|
||||||
|
│ ├── business-rules.md
|
||||||
|
│ └── state-transitions.md
|
||||||
|
│
|
||||||
|
├── flows/
|
||||||
|
│ ├── company-a/
|
||||||
|
│ ├── company-b/
|
||||||
|
│ ├── company-c/
|
||||||
|
│ └── common/
|
||||||
|
│
|
||||||
|
├── api/
|
||||||
|
│ ├── rest.md
|
||||||
|
│ ├── versioning.md
|
||||||
|
│ ├── pagination.md
|
||||||
|
│ ├── errors.md
|
||||||
|
│ └── examples/
|
||||||
|
│
|
||||||
|
├── deployment/
|
||||||
|
│ ├── docker.md
|
||||||
|
│ ├── environments.md
|
||||||
|
│ ├── ci.md
|
||||||
|
│ ├── cd.md
|
||||||
|
│ ├── backups.md
|
||||||
|
│ └── monitoring.md
|
||||||
|
│
|
||||||
|
├── onboarding/
|
||||||
|
│ ├── setup.md
|
||||||
|
│ ├── first-day.md
|
||||||
|
│ ├── debugging.md
|
||||||
|
│ ├── faq.md
|
||||||
|
│ └── common-mistakes.md
|
||||||
|
│
|
||||||
|
├── operations/
|
||||||
|
│ ├── runbooks.md
|
||||||
|
│ ├── incident-response.md
|
||||||
|
│ ├── rca/
|
||||||
|
│ ├── postmortems/
|
||||||
|
│ └── troubleshooting.md
|
||||||
|
│
|
||||||
|
├── backlog/
|
||||||
|
│ ├── ideas.md
|
||||||
|
│ ├── technical-debt.md
|
||||||
|
│ ├── future-features.md
|
||||||
|
│ └── experiments.md
|
||||||
|
│
|
||||||
|
├── decisions/
|
||||||
|
│ ├── rejected-ideas.md
|
||||||
|
│ ├── deprecated.md
|
||||||
|
│ └── migration-plans.md
|
||||||
|
│
|
||||||
|
├── changelog.md
|
||||||
|
└── roadmap.md
|
||||||
@@ -461,6 +461,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Optional document shared by every claim flow</div>
|
||||||
|
<p class="step-note">
|
||||||
|
Every V1–V6 document-upload step also offers
|
||||||
|
<code>accident_sketch</code> (کروکی). It is optional for both
|
||||||
|
<code>THIRD_PARTY</code> and <code>CAR_BODY</code>, may be omitted without
|
||||||
|
blocking progression, and is returned in expert and insurer case details when uploaded.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
<h2>Flow V1 / V2 — User-Initiated Blame & Claim</h2>
|
<h2>Flow V1 / V2 — User-Initiated Blame & Claim</h2>
|
||||||
<p class="section-intro">
|
<p class="section-intro">
|
||||||
@@ -1364,14 +1374,15 @@
|
|||||||
<div class="step-num teal">10</div>
|
<div class="step-num teal">10</div>
|
||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
<div class="step-label">
|
<div class="step-label">
|
||||||
Owner sign on expert pricing (FileReviewer acts on
|
Claim completes after expert work (no final owner
|
||||||
behalf of user)
|
signature)
|
||||||
</div>
|
</div>
|
||||||
<div class="step-route">
|
<div class="step-route">
|
||||||
PUT /claim-sign/:claimId
|
Expert submits to Fanavaran manually when ready
|
||||||
</div>
|
</div>
|
||||||
<div class="step-note">
|
<div class="step-note">
|
||||||
Submits agree + branchId + signature image
|
Mixed priced/factor claims still require priced-line
|
||||||
|
acceptance before factor uploads
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1395,11 +1406,13 @@
|
|||||||
FileMaker steps are identical to V4 except
|
FileMaker steps are identical to V4 except
|
||||||
<code>requiresFileMakerApproval=true</code> is set at creation. The
|
<code>requiresFileMakerApproval=true</code> is set at creation. The
|
||||||
FileReviewer steps are also identical to V4. The only difference is the
|
FileReviewer steps are also identical to V4. The only difference is the
|
||||||
tail: after the damage expert completes their review and the owner signs,
|
tail: after the damage expert completes their review (and any required factors
|
||||||
|
are validated),
|
||||||
the claim moves to
|
the claim moves to
|
||||||
<strong>WAITING_FOR_FILE_MAKER_APPROVAL</strong> instead of proceeding
|
<strong>WAITING_FOR_FILE_MAKER_APPROVAL</strong> instead of proceeding
|
||||||
directly to fanavaran submission. The FileMaker then approves (→ fanavaran)
|
directly to completion. The FileMaker then approves (→ COMPLETED; manual
|
||||||
or rejects (→ back to WAITING_FOR_DAMAGE_EXPERT, max 2 rejections).
|
Fanavaran submission) or rejects (→ back to WAITING_FOR_DAMAGE_EXPERT,
|
||||||
|
max 2 rejections).
|
||||||
</p>
|
</p>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="roles-row">
|
<div class="roles-row">
|
||||||
@@ -1468,7 +1481,7 @@
|
|||||||
Claim → WAITING_FOR_FILE_MAKER_APPROVAL
|
Claim → WAITING_FOR_FILE_MAKER_APPROVAL
|
||||||
</div>
|
</div>
|
||||||
<div class="step-note">
|
<div class="step-note">
|
||||||
(instead of direct fanavaran submit)
|
(before final completion; no owner signature)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1496,7 +1509,7 @@
|
|||||||
<div class="step-num orange">✓</div>
|
<div class="step-num orange">✓</div>
|
||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
<div class="step-label">
|
<div class="step-label">
|
||||||
Fanavaran submission (on approve)
|
Claim COMPLETED (on approve)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1845,8 +1858,8 @@
|
|||||||
<td>DAMAGE_EXPERT</td>
|
<td>DAMAGE_EXPERT</td>
|
||||||
<td>DAMAGE_EXPERT</td>
|
<td>DAMAGE_EXPERT</td>
|
||||||
<td>DAMAGE_EXPERT</td>
|
<td>DAMAGE_EXPERT</td>
|
||||||
<td>DAMAGE_EXPERT → owner sign (FileReviewer)</td>
|
<td>DAMAGE_EXPERT → COMPLETED (manual Fanavaran submit)</td>
|
||||||
<td>DAMAGE_EXPERT → owner sign → FileMaker approval</td>
|
<td>DAMAGE_EXPERT → FileMaker approval → COMPLETED (manual Fanavaran submit)</td>
|
||||||
<td>DAMAGE_EXPERT (standard V2)</td>
|
<td>DAMAGE_EXPERT (standard V2)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
228
docs/expert-claim-reply-submit-v2-frontend.fa.md
Normal file
228
docs/expert-claim-reply-submit-v2-frontend.fa.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# راهنمای فرانتاند: ثبت پاسخ کارشناس خسارت (V2)
|
||||||
|
|
||||||
|
این مستند قرارداد API زیر را توضیح میدهد، بهویژه اعتبارسنجی مبلغها و ساختار خطاهایی که باید در فرم نمایش داده شوند.
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /v2/expert-claim/reply/submit/:claimRequestId
|
||||||
|
Authorization: Bearer <actor-token>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر محیط شما API را زیر پیشوند `/api` منتشر میکند، مسیر نهایی بهصورت `/api/v2/expert-claim/reply/submit/:claimRequestId` است.
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
|
||||||
|
- کاربر باید نقش کارشناس مجاز داشته باشد.
|
||||||
|
- پرونده باید پیش از ارسال توسط همین کارشناس قفل شده باشد.
|
||||||
|
- `partId` هر ردیف باید از `damagedParts[].partId` در جزئیات پرونده انتخاب شود؛ شناسه دلخواه ارسال نکنید.
|
||||||
|
- دستکم یک ردیف در `parts` لازم است.
|
||||||
|
|
||||||
|
## بدنه درخواست
|
||||||
|
|
||||||
|
تمام مبلغها باید **string** باشند. ارقام فارسی/انگلیسی و جداکننده هزارگان پذیرفته میشوند؛ برای نمونه هر دو مقدار `"100000"` و `"۱۰۰,۰۰۰"` معتبرند.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"description": "تعویض درب جلو لازم است",
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"partId": 201,
|
||||||
|
"typeOfDamage": "تعویض",
|
||||||
|
"price": "2500000",
|
||||||
|
"salary": "400000",
|
||||||
|
"totalPayment": "2900000",
|
||||||
|
"factorNeeded": false,
|
||||||
|
"daghi": {
|
||||||
|
"option": "ارزش لوازم بازیافتی",
|
||||||
|
"price": "300000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### فیلدهای سطح بالا
|
||||||
|
|
||||||
|
| فیلد | الزامی | توضیح |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `description` | خیر | یادداشت متنی کارشناس. میتواند ارسال نشود. |
|
||||||
|
| `parts` | بله | آرایهای با حداقل یک ردیف قیمتگذاری. |
|
||||||
|
|
||||||
|
### فیلدهای هر ردیف `parts[i]`
|
||||||
|
|
||||||
|
| فیلد | الزامی | مقدار و قاعده |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `partId` | بله | عدد صحیح؛ باید متعلق به قطعات آسیبدیده همان پرونده باشد. |
|
||||||
|
| `typeOfDamage` | بله | دقیقاً یکی از `"تعمیر"` یا `"تعویض"`. |
|
||||||
|
| `price` | برای `"تعویض"` بله؛ برای `"تعمیر"` اختیاری | در صورت ارسال، مبلغ بین 100,000 تا 10,000,000,000 تومان. |
|
||||||
|
| `salary` | بله | مبلغ بین 100,000 تا 10,000,000,000 تومان. |
|
||||||
|
| `totalPayment` | بله | مبلغ بین 1,000,000 تا 100,000,000,000 ریال. فقط در جریان V1، مجموع این فیلدها در کل پرونده نباید از 530,000,000 ریال بیشتر شود. |
|
||||||
|
| `factorNeeded` | بله | مقدار Boolean واقعی (`true` یا `false`)؛ رشته ارسال نکنید. |
|
||||||
|
| `daghi` | برای `"تعویض"` بله | برای `"تعمیر"` لازم نیست و در ثبت نهایی حذف میشود. |
|
||||||
|
| `daghi.option` | در صورت وجود `daghi` بله | یکی از `"ارزش لوازم بازیافتی"`، `"تحویل داغی"`، `"فاقد ارزش"` یا `"با احتساب داغی"`. |
|
||||||
|
| `daghi.price` | فقط وقتی `option` برابر `"ارزش لوازم بازیافتی"` است | مبلغ بین 100,000 تا 10,000,000,000 تومان. |
|
||||||
|
| `daghi.branchId` | فقط وقتی `option` برابر `"تحویل داغی"` است | Mongo ObjectId معتبرِ شعبه. |
|
||||||
|
|
||||||
|
نکتهها:
|
||||||
|
|
||||||
|
- مقدار `0` برای هیچ مبلغ ارسالی این endpoint معتبر نیست؛ حداقل مبلغ 100,000 تومان است.
|
||||||
|
- محدودیت هر فیلد مبلغ مستقل است؛ سقف 530 میلیون ریال فقط برای **مجموع `totalPayment` تمام ردیفهای پرونده V1** اعمال میشود. جریانهای V2 تا V6 سقف مجموع ندارند.
|
||||||
|
- فیلدهای ناشناخته در body حذف میشوند. فرانتاند نباید برای انتقال داده به آنها تکیه کند.
|
||||||
|
|
||||||
|
## پاسخ موفق
|
||||||
|
|
||||||
|
پاسخ `200` شامل مسیر بعدی workflow است. نمونه:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"claimRequestId": "66c...",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"claimStatus": "APPROVED",
|
||||||
|
"currentStep": "CLAIM_COMPLETED",
|
||||||
|
"workflowNextStep": "CLAIM_COMPLETED",
|
||||||
|
"factorNeeded": false,
|
||||||
|
"mixedPricingAndFactors": false,
|
||||||
|
"allPartsFactorNeeded": false,
|
||||||
|
"isFinalReplyAfterObjection": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
مقادیر `status` و step به این وابستهاند که ردیف factor داشته باشید یا نه؛ فرانتاند باید از مقادیر پاسخ استفاده کند، نه اینکه مسیر بعدی را فقط از payload حدس بزند.
|
||||||
|
|
||||||
|
## قرارداد خطا
|
||||||
|
|
||||||
|
همه خطاهای این endpoint JSON هستند و NestJS مقدار `statusCode` را نیز به پاسخ اضافه میکند. برای نمایش پیام به کاربر از `message` استفاده کنید و برای منطق برنامه از `code` استفاده کنید؛ متن فارسی را با متن ثابت در فرانتاند جایگزین نکنید.
|
||||||
|
|
||||||
|
### 1. خطای ساختار DTO — `400`
|
||||||
|
|
||||||
|
برای نبودن فیلد الزامی، نوع اشتباه، enum نامعتبر، آرایه خالی، یا فرمت مبلغ نامعتبر، پاسخ زیر برمیگردد:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 400,
|
||||||
|
"message": "اطلاعات ارسالی پاسخ کارشناسی معتبر نیست.",
|
||||||
|
"error": "EXPERT_REPLY_VALIDATION_ERROR",
|
||||||
|
"code": "EXPERT_REPLY_VALIDATION_ERROR",
|
||||||
|
"validationErrors": [
|
||||||
|
{
|
||||||
|
"field": "parts[0].salary",
|
||||||
|
"message": "باید مبلغ صحیح و غیرمنفی به تومان باشد."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "parts[0].factorNeeded",
|
||||||
|
"message": "باید درست یا نادرست باشد."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`validationErrors` ممکن است چند خطا داشته باشد. کلید `field` دقیقاً برای اتصال به کنترل فرم است؛ مانند `parts[0].daghi.price` یا `parts[2].partId`.
|
||||||
|
|
||||||
|
### 2. خطای قواعد مبلغ و قیمتگذاری — `400`
|
||||||
|
|
||||||
|
این خطاها پس از اعتبارسنجی ساختار و پیش از تغییر وضعیت پرونده بررسی میشوند. برای مثال، مبلغ کمتر از حداقل:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 400,
|
||||||
|
"message": "قیمت قطعه 201 باید حداقل ۱۰۰٬۰۰۰ و حداکثر ۱۰٬۰۰۰٬۰۰۰٬۰۰۰ تومان باشد.",
|
||||||
|
"error": "EXPERT_REPLY_VALIDATION_ERROR",
|
||||||
|
"code": "EXPERT_REPLY_VALIDATION_ERROR",
|
||||||
|
"field": "parts[0].price",
|
||||||
|
"partId": "201",
|
||||||
|
"rule": "amount_out_of_range",
|
||||||
|
"minAmount": 100000,
|
||||||
|
"maxAmount": 10000000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`rule` یکی از این مقادیر است:
|
||||||
|
|
||||||
|
| مقدار | معنی |
|
||||||
|
| --- | --- |
|
||||||
|
| `required` | فیلد لازم ارسال نشده است. |
|
||||||
|
| `invalid_value` | مقدار enum یا شناسه معتبر نیست. |
|
||||||
|
| `invalid_amount` | مبلغ، عدد صحیح غیرمنفی به تومان نیست. |
|
||||||
|
| `amount_out_of_range` | مبلغ خارج از بازه `minAmount` و `maxAmount` است. |
|
||||||
|
|
||||||
|
در صورت `amount_out_of_range`، برای نمایش محدوده از اعداد `minAmount` و `maxAmount` استفاده کنید، نه از parse کردن متن `message`.
|
||||||
|
|
||||||
|
### 3. سقف مجموع مبلغها — `400`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 400,
|
||||||
|
"message": "مجموع مبلغ قطعات (۵۴۰٬۰۰۰٬۰۰۰) از سقف مجاز (۵۳۰٬۰۰۰٬۰۰۰) ریال بیشتر است.",
|
||||||
|
"error": "PRICE_CAP_ERROR",
|
||||||
|
"code": "PRICE_CAP_ERROR",
|
||||||
|
"totalPrice": 540000000,
|
||||||
|
"priceCap": 530000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
این خطا فقط برای پروندههای V1 رخ میدهد و به یک ردیف مشخص وصل نیست. آن را در بالای جدول قیمتها نمایش دهید و از `priceCap` جزئیات پرونده/خطا برای پیام UI استفاده کنید. مقدار `priceCap: null` یعنی پرونده سقف مجموع ندارد.
|
||||||
|
|
||||||
|
### 4. خطاهای قواعد پرونده و workflow
|
||||||
|
|
||||||
|
این خطاها ساختار مشترک زیر را دارند:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 400,
|
||||||
|
"message": "قطعه با شناسه 201 در فهرست قطعات آسیبدیده پرونده وجود ندارد.",
|
||||||
|
"error": "EXPERT_REPLY_SUBMISSION_ERROR",
|
||||||
|
"code": "PART_NOT_ON_CLAIM",
|
||||||
|
"field": "partId",
|
||||||
|
"partId": 201
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
کدهای مهم:
|
||||||
|
|
||||||
|
| HTTP | `code` | رفتار پیشنهادی فرانتاند |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 404 | `CLAIM_NOT_FOUND` | پیام خطا و بازگشت به فهرست پروندهها. |
|
||||||
|
| 400 | `CLAIM_NOT_REVIEWABLE` | جزئیات پرونده را refresh کنید؛ ثبت در وضعیت فعلی مجاز نیست. |
|
||||||
|
| 403 | `CLAIM_NOT_LOCKED` | کاربر باید ابتدا پرونده را قفل کند. |
|
||||||
|
| 403 | `CLAIM_LOCKED_BY_ANOTHER_EXPERT` | فرم را read-only کنید و پیام مناسب نمایش دهید. |
|
||||||
|
| 400 | `PART_NOT_ON_CLAIM` | داده قطعات را refresh کنید و ردیف خطادار را اصلاح/حذف کنید. |
|
||||||
|
| 400 | `PART_INVALID` / `PART_ID_INVALID` | ردیف دارای `partId` را اصلاح کنید. |
|
||||||
|
| 400 | `DUPLICATE_PART` | ردیف تکراری را حذف کنید. |
|
||||||
|
| 400 | `DAGHI_OPTION_REQUIRED` | فیلد `daghi.option` همان ردیف را نشاندار کنید. |
|
||||||
|
| 400 | `DAGHI_PRICE_REQUIRED` | فیلد `daghi.price` همان ردیف را نشاندار کنید. |
|
||||||
|
| 400 | `DAGHI_BRANCH_REQUIRED` / `DAGHI_BRANCH_INVALID` | فیلد `daghi.branchId` همان ردیف را نشاندار کنید. |
|
||||||
|
| 409 | `FINAL_REPLY_ALREADY_SUBMITTED` | ثبت مجدد ممنوع است؛ داده پرونده را refresh کنید. |
|
||||||
|
|
||||||
|
برای خطاهای داغی که از سرویس برمیگردند، `field` به شکل `parts[<index>].daghi.<property>` است.
|
||||||
|
|
||||||
|
## الگوی پیشنهادی هندل کردن خطا در فرانتاند
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ApiErrorBody = {
|
||||||
|
message?: string;
|
||||||
|
code?: string;
|
||||||
|
field?: string;
|
||||||
|
validationErrors?: Array<{ field: string; message: string }>;
|
||||||
|
minAmount?: number;
|
||||||
|
maxAmount?: number;
|
||||||
|
priceCap?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function applySubmitError(body: ApiErrorBody) {
|
||||||
|
if (body.validationErrors?.length) {
|
||||||
|
for (const issue of body.validationErrors) {
|
||||||
|
setFieldError(issue.field, issue.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.field) {
|
||||||
|
setFieldError(body.field, body.message ?? "مقدار واردشده معتبر نیست.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormError(body.message ?? "ثبت پاسخ کارشناسی انجام نشد.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
قبل از ارسال، فرانتاند میتواند همین بازه مبلغ را برای تجربه کاربری بهتر بررسی کند؛ با این حال اعتبار نهایی همیشه پاسخ API است. جمع `totalPayment` را فقط وقتی `priceCap` جزئیات پرونده عدد است با سقف مقایسه کنید؛ `null` یعنی V2 تا V6 و بدون سقف مجموع.
|
||||||
@@ -433,6 +433,23 @@ curl -X POST "$ESG_URL/inquiry/policyByPlate" \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Car By Chassis (Two-Factor VIN Inquiry)
|
||||||
|
|
||||||
|
Use the two-factor chassis route when the backend must match both the VIN and
|
||||||
|
the resolved third-party policyholder. Do not send `nationalCode` to the
|
||||||
|
one-factor `policyByChassis` route.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST "$ESG_URL/inquiry/carByChassis" \
|
||||||
|
-H "Authorization: Bearer $ESG_ACCESS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
--data '{
|
||||||
|
"nationalCode": "0012345678",
|
||||||
|
"chassisNo": "NAAR03HFFRDE07024"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
### Person Inquiry
|
### Person Inquiry
|
||||||
|
|
||||||
ESG expects Jalali birth date, normalized as `YYYY-MM-DD`.
|
ESG expects Jalali birth date, normalized as `YYYY-MM-DD`.
|
||||||
|
|||||||
@@ -159,8 +159,9 @@
|
|||||||
<div class="decision-tree">
|
<div class="decision-tree">
|
||||||
<strong>برای هر پرسوجوی مبتنی بر پلاک:</strong>
|
<strong>برای هر پرسوجوی مبتنی بر پلاک:</strong>
|
||||||
<ul>
|
<ul>
|
||||||
|
<li>درخواست همیشه با پلاک فعلی ارسالشده و بیمهگذار نهایی همان نوع بیمه انجام میشود. متادیتای انتقال اخیر هیچ استعلامی برای پلاک یا بیمهگذار قبلی ایجاد نمیکند.</li>
|
||||||
<li>۱. بررسی دادههای آفلاین (MongoDB) — اگر داده مطابق یافت شد، آن را برگردانده و تمام HTTP را رد کن.</li>
|
<li>۱. بررسی دادههای آفلاین (MongoDB) — اگر داده مطابق یافت شد، آن را برگردانده و تمام HTTP را رد کن.</li>
|
||||||
<li>۲. اگر <code>CLIENT_ID=8</code> (تنانت پارسیان/ESG) → مسیریابی به <strong>ESG</strong> <code>/inquiry/policyByPlate</code> یا <code>/inquiry/policyByChassis</code>.</li>
|
<li>۲. اگر <code>CLIENT_ID=8</code> (تنانت پارسیان/ESG) → برای پلاک به <strong>ESG</strong> <code>/inquiry/policyByPlate</code> و برای VIN/شاسی به مسیر دوعاملی <code>/inquiry/carByChassis</code> مسیریابی میشود.</li>
|
||||||
<li>۳. در غیر این صورت → مسیریابی به <strong>پرسوجوی تجارت</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) یا <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>
|
<li>۳. در غیر این صورت → مسیریابی به <strong>پرسوجوی تجارت</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) یا <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>
|
||||||
<li>۴. اگر <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (پیشفرض) → پاسخ mock برگردانده شود به جای انجام فراخوانیهای HTTP.</li>
|
<li>۴. اگر <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (پیشفرض) → پاسخ mock برگردانده شود به جای انجام فراخوانیهای HTTP.</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -207,7 +208,7 @@
|
|||||||
<div class="card card-blue">
|
<div class="card card-blue">
|
||||||
<h3>پروتکل ارسال خسارت (۴ مرحله)</h3>
|
<h3>پروتکل ارسال خسارت (۴ مرحله)</h3>
|
||||||
<div class="flow-box">
|
<div class="flow-box">
|
||||||
<div class="flow-step"><div class="flow-num">۱</div><div class="flow-text"><strong>خسارت پایه (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. دادههای مالک، راننده، بیمه، وسیله نقلیه و تصادف را ارسال میکند. یک <code>claimId</code> و <code>claimNo</code> فناوران برمیگرداند. پیامک با هر دو شناسه برای مالک ارسال میشود.</div></div>
|
<div class="flow-step"><div class="flow-num">۱</div><div class="flow-text"><strong>خسارت پایه (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. دادههای مالک، راننده، بیمه، وسیله نقلیه و تصادف را ارسال میکند. یک <code>claimId</code> و <code>claimNo</code> فناوران برمیگرداند که برای نمایش در پنل ذخیره میشوند.</div></div>
|
||||||
<div class="flow-step"><div class="flow-num">۲</div><div class="flow-text"><strong>موارد خسارت (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. یک ورودی به ازای هر قطعه آسیبدیده با شناسه کامپوننت، شدت و قیمت. سقف: کل ≤ ۵۳،۰۰۰،۰۰۰ تومان.</div></div>
|
<div class="flow-step"><div class="flow-num">۲</div><div class="flow-text"><strong>موارد خسارت (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. یک ورودی به ازای هر قطعه آسیبدیده با شناسه کامپوننت، شدت و قیمت. سقف: کل ≤ ۵۳،۰۰۰،۰۰۰ تومان.</div></div>
|
||||||
<div class="flow-step"><div class="flow-num">۳</div><div class="flow-text"><strong>پیوستها (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. اسناد، تصاویر car-capture و ویدیوها که با شناسه فایل ارجاع داده شدهاند.</div></div>
|
<div class="flow-step"><div class="flow-num">۳</div><div class="flow-text"><strong>پیوستها (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. اسناد، تصاویر car-capture و ویدیوها که با شناسه فایل ارجاع داده شدهاند.</div></div>
|
||||||
<div class="flow-step"><div class="flow-num">۴</div><div class="flow-text"><strong>کارشناسی (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. متادیتای ارزیابی کارشناس (نقش کارشناس، تاریخ، نتیجه). ارسال را نهایی میکند.</div></div>
|
<div class="flow-step"><div class="flow-num">۴</div><div class="flow-text"><strong>کارشناسی (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. متادیتای ارزیابی کارشناس (نقش کارشناس، تاریخ، نتیجه). ارسال را نهایی میکند.</div></div>
|
||||||
@@ -358,12 +359,12 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
|
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>جستجوی بیمهنامه مبتنی بر پلاک (THIRD_PARTY). بدنه: <code>nationalCode</code>، <code>plk1–plk4</code>. پاسخ قبل از ذخیره به فرمت قدیمی تجارت نگاشت میشود.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>جستجوی بیمهنامه مبتنی بر پلاک (THIRD_PARTY). بدنه: <code>nationalCode</code>، <code>plk1–plk4</code>. پاسخ قبل از ذخیره به فرمت قدیمی تجارت نگاشت میشود.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByChassis</code></td><td>جایگزین مبتنی بر VIN/شاسی برای پرسوجوی پلاک. توسط اندپوینتهای <code>run-inquiries-vin</code> فراخوانی میشود. از جستجوی شاسی ESG استفاده میکند (نه مسیر SandHub). بدنه: <code>nationalCode</code>، <code>chassis</code>.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/carByChassis</code></td><td>جایگزین دوعاملی VIN/شاسی برای استعلام پلاک. توسط اندپوینتهای <code>run-inquiries-vin</code> فراخوانی میشود. بدنه: <code>nationalCode</code>، <code>chassisNo</code>. مسیر تکعاملی <code>policyByChassis</code> استفاده نمیشود، چون فیلد <code>nationalCode</code> را نمیپذیرد.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>بررسی هویت شخصی. بدنه: <code>nationalCode</code>، <code>birthDate</code> (جلالی، نه میلادی).</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>بررسی هویت شخصی. بدنه: <code>nationalCode</code>، <code>birthDate</code> (جلالی، نه میلادی).</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>اعتبارسنجی شبا / حساب بانکی.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>اعتبارسنجی شبا / حساب بانکی.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p class="note" style="margin-top:8px;">
|
<p class="note" style="margin-top:8px;">
|
||||||
ESG هر پاسخ را به صورت <code>{ success: boolean, data: … }</code> میپیچد. یک بدنه <code>success=false</code> به یک خطای فارسی "استعلام در دسترس نیست" ترجمه میشود.
|
ESG هر پاسخ را به صورت <code>{ success: boolean, data: … }</code> میپیچد. در envelope نرمالشده خطا، بکاند مقدار <code>error.messageFa</code> را بدون تغییر به فراخواننده برمیگرداند؛ فیلدهای فنی مانند <code>message</code>، <code>providerMessage</code> و <code>providerCode</code> برای ثبت لاگ و دستهبندی حفظ میشوند. خطای کسبوکاری «یافت نشد» بهعنوان قطعی سرویس گزارش نمیشود.
|
||||||
بررسی داده آفلاین-پرسوجو هنوز ابتدا اجرا میشود، قبل از هر فراخوانی HTTP ESG.
|
بررسی داده آفلاین-پرسوجو هنوز ابتدا اجرا میشود، قبل از هر فراخوانی HTTP ESG.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,7 +415,7 @@
|
|||||||
<tr><td><code>token</code> = "تصادف"/"خسارت"، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس</td><td>کارشناس یک فایل تقصیر یا خسارت را قفل میکند</td><td><code>yara-expert-lock</code></td></tr>
|
<tr><td><code>token</code> = "تصادف"/"خسارت"، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس</td><td>کارشناس یک فایل تقصیر یا خسارت را قفل میکند</td><td><code>yara-expert-lock</code></td></tr>
|
||||||
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = لینک</td><td>کارشناس درخواست ارسال مجدد اسناد میدهد</td><td><code>yara-resend-documents</code></td></tr>
|
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = لینک</td><td>کارشناس درخواست ارسال مجدد اسناد میدهد</td><td><code>yara-resend-documents</code></td></tr>
|
||||||
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس، <code>token10</code> = لینک</td><td>طرف مطلع میشود که ارزیابی خسارت کارشناس را امضا کند</td><td><code>yara-signature</code></td></tr>
|
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس، <code>token10</code> = لینک</td><td>طرف مطلع میشود که ارزیابی خسارت کارشناس را امضا کند</td><td><code>yara-signature</code></td></tr>
|
||||||
<tr><td><code>token</code> = publicId، <code>token2</code> = claimId فناوران، <code>token3</code> = claimNo فناوران</td><td>ارسال فناوران تأیید شد — با شماره و شناسه خسارت فناوران برای مالک خسارت ارسال میشود</td><td><code>yara-fanavaran-claim</code></td></tr>
|
<tr><td><code>token</code> = publicId، <code>token2</code> = claimId فناوران، <code>token3</code> = claimNo فناوران</td><td>قالب قدیمی نگهداری شده است؛ ارسال خودکار پس از آخرین مرحله فناوران غیرفعال است</td><td><code>yara-fanavaran-claim</code></td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p class="note" style="margin-top:8px;">
|
<p class="note" style="margin-top:8px;">
|
||||||
تمام فراخوانیهای پیامک fire-and-forget هستند — هرگز throw نمیکنند. شکستها log میشوند اما جریان اصلی را مسدود نمیکنند.
|
تمام فراخوانیهای پیامک fire-and-forget هستند — هرگز throw نمیکنند. شکستها log میشوند اما جریان اصلی را مسدود نمیکنند.
|
||||||
|
|||||||
@@ -154,8 +154,9 @@
|
|||||||
<div class="decision-tree">
|
<div class="decision-tree">
|
||||||
<strong>For every plate-based block inquiry:</strong>
|
<strong>For every plate-based block inquiry:</strong>
|
||||||
<ul>
|
<ul>
|
||||||
|
<li>The request always uses the submitted current plate and the resolved policyholder for that policy type. Recent-transfer metadata never triggers a previous-plate or previous-policyholder lookup.</li>
|
||||||
<li>1. Check offline-inquiry seeds (MongoDB) — if a matching seed exists, return it and skip all HTTP.</li>
|
<li>1. Check offline-inquiry seeds (MongoDB) — if a matching seed exists, return it and skip all HTTP.</li>
|
||||||
<li>2. If <code>CLIENT_ID=8</code> (Parsian/ESG tenant) → route to <strong>ESG</strong> <code>/inquiry/policyByPlate</code> or <code>/inquiry/policyByChassis</code>.</li>
|
<li>2. If <code>CLIENT_ID=8</code> (Parsian/ESG tenant) → route to <strong>ESG</strong> <code>/inquiry/policyByPlate</code> for plates or the two-factor <code>/inquiry/carByChassis</code> for VIN/chassis inquiries.</li>
|
||||||
<li>3. Otherwise → route to <strong>Tejarat inquiry</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) or <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>
|
<li>3. Otherwise → route to <strong>Tejarat inquiry</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) or <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>
|
||||||
<li>4. If <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (default) → return mock response instead of making HTTP calls.</li>
|
<li>4. If <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (default) → return mock response instead of making HTTP calls.</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -203,7 +204,7 @@
|
|||||||
<div class="card card-blue">
|
<div class="card card-blue">
|
||||||
<h3>Claim submission protocol (4 steps)</h3>
|
<h3>Claim submission protocol (4 steps)</h3>
|
||||||
<div class="flow-box">
|
<div class="flow-box">
|
||||||
<div class="flow-step"><div class="flow-num">1</div><div class="flow-text"><strong>Base claim (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. Sends owner, driver, insurance, vehicle, and accident data. Returns a Fanavaran <code>claimId</code> and <code>claimNo</code>. SMS is sent to the owner with both identifiers.</div></div>
|
<div class="flow-step"><div class="flow-num">1</div><div class="flow-text"><strong>Base claim (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. Sends owner, driver, insurance, vehicle, and accident data. Returns a Fanavaran <code>claimId</code> and <code>claimNo</code>, which are persisted for panel display.</div></div>
|
||||||
<div class="flow-step"><div class="flow-num">2</div><div class="flow-text"><strong>Damage cases (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. One entry per damaged part with component ID, severity, and price. Cap: total ≤ 53 000 000 Toman.</div></div>
|
<div class="flow-step"><div class="flow-num">2</div><div class="flow-text"><strong>Damage cases (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. One entry per damaged part with component ID, severity, and price. Cap: total ≤ 53 000 000 Toman.</div></div>
|
||||||
<div class="flow-step"><div class="flow-num">3</div><div class="flow-text"><strong>Attachments (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. Documents, car-capture images, and videos referenced by file ID.</div></div>
|
<div class="flow-step"><div class="flow-num">3</div><div class="flow-text"><strong>Attachments (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. Documents, car-capture images, and videos referenced by file ID.</div></div>
|
||||||
<div class="flow-step"><div class="flow-num">4</div><div class="flow-text"><strong>Expertise (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. Expert assessment metadata (expert role, date, result). Finalises the submission.</div></div>
|
<div class="flow-step"><div class="flow-num">4</div><div class="flow-text"><strong>Expertise (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. Expert assessment metadata (expert role, date, result). Finalises the submission.</div></div>
|
||||||
@@ -355,12 +356,12 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
|
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>Plate-based policy lookup (THIRD_PARTY). Body: <code>nationalCode</code>, <code>plk1–plk4</code>. Response is mapped to the old Tejarat format before being stored.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>Plate-based policy lookup (THIRD_PARTY). Body: <code>nationalCode</code>, <code>plk1–plk4</code>. Response is mapped to the old Tejarat format before being stored.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByChassis</code></td><td>VIN/chassis-based alternative to plate inquiry. Called by <code>run-inquiries-vin</code> endpoints. Uses ESG chassis lookup (not the SandHub path). Body: <code>nationalCode</code>, <code>chassis</code>.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/carByChassis</code></td><td>Two-factor VIN/chassis alternative to the plate inquiry. Called by <code>run-inquiries-vin</code> endpoints. Body: <code>nationalCode</code>, <code>chassisNo</code>. The one-factor <code>policyByChassis</code> route is not used because it rejects <code>nationalCode</code>.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>Personal identity check. Body: <code>nationalCode</code>, <code>birthDate</code> (Jalali, NOT Gregorian).</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>Personal identity check. Body: <code>nationalCode</code>, <code>birthDate</code> (Jalali, NOT Gregorian).</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p class="note" style="margin-top:8px;">
|
<p class="note" style="margin-top:8px;">
|
||||||
ESG wraps every response as <code>{ success: boolean, data: … }</code>. A <code>success=false</code> body is translated to a Persian "استعلام در دسترس نیست" (inquiry unavailable) error.
|
ESG wraps every response as <code>{ success: boolean, data: … }</code>. For normalized error envelopes, the backend returns <code>error.messageFa</code> unchanged to the caller; technical fields such as <code>message</code>, <code>providerMessage</code>, and <code>providerCode</code> remain available for logging and classification. A business-level not-found response is not reported as a provider outage.
|
||||||
The offline-inquiry seed check still runs first, before any ESG HTTP call.
|
The offline-inquiry seed check still runs first, before any ESG HTTP call.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -411,7 +412,7 @@
|
|||||||
<tr><td><code>yara-expert-lock</code></td><td>Expert locks a blame or claim file</td><td><code>token</code> = "تصادف"/"خسارت", <code>token2</code> = publicId, <code>token3</code> = expert surname</td></tr>
|
<tr><td><code>yara-expert-lock</code></td><td>Expert locks a blame or claim file</td><td><code>token</code> = "تصادف"/"خسارت", <code>token2</code> = publicId, <code>token3</code> = expert surname</td></tr>
|
||||||
<tr><td><code>yara-resend-documents</code></td><td>Expert requests document resend</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = link</td></tr>
|
<tr><td><code>yara-resend-documents</code></td><td>Expert requests document resend</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = link</td></tr>
|
||||||
<tr><td><code>yara-signature</code></td><td>Party notified to sign the expert's damage assessment</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = expert surname, <code>token10</code> = link</td></tr>
|
<tr><td><code>yara-signature</code></td><td>Party notified to sign the expert's damage assessment</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = expert surname, <code>token10</code> = link</td></tr>
|
||||||
<tr><td><code>yara-fanavaran-claim</code></td><td>Fanavaran submission confirmed — sent to claim owner with Fanavaran claim number and ID</td><td><code>token</code> = publicId, <code>token2</code> = Fanavaran claimId, <code>token3</code> = Fanavaran claimNo</td></tr>
|
<tr><td><code>yara-fanavaran-claim</code></td><td>Retained legacy template; automatic dispatch after the final Fanavaran stage is disabled</td><td><code>token</code> = publicId, <code>token2</code> = Fanavaran claimId, <code>token3</code> = Fanavaran claimNo</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p class="note" style="margin-top:8px;">
|
<p class="note" style="margin-top:8px;">
|
||||||
All SMS calls are fire-and-forget — they never throw. Failures are logged but do not block the main flow.
|
All SMS calls are fire-and-forget — they never throw. Failures are logged but do not block the main flow.
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ Requires `claimId` + `dmgCaseId` (soft-ensures earlier stages).
|
|||||||
| `Id` | `claimCases.expertiseId` |
|
| `Id` | `claimCases.expertiseId` |
|
||||||
|
|
||||||
History: `FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED`.
|
History: `FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED`.
|
||||||
SMS owner via `SmsOrchestrationService` (deduped `fanavaranSync.expertise.smsNotifiedAt`).
|
No owner SMS is sent after expertise; the returned identifier is persisted only.
|
||||||
Proven Parsian: `expertiseId=403144`, `ClaimExpertId=29`.
|
Proven Parsian: `expertiseId=403144`, `ClaimExpertId=29`.
|
||||||
|
|
||||||
Manual: `POST /v2/fanavaran/{client}/claim-cases/{id}/expertise/submit`.
|
Manual: `POST /v2/fanavaran/{client}/claim-cases/{id}/expertise/submit`.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Start local claim
|
|||||||
→ Upload docs/images locally
|
→ Upload docs/images locally
|
||||||
→ GEN.07 attachments (per file, best-effort)
|
→ GEN.07 attachments (per file, best-effort)
|
||||||
→ Expert pricing ready
|
→ Expert pricing ready
|
||||||
→ GEN.08 expertise → store expertiseId → SMS owner
|
→ GEN.08 expertise → store expertiseId (no SMS)
|
||||||
→ End (local completion independent of Fanavaran success)
|
→ End (local completion independent of Fanavaran success)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ sequenceDiagram
|
|||||||
YARA->>FV: POST .../expertise (GEN.08)
|
YARA->>FV: POST .../expertise (GEN.08)
|
||||||
FV-->>YARA: ExpertiseId
|
FV-->>YARA: ExpertiseId
|
||||||
YARA->>DB: expertiseId, history SUCCESS
|
YARA->>DB: expertiseId, history SUCCESS
|
||||||
YARA->>YARA: SMS owner (deduped)
|
Note over YARA: Post-expertise owner SMS is disabled
|
||||||
```
|
```
|
||||||
|
|
||||||
## Flow diagram (stages + soft-ensure)
|
## Flow diagram (stages + soft-ensure)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ source: fanavaran-module-docs
|
|||||||
| Tenant config | boot + System Settings | `fanavaran-client-config.service.ts`, `system-settings.*` |
|
| Tenant config | boot + System Settings | `fanavaran-client-config.service.ts`, `system-settings.*` |
|
||||||
| Orchestration | `ClaimRequestManagementModule` | `claim-request-management.service.ts` |
|
| Orchestration | `ClaimRequestManagementModule` | `claim-request-management.service.ts` |
|
||||||
| Policy select | same | `fanavaran-policy-selection.ts` |
|
| Policy select | same | `fanavaran-policy-selection.ts` |
|
||||||
| SMS after expertise | `SmsOrchestrationModule` | `sms-orchestration.service.ts` |
|
| SMS after expertise | Disabled by product decision | No message is dispatched |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ source: fanavaran-module-docs
|
|||||||
| Send | GEN.03 JSON + business headers |
|
| Send | GEN.03 JSON + business headers |
|
||||||
| Receive | `Id`, `ClaimNo` |
|
| Receive | `Id`, `ClaimNo` |
|
||||||
| Store | `claimId`, `claimNo`, `fanavaranSync.baseClaim.*`, history |
|
| Store | `claimId`, `claimNo`, `fanavaranSync.baseClaim.*`, history |
|
||||||
| Consumers | Damage/expertise soft-ensure; UI claim detail; SMS |
|
| Consumers | Damage/expertise soft-ensure; UI claim detail |
|
||||||
|
|
||||||
### C. Damage case
|
### C. Damage case
|
||||||
|
|
||||||
|
|||||||
@@ -69,4 +69,4 @@ Per HTTP step:
|
|||||||
|
|
||||||
## SMS
|
## SMS
|
||||||
|
|
||||||
After successful expertise (last Fanavaran stage), owner SMS may be sent once (`fanavaranSync.expertise.smsNotifiedAt`). Failure to SMS should not roll back Fanavaran expertise submit; treat as separate notification concern. Claims already notified at base claim (`fanavaranSync.baseClaim.smsNotifiedAt`) are not re-notified.
|
The post-expertise owner SMS is disabled. A successful final Fanavaran response only persists the returned `expertiseId` and synchronization state; it does not dispatch a notification.
|
||||||
|
|||||||
233
docs/inquiry-participants-frontend-guide.fa.md
Normal file
233
docs/inquiry-participants-frontend-guide.fa.md
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# راهنمای فرانتاند برای ارسال اطلاعات استعلام
|
||||||
|
|
||||||
|
این سند قرارداد نهایی فرانتاند برای مرحله استعلام است. از این به بعد اطلاعات اشخاص و خودرو باید با ساختار نقشمحور زیر ارسال شود. فیلدهای تخت قدیمی مانند `nationalCodeOfDriver` و `nationalCodeOfInsurer` دیگر ورودی معتبر نیستند.
|
||||||
|
|
||||||
|
## ساختار کلی درخواست
|
||||||
|
|
||||||
|
برای پرونده `THIRD_PARTY`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"driver": {
|
||||||
|
"nationalCode": "0012345678",
|
||||||
|
"birthday": "1370/01/01",
|
||||||
|
"hasDrivingLicense": true,
|
||||||
|
"licenseNumber": "123456789",
|
||||||
|
"licenseType": "1"
|
||||||
|
},
|
||||||
|
"vehicleOwner": { "sameAs": "DRIVER" },
|
||||||
|
"thirdPartyPolicyholder": { "sameAs": "VEHICLE_OWNER" },
|
||||||
|
"vehicle": {
|
||||||
|
"registrationState": "CURRENT",
|
||||||
|
"currentPlate": {
|
||||||
|
"leftDigits": "44",
|
||||||
|
"centerAlphabet": "ب",
|
||||||
|
"centerDigits": "111",
|
||||||
|
"ir": "22"
|
||||||
|
},
|
||||||
|
"isNewCar": false
|
||||||
|
},
|
||||||
|
"sheba": "IR123456789012345678901234"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
برای پرونده `CAR_BODY`، نقش بیمهگذار بدنه هم الزامی است:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"driver": {
|
||||||
|
"nationalCode": "0012345678",
|
||||||
|
"birthday": "1370/01/01",
|
||||||
|
"hasDrivingLicense": false
|
||||||
|
},
|
||||||
|
"vehicleOwner": { "sameAs": "DRIVER" },
|
||||||
|
"thirdPartyPolicyholder": {
|
||||||
|
"nationalCode": "0023456789",
|
||||||
|
"birthday": "1360/02/02"
|
||||||
|
},
|
||||||
|
"carBodyPolicyholder": {
|
||||||
|
"nationalCode": "0034567890",
|
||||||
|
"birthday": "1350/03/03"
|
||||||
|
},
|
||||||
|
"vehicle": {
|
||||||
|
"currentPlate": {
|
||||||
|
"leftDigits": "44",
|
||||||
|
"centerAlphabet": "ب",
|
||||||
|
"centerDigits": "111",
|
||||||
|
"ir": "22"
|
||||||
|
},
|
||||||
|
"isNewCar": false
|
||||||
|
},
|
||||||
|
"sheba": "IR123456789012345678901234"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`sheba` فقط در routeهایی که قبلاً اطلاعات بانکی را در مرحله استعلام دریافت میکردند ارسال میشود؛ در V6 مرکز تماس، شماره شبا در این body نیست و بعداً توسط کاربر دریافت میشود.
|
||||||
|
|
||||||
|
## هر استعلام با اطلاعات کدام نقش انجام میشود؟
|
||||||
|
|
||||||
|
فرانتاند فقط اشخاص را با ساختار نقشمحور ارسال میکند؛ انتخاب کد ملی مناسب برای هر سرویس در بکاند انجام میشود:
|
||||||
|
|
||||||
|
| استعلام | کد ملی مورد استفاده | شناسه خودرو/بانکی |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| بیمه شخص ثالث با پلاک یا VIN | `thirdPartyPolicyholder.nationalCode` | پلاک یا `vehicle.vin` |
|
||||||
|
| بیمه بدنه با پلاک یا VIN | `carBodyPolicyholder.nationalCode` | پلاک یا `vehicle.vin` |
|
||||||
|
| تطبیق شبا | `vehicleOwner.nationalCode` | `sheba` |
|
||||||
|
| گواهینامه | `driver.nationalCode` | `driver.licenseNumber` |
|
||||||
|
|
||||||
|
بنابراین کد ملی راننده نباید بهجای بیمهگذار یا مالک ارسال یا تکرار شود. اگر چند نقش متعلق به یک نفر است، `sameAs` ارتباط را مشخص میکند و بکاند همان شخص را برای استعلام مربوط به هر نقش انتخاب میکند.
|
||||||
|
|
||||||
|
در مرحله بانکی بعدی جریان V6، برای پروندههای جدید فقط `sheba` لازم است. بکاند کد ملی مالک خودرو را از `participants` و `participantRoles` ذخیرهشده در پرونده تقصیر میخواند. فیلدهای قدیمی `nationalCodeOfInsurer` یا `nationalCodeOfOwner` فقط برای پروندههای تاریخی فاقد اطلاعات نقشمحور fallback هستند؛ اگر همراه پرونده جدید ارسال شوند باید با مالک ذخیرهشده یکسان باشند.
|
||||||
|
|
||||||
|
پنلهای کارشناسی اطلاعات کامل اشخاص را در `parties[].participants` و نگاشت نقشها را در `parties[].participantRoles` دریافت میکنند. اطلاعات راننده حتی اگر در استعلام بیمه یا شبا استفاده نشود در همین ساختار ذخیره و نمایش داده میشود.
|
||||||
|
|
||||||
|
## نقشها و فیلدهای هر شخص
|
||||||
|
|
||||||
|
هر نقش باید یکی از این دو حالت را داشته باشد، نه هر دو را:
|
||||||
|
|
||||||
|
1. اطلاعات یک شخص جدید؛ یا
|
||||||
|
2. ارجاع با `sameAs` به شخصی که قبلاً در همین درخواست معرفی شده است.
|
||||||
|
|
||||||
|
| فیلد | کاربرد | وضعیت |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `nationalCode` | کد ملی شخص | برای شخص جدید الزامی |
|
||||||
|
| `birthday` | تاریخ تولد جلالی | برای شخص جدید الزامی |
|
||||||
|
| `fullName` | نام نمایشی شخص | اختیاری |
|
||||||
|
| `sameAs` | اتصال این نقش به نقش دیگر | بهجای اطلاعات شخص جدید |
|
||||||
|
| `hasDrivingLicense` | داشتن گواهینامه راننده | برای نقش راننده الزامی |
|
||||||
|
| `licenseNumber` | شماره گواهینامه | اگر `hasDrivingLicense=true` الزامی |
|
||||||
|
| `licenseType` | نوع گواهینامه | اگر `hasDrivingLicense=true` الزامی |
|
||||||
|
|
||||||
|
مقادیر مجاز `sameAs` عبارتاند از:
|
||||||
|
|
||||||
|
- `DRIVER`
|
||||||
|
- `VEHICLE_OWNER`
|
||||||
|
- `THIRD_PARTY_POLICYHOLDER`
|
||||||
|
- `CAR_BODY_POLICYHOLDER`
|
||||||
|
|
||||||
|
برای `sameAs` هیچکدام از `nationalCode`، `birthday`، `fullName`، اطلاعات گواهینامه یا `phoneNumber` را در همان آبجکت نفرستید.
|
||||||
|
|
||||||
|
شماره تلفن بخشی از هویت استعلام نیست و در این DTOها وجود ندارد. احراز هویت پیامکی و شماره تماس طرفین از این مرحله جداست.
|
||||||
|
|
||||||
|
## اطلاعات خودرو و پلاک
|
||||||
|
|
||||||
|
| فیلد | کاربرد | وضعیت |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `vehicle.registrationState` | وضعیت ثبت رسمی خودرو | `CURRENT` یا `RECENTLY_TRANSFERRED`؛ پیشفرض `CURRENT` |
|
||||||
|
| `vehicle.currentPlate` | پلاک رسمی فعلی و شناسه اصلی خودرو | الزامی |
|
||||||
|
| `vehicle.previousPlate` | پلاک قبلی در انتقال اخیر | فقط در `RECENTLY_TRANSFERRED` |
|
||||||
|
| `vehicle.previousPolicyholderNationalCode` | کد ملی بیمهگذار مربوط به پلاک قبلی | فقط در `RECENTLY_TRANSFERRED` و الزامی |
|
||||||
|
| `vehicle.vin` | شماره شاسی/VIN | در انتقال اخیر الزامی؛ در صورت ارسال دقیقاً ۱۷ کاراکتر |
|
||||||
|
| `vehicle.isNewCar` | نو بودن خودرو | اختیاری |
|
||||||
|
|
||||||
|
اجزای پلاک:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"leftDigits": "44",
|
||||||
|
"centerAlphabet": "ب",
|
||||||
|
"centerDigits": "111",
|
||||||
|
"ir": "22"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`leftDigits`، `centerDigits` و `ir` را میتوان بهصورت string یا number فرستاد؛ ارسال string پیشنهاد میشود تا صفرهای ابتدایی از بین نروند. `centerAlphabet` باید حرف فارسی پلاک باشد.
|
||||||
|
|
||||||
|
### انتقال اخیر
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vehicle": {
|
||||||
|
"registrationState": "RECENTLY_TRANSFERRED",
|
||||||
|
"currentPlate": {
|
||||||
|
"leftDigits": "44",
|
||||||
|
"centerAlphabet": "ب",
|
||||||
|
"centerDigits": "111",
|
||||||
|
"ir": "22"
|
||||||
|
},
|
||||||
|
"previousPlate": {
|
||||||
|
"leftDigits": "55",
|
||||||
|
"centerAlphabet": "ج",
|
||||||
|
"centerDigits": "222",
|
||||||
|
"ir": "33"
|
||||||
|
},
|
||||||
|
"previousPolicyholderNationalCode": "0098765432",
|
||||||
|
"vin": "NAAM01E15HK123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
اطلاعات انتقال اخیر فقط بهعنوان متادیتای پرونده ذخیره میشوند. در route پلاک، سیستم فقط `currentPlate` را با کد ملی بیمهگذار نهاییِ مرتبط با نوع بیمه استعلام میکند و هیچ fallbackای به `previousPlate` یا `previousPolicyholderNationalCode` ندارد. در route شماره شاسی نیز فقط `vehicle.vin` با همان بیمهگذار نهایی استعلام میشود.
|
||||||
|
|
||||||
|
حتی در route مربوط به VIN، آبجکت `vehicle` از قرارداد مشترک استفاده میکند و `currentPlate` در قرارداد فعلی الزامی است. مقدار VIN در `vehicle.vin` قرار میگیرد، نه در فیلد سطح بالای `vin`.
|
||||||
|
|
||||||
|
## ترتیب پیشنهادی نمایش فرم
|
||||||
|
|
||||||
|
1. پلاک فعلی و وضعیت انتقال خودرو را بگیرید.
|
||||||
|
2. اگر انتقال اخیر بود، پلاک قبلی، کد ملی بیمهگذار پلاک قبلی و VIN را بگیرید.
|
||||||
|
3. اطلاعات راننده و وضعیت گواهینامه را بگیرید.
|
||||||
|
4. بپرسید مالک خودرو همان راننده است یا شخص دیگری؛ در حالت یکسان از `sameAs` استفاده کنید.
|
||||||
|
5. بیمهگذار شخص ثالث را از بین راننده، مالک یا شخص دیگر انتخاب کنید.
|
||||||
|
6. در `CAR_BODY` همین کار را برای بیمهگذار بدنه انجام دهید.
|
||||||
|
7. خلاصه اطلاعات را به کاربر نشان دهید و سپس درخواست استعلام را ارسال کنید.
|
||||||
|
|
||||||
|
## مسیرهای اصلی
|
||||||
|
|
||||||
|
بدنه درخواست در همه این مسیرها همین ساختار را دارد:
|
||||||
|
|
||||||
|
| جریان | مسیر پلاک | مسیر VIN |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| کاربر V2 | `/v2/blame-request-management/initial-form/:requestId` | `/v2/blame-request-management/initial-form-vin/:requestId` |
|
||||||
|
| کارشناس/پروندهساز 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`) است. اطلاعات بیمهگذار برای هر دو طرف الزامی است.
|
||||||
|
|
||||||
|
## تفاوت اطلاعات مقصر و زیاندیده
|
||||||
|
|
||||||
|
ساختار نقشمحور اشخاص و خودرو برای هر دو طرف یکسان است، اما ترتیب و قواعد کسبوکار آنها تفاوت دارد:
|
||||||
|
|
||||||
|
| پرونده و طرف | نقشها و رفتار |
|
||||||
|
| --- | --- |
|
||||||
|
| `THIRD_PARTY / FIRST` (مقصر) | راننده، مالک و بیمهگذار شخص ثالثِ خودروی مقصر ارسال میشوند. شبا در این فراخوان لازم نیست. بیمهنامه مقصر باید متعلق به شرکت بیمه همین سامانه باشد. |
|
||||||
|
| `THIRD_PARTY / SECOND` (زیاندیده) | راننده، مالک و بیمهگذار شخص ثالثِ خودروی زیاندیده ارسال میشوند. این مرحله فقط بعد از امضای مقصر و احراز OTP زیاندیده اجرا میشود. `sheba` الزامی است و با کد ملی `vehicleOwner` اعتبارسنجی میشود. بیمهگذار زیاندیده نیز همیشه باید مشخص باشد. |
|
||||||
|
| `CAR_BODY / FIRST` (بیمهگذار/زیاندیده بدنه) | هر چهار نقش راننده، مالک، بیمهگذار شخص ثالث و بیمهگذار بدنه ارسال میشوند. `sheba` در همین فراخوان الزامی است و با کد ملی `vehicleOwner` اعتبارسنجی میشود. استعلام بدنه با کد ملی `carBodyPolicyholder` انجام میشود. |
|
||||||
|
|
||||||
|
در V2 و V6 که شبا در مرحله جداگانه از کاربر دریافت میشود، شبا داخل درخواست استعلام مقصر ارسال نمیشود؛ بکاند هنگام مرحله بانکی آن را با کد ملی مالک ذخیرهشده تطبیق میدهد.
|
||||||
|
|
||||||
|
## فیلدهایی که نباید ارسال شوند
|
||||||
|
|
||||||
|
این فیلدها دیگر بخشی از قرارداد ورودی نیستند و ارسال آنها باعث خطای اعتبارسنجی میشود:
|
||||||
|
|
||||||
|
```text
|
||||||
|
nationalCodeOfDriver
|
||||||
|
driverBirthday
|
||||||
|
driverLicense
|
||||||
|
licenseType // در سطح بالا؛ مقدار صحیح داخل driver است
|
||||||
|
nationalCodeOfInsurer
|
||||||
|
insurerBirthday
|
||||||
|
insurerLicense
|
||||||
|
driverIsInsurer
|
||||||
|
userNoCertificate
|
||||||
|
plate // در سطح بالا؛ مقدار صحیح داخل vehicle.currentPlate است
|
||||||
|
plateId
|
||||||
|
vin // در سطح بالا؛ مقدار صحیح داخل vehicle.vin است
|
||||||
|
isNewCar // در سطح بالا؛ مقدار صحیح داخل vehicle.isNewCar است
|
||||||
|
phoneNumber // در participantها
|
||||||
|
unknown // حذف شده؛ بیمهگذار همیشه باید مشخص باشد
|
||||||
|
```
|
||||||
|
|
||||||
|
## خطاهای رایج فرانتاند
|
||||||
|
|
||||||
|
- ارسال `vehicleOwner` بهصورت خالی؛ باید شخص جدید یا `sameAs` باشد.
|
||||||
|
- استفاده از `sameAs` همراه با `nationalCode` یا `birthday`.
|
||||||
|
- ارسال `carBodyPolicyholder` برای `THIRD_PARTY`.
|
||||||
|
- ارسال `unknown` برای هر نقش؛ این فیلد دیگر پذیرفته نمیشود.
|
||||||
|
- فرستادن `previousPlate` بدون `registrationState=RECENTLY_TRANSFERRED`.
|
||||||
|
- فرستادن `RECENTLY_TRANSFERRED` بدون `previousPlate`، `previousPolicyholderNationalCode` یا `vin`.
|
||||||
|
- فرستادن `previousPolicyholderNationalCode` برای خودروی دارای وضعیت `CURRENT`.
|
||||||
|
- قرار دادن VIN یا پلاک در سطح بالای body.
|
||||||
|
- ارسال شماره تلفن در آبجکت شخص.
|
||||||
|
- تکرار کد ملی راننده یا بیمهگذار در مرحله شبا؛ تطبیق شبا همیشه با مالک خودرو انجام میشود.
|
||||||
|
|
||||||
|
مستند مدل دامنه و جزئیات تصمیم معماری در [inquiry-participants-proposal.fa.md](./inquiry-participants-proposal.fa.md) قرار دارد.
|
||||||
118
docs/inquiry-participants-proposal.fa.md
Normal file
118
docs/inquiry-participants-proposal.fa.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# پیشنهاد مدل اشخاص در مرحله استعلام
|
||||||
|
|
||||||
|
وضعیت: پیادهسازیشده در ۱۴۰۵/۰۶/۲۲
|
||||||
|
دامنه: تمام جریانهای استعلام کاربر، کارشناس، پروندهساز و مرکز تماس
|
||||||
|
نسخه انگلیسی: [inquiry-participants-proposal.md](./inquiry-participants-proposal.md)
|
||||||
|
|
||||||
|
## interface پیادهسازیشده
|
||||||
|
|
||||||
|
فیلدهای نقشها و آبجکت الزامی `vehicle` که در ادامه آمدهاند، مستقیماً در body تمام درخواستهای استعلام فعلی پذیرفته میشوند. این تغییر شامل فرم اولیه کاربر و mirror کارشناس/ثبتکننده در V2، جریان کارشناس V3، جریانهای پروندهساز V4/V5، مرکز تماس V6 و مسیرهای تکدرخواستی حضوری است. routeهای پلاک و VIN از قوانین مشترک اشخاص استفاده میکنند. فیلدهای تخت راننده/بیمهگذار و شماره تلفن، ورودی استعلام نیستند.
|
||||||
|
|
||||||
|
پاسخها و جزئیات پرونده برای نقشهای عملیاتی، در صورت وجود داده، فیلدهای نرمالشده `participants`، `participantRoles`، `vehicle.registrationState`، `vehicle.previousPlateId` و `vehicle.previousPolicyholderNationalCode` را نمایش میدهند.
|
||||||
|
|
||||||
|
## مسئله
|
||||||
|
|
||||||
|
قرارداد فعلی استعلام عمدتاً فقط راننده و شخصی با عنوان `insurer` را نگه میدارد. این مدل کامل نیست و نامگذاری نیز دقیق نیست: شخص، **بیمهگذار** است و **بیمهگر** شرکت بیمه است.
|
||||||
|
|
||||||
|
برای هر وسیله نقلیه در یک `Party` نقشهای هویتی زیر وجود دارد:
|
||||||
|
|
||||||
|
| نوع پرونده | نقشهای الزامی |
|
||||||
|
| ------------- | ------------------------------------------------------------ |
|
||||||
|
| `THIRD_PARTY` | راننده، مالک وسیله نقلیه، بیمهگذار شخص ثالث |
|
||||||
|
| `CAR_BODY` | راننده، مالک وسیله نقلیه، بیمهگذار شخص ثالث، بیمهگذار بدنه |
|
||||||
|
|
||||||
|
ممکن است یک شخص چند نقش را داشته باشد، اما سیستم نباید یکسان بودن آنها را فرض کند.
|
||||||
|
|
||||||
|
## پیشنهاد
|
||||||
|
|
||||||
|
پیش از اجرای استعلام، یک **مرحله کوتاه تعیین اشخاص** اضافه شود. ابتدا نسبت اشخاص پرسیده شود و اطلاعات فقط برای افراد متفاوت دریافت شود. دریافت بدون شرط اطلاعات کامل همه اشخاص مناسب نیست. همچنین افزودن فلگهای دوتایی متعدد مانند `driverIsOwner` و `ownerIsPolicyholder` باعث ابهام، تناقض و رشد سریع حالتها میشود.
|
||||||
|
|
||||||
|
بهجای آن، هر نقش یا اطلاعات یک شخص جدید را داشته باشد یا به نقش قبلی ارجاع دهد:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"driver": {
|
||||||
|
"nationalCode": "0012345678",
|
||||||
|
"birthday": "1370/01/01",
|
||||||
|
"hasDrivingLicense": true,
|
||||||
|
"licenseNumber": "123456789",
|
||||||
|
"licenseType": "1"
|
||||||
|
},
|
||||||
|
"vehicleOwner": { "sameAs": "DRIVER" },
|
||||||
|
"thirdPartyPolicyholder": { "sameAs": "VEHICLE_OWNER" },
|
||||||
|
"carBodyPolicyholder": {
|
||||||
|
"nationalCode": "0098765432",
|
||||||
|
"birthday": "1365/02/03"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
فیلد `carBodyPolicyholder` برای `THIRD_PARTY` مجاز نیست و برای `CAR_BODY` الزامی است. هر نقش باید فقط یکی از دو حالت «اطلاعات شخص» یا `sameAs` را داشته باشد. بکاند این ورودی را به فهرست اشخاص یکتا و اتصال نقشها به آنها تبدیل میکند.
|
||||||
|
|
||||||
|
هر بیمهگذار باید با اطلاعات هویتی یا `sameAs` به یک شخص مشخص متصل شود. گزینه حذفشده `unknown` برای هیچ نقشی پذیرفته نمیشود؛ بنابراین استعلام بیمه بهدلیل نامشخص بودن هویت بیمهگذار رد یا عمداً اجرانشده ثبت نمیشود.
|
||||||
|
|
||||||
|
برای راننده، `hasDrivingLicense` الزامی است. اگر مقدار آن `true` باشد، هر دو فیلد `licenseNumber` و `licenseType` نیز الزامی هستند؛ اگر مقدار آن `false` باشد، استعلام گواهینامه عمداً اجرا نمیشود.
|
||||||
|
|
||||||
|
## انتقال مالکیت اخیر و پلاک قبلی
|
||||||
|
|
||||||
|
نقش اشخاص و شناسههای خودرو دو موضوع جدا هستند. اگر خودرو بهتازگی فروخته یا خریداری شده باشد، ممکن است اطلاعات رسمی یا بیمهنامه هنوز به پلاک قبلی متصل باشد. این وضعیت باید صریح ثبت شود و پلاک قبلی نباید جایگزین پلاک فعلی شود:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vehicle": {
|
||||||
|
"registrationState": "RECENTLY_TRANSFERRED",
|
||||||
|
"currentPlate": {
|
||||||
|
"leftDigits": "44",
|
||||||
|
"centerAlphabet": "ب",
|
||||||
|
"centerDigits": "111",
|
||||||
|
"ir": "22"
|
||||||
|
},
|
||||||
|
"previousPlate": {
|
||||||
|
"leftDigits": "55",
|
||||||
|
"centerAlphabet": "ج",
|
||||||
|
"centerDigits": "222",
|
||||||
|
"ir": "33"
|
||||||
|
},
|
||||||
|
"previousPolicyholderNationalCode": "0098765432",
|
||||||
|
"vin": "NAAM01E15HK123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
مقدار پیشفرض `registrationState` برابر `CURRENT` است و برای این مسیر استثنایی مقدار `RECENTLY_TRANSFERRED` استفاده میشود. در انتقال اخیر، `previousPlate`، `previousPolicyholderNationalCode` و `vin` الزامیاند؛ فیلدهای مربوط به پلاک قبلی در حالت عادی `CURRENT` نباید ارسال شوند. این اطلاعات انتقال فقط بهعنوان متادیتای پرونده نگهداری میشوند و پلاک فعلی همچنان شناسه اصلی خودرو است.
|
||||||
|
|
||||||
|
در route پلاک، هماهنگکننده دقیقاً یک استعلام بیمه انجام میدهد: `currentPlate` همراه با بیمهگذار نهایی همان نوع بیمه. در route شماره شاسی نیز `vehicle.vin` با همان بیمهگذار نهایی ارسال میشود. `previousPlate` هیچگاه استعلام نمیشود، `previousPolicyholderNationalCode` به ارائهدهنده استعلام ارسال نمیشود و VIN برای انتخاب نتیجه پلاک قبلی به کار نمیرود.
|
||||||
|
|
||||||
|
## ترتیب پیشنهادی فرم
|
||||||
|
|
||||||
|
1. پلاک فعلی دریافت و درباره انتقال مالکیت اخیر پرسیده شود. در صورت انتقال اخیر، پلاک قبلی، کد ملی بیمهگذار مربوط به پلاک قبلی و VIN/شماره شاسی نیز دریافت شوند.
|
||||||
|
2. اطلاعات هویتی و گواهینامه راننده دریافت شود.
|
||||||
|
3. پرسیده شود آیا مالک خودرو همان راننده است؛ فقط در صورت تفاوت، اطلاعات مالک دریافت شود.
|
||||||
|
4. برای بیمهگذار شخص ثالث یکی از «راننده»، «مالک» یا «شخص دیگر» انتخاب شود؛ فقط برای شخص دیگر فرم جدید نمایش داده شود.
|
||||||
|
5. در `CAR_BODY` همین انتخاب برای بیمهگذار بدنه انجام شود و امکان انتخاب هر شخص ثبتشده یا شخص دیگر وجود داشته باشد.
|
||||||
|
6. خلاصه اشخاص نمایش داده شود و سپس استعلامها اجرا شوند.
|
||||||
|
|
||||||
|
به این ترتیب مسیر رایج کوتاه میماند و همه ترکیبهای معتبر نیز پشتیبانی میشوند.
|
||||||
|
|
||||||
|
## محل منطق در بکاند
|
||||||
|
|
||||||
|
یک resolver مشترک برای اشخاص ساخته شود و تمام routeهای استعلام از آن استفاده کنند. interface این ماژول باید:
|
||||||
|
|
||||||
|
- نقشهای لازم را بر اساس نوع پرونده اعتبارسنجی و ارجاعهای نامعتبر یا حلقوی `sameAs` را رد کند؛
|
||||||
|
- شخص نهایی هر نقش را برگرداند؛
|
||||||
|
- هویت درست را به استعلام مرتبط بدهد: گواهینامه ← راننده، مالکیت و تطبیق شبا ← مالک خودرو، بیمه شخص ثالث با پلاک/VIN ← بیمهگذار شخص ثالث، بیمه بدنه با پلاک/VIN ← بیمهگذار بدنه؛
|
||||||
|
- شبا را در استعلام شخص مطالبهکننده خسارت (`SECOND` زیاندیده در `THIRD_PARTY` و طرف اول در `CAR_BODY`) الزامی کند و با کد ملی مالک خودرو اعتبارسنجی کند؛ در استعلام `FIRST` مقصر پرونده ثالث شبا دریافت نمیشود؛
|
||||||
|
- فقط پلاک فعلی یا VIN ارسالشده را با بیمهگذار نهایی همان نوع بیمه استعلام کند؛ متادیتای انتقال قبلی نباید مسیریابی استعلام را تغییر دهد؛
|
||||||
|
- استعلام هویت را برای هر شخص یکتا فقط یک بار اجرا کند؛
|
||||||
|
- اشخاص نرمالشده و نقشهای آنها را در `Party` مربوط ذخیره کند.
|
||||||
|
- `participants` و `participantRoles` ذخیرهشده را بدون حذف اطلاعات در جزئیات پرونده پنلهای کارشناسی و پرونده خسارت متصل نمایش دهد تا اطلاعات راننده و سایر نقشها برای بررسی در دسترس بماند.
|
||||||
|
|
||||||
|
جریانهای V2 کاربر/کارشناس، V3، V4، V5 و V6 باید adapter همین قوانین مشترک باشند و منطق نسبت اشخاص را جداگانه پیادهسازی نکنند.
|
||||||
|
|
||||||
|
## مرز قرارداد
|
||||||
|
|
||||||
|
ورودی استعلام فقط شامل آبجکتهای ساختاریافته اشخاص و خودرو است. بکاند فیلدهای تختی مانند `nationalCodeOfDriver`، `nationalCodeOfInsurer`، `driverIsInsurer`، `plate`/`vin` سطح بالا و `phoneNumber` را با خطای اعتبارسنجی رد میکند. احراز هویت تلفنی و جریانهای تماس با طرفین از جمعآوری هویت برای استعلام جدا هستند.
|
||||||
|
|
||||||
|
## تصمیم پیشنهادی
|
||||||
|
|
||||||
|
راهحل مناسب، **دریافت شرطی اطلاعات همراه با اتصال صریح نقشها** است. این روش بدون طولانی کردن مسیر اکثر کاربران، اطلاعات کامل فراهم میکند، از تناقض فلگها جلوگیری میکند و یک مدل یکسان برای همه جریانهای استعلام میسازد.
|
||||||
118
docs/inquiry-participants-proposal.md
Normal file
118
docs/inquiry-participants-proposal.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Inquiry participant identity proposal
|
||||||
|
|
||||||
|
Status: implemented on 2026-09-13
|
||||||
|
Scope: every user, expert, FileMaker, and call-center inquiry flow
|
||||||
|
Persian version: [inquiry-participants-proposal.fa.md](./inquiry-participants-proposal.fa.md)
|
||||||
|
|
||||||
|
## Implemented interface
|
||||||
|
|
||||||
|
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`, `vehicle.previousPlateId`, and `vehicle.previousPolicyholderNationalCode` where available.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The current inquiry contract mainly models a driver and a value named `insurer`. That is incomplete and the name is misleading: a person is the **policyholder**; the **insurer** is the insurance company.
|
||||||
|
|
||||||
|
Each vehicle-side `Party` can have these identity roles:
|
||||||
|
|
||||||
|
| Case type | Required roles |
|
||||||
|
| ------------- | ---------------------------------------------------------------------- |
|
||||||
|
| `THIRD_PARTY` | Driver, vehicle owner, third-party policyholder |
|
||||||
|
| `CAR_BODY` | Driver, vehicle owner, third-party policyholder, car-body policyholder |
|
||||||
|
|
||||||
|
One person may hold several roles, but the system must not assume that they do.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Add a short **participant-identification step before inquiry**. Ask relationship questions and collect details only for distinct people. Do not ask for every person's complete data unconditionally, and do not add pairwise flags such as `driverIsOwner`, `ownerIsPolicyholder`, and `driverIsBodyPolicyholder`; that becomes ambiguous and grows combinatorially.
|
||||||
|
|
||||||
|
Use explicit role references instead:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"driver": {
|
||||||
|
"nationalCode": "0012345678",
|
||||||
|
"birthday": "1370/01/01",
|
||||||
|
"hasDrivingLicense": true,
|
||||||
|
"licenseNumber": "123456789",
|
||||||
|
"licenseType": "1"
|
||||||
|
},
|
||||||
|
"vehicleOwner": { "sameAs": "DRIVER" },
|
||||||
|
"thirdPartyPolicyholder": { "sameAs": "VEHICLE_OWNER" },
|
||||||
|
"carBodyPolicyholder": {
|
||||||
|
"nationalCode": "0098765432",
|
||||||
|
"birthday": "1365/02/03"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Recent ownership transfer and previous plate
|
||||||
|
|
||||||
|
Participant roles and vehicle identifiers are separate concerns. When a vehicle has recently been sold or purchased, the current official record or policy may still be connected to its previous plate. Model this explicitly instead of replacing the current plate:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vehicle": {
|
||||||
|
"registrationState": "RECENTLY_TRANSFERRED",
|
||||||
|
"currentPlate": {
|
||||||
|
"leftDigits": "44",
|
||||||
|
"centerAlphabet": "ب",
|
||||||
|
"centerDigits": "111",
|
||||||
|
"ir": "22"
|
||||||
|
},
|
||||||
|
"previousPlate": {
|
||||||
|
"leftDigits": "55",
|
||||||
|
"centerAlphabet": "ج",
|
||||||
|
"centerDigits": "222",
|
||||||
|
"ir": "33"
|
||||||
|
},
|
||||||
|
"previousPolicyholderNationalCode": "0098765432",
|
||||||
|
"vin": "NAAM01E15HK123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
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, 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”.
|
||||||
|
5. For `CAR_BODY`, ask the same question for the car-body policyholder, allowing any already entered person or another person.
|
||||||
|
6. Show a short review, then run the inquiries.
|
||||||
|
|
||||||
|
This keeps the common case fast while representing all valid combinations.
|
||||||
|
|
||||||
|
## Backend seam
|
||||||
|
|
||||||
|
Create one shared participant resolver used by every inquiry route. Its interface should:
|
||||||
|
|
||||||
|
- 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;
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
V2 user/expert routes, V3, V4, V5, and V6 should be adapters over this shared rule set rather than implementing their own relationship logic.
|
||||||
|
|
||||||
|
## Contract boundary
|
||||||
|
|
||||||
|
The structured participant and vehicle objects are the only accepted inquiry input. The backend rejects flat fields such as `nationalCodeOfDriver`, `nationalCodeOfInsurer`, `driverIsInsurer`, top-level `plate`/`vin`, and `phoneNumber` with a validation error. Phone-based authentication and party contact flows remain separate from inquiry identity collection.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Prefer **conditional collection plus explicit role assignments**. It provides complete data without burdening most users, prevents contradictory booleans, and gives all inquiry flows one consistent domain model.
|
||||||
26
docs/panel-data-frontend-handoff.fa.md
Normal file
26
docs/panel-data-frontend-handoff.fa.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# اطلاعات پنلها و PDF
|
||||||
|
|
||||||
|
## مواردی که نبود و اضافه شد
|
||||||
|
|
||||||
|
- نام شخص در پنلهای فلو ۴ و ۵
|
||||||
|
- کد ملی بیمهگذار و راننده در پنلهای فلو ۴ و ۵
|
||||||
|
- مشخصبودن یکسانبودن راننده و بیمهگذار در فلو ۴ و ۵
|
||||||
|
- نوع، شماره و تاریخ گواهینامه در پنلهای فلو ۴ و ۵
|
||||||
|
- تاریخ تولد بیمهگذار و راننده در پنلهای فلو ۴ و ۵
|
||||||
|
- پلاک و VIN در پنلهای فلو ۴ و ۵
|
||||||
|
- شماره شبا در پنلهای فلو ۴ و ۵
|
||||||
|
- کدهای تکمیلی فناوران در پنلها: بیمهنامه، راننده، نوع خودرو، نوع/نمونه پلاک، کاربری خودرو و شرکت بیمه
|
||||||
|
- نمایش نوع واقعی گواهینامه، مثل «پایه یک»، در PDF
|
||||||
|
|
||||||
|
## مواردی که از قبل وجود داشت
|
||||||
|
|
||||||
|
- نام و کد ملی افراد در پنلهای کارشناس و بیمهگر
|
||||||
|
- کد ملی راننده، در صورت متفاوتبودن با بیمهگذار
|
||||||
|
- شماره شبا در پنل بیمهگر و PDF
|
||||||
|
- اطلاعات راننده در PDF، وقتی راننده با بیمهگذار متفاوت است
|
||||||
|
- پلاک خودرو در API؛ فرانت باید از `vehicle.plateId` بخواند
|
||||||
|
- کدهای اصلی فناوران: شماره خسارت، شماره پرونده، شناسه پرونده خسارت و شناسه کارشناسی
|
||||||
|
|
||||||
|
## موردی که هنوز نداریم
|
||||||
|
|
||||||
|
- `expedited` → در بکاند فیلد و قرارداد API ندارد.
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
<td><code>file_maker</code></td>
|
<td><code>file_maker</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>ارزیابی خسارت V4/V5 (فیلدهای تصادف، قطعات، عکسها، امضای مالک)</td>
|
<td>ارزیابی خسارت V4/V5 (فیلدهای تصادف، قطعات و عکسها؛ تأیید خطوط قیمتگذاریشده در جریان ترکیبیِ فاکتور در صورت نیاز)</td>
|
||||||
<td>فایلهای تخصیصیافته</td>
|
<td>فایلهای تخصیصیافته</td>
|
||||||
<td>پنل بازبین فایل</td>
|
<td>پنل بازبین فایل</td>
|
||||||
<td><code>file_reviewer</code></td>
|
<td><code>file_reviewer</code></td>
|
||||||
@@ -370,14 +370,14 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
|
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>فهرست خسارتها در صف <code>WAITING_FOR_DAMAGE_EXPERT</code> + صف اعتبارسنجی فاکتور. پارامترها: search، sortBy، page، limit، unifiedStatus، fileType.</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>فهرست خسارتها در صف <code>WAITING_FOR_DAMAGE_EXPERT</code> + صف اعتبارسنجی فاکتور. پارامترها: search، sortBy، page، limit، unifiedStatus، fileType.</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>جزئیات کامل خسارت: قطعات آسیبدیده، تصاویر گرفتهشده، اسناد، priceDrop، داده طرف بلیم، آدرسهای ویدیو.</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>جزئیات کامل خسارت، بههمراه <code>priceCap</code> مؤثر: ۵۳۰٬۰۰۰٬۰۰۰ ریال برای V1 و <code>null</code> برای V2 تا V6 یا وقتی سقف غیرفعال است.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>قفل خسارت برای این کارشناس. بازمیگرداند: <code>assigned</code>، <code>already_assigned_to_you</code>، یا ۴۰۹.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>قفل خسارت برای این کارشناس. بازمیگرداند: <code>assigned</code>، <code>already_assigned_to_you</code>، یا ۴۰۹.</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>محتوای کاهش قیمت: برچسبهای شدت، کاتالوگ ضریب، قطعات آسیبدیده + نگاشت، سال پیشنهادی خودرو از استعلام تقصیر.</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>محتوای کاهش قیمت: برچسبهای شدت، کاتالوگ ضریب، قطعات آسیبدیده + نگاشت، سال پیشنهادی خودرو از استعلام تقصیر.</td></tr>
|
||||||
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>محاسبه و ذخیره کاهش قیمت: قیمت خودرو × ضریب سال × مجموع ضرایب ÷ ۴۰۰.</td></tr>
|
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>محاسبه و ذخیره کاهش قیمت: قیمت خودرو × ضریب سال × مجموع ضرایب ÷ ۴۰۰.</td></tr>
|
||||||
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>ارسال پاسخ ارزیابی خسارت (لیست قطعات قیمتگذاریشده، داغی، branchId). سقف: کل ≤ ۵۳،۰۰۰،۰۰۰ تومان. بسته به پرچمهای factorNeeded، خسارت را به owner-sign، mixed-factors-pending، یا صف اعتبارسنجی فاکتور منتقل میکند.</td></tr>
|
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>ارسال پاسخ ارزیابی خسارت (لیست قطعات قیمتگذاریشده، داغی، branchId). فقط V1: کل ≤ ۵۳۰٬۰۰۰٬۰۰۰ ریال؛ V2 تا V6 بدون سقف مجموع هستند.</td></tr>
|
||||||
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>درخواست از کاربر برای ارسال مجدد اسناد/عکسها. یک ارسال مجدد در هر چرخه خسارت؛ در صورت تکمیل قبلی ۴۲۲ برمیگرداند.</td></tr>
|
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>درخواست از کاربر برای ارسال مجدد اسناد/عکسها. یک ارسال مجدد در هر چرخه خسارت؛ در صورت تکمیل قبلی ۴۲۲ برمیگرداند.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>درخواست از کاربر برای مراجعه حضوری. خسارت را آزاد میکند، وضعیت claimStatus را به NEEDS_REVISION تنظیم میکند.</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>درخواست از کاربر برای مراجعه حضوری. خسارت را آزاد میکند، وضعیت claimStatus را به NEEDS_REVISION تنظیم میکند.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>اعتبارسنجی فاکتورهای تعمیرگاه بارگذاریشده. تأیید یا رد هر خط فاکتور با totalPayment. سقف برای تمام خطوط اعمال میشود (≤ ۵۳،۰۰۰،۰۰۰ تومان). پس از تصمیمگیری درباره تمام خطوط، بهصورت خودکار تکمیل میشود.</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>اعتبارسنجی فاکتورهای تعمیرگاه بارگذاریشده. سقف ۵۳۰٬۰۰۰٬۰۰۰ ریال تمام خطوط فقط برای V1 اعمال میشود؛ V2 تا V6 بدون سقف هستند.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/request/:claimRequestId/damaged-parts</code></td><td>ویرایش قطعات آسیبدیده انتخابشده در حالی که خسارت توسط این کارشناس قفل است (EXPERT_REVIEWING).</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/request/:claimRequestId/damaged-parts</code></td><td>ویرایش قطعات آسیبدیده انتخابشده در حالی که خسارت توسط این کارشناس قفل است (EXPERT_REVIEWING).</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/outer-parts-catalog</code></td><td>کاتالوگ قطعات بیرونی خودرو فناوران (مشترک با جریان کاربر).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/outer-parts-catalog</code></td><td>کاتالوگ قطعات بیرونی خودرو فناوران (مشترک با جریان کاربر).</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/inner-parts-catalog</code></td><td>JSON ثابت کاتالوگ قطعات داخلی خودرو.</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/inner-parts-catalog</code></td><td>JSON ثابت کاتالوگ قطعات داخلی خودرو.</td></tr>
|
||||||
@@ -451,7 +451,8 @@
|
|||||||
OTPها، استعلامها، موقعیت/توضیحات/صدا و امضاها برای هر دو طرف.
|
OTPها، استعلامها، موقعیت/توضیحات/صدا و امضاها برای هر دو طرف.
|
||||||
همچنین اسناد اولیه خسارت (گواهینامهها، کارتهای خودرو) را بارگذاری میکند. پس از
|
همچنین اسناد اولیه خسارت (گواهینامهها، کارتهای خودرو) را بارگذاری میکند. پس از
|
||||||
امضای دوم، فایل برای تحویل به بازبین فایل «مهرومومه» میشود. در V5، فایلساز
|
امضای دوم، فایل برای تحویل به بازبین فایل «مهرومومه» میشود. در V5، فایلساز
|
||||||
در انتها بازمیگردد تا خسارت تکمیلشده را قبل از ارسال به فناوران تأیید یا رد کند.
|
در انتها بازمیگردد تا خسارت تکمیلشده را تأیید یا رد کند. پس از تأیید،
|
||||||
|
کارشناس پرونده را بهصورت دستی به فناوران ارسال میکند.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="card card-purple">
|
<div class="card card-purple">
|
||||||
@@ -475,10 +476,10 @@
|
|||||||
|
|
||||||
<div class="card card-purple">
|
<div class="card card-purple">
|
||||||
<h3>تأیید خسارت V5 — <code>v5/file-maker/claim-approval/</code></h3>
|
<h3>تأیید خسارت V5 — <code>v5/file-maker/claim-approval/</code></h3>
|
||||||
<p class="note">فقط در V5 استفاده میشود. پس از بررسی کارشناس خسارت و امضای مالک، خسارت وارد <code>WAITING_FOR_FILE_MAKER_APPROVAL</code> میشود.</p>
|
<p class="note">فقط در V5 استفاده میشود. پس از بررسی کارشناس خسارت و اعتبارسنجی فاکتورهای لازم، خسارت وارد <code>WAITING_FOR_FILE_MAKER_APPROVAL</code> میشود؛ امضای نهایی مالک لازم نیست.</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
|
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>approve/:claimId</code></td><td>تأیید خسارت تکمیلشده ← ارسال SMS امضای مالک را فعال میکند ← پس از امضای مالک به فناوران ارسال میشود.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>approve/:claimId</code></td><td>تأیید خسارت تکمیلشده ← وضعیت خسارت <code>COMPLETED</code> میشود. کارشناس در زمان مناسب آن را بهصورت دستی به فناوران ارسال میکند.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>reject/:claimId</code></td><td>رد به بازبین فایل ← خسارت به WAITING_FOR_DAMAGE_EXPERT برمیگردد. محدودیت: حداکثر ۲ رد در هر خسارت؛ تلاش سوم ۴۲۲ با کد <code>FILE_MAKER_REJECTION_LIMIT_EXCEEDED</code> برمیگرداند.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>reject/:claimId</code></td><td>رد به بازبین فایل ← خسارت به WAITING_FOR_DAMAGE_EXPERT برمیگردد. محدودیت: حداکثر ۲ رد در هر خسارت؛ تلاش سوم ۴۲۲ با کد <code>FILE_MAKER_REJECTION_LIMIT_EXCEEDED</code> برمیگرداند.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -489,7 +490,7 @@
|
|||||||
دومین اکتور در تقسیم V4/V5. بازبین فایل فایلهای مهرومومهشده (پس از اتمام کار
|
دومین اکتور در تقسیم V4/V5. بازبین فایل فایلهای مهرومومهشده (پس از اتمام کار
|
||||||
فایلساز) را تحویل میگیرد و مرحله کامل ارزیابی خسارت را انجام میدهد: فیلدهای
|
فایلساز) را تحویل میگیرد و مرحله کامل ارزیابی خسارت را انجام میدهد: فیلدهای
|
||||||
تصادف، دریافت الزامات عکسبرداری، بارگذاری اسناد (شاسی/موتور)، انتخاب قطعات،
|
تصادف، دریافت الزامات عکسبرداری، بارگذاری اسناد (شاسی/موتور)، انتخاب قطعات،
|
||||||
عکسهای قطعات، ویدیوی دور زدن خودرو، امضای مالک. تقصیر با car-capture به
|
عکسهای قطعات و ویدیوی دور زدن خودرو. تقصیر با car-capture به
|
||||||
COMPLETED علامتگذاری میشود. بازبین فایل همچنین دسترسی خواندن به پنل expert-claim
|
COMPLETED علامتگذاری میشود. بازبین فایل همچنین دسترسی خواندن به پنل expert-claim
|
||||||
برای خسارتهایی که بررسی میکند دارد.
|
برای خسارتهایی که بررسی میکند دارد.
|
||||||
</p>
|
</p>
|
||||||
@@ -499,8 +500,8 @@
|
|||||||
<p class="note">اندپوینتهای V4 و V5 یکسان هستند — فقط پیشوند تغییر میکند.</p>
|
<p class="note">اندپوینتهای V4 و V5 یکسان هستند — فقط پیشوند تغییر میکند.</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
|
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>فهرست تمام فایلهای تخصیصیافته به این بازبین فایل.</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>فهرست فایلهای مهرومومشدهٔ فایلساز که در شرکت بیمهٔ این بازبین برای دریافت آمادهاند، بهعلاوهٔ فایلهای تخصیصیافته به خود او.</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>my-files/:requestId</code></td><td>جزئیات کامل یک فایل (طرفین، گردش کار، فیلدهای کارشناس، شناسه خسارت مرتبط).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>my-files/:requestId</code></td><td>جزئیات کامل یک فایل آماده برای دریافت یا تخصیصیافته، در شرکت بیمهٔ همین بازبین (طرفین، گردش کار، فیلدهای کارشناس، شناسه خسارت مرتبط).</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>دریافت شناسه خسارت ایجادشده بهصورت خودکار (از استعلام طرف مقصر فایلساز).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>دریافت شناسه خسارت ایجادشده بهصورت خودکار (از استعلام طرف مقصر فایلساز).</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:requestId</code></td><td>مرحله ۱ (بازبین): ذخیره فیلدهای تصادف (accidentWay، accidentReason، accidentType).</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:requestId</code></td><td>مرحله ۱ (بازبین): ذخیره فیلدهای تصادف (accidentWay، accidentReason، accidentType).</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>الزامات عکسبرداری آگاه از مرحله (فاز اسناد پیش از عکسبرداری در مقابل فاز عکسبرداری قطعات).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>الزامات عکسبرداری آگاه از مرحله (فاز اسناد پیش از عکسبرداری در مقابل فاز عکسبرداری قطعات).</td></tr>
|
||||||
@@ -509,7 +510,7 @@
|
|||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>انتخاب سایر قطعات آسیبدیده (غیر بدنه).</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>انتخاب سایر قطعات آسیبدیده (غیر بدنه).</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>عکسبرداری از قطعات + زوایا برای هر قطعه آسیبدیده انتخابشده.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>عکسبرداری از قطعات + زوایا برای هر قطعه آسیبدیده انتخابشده.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>ویدیوی دور زدن خودرو (آخرین مرحله عکسبرداری بازبین). خسارت ← <code>WAITING_FOR_DAMAGE_EXPERT</code>، تقصیر ← <code>COMPLETED</code>.</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>ویدیوی دور زدن خودرو (آخرین مرحله عکسبرداری بازبین). خسارت ← <code>WAITING_FOR_DAMAGE_EXPERT</code>، تقصیر ← <code>COMPLETED</code>.</td></tr>
|
||||||
<tr><td><span class="method put">PUT</span></td><td><code>claim-sign/:claimId</code></td><td>ثبت امضای مالک روی قیمتگذاری کارشناس به نمایندگی از طرف زیاندیده (موافقت + branchId + تصویر امضا).</td></tr>
|
<tr><td><span class="method put">PUT</span></td><td><code>claim-sign/:claimId</code></td><td>فقط برای خسارتهای ترکیبیِ قیمت/فاکتور: ثبت موافقت با خطوط قیمتگذاریشده پیش از بارگذاری فاکتور. امضای نهایی مالک دیگر لازم نیست.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:requestId</code></td><td class="dep">در V4/V5 بیاثر است — تقصیر از قبل توسط car-capture COMPLETED شده. موفقیت idempotent برمیگرداند.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:requestId</code></td><td class="dep">در V4/V5 بیاثر است — تقصیر از قبل توسط car-capture COMPLETED شده. موفقیت idempotent برمیگرداند.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -217,7 +217,7 @@
|
|||||||
<td><code>file_reviewer</code></td>
|
<td><code>file_reviewer</code></td>
|
||||||
<td>FileReviewer panel</td>
|
<td>FileReviewer panel</td>
|
||||||
<td>Assigned files</td>
|
<td>Assigned files</td>
|
||||||
<td>V4/V5 damage assessment (accident fields, parts, captures, owner sign)</td>
|
<td>V4/V5 damage assessment (accident fields, parts, captures; mixed-factor priced-line acceptance when needed)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>registrar</code></td>
|
<td><code>registrar</code></td>
|
||||||
@@ -363,14 +363,14 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
|
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>List claims in <code>WAITING_FOR_DAMAGE_EXPERT</code> queue + factor-validation queue. Query: search, sortBy, page, limit, unifiedStatus, fileType.</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>List claims in <code>WAITING_FOR_DAMAGE_EXPERT</code> queue + factor-validation queue. Query: search, sortBy, page, limit, unifiedStatus, fileType.</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs.</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs, and the effective <code>priceCap</code> (530,000,000 Rial for V1; <code>null</code> for V2–V6 or when disabled). Completed claims include Fanavaran claimNo / claimId when available.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>Lock claim to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>Lock claim to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409.</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Price-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry.</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Price-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry.</td></tr>
|
||||||
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Calculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400.</td></tr>
|
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Calculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400.</td></tr>
|
||||||
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>Submit damage assessment reply (priced parts list, daghi, branchId). Cap: total ≤ 53 000 000 Toman. Depending on factorNeeded flags moves claim to owner-sign, mixed-factors-pending, or factor-validation queue.</td></tr>
|
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>Submit damage assessment reply (priced parts list and daghi). <code>daghi.branchId</code> is used only with the <code>تحویل داغی</code> option. V1 only: total ≤ 530,000,000 Rial; V2–V6 are uncapped. A priced-only claim completes immediately; factor claims continue through factor collection/validation. No final owner signature or automatic Fanavaran submission.</td></tr>
|
||||||
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>Request user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled.</td></tr>
|
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>Request user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>Ask user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION.</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>Ask user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>Validate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. Cap applies across all lines (≤ 53 000 000 Toman). Auto-completes when all lines are decided.</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>Validate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. The 530,000,000 Rial all-lines cap applies only to V1. Auto-completes when all lines are decided.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/request/:claimRequestId/damaged-parts</code></td><td>Edit selected damaged parts while the claim is locked by this expert (EXPERT_REVIEWING).</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/request/:claimRequestId/damaged-parts</code></td><td>Edit selected damaged parts while the claim is locked by this expert (EXPERT_REVIEWING).</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/outer-parts-catalog</code></td><td>Fanavaran outer car-components catalog (shared with user flow).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/outer-parts-catalog</code></td><td>Fanavaran outer car-components catalog (shared with user flow).</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/inner-parts-catalog</code></td><td>Static inner car-parts catalog JSON.</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/inner-parts-catalog</code></td><td>Static inner car-parts catalog JSON.</td></tr>
|
||||||
@@ -444,7 +444,8 @@
|
|||||||
OTPs, inquiries, location/description/voice, and signatures for both parties.
|
OTPs, inquiries, location/description/voice, and signatures for both parties.
|
||||||
They also upload the initial claim documents (licences, car cards). After the second
|
They also upload the initial claim documents (licences, car cards). After the second
|
||||||
signature the file is "sealed" for FileReviewer pickup. In V5, FileMaker comes
|
signature the file is "sealed" for FileReviewer pickup. In V5, FileMaker comes
|
||||||
back at the end to approve or reject the completed claim before fanavaran submission.
|
back at the end to approve or reject the completed claim. After approval, an
|
||||||
|
expert submits the case to Fanavaran manually.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="card card-purple">
|
<div class="card card-purple">
|
||||||
@@ -461,17 +462,17 @@
|
|||||||
<tr><td><span class="method post">POST</span></td><td><code>run-inquiries/:id</code> / <code>run-inquiries-vin/:id</code></td><td>Run plate or VIN inquiry. First call = guilty (+ auto-creates claim). Second call = damaged (THIRD_PARTY only).</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>run-inquiries/:id</code> / <code>run-inquiries-vin/:id</code></td><td>Run plate or VIN inquiry. First call = guilty (+ auto-creates claim). Second call = damaged (THIRD_PARTY only).</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>add-detail-location/:id</code> / <code>add-detail-description/:id</code> / <code>upload-voice/:id</code></td><td>Add location, description, and voice for current party (partyRole param selects FIRST/SECOND).</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>add-detail-location/:id</code> / <code>add-detail-description/:id</code> / <code>upload-voice/:id</code></td><td>Add location, description, and voice for current party (partyRole param selects FIRST/SECOND).</td></tr>
|
||||||
<tr><td><span class="method put">PUT</span></td><td><code>sign/:id</code></td><td>Upload party signature (partyRole=FIRST then SECOND). After second signature, file is sealed.</td></tr>
|
<tr><td><span class="method put">PUT</span></td><td><code>sign/:id</code></td><td>Upload party signature (partyRole=FIRST then SECOND). After second signature, file is sealed.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload licences / car cards against the auto-created claim.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload licences / car cards and the optional <code>accident_sketch</code> (کروکی) against the auto-created claim. The sketch never gates completion.</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (phases: pre-capture docs vs damaged parts + chassis/engine).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (phases: pre-capture docs vs damaged parts + chassis/engine).</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-purple">
|
<div class="card card-purple">
|
||||||
<h3>V5 claim approval — <code>v5/file-maker/claim-approval/</code></h3>
|
<h3>V5 claim approval — <code>v5/file-maker/claim-approval/</code></h3>
|
||||||
<p class="note">Used only in V5. After damage expert review and owner sign, claim enters <code>WAITING_FOR_FILE_MAKER_APPROVAL</code>.</p>
|
<p class="note">Used only in V5. After damage expert review and any required factor validation, claim enters <code>WAITING_FOR_FILE_MAKER_APPROVAL</code>; no final owner signature is needed.</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
|
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>approve/:claimId</code></td><td>Approve the completed claim → triggers owner sign SMS → fanavaran submission after owner signs.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>approve/:claimId</code></td><td>Approve the completed claim → claim becomes <code>COMPLETED</code>. An expert submits to Fanavaran manually when ready.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>reject/:claimId</code></td><td>Reject back to FileReviewer → claim returns to WAITING_FOR_DAMAGE_EXPERT. Limit: max 2 rejections per claim; 3rd attempt returns 422 <code>FILE_MAKER_REJECTION_LIMIT_EXCEEDED</code>.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>reject/:claimId</code></td><td>Reject back to FileReviewer → claim returns to WAITING_FOR_DAMAGE_EXPERT. Limit: max 2 rejections per claim; 3rd attempt returns 422 <code>FILE_MAKER_REJECTION_LIMIT_EXCEEDED</code>.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,7 +483,7 @@
|
|||||||
The second actor in the V4/V5 split. FileReviewer picks up sealed files (after
|
The second actor in the V4/V5 split. FileReviewer picks up sealed files (after
|
||||||
FileMaker is done) and performs the full damage assessment pass: accident fields,
|
FileMaker is done) and performs the full damage assessment pass: accident fields,
|
||||||
capture requirements lookup, document upload (chassis/engine), part selection,
|
capture requirements lookup, document upload (chassis/engine), part selection,
|
||||||
part photos, walk-around video, owner signature. The blame is marked COMPLETED
|
part photos and walk-around video. The blame is marked COMPLETED
|
||||||
by car-capture. FileReviewer also has read access to the expert-claim panel for
|
by car-capture. FileReviewer also has read access to the expert-claim panel for
|
||||||
claims they are reviewing.
|
claims they are reviewing.
|
||||||
</p>
|
</p>
|
||||||
@@ -492,17 +493,17 @@
|
|||||||
<p class="note">V4 and V5 endpoints are identical — only the prefix changes.</p>
|
<p class="note">V4 and V5 endpoints are identical — only the prefix changes.</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
|
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>List all files assigned to this FileReviewer.</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>List FileMaker-sealed files available to claim in this reviewer’s insurer, plus files already assigned to this FileReviewer.</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>my-files/:requestId</code></td><td>Full detail for one file (parties, workflow, expert fields, linked claim ID).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>my-files/:requestId</code></td><td>Full detail for one available or assigned file in this reviewer’s insurer (parties, workflow, expert fields, linked claim ID).</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>Get the auto-created claim ID (from FileMaker's guilty-party inquiry).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>Get the auto-created claim ID (from FileMaker's guilty-party inquiry).</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:requestId</code></td><td>Step 1 (FileReviewer): save accident fields (accidentWay, accidentReason, accidentType).</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:requestId</code></td><td>Step 1 (FileReviewer): save accident fields (accidentWay, accidentReason, accidentType).</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (pre-capture docs phase vs capture-parts phase).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (pre-capture docs phase vs capture-parts phase).</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload chassis / engine / metal-plate documents.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload chassis / engine / metal-plate documents; <code>accident_sketch</code> (کروکی) remains optional and does not affect completion.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>Select outer (body) damaged parts.</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>Select outer (body) damaged parts.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>Select other (non-body) damaged parts.</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>Select other (non-body) damaged parts.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>Capture part photos + angles for each selected damaged part.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>Capture part photos + angles for each selected damaged part.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>Walk-around video (final FileReviewer capture step). Claim → <code>WAITING_FOR_DAMAGE_EXPERT</code>, blame → <code>COMPLETED</code>.</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>Walk-around video (final FileReviewer capture step). Claim → <code>WAITING_FOR_DAMAGE_EXPERT</code>, blame → <code>COMPLETED</code>.</td></tr>
|
||||||
<tr><td><span class="method put">PUT</span></td><td><code>claim-sign/:claimId</code></td><td>Submit owner signature on expert pricing on behalf of the damaged party (agree + branchId + signature image).</td></tr>
|
<tr><td><span class="method put">PUT</span></td><td><code>claim-sign/:claimId</code></td><td>For mixed priced/factor claims only: record acceptance of priced lines before factor uploads. A final owner signature is no longer required.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:requestId</code></td><td class="dep">No-op in V4/V5 — blame already COMPLETED by car-capture. Returns idempotent success.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:requestId</code></td><td class="dep">No-op in V4/V5 — blame already COMPLETED by car-capture. Returns idempotent success.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -551,7 +552,7 @@
|
|||||||
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
|
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>create-from-blame/:blameId</code></td><td>Create claim from a completed blame file.</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>create-from-blame/:blameId</code></td><td>Create claim from a completed blame file.</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>outer-parts-catalog</code> / <code>car-other-part</code></td><td>Parts catalogs (outer body parts + other parts JSON).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>outer-parts-catalog</code> / <code>car-other-part</code></td><td>Parts catalogs (outer body parts + other parts JSON).</td></tr>
|
||||||
<tr><td><span class="method get">GET</span></td><td><code>branches/:insuranceId</code></td><td>Insurer branch list (for branch selection in claim sign step).</td></tr>
|
<tr><td><span class="method get">GET</span></td><td><code>branches/:insuranceId</code></td><td>Insurer branch list for <code>daghi.branchId</code> when the expert selects the <code>تحویل داغی</code> option.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>Select outer damaged parts.</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>Select outer damaged parts.</td></tr>
|
||||||
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>Select other damaged parts + bank info.</td></tr>
|
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>Select other damaged parts + bank info.</td></tr>
|
||||||
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload claim documents (licences, car card).</td></tr>
|
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload claim documents (licences, car card).</td></tr>
|
||||||
|
|||||||
402
docs/report-api-frontend-fa.md
Normal file
402
docs/report-api-frontend-fa.md
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
# مستند فرانتاند API گزارش PDF پرونده
|
||||||
|
|
||||||
|
## هدف API
|
||||||
|
این API دادهی ساختیافتهی لازم برای تولید PDF پرونده در پنل بیمهگر را برمیگرداند.
|
||||||
|
خروجی آن ترکیبی از اطلاعات پروندهی تقصیر (`blame`) و پروندهی خسارت (`claim`) است و طوری طراحی شده که فرانتاند بدون وابستگی به مدلهای داخلی بکاند، فقط با `sections` و `fields` بتواند PDF را رندر کند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## آدرس API
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /expert-insurer/files/:publicId/report
|
||||||
|
```
|
||||||
|
|
||||||
|
### پارامتر مسیر
|
||||||
|
- `publicId`: شناسه عمومی پرونده
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ساختار کلی پاسخ
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "گزارش پرونده بیمه گر",
|
||||||
|
"publicId": "RPT832-00406",
|
||||||
|
"requestNo": "BL-RPT832-000406",
|
||||||
|
"sections": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### فیلدهای سطح بالا
|
||||||
|
|
||||||
|
#### `title`
|
||||||
|
عنوان کلی گزارش.
|
||||||
|
|
||||||
|
#### `publicId`
|
||||||
|
شناسه عمومی پرونده.
|
||||||
|
|
||||||
|
#### `requestNo`
|
||||||
|
شماره درخواست، اگر در دادههای پرونده موجود باشد.
|
||||||
|
|
||||||
|
#### `sections`
|
||||||
|
آرایهای از سکشنهای گزارش.
|
||||||
|
هر سکشن یک عنوان دارد و شامل تعدادی ردیف اطلاعات (`fields`) است.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ساختار هر سکشن
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "زمانبندی پرونده",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"label": "تاریخ و ساعت ثبت پرونده",
|
||||||
|
"value": "1405/06/02 07:02"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `title`
|
||||||
|
عنوان فارسی سکشن، آمادهی نمایش در PDF.
|
||||||
|
|
||||||
|
### `fields`
|
||||||
|
لیست ردیفهای اطلاعاتی همان سکشن.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ساختار هر فیلد
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label": "شماره بیمهنامه",
|
||||||
|
"value": "POL-12345"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `label`
|
||||||
|
عنوان فارسی فیلد.
|
||||||
|
|
||||||
|
### `value`
|
||||||
|
مقدار فیلد.
|
||||||
|
برای نمایش مستقیم در PDF استفاده میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## سکشنهای ممکن در پاسخ
|
||||||
|
سکشنها معمولاً با ترتیب زیر برمیگردند، ولی فرانتاند بهتر است بهجای تکیه بر ایندکس آرایه، سکشن را با `title` پیدا کند:
|
||||||
|
|
||||||
|
1. `زمانبندی پرونده`
|
||||||
|
2. `مالک خودروی زیان دیده`
|
||||||
|
3. `مالک خودروی مقصر`
|
||||||
|
4. `راننده خودروی زیان دیده`
|
||||||
|
5. `بیمه شخص ثالث زیاندیده`
|
||||||
|
6. `بیمه بدنه زیاندیده`
|
||||||
|
7. `بیمه شخص ثالث مقصر`
|
||||||
|
8. `بیمه بدنه مقصر`
|
||||||
|
9. `اطلاعات خودروی زیاندیده`
|
||||||
|
10. `اطلاعات خودروی مقصر`
|
||||||
|
11. `اظهارات و اقرار زیاندیده`
|
||||||
|
12. `اظهارات و اقرار مقصر`
|
||||||
|
13. `کدهای فناوران`
|
||||||
|
14. `نتیجه ارزیابی`
|
||||||
|
15. `گزارش حادثه`
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
- بعضی سکشنها بسته به نوع پرونده ممکن است وجود نداشته باشند.
|
||||||
|
- در پروندههای بدنه (`CAR_BODY`) اگر طرفین عملاً یک نفر باشند، سکشنهای مربوط به مقصر ممکن است حذف شوند.
|
||||||
|
- در پروندههای شخص ثالث (`THIRD_PARTY`) انتظار میرود اطلاعات هر دو طرف بهصورت تفکیکشده برگردد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# توضیح سکشنها
|
||||||
|
|
||||||
|
## 1) `زمانبندی پرونده`
|
||||||
|
برای نمایش زمانهای مهم پرونده.
|
||||||
|
|
||||||
|
فیلدهای مهم:
|
||||||
|
- `تاریخ و ساعت ثبت پرونده`
|
||||||
|
- `تاریخ و ساعت ثبت نتیجه ارزیابی`
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
- این تاریخها در بکاند فرمت شدهاند و آمادهی نمایش هستند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) `مالک خودروی زیان دیده`
|
||||||
|
اطلاعات مالک یا صاحب خودروی زیاندیده.
|
||||||
|
|
||||||
|
فیلدهای رایج:
|
||||||
|
- `نام`
|
||||||
|
- `شماره تلفن`
|
||||||
|
- `کد ملی`
|
||||||
|
- `تاریخ تولد`
|
||||||
|
- `شماره شبا`
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
- `شماره شبا` معمولاً برای زیاندیده مهم است و ممکن است فقط در همین سکشن وجود داشته باشد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) `مالک خودروی مقصر`
|
||||||
|
اطلاعات مالک خودروی مقصر.
|
||||||
|
|
||||||
|
فیلدهای رایج:
|
||||||
|
- `نام`
|
||||||
|
- `شماره تلفن`
|
||||||
|
- `کد ملی`
|
||||||
|
- `تاریخ تولد`
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
- این سکشن مخصوص پروندههای شخص ثالث اهمیت دارد تا اطلاعات مالک هر دو طرف در PDF موجود باشد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) `راننده خودروی زیان دیده`
|
||||||
|
اگر راننده با مالک/بیمهگذار متفاوت باشد، این سکشن برمیگردد.
|
||||||
|
|
||||||
|
فیلدهای رایج:
|
||||||
|
- `نام`
|
||||||
|
- `نوع گواهینامه`
|
||||||
|
- `تاریخ گواهینامه`
|
||||||
|
- `شماره تلفن`
|
||||||
|
- `کد ملی`
|
||||||
|
- `تاریخ تولد`
|
||||||
|
- `شماره گواهینامه`
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
- اگر راننده و مالک یکی باشند، این سکشن ممکن است وجود نداشته باشد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) `بیمه شخص ثالث زیاندیده`
|
||||||
|
اطلاعات بیمه شخص ثالث طرف زیاندیده.
|
||||||
|
|
||||||
|
فیلدهای رایج:
|
||||||
|
- `شماره بیمهنامه`
|
||||||
|
- `شرکت بیمه`
|
||||||
|
- `تاریخ شروع بیمهنامه`
|
||||||
|
- `تاریخ پایان بیمهنامه`
|
||||||
|
- `سقف تعهد مالی`
|
||||||
|
- `پوششها`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) `بیمه بدنه زیاندیده`
|
||||||
|
اطلاعات بیمه بدنهی طرف زیاندیده.
|
||||||
|
|
||||||
|
فیلدهای رایج:
|
||||||
|
- `شماره بیمهنامه`
|
||||||
|
- `شرکت بیمه`
|
||||||
|
- `تاریخ شروع بیمهنامه`
|
||||||
|
- `تاریخ پایان بیمهنامه`
|
||||||
|
- `پوششها`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) `بیمه شخص ثالث مقصر`
|
||||||
|
اطلاعات بیمه شخص ثالث طرف مقصر.
|
||||||
|
|
||||||
|
فیلدهای رایج:
|
||||||
|
- `شماره بیمهنامه`
|
||||||
|
- `شرکت بیمه`
|
||||||
|
- `تاریخ شروع بیمهنامه`
|
||||||
|
- `تاریخ پایان بیمهنامه`
|
||||||
|
- `سقف تعهد مالی`
|
||||||
|
- `پوششها`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) `بیمه بدنه مقصر`
|
||||||
|
اطلاعات بیمه بدنهی طرف مقصر.
|
||||||
|
|
||||||
|
فیلدهای رایج:
|
||||||
|
- `شماره بیمهنامه`
|
||||||
|
- `شرکت بیمه`
|
||||||
|
- `تاریخ شروع بیمهنامه`
|
||||||
|
- `تاریخ پایان بیمهنامه`
|
||||||
|
- `پوششها`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) `اطلاعات خودروی زیاندیده`
|
||||||
|
جزئیات خودروی زیاندیده.
|
||||||
|
|
||||||
|
فیلدها میتوانند شامل موارد زیر باشند:
|
||||||
|
- `خودرو / پلاک`
|
||||||
|
- `خودرو / نام خودرو`
|
||||||
|
- `خودرو / مدل خودرو`
|
||||||
|
- `خودرو / نوع خودرو`
|
||||||
|
- `VIN`
|
||||||
|
- `شماره موتور`
|
||||||
|
- `شماره شاسی`
|
||||||
|
- `رنگ اصلی`
|
||||||
|
- `رنگ فرعی`
|
||||||
|
- `سیستم`
|
||||||
|
- `تیپ`
|
||||||
|
- `کاربری`
|
||||||
|
- `ظرفیت`
|
||||||
|
- `تعداد سیلندر`
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
- بسته به منبع داده، ممکن است بعضی فیلدها با برچسبهای نزدیک به هم ولی از دو منبع مختلف برگردند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) `اطلاعات خودروی مقصر`
|
||||||
|
جزئیات خودروی طرف مقصر.
|
||||||
|
|
||||||
|
فیلدها مشابه سکشن خودروی زیاندیده هستند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11) `اظهارات و اقرار زیاندیده`
|
||||||
|
اطلاعات مربوط به اظهارات طرف زیاندیده.
|
||||||
|
|
||||||
|
فیلدهای رایج:
|
||||||
|
- `نقش طرف`
|
||||||
|
- `نام`
|
||||||
|
- `ادعای خسارت`
|
||||||
|
- `پذیرش نظر کارشناس`
|
||||||
|
- `توضیحات طرف`
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
- معمولاً `اقرار به تقصیر` برای زیاندیده نمایش داده نمیشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12) `اظهارات و اقرار مقصر`
|
||||||
|
اطلاعات مربوط به اظهارات طرف مقصر.
|
||||||
|
|
||||||
|
فیلدهای رایج:
|
||||||
|
- `نقش طرف`
|
||||||
|
- `نام`
|
||||||
|
- `اقرار به تقصیر`
|
||||||
|
- `پذیرش نظر کارشناس`
|
||||||
|
- `توضیحات طرف`
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
- معمولاً `ادعای خسارت` برای مقصر نمایش داده نمیشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13) `کدهای فناوران`
|
||||||
|
کدها و شناسههای فنی مرتبط با پرونده در فناوران.
|
||||||
|
|
||||||
|
فیلدهای ممکن:
|
||||||
|
- `شماره پرونده فناوران`
|
||||||
|
- `کد پرونده فناوران`
|
||||||
|
- `کد کیس خسارت فناوران`
|
||||||
|
- `کد کارشناسی فناوران`
|
||||||
|
- `کد بیمهنامه فناوران`
|
||||||
|
- `کد راننده فناوران`
|
||||||
|
- `کد نوع خودرو فناوران`
|
||||||
|
- `کد شرکت بیمه فناوران`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14) `نتیجه ارزیابی`
|
||||||
|
اطلاعات نتیجهی ارزیابی کارشناس خسارت.
|
||||||
|
|
||||||
|
فیلدهای مهم:
|
||||||
|
- `نتیجه ارزیابی`
|
||||||
|
- `کارشناس ارزیاب`
|
||||||
|
- `تاریخ و ساعت ثبت ارزیابی`
|
||||||
|
- `پاسخ / توضیحات کارشناس`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15) `گزارش حادثه`
|
||||||
|
خلاصهی اطلاعات حادثه، وضعیت پرونده و برخی خروجیهای کارشناسی.
|
||||||
|
|
||||||
|
فیلدهای رایج:
|
||||||
|
- `تاریخ حادثه`
|
||||||
|
- `ساعت حادثه`
|
||||||
|
- `کارشناس(ان)`
|
||||||
|
- `موقعیت (عرض و طول جغرافیایی)`
|
||||||
|
- `وضعیت آب و هوا`
|
||||||
|
- `وضعیت جاده`
|
||||||
|
- `وضعیت نور`
|
||||||
|
- `وضعیت مقصر`
|
||||||
|
- `وضعیت خسارت`
|
||||||
|
- `نظر کارشناس مقصر`
|
||||||
|
- `نحوه برخورد`
|
||||||
|
- `علت حادثه`
|
||||||
|
- `نوع حادثه`
|
||||||
|
- `توضیحات طرف`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات مهم برای فرانتاند
|
||||||
|
|
||||||
|
### 1) فقط بر اساس `sections` و `fields` رندر کنید
|
||||||
|
ساختار اصلی خروجی این است:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
response.sections[].title
|
||||||
|
response.sections[].fields[].label
|
||||||
|
response.sections[].fields[].value
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2) به ایندکس سکشنها وابسته نشوید
|
||||||
|
ممکن است بعضی سکشنها در بعضی پروندهها وجود نداشته باشند.
|
||||||
|
بهتر است سکشن را با `title` پیدا کنید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3) نبودن بعضی سکشنها طبیعی است
|
||||||
|
مثلاً:
|
||||||
|
- `راننده خودروی زیان دیده`
|
||||||
|
- سکشنهای مربوط به مقصر در بعضی پروندههای بدنه
|
||||||
|
- بعضی دادههای فناوران
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4) مقدار `-` یعنی دادهای برای نمایش وجود نداشته
|
||||||
|
اگر سکشنی دادهی واقعی نداشته باشد، ممکن است فقط این مقدار را داشته باشد:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label": "اطلاعات",
|
||||||
|
"value": "-"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5) برچسبها فارسی و آمادهی نمایش هستند
|
||||||
|
فیلدهای `title` و `label` نیازی به ترجمهی مجدد در فرانتاند ندارند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نمونهی سادهی رندر در فرانتاند
|
||||||
|
|
||||||
|
```ts
|
||||||
|
for (const section of response.sections) {
|
||||||
|
renderSectionTitle(section.title)
|
||||||
|
|
||||||
|
for (const field of section.fields) {
|
||||||
|
renderRow(field.label, field.value ?? "-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## خلاصه
|
||||||
|
این API برای تولید PDF پرونده، دادهها را بهصورت کامل و تفکیکشده برمیگرداند، از جمله:
|
||||||
|
|
||||||
|
- زمانبندی پرونده
|
||||||
|
- اطلاعات مالک زیاندیده
|
||||||
|
- اطلاعات مالک مقصر
|
||||||
|
- اطلاعات راننده در صورت متفاوت بودن
|
||||||
|
- بیمهنامههای تفکیکشدهی ثالث و بدنه برای هر طرف
|
||||||
|
- اطلاعات خودرو برای هر دو طرف
|
||||||
|
- اظهارات و اقرار هر دو طرف
|
||||||
|
- کدهای فناوران
|
||||||
|
- نتیجه ارزیابی و توضیحات کارشناس
|
||||||
|
- گزارش حادثه
|
||||||
1422
package-lock.json
generated
1422
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
|||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"seed:parsian-tehran": "ts-node scripts/seed-parsian-tehran.ts",
|
"seed:parsian-tehran": "ts-node scripts/seed-parsian-tehran.ts",
|
||||||
|
"seed:reports-fixtures": "ts-node scripts/seed-insurer-reports-fixtures.ts",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"svg-captcha": "^1.4.0"
|
"svg-captcha": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@compodoc/compodoc": "^2.0.0",
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
|||||||
464
scripts/data/parsian-tehran/file-makers.json
Normal file
464
scripts/data/parsian-tehran/file-makers.json
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
{
|
||||||
|
"clientCode": 8,
|
||||||
|
"fileMakers": [
|
||||||
|
{
|
||||||
|
"nationalCode": "0061077410",
|
||||||
|
"mobile": "09125864643",
|
||||||
|
"firstName": "احمد",
|
||||||
|
"lastName": "بستان پيرا",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210110",
|
||||||
|
"name": "شعبه پونک"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "32",
|
||||||
|
"CarBodyClaimExpertId": "33"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0492009856",
|
||||||
|
"mobile": "09128465836",
|
||||||
|
"firstName": "اسداله",
|
||||||
|
"lastName": "نجفي پور",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210040",
|
||||||
|
"name": "شعبه شرق تهران(210040)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "91",
|
||||||
|
"CarBodyClaimExpertId": "92"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0062277553",
|
||||||
|
"mobile": "09125012274",
|
||||||
|
"firstName": "اسماعيل",
|
||||||
|
"lastName": "سلطانمحمدي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "4221",
|
||||||
|
"CarBodyClaimExpertId": "4222"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0492295557",
|
||||||
|
"mobile": "09125409101",
|
||||||
|
"firstName": "اکبر",
|
||||||
|
"lastName": "بيگ محمدي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "111130",
|
||||||
|
"name": "شعبه ويژه ميرداماد"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "110011",
|
||||||
|
"name": "ستاد مرکزي"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "278"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "3220096573",
|
||||||
|
"mobile": "09104933206",
|
||||||
|
"firstName": "پدرام",
|
||||||
|
"lastName": "حاتمي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210120",
|
||||||
|
"name": "شعبه والفجر"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "219"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0078954010",
|
||||||
|
"mobile": "09128894315",
|
||||||
|
"firstName": "پروانه",
|
||||||
|
"lastName": "آدابي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210040",
|
||||||
|
"name": "شعبه شرق تهران(210040)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "739",
|
||||||
|
"CarBodyClaimExpertId": "740"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0075127105",
|
||||||
|
"mobile": "09122938429",
|
||||||
|
"firstName": "حميد",
|
||||||
|
"lastName": "غوثي هوجقان",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210040",
|
||||||
|
"name": "شعبه شرق تهران(210040)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyClaimExpertId": "3215",
|
||||||
|
"ThirdPartyClaimExpertId": "3214"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0081114494",
|
||||||
|
"mobile": "09374439044",
|
||||||
|
"firstName": "داود",
|
||||||
|
"lastName": "صديق",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210120",
|
||||||
|
"name": "شعبه والفجر"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "222",
|
||||||
|
"CarBodyClaimExpertId": "223"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0084130938",
|
||||||
|
"mobile": "09392558640",
|
||||||
|
"firstName": "رسول",
|
||||||
|
"lastName": "کرکي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "4662"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0014103788",
|
||||||
|
"mobile": "09118861247",
|
||||||
|
"firstName": "سجاد",
|
||||||
|
"lastName": "حشمت",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210040",
|
||||||
|
"name": "شعبه شرق تهران(210040)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "4245",
|
||||||
|
"CarBodyClaimExpertId": "4246"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0065938100",
|
||||||
|
"mobile": "09122366860",
|
||||||
|
"firstName": "سهيلا",
|
||||||
|
"lastName": "شکوري قره چيق",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210040",
|
||||||
|
"name": "شعبه شرق تهران(210040)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyClaimExpertId": "89",
|
||||||
|
"ThirdPartyClaimExpertId": "88"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "5779888949",
|
||||||
|
"mobile": "09371290042",
|
||||||
|
"firstName": "عاطفه",
|
||||||
|
"lastName": "نصيري",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210040",
|
||||||
|
"name": "شعبه شرق تهران(210040)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyClaimExpertId": "4455",
|
||||||
|
"ThirdPartyClaimExpertId": "4454"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0069608210",
|
||||||
|
"mobile": "09120766792",
|
||||||
|
"firstName": "عباس",
|
||||||
|
"lastName": "سلطاني محمدي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210040",
|
||||||
|
"name": "شعبه شرق تهران(210040)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "742",
|
||||||
|
"CarBodyClaimExpertId": "743"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0065409027",
|
||||||
|
"mobile": "09125045732",
|
||||||
|
"firstName": "علي",
|
||||||
|
"lastName": "اصلاني حاجي آبادي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210120",
|
||||||
|
"name": "شعبه والفجر"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "4338",
|
||||||
|
"CarBodyClaimExpertId": "4339"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "4570007309",
|
||||||
|
"mobile": "09104873190",
|
||||||
|
"firstName": "علي",
|
||||||
|
"lastName": "قرباني",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210120",
|
||||||
|
"name": "شعبه والفجر"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyClaimExpertId": "4540",
|
||||||
|
"ThirdPartyClaimExpertId": "4539"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0077396881",
|
||||||
|
"mobile": "09120000001",
|
||||||
|
"firstName": "علي",
|
||||||
|
"lastName": "مهرجو",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210040",
|
||||||
|
"name": "شعبه شرق تهران(210040)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyClaimExpertId": "4652",
|
||||||
|
"ThirdPartyClaimExpertId": "4650"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0068289782",
|
||||||
|
"mobile": "09124196488",
|
||||||
|
"firstName": "عليرضا",
|
||||||
|
"lastName": "درگاهي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210120",
|
||||||
|
"name": "شعبه والفجر"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "57",
|
||||||
|
"CarBodyClaimExpertId": "58"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0066868521",
|
||||||
|
"mobile": "09129344240",
|
||||||
|
"firstName": "عليرضا",
|
||||||
|
"lastName": "گودرزي پور",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "4663"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0061947466",
|
||||||
|
"mobile": "09125045283",
|
||||||
|
"firstName": "غزال",
|
||||||
|
"lastName": "عطائي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyClaimExpertId": "4220",
|
||||||
|
"ThirdPartyClaimExpertId": "4219"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0062500767",
|
||||||
|
"mobile": "09124464022",
|
||||||
|
"firstName": "غلامرضا",
|
||||||
|
"lastName": "حسن پوراقدم",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyClaimExpertId": "4452",
|
||||||
|
"ThirdPartyClaimExpertId": "4451"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0019675526",
|
||||||
|
"mobile": "09120399833",
|
||||||
|
"firstName": "فاطمه",
|
||||||
|
"lastName": "عظيميان",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210120",
|
||||||
|
"name": "شعبه والفجر"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyClaimExpertId": "4340",
|
||||||
|
"ThirdPartyClaimExpertId": "4341"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0056888082",
|
||||||
|
"mobile": "09122406750",
|
||||||
|
"firstName": "قاسم",
|
||||||
|
"lastName": "نصراللهي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "4664"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "3932143930",
|
||||||
|
"mobile": "09125255267",
|
||||||
|
"firstName": "محسن",
|
||||||
|
"lastName": "کرمي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210120",
|
||||||
|
"name": "شعبه والفجر"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyClaimExpertId": "4415",
|
||||||
|
"ThirdPartyClaimExpertId": "4414"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0013269755",
|
||||||
|
"mobile": "09195502061",
|
||||||
|
"firstName": "محمد",
|
||||||
|
"lastName": "ابراهيمي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyClaimExpertId": "4092",
|
||||||
|
"ThirdPartyClaimExpertId": "4091"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0011834803",
|
||||||
|
"mobile": "09127059125",
|
||||||
|
"firstName": "محمد",
|
||||||
|
"lastName": "ترابي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "1010",
|
||||||
|
"CarBodyClaimExpertId": "1011"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0493217789",
|
||||||
|
"mobile": "09126966943",
|
||||||
|
"firstName": "مصطفي",
|
||||||
|
"lastName": "محمدزاده قورقچي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "4665"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0080501281",
|
||||||
|
"mobile": "09356468730",
|
||||||
|
"firstName": "موسي",
|
||||||
|
"lastName": "موسي نژاد",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210040",
|
||||||
|
"name": "شعبه شرق تهران(210040)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyClaimExpertId": "745",
|
||||||
|
"ThirdPartyClaimExpertId": "744"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0078209129",
|
||||||
|
"mobile": "09126038117",
|
||||||
|
"firstName": "مهدي",
|
||||||
|
"lastName": "شاملوفرد",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "4666"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0074009141",
|
||||||
|
"mobile": "09339103762",
|
||||||
|
"firstName": "مهدي",
|
||||||
|
"lastName": "معمارباشي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210120",
|
||||||
|
"name": "شعبه والفجر"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "216",
|
||||||
|
"CarBodyClaimExpertId": "217"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "3720211207",
|
||||||
|
"mobile": "09379120944",
|
||||||
|
"firstName": "مهسا",
|
||||||
|
"lastName": "وطن نيا",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210110",
|
||||||
|
"name": "شعبه پونک"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "34",
|
||||||
|
"CarBodyClaimExpertId": "35"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0079616801",
|
||||||
|
"mobile": "09379669839",
|
||||||
|
"firstName": "مهيار",
|
||||||
|
"lastName": "کيائي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "1004",
|
||||||
|
"CarBodyClaimExpertId": "1005"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0079815278",
|
||||||
|
"mobile": "09195883178",
|
||||||
|
"firstName": "ميگل",
|
||||||
|
"lastName": "ميرشکاري",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210040",
|
||||||
|
"name": "شعبه شرق تهران(210040)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "4646",
|
||||||
|
"CarBodyClaimExpertId": "4648"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0010736638",
|
||||||
|
"mobile": "09355242492",
|
||||||
|
"firstName": "ناصر",
|
||||||
|
"lastName": "عيوضي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210040",
|
||||||
|
"name": "شعبه شرق تهران(210040)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyClaimExpertId": "228",
|
||||||
|
"CarBodyClaimExpertId": "229"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
161
scripts/data/parsian-tehran/file-reviewers.json
Normal file
161
scripts/data/parsian-tehran/file-reviewers.json
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
{
|
||||||
|
"clientCode": 8,
|
||||||
|
"fileReviewers": [
|
||||||
|
{
|
||||||
|
"nationalCode": "0051967839",
|
||||||
|
"mobile": "09121354859",
|
||||||
|
"firstName": "حسين",
|
||||||
|
"lastName": "جعفري",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210120",
|
||||||
|
"name": "شعبه والفجر"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyExpertiseClaim": "3542",
|
||||||
|
"CarBodyExpertiseClaim": "3541"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0084130938",
|
||||||
|
"mobile": "09392558640",
|
||||||
|
"firstName": "رسول",
|
||||||
|
"lastName": "کرکي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyExpertiseClaim": "29",
|
||||||
|
"CarBodyExpertiseClaim": "28"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "1262982308",
|
||||||
|
"mobile": "09130121246",
|
||||||
|
"firstName": "روح الله",
|
||||||
|
"lastName": "سلمانيان مقدم نياسري",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210120",
|
||||||
|
"name": "شعبه والفجر"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyExpertiseClaim": "3705"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0066868521",
|
||||||
|
"mobile": "09129344240",
|
||||||
|
"firstName": "عليرضا",
|
||||||
|
"lastName": "گودرزي پور",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyExpertiseClaim": "15",
|
||||||
|
"ThirdPartyExpertiseClaim": "16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0440245151",
|
||||||
|
"mobile": "09130606183",
|
||||||
|
"firstName": "فرهاد",
|
||||||
|
"lastName": "ملکي مونقي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "110011",
|
||||||
|
"name": "ستاد مرکزي"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyExpertiseClaim": "60",
|
||||||
|
"CarBodyExpertiseClaim": "61"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0056888082",
|
||||||
|
"mobile": "09122406750",
|
||||||
|
"firstName": "قاسم",
|
||||||
|
"lastName": "نصراللهي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyExpertiseClaim": "4645"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0670358118",
|
||||||
|
"mobile": "09124421539",
|
||||||
|
"firstName": "مجيد",
|
||||||
|
"lastName": "اميري",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210040",
|
||||||
|
"name": "شعبه شرق تهران(210040)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyExpertiseClaim": "3985",
|
||||||
|
"CarBodyExpertiseClaim": "3986"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0083730397",
|
||||||
|
"mobile": "09125759960",
|
||||||
|
"firstName": "مجيد",
|
||||||
|
"lastName": "کاظمي دولت سرا",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210120",
|
||||||
|
"name": "شعبه والفجر"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyExpertiseClaim": "3992",
|
||||||
|
"CarBodyExpertiseClaim": "3991"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0493217789",
|
||||||
|
"mobile": "09126966943",
|
||||||
|
"firstName": "مصطفي",
|
||||||
|
"lastName": "محمدزاده قورقچي",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ThirdPartyExpertiseClaim": "3545",
|
||||||
|
"CarBodyExpertiseClaim": "3544"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0076988961",
|
||||||
|
"mobile": "09108357378",
|
||||||
|
"firstName": "مهدي",
|
||||||
|
"lastName": "روشن دل",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210110",
|
||||||
|
"name": "شعبه پونک"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyExpertiseClaim": "3988",
|
||||||
|
"ThirdPartyExpertiseClaim": "3989"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0078209129",
|
||||||
|
"mobile": "09126038117",
|
||||||
|
"firstName": "مهدي",
|
||||||
|
"lastName": "شاملوفرد",
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "210050",
|
||||||
|
"name": "شعبه غرب تهران(210050)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "210110",
|
||||||
|
"name": "شعبه پونک"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CarBodyExpertiseClaim": "73",
|
||||||
|
"ThirdPartyExpertiseClaim": "72"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2487
scripts/seed-insurer-reports-fixtures.ts
Normal file
2487
scripts/seed-insurer-reports-fixtures.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
|||||||
export enum ClaimRequiredDocumentType {
|
export enum ClaimRequiredDocumentType {
|
||||||
|
/** Optional police accident sketch (Persian: کروکی). Never gates claim progress. */
|
||||||
|
ACCIDENT_SKETCH = "accident_sketch",
|
||||||
|
|
||||||
// Car green card
|
// Car green card
|
||||||
CAR_GREEN_CARD = "car_green_card",
|
CAR_GREEN_CARD = "car_green_card",
|
||||||
CAR_CERTIFICATE = "car_certificate",
|
CAR_CERTIFICATE = "car_certificate",
|
||||||
@@ -34,4 +37,3 @@ export enum CarAngle {
|
|||||||
LEFT = "left",
|
LEFT = "left",
|
||||||
RIGHT = "right",
|
RIGHT = "right",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export enum TypeOfDamage {
|
export enum TypeOfDamage {
|
||||||
Repair = "repair",
|
Repair = "تعمیر",
|
||||||
Change = "change",
|
Change = "تعویض",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { buildInsurerFileReport } from "./case-expert-report.builder";
|
import { buildInsurerFileReport } from "./case-expert-report.builder";
|
||||||
import { PR } from "./persian-report-labels";
|
import { PR } from "./persian-report-labels";
|
||||||
import { toJalaliDateAndTime } from "../helpers/date-jalali";
|
|
||||||
|
|
||||||
describe("buildInsurerFileReport", () => {
|
describe("buildInsurerFileReport", () => {
|
||||||
const getFieldValue = (
|
const getFieldValue = (
|
||||||
@@ -18,9 +17,6 @@ describe("buildInsurerFileReport", () => {
|
|||||||
it("separates insurance blocks and exposes Fanavaran/timeline/evaluation data", () => {
|
it("separates insurance blocks and exposes Fanavaran/timeline/evaluation data", () => {
|
||||||
const fileCreatedAt = new Date("2026-08-10T09:53:23.588Z");
|
const fileCreatedAt = new Date("2026-08-10T09:53:23.588Z");
|
||||||
const evaluationSubmittedAt = new Date("2026-08-10T12:30:45.000Z");
|
const evaluationSubmittedAt = new Date("2026-08-10T12:30:45.000Z");
|
||||||
const [fileDate, fileTime] = toJalaliDateAndTime(fileCreatedAt);
|
|
||||||
const [evaluationDate, evaluationTime] =
|
|
||||||
toJalaliDateAndTime(evaluationSubmittedAt);
|
|
||||||
|
|
||||||
const report = buildInsurerFileReport({
|
const report = buildInsurerFileReport({
|
||||||
overview: {
|
overview: {
|
||||||
@@ -39,9 +35,25 @@ describe("buildInsurerFileReport", () => {
|
|||||||
userId: "guilty-user-id",
|
userId: "guilty-user-id",
|
||||||
fullName: "مقصر نمونه",
|
fullName: "مقصر نمونه",
|
||||||
phoneNumber: "09120000000",
|
phoneNumber: "09120000000",
|
||||||
|
nationalCodeOfInsurer: "0987654321",
|
||||||
|
insurerBirthday: 13650115,
|
||||||
clientId: "client-guilty",
|
clientId: "client-guilty",
|
||||||
},
|
},
|
||||||
statement: { admitsGuilt: true },
|
statement: {
|
||||||
|
admitsGuilt: true,
|
||||||
|
acceptsExpertOpinion: true,
|
||||||
|
description: "توضیحات مقصر",
|
||||||
|
},
|
||||||
|
vehicle: {
|
||||||
|
carName: "206",
|
||||||
|
carModel: "1401",
|
||||||
|
plate: {
|
||||||
|
leftDigits: 98,
|
||||||
|
centerAlphabet: "ج",
|
||||||
|
centerDigits: 765,
|
||||||
|
ir: 22,
|
||||||
|
},
|
||||||
|
},
|
||||||
insurance: {
|
insurance: {
|
||||||
policyNumber: "TP-GUILTY-001",
|
policyNumber: "TP-GUILTY-001",
|
||||||
company: "بیمه ثالث مقصر",
|
company: "بیمه ثالث مقصر",
|
||||||
@@ -58,6 +70,26 @@ describe("buildInsurerFileReport", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "SECOND",
|
role: "SECOND",
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
participantId: "DRIVER",
|
||||||
|
fullName: "راننده نمونه",
|
||||||
|
nationalCode: "0012345678",
|
||||||
|
birthday: "1370/01/01",
|
||||||
|
licenseNumber: "LIC-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
participantId: "VEHICLE_OWNER",
|
||||||
|
fullName: "زیان دیده نمونه",
|
||||||
|
nationalCode: "1234567890",
|
||||||
|
birthday: "1370/01/01",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
participantRoles: {
|
||||||
|
driver: "DRIVER",
|
||||||
|
vehicleOwner: "VEHICLE_OWNER",
|
||||||
|
thirdPartyPolicyholder: "VEHICLE_OWNER",
|
||||||
|
},
|
||||||
person: {
|
person: {
|
||||||
userId: "damaged-user-id",
|
userId: "damaged-user-id",
|
||||||
fullName: "زیان دیده نمونه",
|
fullName: "زیان دیده نمونه",
|
||||||
@@ -68,6 +100,7 @@ describe("buildInsurerFileReport", () => {
|
|||||||
},
|
},
|
||||||
statement: {
|
statement: {
|
||||||
claimsDamage: true,
|
claimsDamage: true,
|
||||||
|
acceptsExpertOpinion: false,
|
||||||
description: "توضیحات زیاندیده",
|
description: "توضیحات زیاندیده",
|
||||||
accidentDate: "2026-08-10",
|
accidentDate: "2026-08-10",
|
||||||
accidentTime: "13:23",
|
accidentTime: "13:23",
|
||||||
@@ -135,49 +168,422 @@ describe("buildInsurerFileReport", () => {
|
|||||||
submittedAt: evaluationSubmittedAt,
|
submittedAt: evaluationSubmittedAt,
|
||||||
description: "نیاز به تعویض سپر جلو",
|
description: "نیاز به تعویض سپر جلو",
|
||||||
actorDetail: { actorName: "کارشناس خسارت نمونه" },
|
actorDetail: { actorName: "کارشناس خسارت نمونه" },
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
partId: 12,
|
||||||
|
carPartDamage: { label_fa: "سپر جلو" },
|
||||||
|
typeOfDamage: "تعویض",
|
||||||
|
price: 2_500_000,
|
||||||
|
salary: 500_000,
|
||||||
|
totalPayment: 3_000_000,
|
||||||
|
factorNeeded: false,
|
||||||
|
daghi: { option: "تحویل داغی" },
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getFieldValue(report, PR.damagedThirdPartyInsuranceSection, PR.policyNumber)).toBe(
|
expect(getFieldValue(report, PR.ownerSection, PR.name)).toBe(
|
||||||
"TP-DAMAGED-001",
|
"زیان دیده نمونه",
|
||||||
);
|
);
|
||||||
expect(getFieldValue(report, PR.guiltyThirdPartyInsuranceSection, PR.policyNumber)).toBe(
|
expect(
|
||||||
"TP-GUILTY-001",
|
getFieldValue(report, PR.damagedParticipantsSection, PR.driverRole),
|
||||||
|
).toContain("راننده نمونه");
|
||||||
|
expect(
|
||||||
|
getFieldValue(
|
||||||
|
report,
|
||||||
|
PR.damagedParticipantsSection,
|
||||||
|
PR.thirdPartyPolicyholderRole,
|
||||||
|
),
|
||||||
|
).toContain("1234567890");
|
||||||
|
expect(getFieldValue(report, PR.guiltyOwnerSection, PR.name)).toBe(
|
||||||
|
"مقصر نمونه",
|
||||||
);
|
);
|
||||||
expect(getFieldValue(report, PR.damagedCarBodyInsuranceSection, PR.policyNumber)).toBe(
|
expect(getFieldValue(report, PR.guiltyOwnerSection, PR.phone)).toBe(
|
||||||
"CB-DAMAGED-001",
|
"09120000000",
|
||||||
);
|
);
|
||||||
expect(getFieldValue(report, PR.guiltyCarBodyInsuranceSection, PR.policyNumber)).toBe(
|
expect(getFieldValue(report, PR.guiltyOwnerSection, PR.nationalCode)).toBe(
|
||||||
"CB-GUILTY-001",
|
"0987654321",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getFieldValue(report, PR.fanavaranSection, PR.fanavaranClaimNo)).toBe(
|
expect(
|
||||||
"111",
|
getFieldValue(
|
||||||
);
|
report,
|
||||||
|
PR.damagedThirdPartyInsuranceSection,
|
||||||
|
PR.policyNumber,
|
||||||
|
),
|
||||||
|
).toBe("TP-DAMAGED-001");
|
||||||
|
expect(
|
||||||
|
getFieldValue(
|
||||||
|
report,
|
||||||
|
PR.guiltyThirdPartyInsuranceSection,
|
||||||
|
PR.policyNumber,
|
||||||
|
),
|
||||||
|
).toBe("TP-GUILTY-001");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.damagedCarBodyInsuranceSection, PR.policyNumber),
|
||||||
|
).toBe("CB-DAMAGED-001");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.guiltyCarBodyInsuranceSection, PR.policyNumber),
|
||||||
|
).toBe("CB-GUILTY-001");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.damagedVehicleSection, "خودرو / نام خودرو"),
|
||||||
|
).toBe("207");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.guiltyVehicleSection, "خودرو / نام خودرو"),
|
||||||
|
).toBe("206");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.damagedStatementSection, PR.partyDescription),
|
||||||
|
).toBe("توضیحات زیاندیده");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.damagedStatementSection, PR.claimsDamage),
|
||||||
|
).toBe("بله");
|
||||||
|
expect(
|
||||||
|
getFieldValue(
|
||||||
|
report,
|
||||||
|
PR.damagedStatementSection,
|
||||||
|
PR.acceptsExpertOpinion,
|
||||||
|
),
|
||||||
|
).toBe("خیر");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.guiltyStatementSection, PR.partyDescription),
|
||||||
|
).toBe("توضیحات مقصر");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.guiltyStatementSection, PR.admitsGuilt),
|
||||||
|
).toBe("بله");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.guiltyStatementSection, PR.acceptsExpertOpinion),
|
||||||
|
).toBe("بله");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.fanavaranSection, PR.fanavaranClaimNo),
|
||||||
|
).toBe("111");
|
||||||
expect(
|
expect(
|
||||||
getFieldValue(report, PR.fanavaranSection, PR.fanavaranExpertiseId),
|
getFieldValue(report, PR.fanavaranSection, PR.fanavaranExpertiseId),
|
||||||
).toBe("444");
|
).toBe("444");
|
||||||
expect(getFieldValue(report, PR.fanavaranSection, PR.fanavaranPolicyId)).toBe(
|
expect(
|
||||||
"555",
|
getFieldValue(report, PR.fanavaranSection, PR.fanavaranPolicyId),
|
||||||
);
|
).toBe("555");
|
||||||
|
|
||||||
expect(getFieldValue(report, PR.timelineSection, PR.fileRegisteredAt)).toBe(
|
expect(getFieldValue(report, PR.timelineSection, PR.fileRegisteredAt)).toBe(
|
||||||
`${fileDate} ${fileTime}`,
|
"1405/05/19 13:23",
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt),
|
getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt),
|
||||||
).toBe(`${evaluationDate} ${evaluationTime}`);
|
).toBe("1405/05/19 16:00");
|
||||||
|
|
||||||
expect(getFieldValue(report, PR.evaluationSection, PR.evaluationResult)).toBe(
|
expect(
|
||||||
"تأیید شده",
|
getFieldValue(report, PR.evaluationSection, PR.evaluationResult),
|
||||||
|
).toBe("تأیید شده");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.evaluationSection, PR.evaluationExpert),
|
||||||
|
).toBe("کارشناس خسارت نمونه");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.evaluationSection, PR.evaluationResponse),
|
||||||
|
).toBe("نیاز به تعویض سپر جلو");
|
||||||
|
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / نام قطعه")).toBe(
|
||||||
|
"سپر جلو",
|
||||||
);
|
);
|
||||||
expect(getFieldValue(report, PR.evaluationSection, PR.evaluationExpert)).toBe(
|
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / قیمت قطعه")).toBe(
|
||||||
"کارشناس خسارت نمونه",
|
"۲٬۵۰۰٬۰۰۰ تومان",
|
||||||
);
|
);
|
||||||
expect(getFieldValue(report, PR.evaluationSection, PR.evaluationResponse)).toBe(
|
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / مبلغ کل")).toBe(
|
||||||
"نیاز به تعویض سپر جلو",
|
"۳٬۰۰۰٬۰۰۰ تومان",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("localizes legacy English car-body environmental conditions", () => {
|
||||||
|
const report = buildInsurerFileReport({
|
||||||
|
overview: { publicId: "A00012" },
|
||||||
|
blame: {
|
||||||
|
type: "CAR_BODY",
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
role: "FIRST",
|
||||||
|
person: { userId: "damaged", fullName: "بیمهگذار" },
|
||||||
|
statement: {
|
||||||
|
weatherCondition: "CLEAR",
|
||||||
|
roadCondition: "wet",
|
||||||
|
lightCondition: "LOW_LIGHT",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getFieldValue(report, PR.accidentSection, PR.weather)).toBe("صاف");
|
||||||
|
expect(getFieldValue(report, PR.accidentSection, PR.road)).toBe("مرطوب");
|
||||||
|
expect(getFieldValue(report, PR.accidentSection, PR.light)).toBe("کمنور");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the damage expert part-price result as a separate section", () => {
|
||||||
|
const report = buildInsurerFileReport({
|
||||||
|
overview: { publicId: "A00013" },
|
||||||
|
claim: {
|
||||||
|
claimStatus: "APPROVED",
|
||||||
|
evaluation: {
|
||||||
|
damageExpertReply: {
|
||||||
|
description: "تعویض قطعه ضروری است",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
carPartDamage: { label_fa: "چراغ جلو" },
|
||||||
|
price: 4_000_000,
|
||||||
|
totalPayment: 4_000_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / نام قطعه")).toBe(
|
||||||
|
"چراغ جلو",
|
||||||
|
);
|
||||||
|
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / قیمت قطعه")).toBe(
|
||||||
|
"۴٬۰۰۰٬۰۰۰ تومان",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to claim history for the evaluation submission time", () => {
|
||||||
|
const report = buildInsurerFileReport({
|
||||||
|
overview: { publicId: "A00014" },
|
||||||
|
claim: {
|
||||||
|
evaluation: {
|
||||||
|
damageExpertReply: { description: "ارزیابی ثبت شد" },
|
||||||
|
},
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
type: "EXPERT_REPLY_SUBMITTED",
|
||||||
|
timestamp: "2026-08-10T12:30:45.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt),
|
||||||
|
).toBe("1405/05/19 16:00");
|
||||||
|
expect(
|
||||||
|
getFieldValue(report, PR.evaluationSection, PR.evaluationSubmittedAt),
|
||||||
|
).toBe("1405/05/19 16:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("localizes every boolean value", () => {
|
||||||
|
const report = buildInsurerFileReport({
|
||||||
|
overview: { publicId: "A00015" },
|
||||||
|
blame: {
|
||||||
|
type: "CAR_BODY",
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
role: "FIRST",
|
||||||
|
person: { userId: "damaged", fullName: "بیمهگذار" },
|
||||||
|
vehicle: {
|
||||||
|
isNew: true,
|
||||||
|
hasEndorsement: false,
|
||||||
|
source: "ESG_CAR_BODY_INQUIRY",
|
||||||
|
inquiry: { source: "TEJARAT_BLOCK_INQUIRY" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = report.sections.flatMap((section) => section.fields);
|
||||||
|
expect(
|
||||||
|
fields.find((field) => field.label === "خودرو / خودرو نو")?.value,
|
||||||
|
).toBe("بله");
|
||||||
|
expect(fields.some((field) => field.value === "خیر")).toBe(true);
|
||||||
|
expect(fields.some((field) => field.value === "true")).toBe(false);
|
||||||
|
expect(fields.some((field) => field.value === "false")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides ESG and TEJARAT inquiry source details", () => {
|
||||||
|
const report = buildInsurerFileReport({
|
||||||
|
overview: { publicId: "A00016" },
|
||||||
|
blame: {
|
||||||
|
type: "CAR_BODY",
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
role: "FIRST",
|
||||||
|
person: { userId: "damaged", fullName: "بیمهگذار" },
|
||||||
|
vehicle: {
|
||||||
|
source: "ESG_CAR_BODY_INQUIRY",
|
||||||
|
inquiry: { source: "TEJARAT_BLOCK_INQUIRY" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = report.sections.flatMap((section) => section.fields);
|
||||||
|
expect(fields.some((field) => String(field.value).includes("ESG"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
fields.some((field) => String(field.value).includes("TEJARAT")),
|
||||||
|
).toBe(false);
|
||||||
|
expect(fields.some((field) => field.label.includes("منبع"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the stored V4 licence type for a driver who differs from the policyholder", () => {
|
||||||
|
const report = buildInsurerFileReport({
|
||||||
|
overview: { publicId: "A00011" },
|
||||||
|
blame: {
|
||||||
|
type: "THIRD_PARTY",
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
role: "FIRST",
|
||||||
|
person: { userId: "guilty", fullName: "مقصر" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "SECOND",
|
||||||
|
person: {
|
||||||
|
userId: "damaged",
|
||||||
|
fullName: "بیمهگذار زیاندیده",
|
||||||
|
driverIsInsurer: false,
|
||||||
|
nationalCodeOfDriver: "0123456789",
|
||||||
|
driverLicense: "L-123",
|
||||||
|
licenseType: "پایه یک",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expert: { decision: { guiltyPartyId: "guilty" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getFieldValue(report, PR.driverSection, PR.nationalCode)).toBe(
|
||||||
|
"0123456789",
|
||||||
|
);
|
||||||
|
expect(getFieldValue(report, PR.driverSection, PR.licenseType)).toBe(
|
||||||
|
"پایه یک",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ import {
|
|||||||
} from "src/helpers/blame-damaged-party";
|
} from "src/helpers/blame-damaged-party";
|
||||||
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
||||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||||
|
import { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver";
|
||||||
import {
|
import {
|
||||||
InsurerFileReportField,
|
InsurerFileReportField,
|
||||||
InsurerFileReportSection,
|
InsurerFileReportSection,
|
||||||
InsurerFileReportViewModel,
|
InsurerFileReportViewModel,
|
||||||
} from "./case-expert-report.types";
|
} from "./case-expert-report.types";
|
||||||
import { PR, persianFieldPath, persianStatus } from "./persian-report-labels";
|
import {
|
||||||
|
PR,
|
||||||
|
persianAccidentCondition,
|
||||||
|
persianFieldPath,
|
||||||
|
persianReportValue,
|
||||||
|
persianStatus,
|
||||||
|
} from "./persian-report-labels";
|
||||||
|
|
||||||
const SKIP_FLATTEN_KEYS = new Set([
|
const SKIP_FLATTEN_KEYS = new Set([
|
||||||
"_id",
|
"_id",
|
||||||
@@ -20,6 +27,7 @@ const SKIP_FLATTEN_KEYS = new Set([
|
|||||||
"confirmation",
|
"confirmation",
|
||||||
"inquiries",
|
"inquiries",
|
||||||
"raw",
|
"raw",
|
||||||
|
"source",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
type ReportRecord = Record<string, unknown>;
|
type ReportRecord = Record<string, unknown>;
|
||||||
@@ -30,10 +38,13 @@ type ReportParty = ReportRecord & {
|
|||||||
vehicle?: ReportRecord;
|
vehicle?: ReportRecord;
|
||||||
statement?: ReportRecord;
|
statement?: ReportRecord;
|
||||||
location?: { lat?: number; lon?: number };
|
location?: { lat?: number; lon?: number };
|
||||||
|
participants?: ReportRecord[];
|
||||||
|
participantRoles?: ReportRecord;
|
||||||
};
|
};
|
||||||
|
|
||||||
function asString(value: unknown): string | undefined {
|
function asString(value: unknown): string | undefined {
|
||||||
if (value === undefined || value === null || value === "") return undefined;
|
if (value === undefined || value === null || value === "") return undefined;
|
||||||
|
if (typeof value === "boolean") return value ? "بله" : "خیر";
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
const [d, t] = toJalaliDateAndTime(value);
|
const [d, t] = toJalaliDateAndTime(value);
|
||||||
return `${d} ${t}`;
|
return `${d} ${t}`;
|
||||||
@@ -59,12 +70,39 @@ function formatBirthDate(value: unknown): string | undefined {
|
|||||||
|
|
||||||
function formatDateTime(value: unknown): string | undefined {
|
function formatDateTime(value: unknown): string | undefined {
|
||||||
if (value === undefined || value === null || value === "") return undefined;
|
if (value === undefined || value === null || value === "") return undefined;
|
||||||
const date = value instanceof Date ? value : new Date(value as string | number);
|
const date =
|
||||||
|
value instanceof Date ? value : new Date(value as string | number);
|
||||||
if (Number.isNaN(date.getTime())) return asString(value);
|
if (Number.isNaN(date.getTime())) return asString(value);
|
||||||
const [d, t] = toJalaliDateAndTime(date);
|
const [d, t] = toJalaliDateAndTime(date);
|
||||||
return `${d} ${t}`;
|
return `${d} ${t}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PERSIAN_NUMBER_FORMATTER = new Intl.NumberFormat("fa-IR", {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeNumber(value: unknown): number | undefined {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string" || !value.trim()) return undefined;
|
||||||
|
|
||||||
|
const normalized = value
|
||||||
|
.trim()
|
||||||
|
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
|
||||||
|
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)))
|
||||||
|
.replace(/[٬,\s]/g, "");
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToman(value: unknown): string | undefined {
|
||||||
|
if (value === undefined || value === null || value === "") return undefined;
|
||||||
|
const amount = normalizeNumber(value);
|
||||||
|
if (amount === undefined) return asString(value);
|
||||||
|
return `${PERSIAN_NUMBER_FORMATTER.format(amount)} تومان`;
|
||||||
|
}
|
||||||
|
|
||||||
function firstDefined(...values: unknown[]): string | undefined {
|
function firstDefined(...values: unknown[]): string | undefined {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const str = asString(value);
|
const str = asString(value);
|
||||||
@@ -73,10 +111,13 @@ function firstDefined(...values: unknown[]): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeListValue(value: unknown): string | undefined {
|
function normalizeListValue(value: unknown, path = ""): string | undefined {
|
||||||
if (!Array.isArray(value) || !value.length) return undefined;
|
if (!Array.isArray(value) || !value.length) return undefined;
|
||||||
const items = value
|
const items = value
|
||||||
.map((item) => asString(item) ?? JSON.stringify(item))
|
.map(
|
||||||
|
(item) =>
|
||||||
|
asString(persianReportValue(path, item)) ?? JSON.stringify(item),
|
||||||
|
)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return items.length ? items.join("، ") : undefined;
|
return items.length ? items.join("، ") : undefined;
|
||||||
}
|
}
|
||||||
@@ -87,19 +128,32 @@ function flattenObject(
|
|||||||
depth = 0,
|
depth = 0,
|
||||||
): InsurerFileReportField[] {
|
): InsurerFileReportField[] {
|
||||||
if (obj == null) return [];
|
if (obj == null) return [];
|
||||||
if (depth > 4) {
|
if (depth > 6) {
|
||||||
return [{ label: persianFieldPath(prefix), value: asString(obj) }];
|
return [
|
||||||
|
{
|
||||||
|
label: persianFieldPath(prefix),
|
||||||
|
value: asString(persianReportValue(prefix, obj)),
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
const value = normalizeListValue(obj);
|
if (obj.some((item) => item && typeof item === "object")) {
|
||||||
return value
|
return obj.flatMap((item, index) =>
|
||||||
? [{ label: persianFieldPath(prefix || "items"), value }]
|
flattenObject(item, `${prefix}.${index + 1}`, depth + 1),
|
||||||
: [];
|
);
|
||||||
|
}
|
||||||
|
const value = normalizeListValue(obj, prefix);
|
||||||
|
return value ? [{ label: persianFieldPath(prefix || "items"), value }] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof obj !== "object") {
|
if (typeof obj !== "object") {
|
||||||
return [{ label: persianFieldPath(prefix || "value"), value: asString(obj) }];
|
return [
|
||||||
|
{
|
||||||
|
label: persianFieldPath(prefix || "value"),
|
||||||
|
value: asString(persianReportValue(prefix || "value", obj)),
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows: InsurerFileReportField[] = [];
|
const rows: InsurerFileReportField[] = [];
|
||||||
@@ -113,21 +167,22 @@ function flattenObject(
|
|||||||
if (value === undefined || value === null || value === "") continue;
|
if (value === undefined || value === null || value === "") continue;
|
||||||
|
|
||||||
const path = prefix ? `${prefix}.${key}` : key;
|
const path = prefix ? `${prefix}.${key}` : key;
|
||||||
if (
|
if (typeof value === "object" && !(value instanceof Date)) {
|
||||||
typeof value === "object" &&
|
|
||||||
!Array.isArray(value) &&
|
|
||||||
!(value instanceof Date)
|
|
||||||
) {
|
|
||||||
rows.push(...flattenObject(value, path, depth + 1));
|
rows.push(...flattenObject(value, path, depth + 1));
|
||||||
} else {
|
} else {
|
||||||
rows.push({ label: persianFieldPath(path), value: asString(value) });
|
rows.push({
|
||||||
|
label: persianFieldPath(path),
|
||||||
|
value: asString(persianReportValue(path, value)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dedupeFields(fields: InsurerFileReportField[]): InsurerFileReportField[] {
|
function dedupeFields(
|
||||||
|
fields: InsurerFileReportField[],
|
||||||
|
): InsurerFileReportField[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const out: InsurerFileReportField[] = [];
|
const out: InsurerFileReportField[] = [];
|
||||||
|
|
||||||
@@ -147,7 +202,8 @@ function filterEmptySections(
|
|||||||
sections: Array<InsurerFileReportSection | undefined>,
|
sections: Array<InsurerFileReportSection | undefined>,
|
||||||
): InsurerFileReportSection[] {
|
): InsurerFileReportSection[] {
|
||||||
return sections.filter(
|
return sections.filter(
|
||||||
(section): section is InsurerFileReportSection => !!section && section.fields.length > 0,
|
(section): section is InsurerFileReportSection =>
|
||||||
|
!!section && section.fields.length > 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +227,9 @@ function expertNameFromSnapshot(snapshot?: {
|
|||||||
lastName?: string;
|
lastName?: string;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
if (!snapshot) return undefined;
|
if (!snapshot) return undefined;
|
||||||
const name = [snapshot.firstName, snapshot.lastName].filter(Boolean).join(" ");
|
const name = [snapshot.firstName, snapshot.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
return name || undefined;
|
return name || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,9 +239,8 @@ function collectExpertNames(
|
|||||||
): string | undefined {
|
): string | undefined {
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
|
|
||||||
const blameDecision = (
|
const blameDecision = (blame?.expert as Record<string, unknown> | undefined)
|
||||||
blame?.expert as Record<string, unknown> | undefined
|
?.decision as Record<string, unknown> | undefined;
|
||||||
)?.decision as Record<string, unknown> | undefined;
|
|
||||||
const blameExpert = expertNameFromSnapshot(
|
const blameExpert = expertNameFromSnapshot(
|
||||||
blameDecision?.expertProfileSnapshot as
|
blameDecision?.expertProfileSnapshot as
|
||||||
| { firstName?: string; lastName?: string }
|
| { firstName?: string; lastName?: string }
|
||||||
@@ -247,23 +304,49 @@ function resolveReportBlameContext(
|
|||||||
(claim?.blameFileContext as Record<string, unknown> | undefined)
|
(claim?.blameFileContext as Record<string, unknown> | undefined)
|
||||||
?.blameRequestType ??
|
?.blameRequestType ??
|
||||||
(snapshot.accident as Record<string, unknown> | undefined)?.type,
|
(snapshot.accident as Record<string, unknown> | undefined)?.type,
|
||||||
blameStatus: (claim?.blameFileContext as Record<string, unknown> | undefined)
|
blameStatus: (
|
||||||
?.blameStatus,
|
claim?.blameFileContext as Record<string, unknown> | undefined
|
||||||
|
)?.blameStatus,
|
||||||
parties: snapshot.parties,
|
parties: snapshot.parties,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPartyRole(party: ReportParty | null | undefined): string | undefined {
|
function getPartyRole(
|
||||||
|
party: ReportParty | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
const role = party?.role;
|
const role = party?.role;
|
||||||
return typeof role === "string" ? role : undefined;
|
return typeof role === "string" ? role : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function partyKindLabel(
|
||||||
|
party: ReportParty | null | undefined,
|
||||||
|
damagedParty: ReportParty | null,
|
||||||
|
guiltyParty: ReportParty | null,
|
||||||
|
): string | undefined {
|
||||||
|
if (sameParty(party, damagedParty)) return "damaged";
|
||||||
|
if (sameParty(party, guiltyParty)) return "guilty";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function partyRoleLabel(role: string | undefined): string | undefined {
|
||||||
|
if (!role) return undefined;
|
||||||
|
if (role === PartyRole.FIRST) return "طرف اول";
|
||||||
|
if (role === PartyRole.SECOND) return "طرف دوم";
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statementBoolean(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "boolean") return undefined;
|
||||||
|
return persianStatus(value);
|
||||||
|
}
|
||||||
|
|
||||||
function sameParty(
|
function sameParty(
|
||||||
first: ReportParty | null | undefined,
|
first: ReportParty | null | undefined,
|
||||||
second: ReportParty | null | undefined,
|
second: ReportParty | null | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!first || !second) return false;
|
if (!first || !second) return false;
|
||||||
const firstUserId = first.person?.userId != null ? String(first.person.userId) : "";
|
const firstUserId =
|
||||||
|
first.person?.userId != null ? String(first.person.userId) : "";
|
||||||
const secondUserId =
|
const secondUserId =
|
||||||
second.person?.userId != null ? String(second.person.userId) : "";
|
second.person?.userId != null ? String(second.person.userId) : "";
|
||||||
if (firstUserId && secondUserId) return firstUserId === secondUserId;
|
if (firstUserId && secondUserId) return firstUserId === secondUserId;
|
||||||
@@ -275,11 +358,30 @@ function resolveEvaluationReply(
|
|||||||
): Record<string, unknown> | undefined {
|
): Record<string, unknown> | undefined {
|
||||||
const evaluation = claim?.evaluation as Record<string, unknown> | undefined;
|
const evaluation = claim?.evaluation as Record<string, unknown> | undefined;
|
||||||
return (
|
return (
|
||||||
(evaluation?.damageExpertReplyFinal as Record<string, unknown> | undefined) ??
|
(evaluation?.damageExpertReplyFinal as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined) ??
|
||||||
(evaluation?.damageExpertReply as Record<string, unknown> | undefined)
|
(evaluation?.damageExpertReply as Record<string, unknown> | undefined)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveEvaluationSubmittedAt(
|
||||||
|
claim: Record<string, unknown> | null | undefined,
|
||||||
|
reply: Record<string, unknown> | undefined,
|
||||||
|
): unknown {
|
||||||
|
if (reply?.submittedAt != null) return reply.submittedAt;
|
||||||
|
|
||||||
|
const history = Array.isArray(claim?.history) ? claim.history : [];
|
||||||
|
const submittedEvent = [...history]
|
||||||
|
.reverse()
|
||||||
|
.find((event) =>
|
||||||
|
["EXPERT_REPLY_SUBMITTED", "EXPERT_FINAL_REPLY_SUBMITTED"].includes(
|
||||||
|
String((event as Record<string, unknown>)?.type ?? ""),
|
||||||
|
),
|
||||||
|
) as Record<string, unknown> | undefined;
|
||||||
|
return submittedEvent?.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
function insuranceValueFromCandidates(
|
function insuranceValueFromCandidates(
|
||||||
...values: unknown[]
|
...values: unknown[]
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
@@ -303,10 +405,18 @@ function buildThirdPartyInsuranceFields(
|
|||||||
const role = getPartyRole(party);
|
const role = getPartyRole(party);
|
||||||
const direct = (party?.insurance ?? {}) as Record<string, unknown>;
|
const direct = (party?.insurance ?? {}) as Record<string, unknown>;
|
||||||
const blameInquiry = pickInquiryReportPayload(
|
const blameInquiry = pickInquiryReportPayload(
|
||||||
inquiryRoleData(blame?.inquiries as Record<string, unknown> | undefined, "thirdParty", role),
|
inquiryRoleData(
|
||||||
|
blame?.inquiries as Record<string, unknown> | undefined,
|
||||||
|
"thirdParty",
|
||||||
|
role,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const claimInquiry = pickInquiryReportPayload(
|
const claimInquiry = pickInquiryReportPayload(
|
||||||
inquiryRoleData(claim?.inquiries as Record<string, unknown> | undefined, "thirdParty", role),
|
inquiryRoleData(
|
||||||
|
claim?.inquiries as Record<string, unknown> | undefined,
|
||||||
|
"thirdParty",
|
||||||
|
role,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -390,12 +500,23 @@ function buildCarBodyInsuranceFields(
|
|||||||
party?.insurance?.carBody ??
|
party?.insurance?.carBody ??
|
||||||
{}) as Record<string, unknown>;
|
{}) as Record<string, unknown>;
|
||||||
const blameInquiry = pickInquiryReportPayload(
|
const blameInquiry = pickInquiryReportPayload(
|
||||||
inquiryRoleData(blame?.inquiries as Record<string, unknown> | undefined, "carBody", role),
|
inquiryRoleData(
|
||||||
|
blame?.inquiries as Record<string, unknown> | undefined,
|
||||||
|
"carBody",
|
||||||
|
role,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const claimInquiry = pickInquiryReportPayload(
|
const claimInquiry = pickInquiryReportPayload(
|
||||||
inquiryRoleData(claim?.inquiries as Record<string, unknown> | undefined, "carBody", role),
|
inquiryRoleData(
|
||||||
|
claim?.inquiries as Record<string, unknown> | undefined,
|
||||||
|
"carBody",
|
||||||
|
role,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const legacy = (blame?.carBodyInsuranceDetail ?? {}) as Record<string, unknown>;
|
const legacy = (blame?.carBodyInsuranceDetail ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -455,34 +576,111 @@ function buildCarBodyInsuranceFields(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOwnerSection(
|
function buildPartyOwnerSection(
|
||||||
damagedParty: ReportParty | null,
|
title: string,
|
||||||
claim?: Record<string, unknown> | null,
|
party: ReportParty | null | undefined,
|
||||||
): InsurerFileReportSection {
|
options?: {
|
||||||
const person = damagedParty?.person as ReportRecord | undefined;
|
claim?: Record<string, unknown> | null;
|
||||||
|
useClaimOwnerFallback?: boolean;
|
||||||
|
includeSheba?: boolean;
|
||||||
|
},
|
||||||
|
): InsurerFileReportSection | undefined {
|
||||||
|
const person = party?.person as ReportRecord | undefined;
|
||||||
|
const claim = options?.claim;
|
||||||
const money = claim?.money as
|
const money = claim?.money as
|
||||||
| { sheba?: string; nationalCodeOfInsurer?: string }
|
| { sheba?: string; nationalCodeOfInsurer?: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
const claimOwner = claim?.owner as { fullName?: string } | undefined;
|
const claimOwner = claim?.owner as { fullName?: string } | undefined;
|
||||||
|
|
||||||
return buildSection(PR.ownerSection, [
|
if (!person && !options?.useClaimOwnerFallback) return undefined;
|
||||||
{ label: PR.name, value: firstDefined(person?.fullName, claimOwner?.fullName) },
|
|
||||||
|
return buildSection(title, [
|
||||||
|
{
|
||||||
|
label: PR.name,
|
||||||
|
value: options?.useClaimOwnerFallback
|
||||||
|
? firstDefined(person?.fullName, claimOwner?.fullName)
|
||||||
|
: firstDefined(person?.fullName),
|
||||||
|
},
|
||||||
{ label: PR.phone, value: asString(person?.phoneNumber) },
|
{ label: PR.phone, value: asString(person?.phoneNumber) },
|
||||||
{
|
{
|
||||||
label: PR.nationalCode,
|
label: PR.nationalCode,
|
||||||
value: firstDefined(person?.nationalCodeOfInsurer, money?.nationalCodeOfInsurer),
|
value: options?.useClaimOwnerFallback
|
||||||
|
? firstDefined(
|
||||||
|
person?.nationalCodeOfInsurer,
|
||||||
|
money?.nationalCodeOfInsurer,
|
||||||
|
)
|
||||||
|
: firstDefined(person?.nationalCodeOfInsurer, person?.nationalCode),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: PR.birthDate,
|
label: PR.birthDate,
|
||||||
value: formatBirthDate(person?.insurerBirthday ?? person?.birthday),
|
value: formatBirthDate(
|
||||||
|
person?.insurerBirthday ?? person?.birthday ?? person?.driverBirthday,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: PR.sheba,
|
||||||
|
value: options?.includeSheba ? money?.sheba : undefined,
|
||||||
},
|
},
|
||||||
{ label: PR.sheba, value: money?.sheba },
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function licenseFieldsFromInquiry(
|
function buildParticipantRolesSection(
|
||||||
inquiry?: Record<string, unknown>,
|
title: string,
|
||||||
): { licenseType?: string; licenseDate?: string } {
|
party: ReportParty | null | undefined,
|
||||||
|
): InsurerFileReportSection | undefined {
|
||||||
|
const participants =
|
||||||
|
sanitizeStoredInquiryParticipants(party?.participants) ?? [];
|
||||||
|
const assignments = party?.participantRoles;
|
||||||
|
if (!assignments || participants.length === 0) return undefined;
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
driver: PR.driverRole,
|
||||||
|
vehicleOwner: PR.vehicleOwnerRole,
|
||||||
|
thirdPartyPolicyholder: PR.thirdPartyPolicyholderRole,
|
||||||
|
carBodyPolicyholder: PR.carBodyPolicyholderRole,
|
||||||
|
};
|
||||||
|
const fields = Object.entries(assignments).map(([role, participantId]) => {
|
||||||
|
const participant = participants.find(
|
||||||
|
(candidate) => String(candidate.participantId) === String(participantId),
|
||||||
|
);
|
||||||
|
const value = [
|
||||||
|
asString(participant?.fullName),
|
||||||
|
participant?.nationalCode
|
||||||
|
? `${PR.nationalCode}: ${asString(participant.nationalCode)}`
|
||||||
|
: undefined,
|
||||||
|
participant?.birthday
|
||||||
|
? `${PR.birthDate}: ${formatBirthDate(participant.birthday)}`
|
||||||
|
: undefined,
|
||||||
|
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("، ");
|
||||||
|
return {
|
||||||
|
label: roleLabels[role] ?? persianFieldPath(role),
|
||||||
|
value: value || PR.empty,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildSection(title, fields, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function licenseFieldsFromInquiry(inquiry?: Record<string, unknown>): {
|
||||||
|
licenseType?: string;
|
||||||
|
licenseDate?: string;
|
||||||
|
} {
|
||||||
if (!inquiry) return {};
|
if (!inquiry) return {};
|
||||||
return {
|
return {
|
||||||
licenseType: firstDefined(
|
licenseType: firstDefined(
|
||||||
@@ -523,7 +721,14 @@ function buildDriverSection(
|
|||||||
{ label: PR.name, value: asString(person.fullName) },
|
{ label: PR.name, value: asString(person.fullName) },
|
||||||
{
|
{
|
||||||
label: PR.licenseType,
|
label: PR.licenseType,
|
||||||
value: licenseType ?? (person.driverLicense ? PR.driverLicense : undefined),
|
value:
|
||||||
|
asString(
|
||||||
|
persianReportValue(
|
||||||
|
"participant.licenseType",
|
||||||
|
licenseType ?? person.licenseType,
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
(person.driverLicense ? PR.driverLicense : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: PR.licenseDate,
|
label: PR.licenseDate,
|
||||||
@@ -536,16 +741,60 @@ function buildDriverSection(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVehicleSection(
|
function buildPartyVehicleSection(
|
||||||
damagedParty: ReportParty | null,
|
title: string,
|
||||||
claim?: Record<string, unknown> | null,
|
party: ReportParty | null | undefined,
|
||||||
): InsurerFileReportSection {
|
claimVehicle?: Record<string, unknown>,
|
||||||
const partyVehicle = damagedParty?.vehicle;
|
): InsurerFileReportSection | undefined {
|
||||||
const claimVehicle = claim?.vehicle as Record<string, unknown> | undefined;
|
if (!party && !claimVehicle) return undefined;
|
||||||
|
|
||||||
return buildSection(PR.vehicleSection, [
|
return buildSection(title, [
|
||||||
...flattenObject(claimVehicle, "claim.vehicle"),
|
...flattenObject(claimVehicle, "claim.vehicle"),
|
||||||
...flattenObject(partyVehicle, "party.vehicle"),
|
...flattenObject(party?.vehicle, "party.vehicle"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPartyStatementSection(
|
||||||
|
title: string,
|
||||||
|
party: ReportParty | null | undefined,
|
||||||
|
damagedParty: ReportParty | null,
|
||||||
|
guiltyParty: ReportParty | null,
|
||||||
|
): InsurerFileReportSection | undefined {
|
||||||
|
if (!party) return undefined;
|
||||||
|
const statement = (party.statement ?? {}) as ReportRecord;
|
||||||
|
const kind = partyKindLabel(party, damagedParty, guiltyParty);
|
||||||
|
|
||||||
|
return buildSection(title, [
|
||||||
|
{
|
||||||
|
label: PR.partyRole,
|
||||||
|
value: partyRoleLabel(getPartyRole(party)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: PR.name,
|
||||||
|
value: firstDefined(party.person?.fullName),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: PR.admitsGuilt,
|
||||||
|
value:
|
||||||
|
kind === "damaged"
|
||||||
|
? undefined
|
||||||
|
: statementBoolean(statement.admitsGuilt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: PR.claimsDamage,
|
||||||
|
value:
|
||||||
|
kind === "guilty"
|
||||||
|
? undefined
|
||||||
|
: statementBoolean(statement.claimsDamage),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: PR.acceptsExpertOpinion,
|
||||||
|
value: statementBoolean(statement.acceptsExpertOpinion),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: PR.partyDescription,
|
||||||
|
value: asString(statement.description),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +810,9 @@ function buildCaseTimelineSection(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: PR.evaluationRegisteredAt,
|
label: PR.evaluationRegisteredAt,
|
||||||
value: formatDateTime(evaluationReply?.submittedAt),
|
value: formatDateTime(
|
||||||
|
resolveEvaluationSubmittedAt(claim, evaluationReply),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -570,10 +821,14 @@ function buildFanavaranCodesSection(
|
|||||||
claim?: Record<string, unknown> | null,
|
claim?: Record<string, unknown> | null,
|
||||||
): InsurerFileReportSection | undefined {
|
): InsurerFileReportSection | undefined {
|
||||||
if (!claim) return undefined;
|
if (!claim) return undefined;
|
||||||
const sync = (claim.fanavaranSync as Record<string, unknown> | undefined) ?? {};
|
const sync =
|
||||||
const baseClaim = (sync.baseClaim as Record<string, unknown> | undefined) ?? {};
|
(claim.fanavaranSync as Record<string, unknown> | undefined) ?? {};
|
||||||
const damageCase = (sync.damageCase as Record<string, unknown> | undefined) ?? {};
|
const baseClaim =
|
||||||
const expertise = (sync.expertise as Record<string, unknown> | undefined) ?? {};
|
(sync.baseClaim as Record<string, unknown> | undefined) ?? {};
|
||||||
|
const damageCase =
|
||||||
|
(sync.damageCase as Record<string, unknown> | undefined) ?? {};
|
||||||
|
const expertise =
|
||||||
|
(sync.expertise as Record<string, unknown> | undefined) ?? {};
|
||||||
|
|
||||||
return buildSection(PR.fanavaranSection, [
|
return buildSection(PR.fanavaranSection, [
|
||||||
{
|
{
|
||||||
@@ -586,7 +841,11 @@ function buildFanavaranCodesSection(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: PR.fanavaranDamageCaseId,
|
label: PR.fanavaranDamageCaseId,
|
||||||
value: firstDefined(claim.dmgCaseId, damageCase.dmgCaseId, expertise.dmgCaseId),
|
value: firstDefined(
|
||||||
|
claim.dmgCaseId,
|
||||||
|
damageCase.dmgCaseId,
|
||||||
|
expertise.dmgCaseId,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: PR.fanavaranExpertiseId,
|
label: PR.fanavaranExpertiseId,
|
||||||
@@ -634,7 +893,7 @@ function buildEvaluationSection(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: PR.evaluationSubmittedAt,
|
label: PR.evaluationSubmittedAt,
|
||||||
value: formatDateTime(reply?.submittedAt),
|
value: formatDateTime(resolveEvaluationSubmittedAt(claim, reply)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: PR.evaluationResponse,
|
label: PR.evaluationResponse,
|
||||||
@@ -643,6 +902,80 @@ function buildEvaluationSection(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function evaluationPartName(part: ReportRecord): string | undefined {
|
||||||
|
const damage = part.carPartDamage;
|
||||||
|
if (damage && typeof damage === "object" && !Array.isArray(damage)) {
|
||||||
|
const record = damage as ReportRecord;
|
||||||
|
return firstDefined(
|
||||||
|
record.label_fa,
|
||||||
|
record.name,
|
||||||
|
record.part,
|
||||||
|
record.label,
|
||||||
|
part.partId != null ? `${PR.part} ${part.partId}` : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return firstDefined(
|
||||||
|
damage,
|
||||||
|
part.partName,
|
||||||
|
part.partId != null ? `${PR.part} ${part.partId}` : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluationDaghiValue(value: unknown): string | undefined {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return asString(value);
|
||||||
|
}
|
||||||
|
const daghi = value as ReportRecord;
|
||||||
|
const option = asString(persianReportValue("daghi.option", daghi.option));
|
||||||
|
const price = formatToman(daghi.price);
|
||||||
|
return [option, price].filter(Boolean).join(" - ") || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEvaluationPartsSection(
|
||||||
|
claim?: Record<string, unknown> | null,
|
||||||
|
): InsurerFileReportSection | undefined {
|
||||||
|
const reply = resolveEvaluationReply(claim);
|
||||||
|
const parts = Array.isArray(reply?.parts)
|
||||||
|
? (reply.parts as ReportRecord[])
|
||||||
|
: [];
|
||||||
|
if (!parts.length) return undefined;
|
||||||
|
|
||||||
|
const fields = parts.flatMap((part, index) => {
|
||||||
|
const prefix = `${PR.part} ${PERSIAN_NUMBER_FORMATTER.format(index + 1)}`;
|
||||||
|
return [
|
||||||
|
{ label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) },
|
||||||
|
{
|
||||||
|
label: `${prefix} / ${PR.damageType}`,
|
||||||
|
value: asString(
|
||||||
|
persianReportValue("evaluation.part.typeOfDamage", part.typeOfDamage),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: `${prefix} / ${PR.partPrice}`, value: formatToman(part.price) },
|
||||||
|
{
|
||||||
|
label: `${prefix} / ${PR.repairSalary}`,
|
||||||
|
value: formatToman(part.salary),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${prefix} / ${PR.totalPayment}`,
|
||||||
|
value: formatToman(part.totalPayment),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${prefix} / ${PR.factorNeeded}`,
|
||||||
|
value:
|
||||||
|
typeof part.factorNeeded === "boolean"
|
||||||
|
? persianStatus(part.factorNeeded)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${prefix} / ${PR.daghi}`,
|
||||||
|
value: evaluationDaghiValue(part.daghi),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildSection(PR.evaluationPartsSection, fields, false);
|
||||||
|
}
|
||||||
|
|
||||||
function buildAccidentReportSection(
|
function buildAccidentReportSection(
|
||||||
blame?: Record<string, unknown> | null,
|
blame?: Record<string, unknown> | null,
|
||||||
claim?: Record<string, unknown> | null,
|
claim?: Record<string, unknown> | null,
|
||||||
@@ -653,10 +986,11 @@ function buildAccidentReportSection(
|
|||||||
const snapshotAccident = (
|
const snapshotAccident = (
|
||||||
claim?.snapshot as { accident?: Record<string, unknown> } | undefined
|
claim?.snapshot as { accident?: Record<string, unknown> } | undefined
|
||||||
)?.accident;
|
)?.accident;
|
||||||
const blameDecision = (
|
const blameDecision = (blame?.expert as Record<string, unknown> | undefined)
|
||||||
blame?.expert as Record<string, unknown> | undefined
|
?.decision as Record<string, unknown> | undefined;
|
||||||
)?.decision as Record<string, unknown> | undefined;
|
const decisionFields = blameDecision?.fields as
|
||||||
const decisionFields = blameDecision?.fields as Record<string, unknown> | undefined;
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
return buildSection(PR.accidentSection, [
|
return buildSection(PR.accidentSection, [
|
||||||
{
|
{
|
||||||
@@ -669,7 +1003,8 @@ function buildAccidentReportSection(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: PR.accidentTime,
|
label: PR.accidentTime,
|
||||||
value: asString(statement?.accidentTime) ?? asString(snapshotAccident?.time),
|
value:
|
||||||
|
asString(statement?.accidentTime) ?? asString(snapshotAccident?.time),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: PR.experts,
|
label: PR.experts,
|
||||||
@@ -684,21 +1019,24 @@ function buildAccidentReportSection(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: PR.weather,
|
label: PR.weather,
|
||||||
value:
|
value: persianAccidentCondition(
|
||||||
asString(statement?.weatherCondition) ??
|
"weather",
|
||||||
asString(snapshotAccident?.weatherCondition),
|
statement?.weatherCondition ?? snapshotAccident?.weatherCondition,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: PR.road,
|
label: PR.road,
|
||||||
value:
|
value: persianAccidentCondition(
|
||||||
asString(statement?.roadCondition) ??
|
"road",
|
||||||
asString(snapshotAccident?.roadCondition),
|
statement?.roadCondition ?? snapshotAccident?.roadCondition,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: PR.light,
|
label: PR.light,
|
||||||
value:
|
value: persianAccidentCondition(
|
||||||
asString(statement?.lightCondition) ??
|
"light",
|
||||||
asString(snapshotAccident?.lightCondition),
|
statement?.lightCondition ?? snapshotAccident?.lightCondition,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: PR.blameStatus,
|
label: PR.blameStatus,
|
||||||
@@ -712,7 +1050,8 @@ function buildAccidentReportSection(
|
|||||||
{
|
{
|
||||||
label: PR.accidentWay,
|
label: PR.accidentWay,
|
||||||
value: asString(
|
value: asString(
|
||||||
(decisionFields?.accidentWay as { label?: string } | undefined)?.label ??
|
(decisionFields?.accidentWay as { label?: string } | undefined)
|
||||||
|
?.label ??
|
||||||
(
|
(
|
||||||
snapshotAccident?.classification as {
|
snapshotAccident?.classification as {
|
||||||
accidentWay?: { label?: string };
|
accidentWay?: { label?: string };
|
||||||
@@ -723,7 +1062,8 @@ function buildAccidentReportSection(
|
|||||||
{
|
{
|
||||||
label: PR.accidentReason,
|
label: PR.accidentReason,
|
||||||
value: asString(
|
value: asString(
|
||||||
(decisionFields?.accidentReason as { label?: string } | undefined)?.label ??
|
(decisionFields?.accidentReason as { label?: string } | undefined)
|
||||||
|
?.label ??
|
||||||
(
|
(
|
||||||
snapshotAccident?.classification as {
|
snapshotAccident?.classification as {
|
||||||
accidentReason?: { label?: string };
|
accidentReason?: { label?: string };
|
||||||
@@ -734,7 +1074,8 @@ function buildAccidentReportSection(
|
|||||||
{
|
{
|
||||||
label: PR.accidentType,
|
label: PR.accidentType,
|
||||||
value: asString(
|
value: asString(
|
||||||
(decisionFields?.accidentType as { label?: string } | undefined)?.label ??
|
(decisionFields?.accidentType as { label?: string } | undefined)
|
||||||
|
?.label ??
|
||||||
(
|
(
|
||||||
snapshotAccident?.classification as {
|
snapshotAccident?.classification as {
|
||||||
accidentType?: { label?: string };
|
accidentType?: { label?: string };
|
||||||
@@ -766,11 +1107,25 @@ export function buildInsurerFileReport(file: {
|
|||||||
: null;
|
: null;
|
||||||
const isCarBody =
|
const isCarBody =
|
||||||
String((blameContext?.type as string | undefined) ?? "") === "CAR_BODY";
|
String((blameContext?.type as string | undefined) ?? "") === "CAR_BODY";
|
||||||
const includeGuiltySections = !!guiltyParty && !(isCarBody && sameParty(damagedParty, guiltyParty));
|
const includeGuiltySections =
|
||||||
|
!!guiltyParty && !(isCarBody && sameParty(damagedParty, guiltyParty));
|
||||||
|
|
||||||
|
const claimVehicle = claim?.vehicle as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
const sections = filterEmptySections([
|
const sections = filterEmptySections([
|
||||||
buildCaseTimelineSection(overview, claim),
|
buildCaseTimelineSection(overview, claim),
|
||||||
buildOwnerSection(damagedParty, claim),
|
buildParticipantRolesSection(PR.damagedParticipantsSection, damagedParty),
|
||||||
|
includeGuiltySections
|
||||||
|
? buildParticipantRolesSection(PR.guiltyParticipantsSection, guiltyParty)
|
||||||
|
: undefined,
|
||||||
|
buildPartyOwnerSection(PR.ownerSection, damagedParty, {
|
||||||
|
claim,
|
||||||
|
useClaimOwnerFallback: true,
|
||||||
|
includeSheba: true,
|
||||||
|
}),
|
||||||
|
includeGuiltySections
|
||||||
|
? buildPartyOwnerSection(PR.guiltyOwnerSection, guiltyParty)
|
||||||
|
: undefined,
|
||||||
buildDriverSection(damagedParty, blameContext),
|
buildDriverSection(damagedParty, blameContext),
|
||||||
buildSection(
|
buildSection(
|
||||||
PR.damagedThirdPartyInsuranceSection,
|
PR.damagedThirdPartyInsuranceSection,
|
||||||
@@ -792,9 +1147,31 @@ export function buildInsurerFileReport(file: {
|
|||||||
buildCarBodyInsuranceFields(guiltyParty, blameContext, claim),
|
buildCarBodyInsuranceFields(guiltyParty, blameContext, claim),
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
buildVehicleSection(damagedParty, claim),
|
buildPartyVehicleSection(
|
||||||
|
PR.damagedVehicleSection,
|
||||||
|
damagedParty,
|
||||||
|
claimVehicle,
|
||||||
|
),
|
||||||
|
includeGuiltySections
|
||||||
|
? buildPartyVehicleSection(PR.guiltyVehicleSection, guiltyParty)
|
||||||
|
: undefined,
|
||||||
|
buildPartyStatementSection(
|
||||||
|
PR.damagedStatementSection,
|
||||||
|
damagedParty,
|
||||||
|
damagedParty,
|
||||||
|
guiltyParty,
|
||||||
|
),
|
||||||
|
includeGuiltySections
|
||||||
|
? buildPartyStatementSection(
|
||||||
|
PR.guiltyStatementSection,
|
||||||
|
guiltyParty,
|
||||||
|
damagedParty,
|
||||||
|
guiltyParty,
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
buildFanavaranCodesSection(claim),
|
buildFanavaranCodesSection(claim),
|
||||||
buildEvaluationSection(claim),
|
buildEvaluationSection(claim),
|
||||||
|
buildEvaluationPartsSection(claim),
|
||||||
buildAccidentReportSection(blameContext, claim, damagedParty),
|
buildAccidentReportSection(blameContext, claim, damagedParty),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ export const PR = {
|
|||||||
requestNo: "شماره درخواست",
|
requestNo: "شماره درخواست",
|
||||||
empty: "-",
|
empty: "-",
|
||||||
ownerSection: "مالک خودروی زیان دیده",
|
ownerSection: "مالک خودروی زیان دیده",
|
||||||
|
guiltyOwnerSection: "مالک خودروی مقصر",
|
||||||
|
damagedParticipantsSection: "اشخاص و نقشهای خودروی زیاندیده",
|
||||||
|
guiltyParticipantsSection: "اشخاص و نقشهای خودروی مقصر",
|
||||||
driverSection: "راننده خودروی زیان دیده",
|
driverSection: "راننده خودروی زیان دیده",
|
||||||
damagedThirdPartyInsuranceSection: "بیمه شخص ثالث زیاندیده",
|
damagedThirdPartyInsuranceSection: "بیمه شخص ثالث زیاندیده",
|
||||||
damagedCarBodyInsuranceSection: "بیمه بدنه زیاندیده",
|
damagedCarBodyInsuranceSection: "بیمه بدنه زیاندیده",
|
||||||
guiltyThirdPartyInsuranceSection: "بیمه شخص ثالث مقصر",
|
guiltyThirdPartyInsuranceSection: "بیمه شخص ثالث مقصر",
|
||||||
guiltyCarBodyInsuranceSection: "بیمه بدنه مقصر",
|
guiltyCarBodyInsuranceSection: "بیمه بدنه مقصر",
|
||||||
|
damagedVehicleSection: "اطلاعات خودروی زیاندیده",
|
||||||
|
guiltyVehicleSection: "اطلاعات خودروی مقصر",
|
||||||
vehicleSection: "اطلاعات خودرو",
|
vehicleSection: "اطلاعات خودرو",
|
||||||
|
damagedStatementSection: "اظهارات و اقرار زیاندیده",
|
||||||
|
guiltyStatementSection: "اظهارات و اقرار مقصر",
|
||||||
timelineSection: "زمانبندی پرونده",
|
timelineSection: "زمانبندی پرونده",
|
||||||
fanavaranSection: "کدهای فناوران",
|
fanavaranSection: "کدهای فناوران",
|
||||||
evaluationSection: "نتیجه ارزیابی",
|
evaluationSection: "نتیجه ارزیابی",
|
||||||
@@ -22,6 +29,7 @@ export const PR = {
|
|||||||
licenseType: "نوع گواهینامه",
|
licenseType: "نوع گواهینامه",
|
||||||
licenseDate: "تاریخ گواهینامه",
|
licenseDate: "تاریخ گواهینامه",
|
||||||
licenseNumber: "شماره گواهینامه",
|
licenseNumber: "شماره گواهینامه",
|
||||||
|
hasDrivingLicense: "گواهینامه دارد",
|
||||||
driverLicense: "گواهینامه راننده",
|
driverLicense: "گواهینامه راننده",
|
||||||
insuranceCompany: "شرکت بیمه",
|
insuranceCompany: "شرکت بیمه",
|
||||||
policyNumber: "شماره بیمهنامه",
|
policyNumber: "شماره بیمهنامه",
|
||||||
@@ -43,6 +51,23 @@ export const PR = {
|
|||||||
evaluationExpert: "کارشناس ارزیاب",
|
evaluationExpert: "کارشناس ارزیاب",
|
||||||
evaluationSubmittedAt: "تاریخ و ساعت ثبت ارزیابی",
|
evaluationSubmittedAt: "تاریخ و ساعت ثبت ارزیابی",
|
||||||
evaluationResponse: "پاسخ / توضیحات کارشناس",
|
evaluationResponse: "پاسخ / توضیحات کارشناس",
|
||||||
|
evaluationPartsSection: "قیمت قطعات",
|
||||||
|
part: "قطعه",
|
||||||
|
partName: "نام قطعه",
|
||||||
|
damageType: "نوع خسارت",
|
||||||
|
partPrice: "قیمت قطعه",
|
||||||
|
repairSalary: "اجرت تعمیر",
|
||||||
|
totalPayment: "مبلغ کل",
|
||||||
|
factorNeeded: "نیازمند فاکتور",
|
||||||
|
daghi: "داغی",
|
||||||
|
admitsGuilt: "اقرار به تقصیر",
|
||||||
|
claimsDamage: "ادعای خسارت",
|
||||||
|
acceptsExpertOpinion: "پذیرش نظر کارشناس",
|
||||||
|
partyRole: "نقش طرف",
|
||||||
|
driverRole: "راننده",
|
||||||
|
vehicleOwnerRole: "مالک وسیله نقلیه",
|
||||||
|
thirdPartyPolicyholderRole: "بیمهگذار شخص ثالث",
|
||||||
|
carBodyPolicyholderRole: "بیمهگذار بدنه",
|
||||||
data: "اطلاعات",
|
data: "اطلاعات",
|
||||||
date: "تاریخ",
|
date: "تاریخ",
|
||||||
time: "زمان",
|
time: "زمان",
|
||||||
@@ -170,6 +195,31 @@ const KEY_LABELS: Record<string, string> = {
|
|||||||
StatusTypeCode: "کد وضعیت",
|
StatusTypeCode: "کد وضعیت",
|
||||||
label_fa: "برچسب فارسی",
|
label_fa: "برچسب فارسی",
|
||||||
catalogKey: "کلید کاتالوگ",
|
catalogKey: "کلید کاتالوگ",
|
||||||
|
participantId: "شناسه شخص",
|
||||||
|
participants: "اشخاص استعلام",
|
||||||
|
participantRoles: "نقشهای اشخاص",
|
||||||
|
nationalCode: "کد ملی",
|
||||||
|
birthday: "تاریخ تولد",
|
||||||
|
fullName: "نام و نام خانوادگی",
|
||||||
|
phoneNumber: "شماره تلفن",
|
||||||
|
hasDrivingLicense: "گواهینامه دارد",
|
||||||
|
licenseNumber: "شماره گواهینامه",
|
||||||
|
registrationState: "وضعیت پلاک و مالکیت",
|
||||||
|
currentPlate: "پلاک فعلی",
|
||||||
|
previousPlate: "پلاک قبلی",
|
||||||
|
previousPlateId: "پلاک قبلی",
|
||||||
|
previousPolicyholderNationalCode: "کد ملی بیمهگذار پلاک قبلی",
|
||||||
|
vehicleVin: "شماره شاسی (VIN)",
|
||||||
|
driver: "راننده",
|
||||||
|
vehicleOwner: "مالک وسیله نقلیه",
|
||||||
|
thirdPartyPolicyholder: "بیمهگذار شخص ثالث",
|
||||||
|
carBodyPolicyholder: "بیمهگذار بدنه",
|
||||||
|
sameAs: "همان شخص",
|
||||||
|
plateKind: "نوع پلاک استعلامشده",
|
||||||
|
succeeded: "موفق",
|
||||||
|
usable: "قابل استفاده",
|
||||||
|
attempts: "سوابق تلاش استعلام",
|
||||||
|
error: "خطا",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -185,6 +235,102 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
false: "خیر",
|
false: "خیر",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REGISTRATION_STATE_LABELS: Record<string, string> = {
|
||||||
|
CURRENT: "عادی (پلاک فعلی)",
|
||||||
|
RECENTLY_TRANSFERRED: "انتقال مالکیت اخیر",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATE_KIND_LABELS: Record<string, string> = {
|
||||||
|
CURRENT: "پلاک فعلی",
|
||||||
|
PREVIOUS: "پلاک قبلی",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARTICIPANT_ROLE_VALUE_LABELS: Record<string, string> = {
|
||||||
|
DRIVER: "راننده",
|
||||||
|
VEHICLE_OWNER: "مالک وسیله نقلیه",
|
||||||
|
THIRD_PARTY_POLICYHOLDER: "بیمهگذار شخص ثالث",
|
||||||
|
CAR_BODY_POLICYHOLDER: "بیمهگذار بدنه",
|
||||||
|
FIRST: "طرف اول",
|
||||||
|
SECOND: "طرف دوم",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CASE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
THIRD_PARTY: "شخص ثالث",
|
||||||
|
CAR_BODY: "بدنه",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VEHICLE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
SEDAN: "سواری",
|
||||||
|
SUV: "شاسیبلند",
|
||||||
|
HATCHBACK: "هاچبک",
|
||||||
|
PICKUP: "وانت",
|
||||||
|
VAN: "ون",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VALIDITY_LABELS: Record<string, string> = {
|
||||||
|
ACTIVE: "فعال",
|
||||||
|
INACTIVE: "غیرفعال",
|
||||||
|
VALID: "معتبر",
|
||||||
|
INVALID: "نامعتبر",
|
||||||
|
EXPIRED: "منقضیشده",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAMAGE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
REPAIR: "تعمیر",
|
||||||
|
CHANGE: "تعویض",
|
||||||
|
REPLACE: "تعویض",
|
||||||
|
};
|
||||||
|
|
||||||
|
const INQUIRY_ERROR_LABELS: Record<string, string> = {
|
||||||
|
NOT_FOUND: "موردی یافت نشد",
|
||||||
|
NO_RECORD_FOUND: "موردی یافت نشد",
|
||||||
|
TIMEOUT: "مهلت پاسخ استعلام به پایان رسید",
|
||||||
|
REQUEST_FAILED: "استعلام ناموفق بود",
|
||||||
|
FAILED: "استعلام ناموفق بود",
|
||||||
|
UNAVAILABLE: "سرویس استعلام در دسترس نیست",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LICENSE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
"1": "پایه یک",
|
||||||
|
"2": "پایه دو",
|
||||||
|
"3": "پایه سه",
|
||||||
|
BASE_1: "پایه یک",
|
||||||
|
BASE1: "پایه یک",
|
||||||
|
GRADE_1: "پایه یک",
|
||||||
|
BASE_2: "پایه دو",
|
||||||
|
BASE2: "پایه دو",
|
||||||
|
GRADE_2: "پایه دو",
|
||||||
|
BASE_3: "پایه سه",
|
||||||
|
BASE3: "پایه سه",
|
||||||
|
GRADE_3: "پایه سه",
|
||||||
|
};
|
||||||
|
|
||||||
|
const WEATHER_LABELS: Record<string, string> = {
|
||||||
|
clear: "صاف",
|
||||||
|
sunny: "صاف",
|
||||||
|
rainy: "بارانی",
|
||||||
|
rain: "بارانی",
|
||||||
|
snowy: "برفی",
|
||||||
|
snow: "برفی",
|
||||||
|
foggy: "مهآلود",
|
||||||
|
fog: "مهآلود",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROAD_LABELS: Record<string, string> = {
|
||||||
|
muddy: "گلی",
|
||||||
|
icy: "یخزده",
|
||||||
|
wet: "مرطوب",
|
||||||
|
dry: "خشک",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIGHT_LABELS: Record<string, string> = {
|
||||||
|
daylight: "روز",
|
||||||
|
day: "روز",
|
||||||
|
night: "شب",
|
||||||
|
low_light: "کمنور",
|
||||||
|
lowlight: "کمنور",
|
||||||
|
};
|
||||||
|
|
||||||
/** Turn `party.insurance.policyNumber` into a Persian label. */
|
/** Turn `party.insurance.policyNumber` into a Persian label. */
|
||||||
export function persianFieldPath(path: string): string {
|
export function persianFieldPath(path: string): string {
|
||||||
if (!path) return PR.data;
|
if (!path) return PR.data;
|
||||||
@@ -205,9 +351,39 @@ export function persianFieldPath(path: string): string {
|
|||||||
return `بیمه / ${translatedLast}`;
|
return `بیمه / ${translatedLast}`;
|
||||||
}
|
}
|
||||||
if (parts[0] === "claim" && parts[1] === "vehicle") {
|
if (parts[0] === "claim" && parts[1] === "vehicle") {
|
||||||
|
const attemptIndex = parts.indexOf("attempts");
|
||||||
|
if (attemptIndex >= 0) {
|
||||||
|
const attemptNumber = Number(parts[attemptIndex + 1]);
|
||||||
|
const attemptLabel = Number.isFinite(attemptNumber)
|
||||||
|
? `تلاش ${attemptNumber.toLocaleString("fa-IR")}`
|
||||||
|
: "تلاش استعلام";
|
||||||
|
const plateLabel = parts.includes("plate") ? " / پلاک" : "";
|
||||||
|
return `خودرو / سوابق تلاش استعلام / ${attemptLabel}${plateLabel} / ${translatedLast}`;
|
||||||
|
}
|
||||||
|
if (parts.includes("currentPlate")) {
|
||||||
|
return `خودرو / پلاک فعلی / ${translatedLast}`;
|
||||||
|
}
|
||||||
|
if (parts.includes("previousPlate")) {
|
||||||
|
return `خودرو / پلاک قبلی / ${translatedLast}`;
|
||||||
|
}
|
||||||
return `خودرو / ${translatedLast}`;
|
return `خودرو / ${translatedLast}`;
|
||||||
}
|
}
|
||||||
if (parts[0] === "party" && parts[1] === "vehicle") {
|
if (parts[0] === "party" && parts[1] === "vehicle") {
|
||||||
|
const attemptIndex = parts.indexOf("attempts");
|
||||||
|
if (attemptIndex >= 0) {
|
||||||
|
const attemptNumber = Number(parts[attemptIndex + 1]);
|
||||||
|
const attemptLabel = Number.isFinite(attemptNumber)
|
||||||
|
? `تلاش ${attemptNumber.toLocaleString("fa-IR")}`
|
||||||
|
: "تلاش استعلام";
|
||||||
|
const plateLabel = parts.includes("plate") ? " / پلاک" : "";
|
||||||
|
return `خودرو / سوابق تلاش استعلام / ${attemptLabel}${plateLabel} / ${translatedLast}`;
|
||||||
|
}
|
||||||
|
if (parts.includes("currentPlate")) {
|
||||||
|
return `خودرو / پلاک فعلی / ${translatedLast}`;
|
||||||
|
}
|
||||||
|
if (parts.includes("previousPlate")) {
|
||||||
|
return `خودرو / پلاک قبلی / ${translatedLast}`;
|
||||||
|
}
|
||||||
return `خودرو / ${translatedLast}`;
|
return `خودرو / ${translatedLast}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,8 +393,69 @@ export function persianFieldPath(path: string): string {
|
|||||||
return translated.join(" / ");
|
return translated.join(" / ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localize only known domain tokens. Free-form provider values, names,
|
||||||
|
* identifiers, plates and VINs are deliberately preserved verbatim.
|
||||||
|
*/
|
||||||
|
export function persianReportValue(
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
): unknown {
|
||||||
|
if (value === undefined || value === null || value === "") return value;
|
||||||
|
if (typeof value === "boolean") return value ? "بله" : "خیر";
|
||||||
|
if (typeof value !== "string" && typeof value !== "number") return value;
|
||||||
|
|
||||||
|
const raw = String(value).trim();
|
||||||
|
const key = raw.toUpperCase().replace(/[\s-]+/g, "_");
|
||||||
|
const field = path.split(".").filter(Boolean).pop() ?? "";
|
||||||
|
|
||||||
|
if (field === "licenseType" || field === "LicenseType") {
|
||||||
|
return LICENSE_TYPE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (field === "registrationState") {
|
||||||
|
return REGISTRATION_STATE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (field === "plateKind") return PLATE_KIND_LABELS[key] ?? raw;
|
||||||
|
if (field === "role" || field.endsWith("Role")) {
|
||||||
|
return PARTICIPANT_ROLE_VALUE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (field === "blameRequestType" || field === "fileType") {
|
||||||
|
return CASE_TYPE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(field === "carType" || field === "type") &&
|
||||||
|
path.toLowerCase().includes("vehicle")
|
||||||
|
) {
|
||||||
|
return VEHICLE_TYPE_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
if (field === "typeOfDamage") return DAMAGE_TYPE_LABELS[key] ?? raw;
|
||||||
|
if (field === "error") return INQUIRY_ERROR_LABELS[key] ?? raw;
|
||||||
|
if (/status$/i.test(field)) {
|
||||||
|
return STATUS_LABELS[raw] ?? VALIDITY_LABELS[key] ?? raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw === "true" || raw === "false") return STATUS_LABELS[raw];
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
export function persianStatus(value: unknown): string | undefined {
|
export function persianStatus(value: unknown): string | undefined {
|
||||||
if (value === undefined || value === null || value === "") return undefined;
|
if (value === undefined || value === null || value === "") return undefined;
|
||||||
const key = String(value);
|
const key = String(value);
|
||||||
return STATUS_LABELS[key] ?? key;
|
return STATUS_LABELS[key] ?? key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function persianAccidentCondition(
|
||||||
|
kind: "weather" | "road" | "light",
|
||||||
|
value: unknown,
|
||||||
|
): string | undefined {
|
||||||
|
if (value === undefined || value === null || value === "") return undefined;
|
||||||
|
const raw = String(value).trim();
|
||||||
|
const normalized = raw.toLowerCase().replace(/[\s-]+/g, "_");
|
||||||
|
const labels =
|
||||||
|
kind === "weather"
|
||||||
|
? WEATHER_LABELS
|
||||||
|
: kind === "road"
|
||||||
|
? ROAD_LABELS
|
||||||
|
: LIGHT_LABELS;
|
||||||
|
return labels[normalized] ?? raw;
|
||||||
|
}
|
||||||
|
|||||||
113
src/claim-request-management/capture-part-concurrency.spec.ts
Normal file
113
src/claim-request-management/capture-part-concurrency.spec.ts
Normal 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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/claim-request-management/claim-details-access.v2.spec.ts
Normal file
59
src/claim-request-management/claim-details-access.v2.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { ForbiddenException } from "@nestjs/common";
|
||||||
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
|
||||||
|
describe("V2 claim-detail access for split file roles", () => {
|
||||||
|
const makerId = "maker-id";
|
||||||
|
const reviewerId = "reviewer-id";
|
||||||
|
|
||||||
|
const createService = (blame: Record<string, unknown>) => {
|
||||||
|
const service = Object.create(
|
||||||
|
ClaimRequestManagementService.prototype,
|
||||||
|
) as ClaimRequestManagementService;
|
||||||
|
(service as any).blameRequestDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue(blame),
|
||||||
|
};
|
||||||
|
return service;
|
||||||
|
};
|
||||||
|
|
||||||
|
const claim = { blameRequestId: "blame-id" };
|
||||||
|
|
||||||
|
it("allows the FileMaker who created a completed V4/V5 file", async () => {
|
||||||
|
const service = createService({
|
||||||
|
isMadeByFileMaker: true,
|
||||||
|
expertInitiated: true,
|
||||||
|
creationMethod: "IN_PERSON",
|
||||||
|
initiatedByFieldExpertId: makerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(service as any).assertActorCanViewClaimV2(claim, makerId, {
|
||||||
|
sub: makerId,
|
||||||
|
role: RoleEnum.FILE_MAKER,
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows only the assigned FileReviewer", async () => {
|
||||||
|
const service = createService({
|
||||||
|
isMadeByFileMaker: true,
|
||||||
|
expertInitiated: true,
|
||||||
|
creationMethod: "IN_PERSON",
|
||||||
|
assignedFileReviewerId: reviewerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(service as any).assertActorCanViewClaimV2(claim, reviewerId, {
|
||||||
|
sub: reviewerId,
|
||||||
|
role: RoleEnum.FILE_REVIEWER,
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(service as any).assertActorCanViewClaimV2(claim, "other-reviewer", {
|
||||||
|
sub: "other-reviewer",
|
||||||
|
role: RoleEnum.FILE_REVIEWER,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
src/claim-request-management/claim-optional-document.spec.ts
Normal file
45
src/claim-request-management/claim-optional-document.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||||
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
|
|
||||||
|
describe("optional accident sketch", () => {
|
||||||
|
const service = Object.create(
|
||||||
|
ClaimRequestManagementService.prototype,
|
||||||
|
) as ClaimRequestManagementService;
|
||||||
|
|
||||||
|
it("is excluded from legacy required-document completion", () => {
|
||||||
|
const requiredTypes = (service as any).getRequiredDocumentTypes({
|
||||||
|
blameFile: { type: "THIRD_PARTY" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requiredTypes).not.toContain(
|
||||||
|
ClaimRequiredDocumentType.ACCIDENT_SKETCH,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not gate V2 owner-document completion", () => {
|
||||||
|
const mandatoryKeys = (service as any).requiredDocumentKeysV2(false);
|
||||||
|
const requiredDocuments = Object.fromEntries(
|
||||||
|
[...mandatoryKeys, ClaimRequiredDocumentType.CAR_GREEN_CARD].map(
|
||||||
|
(key) => [key, { uploaded: true }],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(service as any).allV2OwnerDocumentsComplete(
|
||||||
|
{ requiredDocuments },
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
requiredDocuments[ClaimRequiredDocumentType.ACCIDENT_SKETCH] = {
|
||||||
|
uploaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(service as any).allV2OwnerDocumentsComplete(
|
||||||
|
{ requiredDocuments },
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,7 +37,6 @@ import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-managem
|
|||||||
import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto";
|
import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto";
|
||||||
import { UserCommentDto } from "./dto/user-comment.dto";
|
import { UserCommentDto } from "./dto/user-comment.dto";
|
||||||
import { UserObjectionDto } from "./dto/user-objection.dto";
|
import { UserObjectionDto } from "./dto/user-objection.dto";
|
||||||
import { InPersonVisitDto } from "./dto/in-person-visit.dto";
|
|
||||||
import { UserRatingDto } from "./dto/user-rating.dto";
|
import { UserRatingDto } from "./dto/user-rating.dto";
|
||||||
|
|
||||||
@ApiExcludeController()
|
@ApiExcludeController()
|
||||||
@@ -464,29 +463,27 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ApiBody({ type: InPersonVisitDto })
|
|
||||||
// @ApiParam({ name: "id" })
|
// @ApiParam({ name: "id" })
|
||||||
// @ApiOperation({ deprecated: true })
|
// @ApiOperation({ deprecated: true })
|
||||||
@Patch(":id/visit")
|
@Patch(":id/visit")
|
||||||
async inPersonVisit(
|
async inPersonVisit(
|
||||||
@Param("id") requestId: string,
|
@Param("id") requestId: string,
|
||||||
@Body() body: InPersonVisitDto,
|
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
) {
|
) {
|
||||||
// Pass the branchId from the body to the service
|
return await this.claimRequestManagementService.inPersonVisit(requestId, actor);
|
||||||
return await this.claimRequestManagementService.inPersonVisit(
|
|
||||||
requestId,
|
|
||||||
body.branchId,
|
|
||||||
actor,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ApiOperation({ deprecated: true })
|
// Legacy V1 owner branch picker — intentionally disabled.
|
||||||
@Get("branches/:insuranceId")
|
// A claim is handled by an expert assigned to the insurer branch, so the
|
||||||
// @ApiParam({ name: "insuranceId" })
|
// owner must not choose an insurer branch while signing. This does not apply
|
||||||
async insuranceBranches(@Param("insuranceId") insuranceId: string) {
|
// to the separate expert-only `daghi.branchId` choice in pricing flows.
|
||||||
return await this.claimRequestManagementService.retrieveInsuranceBranches(insuranceId);
|
//
|
||||||
}
|
// @Get("branches/:insuranceId")
|
||||||
|
// async insuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||||
|
// return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||||
|
// insuranceId,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
// @ApiOperation({ deprecated: true })
|
// @ApiOperation({ deprecated: true })
|
||||||
@Get("fanavaran-submit/:claimRequestId")
|
@Get("fanavaran-submit/:claimRequestId")
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ import { MediaPolicyModule } from "src/media-policy/media-policy.module";
|
|||||||
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
|
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
|
||||||
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
||||||
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
||||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -77,7 +76,6 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
|
|||||||
SandHubModule,
|
SandHubModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
MediaPolicyModule,
|
MediaPolicyModule,
|
||||||
SmsOrchestrationModule,
|
|
||||||
JwtModule.register({}),
|
JwtModule.register({}),
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -83,7 +83,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get My Claims (V2)",
|
summary: "Get My Claims (V2)",
|
||||||
description:
|
description:
|
||||||
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
|
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType`, `startDate`, `endDate`.",
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -117,7 +117,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get Claim Details (V2)",
|
summary: "Get Claim Details (V2)",
|
||||||
description:
|
description:
|
||||||
"Returns the claim snapshot for **USER** (owner), **FIELD_EXPERT**, or **REGISTRAR** when permitted. Initiating experts/registrars see unmasked money fields; owners get `ownerGuidance`.",
|
"Returns the claim snapshot for an authorized **USER**, **FIELD_EXPERT**, **REGISTRAR**, **FILE_MAKER**, or assigned **FILE_REVIEWER**. Initiating experts/registrars see unmasked money fields; owners get `ownerGuidance`. Completed claims include Fanavaran `claimId` / `claimNo` / `dmgCaseId` / `expertiseId` when available.",
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -322,7 +322,8 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Owner signature — priced-line gate (mixed factors) or final accept/reject.
|
* V2–V5: owner signature used only as the priced-line gate for mixed-factor claims.
|
||||||
|
* The final accept/reject phase is retained for legacy rows only.
|
||||||
*/
|
*/
|
||||||
@Put("request/:claimRequestId/owner-insurer-approval/sign")
|
@Put("request/:claimRequestId/owner-insurer-approval/sign")
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
@@ -332,18 +333,18 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
})
|
})
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Sign priced lines or final claim pricing (owner)",
|
summary: "Sign priced lines before factor uploads (owner; final phase is legacy only)",
|
||||||
description:
|
description:
|
||||||
"Multipart: `sign`, `agree`, `branchId`. Requires `ClaimCaseStatus` **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, and `workflow.currentStep=INSURER_REVIEW` (not during owner factor upload or `EXPERT_COST_EVALUATION`).\n\n" +
|
"Multipart: `sign`, `agree`. Requires `ClaimCaseStatus` **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, and `workflow.currentStep=INSURER_REVIEW` (not during owner factor upload or `EXPERT_COST_EVALUATION`).\n\n" +
|
||||||
"**Phase A — Mixed reply, priced lines only:** `claimStatus=NEEDS_REVISION`, no `evaluation.ownerPricedPartsApproval` yet. `agree=true` records that signature and moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads; `agree=false` rejects the whole case (`REJECTED`).\n\n" +
|
"**Phase A — Mixed reply, priced lines only:** `claimStatus=NEEDS_REVISION`, no `evaluation.ownerPricedPartsApproval` yet. `agree=true` records that signature and moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads; `agree=false` rejects the whole case (`REJECTED`).\n\n" +
|
||||||
"**Phase B — Final:** `claimStatus=APPROVED`, no `evaluation.ownerInsurerApproval` yet. `agree=true` → `COMPLETED`; `agree=false` → `REJECTED`.\n\n" +
|
"**Phase B — Legacy final phase only:** pre-existing rows with `claimStatus=APPROVED` may still be accepted or rejected through this endpoint. New V2–V5 claims complete after expert work (and V5 FileMaker approval) without a final owner signature.\n\n" +
|
||||||
"Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or `FINAL_APPROVAL` for UI state.",
|
"Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or legacy `FINAL_APPROVAL` for UI state.",
|
||||||
})
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
description: "Signature file, agreement, and branch",
|
description: "Signature file and agreement",
|
||||||
schema: {
|
schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["sign", "agree", "branchId"],
|
required: ["sign", "agree"],
|
||||||
properties: {
|
properties: {
|
||||||
sign: {
|
sign: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -354,12 +355,6 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
description: "true to accept expert pricing and complete the claim",
|
description: "true to accept expert pricing and complete the claim",
|
||||||
},
|
},
|
||||||
branchId: {
|
|
||||||
type: "string",
|
|
||||||
description:
|
|
||||||
"Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
|
|
||||||
example: "507f1f77bcf86cd799439011",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -391,7 +386,6 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
async submitOwnerInsurerApprovalSignV2(
|
async submitOwnerInsurerApprovalSignV2(
|
||||||
@Param("claimRequestId") claimRequestId: string,
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
@Body("agree") agree: string | boolean,
|
@Body("agree") agree: string | boolean,
|
||||||
@Body("branchId") branchId: string,
|
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
@UploadedFile() sign: Express.Multer.File,
|
@UploadedFile() sign: Express.Multer.File,
|
||||||
) {
|
) {
|
||||||
@@ -407,7 +401,6 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
agreed,
|
agreed,
|
||||||
typeof branchId === "string" ? branchId : "",
|
|
||||||
sign,
|
sign,
|
||||||
user.sub,
|
user.sub,
|
||||||
user,
|
user,
|
||||||
@@ -466,7 +459,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get insurer branches (V2)",
|
summary: "Get insurer branches (V2)",
|
||||||
description:
|
description:
|
||||||
"Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.",
|
"Returns branch list for a given insurer/client id. Claim pricing and owner approval no longer require branch selection.",
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "insuranceId",
|
name: "insuranceId",
|
||||||
@@ -808,6 +801,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
|
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
|
||||||
|
|
||||||
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
|
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
|
||||||
|
- accident_sketch (optional; never blocks workflow completion)
|
||||||
- damaged_driving_license_front/back
|
- damaged_driving_license_front/back
|
||||||
- damaged_chassis_number, damaged_engine_photo
|
- damaged_chassis_number, damaged_engine_photo
|
||||||
- damaged_car_card_front/back, damaged_metal_plate
|
- damaged_car_card_front/back, damaged_metal_plate
|
||||||
@@ -843,6 +837,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
"guilty_car_card_front",
|
"guilty_car_card_front",
|
||||||
"guilty_car_card_back",
|
"guilty_car_card_back",
|
||||||
"guilty_metal_plate",
|
"guilty_metal_plate",
|
||||||
|
"accident_sketch",
|
||||||
],
|
],
|
||||||
example: "damaged_driving_license_front",
|
example: "damaged_driving_license_front",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ export class RequiredDocumentItem {
|
|||||||
example: true,
|
example: true,
|
||||||
})
|
})
|
||||||
preferUploadDuringCapture?: boolean;
|
preferUploadDuringCapture?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'Whether this document is mandatory for workflow completion. Optional documents are uploadable but never included in remaining/progress counts.',
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
required: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export class ClaimDetailsV2ResponseDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"ClaimCaseStatus; see also `ownerGuidance` for UX. Post-expert: INSURER_REVIEW_AWAITING_OWNER_SIGN | INSURER_REVIEW_MIXED_FACTORS_PENDING | OWNER_REPAIR_FACTOR_UPLOAD_PENDING | EXPERT_VALIDATING_REPAIR_FACTORS; legacy WAITING_FOR_INSURER_APPROVAL may still appear.",
|
"ClaimCaseStatus; see also `ownerGuidance` for UX. New V2–V5 priced-only claims are COMPLETED after expert work; factor claims use INSURER_REVIEW_MIXED_FACTORS_PENDING | OWNER_REPAIR_FACTOR_UPLOAD_PENDING | EXPERT_VALIDATING_REPAIR_FACTORS. V5 then uses WAITING_FOR_FILE_MAKER_APPROVAL. Legacy WAITING_FOR_INSURER_APPROVAL or INSURER_REVIEW_AWAITING_OWNER_SIGN may still appear.",
|
||||||
example: "OWNER_REPAIR_FACTOR_UPLOAD_PENDING",
|
example: "OWNER_REPAIR_FACTOR_UPLOAD_PENDING",
|
||||||
})
|
})
|
||||||
status: string;
|
status: string;
|
||||||
@@ -204,12 +204,56 @@ export class ClaimDetailsV2ResponseDto {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Append-only expert damaged-part revisions. Removed rows retain their original capture URL.',
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
revisionId: { type: 'string' },
|
||||||
|
changedAt: { type: 'string', format: 'date-time' },
|
||||||
|
changedBy: { type: 'object' },
|
||||||
|
removedParts: { type: 'array', items: { type: 'object' } },
|
||||||
|
addedParts: { type: 'array', items: { type: 'object' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
damagedPartsHistory?: Array<{
|
||||||
|
revisionId: string;
|
||||||
|
changedAt: Date | string;
|
||||||
|
changedBy: {
|
||||||
|
actorId: string;
|
||||||
|
actorName?: string;
|
||||||
|
actorType: string;
|
||||||
|
};
|
||||||
|
removedParts: Array<Record<string, unknown>>;
|
||||||
|
addedParts: Array<Record<string, unknown>>;
|
||||||
|
}>;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
|
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
|
||||||
})
|
})
|
||||||
expertResend?: ExpertResendDetailsV2Dto;
|
expertResend?: ExpertResendDetailsV2Dto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Fanavaran claim reference. Returned only after the local claim reaches COMPLETED. Each of claimId, claimNo, dmgCaseId, and expertiseId is included when Fanavaran has supplied it.",
|
||||||
|
example: {
|
||||||
|
claimId: 987654,
|
||||||
|
claimNo: 123456,
|
||||||
|
dmgCaseId: 11,
|
||||||
|
expertiseId: 22,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
fanavaran?: {
|
||||||
|
claimId?: number;
|
||||||
|
claimNo?: number;
|
||||||
|
dmgCaseId?: number;
|
||||||
|
expertiseId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: "Damage expert opinion(s): initial and final (after objection).",
|
description: "Damage expert opinion(s): initial and final (after objection).",
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
|
||||||
import { IsMongoId, IsNotEmpty } from "class-validator";
|
|
||||||
|
|
||||||
export class InPersonVisitDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: "60d5ec49e7b2f8001c8e4d2a",
|
|
||||||
description: "The unique ID of the branch the user is being sent to.",
|
|
||||||
})
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsMongoId()
|
|
||||||
branchId: string;
|
|
||||||
}
|
|
||||||
@@ -12,8 +12,8 @@ export class ClaimListItemV2Dto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"ClaimCaseStatus. Post-expert owner phase includes: INSURER_REVIEW_AWAITING_OWNER_SIGN (priced lines only → final owner sign); INSURER_REVIEW_MIXED_FACTORS_PENDING (priced + factor lines); OWNER_REPAIR_FACTOR_UPLOAD_PENDING (all lines factor-needed); EXPERT_VALIDATING_REPAIR_FACTORS (all factors uploaded, expert validating). Legacy DB rows may still use WAITING_FOR_INSURER_APPROVAL for those flows.",
|
"ClaimCaseStatus. New V2–V5 priced-only claims become COMPLETED after expert work. Factor claims use INSURER_REVIEW_MIXED_FACTORS_PENDING (priced-line acceptance before factor uploads), OWNER_REPAIR_FACTOR_UPLOAD_PENDING, and EXPERT_VALIDATING_REPAIR_FACTORS. V5 then waits at WAITING_FOR_FILE_MAKER_APPROVAL. Legacy DB rows may still use WAITING_FOR_INSURER_APPROVAL or INSURER_REVIEW_AWAITING_OWNER_SIGN.",
|
||||||
example: "INSURER_REVIEW_AWAITING_OWNER_SIGN",
|
example: "COMPLETED",
|
||||||
})
|
})
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
@@ -40,6 +40,12 @@ export class ClaimListItemV2Dto {
|
|||||||
example: 'IN_PERSON',
|
example: 'IN_PERSON',
|
||||||
})
|
})
|
||||||
creationMethod?: string;
|
creationMethod?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Calculated combined blame and claim lifecycle status',
|
||||||
|
example: 'WAITING_FOR_DAMAGE_EXPERT',
|
||||||
|
})
|
||||||
|
unifiedFileStatus?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetMyClaimsV2ResponseDto {
|
export class GetMyClaimsV2ResponseDto {
|
||||||
|
|||||||
@@ -56,20 +56,21 @@ export class SelectOtherPartsV2Dto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
shebaNumber?: string;
|
shebaNumber?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiPropertyOptional({
|
||||||
description: 'National code of insurer/owner - 10 digits',
|
description:
|
||||||
|
'Legacy fallback for claims created before participant roles were stored. New claims derive the vehicle owner national code from the linked blame case.',
|
||||||
example: '1234567890',
|
example: '1234567890',
|
||||||
pattern: '^[0-9]{10}$',
|
pattern: '^[0-9]{10}$',
|
||||||
minLength: 10,
|
minLength: 10,
|
||||||
maxLength: 10,
|
maxLength: 10,
|
||||||
})
|
})
|
||||||
@IsNotEmpty({ message: 'nationalCodeOfInsurer is required' })
|
@IsOptional()
|
||||||
@IsString({ message: 'National code must be a string' })
|
@IsString({ message: 'National code must be a string' })
|
||||||
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
|
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
|
||||||
@Matches(/^[0-9]{10}$/, {
|
@Matches(/^[0-9]{10}$/, {
|
||||||
message: 'National code must contain exactly 10 digits',
|
message: 'National code must contain exactly 10 digits',
|
||||||
})
|
})
|
||||||
nationalCodeOfInsurer: string;
|
nationalCodeOfInsurer?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Legacy alias for backward compatibility',
|
description: 'Legacy alias for backward compatibility',
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ export class ClaimDamageSelection {
|
|||||||
@Prop({ type: [String], default: [] })
|
@Prop({ type: [String], default: [] })
|
||||||
otherParts?: string[];
|
otherParts?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only expert edit revisions. Each revision preserves the prior
|
||||||
|
* selection and capture metadata for removed parts before the live arrays
|
||||||
|
* are replaced. Mixed keeps legacy/free-text part shapes readable.
|
||||||
|
*/
|
||||||
|
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||||
|
partSelectionHistory?: unknown[];
|
||||||
|
|
||||||
|
/** Parts introduced by an expert across revisions (used for origin labels). */
|
||||||
|
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||||
|
expertAddedParts?: unknown[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy fields - kept for backward compatibility
|
* Legacy fields - kept for backward compatibility
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ export class ClaimOwnerInsurerApproval {
|
|||||||
@Prop({ type: Boolean, required: true })
|
@Prop({ type: Boolean, required: true })
|
||||||
agree: boolean;
|
agree: boolean;
|
||||||
|
|
||||||
/** Branch the owner is signing for (must belong to their insurer; aligned with expert daghi options when present). */
|
|
||||||
@Prop({ type: Types.ObjectId })
|
|
||||||
branchId?: Types.ObjectId;
|
|
||||||
|
|
||||||
@Prop({ type: Types.ObjectId })
|
@Prop({ type: Types.ObjectId })
|
||||||
signDetailId?: Types.ObjectId;
|
signDetailId?: Types.ObjectId;
|
||||||
|
|
||||||
@@ -102,9 +98,6 @@ export class ClaimOwnerPricedPartsApproval {
|
|||||||
@Prop({ type: Boolean, required: true })
|
@Prop({ type: Boolean, required: true })
|
||||||
agree: boolean;
|
agree: boolean;
|
||||||
|
|
||||||
@Prop({ type: Types.ObjectId })
|
|
||||||
branchId?: Types.ObjectId;
|
|
||||||
|
|
||||||
@Prop({ type: Types.ObjectId })
|
@Prop({ type: Types.ObjectId })
|
||||||
signDetailId?: Types.ObjectId;
|
signDetailId?: Types.ObjectId;
|
||||||
|
|
||||||
@@ -349,4 +342,3 @@ export class ClaimEvaluation {
|
|||||||
}
|
}
|
||||||
export const ClaimEvaluationSchema =
|
export const ClaimEvaluationSchema =
|
||||||
SchemaFactory.createForClass(ClaimEvaluation);
|
SchemaFactory.createForClass(ClaimEvaluation);
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export class ClaimVehicleSnapshot {
|
|||||||
|
|
||||||
@Prop({ type: ClaimPlateSchema })
|
@Prop({ type: ClaimPlateSchema })
|
||||||
plate?: ClaimPlate;
|
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 =
|
export const ClaimVehicleSnapshotSchema =
|
||||||
SchemaFactory.createForClass(ClaimVehicleSnapshot);
|
SchemaFactory.createForClass(ClaimVehicleSnapshot);
|
||||||
|
|||||||
@@ -89,10 +89,45 @@ export class FanavaranSyncStage {
|
|||||||
@Prop({ type: Number })
|
@Prop({ type: Number })
|
||||||
driverId?: number;
|
driverId?: number;
|
||||||
|
|
||||||
|
/** Cached GEN.44 other-people Id when the person was created because inquiry missed. */
|
||||||
|
@Prop({ type: Number })
|
||||||
|
otherPersonId?: number;
|
||||||
|
|
||||||
/** Cached Fanavaran VehicleKindId. */
|
/** Cached Fanavaran VehicleKindId. */
|
||||||
@Prop({ type: Number })
|
@Prop({ type: Number })
|
||||||
vehicleKindId?: number;
|
vehicleKindId?: number;
|
||||||
|
|
||||||
|
/** How VehicleKindId was resolved: vehicle-inquiry | car-type-lookup */
|
||||||
|
@Prop({ type: String })
|
||||||
|
vehicleKindSource?: string;
|
||||||
|
|
||||||
|
/** Cached Fanavaran PlaqueKindId from VIN inquiry / vehicle GET. */
|
||||||
|
@Prop({ type: Number })
|
||||||
|
plaqueKindId?: number;
|
||||||
|
|
||||||
|
/** How PlaqueKindId was resolved: vehicle-inquiry | config-default */
|
||||||
|
@Prop({ type: String })
|
||||||
|
plaqueKindSource?: string;
|
||||||
|
|
||||||
|
/** Cached Fanavaran PlaqueSampleId from VIN inquiry / vehicle GET. */
|
||||||
|
@Prop({ type: Number })
|
||||||
|
plaqueSampleId?: number;
|
||||||
|
|
||||||
|
/** How PlaqueSampleId was resolved: vehicle-inquiry | config-default */
|
||||||
|
@Prop({ type: String })
|
||||||
|
plaqueSampleSource?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached Fanavaran AccidentVehicleUsedId, resolved from VIN inquiry / vehicle GET
|
||||||
|
* UsedId (lookup-filter safe). Not the Mongo tenant default.
|
||||||
|
*/
|
||||||
|
@Prop({ type: Number })
|
||||||
|
accidentVehicleUsedId?: number;
|
||||||
|
|
||||||
|
/** How AccidentVehicleUsedId was resolved: vin-inquiry | policy-vehicle */
|
||||||
|
@Prop({ type: String })
|
||||||
|
accidentVehicleUsedSource?: string;
|
||||||
|
|
||||||
/** Cached Fanavaran InsuranceCorpId. */
|
/** Cached Fanavaran InsuranceCorpId. */
|
||||||
@Prop({ type: Number })
|
@Prop({ type: Number })
|
||||||
insuranceCorpId?: number;
|
insuranceCorpId?: number;
|
||||||
@@ -107,7 +142,7 @@ export class FanavaranSyncStage {
|
|||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
lastPayloadBuiltAt?: Date;
|
lastPayloadBuiltAt?: Date;
|
||||||
|
|
||||||
/** When we SMS'd the claim owner about Fanavaran claimId/ClaimNo (after expertise). */
|
/** Legacy marker from the retired post-expertise Fanavaran SMS flow. */
|
||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
smsNotifiedAt?: Date;
|
smsNotifiedAt?: Date;
|
||||||
|
|
||||||
@@ -204,6 +239,10 @@ export class ClaimCase {
|
|||||||
@Prop({ type: Types.ObjectId, index: true })
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
initiatedByFieldExpertId?: Types.ObjectId;
|
initiatedByFieldExpertId?: Types.ObjectId;
|
||||||
|
|
||||||
|
/** Branch snapshot inherited from the originating expert-created blame. */
|
||||||
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
|
branchId?: Types.ObjectId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The damaged party's userId, resolved from the blame at claim-creation time.
|
* The damaged party's userId, resolved from the blame at claim-creation time.
|
||||||
* Stored here so view/list access does not require an extra blame lookup.
|
* Stored here so view/list access does not require an extra blame lookup.
|
||||||
|
|||||||
@@ -416,6 +416,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
"guilty_car_card_front",
|
"guilty_car_card_front",
|
||||||
"guilty_car_card_back",
|
"guilty_car_card_back",
|
||||||
"guilty_metal_plate",
|
"guilty_metal_plate",
|
||||||
|
"accident_sketch",
|
||||||
],
|
],
|
||||||
example: "damaged_driving_license_front",
|
example: "damaged_driving_license_front",
|
||||||
},
|
},
|
||||||
@@ -527,16 +528,16 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Owner signature on expert pricing ───────────────────────────────────────
|
// ─── Mixed-factor priced-line signature ──────────────────────────────────────
|
||||||
|
|
||||||
@Put("claim-sign/:claimRequestId")
|
@Put("claim-sign/:claimRequestId")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
description: "Signature file, agreement, and branch",
|
description: "Signature file and agreement",
|
||||||
schema: {
|
schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["sign", "agree", "branchId"],
|
required: ["sign", "agree"],
|
||||||
properties: {
|
properties: {
|
||||||
sign: {
|
sign: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -547,17 +548,17 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
description: "true to accept, false to reject",
|
description: "true to accept, false to reject",
|
||||||
},
|
},
|
||||||
branchId: { type: "string", description: "Insurer branch ID" },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Owner signature on expert pricing (Flow 3 — expert acts on behalf of user)",
|
"Priced-line acceptance before factor uploads (Flow 3 — expert acts on behalf of user)",
|
||||||
description:
|
description:
|
||||||
"Field expert submits the damaged party's signature during the final approval stage. " +
|
"For mixed priced/factor claims only, the field expert records the damaged party's " +
|
||||||
"Delegates to the same service method as the user sign endpoint; the expert's " +
|
"acceptance of priced lines before factor uploads. V2–V5 no longer require a final " +
|
||||||
"identity is resolved to the claim owner via `resolveClaimEffectiveUserId`.",
|
"owner signature: they complete after expert work. The expert's identity is resolved " +
|
||||||
|
"to the claim owner via `resolveClaimEffectiveUserId`.",
|
||||||
})
|
})
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor("sign", {
|
FileInterceptor("sign", {
|
||||||
@@ -576,7 +577,6 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
async submitOwnerSign(
|
async submitOwnerSign(
|
||||||
@Param("claimRequestId") claimRequestId: string,
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
@Body("agree") agree: string | boolean,
|
@Body("agree") agree: string | boolean,
|
||||||
@Body("branchId") branchId: string,
|
|
||||||
@CurrentUser() expert: any,
|
@CurrentUser() expert: any,
|
||||||
@UploadedFile() sign: Express.Multer.File,
|
@UploadedFile() sign: Express.Multer.File,
|
||||||
) {
|
) {
|
||||||
@@ -589,7 +589,6 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
agreed,
|
agreed,
|
||||||
typeof branchId === "string" ? branchId : "",
|
|
||||||
sign,
|
sign,
|
||||||
expert.sub,
|
expert.sub,
|
||||||
expert,
|
expert,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
selectAccidentVehicleUsedId,
|
||||||
|
vehicleGroupIdForKind,
|
||||||
|
} from "./fanavaran-accident-vehicle-used";
|
||||||
|
|
||||||
|
describe("selectAccidentVehicleUsedId", () => {
|
||||||
|
const useTypes = [
|
||||||
|
{ Id: 1, Caption: "شخصي", IsActive: 1, VehicleGroupId: 1 },
|
||||||
|
{ Id: 3, Caption: "تاکسي درون شهري", IsActive: 1, VehicleGroupId: 1 },
|
||||||
|
{ Id: 35, Caption: "آمبولانس", IsActive: 1, VehicleGroupId: 2 },
|
||||||
|
{ Id: 36, Caption: "حمل مواد سريع الاشتعال", IsActive: 1, VehicleGroupId: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const kinds = [
|
||||||
|
{ Id: 8671, VehicleGroupId: 1, IsActive: 1 },
|
||||||
|
{ Id: 5904, VehicleGroupId: 3, IsActive: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("keeps preferred id 1 when the car is passenger group 1", () => {
|
||||||
|
expect(vehicleGroupIdForKind(kinds, 8671)).toBe(1);
|
||||||
|
expect(selectAccidentVehicleUsedId(useTypes, 1, 1)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the vehicle UsedId when it is valid for that group", () => {
|
||||||
|
expect(selectAccidentVehicleUsedId(useTypes, 3, 36)).toBe(36);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks an active use type for a non-passenger group instead of 1", () => {
|
||||||
|
expect(vehicleGroupIdForKind(kinds, 5904)).toBe(3);
|
||||||
|
expect(selectAccidentVehicleUsedId(useTypes, 3, 1)).toBe(36);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the vehicle UsedId when group is unknown", () => {
|
||||||
|
expect(selectAccidentVehicleUsedId(useTypes, null, 84)).toBe(84);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export type FanavaranVehicleKindRow = {
|
||||||
|
Id?: unknown;
|
||||||
|
VehicleGroupId?: unknown;
|
||||||
|
IsActive?: unknown;
|
||||||
|
Caption?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FanavaranVehicleUseTypeRow = {
|
||||||
|
Id?: unknown;
|
||||||
|
VehicleGroupId?: unknown;
|
||||||
|
IsActive?: unknown;
|
||||||
|
Caption?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asPositiveId(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
const id = Number(value);
|
||||||
|
return Number.isFinite(id) && id > 0 ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vehicleGroupIdForKind(
|
||||||
|
kinds: unknown,
|
||||||
|
vehicleKindId: number,
|
||||||
|
): number | null {
|
||||||
|
if (!Array.isArray(kinds)) return null;
|
||||||
|
const match = kinds.find(
|
||||||
|
(row) => asPositiveId((row as FanavaranVehicleKindRow)?.Id) === vehicleKindId,
|
||||||
|
) as FanavaranVehicleKindRow | undefined;
|
||||||
|
return asPositiveId(match?.VehicleGroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fanavaran filters AccidentVehicleUsedId by the policy car's VehicleGroupId.
|
||||||
|
* `preferredId` is the vehicle UsedId from VIN inquiry / vehicle GET — never a Mongo tenant default.
|
||||||
|
*/
|
||||||
|
export function selectAccidentVehicleUsedId(
|
||||||
|
useTypes: unknown,
|
||||||
|
vehicleGroupId: number | null,
|
||||||
|
preferredId: number | null,
|
||||||
|
): number | null {
|
||||||
|
if (!Array.isArray(useTypes) || vehicleGroupId == null) {
|
||||||
|
return preferredId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = useTypes.filter((row) => {
|
||||||
|
const item = row as FanavaranVehicleUseTypeRow;
|
||||||
|
return (
|
||||||
|
item?.IsActive === 1 &&
|
||||||
|
asPositiveId(item.VehicleGroupId) === vehicleGroupId &&
|
||||||
|
asPositiveId(item.Id) != null
|
||||||
|
);
|
||||||
|
}) as FanavaranVehicleUseTypeRow[];
|
||||||
|
|
||||||
|
if (active.length === 0) {
|
||||||
|
return preferredId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferred = active.find((row) => asPositiveId(row.Id) === preferredId);
|
||||||
|
if (preferred && preferredId != null) {
|
||||||
|
return preferredId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const personal = active.find((row) =>
|
||||||
|
String(row.Caption ?? "").includes("شخص"),
|
||||||
|
);
|
||||||
|
if (personal) {
|
||||||
|
return asPositiveId(personal.Id) ?? preferredId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return asPositiveId(active[0].Id) ?? preferredId;
|
||||||
|
}
|
||||||
157
src/claim-request-management/fanavaran-claim-product.spec.ts
Normal file
157
src/claim-request-management/fanavaran-claim-product.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
|
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||||
|
import {
|
||||||
|
fanavaranClaimProductFromBlameType,
|
||||||
|
fanavaranClaimsBaseUrl,
|
||||||
|
fanavaranClaimStageUrl,
|
||||||
|
fanavaranHasDamageCaseStage,
|
||||||
|
fanavaranHullImportClaimsUrl,
|
||||||
|
fanavaranPartyInquirySources,
|
||||||
|
isFanavaranSubmitSupportedBlameType,
|
||||||
|
pickCarBodyPolicyNationalCode,
|
||||||
|
pickStoredCarBodyPolicyId,
|
||||||
|
} from "./fanavaran-claim-product";
|
||||||
|
|
||||||
|
describe("fanavaran claim product", () => {
|
||||||
|
it("maps CAR_BODY blame files onto the hull claims resource", () => {
|
||||||
|
expect(fanavaranClaimProductFromBlameType(BlameRequestType.CAR_BODY)).toBe(
|
||||||
|
"car-body",
|
||||||
|
);
|
||||||
|
expect(fanavaranClaimsBaseUrl("car-body")).toBe(
|
||||||
|
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps THIRD_PARTY on the financial third-party claims resource", () => {
|
||||||
|
expect(
|
||||||
|
fanavaranClaimProductFromBlameType(BlameRequestType.THIRD_PARTY),
|
||||||
|
).toBe("third-party");
|
||||||
|
expect(fanavaranClaimsBaseUrl("third-party")).toBe(
|
||||||
|
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/third-party-car-financial-claims",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not POST hull damage to the missing dmg-cases resource", () => {
|
||||||
|
expect(fanavaranHasDamageCaseStage("car-body")).toBe(false);
|
||||||
|
expect(fanavaranClaimStageUrl("car-body", "damage-case", 5023617)).toBeNull();
|
||||||
|
expect(fanavaranClaimStageUrl("car-body", "attachments", 5023617)).toBe(
|
||||||
|
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/5023617/files",
|
||||||
|
);
|
||||||
|
expect(fanavaranClaimStageUrl("car-body", "expertise", 5023617)).toBe(
|
||||||
|
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/5023617/expertise",
|
||||||
|
);
|
||||||
|
expect(fanavaranClaimStageUrl("car-body", "culprits", 5023617)).toBe(
|
||||||
|
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/5023617/culprits",
|
||||||
|
);
|
||||||
|
expect(fanavaranClaimStageUrl("third-party", "culprits", 1)).toBeNull();
|
||||||
|
expect(fanavaranHullImportClaimsUrl()).toBe(
|
||||||
|
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-import-claims",
|
||||||
|
);
|
||||||
|
expect(fanavaranClaimsBaseUrl("car-body")).not.toContain(
|
||||||
|
"vehicle-hull-import-claims",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows Fanavaran submit for both car products", () => {
|
||||||
|
expect(isFanavaranSubmitSupportedBlameType("THIRD_PARTY")).toBe(true);
|
||||||
|
expect(isFanavaranSubmitSupportedBlameType("CAR_BODY")).toBe(true);
|
||||||
|
expect(isFanavaranSubmitSupportedBlameType("OTHER")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses the car-body inquiry PolicyId without another policy list call", () => {
|
||||||
|
const policyId = pickStoredCarBodyPolicyId({
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
role: PartyRole.FIRST,
|
||||||
|
insurance: { carBodyInsurance: { policyId: 15292336 } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(policyId).toBe(15292336);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads PolicyId from nested car-body inquiry raw when the flat field is missing", () => {
|
||||||
|
const policyId = pickStoredCarBodyPolicyId({
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
role: PartyRole.FIRST,
|
||||||
|
vehicle: {
|
||||||
|
inquiry: {
|
||||||
|
carBody: {
|
||||||
|
mapped: {},
|
||||||
|
raw: { policyId: "15292336", policy: { PolicyId: 99 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(policyId).toBe(15292336);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the first-party car-body policyholder national code", () => {
|
||||||
|
const nationalCode = pickCarBodyPolicyNationalCode({
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
role: PartyRole.FIRST,
|
||||||
|
person: { nationalCodeOfInsurer: "0012345678" },
|
||||||
|
insurance: {
|
||||||
|
carBodyInsurance: { ownerNationalCode: "0098765432" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nationalCode).toBe("0012345678");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flattens car-body inquiry fields onto the damage-case aliases", () => {
|
||||||
|
const { mapped } = fanavaranPartyInquirySources("car-body", {
|
||||||
|
insurance: {
|
||||||
|
carBodyInsurance: {
|
||||||
|
policyNumber: "70019846985",
|
||||||
|
chassisNumber: "IRNKAEK4150012345",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vehicle: {
|
||||||
|
inquiry: {
|
||||||
|
carBody: {
|
||||||
|
mapped: {
|
||||||
|
policyId: 15292336,
|
||||||
|
policyNumber: "70019846985",
|
||||||
|
chassisNumber: "IRNKAEK4150012345",
|
||||||
|
motorNumber: "M123",
|
||||||
|
vin: "IRNKAEK4150012345",
|
||||||
|
StartDate: "1405/05/18",
|
||||||
|
EndDate: "1406/05/18",
|
||||||
|
builtYear: 1402,
|
||||||
|
platePartOne: "29",
|
||||||
|
plateLetterTitle: "د",
|
||||||
|
platePartThree: "782",
|
||||||
|
plateSerialNumber: "44",
|
||||||
|
},
|
||||||
|
raw: {
|
||||||
|
policy: { CINumber: "70019846985" },
|
||||||
|
vehicle: { ChassisNo: "IRNKAEK4150012345" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mapped.policyId).toBe(15292336);
|
||||||
|
expect(mapped.PrntCmpDocNo).toBe("70019846985");
|
||||||
|
expect(mapped.ShsNum).toBe("IRNKAEK4150012345");
|
||||||
|
expect(mapped.MtrNum).toBe("M123");
|
||||||
|
expect(mapped.VIN).toBe("IRNKAEK4150012345");
|
||||||
|
expect(mapped.HBgnDte).toBe("1405/05/18");
|
||||||
|
expect(mapped.HEndDte).toBe("1406/05/18");
|
||||||
|
expect(mapped.PrdDte).toBe(1402);
|
||||||
|
expect(mapped.plk1).toBe("29");
|
||||||
|
expect(mapped.plk2).toBe("د");
|
||||||
|
expect(mapped.plk3).toBe("782");
|
||||||
|
expect(mapped.plksrl).toBe("44");
|
||||||
|
});
|
||||||
|
});
|
||||||
271
src/claim-request-management/fanavaran-claim-product.ts
Normal file
271
src/claim-request-management/fanavaran-claim-product.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
|
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||||
|
import {
|
||||||
|
FANAVARAN_CAR_BODY_LINE_ID,
|
||||||
|
FANAVARAN_THIRD_PARTY_LINE_ID,
|
||||||
|
asObjectRecord,
|
||||||
|
parseFanavaranId,
|
||||||
|
type FanavaranCarPolicyProduct,
|
||||||
|
} from "src/lookups/fanavaran-last-car-policy";
|
||||||
|
|
||||||
|
export type FanavaranClaimProduct = FanavaranCarPolicyProduct;
|
||||||
|
|
||||||
|
const FANAVARAN_CLAIMS_HOST =
|
||||||
|
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car";
|
||||||
|
|
||||||
|
export function fanavaranClaimProductFromBlameType(
|
||||||
|
type?: string | null,
|
||||||
|
): FanavaranClaimProduct {
|
||||||
|
return type === BlameRequestType.CAR_BODY || type === "CAR_BODY"
|
||||||
|
? "car-body"
|
||||||
|
: "third-party";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFanavaranSubmitSupportedBlameType(
|
||||||
|
type?: string | null,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
type === BlameRequestType.THIRD_PARTY ||
|
||||||
|
type === "THIRD_PARTY" ||
|
||||||
|
type === BlameRequestType.CAR_BODY ||
|
||||||
|
type === "CAR_BODY"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fanavaranClaimsResourcePath(
|
||||||
|
product: FanavaranClaimProduct,
|
||||||
|
): string {
|
||||||
|
return product === "car-body"
|
||||||
|
? "vehicle-hull-claims"
|
||||||
|
: "third-party-car-financial-claims";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fanavaranClaimsBaseUrl(
|
||||||
|
product: FanavaranClaimProduct,
|
||||||
|
): string {
|
||||||
|
return `${FANAVARAN_CLAIMS_HOST}/${fanavaranClaimsResourcePath(product)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FanavaranClaimStage =
|
||||||
|
| "damage-case"
|
||||||
|
| "attachments"
|
||||||
|
| "expertise"
|
||||||
|
| "culprits";
|
||||||
|
|
||||||
|
/** Hull (بدنه) has no GEN.12 dmg-cases resource. Proven 2026-09-15: Feature:url(.../vehicle-hull-claims/{id}/dmg-cases) not found. Files and expertise do exist. */
|
||||||
|
export const FANAVARAN_HULL_NO_DAMAGE_CASE_REASON =
|
||||||
|
"Fanavaran vehicle-hull-claims has no dmg-cases resource. Hull is first-party (one damaged vehicle on the base claim). Continue with files and expertise.";
|
||||||
|
|
||||||
|
export const FANAVARAN_HULL_IMPORT_CLAIMS_PATH = "vehicle-hull-import-claims";
|
||||||
|
|
||||||
|
export function fanavaranHasDamageCaseStage(
|
||||||
|
product: FanavaranClaimProduct,
|
||||||
|
): boolean {
|
||||||
|
return product === "third-party";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fanavaranHullImportClaimsUrl(): string {
|
||||||
|
return `${FANAVARAN_CLAIMS_HOST}/${FANAVARAN_HULL_IMPORT_CLAIMS_PATH}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fanavaranClaimStageUrl(
|
||||||
|
product: FanavaranClaimProduct,
|
||||||
|
stage: FanavaranClaimStage,
|
||||||
|
claimId: number | string,
|
||||||
|
): string | null {
|
||||||
|
if (stage === "damage-case" && !fanavaranHasDamageCaseStage(product)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (stage === "culprits" && product !== "car-body") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const suffix =
|
||||||
|
stage === "damage-case"
|
||||||
|
? "dmg-cases"
|
||||||
|
: stage === "attachments"
|
||||||
|
? "files"
|
||||||
|
: stage === "culprits"
|
||||||
|
? "culprits"
|
||||||
|
: "expertise";
|
||||||
|
return `${fanavaranClaimsBaseUrl(product)}/${claimId}/${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fanavaranInsuranceLineIdForProduct(
|
||||||
|
product: FanavaranClaimProduct,
|
||||||
|
): number {
|
||||||
|
return product === "car-body"
|
||||||
|
? FANAVARAN_CAR_BODY_LINE_ID
|
||||||
|
: FANAVARAN_THIRD_PARTY_LINE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlamePartyForCarBodyPolicy = {
|
||||||
|
role?: string;
|
||||||
|
person?: { nationalCodeOfInsurer?: unknown };
|
||||||
|
insurance?: {
|
||||||
|
carBodyInsurance?: {
|
||||||
|
policyId?: unknown;
|
||||||
|
ownerNationalCode?: unknown;
|
||||||
|
insurerNationalCode?: unknown;
|
||||||
|
policyNumber?: unknown;
|
||||||
|
chassisNumber?: unknown;
|
||||||
|
motorNumber?: unknown;
|
||||||
|
vin?: unknown;
|
||||||
|
startDate?: unknown;
|
||||||
|
endDate?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
vehicle?: {
|
||||||
|
inquiry?: {
|
||||||
|
mapped?: Record<string, unknown>;
|
||||||
|
raw?: Record<string, unknown> | { data?: Record<string, unknown> };
|
||||||
|
carBody?: {
|
||||||
|
mapped?: Record<string, unknown>;
|
||||||
|
raw?: Record<string, unknown> & {
|
||||||
|
policyId?: unknown;
|
||||||
|
policy?: Record<string, unknown>;
|
||||||
|
vehicle?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function firstCarBodyParty(
|
||||||
|
blame?: { parties?: BlamePartyForCarBodyPolicy[] } | null,
|
||||||
|
): BlamePartyForCarBodyPolicy | null {
|
||||||
|
const parties = blame?.parties ?? [];
|
||||||
|
return (
|
||||||
|
parties.find((party) => party?.role === PartyRole.FIRST) ??
|
||||||
|
parties[0] ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmptyText(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickStoredCarBodyPolicyId(
|
||||||
|
blame?: { parties?: BlamePartyForCarBodyPolicy[] } | null,
|
||||||
|
): number | null {
|
||||||
|
const party = firstCarBodyParty(blame);
|
||||||
|
const carBody = party?.vehicle?.inquiry?.carBody;
|
||||||
|
const raw = asObjectRecord(carBody?.raw) ?? {};
|
||||||
|
const mapped = asObjectRecord(carBody?.mapped) ?? {};
|
||||||
|
const rawPolicy = asObjectRecord(raw.policy);
|
||||||
|
return parseFanavaranId(
|
||||||
|
party?.insurance?.carBodyInsurance?.policyId ??
|
||||||
|
mapped.policyId ??
|
||||||
|
raw.policyId ??
|
||||||
|
rawPolicy?.PolicyId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCarBodyPolicyNationalCode(
|
||||||
|
blame?: { parties?: BlamePartyForCarBodyPolicy[] } | null,
|
||||||
|
): string | null {
|
||||||
|
const party = firstCarBodyParty(blame);
|
||||||
|
return (
|
||||||
|
nonEmptyText(party?.person?.nationalCodeOfInsurer) ??
|
||||||
|
nonEmptyText(party?.insurance?.carBodyInsurance?.ownerNationalCode) ??
|
||||||
|
nonEmptyText(party?.insurance?.carBodyInsurance?.insurerNationalCode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fanavaranPartyInquirySources(
|
||||||
|
product: FanavaranClaimProduct,
|
||||||
|
party?: BlamePartyForCarBodyPolicy | null,
|
||||||
|
): {
|
||||||
|
mapped: Record<string, any>;
|
||||||
|
raw: Record<string, any>;
|
||||||
|
} {
|
||||||
|
const inquiry = party?.vehicle?.inquiry ?? {};
|
||||||
|
if (product !== "car-body") {
|
||||||
|
const mapped = asObjectRecord(inquiry.mapped) ?? {};
|
||||||
|
const rawValue = inquiry.raw as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| { data?: Record<string, unknown> }
|
||||||
|
| undefined;
|
||||||
|
const raw =
|
||||||
|
asObjectRecord(
|
||||||
|
rawValue &&
|
||||||
|
typeof rawValue === "object" &&
|
||||||
|
"data" in rawValue &&
|
||||||
|
rawValue.data != null &&
|
||||||
|
typeof rawValue.data === "object" &&
|
||||||
|
!Array.isArray(rawValue.data)
|
||||||
|
? rawValue.data
|
||||||
|
: rawValue,
|
||||||
|
) ?? {};
|
||||||
|
return { mapped, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
const carBody = inquiry.carBody ?? {};
|
||||||
|
const mapped = asObjectRecord(carBody.mapped) ?? {};
|
||||||
|
const raw = asObjectRecord(carBody.raw) ?? {};
|
||||||
|
const policy = asObjectRecord(raw.policy) ?? {};
|
||||||
|
const vehicle = asObjectRecord(raw.vehicle) ?? {};
|
||||||
|
const insurance = party?.insurance?.carBodyInsurance ?? {};
|
||||||
|
|
||||||
|
const policyNumber =
|
||||||
|
mapped.policyNumber ??
|
||||||
|
insurance.policyNumber ??
|
||||||
|
policy.CINumber ??
|
||||||
|
policy.PolicyNo;
|
||||||
|
const chassisNumber =
|
||||||
|
mapped.chassisNumber ?? insurance.chassisNumber ?? vehicle.ChassisNo;
|
||||||
|
const motorNumber =
|
||||||
|
mapped.motorNumber ?? insurance.motorNumber ?? vehicle.MotorNo;
|
||||||
|
const vin = mapped.vin ?? insurance.vin ?? vehicle.VIN ?? vehicle.ChassisNo;
|
||||||
|
const beginDate =
|
||||||
|
mapped.StartDate ?? mapped.startDate ?? insurance.startDate ?? policy.BeginDate;
|
||||||
|
const endDate =
|
||||||
|
mapped.EndDate ?? mapped.endDate ?? insurance.endDate ?? policy.EndDate;
|
||||||
|
const builtYear = mapped.builtYear ?? vehicle.BuiltYear;
|
||||||
|
const plaque = asObjectRecord(vehicle.plaque) ?? {};
|
||||||
|
const plk1 =
|
||||||
|
mapped.platePartOne ?? plaque.leftTwoDigits ?? vehicle.PlaqueLeftNo;
|
||||||
|
const plk2 =
|
||||||
|
mapped.plateLetterTitle ?? plaque.serialLetter;
|
||||||
|
const plk3 =
|
||||||
|
mapped.platePartThree ?? plaque.threeDigits ?? vehicle.PlaqueRightNo;
|
||||||
|
const plksrl =
|
||||||
|
mapped.plateSerialNumber ??
|
||||||
|
plaque.rightTwoDigits ??
|
||||||
|
vehicle.PlaqueSerial;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mapped: {
|
||||||
|
...mapped,
|
||||||
|
...insurance,
|
||||||
|
policyId:
|
||||||
|
mapped.policyId ??
|
||||||
|
insurance.policyId ??
|
||||||
|
raw.policyId ??
|
||||||
|
policy.PolicyId,
|
||||||
|
PrntCmpDocNo: policyNumber,
|
||||||
|
PlcyUnqCod: policy.PolicyUnqCod ?? mapped.policyNumber,
|
||||||
|
ShsNum: chassisNumber,
|
||||||
|
MtrNum: motorNumber,
|
||||||
|
VIN: vin,
|
||||||
|
HBgnDte: beginDate,
|
||||||
|
HEndDte: endDate,
|
||||||
|
PrdDte: builtYear,
|
||||||
|
plk1,
|
||||||
|
Plk1: plk1,
|
||||||
|
plk2,
|
||||||
|
Plk2: plk2,
|
||||||
|
plk3,
|
||||||
|
Plk3: plk3,
|
||||||
|
plksrl,
|
||||||
|
PlkSrl: plksrl,
|
||||||
|
},
|
||||||
|
raw: {
|
||||||
|
...policy,
|
||||||
|
...vehicle,
|
||||||
|
...raw,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
|
import { fanavaranClaimReferences } from "./fanavaran-claim-references";
|
||||||
|
|
||||||
|
describe("fanavaranClaimReferences", () => {
|
||||||
|
it("returns all four Fanavaran ids when the claim is completed", () => {
|
||||||
|
expect(
|
||||||
|
fanavaranClaimReferences({
|
||||||
|
status: ClaimCaseStatus.COMPLETED,
|
||||||
|
claimId: 987654,
|
||||||
|
claimNo: 123456,
|
||||||
|
dmgCaseId: 11,
|
||||||
|
expertiseId: 22,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
claimId: 987654,
|
||||||
|
claimNo: 123456,
|
||||||
|
dmgCaseId: 11,
|
||||||
|
expertiseId: 22,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the persisted Fanavaran reference codes to expert detail APIs", () => {
|
||||||
|
expect(
|
||||||
|
fanavaranClaimReferences({
|
||||||
|
status: ClaimCaseStatus.COMPLETED,
|
||||||
|
claimId: 100,
|
||||||
|
claimNo: 101,
|
||||||
|
dmgCaseId: 102,
|
||||||
|
expertiseId: 103,
|
||||||
|
fanavaranSync: {
|
||||||
|
baseClaim: { policyId: 104 },
|
||||||
|
damageCase: {
|
||||||
|
driverId: 105,
|
||||||
|
vehicleKindId: 106,
|
||||||
|
plaqueKindId: 107,
|
||||||
|
plaqueSampleId: 108,
|
||||||
|
accidentVehicleUsedId: 109,
|
||||||
|
insuranceCorpId: 110,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
claimId: 100,
|
||||||
|
claimNo: 101,
|
||||||
|
dmgCaseId: 102,
|
||||||
|
expertiseId: 103,
|
||||||
|
policyId: 104,
|
||||||
|
driverId: 105,
|
||||||
|
vehicleKindId: 106,
|
||||||
|
plaqueKindId: 107,
|
||||||
|
plaqueSampleId: 108,
|
||||||
|
accidentVehicleUsedId: 109,
|
||||||
|
insuranceCorpId: 110,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits missing ids", () => {
|
||||||
|
expect(
|
||||||
|
fanavaranClaimReferences({
|
||||||
|
status: ClaimCaseStatus.COMPLETED,
|
||||||
|
claimId: 987654,
|
||||||
|
claimNo: null,
|
||||||
|
dmgCaseId: undefined,
|
||||||
|
expertiseId: 22,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
claimId: 987654,
|
||||||
|
expertiseId: 22,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined before the claim is completed", () => {
|
||||||
|
expect(
|
||||||
|
fanavaranClaimReferences({
|
||||||
|
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||||
|
claimId: 987654,
|
||||||
|
dmgCaseId: 11,
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
80
src/claim-request-management/fanavaran-claim-references.ts
Normal file
80
src/claim-request-management/fanavaran-claim-references.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
|
|
||||||
|
export type FanavaranClaimReferences = {
|
||||||
|
claimId?: number;
|
||||||
|
claimNo?: number;
|
||||||
|
dmgCaseId?: number;
|
||||||
|
expertiseId?: number;
|
||||||
|
policyId?: number;
|
||||||
|
driverId?: number;
|
||||||
|
vehicleKindId?: number;
|
||||||
|
plaqueKindId?: number;
|
||||||
|
plaqueSampleId?: number;
|
||||||
|
accidentVehicleUsedId?: number;
|
||||||
|
insuranceCorpId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fanavaran ids for v2 claim GET. Only on COMPLETED; each field is omitted when missing.
|
||||||
|
*/
|
||||||
|
export function fanavaranClaimReferences(claim: {
|
||||||
|
status?: string;
|
||||||
|
claimId?: number | null;
|
||||||
|
claimNo?: number | null;
|
||||||
|
dmgCaseId?: number | null;
|
||||||
|
expertiseId?: number | null;
|
||||||
|
fanavaranSync?: {
|
||||||
|
baseClaim?: FanavaranReferenceStage | null;
|
||||||
|
damageCase?: FanavaranReferenceStage | null;
|
||||||
|
} | null;
|
||||||
|
}): FanavaranClaimReferences | undefined {
|
||||||
|
if (claim.status !== ClaimCaseStatus.COMPLETED) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refs: FanavaranClaimReferences = {};
|
||||||
|
if (claim.claimId != null) refs.claimId = claim.claimId;
|
||||||
|
if (claim.claimNo != null) refs.claimNo = claim.claimNo;
|
||||||
|
if (claim.dmgCaseId != null) refs.dmgCaseId = claim.dmgCaseId;
|
||||||
|
if (claim.expertiseId != null) refs.expertiseId = claim.expertiseId;
|
||||||
|
|
||||||
|
const baseClaim = claim.fanavaranSync?.baseClaim;
|
||||||
|
const damageCase = claim.fanavaranSync?.damageCase;
|
||||||
|
if (baseClaim?.policyId != null) refs.policyId = baseClaim.policyId;
|
||||||
|
if ((damageCase?.driverId ?? baseClaim?.driverId) != null) {
|
||||||
|
refs.driverId = damageCase?.driverId ?? baseClaim?.driverId;
|
||||||
|
}
|
||||||
|
if ((damageCase?.vehicleKindId ?? baseClaim?.vehicleKindId) != null) {
|
||||||
|
refs.vehicleKindId =
|
||||||
|
damageCase?.vehicleKindId ?? baseClaim?.vehicleKindId;
|
||||||
|
}
|
||||||
|
if (damageCase?.plaqueKindId != null) {
|
||||||
|
refs.plaqueKindId = damageCase.plaqueKindId;
|
||||||
|
}
|
||||||
|
if (damageCase?.plaqueSampleId != null) {
|
||||||
|
refs.plaqueSampleId = damageCase.plaqueSampleId;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(damageCase?.accidentVehicleUsedId ?? baseClaim?.accidentVehicleUsedId) !=
|
||||||
|
null
|
||||||
|
) {
|
||||||
|
refs.accidentVehicleUsedId =
|
||||||
|
damageCase?.accidentVehicleUsedId ?? baseClaim?.accidentVehicleUsedId;
|
||||||
|
}
|
||||||
|
if ((damageCase?.insuranceCorpId ?? baseClaim?.insuranceCorpId) != null) {
|
||||||
|
refs.insuranceCorpId =
|
||||||
|
damageCase?.insuranceCorpId ?? baseClaim?.insuranceCorpId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(refs).length > 0 ? refs : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FanavaranReferenceStage = {
|
||||||
|
policyId?: number | null;
|
||||||
|
driverId?: number | null;
|
||||||
|
vehicleKindId?: number | null;
|
||||||
|
plaqueKindId?: number | null;
|
||||||
|
plaqueSampleId?: number | null;
|
||||||
|
accidentVehicleUsedId?: number | null;
|
||||||
|
insuranceCorpId?: number | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
asPartyInquiryRows,
|
||||||
|
collectPersonNationalCodes,
|
||||||
|
parseJalaliDateParts,
|
||||||
|
pickPersonBirthday,
|
||||||
|
pickPersonNationalCode,
|
||||||
|
selectFanavaranDriverId,
|
||||||
|
} from "./fanavaran-driver-inquiry";
|
||||||
|
|
||||||
|
describe("selectFanavaranDriverId", () => {
|
||||||
|
const rows = [
|
||||||
|
{ Id: 10, RoleId: 161, Name: "insurer" },
|
||||||
|
{ Id: 20, RoleId: 166, Name: "driver" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("prefers insurer role 161 when the driver is the owner", () => {
|
||||||
|
expect(selectFanavaranDriverId(rows, true)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers driver role 166 when the driver is not the owner", () => {
|
||||||
|
expect(selectFanavaranDriverId(rows, false)).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the other role when the preferred role is missing", () => {
|
||||||
|
expect(selectFanavaranDriverId([{ Id: 20, RoleId: 166 }], true)).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks 4773521 from a real Fanavaran unique-identifier response", () => {
|
||||||
|
expect(
|
||||||
|
selectFanavaranDriverId(
|
||||||
|
[
|
||||||
|
{ Id: 4773521, RoleId: 161 },
|
||||||
|
{ Id: 4773521, RoleId: 163 },
|
||||||
|
{ Id: 4773521, RoleId: 166 },
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toBe(4773521);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickPersonNationalCode / birthday", () => {
|
||||||
|
it("uses insurer national code when driverIsInsurer", () => {
|
||||||
|
expect(
|
||||||
|
pickPersonNationalCode({
|
||||||
|
driverIsInsurer: true,
|
||||||
|
nationalCodeOfInsurer: "0012345678",
|
||||||
|
nationalCodeOfDriver: "",
|
||||||
|
}),
|
||||||
|
).toBe("0012345678");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back across birthday fields", () => {
|
||||||
|
expect(
|
||||||
|
pickPersonBirthday({
|
||||||
|
driverBirthday: null,
|
||||||
|
birthday: "13480313",
|
||||||
|
}),
|
||||||
|
).toBe("13480313");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("asPartyInquiryRows", () => {
|
||||||
|
it("normalizes Persian digits and compact insurer birthday 13640628", () => {
|
||||||
|
expect(
|
||||||
|
collectPersonNationalCodes({
|
||||||
|
nationalCodeOfInsurer: "۰۰۸۰۰۸۶۵۱۹",
|
||||||
|
}),
|
||||||
|
).toEqual(["0080086519"]);
|
||||||
|
expect(parseJalaliDateParts(13640628)).toEqual({
|
||||||
|
year: 1364,
|
||||||
|
month: 6,
|
||||||
|
day: 28,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
165
src/claim-request-management/fanavaran-driver-inquiry.ts
Normal file
165
src/claim-request-management/fanavaran-driver-inquiry.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { toEnglishDigits } from "src/lookups/fanavaran-last-car-policy";
|
||||||
|
|
||||||
|
export const FANAVARAN_INSURER_ROLE_ID = 161;
|
||||||
|
export const FANAVARAN_PLAQUE_OWNER_ROLE_ID = 163;
|
||||||
|
export const FANAVARAN_DRIVER_ROLE_ID = 166;
|
||||||
|
|
||||||
|
export type FanavaranPartyInquiryRow = {
|
||||||
|
Id?: unknown;
|
||||||
|
RoleId?: unknown;
|
||||||
|
NationalCode?: unknown;
|
||||||
|
Name?: unknown;
|
||||||
|
LastName?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asPositiveId(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
const id = Number(value);
|
||||||
|
return Number.isFinite(id) && id > 0 ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asPartyInquiryRows(value: unknown): FanavaranPartyInquiryRow[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter(
|
||||||
|
(row): row is FanavaranPartyInquiryRow =>
|
||||||
|
!!row && typeof row === "object" && !Array.isArray(row),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object") return [];
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
for (const key of ["value", "Value", "items", "Items", "data", "Data"]) {
|
||||||
|
const nested = record[key];
|
||||||
|
if (Array.isArray(nested)) return asPartyInquiryRows(nested);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DriverId for GEN.12 زیاندیده. Prefer insurer (161) or driver (166)
|
||||||
|
* based on driverIsInsurer, then any returned party id.
|
||||||
|
*/
|
||||||
|
export function selectFanavaranDriverId(
|
||||||
|
rows: unknown,
|
||||||
|
driverIsInsurer: boolean | undefined,
|
||||||
|
): number | null {
|
||||||
|
const parties = asPartyInquiryRows(rows);
|
||||||
|
if (parties.length === 0) return null;
|
||||||
|
|
||||||
|
const preferredRoles = driverIsInsurer
|
||||||
|
? [FANAVARAN_INSURER_ROLE_ID, FANAVARAN_DRIVER_ROLE_ID, FANAVARAN_PLAQUE_OWNER_ROLE_ID]
|
||||||
|
: [FANAVARAN_DRIVER_ROLE_ID, FANAVARAN_INSURER_ROLE_ID, FANAVARAN_PLAQUE_OWNER_ROLE_ID];
|
||||||
|
|
||||||
|
for (const roleId of preferredRoles) {
|
||||||
|
const match = parties.find((row) => asPositiveId(row.RoleId) === roleId);
|
||||||
|
if (match) return asPositiveId(match.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return asPositiveId(parties[0].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeNationalCode(value: unknown): string | null {
|
||||||
|
const digits = toEnglishDigits(value).replace(/\D/g, "");
|
||||||
|
if (digits.length === 10) return digits;
|
||||||
|
if (digits.length === 8 || digits.length === 9) return digits.padStart(10, "0");
|
||||||
|
return digits.length > 0 ? digits : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickPersonNationalCode(person: {
|
||||||
|
driverIsInsurer?: boolean;
|
||||||
|
nationalCodeOfDriver?: unknown;
|
||||||
|
nationalCodeOfInsurer?: unknown;
|
||||||
|
nationalCode?: unknown;
|
||||||
|
} | null | undefined): string | null {
|
||||||
|
if (!person) return null;
|
||||||
|
const driver = normalizeNationalCode(person.nationalCodeOfDriver);
|
||||||
|
const insurer = normalizeNationalCode(person.nationalCodeOfInsurer);
|
||||||
|
const generic = normalizeNationalCode(person.nationalCode);
|
||||||
|
if (person.driverIsInsurer) {
|
||||||
|
return insurer || driver || generic;
|
||||||
|
}
|
||||||
|
return driver || insurer || generic;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickPersonBirthday(person: {
|
||||||
|
driverIsInsurer?: boolean;
|
||||||
|
driverBirthday?: unknown;
|
||||||
|
birthday?: unknown;
|
||||||
|
insurerBirthday?: unknown;
|
||||||
|
} | null | undefined): string | number | null {
|
||||||
|
if (!person) return null;
|
||||||
|
const ordered = person.driverIsInsurer
|
||||||
|
? [person.insurerBirthday, person.birthday, person.driverBirthday]
|
||||||
|
: [person.driverBirthday, person.birthday, person.insurerBirthday];
|
||||||
|
for (const value of ordered) {
|
||||||
|
if (value == null || String(value).trim() === "") continue;
|
||||||
|
if (parseJalaliDateParts(value)) return value as string | number;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectPersonNationalCodes(
|
||||||
|
person: {
|
||||||
|
nationalCodeOfDriver?: unknown;
|
||||||
|
nationalCodeOfInsurer?: unknown;
|
||||||
|
nationalCode?: unknown;
|
||||||
|
} | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
const codes = [
|
||||||
|
normalizeNationalCode(person?.nationalCodeOfDriver),
|
||||||
|
normalizeNationalCode(person?.nationalCodeOfInsurer),
|
||||||
|
normalizeNationalCode(person?.nationalCode),
|
||||||
|
].filter((value): value is string => !!value);
|
||||||
|
return [...new Set(codes)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectPersonBirthdays(person: {
|
||||||
|
driverBirthday?: unknown;
|
||||||
|
birthday?: unknown;
|
||||||
|
insurerBirthday?: unknown;
|
||||||
|
} | null | undefined): Array<string | number> {
|
||||||
|
const values = [
|
||||||
|
person?.insurerBirthday,
|
||||||
|
person?.birthday,
|
||||||
|
person?.driverBirthday,
|
||||||
|
].filter((value) => value != null && String(value).trim() !== "");
|
||||||
|
const unique: Array<string | number> = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const value of values) {
|
||||||
|
const parsed = parseJalaliDateParts(value);
|
||||||
|
const key = parsed
|
||||||
|
? `${parsed.year}-${parsed.month}-${parsed.day}`
|
||||||
|
: String(value);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
unique.push(value as string | number);
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJalaliDateParts(
|
||||||
|
birthday: unknown,
|
||||||
|
): { year: number; month: number; day: number } | null {
|
||||||
|
if (birthday == null) return null;
|
||||||
|
if (typeof birthday === "object") {
|
||||||
|
const year = Number(toEnglishDigits((birthday as any).year ?? (birthday as any).BirthYear));
|
||||||
|
const month = Number(toEnglishDigits((birthday as any).month ?? (birthday as any).BirthMonth));
|
||||||
|
const day = Number(toEnglishDigits((birthday as any).day ?? (birthday as any).BirthDay));
|
||||||
|
if (year > 0 && month > 0 && day > 0) return { year, month, day };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const str = toEnglishDigits(birthday).trim();
|
||||||
|
if (/^\d{8}$/.test(str)) {
|
||||||
|
const year = parseInt(str.slice(0, 4), 10);
|
||||||
|
const month = parseInt(str.slice(4, 6), 10);
|
||||||
|
const day = parseInt(str.slice(6, 8), 10);
|
||||||
|
if (!year || !month || !day) return null;
|
||||||
|
return { year, month, day };
|
||||||
|
}
|
||||||
|
const parts = str.split(/[/\-.]/);
|
||||||
|
if (parts.length < 3) return null;
|
||||||
|
const year = parseInt(parts[0], 10);
|
||||||
|
const month = parseInt(parts[1], 10);
|
||||||
|
const day = parseInt(parts[2], 10);
|
||||||
|
if (!year || !month || !day) return null;
|
||||||
|
return { year, month, day };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
184
src/claim-request-management/fanavaran-hull-base-claim.ts
Normal file
184
src/claim-request-management/fanavaran-hull-base-claim.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
172
src/claim-request-management/fanavaran-hull-expertise.spec.ts
Normal file
172
src/claim-request-management/fanavaran-hull-expertise.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||||
|
import {
|
||||||
|
hullExpertiseAssertFields,
|
||||||
|
collectFanavaranExpertiseReadinessWarnings,
|
||||||
|
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: [
|
||||||
|
{
|
||||||
|
role: PartyRole.FIRST,
|
||||||
|
insurance: {
|
||||||
|
carBodyInsurance: {
|
||||||
|
chassisNumber: "NAS431100K1050805",
|
||||||
|
motorNumber: "M136251767",
|
||||||
|
vin: "VIN123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vehicle: {
|
||||||
|
inquiry: {
|
||||||
|
carBody: {
|
||||||
|
mapped: {
|
||||||
|
platePartOne: "29",
|
||||||
|
plateLetterTitle: "د",
|
||||||
|
platePartThree: "782",
|
||||||
|
plateSerialNumber: "60",
|
||||||
|
builtYear: 1398,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(vehicle).toEqual({
|
||||||
|
motorNo: "M136251767",
|
||||||
|
chassisNo: "NAS431100K1050805",
|
||||||
|
vin: "VIN123",
|
||||||
|
plaqueNo: "29د782",
|
||||||
|
plaqueSerial: "60",
|
||||||
|
builtYear: 1398,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a GEN.06 payload without ثالث expertise fields", () => {
|
||||||
|
const payload = toFanavaranHullExpertisePayload({
|
||||||
|
claimExpertId: 29,
|
||||||
|
dmgAssessmentDate: "1405/06/24",
|
||||||
|
inspectionTime: "10:33",
|
||||||
|
wage: 1000,
|
||||||
|
componentReplacementCost: 2000,
|
||||||
|
wasteValue: 0,
|
||||||
|
dropAmount: 12,
|
||||||
|
vehicleCurrentValue: 1900000000,
|
||||||
|
vehicle: {
|
||||||
|
motorNo: "M1",
|
||||||
|
chassisNo: "C1",
|
||||||
|
vin: null,
|
||||||
|
plaqueNo: "695ط61",
|
||||||
|
plaqueSerial: "87",
|
||||||
|
builtYear: 1398,
|
||||||
|
},
|
||||||
|
dmgSections: [
|
||||||
|
toFanavaranHullExpertiseDmgSection({
|
||||||
|
partId: 9,
|
||||||
|
desc: "bumper",
|
||||||
|
wasteValue: 0,
|
||||||
|
amount: 3000,
|
||||||
|
dmgKindId: 5485,
|
||||||
|
dmgCostKindId: 1,
|
||||||
|
vehicleHullAccessoryId: 3043330,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.Wage).toBe(1000);
|
||||||
|
expect(payload.RepairWage).toBeUndefined();
|
||||||
|
expect(payload.DmgCaseId).toBeUndefined();
|
||||||
|
expect(payload.InspectionPlaceId).toBeUndefined();
|
||||||
|
expect(payload.DropAmountStatus).toBeUndefined();
|
||||||
|
expect(payload.DamagedVehicleCurrentPrice).toBeUndefined();
|
||||||
|
expect(payload.ClaimExpertId).toBe(29);
|
||||||
|
expect(payload.PlaqueNo).toBe("695ط61");
|
||||||
|
expect(payload.DmgSections).toEqual([
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Desc: "bumper",
|
||||||
|
WasteValue: 0,
|
||||||
|
AccessoryKindId: 9,
|
||||||
|
DmgKindId: 5485,
|
||||||
|
VehicleHullAccessoryId: 3043330,
|
||||||
|
DmgSectionCosts: [
|
||||||
|
{ Caption: "bumper", Amount: 3000, DmgCostKindId: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not require ثالث expertise fields on a GEN.06 hull payload", () => {
|
||||||
|
const payload = toFanavaranHullExpertisePayload({
|
||||||
|
claimExpertId: 29,
|
||||||
|
dmgAssessmentDate: "1405/06/24",
|
||||||
|
inspectionTime: "10:41",
|
||||||
|
wage: 6000000,
|
||||||
|
componentReplacementCost: 10000000,
|
||||||
|
wasteValue: 0,
|
||||||
|
dropAmount: 10000,
|
||||||
|
vehicleCurrentValue: null,
|
||||||
|
vehicle: {
|
||||||
|
motorNo: "13389030167",
|
||||||
|
chassisNo: "NAAP41FD5BJ301065",
|
||||||
|
vin: "IRFC891V7D2301065",
|
||||||
|
plaqueNo: "56د394",
|
||||||
|
plaqueSerial: "66",
|
||||||
|
builtYear: 1389,
|
||||||
|
},
|
||||||
|
dmgSections: [
|
||||||
|
toFanavaranHullExpertiseDmgSection({
|
||||||
|
partId: 9,
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
collectFanavaranExpertiseReadinessWarnings(
|
||||||
|
payload,
|
||||||
|
hullExpertiseAssertFields("car-body"),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
collectFanavaranExpertiseReadinessWarnings(
|
||||||
|
payload,
|
||||||
|
hullExpertiseAssertFields("third-party"),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
"DmgCaseId is required.",
|
||||||
|
"InspectionPlaceId is required.",
|
||||||
|
"DropAmountStatus is required.",
|
||||||
|
"DmgSections[0].DmgSectionId is required.",
|
||||||
|
"DmgSections[0].AccidentLevel is required.",
|
||||||
|
"DmgSections[1].DmgSectionId is required.",
|
||||||
|
"DmgSections[1].AccidentLevel is required.",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
210
src/claim-request-management/fanavaran-hull-expertise.ts
Normal file
210
src/claim-request-management/fanavaran-hull-expertise.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { parseFanavaranId } from "src/lookups/fanavaran-last-car-policy";
|
||||||
|
import {
|
||||||
|
fanavaranPartyInquirySources,
|
||||||
|
type FanavaranClaimProduct,
|
||||||
|
} from "./fanavaran-claim-product";
|
||||||
|
|
||||||
|
export type FanavaranHullVehicleIdentity = {
|
||||||
|
motorNo: string | null;
|
||||||
|
chassisNo: string | null;
|
||||||
|
vin: string | null;
|
||||||
|
plaqueNo: string | null;
|
||||||
|
plaqueSerial: string | null;
|
||||||
|
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 {
|
||||||
|
const party = (blame?.parties ?? [])[0] as
|
||||||
|
| Parameters<typeof fanavaranPartyInquirySources>[1]
|
||||||
|
| undefined;
|
||||||
|
const first =
|
||||||
|
(blame?.parties ?? []).find(
|
||||||
|
(row) => (row as { role?: string })?.role === "FIRST",
|
||||||
|
) ?? party;
|
||||||
|
const { mapped } = fanavaranPartyInquirySources(
|
||||||
|
"car-body",
|
||||||
|
first as Parameters<typeof fanavaranPartyInquirySources>[1],
|
||||||
|
);
|
||||||
|
const left = text(mapped.plk1 ?? mapped.Plk1);
|
||||||
|
const letter = text(mapped.plk2 ?? mapped.Plk2);
|
||||||
|
const right = text(mapped.plk3 ?? mapped.Plk3);
|
||||||
|
const serial = text(mapped.plksrl ?? mapped.PlkSrl);
|
||||||
|
const plaqueNo = [left, letter, right].filter(Boolean).join("") || null;
|
||||||
|
return {
|
||||||
|
motorNo: text(mapped.MtrNum),
|
||||||
|
chassisNo: text(mapped.ShsNum),
|
||||||
|
vin: text(mapped.VIN),
|
||||||
|
plaqueNo,
|
||||||
|
plaqueSerial: serial,
|
||||||
|
builtYear: parseFanavaranId(mapped.PrdDte),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const next = String(value).trim();
|
||||||
|
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;
|
||||||
|
inspectionTime: string;
|
||||||
|
wage: number;
|
||||||
|
componentReplacementCost: number;
|
||||||
|
wasteValue: number;
|
||||||
|
dropAmount: number;
|
||||||
|
vehicleCurrentValue: number | null;
|
||||||
|
vehicle: FanavaranHullVehicleIdentity;
|
||||||
|
colorId?: number | null;
|
||||||
|
repairDuration?: number | null;
|
||||||
|
dmgSections: Record<string, unknown>[];
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
ClaimExpertId: input.claimExpertId,
|
||||||
|
DmgAssessmentDate: input.dmgAssessmentDate,
|
||||||
|
InspectionTime: input.inspectionTime,
|
||||||
|
MotorNo: input.vehicle.motorNo,
|
||||||
|
ChassisNo: input.vehicle.chassisNo,
|
||||||
|
BuiltYear: input.vehicle.builtYear,
|
||||||
|
VIN: input.vehicle.vin,
|
||||||
|
PlaqueNo: input.vehicle.plaqueNo,
|
||||||
|
PlaqueSerial: input.vehicle.plaqueSerial,
|
||||||
|
Wage: input.wage,
|
||||||
|
ComponentReplacementCost: input.componentReplacementCost,
|
||||||
|
WasteValue: input.wasteValue,
|
||||||
|
CarryAndRescueCost: null,
|
||||||
|
RepairDuration:
|
||||||
|
input.repairDuration ?? FANAVARAN_DEFAULT_HULL_REPAIR_DURATION,
|
||||||
|
VehicleCurrentValue: input.vehicleCurrentValue,
|
||||||
|
WreckHighestValue: null,
|
||||||
|
InspectionDeduction: null,
|
||||||
|
DmgAndWasteDesc: null,
|
||||||
|
WentDistanceByExpert: null,
|
||||||
|
IsDestruction: null,
|
||||||
|
DropAmount: input.dropAmount,
|
||||||
|
ColorId: input.colorId ?? null,
|
||||||
|
PlaqueDesignId: null,
|
||||||
|
PlaqueCityId: null,
|
||||||
|
AccidentPercent: null,
|
||||||
|
TheftCases: [],
|
||||||
|
DmgSections: input.dmgSections,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toFanavaranHullExpertiseDmgSection(input: {
|
||||||
|
partId?: unknown;
|
||||||
|
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: input.dmgKindId,
|
||||||
|
VehicleHullAccessoryId: input.vehicleHullAccessoryId,
|
||||||
|
DmgSectionCosts: [
|
||||||
|
{
|
||||||
|
Caption: input.desc,
|
||||||
|
Amount: input.amount,
|
||||||
|
DmgCostKindId: input.dmgCostKindId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hullExpertiseAssertFields(
|
||||||
|
product: FanavaranClaimProduct,
|
||||||
|
): { requireDmgCaseId: boolean; requireThirdPartyLookups: boolean } {
|
||||||
|
if (product === "car-body") {
|
||||||
|
return { requireDmgCaseId: false, requireThirdPartyLookups: false };
|
||||||
|
}
|
||||||
|
return { requireDmgCaseId: true, requireThirdPartyLookups: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectFanavaranExpertiseReadinessWarnings(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
options?: { requireDmgCaseId?: boolean; requireThirdPartyLookups?: boolean },
|
||||||
|
): string[] {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const sections = Array.isArray(payload.DmgSections)
|
||||||
|
? payload.DmgSections
|
||||||
|
: [];
|
||||||
|
const thirdPartyLookups = options?.requireThirdPartyLookups !== false;
|
||||||
|
if (options?.requireDmgCaseId !== false && !payload.DmgCaseId) {
|
||||||
|
warnings.push("DmgCaseId is required.");
|
||||||
|
}
|
||||||
|
if (thirdPartyLookups) {
|
||||||
|
if (!payload.InspectionPlaceId) {
|
||||||
|
warnings.push("InspectionPlaceId is required.");
|
||||||
|
}
|
||||||
|
if (!payload.DropAmountStatus) {
|
||||||
|
warnings.push("DropAmountStatus is required.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sections.length) {
|
||||||
|
warnings.push("At least one DmgSections row is required.");
|
||||||
|
}
|
||||||
|
for (const [index, section] of sections.entries()) {
|
||||||
|
const row = section as Record<string, unknown>;
|
||||||
|
if (!thirdPartyLookups) continue;
|
||||||
|
if (!row.DmgSectionId) {
|
||||||
|
warnings.push(`DmgSections[${index}].DmgSectionId is required.`);
|
||||||
|
}
|
||||||
|
if (!row.AccidentLevel) {
|
||||||
|
warnings.push(`DmgSections[${index}].AccidentLevel is required.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(new Set(warnings));
|
||||||
|
}
|
||||||
80
src/claim-request-management/fanavaran-other-people.spec.ts
Normal file
80
src/claim-request-management/fanavaran-other-people.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
buildFanavaranOtherPeoplePayload,
|
||||||
|
pickFanavaranRecordId,
|
||||||
|
pickLookupIdByCaption,
|
||||||
|
splitPersianFullName,
|
||||||
|
} from "./fanavaran-other-people";
|
||||||
|
|
||||||
|
describe("fanavaran other people (GEN.44)", () => {
|
||||||
|
it("builds a civil-registry create payload from local case + user fields", () => {
|
||||||
|
const payload = buildFanavaranOtherPeoplePayload(
|
||||||
|
{
|
||||||
|
nationalCode: "3392645966",
|
||||||
|
birthday: "1364/09/01",
|
||||||
|
fullName: "الهام مهدوی نیا",
|
||||||
|
mobile: "9366666666",
|
||||||
|
address: "گاندی ک نهم",
|
||||||
|
cityName: "تهران",
|
||||||
|
gender: "female",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cities: [{ Id: 9131, Caption: "تهران", IsActive: 1 }],
|
||||||
|
gender: [
|
||||||
|
{ Id: 26, Caption: "مرد", IsActive: 1 },
|
||||||
|
{ Id: 27, Caption: "زن", IsActive: 1 },
|
||||||
|
],
|
||||||
|
ans: [
|
||||||
|
{ Id: 1, Caption: "بله", IsActive: 1 },
|
||||||
|
{ Id: 0, Caption: "خیر", IsActive: 1 },
|
||||||
|
],
|
||||||
|
personKind: [{ Id: 46, Caption: "حقیقی", IsActive: 1 }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
NationalCode: "3392645966",
|
||||||
|
Name: "الهام",
|
||||||
|
LastName: "مهدوی نیا",
|
||||||
|
BirthYear: 1364,
|
||||||
|
BirthMonth: 9,
|
||||||
|
BirthDay: 1,
|
||||||
|
Mobile: "09366666666",
|
||||||
|
Address: "گاندی ک نهم",
|
||||||
|
CityId: 9131,
|
||||||
|
GenderId: 27,
|
||||||
|
IsIranian: 1,
|
||||||
|
PersonKindId: 46,
|
||||||
|
ADBirthYear: null,
|
||||||
|
NationalityId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when national code or birthday is missing", () => {
|
||||||
|
expect(
|
||||||
|
buildFanavaranOtherPeoplePayload({
|
||||||
|
nationalCode: "3392645966",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks the created person Id from a wrapped Fanavaran response", () => {
|
||||||
|
expect(pickFanavaranRecordId({ data: { Id: 4553876 } })).toBe(4553876);
|
||||||
|
expect(pickFanavaranRecordId([{ Id: "4553876" }])).toBe(4553876);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches lookup captions ignoring yeh/keheh and extra spaces", () => {
|
||||||
|
expect(
|
||||||
|
pickLookupIdByCaption(
|
||||||
|
[{ Id: 701, Caption: "ايران", IsActive: 1 }],
|
||||||
|
["ایران"],
|
||||||
|
),
|
||||||
|
).toBe(701);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits a Persian full name into first and last name", () => {
|
||||||
|
expect(splitPersianFullName("الهام مهدوی نیا")).toEqual({
|
||||||
|
name: "الهام",
|
||||||
|
lastName: "مهدوی نیا",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
252
src/claim-request-management/fanavaran-other-people.ts
Normal file
252
src/claim-request-management/fanavaran-other-people.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { toEnglishDigits } from "src/lookups/fanavaran-last-car-policy";
|
||||||
|
import {
|
||||||
|
parseJalaliDateParts,
|
||||||
|
pickPersonBirthday,
|
||||||
|
pickPersonNationalCode,
|
||||||
|
} from "./fanavaran-driver-inquiry";
|
||||||
|
|
||||||
|
export type FanavaranLookupRow = {
|
||||||
|
Id?: unknown;
|
||||||
|
Caption?: unknown;
|
||||||
|
Name?: unknown;
|
||||||
|
IsActive?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FanavaranOtherPeopleLookups = {
|
||||||
|
cities?: unknown;
|
||||||
|
gender?: unknown;
|
||||||
|
maritalStatus?: unknown;
|
||||||
|
ans?: unknown;
|
||||||
|
countries?: unknown;
|
||||||
|
personKind?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FanavaranOtherPeopleSource = {
|
||||||
|
nationalCode?: unknown;
|
||||||
|
birthday?: unknown;
|
||||||
|
fullName?: unknown;
|
||||||
|
fatherName?: unknown;
|
||||||
|
mobile?: unknown;
|
||||||
|
tel?: unknown;
|
||||||
|
email?: unknown;
|
||||||
|
address?: unknown;
|
||||||
|
jobAddress?: unknown;
|
||||||
|
postalCode?: unknown;
|
||||||
|
cityName?: unknown;
|
||||||
|
gender?: unknown;
|
||||||
|
isIranian?: boolean;
|
||||||
|
naturalizedCode?: unknown;
|
||||||
|
identityNo?: unknown;
|
||||||
|
identityNoIssuPlace?: unknown;
|
||||||
|
passportNo?: unknown;
|
||||||
|
companyCode?: unknown;
|
||||||
|
economicCode?: unknown;
|
||||||
|
registerNo?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FanavaranOtherPeoplePayload = {
|
||||||
|
NationalCode: string | null;
|
||||||
|
Name: string | null;
|
||||||
|
LastName: string | null;
|
||||||
|
FatherName: string | null;
|
||||||
|
BirthYear: number | null;
|
||||||
|
BirthMonth: number | null;
|
||||||
|
BirthDay: number | null;
|
||||||
|
ADBirthYear: null;
|
||||||
|
ADBirthMonth: null;
|
||||||
|
ADBirthDay: null;
|
||||||
|
IdentityNoIssuPlace: string | null;
|
||||||
|
PassportNo: string | null;
|
||||||
|
Address: string | null;
|
||||||
|
PostalCode: string | null;
|
||||||
|
Tel: string | null;
|
||||||
|
Mobile: string | null;
|
||||||
|
Email: string | null;
|
||||||
|
JobAddress: string | null;
|
||||||
|
EconomicCode: string | null;
|
||||||
|
NaturalizedCode: string | null;
|
||||||
|
CompanyCode: string | null;
|
||||||
|
IdentityNo: string | null;
|
||||||
|
RegisterNo: string | null;
|
||||||
|
CityId: number | null;
|
||||||
|
GenderId: number | null;
|
||||||
|
MaritalStatus: number | null;
|
||||||
|
IsIranian: number | string | null;
|
||||||
|
NationalityId: number | null;
|
||||||
|
PersonKindId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GENDER_MALE_TEXTS = ["مرد", "آقا", "male", "m"];
|
||||||
|
const GENDER_FEMALE_TEXTS = ["زن", "خانم", "female", "f"];
|
||||||
|
const ANS_YES_TEXTS = ["بله", "بلی", "yes", "1"];
|
||||||
|
const NATURAL_PERSON_TEXTS = ["حقیقی", "شخص حقیقی", "طبيعي", "natural"];
|
||||||
|
const IRAN_COUNTRY_TEXTS = ["ایران", "ايران", "iran"];
|
||||||
|
|
||||||
|
function asLookupRows(value: unknown): FanavaranLookupRow[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter(
|
||||||
|
(row): row is FanavaranLookupRow =>
|
||||||
|
!!row && typeof row === "object" && !Array.isArray(row),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asPositiveId(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
const id = Number(value);
|
||||||
|
return Number.isFinite(id) && id > 0 ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLookupText(value: unknown): string {
|
||||||
|
return toEnglishDigits(value)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[ي]/g, "ی")
|
||||||
|
.replace(/[ك]/g, "ک")
|
||||||
|
.replace(/[\u200c\s_\-\/]+/g, "")
|
||||||
|
.replace(/[^\p{L}\p{N}]/gu, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupRowId(row: FanavaranLookupRow): number | null {
|
||||||
|
return asPositiveId(row.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupRowTexts(row: FanavaranLookupRow): string[] {
|
||||||
|
return [row.Caption, row.Name]
|
||||||
|
.filter((value): value is string => typeof value === "string")
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickLookupIdByCaption(
|
||||||
|
rows: unknown,
|
||||||
|
texts: unknown[],
|
||||||
|
): number | null {
|
||||||
|
const wanted = texts
|
||||||
|
.map((text) => normalizeLookupText(text))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!wanted.length) return null;
|
||||||
|
|
||||||
|
for (const row of asLookupRows(rows)) {
|
||||||
|
if (row.IsActive === 0) continue;
|
||||||
|
const rowTexts = lookupRowTexts(row).map(normalizeLookupText);
|
||||||
|
const matched = rowTexts.some((rowText) =>
|
||||||
|
wanted.some(
|
||||||
|
(want) =>
|
||||||
|
rowText === want || rowText.includes(want) || want.includes(rowText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (matched) return lookupRowId(row);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitPersianFullName(
|
||||||
|
fullName: unknown,
|
||||||
|
): { name: string | null; lastName: string | null } {
|
||||||
|
const trimmed = String(fullName ?? "").trim().replace(/\s+/g, " ");
|
||||||
|
if (!trimmed) return { name: null, lastName: null };
|
||||||
|
const parts = trimmed.split(" ");
|
||||||
|
if (parts.length === 1) return { name: parts[0], lastName: null };
|
||||||
|
return { name: parts[0], lastName: parts.slice(1).join(" ") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeIranMobile(value: unknown): string | null {
|
||||||
|
const digits = toEnglishDigits(value).replace(/\D/g, "");
|
||||||
|
if (!digits) return null;
|
||||||
|
if (digits.length === 10 && digits.startsWith("9")) return `0${digits}`;
|
||||||
|
if (digits.length === 11 && digits.startsWith("09")) return digits;
|
||||||
|
if (digits.length === 12 && digits.startsWith("989")) {
|
||||||
|
return `0${digits.slice(2)}`;
|
||||||
|
}
|
||||||
|
return digits.length >= 8 ? digits : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickFanavaranRecordId(value: unknown): number | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (typeof value === "number" || typeof value === "string") {
|
||||||
|
return asPositiveId(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
const id = pickFanavaranRecordId(item);
|
||||||
|
if (id != null) return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof value !== "object") return null;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const direct = asPositiveId(record.Id ?? record.id);
|
||||||
|
if (direct != null) return direct;
|
||||||
|
for (const key of ["value", "Value", "data", "Data", "item", "Item"]) {
|
||||||
|
const nested = pickFanavaranRecordId(record[key]);
|
||||||
|
if (nested != null) return nested;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyToNull(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFanavaranOtherPeoplePayload(
|
||||||
|
source: FanavaranOtherPeopleSource,
|
||||||
|
lookups: FanavaranOtherPeopleLookups = {},
|
||||||
|
): FanavaranOtherPeoplePayload | null {
|
||||||
|
const nationalCode = pickPersonNationalCode({
|
||||||
|
nationalCode: source.nationalCode,
|
||||||
|
nationalCodeOfDriver: source.nationalCode,
|
||||||
|
});
|
||||||
|
const birthday = parseJalaliDateParts(
|
||||||
|
source.birthday ?? pickPersonBirthday({ birthday: source.birthday }),
|
||||||
|
);
|
||||||
|
if (!nationalCode || !birthday) return null;
|
||||||
|
|
||||||
|
const isIranian =
|
||||||
|
source.isIranian !== false && !emptyToNull(source.naturalizedCode);
|
||||||
|
const names = splitPersianFullName(source.fullName);
|
||||||
|
const genderTexts =
|
||||||
|
source.gender === "male" || source.gender === "m"
|
||||||
|
? GENDER_MALE_TEXTS
|
||||||
|
: source.gender === "female" || source.gender === "f"
|
||||||
|
? GENDER_FEMALE_TEXTS
|
||||||
|
: [source.gender];
|
||||||
|
|
||||||
|
return {
|
||||||
|
NationalCode: nationalCode,
|
||||||
|
Name: names.name,
|
||||||
|
LastName: names.lastName,
|
||||||
|
FatherName: emptyToNull(source.fatherName),
|
||||||
|
BirthYear: birthday.year,
|
||||||
|
BirthMonth: birthday.month,
|
||||||
|
BirthDay: birthday.day,
|
||||||
|
ADBirthYear: null,
|
||||||
|
ADBirthMonth: null,
|
||||||
|
ADBirthDay: null,
|
||||||
|
IdentityNoIssuPlace: emptyToNull(source.identityNoIssuPlace),
|
||||||
|
PassportNo: emptyToNull(source.passportNo),
|
||||||
|
Address: emptyToNull(source.address),
|
||||||
|
PostalCode: emptyToNull(source.postalCode),
|
||||||
|
Tel: normalizeIranMobile(source.tel) ?? emptyToNull(source.tel),
|
||||||
|
Mobile: normalizeIranMobile(source.mobile),
|
||||||
|
Email: emptyToNull(source.email),
|
||||||
|
JobAddress: emptyToNull(source.jobAddress),
|
||||||
|
EconomicCode: emptyToNull(source.economicCode),
|
||||||
|
NaturalizedCode: emptyToNull(source.naturalizedCode),
|
||||||
|
CompanyCode: emptyToNull(source.companyCode),
|
||||||
|
IdentityNo: emptyToNull(source.identityNo),
|
||||||
|
RegisterNo: emptyToNull(source.registerNo),
|
||||||
|
CityId: pickLookupIdByCaption(lookups.cities, [source.cityName]),
|
||||||
|
GenderId: pickLookupIdByCaption(lookups.gender, genderTexts),
|
||||||
|
MaritalStatus: pickLookupIdByCaption(lookups.maritalStatus, []),
|
||||||
|
IsIranian: isIranian
|
||||||
|
? (pickLookupIdByCaption(lookups.ans, ANS_YES_TEXTS) ?? 1)
|
||||||
|
: (pickLookupIdByCaption(lookups.ans, ["خیر", "no", "0"]) ?? 0),
|
||||||
|
NationalityId: isIranian
|
||||||
|
? null
|
||||||
|
: pickLookupIdByCaption(lookups.countries, IRAN_COUNTRY_TEXTS),
|
||||||
|
PersonKindId: pickLookupIdByCaption(
|
||||||
|
lookups.personKind,
|
||||||
|
NATURAL_PERSON_TEXTS,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ExpertInitiatedClaimMirrorController } from "./expert-initiated-claim.mirror.controller";
|
||||||
|
import { FileReviewerBlameV4Controller } from "../request-management/file-reviewer-blame-v4.controller";
|
||||||
|
import { FileReviewerBlameV5Controller } from "../request-management/file-reviewer-blame-v5.controller";
|
||||||
|
|
||||||
|
describe("owner insurer approval mirror endpoints", () => {
|
||||||
|
const claimRequestId = "507f1f77bcf86cd799439011";
|
||||||
|
const actor = { sub: "507f1f77bcf86cd799439012" };
|
||||||
|
const sign = {
|
||||||
|
filename: "sign.png",
|
||||||
|
path: "/tmp/sign.png",
|
||||||
|
} as Express.Multer.File;
|
||||||
|
|
||||||
|
const createDependencies = () => {
|
||||||
|
const claimRequestManagementService = {
|
||||||
|
submitOwnerInsurerApprovalSignV2: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ accepted: true }),
|
||||||
|
};
|
||||||
|
const mediaPolicyService = {
|
||||||
|
assertForClaim: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
return { claimRequestManagementService, mediaPolicyService };
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"expert-initiated",
|
||||||
|
(deps: ReturnType<typeof createDependencies>) =>
|
||||||
|
new ExpertInitiatedClaimMirrorController(
|
||||||
|
deps.claimRequestManagementService as never,
|
||||||
|
deps.mediaPolicyService as never,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"V4 file-reviewer",
|
||||||
|
(deps: ReturnType<typeof createDependencies>) =>
|
||||||
|
new FileReviewerBlameV4Controller(
|
||||||
|
{} as never,
|
||||||
|
deps.claimRequestManagementService as never,
|
||||||
|
deps.mediaPolicyService as never,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"V5 file-reviewer",
|
||||||
|
(deps: ReturnType<typeof createDependencies>) =>
|
||||||
|
new FileReviewerBlameV5Controller(
|
||||||
|
{} as never,
|
||||||
|
deps.claimRequestManagementService as never,
|
||||||
|
deps.mediaPolicyService as never,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"forwards a signature without branchId through the %s endpoint",
|
||||||
|
async (_name, createController) => {
|
||||||
|
const dependencies = createDependencies();
|
||||||
|
const controller = createController(dependencies) as {
|
||||||
|
submitOwnerSign: (...args: any[]) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await controller.submitOwnerSign(claimRequestId, "true", actor, sign);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
dependencies.mediaPolicyService.assertForClaim,
|
||||||
|
).toHaveBeenCalledWith(sign, claimRequestId, "image");
|
||||||
|
expect(
|
||||||
|
dependencies.claimRequestManagementService
|
||||||
|
.submitOwnerInsurerApprovalSignV2,
|
||||||
|
).toHaveBeenCalledWith(claimRequestId, true, sign, actor.sub, actor);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
|
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||||||
|
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||||
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
|
|
||||||
|
describe("owner insurer approval without branch selection", () => {
|
||||||
|
it("records a final rejection when no branchId is supplied", async () => {
|
||||||
|
const service = Object.create(
|
||||||
|
ClaimRequestManagementService.prototype,
|
||||||
|
) as ClaimRequestManagementService;
|
||||||
|
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
(service as any).claimCaseDbService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
_id: "507f1f77bcf86cd799439011",
|
||||||
|
status: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
|
||||||
|
claimStatus: ClaimStatus.APPROVED,
|
||||||
|
workflow: { currentStep: ClaimWorkflowStep.INSURER_REVIEW },
|
||||||
|
owner: { fullName: "مالک تست", clientId: "client-id" },
|
||||||
|
evaluation: {
|
||||||
|
damageExpertReply: { submittedAt: new Date(), parts: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
findByIdAndUpdate,
|
||||||
|
};
|
||||||
|
(service as any).resolveClaimEffectiveUserId = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue("507f1f77bcf86cd799439012");
|
||||||
|
(service as any).assertEffectiveUserIsDamagedPartyOnClaim = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
(service as any).claimSignDbService = {
|
||||||
|
create: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ _id: "507f1f77bcf86cd799439013" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.submitOwnerInsurerApprovalSignV2(
|
||||||
|
"507f1f77bcf86cd799439011",
|
||||||
|
false,
|
||||||
|
{ filename: "sign.png", path: "/tmp/sign.png" } as Express.Multer.File,
|
||||||
|
"507f1f77bcf86cd799439012",
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
accepted: false,
|
||||||
|
phase: "FINAL_APPROVAL",
|
||||||
|
status: ClaimCaseStatus.REJECTED,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findByIdAndUpdate).toHaveBeenCalledWith(
|
||||||
|
"507f1f77bcf86cd799439011",
|
||||||
|
expect.objectContaining({
|
||||||
|
$set: expect.objectContaining({
|
||||||
|
"evaluation.ownerInsurerApproval": expect.not.objectContaining({
|
||||||
|
branchId: expect.anything(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -373,6 +373,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
"guilty_car_card_front",
|
"guilty_car_card_front",
|
||||||
"guilty_car_card_back",
|
"guilty_car_card_back",
|
||||||
"guilty_metal_plate",
|
"guilty_metal_plate",
|
||||||
|
"accident_sketch",
|
||||||
],
|
],
|
||||||
example: "damaged_driving_license_front",
|
example: "damaged_driving_license_front",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`). Required for the VIN initial-form path.",
|
"ESG two-factor VIN/chassis inquiry (`/inquiry/carByChassis`). Required for the VIN initial-form path.",
|
||||||
example: false,
|
example: false,
|
||||||
})
|
})
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
200
src/common/dto/inquiry-participants.dto.ts
Normal file
200
src/common/dto/inquiry-participants.dto.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import {
|
||||||
|
ApiHideProperty,
|
||||||
|
ApiProperty,
|
||||||
|
ApiPropertyOptional,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Length,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export enum InquiryParticipantRole {
|
||||||
|
DRIVER = "DRIVER",
|
||||||
|
VEHICLE_OWNER = "VEHICLE_OWNER",
|
||||||
|
THIRD_PARTY_POLICYHOLDER = "THIRD_PARTY_POLICYHOLDER",
|
||||||
|
CAR_BODY_POLICYHOLDER = "CAR_BODY_POLICYHOLDER",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum VehicleRegistrationState {
|
||||||
|
CURRENT = "CURRENT",
|
||||||
|
RECENTLY_TRANSFERRED = "RECENTLY_TRANSFERRED",
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InquiryPlateDto {
|
||||||
|
@ApiProperty({ example: "44" })
|
||||||
|
@IsNotEmpty()
|
||||||
|
leftDigits: string | number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "ب" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
centerAlphabet: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "111" })
|
||||||
|
@IsNotEmpty()
|
||||||
|
centerDigits: string | number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "22" })
|
||||||
|
@IsNotEmpty()
|
||||||
|
ir: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InquiryParticipantInputDto {
|
||||||
|
@ApiPropertyOptional({ enum: InquiryParticipantRole })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(InquiryParticipantRole)
|
||||||
|
sameAs?: InquiryParticipantRole;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: "0012345678" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nationalCode?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: "1370/01/01" })
|
||||||
|
@IsOptional()
|
||||||
|
birthday?: string | number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
fullName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Required for a driver who has a licence.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
hasDrivingLicense?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
licenseNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
licenseType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InquiryVehicleInputDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: VehicleRegistrationState,
|
||||||
|
default: VehicleRegistrationState.CURRENT,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VehicleRegistrationState)
|
||||||
|
registrationState?: VehicleRegistrationState;
|
||||||
|
|
||||||
|
@ApiProperty({ type: InquiryPlateDto })
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => InquiryPlateDto)
|
||||||
|
currentPlate: InquiryPlateDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: InquiryPlateDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => InquiryPlateDto)
|
||||||
|
previousPlate?: InquiryPlateDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"National code of the policyholder associated with previousPlate. Required only for RECENTLY_TRANSFERRED vehicles.",
|
||||||
|
example: "0012345678",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
previousPolicyholderNationalCode?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
minLength: 17,
|
||||||
|
maxLength: 17,
|
||||||
|
example: "NAAM01E15HK123456",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(17, 17)
|
||||||
|
vin?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Whether the vehicle is newly purchased/registered.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isNewCar?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structured role-complete contract mixed into every inquiry DTO. */
|
||||||
|
export class InquiryParticipantFieldsDto {
|
||||||
|
@ApiProperty({ type: InquiryParticipantInputDto })
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => InquiryParticipantInputDto)
|
||||||
|
driver: InquiryParticipantInputDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: InquiryParticipantInputDto })
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => InquiryParticipantInputDto)
|
||||||
|
vehicleOwner: InquiryParticipantInputDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: InquiryParticipantInputDto })
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => InquiryParticipantInputDto)
|
||||||
|
thirdPartyPolicyholder: InquiryParticipantInputDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: InquiryParticipantInputDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => InquiryParticipantInputDto)
|
||||||
|
carBodyPolicyholder?: InquiryParticipantInputDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: InquiryVehicleInputDto })
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => InquiryVehicleInputDto)
|
||||||
|
vehicle: InquiryVehicleInputDto;
|
||||||
|
|
||||||
|
/** Internal normalized projections; rejected as request input by the resolver. */
|
||||||
|
@ApiHideProperty()
|
||||||
|
nationalCodeOfDriver?: string;
|
||||||
|
|
||||||
|
@ApiHideProperty()
|
||||||
|
driverBirthday?: any;
|
||||||
|
|
||||||
|
@ApiHideProperty()
|
||||||
|
driverLicense?: string;
|
||||||
|
|
||||||
|
@ApiHideProperty()
|
||||||
|
licenseType?: string;
|
||||||
|
|
||||||
|
@ApiHideProperty()
|
||||||
|
nationalCodeOfInsurer?: string;
|
||||||
|
|
||||||
|
@ApiHideProperty()
|
||||||
|
insurerBirthday?: any;
|
||||||
|
|
||||||
|
@ApiHideProperty()
|
||||||
|
insurerLicense?: string;
|
||||||
|
|
||||||
|
@ApiHideProperty()
|
||||||
|
driverIsInsurer?: boolean;
|
||||||
|
|
||||||
|
@ApiHideProperty()
|
||||||
|
userNoCertificate?: boolean;
|
||||||
|
|
||||||
|
@ApiHideProperty()
|
||||||
|
plate?: any;
|
||||||
|
|
||||||
|
@ApiHideProperty()
|
||||||
|
plateId?: string;
|
||||||
|
|
||||||
|
@ApiHideProperty()
|
||||||
|
vin?: string;
|
||||||
|
|
||||||
|
@ApiHideProperty()
|
||||||
|
isNewCar?: boolean;
|
||||||
|
}
|
||||||
@@ -27,6 +27,15 @@ export class UnifiedFileStatusReportQueryDto {
|
|||||||
@IsISO8601({ strict: false })
|
@IsISO8601({ strict: false })
|
||||||
to?: string;
|
to?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description:
|
||||||
|
"Preset reporting window in days. Default: 30 when from/to are omitted.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([30, 60, 90, "30", "60", "90"])
|
||||||
|
periodDays?: number | string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: LIST_FILE_TYPE_V2,
|
enum: LIST_FILE_TYPE_V2,
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -30,5 +30,8 @@ export class CaseInquiries {
|
|||||||
|
|
||||||
@Prop({ type: InquiryStatusSchema })
|
@Prop({ type: InquiryStatusSchema })
|
||||||
drivingLicence?: InquiryStatus;
|
drivingLicence?: InquiryStatus;
|
||||||
|
|
||||||
|
@Prop({ type: InquiryStatusSchema })
|
||||||
|
ownership?: InquiryStatus;
|
||||||
}
|
}
|
||||||
export const CaseInquiriesSchema = SchemaFactory.createForClass(CaseInquiries);
|
export const CaseInquiriesSchema = SchemaFactory.createForClass(CaseInquiries);
|
||||||
|
|||||||
101
src/common/utils/inquiry-error.spec.ts
Normal file
101
src/common/utils/inquiry-error.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
getInquiryErrorMessage,
|
||||||
|
isInquiryFailurePayload,
|
||||||
|
} from "./inquiry-error";
|
||||||
|
|
||||||
|
describe("inquiry error messages", () => {
|
||||||
|
it("preserves a Persian ESG not-found response", () => {
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
{ success: false, message: "موردی یافت نشد" },
|
||||||
|
"thirdPartyPlate",
|
||||||
|
),
|
||||||
|
).toBe("موردی یافت نشد");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["RECORD_NOT_FOUND", "رکوردی یافت نشد", "Provider request failed"],
|
||||||
|
[
|
||||||
|
"INQUIRY_NO_MATCH",
|
||||||
|
"نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
|
||||||
|
"Inquiry returned no matching result",
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"prefers ESG messageFa for %s over the technical message",
|
||||||
|
(code, messageFa, message) => {
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
messageFa,
|
||||||
|
providerMessage: message,
|
||||||
|
providerCode: code,
|
||||||
|
},
|
||||||
|
attemptSummary: {
|
||||||
|
attempts: [{ code, message, messageFa }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"thirdPartyPlate",
|
||||||
|
),
|
||||||
|
).toBe(messageFa);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("finds messageFa inside an HTTP response envelope", () => {
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
status: 404,
|
||||||
|
data: {
|
||||||
|
error: {
|
||||||
|
code: "RECORD_NOT_FOUND",
|
||||||
|
message: "Provider request failed",
|
||||||
|
messageFa: "رکوردی یافت نشد",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"thirdPartyPlate",
|
||||||
|
),
|
||||||
|
).toBe("رکوردی یافت نشد");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes VIN and car-body not-found failures", () => {
|
||||||
|
expect(getInquiryErrorMessage({ status: 404 }, "thirdPartyVin")).toContain(
|
||||||
|
"شماره شاسی (VIN)",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
new Error("No active policy found"),
|
||||||
|
"carBodyPlate",
|
||||||
|
),
|
||||||
|
).toBe("بیمهنامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a specific Persian provider message", () => {
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
{ message: "کد ملی واردشده صحیح نیست" },
|
||||||
|
"personalIdentity",
|
||||||
|
),
|
||||||
|
).toBe("کد ملی واردشده صحیح نیست");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose transport or authentication details", () => {
|
||||||
|
expect(
|
||||||
|
getInquiryErrorMessage(
|
||||||
|
{ response: { status: 401 }, message: "ESG authentication failed" },
|
||||||
|
"thirdPartyPlate",
|
||||||
|
),
|
||||||
|
).toBe("سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes provider failure envelopes", () => {
|
||||||
|
expect(isInquiryFailurePayload({ success: false })).toBe(true);
|
||||||
|
expect(isInquiryFailurePayload({ HasError: true })).toBe(true);
|
||||||
|
expect(isInquiryFailurePayload({ success: true, data: {} })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
247
src/common/utils/inquiry-error.ts
Normal file
247
src/common/utils/inquiry-error.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
export type InquiryErrorContext =
|
||||||
|
| "thirdPartyPlate"
|
||||||
|
| "thirdPartyVin"
|
||||||
|
| "carBodyPlate"
|
||||||
|
| "carBodyVin"
|
||||||
|
| "personalIdentity"
|
||||||
|
| "drivingLicense"
|
||||||
|
| "carOwnership"
|
||||||
|
| "sheba"
|
||||||
|
| "generic";
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
const NOT_FOUND_MESSAGES: Record<InquiryErrorContext, string> = {
|
||||||
|
thirdPartyPlate: "بیمهنامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||||
|
thirdPartyVin:
|
||||||
|
"بیمهنامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
|
||||||
|
carBodyPlate: "بیمهنامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||||
|
carBodyVin:
|
||||||
|
"بیمهنامه بدنه فعالی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
|
||||||
|
personalIdentity: "اطلاعات هویتی مطابق کد ملی و تاریخ تولد واردشده یافت نشد.",
|
||||||
|
drivingLicense:
|
||||||
|
"گواهینامهای مطابق کد ملی و شماره گواهینامه واردشده یافت نشد.",
|
||||||
|
carOwnership: "مالکیتی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||||
|
sheba: "اطلاعاتی مطابق شماره شبا و کد ملی واردشده یافت نشد.",
|
||||||
|
generic: "موردی مطابق اطلاعات واردشده یافت نشد.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const INVALID_MESSAGES: Record<InquiryErrorContext, string> = {
|
||||||
|
thirdPartyPlate: "پلاک یا کد ملی واردشده برای استعلام شخص ثالث معتبر نیست.",
|
||||||
|
thirdPartyVin:
|
||||||
|
"شماره شاسی (VIN) یا کد ملی واردشده برای استعلام شخص ثالث معتبر نیست.",
|
||||||
|
carBodyPlate: "پلاک یا کد ملی واردشده برای استعلام بیمه بدنه معتبر نیست.",
|
||||||
|
carBodyVin:
|
||||||
|
"شماره شاسی (VIN) یا کد ملی واردشده برای استعلام بیمه بدنه معتبر نیست.",
|
||||||
|
personalIdentity: "کد ملی یا تاریخ تولد واردشده معتبر نیست.",
|
||||||
|
drivingLicense: "کد ملی یا شماره گواهینامه واردشده معتبر نیست.",
|
||||||
|
carOwnership: "پلاک یا کد ملی واردشده برای استعلام مالکیت معتبر نیست.",
|
||||||
|
sheba: "شماره شبا یا کد ملی واردشده معتبر نیست.",
|
||||||
|
generic: "اطلاعات ارسالشده برای استعلام معتبر نیست.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): UnknownRecord | undefined =>
|
||||||
|
value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as UnknownRecord)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const cleanMessage = (value: unknown): string => {
|
||||||
|
if (typeof value === "string") return value.trim();
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("، ");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
function errorRecords(error: unknown): UnknownRecord[] {
|
||||||
|
const root = asRecord(error);
|
||||||
|
if (!root) return [];
|
||||||
|
|
||||||
|
const response = asRecord(root.response);
|
||||||
|
const responseData = asRecord(response?.data);
|
||||||
|
const data = asRecord(root.data);
|
||||||
|
const nestedError = asRecord(root.Error) ?? asRecord(root.error);
|
||||||
|
const responseError =
|
||||||
|
asRecord(responseData?.Error) ?? asRecord(responseData?.error);
|
||||||
|
|
||||||
|
return [root, responseData, data, nestedError, responseError].filter(
|
||||||
|
(item): item is UnknownRecord => !!item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inquiryErrorStatus(error: unknown): number | undefined {
|
||||||
|
for (const record of errorRecords(error)) {
|
||||||
|
const response = asRecord(record.response);
|
||||||
|
const value =
|
||||||
|
record.statusCode ?? record.status ?? response?.status ?? record.code;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 100 && parsed <= 599) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractInquiryProviderMessage(error: unknown): string {
|
||||||
|
const records = errorRecords(error);
|
||||||
|
|
||||||
|
// The normalized ESG/Parsian envelope carries the safe user-facing text in
|
||||||
|
// messageFa while `message` and `providerMessage` may remain technical.
|
||||||
|
// Search every envelope level for that explicit Persian field before
|
||||||
|
// considering generic message fields on an outer object.
|
||||||
|
for (const record of records) {
|
||||||
|
for (const key of [
|
||||||
|
"messageFa",
|
||||||
|
"MessageFa",
|
||||||
|
"messageFA",
|
||||||
|
"persianMessage",
|
||||||
|
] as const) {
|
||||||
|
const message = cleanMessage(record[key]);
|
||||||
|
if (message) return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
for (const key of ["message", "Message", "detail", "title"] as const) {
|
||||||
|
const message = cleanMessage(record[key]);
|
||||||
|
if (message) return message;
|
||||||
|
}
|
||||||
|
const scalarData = cleanMessage(record.data);
|
||||||
|
if (scalarData) return scalarData;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message.trim() : cleanMessage(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInquiryFailurePayload(value: unknown): boolean {
|
||||||
|
const root = asRecord(value);
|
||||||
|
if (!root) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
root.success === false ||
|
||||||
|
root.isSuccess === false ||
|
||||||
|
root.IsSuccess === false ||
|
||||||
|
root.IsSucceed === false ||
|
||||||
|
root.ReturnValue === false ||
|
||||||
|
root.HasError === true ||
|
||||||
|
root.hasError === true ||
|
||||||
|
root.Error != null ||
|
||||||
|
root.error != null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInquiryTimeout(error: unknown): boolean {
|
||||||
|
const root = asRecord(error);
|
||||||
|
const code = String(root?.code ?? "").toUpperCase();
|
||||||
|
const message = extractInquiryProviderMessage(error);
|
||||||
|
return (
|
||||||
|
["ECONNABORTED", "ETIMEDOUT", "ESOCKETTIMEDOUT"].includes(code) ||
|
||||||
|
/timeout|timed out|مهلت|زمان.*پایان/i.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPersian = (value: string): boolean => /[\u0600-\u06ff]/.test(value);
|
||||||
|
|
||||||
|
const isNotFound = (error: unknown, message: string): boolean => {
|
||||||
|
const codes = errorRecords(error).flatMap((record) =>
|
||||||
|
[record.code, record.providerCode]
|
||||||
|
.map((code) => String(code ?? "").toUpperCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
inquiryErrorStatus(error) === 404 ||
|
||||||
|
codes.some((code) =>
|
||||||
|
[
|
||||||
|
"NOT_FOUND",
|
||||||
|
"POLICY_NOT_FOUND",
|
||||||
|
"NO_POLICY",
|
||||||
|
"RECORD_NOT_FOUND",
|
||||||
|
"INQUIRY_NO_MATCH",
|
||||||
|
].includes(code),
|
||||||
|
) ||
|
||||||
|
/\bnot[ -]?found\b|\bno (?:active |relevant )?(?:record|policy|item)\b|record\.not\.found|موردی یافت نشد|یافت نشد|پیدا نشد|فاقد بیمه(?:| )?نامه/i.test(
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInvalidInput = (message: string): boolean =>
|
||||||
|
/invalid|malformed|required|must contain|bad request|نامعتبر|الزامی|وارد نشده|صحیح نیست/i.test(
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isUnavailable = (error: unknown, message: string): boolean => {
|
||||||
|
const root = asRecord(error);
|
||||||
|
const code = String(root?.code ?? "").toUpperCase();
|
||||||
|
const status = inquiryErrorStatus(error);
|
||||||
|
return (
|
||||||
|
isInquiryTimeout(error) ||
|
||||||
|
["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ERR_NETWORK"].includes(code) ||
|
||||||
|
status === 401 ||
|
||||||
|
status === 502 ||
|
||||||
|
status === 503 ||
|
||||||
|
status === 504 ||
|
||||||
|
/offline|unavailable|connection|socket|network|authentication|credentials|empty response|در دسترس نیست|عدم دسترسی/i.test(
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts provider and transport failures into a stable, user-facing Persian
|
||||||
|
* message. Provider details remain in server logs; raw English or technical
|
||||||
|
* messages are never returned to clients.
|
||||||
|
*/
|
||||||
|
export function getInquiryErrorMessage(
|
||||||
|
error: unknown,
|
||||||
|
context: InquiryErrorContext = "generic",
|
||||||
|
): string {
|
||||||
|
const providerMessage = extractInquiryProviderMessage(error);
|
||||||
|
|
||||||
|
// Persian text supplied by the provider is already the intended client
|
||||||
|
// message. Preserve it verbatim instead of replacing it with a local
|
||||||
|
// contextual fallback such as "inquiry not found".
|
||||||
|
if (providerMessage && hasPersian(providerMessage)) return providerMessage;
|
||||||
|
|
||||||
|
if (isNotFound(error, providerMessage)) return NOT_FOUND_MESSAGES[context];
|
||||||
|
|
||||||
|
if (
|
||||||
|
context === "carOwnership" &&
|
||||||
|
/not the owner|مالک.*نیست/i.test(providerMessage)
|
||||||
|
) {
|
||||||
|
return "پلاک واردشده متعلق به کد ملی واردشده نیست.";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
context === "sheba" &&
|
||||||
|
/does not match|تطابق ندارد/i.test(providerMessage)
|
||||||
|
) {
|
||||||
|
return "شماره شبا متعلق به کد ملی واردشده نیست.";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
context === "drivingLicense" &&
|
||||||
|
/not valid|نامعتبر/i.test(providerMessage)
|
||||||
|
) {
|
||||||
|
return "گواهینامه واردشده معتبر نیست.";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/policy insurer does not match|بیمه.*متعلق.*نیست/i.test(providerMessage)
|
||||||
|
) {
|
||||||
|
return "بیمهنامه یافتشده متعلق به شرکت بیمه این سامانه نیست.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInquiryTimeout(error)) {
|
||||||
|
return "زمان پاسخگویی سرویس استعلام به پایان رسید. لطفاً دوباره تلاش کنید.";
|
||||||
|
}
|
||||||
|
if (isUnavailable(error, providerMessage)) {
|
||||||
|
return "سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInvalidInput(providerMessage) || inquiryErrorStatus(error) === 422) {
|
||||||
|
return INVALID_MESSAGES[context];
|
||||||
|
}
|
||||||
|
|
||||||
|
return "انجام استعلام با خطا مواجه شد. لطفاً دوباره تلاش کنید.";
|
||||||
|
}
|
||||||
@@ -5,22 +5,18 @@ import {
|
|||||||
ValidatorConstraint,
|
ValidatorConstraint,
|
||||||
ValidatorConstraintInterface,
|
ValidatorConstraintInterface,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
import { parseMoneyAmountToman } from "src/utils/unicode-digits";
|
||||||
|
|
||||||
@ValidatorConstraint({ name: "isMoneyAmountString", async: false })
|
@ValidatorConstraint({ name: "isMoneyAmountString", async: false })
|
||||||
export class IsMoneyAmountStringConstraint
|
export class IsMoneyAmountStringConstraint implements ValidatorConstraintInterface {
|
||||||
implements ValidatorConstraintInterface
|
|
||||||
{
|
|
||||||
validate(value: unknown): boolean {
|
validate(value: unknown): boolean {
|
||||||
if (value == null || value === "") return true;
|
if (value == null || value === "") return true;
|
||||||
if (typeof value !== "string") return false;
|
if (typeof value !== "string") return false;
|
||||||
const n = normalizeMoneyAmountString(value);
|
return parseMoneyAmountToman(value) !== null;
|
||||||
if (!n) return false;
|
|
||||||
return /^\d+(\.\d+)?$/.test(n);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultMessage(): string {
|
defaultMessage(): string {
|
||||||
return "Must be a non-negative amount (digits only, optional decimal).";
|
return "Must be a non-negative whole-Toman amount.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ export class IsRepairLineAmountTomanConstraint
|
|||||||
number,
|
number,
|
||||||
boolean | undefined,
|
boolean | undefined,
|
||||||
];
|
];
|
||||||
if (value == null || value === "") return true;
|
// Optional fields are skipped by @IsOptional. A required amount must not
|
||||||
|
// accept an omitted or blank value, otherwise an empty expert pricing line
|
||||||
|
// can be submitted as a zero-cost repair.
|
||||||
|
if (value == null || (typeof value === "string" && value.trim() === "")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const amount = parseMoneyAmountToman(value);
|
const amount = parseMoneyAmountToman(value);
|
||||||
if (amount == null) return false;
|
if (amount == null) return false;
|
||||||
if (allowZero && amount === 0) return true;
|
if (allowZero && amount === 0) return true;
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Per-line and total caps for repair money. All values are **Toman** (no unit conversion in the API).
|
* Per-line and total caps for repair money. All values are **Toman** (no unit conversion in the API).
|
||||||
*/
|
*/
|
||||||
export const REPAIR_LINE_AMOUNT_TOMAN = {
|
export const REPAIR_LINE_AMOUNT_TOMAN = { // IT IS RIAL FROM NOW ON
|
||||||
/** Below this is not credible for a priced repair line (e.g. 1,000 Toman). */
|
/** Below this is not credible for a priced repair line (e.g. 1,000 Toman). */
|
||||||
MIN: 10_000,
|
MIN: 100_000,
|
||||||
/** Aligns with the total assessment cap; rejects absurd values (e.g. 100bn). */
|
/** Aligns with the total assessment cap; rejects absurd values (e.g. 100bn). */
|
||||||
MAX: 53_000_000,
|
MAX: 530_000_000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
|
/** Max sum of all priced + factor lines in one V1 expert reply / validation (Rial). */
|
||||||
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
|
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
|
||||||
|
|
||||||
const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
|
const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns null when the claim v2 total cap is disabled.
|
* Returns null when the V1 claim total cap is disabled.
|
||||||
*
|
*
|
||||||
* Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again.
|
* Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again.
|
||||||
* Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount.
|
* Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount.
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export interface FanavaranAuthConfig {
|
|||||||
password: string;
|
password: string;
|
||||||
corpId: string;
|
corpId: string;
|
||||||
contractId: string;
|
contractId: string;
|
||||||
|
/**
|
||||||
|
* Vehicle-hull (بدنه) ContractId. Must not be the ثالث contract.
|
||||||
|
* When unset, body lookups fall back to `contractId`.
|
||||||
|
*/
|
||||||
|
hullContractId?: string;
|
||||||
location: string;
|
location: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +57,8 @@ export interface FanavaranPayloadDefaults {
|
|||||||
* (Parsian: 29; Tejaratno proven: 2709).
|
* (Parsian: 29; Tejaratno proven: 2709).
|
||||||
*/
|
*/
|
||||||
ExpertiseClaimExpertId: number;
|
ExpertiseClaimExpertId: number;
|
||||||
|
/** GEN.06 hull RepairDuration when configured per tenant (optional). */
|
||||||
|
HullExpertiseRepairDuration?: number | null;
|
||||||
CompensationReferenceId: number;
|
CompensationReferenceId: number;
|
||||||
CulpritLicenceTypeId: number;
|
CulpritLicenceTypeId: number;
|
||||||
CulpritTypeId: number;
|
CulpritTypeId: number;
|
||||||
@@ -209,6 +216,19 @@ export function getFanavaranClientProfile(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ثالث uses `contractId`. بدنه uses `hullContractId` when configured. */
|
||||||
|
export function resolveFanavaranProductContractId(
|
||||||
|
clientKey: FanavaranClientKey,
|
||||||
|
product: "third-party" | "car-body",
|
||||||
|
): string {
|
||||||
|
const profile = getFanavaranClientProfile(clientKey);
|
||||||
|
if (product === "car-body") {
|
||||||
|
const hull = profile.auth.hullContractId?.trim();
|
||||||
|
if (hull) return hull;
|
||||||
|
}
|
||||||
|
return profile.auth.contractId;
|
||||||
|
}
|
||||||
|
|
||||||
export function listFanavaranClientProfiles(): FanavaranClientProfile[] {
|
export function listFanavaranClientProfiles(): FanavaranClientProfile[] {
|
||||||
return FANAVARAN_CLIENT_KEYS.map((key) => getFanavaranClientProfile(key));
|
return FANAVARAN_CLIENT_KEYS.map((key) => getFanavaranClientProfile(key));
|
||||||
}
|
}
|
||||||
|
|||||||
66
src/expert-blame/expert-blame.participants.spec.ts
Normal file
66
src/expert-blame/expert-blame.participants.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
|
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||||
|
import { ExpertBlameService } from "./expert-blame.service";
|
||||||
|
|
||||||
|
describe("ExpertBlameService participant detail contract", () => {
|
||||||
|
it("returns normalized participants and their role assignments from the blame party", async () => {
|
||||||
|
const service = new (ExpertBlameService as any)(
|
||||||
|
...new Array(13).fill(undefined),
|
||||||
|
) as any;
|
||||||
|
const expertId = "66ec0e480e321873c0900001";
|
||||||
|
|
||||||
|
service.expireBlameCaseWorkflowLockV2IfStale = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
service.blameRequestDbService = {
|
||||||
|
findByIdWithoutHistory: jest.fn().mockResolvedValue({
|
||||||
|
_id: "66ec0e480e321873c0900002",
|
||||||
|
type: BlameRequestType.THIRD_PARTY,
|
||||||
|
status: CaseStatus.WAITING_FOR_EXPERT,
|
||||||
|
expertInitiated: true,
|
||||||
|
initiatedByFieldExpertId: expertId,
|
||||||
|
workflow: {},
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
role: "FIRST",
|
||||||
|
person: { fullName: "Legacy Party Name" },
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
participantId: "PERSON_1",
|
||||||
|
nationalCode: "0012345678",
|
||||||
|
birthday: "1370/01/01",
|
||||||
|
unknown: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
participantRoles: {
|
||||||
|
driver: "PERSON_1",
|
||||||
|
vehicleOwner: "PERSON_1",
|
||||||
|
thirdPartyPolicyholder: "PERSON_1",
|
||||||
|
},
|
||||||
|
vehicle: { inquiry: { raw: { large: true } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: new Date("2026-09-19T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-09-19T00:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.findOneV2("blame-1", { sub: expertId });
|
||||||
|
const party = (result.parties as any[])[0];
|
||||||
|
|
||||||
|
expect(party.participants).toEqual([
|
||||||
|
{
|
||||||
|
participantId: "PERSON_1",
|
||||||
|
nationalCode: "0012345678",
|
||||||
|
birthday: "1370/01/01",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(party.participantRoles).toEqual({
|
||||||
|
driver: "PERSON_1",
|
||||||
|
vehicleOwner: "PERSON_1",
|
||||||
|
thirdPartyPolicyholder: "PERSON_1",
|
||||||
|
});
|
||||||
|
expect(party.person.fullName).toBe("Legacy Party Name");
|
||||||
|
expect(party.vehicle.inquiry).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,6 +91,7 @@ import {
|
|||||||
ExpertFileActivityType,
|
ExpertFileActivityType,
|
||||||
ExpertFileKind,
|
ExpertFileKind,
|
||||||
} from "src/users/entities/schema/expert-file-activity.schema";
|
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||||
|
import { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver";
|
||||||
|
|
||||||
interface CheckedRequestEntry {
|
interface CheckedRequestEntry {
|
||||||
CheckedRequest?: {
|
CheckedRequest?: {
|
||||||
@@ -613,9 +614,24 @@ export class ExpertBlameService {
|
|||||||
)) as Record<string, unknown>[];
|
)) as Record<string, unknown>[];
|
||||||
|
|
||||||
// Scope to this reviewer's insurance company via blame party clientId
|
// Scope to this reviewer's insurance company via blame party clientId
|
||||||
const visibleCases = allSealed.filter((doc) =>
|
const tenantVisibleCases = allSealed.filter((doc) =>
|
||||||
blameCaseTouchesClient(doc, clientKey),
|
blameCaseTouchesClient(doc, clientKey),
|
||||||
);
|
);
|
||||||
|
const visibleCases = (
|
||||||
|
await Promise.all(
|
||||||
|
tenantVisibleCases.map(async (doc) => {
|
||||||
|
try {
|
||||||
|
await this.requestManagementService.assertFileReviewerBranchAccess(
|
||||||
|
doc,
|
||||||
|
actor.sub,
|
||||||
|
);
|
||||||
|
return doc;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter((doc): doc is Record<string, unknown> => doc != null);
|
||||||
|
|
||||||
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
|
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
|
||||||
return pagedResult;
|
return pagedResult;
|
||||||
@@ -1250,8 +1266,17 @@ export class ExpertBlameService {
|
|||||||
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
|
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
|
||||||
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
|
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
|
||||||
|
|
||||||
|
// Keep the normalized blame-party participant contract authoritative for
|
||||||
|
// every expert detail response. Legacy person fields remain for old files,
|
||||||
|
// but new UIs must read participants + participantRoles from the same party.
|
||||||
|
doc.parties = parties.map((party: Record<string, unknown>) => ({
|
||||||
|
...party,
|
||||||
|
participants: sanitizeStoredInquiryParticipants(party.participants),
|
||||||
|
participantRoles: party.participantRoles,
|
||||||
|
}));
|
||||||
|
|
||||||
// Strip heavy SandHub inquiry blob
|
// Strip heavy SandHub inquiry blob
|
||||||
for (const party of parties as Array<{
|
for (const party of doc.parties as Array<{
|
||||||
vehicle?: Record<string, unknown>;
|
vehicle?: Record<string, unknown>;
|
||||||
}>) {
|
}>) {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -53,11 +53,19 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
blameRequestType?: BlameRequestType;
|
blameRequestType?: BlameRequestType;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: "How the blame file was initiated: IN_PERSON or LINK",
|
description: "How the blame file was initiated: NORMAL, IN_PERSON, or LINK",
|
||||||
example: "IN_PERSON",
|
example: "IN_PERSON",
|
||||||
})
|
})
|
||||||
creationMethod?: string;
|
creationMethod?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
"Maximum expert-reply total for V1 user-created files. Null for V2-V6 flows or when the cap is disabled.",
|
||||||
|
example: 530000000,
|
||||||
|
})
|
||||||
|
priceCap: number | null;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
"CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)",
|
"CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)",
|
||||||
@@ -131,6 +139,33 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
url?: string;
|
url?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Append-only expert damaged-part revisions. Removed rows retain their original capture URL even after the authoritative selection changes.",
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
revisionId: { type: "string" },
|
||||||
|
changedAt: { type: "string", format: "date-time" },
|
||||||
|
changedBy: { type: "object" },
|
||||||
|
removedParts: { type: "array", items: { type: "object" } },
|
||||||
|
addedParts: { type: "array", items: { type: "object" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
damagedPartsHistory?: Array<{
|
||||||
|
revisionId: string;
|
||||||
|
changedAt: Date | string;
|
||||||
|
changedBy: {
|
||||||
|
actorId: string;
|
||||||
|
actorName?: string;
|
||||||
|
actorType: string;
|
||||||
|
};
|
||||||
|
removedParts: Array<Record<string, unknown>>;
|
||||||
|
addedParts: Array<Record<string, unknown>>;
|
||||||
|
}>;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
"True when user uploaded all required factors and the case awaits expert approve/reject.",
|
"True when user uploaded all required factors and the case awaits expert approve/reject.",
|
||||||
@@ -163,6 +198,30 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
})
|
})
|
||||||
fileMakerApprovalActorId?: string;
|
fileMakerApprovalActorId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Fanavaran claim reference. Returned only after the local claim reaches COMPLETED. Each of claimId, claimNo, dmgCaseId, and expertiseId is included when Fanavaran has supplied it.",
|
||||||
|
example: {
|
||||||
|
claimId: 987654,
|
||||||
|
claimNo: 123456,
|
||||||
|
dmgCaseId: 11,
|
||||||
|
expertiseId: 22,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
fanavaran?: {
|
||||||
|
claimId?: number;
|
||||||
|
claimNo?: number;
|
||||||
|
dmgCaseId?: number;
|
||||||
|
expertiseId?: number;
|
||||||
|
policyId?: number;
|
||||||
|
driverId?: number;
|
||||||
|
vehicleKindId?: number;
|
||||||
|
plaqueKindId?: number;
|
||||||
|
plaqueSampleId?: number;
|
||||||
|
accidentVehicleUsedId?: number;
|
||||||
|
insuranceCorpId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
"Slice of `claim.evaluation` exposed to the damage expert. " +
|
"Slice of `claim.evaluation` exposed to the damage expert. " +
|
||||||
@@ -183,14 +242,12 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
damageExpertResend: unknown;
|
damageExpertResend: unknown;
|
||||||
ownerInsurerApproval?: {
|
ownerInsurerApproval?: {
|
||||||
agree: boolean;
|
agree: boolean;
|
||||||
branchId?: string;
|
|
||||||
signDetailId?: string;
|
signDetailId?: string;
|
||||||
signLink?: string;
|
signLink?: string;
|
||||||
signedAt?: Date | string;
|
signedAt?: Date | string;
|
||||||
};
|
};
|
||||||
ownerPricedPartsApproval?: {
|
ownerPricedPartsApproval?: {
|
||||||
agree: boolean;
|
agree: boolean;
|
||||||
branchId?: string;
|
|
||||||
signDetailId?: string;
|
signDetailId?: string;
|
||||||
signLink?: string;
|
signLink?: string;
|
||||||
signedAt?: Date | string;
|
signedAt?: Date | string;
|
||||||
@@ -229,7 +286,7 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
"Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.",
|
"Linked blame case (`blameCases`), same shape as expert-blame detail. Each party exposes its authoritative normalized `participants` and `participantRoles`, plus video/voice URLs, workflow, expert, and formatted dates.",
|
||||||
})
|
})
|
||||||
blameCase?: Record<string, unknown>;
|
blameCase?: Record<string, unknown>;
|
||||||
|
|
||||||
|
|||||||
110
src/expert-claim/dto/expert-claim-v2.dto.spec.ts
Normal file
110
src/expert-claim/dto/expert-claim-v2.dto.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
|
import { validate } from "class-validator";
|
||||||
|
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
|
||||||
|
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
|
||||||
|
import { SubmitExpertReplyV2Dto } from "./expert-claim-v2.dto";
|
||||||
|
|
||||||
|
describe("SubmitExpertReplyV2Dto", () => {
|
||||||
|
it("rejects a selected damaged part with blank pricing", async () => {
|
||||||
|
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
|
||||||
|
description: "Damage assessment",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
partId: 201,
|
||||||
|
typeOfDamage: TypeOfDamage.Repair,
|
||||||
|
price: "",
|
||||||
|
salary: "",
|
||||||
|
totalPayment: "",
|
||||||
|
factorNeeded: false,
|
||||||
|
daghi: { option: DaghiOption.NO_VALUE },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = await validate(dto);
|
||||||
|
|
||||||
|
expect(errors).not.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a replacement part without a price", async () => {
|
||||||
|
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
|
||||||
|
description: "Damage assessment",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
partId: 201,
|
||||||
|
typeOfDamage: TypeOfDamage.Change,
|
||||||
|
salary: "0",
|
||||||
|
totalPayment: "0",
|
||||||
|
factorNeeded: false,
|
||||||
|
daghi: { option: DaghiOption.NO_VALUE },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await validate(dto)).not.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a repair part without daghi", async () => {
|
||||||
|
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
|
||||||
|
description: "Damage assessment",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
partId: 201,
|
||||||
|
typeOfDamage: TypeOfDamage.Repair,
|
||||||
|
price: "100000",
|
||||||
|
salary: "100000",
|
||||||
|
totalPayment: "200000",
|
||||||
|
factorNeeded: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await validate(dto)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the expert's repair and replacement pricing rules", async () => {
|
||||||
|
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
|
||||||
|
description: "Damage assessment",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
partId: 11,
|
||||||
|
typeOfDamage: TypeOfDamage.Repair,
|
||||||
|
salary: "۱۰۰,۰۰۰",
|
||||||
|
totalPayment: "100000",
|
||||||
|
factorNeeded: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
partId: 23,
|
||||||
|
typeOfDamage: TypeOfDamage.Change,
|
||||||
|
price: "۱۰۰,۰۰۰",
|
||||||
|
salary: "۱۰۰,۰۰۰",
|
||||||
|
totalPayment: "150000",
|
||||||
|
factorNeeded: false,
|
||||||
|
daghi: {
|
||||||
|
option: DaghiOption.RECYCLED_PARTS_VALUE,
|
||||||
|
price: "۱۰۰,۵۰۰,۰۰۰",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,17 +3,20 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
ArrayMinSize,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsInt,
|
IsInt,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsRepairLineAmountToman } from 'src/common/validators/repair-line-amount-toman.validator';
|
import { IsMoneyAmountString } from 'src/common/validators/money-amount-string.validator';
|
||||||
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
||||||
import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
|
import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
|
||||||
import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum';
|
import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum';
|
||||||
|
import { TypeOfDamage } from 'src/Types&Enums/claim-request-management/type-of-damage.enum';
|
||||||
export class DaghiDetailsV2Dto {
|
export class DaghiDetailsV2Dto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
enum: DaghiOption,
|
enum: DaghiOption,
|
||||||
@@ -24,19 +27,17 @@ export class DaghiDetailsV2Dto {
|
|||||||
option: DaghiOption;
|
option: DaghiOption;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman)`,
|
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman; 100,000 to 10,000,000,000).`,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@ValidateIf(
|
||||||
|
(daghi: DaghiDetailsV2Dto) =>
|
||||||
|
daghi.option === DaghiOption.RECYCLED_PARTS_VALUE,
|
||||||
|
)
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsRepairLineAmountToman()
|
@IsNotEmpty()
|
||||||
|
@IsMoneyAmountString()
|
||||||
price?: string;
|
price?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: `Required when option is '${DaghiOption.DELIVER_DAMAGED_PART}' (Mongo ObjectId string)`,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
branchId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PartPricingV2Dto {
|
export class PartPricingV2Dto {
|
||||||
@@ -47,38 +48,60 @@ export class PartPricingV2Dto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
partId: number;
|
partId: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Minor' })
|
|
||||||
@IsString()
|
|
||||||
typeOfDamage: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: "5000000",
|
enum: TypeOfDamage,
|
||||||
description: "Part price in Toman (integer string). Use 0 if the full amount is in salary.",
|
description: "'change' requires price; 'repair' may omit it.",
|
||||||
})
|
})
|
||||||
|
@IsEnum(TypeOfDamage)
|
||||||
|
typeOfDamage: TypeOfDamage;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: "5000000",
|
||||||
|
description:
|
||||||
|
"Required for change lines; omitted for repair lines. Every supplied amount must be between 1,000,000 and 100,000,000,000 Rial.",
|
||||||
|
})
|
||||||
|
@ValidateIf(
|
||||||
|
(part: PartPricingV2Dto) =>
|
||||||
|
part.typeOfDamage === TypeOfDamage.Change ||
|
||||||
|
(part.price != null &&
|
||||||
|
(typeof part.price !== 'string' || part.price.trim() !== '')),
|
||||||
|
)
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsRepairLineAmountToman({ allowZero: true })
|
@IsNotEmpty()
|
||||||
|
@IsMoneyAmountString()
|
||||||
price: string;
|
price: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: "2000000",
|
example: "2000000",
|
||||||
description: "Labor in Toman (integer string). Use 0 if the full amount is in price.",
|
description:
|
||||||
|
"Labor in Rial (integer string; 1,000,000 to 100,000,000,000).",
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsRepairLineAmountToman({ allowZero: true })
|
@IsNotEmpty()
|
||||||
|
@IsMoneyAmountString()
|
||||||
salary: string;
|
salary: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: "7000000",
|
example: "7000000",
|
||||||
description: "Line total in Toman (integer string).",
|
description:
|
||||||
|
"Line total in Rial (integer string; 1,000,000 to 100,000,000,000).",
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsRepairLineAmountToman()
|
@IsNotEmpty()
|
||||||
|
@IsMoneyAmountString()
|
||||||
totalPayment: string;
|
totalPayment: string;
|
||||||
|
|
||||||
@ApiProperty({ type: DaghiDetailsV2Dto })
|
@ApiPropertyOptional({
|
||||||
|
type: DaghiDetailsV2Dto,
|
||||||
|
description: "Required for change lines; omit for repair lines.",
|
||||||
|
})
|
||||||
|
@ValidateIf(
|
||||||
|
(part: PartPricingV2Dto) => part.typeOfDamage === TypeOfDamage.Change,
|
||||||
|
)
|
||||||
|
@IsNotEmpty()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => DaghiDetailsV2Dto)
|
@Type(() => DaghiDetailsV2Dto)
|
||||||
daghi: DaghiDetailsV2Dto;
|
daghi?: DaghiDetailsV2Dto;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: false,
|
example: false,
|
||||||
@@ -90,10 +113,22 @@ export class PartPricingV2Dto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SubmitExpertReplyV2Dto {
|
export class SubmitExpertReplyV2Dto {
|
||||||
@ApiProperty({ example: 'Front door and hood have severe paint damage' })
|
@ApiPropertyOptional({
|
||||||
|
example: 'Front door and hood have severe paint damage',
|
||||||
|
description: "Optional expert note about the damage assessment.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
description?: string;
|
||||||
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({
|
@ApiProperty({
|
||||||
type: [PartPricingV2Dto],
|
type: [PartPricingV2Dto],
|
||||||
@@ -101,6 +136,7 @@ export class SubmitExpertReplyV2Dto {
|
|||||||
"One line per damaged part (`partId` from claim detail `damagedParts[]`), plus pricing, `daghi`, and `factorNeeded`.",
|
"One line per damaged part (`partId` from claim detail `damagedParts[]`), plus pricing, `daghi`, and `factorNeeded`.",
|
||||||
})
|
})
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => PartPricingV2Dto)
|
@Type(() => PartPricingV2Dto)
|
||||||
parts: PartPricingV2Dto[];
|
parts: PartPricingV2Dto[];
|
||||||
|
|||||||
@@ -25,12 +25,6 @@ export class DaghiDetailsDto {
|
|||||||
})
|
})
|
||||||
price?: string;
|
price?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
required: false,
|
|
||||||
type: String,
|
|
||||||
description: "Branch ID required when option is 'تحویل داغی'",
|
|
||||||
})
|
|
||||||
branchId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PartsList {
|
export class PartsList {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { RequestManagementModule } from "src/request-management/request-manageme
|
|||||||
import { SandHubModule } from "src/sand-hub/sand-hub.module";
|
import { SandHubModule } from "src/sand-hub/sand-hub.module";
|
||||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||||
import { UsersModule } from "src/users/users.module";
|
import { UsersModule } from "src/users/users.module";
|
||||||
|
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
||||||
import { ExpertClaimController } from "./expert-claim.controller";
|
import { ExpertClaimController } from "./expert-claim.controller";
|
||||||
import { ExpertClaimV2Controller } from "./expert-claim.v2.controller";
|
import { ExpertClaimV2Controller } from "./expert-claim.v2.controller";
|
||||||
import { ExpertClaimService } from "./expert-claim.service";
|
import { ExpertClaimService } from "./expert-claim.service";
|
||||||
@@ -41,6 +42,7 @@ import { ExpertClaimService } from "./expert-claim.service";
|
|||||||
SmsOrchestrationModule,
|
SmsOrchestrationModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
|
FanavaranLookupModule,
|
||||||
],
|
],
|
||||||
controllers: [ExpertClaimController, ExpertClaimV2Controller],
|
controllers: [ExpertClaimController, ExpertClaimV2Controller],
|
||||||
providers: [ExpertClaimService, ClaimFactorsImageDbService],
|
providers: [ExpertClaimService, ClaimFactorsImageDbService],
|
||||||
|
|||||||
561
src/expert-claim/expert-claim.service.spec.ts
Normal file
561
src/expert-claim/expert-claim.service.spec.ts
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
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: [
|
||||||
|
{
|
||||||
|
partId: 201,
|
||||||
|
typeOfDamage: TypeOfDamage.Repair,
|
||||||
|
price: "",
|
||||||
|
salary: "",
|
||||||
|
totalPayment: "",
|
||||||
|
factorNeeded: false,
|
||||||
|
daghi: { option: DaghiOption.NO_VALUE },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function createService() {
|
||||||
|
return new (ExpertClaimService as any)(
|
||||||
|
...new Array(21).fill(undefined),
|
||||||
|
) 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;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
service.validateAndNormalizeDaghiForExpertReplyV2([
|
||||||
|
{
|
||||||
|
partId: 201,
|
||||||
|
typeOfDamage: TypeOfDamage.Repair,
|
||||||
|
price: "10000",
|
||||||
|
salary: "0",
|
||||||
|
totalPayment: "10000",
|
||||||
|
daghi: { option: DaghiOption.NO_VALUE },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
partId: 201,
|
||||||
|
typeOfDamage: TypeOfDamage.Repair,
|
||||||
|
price: "10000",
|
||||||
|
salary: "0",
|
||||||
|
totalPayment: "10000",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a deliver-damaged-part line without a branch", () => {
|
||||||
|
const service = createService() as any;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
service.validateAndNormalizeDaghiForExpertReplyV2([
|
||||||
|
{
|
||||||
|
partId: 201,
|
||||||
|
typeOfDamage: TypeOfDamage.Change,
|
||||||
|
price: "100000",
|
||||||
|
salary: "100000",
|
||||||
|
totalPayment: "200000",
|
||||||
|
daghi: { option: DaghiOption.DELIVER_DAMAGED_PART },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
partId: 201,
|
||||||
|
typeOfDamage: TypeOfDamage.Change,
|
||||||
|
price: "100000",
|
||||||
|
salary: "100000",
|
||||||
|
totalPayment: "200000",
|
||||||
|
daghi: { option: DaghiOption.DELIVER_DAMAGED_PART },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a blank pricing line on the legacy submit endpoint before changing status", async () => {
|
||||||
|
const service = createService() as any;
|
||||||
|
const findAndUpdate = jest.fn();
|
||||||
|
service.claimRequestManagementDbService = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
_id: "legacy-claim",
|
||||||
|
actorLocked: { actorId: "expert-1" },
|
||||||
|
unlockTime: new Date(Date.now() + 60_000),
|
||||||
|
lockFile: true,
|
||||||
|
}),
|
||||||
|
findAndUpdate,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.submitReplyRequest("legacy-claim", blankPricingReply, {
|
||||||
|
sub: "expert-1",
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(findAndUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a blank pricing line on the V2 submit endpoint before completion", async () => {
|
||||||
|
const service = createService() as any;
|
||||||
|
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, carPrice: "1000000" },
|
||||||
|
{
|
||||||
|
sub: "expert-1",
|
||||||
|
role: RoleEnum.FIELD_EXPERT,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(findByIdAndUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a Persian, field-specific error when a V2 price is below the minimum", async () => {
|
||||||
|
const service = createService() as any;
|
||||||
|
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,
|
||||||
|
typeOfDamage: TypeOfDamage.Repair,
|
||||||
|
price: "99,999",
|
||||||
|
salary: "100000",
|
||||||
|
totalPayment: "100000",
|
||||||
|
factorNeeded: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ sub: "expert-1", role: RoleEnum.FIELD_EXPERT },
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
code: "EXPERT_REPLY_VALIDATION_ERROR",
|
||||||
|
field: "parts[0].price",
|
||||||
|
message: expect.stringContaining("حداقل"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UsePipes,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
|
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
|
||||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||||
import { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
|
import { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
|
||||||
|
import { submitExpertReplyValidationPipe } from "./pipes/submit-expert-reply-validation.pipe";
|
||||||
|
|
||||||
class InPersonVisitV2Dto {
|
class InPersonVisitV2Dto {
|
||||||
@ApiPropertyOptional({ example: "Paint damage requires physical inspection" })
|
@ApiPropertyOptional({ example: "Paint damage requires physical inspection" })
|
||||||
@@ -166,7 +168,7 @@ export class ExpertClaimV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get claim request detail for damage expert",
|
summary: "Get claim request detail for damage expert",
|
||||||
description:
|
description:
|
||||||
"Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` (included whenever saved, regardless of current status), `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). `evaluation.damageExpertReply` / `damageExpertReplyFinal` are always returned once submitted. Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
|
"Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` (included whenever saved, regardless of current status), `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). `evaluation.damageExpertReply` / `damageExpertReplyFinal` are always returned once submitted. Completed claims include Fanavaran `claimId` / `claimNo` / `dmgCaseId` / `expertiseId` when available. Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
async getClaimDetailV2(
|
async getClaimDetailV2(
|
||||||
@@ -266,16 +268,17 @@ export class ExpertClaimV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Submit expert damage assessment reply",
|
summary: "Submit expert damage assessment reply",
|
||||||
description:
|
description:
|
||||||
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 **Toman** (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
|
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 1,000,000–100,000,000,000 **Rial**. **V1-only cap:** for user-created V1 files, sum of line `totalPayment` values ≤ 530,000,000 **Rial** (same limit as factor-validation totals across priced + factor lines). V2–V6 files are uncapped. Claim detail exposes the effective `priceCap` (`null` when uncapped). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
|
||||||
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
|
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
|
||||||
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
|
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
|
||||||
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
|
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
|
||||||
"- **No factors:** **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, `claimStatus=APPROVED`, `currentStep=INSURER_REVIEW`, `nextStep=CLAIM_COMPLETED` → owner final sign/reject only.\n\n" +
|
"- **No factors (V2–V5):** **`COMPLETED`**, `claimStatus=APPROVED`, `workflow.currentStep=CLAIM_COMPLETED` → no final owner signature. The expert can submit to Fanavaran manually when ready. For V5, the case instead waits for FileMaker approval before becoming `COMPLETED`.\n\n" +
|
||||||
"**Legacy rows** may still use `WAITING_FOR_INSURER_APPROVAL` instead of the specific values above.\n\n" +
|
"**Legacy rows** may still use `WAITING_FOR_INSURER_APPROVAL` instead of the specific values above.\n\n" +
|
||||||
"**After the owner fulfilled an expert resend** (`damageExpertResend.fulfilledAt`), this expert **cannot** initiate **another resend**—use **reply/submit**, in-person visit, or **validate-factors** as appropriate.",
|
"**After the owner fulfilled an expert resend** (`damageExpertResend.fulfilledAt`), this expert **cannot** initiate **another resend**—use **reply/submit**, in-person visit, or **validate-factors** as appropriate.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
@ApiBody({ type: SubmitExpertReplyV2Dto })
|
@ApiBody({ type: SubmitExpertReplyV2Dto })
|
||||||
|
@UsePipes(submitExpertReplyValidationPipe)
|
||||||
async submitExpertReplyV2(
|
async submitExpertReplyV2(
|
||||||
@Param("claimRequestId") claimRequestId: string,
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
@Body() body: SubmitExpertReplyV2Dto,
|
@Body() body: SubmitExpertReplyV2Dto,
|
||||||
@@ -350,10 +353,10 @@ export class ExpertClaimV2Controller {
|
|||||||
"**Response:** `claimStatus` = `ClaimStatus` (e.g. APPROVED). `caseStatus` = `ClaimCaseStatus` (e.g. COMPLETED vs insurer-review) — they are not interchangeable.\n\n" +
|
"**Response:** `claimStatus` = `ClaimStatus` (e.g. APPROVED). `caseStatus` = `ClaimCaseStatus` (e.g. COMPLETED vs insurer-review) — they are not interchangeable.\n\n" +
|
||||||
"**Preconditions:** `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`), `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" +
|
"**Preconditions:** `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`), `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" +
|
||||||
"**Decisions:** each factor line gets `APPROVED` or `REJECTED`. **Every** decided line must include expert-entered `totalPayment` **or** both `price` and `salary` (factor photos are not read for amounts).\n\n" +
|
"**Decisions:** each factor line gets `APPROVED` or `REJECTED`. **Every** decided line must include expert-entered `totalPayment` **or** both `price` and `salary` (factor photos are not read for amounts).\n\n" +
|
||||||
"**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000 Toman**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" +
|
"**V1-only cap (when every factor line is decided):** for user-created V1 files, sum of **all** reply lines (priced parts + factor lines) must be ≤ **530,000,000 Rial**; otherwise `PRICE_CAP_ERROR` is returned. V2–V6 files are uncapped.\n\n" +
|
||||||
"**Outcomes:**\n" +
|
"**Outcomes:**\n" +
|
||||||
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature.\n" +
|
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature. V5 instead waits for FileMaker approval.\n" +
|
||||||
"- **Any rejected (repriced):** same auto-complete for now (owner acceptance may be added later).\n" +
|
"- **Any rejected (repriced):** same completion behavior for now (V5 waits for FileMaker approval).\n" +
|
||||||
"- **Partial batch:** returns pending until every factor line has a non-pending decision (no cap error until the batch is complete).",
|
"- **Partial batch:** returns pending until every factor line has a non-pending decision (no cap error until the batch is complete).",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { SubmitExpertReplyV2Dto } from "../dto/expert-claim-v2.dto";
|
||||||
|
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
|
||||||
|
import { submitExpertReplyValidationPipe } from "./submit-expert-reply-validation.pipe";
|
||||||
|
|
||||||
|
describe("submitExpertReplyValidationPipe", () => {
|
||||||
|
it("converts DTO validation failures into Persian, field-addressable errors", async () => {
|
||||||
|
await expect(
|
||||||
|
submitExpertReplyValidationPipe.transform(
|
||||||
|
{ parts: [] },
|
||||||
|
{ type: "body", metatype: SubmitExpertReplyV2Dto },
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
code: "EXPERT_REPLY_VALIDATION_ERROR",
|
||||||
|
message: "اطلاعات ارسالی پاسخ کارشناسی معتبر نیست.",
|
||||||
|
validationErrors: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ field: "parts" }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a reply without a description", async () => {
|
||||||
|
await expect(
|
||||||
|
submitExpertReplyValidationPipe.transform(
|
||||||
|
{
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
partId: 201,
|
||||||
|
typeOfDamage: TypeOfDamage.Repair,
|
||||||
|
salary: "100000",
|
||||||
|
totalPayment: "100000",
|
||||||
|
factorNeeded: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "body", metatype: SubmitExpertReplyV2Dto },
|
||||||
|
),
|
||||||
|
).resolves.toBeInstanceOf(SubmitExpertReplyV2Dto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("identifies invalid nested price fields for the frontend", async () => {
|
||||||
|
await expect(
|
||||||
|
submitExpertReplyValidationPipe.transform(
|
||||||
|
{
|
||||||
|
description: "Damage assessment",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
partId: 201,
|
||||||
|
typeOfDamage: "repair",
|
||||||
|
salary: "not-a-number",
|
||||||
|
totalPayment: "100000",
|
||||||
|
factorNeeded: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "body", metatype: SubmitExpertReplyV2Dto },
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
validationErrors: expect.arrayContaining([
|
||||||
|
{
|
||||||
|
field: "parts[0].salary",
|
||||||
|
message: "باید مبلغ صحیح و غیرمنفی به تومان باشد.",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ValidationError,
|
||||||
|
ValidationPipe,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
|
||||||
|
type FrontendValidationError = {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const constraintMessages: Record<string, string> = {
|
||||||
|
isString: "باید بهصورت متن ارسال شود.",
|
||||||
|
isNotEmpty: "وارد کردن این فیلد الزامی است.",
|
||||||
|
isArray: "باید بهصورت فهرست ارسال شود.",
|
||||||
|
arrayMinSize: "حداقل یک قطعه آسیبدیده باید ارسال شود.",
|
||||||
|
isBoolean: "باید درست یا نادرست باشد.",
|
||||||
|
isInt: "باید یک عدد صحیح باشد.",
|
||||||
|
isEnum: "مقدار واردشده معتبر نیست.",
|
||||||
|
nestedValidation: "اطلاعات این بخش معتبر نیست.",
|
||||||
|
isMoneyAmountString: "باید مبلغ صحیح و غیرمنفی به تومان باشد.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function flattenValidationErrors(
|
||||||
|
errors: ValidationError[],
|
||||||
|
parentPath = "",
|
||||||
|
): FrontendValidationError[] {
|
||||||
|
return errors.flatMap((error) => {
|
||||||
|
const field = parentPath
|
||||||
|
? /^\d+$/.test(error.property)
|
||||||
|
? `${parentPath}[${error.property}]`
|
||||||
|
: `${parentPath}.${error.property}`
|
||||||
|
: error.property;
|
||||||
|
const ownErrors = Object.keys(error.constraints ?? {}).map((constraint) => ({
|
||||||
|
field,
|
||||||
|
message: constraintMessages[constraint] ?? "مقدار واردشده معتبر نیست.",
|
||||||
|
}));
|
||||||
|
return [...ownErrors, ...flattenValidationErrors(error.children ?? [], field)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint-scoped because this application deliberately has no global
|
||||||
|
* ValidationPipe. It preserves DTO validation while returning a stable,
|
||||||
|
* Persian payload that the frontend can render by field.
|
||||||
|
*/
|
||||||
|
export const submitExpertReplyValidationPipe = new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
exceptionFactory: (errors: ValidationError[]) => {
|
||||||
|
const validationErrors = flattenValidationErrors(errors);
|
||||||
|
return new BadRequestException({
|
||||||
|
message: "اطلاعات ارسالی پاسخ کارشناسی معتبر نیست.",
|
||||||
|
error: "EXPERT_REPLY_VALIDATION_ERROR",
|
||||||
|
code: "EXPERT_REPLY_VALIDATION_ERROR",
|
||||||
|
validationErrors,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,8 +1,30 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsEmail, IsEnum, IsMongoId, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
|
IsMongoId,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { UserType } from "src/Types&Enums/userType.enum";
|
import { UserType } from "src/Types&Enums/userType.enum";
|
||||||
|
|
||||||
|
export class ExpertLocationDto {
|
||||||
|
@ApiProperty({ example: "210050", description: "Fanavaran Location / OpBUId" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "شعبه غرب" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateInsurerExpertDto {
|
export class CreateInsurerExpertDto {
|
||||||
@ApiProperty({ example: "Ali" })
|
@ApiProperty({ example: "Ali" })
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -88,6 +110,17 @@ export class CreateFileMakerByInsurerDto extends CreateInsurerExpertDto {
|
|||||||
default: RoleEnum.FILE_MAKER,
|
default: RoleEnum.FILE_MAKER,
|
||||||
})
|
})
|
||||||
role?: RoleEnum.FILE_MAKER;
|
role?: RoleEnum.FILE_MAKER;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [ExpertLocationDto],
|
||||||
|
description:
|
||||||
|
"Fanavaran Location entries (id = OpBUId). Used for Parsian V4/V5 submits.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ExpertLocationDto)
|
||||||
|
locations?: ExpertLocationDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateFileReviewerByInsurerDto extends CreateInsurerExpertDto {
|
export class CreateFileReviewerByInsurerDto extends CreateInsurerExpertDto {
|
||||||
@@ -96,4 +129,15 @@ export class CreateFileReviewerByInsurerDto extends CreateInsurerExpertDto {
|
|||||||
default: RoleEnum.FILE_REVIEWER,
|
default: RoleEnum.FILE_REVIEWER,
|
||||||
})
|
})
|
||||||
role?: RoleEnum.FILE_REVIEWER;
|
role?: RoleEnum.FILE_REVIEWER;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [ExpertLocationDto],
|
||||||
|
description:
|
||||||
|
"Fanavaran Location entries. Must overlap FileMaker locations on Parsian V4/V5 cases.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ExpertLocationDto)
|
||||||
|
locations?: ExpertLocationDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,25 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type ExpertKindFilter = "all" | "expert" | "damage_expert";
|
export type ExpertKindFilter = "all" | "expert" | "damage_expert";
|
||||||
|
export type ReportRangeMode = "preset" | "custom";
|
||||||
|
|
||||||
|
export class InsurerReportAppliedRangeDto {
|
||||||
|
@ApiProperty({ enum: ["preset", "custom"] })
|
||||||
|
mode: ReportRangeMode;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Effective range start (ISO string)" })
|
||||||
|
from: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Effective range end (ISO string)" })
|
||||||
|
to: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Preset day window used when the caller did not provide a full explicit from/to range.",
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
})
|
||||||
|
periodDays?: 30 | 60 | 90;
|
||||||
|
}
|
||||||
|
|
||||||
export class InsurerReportQueryDto {
|
export class InsurerReportQueryDto {
|
||||||
@ApiPropertyOptional({ description: "Start of date range (ISO string)" })
|
@ApiPropertyOptional({ description: "Start of date range (ISO string)" })
|
||||||
@@ -13,6 +32,13 @@ export class InsurerReportQueryDto {
|
|||||||
@ApiPropertyOptional({ description: "End of date range (ISO string)" })
|
@ApiPropertyOptional({ description: "End of date range (ISO string)" })
|
||||||
to?: string;
|
to?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description:
|
||||||
|
"Preset reporting window in days. Default: 30. Ignored when both from and to are provided.",
|
||||||
|
})
|
||||||
|
periodDays?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: ["all", "expert", "damage_expert"],
|
enum: ["all", "expert", "damage_expert"],
|
||||||
description:
|
description:
|
||||||
@@ -29,74 +55,93 @@ export class InsurerWorkLogQueryDto extends InsurerReportQueryDto {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared expert rows / counts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class InsurerExpertCaseBreakdownDto {
|
||||||
|
@ApiProperty({ description: "All files in scope for this row/summary within the selected range" })
|
||||||
|
totalFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Reviewed/handled files in scope for this row/summary" })
|
||||||
|
reviewedFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Unreviewed/not-yet-handled files in scope for this row/summary" })
|
||||||
|
unreviewedFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Third-party files in scope for this row/summary" })
|
||||||
|
thirdPartyFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Car-body files in scope for this row/summary" })
|
||||||
|
carBodyFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Reviewed third-party files in scope for this row/summary" })
|
||||||
|
thirdPartyReviewedFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Unreviewed third-party files in scope for this row/summary" })
|
||||||
|
thirdPartyUnreviewedFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Reviewed car-body files in scope for this row/summary" })
|
||||||
|
carBodyReviewedFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Unreviewed car-body files in scope for this row/summary" })
|
||||||
|
carBodyUnreviewedFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerExpertListItemDto extends InsurerExpertCaseBreakdownDto {
|
||||||
|
@ApiProperty()
|
||||||
|
_id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
fullName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ["blame", "claim"] })
|
||||||
|
expertKind: "blame" | "claim";
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
role: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
type?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "ISO creation datetime of the expert account" })
|
||||||
|
createdAt?: string | Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerExpertsListResponseDto {
|
||||||
|
@ApiProperty({ type: InsurerReportAppliedRangeDto })
|
||||||
|
range: InsurerReportAppliedRangeDto;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Total experts in roster before pagination" })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Current page (1-based)" })
|
||||||
|
page: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Requested page size" })
|
||||||
|
countPerPage: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [InsurerExpertListItemDto] })
|
||||||
|
experts: InsurerExpertListItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Statistics
|
// Statistics
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export class InsurerStatisticsBreakdownDto {
|
export class InsurerStatisticsDto extends InsurerExpertCaseBreakdownDto {
|
||||||
@ApiProperty({ description: "Files that have an insurer rating" })
|
@ApiProperty({ type: InsurerReportAppliedRangeDto })
|
||||||
filesWithInsurerRating: number;
|
range: InsurerReportAppliedRangeDto;
|
||||||
|
|
||||||
@ApiProperty({ description: "Files that have a bot rating" })
|
|
||||||
filesWithBotRating: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "Files that have any user rating" })
|
|
||||||
filesWithUserRating: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "Files that have an objection" })
|
|
||||||
filesWithObjection: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "Average insurer rating (0–5)" })
|
|
||||||
averageInsurerRating: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "Average bot rating (0–5)" })
|
|
||||||
averageBotRating: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InsurerStatisticsDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description:
|
|
||||||
"تعداد کل پروندههای بررسیشده — distinct tenant claim files with at least one expert CHECKED activity.",
|
|
||||||
})
|
|
||||||
totalFilesReviewed: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"رضایت کاربران از روند پرونده — average of progressSpeed + registrationEase + overallEvaluation across rated claim files, normalised to 0–100. Formula: (avg of three dimensions across all rated files / 5) * 100.",
|
"Number of active experts in the selected range (experts with at least one attributed file in that range).",
|
||||||
})
|
})
|
||||||
averageUserRatingPercentage: number;
|
activeExperts: number;
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description:
|
|
||||||
"پروندههای همراه — blame files where expertInitiated === true AND creationMethod === IN_PERSON.",
|
|
||||||
})
|
|
||||||
inPersonAccompaniedCount: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "Claim files created in the current calendar month." })
|
|
||||||
filesCreatedThisMonth: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "Total claim files scoped to this insurer." })
|
|
||||||
totalFiles: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description:
|
|
||||||
"Percentage of total claim files that have any user rating recorded. (NOT a satisfaction metric — do not render as رضایت کاربران.)",
|
|
||||||
deprecated: true,
|
|
||||||
})
|
|
||||||
filesWithUserRatingPercentage: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "Percentage of files with an objection." })
|
|
||||||
objectionPercentage: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "Insurer-to-bot rating ratio (0–100)." })
|
|
||||||
insurerToBotRatingPercentage: number;
|
|
||||||
|
|
||||||
@ApiProperty({ type: InsurerStatisticsBreakdownDto })
|
|
||||||
breakdown: InsurerStatisticsBreakdownDto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Top files
|
// Legacy top files (kept for backward compatibility; not used by the new reports)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export class TopFileUserRatingDto {
|
export class TopFileUserRatingDto {
|
||||||
@@ -125,7 +170,7 @@ export class InsurerTopFileDto {
|
|||||||
// Top experts
|
// Top experts
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export class InsurerTopExpertDto {
|
export class InsurerTopExpertDto extends InsurerExpertCaseBreakdownDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
_id: string;
|
_id: string;
|
||||||
|
|
||||||
@@ -134,24 +179,81 @@ export class InsurerTopExpertDto {
|
|||||||
|
|
||||||
@ApiProperty({ enum: ["blame", "claim"] })
|
@ApiProperty({ enum: ["blame", "claim"] })
|
||||||
expertKind: "blame" | "claim";
|
expertKind: "blame" | "claim";
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: "Overall average combined rating (0–5)" })
|
|
||||||
overallAverageRating: number | null;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "Activity stats from expertFileActivity log" })
|
|
||||||
requestStats: { totalHandled: number; totalChecked: number };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InsurerTopExpertsDto {
|
export class InsurerTopExpertsDto {
|
||||||
@ApiProperty({ type: [InsurerTopExpertDto], description: "Top blame-panel experts" })
|
@ApiProperty({ type: InsurerReportAppliedRangeDto })
|
||||||
blameExperts: InsurerTopExpertDto[];
|
range: InsurerReportAppliedRangeDto;
|
||||||
|
|
||||||
@ApiProperty({ type: [InsurerTopExpertDto], description: "Top damage experts (claim)" })
|
@ApiProperty({ type: [InsurerTopExpertDto], description: "Top 10 experts by total file count" })
|
||||||
claimExperts: InsurerTopExpertDto[];
|
experts: InsurerTopExpertDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Work-log (mirrors reports.dto.ts shapes; exposed from this module for swagger)
|
// Charts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class InsurerExpertChartPointDto {
|
||||||
|
@ApiProperty()
|
||||||
|
expertId: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
fullName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ["blame", "claim"] })
|
||||||
|
expertKind: "blame" | "claim";
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerFilesByDayPointDto {
|
||||||
|
@ApiProperty({ description: "UTC date key (YYYY-MM-DD)" })
|
||||||
|
date: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
thirdPartyFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
carBodyFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerAverageHandlingTimeByDayPointDto {
|
||||||
|
@ApiProperty({ description: "UTC date key (YYYY-MM-DD) based on handled/completed day" })
|
||||||
|
date: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Number of handled files included in the average for that day" })
|
||||||
|
handledFiles: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Average handling time in whole/civil days, rounded to 2 decimals" })
|
||||||
|
averageHandlingDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerChartsDto {
|
||||||
|
@ApiProperty({ type: InsurerReportAppliedRangeDto })
|
||||||
|
range: InsurerReportAppliedRangeDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [InsurerTopExpertDto] })
|
||||||
|
topExperts: InsurerTopExpertDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [InsurerExpertChartPointDto] })
|
||||||
|
filesPerExpertThirdParty: InsurerExpertChartPointDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [InsurerExpertChartPointDto] })
|
||||||
|
filesPerExpertCarBody: InsurerExpertChartPointDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [InsurerFilesByDayPointDto] })
|
||||||
|
filesByDay: InsurerFilesByDayPointDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [InsurerAverageHandlingTimeByDayPointDto] })
|
||||||
|
averageHandlingTimeByDay: InsurerAverageHandlingTimeByDayPointDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Work-log (legacy; kept exposed from this module for swagger)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export class InsurerWorkLogEntryDto {
|
export class InsurerWorkLogEntryDto {
|
||||||
@@ -172,12 +274,15 @@ export class InsurerWorkLogEntryDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"Distinct files with at least one CHECKED event in the reporting window (all-time when no date range given; restricted to from–to otherwise).",
|
"Distinct files with at least one CHECKED event in the reporting window (restricted to the effective selected range).",
|
||||||
})
|
})
|
||||||
distinctFilesCheckedInPeriod: number;
|
distinctFilesCheckedInPeriod: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InsurerWorkLogResponseDto {
|
export class InsurerWorkLogResponseDto {
|
||||||
|
@ApiProperty({ type: InsurerReportAppliedRangeDto })
|
||||||
|
range: InsurerReportAppliedRangeDto;
|
||||||
|
|
||||||
@ApiProperty({ type: [InsurerWorkLogEntryDto] })
|
@ApiProperty({ type: [InsurerWorkLogEntryDto] })
|
||||||
experts: InsurerWorkLogEntryDto[];
|
experts: InsurerWorkLogEntryDto[];
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
CreateFileReviewerByInsurerDto,
|
CreateFileReviewerByInsurerDto,
|
||||||
} from "./dto/create-insurer-expert.dto";
|
} from "./dto/create-insurer-expert.dto";
|
||||||
import {
|
import {
|
||||||
|
InsurerChartsDto,
|
||||||
|
InsurerExpertsListResponseDto,
|
||||||
InsurerStatisticsDto,
|
InsurerStatisticsDto,
|
||||||
InsurerTopExpertsDto,
|
InsurerTopExpertsDto,
|
||||||
InsurerTopFileDto,
|
InsurerTopFileDto,
|
||||||
@@ -148,13 +150,30 @@ export class ExpertInsurerController {
|
|||||||
|
|
||||||
@ApiQuery({ name: "page", type: Number })
|
@ApiQuery({ name: "page", type: Number })
|
||||||
@ApiQuery({ name: "response_count", type: Number })
|
@ApiQuery({ name: "response_count", type: Number })
|
||||||
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "periodDays",
|
||||||
|
required: false,
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: InsurerExpertsListResponseDto })
|
||||||
@Get("experts/list")
|
@Get("experts/list")
|
||||||
async getAllExperts(
|
async getAllExperts(
|
||||||
@Query("page") page: number,
|
@Query("page") page: number,
|
||||||
@Query("response_count") count: number,
|
@Query("response_count") count: number,
|
||||||
|
@Query("from") from: string | undefined,
|
||||||
|
@Query("to") to: string | undefined,
|
||||||
|
@Query("periodDays") periodDays: string | undefined,
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.retrieveAllExpertsOfClient(actor, page, count);
|
return await this.expertInsurerService.retrieveAllExpertsOfClient(
|
||||||
|
actor,
|
||||||
|
page,
|
||||||
|
count,
|
||||||
|
{ from, to, periodDays },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Reports: statistics ──────────────────────────────────────────────────
|
// ─── Reports: statistics ──────────────────────────────────────────────────
|
||||||
@@ -163,64 +182,91 @@ export class ExpertInsurerController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "نمایش کلی — KPI cards for insurer reports page",
|
summary: "نمایش کلی — KPI cards for insurer reports page",
|
||||||
description:
|
description:
|
||||||
"Returns `totalFilesReviewed` (distinct tenant claim files with ≥1 expert CHECKED activity), " +
|
"Returns the four main KPIs for the selected reporting range: total third-party files, total car-body files, total files, and active experts. " +
|
||||||
"`averageUserRatingPercentage` (mean of progressSpeed + registrationEase + overallEvaluation across rated files, normalised to 0–100), " +
|
"Also returns reviewed/unreviewed totals with third-party/car-body breakdown using the same range logic as the expert table and charts.",
|
||||||
"`inPersonAccompaniedCount` (blame files where expertInitiated=true AND creationMethod=IN_PERSON), " +
|
|
||||||
"`filesCreatedThisMonth`, `totalFiles`, `objectionPercentage`, `insurerToBotRatingPercentage`. " +
|
|
||||||
"⚠️ `userRatingPercentage` and `filesWithUserRatingPercentage` are the share of files *that have any user rating* — NOT a satisfaction score. Use `averageUserRatingPercentage` for رضایت کاربران.",
|
|
||||||
})
|
})
|
||||||
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string) — restricts counted claim portfolio" })
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "periodDays",
|
||||||
|
required: false,
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, type: InsurerStatisticsDto })
|
@ApiResponse({ status: 200, type: InsurerStatisticsDto })
|
||||||
async getExpertStatistics(
|
async getExpertStatistics(
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.getExpertStatisticsReport(actor, { from, to });
|
return await this.expertInsurerService.getExpertStatisticsReport(actor, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
periodDays,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Reports: top files ───────────────────────────────────────────────────
|
// ─── Reports: top files ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Get("top-files")
|
@Get("top-files")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Top 10 highest-rated claim files for this insurer",
|
summary: "[Legacy] Top 10 highest-rated claim files for this insurer",
|
||||||
description:
|
description:
|
||||||
"Sorted by combined insurer + user rating blend (getCombinedFileScore). " +
|
"Legacy rating-based report endpoint. It is kept for backward compatibility but is no longer needed for the new insurer KPI/reporting page.",
|
||||||
"Returns slim DTO only: publicId, createdAt, combinedScore, userRating.{comment, overallEvaluation}. " +
|
deprecated: true,
|
||||||
"Use publicId to build مشاهده پرونده links.",
|
|
||||||
})
|
})
|
||||||
@ApiQuery({ name: "from", required: false, description: "Restrict to files created on or after this ISO date" })
|
@ApiQuery({ name: "from", required: false, description: "Restrict to files created on or after this ISO date" })
|
||||||
@ApiQuery({ name: "to", required: false, description: "Restrict to files created on or before this ISO date" })
|
@ApiQuery({ name: "to", required: false, description: "Restrict to files created on or before this ISO date" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "periodDays",
|
||||||
|
required: false,
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, type: [InsurerTopFileDto] })
|
@ApiResponse({ status: 200, type: [InsurerTopFileDto] })
|
||||||
async getTopFiles(
|
async getTopFiles(
|
||||||
@CurrentUser() insurer,
|
@CurrentUser() insurer,
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.getTopFilesForClient(insurer.clientKey, { from, to });
|
return await this.expertInsurerService.getTopFilesForClient(insurer.clientKey, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
periodDays,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Reports: top experts (canonical + alias) ─────────────────────────────
|
// ─── Reports: top experts (canonical + alias) ─────────────────────────────
|
||||||
|
|
||||||
@Get("experts/top")
|
@Get("experts/top")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Top blame vs claim experts for this insurer",
|
summary: "Top 10 experts by file count for this insurer",
|
||||||
description:
|
description:
|
||||||
"Response: `{ blameExperts: [], claimExperts: [] }`. " +
|
"Returns a single top-10 expert list ranked by total attributed files inside the selected reporting range. " +
|
||||||
"Each item: `_id`, `fullName`, `expertKind`, `overallAverageRating`, `requestStats`. " +
|
"Each row includes reviewed/unreviewed and third-party/car-body breakdowns.",
|
||||||
"Sorted by overallAverageRating descending; up to 10 per group. " +
|
|
||||||
"Optional `from` / `to` accepted for forward-compat (currently accepted but roster is full-history).",
|
|
||||||
})
|
})
|
||||||
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "periodDays",
|
||||||
|
required: false,
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
|
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
|
||||||
async getTopExperts(
|
async getTopExperts(
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to });
|
return await this.expertInsurerService.getTopExpertsForClient(actor, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
periodDays,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,18 +275,52 @@ export class ExpertInsurerController {
|
|||||||
*/
|
*/
|
||||||
@Get("top-experts")
|
@Get("top-experts")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Alias for GET experts/top — top blame + claim experts",
|
summary: "Alias for GET experts/top — top 10 experts by file count",
|
||||||
description: "Same response as `GET experts/top`. Provided for front-end compatibility.",
|
description: "Same response as `GET experts/top`. Provided for front-end compatibility.",
|
||||||
})
|
})
|
||||||
@ApiQuery({ name: "from", required: false })
|
@ApiQuery({ name: "from", required: false })
|
||||||
@ApiQuery({ name: "to", required: false })
|
@ApiQuery({ name: "to", required: false })
|
||||||
|
@ApiQuery({ name: "periodDays", required: false, enum: [30, 60, 90] })
|
||||||
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
|
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
|
||||||
async getTopExpertsAlias(
|
async getTopExpertsAlias(
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to });
|
return await this.expertInsurerService.getTopExpertsForClient(actor, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
periodDays,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("report/charts")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Chart datasets for insurer reports",
|
||||||
|
description:
|
||||||
|
"Returns all chart-ready datasets for the selected reporting range: top 10 experts by total files, files per expert for third-party and car-body, daily file counts, and daily average handling time.",
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "periodDays",
|
||||||
|
required: false,
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: InsurerChartsDto })
|
||||||
|
async getReportCharts(
|
||||||
|
@CurrentUser() actor,
|
||||||
|
@Query("from") from?: string,
|
||||||
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
|
) {
|
||||||
|
return this.expertInsurerService.getReportCharts(actor, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
periodDays,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Reports: work-log (delegates to ReportsService) ─────────────────────
|
// ─── Reports: work-log (delegates to ReportsService) ─────────────────────
|
||||||
@@ -258,6 +338,12 @@ export class ExpertInsurerController {
|
|||||||
@ApiQuery({ name: "expertKind", required: false, enum: ["all", "expert", "damage_expert"], description: "Filter by expert kind" })
|
@ApiQuery({ name: "expertKind", required: false, enum: ["all", "expert", "damage_expert"], description: "Filter by expert kind" })
|
||||||
@ApiQuery({ name: "from", required: false, description: "Start of date window (ISO string)" })
|
@ApiQuery({ name: "from", required: false, description: "Start of date window (ISO string)" })
|
||||||
@ApiQuery({ name: "to", required: false, description: "End of date window (ISO string)" })
|
@ApiQuery({ name: "to", required: false, description: "End of date window (ISO string)" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "periodDays",
|
||||||
|
required: false,
|
||||||
|
enum: [30, 60, 90],
|
||||||
|
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
|
||||||
|
})
|
||||||
@ApiQuery({ name: "page", required: false, type: Number, description: "Page (1-based)" })
|
@ApiQuery({ name: "page", required: false, type: Number, description: "Page (1-based)" })
|
||||||
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page" })
|
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page" })
|
||||||
@ApiResponse({ status: 200, type: InsurerWorkLogResponseDto })
|
@ApiResponse({ status: 200, type: InsurerWorkLogResponseDto })
|
||||||
@@ -266,6 +352,7 @@ export class ExpertInsurerController {
|
|||||||
@Query("expertKind") expertKind?: "all" | "expert" | "damage_expert",
|
@Query("expertKind") expertKind?: "all" | "expert" | "damage_expert",
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
@Query("page") page?: string,
|
@Query("page") page?: string,
|
||||||
@Query("limit") limit?: string,
|
@Query("limit") limit?: string,
|
||||||
) {
|
) {
|
||||||
@@ -273,6 +360,7 @@ export class ExpertInsurerController {
|
|||||||
expertKind,
|
expertKind,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
|
periodDays,
|
||||||
page: page ? parseInt(page, 10) : undefined,
|
page: page ? parseInt(page, 10) : undefined,
|
||||||
limit: limit ? parseInt(limit, 10) : undefined,
|
limit: limit ? parseInt(limit, 10) : undefined,
|
||||||
});
|
});
|
||||||
@@ -355,12 +443,19 @@ export class ExpertInsurerController {
|
|||||||
})
|
})
|
||||||
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
|
@ApiQuery({ name: "periodDays", required: false, enum: [30, 60, 90] })
|
||||||
async getInsurerStatusReport(
|
async getInsurerStatusReport(
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
|
@Query("periodDays") periodDays?: string,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.getInsurerFileStatusCounts(actor, from, to);
|
return await this.expertInsurerService.getInsurerFileStatusCounts(
|
||||||
|
actor,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
periodDays,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Expert detail ────────────────────────────────────────────────────────
|
// ─── Expert detail ────────────────────────────────────────────────────────
|
||||||
|
|||||||
74
src/expert-insurer/expert-insurer.file-summary.spec.ts
Normal file
74
src/expert-insurer/expert-insurer.file-summary.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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(
|
||||||
|
ExpertInsurerService.prototype,
|
||||||
|
) as ExpertInsurerService;
|
||||||
|
|
||||||
|
it("calculates blame review duration in minutes on the backend", () => {
|
||||||
|
const summary = (service as any).mapBlameFileSummaryForInsurerExpert({
|
||||||
|
_id: "6aa50ce70545fc1906433c85",
|
||||||
|
publicId: "RPT832-00049",
|
||||||
|
requestNo: "BL-RPT832-000049",
|
||||||
|
type: "THIRD_PARTY",
|
||||||
|
status: "WAITING_FOR_SIGNATURES",
|
||||||
|
blameStatus: "AGREED",
|
||||||
|
createdAt: new Date("2026-06-21T12:42:28.000Z"),
|
||||||
|
updatedAt: new Date("2026-06-21T15:32:28.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary.reviewDurationMinutes).toBe(170);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates claim review duration with the same contract", () => {
|
||||||
|
const summary = (service as any).mapClaimFileSummaryForInsurerExpert({
|
||||||
|
_id: "6aa50ce70545fc1906433c86",
|
||||||
|
publicId: "RPT832-00049",
|
||||||
|
createdAt: "2026-06-21T12:42:28.000Z",
|
||||||
|
updatedAt: "2026-06-21T12:43:58.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary.reviewDurationMinutes).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null instead of NaN when a duration timestamp is unavailable", () => {
|
||||||
|
const summary = (service as any).mapBlameFileSummaryForInsurerExpert({
|
||||||
|
_id: "6aa50ce70545fc1906433c85",
|
||||||
|
createdAt: "invalid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary.reviewDurationMinutes).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps malformed negative durations to zero", () => {
|
||||||
|
const summary = (service as any).mapClaimFileSummaryForInsurerExpert({
|
||||||
|
_id: "6aa50ce70545fc1906433c86",
|
||||||
|
createdAt: "2026-06-21T15:32:28.000Z",
|
||||||
|
updatedAt: "2026-06-21T12:42:28.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
137
src/expert-insurer/helper/timeline-fa-labels.spec.ts
Normal file
137
src/expert-insurer/helper/timeline-fa-labels.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import {
|
||||||
|
getEventFaDescription,
|
||||||
|
getEventFaLabel,
|
||||||
|
localizeTimelineMetadata,
|
||||||
|
} from "./timeline-fa-labels";
|
||||||
|
|
||||||
|
describe("insurer timeline Persian labels", () => {
|
||||||
|
it("localizes the user-submission detail shown to insurers", () => {
|
||||||
|
const event = {
|
||||||
|
type: "STEP_COMPLETED",
|
||||||
|
metadata: {
|
||||||
|
stepKey: "USER_SUBMISSION_COMPLETE",
|
||||||
|
description:
|
||||||
|
"User submission complete. Claim ready for damage expert review.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getEventFaLabel(event)).toBe("ارسال اطلاعات توسط کاربر");
|
||||||
|
expect(getEventFaDescription(event)).toBe(
|
||||||
|
"ثبت اطلاعات کاربر تکمیل شد و پرونده آماده بررسی کارشناس خسارت است.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns an English event type as the description fallback", () => {
|
||||||
|
expect(getEventFaDescription({ type: "UNRECOGNIZED_EVENT" })).toBe(
|
||||||
|
"رویدادی در روند پرونده ثبت شد.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["FIRST_INITIAL_FORM_SUBMITTED", "فرم اولیه طرف اول ثبت شد"],
|
||||||
|
["FIRST_LOCATION_SUBMITTED", "موقعیت مکانی طرف اول ثبت شد"],
|
||||||
|
["FIRST_VOICE_UPLOADED", "صدای طرف اول بارگذاری شد"],
|
||||||
|
["FIRST_DESCRIPTION_SUBMITTED", "توضیحات طرف اول ثبت شد"],
|
||||||
|
["EXPERT_REPLY_SUBMITTED", "پاسخ کارشناس ثبت شد"],
|
||||||
|
])("localizes sample timeline event %s", (type, expectedLabel) => {
|
||||||
|
expect(getEventFaLabel({ type })).toBe(expectedLabel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a Persian faLabel fallback for an unknown event type", () => {
|
||||||
|
expect(getEventFaLabel({ type: "UNRECOGNIZED_EVENT" })).toBe(
|
||||||
|
"رویداد پرونده",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves human-readable assignment metadata exactly", () => {
|
||||||
|
expect(
|
||||||
|
localizeTimelineMetadata({
|
||||||
|
type: "CLAIM_ASSIGNED",
|
||||||
|
metadata: { note: "Expert assigned via review assign endpoint" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
note: "Expert assigned via review assign endpoint",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves Fanavaran metadata keys and values exactly", () => {
|
||||||
|
expect(
|
||||||
|
localizeTimelineMetadata({
|
||||||
|
type: "FANAVARAN_EXPERTISE_AUTO_SUBMIT_FAILED",
|
||||||
|
metadata: {
|
||||||
|
clientKey: "parsian",
|
||||||
|
error: "Fanavaran expertise payload is not ready.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
clientKey: "parsian",
|
||||||
|
error: "Fanavaran expertise payload is not ready.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add fields to existing metadata", () => {
|
||||||
|
expect(
|
||||||
|
localizeTimelineMetadata({
|
||||||
|
type: "STEP_COMPLETED",
|
||||||
|
metadata: {
|
||||||
|
role: "FIRST",
|
||||||
|
stepKey: "USER_SUBMISSION_COMPLETE",
|
||||||
|
carType: "sedan",
|
||||||
|
captureType: "angle",
|
||||||
|
captureKey: "front",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
role: "FIRST",
|
||||||
|
stepKey: "USER_SUBMISSION_COMPLETE",
|
||||||
|
carType: "sedan",
|
||||||
|
captureType: "angle",
|
||||||
|
captureKey: "front",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("localizes the auto-resolved first-party confession reason", () => {
|
||||||
|
expect(
|
||||||
|
localizeTimelineMetadata({
|
||||||
|
type: "AUTO_CONFESSION_SKIPPED",
|
||||||
|
metadata: {
|
||||||
|
reason:
|
||||||
|
"IN_PERSON expert-initiated: first party is always guilty; confession auto-resolved",
|
||||||
|
stepKey: "FIRST_BLAME_CONFESSION",
|
||||||
|
advancedTo: "FIRST_VIDEO",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
reason:
|
||||||
|
"در پرونده حضوری ایجادشده توسط کارشناس، طرف اول همیشه مقصر است؛ بنابراین مرحله اقرار بهصورت خودکار تعیین تکلیف شد.",
|
||||||
|
stepKey: "FIRST_BLAME_CONFESSION",
|
||||||
|
advancedTo: "FIRST_VIDEO",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("localizes the call-center auto-confession reason", () => {
|
||||||
|
expect(
|
||||||
|
localizeTimelineMetadata({
|
||||||
|
type: "AUTO_CONFESSION_SKIPPED",
|
||||||
|
metadata: {
|
||||||
|
reason:
|
||||||
|
"V6 call-center initiated: first party is always guilty; confession auto-resolved",
|
||||||
|
advancedTo: "FIRST_VIDEO",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
reason:
|
||||||
|
"در پرونده ایجادشده توسط مرکز تماس، طرف اول همیشه مقصر است؛ بنابراین مرحله اقرار بهصورت خودکار تعیین تکلیف شد.",
|
||||||
|
advancedTo: "FIRST_VIDEO",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves user-entered reasons that are not system messages", () => {
|
||||||
|
expect(
|
||||||
|
localizeTimelineMetadata({
|
||||||
|
type: "V5_FILE_MAKER_REJECTED",
|
||||||
|
metadata: { reason: "تصویر کارت خودرو خوانا نیست" },
|
||||||
|
}),
|
||||||
|
).toEqual({ reason: "تصویر کارت خودرو خوانا نیست" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
* Usage:
|
* Usage:
|
||||||
* const label = EVENT_TYPE_FA_LABELS[event.type]
|
* const label = EVENT_TYPE_FA_LABELS[event.type]
|
||||||
* ?? resolveStepCompletedFaLabel(event)
|
* ?? resolveStepCompletedFaLabel(event)
|
||||||
* ?? event.type;
|
* ?? "رویداد پرونده";
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -40,6 +40,16 @@ export const EVENT_TYPE_FA_LABELS: Record<string, string> = {
|
|||||||
// First video
|
// First video
|
||||||
FIRST_VIDEO_UPLOADED: "ویدیوی اول بارگذاری شد",
|
FIRST_VIDEO_UPLOADED: "ویدیوی اول بارگذاری شد",
|
||||||
|
|
||||||
|
// Legacy first/second-party self-service flow
|
||||||
|
FIRST_INITIAL_FORM_SUBMITTED: "فرم اولیه طرف اول ثبت شد",
|
||||||
|
SECOND_INITIAL_FORM_SUBMITTED: "فرم اولیه طرف دوم ثبت شد",
|
||||||
|
FIRST_LOCATION_SUBMITTED: "موقعیت مکانی طرف اول ثبت شد",
|
||||||
|
SECOND_LOCATION_SUBMITTED: "موقعیت مکانی طرف دوم ثبت شد",
|
||||||
|
FIRST_VOICE_UPLOADED: "صدای طرف اول بارگذاری شد",
|
||||||
|
SECOND_VOICE_UPLOADED: "صدای طرف دوم بارگذاری شد",
|
||||||
|
FIRST_DESCRIPTION_SUBMITTED: "توضیحات طرف اول ثبت شد",
|
||||||
|
SECOND_DESCRIPTION_SUBMITTED: "توضیحات طرف دوم ثبت شد",
|
||||||
|
|
||||||
// Second party invitation
|
// Second party invitation
|
||||||
SECOND_PARTY_INVITED: "طرف دوم دعوت شد",
|
SECOND_PARTY_INVITED: "طرف دوم دعوت شد",
|
||||||
|
|
||||||
@@ -101,6 +111,12 @@ export const EVENT_TYPE_FA_LABELS: Record<string, string> = {
|
|||||||
EXPERT_RESEND_FULFILLED: "مدارک درخواستی ارسال شد",
|
EXPERT_RESEND_FULFILLED: "مدارک درخواستی ارسال شد",
|
||||||
IN_PERSON_VISIT_REQUESTED: "کارشناس بازدید حضوری درخواست کرد",
|
IN_PERSON_VISIT_REQUESTED: "کارشناس بازدید حضوری درخواست کرد",
|
||||||
EXPERT_DAMAGED_PARTS_UPDATED: "کارشناس قطعات آسیبدیده را بهروزرسانی کرد",
|
EXPERT_DAMAGED_PARTS_UPDATED: "کارشناس قطعات آسیبدیده را بهروزرسانی کرد",
|
||||||
|
EXPERT_REPLY_SUBMITTED: "پاسخ کارشناس ثبت شد",
|
||||||
|
EXPERT_FINAL_REPLY_SUBMITTED: "پاسخ نهایی کارشناس ثبت شد",
|
||||||
|
FACTORS_REJECTED_REPRICED_AUTO_COMPLETED:
|
||||||
|
"بررسی فاکتورها با قیمتگذاری مجدد تکمیل شد",
|
||||||
|
FACTORS_VALIDATED_ALL_APPROVED_AUTO_COMPLETED:
|
||||||
|
"همه فاکتورها تأیید و پرونده تکمیل شد",
|
||||||
|
|
||||||
// Workflow steps (generic)
|
// Workflow steps (generic)
|
||||||
STEP_COMPLETED: "مرحله تکمیل شد",
|
STEP_COMPLETED: "مرحله تکمیل شد",
|
||||||
@@ -120,7 +136,8 @@ export const EVENT_TYPE_FA_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
// Owner insurer approval
|
// Owner insurer approval
|
||||||
OWNER_SIGNED_INSURER_APPROVAL: "صاحب خودرو قرارداد بیمه را امضا کرد",
|
OWNER_SIGNED_INSURER_APPROVAL: "صاحب خودرو قرارداد بیمه را امضا کرد",
|
||||||
OWNER_REJECTED_INSURER_APPROVAL_PRICING: "صاحب خودرو قیمتگذاری بیمه را رد کرد",
|
OWNER_REJECTED_INSURER_APPROVAL_PRICING:
|
||||||
|
"صاحب خودرو قیمتگذاری بیمه را رد کرد",
|
||||||
OWNER_SIGNED_PRICED_PARTS_PENDING_FACTOR_UPLOAD:
|
OWNER_SIGNED_PRICED_PARTS_PENDING_FACTOR_UPLOAD:
|
||||||
"صاحب خودرو قطعات قیمتگذاریشده را امضا کرد و در انتظار بارگذاری فاکتور",
|
"صاحب خودرو قطعات قیمتگذاریشده را امضا کرد و در انتظار بارگذاری فاکتور",
|
||||||
OWNER_REJECTED_PRICED_PARTS_BEFORE_FACTORS:
|
OWNER_REJECTED_PRICED_PARTS_BEFORE_FACTORS:
|
||||||
@@ -129,8 +146,10 @@ export const EVENT_TYPE_FA_LABELS: Record<string, string> = {
|
|||||||
// Fanavaran sync
|
// Fanavaran sync
|
||||||
FANAVARAN_AUTO_SUBMIT_SUCCEEDED: "ارسال خودکار به فناوران موفق بود",
|
FANAVARAN_AUTO_SUBMIT_SUCCEEDED: "ارسال خودکار به فناوران موفق بود",
|
||||||
FANAVARAN_AUTO_SUBMIT_FAILED: "ارسال خودکار به فناوران ناموفق بود",
|
FANAVARAN_AUTO_SUBMIT_FAILED: "ارسال خودکار به فناوران ناموفق بود",
|
||||||
FANAVARAN_EARLY_AUTO_SUBMIT_SUCCEEDED: "ارسال زودهنگام خودکار به فناوران موفق بود",
|
FANAVARAN_EARLY_AUTO_SUBMIT_SUCCEEDED:
|
||||||
FANAVARAN_EARLY_AUTO_SUBMIT_FAILED: "ارسال زودهنگام خودکار به فناوران ناموفق بود",
|
"ارسال زودهنگام خودکار به فناوران موفق بود",
|
||||||
|
FANAVARAN_EARLY_AUTO_SUBMIT_FAILED:
|
||||||
|
"ارسال زودهنگام خودکار به فناوران ناموفق بود",
|
||||||
FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED:
|
FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED:
|
||||||
"ارسال خودکار کارشناسی به فناوران موفق بود",
|
"ارسال خودکار کارشناسی به فناوران موفق بود",
|
||||||
FANAVARAN_EXPERTISE_AUTO_SUBMIT_FAILED:
|
FANAVARAN_EXPERTISE_AUTO_SUBMIT_FAILED:
|
||||||
@@ -168,6 +187,38 @@ export const STEP_KEY_FA_LABELS: Record<string, string> = {
|
|||||||
CLAIM_COMPLETED: "پرونده خسارت تکمیل شد",
|
CLAIM_COMPLETED: "پرونده خسارت تکمیل شد",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const GENERIC_EVENT_FA_LABEL = "رویداد پرونده";
|
||||||
|
const GENERIC_EVENT_FA_DESCRIPTION = "رویدادی در روند پرونده ثبت شد.";
|
||||||
|
|
||||||
|
const SYSTEM_REASON_FA_TRANSLATIONS: Record<string, string> = {
|
||||||
|
"IN_PERSON expert-initiated: first party is always guilty; confession auto-resolved":
|
||||||
|
"در پرونده حضوری ایجادشده توسط کارشناس، طرف اول همیشه مقصر است؛ بنابراین مرحله اقرار بهصورت خودکار تعیین تکلیف شد.",
|
||||||
|
"V6 call-center initiated: first party is always guilty; confession auto-resolved":
|
||||||
|
"در پرونده ایجادشده توسط مرکز تماس، طرف اول همیشه مقصر است؛ بنابراین مرحله اقرار بهصورت خودکار تعیین تکلیف شد.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persian descriptions for workflow-step events. These are deliberately
|
||||||
|
* complete sentences because the insurer timeline exposes them as the event
|
||||||
|
* detail, while `STEP_KEY_FA_LABELS` is intended for short UI labels.
|
||||||
|
*/
|
||||||
|
const STEP_KEY_FA_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
CLAIM_CREATED: "پرونده خسارت ایجاد شد.",
|
||||||
|
SELECT_OUTER_PARTS: "قطعات بیرونی آسیبدیده انتخاب و ثبت شدند.",
|
||||||
|
SELECT_OTHER_PARTS: "سایر قطعات آسیبدیده و اطلاعات بانکی ثبت شدند.",
|
||||||
|
CAPTURE_PART_DAMAGES: "تصاویر آسیب قطعات و تصاویر مورد نیاز خودرو ثبت شدند.",
|
||||||
|
UPLOAD_REQUIRED_DOCUMENTS: "مدارک مورد نیاز برای بررسی خسارت بارگذاری شدند.",
|
||||||
|
USER_SUBMISSION_COMPLETE:
|
||||||
|
"ثبت اطلاعات کاربر تکمیل شد و پرونده آماده بررسی کارشناس خسارت است.",
|
||||||
|
USER_EXPERT_RESEND: "مدارک درخواستی توسط کاربر دوباره ارسال شدند.",
|
||||||
|
EXPERT_DAMAGE_ASSESSMENT: "ارزیابی خسارت توسط کارشناس انجام شد.",
|
||||||
|
EXPERT_FINAL_REPLY: "پاسخ نهایی کارشناس ثبت شد.",
|
||||||
|
EXPERT_COST_EVALUATION: "هزینه خسارت توسط کارشناس ارزیابی شد.",
|
||||||
|
OWNER_UPLOAD_FACTOR_DOCUMENTS: "فاکتورهای تعمیر توسط مالک بارگذاری شدند.",
|
||||||
|
INSURER_REVIEW: "پرونده توسط بیمهگر بررسی شد.",
|
||||||
|
CLAIM_COMPLETED: "رسیدگی به پرونده خسارت تکمیل شد.",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For `STEP_COMPLETED` and `V3_STEP_COMPLETED` events whose final label
|
* For `STEP_COMPLETED` and `V3_STEP_COMPLETED` events whose final label
|
||||||
* depends on the `metadata.stepKey` value, this function returns the
|
* depends on the `metadata.stepKey` value, this function returns the
|
||||||
@@ -177,10 +228,7 @@ export function resolveStepCompletedFaLabel(event: {
|
|||||||
type: string;
|
type: string;
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
if (
|
if (event.type !== "STEP_COMPLETED" && event.type !== "V3_STEP_COMPLETED") {
|
||||||
event.type !== "STEP_COMPLETED" &&
|
|
||||||
event.type !== "V3_STEP_COMPLETED"
|
|
||||||
) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const stepKey: string | undefined = event.metadata?.stepKey;
|
const stepKey: string | undefined = event.metadata?.stepKey;
|
||||||
@@ -200,6 +248,50 @@ export function getEventFaLabel(event: {
|
|||||||
return (
|
return (
|
||||||
resolveStepCompletedFaLabel(event) ??
|
resolveStepCompletedFaLabel(event) ??
|
||||||
EVENT_TYPE_FA_LABELS[event.type] ??
|
EVENT_TYPE_FA_LABELS[event.type] ??
|
||||||
event.type
|
GENERIC_EVENT_FA_LABEL
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Persian sentence suitable for the timeline event `description`.
|
||||||
|
*
|
||||||
|
* History is persisted over time, so its older metadata can contain English
|
||||||
|
* system messages. The insurer endpoint uses this derived value instead of
|
||||||
|
* exposing persisted text, which localizes both old and new timeline records.
|
||||||
|
*/
|
||||||
|
export function getEventFaDescription(event: {
|
||||||
|
type: string;
|
||||||
|
metadata?: any;
|
||||||
|
}): string {
|
||||||
|
const stepKey: string | undefined = event.metadata?.stepKey;
|
||||||
|
if (stepKey && STEP_KEY_FA_DESCRIPTIONS[stepKey]) {
|
||||||
|
return STEP_KEY_FA_DESCRIPTIONS[stepKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
const label =
|
||||||
|
resolveStepCompletedFaLabel(event) ?? EVENT_TYPE_FA_LABELS[event.type];
|
||||||
|
return label ? `${label}.` : GENERIC_EVENT_FA_DESCRIPTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the established metadata contract intact. Only a pre-existing
|
||||||
|
* description field is localized; no keys are added, removed, or renamed.
|
||||||
|
*/
|
||||||
|
export function localizeTimelineMetadata(event: {
|
||||||
|
type: string;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}): Record<string, unknown> | null {
|
||||||
|
if (!event.metadata) return null;
|
||||||
|
|
||||||
|
const reason = event.metadata.reason;
|
||||||
|
const localizedReason =
|
||||||
|
typeof reason === "string" ? SYSTEM_REASON_FA_TRANSLATIONS[reason] : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...event.metadata,
|
||||||
|
...(Object.prototype.hasOwnProperty.call(event.metadata, "description")
|
||||||
|
? { description: getEventFaDescription(event) }
|
||||||
|
: {}),
|
||||||
|
...(localizedReason ? { reason: localizedReason } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -249,6 +249,10 @@ export class FanavaranAuthService {
|
|||||||
options?: {
|
options?: {
|
||||||
auditSession?: FanavaranAuditSession;
|
auditSession?: FanavaranAuditSession;
|
||||||
forceRefresh?: boolean;
|
forceRefresh?: boolean;
|
||||||
|
/** Per-request Location override (does not affect token fingerprint). */
|
||||||
|
locationOverride?: string;
|
||||||
|
/** Per-request ContractId override (بدنه vs ثالث). Does not affect token fingerprint. */
|
||||||
|
contractIdOverride?: string;
|
||||||
},
|
},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
authenticationToken: string;
|
authenticationToken: string;
|
||||||
@@ -261,11 +265,13 @@ export class FanavaranAuthService {
|
|||||||
clientKey,
|
clientKey,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
const locationOverride = options?.locationOverride?.trim();
|
||||||
|
const contractIdOverride = options?.contractIdOverride?.trim();
|
||||||
return {
|
return {
|
||||||
authenticationToken,
|
authenticationToken,
|
||||||
CorpId: profile.auth.corpId,
|
CorpId: profile.auth.corpId,
|
||||||
ContractId: profile.auth.contractId,
|
ContractId: contractIdOverride || profile.auth.contractId,
|
||||||
Location: profile.auth.location,
|
Location: locationOverride || profile.auth.location,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ export class FanavaranClientConfigService implements OnModuleInit {
|
|||||||
password: doc.auth.password,
|
password: doc.auth.password,
|
||||||
corpId: doc.auth.corpId,
|
corpId: doc.auth.corpId,
|
||||||
contractId: doc.auth.contractId,
|
contractId: doc.auth.contractId,
|
||||||
|
...(doc.auth.hullContractId
|
||||||
|
? { hullContractId: doc.auth.hullContractId }
|
||||||
|
: {}),
|
||||||
location: doc.auth.location,
|
location: doc.auth.location,
|
||||||
},
|
},
|
||||||
defaults: { ...doc.defaults },
|
defaults: { ...doc.defaults },
|
||||||
|
|||||||
57
src/fanavaran/fanavaran-file-role-expert-ids.spec.ts
Normal file
57
src/fanavaran/fanavaran-file-role-expert-ids.spec.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
src/fanavaran/fanavaran-file-role-expert-ids.ts
Normal file
90
src/fanavaran/fanavaran-file-role-expert-ids.ts
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/fanavaran/fanavaran-location.service.spec.ts
Normal file
55
src/fanavaran/fanavaran-location.service.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { ForbiddenException } from "@nestjs/common";
|
||||||
|
import { FanavaranLocationService } from "./fanavaran-location.service";
|
||||||
|
|
||||||
|
describe("FanavaranLocationService branch scope", () => {
|
||||||
|
const makerId = "maker-1";
|
||||||
|
const reviewerId = "reviewer-1";
|
||||||
|
|
||||||
|
const createService = (makerBranch?: string, reviewerBranch?: string) =>
|
||||||
|
new FanavaranLocationService(
|
||||||
|
{
|
||||||
|
findById: jest.fn().mockResolvedValue(
|
||||||
|
makerBranch ? { branchId: makerBranch } : {},
|
||||||
|
),
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
findById: jest.fn().mockResolvedValue(
|
||||||
|
reviewerBranch ? { branchId: reviewerBranch } : {},
|
||||||
|
),
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
it("allows a reviewer to view a file from the same branch", async () => {
|
||||||
|
const service = createService("branch-1", "branch-1");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: makerId,
|
||||||
|
fileReviewerId: reviewerId,
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a reviewer from another branch", async () => {
|
||||||
|
const service = createService("branch-1", "branch-2");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: makerId,
|
||||||
|
fileReviewerId: reviewerId,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the immutable case branch snapshot", async () => {
|
||||||
|
const service = createService("old-branch", "branch-2");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: makerId,
|
||||||
|
fileReviewerId: reviewerId,
|
||||||
|
caseBranchId: "branch-2",
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
187
src/fanavaran/fanavaran-location.service.ts
Normal file
187
src/fanavaran/fanavaran-location.service.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { ForbiddenException, Injectable, Logger } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
FanavaranClientKey,
|
||||||
|
getFanavaranClientProfile,
|
||||||
|
resolveFanavaranClientKey,
|
||||||
|
} from "src/core/config/fanavaran-client.config";
|
||||||
|
import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service";
|
||||||
|
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
|
||||||
|
|
||||||
|
type LocationEntry = { id?: string; name?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsian V4/V5 FileMaker flows: resolve Fanavaran HTTP `Location` from
|
||||||
|
* file-maker / file-reviewer `locations[].id`, and enforce maker/reviewer match.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class FanavaranLocationService {
|
||||||
|
private readonly logger = new Logger(FanavaranLocationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly fileMakerDbService: FileMakerDbService,
|
||||||
|
private readonly fileReviewerDbService: FileReviewerDbService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
isParsianClient(clientKey?: FanavaranClientKey): boolean {
|
||||||
|
return (clientKey ?? resolveFanavaranClientKey()) === "parsian";
|
||||||
|
}
|
||||||
|
|
||||||
|
extractLocationIds(locations?: LocationEntry[] | null): string[] {
|
||||||
|
if (!Array.isArray(locations) || locations.length === 0) return [];
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const entry of locations) {
|
||||||
|
const id = String(entry?.id ?? "").trim();
|
||||||
|
if (id && !ids.includes(id)) ids.push(id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryLocationId(locations?: LocationEntry[] | null): string | undefined {
|
||||||
|
return this.extractLocationIds(locations)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maker primary location → reviewer primary → tenant auth.location.
|
||||||
|
* Non-parsian or non-FileMaker flows always use the tenant default.
|
||||||
|
*/
|
||||||
|
async resolveBusinessLocation(input: {
|
||||||
|
clientKey: FanavaranClientKey;
|
||||||
|
isMadeByFileMaker?: boolean;
|
||||||
|
fileMakerId?: string | null;
|
||||||
|
fileReviewerId?: string | null;
|
||||||
|
}): Promise<string> {
|
||||||
|
const defaultLocation = getFanavaranClientProfile(input.clientKey).auth
|
||||||
|
.location;
|
||||||
|
|
||||||
|
if (input.clientKey !== "parsian" || input.isMadeByFileMaker !== true) {
|
||||||
|
return defaultLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makerLocation = await this.loadPrimaryLocationId(input.fileMakerId, "maker");
|
||||||
|
if (makerLocation) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Fanavaran Location from file-maker id=${input.fileMakerId} → ${makerLocation}`,
|
||||||
|
);
|
||||||
|
return makerLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewerLocation = await this.loadPrimaryLocationId(
|
||||||
|
input.fileReviewerId,
|
||||||
|
"reviewer",
|
||||||
|
);
|
||||||
|
if (reviewerLocation) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Fanavaran Location from file-reviewer id=${input.fileReviewerId} → ${reviewerLocation}`,
|
||||||
|
);
|
||||||
|
return reviewerLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Fanavaran Location fallback to tenant default → ${defaultLocation}`,
|
||||||
|
);
|
||||||
|
return defaultLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When both maker and reviewer have locations, they must share at least one id.
|
||||||
|
* No-op for non-parsian deployments or when either side has no locations.
|
||||||
|
*/
|
||||||
|
async assertMakerReviewerLocationCompatible(input: {
|
||||||
|
fileMakerId?: string | null;
|
||||||
|
fileReviewerId?: string | null;
|
||||||
|
clientKey?: FanavaranClientKey;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!this.isParsianClient(input.clientKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makerIds = await this.loadLocationIds(input.fileMakerId, "maker");
|
||||||
|
const reviewerIds = await this.loadLocationIds(
|
||||||
|
input.fileReviewerId,
|
||||||
|
"reviewer",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (makerIds.length === 0 || reviewerIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shared = makerIds.some((id) => reviewerIds.includes(id));
|
||||||
|
if (!shared) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"FileReviewer location must match the FileMaker location for this case.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const ids = await this.loadLocationIds(userId, kind);
|
||||||
|
return ids[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadLocationIds(
|
||||||
|
userId: string | null | undefined,
|
||||||
|
kind: "maker" | "reviewer",
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (!userId) return [];
|
||||||
|
try {
|
||||||
|
const doc =
|
||||||
|
kind === "maker"
|
||||||
|
? await this.fileMakerDbService.findById(String(userId))
|
||||||
|
: await this.fileReviewerDbService.findById(String(userId));
|
||||||
|
return this.extractLocationIds((doc as any)?.locations);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,31 @@ export const FANAVARAN_REMOTE_LOOKUPS: FanavaranRemoteLookupDefinition[] = [
|
|||||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/accident-causes`,
|
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/accident-causes`,
|
||||||
cacheFile: "accident-causes.json",
|
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",
|
name: "accident-report-type",
|
||||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/accident-report-type`,
|
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/accident-report-type`,
|
||||||
@@ -117,6 +142,36 @@ export const FANAVARAN_REMOTE_LOOKUPS: FanavaranRemoteLookupDefinition[] = [
|
|||||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/dmg-business-line`,
|
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/dmg-business-line`,
|
||||||
cacheFile: "dmg-business-line.json",
|
cacheFile: "dmg-business-line.json",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "gender",
|
||||||
|
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/gender`,
|
||||||
|
cacheFile: "gender.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "marital-status",
|
||||||
|
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/marital-status`,
|
||||||
|
cacheFile: "marital-status.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ans",
|
||||||
|
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/ans`,
|
||||||
|
cacheFile: "ans.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "countries",
|
||||||
|
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/base-info/countries`,
|
||||||
|
cacheFile: "countries.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "person-kind",
|
||||||
|
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/person-kind`,
|
||||||
|
cacheFile: "person-kind.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cii-validation-status",
|
||||||
|
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/cii-validation-status`,
|
||||||
|
cacheFile: "cii-validation-status.json",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const TEJARAT_STATIC_ACCIDENT_FILES = {
|
export const TEJARAT_STATIC_ACCIDENT_FILES = {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user