Merge pull request 'Implement role-complete inquiry participants + Bug fixes' (#309) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#309
This commit is contained in:
2026-09-13 16:23:42 +03:30
26 changed files with 4960 additions and 1215 deletions

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

@@ -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` | بله | مبلغ بین 100,000 تا 10,000,000,000 تومان. مجموع این فیلدها در کل پرونده نباید از 53,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 تومان است.
- محدودیت 10 میلیارد مربوط به **هر فیلد مبلغ** است؛ سقف 53 میلیون مربوط به **مجموع `totalPayment` تمام ردیف‌ها** است و همچنان اعمال می‌شود.
- فیلدهای ناشناخته در 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": 54000000,
"priceCap": 53000000
}
```
این خطا به یک ردیف مشخص وصل نیست. آن را در بالای جدول قیمت‌ها نمایش دهید و در صورت نیاز از `priceCap` برای پیام UI استفاده کنید.
### 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` ردیف‌ها را نیز پیش از ارسال محاسبه و نمایش دهید.

View File

@@ -0,0 +1,219 @@
# راهنمای فرانت‌اند برای ارسال اطلاعات استعلام
این سند قرارداد نهایی فرانت‌اند برای مرحله استعلام است. از این به بعد اطلاعات اشخاص و خودرو باید با ساختار نقش‌محور زیر ارسال شود. فیلدهای تخت قدیمی مانند `nationalCodeOfDriver` و `nationalCodeOfInsurer` دیگر ورودی معتبر نیستند.
## پاسخ کوتاه درباره `unknown`
`unknown` فقط برای یک حالت استثنایی لازم است: وقتی در مرحله طرف زیان‌دیده یک پرونده `THIRD_PARTY`، هویت بیمه‌گذار شخص ثالث واقعاً مشخص نیست.
```json
{
"thirdPartyPolicyholder": { "unknown": true }
}
```
قواعد آن:
- فقط برای طرف زیان‌دیده (`SECOND`) مجاز است؛ برای طرف مقصر (`FIRST`) خطا برمی‌گردد.
- فقط برای `thirdPartyPolicyholder` مجاز است؛ برای راننده، مالک یا بیمه‌گذار بدنه مجاز نیست.
- وقتی `unknown: true` ارسال می‌شود، هیچ فیلد هویتی دیگری در همان آبجکت نفرستید.
- در استعلام پلاکی شخص ثالث، استعلام بیمه‌گذار عمداً skip می‌شود و سیستم نباید شخصی را حدس بزند.
- استعلام‌های راننده، مالکیت خودرو و اطلاعات افراد شناخته‌شده همچنان اجرا می‌شوند.
- اگر هویت بیمه‌گذار مشخص است، اصلاً از `unknown` استفاده نکنید و اطلاعات واقعی یا `sameAs` را بفرستید.
## ساختار کلی درخواست
برای پرونده `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 نیست و بعداً توسط کاربر دریافت می‌شود.
## نقش‌ها و فیلدهای هر شخص
هر نقش باید یکی از این دو حالت را داشته باشد، نه هر دو را:
1. اطلاعات یک شخص جدید؛ یا
2. ارجاع با `sameAs` به شخصی که قبلاً در همین درخواست معرفی شده است.
| فیلد | کاربرد | وضعیت |
| --- | --- | --- |
| `nationalCode` | کد ملی شخص | برای شخص جدید الزامی |
| `birthday` | تاریخ تولد جلالی | برای شخص جدید الزامی |
| `fullName` | نام نمایشی شخص | اختیاری |
| `sameAs` | اتصال این نقش به نقش دیگر | به‌جای اطلاعات شخص جدید |
| `unknown` | نامشخص بودن بیمه‌گذار ثالث | فقط `SECOND` در `THIRD_PARTY` |
| `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.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"
},
"vin": "NAAM01E15HK123456"
}
}
```
سیستم ابتدا پلاک فعلی را استعلام می‌کند. اگر نتیجه ناموجود، منقضی یا فاقد بیمه‌نامه مرتبط باشد، پلاک قبلی را امتحان می‌کند. نتیجه پلاک قبلی فقط در صورت تطبیق 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`) است. همین تفاوت تعیین می‌کند که `unknown` مجاز است یا نه.
## فیلدهایی که نباید ارسال شوند
این فیلدها دیگر بخشی از قرارداد ورودی نیستند و ارسال آن‌ها باعث خطای اعتبارسنجی می‌شود:
```text
nationalCodeOfDriver
driverBirthday
driverLicense
licenseType // در سطح بالا؛ مقدار صحیح داخل driver است
nationalCodeOfInsurer
insurerBirthday
insurerLicense
driverIsInsurer
userNoCertificate
plate // در سطح بالا؛ مقدار صحیح داخل vehicle.currentPlate است
plateId
vin // در سطح بالا؛ مقدار صحیح داخل vehicle.vin است
isNewCar // در سطح بالا؛ مقدار صحیح داخل vehicle.isNewCar است
phoneNumber // در participantها
```
## خطاهای رایج فرانت‌اند
- ارسال `vehicleOwner` به‌صورت خالی؛ باید شخص جدید یا `sameAs` باشد.
- استفاده از `sameAs` همراه با `nationalCode` یا `birthday`.
- ارسال `carBodyPolicyholder` برای `THIRD_PARTY`.
- ارسال `unknown` برای طرف مقصر یا برای نقشی غیر از بیمه‌گذار ثالث.
- فرستادن `previousPlate` بدون `registrationState=RECENTLY_TRANSFERRED`.
- فرستادن `RECENTLY_TRANSFERRED` بدون `previousPlate` یا `vin`.
- قرار دادن VIN یا پلاک در سطح بالای body.
- ارسال شماره تلفن در آبجکت شخص.
مستند مدل دامنه و جزئیات تصمیم معماری در [inquiry-participants-proposal.fa.md](./inquiry-participants-proposal.fa.md) قرار دارد.

View File

@@ -0,0 +1,115 @@
# پیشنهاد مدل اشخاص در مرحله استعلام
وضعیت: پیاده‌سازی‌شده در ۱۴۰۵/۰۶/۲۲
دامنه: تمام جریان‌های استعلام کاربر، کارشناس، پرونده‌ساز و مرکز تماس
نسخه انگلیسی: [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` را نمایش می‌دهند.
## مسئله
قرارداد فعلی استعلام عمدتاً فقط راننده و شخصی با عنوان `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` را داشته باشد. بک‌اند این ورودی را به فهرست اشخاص یکتا و اتصال نقش‌ها به آن‌ها تبدیل می‌کند.
فقط برای طرف زیان‌دیده در پرونده `THIRD_PARTY` می‌توان نامشخص بودن بیمه‌گذار را به‌صورت صریح با `"thirdPartyPolicyholder": { "unknown": true }` ارسال کرد. این حالت برای طرف مقصر پذیرفته نمی‌شود. در مسیر پلاک، استعلام بیمه شخص ثالث ــ چون به هویت بیمه‌گذار نیاز دارد ــ به‌شکل قابل‌ممیزی «عمداً اجرا نشد» ثبت می‌شود؛ استعلام مبتنی بر VIN همچنان بدون حدس‌زدن هویت قابل اجرا است. اعتبارسنجی راننده و مالک و استعلام‌های هویت، مالکیت و گواهینامه همچنان انجام می‌شوند.
برای راننده، `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"
},
"vin": "NAAM01E15HK123456"
}
}
```
مقدار پیش‌فرض `registrationState` برابر `CURRENT` است و برای این مسیر استثنایی مقدار `RECENTLY_TRANSFERRED` استفاده می‌شود. `previousPlate` فقط در حالت انتقال اخیر الزامی است. پلاک فعلی همچنان شناسه اصلی خودرو است. هماهنگ‌کننده استعلام باید ابتدا پلاک فعلی را بررسی کند و اگر نتیجه ناموجود، قدیمی یا فاقد بیمه‌نامه مرتبط بود، پلاک قبلی را به‌صورت خودکار استعلام کند.
پیش از پذیرش نتیجه پلاک قبلی، بک‌اند باید یکسان بودن VIN/شماره شاسی را بررسی کند. در صورت مغایرت، انتخاب خودکار متوقف و اصلاح اطلاعات یا بررسی دستی الزامی شود. هر دو پلاک و تمام تلاش‌های استعلام برای ممیزی نگه‌داری شوند، اما پلاک قبلی هیچ‌گاه نباید روی پلاک فعلی نوشته شود.
## ترتیب پیشنهادی فرم
1. پلاک فعلی دریافت و درباره انتقال مالکیت اخیر پرسیده شود. در صورت انتقال اخیر، پلاک قبلی و VIN/شماره شاسی نیز دریافت شوند.
2. اطلاعات هویتی و گواهینامه راننده دریافت شود.
3. پرسیده شود آیا مالک خودرو همان راننده است؛ فقط در صورت تفاوت، اطلاعات مالک دریافت شود.
4. برای بیمه‌گذار شخص ثالث یکی از «راننده»، «مالک» یا «شخص دیگر» انتخاب شود؛ فقط برای شخص دیگر فرم جدید نمایش داده شود.
5. در `CAR_BODY` همین انتخاب برای بیمه‌گذار بدنه انجام شود و امکان انتخاب هر شخص ثبت‌شده یا شخص دیگر وجود داشته باشد.
6. خلاصه اشخاص نمایش داده شود و سپس استعلام‌ها اجرا شوند.
به این ترتیب مسیر رایج کوتاه می‌ماند و همه ترکیب‌های معتبر نیز پشتیبانی می‌شوند.
## محل منطق در بک‌اند
یک resolver مشترک برای اشخاص ساخته شود و تمام routeهای استعلام از آن استفاده کنند. interface این ماژول باید:
- نقش‌های لازم را بر اساس نوع پرونده اعتبارسنجی و ارجاع‌های نامعتبر یا حلقوی `sameAs` را رد کند؛
- شخص نهایی هر نقش را برگرداند؛
- هویت درست را به استعلام مرتبط بدهد: گواهینامه ← راننده، مالکیت ← مالک خودرو، بیمه شخص ثالث ← بیمه‌گذار شخص ثالث، بیمه بدنه ← بیمه‌گذار بدنه؛
- بر اساس یک قاعده مشخص، پلاک فعلی یا قبلی را انتخاب و نتیجه پلاک قبلی را با VIN/شماره شاسی تطبیق دهد؛
- استعلام هویت را برای هر شخص یکتا فقط یک بار اجرا کند؛
- اشخاص نرمال‌شده و نقش‌های آن‌ها را در `Party` مربوط ذخیره کند.
جریان‌های V2 کاربر/کارشناس، V3، V4، V5 و V6 باید adapter همین قوانین مشترک باشند و منطق نسبت اشخاص را جداگانه پیاده‌سازی نکنند.
## مرز قرارداد
ورودی استعلام فقط شامل آبجکت‌های ساختاریافته اشخاص و خودرو است. بک‌اند فیلدهای تختی مانند `nationalCodeOfDriver`، `nationalCodeOfInsurer`، `driverIsInsurer`، `plate`/`vin` سطح بالا و `phoneNumber` را با خطای اعتبارسنجی رد می‌کند. احراز هویت تلفنی و جریان‌های تماس با طرفین از جمع‌آوری هویت برای استعلام جدا هستند.
## تصمیم پیشنهادی
راه‌حل مناسب، **دریافت شرطی اطلاعات همراه با اتصال صریح نقش‌ها** است. این روش بدون طولانی کردن مسیر اکثر کاربران، اطلاعات کامل فراهم می‌کند، از تناقض فلگ‌ها جلوگیری می‌کند و یک مدل یکسان برای همه جریان‌های استعلام می‌سازد.

View File

@@ -0,0 +1,115 @@
# 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`, and `vehicle.previousPlateId` 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.
For the damaged party of a `THIRD_PARTY` case only, the policyholder can be explicitly unresolved with `"thirdPartyPolicyholder": { "unknown": true }`. This is not accepted for the guilty party. Plate-based third-party-policy inquiry is recorded as intentionally skipped because it requires that identity; VIN-based lookup can still run without guessing a person. The driver, owner, personal, ownership, and licence rules remain enforced.
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"
},
"vin": "NAAM01E15HK123456"
}
}
```
`registrationState` is `CURRENT` by default or `RECENTLY_TRANSFERRED` for this exceptional path. `previousPlate` is required only when `registrationState=RECENTLY_TRANSFERRED`. The current plate remains the vehicle's primary identifier. The inquiry orchestrator should query the current plate first and automatically try the previous plate when the current result is missing, stale, or does not find the relevant policy.
Before accepting a previous-plate result, the backend must correlate it to the same VIN/chassis. A mismatch must stop automatic selection and require correction or manual review. Both identifiers and every attempted inquiry should be retained for audit, but a previous plate must never overwrite the current plate.
## Suggested UI sequence
1. Collect the current plate and ask whether the vehicle was recently transferred. If yes, collect the previous plate and VIN/chassis.
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 → Vehicle Owner, third-party policy → Third-party Policyholder, car-body policy → Car-body Policyholder;
- choose the current or previous plate deterministically and verify previous-plate results against VIN/chassis;
- run personal identity inquiry once per distinct person;
- persist normalized participants and role assignments on the relevant `Party`.
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

@@ -367,7 +367,7 @@
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>Lock claim to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409.</td></tr> <tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>Lock claim to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Price-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry.</td></tr> <tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Price-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Calculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400.</td></tr> <tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Calculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>Submit damage assessment reply (priced parts list, daghi, branchId). Cap: total ≤ 53 000 000 Toman. 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. 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/resend/:claimRequestId</code></td><td>Request user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled.</td></tr> <tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>Request user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>Ask user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION.</td></tr> <tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>Ask user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>Validate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. Cap applies across all lines (≤ 53 000 000 Toman). Auto-completes when all lines are decided.</td></tr> <tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>Validate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. Cap applies across all lines (≤ 53 000 000 Toman). Auto-completes when all lines are decided.</td></tr>
@@ -552,7 +552,7 @@
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr> <tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>create-from-blame/:blameId</code></td><td>Create claim from a completed blame file.</td></tr> <tr><td><span class="method post">POST</span></td><td><code>create-from-blame/:blameId</code></td><td>Create claim from a completed blame file.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>outer-parts-catalog</code> / <code>car-other-part</code></td><td>Parts catalogs (outer body parts + other parts JSON).</td></tr> <tr><td><span class="method get">GET</span></td><td><code>outer-parts-catalog</code> / <code>car-other-part</code></td><td>Parts catalogs (outer body parts + other parts JSON).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>branches/:insuranceId</code></td><td>Insurer branch list (for branch selection in claim sign step).</td></tr> <tr><td><span class="method get">GET</span></td><td><code>branches/:insuranceId</code></td><td>Insurer branch list for <code>daghi.branchId</code> when the expert selects the <code>تحویل داغی</code> option.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>Select outer damaged parts.</td></tr> <tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>Select outer damaged parts.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>Select other damaged parts + bank info.</td></tr> <tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>Select other damaged parts + bank info.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload claim documents (licences, car card).</td></tr> <tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload claim documents (licences, car card).</td></tr>

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

View File

@@ -70,6 +70,26 @@ describe("buildInsurerFileReport", () => {
}, },
{ {
role: "SECOND", role: "SECOND",
participants: [
{
participantId: "DRIVER",
fullName: "راننده نمونه",
nationalCode: "0012345678",
birthday: "1370/01/01",
licenseNumber: "LIC-1",
},
{
participantId: "VEHICLE_OWNER",
fullName: "زیان دیده نمونه",
nationalCode: "1234567890",
birthday: "1370/01/01",
},
],
participantRoles: {
driver: "DRIVER",
vehicleOwner: "VEHICLE_OWNER",
thirdPartyPolicyholder: "VEHICLE_OWNER",
},
person: { person: {
userId: "damaged-user-id", userId: "damaged-user-id",
fullName: "زیان دیده نمونه", fullName: "زیان دیده نمونه",
@@ -165,61 +185,89 @@ describe("buildInsurerFileReport", () => {
}, },
}); });
expect(getFieldValue(report, PR.ownerSection, PR.name)).toBe("زیان دیده نمونه"); 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.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( expect(getFieldValue(report, PR.guiltyOwnerSection, PR.nationalCode)).toBe(
"0987654321", "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( 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("خیر"); ).toBe("خیر");
expect(getFieldValue(report, PR.guiltyStatementSection, PR.partyDescription)).toBe( expect(
"توضیحات مقصر", getFieldValue(report, PR.guiltyStatementSection, PR.partyDescription),
); ).toBe("توضیحات مقصر");
expect(getFieldValue(report, PR.guiltyStatementSection, PR.admitsGuilt)).toBe( expect(
"بله", getFieldValue(report, PR.guiltyStatementSection, PR.admitsGuilt),
); ).toBe("بله");
expect( expect(
getFieldValue(report, PR.guiltyStatementSection, PR.acceptsExpertOpinion), getFieldValue(report, PR.guiltyStatementSection, PR.acceptsExpertOpinion),
).toBe("بله"); ).toBe("بله");
expect(getFieldValue(report, PR.fanavaranSection, PR.fanavaranClaimNo)).toBe( expect(
"111", getFieldValue(report, PR.fanavaranSection, PR.fanavaranClaimNo),
); ).toBe("111");
expect( expect(
getFieldValue(report, PR.fanavaranSection, PR.fanavaranExpertiseId), getFieldValue(report, PR.fanavaranSection, PR.fanavaranExpertiseId),
).toBe("444"); ).toBe("444");
expect(getFieldValue(report, PR.fanavaranSection, PR.fanavaranPolicyId)).toBe( expect(
"555", getFieldValue(report, PR.fanavaranSection, PR.fanavaranPolicyId),
); ).toBe("555");
expect(getFieldValue(report, PR.timelineSection, PR.fileRegisteredAt)).toBe( expect(getFieldValue(report, PR.timelineSection, PR.fileRegisteredAt)).toBe(
"1405/05/19 13:23", "1405/05/19 13:23",
@@ -228,15 +276,15 @@ describe("buildInsurerFileReport", () => {
getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt), getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt),
).toBe("1405/05/19 16:00"); ).toBe("1405/05/19 16:00");
expect(getFieldValue(report, PR.evaluationSection, PR.evaluationResult)).toBe( expect(
"تأیید شده", getFieldValue(report, PR.evaluationSection, PR.evaluationResult),
); ).toBe("تأیید شده");
expect(getFieldValue(report, PR.evaluationSection, PR.evaluationExpert)).toBe( expect(
"کارشناس خسارت نمونه", getFieldValue(report, PR.evaluationSection, PR.evaluationExpert),
); ).toBe("کارشناس خسارت نمونه");
expect(getFieldValue(report, PR.evaluationSection, PR.evaluationResponse)).toBe( expect(
"نیاز به تعویض سپر جلو", getFieldValue(report, PR.evaluationSection, PR.evaluationResponse),
); ).toBe("نیاز به تعویض سپر جلو");
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / نام قطعه")).toBe( expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / نام قطعه")).toBe(
"سپر جلو", "سپر جلو",
); );
@@ -345,9 +393,9 @@ describe("buildInsurerFileReport", () => {
}); });
const fields = report.sections.flatMap((section) => section.fields); 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 === "خیر")).toBe(true);
expect(fields.some((field) => field.value === "true")).toBe(false); expect(fields.some((field) => field.value === "true")).toBe(false);
expect(fields.some((field) => field.value === "false")).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); 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("ESG"))).toBe(
expect(fields.some((field) => String(field.value).includes("TEJARAT"))).toBe(
false, false,
); );
expect(
fields.some((field) => String(field.value).includes("TEJARAT")),
).toBe(false);
expect(fields.some((field) => field.label.includes("منبع"))).toBe(false); expect(fields.some((field) => field.label.includes("منبع"))).toBe(false);
}); });

View File

@@ -36,6 +36,8 @@ type ReportParty = ReportRecord & {
vehicle?: ReportRecord; vehicle?: ReportRecord;
statement?: ReportRecord; statement?: ReportRecord;
location?: { lat?: number; lon?: number }; location?: { lat?: number; lon?: number };
participants?: ReportRecord[];
participantRoles?: ReportRecord;
}; };
function asString(value: unknown): string | undefined { function asString(value: unknown): string | undefined {
@@ -66,7 +68,8 @@ function formatBirthDate(value: unknown): string | undefined {
function formatDateTime(value: unknown): string | undefined { function formatDateTime(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined; if (value === undefined || value === null || value === "") return undefined;
const date = value instanceof Date ? value : new Date(value as string | number); const date =
value instanceof Date ? value : new Date(value as string | number);
if (Number.isNaN(date.getTime())) return asString(value); if (Number.isNaN(date.getTime())) return asString(value);
const [d, t] = toJalaliDateAndTime(date); const [d, t] = toJalaliDateAndTime(date);
return `${d} ${t}`; return `${d} ${t}`;
@@ -126,13 +129,13 @@ function flattenObject(
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
const value = normalizeListValue(obj); const value = normalizeListValue(obj);
return value return value ? [{ label: persianFieldPath(prefix || "items"), value }] : [];
? [{ label: persianFieldPath(prefix || "items"), value }]
: [];
} }
if (typeof obj !== "object") { if (typeof obj !== "object") {
return [{ label: persianFieldPath(prefix || "value"), value: asString(obj) }]; return [
{ label: persianFieldPath(prefix || "value"), value: asString(obj) },
];
} }
const rows: InsurerFileReportField[] = []; const rows: InsurerFileReportField[] = [];
@@ -160,7 +163,9 @@ function flattenObject(
return rows; return rows;
} }
function dedupeFields(fields: InsurerFileReportField[]): InsurerFileReportField[] { function dedupeFields(
fields: InsurerFileReportField[],
): InsurerFileReportField[] {
const seen = new Set<string>(); const seen = new Set<string>();
const out: InsurerFileReportField[] = []; const out: InsurerFileReportField[] = [];
@@ -180,7 +185,8 @@ function filterEmptySections(
sections: Array<InsurerFileReportSection | undefined>, sections: Array<InsurerFileReportSection | undefined>,
): InsurerFileReportSection[] { ): InsurerFileReportSection[] {
return sections.filter( return sections.filter(
(section): section is InsurerFileReportSection => !!section && section.fields.length > 0, (section): section is InsurerFileReportSection =>
!!section && section.fields.length > 0,
); );
} }
@@ -204,7 +210,9 @@ function expertNameFromSnapshot(snapshot?: {
lastName?: string; lastName?: string;
}): string | undefined { }): string | undefined {
if (!snapshot) return undefined; if (!snapshot) return undefined;
const name = [snapshot.firstName, snapshot.lastName].filter(Boolean).join(" "); const name = [snapshot.firstName, snapshot.lastName]
.filter(Boolean)
.join(" ");
return name || undefined; return name || undefined;
} }
@@ -214,9 +222,8 @@ function collectExpertNames(
): string | undefined { ): string | undefined {
const names = new Set<string>(); const names = new Set<string>();
const blameDecision = ( const blameDecision = (blame?.expert as Record<string, unknown> | undefined)
blame?.expert as Record<string, unknown> | undefined ?.decision as Record<string, unknown> | undefined;
)?.decision as Record<string, unknown> | undefined;
const blameExpert = expertNameFromSnapshot( const blameExpert = expertNameFromSnapshot(
blameDecision?.expertProfileSnapshot as blameDecision?.expertProfileSnapshot as
| { firstName?: string; lastName?: string } | { firstName?: string; lastName?: string }
@@ -280,13 +287,16 @@ function resolveReportBlameContext(
(claim?.blameFileContext as Record<string, unknown> | undefined) (claim?.blameFileContext as Record<string, unknown> | undefined)
?.blameRequestType ?? ?.blameRequestType ??
(snapshot.accident as Record<string, unknown> | undefined)?.type, (snapshot.accident as Record<string, unknown> | undefined)?.type,
blameStatus: (claim?.blameFileContext as Record<string, unknown> | undefined) blameStatus: (
?.blameStatus, claim?.blameFileContext as Record<string, unknown> | undefined
)?.blameStatus,
parties: snapshot.parties, parties: snapshot.parties,
}; };
} }
function getPartyRole(party: ReportParty | null | undefined): string | undefined { function getPartyRole(
party: ReportParty | null | undefined,
): string | undefined {
const role = party?.role; const role = party?.role;
return typeof role === "string" ? role : undefined; return typeof role === "string" ? role : undefined;
} }
@@ -318,7 +328,8 @@ function sameParty(
second: ReportParty | null | undefined, second: ReportParty | null | undefined,
): boolean { ): boolean {
if (!first || !second) return false; if (!first || !second) return false;
const firstUserId = first.person?.userId != null ? String(first.person.userId) : ""; const firstUserId =
first.person?.userId != null ? String(first.person.userId) : "";
const secondUserId = const secondUserId =
second.person?.userId != null ? String(second.person.userId) : ""; second.person?.userId != null ? String(second.person.userId) : "";
if (firstUserId && secondUserId) return firstUserId === secondUserId; if (firstUserId && secondUserId) return firstUserId === secondUserId;
@@ -330,7 +341,9 @@ function resolveEvaluationReply(
): Record<string, unknown> | undefined { ): Record<string, unknown> | undefined {
const evaluation = claim?.evaluation as Record<string, unknown> | undefined; const evaluation = claim?.evaluation as Record<string, unknown> | undefined;
return ( return (
(evaluation?.damageExpertReplyFinal as Record<string, unknown> | undefined) ?? (evaluation?.damageExpertReplyFinal as
| Record<string, unknown>
| undefined) ??
(evaluation?.damageExpertReply as Record<string, unknown> | undefined) (evaluation?.damageExpertReply as Record<string, unknown> | undefined)
); );
} }
@@ -375,10 +388,18 @@ function buildThirdPartyInsuranceFields(
const role = getPartyRole(party); const role = getPartyRole(party);
const direct = (party?.insurance ?? {}) as Record<string, unknown>; const direct = (party?.insurance ?? {}) as Record<string, unknown>;
const blameInquiry = pickInquiryReportPayload( const blameInquiry = pickInquiryReportPayload(
inquiryRoleData(blame?.inquiries as Record<string, unknown> | undefined, "thirdParty", role), inquiryRoleData(
blame?.inquiries as Record<string, unknown> | undefined,
"thirdParty",
role,
),
); );
const claimInquiry = pickInquiryReportPayload( const claimInquiry = pickInquiryReportPayload(
inquiryRoleData(claim?.inquiries as Record<string, unknown> | undefined, "thirdParty", role), inquiryRoleData(
claim?.inquiries as Record<string, unknown> | undefined,
"thirdParty",
role,
),
); );
return [ return [
@@ -462,12 +483,23 @@ function buildCarBodyInsuranceFields(
party?.insurance?.carBody ?? party?.insurance?.carBody ??
{}) as Record<string, unknown>; {}) as Record<string, unknown>;
const blameInquiry = pickInquiryReportPayload( const blameInquiry = pickInquiryReportPayload(
inquiryRoleData(blame?.inquiries as Record<string, unknown> | undefined, "carBody", role), inquiryRoleData(
blame?.inquiries as Record<string, unknown> | undefined,
"carBody",
role,
),
); );
const claimInquiry = pickInquiryReportPayload( const claimInquiry = pickInquiryReportPayload(
inquiryRoleData(claim?.inquiries as Record<string, unknown> | undefined, "carBody", role), inquiryRoleData(
claim?.inquiries as Record<string, unknown> | undefined,
"carBody",
role,
),
); );
const legacy = (blame?.carBodyInsuranceDetail ?? {}) as Record<string, unknown>; const legacy = (blame?.carBodyInsuranceDetail ?? {}) as Record<
string,
unknown
>;
return [ return [
{ {
@@ -556,7 +588,10 @@ function buildPartyOwnerSection(
{ {
label: PR.nationalCode, label: PR.nationalCode,
value: options?.useClaimOwnerFallback value: options?.useClaimOwnerFallback
? firstDefined(person?.nationalCodeOfInsurer, money?.nationalCodeOfInsurer) ? firstDefined(
person?.nationalCodeOfInsurer,
money?.nationalCodeOfInsurer,
)
: firstDefined(person?.nationalCodeOfInsurer, person?.nationalCode), : firstDefined(person?.nationalCodeOfInsurer, person?.nationalCode),
}, },
{ {
@@ -572,9 +607,55 @@ function buildPartyOwnerSection(
]); ]);
} }
function licenseFieldsFromInquiry( function buildParticipantRolesSection(
inquiry?: Record<string, unknown>, title: string,
): { licenseType?: string; licenseDate?: string } { party: ReportParty | null | undefined,
): InsurerFileReportSection | undefined {
const participants = Array.isArray(party?.participants)
? 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 = participant?.unknown
? PR.unknown
: [
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,
]
.filter(Boolean)
.join("، ");
return {
label: roleLabels[role] ?? persianFieldPath(role),
value: value || PR.empty,
};
});
return buildSection(title, fields, false);
}
function licenseFieldsFromInquiry(inquiry?: Record<string, unknown>): {
licenseType?: string;
licenseDate?: string;
} {
if (!inquiry) return {}; if (!inquiry) return {};
return { return {
licenseType: firstDefined( licenseType: firstDefined(
@@ -700,7 +781,9 @@ function buildCaseTimelineSection(
}, },
{ {
label: PR.evaluationRegisteredAt, label: PR.evaluationRegisteredAt,
value: formatDateTime(resolveEvaluationSubmittedAt(claim, evaluationReply)), value: formatDateTime(
resolveEvaluationSubmittedAt(claim, evaluationReply),
),
}, },
]); ]);
} }
@@ -709,10 +792,14 @@ function buildFanavaranCodesSection(
claim?: Record<string, unknown> | null, claim?: Record<string, unknown> | null,
): InsurerFileReportSection | undefined { ): InsurerFileReportSection | undefined {
if (!claim) return undefined; if (!claim) return undefined;
const sync = (claim.fanavaranSync as Record<string, unknown> | undefined) ?? {}; const sync =
const baseClaim = (sync.baseClaim as Record<string, unknown> | undefined) ?? {}; (claim.fanavaranSync as Record<string, unknown> | undefined) ?? {};
const damageCase = (sync.damageCase as Record<string, unknown> | undefined) ?? {}; const baseClaim =
const expertise = (sync.expertise as Record<string, unknown> | undefined) ?? {}; (sync.baseClaim as Record<string, unknown> | undefined) ?? {};
const damageCase =
(sync.damageCase as Record<string, unknown> | undefined) ?? {};
const expertise =
(sync.expertise as Record<string, unknown> | undefined) ?? {};
return buildSection(PR.fanavaranSection, [ return buildSection(PR.fanavaranSection, [
{ {
@@ -725,7 +812,11 @@ function buildFanavaranCodesSection(
}, },
{ {
label: PR.fanavaranDamageCaseId, label: PR.fanavaranDamageCaseId,
value: firstDefined(claim.dmgCaseId, damageCase.dmgCaseId, expertise.dmgCaseId), value: firstDefined(
claim.dmgCaseId,
damageCase.dmgCaseId,
expertise.dmgCaseId,
),
}, },
{ {
label: PR.fanavaranExpertiseId, label: PR.fanavaranExpertiseId,
@@ -824,10 +915,19 @@ function buildEvaluationPartsSection(
const prefix = `${PR.part} ${PERSIAN_NUMBER_FORMATTER.format(index + 1)}`; const prefix = `${PR.part} ${PERSIAN_NUMBER_FORMATTER.format(index + 1)}`;
return [ return [
{ label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) }, { label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) },
{ label: `${prefix} / ${PR.damageType}`, value: asString(part.typeOfDamage) }, {
label: `${prefix} / ${PR.damageType}`,
value: asString(part.typeOfDamage),
},
{ label: `${prefix} / ${PR.partPrice}`, value: formatToman(part.price) }, { 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}`, label: `${prefix} / ${PR.factorNeeded}`,
value: value:
@@ -835,7 +935,10 @@ function buildEvaluationPartsSection(
? persianStatus(part.factorNeeded) ? persianStatus(part.factorNeeded)
: undefined, : undefined,
}, },
{ label: `${prefix} / ${PR.daghi}`, value: evaluationDaghiValue(part.daghi) }, {
label: `${prefix} / ${PR.daghi}`,
value: evaluationDaghiValue(part.daghi),
},
]; ];
}); });
@@ -852,10 +955,11 @@ function buildAccidentReportSection(
const snapshotAccident = ( const snapshotAccident = (
claim?.snapshot as { accident?: Record<string, unknown> } | undefined claim?.snapshot as { accident?: Record<string, unknown> } | undefined
)?.accident; )?.accident;
const blameDecision = ( const blameDecision = (blame?.expert as Record<string, unknown> | undefined)
blame?.expert as Record<string, unknown> | undefined ?.decision as Record<string, unknown> | undefined;
)?.decision as Record<string, unknown> | undefined; const decisionFields = blameDecision?.fields as
const decisionFields = blameDecision?.fields as Record<string, unknown> | undefined; | Record<string, unknown>
| undefined;
return buildSection(PR.accidentSection, [ return buildSection(PR.accidentSection, [
{ {
@@ -868,7 +972,8 @@ function buildAccidentReportSection(
}, },
{ {
label: PR.accidentTime, label: PR.accidentTime,
value: asString(statement?.accidentTime) ?? asString(snapshotAccident?.time), value:
asString(statement?.accidentTime) ?? asString(snapshotAccident?.time),
}, },
{ {
label: PR.experts, label: PR.experts,
@@ -914,7 +1019,8 @@ function buildAccidentReportSection(
{ {
label: PR.accidentWay, label: PR.accidentWay,
value: asString( value: asString(
(decisionFields?.accidentWay as { label?: string } | undefined)?.label ?? (decisionFields?.accidentWay as { label?: string } | undefined)
?.label ??
( (
snapshotAccident?.classification as { snapshotAccident?.classification as {
accidentWay?: { label?: string }; accidentWay?: { label?: string };
@@ -925,7 +1031,8 @@ function buildAccidentReportSection(
{ {
label: PR.accidentReason, label: PR.accidentReason,
value: asString( value: asString(
(decisionFields?.accidentReason as { label?: string } | undefined)?.label ?? (decisionFields?.accidentReason as { label?: string } | undefined)
?.label ??
( (
snapshotAccident?.classification as { snapshotAccident?.classification as {
accidentReason?: { label?: string }; accidentReason?: { label?: string };
@@ -936,7 +1043,8 @@ function buildAccidentReportSection(
{ {
label: PR.accidentType, label: PR.accidentType,
value: asString( value: asString(
(decisionFields?.accidentType as { label?: string } | undefined)?.label ?? (decisionFields?.accidentType as { label?: string } | undefined)
?.label ??
( (
snapshotAccident?.classification as { snapshotAccident?.classification as {
accidentType?: { label?: string }; accidentType?: { label?: string };
@@ -968,12 +1076,17 @@ export function buildInsurerFileReport(file: {
: null; : null;
const isCarBody = const isCarBody =
String((blameContext?.type as string | undefined) ?? "") === "CAR_BODY"; String((blameContext?.type as string | undefined) ?? "") === "CAR_BODY";
const includeGuiltySections = !!guiltyParty && !(isCarBody && sameParty(damagedParty, guiltyParty)); const includeGuiltySections =
!!guiltyParty && !(isCarBody && sameParty(damagedParty, guiltyParty));
const claimVehicle = claim?.vehicle as Record<string, unknown> | undefined; const claimVehicle = claim?.vehicle as Record<string, unknown> | undefined;
const sections = filterEmptySections([ const sections = filterEmptySections([
buildCaseTimelineSection(overview, claim), buildCaseTimelineSection(overview, claim),
buildParticipantRolesSection(PR.damagedParticipantsSection, damagedParty),
includeGuiltySections
? buildParticipantRolesSection(PR.guiltyParticipantsSection, guiltyParty)
: undefined,
buildPartyOwnerSection(PR.ownerSection, damagedParty, { buildPartyOwnerSection(PR.ownerSection, damagedParty, {
claim, claim,
useClaimOwnerFallback: true, useClaimOwnerFallback: true,
@@ -1003,7 +1116,11 @@ export function buildInsurerFileReport(file: {
buildCarBodyInsuranceFields(guiltyParty, blameContext, claim), buildCarBodyInsuranceFields(guiltyParty, blameContext, claim),
) )
: undefined, : undefined,
buildPartyVehicleSection(PR.damagedVehicleSection, damagedParty, claimVehicle), buildPartyVehicleSection(
PR.damagedVehicleSection,
damagedParty,
claimVehicle,
),
includeGuiltySections includeGuiltySections
? buildPartyVehicleSection(PR.guiltyVehicleSection, guiltyParty) ? buildPartyVehicleSection(PR.guiltyVehicleSection, guiltyParty)
: undefined, : undefined,

View File

@@ -5,6 +5,8 @@ export const PR = {
empty: "-", empty: "-",
ownerSection: "مالک خودروی زیان دیده", ownerSection: "مالک خودروی زیان دیده",
guiltyOwnerSection: "مالک خودروی مقصر", guiltyOwnerSection: "مالک خودروی مقصر",
damagedParticipantsSection: "اشخاص و نقش‌های خودروی زیان‌دیده",
guiltyParticipantsSection: "اشخاص و نقش‌های خودروی مقصر",
driverSection: "راننده خودروی زیان دیده", driverSection: "راننده خودروی زیان دیده",
damagedThirdPartyInsuranceSection: "بیمه شخص ثالث زیان‌دیده", damagedThirdPartyInsuranceSection: "بیمه شخص ثالث زیان‌دیده",
damagedCarBodyInsuranceSection: "بیمه بدنه زیان‌دیده", damagedCarBodyInsuranceSection: "بیمه بدنه زیان‌دیده",
@@ -61,6 +63,11 @@ export const PR = {
claimsDamage: "ادعای خسارت", claimsDamage: "ادعای خسارت",
acceptsExpertOpinion: "پذیرش نظر کارشناس", acceptsExpertOpinion: "پذیرش نظر کارشناس",
partyRole: "نقش طرف", partyRole: "نقش طرف",
driverRole: "راننده",
vehicleOwnerRole: "مالک وسیله نقلیه",
thirdPartyPolicyholderRole: "بیمه‌گذار شخص ثالث",
carBodyPolicyholderRole: "بیمه‌گذار بدنه",
unknown: "نامشخص",
data: "اطلاعات", data: "اطلاعات",
date: "تاریخ", date: "تاریخ",
time: "زمان", time: "زمان",

View File

@@ -0,0 +1,195 @@
import {
ApiHideProperty,
ApiProperty,
ApiPropertyOptional,
} from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
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({
description:
"Only for the damaged party's third-party policyholder when the identity is genuinely unknown.",
})
@IsOptional()
@IsBoolean()
unknown?: boolean;
@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({ maxLength: 17, example: "NAAM01E15HK123456" })
@IsOptional()
@IsString()
@MaxLength(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 }) @Prop({ type: InquiryStatusSchema })
drivingLicence?: InquiryStatus; drivingLicence?: InquiryStatus;
@Prop({ type: InquiryStatusSchema })
ownership?: InquiryStatus;
} }
export const CaseInquiriesSchema = SchemaFactory.createForClass(CaseInquiries); export const CaseInquiriesSchema = SchemaFactory.createForClass(CaseInquiries);

View File

@@ -0,0 +1,52 @@
import { ExpertInsurerService } from "./expert-insurer.service";
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);
});
});

View File

@@ -264,6 +264,18 @@ export class ExpertInsurerService {
return String(aid); 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) { private mapBlameFileSummaryForInsurerExpert(b: any) {
return { return {
kind: "blame" as const, kind: "blame" as const,
@@ -275,6 +287,10 @@ export class ExpertInsurerService {
blameStatus: b.blameStatus, blameStatus: b.blameStatus,
createdAt: b.createdAt, createdAt: b.createdAt,
updatedAt: b.updatedAt, updatedAt: b.updatedAt,
reviewDurationMinutes: this.calculateReviewDurationMinutes(
b.createdAt,
b.updatedAt,
),
}; };
} }
@@ -288,6 +304,10 @@ export class ExpertInsurerService {
claimStatus: c.claimStatus, claimStatus: c.claimStatus,
createdAt: c.createdAt, createdAt: c.createdAt,
updatedAt: c.updatedAt, updatedAt: c.updatedAt,
reviewDurationMinutes: this.calculateReviewDurationMinutes(
c.createdAt,
c.updatedAt,
),
}; };
} }

View File

@@ -89,4 +89,49 @@ describe("insurer timeline Persian labels", () => {
captureKey: "front", 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_LABEL = "رویداد پرونده";
const GENERIC_EVENT_FA_DESCRIPTION = "رویدادی در روند پرونده ثبت شد."; 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 * Persian descriptions for workflow-step events. These are deliberately
* complete sentences because the insurer timeline exposes them as the event * complete sentences because the insurer timeline exposes them as the event
@@ -276,10 +283,15 @@ export function localizeTimelineMetadata(event: {
}): Record<string, unknown> | null { }): Record<string, unknown> | null {
if (!event.metadata) return null; if (!event.metadata) return null;
const reason = event.metadata.reason;
const localizedReason =
typeof reason === "string" ? SYSTEM_REASON_FA_TRANSLATIONS[reason] : null;
return { return {
...event.metadata, ...event.metadata,
...(Object.prototype.hasOwnProperty.call(event.metadata, "description") ...(Object.prototype.hasOwnProperty.call(event.metadata, "description")
? { description: getEventFaDescription(event) } ? { description: getEventFaDescription(event) }
: {}), : {}),
...(localizedReason ? { reason: localizedReason } : {}),
}; };
} }

View File

@@ -1,44 +1,9 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { PlatesDto } from "src/plates/dto/plate.dto"; import { PlatesDto } from "src/plates/dto/plate.dto";
import { Plates } from "src/Types&Enums/plate.interface"; import { Plates } from "src/Types&Enums/plate.interface";
import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto";
export class AddPlateDto { export class AddPlateDto extends InquiryParticipantFieldsDto {}
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 AddPlateProfileDto { export class AddPlateProfileDto {
plateId: string; plateId: string;

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; 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 { Types } from "mongoose";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; 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"; } from "src/Types&Enums/blame-request-management/accident-conditions.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { IsEnum } from "class-validator"; import { IsEnum } from "class-validator";
import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto";
export class InitialFormDto { export class InitialFormDto {
@ApiProperty({ required: false, default: false }) @ApiProperty({ required: false, default: false })
@@ -222,51 +223,8 @@ export class BlameConfessionDtoV2 {
imGuilty?: boolean; imGuilty?: boolean;
} }
/** /** V2 initial-form step submitted with the shared structured vehicle contract. */
* V2 initial-form step submitted with a VIN/chassis number instead of a plate. export class InitialFormVinDto extends InquiryParticipantFieldsDto {}
* 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;
}
// export class DocsOfThisFile { // export class DocsOfThisFile {
// @ApiProperty() // @ApiProperty()

View File

@@ -1,143 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto";
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;
}
/** /**
* Inquiry body for the V6 call-center flow. * Inquiry body for the V6 call-center flow.
* Same as V3 but without `sheba` — the user adds their own IBAN later via the link. * Same as V3 but without `sheba` — the user adds their own IBAN later via the link.
*/ */
export class RunCallCenterInquiryV6Dto { export class RunCallCenterInquiryV6Dto extends InquiryParticipantFieldsDto {}
@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;
}
/** /**
* VIN / chassis variant of the V6 call-center inquiry. * VIN / chassis variant of the V6 call-center inquiry.
* Identical to `RunCallCenterInquiryV6Dto` but replaces `plate` with `vin`. * Identical to `RunCallCenterInquiryV6Dto` but replaces `plate` with `vin`.
* Sheba (IBAN) is intentionally absent — the user provides it themselves via the link. * Sheba (IBAN) is intentionally absent — the user provides it themselves via the link.
*/ */
export class RunCallCenterInquiryVinV6Dto { export class RunCallCenterInquiryVinV6Dto extends InquiryParticipantFieldsDto {}
@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;
}

View File

@@ -1,70 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiPropertyOptional } from "@nestjs/swagger";
import { import { IsOptional, IsString } from "class-validator";
IsBoolean, import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto";
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;
}
/** /**
* Body for `POST run-inquiries/:requestId`. * Body for `POST run-inquiries/:requestId`.
* First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only). * First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only).
*/ */
export class RunInquiriesV3Dto { export class RunInquiriesV3Dto extends InquiryParticipantFieldsDto {
@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;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: "IR123456789012345678901234", example: "IR123456789012345678901234",
description: description:
@@ -73,32 +15,6 @@ export class RunInquiriesV3Dto {
@IsOptional() @IsOptional()
@IsString() @IsString()
sheba?: string; 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`. * 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). * First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only).
*/ */
export class RunInquiriesVinV3Dto { export class RunInquiriesVinV3Dto extends InquiryParticipantFieldsDto {
@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({ @ApiPropertyOptional({
example: "IR123456789012345678901234", example: "IR123456789012345678901234",
description: description:
@@ -146,30 +31,4 @@ export class RunInquiriesVinV3Dto {
@IsOptional() @IsOptional()
@IsString() @IsString()
sheba?: string; 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

@@ -50,6 +50,38 @@ export class Person {
} }
export const PersonSchema = SchemaFactory.createForClass(Person); export const PersonSchema = SchemaFactory.createForClass(Person);
@Schema({ _id: false })
export class InquiryParticipant {
@Prop({ required: true })
participantId: string;
@Prop()
nationalCode: string;
@Prop()
birthday: string;
@Prop() fullName?: string;
@Prop() phoneNumber?: string;
@Prop({ type: Boolean }) hasDrivingLicense?: boolean;
@Prop() licenseNumber?: string;
@Prop() licenseType?: string;
@Prop({ type: Boolean }) unknown?: boolean;
}
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 }) @Schema({ _id: false })
export class Vehicle { export class Vehicle {
@Prop() plateId?: string; @Prop() plateId?: string;
@@ -59,6 +91,9 @@ export class Vehicle {
@Prop() model?: string; @Prop() model?: string;
@Prop() type?: string; @Prop() type?: string;
@Prop() isNew?: boolean; @Prop() isNew?: boolean;
@Prop({ enum: ["CURRENT", "RECENTLY_TRANSFERRED"] })
registrationState?: "CURRENT" | "RECENTLY_TRANSFERRED";
@Prop() previousPlateId?: string;
/** /**
* Full external inquiry payload (Tejarat/SandHub) stored as-is * Full external inquiry payload (Tejarat/SandHub) stored as-is
@@ -123,7 +158,6 @@ export const InsuranceSchema = SchemaFactory.createForClass(Insurance);
@Schema({ _id: false }) @Schema({ _id: false })
export class PartyStatement { export class PartyStatement {
@Prop({ default: false }) @Prop({ default: false })
acceptsExpertOpinion?: boolean; acceptsExpertOpinion?: boolean;
@@ -143,11 +177,11 @@ export class PartyStatement {
@Prop() roadCondition?: string; @Prop() roadCondition?: string;
@Prop() lightCondition?: string; @Prop() lightCondition?: string;
} }
export const PartyStatementSchema = SchemaFactory.createForClass(PartyStatement); export const PartyStatementSchema =
SchemaFactory.createForClass(PartyStatement);
@Schema({ _id: false }) @Schema({ _id: false })
export class EvidenceBundle { export class EvidenceBundle {
@Prop({ type: [String] }) @Prop({ type: [String] })
images?: string[]; images?: string[];
@@ -156,7 +190,8 @@ export class EvidenceBundle {
@Prop() videoId?: string; @Prop() videoId?: string;
} }
export const EvidenceBundleSchema = SchemaFactory.createForClass(EvidenceBundle); export const EvidenceBundleSchema =
SchemaFactory.createForClass(EvidenceBundle);
@Schema({ _id: false }) @Schema({ _id: false })
export class Signature { export class Signature {
@@ -178,13 +213,19 @@ export const PartyConfirmationSchema =
@Schema({ _id: false }) @Schema({ _id: false })
export class Party { export class Party {
@Prop({ enum: PartyRole }) @Prop({ enum: PartyRole })
role: PartyRole; role: PartyRole;
@Prop({ type: PersonSchema }) @Prop({ type: PersonSchema })
person: Person; person: Person;
/** Distinct people involved with this vehicle; roles below reference participantId. */
@Prop({ type: [InquiryParticipantSchema], default: undefined })
participants?: InquiryParticipant[];
@Prop({ type: InquiryParticipantRolesSchema })
participantRoles?: InquiryParticipantRoles;
/** /**
* CAR_BODY only: first form – accident with car vs object. * CAR_BODY only: first form – accident with car vs object.
* Second form (guilty/damaged) may be added later as carBodySecondForm. * Second form (guilty/damaged) may be added later as carBodySecondForm.

View File

@@ -146,6 +146,22 @@ export class FirstPartyDetail {
@Prop({ type: Boolean }) @Prop({ type: Boolean })
firstPartyCertificate: boolean | null; 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() vehicleVin?: string;
} }
export class SecondPartyDetail { export class SecondPartyDetail {
@@ -178,6 +194,22 @@ export class SecondPartyDetail {
@Prop({ type: SecondPartyFile }) @Prop({ type: SecondPartyFile })
secondPartyFiles?: 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() vehicleVin?: string;
} }
export class LocationDto { export class LocationDto {

View File

@@ -0,0 +1,508 @@
import { BadRequestException } from "@nestjs/common";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import {
InquiryParticipantRole,
VehicleRegistrationState,
} from "src/common/dto/inquiry-participants.dto";
import {
assertPreviousPlateInquiryMatchesVin,
isMappedPolicyCurrent,
normalizeInquirySubmission,
participantForRole,
resolveInquiryParticipants,
resolveInquiryVehicle,
runPlateInquiryWithFallback,
vehiclePlateCandidates,
} from "./inquiry-participant-resolver";
describe("inquiry participant resolver", () => {
it("links several roles to one explicitly shared person", () => {
const resolved = resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
driver: {
nationalCode: "0012345678",
birthday: "1370/01/01",
hasDrivingLicense: true,
licenseNumber: "123456789",
licenseType: "1",
},
vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER },
thirdPartyPolicyholder: {
sameAs: InquiryParticipantRole.VEHICLE_OWNER,
},
});
expect(resolved.participants).toHaveLength(1);
expect(resolved.roles).toEqual({
driver: "DRIVER",
vehicleOwner: "DRIVER",
thirdPartyPolicyholder: "DRIVER",
});
});
it("requires both previous plate and VIN for a recent transfer", () => {
expect(() =>
resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate: {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
},
}),
).toThrow(BadRequestException);
});
it("defaults an omitted registration state to CURRENT", () => {
expect(
resolveInquiryVehicle({
currentPlate: {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
},
}).registrationState,
).toBe(VehicleRegistrationState.CURRENT);
});
it("rejects the removed flat inquiry contract", () => {
expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
nationalCodeOfDriver: "0012345678",
driverBirthday: "1370/01/01",
driverLicense: "123456789",
nationalCodeOfInsurer: "0098765432",
insurerBirthday: "1360/02/02",
driverIsInsurer: false,
} as any),
).toThrow(BadRequestException);
});
it("rejects phone numbers in participant inquiry identity", () => {
expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
driver: {
nationalCode: "0012345678",
birthday: "1370/01/01",
hasDrivingLicense: false,
phoneNumber: "09120000000",
},
vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER },
thirdPartyPolicyholder: {
sameAs: InquiryParticipantRole.DRIVER,
},
} as any),
).toThrow(BadRequestException);
});
it("keeps third-party and car-body policyholders distinct", () => {
const resolved = resolveInquiryParticipants(BlameRequestType.CAR_BODY, {
driver: {
nationalCode: "0012345678",
birthday: "1370/01/01",
hasDrivingLicense: false,
},
vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER },
thirdPartyPolicyholder: {
nationalCode: "0023456789",
birthday: "1360/02/02",
},
carBodyPolicyholder: {
nationalCode: "0034567890",
birthday: "1350/03/03",
},
});
expect(
participantForRole(
resolved,
InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER,
)?.nationalCode,
).toBe("0023456789");
expect(
participantForRole(resolved, InquiryParticipantRole.CAR_BODY_POLICYHOLDER)
?.nationalCode,
).toBe("0034567890");
});
it("projects role-complete input onto the existing inquiry fields", () => {
const normalized = normalizeInquirySubmission(BlameRequestType.CAR_BODY, {
vehicle: {
currentPlate: {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
},
},
driver: {
nationalCode: "0012345678",
birthday: "1370/01/01",
hasDrivingLicense: true,
licenseNumber: "D-1",
licenseType: "1",
},
vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER },
thirdPartyPolicyholder: {
nationalCode: "0023456789",
birthday: "1360/02/02",
},
carBodyPolicyholder: {
nationalCode: "0034567890",
birthday: "1350/03/03",
},
});
expect(normalized.dto.nationalCodeOfInsurer).toBe("0023456789");
expect(normalized.dto.nationalCodeOfDriver).toBe("0012345678");
expect(normalized.carBodyPolicyholder?.nationalCode).toBe("0034567890");
expect(normalized.vehicleOwner?.nationalCode).toBe("0012345678");
});
it("orders the current plate before the previous-plate fallback", () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
expect(
vehiclePlateCandidates({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
}),
).toEqual([
{ kind: "CURRENT", plate: currentPlate },
{ kind: "PREVIOUS", plate: previousPlate },
]);
});
it("rejects a previous-plate result for another chassis", () => {
expect(() =>
assertPreviousPlateInquiryMatchesVin("NAAM01E15HK123456", {
VinNumberField: "DIFFERENTVIN00001",
}),
).toThrow(BadRequestException);
});
it("requires the driver's licence status in the new contract", () => {
expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
driver: { nationalCode: "0012345678", birthday: "1370/01/01" },
vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER },
thirdPartyPolicyholder: {
sameAs: InquiryParticipantRole.VEHICLE_OWNER,
},
}),
).toThrow(BadRequestException);
});
it("falls back to the previous plate and accepts only a matching VIN", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
const query = jest
.fn()
.mockRejectedValueOnce(new Error("not found"))
.mockResolvedValueOnce({
mapped: { VinNumberField: "NAAM01E15HK123456", CompanyName: "پارسیان" },
});
const result = await runPlateInquiryWithFallback<{
mapped: { VinNumberField?: string; CompanyName?: string };
}>({
vehicle: {
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
},
fallbackCurrentPlate: currentPlate,
query,
isUsable: (value) => !!value.mapped.CompanyName,
mappedValue: (value) => value.mapped,
});
expect(query).toHaveBeenCalledTimes(2);
expect(result.plateKind).toBe("PREVIOUS");
expect(result.attempts).toMatchObject([
{ plateKind: "CURRENT", succeeded: false, error: "not found" },
{ plateKind: "PREVIOUS", succeeded: true, usable: true },
]);
expect(result.attempts[0]).toMatchObject({
plate: currentPlate,
vin: "NAAM01E15HK123456",
});
expect(result.attempts[1]).toMatchObject({
plate: previousPlate,
vin: "NAAM01E15HK123456",
});
});
it("rejects a car-body policyholder on a third-party case", () => {
expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
driver: {
nationalCode: "0012345678",
birthday: "1370/01/01",
hasDrivingLicense: false,
},
vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER },
thirdPartyPolicyholder: {
sameAs: InquiryParticipantRole.VEHICLE_OWNER,
},
carBodyPolicyholder: {
nationalCode: "0034567890",
birthday: "1350/03/03",
},
}),
).toThrow(BadRequestException);
});
it("does not accept a previous plate unless recent transfer is declared", () => {
expect(() =>
resolveInquiryVehicle({
registrationState: VehicleRegistrationState.CURRENT,
currentPlate: {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
},
previousPlate: {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
},
}),
).toThrow(BadRequestException);
});
it("rejects sameAs together with any person-specific fields", () => {
expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
driver: {
nationalCode: "0012345678",
birthday: "1370/01/01",
hasDrivingLicense: false,
},
vehicleOwner: {
sameAs: InquiryParticipantRole.DRIVER,
fullName: "Duplicate details must not be silently discarded",
},
thirdPartyPolicyholder: {
sameAs: InquiryParticipantRole.DRIVER,
},
}),
).toThrow(BadRequestException);
});
it("rejects conflicting legacy and structured current plates", () => {
expect(() =>
normalizeInquirySubmission(BlameRequestType.THIRD_PARTY, {
plate: {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
},
vehicle: {
registrationState: VehicleRegistrationState.CURRENT,
currentPlate: {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
},
},
driver: {
nationalCode: "0012345678",
birthday: "1370/01/01",
hasDrivingLicense: false,
},
vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER },
thirdPartyPolicyholder: {
sameAs: InquiryParticipantRole.DRIVER,
},
}),
).toThrow(BadRequestException);
});
it("treats an expired policy result as stale", () => {
expect(isMappedPolicyCurrent({ EndDate: "1404/01/01" }, "2026-09-13")).toBe(
false,
);
expect(isMappedPolicyCurrent({ EndDate: "1406/01/01" }, "2026-09-13")).toBe(
true,
);
});
it("falls back from a stale current policy to a current previous-plate policy", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
const query = jest
.fn()
.mockResolvedValueOnce({
mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" },
})
.mockResolvedValueOnce({
mapped: {
CompanyName: "پارسیان",
EndDate: "1406/01/01",
VinNumberField: "NAAM01E15HK123456",
},
});
const result = await runPlateInquiryWithFallback<{
mapped: Record<string, any>;
}>({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
}),
fallbackCurrentPlate: currentPlate,
query,
isUsable: (value) =>
!!value.mapped.CompanyName &&
isMappedPolicyCurrent(value.mapped, "2026-09-13"),
mappedValue: (value) => value.mapped,
});
expect(result.plateKind).toBe("PREVIOUS");
expect(result.attempts[0]).toMatchObject({
plateKind: "CURRENT",
succeeded: true,
usable: false,
});
});
it("rejects the result and retains audit attempts when every plate is unusable", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
await expect(
runPlateInquiryWithFallback({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
}),
fallbackCurrentPlate: currentPlate,
query: async () => ({ mapped: { CompanyName: "پارسیان" } }),
isUsable: () => false,
mappedValue: (value) => value.mapped,
}),
).rejects.toMatchObject({
attempts: [
{ plateKind: "CURRENT", succeeded: true, usable: false },
{ plateKind: "PREVIOUS", succeeded: true, usable: false },
],
});
});
it("does not use the previous plate after a transport or provider outage", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const query = jest.fn().mockRejectedValue(
Object.assign(new Error("upstream timeout"), {
code: "ETIMEDOUT",
}),
);
await expect(
runPlateInquiryWithFallback({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate,
previousPlate: {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
},
vin: "NAAM01E15HK123456",
}),
fallbackCurrentPlate: currentPlate,
query,
isUsable: () => false,
mappedValue: () => ({}),
}),
).rejects.toThrow("upstream timeout");
expect(query).toHaveBeenCalledTimes(1);
});
it("allows an explicitly unknown policyholder only for a damaged-party submission", () => {
const input = {
driver: {
nationalCode: "0012345678",
birthday: "1370/01/01",
hasDrivingLicense: false,
},
vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER },
thirdPartyPolicyholder: { unknown: true },
};
expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, input),
).toThrow(BadRequestException);
const resolved = resolveInquiryParticipants(
BlameRequestType.THIRD_PARTY,
input,
{ allowUnknownThirdPartyPolicyholder: true },
);
expect(
participantForRole(
resolved,
InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER,
),
).toMatchObject({ unknown: true });
});
});

View File

@@ -0,0 +1,575 @@
import { BadRequestException } from "@nestjs/common";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
import { gregorianDateInIran } from "src/helpers/iran-datetime";
import {
InquiryParticipantFieldsDto,
InquiryParticipantInputDto,
InquiryParticipantRole,
InquiryVehicleInputDto,
VehicleRegistrationState,
} from "src/common/dto/inquiry-participants.dto";
export interface ResolvedInquiryParticipant {
participantId: string;
nationalCode: string;
birthday: string;
fullName?: string;
hasDrivingLicense?: boolean;
licenseNumber?: string;
licenseType?: string;
unknown?: boolean;
}
export interface InquiryParticipantResolutionOptions {
allowUnknownThirdPartyPolicyholder?: boolean;
}
export interface InquiryParticipantRoleAssignments {
driver: string;
vehicleOwner?: string;
thirdPartyPolicyholder: string;
carBodyPolicyholder?: string;
}
export interface ResolvedInquiryParticipants {
participants: ResolvedInquiryParticipant[];
roles: InquiryParticipantRoleAssignments;
legacy: boolean;
}
export interface NormalizedInquirySubmission<T extends Record<string, any>> {
dto: T & {
nationalCodeOfDriver: string;
driverBirthday: string;
driverLicense?: string;
licenseType?: string;
userNoCertificate?: boolean;
nationalCodeOfInsurer: string;
insurerBirthday: string;
driverIsInsurer: boolean;
insurerLicense?: string;
};
participants: ResolvedInquiryParticipants;
driver: ResolvedInquiryParticipant;
vehicleOwner?: ResolvedInquiryParticipant;
thirdPartyPolicyholder: ResolvedInquiryParticipant;
carBodyPolicyholder?: ResolvedInquiryParticipant;
vehicle?: ResolvedInquiryVehicle;
}
export type ResolvedInquiryVehicle = InquiryVehicleInputDto & {
registrationState: VehicleRegistrationState;
};
export function participantForRole(
resolved: ResolvedInquiryParticipants,
role: InquiryParticipantRole,
): ResolvedInquiryParticipant | undefined {
const roleField = ROLE_FIELDS[
role
] as keyof InquiryParticipantRoleAssignments;
const participantId = resolved.roles[roleField];
return resolved.participants.find(
(participant) => participant.participantId === participantId,
);
}
const ROLE_FIELDS: Record<
InquiryParticipantRole,
keyof InquiryParticipantFieldsDto
> = {
[InquiryParticipantRole.DRIVER]: "driver",
[InquiryParticipantRole.VEHICLE_OWNER]: "vehicleOwner",
[InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER]: "thirdPartyPolicyholder",
[InquiryParticipantRole.CAR_BODY_POLICYHOLDER]: "carBodyPolicyholder",
};
function requiredIdentity(
role: InquiryParticipantRole,
input: InquiryParticipantInputDto,
): ResolvedInquiryParticipant {
if (Object.prototype.hasOwnProperty.call(input, "phoneNumber")) {
throw new BadRequestException(
`${role} does not accept phoneNumber; phone numbers are collected separately from inquiry identity.`,
);
}
const nationalCode = String(input.nationalCode ?? "").trim();
const birthday = String(input.birthday ?? "").trim();
if (!nationalCode || !birthday) {
throw new BadRequestException(
`${role} requires nationalCode and birthday.`,
);
}
if (
role === InquiryParticipantRole.DRIVER &&
typeof input.hasDrivingLicense !== "boolean"
) {
throw new BadRequestException("DRIVER requires hasDrivingLicense.");
}
if (
role === InquiryParticipantRole.DRIVER &&
input.hasDrivingLicense === true &&
(!String(input.licenseNumber ?? "").trim() ||
!String(input.licenseType ?? "").trim())
) {
throw new BadRequestException(
"DRIVER requires licenseNumber and licenseType when hasDrivingLicense is true.",
);
}
return {
participantId: role,
nationalCode,
birthday,
...(input.fullName ? { fullName: input.fullName } : {}),
...(input.hasDrivingLicense != null
? { hasDrivingLicense: input.hasDrivingLicense }
: {}),
...(input.licenseNumber ? { licenseNumber: input.licenseNumber } : {}),
...(input.licenseType ? { licenseType: input.licenseType } : {}),
};
}
export function resolveInquiryParticipants(
caseType: BlameRequestType,
input: Partial<InquiryParticipantFieldsDto> & Record<string, any>,
options: InquiryParticipantResolutionOptions = {},
): ResolvedInquiryParticipants {
assertStructuredInquiryInput(input);
const hasRoleCompleteInput = Object.values(ROLE_FIELDS).some(
(field) => input[field] != null,
);
if (!hasRoleCompleteInput) {
throw new BadRequestException(
"driver, vehicleOwner, and thirdPartyPolicyholder are required in the structured inquiry format.",
);
}
if (
caseType === BlameRequestType.THIRD_PARTY &&
input.carBodyPolicyholder != null
) {
throw new BadRequestException(
"CAR_BODY_POLICYHOLDER is not allowed for a THIRD_PARTY case.",
);
}
const requiredRoles = [
InquiryParticipantRole.DRIVER,
InquiryParticipantRole.VEHICLE_OWNER,
InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER,
...(caseType === BlameRequestType.CAR_BODY
? [InquiryParticipantRole.CAR_BODY_POLICYHOLDER]
: []),
];
const participants = new Map<string, ResolvedInquiryParticipant>();
const resolvedRoleIds = new Map<InquiryParticipantRole, string>();
const resolving = new Set<InquiryParticipantRole>();
const resolveRole = (role: InquiryParticipantRole): string => {
const existing = resolvedRoleIds.get(role);
if (existing) return existing;
if (resolving.has(role)) {
throw new BadRequestException(
"Participant sameAs references cannot be circular.",
);
}
const value = input[ROLE_FIELDS[role]] as
| InquiryParticipantInputDto
| undefined;
if (!value) throw new BadRequestException(`${role} is required.`);
resolving.add(role);
let participantId: string;
if (
role === InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER &&
value.unknown === true
) {
if (!options.allowUnknownThirdPartyPolicyholder) {
throw new BadRequestException(
"An unknown third-party policyholder is allowed only for the damaged party.",
);
}
const hasIdentityFields = Object.entries(value).some(
([field, fieldValue]) => field !== "unknown" && fieldValue != null,
);
if (hasIdentityFields) {
throw new BadRequestException(
`${role} must contain either unknown=true or person fields, not both.`,
);
}
const participant: ResolvedInquiryParticipant = {
participantId: role,
nationalCode: "",
birthday: "",
unknown: true,
};
participants.set(participant.participantId, participant);
participantId = participant.participantId;
} else if (value.unknown != null) {
throw new BadRequestException(
`${role} does not support the unknown option.`,
);
} else if (value.sameAs) {
const hasPersonSpecificFields = Object.entries(value).some(
([field, fieldValue]) => field !== "sameAs" && fieldValue != null,
);
if (hasPersonSpecificFields) {
throw new BadRequestException(
`${role} must contain either sameAs or identity fields, not both.`,
);
}
participantId = resolveRole(value.sameAs);
} else {
const participant = requiredIdentity(role, value);
const duplicate = [...participants.values()].find(
(item) => item.nationalCode === participant.nationalCode,
);
if (duplicate) {
throw new BadRequestException(
`${role} duplicates an existing nationalCode; use sameAs instead.`,
);
}
participants.set(participant.participantId, participant);
participantId = participant.participantId;
}
resolving.delete(role);
resolvedRoleIds.set(role, participantId);
return participantId;
};
for (const role of requiredRoles) resolveRole(role);
return {
participants: [...participants.values()],
roles: {
driver: resolvedRoleIds.get(InquiryParticipantRole.DRIVER)!,
vehicleOwner: resolvedRoleIds.get(InquiryParticipantRole.VEHICLE_OWNER)!,
thirdPartyPolicyholder: resolvedRoleIds.get(
InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER,
)!,
...(caseType === BlameRequestType.CAR_BODY
? {
carBodyPolicyholder: resolvedRoleIds.get(
InquiryParticipantRole.CAR_BODY_POLICYHOLDER,
)!,
}
: {}),
},
legacy: false,
};
}
export function resolveInquiryVehicle(
input: InquiryVehicleInputDto,
): ResolvedInquiryVehicle {
const registrationState =
input.registrationState ?? VehicleRegistrationState.CURRENT;
if (!input.currentPlate) {
throw new BadRequestException("vehicle.currentPlate is required.");
}
if (
registrationState === VehicleRegistrationState.RECENTLY_TRANSFERRED &&
(!input.previousPlate || !String(input.vin ?? "").trim())
) {
throw new BadRequestException(
"RECENTLY_TRANSFERRED requires previousPlate and vin.",
);
}
if (
registrationState !== VehicleRegistrationState.RECENTLY_TRANSFERRED &&
input.previousPlate
) {
throw new BadRequestException(
"previousPlate is only allowed for RECENTLY_TRANSFERRED vehicles.",
);
}
return { ...input, registrationState };
}
export function vehiclePlateCandidates(input?: ResolvedInquiryVehicle): Array<{
kind: "CURRENT" | "PREVIOUS";
plate: InquiryVehicleInputDto["currentPlate"];
}> {
if (!input) return [];
return [
{ kind: "CURRENT" as const, plate: input.currentPlate },
...(input.registrationState ===
VehicleRegistrationState.RECENTLY_TRANSFERRED && input.previousPlate
? [{ kind: "PREVIOUS" as const, plate: input.previousPlate }]
: []),
];
}
function normalizeVehicleSerial(value: unknown): string {
return String(value ?? "")
.toUpperCase()
.replace(/[^A-Z0-9]/g, "");
}
/** A dated result is usable only while the returned policy has not expired. */
export function isMappedPolicyCurrent(
mapped: Record<string, any>,
todayGregorian: string = gregorianDateInIran(new Date()),
): boolean {
const rawEndDate =
mapped?.EndDate ??
mapped?.HEndDte ??
mapped?.persianEndDate ??
mapped?.PolicyEndDate ??
mapped?.endDate;
if (rawEndDate == null || String(rawEndDate).trim() === "") return true;
const endDate = jalaliToGregorianDate(rawEndDate);
return endDate != null && endDate >= todayGregorian;
}
function normalizePlateForComparison(
plate: InquiryVehicleInputDto["currentPlate"],
): string {
return [
plate?.ir,
plate?.leftDigits,
plate?.centerAlphabet,
plate?.centerDigits,
]
.map((part) => String(part ?? "").trim())
.join("|");
}
const LEGACY_INQUIRY_FIELDS = [
"nationalCodeOfDriver",
"driverBirthday",
"driverLicense",
"licenseType",
"nationalCodeOfInsurer",
"insurerBirthday",
"insurerLicense",
"driverIsInsurer",
"userNoCertificate",
"plate",
"plateId",
"vin",
"isNewCar",
] as const;
function assertStructuredInquiryInput(input: Record<string, any>): void {
const legacyFields = LEGACY_INQUIRY_FIELDS.filter((field) =>
Object.prototype.hasOwnProperty.call(input, field),
);
if (legacyFields.length > 0) {
throw new BadRequestException(
`Legacy inquiry fields are not accepted: ${legacyFields.join(", ")}. Use driver, vehicleOwner, thirdPartyPolicyholder, carBodyPolicyholder, and vehicle.`,
);
}
}
export function assertPreviousPlateInquiryMatchesVin(
expectedVin: string,
mapped: Record<string, any>,
): void {
const expected = normalizeVehicleSerial(expectedVin);
const candidates = [
mapped?.VinNumberField,
mapped?.vin,
mapped?.VIN,
mapped?.ChassisNumberField,
mapped?.chassisNumber,
mapped?.ChassisNo,
mapped?.vehicle?.VIN,
mapped?.vehicle?.ChassisNo,
]
.map(normalizeVehicleSerial)
.filter(Boolean);
if (!expected || !candidates.includes(expected)) {
throw new BadRequestException(
"Previous-plate inquiry does not match the submitted VIN/chassis; manual review is required.",
);
}
}
export function isPolicyNotFoundError(error: unknown): boolean {
const candidate = error as Record<string, any> | null;
const status = candidate?.status ?? candidate?.response?.status;
if (Number(status) === 404) return true;
const code = String(
candidate?.code ?? candidate?.response?.data?.code ?? "",
).toUpperCase();
if (["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY"].includes(code)) {
return true;
}
const message = String(
candidate?.message ?? candidate?.response?.data?.message ?? error ?? "",
);
return /\bnot[ -]?found\b|\bno (?:relevant )?policy\b|یافت نشد|فاقد بیمه(?:نامه)?/i.test(
message,
);
}
export async function runPlateInquiryWithFallback<T>(options: {
vehicle?: ResolvedInquiryVehicle;
fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"];
query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise<T>;
isUsable: (value: T) => boolean;
mappedValue: (value: T) => Record<string, any>;
shouldFallbackOnError?: (error: unknown) => boolean;
}): Promise<{
value: T;
plateKind: "CURRENT" | "PREVIOUS";
attempts: Array<{
plateKind: "CURRENT" | "PREVIOUS";
plate: InquiryVehicleInputDto["currentPlate"];
vin?: string;
succeeded: boolean;
usable?: boolean;
error?: string;
}>;
}> {
const candidates = options.vehicle
? vehiclePlateCandidates(options.vehicle)
: [{ kind: "CURRENT" as const, plate: options.fallbackCurrentPlate }];
let lastError: unknown;
const attempts: Array<{
plateKind: "CURRENT" | "PREVIOUS";
plate: InquiryVehicleInputDto["currentPlate"];
vin?: string;
succeeded: boolean;
usable?: boolean;
error?: string;
}> = [];
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
const isLast = index === candidates.length - 1;
try {
const value = await options.query(candidate.plate);
const usable = options.isUsable(value);
if (!usable) {
attempts.push({
plateKind: candidate.kind,
plate: candidate.plate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
succeeded: true,
usable: false,
});
if (!isLast) continue;
const error = new BadRequestException(
"No current usable policy was found for the submitted vehicle identifiers.",
) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts;
throw error;
}
if (candidate.kind === "PREVIOUS" && usable) {
assertPreviousPlateInquiryMatchesVin(
options.vehicle!.vin!,
options.mappedValue(value),
);
}
attempts.push({
plateKind: candidate.kind,
plate: candidate.plate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
succeeded: true,
usable: true,
});
return { value, plateKind: candidate.kind, attempts };
} catch (error) {
lastError = error;
const alreadyRecorded =
typeof error === "object" &&
error != null &&
Array.isArray((error as { attempts?: unknown }).attempts);
if (!alreadyRecorded) {
attempts.push({
plateKind: candidate.kind,
plate: candidate.plate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
succeeded: false,
error: error instanceof Error ? error.message : String(error),
});
}
if (
!isLast &&
!(options.shouldFallbackOnError ?? isPolicyNotFoundError)(error)
) {
if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts;
}
throw error;
}
if (isLast) {
if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts;
}
throw error;
}
}
}
throw lastError ?? new BadRequestException("Inquiry failed for all plates.");
}
export function normalizeInquirySubmission<T extends Record<string, any>>(
caseType: BlameRequestType,
input: T,
options: InquiryParticipantResolutionOptions = {},
): NormalizedInquirySubmission<T> {
const participants = resolveInquiryParticipants(
caseType,
input as any,
options,
);
const driver = participantForRole(
participants,
InquiryParticipantRole.DRIVER,
);
const thirdPartyPolicyholder = participantForRole(
participants,
InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER,
);
if (!driver || !thirdPartyPolicyholder) {
throw new BadRequestException(
"Driver and third-party policyholder identities are required.",
);
}
const vehicleOwner = participantForRole(
participants,
InquiryParticipantRole.VEHICLE_OWNER,
);
const carBodyPolicyholder = participantForRole(
participants,
InquiryParticipantRole.CAR_BODY_POLICYHOLDER,
);
const vehicle = resolveInquiryVehicle(
input.vehicle as InquiryVehicleInputDto,
);
const sameDriverAndPolicyholder =
participants.roles.driver === participants.roles.thirdPartyPolicyholder;
return {
dto: {
...input,
plate: vehicle.currentPlate,
vin: vehicle.vin,
nationalCodeOfDriver: driver.nationalCode,
driverBirthday: driver.birthday,
driverLicense: driver.licenseNumber,
licenseType: driver.licenseType,
userNoCertificate:
driver.hasDrivingLicense == null
? undefined
: !driver.hasDrivingLicense,
nationalCodeOfInsurer: thirdPartyPolicyholder.nationalCode,
insurerBirthday: thirdPartyPolicyholder.birthday,
driverIsInsurer: sameDriverAndPolicyholder,
insurerLicense: sameDriverAndPolicyholder
? driver.licenseNumber
: undefined,
isNewCar: vehicle.isNewCar,
} as NormalizedInquirySubmission<T>["dto"],
participants,
driver,
vehicleOwner,
thirdPartyPolicyholder,
carBodyPolicyholder,
vehicle,
};
}

File diff suppressed because it is too large Load Diff