Compare commits

...

78 Commits

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 16:26:58 +03:30
acb5d2a682 car body implemented 2026-09-16 16:05:54 +03:30
a174332d81 Merge pull request 'Rollback uploadDocument resume, added carPrice' (#324) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#324
2026-09-16 15:41:17 +03:30
SepehrYahyaee
c9e273fc4e Rollback uploadDocument step resume 2026-09-16 15:37:27 +03:30
SepehrYahyaee
ecf69cc332 Change Car_BODY fanavaran default client code and id to parsian 2026-09-16 14:16:44 +03:30
31f8cabf80 Merge pull request 'Fixed uploadDocuments status bug, Fixed image deletion bug' (#323) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#323
2026-09-16 12:52:29 +03:30
SepehrYahyaee
fee0bcb5be Fixed images being removed, fixed v4/v5 wrong status on uploadDocument 2026-09-16 12:50:12 +03:30
SepehrYahyaee
dc30518a7f Completed DOCS for participants 2026-09-16 10:24:22 +03:30
8173764913 Merge pull request 'main' (#322) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#322
2026-09-15 17:48:40 +03:30
c72cb265f1 merge upstream 2026-09-15 17:48:20 +03:30
271dc4df2a badane bugs continues 2026-09-15 17:47:38 +03:30
b2ff3f574b Merge pull request 'main' (#321) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#321
2026-09-15 17:07:37 +03:30
769a581a51 merge upstream 2026-09-15 17:07:08 +03:30
466773fb2b badane update is implemented now 2026-09-15 17:05:54 +03:30
37041ba19f Merge pull request 'third party update , optional body is done' (#320) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#320
2026-09-15 16:36:52 +03:30
0b0a1dfa13 third party update , optional body is done 2026-09-15 16:36:13 +03:30
073b4dec38 Merge pull request 'Fixed THIRD_PARTY VIN inquiry request' (#319) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#319
2026-09-15 16:00:44 +03:30
SepehrYahyaee
cb4f02918f Fixed THIRD_PARTY VIN inquiry request 2026-09-15 15:50:29 +03:30
c1f0d041ce Merge pull request 'fanavaran stages matching data is double checked and more controlled +' (#318) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#318
2026-09-15 15:39:38 +03:30
f9c60a2854 fanavaran stages matching data is double checked and more controlled + 2026-09-15 15:36:28 +03:30
4310ce6398 Merge pull request 'Changed CAP to RIAL' (#317) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#317
2026-09-15 11:56:24 +03:30
SepehrYahyaee
96b56233bf Changed CAP to RIAL 2026-09-15 11:54:14 +03:30
d6b5f8f4d4 Merge pull request 'Changed Price units to Rial' (#316) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#316
2026-09-14 16:49:02 +03:30
SepehrYahyaee
152250a387 Changed Price units to Rial 2026-09-14 16:48:34 +03:30
ada85f9e0c Merge pull request 'Fixed mock data' (#315) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#315
2026-09-14 16:07:02 +03:30
SepehrYahyaee
e7103a9408 Fixed mock data 2026-09-14 16:06:39 +03:30
af8bec193b Merge pull request 'Fixed Participants' (#314) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#314
2026-09-14 14:27:49 +03:30
SepehrYahyaee
9930e9ff5b Fixed Participants 2026-09-14 14:27:23 +03:30
b3a298e254 Merge pull request 'update the fanavaran which accepts unknown person too' (#313) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#313
2026-09-14 13:43:16 +03:30
b13b0211ad update the fanavaran which accepts unknown person too 2026-09-14 13:42:53 +03:30
6e5b7cb1fd Merge pull request 'Fixed type fixes and errors' (#312) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#312
2026-09-14 12:52:02 +03:30
SepehrYahyaee
51166a8d0d Fixed type fixes and errors 2026-09-14 12:49:46 +03:30
4f301d1d03 Merge pull request 'Fix V4 FileMaker workflow re-entry' (#311) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#311
2026-09-14 11:43:41 +03:30
SepehrYahyaee
1b69da38ff Fix V4 FileMaker workflow re-entry 2026-09-14 11:42:48 +03:30
1941013b00 Merge pull request 'Route inquiries by participant role' (#310) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#310
2026-09-14 10:51:40 +03:30
SepehrYahyaee
98c7ebb83a Route inquiries by participant role 2026-09-14 10:43:57 +03:30
461afbb6b9 Merge pull request 'Implement role-complete inquiry participants + Bug fixes' (#309) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#309
2026-09-13 16:23:42 +03:30
SepehrYahyaee
6a26811555 Calculate insurer report review duration 2026-09-13 16:15:12 +03:30
SepehrYahyaee
f9d4b5df9a Localize timeline system reasons 2026-09-13 16:04:51 +03:30
SepehrYahyaee
0eaf55e73c Document structured inquiry payload for frontend 2026-09-13 14:35:45 +03:30
SepehrYahyaee
5b77241f28 Require structured inquiry participant inputs 2026-09-13 14:13:05 +03:30
SepehrYahyaee
cc8a96c875 Persist inquiry audit on client resolution failures 2026-09-13 12:59:39 +03:30
SepehrYahyaee
5812637a1e Mark rejected inquiry results as failed 2026-09-13 12:55:55 +03:30
SepehrYahyaee
287978a555 Retain inquiry audit context on failure 2026-09-13 11:42:20 +03:30
SepehrYahyaee
fe22e69351 Persist failed inquiry audit trails 2026-09-13 11:36:03 +03:30
SepehrYahyaee
b5b18114d8 Complete inquiry participant flow coverage 2026-09-13 11:29:43 +03:30
SepehrYahyaee
d060b6d9e2 Harden inquiry participant edge cases 2026-09-13 11:18:37 +03:30
SepehrYahyaee
c64f23091a Implement role-complete inquiry participants 2026-09-13 10:59:00 +03:30
SepehrYahyaee
401ad6a143 Added Docs 2026-09-12 17:02:12 +03:30
117 changed files with 13551 additions and 2149 deletions

1
.gitignore vendored
View File

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

BIN
docs/Yara724 flows.pdf Normal file

Binary file not shown.

103
docs/architecture.md Normal file
View 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

View File

@@ -461,6 +461,16 @@
</div>
</div>
<div class="card">
<div class="card-title">Optional document shared by every claim flow</div>
<p class="step-note">
Every V1–V6 document-upload step also offers
<code>accident_sketch</code> (کروکی). It is optional for both
<code>THIRD_PARTY</code> and <code>CAR_BODY</code>, may be omitted without
blocking progression, and is returned in expert and insurer case details when uploaded.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<h2>Flow V1 / V2 — User-Initiated Blame &amp; Claim</h2>
<p class="section-intro">

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

View File

@@ -433,6 +433,23 @@ curl -X POST "$ESG_URL/inquiry/policyByPlate" \
}'
```
### Car By Chassis (Two-Factor VIN Inquiry)
Use the two-factor chassis route when the backend must match both the VIN and
the resolved third-party policyholder. Do not send `nationalCode` to the
one-factor `policyByChassis` route.
```sh
curl -X POST "$ESG_URL/inquiry/carByChassis" \
-H "Authorization: Bearer $ESG_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data '{
"nationalCode": "0012345678",
"chassisNo": "NAAR03HFFRDE07024"
}'
```
### Person Inquiry
ESG expects Jalali birth date, normalized as `YYYY-MM-DD`.

View File

@@ -159,8 +159,9 @@
<div class="decision-tree">
<strong>برای هر پرس‌وجوی مبتنی بر پلاک:</strong>
<ul>
<li>درخواست همیشه با پلاک فعلی ارسال‌شده و بیمه‌گذار نهایی همان نوع بیمه انجام می‌شود. متادیتای انتقال اخیر هیچ استعلامی برای پلاک یا بیمه‌گذار قبلی ایجاد نمی‌کند.</li>
<li>۱. بررسی داده‌های آفلاین (MongoDB) — اگر داده مطابق یافت شد، آن را برگردانده و تمام HTTP را رد کن.</li>
<li>۲. اگر <code>CLIENT_ID=8</code> (تنانت پارسیان/ESG) → مسیریابی به <strong>ESG</strong> <code>/inquiry/policyByPlate</code> یا <code>/inquiry/policyByChassis</code>.</li>
<li>۲. اگر <code>CLIENT_ID=8</code> (تنانت پارسیان/ESG) → برای پلاک به <strong>ESG</strong> <code>/inquiry/policyByPlate</code> و برای VIN/شاسی به مسیر دوعاملی <code>/inquiry/carByChassis</code> مسیریابی می‌شود.</li>
<li>۳. در غیر این صورت → مسیریابی به <strong>پرس‌وجوی تجارت</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) یا <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>
<li>۴. اگر <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (پیش‌فرض) → پاسخ mock برگردانده شود به جای انجام فراخوانی‌های HTTP.</li>
</ul>
@@ -207,7 +208,7 @@
<div class="card card-blue">
<h3>پروتکل ارسال خسارت (۴ مرحله)</h3>
<div class="flow-box">
<div class="flow-step"><div class="flow-num">۱</div><div class="flow-text"><strong>خسارت پایه (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. داده‌های مالک، راننده، بیمه، وسیله نقلیه و تصادف را ارسال می‌کند. یک <code>claimId</code> و <code>claimNo</code> فناوران برمی‌گرداند. پیامک با هر دو شناسه برای مالک ارسال می‌شود.</div></div>
<div class="flow-step"><div class="flow-num">۱</div><div class="flow-text"><strong>خسارت پایه (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. داده‌های مالک، راننده، بیمه، وسیله نقلیه و تصادف را ارسال می‌کند. یک <code>claimId</code> و <code>claimNo</code> فناوران برمی‌گرداند که برای نمایش در پنل ذخیره می‌شوند.</div></div>
<div class="flow-step"><div class="flow-num">۲</div><div class="flow-text"><strong>موارد خسارت (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. یک ورودی به ازای هر قطعه آسیب‌دیده با شناسه کامپوننت، شدت و قیمت. سقف: کل ≤ ۵۳،۰۰۰،۰۰۰ تومان.</div></div>
<div class="flow-step"><div class="flow-num">۳</div><div class="flow-text"><strong>پیوست‌ها (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. اسناد، تصاویر car-capture و ویدیوها که با شناسه فایل ارجاع داده شده‌اند.</div></div>
<div class="flow-step"><div class="flow-num">۴</div><div class="flow-text"><strong>کارشناسی (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. متادیتای ارزیابی کارشناس (نقش کارشناس، تاریخ، نتیجه). ارسال را نهایی می‌کند.</div></div>
@@ -358,12 +359,12 @@
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>جستجوی بیمه‌نامه مبتنی بر پلاک (THIRD_PARTY). بدنه: <code>nationalCode</code>، <code>plk1–plk4</code>. پاسخ قبل از ذخیره به فرمت قدیمی تجارت نگاشت می‌شود.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByChassis</code></td><td>جایگزین مبتنی بر VIN/شاسی برای پرس‌وجوی پلاک. توسط اندپوینت‌های <code>run-inquiries-vin</code> فراخوانی می‌شود. از جستجوی شاسی ESG استفاده می‌کند (نه مسیر SandHub). بدنه: <code>nationalCode</code>، <code>chassis</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/carByChassis</code></td><td>جایگزین دوعاملی VIN/شاسی برای استعلام پلاک. توسط اندپوینت‌های <code>run-inquiries-vin</code> فراخوانی می‌شود. بدنه: <code>nationalCode</code>، <code>chassisNo</code>. مسیر تک‌عاملی <code>policyByChassis</code> استفاده نمی‌شود، چون فیلد <code>nationalCode</code> را نمی‌پذیرد.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>بررسی هویت شخصی. بدنه: <code>nationalCode</code>، <code>birthDate</code> (جلالی، نه میلادی).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>اعتبارسنجی شبا / حساب بانکی.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
ESG هر پاسخ را به صورت <code>{ success: boolean, data: … }</code> می‌پیچد. یک بدنه <code>success=false</code> به یک خطای فارسی "استعلام در دسترس نیست" ترجمه می‌شود.
ESG هر پاسخ را به صورت <code>{ success: boolean, data: … }</code> می‌پیچد. در envelope نرمال‌شده خطا، بک‌اند مقدار <code>error.messageFa</code> را بدون تغییر به فراخواننده برمی‌گرداند؛ فیلدهای فنی مانند <code>message</code>، <code>providerMessage</code> و <code>providerCode</code> برای ثبت لاگ و دسته‌بندی حفظ می‌شوند. خطای کسب‌وکاری «یافت نشد» به‌عنوان قطعی سرویس گزارش نمی‌شود.
بررسی داده آفلاین-پرس‌وجو هنوز ابتدا اجرا می‌شود، قبل از هر فراخوانی HTTP ESG.
</p>
</div>
@@ -414,7 +415,7 @@
<tr><td><code>token</code> = "تصادف"/"خسارت"، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس</td><td>کارشناس یک فایل تقصیر یا خسارت را قفل می‌کند</td><td><code>yara-expert-lock</code></td></tr>
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = لینک</td><td>کارشناس درخواست ارسال مجدد اسناد می‌دهد</td><td><code>yara-resend-documents</code></td></tr>
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس، <code>token10</code> = لینک</td><td>طرف مطلع می‌شود که ارزیابی خسارت کارشناس را امضا کند</td><td><code>yara-signature</code></td></tr>
<tr><td><code>token</code> = publicId، <code>token2</code> = claimId فناوران، <code>token3</code> = claimNo فناوران</td><td>ارسال فناوران تأیید شد — با شماره و شناسه خسارت فناوران برای مالک خسارت ارسال می‌شود</td><td><code>yara-fanavaran-claim</code></td></tr>
<tr><td><code>token</code> = publicId، <code>token2</code> = claimId فناوران، <code>token3</code> = claimNo فناوران</td><td>قالب قدیمی نگه‌داری شده است؛ ارسال خودکار پس از آخرین مرحله فناوران غیرفعال است</td><td><code>yara-fanavaran-claim</code></td></tr>
</table>
<p class="note" style="margin-top:8px;">
تمام فراخوانی‌های پیامک fire-and-forget هستند — هرگز throw نمی‌کنند. شکست‌ها log می‌شوند اما جریان اصلی را مسدود نمی‌کنند.

View File

@@ -154,8 +154,9 @@
<div class="decision-tree">
<strong>For every plate-based block inquiry:</strong>
<ul>
<li>The request always uses the submitted current plate and the resolved policyholder for that policy type. Recent-transfer metadata never triggers a previous-plate or previous-policyholder lookup.</li>
<li>1. Check offline-inquiry seeds (MongoDB) — if a matching seed exists, return it and skip all HTTP.</li>
<li>2. If <code>CLIENT_ID=8</code> (Parsian/ESG tenant) → route to <strong>ESG</strong> <code>/inquiry/policyByPlate</code> or <code>/inquiry/policyByChassis</code>.</li>
<li>2. If <code>CLIENT_ID=8</code> (Parsian/ESG tenant) → route to <strong>ESG</strong> <code>/inquiry/policyByPlate</code> for plates or the two-factor <code>/inquiry/carByChassis</code> for VIN/chassis inquiries.</li>
<li>3. Otherwise → route to <strong>Tejarat inquiry</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) or <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>
<li>4. If <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (default) → return mock response instead of making HTTP calls.</li>
</ul>
@@ -203,7 +204,7 @@
<div class="card card-blue">
<h3>Claim submission protocol (4 steps)</h3>
<div class="flow-box">
<div class="flow-step"><div class="flow-num">1</div><div class="flow-text"><strong>Base claim (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. Sends owner, driver, insurance, vehicle, and accident data. Returns a Fanavaran <code>claimId</code> and <code>claimNo</code>. SMS is sent to the owner with both identifiers.</div></div>
<div class="flow-step"><div class="flow-num">1</div><div class="flow-text"><strong>Base claim (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. Sends owner, driver, insurance, vehicle, and accident data. Returns a Fanavaran <code>claimId</code> and <code>claimNo</code>, which are persisted for panel display.</div></div>
<div class="flow-step"><div class="flow-num">2</div><div class="flow-text"><strong>Damage cases (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. One entry per damaged part with component ID, severity, and price. Cap: total ≤ 53 000 000 Toman.</div></div>
<div class="flow-step"><div class="flow-num">3</div><div class="flow-text"><strong>Attachments (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. Documents, car-capture images, and videos referenced by file ID.</div></div>
<div class="flow-step"><div class="flow-num">4</div><div class="flow-text"><strong>Expertise (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. Expert assessment metadata (expert role, date, result). Finalises the submission.</div></div>
@@ -355,12 +356,12 @@
<table>
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>Plate-based policy lookup (THIRD_PARTY). Body: <code>nationalCode</code>, <code>plk1–plk4</code>. Response is mapped to the old Tejarat format before being stored.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByChassis</code></td><td>VIN/chassis-based alternative to plate inquiry. Called by <code>run-inquiries-vin</code> endpoints. Uses ESG chassis lookup (not the SandHub path). Body: <code>nationalCode</code>, <code>chassis</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/carByChassis</code></td><td>Two-factor VIN/chassis alternative to the plate inquiry. Called by <code>run-inquiries-vin</code> endpoints. Body: <code>nationalCode</code>, <code>chassisNo</code>. The one-factor <code>policyByChassis</code> route is not used because it rejects <code>nationalCode</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>Personal identity check. Body: <code>nationalCode</code>, <code>birthDate</code> (Jalali, NOT Gregorian).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
ESG wraps every response as <code>{ success: boolean, data: … }</code>. A <code>success=false</code> body is translated to a Persian "استعلام در دسترس نیست" (inquiry unavailable) error.
ESG wraps every response as <code>{ success: boolean, data: … }</code>. For normalized error envelopes, the backend returns <code>error.messageFa</code> unchanged to the caller; technical fields such as <code>message</code>, <code>providerMessage</code>, and <code>providerCode</code> remain available for logging and classification. A business-level not-found response is not reported as a provider outage.
The offline-inquiry seed check still runs first, before any ESG HTTP call.
</p>
</div>
@@ -411,7 +412,7 @@
<tr><td><code>yara-expert-lock</code></td><td>Expert locks a blame or claim file</td><td><code>token</code> = "تصادف"/"خسارت", <code>token2</code> = publicId, <code>token3</code> = expert surname</td></tr>
<tr><td><code>yara-resend-documents</code></td><td>Expert requests document resend</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = link</td></tr>
<tr><td><code>yara-signature</code></td><td>Party notified to sign the expert's damage assessment</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = expert surname, <code>token10</code> = link</td></tr>
<tr><td><code>yara-fanavaran-claim</code></td><td>Fanavaran submission confirmed — sent to claim owner with Fanavaran claim number and ID</td><td><code>token</code> = publicId, <code>token2</code> = Fanavaran claimId, <code>token3</code> = Fanavaran claimNo</td></tr>
<tr><td><code>yara-fanavaran-claim</code></td><td>Retained legacy template; automatic dispatch after the final Fanavaran stage is disabled</td><td><code>token</code> = publicId, <code>token2</code> = Fanavaran claimId, <code>token3</code> = Fanavaran claimNo</td></tr>
</table>
<p class="note" style="margin-top:8px;">
All SMS calls are fire-and-forget — they never throw. Failures are logged but do not block the main flow.

View File

@@ -308,7 +308,7 @@ Requires `claimId` + `dmgCaseId` (soft-ensures earlier stages).
| `Id` | `claimCases.expertiseId` |
History: `FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED`.
SMS owner via `SmsOrchestrationService` (deduped `fanavaranSync.expertise.smsNotifiedAt`).
No owner SMS is sent after expertise; the returned identifier is persisted only.
Proven Parsian: `expertiseId=403144`, `ClaimExpertId=29`.
Manual: `POST /v2/fanavaran/{client}/claim-cases/{id}/expertise/submit`.

View File

@@ -21,7 +21,7 @@ Start local claim
→ Upload docs/images locally
→ GEN.07 attachments (per file, best-effort)
→ Expert pricing ready
→ GEN.08 expertise → store expertiseId → SMS owner
→ GEN.08 expertise → store expertiseId (no SMS)
→ End (local completion independent of Fanavaran success)
```
@@ -76,7 +76,7 @@ sequenceDiagram
YARA->>FV: POST .../expertise (GEN.08)
FV-->>YARA: ExpertiseId
YARA->>DB: expertiseId, history SUCCESS
YARA->>YARA: SMS owner (deduped)
Note over YARA: Post-expertise owner SMS is disabled
```
## Flow diagram (stages + soft-ensure)

View File

@@ -17,7 +17,7 @@ source: fanavaran-module-docs
| Tenant config | boot + System Settings | `fanavaran-client-config.service.ts`, `system-settings.*` |
| Orchestration | `ClaimRequestManagementModule` | `claim-request-management.service.ts` |
| Policy select | same | `fanavaran-policy-selection.ts` |
| SMS after expertise | `SmsOrchestrationModule` | `sms-orchestration.service.ts` |
| SMS after expertise | Disabled by product decision | No message is dispatched |
---
@@ -49,7 +49,7 @@ source: fanavaran-module-docs
| Send | GEN.03 JSON + business headers |
| Receive | `Id`, `ClaimNo` |
| Store | `claimId`, `claimNo`, `fanavaranSync.baseClaim.*`, history |
| Consumers | Damage/expertise soft-ensure; UI claim detail; SMS |
| Consumers | Damage/expertise soft-ensure; UI claim detail |
### C. Damage case

View File

@@ -69,4 +69,4 @@ Per HTTP step:
## SMS
After successful expertise (last Fanavaran stage), owner SMS may be sent once (`fanavaranSync.expertise.smsNotifiedAt`). Failure to SMS should not roll back Fanavaran expertise submit; treat as separate notification concern. Claims already notified at base claim (`fanavaranSync.baseClaim.smsNotifiedAt`) are not re-notified.
The post-expertise owner SMS is disabled. A successful final Fanavaran response only persists the returned `expertiseId` and synchronization state; it does not dispatch a notification.

View File

@@ -0,0 +1,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) قرار دارد.

View 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` را با خطای اعتبارسنجی رد می‌کند. احراز هویت تلفنی و جریان‌های تماس با طرفین از جمع‌آوری هویت برای استعلام جدا هستند.
## تصمیم پیشنهادی
راه‌حل مناسب، **دریافت شرطی اطلاعات همراه با اتصال صریح نقش‌ها** است. این روش بدون طولانی کردن مسیر اکثر کاربران، اطلاعات کامل فراهم می‌کند، از تناقض فلگ‌ها جلوگیری می‌کند و یک مدل یکسان برای همه جریان‌های استعلام می‌سازد.

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

View File

@@ -0,0 +1,26 @@
# اطلاعات پنل‌ها و PDF
## مواردی که نبود و اضافه شد
- نام شخص در پنل‌های فلو ۴ و ۵
- کد ملی بیمه‌گذار و راننده در پنل‌های فلو ۴ و ۵
- مشخص‌بودن یکسان‌بودن راننده و بیمه‌گذار در فلو ۴ و ۵
- نوع، شماره و تاریخ گواهینامه در پنل‌های فلو ۴ و ۵
- تاریخ تولد بیمه‌گذار و راننده در پنل‌های فلو ۴ و ۵
- پلاک و VIN در پنل‌های فلو ۴ و ۵
- شماره شبا در پنل‌های فلو ۴ و ۵
- کدهای تکمیلی فناوران در پنل‌ها: بیمه‌نامه، راننده، نوع خودرو، نوع/نمونه پلاک، کاربری خودرو و شرکت بیمه
- نمایش نوع واقعی گواهینامه، مثل «پایه یک»، در PDF
## مواردی که از قبل وجود داشت
- نام و کد ملی افراد در پنل‌های کارشناس و بیمه‌گر
- کد ملی راننده، در صورت متفاوت‌بودن با بیمه‌گذار
- شماره شبا در پنل بیمه‌گر و PDF
- اطلاعات راننده در PDF، وقتی راننده با بیمه‌گذار متفاوت است
- پلاک خودرو در API؛ فرانت باید از `vehicle.plateId` بخواند
- کدهای اصلی فناوران: شماره خسارت، شماره پرونده، شناسه پرونده خسارت و شناسه کارشناسی
## موردی که هنوز نداریم
- `expedited` → در بک‌اند فیلد و قرارداد API ندارد.

View File

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

View File

@@ -363,14 +363,14 @@
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>List claims in <code>WAITING_FOR_DAMAGE_EXPERT</code> queue + factor-validation queue. Query: search, sortBy, page, limit, unifiedStatus, fileType.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs, and the effective <code>priceCap</code> (530,000,000 Rial for V1; <code>null</code> for V2–V6 or when disabled). Completed claims include Fanavaran claimNo / claimId when available.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>Lock claim to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Price-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Calculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>Submit damage assessment reply (priced parts list, daghi, branchId). Cap: total ≤ 53 000 000 Toman. A priced-only claim completes immediately; factor claims continue through factor collection/validation. No final owner signature or automatic Fanavaran submission.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>Submit damage assessment reply (priced parts list and daghi). <code>daghi.branchId</code> is used only with the <code>تحویل داغی</code> option. V1 only: total ≤ 530,000,000 Rial; V2–V6 are uncapped. A priced-only claim completes immediately; factor claims continue through factor collection/validation. No final owner signature or automatic Fanavaran submission.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>Request user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>Ask user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>Validate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. Cap applies across all lines (≤ 53 000 000 Toman). Auto-completes when all lines are decided.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>Validate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. The 530,000,000 Rial all-lines cap applies only to V1. Auto-completes when all lines are decided.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/request/:claimRequestId/damaged-parts</code></td><td>Edit selected damaged parts while the claim is locked by this expert (EXPERT_REVIEWING).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/outer-parts-catalog</code></td><td>Fanavaran outer car-components catalog (shared with user flow).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/inner-parts-catalog</code></td><td>Static inner car-parts catalog JSON.</td></tr>
@@ -462,7 +462,7 @@
<tr><td><span class="method post">POST</span></td><td><code>run-inquiries/:id</code> / <code>run-inquiries-vin/:id</code></td><td>Run plate or VIN inquiry. First call = guilty (+ auto-creates claim). Second call = damaged (THIRD_PARTY only).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-detail-location/:id</code> / <code>add-detail-description/:id</code> / <code>upload-voice/:id</code></td><td>Add location, description, and voice for current party (partyRole param selects FIRST/SECOND).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>sign/:id</code></td><td>Upload party signature (partyRole=FIRST then SECOND). After second signature, file is sealed.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload licences / car cards against the auto-created claim.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload licences / car cards and the optional <code>accident_sketch</code> (کروکی) against the auto-created claim. The sketch never gates completion.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (phases: pre-capture docs vs damaged parts + chassis/engine).</td></tr>
</table>
</div>
@@ -498,7 +498,7 @@
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>Get the auto-created claim ID (from FileMaker's guilty-party inquiry).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:requestId</code></td><td>Step 1 (FileReviewer): save accident fields (accidentWay, accidentReason, accidentType).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (pre-capture docs phase vs capture-parts phase).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload chassis / engine / metal-plate documents.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload chassis / engine / metal-plate documents; <code>accident_sketch</code> (کروکی) remains optional and does not affect completion.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>Select outer (body) damaged parts.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>Select other (non-body) damaged parts.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>Capture part photos + angles for each selected damaged part.</td></tr>
@@ -552,7 +552,7 @@
<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 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-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>

View 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 پرونده، داده‌ها را به‌صورت کامل و تفکیک‌شده برمی‌گرداند، از جمله:
- زمان‌بندی پرونده
- اطلاعات مالک زیان‌دیده
- اطلاعات مالک مقصر
- اطلاعات راننده در صورت متفاوت بودن
- بیمه‌نامه‌های تفکیک‌شده‌ی ثالث و بدنه برای هر طرف
- اطلاعات خودرو برای هر دو طرف
- اظهارات و اقرار هر دو طرف
- کدهای فناوران
- نتیجه ارزیابی و توضیحات کارشناس
- گزارش حادثه

6
package-lock.json generated
View File

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

View File

@@ -1,4 +1,7 @@
export enum ClaimRequiredDocumentType {
/** Optional police accident sketch (Persian: کروکی). Never gates claim progress. */
ACCIDENT_SKETCH = "accident_sketch",
// Car green card
CAR_GREEN_CARD = "car_green_card",
CAR_CERTIFICATE = "car_certificate",
@@ -34,4 +37,3 @@ export enum CarAngle {
LEFT = "left",
RIGHT = "right",
}

View File

@@ -70,6 +70,26 @@ describe("buildInsurerFileReport", () => {
},
{
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: {
userId: "damaged-user-id",
fullName: "زیان دیده نمونه",
@@ -165,61 +185,89 @@ describe("buildInsurerFileReport", () => {
},
});
expect(getFieldValue(report, PR.ownerSection, PR.name)).toBe("زیان دیده نمونه");
expect(getFieldValue(report, PR.guiltyOwnerSection, PR.name)).toBe("مقصر نمونه");
expect(getFieldValue(report, PR.guiltyOwnerSection, PR.phone)).toBe("09120000000");
expect(getFieldValue(report, PR.ownerSection, PR.name)).toBe(
"زیان دیده نمونه",
);
expect(
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.guiltyOwnerSection, PR.phone)).toBe(
"09120000000",
);
expect(getFieldValue(report, PR.guiltyOwnerSection, PR.nationalCode)).toBe(
"0987654321",
);
expect(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),
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.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(
getFieldValue(report, PR.fanavaranSection, PR.fanavaranClaimNo),
).toBe("111");
expect(
getFieldValue(report, PR.fanavaranSection, PR.fanavaranExpertiseId),
).toBe("444");
expect(getFieldValue(report, PR.fanavaranSection, PR.fanavaranPolicyId)).toBe(
"555",
);
expect(
getFieldValue(report, PR.fanavaranSection, PR.fanavaranPolicyId),
).toBe("555");
expect(getFieldValue(report, PR.timelineSection, PR.fileRegisteredAt)).toBe(
"1405/05/19 13:23",
@@ -228,15 +276,15 @@ describe("buildInsurerFileReport", () => {
getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt),
).toBe("1405/05/19 16:00");
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, 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(
"سپر جلو",
);
@@ -345,9 +393,9 @@ describe("buildInsurerFileReport", () => {
});
const fields = report.sections.flatMap((section) => section.fields);
expect(fields.find((field) => field.label === "خودرو / خودرو نو")?.value).toBe(
"بله",
);
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);
@@ -372,10 +420,12 @@ describe("buildInsurerFileReport", () => {
});
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(
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);
});
@@ -412,4 +462,128 @@ describe("buildInsurerFileReport", () => {
"پایه یک",
);
});
it("localizes participant and recent-transfer inquiry data", () => {
const report = buildInsurerFileReport({
overview: { publicId: "A00017" },
blame: {
type: "THIRD_PARTY",
parties: [
{
role: "FIRST",
person: { userId: "guilty", fullName: "مقصر" },
},
{
role: "SECOND",
person: { userId: "damaged", fullName: "زیان‌دیده" },
participants: [
{
participantId: "DRIVER",
fullName: "راننده",
nationalCode: "0012345678",
birthday: "1370/01/01",
hasDrivingLicense: true,
licenseNumber: "123456789",
licenseType: "BASE_2",
},
],
participantRoles: {
driver: "DRIVER",
vehicleOwner: "DRIVER",
thirdPartyPolicyholder: "DRIVER",
},
vehicle: {
registrationState: "RECENTLY_TRANSFERRED",
previousPlateId: "55ج222ایران33",
previousPolicyholderNationalCode: "0098765432",
currentPlate: {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
},
inquiry: {
plateKind: "PREVIOUS",
attempts: [
{
plateKind: "CURRENT",
plate: {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
},
succeeded: false,
error: "not found",
},
{
plateKind: "PREVIOUS",
succeeded: true,
usable: true,
},
],
},
},
},
],
expert: { decision: { guiltyPartyId: "guilty" } },
},
claim: {
vehicle: { carType: "SEDAN" },
},
});
expect(
getFieldValue(
report,
PR.damagedVehicleSection,
"خودرو / وضعیت پلاک و مالکیت",
),
).toBe("انتقال مالکیت اخیر");
expect(
getFieldValue(report, PR.damagedVehicleSection, "خودرو / نوع خودرو"),
).toBe("سواری");
expect(
getFieldValue(report, PR.damagedVehicleSection, "خودرو / پلاک قبلی"),
).toBe("55ج222ایران33");
expect(
getFieldValue(
report,
PR.damagedVehicleSection,
"خودرو / پلاک فعلی / دو رقم چپ پلاک",
),
).toBe("44");
const driver = getFieldValue(
report,
PR.damagedParticipantsSection,
PR.driverRole,
);
expect(driver).toContain("گواهینامه دارد: بله");
expect(driver).toContain("نوع گواهینامه: پایه دو");
expect(
getFieldValue(
report,
PR.damagedVehicleSection,
"خودرو / سوابق تلاش استعلام / تلاش ۱ / نوع پلاک استعلام‌شده",
),
).toBe("پلاک فعلی");
expect(
getFieldValue(
report,
PR.damagedVehicleSection,
"خودرو / سوابق تلاش استعلام / تلاش ۱ / خطا",
),
).toBe("موردی یافت نشد");
const renderedText = report.sections
.flatMap((section) => [
section.title,
...section.fields.flatMap((field) => [field.label, field.value]),
])
.join(" ");
expect(renderedText).not.toMatch(
/RECENTLY_TRANSFERRED|BASE_2|registrationState|previousPlateId|plateKind|not found|CURRENT|PREVIOUS/,
);
});
});

View File

@@ -4,6 +4,7 @@ import {
} from "src/helpers/blame-damaged-party";
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver";
import {
InsurerFileReportField,
InsurerFileReportSection,
@@ -13,6 +14,7 @@ import {
PR,
persianAccidentCondition,
persianFieldPath,
persianReportValue,
persianStatus,
} from "./persian-report-labels";
@@ -36,6 +38,8 @@ type ReportParty = ReportRecord & {
vehicle?: ReportRecord;
statement?: ReportRecord;
location?: { lat?: number; lon?: number };
participants?: ReportRecord[];
participantRoles?: ReportRecord;
};
function asString(value: unknown): string | undefined {
@@ -66,7 +70,8 @@ function formatBirthDate(value: unknown): string | undefined {
function formatDateTime(value: unknown): string | 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);
const [d, t] = toJalaliDateAndTime(date);
return `${d} ${t}`;
@@ -106,10 +111,13 @@ function firstDefined(...values: unknown[]): string | undefined {
return undefined;
}
function normalizeListValue(value: unknown): string | undefined {
function normalizeListValue(value: unknown, path = ""): string | undefined {
if (!Array.isArray(value) || !value.length) return undefined;
const items = value
.map((item) => asString(item) ?? JSON.stringify(item))
.map(
(item) =>
asString(persianReportValue(path, item)) ?? JSON.stringify(item),
)
.filter(Boolean);
return items.length ? items.join("، ") : undefined;
}
@@ -120,19 +128,32 @@ function flattenObject(
depth = 0,
): InsurerFileReportField[] {
if (obj == null) return [];
if (depth > 4) {
return [{ label: persianFieldPath(prefix), value: asString(obj) }];
if (depth > 6) {
return [
{
label: persianFieldPath(prefix),
value: asString(persianReportValue(prefix, obj)),
},
];
}
if (Array.isArray(obj)) {
const value = normalizeListValue(obj);
return value
? [{ label: persianFieldPath(prefix || "items"), value }]
: [];
if (obj.some((item) => item && typeof item === "object")) {
return obj.flatMap((item, index) =>
flattenObject(item, `${prefix}.${index + 1}`, depth + 1),
);
}
const value = normalizeListValue(obj, prefix);
return value ? [{ label: persianFieldPath(prefix || "items"), value }] : [];
}
if (typeof obj !== "object") {
return [{ label: persianFieldPath(prefix || "value"), value: asString(obj) }];
return [
{
label: persianFieldPath(prefix || "value"),
value: asString(persianReportValue(prefix || "value", obj)),
},
];
}
const rows: InsurerFileReportField[] = [];
@@ -146,21 +167,22 @@ function flattenObject(
if (value === undefined || value === null || value === "") continue;
const path = prefix ? `${prefix}.${key}` : key;
if (
typeof value === "object" &&
!Array.isArray(value) &&
!(value instanceof Date)
) {
if (typeof value === "object" && !(value instanceof Date)) {
rows.push(...flattenObject(value, path, depth + 1));
} else {
rows.push({ label: persianFieldPath(path), value: asString(value) });
rows.push({
label: persianFieldPath(path),
value: asString(persianReportValue(path, value)),
});
}
}
return rows;
}
function dedupeFields(fields: InsurerFileReportField[]): InsurerFileReportField[] {
function dedupeFields(
fields: InsurerFileReportField[],
): InsurerFileReportField[] {
const seen = new Set<string>();
const out: InsurerFileReportField[] = [];
@@ -180,7 +202,8 @@ function filterEmptySections(
sections: Array<InsurerFileReportSection | undefined>,
): InsurerFileReportSection[] {
return sections.filter(
(section): section is InsurerFileReportSection => !!section && section.fields.length > 0,
(section): section is InsurerFileReportSection =>
!!section && section.fields.length > 0,
);
}
@@ -204,7 +227,9 @@ function expertNameFromSnapshot(snapshot?: {
lastName?: string;
}): string | 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;
}
@@ -214,9 +239,8 @@ function collectExpertNames(
): string | undefined {
const names = new Set<string>();
const blameDecision = (
blame?.expert as Record<string, unknown> | undefined
)?.decision as Record<string, unknown> | undefined;
const blameDecision = (blame?.expert as Record<string, unknown> | undefined)
?.decision as Record<string, unknown> | undefined;
const blameExpert = expertNameFromSnapshot(
blameDecision?.expertProfileSnapshot as
| { firstName?: string; lastName?: string }
@@ -280,13 +304,16 @@ function resolveReportBlameContext(
(claim?.blameFileContext as Record<string, unknown> | undefined)
?.blameRequestType ??
(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,
};
}
function getPartyRole(party: ReportParty | null | undefined): string | undefined {
function getPartyRole(
party: ReportParty | null | undefined,
): string | undefined {
const role = party?.role;
return typeof role === "string" ? role : undefined;
}
@@ -318,7 +345,8 @@ function sameParty(
second: ReportParty | null | undefined,
): boolean {
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 =
second.person?.userId != null ? String(second.person.userId) : "";
if (firstUserId && secondUserId) return firstUserId === secondUserId;
@@ -330,7 +358,9 @@ function resolveEvaluationReply(
): Record<string, unknown> | undefined {
const evaluation = claim?.evaluation as Record<string, unknown> | undefined;
return (
(evaluation?.damageExpertReplyFinal as Record<string, unknown> | undefined) ??
(evaluation?.damageExpertReplyFinal as
| Record<string, unknown>
| undefined) ??
(evaluation?.damageExpertReply as Record<string, unknown> | undefined)
);
}
@@ -375,10 +405,18 @@ function buildThirdPartyInsuranceFields(
const role = getPartyRole(party);
const direct = (party?.insurance ?? {}) as Record<string, unknown>;
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(
inquiryRoleData(claim?.inquiries as Record<string, unknown> | undefined, "thirdParty", role),
inquiryRoleData(
claim?.inquiries as Record<string, unknown> | undefined,
"thirdParty",
role,
),
);
return [
@@ -462,12 +500,23 @@ function buildCarBodyInsuranceFields(
party?.insurance?.carBody ??
{}) as Record<string, unknown>;
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(
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 [
{
@@ -556,7 +605,10 @@ function buildPartyOwnerSection(
{
label: PR.nationalCode,
value: options?.useClaimOwnerFallback
? firstDefined(person?.nationalCodeOfInsurer, money?.nationalCodeOfInsurer)
? firstDefined(
person?.nationalCodeOfInsurer,
money?.nationalCodeOfInsurer,
)
: firstDefined(person?.nationalCodeOfInsurer, person?.nationalCode),
},
{
@@ -572,9 +624,63 @@ function buildPartyOwnerSection(
]);
}
function licenseFieldsFromInquiry(
inquiry?: Record<string, unknown>,
): { licenseType?: string; licenseDate?: string } {
function buildParticipantRolesSection(
title: 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 {};
return {
licenseType: firstDefined(
@@ -616,8 +722,12 @@ function buildDriverSection(
{
label: PR.licenseType,
value:
licenseType ??
asString(person.licenseType) ??
asString(
persianReportValue(
"participant.licenseType",
licenseType ?? person.licenseType,
),
) ??
(person.driverLicense ? PR.driverLicense : undefined),
},
{
@@ -700,7 +810,9 @@ function buildCaseTimelineSection(
},
{
label: PR.evaluationRegisteredAt,
value: formatDateTime(resolveEvaluationSubmittedAt(claim, evaluationReply)),
value: formatDateTime(
resolveEvaluationSubmittedAt(claim, evaluationReply),
),
},
]);
}
@@ -709,10 +821,14 @@ function buildFanavaranCodesSection(
claim?: Record<string, unknown> | null,
): InsurerFileReportSection | undefined {
if (!claim) return undefined;
const sync = (claim.fanavaranSync as Record<string, unknown> | undefined) ?? {};
const baseClaim = (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) ?? {};
const sync =
(claim.fanavaranSync as Record<string, unknown> | undefined) ?? {};
const baseClaim =
(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, [
{
@@ -725,7 +841,11 @@ function buildFanavaranCodesSection(
},
{
label: PR.fanavaranDamageCaseId,
value: firstDefined(claim.dmgCaseId, damageCase.dmgCaseId, expertise.dmgCaseId),
value: firstDefined(
claim.dmgCaseId,
damageCase.dmgCaseId,
expertise.dmgCaseId,
),
},
{
label: PR.fanavaranExpertiseId,
@@ -806,7 +926,7 @@ function evaluationDaghiValue(value: unknown): string | undefined {
return asString(value);
}
const daghi = value as ReportRecord;
const option = asString(daghi.option);
const option = asString(persianReportValue("daghi.option", daghi.option));
const price = formatToman(daghi.price);
return [option, price].filter(Boolean).join(" - ") || undefined;
}
@@ -824,10 +944,21 @@ function buildEvaluationPartsSection(
const prefix = `${PR.part} ${PERSIAN_NUMBER_FORMATTER.format(index + 1)}`;
return [
{ label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) },
{ label: `${prefix} / ${PR.damageType}`, value: asString(part.typeOfDamage) },
{
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.repairSalary}`,
value: formatToman(part.salary),
},
{
label: `${prefix} / ${PR.totalPayment}`,
value: formatToman(part.totalPayment),
},
{
label: `${prefix} / ${PR.factorNeeded}`,
value:
@@ -835,7 +966,10 @@ function buildEvaluationPartsSection(
? persianStatus(part.factorNeeded)
: undefined,
},
{ label: `${prefix} / ${PR.daghi}`, value: evaluationDaghiValue(part.daghi) },
{
label: `${prefix} / ${PR.daghi}`,
value: evaluationDaghiValue(part.daghi),
},
];
});
@@ -852,10 +986,11 @@ function buildAccidentReportSection(
const snapshotAccident = (
claim?.snapshot as { accident?: Record<string, unknown> } | undefined
)?.accident;
const blameDecision = (
blame?.expert as Record<string, unknown> | undefined
)?.decision as Record<string, unknown> | undefined;
const decisionFields = blameDecision?.fields as Record<string, unknown> | undefined;
const blameDecision = (blame?.expert as Record<string, unknown> | undefined)
?.decision as Record<string, unknown> | undefined;
const decisionFields = blameDecision?.fields as
| Record<string, unknown>
| undefined;
return buildSection(PR.accidentSection, [
{
@@ -868,7 +1003,8 @@ function buildAccidentReportSection(
},
{
label: PR.accidentTime,
value: asString(statement?.accidentTime) ?? asString(snapshotAccident?.time),
value:
asString(statement?.accidentTime) ?? asString(snapshotAccident?.time),
},
{
label: PR.experts,
@@ -914,7 +1050,8 @@ function buildAccidentReportSection(
{
label: PR.accidentWay,
value: asString(
(decisionFields?.accidentWay as { label?: string } | undefined)?.label ??
(decisionFields?.accidentWay as { label?: string } | undefined)
?.label ??
(
snapshotAccident?.classification as {
accidentWay?: { label?: string };
@@ -925,7 +1062,8 @@ function buildAccidentReportSection(
{
label: PR.accidentReason,
value: asString(
(decisionFields?.accidentReason as { label?: string } | undefined)?.label ??
(decisionFields?.accidentReason as { label?: string } | undefined)
?.label ??
(
snapshotAccident?.classification as {
accidentReason?: { label?: string };
@@ -936,7 +1074,8 @@ function buildAccidentReportSection(
{
label: PR.accidentType,
value: asString(
(decisionFields?.accidentType as { label?: string } | undefined)?.label ??
(decisionFields?.accidentType as { label?: string } | undefined)
?.label ??
(
snapshotAccident?.classification as {
accidentType?: { label?: string };
@@ -968,12 +1107,17 @@ export function buildInsurerFileReport(file: {
: null;
const isCarBody =
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([
buildCaseTimelineSection(overview, claim),
buildParticipantRolesSection(PR.damagedParticipantsSection, damagedParty),
includeGuiltySections
? buildParticipantRolesSection(PR.guiltyParticipantsSection, guiltyParty)
: undefined,
buildPartyOwnerSection(PR.ownerSection, damagedParty, {
claim,
useClaimOwnerFallback: true,
@@ -1003,7 +1147,11 @@ export function buildInsurerFileReport(file: {
buildCarBodyInsuranceFields(guiltyParty, blameContext, claim),
)
: undefined,
buildPartyVehicleSection(PR.damagedVehicleSection, damagedParty, claimVehicle),
buildPartyVehicleSection(
PR.damagedVehicleSection,
damagedParty,
claimVehicle,
),
includeGuiltySections
? buildPartyVehicleSection(PR.guiltyVehicleSection, guiltyParty)
: undefined,

View File

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

View File

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

View File

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

View File

@@ -58,7 +58,6 @@ import { MediaPolicyModule } from "src/media-policy/media-policy.module";
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
@Module({
imports: [
@@ -77,7 +76,6 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
SandHubModule,
ClientModule,
MediaPolicyModule,
SmsOrchestrationModule,
JwtModule.register({}),
MongooseModule.forFeature([
{ name: ClaimCase.name, schema: ClaimCaseSchema },

View File

@@ -83,7 +83,7 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({
summary: "Get My Claims (V2)",
description:
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType`, `startDate`, `endDate`.",
})
@ApiResponse({
status: 200,
@@ -801,6 +801,7 @@ Returns status of each item (uploaded/captured or not).
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
- accident_sketch (optional; never blocks workflow completion)
- damaged_driving_license_front/back
- damaged_chassis_number, damaged_engine_photo
- damaged_car_card_front/back, damaged_metal_plate
@@ -836,6 +837,7 @@ Returns status of each item (uploaded/captured or not).
"guilty_car_card_front",
"guilty_car_card_back",
"guilty_metal_plate",
"accident_sketch",
],
example: "damaged_driving_license_front",
},

View File

@@ -42,6 +42,13 @@ export class RequiredDocumentItem {
example: true,
})
preferUploadDuringCapture?: boolean;
@ApiProperty({
description:
'Whether this document is mandatory for workflow completion. Optional documents are uploadable but never included in remaining/progress counts.',
example: true,
})
required: boolean;
}
/**

View File

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

View File

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

View File

@@ -56,20 +56,21 @@ export class SelectOtherPartsV2Dto {
@IsString()
shebaNumber?: string;
@ApiProperty({
description: 'National code of insurer/owner - 10 digits',
@ApiPropertyOptional({
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',
pattern: '^[0-9]{10}$',
minLength: 10,
maxLength: 10,
})
@IsNotEmpty({ message: 'nationalCodeOfInsurer is required' })
@IsOptional()
@IsString({ message: 'National code must be a string' })
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
@Matches(/^[0-9]{10}$/, {
message: 'National code must contain exactly 10 digits',
})
nationalCodeOfInsurer: string;
nationalCodeOfInsurer?: string;
@ApiPropertyOptional({
description: 'Legacy alias for backward compatibility',

View File

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

View File

@@ -36,6 +36,13 @@ export class ClaimVehicleSnapshot {
@Prop({ type: ClaimPlateSchema })
plate?: ClaimPlate;
@Prop({ type: String })
price?: string;
/** Vehicle value in Rial from hull policy VehicleValue or expert assessment. */
@Prop({ type: Number })
carPrice?: number;
}
export const ClaimVehicleSnapshotSchema =
SchemaFactory.createForClass(ClaimVehicleSnapshot);

View File

@@ -89,18 +89,34 @@ export class FanavaranSyncStage {
@Prop({ type: 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. */
@Prop({ type: 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.
@@ -126,7 +142,7 @@ export class FanavaranSyncStage {
@Prop({ type: Date })
lastPayloadBuiltAt?: Date;
/** When we SMS'd the claim owner about Fanavaran claimId/ClaimNo (after expertise). */
/** Legacy marker from the retired post-expertise Fanavaran SMS flow. */
@Prop({ type: Date })
smsNotifiedAt?: Date;
@@ -223,6 +239,10 @@ export class ClaimCase {
@Prop({ type: Types.ObjectId, index: true })
initiatedByFieldExpertId?: Types.ObjectId;
/** Branch snapshot inherited from the originating expert-created blame. */
@Prop({ type: Types.ObjectId, index: true })
branchId?: Types.ObjectId;
/**
* The damaged party's userId, resolved from the blame at claim-creation time.
* Stored here so view/list access does not require an extra blame lookup.

View File

@@ -416,6 +416,7 @@ Returns status of each item (uploaded/captured or not).
"guilty_car_card_front",
"guilty_car_card_back",
"guilty_metal_plate",
"accident_sketch",
],
example: "damaged_driving_license_front",
},

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

View 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,
},
};
}

View File

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

View File

@@ -0,0 +1,184 @@
/** GEN.03 VHUD input fields. Do not send ثالث-only keys (DamagedCount, HasOtherCulprit, plaques, …). */
export const FANAVARAN_HULL_BASE_CLAIM_KEYS = [
"ArchiveNo",
"AccidentDate",
"AccidentTime",
"AnnouncementDate",
"AccidentLocationAddress",
"ClaimExpertId",
"EntryDate",
"CulpritLicenceNo",
"CulpritLicenceIssuDate",
"CulpritLicenceForeignCityName",
"PoliceReportSeri",
"PoliceReportSerial",
"PoliceReportDesc",
"CustomerFaultPercent",
"CulpritLevelTwoLicenceIssuDate",
"ActualPremium",
"EstimateAmount",
"TrackingCode",
"CourtArchiveNo",
"PlaqueReplacementDate",
"StatusChangeDate",
"ClaimCompletionDate",
"DmgAssessorFirstCreationTime",
"PolicyId",
"IsSurplusArticleEighthLaw",
"AccidentCityId",
"AccidentCauseId",
"AccidentTypeId",
"GlassBreakReasonId",
"CulpritTypeId",
"AuthorityCulpritId",
"AccidentCulpritId",
"CulpritLicenceTypeId",
"IsLicenseReplaced",
"CulpritLicenceCountryId",
"CulpritLicenceCityId",
"AccidentReportTypeId",
"PoliceOfficerId",
"IsAccidentOutOfBorder",
"AccidentVehicleUsedId",
"IsOwnerChanged",
"CostSeparationToDmgSections",
] as const;
/** GEN.03 default: تصادف(حادثه). Lookup: GET /lookups/vehicle-hull-accident-types */
export const FANAVARAN_DEFAULT_HULL_ACCIDENT_TYPE_ID = 2;
/** GEN.03 ans (0=خیر). Used for CostSeparationToDmgSections, IsOwnerChanged, IsLicenseReplaced. */
export const FANAVARAN_HULL_ANS_NO = 0;
/** GEN.03 hull culprit type when third-party is at fault. Lookup: vehicle-hull-accident-culprit-type */
export const FANAVARAN_DEFAULT_HULL_CULPRIT_TYPE_ID = 300;
/** GEN.03 required: درصد تقصير بيمه گذار. Proven Parsian submit uses 100. */
export const FANAVARAN_DEFAULT_HULL_CUSTOMER_FAULT_PERCENT = 100;
/**
* GEN.03 required: مرجع تعيين مقصر.
* Lookup: GET /lookups/detection-accident-culprits. Fallback is بیمه‌گذار.
*/
export const FANAVARAN_DEFAULT_HULL_AUTHORITY_CULPRIT_ID = 1;
export const FANAVARAN_HULL_AUTHORITY_CULPRIT_CAPTION = "بيمه گذار";
/** GEN.03 ActualPremium from GET car/vehicle-hull-policies/{PolicyId} → TotalPremium. */
export function actualPremiumFromHullPolicyRecord(
policy: Record<string, unknown> | null | undefined,
): number | null {
if (!policy) return null;
const raw = policy.TotalPremium ?? policy.totalPremium;
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isFinite(n) && n >= 0 ? n : null;
}
function normalizeHullCaption(value: unknown): string {
return String(value ?? "")
.replace(/ي/g, "ی")
.replace(/ك/g, "ک")
.replace(/\s+/g, " ")
.trim();
}
/** AuthorityCulpritId from GET /lookups/detection-accident-culprits. */
export function authorityCulpritIdFromDetectionRows(
rows: unknown,
): number {
const list = Array.isArray(rows) ? rows : [];
const wanted = normalizeHullCaption(
FANAVARAN_HULL_AUTHORITY_CULPRIT_CAPTION,
);
for (const row of list) {
if (!row || typeof row !== "object") continue;
const caption = normalizeHullCaption(
(row as { Caption?: unknown }).Caption,
);
if (!caption.includes(wanted)) continue;
const raw = (row as { Id?: unknown }).Id;
const n = typeof raw === "number" ? raw : Number(raw);
if (Number.isFinite(n) && n > 0) return n;
}
for (const row of list) {
if (!row || typeof row !== "object") continue;
const raw = (row as { Id?: unknown }).Id;
const n = typeof raw === "number" ? raw : Number(raw);
if (Number.isFinite(n) && n > 0) return n;
}
return FANAVARAN_DEFAULT_HULL_AUTHORITY_CULPRIT_ID;
}
/** Fallback AccidentCulpritId when parties inquiry is unavailable. */
export function customerIdFromHullPolicyRecord(
policy: Record<string, unknown> | null | undefined,
): number | null {
if (!policy) return null;
const raw = policy.CustomerId ?? policy.customerId;
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isFinite(n) && n > 0 ? n : null;
}
export function toFanavaranHullBaseClaimPayload(
built: Record<string, unknown>,
): Record<string, unknown> {
const next: Record<string, unknown> = {};
for (const key of FANAVARAN_HULL_BASE_CLAIM_KEYS) {
if (key === "IsLicenseReplaced") {
// GEN.03: must be empty/null. Do not map ثالث IsLicenseReplacement (default 0).
next[key] = null;
continue;
}
if (key === "AccidentTypeId") {
next[key] =
built.AccidentTypeId ?? FANAVARAN_DEFAULT_HULL_ACCIDENT_TYPE_ID;
continue;
}
if (key === "CostSeparationToDmgSections") {
next[key] =
built.CostSeparationToDmgSections ?? FANAVARAN_HULL_ANS_NO;
continue;
}
if (key === "IsOwnerChanged") {
next[key] = built.IsOwnerChanged ?? FANAVARAN_HULL_ANS_NO;
continue;
}
if (key === "CulpritTypeId") {
const raw = built.CulpritTypeId;
// ثالث profile default 337 is not in vehicle-hull-accident-culprit-type.
if (raw == null || raw === 337) {
next[key] = FANAVARAN_DEFAULT_HULL_CULPRIT_TYPE_ID;
} else {
next[key] = raw;
}
continue;
}
if (key === "CustomerFaultPercent") {
next[key] =
built.CustomerFaultPercent ??
FANAVARAN_DEFAULT_HULL_CUSTOMER_FAULT_PERCENT;
continue;
}
if (key === "AuthorityCulpritId") {
next[key] =
built.AuthorityCulpritId ?? FANAVARAN_DEFAULT_HULL_AUTHORITY_CULPRIT_ID;
continue;
}
next[key] = Object.prototype.hasOwnProperty.call(built, key)
? built[key]
: null;
}
return next;
}
/** Nested hull files/expertise use GEN.03 `Id` (کد رایانه), never `ClaimNo` (شماره پرونده). */
export function fanavaranHullNestedClaimId(input: {
Id?: unknown;
ClaimNo?: unknown;
claimId?: unknown;
claimNo?: unknown;
}): number | null {
const id = input.claimId ?? input.Id;
if (typeof id === "number" && Number.isFinite(id) && id > 0) return id;
if (typeof id === "string" && /^\d+$/.test(id.trim())) return Number(id);
return null;
}

View File

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

View File

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

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

View 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: "مهدوی نیا",
});
});
});

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

View File

@@ -373,6 +373,7 @@ Returns status of each item (uploaded/captured or not).
"guilty_car_card_front",
"guilty_car_card_back",
"guilty_metal_plate",
"accident_sketch",
],
example: "damaged_driving_license_front",
},

View File

@@ -52,7 +52,7 @@ export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
@ApiProperty({
description:
"ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`). Required for the VIN initial-form path.",
"ESG two-factor VIN/chassis inquiry (`/inquiry/carByChassis`). Required for the VIN initial-form path.",
example: false,
})
@IsBoolean()

View File

@@ -0,0 +1,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;
}

View File

@@ -30,5 +30,8 @@ export class CaseInquiries {
@Prop({ type: InquiryStatusSchema })
drivingLicence?: InquiryStatus;
@Prop({ type: InquiryStatusSchema })
ownership?: InquiryStatus;
}
export const CaseInquiriesSchema = SchemaFactory.createForClass(CaseInquiries);

View File

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

View File

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

View File

@@ -1,20 +1,20 @@
/**
* 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). */
MIN: 10_000,
MIN: 100_000,
/** Aligns with the total assessment cap; rejects absurd values (e.g. 100bn). */
MAX: 53_000_000,
MAX: 530_000_000,
} as const;
/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
/** Max sum of all priced + factor lines in one V1 expert reply / validation (Rial). */
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
/**
* Returns null when the claim v2 total cap is disabled.
* Returns null when the V1 claim total cap is disabled.
*
* Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again.
* Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount.

View File

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

View File

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

View File

@@ -91,6 +91,7 @@ import {
ExpertFileActivityType,
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
import { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver";
interface CheckedRequestEntry {
CheckedRequest?: {
@@ -613,9 +614,24 @@ export class ExpertBlameService {
)) as Record<string, unknown>[];
// Scope to this reviewer's insurance company via blame party clientId
const visibleCases = allSealed.filter((doc) =>
const tenantVisibleCases = allSealed.filter((doc) =>
blameCaseTouchesClient(doc, clientKey),
);
const visibleCases = (
await Promise.all(
tenantVisibleCases.map(async (doc) => {
try {
await this.requestManagementService.assertFileReviewerBranchAccess(
doc,
actor.sub,
);
return doc;
} catch {
return null;
}
}),
)
).filter((doc): doc is Record<string, unknown> => doc != null);
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
return pagedResult;
@@ -1250,8 +1266,17 @@ export class ExpertBlameService {
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
// Keep the normalized blame-party participant contract authoritative for
// every expert detail response. Legacy person fields remain for old files,
// but new UIs must read participants + participantRoles from the same party.
doc.parties = parties.map((party: Record<string, unknown>) => ({
...party,
participants: sanitizeStoredInquiryParticipants(party.participants),
participantRoles: party.participantRoles,
}));
// Strip heavy SandHub inquiry blob
for (const party of parties as Array<{
for (const party of doc.parties as Array<{
vehicle?: Record<string, unknown>;
}>) {
if (

View File

@@ -53,11 +53,19 @@ export class ClaimDetailV2ResponseDto {
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
description: "How the blame file was initiated: IN_PERSON or LINK",
description: "How the blame file was initiated: NORMAL, IN_PERSON, or LINK",
example: "IN_PERSON",
})
creationMethod?: string;
@ApiProperty({
nullable: true,
description:
"Maximum expert-reply total for V1 user-created files. Null for V2-V6 flows or when the cap is disabled.",
example: 530000000,
})
priceCap: number | null;
@ApiPropertyOptional({
description:
"CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)",
@@ -131,6 +139,33 @@ export class ClaimDetailV2ResponseDto {
url?: string;
}>;
@ApiPropertyOptional({
description:
"Append-only expert damaged-part revisions. Removed rows retain their original capture URL even after the authoritative selection changes.",
type: "array",
items: {
type: "object",
properties: {
revisionId: { type: "string" },
changedAt: { type: "string", format: "date-time" },
changedBy: { type: "object" },
removedParts: { type: "array", items: { type: "object" } },
addedParts: { type: "array", items: { type: "object" } },
},
},
})
damagedPartsHistory?: Array<{
revisionId: string;
changedAt: Date | string;
changedBy: {
actorId: string;
actorName?: string;
actorType: string;
};
removedParts: Array<Record<string, unknown>>;
addedParts: Array<Record<string, unknown>>;
}>;
@ApiPropertyOptional({
description:
"True when user uploaded all required factors and the case awaits expert approve/reject.",
@@ -251,7 +286,7 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({
description:
"Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.",
"Linked blame case (`blameCases`), same shape as expert-blame detail. Each party exposes its authoritative normalized `participants` and `participantRoles`, plus video/voice URLs, workflow, expert, and formatted dates.",
})
blameCase?: Record<string, unknown>;

View File

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

View File

@@ -58,7 +58,7 @@ export class PartPricingV2Dto {
@ApiPropertyOptional({
example: "5000000",
description:
"Required for change lines; omitted for repair lines. Every supplied amount must be between 100,000 and 10,000,000,000 Toman.",
"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) =>
@@ -74,7 +74,7 @@ export class PartPricingV2Dto {
@ApiProperty({
example: "2000000",
description:
"Labor in Toman (integer string; 100,000 to 10,000,000,000).",
"Labor in Rial (integer string; 1,000,000 to 100,000,000,000).",
})
@IsString()
@IsNotEmpty()
@@ -84,7 +84,7 @@ export class PartPricingV2Dto {
@ApiProperty({
example: "7000000",
description:
"Line total in Toman (integer string; 100,000 to 10,000,000,000).",
"Line total in Rial (integer string; 1,000,000 to 100,000,000,000).",
})
@IsString()
@IsNotEmpty()
@@ -121,6 +121,15 @@ export class SubmitExpertReplyV2Dto {
@IsString()
description?: string;
@ApiPropertyOptional({
example: '1000000000',
description:
"Current vehicle price in Rial. Required for car-body claims and forbidden for third-party claims. This is independent from evaluation.priceDrop.carPrice.",
})
@IsOptional()
@IsMoneyAmountString()
carPrice?: string;
@ApiProperty({
type: [PartPricingV2Dto],
description:

View File

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

View File

@@ -92,6 +92,7 @@ import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/clai
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver";
import {
GetClaimListV2ResponseDto,
ClaimListItemV2Dto,
@@ -142,6 +143,10 @@ import {
} from "src/helpers/outer-damage-parts";
import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import {
buildDamagedPartSelectionRevision,
serializeDamagedPartSelectionHistory,
} from "src/helpers/claim-damaged-part-audit";
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
@@ -151,7 +156,7 @@ import {
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
/** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */
/** Configured V1 maximum sum of line `totalPayment` across the claim (Rial). */
import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
@@ -171,6 +176,11 @@ import {
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing";
import {
normalizeMoneyAmountString,
parseMoneyAmountToman,
} from "src/utils/unicode-digits";
import { claimPriceCapAppliesToBlame } from "src/helpers/claim-price-cap";
@Injectable()
export class ExpertClaimService {
@@ -408,14 +418,145 @@ export class ExpertClaimService {
claim: any,
actor: any,
): Promise<void> {
const blame = await this.loadBlameForClaim(claim);
// FILE_REVIEWER: by the time we reach this method the caller has already
// verified that actor.sub === blame.assignedFileReviewerId (Phase 2 of
// assignClaimForReviewV2). The generic tenant-scope check would incorrectly
// reject them because initiatedByFieldExpertId belongs to the FileMaker, not
// the reviewer. Skip it — the assignment check is the access proof.
if ((actor as any).role === RoleEnum.FILE_REVIEWER) return;
const blame = await this.loadBlameForClaim(claim);
if ((actor as any).role === RoleEnum.FILE_REVIEWER) {
await this.assertActorBranchAccess(claim, actor, blame);
return;
}
assertClaimCaseForExpertActor(claim, actor, blame);
await this.assertActorBranchAccess(claim, actor, blame);
}
private async assertActorBranchAccess(
claim: any,
actor: any,
blame?: any,
): Promise<void> {
const caseBranchId = claim?.branchId
? String(claim.branchId)
: blame?.branchId
? String(blame.branchId)
: undefined;
if (actor?.role === RoleEnum.FILE_REVIEWER) {
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
fileMakerId: blame?.initiatedByFieldExpertId
? String(blame.initiatedByFieldExpertId)
: null,
fileReviewerId: String(actor.sub),
caseBranchId,
});
return;
}
if (actor?.role === RoleEnum.FILE_MAKER) {
const actorBranchId = await this.fanavaranLocationService.fileMakerBranchId(
String(actor.sub),
);
if (!actorBranchId || (caseBranchId && caseBranchId !== actorBranchId)) {
throw new ForbiddenException("This file belongs to another branch.");
}
return;
}
if (actor?.role === RoleEnum.FIELD_EXPERT) return;
const damageExpert = await this.damageExpertDbService.findById(
String(actor.sub),
);
const actorBranchId = (damageExpert as any)?.branchId
? String((damageExpert as any).branchId)
: undefined;
if (!actorBranchId) {
throw new ForbiddenException(
"DamageExpert account is not assigned to a branch.",
);
}
// Ordinary user-created legacy claims have no branch snapshot. Preserve
// their existing tenant queue; branch-created claims must match exactly.
if (caseBranchId && caseBranchId !== actorBranchId) {
throw new ForbiddenException("This file belongs to another branch.");
}
}
private async filterDamageExpertClaimsByBranch(
claims: any[],
actor: any,
): Promise<any[]> {
const damageExpert = await this.damageExpertDbService.findById(
String(actor.sub),
);
const actorBranchId = (damageExpert as any)?.branchId
? String((damageExpert as any).branchId)
: undefined;
if (!actorBranchId) {
throw new ForbiddenException(
"DamageExpert account is not assigned to a branch.",
);
}
const missingBranchBlameIds = [
...new Set(
claims
.filter((claim) => !claim?.branchId && claim?.blameRequestId)
.map((claim) => String(claim.blameRequestId)),
),
];
const blames = missingBranchBlameIds.length
? ((await this.blameRequestDbService.find(
{
_id: {
$in: missingBranchBlameIds.map((id) => new Types.ObjectId(id)),
},
},
{
lean: true,
select:
"_id branchId isMadeByFileMaker initiatedByFieldExpertId",
},
)) as any[])
: [];
const blameById = new Map(
blames.map((blame) => [String(blame._id), blame] as const),
);
const makerBranchById = new Map<string, string | undefined>();
await Promise.all(
[
...new Set(
blames
.filter(
(blame) =>
blame.isMadeByFileMaker &&
!blame.branchId &&
blame.initiatedByFieldExpertId,
)
.map((blame) => String(blame.initiatedByFieldExpertId)),
),
].map(async (makerId) => {
makerBranchById.set(
makerId,
await this.fanavaranLocationService.fileMakerBranchId(makerId),
);
}),
);
return claims.filter((claim) => {
let caseBranchId = claim?.branchId ? String(claim.branchId) : undefined;
if (!caseBranchId && claim?.blameRequestId) {
const blame = blameById.get(String(claim.blameRequestId));
caseBranchId = blame?.branchId
? String(blame.branchId)
: blame?.isMadeByFileMaker && blame?.initiatedByFieldExpertId
? makerBranchById.get(String(blame.initiatedByFieldExpertId))
: undefined;
}
return !caseBranchId || caseBranchId === actorBranchId;
});
}
private async fieldExpertOwnsClaim(
@@ -1685,8 +1826,13 @@ export class ExpertClaimService {
throw new BadRequestException(pricingValidationError);
}
// Validate total price cap (priced lines sum), when enabled.
const priceCap = getClaimV2TotalPaymentCapToman();
// The total cap is a V1-only rule. Legacy claims embed their blame file.
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
const priceCap =
configuredPriceCap !== null &&
claimPriceCapAppliesToBlame(request.blameFile)
? configuredPriceCap
: null;
if (priceCap !== null && reply.parts && reply.parts.length > 0) {
let totalPrice = 0;
@@ -1718,7 +1864,7 @@ export class ExpertClaimService {
if (totalPrice > priceCap) {
throw new BadRequestException({
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
message: `You have reached the maximum acceptable total price (Rial). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice: totalPrice,
@@ -2284,7 +2430,8 @@ export class ExpertClaimService {
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
* — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature).
* — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later).
* When enabled by env, total of all repair lines must be ≤ the claim v2 total payment cap.
* For V1 user-created files, when enabled by env, the total of all repair
* lines must be no greater than the configured total-payment cap.
* Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`.
*/
async validateClaimFactorsV2(
@@ -2424,7 +2571,12 @@ export class ExpertClaimService {
};
}
const priceCap = getClaimV2TotalPaymentCapToman();
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
const priceCap =
configuredPriceCap !== null &&
claimPriceCapAppliesToBlame(await this.loadBlameForClaim(claim))
? configuredPriceCap
: null;
if (priceCap !== null) {
let totalPrice = 0;
for (const part of updatedReply.parts || []) {
@@ -2439,7 +2591,7 @@ export class ExpertClaimService {
}
if (totalPrice > priceCap) {
throw new BadRequestException({
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
message: `You have reached the maximum acceptable total price (Rial). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR",
totalPrice,
priceCap,
@@ -2579,6 +2731,15 @@ export class ExpertClaimService {
"This file does not belong to your organization.",
);
}
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
fileMakerId: (blame as any)?.initiatedByFieldExpertId
? String((blame as any).initiatedByFieldExpertId)
: null,
fileReviewerId: actor.sub,
caseBranchId: (blame as any)?.branchId
? String((blame as any).branchId)
: null,
});
if (
!(blame as any).isMadeByFileMaker ||
!(blame as any).expertInitiated ||
@@ -2711,6 +2872,15 @@ export class ExpertClaimService {
"This file does not belong to your organization.",
);
}
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
fileMakerId: (reviewerBlame as any).initiatedByFieldExpertId
? String((reviewerBlame as any).initiatedByFieldExpertId)
: null,
fileReviewerId: actor.sub,
caseBranchId: (reviewerBlame as any).branchId
? String((reviewerBlame as any).branchId)
: null,
});
const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId
? String((reviewerBlame as any).assignedFileReviewerId)
: null;
@@ -2718,9 +2888,14 @@ export class ExpertClaimService {
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
if (assignedReviewerId && assignedReviewerId === actor.sub) {
// Reviewer already assigned (e.g. after a FileMaker rejection that reset
// blame back to WAITING_FOR_FILE_REVIEWER) — skip Phase 1 and fall
// through to the damage-expert workflow lock below.
// Phase 1 is intentionally idempotent. The linked claim can still be
// in a data-capture status here, so falling through to the damage
// assessment status gate would incorrectly reject the same reviewer.
return {
success: true,
status: "already_assigned_to_you",
message: "You have already taken this file.",
};
} else {
// Phase 1: first-time blame assignment
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
@@ -3245,7 +3420,7 @@ export class ExpertClaimService {
* - Claim must exist
* - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub)
* - Must be in EXPERT_REVIEWING status
* - Total payment across all parts must not exceed 53,000,000 (same cap as factor validation totals)
* - V1 only: total payment across all parts must not exceed the configured cap
* - Each part must include `daghi` (option + conditional price) like V1
*
* On success:
@@ -3262,7 +3437,6 @@ export class ExpertClaimService {
) {
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException(
this.expertReplySubmissionError(
@@ -3272,6 +3446,18 @@ export class ExpertClaimService {
);
}
const blame = await this.blameRequestDbService.findById(
claim.blameRequestId,
);
if (!blame) {
throw new NotFoundException(
this.expertReplySubmissionError(
"پرونده تعیین خسارت مرتبط یافت نشد.",
"BLAME_NOT_FOUND",
),
);
}
await this.assertExpertActorOnClaim(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
@@ -3302,6 +3488,36 @@ export class ExpertClaimService {
);
}
const carPriceWasProvided = reply.carPrice != null;
let normalizedCurrentCarPrice: string | undefined;
let parsedCurrentCarPrice: number | undefined;
if (blame.type === BlameRequestType.CAR_BODY) {
parsedCurrentCarPrice = parseMoneyAmountToman(reply.carPrice) ?? undefined;
if (
parsedCurrentCarPrice == null ||
!Number.isSafeInteger(parsedCurrentCarPrice) ||
parsedCurrentCarPrice <= 0
) {
throw new BadRequestException(
this.expertReplySubmissionError(
"قیمت روز خودرو در پرونده بدنه الزامی است و باید مبلغی صحیح و بیشتر از صفر باشد.",
"CAR_PRICE_REQUIRED",
{ field: "carPrice" },
),
);
}
normalizedCurrentCarPrice = normalizeMoneyAmountString(reply.carPrice!);
} else if (carPriceWasProvided) {
throw new ForbiddenException(
this.expertReplySubmissionError(
"قیمت روز خودرو فقط برای پرونده بدنه قابل ثبت است.",
"CAR_PRICE_NOT_ALLOWED",
{ field: "carPrice" },
),
);
}
const pricingValidationError = getExpertReplyPricingValidationError(
reply.parts,
{ locale: "fa", enforceAmountBounds: true },
@@ -3310,8 +3526,12 @@ export class ExpertClaimService {
throw new BadRequestException(pricingValidationError);
}
// Price cap validation, when enabled.
const priceCap = getClaimV2TotalPaymentCapToman();
// The configured total cap is enforced only for V1 user-created files.
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
const priceCap =
configuredPriceCap !== null && claimPriceCapAppliesToBlame(blame)
? configuredPriceCap
: null;
if (priceCap !== null) {
let totalPrice = 0;
for (const part of reply.parts || []) {
@@ -3322,7 +3542,7 @@ export class ExpertClaimService {
}
if (totalPrice > priceCap) {
throw new BadRequestException({
message: `مجموع مبلغ قطعات (${totalPrice.toLocaleString("fa-IR")}) از سقف مجاز (${priceCap.toLocaleString("fa-IR")}) تومان بیشتر است.`,
message: `مجموع مبلغ قطعات (${totalPrice.toLocaleString("fa-IR")}) از سقف مجاز (${priceCap.toLocaleString("fa-IR")}) ریال بیشتر است.`,
error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice,
@@ -3530,6 +3750,12 @@ export class ExpertClaimService {
"evaluation.ownerInsurerApproval": "",
"evaluation.ownerPricedPartsApproval": "",
},
...(normalizedCurrentCarPrice && parsedCurrentCarPrice != null
? {
"vehicle.price": normalizedCurrentCarPrice,
"vehicle.carPrice": parsedCurrentCarPrice,
}
: {}),
"workflow.currentStep": currentStep,
"workflow.nextStep": nextWorkflowStep,
[`evaluation.${replyField}`]: replyPayload,
@@ -4044,9 +4270,13 @@ export class ExpertClaimService {
],
});
const filtered = (claims as any[]).filter((c) =>
const tenantFiltered = (claims as any[]).filter((c) =>
claimCaseTouchesClient(c, clientKey),
);
const filtered = await this.filterDamageExpertClaimsByBranch(
tenantFiltered,
actor,
);
// Reconcile stale locks — if no decision was made, also clear assignedForReviewBy
const staleLockToReconcile = filtered.filter(
@@ -4312,7 +4542,7 @@ export class ExpertClaimService {
{
lean: true,
select:
"_id type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval",
"_id branchId initiatedByFieldExpertId type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval",
},
)) as any[];
@@ -4321,9 +4551,31 @@ export class ExpertClaimService {
}
// Scope to this reviewer's insurer via blame party clientId
const scopedBlames = blames.filter((b) =>
const tenantScopedBlames = blames.filter((b) =>
blameCaseTouchesClient(b, clientKey),
);
const scopedBlames = (
await Promise.all(
tenantScopedBlames.map(async (blame) => {
try {
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible(
{
fileMakerId: blame.initiatedByFieldExpertId
? String(blame.initiatedByFieldExpertId)
: null,
fileReviewerId: actor.sub,
caseBranchId: blame.branchId
? String(blame.branchId)
: null,
},
);
return blame;
} catch {
return null;
}
}),
)
).filter((blame): blame is any => blame != null);
if (scopedBlames.length === 0) {
return this.paginateClaimListV2([], query);
@@ -4401,10 +4653,25 @@ export class ExpertClaimService {
): Promise<GetClaimListV2ResponseDto> {
const makerOid = new Types.ObjectId(actor.sub);
const makerBlames = (await this.blameRequestDbService.find(
const ownMakerBlames = (await this.blameRequestDbService.find(
{ isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid },
{ lean: true, select: "_id type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval" },
{ lean: true, select: "_id branchId type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval" },
)) as any[];
const hasBranchScopedFiles = ownMakerBlames.some(
(blame) => !!blame.branchId,
);
const makerBranchId = hasBranchScopedFiles
? await this.fanavaranLocationService.fileMakerBranchId(actor.sub)
: undefined;
if (hasBranchScopedFiles && !makerBranchId) {
throw new ForbiddenException(
"FileMaker account is not assigned to a branch.",
);
}
const makerBlames = ownMakerBlames.filter(
(blame) =>
!blame.branchId || String(blame.branchId) === makerBranchId,
);
if (makerBlames.length === 0) {
return this.paginateClaimListV2([], query);
@@ -4551,7 +4818,12 @@ export class ExpertClaimService {
(doc as Record<string, unknown>).updatedAtFormatted =
`${updatedDate} ${updatedTime}`;
docRec.parties = enrichBlamePartiesForAgreementView(docRec);
docRec.parties = enrichBlamePartiesForAgreementView(docRec).map(
(party) => ({
...party,
participants: sanitizeStoredInquiryParticipants(party.participants),
}),
);
return docRec;
}
@@ -4577,7 +4849,11 @@ export class ExpertClaimService {
if (Array.isArray(parties)) {
out.parties = parties.map((p: any) =>
p && typeof p === "object"
? { ...p, vehicle: this.sanitizeVehicleInquiryForApi(p.vehicle) }
? {
...p,
participants: sanitizeStoredInquiryParticipants(p.participants),
vehicle: this.sanitizeVehicleInquiryForApi(p.vehicle),
}
: p,
);
}
@@ -4834,6 +5110,7 @@ export class ExpertClaimService {
if (actor.role !== RoleEnum.FILE_REVIEWER) {
assertClaimCaseForExpertActor(claim, actor, linkedBlame);
}
await this.assertActorBranchAccess(claim, actor, linkedBlame);
// Variables used both in the gate block and in the detail-build section below
const isDamageExpertPhase =
@@ -5030,6 +5307,10 @@ export class ExpertClaimService {
buildFileLink,
resolveStoredFileUrl,
});
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
history: (claim.damage as any)?.partSelectionHistory,
currentSelectedParts: selectedNormExpert,
});
// Vehicle payload — fall back to blame inquiry if claim vehicle is sparse
let vehiclePayload = claim.vehicle as any;
@@ -5055,7 +5336,11 @@ export class ExpertClaimService {
claim.blameRequestId
? this.blameRequestDbService.find(
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
{ lean: true, select: "type parties expert.decision" },
{
lean: true,
select:
"type parties expert.decision creationMethod expertInitiated registrarInitiated callCenterInitiated initiatedByFieldExpertId initiatedByRegistrarId initiatedByCallCenterId",
},
)
: Promise.resolve([]),
]);
@@ -5090,6 +5375,11 @@ export class ExpertClaimService {
const blameFileContext = blameLean
? this.blameFileContextForExpert(blameLean)
: {};
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
const priceCap =
configuredPriceCap !== null && claimPriceCapAppliesToBlame(linkedBlame)
? configuredPriceCap
: null;
let videoCapture: ClaimDetailV2ResponseDto["videoCapture"] = undefined;
if (videoCaptureRow) {
@@ -5199,6 +5489,7 @@ export class ExpertClaimService {
? this.sanitizeVehicleInquiryForApi(vehiclePayload)
: undefined,
...blameFileContext,
priceCap,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
money: moneyPayload,
@@ -5209,6 +5500,7 @@ export class ExpertClaimService {
: undefined,
carAngles,
damagedParts,
damagedPartsHistory,
awaitingFactorValidation: isFactorValidationPending,
requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval,
fileMakerRejectionCount: (claim as any).fileMakerRejectionCount ?? 0,
@@ -5586,33 +5878,58 @@ export class ExpertClaimService {
);
const mergedExpertAdded = [...existingExpertAdded, ...expertAddedToAppend];
const changedAt = new Date();
const partSelectionRevision = buildDamagedPartSelectionRevision({
revisionId: new Types.ObjectId().toString(),
changedAt,
changedBy: {
actorId: actor.sub,
actorName: actor.fullName,
actorType: "damage_expert",
},
expertProfileSnapshot: damagedPartsEditSnapshot,
previousParts: previousNorm,
selectedParts: nextNorm,
previousMedia: prevMedia,
});
const $set: Record<string, unknown> = {
"damage.selectedParts": nextNorm,
"media.damagedParts": nextMedia,
"damage.expertAddedParts": mergedExpertAdded,
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set,
$push: {
history: {
type: "EXPERT_DAMAGED_PARTS_UPDATED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: new Date(),
metadata: {
previousSelectedParts: previous,
selectedParts: nextNorm,
expertAddedParts: mergedExpertAdded,
...(damagedPartsEditSnapshot && {
expertProfileSnapshot: damagedPartsEditSnapshot,
}),
},
const push: Record<string, unknown> = {
history: {
type: "EXPERT_DAMAGED_PARTS_UPDATED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: changedAt,
metadata: {
previousSelectedParts: previous,
selectedParts: nextNorm,
expertAddedParts: mergedExpertAdded,
...(partSelectionRevision && {
partSelectionRevisionId: partSelectionRevision.revisionId,
removedParts: partSelectionRevision.removedParts,
addedParts: partSelectionRevision.addedParts,
}),
...(damagedPartsEditSnapshot && {
expertProfileSnapshot: damagedPartsEditSnapshot,
}),
},
},
};
if (partSelectionRevision) {
push["damage.partSelectionHistory"] = partSelectionRevision;
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set,
$push: push,
});
return {
@@ -5620,6 +5937,7 @@ export class ExpertClaimService {
selectedParts: nextNorm,
previousSelectedParts: previous,
expertAddedParts: mergedExpertAdded,
partSelectionRevision,
message: "Damaged parts updated successfully.",
};
}

View File

@@ -268,7 +268,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({
summary: "Submit expert damage assessment reply",
description:
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 100,000–10,000,000,000 **Toman**. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 **Toman** (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 1,000,000–100,000,000,000 **Rial**. **V1-only cap:** for user-created V1 files, sum of line `totalPayment` values ≤ 530,000,000 **Rial** (same limit as factor-validation totals across priced + factor lines). V2–V6 files are uncapped. Claim detail exposes the effective `priceCap` (`null` when uncapped). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
@@ -353,7 +353,7 @@ export class ExpertClaimV2Controller {
"**Response:** `claimStatus` = `ClaimStatus` (e.g. APPROVED). `caseStatus` = `ClaimCaseStatus` (e.g. COMPLETED vs insurer-review) — they are not interchangeable.\n\n" +
"**Preconditions:** `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`), `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" +
"**Decisions:** each factor line gets `APPROVED` or `REJECTED`. **Every** decided line must include expert-entered `totalPayment` **or** both `price` and `salary` (factor photos are not read for amounts).\n\n" +
"**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000 Toman**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" +
"**V1-only cap (when every factor line is decided):** for user-created V1 files, sum of **all** reply lines (priced parts + factor lines) must be ≤ **530,000,000 Rial**; otherwise `PRICE_CAP_ERROR` is returned. V2–V6 files are uncapped.\n\n" +
"**Outcomes:**\n" +
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature. V5 instead waits for FileMaker approval.\n" +
"- **Any rejected (repriced):** same completion behavior for now (V5 waits for FileMaker approval).\n" +

View File

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

View File

@@ -46,6 +46,7 @@ import { toJalaliDateAndTime } from "src/helpers/date-jalali";
import { enrichBlamePartiesForAgreementView } from "src/helpers/blame-party-agreement-decision";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
UnifiedFileStatusReportDto,
@@ -84,6 +85,8 @@ import {
localizeTimelineMetadata,
} from "./helper/timeline-fa-labels";
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
import { serializeDamagedPartSelectionHistory } from "src/helpers/claim-damaged-part-audit";
import { fanavaranClaimReferences } from "src/claim-request-management/fanavaran-claim-references";
@Injectable()
export class ExpertInsurerService {
@@ -264,6 +267,18 @@ export class ExpertInsurerService {
return String(aid);
}
private calculateReviewDurationMinutes(
createdAt: Date | string | undefined,
updatedAt: Date | string | undefined,
): number | null {
const startedAtMs = new Date(createdAt as any).getTime();
const reviewedAtMs = new Date(updatedAt as any).getTime();
if (Number.isNaN(startedAtMs) || Number.isNaN(reviewedAtMs)) return null;
const durationMinutes = Math.max(reviewedAtMs - startedAtMs, 0) / 60_000;
return Math.round(durationMinutes * 100) / 100;
}
private mapBlameFileSummaryForInsurerExpert(b: any) {
return {
kind: "blame" as const,
@@ -275,6 +290,10 @@ export class ExpertInsurerService {
blameStatus: b.blameStatus,
createdAt: b.createdAt,
updatedAt: b.updatedAt,
reviewDurationMinutes: this.calculateReviewDurationMinutes(
b.createdAt,
b.updatedAt,
),
};
}
@@ -288,6 +307,10 @@ export class ExpertInsurerService {
claimStatus: c.claimStatus,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
reviewDurationMinutes: this.calculateReviewDurationMinutes(
c.createdAt,
c.updatedAt,
),
};
}
@@ -429,6 +452,7 @@ export class ExpertInsurerService {
if (!party || typeof party !== "object") return party;
return {
...party,
participants: sanitizeStoredInquiryParticipants(party.participants),
person: this.mapPersonForInsurer(party.person, clientNames),
vehicle: this.sanitizeVehicleInquiry(party.vehicle),
};
@@ -502,6 +526,8 @@ export class ExpertInsurerService {
const d = { ...(damage as Record<string, unknown>) };
delete d.selectedOuterParts;
delete d.selectedPartIds;
// Expose the stable, URL-enriched contract instead of raw audit storage.
delete d.partSelectionHistory;
return d;
}
@@ -655,6 +681,10 @@ export class ExpertInsurerService {
catalogLikeKeyFromPart,
buildFileLink,
});
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
history: (claim as any).damage?.partSelectionHistory,
currentSelectedParts: selectedNorm,
});
// Then add damagedParts to the return object
const requiredDocs = claim.requiredDocuments as any;
@@ -757,6 +787,7 @@ export class ExpertInsurerService {
publicId: claim.publicId,
status: claim.status,
claimStatus: claim.claimStatus,
fanavaran: fanavaranClaimReferences(claim),
blameDocumentResendPending: claim.blameDocumentResendPending,
workflow: claim.workflow,
owner: ownerOut,
@@ -775,6 +806,7 @@ export class ExpertInsurerService {
: undefined,
carAngles,
damagedParts,
damagedPartsHistory,
videoCapture,
evaluation: evaluationEnriched,
userRating: claim.userRating,

View File

@@ -89,4 +89,49 @@ describe("insurer timeline Persian labels", () => {
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: "تصویر کارت خودرو خوانا نیست" });
});
});

View File

@@ -190,6 +190,13 @@ export const STEP_KEY_FA_LABELS: Record<string, string> = {
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
@@ -276,10 +283,15 @@ export function localizeTimelineMetadata(event: {
}): 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 } : {}),
};
}

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
import { ForbiddenException } from "@nestjs/common";
import { FanavaranLocationService } from "./fanavaran-location.service";
describe("FanavaranLocationService branch scope", () => {
const makerId = "maker-1";
const reviewerId = "reviewer-1";
const createService = (makerBranch?: string, reviewerBranch?: string) =>
new FanavaranLocationService(
{
findById: jest.fn().mockResolvedValue(
makerBranch ? { branchId: makerBranch } : {},
),
} as any,
{
findById: jest.fn().mockResolvedValue(
reviewerBranch ? { branchId: reviewerBranch } : {},
),
} as any,
);
it("allows a reviewer to view a file from the same branch", async () => {
const service = createService("branch-1", "branch-1");
await expect(
service.assertMakerReviewerBranchCompatible({
fileMakerId: makerId,
fileReviewerId: reviewerId,
}),
).resolves.toBeUndefined();
});
it("rejects a reviewer from another branch", async () => {
const service = createService("branch-1", "branch-2");
await expect(
service.assertMakerReviewerBranchCompatible({
fileMakerId: makerId,
fileReviewerId: reviewerId,
}),
).rejects.toBeInstanceOf(ForbiddenException);
});
it("prefers the immutable case branch snapshot", async () => {
const service = createService("old-branch", "branch-2");
await expect(
service.assertMakerReviewerBranchCompatible({
fileMakerId: makerId,
fileReviewerId: reviewerId,
caseBranchId: "branch-2",
}),
).resolves.toBeUndefined();
});
});

View File

@@ -113,6 +113,54 @@ export class FanavaranLocationService {
}
}
async fileMakerBranchId(
fileMakerId: string | null | undefined,
): Promise<string | undefined> {
if (!fileMakerId) return undefined;
const doc = await this.fileMakerDbService.findById(String(fileMakerId));
const branchId = (doc as any)?.branchId;
return branchId ? String(branchId) : undefined;
}
async fileReviewerBranchId(
fileReviewerId: string | null | undefined,
): Promise<string | undefined> {
if (!fileReviewerId) return undefined;
const doc = await this.fileReviewerDbService.findById(
String(fileReviewerId),
);
const branchId = (doc as any)?.branchId;
return branchId ? String(branchId) : undefined;
}
/**
* V4/V5 case visibility is branch-scoped by the FileMaker who created it.
* Missing branch assignments are denied instead of widening visibility.
*/
async assertMakerReviewerBranchCompatible(input: {
fileMakerId?: string | null;
fileReviewerId?: string | null;
caseBranchId?: string | null;
}): Promise<void> {
const reviewerBranchId = await this.fileReviewerBranchId(
input.fileReviewerId,
);
const caseBranchId =
(input.caseBranchId ? String(input.caseBranchId) : undefined) ??
(await this.fileMakerBranchId(input.fileMakerId));
if (!reviewerBranchId) {
throw new ForbiddenException(
"FileReviewer account is not assigned to a branch.",
);
}
if (!caseBranchId || caseBranchId !== reviewerBranchId) {
throw new ForbiddenException(
"This file belongs to another branch.",
);
}
}
private async loadPrimaryLocationId(
userId: string | null | undefined,
kind: "maker" | "reviewer",

View File

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

View File

@@ -277,6 +277,16 @@ export class FanavaranLookupService {
return this.fetchFromFanavaran(clientKey, url, options);
}
/** GEN.06 VehicleHullAccessoryId — requires hull PolicyId (کد رایانه بیمه‌نامه). */
async vehicleHullDmgAccessoriesByPolicyId(
clientKey: FanavaranClientKey,
policyId: number,
options?: { contractIdOverride?: string },
): Promise<unknown> {
const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicle-hull-policies/${policyId}/dmg-accessories`;
return this.fetchFromFanavaran(clientKey, url, options);
}
async vehicleById(
clientKey: FanavaranClientKey,
vehicleId: number,
@@ -315,6 +325,63 @@ export class FanavaranLookupService {
return this.fetchFromFanavaran(clientKey, url);
}
async getOtherPerson(
clientKey: FanavaranClientKey,
personId: number,
): Promise<unknown> {
const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/other-people/${personId}`;
return this.fetchFromFanavaran(clientKey, url);
}
async createOtherPerson(
clientKey: FanavaranClientKey,
payload: Record<string, unknown>,
): Promise<unknown> {
const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/other-people`;
return this.postToFanavaran(clientKey, url, payload);
}
async postToFanavaran(
clientKey: FanavaranClientKey,
url: string,
payload: Record<string, unknown>,
): Promise<unknown> {
try {
const headers = await this.fanavaranAuthService.getRequestHeaders(
clientKey,
);
this.logger.log(`[${clientKey}] POST Fanavaran: ${url}`);
const response = await firstValueFrom(
this.httpService.post(url, payload, {
headers: {
...headers,
"Content-Type": "application/json",
},
timeout: 20000,
}),
);
this.fanavaranAuthService.clearBackoff(clientKey);
return response.data;
} catch (error) {
this.fanavaranAuthService.registerFailure(clientKey, error);
const message = isAxiosError(error)
? error.response?.data?.Message ||
error.response?.data?.message ||
error.message
: error instanceof Error
? error.message
: "Fanavaran POST request failed";
this.logger.error(
`Fanavaran POST failed for ${clientKey} (${url}): ${message}`,
);
throw new BadGatewayException(String(message));
}
}
async resolveInsuranceCorpId(
clientKey: FanavaranClientKey,
): Promise<number | null> {

View File

@@ -10,6 +10,7 @@ import {
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiQuery,
@@ -57,7 +58,7 @@ export class FanavaranController {
@ApiOperation({
summary: "Preview Fanavaran base claim create payload",
description:
"Builds the GEN.03 payload from local claim/blame data. Fanavaran-sourced PolicyId is resolve-once: first call may inquire and caches on the claim; later calls reuse the cache. Auth token is shared until Asia/Tehran midnight. Pass forceRefreshPolicy=true to re-inquire.",
"GET has no request body. Always rebuilds GEN.03 from live policy + VIN/vehicle inquiries. Does not reuse fanavaranSync lastPayload / PolicyId / UsedId cache. Auth token is still shared until Asia/Tehran midnight.",
})
@ApiParam({
name: "client",
@@ -76,34 +77,30 @@ export class FanavaranController {
@ApiQuery({
name: "forceRefreshPolicy",
required: false,
description:
"When true, ignores cached PolicyId and performs a live Fanavaran policy inquiry again. Do not pass this from normal UI loads.",
deprecated: true,
description: "Ignored. Preview always re-inquires PolicyId.",
})
@ApiQuery({
name: "resolvePolicy",
required: false,
deprecated: true,
description:
"Deprecated. Ignored for cache-busting. PolicyId is resolve-once from fanavaranSync.baseClaim.policyId; use forceRefreshPolicy=true only to re-inquire.",
description: "Ignored. Preview always re-inquires PolicyId.",
})
async preview(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
@Query("debug") debug?: string,
@Query("forceRefreshPolicy") forceRefreshPolicy?: string,
@Query("forceRefreshPolicy") _forceRefreshPolicy?: string,
@Query("resolvePolicy") _resolvePolicy?: string,
) {
const clientKey = this.parseClientParam(client);
// IMPORTANT: resolvePolicy must NOT force a live inquiry. Older UI clients
// send resolvePolicy=true on every preview load; that used to defeat the
// PolicyId cache and re-Login Fanavaran on every click.
return await this.claimRequestManagementService.previewFanavaranSubmitV2(
claimCaseId,
clientKey,
{
debug: debug === "1" || debug === "true",
forceRefreshPolicy:
forceRefreshPolicy === "1" || forceRefreshPolicy === "true",
forceRefreshPolicy: true,
forceRefreshUsedId: true,
requirePolicyId: false,
},
);
@@ -113,7 +110,7 @@ export class FanavaranController {
@ApiOperation({
summary: "Submit Fanavaran base claim create request",
description:
"Authenticates with the selected tenant credentials and submits the GEN.03 base claim create request. Stores returned Id as claimId and ClaimNo when present.",
"Swagger JSON body is optional. Always rebuilds GEN.03 from live inquiries, then POSTs that rebuilt payload. Pasting a previous preview JSON does not skip inquiry. Only user-entry keys are applied from the body (EstimateAmount, licence, dates, address, police report). PolicyId and AccidentVehicleUsedId in pasted JSON are ignored.",
})
@ApiParam({
name: "client",
@@ -124,6 +121,12 @@ export class FanavaranController {
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
@ApiBody({
required: false,
description:
"Optional. Paste previous preview JSON if you want to tweak user-entry fields. Lookup ids in that JSON are ignored.",
schema: { type: "object", additionalProperties: true, example: {} },
})
async submit(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
@@ -141,7 +144,7 @@ export class FanavaranController {
@ApiOperation({
summary: "Preview Fanavaran damage-case payload",
description:
"Builds the GEN.12 damaged vehicle/person case payload without calling Fanavaran. Requires selected damaged parts; submit requires a Fanavaran claimId.",
"GET has no request body. Always rebuilds GEN.12 from live VIN/vehicle/driver inquiries. Does not reuse lastPayload / plaqueKindId / DriverId cache.",
})
@ApiParam({
name: "client",
@@ -167,7 +170,7 @@ export class FanavaranController {
@ApiOperation({
summary: "Submit Fanavaran damage-case request",
description:
"Submits the GEN.12 dmg-cases request. If base claim (claimId) is missing, soft-ensures GEN.03 base claim first, then submits damage. Skips when dmgCaseId already exists.",
"Swagger JSON body is optional. Always rebuilds GEN.12 from live VIN/vehicle/driver inquiries, then POSTs that rebuilt payload. Pasting a previous preview JSON does not skip inquiry. Only Desc, EstimateAmount, LicenceNo, LicenceIssuDate, DriverIsOwner are taken from the body. PlaqueKindId, PlaqueSampleId, VehicleKindId, DriverId, InsuranceCorpId in pasted JSON are ignored.",
})
@ApiParam({
name: "client",
@@ -178,6 +181,12 @@ export class FanavaranController {
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
@ApiBody({
required: false,
description:
"Optional. Paste previous preview JSON if you want to tweak Desc / licence / estimate. Lookup-filter ids in that JSON are ignored.",
schema: { type: "object", additionalProperties: true, example: {} },
})
async submitDamageCase(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
@@ -247,7 +256,7 @@ export class FanavaranController {
@ApiOperation({
summary: "Preview Fanavaran expertise payload",
description:
"Builds the GEN.08 expertise payload from the active damage expert reply, price-drop data, and Fanavaran lookup mappings without calling Fanavaran.",
"GET has no request body. Rebuilds GEN.08 from the active expert reply and lookups on every call. Does not reuse lastPayload cache.",
})
@ApiParam({
name: "client",
@@ -273,7 +282,7 @@ export class FanavaranController {
@ApiOperation({
summary: "Submit Fanavaran expertise request",
description:
"Submits the GEN.08 expertise payload for a claim with existing Fanavaran claimId and dmgCaseId. Stores returned Id as local expertiseId.",
"Swagger JSON body is optional. Always rebuilds GEN.08 from the local expert reply and lookups, then POSTs that rebuilt payload. Pasting a previous preview JSON does not skip rebuild. Only money/date fields are taken from the body. DmgCaseId, ClaimExpertId, and DmgSections in pasted JSON are ignored.",
})
@ApiParam({
name: "client",
@@ -284,6 +293,12 @@ export class FanavaranController {
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
@ApiBody({
required: false,
description:
"Optional. Paste previous preview JSON if you want to tweak money/date fields. DmgCaseId / ClaimExpertId / DmgSections in that JSON are ignored.",
schema: { type: "object", additionalProperties: true, example: {} },
})
async submitExpertise(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,

View File

@@ -8,6 +8,7 @@ export enum FanavaranAuditStep {
POLICY_INQUIRY = "POLICY_INQUIRY",
BUILD_PAYLOAD = "BUILD_PAYLOAD",
SUBMIT_CLAIM = "SUBMIT_CLAIM",
CREATE_OTHER_PERSON = "CREATE_OTHER_PERSON",
SUBMIT_DAMAGE_CASE = "SUBMIT_DAMAGE_CASE",
SUBMIT_ATTACHMENT = "SUBMIT_ATTACHMENT",
SUBMIT_EXPERTISE = "SUBMIT_EXPERTISE",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,8 +11,8 @@ type ExpertReplyPricingPart = {
daghi?: { option?: unknown; price?: unknown };
};
export const EXPERT_REPLY_MIN_AMOUNT_TOMAN = 100_000;
export const EXPERT_REPLY_MAX_AMOUNT_TOMAN = 10_000_000_000;
export const EXPERT_REPLY_MIN_AMOUNT_TOMAN = 1_000_000; // It is Rial from now on but we managed to keep the variables named as *Toman so that we won't have conflicts elsewhere.
export const EXPERT_REPLY_MAX_AMOUNT_TOMAN = 100_000_000_000; // It it Rial from now on
export type ExpertReplyPricingValidationError = {
message: string;
@@ -150,7 +150,7 @@ export function getExpertReplyPricingValidationError(
? "دستمزد"
: "مبلغ کل";
return localizedError(
`${fieldName} قطعه ${String(part.partId)} باید به‌صورت مبلغ صحیح و غیرمنفی (تومان) وارد شود.`,
`${fieldName} قطعه ${String(part.partId)} باید به‌صورت مبلغ صحیح و غیرمنفی (ریال) وارد شود.`,
`parts[${index}].${field}`,
"invalid_amount",
String(part.partId),

View File

@@ -18,9 +18,22 @@ describe("parseIranLocalDateTime", () => {
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
});
it("applies a separately supplied time to an explicit-offset date", () => {
const instant = parseIranLocalDateTime(
"2025-05-16T00:00:00.000Z",
"18:28",
);
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
});
it("combines Date calendar day in Iran with accidentTime", () => {
const dateOnly = new Date("2025-05-16T00:00:00.000Z");
const instant = parseIranLocalDateTime(dateOnly, "18:28");
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
});
it("rejects out-of-range hours and minutes", () => {
expect(parseIranLocalDateTime("2025-05-16", "24:00")).toBeNull();
expect(parseIranLocalDateTime("2025-05-16", "12:60")).toBeNull();
});
});

View File

@@ -26,6 +26,7 @@ function normalizeTimeToHms(time: string): string | null {
const hh = m[1].padStart(2, "0");
const mm = m[2];
const ss = m[3] ?? "00";
if (Number(hh) > 23 || Number(mm) > 59 || Number(ss) > 59) return null;
return `${hh}:${mm}:${ss}`;
}
@@ -56,7 +57,12 @@ export function parseIranLocalDateTime(
if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(raw)) {
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
if (Number.isNaN(d.getTime())) return null;
const cleanTime = (time ?? "").trim();
if (!cleanTime) return d;
const timeHms = normalizeTimeToHms(cleanTime);
if (!timeHms) return null;
return parseIranLocalIso(gregorianDateInIran(d), timeHms);
}
const naive = raw.match(NAIVE_ISO_DATETIME_RE);

View File

@@ -2,6 +2,7 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { buildCoefficientsFromPartSeverities } from "./claim-price-drop";
import {
catalogPartIdFromSelectedPart,
normalizeDamageSelectedParts,
resolveCatalogPartByPartId,
resolvePartForExpertReply,
resolveSelectedPartByPartId,
@@ -43,36 +44,36 @@ describe("resolveSelectedPartByPartId", () => {
});
it("resolves catalog id from outer catalog when not on claim", () => {
const hit = resolveCatalogPartByPartId(201, ClaimVehicleTypeV2.HATCHBACK);
expect(hit?.id).toBe(201);
expect(hit?.side).toBe("left");
expect(hit?.catalogKey).toBe("left_backfender");
const hit = resolveCatalogPartByPartId(36, ClaimVehicleTypeV2.HATCHBACK);
expect(hit?.id).toBe(36);
expect(hit?.side).toBe("");
expect(hit?.catalogKey).toBe("36");
});
it("resolvePartForExpertReply uses catalog for new expert line", () => {
const hit = resolvePartForExpertReply(
201,
36,
[],
ClaimVehicleTypeV2.HATCHBACK,
);
expect(hit?.id).toBe(201);
expect(hit?.id).toBe(36);
});
it("sanitize fixes Persian side and re-hydrates from catalog id", () => {
const fixed = sanitizeDamageSelectedPartV2(
{
id: 201,
name: "گلگیر عقب (چپ)",
id: 36,
name: "گلگير عقب سمت راننده",
side: "چپ",
label_fa: "",
catalogKey: "چپ",
},
ClaimVehicleTypeV2.HATCHBACK,
);
expect(fixed.side).toBe("left");
expect(fixed.name).toBe("backfender");
expect(fixed.catalogKey).toBe("left_backfender");
expect(catalogPartIdFromSelectedPart(fixed)).toBe(201);
expect(fixed.side).toBe("");
expect(fixed.name).toBe("36");
expect(fixed.catalogKey).toBe("36");
expect(catalogPartIdFromSelectedPart(fixed)).toBe(36);
});
it("internal parts have null catalog partId", () => {
@@ -86,34 +87,59 @@ describe("resolveSelectedPartByPartId", () => {
expect(catalogPartIdFromSelectedPart(row)).toBeNull();
});
it("preserves a live Fanavaran id that is newer than the static snapshot", () => {
const [row] = normalizeDamageSelectedParts(
[{
id: 96,
name: "96",
side: "",
label_fa: "تیوتر",
catalogKey: "96",
}],
ClaimVehicleTypeV2.SEDAN,
);
expect(row).toEqual({
id: 96,
name: "96",
side: "",
label_fa: "تیوتر",
catalogKey: "96",
});
expect(catalogPartIdFromSelectedPart(row)).toBe(96);
expect(resolvePartForExpertReply(96, [row], ClaimVehicleTypeV2.SEDAN)).toBe(
row,
);
});
it("recovers outer part wrongly stored as internal with Persian label", () => {
const fixed = sanitizeDamageSelectedPartV2(
{
id: null,
name: "گلگیر عقب (چپ)",
name: "گلگير عقب سمت راننده",
side: "internal",
label_fa: "گلگیر عقب (چپ)",
label_fa: "گلگير عقب سمت راننده",
},
ClaimVehicleTypeV2.HATCHBACK,
);
expect(fixed.id).toBe(201);
expect(fixed.side).toBe("left");
expect(fixed.name).toBe("backfender");
expect(fixed.catalogKey).toBe("left_backfender");
expect(fixed.id).toBe(36);
expect(fixed.side).toBe("");
expect(fixed.name).toBe("36");
expect(fixed.catalogKey).toBe("36");
});
it("price drop resolves catalog id when claim row is corrupt", () => {
const corrupt: DamageSelectedPartV2[] = [
{
id: null,
name: "گلگیر عقب (چپ)",
name: "گلگير عقب سمت راننده",
side: "internal",
label_fa: "گلگیر عقب (چپ)",
label_fa: "گلگير عقب سمت راننده",
},
];
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
corrupt,
[{ partId: 201, severity: "Minor" }],
[{ partId: 36, severity: "Minor" }],
ClaimVehicleTypeV2.HATCHBACK,
);
expect(errors).toHaveLength(0);

View File

@@ -466,6 +466,28 @@ export function sanitizeDamageSelectedPartV2(
}
}
// Fanavaran serves this catalog live and can add ids that do not exist in
// our fallback snapshot yet. Live rows are persisted with their numeric id
// repeated as `name`/`catalogKey` and an empty side. Preserve that identity
// instead of downgrading the row to an internal free-text part. Otherwise a
// newly-added Fanavaran part (for example id 96) disappears from the expert
// reply payload and later fails with PART_NOT_ON_CLAIM.
const numericCatalogIdentity =
id != null &&
Number.isFinite(id) &&
Number(id) > 0 &&
sideTrim === "" &&
(nameTrim === String(id) || String(catalogKey ?? "").trim() === String(id));
if (numericCatalogIdentity) {
return {
id: Number(id),
name: String(id),
side: "",
label_fa: labelTrim || nameTrim || String(id),
catalogKey: String(id),
};
}
if (isInternalDamageSide(side)) {
return {
id: null,

View File

@@ -1,7 +1,12 @@
import {
canReuseCachedFanavaranVehicleIds,
collectPartyVinCandidates,
fanavaranIdOrConfigDefault,
fanavaranPayloadMatchesDamagedIdentity,
fanavaranVehicleMatchesDamagedIdentity,
filterPoliciesByLine,
applyFanavaranManualPayloadOverrides,
resolveDamageCasePlaqueLookupIds,
insuranceLineLabel,
parseLastCarPolicyInput,
pickFanavaranPlaqueLookupFields,
@@ -10,6 +15,7 @@ import {
pickVinFromPartyVehicle,
plaqueLetterFromMiddleCode,
plaqueMatchesVehicle,
selectFanavaranVehicleForDamageCase,
selectLastAmongCarMatches,
toAppPlaque,
vehicleMatchesCar,
@@ -311,4 +317,188 @@ describe("fanavaran last car policy", () => {
it("returns null when there are no matches", () => {
expect(selectLastAmongCarMatches([], today)).toBeNull();
});
const damagedIdentity = {
vin: "IRPC941V2BD798656",
chassis: "NAS431100E5798656",
plaque: { left: "59", letter: "ی", right: "419", serial: "78" },
policyCINumber: "16040446424",
};
const culpritVehicle = {
Id: 475449,
VIN: "IRFC891V7D2301065",
ChassisNo: "NAAP41FD5BJ301065",
VehicleKindId: 6953,
VersionNo: 3,
PlaqueKindId: 8,
PlaqueSampleId: 10,
PlaqueLeftNo: "56",
PlaqueMiddleCodeId: 5,
PlaqueRightNo: "394",
PlaqueSerial: "66",
PlaqueNo: "394د56",
};
it("does not treat the culprit vehicle as the damaged car", () => {
expect(
fanavaranVehicleMatchesDamagedIdentity(culpritVehicle, damagedIdentity),
).toBe(false);
expect(
selectFanavaranVehicleForDamageCase(
[
culpritVehicle,
{ Id: 9, VIN: "OTHER", PlaqueKindId: 15, PlaqueSampleId: 24 },
],
damagedIdentity,
),
).toBeNull();
});
it("ignores a PlaqueKindId 15 row that does not share VIN or plate", () => {
expect(
selectFanavaranVehicleForDamageCase(
[
{ Id: 1, VIN: "IRPC941V2BD798656" },
{
Id: 2,
VIN: "IRFC901V119493683",
PlaqueKindId: 15,
PlaqueSampleId: 24,
PlaqueLeftNo: "62",
PlaqueMiddleCodeId: 14,
PlaqueRightNo: "278",
PlaqueSerial: "50",
},
],
damagedIdentity,
),
).toEqual({ Id: 1, VIN: "IRPC941V2BD798656" });
});
it("requires plate agreement when the inquiry row has plaque parts", () => {
expect(
selectFanavaranVehicleForDamageCase(
[
{
Id: 1,
VIN: "IRPC941V2BD798656",
PlaqueKindId: 15,
PlaqueLeftNo: "56",
PlaqueMiddleCodeId: 5,
PlaqueRightNo: "394",
PlaqueSerial: "66",
},
],
damagedIdentity,
),
).toBeNull();
});
it("picks the newest matching version of the damaged VIN and plate", () => {
expect(
selectFanavaranVehicleForDamageCase(
[
{
Id: 10,
VIN: "IRPC941V2BD798656",
ChassisNo: "NAS431100E5798656",
VersionNo: 1,
PlaqueKindId: 15,
PlaqueSampleId: 24,
PlaqueLeftNo: "59",
PlaqueMiddleCodeId: 16,
PlaqueRightNo: "419",
PlaqueSerial: "78",
},
{
Id: 10,
VIN: "IRPC941V2BD798656",
ChassisNo: "NAS431100E5798656",
VersionNo: 3,
PlaqueKindId: 8,
PlaqueSampleId: 10,
PlaqueLeftNo: "59",
PlaqueMiddleCodeId: 16,
PlaqueRightNo: "419",
PlaqueSerial: "78",
},
],
damagedIdentity,
)?.PlaqueKindId,
).toBe(8);
});
it("rejects cached payload ids from a different VIN, plate, or CI number", () => {
expect(
fanavaranPayloadMatchesDamagedIdentity(
{
VIN: "IRFC891V7D2301065",
ChassisNo: "NAAP41FD5BJ301065",
PolicyCINumber: "16018466143",
PlaqueKindId: 8,
PlaqueLeftNo: "56",
PlaqueMiddleCodeId: 5,
PlaqueRightNo: "394",
PlaqueSerial: "66",
},
damagedIdentity,
),
).toBe(false);
expect(canReuseCachedFanavaranVehicleIds("config-default")).toBe(false);
expect(canReuseCachedFanavaranVehicleIds("vehicle-inquiry")).toBe(true);
expect(canReuseCachedFanavaranVehicleIds(undefined)).toBe(false);
});
it("accepts cache only when VIN, plate, and CI number all agree", () => {
expect(
fanavaranPayloadMatchesDamagedIdentity(
{
VIN: "IRPC941V2BD798656",
ChassisNo: "NAS431100E5798656",
PolicyCINumber: "16040446424",
PlaqueLeftNo: "59",
PlaqueMiddleCodeId: 16,
PlaqueRightNo: "419",
PlaqueSerial: "78",
PlaqueKindId: 8,
},
damagedIdentity,
),
).toBe(true);
});
it("uses national plaque 8/10 when the damaged VIN is unknown to Fanavaran", () => {
expect(resolveDamageCasePlaqueLookupIds(null)).toEqual({
kindId: 8,
sampleId: 10,
source: "national-plate-default",
});
});
it("keeps live PlaqueKindId 8 when Swagger body still has stale 15", () => {
expect(
applyFanavaranManualPayloadOverrides(
{
PlaqueKindId: 8,
PlaqueSampleId: 10,
VehicleKindId: 6704,
AccidentVehicleUsedId: 1,
Desc: "سپر جلو",
},
{
PlaqueKindId: 15,
PlaqueSampleId: 24,
Desc: "سپر جلو (edited)",
},
["Desc", "EstimateAmount", "LicenceNo"],
),
).toEqual({
PlaqueKindId: 8,
PlaqueSampleId: 10,
VehicleKindId: 6704,
AccidentVehicleUsedId: 1,
Desc: "سپر جلو (edited)",
});
});
});

View File

@@ -262,6 +262,242 @@ export function pickFanavaranVehicleFromInquiry(
return asObjectRecord(inquired);
}
export type FanavaranDamagedVehicleIdentity = {
vin?: string | null;
chassis?: string | null;
plaque?: FanavaranPlaqueParts | null;
policyCINumber?: string | null;
vehicleKindId?: number | null;
};
export function asVehicleInquiryRows(
inquired: unknown,
): Record<string, unknown>[] {
if (Array.isArray(inquired)) {
return inquired
.map((row) => asObjectRecord(row))
.filter((row): row is Record<string, unknown> => row != null);
}
const one = asObjectRecord(inquired);
return one ? [one] : [];
}
export function normalizePolicyCINumber(value: unknown): string {
return toEnglishDigits(value).replace(/\s+/g, "").trim();
}
function vehicleHasPlaqueParts(vehicle: Record<string, unknown>): boolean {
return Boolean(
asPlaqueText(vehicle.PlaqueLeftNo ?? vehicle.plaqueLeftNo) &&
(parseFanavaranId(
vehicle.PlaqueMiddleCodeId ?? vehicle.plaqueMiddleCodeId,
) != null ||
String(vehicle.PlaqueLetter ?? vehicle.plaqueLetter ?? "").trim()) &&
asPlaqueText(vehicle.PlaqueRightNo ?? vehicle.plaqueRightNo) &&
asPlaqueText(vehicle.PlaqueSerial ?? vehicle.plaqueSerial),
);
}
function vinEquals(left: unknown, right: unknown): boolean {
const a = normalizeVin(left);
const b = normalizeVin(right);
return Boolean(a && b && a === b);
}
/**
* Same physical car: VIN and plaque must not contradict the damaged-party
* inquiry. A row that only "has PlaqueKindId" is not enough.
*/
export function fanavaranVehicleMatchesDamagedIdentity(
vehicle: Record<string, unknown> | null,
identity: FanavaranDamagedVehicleIdentity,
): boolean {
if (!vehicle) return false;
const identityVin = normalizeVin(identity.vin);
const identityChassis = normalizeVin(identity.chassis);
const vehicleVin = pickVehicleVin(vehicle);
const vehicleChassis = normalizeVin(
vehicle.ChassisNo ?? vehicle.chassisNo ?? vehicle.ShsNum,
);
const vinMatched = Boolean(
identityVin &&
(vinEquals(vehicleVin, identityVin) ||
vinEquals(vehicleChassis, identityVin)),
);
const chassisMatched = Boolean(
identityChassis &&
(vinEquals(vehicleVin, identityChassis) ||
vinEquals(vehicleChassis, identityChassis)),
);
if (identityVin && vehicleVin && !vinMatched && !chassisMatched) {
return false;
}
const plaqueMatched = Boolean(
identity.plaque && plaqueMatchesVehicle(identity.plaque, vehicle),
);
if (identity.plaque && vehicleHasPlaqueParts(vehicle) && !plaqueMatched) {
return false;
}
return vinMatched || chassisMatched || plaqueMatched;
}
export function fanavaranPayloadMatchesDamagedIdentity(
payload: Record<string, unknown> | null,
identity: FanavaranDamagedVehicleIdentity,
): boolean {
if (!payload) return false;
const payloadVin = normalizeVin(payload.VIN ?? payload.vin);
const identityVin = normalizeVin(identity.vin);
if (payloadVin && identityVin && payloadVin !== identityVin) return false;
const payloadChassis = normalizeVin(
payload.ChassisNo ?? payload.chassisNo,
);
const identityChassis = normalizeVin(identity.chassis);
if (payloadChassis && identityChassis && payloadChassis !== identityChassis) {
return false;
}
const payloadCI = normalizePolicyCINumber(payload.PolicyCINumber);
const identityCI = normalizePolicyCINumber(identity.policyCINumber);
if (payloadCI && identityCI && payloadCI !== identityCI) return false;
if (identity.plaque && vehicleHasPlaqueParts(payload)) {
if (!plaqueMatchesVehicle(identity.plaque, payload)) return false;
}
const vinMatched = Boolean(payloadVin && identityVin && payloadVin === identityVin);
const chassisMatched = Boolean(
payloadChassis && identityChassis && payloadChassis === identityChassis,
);
const ciMatched = Boolean(payloadCI && identityCI && payloadCI === identityCI);
const plaqueMatched = Boolean(
identity.plaque && plaqueMatchesVehicle(identity.plaque, payload),
);
return vinMatched || chassisMatched || ciMatched || plaqueMatched;
}
export function canReuseCachedFanavaranVehicleIds(source: unknown): boolean {
return source === "vehicle-inquiry" || source === "vin-inquiry" || source === "vehicle-get";
}
/** Proven Parsian national (چهار تکه) plaque codebook ids. */
export const FANAVARAN_NATIONAL_PLAQUE_KIND_ID = 8;
export const FANAVARAN_NATIONAL_PLAQUE_SAMPLE_ID = 10;
export const FANAVARAN_DAMAGE_MANUAL_OVERRIDE_KEYS = [
"Desc",
"EstimateAmount",
"LicenceNo",
"LicenceIssuDate",
"DriverIsOwner",
] as const;
export const FANAVARAN_EXPERTISE_MANUAL_OVERRIDE_KEYS = [
"RepairWage",
"ComponentReplacementCost",
"WasteValue",
"DmgAssessmentDate",
"InspectionTime",
"DamagedVehicleCurrentPrice",
"DropAmountAdditionsDeductions",
] as const;
export const FANAVARAN_BASE_MANUAL_OVERRIDE_KEYS = [
"EstimateAmount",
"CulpritLicenceNo",
"CulpritLicenceIssuDate",
"AccidentLocationAddress",
"AccidentDate",
"AnnouncementDate",
"DocReceivedDate",
"AccidentTime",
"PoliceReportDesc",
"PoliceReportSeri",
"PoliceReportSerial",
] as const;
/**
* Swagger POST body is optional user-entry tweaks only.
* Inquiry / lookup-filter ids always come from the live rebuilt payload.
*/
export function applyFanavaranManualPayloadOverrides(
built: Record<string, unknown>,
body: Record<string, unknown> | null | undefined,
keys: readonly string[],
): Record<string, unknown> {
if (!body) return built;
const next = { ...built };
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
if (body[key] === undefined) continue;
next[key] = body[key];
}
return next;
}
/**
* Plaque kind/sample only from a matched Fanavaran vehicle record.
* CII/ESG inquiry, unsourced cache, and tenant defaults of 15 must not win.
* National 4-part plates fall back to 8/10 (lookup-filter safe on Parsian).
*/
export function resolveDamageCasePlaqueLookupIds(matchedVehicle: Record<
string,
unknown
> | null): {
kindId: number;
sampleId: number;
source: "vehicle-inquiry" | "national-plate-default";
} {
const plaque = pickFanavaranPlaqueLookupFields(matchedVehicle);
if (plaque.kindId != null) {
return {
kindId: plaque.kindId,
sampleId: plaque.sampleId ?? FANAVARAN_NATIONAL_PLAQUE_SAMPLE_ID,
source: "vehicle-inquiry",
};
}
return {
kindId: FANAVARAN_NATIONAL_PLAQUE_KIND_ID,
sampleId: FANAVARAN_NATIONAL_PLAQUE_SAMPLE_ID,
source: "national-plate-default",
};
}
/**
* Pick the VIN-inquiry / vehicle-GET row that is the damaged car.
* Newest VersionNo wins among matches; PlaqueKindId is a tie-break only.
*/
export function selectFanavaranVehicleForDamageCase(
inquired: unknown,
identity: FanavaranDamagedVehicleIdentity,
): Record<string, unknown> | null {
const matches = asVehicleInquiryRows(inquired).filter((row) =>
fanavaranVehicleMatchesDamagedIdentity(row, identity),
);
if (matches.length === 0) return null;
return matches.reduce((current, candidate) => {
const currentVersion = parseFanavaranId(current.VersionNo) ?? 0;
const candidateVersion = parseFanavaranId(candidate.VersionNo) ?? 0;
if (candidateVersion !== currentVersion) {
return candidateVersion > currentVersion ? candidate : current;
}
const currentHasKind =
pickFanavaranPlaqueLookupFields(current).kindId != null;
const candidateHasKind =
pickFanavaranPlaqueLookupFields(candidate).kindId != null;
if (candidateHasKind && !currentHasKind) return candidate;
return current;
});
}
export function pickVinFromPartyVehicle(party: {
vehicle?: { vin?: unknown; inquiry?: unknown };
} | null | undefined): string | null {

View File

@@ -36,6 +36,99 @@ export class LookupsController {
return await this.lookupsService.getAccidentCauses();
}
@Get("vehicle-hull-accident-types")
@ApiOperation({
summary: "Fanavaran GEN.03 hull accident type lookup",
description:
"Returns values for base-claim field AccidentTypeId from car/base-info/vehicle-hull-accident-types (not the Tejarat static accident-type list).",
})
@ApiOkResponse({
description: "Returns Fanavaran vehicle hull accident types",
schema: { type: "array", items: { type: "object" } },
})
async getVehicleHullAccidentTypes() {
return await this.lookupsService.getVehicleHullAccidentTypes();
}
@Get("vehicle-hull-accident-culprit-type")
@ApiOperation({
summary: "Fanavaran GEN.03 hull culprit type lookup",
description:
"Returns values for base-claim field CulpritTypeId from car/code-list/vehicle-hull-accident-culprit-type (not accident-culprit-type used for ثالث).",
})
@ApiOkResponse({
description: "Returns Fanavaran vehicle hull accident culprit types",
schema: { type: "array", items: { type: "object" } },
})
async getVehicleHullAccidentCulpritType() {
return await this.lookupsService.getVehicleHullAccidentCulpritType();
}
@Get("detection-accident-culprits")
@ApiOperation({
summary: "Fanavaran GEN.03 hull authority that determined the culprit",
description:
"Returns values for base-claim field AuthorityCulpritId from car/base-info/detection-accident-culprits.",
})
@ApiOkResponse({
description: "Returns Fanavaran detection-accident-culprits rows",
schema: { type: "array", items: { type: "object" } },
})
async getDetectionAccidentCulprits() {
return await this.lookupsService.getDetectionAccidentCulprits();
}
@Get("vehicle-hull-dmg-kind")
@ApiOperation({
summary: "Fanavaran GEN.06 hull damage kind lookup",
description:
"Returns values for DmgSections[].DmgKindId from car/code-list/vehicle-hull-dmg-kind.",
})
@ApiOkResponse({
description: "Returns Fanavaran vehicle hull damage kinds",
schema: { type: "array", items: { type: "object" } },
})
async getVehicleHullDmgKind() {
return await this.lookupsService.getVehicleHullDmgKind();
}
@Get("vehicle-hull-dmg-cost-kinds")
@ApiOperation({
summary: "Fanavaran GEN.06 hull damage cost kind lookup",
description:
"Returns values for DmgSections[].DmgSectionCosts[].DmgCostKindId from car/base-info/vehicle-hull-dmg-cost-kinds.",
})
@ApiOkResponse({
description: "Returns Fanavaran vehicle hull damage cost kinds",
schema: { type: "array", items: { type: "object" } },
})
async getVehicleHullDmgCostKinds() {
return await this.lookupsService.getVehicleHullDmgCostKinds();
}
@Get("vehicle-hull-dmg-accessories/:policyId")
@ApiOperation({
summary: "Fanavaran GEN.06 hull policy damage accessories",
description:
"Returns rows for DmgSections[].VehicleHullAccessoryId from car/vehicle-hull-policies/{policyId}/dmg-accessories. PolicyId is required (Fanavaran: کد رایانه ریسورس).",
})
@ApiParam({
name: "policyId",
description: "Fanavaran hull policy Id (same as GEN.03 PolicyId)",
example: 13764610,
})
@ApiOkResponse({
description: "Returns Fanavaran vehicle hull damage accessories for the policy",
schema: { type: "array", items: { type: "object" } },
})
async getVehicleHullDmgAccessoriesByPolicyId(
@Param("policyId", ParseIntPipe) policyId: number,
) {
return await this.lookupsService.getVehicleHullDmgAccessoriesByPolicyId(
policyId,
);
}
@Get("accident-report-type")
@ApiOkResponse({
description: "Returns accident report type lookup data",
@@ -327,6 +420,90 @@ export class LookupsController {
return await this.lookupsService.getDmgBusinessLine();
}
@Get("gender")
@ApiOperation({
summary: "Fanavaran GEN.44 gender lookup",
description:
"Returns values for other-people payload field GenderId from common/code-list/gender.",
})
@ApiOkResponse({
description: "Returns Fanavaran gender lookup data",
schema: { type: "array", items: { type: "object" } },
})
async getGender() {
return await this.lookupsService.getGender();
}
@Get("marital-status")
@ApiOperation({
summary: "Fanavaran GEN.44 marital status lookup",
description:
"Returns values for other-people payload field MaritalStatus from common/code-list/marital-status.",
})
@ApiOkResponse({
description: "Returns Fanavaran marital status lookup data",
schema: { type: "array", items: { type: "object" } },
})
async getMaritalStatus() {
return await this.lookupsService.getMaritalStatus();
}
@Get("ans")
@ApiOperation({
summary: "Fanavaran GEN.44 yes/no lookup",
description:
"Returns values for other-people fields IsIranian / IsValid / IsVerified from common/code-list/ans.",
})
@ApiOkResponse({
description: "Returns Fanavaran yes/no (ans) lookup data",
schema: { type: "array", items: { type: "object" } },
})
async getAns() {
return await this.lookupsService.getAns();
}
@Get("countries")
@ApiOperation({
summary: "Fanavaran GEN.44 countries lookup",
description:
"Returns values for other-people payload field NationalityId from common/base-info/countries.",
})
@ApiOkResponse({
description: "Returns Fanavaran countries lookup data",
schema: { type: "array", items: { type: "object" } },
})
async getCountries() {
return await this.lookupsService.getCountries();
}
@Get("person-kind")
@ApiOperation({
summary: "Fanavaran GEN.44 person kind lookup",
description:
"Returns values for other-people payload field PersonKindId from common/code-list/person-kind.",
})
@ApiOkResponse({
description: "Returns Fanavaran person kind lookup data",
schema: { type: "array", items: { type: "object" } },
})
async getPersonKind() {
return await this.lookupsService.getPersonKind();
}
@Get("cii-validation-status")
@ApiOperation({
summary: "Fanavaran GEN.44 CII validation status lookup",
description:
"Returns values for other-people response fields CIIValidationStatus / CIIMobileStatus from common/code-list/cii-validation-status.",
})
@ApiOkResponse({
description: "Returns Fanavaran CII validation status lookup data",
schema: { type: "array", items: { type: "object" } },
})
async getCiiValidationStatus() {
return await this.lookupsService.getCiiValidationStatus();
}
@Get("inquiry-by-vin")
@ApiOperation({
summary: "Fanavaran vehicle inquiry by VIN",
@@ -382,6 +559,25 @@ export class LookupsController {
});
}
@Get("other-people/:personId")
@ApiOperation({
summary: "Fanavaran GEN.44 other person by id",
description:
"GET common/other-people/{Id}. Use after parties inquiry misses and GEN.44 create, or to inspect a known person Id.",
})
@ApiParam({
name: "personId",
description: "Fanavaran other-people Id",
example: 4553876,
})
@ApiOkResponse({
description: "Returns the Fanavaran other-person record",
schema: { type: "object" },
})
async otherPersonById(@Param("personId", ParseIntPipe) personId: number) {
return await this.lookupsService.otherPersonById(personId);
}
@Get("fanavaran")
@ApiOperation({
summary: "List configured Fanavaran remote lookups",

View File

@@ -106,6 +106,40 @@ export class LookupsService {
return await this.getClientRemoteLookup("accident-causes");
}
async getVehicleHullAccidentTypes(): Promise<any> {
return await this.getClientRemoteLookup("vehicle-hull-accident-types");
}
async getVehicleHullAccidentCulpritType(): Promise<any> {
return await this.getClientRemoteLookup("vehicle-hull-accident-culprit-type");
}
async getDetectionAccidentCulprits(): Promise<any> {
return await this.getClientRemoteLookup("detection-accident-culprits");
}
async getVehicleHullDmgKind(): Promise<any> {
return await this.getClientRemoteLookup("vehicle-hull-dmg-kind");
}
async getVehicleHullDmgCostKinds(): Promise<any> {
return await this.getClientRemoteLookup("vehicle-hull-dmg-cost-kinds");
}
async getVehicleHullDmgAccessoriesByPolicyId(policyId: number): Promise<unknown> {
const clientKey = this.activeClientKey();
return this.fanavaranLookupService.vehicleHullDmgAccessoriesByPolicyId(
clientKey,
policyId,
{
contractIdOverride: resolveFanavaranProductContractId(
clientKey,
"car-body",
),
},
);
}
async getAccidentReportType(): Promise<any> {
return await this.getClientRemoteLookup("accident-report-type");
}
@@ -182,6 +216,44 @@ export class LookupsService {
return await this.getClientRemoteLookup("dmg-business-line");
}
async getGender(): Promise<any> {
return await this.getClientRemoteLookup("gender");
}
async getMaritalStatus(): Promise<any> {
return await this.getClientRemoteLookup("marital-status");
}
async getAns(): Promise<any> {
return await this.getClientRemoteLookup("ans");
}
async getCountries(): Promise<any> {
return await this.getClientRemoteLookup("countries");
}
async getPersonKind(): Promise<any> {
return await this.getClientRemoteLookup("person-kind");
}
async getCiiValidationStatus(): Promise<any> {
return await this.getClientRemoteLookup("cii-validation-status");
}
async otherPersonById(personId: number): Promise<unknown> {
const clientKey = this.activeClientKey();
const person = await this.fanavaranLookupService.getOtherPerson(
clientKey,
personId,
);
if (person == null) {
throw new NotFoundException(
`Fanavaran other-person ${personId} was not found.`,
);
}
return person;
}
async inquiryByUniqueIdentifier(query: {
nationalCode?: string;
birthday?: string;

View File

@@ -1,44 +1,9 @@
import { ApiProperty } from "@nestjs/swagger";
import { PlatesDto } from "src/plates/dto/plate.dto";
import { Plates } from "src/Types&Enums/plate.interface";
import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto";
export class AddPlateDto {
plateId: string;
@ApiProperty({ type: String, required: true })
nationalCodeOfInsurer: string;
@ApiProperty({ type: String, required: true })
nationalCodeOfDriver: string;
@ApiProperty({ type: String, required: true })
insurerLicense: string;
@ApiProperty({ type: String, required: true })
driverLicense: string;
@ApiProperty({ type: PlatesDto, required: true })
plate: Plates;
@ApiProperty({ type: Boolean, required: true })
driverIsInsurer: boolean;
@ApiProperty({ type: Boolean, required: true, default: false })
isNewCar: boolean;
@ApiProperty({ type: Boolean, required: true })
userNoCertificate: boolean;
@ApiProperty({
type: Number,
required: true,
})
insurerBirthday: number;
@ApiProperty({
type: String,
required: false,
})
driverBirthday: string | null;
}
export class AddPlateDto extends InquiryParticipantFieldsDto {}
export class AddPlateProfileDto {
plateId: string;

View File

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

View File

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

View File

@@ -114,7 +114,7 @@ export class CallCenterBlameV6Controller {
description:
"VIN alternative to `run-inquiry`. " +
"The agent supplies the chassis number and personal data collected from the caller. " +
"ESG chassis lookup (`policyByChassis`) is executed and the result is stored on the " +
"ESG two-factor chassis lookup (`carByChassis`) is executed and the result is stored on the " +
"blame document under `vehicle.vin` (plateId is left empty). " +
"Identical eligibility guards and insurer-company validation as the plate variant. " +
"After this call, proceed to `send-link` exactly as in the plate flow.",

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
import { Types } from "mongoose";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
@@ -10,6 +10,7 @@ import {
} from "src/Types&Enums/blame-request-management/accident-conditions.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { IsEnum } from "class-validator";
import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto";
export class InitialFormDto {
@ApiProperty({ required: false, default: false })
@@ -222,51 +223,8 @@ export class BlameConfessionDtoV2 {
imGuilty?: boolean;
}
/**
* V2 initial-form step submitted with a VIN/chassis number instead of a plate.
* All identity and license fields from {@link AddPlateDto} are preserved; only
* `plate` is replaced by `vin` (the 17-character chassis / VIN string).
*/
export class InitialFormVinDto {
@ApiProperty({
type: String,
required: true,
description: "17-character VIN / chassis number (شماره شاسی)",
example: "NAAM01E15HK123456",
maxLength: 17,
})
@IsString()
@IsNotEmpty()
@MaxLength(17)
vin: string;
@ApiProperty({ type: String, required: true })
nationalCodeOfInsurer: string;
@ApiProperty({ type: String, required: true })
nationalCodeOfDriver: string;
@ApiProperty({ type: String, required: true })
insurerLicense: string;
@ApiProperty({ type: String, required: true })
driverLicense: string;
@ApiProperty({ type: Boolean, required: true })
driverIsInsurer: boolean;
@ApiProperty({ type: Boolean, required: true, default: false })
isNewCar: boolean;
@ApiProperty({ type: Boolean, required: true })
userNoCertificate: boolean;
@ApiProperty({ type: Number, required: true })
insurerBirthday: number;
@ApiPropertyOptional({ type: String, required: false })
driverBirthday: string | null;
}
/** V2 initial-form step submitted with the shared structured vehicle contract. */
export class InitialFormVinDto extends InquiryParticipantFieldsDto {}
// export class DocsOfThisFile {
// @ApiProperty()

View File

@@ -1,4 +1,11 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import {
ArrayMinSize,
ArrayUnique,
IsArray,
IsInt,
IsOptional,
} from "class-validator";
import { CarDamagePartDto } from "src/claim-request-management/dto/car-part.dto";
/**
@@ -6,6 +13,19 @@ import { CarDamagePartDto } from "src/claim-request-management/dto/car-part.dto"
* All fields optional so expert can submit in one or two steps (e.g. car parts first, then sheba/other).
*/
export class ExpertCompleteClaimDataDto {
@ApiPropertyOptional({
description:
"Selected damaged-part IDs from the live Fanavaran car-components catalog.",
example: [9, 10, 30],
type: [Number],
})
@IsOptional()
@IsArray({ message: "selectedPartIds must be an array" })
@ArrayMinSize(1, { message: "At least one part ID must be selected" })
@ArrayUnique({ message: "Duplicate part IDs are not allowed" })
@IsInt({ each: true, message: "Each selected part ID must be an integer" })
selectedPartIds?: number[];
@ApiPropertyOptional({
description: "Car part damage selection (same as selectCarPartDamage). Required for first claim data step.",
type: CarDamagePartDto,

View File

@@ -1,143 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
class PlateV6Dto {
@ApiProperty({ example: "44", description: "Left two digits" })
@IsString()
@IsNotEmpty()
leftDigits: string;
@ApiProperty({ example: "ب", description: "Center alphabet letter" })
@IsString()
@IsNotEmpty()
centerAlphabet: string;
@ApiProperty({ example: "111", description: "Center three digits" })
@IsString()
@IsNotEmpty()
centerDigits: string;
@ApiProperty({ example: "22", description: "Right two digits (Iran region code)" })
@IsString()
@IsNotEmpty()
ir: string;
}
import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto";
/**
* Inquiry body for the V6 call-center flow.
* Same as V3 but without `sheba` — the user adds their own IBAN later via the link.
*/
export class RunCallCenterInquiryV6Dto {
@ApiProperty({
type: PlateV6Dto,
description: "Plate segments — Tejarat block / third-party inquiry.",
})
@ValidateNested()
@Type(() => PlateV6Dto)
plate: PlateV6Dto;
@ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" })
@IsString()
@IsNotEmpty()
nationalCodeOfInsurer: string;
@ApiProperty({ example: "1234567890", description: "National code of the driver" })
@IsString()
@IsNotEmpty()
nationalCodeOfDriver: string;
@ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" })
@IsBoolean()
driverIsInsurer: boolean;
@ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" })
insurerBirthday: number | string;
@ApiPropertyOptional({
example: 13780624,
description: "Driver birth date (Jalali). Required when driverIsInsurer is false.",
})
@IsOptional()
driverBirthday?: number | string | null;
@ApiPropertyOptional({
example: "123456789",
description: "Driver license (required when driverIsInsurer is false).",
})
@IsOptional()
@IsString()
driverLicense?: string;
@ApiPropertyOptional({
example: "123456789",
description: "Insurer license (required when driverIsInsurer is true).",
})
@IsOptional()
@IsString()
insurerLicense?: string;
}
export class RunCallCenterInquiryV6Dto extends InquiryParticipantFieldsDto {}
/**
* VIN / chassis variant of the V6 call-center inquiry.
* Identical to `RunCallCenterInquiryV6Dto` but replaces `plate` with `vin`.
* Sheba (IBAN) is intentionally absent — the user provides it themselves via the link.
*/
export class RunCallCenterInquiryVinV6Dto {
@ApiProperty({
example: "NAAM01E15HK123456",
description: "17-character VIN / chassis number (شماره شاسی)",
maxLength: 17,
})
@IsString()
@IsNotEmpty()
@MaxLength(17)
vin: string;
@ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" })
@IsString()
@IsNotEmpty()
nationalCodeOfInsurer: string;
@ApiProperty({ example: "1234567890", description: "National code of the driver" })
@IsString()
@IsNotEmpty()
nationalCodeOfDriver: string;
@ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" })
@IsBoolean()
driverIsInsurer: boolean;
@ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" })
insurerBirthday: number | string;
@ApiPropertyOptional({
example: 13780624,
description: "Driver birth date (Jalali). Required when driverIsInsurer is false.",
})
@IsOptional()
driverBirthday?: number | string | null;
@ApiPropertyOptional({
example: "123456789",
description: "Driver license (required when driverIsInsurer is false).",
})
@IsOptional()
@IsString()
driverLicense?: string;
@ApiPropertyOptional({
example: "123456789",
description: "Insurer license (required when driverIsInsurer is true).",
})
@IsOptional()
@IsString()
insurerLicense?: string;
}
export class RunCallCenterInquiryVinV6Dto extends InquiryParticipantFieldsDto {}

View File

@@ -1,70 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
class PlateV3Dto {
@ApiProperty({ example: "44", description: "Left two digits" })
@IsString()
@IsNotEmpty()
leftDigits: string;
@ApiProperty({ example: "ب", description: "Center alphabet letter" })
@IsString()
@IsNotEmpty()
centerAlphabet: string;
@ApiProperty({ example: "111", description: "Center three digits" })
@IsString()
@IsNotEmpty()
centerDigits: string;
@ApiProperty({ example: "22", description: "Right two digits (Iran region code)" })
@IsString()
@IsNotEmpty()
ir: string;
}
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator";
import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto";
/**
* Body for `POST run-inquiries/:requestId`.
* First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only).
*/
export class RunInquiriesV3Dto {
@ApiProperty({
type: PlateV3Dto,
description: "Plate segments — Tejarat block / third-party inquiry.",
})
@ValidateNested()
@Type(() => PlateV3Dto)
plate: PlateV3Dto;
@ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" })
@IsString()
@IsNotEmpty()
nationalCodeOfInsurer: string;
@ApiProperty({ example: "1234567890", description: "National code of the driver" })
@IsString()
@IsNotEmpty()
nationalCodeOfDriver: string;
@ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" })
@IsBoolean()
driverIsInsurer: boolean;
@ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" })
insurerBirthday: number | string;
@ApiPropertyOptional({ example: 13780624, description: "Driver birth date (Jalali). Required when driverIsInsurer is false." })
@IsOptional()
driverBirthday?: number | string | null;
export class RunInquiriesV3Dto extends InquiryParticipantFieldsDto {
@ApiPropertyOptional({
example: "IR123456789012345678901234",
description:
@@ -73,32 +15,6 @@ export class RunInquiriesV3Dto {
@IsOptional()
@IsString()
sheba?: string;
@ApiPropertyOptional({
example: "123456789",
description: "Driver license (required when driverIsInsurer is false).",
})
@IsOptional()
@IsString()
driverLicense?: string;
@ApiPropertyOptional({
example: "123456789",
description: "Insurer license (required when driverIsInsurer is true).",
})
@IsOptional()
@IsString()
insurerLicense?: string;
@ApiPropertyOptional({
example: "1",
description:
"Driving licence type code from GET /lookups/driving-licence-types. " +
"Optional — used in V4 FileMaker flow.",
})
@IsOptional()
@IsString()
licenseType?: string;
}
/**
@@ -106,38 +22,7 @@ export class RunInquiriesV3Dto {
* Identical to RunInquiriesV3Dto but uses `vin` (17-char chassis number) instead of `plate`.
* First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only).
*/
export class RunInquiriesVinV3Dto {
@ApiProperty({
example: "NAAM01E15HK123456",
description: "17-character VIN / chassis number (شماره شاسی)",
maxLength: 17,
})
@IsString()
@IsNotEmpty()
@MaxLength(17)
vin: string;
@ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" })
@IsString()
@IsNotEmpty()
nationalCodeOfInsurer: string;
@ApiProperty({ example: "1234567890", description: "National code of the driver" })
@IsString()
@IsNotEmpty()
nationalCodeOfDriver: string;
@ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" })
@IsBoolean()
driverIsInsurer: boolean;
@ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" })
insurerBirthday: number | string;
@ApiPropertyOptional({ example: 13780624, description: "Driver birth date (Jalali). Required when driverIsInsurer is false." })
@IsOptional()
driverBirthday?: number | string | null;
export class RunInquiriesVinV3Dto extends InquiryParticipantFieldsDto {
@ApiPropertyOptional({
example: "IR123456789012345678901234",
description:
@@ -146,30 +31,4 @@ export class RunInquiriesVinV3Dto {
@IsOptional()
@IsString()
sheba?: string;
@ApiPropertyOptional({
example: "123456789",
description: "Driver license (required when driverIsInsurer is false).",
})
@IsOptional()
@IsString()
driverLicense?: string;
@ApiPropertyOptional({
example: "123456789",
description: "Insurer license (required when driverIsInsurer is true).",
})
@IsOptional()
@IsString()
insurerLicense?: string;
@ApiPropertyOptional({
example: "1",
description:
"Driving licence type code from GET /lookups/driving-licence-types. " +
"Optional — used in V4 FileMaker flow.",
})
@IsOptional()
@IsString()
licenseType?: string;
}

View File

@@ -94,6 +94,10 @@ export class BlameRequest {
@Prop({ type: Types.ObjectId })
initiatedByFieldExpertId?: Types.ObjectId;
/** Branch snapshot of the expert/FileMaker who created this file. */
@Prop({ type: Types.ObjectId, index: true })
branchId?: Types.ObjectId;
/** True when this blame was created by a registrar. */
@Prop({ default: false })
registrarInitiated?: boolean;

View File

@@ -4,213 +4,254 @@ import { Schema as MongooseSchema, Types } from "mongoose";
import { Location, LocationSchema } from "./accidentInformation.type";
export enum PartyRole {
FIRST = "FIRST",
SECOND = "SECOND",
}
FIRST = "FIRST",
SECOND = "SECOND",
}
@Schema({ _id: false })
export class Person {
@Prop({ type: Types.ObjectId })
userId?: Types.ObjectId;
@Prop({ type: Types.ObjectId })
userId?: Types.ObjectId;
@Prop() fullName?: string;
@Prop() phoneNumber?: string;
@Prop({ type: Types.ObjectId }) clientId?: Types.ObjectId;
@Prop() birthday?: string;
@Prop() fullName?: string;
@Prop() phoneNumber?: string;
@Prop({ type: Types.ObjectId }) clientId?: Types.ObjectId;
@Prop() birthday?: string;
// ---- FIRST_INITIAL_FORM (step-manager driven) ----
@Prop() nationalCodeOfInsurer?: string;
@Prop() nationalCodeOfDriver?: string;
@Prop() insurerLicense?: string;
@Prop() driverLicense?: string;
/** Driving licence type code from the Fanavaran lookup (GET /lookups/driving-licence-types). */
@Prop() licenseType?: string;
// ---- FIRST_INITIAL_FORM (step-manager driven) ----
@Prop() nationalCodeOfInsurer?: string;
@Prop() nationalCodeOfDriver?: string;
@Prop() insurerLicense?: string;
@Prop() driverLicense?: string;
/** Driving licence type code from the Fanavaran lookup (GET /lookups/driving-licence-types). */
@Prop() licenseType?: string;
@Prop({ type: Boolean })
driverIsInsurer?: boolean;
@Prop({ type: Boolean })
driverIsInsurer?: boolean;
@Prop({ type: Boolean })
isNewCar?: boolean;
@Prop({ type: Boolean })
isNewCar?: boolean;
/**
* Mirrors existing DTO `userNoCertificate` (true means user has NO certificate).
*/
@Prop({ type: Boolean })
userNoCertificate?: boolean;
/**
* Mirrors existing DTO `userNoCertificate` (true means user has NO certificate).
*/
@Prop({ type: Boolean })
userNoCertificate?: boolean;
@Prop({ type: Number })
insurerBirthday?: number;
@Prop({ type: String })
insurerBirthday?: string;
@Prop({ type: String })
driverBirthday?: string | null;
@Prop({ type: String })
driverBirthday?: string | null;
/** Cached Fanavaran party ID resolved from nationalCodeOfDriver + driverBirthday + driverIsInsurer */
@Prop({ type: Number })
fanavaranDriverId?: number;
/** Cached Fanavaran party ID resolved from nationalCodeOfDriver + driverBirthday + driverIsInsurer */
@Prop({ type: Number })
fanavaranDriverId?: number;
}
export const PersonSchema = SchemaFactory.createForClass(Person);
@Schema({ _id: false })
export class Vehicle {
@Prop() plateId?: string;
/** VIN / chassis number — populated only when the inquiry was performed via VIN lookup. */
@Prop() vin?: string;
@Prop() name?: string;
@Prop() model?: string;
@Prop() type?: string;
@Prop() isNew?: boolean;
export class InquiryParticipant {
@Prop({ required: true })
participantId: string;
/**
* Full external inquiry payload (Tejarat/SandHub) stored as-is
* so we never lose fields that are not mapped yet.
*/
@Prop({ type: MongooseSchema.Types.Mixed })
inquiry?: any;
@Prop()
nationalCode: string;
@Prop()
birthday: string;
@Prop() fullName?: string;
@Prop() phoneNumber?: string;
@Prop({ type: Boolean }) hasDrivingLicense?: boolean;
@Prop() licenseNumber?: string;
@Prop() licenseType?: string;
}
export const InquiryParticipantSchema =
SchemaFactory.createForClass(InquiryParticipant);
@Schema({ _id: false })
export class InquiryParticipantRoles {
@Prop() driver?: string;
@Prop() vehicleOwner?: string;
@Prop() thirdPartyPolicyholder?: string;
@Prop() carBodyPolicyholder?: string;
}
export const InquiryParticipantRolesSchema = SchemaFactory.createForClass(
InquiryParticipantRoles,
);
@Schema({ _id: false })
export class Vehicle {
@Prop() plateId?: string;
/** VIN / chassis number — populated only when the inquiry was performed via VIN lookup. */
@Prop() vin?: string;
@Prop() name?: string;
@Prop() model?: string;
@Prop() type?: string;
@Prop({ type: Boolean }) isNewCar?: boolean;
@Prop({ enum: ["CURRENT", "RECENTLY_TRANSFERRED"] })
registrationState?: "CURRENT" | "RECENTLY_TRANSFERRED";
@Prop() previousPlateId?: string;
@Prop() previousPolicyholderNationalCode?: string;
/**
* Full external inquiry payload (Tejarat/SandHub) stored as-is
* so we never lose fields that are not mapped yet.
*/
@Prop({ type: MongooseSchema.Types.Mixed })
inquiry?: any;
}
export const VehicleSchema = SchemaFactory.createForClass(Vehicle);
@Schema({ _id: false })
export class Insurance {
@Prop() policyNumber?: string;
@Prop() company?: string;
@Prop() startDate?: string;
@Prop() endDate?: string;
@Prop() financialCeiling?: string;
@Prop({ type: [String] })
coverages?: string[];
@Prop() policyNumber?: string;
@Prop() company?: string;
@Prop() startDate?: string;
@Prop() endDate?: string;
@Prop() financialCeiling?: string;
@Prop({ type: [String] })
coverages?: string[];
/** CAR_BODY inquiry result; raw provider data is retained in vehicle.inquiry.carBody.raw. */
@Prop({ type: MongooseSchema.Types.Mixed })
carBodyInsurance?: {
policyNumber?: string;
policyId?: number;
contractId?: number;
startDate?: string;
endDate?: string;
insurerCompany?: string;
coverages?: string[];
companyId?: number | string;
companyName?: string;
insurerName?: string;
insurerNationalCode?: string;
ownerNationalCode?: string;
ownerName?: string;
customerName?: string;
customerLastName?: string;
customerFatherName?: string;
customerMobile?: string;
customerAddress?: string;
customerPostalCode?: string;
chassisNumber?: string;
vin?: string;
motorNumber?: string;
vehicleGroup?: string;
vehicleSystem?: string;
vehicleKind?: string;
builtYear?: number;
cylinderCount?: number;
passengerCount?: number;
usage?: string;
issueDate?: string;
vehicleValue?: number;
totalPremium?: number;
noLossYearsCount?: number;
lossDocuments?: unknown[];
hasEndorsement?: boolean;
};
/** CAR_BODY inquiry result; raw provider data is retained in vehicle.inquiry.carBody.raw. */
@Prop({ type: MongooseSchema.Types.Mixed })
carBodyInsurance?: {
policyNumber?: string;
policyId?: number;
contractId?: number;
startDate?: string;
endDate?: string;
insurerCompany?: string;
coverages?: string[];
companyId?: number | string;
companyName?: string;
insurerName?: string;
insurerNationalCode?: string;
ownerNationalCode?: string;
ownerName?: string;
customerName?: string;
customerLastName?: string;
customerFatherName?: string;
customerMobile?: string;
customerAddress?: string;
customerPostalCode?: string;
chassisNumber?: string;
vin?: string;
motorNumber?: string;
vehicleGroup?: string;
vehicleSystem?: string;
vehicleKind?: string;
builtYear?: number;
cylinderCount?: number;
passengerCount?: number;
usage?: string;
issueDate?: string;
vehicleValue?: number;
totalPremium?: number;
noLossYearsCount?: number;
lossDocuments?: unknown[];
hasEndorsement?: boolean;
};
}
export const InsuranceSchema = SchemaFactory.createForClass(Insurance);
@Schema({ _id: false })
export class PartyStatement {
@Prop({ default: false })
acceptsExpertOpinion?: boolean;
@Prop({ default: false })
acceptsExpertOpinion?: boolean;
@Prop({ default: false })
claimsDamage?: boolean;
@Prop({ default: false })
admitsGuilt?: boolean;
@Prop({ type: String })
description?: string;
@Prop({ default: false })
claimsDamage?: boolean;
/** CAR_BODY: accident conditions from description step */
@Prop() accidentDate?: Date;
@Prop() accidentTime?: string;
@Prop() weatherCondition?: string;
@Prop() roadCondition?: string;
@Prop() lightCondition?: string;
@Prop({ default: false })
admitsGuilt?: boolean;
@Prop({ type: String })
description?: string;
/** CAR_BODY: accident conditions from description step */
@Prop() accidentDate?: Date;
@Prop() accidentTime?: string;
@Prop() weatherCondition?: string;
@Prop() roadCondition?: string;
@Prop() lightCondition?: string;
}
export const PartyStatementSchema = SchemaFactory.createForClass(PartyStatement);
export const PartyStatementSchema =
SchemaFactory.createForClass(PartyStatement);
@Schema({ _id: false })
export class EvidenceBundle {
@Prop({ type: [String] })
images?: string[];
@Prop({ type: [String] })
images?: string[];
@Prop({ type: [String] })
voices?: string[];
@Prop() videoId?: string;
@Prop({ type: [String] })
voices?: string[];
@Prop() videoId?: string;
}
export const EvidenceBundleSchema = SchemaFactory.createForClass(EvidenceBundle);
export const EvidenceBundleSchema =
SchemaFactory.createForClass(EvidenceBundle);
@Schema({ _id: false })
export class Signature {
@Prop() fileId: string;
@Prop() fileName: string;
@Prop() fileUrl: string;
@Prop() fileId: string;
@Prop() fileName: string;
@Prop() fileUrl: string;
}
export const SignatureSchema = SchemaFactory.createForClass(Signature);
@Schema({ _id: false })
export class PartyConfirmation {
@Prop() partyRole: PartyRole;
@Prop() accepted: boolean;
@Prop({ type: SignatureSchema })
signature?: Signature;
@Prop() partyRole: PartyRole;
@Prop() accepted: boolean;
@Prop({ type: SignatureSchema })
signature?: Signature;
}
export const PartyConfirmationSchema =
SchemaFactory.createForClass(PartyConfirmation);
@Schema({ _id: false })
export class Party {
@Prop({ enum: PartyRole })
role: PartyRole;
@Prop({ enum: PartyRole })
role: PartyRole;
@Prop({ type: PersonSchema })
person: Person;
@Prop({ type: PersonSchema })
person: Person;
/**
* CAR_BODY only: first form – accident with car vs object.
* Second form (guilty/damaged) may be added later as carBodySecondForm.
*/
@Prop({ type: MongooseSchema.Types.Mixed })
carBodyFirstForm?: { car?: boolean; object?: boolean };
/** Distinct people involved with this vehicle; roles below reference participantId. */
@Prop({ type: [InquiryParticipantSchema], default: undefined })
participants?: InquiryParticipant[];
/**
* Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION).
*/
@Prop({ type: LocationSchema })
location?: Location;
@Prop({ type: VehicleSchema })
vehicle?: Vehicle;
@Prop({ type: InsuranceSchema })
insurance?: Insurance;
@Prop({ type: PartyStatementSchema })
statement?: PartyStatement;
@Prop({ type: EvidenceBundleSchema })
evidence?: EvidenceBundle;
@Prop({ type: PartyConfirmationSchema })
confirmation?: PartyConfirmation;
@Prop({ type: InquiryParticipantRolesSchema })
participantRoles?: InquiryParticipantRoles;
/**
* CAR_BODY only: first form – accident with car vs object.
* Second form (guilty/damaged) may be added later as carBodySecondForm.
*/
@Prop({ type: MongooseSchema.Types.Mixed })
carBodyFirstForm?: { car?: boolean; object?: boolean };
/**
* Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION).
*/
@Prop({ type: LocationSchema })
location?: Location;
@Prop({ type: VehicleSchema })
vehicle?: Vehicle;
@Prop({ type: InsuranceSchema })
insurance?: Insurance;
@Prop({ type: PartyStatementSchema })
statement?: PartyStatement;
@Prop({ type: EvidenceBundleSchema })
evidence?: EvidenceBundle;
@Prop({ type: PartyConfirmationSchema })
confirmation?: PartyConfirmation;
}
export const PartySchema = SchemaFactory.createForClass(Party);

View File

@@ -146,6 +146,23 @@ export class FirstPartyDetail {
@Prop({ type: Boolean })
firstPartyCertificate: boolean | null;
@Prop({ type: [MongooseSchema.Types.Mixed] })
participants?: Record<string, unknown>[];
@Prop({ type: MongooseSchema.Types.Mixed })
participantRoles?: Record<string, string>;
@Prop({ type: MongooseSchema.Types.Mixed })
participantInquiries?: Record<string, unknown>;
@Prop({ type: MongooseSchema.Types.Mixed })
policyInquiry?: Record<string, unknown>;
@Prop() registrationState?: string;
@Prop() previousPlateId?: string;
@Prop() previousPolicyholderNationalCode?: string;
@Prop() vehicleVin?: string;
}
export class SecondPartyDetail {
@@ -178,6 +195,23 @@ export class SecondPartyDetail {
@Prop({ type: SecondPartyFile })
secondPartyFiles?: SecondPartyFile;
@Prop({ type: [MongooseSchema.Types.Mixed] })
participants?: Record<string, unknown>[];
@Prop({ type: MongooseSchema.Types.Mixed })
participantRoles?: Record<string, string>;
@Prop({ type: MongooseSchema.Types.Mixed })
participantInquiries?: Record<string, unknown>;
@Prop({ type: MongooseSchema.Types.Mixed })
policyInquiry?: Record<string, unknown>;
@Prop() registrationState?: string;
@Prop() previousPlateId?: string;
@Prop() previousPolicyholderNationalCode?: string;
@Prop() vehicleVin?: string;
}
export class LocationDto {

Some files were not shown because too many files have changed in this diff Show More