From 401ad6a143fd77ed4671832aaedd9a155bf9aa57 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 12 Sep 2026 17:02:12 +0330 Subject: [PATCH 01/12] Added Docs --- docs/architecture.md | 103 +++++ ...xpert-claim-reply-submit-v2-frontend.fa.md | 228 ++++++++++ docs/inquiry-participants-proposal.fa.md | 109 +++++ docs/inquiry-participants-proposal.md | 109 +++++ docs/panel-data-frontend-handoff.fa.md | 26 ++ docs/panel-roles-reference.html | 4 +- docs/report-api-frontend-fa.md | 402 ++++++++++++++++++ 7 files changed, 979 insertions(+), 2 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/expert-claim-reply-submit-v2-frontend.fa.md create mode 100644 docs/inquiry-participants-proposal.fa.md create mode 100644 docs/inquiry-participants-proposal.md create mode 100644 docs/panel-data-frontend-handoff.fa.md create mode 100644 docs/report-api-frontend-fa.md diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..7e447f6 --- /dev/null +++ b/docs/architecture.md @@ -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 diff --git a/docs/expert-claim-reply-submit-v2-frontend.fa.md b/docs/expert-claim-reply-submit-v2-frontend.fa.md new file mode 100644 index 0000000..a4c452c --- /dev/null +++ b/docs/expert-claim-reply-submit-v2-frontend.fa.md @@ -0,0 +1,228 @@ +# راهنمای فرانت‌اند: ثبت پاسخ کارشناس خسارت (V2) + +این مستند قرارداد API زیر را توضیح می‌دهد، به‌ویژه اعتبارسنجی مبلغ‌ها و ساختار خطاهایی که باید در فرم نمایش داده شوند. + +```http +PUT /v2/expert-claim/reply/submit/:claimRequestId +Authorization: Bearer +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[].daghi.` است. + +## الگوی پیشنهادی هندل کردن خطا در فرانت‌اند + +```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` ردیف‌ها را نیز پیش از ارسال محاسبه و نمایش دهید. diff --git a/docs/inquiry-participants-proposal.fa.md b/docs/inquiry-participants-proposal.fa.md new file mode 100644 index 0000000..43545c3 --- /dev/null +++ b/docs/inquiry-participants-proposal.fa.md @@ -0,0 +1,109 @@ +# پیشنهاد مدل اشخاص در مرحله استعلام + +وضعیت: پیشنهادی +دامنه: تمام جریان‌های استعلام کاربر، کارشناس، پرونده‌ساز و مرکز تماس +نسخه انگلیسی: [inquiry-participants-proposal.md](./inquiry-participants-proposal.md) + +## مسئله + +قرارداد فعلی استعلام عمدتاً فقط راننده و شخصی با عنوان `insurer` را نگه می‌دارد. این مدل کامل نیست و نام‌گذاری نیز دقیق نیست: شخص، **بیمه‌گذار** است و **بیمه‌گر** شرکت بیمه است. + +برای هر وسیله نقلیه در یک `Party` نقش‌های هویتی زیر وجود دارد: + +| نوع پرونده | نقش‌های الزامی | +| --- | --- | +| `THIRD_PARTY` | راننده، مالک وسیله نقلیه، بیمه‌گذار شخص ثالث | +| `CAR_BODY` | راننده، مالک وسیله نقلیه، بیمه‌گذار شخص ثالث، بیمه‌گذار بدنه | + +ممکن است یک شخص چند نقش را داشته باشد، اما سیستم نباید یکسان بودن آن‌ها را فرض کند. + +## پیشنهاد + +پیش از اجرای استعلام، یک **مرحله کوتاه تعیین اشخاص** اضافه شود. ابتدا نسبت اشخاص پرسیده شود و اطلاعات فقط برای افراد متفاوت دریافت شود. دریافت بدون شرط اطلاعات کامل همه اشخاص مناسب نیست. همچنین افزودن فلگ‌های دوتایی متعدد مانند `driverIsOwner` و `ownerIsPolicyholder` باعث ابهام، تناقض و رشد سریع حالت‌ها می‌شود. + +به‌جای آن، هر نقش یا اطلاعات یک شخص جدید را داشته باشد یا به نقش قبلی ارجاع دهد: + +```json +{ + "driver": { + "nationalCode": "0012345678", + "birthday": "1370/01/01", + "licenseNumber": "123456789", + "licenseType": "1" + }, + "vehicleOwner": { "sameAs": "DRIVER" }, + "thirdPartyPolicyholder": { "sameAs": "VEHICLE_OWNER" }, + "carBodyPolicyholder": { + "nationalCode": "0098765432", + "birthday": "1365/02/03" + } +} +``` + +فیلد `carBodyPolicyholder` برای `THIRD_PARTY` مجاز نیست و برای `CAR_BODY` الزامی است. هر نقش باید فقط یکی از دو حالت «اطلاعات شخص» یا `sameAs` را داشته باشد. بک‌اند این ورودی را به فهرست اشخاص یکتا و اتصال نقش‌ها به آن‌ها تبدیل می‌کند. + +## انتقال مالکیت اخیر و پلاک قبلی + +نقش اشخاص و شناسه‌های خودرو دو موضوع جدا هستند. اگر خودرو به‌تازگی فروخته یا خریداری شده باشد، ممکن است اطلاعات رسمی یا بیمه‌نامه هنوز به پلاک قبلی متصل باشد. این وضعیت باید صریح ثبت شود و پلاک قبلی نباید جایگزین پلاک فعلی شود: + +```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 همین قوانین مشترک باشند و منطق نسبت اشخاص را جداگانه پیاده‌سازی نکنند. + +## سازگاری و انتشار تدریجی + +1. در دوره گذار، ساختار جدید در کنار فیلدهای قدیمی پذیرفته شود. +2. `nationalCodeOfDriver` به راننده و `nationalCodeOfInsurer` به بیمه‌گذار شخص ثالث نگاشت شود. اگر `driverIsInsurer=true` است، هر دو نقش به یک شخص متصل شوند. +3. `plate` یا `plateId` قدیمی به پلاک فعلی نگاشت شود؛ پلاک قبلی فقط وقتی ثبت شود که صریحاً از کاربر دریافت شده باشد. +4. برای داده‌های قدیمی، مالک خودرو یا بیمه‌گذار بدنه حدس زده نشود؛ مگر اینکه نتیجه استعلام ذخیره‌شده آن را قطعی کند، نقش «نامشخص» باقی بماند. +5. پاسخ جزئیات و گزارش، اشخاص را بر اساس نقش نمایش دهد و در زمان مهاجرت فیلدهای قدیمی را نیز حفظ کند. +6. پس از مهاجرت همه فرانت‌اندها، قرارداد جدید برای پرونده‌های تازه الزامی و فیلدهای قدیمی deprecated شوند. + +## تصمیم پیشنهادی + +راه‌حل مناسب، **دریافت شرطی اطلاعات همراه با اتصال صریح نقش‌ها** است. این روش بدون طولانی کردن مسیر اکثر کاربران، اطلاعات کامل فراهم می‌کند، از تناقض فلگ‌ها جلوگیری می‌کند و یک مدل یکسان برای همه جریان‌های استعلام می‌سازد. diff --git a/docs/inquiry-participants-proposal.md b/docs/inquiry-participants-proposal.md new file mode 100644 index 0000000..5899881 --- /dev/null +++ b/docs/inquiry-participants-proposal.md @@ -0,0 +1,109 @@ +# Inquiry participant identity proposal + +Status: proposed +Scope: every user, expert, FileMaker, and call-center inquiry flow +Persian version: [inquiry-participants-proposal.fa.md](./inquiry-participants-proposal.fa.md) + +## 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", + "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. + +## 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. + +## Compatibility and rollout + +1. Accept the new participant object alongside the legacy fields temporarily. +2. Map legacy `nationalCodeOfDriver` to Driver and `nationalCodeOfInsurer` to Third-party Policyholder. When `driverIsInsurer=true`, bind both roles to the same participant. +3. Map the legacy `plate`/`plateId` to Current Plate; leave Previous Plate absent unless it was explicitly collected. +4. Do not guess Vehicle Owner or Car-body Policyholder for old records. Mark unresolved roles as unknown unless stored inquiry evidence identifies them. +5. Update report/detail responses to expose people by role while retaining legacy fields during migration. +6. After all frontends use the new step, require the new contract for newly created cases and deprecate the legacy fields. + +## 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. diff --git a/docs/panel-data-frontend-handoff.fa.md b/docs/panel-data-frontend-handoff.fa.md new file mode 100644 index 0000000..2d32a27 --- /dev/null +++ b/docs/panel-data-frontend-handoff.fa.md @@ -0,0 +1,26 @@ +# اطلاعات پنل‌ها و PDF + +## مواردی که نبود و اضافه شد + +- نام شخص در پنل‌های فلو ۴ و ۵ +- کد ملی بیمه‌گذار و راننده در پنل‌های فلو ۴ و ۵ +- مشخص‌بودن یکسان‌بودن راننده و بیمه‌گذار در فلو ۴ و ۵ +- نوع، شماره و تاریخ گواهینامه در پنل‌های فلو ۴ و ۵ +- تاریخ تولد بیمه‌گذار و راننده در پنل‌های فلو ۴ و ۵ +- پلاک و VIN در پنل‌های فلو ۴ و ۵ +- شماره شبا در پنل‌های فلو ۴ و ۵ +- کدهای تکمیلی فناوران در پنل‌ها: بیمه‌نامه، راننده، نوع خودرو، نوع/نمونه پلاک، کاربری خودرو و شرکت بیمه +- نمایش نوع واقعی گواهینامه، مثل «پایه یک»، در PDF + +## مواردی که از قبل وجود داشت + +- نام و کد ملی افراد در پنل‌های کارشناس و بیمه‌گر +- کد ملی راننده، در صورت متفاوت‌بودن با بیمه‌گذار +- شماره شبا در پنل بیمه‌گر و PDF +- اطلاعات راننده در PDF، وقتی راننده با بیمه‌گذار متفاوت است +- پلاک خودرو در API؛ فرانت باید از `vehicle.plateId` بخواند +- کدهای اصلی فناوران: شماره خسارت، شماره پرونده، شناسه پرونده خسارت و شناسه کارشناسی + +## موردی که هنوز نداریم + +- `expedited` → در بک‌اند فیلد و قرارداد API ندارد. diff --git a/docs/panel-roles-reference.html b/docs/panel-roles-reference.html index 145fc3d..52b6a45 100644 --- a/docs/panel-roles-reference.html +++ b/docs/panel-roles-reference.html @@ -367,7 +367,7 @@ POSTv2/expert-claim/assign/:claimRequestIdLock claim to this expert. Returns assigned, already_assigned_to_you, or 409. GETv2/expert-claim/request/:claimRequestId/price-dropPrice-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry. PUTv2/expert-claim/request/:claimRequestId/price-dropCalculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400. - PUTv2/expert-claim/reply/submit/:claimRequestIdSubmit 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. + PUTv2/expert-claim/reply/submit/:claimRequestIdSubmit damage assessment reply (priced parts list and daghi). daghi.branchId is used only with the تحویل داغی 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. PUTv2/expert-claim/reply/resend/:claimRequestIdRequest user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled. PATCHv2/expert-claim/:claimRequestId/visitAsk user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION. PATCHv2/expert-claim/validate-factors/:claimRequestIdValidate 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. @@ -552,7 +552,7 @@ MethodRouteWhat it does POSTcreate-from-blame/:blameIdCreate claim from a completed blame file. GETouter-parts-catalog / car-other-partParts catalogs (outer body parts + other parts JSON). - GETbranches/:insuranceIdInsurer branch list (for branch selection in claim sign step). + GETbranches/:insuranceIdInsurer branch list for daghi.branchId when the expert selects the تحویل داغی option. PATCHselect-outer-parts/:claimIdSelect outer damaged parts. PATCHselect-other-parts/:claimIdSelect other damaged parts + bank info. POSTupload-document/:claimIdUpload claim documents (licences, car card). diff --git a/docs/report-api-frontend-fa.md b/docs/report-api-frontend-fa.md new file mode 100644 index 0000000..7ee1a99 --- /dev/null +++ b/docs/report-api-frontend-fa.md @@ -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 پرونده، داده‌ها را به‌صورت کامل و تفکیک‌شده برمی‌گرداند، از جمله: + +- زمان‌بندی پرونده +- اطلاعات مالک زیان‌دیده +- اطلاعات مالک مقصر +- اطلاعات راننده در صورت متفاوت بودن +- بیمه‌نامه‌های تفکیک‌شده‌ی ثالث و بدنه برای هر طرف +- اطلاعات خودرو برای هر دو طرف +- اظهارات و اقرار هر دو طرف +- کدهای فناوران +- نتیجه ارزیابی و توضیحات کارشناس +- گزارش حادثه From c64f23091ae5a0637859a2aab390aed2fe5213f4 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 13 Sep 2026 10:59:00 +0330 Subject: [PATCH 02/12] Implement role-complete inquiry participants --- docs/inquiry-participants-proposal.fa.md | 18 +- docs/inquiry-participants-proposal.md | 18 +- src/common/schema/case-inquiries.schema.ts | 3 + src/profile/dto/user/AddPlateDto.ts | 39 +- .../dto/create-request-management.dto.ts | 50 +- .../dto/inquiry-participants.dto.ts | 144 ++ .../dto/run-call-center-inquiry-v6.dto.ts | 69 +- .../dto/run-inquiries-v3.dto.ts | 75 +- .../entities/schema/partyRole.enum.ts | 352 +++-- .../inquiry-participant-resolver.spec.ts | 264 ++++ .../inquiry-participant-resolver.ts | 450 ++++++ .../request-management.service.ts | 1387 +++++++++++------ 12 files changed, 2179 insertions(+), 690 deletions(-) create mode 100644 src/request-management/dto/inquiry-participants.dto.ts create mode 100644 src/request-management/inquiry-participant-resolver.spec.ts create mode 100644 src/request-management/inquiry-participant-resolver.ts diff --git a/docs/inquiry-participants-proposal.fa.md b/docs/inquiry-participants-proposal.fa.md index 43545c3..382b58c 100644 --- a/docs/inquiry-participants-proposal.fa.md +++ b/docs/inquiry-participants-proposal.fa.md @@ -1,19 +1,25 @@ # پیشنهاد مدل اشخاص در مرحله استعلام -وضعیت: پیشنهادی +وضعیت: پیاده‌سازی‌شده در ۱۴۰۵/۰۶/۲۲ دامنه: تمام جریان‌های استعلام کاربر، کارشناس، پرونده‌ساز و مرکز تماس نسخه انگلیسی: [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` | راننده، مالک وسیله نقلیه، بیمه‌گذار شخص ثالث، بیمه‌گذار بدنه | +| نوع پرونده | نقش‌های الزامی | +| ------------- | ------------------------------------------------------------ | +| `THIRD_PARTY` | راننده، مالک وسیله نقلیه، بیمه‌گذار شخص ثالث | +| `CAR_BODY` | راننده، مالک وسیله نقلیه، بیمه‌گذار شخص ثالث، بیمه‌گذار بدنه | ممکن است یک شخص چند نقش را داشته باشد، اما سیستم نباید یکسان بودن آن‌ها را فرض کند. @@ -42,6 +48,8 @@ فیلد `carBodyPolicyholder` برای `THIRD_PARTY` مجاز نیست و برای `CAR_BODY` الزامی است. هر نقش باید فقط یکی از دو حالت «اطلاعات شخص» یا `sameAs` را داشته باشد. بک‌اند این ورودی را به فهرست اشخاص یکتا و اتصال نقش‌ها به آن‌ها تبدیل می‌کند. +برای راننده، `hasDrivingLicense` الزامی است. اگر مقدار آن `true` باشد، هر دو فیلد `licenseNumber` و `licenseType` نیز الزامی هستند؛ اگر مقدار آن `false` باشد، استعلام گواهینامه عمداً اجرا نمی‌شود. + ## انتقال مالکیت اخیر و پلاک قبلی نقش اشخاص و شناسه‌های خودرو دو موضوع جدا هستند. اگر خودرو به‌تازگی فروخته یا خریداری شده باشد، ممکن است اطلاعات رسمی یا بیمه‌نامه هنوز به پلاک قبلی متصل باشد. این وضعیت باید صریح ثبت شود و پلاک قبلی نباید جایگزین پلاک فعلی شود: diff --git a/docs/inquiry-participants-proposal.md b/docs/inquiry-participants-proposal.md index 5899881..723a264 100644 --- a/docs/inquiry-participants-proposal.md +++ b/docs/inquiry-participants-proposal.md @@ -1,19 +1,25 @@ # Inquiry participant identity proposal -Status: proposed +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 optional `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. + +Existing flat fields remain accepted during migration. 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 | +| 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. @@ -42,6 +48,8 @@ Use explicit role references instead: `carBodyPolicyholder` is forbidden for `THIRD_PARTY` and required for `CAR_BODY`. A role is either a new person's identity or a `sameAs` reference, never both. The backend should normalize this input into unique participants plus role assignments. +For 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: diff --git a/src/common/schema/case-inquiries.schema.ts b/src/common/schema/case-inquiries.schema.ts index f172b92..9db197b 100644 --- a/src/common/schema/case-inquiries.schema.ts +++ b/src/common/schema/case-inquiries.schema.ts @@ -30,5 +30,8 @@ export class CaseInquiries { @Prop({ type: InquiryStatusSchema }) drivingLicence?: InquiryStatus; + + @Prop({ type: InquiryStatusSchema }) + ownership?: InquiryStatus; } export const CaseInquiriesSchema = SchemaFactory.createForClass(CaseInquiries); diff --git a/src/profile/dto/user/AddPlateDto.ts b/src/profile/dto/user/AddPlateDto.ts index bd090dd..1fc2ac6 100644 --- a/src/profile/dto/user/AddPlateDto.ts +++ b/src/profile/dto/user/AddPlateDto.ts @@ -1,36 +1,53 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { PlatesDto } from "src/plates/dto/plate.dto"; import { Plates } from "src/Types&Enums/plate.interface"; +import { InquiryParticipantFieldsDto } from "src/request-management/dto/inquiry-participants.dto"; -export class AddPlateDto { +export class AddPlateDto extends InquiryParticipantFieldsDto { plateId: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ + type: String, + description: "Legacy third-party policyholder national code", + }) nationalCodeOfInsurer: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ + type: String, + description: "Legacy driver national code", + }) nationalCodeOfDriver: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ type: String }) insurerLicense: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ type: String }) driverLicense: string; - @ApiProperty({ type: PlatesDto, required: true }) + @ApiPropertyOptional({ + type: PlatesDto, + description: + "Legacy/current plate; may be supplied as vehicle.currentPlate", + }) plate: Plates; - @ApiProperty({ type: Boolean, required: true }) + @ApiPropertyOptional({ + type: Boolean, + description: "Legacy role relationship", + }) driverIsInsurer: boolean; @ApiProperty({ type: Boolean, required: true, default: false }) isNewCar: boolean; - @ApiProperty({ type: Boolean, required: true }) + @ApiPropertyOptional({ + type: Boolean, + description: "Legacy; use driver.hasDrivingLicense", + }) userNoCertificate: boolean; - @ApiProperty({ + @ApiPropertyOptional({ type: Number, - required: true, + description: "Legacy third-party policyholder birthday", }) insurerBirthday: number; @ApiProperty({ diff --git a/src/request-management/dto/create-request-management.dto.ts b/src/request-management/dto/create-request-management.dto.ts index ea37777..6de2d45 100644 --- a/src/request-management/dto/create-request-management.dto.ts +++ b/src/request-management/dto/create-request-management.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator"; +import { + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + ValidateIf, +} from "class-validator"; import { Types } from "mongoose"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; @@ -10,6 +16,7 @@ import { } from "src/Types&Enums/blame-request-management/accident-conditions.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { IsEnum } from "class-validator"; +import { InquiryParticipantFieldsDto } from "./inquiry-participants.dto"; export class InitialFormDto { @ApiProperty({ required: false, default: false }) @@ -227,10 +234,10 @@ export class BlameConfessionDtoV2 { * 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({ +export class InitialFormVinDto extends InquiryParticipantFieldsDto { + @ApiPropertyOptional({ type: String, - required: true, + required: false, description: "17-character VIN / chassis number (شماره شاسی)", example: "NAAM01E15HK123456", maxLength: 17, @@ -238,30 +245,49 @@ export class InitialFormVinDto { @IsString() @IsNotEmpty() @MaxLength(17) + @ValidateIf((dto) => !dto.vehicle?.vin) vin: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ + type: String, + description: "Legacy third-party policyholder national code", + }) + @ValidateIf((dto) => !dto.thirdPartyPolicyholder) nationalCodeOfInsurer: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ + type: String, + description: "Legacy driver national code", + }) + @ValidateIf((dto) => !dto.driver) nationalCodeOfDriver: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ type: String }) insurerLicense: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ type: String }) driverLicense: string; - @ApiProperty({ type: Boolean, required: true }) + @ApiPropertyOptional({ + type: Boolean, + description: "Legacy role relationship", + }) + @ValidateIf((dto) => !dto.driver) driverIsInsurer: boolean; - @ApiProperty({ type: Boolean, required: true, default: false }) + @ApiPropertyOptional({ type: Boolean, default: false }) isNewCar: boolean; - @ApiProperty({ type: Boolean, required: true }) + @ApiPropertyOptional({ + type: Boolean, + description: "Legacy; use driver.hasDrivingLicense", + }) userNoCertificate: boolean; - @ApiProperty({ type: Number, required: true }) + @ApiPropertyOptional({ + type: Number, + description: "Legacy policyholder birthday", + }) insurerBirthday: number; @ApiPropertyOptional({ type: String, required: false }) diff --git a/src/request-management/dto/inquiry-participants.dto.ts b/src/request-management/dto/inquiry-participants.dto.ts new file mode 100644 index 0000000..ae80299 --- /dev/null +++ b/src/request-management/dto/inquiry-participants.dto.ts @@ -0,0 +1,144 @@ +import { 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" }) + @IsString() + @IsNotEmpty() + leftDigits: string; + + @ApiProperty({ example: "ب" }) + @IsString() + @IsNotEmpty() + centerAlphabet: string; + + @ApiProperty({ example: "111" }) + @IsString() + @IsNotEmpty() + centerDigits: string; + + @ApiProperty({ example: "22" }) + @IsString() + @IsNotEmpty() + ir: string; +} + +export class InquiryParticipantInputDto { + @ApiPropertyOptional({ enum: InquiryParticipantRole }) + @IsOptional() + @IsEnum(InquiryParticipantRole) + sameAs?: InquiryParticipantRole; + + @ApiPropertyOptional({ example: "0012345678" }) + @IsOptional() + @IsString() + nationalCode?: string; + + @ApiPropertyOptional({ example: "1370/01/01" }) + @IsOptional() + birthday?: string | number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fullName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + phoneNumber?: 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 { + @ApiProperty({ enum: VehicleRegistrationState }) + @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; +} + +/** New role-complete contract mixed into every inquiry DTO. Legacy fields remain during rollout. */ +export class InquiryParticipantFieldsDto { + @ApiPropertyOptional({ type: InquiryParticipantInputDto }) + @IsOptional() + @ValidateNested() + @Type(() => InquiryParticipantInputDto) + driver?: InquiryParticipantInputDto; + + @ApiPropertyOptional({ type: InquiryParticipantInputDto }) + @IsOptional() + @ValidateNested() + @Type(() => InquiryParticipantInputDto) + vehicleOwner?: InquiryParticipantInputDto; + + @ApiPropertyOptional({ type: InquiryParticipantInputDto }) + @IsOptional() + @ValidateNested() + @Type(() => InquiryParticipantInputDto) + thirdPartyPolicyholder?: InquiryParticipantInputDto; + + @ApiPropertyOptional({ type: InquiryParticipantInputDto }) + @IsOptional() + @ValidateNested() + @Type(() => InquiryParticipantInputDto) + carBodyPolicyholder?: InquiryParticipantInputDto; + + @ApiPropertyOptional({ type: InquiryVehicleInputDto }) + @IsOptional() + @ValidateNested() + @Type(() => InquiryVehicleInputDto) + vehicle?: InquiryVehicleInputDto; +} diff --git a/src/request-management/dto/run-call-center-inquiry-v6.dto.ts b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts index a201467..512f0a1 100644 --- a/src/request-management/dto/run-call-center-inquiry-v6.dto.ts +++ b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts @@ -5,9 +5,11 @@ import { IsOptional, IsString, MaxLength, + ValidateIf, ValidateNested, } from "class-validator"; import { Type } from "class-transformer"; +import { InquiryParticipantFieldsDto } from "./inquiry-participants.dto"; class PlateV6Dto { @ApiProperty({ example: "44", description: "Left two digits" }) @@ -25,7 +27,10 @@ class PlateV6Dto { @IsNotEmpty() centerDigits: string; - @ApiProperty({ example: "22", description: "Right two digits (Iran region code)" }) + @ApiProperty({ + example: "22", + description: "Right two digits (Iran region code)", + }) @IsString() @IsNotEmpty() ir: string; @@ -35,35 +40,52 @@ class PlateV6Dto { * Inquiry body for the V6 call-center flow. * Same as V3 but without `sheba` — the user adds their own IBAN later via the link. */ -export class RunCallCenterInquiryV6Dto { - @ApiProperty({ +export class RunCallCenterInquiryV6Dto extends InquiryParticipantFieldsDto { + @ApiPropertyOptional({ type: PlateV6Dto, description: "Plate segments — Tejarat block / third-party inquiry.", }) @ValidateNested() @Type(() => PlateV6Dto) + @ValidateIf((dto) => !dto.vehicle?.currentPlate) plate: PlateV6Dto; - @ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy third-party policyholder national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.thirdPartyPolicyholder) nationalCodeOfInsurer: string; - @ApiProperty({ example: "1234567890", description: "National code of the driver" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy driver national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.driver) nationalCodeOfDriver: string; - @ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" }) + @ApiPropertyOptional({ + example: true, + description: "Legacy role relationship", + }) @IsBoolean() + @ValidateIf((dto) => !dto.driver) driverIsInsurer: boolean; - @ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" }) + @ApiPropertyOptional({ + example: 13780624, + description: "Legacy policyholder birth date (Jalali)", + }) insurerBirthday: number | string; @ApiPropertyOptional({ example: 13780624, - description: "Driver birth date (Jalali). Required when driverIsInsurer is false.", + description: + "Driver birth date (Jalali). Required when driverIsInsurer is false.", }) @IsOptional() driverBirthday?: number | string | null; @@ -90,8 +112,8 @@ export class RunCallCenterInquiryV6Dto { * Identical to `RunCallCenterInquiryV6Dto` but replaces `plate` with `vin`. * Sheba (IBAN) is intentionally absent — the user provides it themselves via the link. */ -export class RunCallCenterInquiryVinV6Dto { - @ApiProperty({ +export class RunCallCenterInquiryVinV6Dto extends InquiryParticipantFieldsDto { + @ApiPropertyOptional({ example: "NAAM01E15HK123456", description: "17-character VIN / chassis number (شماره شاسی)", maxLength: 17, @@ -99,28 +121,45 @@ export class RunCallCenterInquiryVinV6Dto { @IsString() @IsNotEmpty() @MaxLength(17) + @ValidateIf((dto) => !dto.vehicle?.vin) vin: string; - @ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy third-party policyholder national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.thirdPartyPolicyholder) nationalCodeOfInsurer: string; - @ApiProperty({ example: "1234567890", description: "National code of the driver" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy driver national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.driver) nationalCodeOfDriver: string; - @ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" }) + @ApiPropertyOptional({ + example: true, + description: "Legacy role relationship", + }) @IsBoolean() + @ValidateIf((dto) => !dto.driver) driverIsInsurer: boolean; - @ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" }) + @ApiPropertyOptional({ + example: 13780624, + description: "Legacy policyholder birth date (Jalali)", + }) insurerBirthday: number | string; @ApiPropertyOptional({ example: 13780624, - description: "Driver birth date (Jalali). Required when driverIsInsurer is false.", + description: + "Driver birth date (Jalali). Required when driverIsInsurer is false.", }) @IsOptional() driverBirthday?: number | string | null; diff --git a/src/request-management/dto/run-inquiries-v3.dto.ts b/src/request-management/dto/run-inquiries-v3.dto.ts index 5d9b436..c658b57 100644 --- a/src/request-management/dto/run-inquiries-v3.dto.ts +++ b/src/request-management/dto/run-inquiries-v3.dto.ts @@ -5,9 +5,11 @@ import { IsOptional, IsString, MaxLength, + ValidateIf, ValidateNested, } from "class-validator"; import { Type } from "class-transformer"; +import { InquiryParticipantFieldsDto } from "./inquiry-participants.dto"; class PlateV3Dto { @ApiProperty({ example: "44", description: "Left two digits" }) @@ -25,7 +27,10 @@ class PlateV3Dto { @IsNotEmpty() centerDigits: string; - @ApiProperty({ example: "22", description: "Right two digits (Iran region code)" }) + @ApiProperty({ + example: "22", + description: "Right two digits (Iran region code)", + }) @IsString() @IsNotEmpty() ir: string; @@ -35,33 +40,53 @@ class PlateV3Dto { * Body for `POST run-inquiries/:requestId`. * First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only). */ -export class RunInquiriesV3Dto { - @ApiProperty({ +export class RunInquiriesV3Dto extends InquiryParticipantFieldsDto { + @ApiPropertyOptional({ type: PlateV3Dto, description: "Plate segments — Tejarat block / third-party inquiry.", }) @ValidateNested() @Type(() => PlateV3Dto) + @ValidateIf((dto) => !dto.vehicle?.currentPlate) plate: PlateV3Dto; - @ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy third-party policyholder national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.thirdPartyPolicyholder) nationalCodeOfInsurer: string; - @ApiProperty({ example: "1234567890", description: "National code of the driver" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy driver national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.driver) nationalCodeOfDriver: string; - @ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" }) + @ApiPropertyOptional({ + example: true, + description: "Legacy role relationship", + }) @IsBoolean() + @ValidateIf((dto) => !dto.driver) driverIsInsurer: boolean; - @ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" }) + @ApiPropertyOptional({ + example: 13780624, + description: "Legacy policyholder birth date (Jalali)", + }) insurerBirthday: number | string; - @ApiPropertyOptional({ example: 13780624, description: "Driver birth date (Jalali). Required when driverIsInsurer is false." }) + @ApiPropertyOptional({ + example: 13780624, + description: + "Driver birth date (Jalali). Required when driverIsInsurer is false.", + }) @IsOptional() driverBirthday?: number | string | null; @@ -106,8 +131,8 @@ export class RunInquiriesV3Dto { * Identical to RunInquiriesV3Dto but uses `vin` (17-char chassis number) instead of `plate`. * First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only). */ -export class RunInquiriesVinV3Dto { - @ApiProperty({ +export class RunInquiriesVinV3Dto extends InquiryParticipantFieldsDto { + @ApiPropertyOptional({ example: "NAAM01E15HK123456", description: "17-character VIN / chassis number (شماره شاسی)", maxLength: 17, @@ -115,26 +140,46 @@ export class RunInquiriesVinV3Dto { @IsString() @IsNotEmpty() @MaxLength(17) + @ValidateIf((dto) => !dto.vehicle?.vin) vin: string; - @ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy third-party policyholder national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.thirdPartyPolicyholder) nationalCodeOfInsurer: string; - @ApiProperty({ example: "1234567890", description: "National code of the driver" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy driver national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.driver) nationalCodeOfDriver: string; - @ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" }) + @ApiPropertyOptional({ + example: true, + description: "Legacy role relationship", + }) @IsBoolean() + @ValidateIf((dto) => !dto.driver) driverIsInsurer: boolean; - @ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" }) + @ApiPropertyOptional({ + example: 13780624, + description: "Legacy policyholder birth date (Jalali)", + }) insurerBirthday: number | string; - @ApiPropertyOptional({ example: 13780624, description: "Driver birth date (Jalali). Required when driverIsInsurer is false." }) + @ApiPropertyOptional({ + example: 13780624, + description: + "Driver birth date (Jalali). Required when driverIsInsurer is false.", + }) @IsOptional() driverBirthday?: number | string | null; diff --git a/src/request-management/entities/schema/partyRole.enum.ts b/src/request-management/entities/schema/partyRole.enum.ts index 61fb12e..6a1ea5c 100644 --- a/src/request-management/entities/schema/partyRole.enum.ts +++ b/src/request-management/entities/schema/partyRole.enum.ts @@ -4,213 +4,253 @@ import { Schema as MongooseSchema, Types } from "mongoose"; import { Location, LocationSchema } from "./accidentInformation.type"; export enum PartyRole { - FIRST = "FIRST", - SECOND = "SECOND", - } + FIRST = "FIRST", + SECOND = "SECOND", +} @Schema({ _id: false }) export class Person { - @Prop({ type: Types.ObjectId }) - userId?: Types.ObjectId; + @Prop({ type: Types.ObjectId }) + userId?: Types.ObjectId; - @Prop() fullName?: string; - @Prop() phoneNumber?: string; - @Prop({ type: Types.ObjectId }) clientId?: Types.ObjectId; - @Prop() birthday?: string; + @Prop() fullName?: string; + @Prop() phoneNumber?: string; + @Prop({ type: Types.ObjectId }) clientId?: Types.ObjectId; + @Prop() birthday?: string; - // ---- FIRST_INITIAL_FORM (step-manager driven) ---- - @Prop() nationalCodeOfInsurer?: string; - @Prop() nationalCodeOfDriver?: string; - @Prop() insurerLicense?: string; - @Prop() driverLicense?: string; - /** Driving licence type code from the Fanavaran lookup (GET /lookups/driving-licence-types). */ - @Prop() licenseType?: string; + // ---- FIRST_INITIAL_FORM (step-manager driven) ---- + @Prop() nationalCodeOfInsurer?: string; + @Prop() nationalCodeOfDriver?: string; + @Prop() insurerLicense?: string; + @Prop() driverLicense?: string; + /** Driving licence type code from the Fanavaran lookup (GET /lookups/driving-licence-types). */ + @Prop() licenseType?: string; - @Prop({ type: Boolean }) - driverIsInsurer?: boolean; + @Prop({ type: Boolean }) + driverIsInsurer?: boolean; - @Prop({ type: Boolean }) - isNewCar?: boolean; + @Prop({ type: Boolean }) + isNewCar?: boolean; - /** - * Mirrors existing DTO `userNoCertificate` (true means user has NO certificate). - */ - @Prop({ type: Boolean }) - userNoCertificate?: boolean; + /** + * Mirrors existing DTO `userNoCertificate` (true means user has NO certificate). + */ + @Prop({ type: Boolean }) + userNoCertificate?: boolean; - @Prop({ type: Number }) - insurerBirthday?: number; + @Prop({ type: Number }) + insurerBirthday?: number; - @Prop({ type: String }) - driverBirthday?: string | null; + @Prop({ type: String }) + driverBirthday?: string | null; - /** Cached Fanavaran party ID resolved from nationalCodeOfDriver + driverBirthday + driverIsInsurer */ - @Prop({ type: Number }) - fanavaranDriverId?: number; + /** Cached Fanavaran party ID resolved from nationalCodeOfDriver + driverBirthday + driverIsInsurer */ + @Prop({ type: Number }) + fanavaranDriverId?: number; } export const PersonSchema = SchemaFactory.createForClass(Person); @Schema({ _id: false }) -export class Vehicle { - @Prop() plateId?: string; - /** VIN / chassis number — populated only when the inquiry was performed via VIN lookup. */ - @Prop() vin?: string; - @Prop() name?: string; - @Prop() model?: string; - @Prop() type?: string; - @Prop() isNew?: boolean; +export class InquiryParticipant { + @Prop({ required: true }) + participantId: string; - /** - * Full external inquiry payload (Tejarat/SandHub) stored as-is - * so we never lose fields that are not mapped yet. - */ - @Prop({ type: MongooseSchema.Types.Mixed }) - inquiry?: any; + @Prop() + nationalCode: string; + + @Prop() + birthday: string; + + @Prop() fullName?: string; + @Prop() phoneNumber?: string; + @Prop({ type: Boolean }) hasDrivingLicense?: boolean; + @Prop() licenseNumber?: string; + @Prop() licenseType?: string; +} +export const InquiryParticipantSchema = + SchemaFactory.createForClass(InquiryParticipant); + +@Schema({ _id: false }) +export class InquiryParticipantRoles { + @Prop() driver?: string; + @Prop() vehicleOwner?: string; + @Prop() thirdPartyPolicyholder?: string; + @Prop() carBodyPolicyholder?: string; +} +export const InquiryParticipantRolesSchema = SchemaFactory.createForClass( + InquiryParticipantRoles, +); + +@Schema({ _id: false }) +export class Vehicle { + @Prop() plateId?: string; + /** VIN / chassis number — populated only when the inquiry was performed via VIN lookup. */ + @Prop() vin?: string; + @Prop() name?: string; + @Prop() model?: string; + @Prop() type?: string; + @Prop() isNew?: boolean; + @Prop({ enum: ["CURRENT", "RECENTLY_TRANSFERRED"] }) + registrationState?: "CURRENT" | "RECENTLY_TRANSFERRED"; + @Prop() previousPlateId?: string; + + /** + * Full external inquiry payload (Tejarat/SandHub) stored as-is + * so we never lose fields that are not mapped yet. + */ + @Prop({ type: MongooseSchema.Types.Mixed }) + inquiry?: any; } export const VehicleSchema = SchemaFactory.createForClass(Vehicle); @Schema({ _id: false }) export class Insurance { - @Prop() policyNumber?: string; - @Prop() company?: string; - @Prop() startDate?: string; - @Prop() endDate?: string; - @Prop() financialCeiling?: string; - @Prop({ type: [String] }) - coverages?: string[]; + @Prop() policyNumber?: string; + @Prop() company?: string; + @Prop() startDate?: string; + @Prop() endDate?: string; + @Prop() financialCeiling?: string; + @Prop({ type: [String] }) + coverages?: string[]; - /** CAR_BODY inquiry result; raw provider data is retained in vehicle.inquiry.carBody.raw. */ - @Prop({ type: MongooseSchema.Types.Mixed }) - carBodyInsurance?: { - policyNumber?: string; - policyId?: number; - contractId?: number; - startDate?: string; - endDate?: string; - insurerCompany?: string; - coverages?: string[]; - companyId?: number | string; - companyName?: string; - insurerName?: string; - insurerNationalCode?: string; - ownerNationalCode?: string; - ownerName?: string; - customerName?: string; - customerLastName?: string; - customerFatherName?: string; - customerMobile?: string; - customerAddress?: string; - customerPostalCode?: string; - chassisNumber?: string; - vin?: string; - motorNumber?: string; - vehicleGroup?: string; - vehicleSystem?: string; - vehicleKind?: string; - builtYear?: number; - cylinderCount?: number; - passengerCount?: number; - usage?: string; - issueDate?: string; - vehicleValue?: number; - totalPremium?: number; - noLossYearsCount?: number; - lossDocuments?: unknown[]; - hasEndorsement?: boolean; - }; + /** CAR_BODY inquiry result; raw provider data is retained in vehicle.inquiry.carBody.raw. */ + @Prop({ type: MongooseSchema.Types.Mixed }) + carBodyInsurance?: { + policyNumber?: string; + policyId?: number; + contractId?: number; + startDate?: string; + endDate?: string; + insurerCompany?: string; + coverages?: string[]; + companyId?: number | string; + companyName?: string; + insurerName?: string; + insurerNationalCode?: string; + ownerNationalCode?: string; + ownerName?: string; + customerName?: string; + customerLastName?: string; + customerFatherName?: string; + customerMobile?: string; + customerAddress?: string; + customerPostalCode?: string; + chassisNumber?: string; + vin?: string; + motorNumber?: string; + vehicleGroup?: string; + vehicleSystem?: string; + vehicleKind?: string; + builtYear?: number; + cylinderCount?: number; + passengerCount?: number; + usage?: string; + issueDate?: string; + vehicleValue?: number; + totalPremium?: number; + noLossYearsCount?: number; + lossDocuments?: unknown[]; + hasEndorsement?: boolean; + }; } export const InsuranceSchema = SchemaFactory.createForClass(Insurance); @Schema({ _id: false }) export class PartyStatement { + @Prop({ default: false }) + acceptsExpertOpinion?: boolean; - @Prop({ default: false }) - acceptsExpertOpinion?: boolean; - - @Prop({ default: false }) - claimsDamage?: boolean; - - @Prop({ default: false }) - admitsGuilt?: boolean; - - @Prop({ type: String }) - description?: string; + @Prop({ default: false }) + claimsDamage?: boolean; - /** CAR_BODY: accident conditions from description step */ - @Prop() accidentDate?: Date; - @Prop() accidentTime?: string; - @Prop() weatherCondition?: string; - @Prop() roadCondition?: string; - @Prop() lightCondition?: string; + @Prop({ default: false }) + admitsGuilt?: boolean; + + @Prop({ type: String }) + description?: string; + + /** CAR_BODY: accident conditions from description step */ + @Prop() accidentDate?: Date; + @Prop() accidentTime?: string; + @Prop() weatherCondition?: string; + @Prop() roadCondition?: string; + @Prop() lightCondition?: string; } -export const PartyStatementSchema = SchemaFactory.createForClass(PartyStatement); +export const PartyStatementSchema = + SchemaFactory.createForClass(PartyStatement); @Schema({ _id: false }) export class EvidenceBundle { + @Prop({ type: [String] }) + images?: string[]; - @Prop({ type: [String] }) - images?: string[]; - - @Prop({ type: [String] }) - voices?: string[]; - - @Prop() videoId?: string; + @Prop({ type: [String] }) + voices?: string[]; + + @Prop() videoId?: string; } -export const EvidenceBundleSchema = SchemaFactory.createForClass(EvidenceBundle); +export const EvidenceBundleSchema = + SchemaFactory.createForClass(EvidenceBundle); @Schema({ _id: false }) export class Signature { - @Prop() fileId: string; - @Prop() fileName: string; - @Prop() fileUrl: string; + @Prop() fileId: string; + @Prop() fileName: string; + @Prop() fileUrl: string; } export const SignatureSchema = SchemaFactory.createForClass(Signature); @Schema({ _id: false }) export class PartyConfirmation { - @Prop() partyRole: PartyRole; - @Prop() accepted: boolean; - @Prop({ type: SignatureSchema }) - signature?: Signature; + @Prop() partyRole: PartyRole; + @Prop() accepted: boolean; + @Prop({ type: SignatureSchema }) + signature?: Signature; } export const PartyConfirmationSchema = SchemaFactory.createForClass(PartyConfirmation); @Schema({ _id: false }) export class Party { + @Prop({ enum: PartyRole }) + role: PartyRole; - @Prop({ enum: PartyRole }) - role: PartyRole; - - @Prop({ type: PersonSchema }) - person: Person; + @Prop({ type: PersonSchema }) + person: Person; - /** - * CAR_BODY only: first form – accident with car vs object. - * Second form (guilty/damaged) may be added later as carBodySecondForm. - */ - @Prop({ type: MongooseSchema.Types.Mixed }) - carBodyFirstForm?: { car?: boolean; object?: boolean }; + /** Distinct people involved with this vehicle; roles below reference participantId. */ + @Prop({ type: [InquiryParticipantSchema], default: undefined }) + participants?: InquiryParticipant[]; - /** - * Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION). - */ - @Prop({ type: LocationSchema }) - location?: Location; - - @Prop({ type: VehicleSchema }) - vehicle?: Vehicle; - - @Prop({ type: InsuranceSchema }) - insurance?: Insurance; - - @Prop({ type: PartyStatementSchema }) - statement?: PartyStatement; - - @Prop({ type: EvidenceBundleSchema }) - evidence?: EvidenceBundle; - - @Prop({ type: PartyConfirmationSchema }) - confirmation?: PartyConfirmation; + @Prop({ type: InquiryParticipantRolesSchema }) + participantRoles?: InquiryParticipantRoles; + + /** + * CAR_BODY only: first form – accident with car vs object. + * Second form (guilty/damaged) may be added later as carBodySecondForm. + */ + @Prop({ type: MongooseSchema.Types.Mixed }) + carBodyFirstForm?: { car?: boolean; object?: boolean }; + + /** + * Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION). + */ + @Prop({ type: LocationSchema }) + location?: Location; + + @Prop({ type: VehicleSchema }) + vehicle?: Vehicle; + + @Prop({ type: InsuranceSchema }) + insurance?: Insurance; + + @Prop({ type: PartyStatementSchema }) + statement?: PartyStatement; + + @Prop({ type: EvidenceBundleSchema }) + evidence?: EvidenceBundle; + + @Prop({ type: PartyConfirmationSchema }) + confirmation?: PartyConfirmation; } export const PartySchema = SchemaFactory.createForClass(Party); diff --git a/src/request-management/inquiry-participant-resolver.spec.ts b/src/request-management/inquiry-participant-resolver.spec.ts new file mode 100644 index 0000000..7e08e2b --- /dev/null +++ b/src/request-management/inquiry-participant-resolver.spec.ts @@ -0,0 +1,264 @@ +import { BadRequestException } from "@nestjs/common"; +import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; +import { + InquiryParticipantRole, + VehicleRegistrationState, +} from "./dto/inquiry-participants.dto"; +import { + assertPreviousPlateInquiryMatchesVin, + 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("keeps legacy driver/policyholder payloads working during rollout", () => { + const resolved = resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, { + nationalCodeOfDriver: "0012345678", + driverBirthday: "1370/01/01", + driverLicense: "123456789", + nationalCodeOfInsurer: "0098765432", + insurerBirthday: "1360/02/02", + driverIsInsurer: false, + } as any); + + expect(resolved.legacy).toBe(true); + expect(resolved.roles.driver).toBe("DRIVER"); + expect(resolved.roles.thirdPartyPolicyholder).toBe( + "THIRD_PARTY_POLICYHOLDER", + ); + expect(resolved.roles.vehicleOwner).toBeUndefined(); + }); + + 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, { + plate: { + 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).toEqual([ + { plateKind: "CURRENT", succeeded: false, error: "not found" }, + { plateKind: "PREVIOUS", succeeded: true, usable: true }, + ]); + }); + + 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); + }); +}); diff --git a/src/request-management/inquiry-participant-resolver.ts b/src/request-management/inquiry-participant-resolver.ts new file mode 100644 index 0000000..f1f022e --- /dev/null +++ b/src/request-management/inquiry-participant-resolver.ts @@ -0,0 +1,450 @@ +import { BadRequestException } from "@nestjs/common"; +import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; +import { + InquiryParticipantFieldsDto, + InquiryParticipantInputDto, + InquiryParticipantRole, + InquiryVehicleInputDto, + VehicleRegistrationState, +} from "./dto/inquiry-participants.dto"; + +export interface ResolvedInquiryParticipant { + participantId: string; + nationalCode: string; + birthday: string; + fullName?: string; + phoneNumber?: string; + hasDrivingLicense?: boolean; + licenseNumber?: string; + licenseType?: string; +} + +export interface InquiryParticipantRoleAssignments { + driver: string; + vehicleOwner?: string; + thirdPartyPolicyholder: string; + carBodyPolicyholder?: string; +} + +export interface ResolvedInquiryParticipants { + participants: ResolvedInquiryParticipant[]; + roles: InquiryParticipantRoleAssignments; + legacy: boolean; +} + +export interface NormalizedInquirySubmission> { + 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?: InquiryVehicleInputDto; +} + +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 { + 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.phoneNumber ? { phoneNumber: input.phoneNumber } : {}), + ...(input.hasDrivingLicense != null + ? { hasDrivingLicense: input.hasDrivingLicense } + : {}), + ...(input.licenseNumber ? { licenseNumber: input.licenseNumber } : {}), + ...(input.licenseType ? { licenseType: input.licenseType } : {}), + }; +} + +export function resolveInquiryParticipants( + caseType: BlameRequestType, + input: InquiryParticipantFieldsDto & Record, +): ResolvedInquiryParticipants { + const hasRoleCompleteInput = Object.values(ROLE_FIELDS).some( + (field) => input[field] != null, + ); + if (!hasRoleCompleteInput) { + const driverId = InquiryParticipantRole.DRIVER; + const policyholderId = input.driverIsInsurer + ? driverId + : InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER; + const participants: ResolvedInquiryParticipant[] = [ + { + participantId: driverId, + nationalCode: String(input.nationalCodeOfDriver ?? ""), + birthday: String( + input.driverBirthday ?? + (input.driverIsInsurer ? input.insurerBirthday : "") ?? + "", + ), + ...(input.driverLicense + ? { licenseNumber: String(input.driverLicense) } + : {}), + ...(input.licenseType + ? { licenseType: String(input.licenseType) } + : {}), + ...(input.userNoCertificate != null + ? { hasDrivingLicense: !input.userNoCertificate } + : {}), + }, + ]; + if (policyholderId !== driverId) { + participants.push({ + participantId: policyholderId, + nationalCode: String(input.nationalCodeOfInsurer ?? ""), + birthday: String(input.insurerBirthday ?? ""), + }); + } + return { + participants, + roles: { + driver: driverId, + thirdPartyPolicyholder: policyholderId, + }, + legacy: true, + }; + } + 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(); + const resolvedRoleIds = new Map(); + const resolving = new Set(); + + 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 (value.sameAs) { + if (value.nationalCode != null || value.birthday != null) { + 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, +): InquiryVehicleInputDto { + 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?: InquiryVehicleInputDto): 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, ""); +} + +export function assertPreviousPlateInquiryMatchesVin( + expectedVin: string, + mapped: Record, +): 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 async function runPlateInquiryWithFallback(options: { + vehicle?: InquiryVehicleInputDto; + fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"]; + query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise; + isUsable: (value: T) => boolean; + mappedValue: (value: T) => Record; +}): Promise<{ + value: T; + plateKind: "CURRENT" | "PREVIOUS"; + attempts: Array<{ + plateKind: "CURRENT" | "PREVIOUS"; + 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"; + 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, + succeeded: true, + usable: false, + }); + if (!isLast) continue; + return { value, plateKind: candidate.kind, attempts }; + } + if (candidate.kind === "PREVIOUS" && usable) { + assertPreviousPlateInquiryMatchesVin( + options.vehicle!.vin!, + options.mappedValue(value), + ); + } + attempts.push({ + plateKind: candidate.kind, + succeeded: true, + usable: true, + }); + return { value, plateKind: candidate.kind, attempts }; + } catch (error) { + lastError = error; + attempts.push({ + plateKind: candidate.kind, + succeeded: false, + error: error instanceof Error ? error.message : String(error), + }); + if (isLast) throw error; + } + } + + throw lastError ?? new BadRequestException("Inquiry failed for all plates."); +} + +export function normalizeInquirySubmission>( + caseType: BlameRequestType, + input: T, +): NormalizedInquirySubmission { + const participants = resolveInquiryParticipants(caseType, input); + 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 = input.vehicle + ? resolveInquiryVehicle(input.vehicle as InquiryVehicleInputDto) + : undefined; + const sameDriverAndPolicyholder = + participants.roles.driver === participants.roles.thirdPartyPolicyholder; + + return { + dto: { + ...input, + ...(vehicle?.currentPlate && !input.plate + ? { plate: vehicle.currentPlate } + : {}), + ...(vehicle?.vin && !input.vin ? { vin: vehicle.vin } : {}), + nationalCodeOfDriver: driver.nationalCode, + driverBirthday: driver.birthday, + driverLicense: driver.licenseNumber, + licenseType: driver.licenseType, + userNoCertificate: + driver.hasDrivingLicense == null + ? input.userNoCertificate + : !driver.hasDrivingLicense, + nationalCodeOfInsurer: thirdPartyPolicyholder.nationalCode, + insurerBirthday: thirdPartyPolicyholder.birthday, + driverIsInsurer: sameDriverAndPolicyholder, + insurerLicense: sameDriverAndPolicyholder + ? driver.licenseNumber + : input.insurerLicense, + } as NormalizedInquirySubmission["dto"], + participants, + driver, + vehicleOwner, + thirdPartyPolicyholder, + carBodyPolicyholder, + vehicle, + }; +} diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index adffb84..b7f1aee 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -42,7 +42,10 @@ import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatu import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { resolveClaimOwnerFieldsFromBlame } from "src/helpers/blame-damaged-party"; import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto"; -import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto"; +import { + RunInquiriesV3Dto, + RunInquiriesVinV3Dto, +} from "./dto/run-inquiries-v3.dto"; import { SandHubService } from "src/sand-hub/sand-hub.service"; import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; @@ -86,7 +89,10 @@ import { WorkflowStepDbService } from "src/workflow-step-management/entities/db- import { WorkflowStepModel } from "src/workflow-step-management/entities/schema/workflow-step.schema"; import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum"; import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; -import { BusinessRuleException, BusinessErrorCode } from "src/expert-claim/exceptions/business-rule.exception"; +import { + BusinessRuleException, + BusinessErrorCode, +} from "src/expert-claim/exceptions/business-rule.exception"; import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service"; import { ExpertFileActivityType, @@ -117,12 +123,19 @@ import { import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver"; import { normalizePlateText } from "src/utils/plate-normalizer/plate-normalizer.service"; import { fanavaranClaimReferences } from "src/claim-request-management/fanavaran-claim-references"; +import { + NormalizedInquirySubmission, + normalizeInquirySubmission, + runPlateInquiryWithFallback, +} from "./inquiry-participant-resolver"; /** * Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD. * Returns the original value as a string if it cannot be parsed. */ -function formatJalaliCompact(raw: number | string | null | undefined): string | null | undefined { +function formatJalaliCompact( + raw: number | string | null | undefined, +): string | null | undefined { if (raw == null) return raw as null | undefined; const s = String(raw).replace(/\D/g, ""); if (s.length === 8) { @@ -163,10 +176,7 @@ export class RequestManagementService { private throwCarBodyInquiryFailure(err: unknown): never { if (err instanceof ForbiddenException) throw err; - throw new HttpException( - "Car body inquiry failed", - HttpStatus.BAD_REQUEST, - ); + throw new HttpException("Car body inquiry failed", HttpStatus.BAD_REQUEST); } /** @@ -238,13 +248,33 @@ export class RequestManagementService { * ESG VIN inquiry via parsePlkString already returns the actual letter string, * but the mock and some Tejarat responses use the numeric code form. */ - private static readonly PLATE_LETTER_CODE_TO_LETTER: Record = { - 1: "الف", 2: "ب", 3: "پ", 4: "ج", 5: "د", - 6: "س", 7: "ص", 8: "ط", 9: "ع", 10: "ق", - 11: "ل", 12: "م", 13: "ن", 14: "و", 15: "ه", - 16: "ی", 17: "ک", 18: "ژ", 19: "ت", 20: "ث", - 21: "ز", 22: "ش", 23: "ف", 24: "گ", - }; + private static readonly PLATE_LETTER_CODE_TO_LETTER: Record = + { + 1: "الف", + 2: "ب", + 3: "پ", + 4: "ج", + 5: "د", + 6: "س", + 7: "ص", + 8: "ط", + 9: "ع", + 10: "ق", + 11: "ل", + 12: "م", + 13: "ن", + 14: "و", + 15: "ه", + 16: "ی", + 17: "ک", + 18: "ژ", + 19: "ت", + 20: "ث", + 21: "ز", + 22: "ش", + 23: "ف", + 24: "گ", + }; /** * Resolve a plate center letter from whatever shape Plk2 arrives in: @@ -275,6 +305,224 @@ export class RequestManagementService { .join("-"); } + private normalizeInquiryInput>( + type: BlameRequestType, + input: T, + ): NormalizedInquirySubmission { + return normalizeInquirySubmission(type, input); + } + + private applyInquiryParticipantsToParty( + party: any, + submission: NormalizedInquirySubmission>, + ): void { + party.participants = submission.participants.participants; + party.participantRoles = submission.participants.roles; + if (!party.vehicle) party.vehicle = {}; + if (submission.vehicle) { + party.vehicle.registrationState = submission.vehicle.registrationState; + party.vehicle.plateId = this.plateToPlateIdString( + submission.vehicle.currentPlate, + ); + party.vehicle.previousPlateId = submission.vehicle.previousPlate + ? this.plateToPlateIdString(submission.vehicle.previousPlate) + : undefined; + if (submission.vehicle.vin) party.vehicle.vin = submission.vehicle.vin; + } + } + + private async getThirdPartyPlateInquiry( + submission: NormalizedInquirySubmission>, + options?: Record, + ): Promise { + const result = await runPlateInquiryWithFallback({ + vehicle: submission.vehicle, + fallbackCurrentPlate: submission.dto.plate, + query: (plate) => + this.sandHubService.getTejaratBlockInquiry( + { + plate: plate as any, + nationalCodeOfInsurer: + submission.thirdPartyPolicyholder.nationalCode, + }, + options, + ), + isUsable: (value) => + !value?.mapped?.Error && !!value?.mapped?.CompanyName, + mappedValue: (value) => value?.mapped ?? {}, + }); + return { + ...result.value, + plateKind: result.plateKind, + attempts: result.attempts, + }; + } + + private async getCarBodyPlateInquiry( + submission: NormalizedInquirySubmission>, + ): Promise { + const policyholder = + submission.carBodyPolicyholder ?? submission.thirdPartyPolicyholder; + const result = await runPlateInquiryWithFallback({ + vehicle: submission.vehicle, + fallbackCurrentPlate: submission.dto.plate, + query: (plate) => + this.sandHubService.getCarBodyInquiry({ + nationalCodeOfInsurer: policyholder.nationalCode, + plate: plate as any, + }), + isUsable: (value) => + !!value?.mapped && + !!( + value.mapped.policyNumber || + value.mapped.CompanyName || + value.mapped.companyId + ), + mappedValue: (value) => value?.mapped ?? {}, + }); + return { + ...result.value, + plateKind: result.plateKind, + attempts: result.attempts, + }; + } + + private async runParticipantPersonalInquiries( + req: any, + role: PartyRole, + submission: NormalizedInquirySubmission>, + options?: Record, + ): Promise { + const participants = submission.participants.legacy + ? [submission.thirdPartyPolicyholder] + : submission.participants.participants; + const results: Record = {}; + + for (const participant of participants) { + if (!participant.nationalCode || !participant.birthday) { + throw new BadRequestException( + `${participant.participantId} requires nationalCode and birthday for personal inquiry.`, + ); + } + try { + results[participant.participantId] = + await this.sandHubService.getPersonalInquiry( + participant.nationalCode, + participant.birthday, + options, + ); + } catch (error: any) { + this.recordPartyCaseInquiryStatus( + req, + "person", + role, + false, + { + participantId: participant.participantId, + completedParticipants: results, + }, + error, + ); + throw new BadRequestException( + `${participant.participantId} personal identity inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, + ); + } + } + + this.recordPartyCaseInquiryStatus( + req, + "person", + role, + true, + submission.participants.legacy + ? results[submission.thirdPartyPolicyholder.participantId] + : { participants: results }, + ); + } + + private async runVehicleOwnershipInquiry( + req: any, + role: PartyRole, + submission: NormalizedInquirySubmission>, + options?: Record, + ): Promise { + if (submission.participants.legacy || !submission.vehicleOwner) return; + const plate = submission.vehicle?.currentPlate ?? submission.dto.plate; + if (!plate) { + throw new BadRequestException( + "Current plate is required for vehicle ownership inquiry.", + ); + } + try { + const result = await this.sandHubService.getCarOwnershipInfo( + plate, + submission.vehicleOwner.nationalCode, + options, + ); + this.recordPartyCaseInquiryStatus(req, "ownership", role, true, { + participantId: submission.vehicleOwner.participantId, + result, + }); + } catch (error: any) { + this.recordPartyCaseInquiryStatus( + req, + "ownership", + role, + false, + { participantId: submission.vehicleOwner.participantId }, + error, + ); + throw new BadRequestException( + `Vehicle ownership inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, + ); + } + } + + private async runRoleCompleteDriverLicenceInquiry( + req: any, + role: PartyRole, + submission: NormalizedInquirySubmission>, + options?: Record, + ): Promise { + if (submission.participants.legacy) return; + if (submission.driver.hasDrivingLicense === false) { + this.recordPartyCaseInquiryStatus(req, "drivingLicence", role, true, { + participantId: submission.driver.participantId, + skipped: true, + reason: "Driver declared no driving licence", + }); + return; + } + if (!submission.driver.licenseNumber) { + throw new BadRequestException( + "Driver licence number is required when the driver has a licence.", + ); + } + try { + const result = await this.sandHubService.getDrivingLicenseInfo( + submission.driver.nationalCode, + submission.driver.licenseNumber, + options, + ); + this.recordPartyCaseInquiryStatus(req, "drivingLicence", role, true, { + participantId: submission.driver.participantId, + result, + }); + } catch (error: any) { + this.recordPartyCaseInquiryStatus( + req, + "drivingLicence", + role, + false, + { participantId: submission.driver.participantId }, + error, + ); + throw new BadRequestException( + `Driver licence inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, + ); + } + } + private getPartyIndex(req: any, role: PartyRole): number { return Array.isArray(req.parties) ? req.parties.findIndex((p) => p?.role === role) @@ -410,7 +658,8 @@ export class RequestManagementService { const completed = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; - if (!completed.includes(submittedStepKey)) completed.push(submittedStepKey); + if (!completed.includes(submittedStepKey)) + completed.push(submittedStepKey); // Also mark FIRST_INITIAL_FORM as completed so the step chain is consistent if (!completed.includes(WorkflowStep.FIRST_INITIAL_FORM)) { completed.push(WorkflowStep.FIRST_INITIAL_FORM); @@ -481,10 +730,11 @@ export class RequestManagementService { // V6 call-center THIRD_PARTY: guilt is pre-decided (first party is always guilty), // so skip the WAITING_FOR_GUILT_DECISION waiting state just like IN_PERSON. const isCallCenterThirdParty = - req.type === BlameRequestType.THIRD_PARTY && - !!req.callCenterInitiated; + req.type === BlameRequestType.THIRD_PARTY && !!req.callCenterInitiated; - const resolveStep = (step: WorkflowStep | undefined): WorkflowStep | undefined => { + const resolveStep = ( + step: WorkflowStep | undefined, + ): WorkflowStep | undefined => { if ( (isInPersonThirdParty || isCallCenterThirdParty) && step === WorkflowStep.WAITING_FOR_GUILT_DECISION @@ -516,8 +766,9 @@ export class RequestManagementService { ); const nextStepKey = resolveStep( - (sequentialNextStep?.stepKey ?? - submittedStep.nextPossibleSteps?.[0]) as WorkflowStep | undefined, + (sequentialNextStep?.stepKey ?? submittedStep.nextPossibleSteps?.[0]) as + | WorkflowStep + | undefined, ); if (!nextStepKey) { @@ -1324,11 +1575,17 @@ export class RequestManagementService { : undefined; } + const inquirySubmission = this.normalizeInquiryInput(req.type, body); + body = inquirySubmission.dto as unknown as AddPlateDto; + this.applyInquiryParticipantsToParty(party, inquirySubmission); + // Validation: driver/insurer sameness rules if (body.driverIsInsurer === false) { if ( body.nationalCodeOfInsurer === body.nationalCodeOfDriver || - body.insurerLicense === body.driverLicense + (body.insurerLicense && + body.driverLicense && + body.insurerLicense === body.driverLicense) ) { throw new BadRequestException( "Insurer and Driver should be two different persons in this mode.", @@ -1352,6 +1609,8 @@ export class RequestManagementService { let inquiryRaw: any; let inquiryMapped: any; let inquirySource = "TEJARAT_BLOCK_INQUIRY"; + let inquiryPlateKind = "CURRENT"; + let inquiryAttempts: any[] = []; const inquiryClientId = party.person?.clientId ? String(party.person.clientId) : undefined; @@ -1361,15 +1620,14 @@ export class RequestManagementService { inquiryClientId, ); try { - const inquiry = await this.sandHubService.getTejaratBlockInquiry( - { - plate: body.plate, - nationalCodeOfInsurer: body.nationalCodeOfInsurer, - }, + const inquiry = await this.getThirdPartyPlateInquiry( + inquirySubmission, inquiryOptions, ); inquiryRaw = inquiry.raw; inquiryMapped = inquiry.mapped; + inquiryPlateKind = inquiry.plateKind; + inquiryAttempts = inquiry.attempts; if (inquiry.offline?.source) { inquirySource = inquiry.offline.source; } @@ -1381,6 +1639,8 @@ export class RequestManagementService { ); this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, { source: inquirySource, + plateKind: inquiry.plateKind, + attempts: inquiry.attempts, raw: inquiryRaw, mapped: inquiryMapped, }); @@ -1433,87 +1693,24 @@ export class RequestManagementService { ); } - // ---- External inquiry 2: personal identity check (insurer/driver nationalCode + birthDate) ---- - // The form sends the birthday as a Jalali date in any of these shapes: - // - number 13770624 (packed YYYYMMDD) - // - string "1377-06-24" / "1377/06/24" / "13770624" - // We forward it as-is; sandHubService.getPersonalInquiry() handles the - // Jalali → Gregorian conversion required by the external API. - const personalNationalCode = - body.nationalCodeOfInsurer || body.nationalCodeOfDriver; - const personalBirthDate: number | string | null = (body.insurerBirthday ?? - body.driverBirthday ?? - null) as number | string | null; - if ( - !personalNationalCode || - personalBirthDate === null || - personalBirthDate === undefined || - String(personalBirthDate).trim() === "" - ) { - throw new BadRequestException( - "Valid nationalCode and birthDate are required for personal inquiry.", - ); - } - - try { - const personalInquiry = await this.sandHubService.getPersonalInquiry( - personalNationalCode, - personalBirthDate, - inquiryOptions, - ); - this.recordPartyCaseInquiryStatus( - req, - "person", - role, - true, - personalInquiry, - ); - this.logger.log( - `[SANDHUB] personal inquiry success request=${req._id} nationalCode=${personalNationalCode}: ${JSON.stringify( - personalInquiry, - )}`, - ); - } catch (err: any) { - this.logger.error( - `[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`, - ); - this.recordPartyCaseInquiryStatus(req, "person", role, false, {}, err); - await this.blameRequestDbService.findByIdAndUpdate(req._id, { - $set: { inquiries: req.inquiries }, - }); - throw new HttpException( - "Personal identity inquiry failed", - HttpStatus.BAD_REQUEST, - ); - } - - // ---- External inquiry 3: driving license check (insurerLicense + nationalCode) ---- - // const licenseNationalCode = body.nationalCodeOfInsurer || body.nationalCodeOfDriver; - // const licenseNumber = body.insurerLicense; - // if (!licenseNationalCode || !licenseNumber) { - // throw new BadRequestException( - // "nationalCode and insurerLicense are required for driving license inquiry.", - // ); - // } - // try { - // const licenseInquiry = await this.sandHubService.getDrivingLicenseInfo( - // licenseNationalCode, - // licenseNumber, - // ); - // this.logger.log( - // `[SANDHUB] license inquiry success request=${req._id} nationalCode=${licenseNationalCode}: ${JSON.stringify( - // licenseInquiry, - // )}`, - // ); - // } catch (err: any) { - // this.logger.error( - // `[SANDHUB] license inquiry failed request=${req._id} nationalCode=${licenseNationalCode}: ${err?.message || err}`, - // ); - // throw new HttpException( - // "Driving license inquiry failed", - // HttpStatus.BAD_REQUEST, - // ); - // } + await this.runParticipantPersonalInquiries( + req, + role, + inquirySubmission, + inquiryOptions, + ); + await this.runVehicleOwnershipInquiry( + req, + role, + inquirySubmission, + inquiryOptions, + ); + await this.runRoleCompleteDriverLicenceInquiry( + req, + role, + inquirySubmission, + inquiryOptions, + ); // Find client by company code const clientName = inquiryMapped?.CompanyName; @@ -1560,6 +1757,8 @@ export class RequestManagementService { party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`; party.vehicle.inquiry = { source: inquirySource, + plateKind: inquiryPlateKind, + attempts: inquiryAttempts, raw: inquiryRaw, mapped: inquiryMapped, }; @@ -1587,12 +1786,11 @@ export class RequestManagementService { if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) { let carBodyInfo: any; try { - carBodyInfo = await this.sandHubService.getCarBodyInquiry({ - nationalCodeOfInsurer: body.nationalCodeOfInsurer, - plate: body.plate, - }); + carBodyInfo = await this.getCarBodyPlateInquiry(inquirySubmission); this.recordPartyCaseInquiryStatus(req, "carBody", role, true, { source: carBodyInfo.source, + plateKind: carBodyInfo.plateKind, + attempts: carBodyInfo.attempts, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }); @@ -1619,6 +1817,8 @@ export class RequestManagementService { ...party.vehicle.inquiry, carBody: { source: carBodyInfo.source, + plateKind: carBodyInfo.plateKind, + attempts: carBodyInfo.attempts, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }, @@ -1691,6 +1891,8 @@ export class RequestManagementService { await this.blameRequestDbService.findByIdAndUpdate(req._id, { $set: { [`parties.${idx}.person`]: party.person, + [`parties.${idx}.participants`]: party.participants, + [`parties.${idx}.participantRoles`]: party.participantRoles, [`parties.${idx}.vehicle`]: party.vehicle, [`parties.${idx}.insurance`]: party.insurance, inquiries: req.inquiries, @@ -1778,11 +1980,17 @@ export class RequestManagementService { : undefined; } + const inquirySubmission = this.normalizeInquiryInput(req.type, body); + body = inquirySubmission.dto as unknown as InitialFormVinDto; + this.applyInquiryParticipantsToParty(party, inquirySubmission); + // Validation: driver/insurer sameness rules (identical to plate path) if (body.driverIsInsurer === false) { if ( body.nationalCodeOfInsurer === body.nationalCodeOfDriver || - body.insurerLicense === body.driverLicense + (body.insurerLicense && + body.driverLicense && + body.insurerLicense === body.driverLicense) ) { throw new BadRequestException( "Insurer and Driver should be two different persons in this mode.", @@ -1875,52 +2083,24 @@ export class RequestManagementService { ); } - // ---- External inquiry 2: personal identity (same as plate path) ---- - const personalNationalCode = - body.nationalCodeOfInsurer || body.nationalCodeOfDriver; - const personalBirthDate: number | string | null = (body.insurerBirthday ?? - body.driverBirthday ?? - null) as number | string | null; - if ( - !personalNationalCode || - personalBirthDate === null || - personalBirthDate === undefined || - String(personalBirthDate).trim() === "" - ) { - throw new BadRequestException( - "Valid nationalCode and birthDate are required for personal inquiry.", - ); - } - - try { - const personalInquiry = await this.sandHubService.getPersonalInquiry( - personalNationalCode, - personalBirthDate, - inquiryOptions, - ); - this.recordPartyCaseInquiryStatus( - req, - "person", - role, - true, - personalInquiry, - ); - this.logger.log( - `[SANDHUB] personal inquiry success request=${req._id} nationalCode=${personalNationalCode}: ${JSON.stringify(personalInquiry)}`, - ); - } catch (err: any) { - this.logger.error( - `[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`, - ); - this.recordPartyCaseInquiryStatus(req, "person", role, false, {}, err); - await this.blameRequestDbService.findByIdAndUpdate(req._id, { - $set: { inquiries: req.inquiries }, - }); - throw new HttpException( - "Personal identity inquiry failed", - HttpStatus.BAD_REQUEST, - ); - } + await this.runParticipantPersonalInquiries( + req, + role, + inquirySubmission, + inquiryOptions, + ); + await this.runVehicleOwnershipInquiry( + req, + role, + inquirySubmission, + inquiryOptions, + ); + await this.runRoleCompleteDriverLicenceInquiry( + req, + role, + inquirySubmission, + inquiryOptions, + ); // Resolve insurer client from inquiry response const clientName = inquiryMapped?.CompanyName; @@ -1962,14 +2142,16 @@ export class RequestManagementService { // Plk2 may be a numeric letter code (e.g. 5 → "د") or already a Persian // letter string — resolvePlk2Letter handles both forms. // Falls back to the submitted VIN so plateId is never empty. - party.vehicle.plateId = this.plateToPlateIdString({ - ir: inquiryMapped?.PlkSrl, - leftDigits: inquiryMapped?.Plk1, - centerAlphabet: this.resolvePlk2Letter( - inquiryMapped?.Plk2 ?? inquiryMapped?.plateLetterid, - ), - centerDigits: inquiryMapped?.Plk3, - }) || body.vin; + party.vehicle.plateId = inquirySubmission.vehicle?.currentPlate + ? this.plateToPlateIdString(inquirySubmission.vehicle.currentPlate) + : this.plateToPlateIdString({ + ir: inquiryMapped?.PlkSrl, + leftDigits: inquiryMapped?.Plk1, + centerAlphabet: this.resolvePlk2Letter( + inquiryMapped?.Plk2 ?? inquiryMapped?.plateLetterid, + ), + centerDigits: inquiryMapped?.Plk3, + }) || body.vin; party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`; party.vehicle.inquiry = { @@ -2003,7 +2185,9 @@ export class RequestManagementService { if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) { try { const carBodyInfo = await this.sandHubService.getCarBodyInquiry({ - nationalCodeOfInsurer: body.nationalCodeOfInsurer, + nationalCodeOfInsurer: + inquirySubmission.carBodyPolicyholder?.nationalCode ?? + body.nationalCodeOfInsurer, plate: body.vin, }); this.recordPartyCaseInquiryStatus(req, "carBody", role, true, { @@ -2080,6 +2264,8 @@ export class RequestManagementService { await this.blameRequestDbService.findByIdAndUpdate(req._id, { $set: { [`parties.${idx}.person`]: party.person, + [`parties.${idx}.participants`]: party.participants, + [`parties.${idx}.participantRoles`]: party.participantRoles, [`parties.${idx}.vehicle`]: party.vehicle, [`parties.${idx}.insurance`]: party.insurance, inquiries: req.inquiries, @@ -2103,7 +2289,6 @@ export class RequestManagementService { } } - async addDetailLocationV2( requestId: string, body: LocationDto, @@ -2378,10 +2563,15 @@ export class RequestManagementService { // V6 call-center initiated THIRD_PARTY: first party is always the guilty party. // If blameStatus is still UNKNOWN here (pre-fix file or race condition), force it to AGREED now. - if (isSecondThirdParty && req.callCenterInitiated && req.blameStatus !== BlameStatus.AGREED) { + if ( + isSecondThirdParty && + req.callCenterInitiated && + req.blameStatus !== BlameStatus.AGREED + ) { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx !== -1) { - if (!req.parties[firstIdx].statement) req.parties[firstIdx].statement = {} as any; + if (!req.parties[firstIdx].statement) + req.parties[firstIdx].statement = {} as any; req.parties[firstIdx].statement.admitsGuilt = true; req.parties[firstIdx].statement.claimsDamage = false; req.parties[firstIdx].statement.acceptsExpertOpinion = false; @@ -3162,12 +3352,10 @@ export class RequestManagementService { setFields[ "firstPartyDetails.firstPartyCarBodyInsuranceDetail.coverages" ] = carBodyInfo.coverages; - setFields[ - "firstPartyDetails.firstPartyCarBodyInsuranceDetail.raw" - ] = carBodyInquiry.raw; - setFields[ - "firstPartyDetails.firstPartyCarBodyInsuranceDetail.source" - ] = carBodyInquiry.source; + setFields["firstPartyDetails.firstPartyCarBodyInsuranceDetail.raw"] = + carBodyInquiry.raw; + setFields["firstPartyDetails.firstPartyCarBodyInsuranceDetail.source"] = + carBodyInquiry.source; setFields[`${partyDetails}.${partyType}Client.clientId`] = await this.resolveCarBodyPolicyClientId(carBodyInfo); } @@ -4770,11 +4958,18 @@ export class RequestManagementService { /** * Verify the current user (field expert) can access this BlameRequest (v2). */ - private async verifyExpertAccessForBlameV2(req: any, expert: any): Promise { + private async verifyExpertAccessForBlameV2( + req: any, + expert: any, + ): Promise { // FILE_REVIEWER can access V4/V5 FileMaker-sealed files. // They must be the assigned reviewer (or the file is still open for taking). if (expert?.role === RoleEnum.FILE_REVIEWER) { - if (!req?.isMadeByFileMaker || !req?.expertInitiated || req?.creationMethod !== CreationMethod.IN_PERSON) { + if ( + !req?.isMadeByFileMaker || + !req?.expertInitiated || + req?.creationMethod !== CreationMethod.IN_PERSON + ) { throw new ForbiddenException( "FileReviewer can only access V4/V5 FileMaker files.", ); @@ -4811,12 +5006,14 @@ export class RequestManagementService { if (!assignedId) { // Atomically claim — ignore if another reviewer won the race (they would have // been caught by the assignedId check above on their own first call). - await this.fanavaranLocationService.assertMakerReviewerLocationCompatible({ - fileMakerId: req?.initiatedByFieldExpertId - ? String(req.initiatedByFieldExpertId) - : null, - fileReviewerId: String(expert.sub), - }); + await this.fanavaranLocationService.assertMakerReviewerLocationCompatible( + { + fileMakerId: req?.initiatedByFieldExpertId + ? String(req.initiatedByFieldExpertId) + : null, + fileReviewerId: String(expert.sub), + }, + ); await this.blameRequestDbService.findOneAndUpdate( { _id: req._id, @@ -4825,17 +5022,23 @@ export class RequestManagementService { { assignedFileReviewerId: null }, ], }, - { $set: { assignedFileReviewerId: new Types.ObjectId(String(expert.sub)) } }, + { + $set: { + assignedFileReviewerId: new Types.ObjectId(String(expert.sub)), + }, + }, { new: false }, ); } else { // Already assigned to this reviewer — re-check location compatibility. - await this.fanavaranLocationService.assertMakerReviewerLocationCompatible({ - fileMakerId: req?.initiatedByFieldExpertId - ? String(req.initiatedByFieldExpertId) - : null, - fileReviewerId: String(expert.sub), - }); + await this.fanavaranLocationService.assertMakerReviewerLocationCompatible( + { + fileMakerId: req?.initiatedByFieldExpertId + ? String(req.initiatedByFieldExpertId) + : null, + fileReviewerId: String(expert.sub), + }, + ); } return; } @@ -5130,7 +5333,9 @@ export class RequestManagementService { ...(isFileMakerRole ? { isMadeByFileMaker: true } : {}), // V5 only: flag that causes the resulting claim to require FileMaker // approval before fanavaran submission. V4 files never set this. - ...(dto.requiresFileMakerApproval ? { requiresFileMakerApproval: true } : {}), + ...(dto.requiresFileMakerApproval + ? { requiresFileMakerApproval: true } + : {}), }); const requestId = String((created as any)._id); @@ -5739,7 +5944,8 @@ export class RequestManagementService { type: "AUTO_CONFESSION_SKIPPED", actor: { actorId: new Types.ObjectId(actor.sub), - actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorName: + `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { @@ -5765,8 +5971,7 @@ export class RequestManagementService { type: "SECOND_PARTY_OTP_VERIFIED_ADVANCED", actor: { actorId: new Types.ObjectId(actor.sub), - actorName: - `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { @@ -6370,20 +6575,44 @@ export class RequestManagementService { formData.firstPartyPhoneNumber, ); - const firstPartyPlate = formData.firstPartyPlate; + const inquirySubmission = this.normalizeInquiryInput( + req.type, + formData.firstPartyPlate, + ); + const firstPartyPlate = inquirySubmission.dto; let sandHubReport: any; try { - const sandHubResponse = await this.sandHubService.getSandHubResponse({ - plate: firstPartyPlate.plate, - nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, - }); - sandHubReport = sandHubResponse["_doc"] || sandHubResponse; + const inquiry = inquirySubmission.participants.legacy + ? await (async () => { + const response = await this.sandHubService.getSandHubResponse({ + plate: firstPartyPlate.plate, + nationalCodeOfInsurer: + firstPartyPlate.nationalCodeOfInsurer, + }); + const mapped = response["_doc"] || response; + return { + raw: mapped, + mapped, + plateKind: "CURRENT", + attempts: [ + { plateKind: "CURRENT", succeeded: true, usable: true }, + ], + }; + })() + : await this.getThirdPartyPlateInquiry(inquirySubmission); + sandHubReport = inquiry.mapped; this.recordPartyCaseInquiryStatus( req, "thirdParty", PartyRole.FIRST, true, - sandHubReport, + { + source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", + plateKind: inquiry.plateKind, + attempts: inquiry.attempts, + raw: inquiry.raw, + mapped: inquiry.mapped, + }, ); } catch (e) { this.logger.error("SandHub error in expertCompleteCarBodyFormV2:", e); @@ -6416,25 +6645,38 @@ export class RequestManagementService { ); } - const carBodyInquiry = await this.sandHubService.getCarBodyInquiry({ - nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, - plate: firstPartyPlate.plate, - } as any); + const carBodyInquiry = await this.getCarBodyPlateInquiry(inquirySubmission); const carBodyInfo = carBodyInquiry.mapped as any; - this.recordPartyCaseInquiryStatus( - req, - "carBody", - PartyRole.FIRST, - true, - { - source: carBodyInquiry.source, - raw: carBodyInquiry.raw, - mapped: carBodyInfo, - }, - ); - const carBodyClientId = await this.resolveCarBodyPolicyClientId( - carBodyInfo, - ); + this.recordPartyCaseInquiryStatus(req, "carBody", PartyRole.FIRST, true, { + source: carBodyInquiry.source, + raw: carBodyInquiry.raw, + mapped: carBodyInfo, + }); + const carBodyClientId = + await this.resolveCarBodyPolicyClientId(carBodyInfo); + if (!inquirySubmission.participants.legacy) { + const options = carBodyClientId + ? { clientId: String(carBodyClientId) } + : undefined; + await this.runParticipantPersonalInquiries( + req, + PartyRole.FIRST, + inquirySubmission, + options, + ); + await this.runVehicleOwnershipInquiry( + req, + PartyRole.FIRST, + inquirySubmission, + options, + ); + await this.runRoleCompleteDriverLicenceInquiry( + req, + PartyRole.FIRST, + inquirySubmission, + options, + ); + } // Gate stale CAR_BODY submissions against the body-policy insurer, not // the unrelated third-party insurer resolved above. @@ -6448,6 +6690,8 @@ export class RequestManagementService { const firstParty: any = { role: PartyRole.FIRST, + participants: inquirySubmission.participants.participants, + participantRoles: inquirySubmission.participants.roles, person: { userId: firstPartyUserId, phoneNumber: @@ -6472,6 +6716,11 @@ export class RequestManagementService { model: sandHubReport?.MapTypNam, type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`, isNew: firstPartyPlate.isNewCar, + registrationState: inquirySubmission.vehicle?.registrationState, + previousPlateId: inquirySubmission.vehicle?.previousPlate + ? this.plateToPlateIdString(inquirySubmission.vehicle.previousPlate) + : undefined, + vin: inquirySubmission.vehicle?.vin, inquiry: { ...sandHubReport, carBody: { @@ -6584,9 +6833,7 @@ export class RequestManagementService { "secondParty and guiltyPartyPhoneNumber are required.", ); } - if ( - formData.guiltyPartyPhoneNumber !== formData.firstPartyPhoneNumber - ) { + if (formData.guiltyPartyPhoneNumber !== formData.firstPartyPhoneNumber) { throw new BadRequestException( "The first party is the guilty party in this flow.", ); @@ -6610,12 +6857,27 @@ export class RequestManagementService { desc: string, role: PartyRole, ) => { - let sandHubResponse: any; + const inquirySubmission = this.normalizeInquiryInput(req.type, plateDto); + plateDto = inquirySubmission.dto; + let inquiry: any; try { - sandHubResponse = await this.sandHubService.getSandHubResponse({ - plate: plateDto.plate, - nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer, - }); + if (inquirySubmission.participants.legacy) { + const response = await this.sandHubService.getSandHubResponse({ + plate: plateDto.plate, + nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer, + }); + const mapped = response["_doc"] || response; + inquiry = { + raw: mapped, + mapped, + plateKind: "CURRENT", + attempts: [ + { plateKind: "CURRENT", succeeded: true, usable: true }, + ], + }; + } else { + inquiry = await this.getThirdPartyPlateInquiry(inquirySubmission); + } } catch (err: any) { this.recordPartyCaseInquiryStatus( req, @@ -6628,20 +6890,20 @@ export class RequestManagementService { await (req as any).save(); throw err; } - const sandHubReport = (sandHubResponse["_doc"] || sandHubResponse) as any; + const sandHubReport = inquiry.mapped as any; if (role === PartyRole.FIRST) { this.sandHubService.assertInsuranceMatchesDeployment( sandHubReport, "THIRD_PARTY", ); } - this.recordPartyCaseInquiryStatus( - req, - "thirdParty", - role, - true, - sandHubReport, - ); + this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, { + source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", + plateKind: inquiry.plateKind, + attempts: inquiry.attempts, + raw: inquiry.raw, + mapped: inquiry.mapped, + }); const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode; @@ -6658,8 +6920,33 @@ export class RequestManagementService { } const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; + if (!inquirySubmission.participants.legacy) { + const options = resolvedClientId + ? { clientId: String(resolvedClientId) } + : undefined; + await this.runParticipantPersonalInquiries( + req, + role, + inquirySubmission, + options, + ); + await this.runVehicleOwnershipInquiry( + req, + role, + inquirySubmission, + options, + ); + await this.runRoleCompleteDriverLicenceInquiry( + req, + role, + inquirySubmission, + options, + ); + } return { role, + participants: inquirySubmission.participants.participants, + participantRoles: inquirySubmission.participants.roles, person: { userId, phoneNumber: normalizeIranMobile(phoneNumber) ?? phoneNumber, @@ -6682,6 +6969,11 @@ export class RequestManagementService { model: sandHubReport?.MapTypNam, type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`, isNew: plateDto.isNewCar, + registrationState: inquirySubmission.vehicle?.registrationState, + previousPlateId: inquirySubmission.vehicle?.previousPlate + ? this.plateToPlateIdString(inquirySubmission.vehicle.previousPlate) + : undefined, + vin: inquirySubmission.vehicle?.vin, inquiry: sandHubReport, }, insurance: { @@ -7248,9 +7540,7 @@ export class RequestManagementService { // The first party is the guilty party in every supported THIRD_PARTY flow. if (request.type === "THIRD_PARTY") { - if ( - formData.guiltyPartyPhoneNumber !== formData.firstPartyPhoneNumber - ) { + if (formData.guiltyPartyPhoneNumber !== formData.firstPartyPhoneNumber) { throw new BadRequestException( "The first party is the guilty party in this flow.", ); @@ -8822,6 +9112,9 @@ export class RequestManagementService { partyRole: PartyRole, party: any, ): Promise<{ clientId?: string }> { + const inquirySubmission = this.normalizeInquiryInput(req.type, partyData); + partyData = inquirySubmission.dto as RunInquiriesV3Dto; + this.applyInquiryParticipantsToParty(party, inquirySubmission); const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; let clientId: string | undefined; const inquiryOptions = (cid?: string) => @@ -8830,21 +9123,24 @@ export class RequestManagementService { let inquiryRaw: any; let inquiryMapped: any; let inquirySource = "TEJARAT_BLOCK_INQUIRY"; + let inquiryPlateKind = "CURRENT"; + let inquiryAttempts: any[] = []; try { - const inquiry = await this.sandHubService.getTejaratBlockInquiry( - { - plate: partyData.plate as any, - nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, - }, + const inquiry = await this.getThirdPartyPlateInquiry( + inquirySubmission, inquiryOptions(party?.person?.clientId?.toString()), ); inquiryRaw = inquiry.raw; inquiryMapped = inquiry.mapped; + inquiryPlateKind = inquiry.plateKind; + inquiryAttempts = inquiry.attempts; if (inquiry.offline?.source) { inquirySource = inquiry.offline.source; } this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, true, { source: inquirySource, + plateKind: inquiry.plateKind, + attempts: inquiry.attempts, raw: inquiryRaw, mapped: inquiryMapped, }); @@ -8917,6 +9213,8 @@ export class RequestManagementService { party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`; party.vehicle.inquiry = { source: inquirySource, + plateKind: inquiryPlateKind, + attempts: inquiryAttempts, raw: inquiryRaw, mapped: inquiryMapped, }; @@ -8945,12 +9243,12 @@ export class RequestManagementService { partyRole === PartyRole.FIRST ) { try { - const carBodyInfo = await this.sandHubService.getCarBodyInquiry({ - nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, - plate: partyData.plate as any, - }); + const carBodyInfo = + await this.getCarBodyPlateInquiry(inquirySubmission); this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, { source: carBodyInfo.source, + plateKind: carBodyInfo.plateKind, + attempts: carBodyInfo.attempts, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }); @@ -8958,6 +9256,8 @@ export class RequestManagementService { ...party.vehicle.inquiry, carBody: { source: carBodyInfo.source, + plateKind: carBodyInfo.plateKind, + attempts: carBodyInfo.attempts, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }, @@ -9017,40 +9317,18 @@ export class RequestManagementService { } } - const nationalCode = - partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver; - const birthDate = partyData.insurerBirthday ?? partyData.driverBirthday; - if (nationalCode && birthDate != null) { - try { - const personalInquiry = await this.sandHubService.getPersonalInquiry( - nationalCode, - birthDate as string | number, - clientId ? { clientId } : undefined, - ); - this.recordPartyCaseInquiryStatus( - req, - "person", - partyRole, - true, - personalInquiry, - ); - } catch (err: any) { - this.logger.error( - `[V3] personal inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`, - ); - this.recordPartyCaseInquiryStatus( - req, - "person", - partyRole, - false, - {}, - err, - ); - throw new BadRequestException( - `${roleLabel} party personal identity inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, - ); - } - } + await this.runParticipantPersonalInquiries( + req, + partyRole, + inquirySubmission, + clientId ? { clientId } : undefined, + ); + await this.runVehicleOwnershipInquiry( + req, + partyRole, + inquirySubmission, + clientId ? { clientId } : undefined, + ); const licenseNationalCode = partyData.driverIsInsurer ? partyData.nationalCodeOfInsurer @@ -9126,11 +9404,19 @@ export class RequestManagementService { if (partyRole === PartyRole.FIRST) { const completedSteps = skipLocation - ? [WorkflowStep.CREATED, WorkflowStep.FIRST_INITIAL_FORM, WorkflowStep.FIRST_LOCATION] + ? [ + WorkflowStep.CREATED, + WorkflowStep.FIRST_INITIAL_FORM, + WorkflowStep.FIRST_LOCATION, + ] : [WorkflowStep.CREATED, WorkflowStep.FIRST_INITIAL_FORM]; this.pushWorkflowSteps(req, completedSteps); - req.workflow.currentStep = skipLocation ? WorkflowStep.FIRST_VOICE : WorkflowStep.FIRST_LOCATION; - req.workflow.nextStep = skipLocation ? WorkflowStep.FIRST_DESCRIPTION : WorkflowStep.FIRST_VOICE; + req.workflow.currentStep = skipLocation + ? WorkflowStep.FIRST_VOICE + : WorkflowStep.FIRST_LOCATION; + req.workflow.nextStep = skipLocation + ? WorkflowStep.FIRST_DESCRIPTION + : WorkflowStep.FIRST_VOICE; const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; @@ -9149,8 +9435,12 @@ export class RequestManagementService { ? [WorkflowStep.SECOND_INITIAL_FORM, WorkflowStep.SECOND_LOCATION] : [WorkflowStep.SECOND_INITIAL_FORM]; this.pushWorkflowSteps(req, completedSteps); - req.workflow.currentStep = skipLocation ? WorkflowStep.SECOND_VOICE : WorkflowStep.SECOND_LOCATION; - req.workflow.nextStep = skipLocation ? WorkflowStep.SECOND_DESCRIPTION : WorkflowStep.SECOND_VOICE; + req.workflow.currentStep = skipLocation + ? WorkflowStep.SECOND_VOICE + : WorkflowStep.SECOND_LOCATION; + req.workflow.nextStep = skipLocation + ? WorkflowStep.SECOND_DESCRIPTION + : WorkflowStep.SECOND_VOICE; } } @@ -9393,6 +9683,7 @@ export class RequestManagementService { if (!req) throw new NotFoundException("Blame request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, actor); + dto = this.normalizeInquiryInput(req.type, dto).dto as RunInquiriesV3Dto; const partyRole = this.resolveV3InquiryPartyRole(req); if (!partyRole) { @@ -9529,6 +9820,7 @@ export class RequestManagementService { if (!req) throw new NotFoundException("Blame request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, actor); + dto = this.normalizeInquiryInput(req.type, dto).dto as RunInquiriesVinV3Dto; const partyRole = this.resolveV3InquiryPartyRole(req); if (!partyRole) { @@ -9660,6 +9952,9 @@ export class RequestManagementService { partyRole: PartyRole, party: any, ): Promise<{ clientId?: string }> { + const inquirySubmission = this.normalizeInquiryInput(req.type, partyData); + partyData = inquirySubmission.dto as RunInquiriesVinV3Dto; + this.applyInquiryParticipantsToParty(party, inquirySubmission); const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; let clientId: string | undefined; const inquiryOptions = (cid?: string) => @@ -9743,14 +10038,16 @@ export class RequestManagementService { // Plk2 may be a numeric letter code (e.g. 5 → "د") or already a Persian // letter string — resolvePlk2Letter handles both forms. // Falls back to the submitted VIN so plateId is never empty. - party.vehicle.plateId = this.plateToPlateIdString({ - ir: inquiryMapped?.PlkSrl, - leftDigits: inquiryMapped?.Plk1, - centerAlphabet: this.resolvePlk2Letter( - inquiryMapped?.Plk2 ?? inquiryMapped?.plateLetterid, - ), - centerDigits: inquiryMapped?.Plk3, - }) || partyData.vin; + party.vehicle.plateId = inquirySubmission.vehicle?.currentPlate + ? this.plateToPlateIdString(inquirySubmission.vehicle.currentPlate) + : this.plateToPlateIdString({ + ir: inquiryMapped?.PlkSrl, + leftDigits: inquiryMapped?.Plk1, + centerAlphabet: this.resolvePlk2Letter( + inquiryMapped?.Plk2 ?? inquiryMapped?.plateLetterid, + ), + centerDigits: inquiryMapped?.Plk3, + }) || partyData.vin; party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`; party.vehicle.inquiry = { @@ -9784,7 +10081,9 @@ export class RequestManagementService { ) { try { const carBodyInfo = await this.sandHubService.getCarBodyInquiry({ - nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, + nationalCodeOfInsurer: + inquirySubmission.carBodyPolicyholder?.nationalCode ?? + partyData.nationalCodeOfInsurer, plate: partyData.vin as any, // VIN used as identifier for CAR_BODY }); this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, { @@ -9855,40 +10154,18 @@ export class RequestManagementService { } } - const nationalCode = - partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver; - const birthDate = partyData.insurerBirthday ?? partyData.driverBirthday; - if (nationalCode && birthDate != null) { - try { - const personalInquiry = await this.sandHubService.getPersonalInquiry( - nationalCode, - birthDate as string | number, - clientId ? { clientId } : undefined, - ); - this.recordPartyCaseInquiryStatus( - req, - "person", - partyRole, - true, - personalInquiry, - ); - } catch (err: any) { - this.logger.error( - `[V3-VIN] personal inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`, - ); - this.recordPartyCaseInquiryStatus( - req, - "person", - partyRole, - false, - {}, - err, - ); - throw new BadRequestException( - `${roleLabel} party personal identity inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, - ); - } - } + await this.runParticipantPersonalInquiries( + req, + partyRole, + inquirySubmission, + clientId ? { clientId } : undefined, + ); + await this.runVehicleOwnershipInquiry( + req, + partyRole, + inquirySubmission, + clientId ? { clientId } : undefined, + ); const licenseNationalCode = partyData.driverIsInsurer ? partyData.nationalCodeOfInsurer @@ -10174,7 +10451,8 @@ export class RequestManagementService { type: "V3_ACCIDENT_FIELDS_SAVED", actor: { actorId: new Types.ObjectId(String(expert.sub)), - actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorName: + `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(expert), }, metadata: { accidentWay: fields.accidentWay }, @@ -10255,8 +10533,7 @@ export class RequestManagementService { if ( claim.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && - claim.workflow?.currentStep !== - ClaimWorkflowStep.USER_SUBMISSION_COMPLETE + claim.workflow?.currentStep !== ClaimWorkflowStep.USER_SUBMISSION_COMPLETE ) { throw new BadRequestException( "Complete capture-part (parts, angles, and capture-phase documents) before the blame accident video.", @@ -10564,7 +10841,8 @@ export class RequestManagementService { const party = req.parties[idx]; if (!party.statement) party.statement = {} as any; party.statement.description = body.desc; - if (body.accidentDate != null) party.statement.accidentDate = body.accidentDate as any; + if (body.accidentDate != null) + party.statement.accidentDate = body.accidentDate as any; if (body.accidentTime) party.statement.accidentTime = body.accidentTime; if (!Array.isArray(req.history)) req.history = []; @@ -10771,7 +11049,8 @@ export class RequestManagementService { type: "V5_BLAME_ACCIDENT_VIDEO_UPLOADED", actor: { actorId: new Types.ObjectId(expert.sub), - actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorName: + `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "file_reviewer", }, timestamp: new Date(), @@ -10836,7 +11115,8 @@ export class RequestManagementService { ); } - const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); + const actorName = + `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); const historyEntry = { type: "V5_FILE_MAKER_APPROVED", actor: { @@ -10913,7 +11193,8 @@ export class RequestManagementService { } const newRejectionCount = currentRejections + 1; - const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); + const actorName = + `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { @@ -10949,7 +11230,10 @@ export class RequestManagementService { actorType: "file_maker", }, timestamp: new Date(), - metadata: { reason: reason ?? null, rejectionCount: newRejectionCount }, + metadata: { + reason: reason ?? null, + rejectionCount: newRejectionCount, + }, }, }, }); @@ -11023,7 +11307,9 @@ export class RequestManagementService { dto: { type: "THIRD_PARTY" | "CAR_BODY" }, ): Promise<{ requestId: string; publicId: string }> { if (agent?.role !== RoleEnum.CALL_CENTER) { - throw new ForbiddenException("Only call-center agents can use this endpoint."); + throw new ForbiddenException( + "Only call-center agents can use this endpoint.", + ); } const agentId = new Types.ObjectId(agent.sub); const type = @@ -11098,19 +11384,33 @@ export class RequestManagementService { dto: Omit, ): Promise> { if (agent?.role !== RoleEnum.CALL_CENTER) { - throw new ForbiddenException("Only call-center agents can use this endpoint."); + throw new ForbiddenException( + "Only call-center agents can use this endpoint.", + ); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); - if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) { - throw new ForbiddenException("You can only access files that you have initiated."); + if ( + !req.callCenterInitiated || + String(req.initiatedByCallCenterId) !== String(agent.sub) + ) { + throw new ForbiddenException( + "You can only access files that you have initiated.", + ); } const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; - await this.runPartyInquiriesV3Internal(req, dto, PartyRole.FIRST, firstParty); + dto = this.normalizeInquiryInput(req.type, dto).dto; + + await this.runPartyInquiriesV3Internal( + req, + dto, + PartyRole.FIRST, + firstParty, + ); // V6 call-center guard: the guilty party's insurance must belong to THIS // deployment's insurer company. Reject the file early so the agent cannot @@ -11152,11 +11452,17 @@ export class RequestManagementService { publicId: (req as any).publicId, type: (req as any).type, status: (req as any).status, - message: "استعلام طرف مقصر با موفقیت انجام شد. اکنون می‌توانید لینک تقصیر را برای کاربر ارسال کنید.", + message: + "استعلام طرف مقصر با موفقیت انجام شد. اکنون می‌توانید لینک تقصیر را برای کاربر ارسال کنید.", guiltyParty: { + participants: firstPartyAfter?.participants, + participantRoles: firstPartyAfter?.participantRoles, vehicle: firstPartyAfter?.vehicle ? { plateId: firstPartyAfter.vehicle.plateId, + previousPlateId: firstPartyAfter.vehicle.previousPlateId, + registrationState: firstPartyAfter.vehicle.registrationState, + vin: firstPartyAfter.vehicle.vin, name: firstPartyAfter.vehicle.name, type: firstPartyAfter.vehicle.type, } @@ -11167,16 +11473,22 @@ export class RequestManagementService { policyNumber: firstPartyAfter.insurance.policyNumber, startDate: firstPartyAfter.insurance.startDate, endDate: firstPartyAfter.insurance.endDate, - financialCeiling: (firstPartyAfter.insurance as any).financialCeiling, + financialCeiling: (firstPartyAfter.insurance as any) + .financialCeiling, } : undefined, person: firstPartyAfter?.person ? { - nationalCodeOfInsurer: firstPartyAfter.person.nationalCodeOfInsurer, + nationalCodeOfInsurer: + firstPartyAfter.person.nationalCodeOfInsurer, nationalCodeOfDriver: firstPartyAfter.person.nationalCodeOfDriver, driverIsInsurer: firstPartyAfter.person.driverIsInsurer, - insurerBirthday: formatJalaliCompact(firstPartyAfter.person.insurerBirthday), - driverBirthday: formatJalaliCompact(firstPartyAfter.person.driverBirthday), + insurerBirthday: formatJalaliCompact( + firstPartyAfter.person.insurerBirthday, + ), + driverBirthday: formatJalaliCompact( + firstPartyAfter.person.driverBirthday, + ), } : undefined, }, @@ -11195,19 +11507,33 @@ export class RequestManagementService { dto: Omit, ): Promise> { if (agent?.role !== RoleEnum.CALL_CENTER) { - throw new ForbiddenException("Only call-center agents can use this endpoint."); + throw new ForbiddenException( + "Only call-center agents can use this endpoint.", + ); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); - if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) { - throw new ForbiddenException("You can only access files that you have initiated."); + if ( + !req.callCenterInitiated || + String(req.initiatedByCallCenterId) !== String(agent.sub) + ) { + throw new ForbiddenException( + "You can only access files that you have initiated.", + ); } const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; - await this.runPartyInquiriesVinV3Internal(req, dto as RunInquiriesVinV3Dto, PartyRole.FIRST, firstParty); + dto = this.normalizeInquiryInput(req.type, dto).dto; + + await this.runPartyInquiriesVinV3Internal( + req, + dto as RunInquiriesVinV3Dto, + PartyRole.FIRST, + firstParty, + ); // Same insurer-company guard as the plate variant. const resolvedClientId = req.parties[firstIdx]?.person?.clientId; @@ -11243,11 +11569,17 @@ export class RequestManagementService { publicId: (req as any).publicId, type: (req as any).type, status: (req as any).status, - message: "استعلام VIN طرف مقصر با موفقیت انجام شد. اکنون می‌توانید لینک تقصیر را برای کاربر ارسال کنید.", + message: + "استعلام VIN طرف مقصر با موفقیت انجام شد. اکنون می‌توانید لینک تقصیر را برای کاربر ارسال کنید.", guiltyParty: { + participants: firstPartyAfter?.participants, + participantRoles: firstPartyAfter?.participantRoles, vehicle: firstPartyAfter?.vehicle ? { vin: firstPartyAfter.vehicle.vin, + plateId: firstPartyAfter.vehicle.plateId, + previousPlateId: firstPartyAfter.vehicle.previousPlateId, + registrationState: firstPartyAfter.vehicle.registrationState, name: firstPartyAfter.vehicle.name, type: firstPartyAfter.vehicle.type, } @@ -11258,16 +11590,22 @@ export class RequestManagementService { policyNumber: firstPartyAfter.insurance.policyNumber, startDate: firstPartyAfter.insurance.startDate, endDate: firstPartyAfter.insurance.endDate, - financialCeiling: (firstPartyAfter.insurance as any).financialCeiling, + financialCeiling: (firstPartyAfter.insurance as any) + .financialCeiling, } : undefined, person: firstPartyAfter?.person ? { - nationalCodeOfInsurer: firstPartyAfter.person.nationalCodeOfInsurer, + nationalCodeOfInsurer: + firstPartyAfter.person.nationalCodeOfInsurer, nationalCodeOfDriver: firstPartyAfter.person.nationalCodeOfDriver, driverIsInsurer: firstPartyAfter.person.driverIsInsurer, - insurerBirthday: formatJalaliCompact(firstPartyAfter.person.insurerBirthday), - driverBirthday: formatJalaliCompact(firstPartyAfter.person.driverBirthday), + insurerBirthday: formatJalaliCompact( + firstPartyAfter.person.insurerBirthday, + ), + driverBirthday: formatJalaliCompact( + firstPartyAfter.person.driverBirthday, + ), } : undefined, }, @@ -11283,24 +11621,42 @@ export class RequestManagementService { agent: any, requestId: string, dto: { phoneNumber: string }, - ): Promise<{ sent: boolean; sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string }[] }> { + ): Promise<{ + sent: boolean; + sentTo: { + role: string; + phoneNumber: string; + smsSent: boolean; + linkUrl: string; + }[]; + }> { if (agent?.role !== RoleEnum.CALL_CENTER) { - throw new ForbiddenException("Only call-center agents can use this endpoint."); + throw new ForbiddenException( + "Only call-center agents can use this endpoint.", + ); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); - if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) { - throw new ForbiddenException("You can only access files that you have initiated."); + if ( + !req.callCenterInitiated || + String(req.initiatedByCallCenterId) !== String(agent.sub) + ) { + throw new ForbiddenException( + "You can only access files that you have initiated.", + ); } if (!process.env.URL) { - throw new InternalServerErrorException("URL environment variable is not configured"); + throw new InternalServerErrorException( + "URL environment variable is not configured", + ); } const phone = (dto?.phoneNumber || "").trim(); if (!phone) throw new BadRequestException("phoneNumber is required"); const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); - if (firstIdx === -1) throw new BadRequestException("First party not found on request"); + if (firstIdx === -1) + throw new BadRequestException("First party not found on request"); // Safeguard: verify that run-inquiry was completed and that the resolved // insurance company belongs to this deployment before proceeding. @@ -11326,7 +11682,8 @@ export class RequestManagementService { const userId = await this.getOrCreateUserByPhoneNumber(phone); if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; - req.parties[firstIdx].person.phoneNumber = normalizeIranMobile(phone) ?? phone; + req.parties[firstIdx].person.phoneNumber = + normalizeIranMobile(phone) ?? phone; req.parties[firstIdx].person.userId = userId; // V6 call-center flow: the first party is always the guilty party. @@ -11338,9 +11695,15 @@ export class RequestManagementService { if (req.type === BlameRequestType.CAR_BODY) { // CAR_BODY: advance CREATED → CAR_BODY_ACCIDENT_TYPE - const carBodyStepDoc = await this.getWorkflowStep({ stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE }); - const afterCarBody = (carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ?? WorkflowStep.FIRST_VIDEO; - const completedSteps = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; + const carBodyStepDoc = await this.getWorkflowStep({ + stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE, + }); + const afterCarBody = + (carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ?? + WorkflowStep.FIRST_VIDEO; + const completedSteps = Array.isArray(req.workflow.completedSteps) + ? req.workflow.completedSteps + : []; req.workflow.completedSteps = completedSteps; req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE; req.workflow.nextStep = afterCarBody; @@ -11348,8 +11711,12 @@ export class RequestManagementService { // THIRD_PARTY: skip confession — first party is always guilty const step3 = await this.getWorkflowStep({ stepNumber: 3 }); // FIRST_VIDEO const step3Key = step3.stepKey as WorkflowStep; - const nextAfterVideo = (step3.nextPossibleSteps?.[0] as WorkflowStep) ?? WorkflowStep.FIRST_INITIAL_FORM; - const completedSteps = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; + const nextAfterVideo = + (step3.nextPossibleSteps?.[0] as WorkflowStep) ?? + WorkflowStep.FIRST_INITIAL_FORM; + const completedSteps = Array.isArray(req.workflow.completedSteps) + ? req.workflow.completedSteps + : []; if (!completedSteps.includes(step2Key)) completedSteps.push(step2Key); req.workflow.completedSteps = completedSteps; req.workflow.currentStep = step3Key; @@ -11357,7 +11724,8 @@ export class RequestManagementService { } // Auto-guilt: first party is always the guilty party in call-center v6 flow - if (!req.parties[firstIdx].statement) req.parties[firstIdx].statement = {} as any; + if (!req.parties[firstIdx].statement) + req.parties[firstIdx].statement = {} as any; req.parties[firstIdx].statement.admitsGuilt = true; req.parties[firstIdx].statement.claimsDamage = false; req.parties[firstIdx].statement.acceptsExpertOpinion = false; @@ -11372,14 +11740,19 @@ export class RequestManagementService { actorType: RoleEnum.CALL_CENTER, }, metadata: { - reason: "V6 call-center initiated: first party is always guilty; confession auto-resolved", + reason: + "V6 call-center initiated: first party is always guilty; confession auto-resolved", advancedTo: req.workflow.currentStep, }, }); } const expertName = `${agent?.lastName || ""}`.trim() || "اپراتور"; - const firstLink = this.smsOrchestrationService.buildBlamePartyLink(requestId, "FIRST", "v6"); + const firstLink = this.smsOrchestrationService.buildBlamePartyLink( + requestId, + "FIRST", + "v6", + ); const smsSent = await this.smsOrchestrationService.sendFieldExpertLink({ receptor: phone, type: req.type, @@ -11387,7 +11760,14 @@ export class RequestManagementService { link: firstLink, }); - const sentTo = [{ role: PartyRole.FIRST, phoneNumber: phone, smsSent, linkUrl: firstLink }]; + const sentTo = [ + { + role: PartyRole.FIRST, + phoneNumber: phone, + smsSent, + linkUrl: firstLink, + }, + ]; if (!Array.isArray((req as any).history)) (req as any).history = []; (req as any).history.push({ @@ -11407,14 +11787,24 @@ export class RequestManagementService { /** * V6: Get a single blame file detail for the call-center agent who created it. */ - async getCallCenterBlameDetailV6(agent: any, requestId: string): Promise { + async getCallCenterBlameDetailV6( + agent: any, + requestId: string, + ): Promise { if (agent?.role !== RoleEnum.CALL_CENTER) { - throw new ForbiddenException("Only call-center agents can use this endpoint."); + throw new ForbiddenException( + "Only call-center agents can use this endpoint.", + ); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); - if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) { - throw new ForbiddenException("You can only access files that you have initiated."); + if ( + !req.callCenterInitiated || + String(req.initiatedByCallCenterId) !== String(agent.sub) + ) { + throw new ForbiddenException( + "You can only access files that you have initiated.", + ); } return { _id: (req as any)._id, @@ -11431,6 +11821,8 @@ export class RequestManagementService { skipInitialFormStep: (req as any).skipInitialFormStep, parties: ((req as any).parties ?? []).map((p: any) => ({ role: p.role, + participants: p.participants, + participantRoles: p.participantRoles, person: { phoneNumber: p.person?.phoneNumber, nationalCodeOfInsurer: p.person?.nationalCodeOfInsurer, @@ -11438,9 +11830,7 @@ export class RequestManagementService { driverIsInsurer: p.person?.driverIsInsurer, insurerBirthday: p.person?.insurerBirthday, driverBirthday: p.person?.driverBirthday, - clientId: p.person?.clientId - ? String(p.person.clientId) - : undefined, + clientId: p.person?.clientId ? String(p.person.clientId) : undefined, licenseType: p.person?.licenseType ?? undefined, }, insurance: p.insurance @@ -11455,12 +11845,14 @@ export class RequestManagementService { vehicle: p.vehicle ? { plateId: p.vehicle.plateId, + previousPlateId: p.vehicle.previousPlateId, + registrationState: p.vehicle.registrationState, vin: p.vehicle.vin, name: p.vehicle.name, type: p.vehicle.type, } : undefined, - inquiryComplete: !!(p.person?.inquiriesCompleted), + inquiryComplete: !!p.person?.inquiriesCompleted, })), createdAt: (req as any).createdAt, updatedAt: (req as any).updatedAt, @@ -11499,7 +11891,10 @@ export class RequestManagementService { })); } - async getMyFileMakerFileDetail(fileMaker: any, requestId: string): Promise { + async getMyFileMakerFileDetail( + fileMaker: any, + requestId: string, + ): Promise { if (fileMaker?.role !== RoleEnum.FILE_MAKER) { throw new ForbiddenException("Only FileMakers can use this endpoint."); } @@ -11509,16 +11904,24 @@ export class RequestManagementService { !req.isMadeByFileMaker || String((req as any).initiatedByFieldExpertId) !== String(fileMaker.sub) ) { - throw new ForbiddenException("You can only access files that you have created."); + throw new ForbiddenException( + "You can only access files that you have created.", + ); } - const plain = typeof (req as any).toObject === "function" - ? (req as any).toObject({ versionKey: false }) - : { ...(req as any) }; + const plain = + typeof (req as any).toObject === "function" + ? (req as any).toObject({ versionKey: false }) + : { ...(req as any) }; // Attach linked claim ID if present - const claim = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id }); - const claimPlain = claim && typeof (claim as any).toObject === "function" - ? (claim as any).toObject({ versionKey: false }) - : claim ? { ...(claim as any) } : null; + const claim = await this.claimCaseDbService.findOne({ + blameRequestId: (req as any)._id, + }); + const claimPlain = + claim && typeof (claim as any).toObject === "function" + ? (claim as any).toObject({ versionKey: false }) + : claim + ? { ...(claim as any) } + : null; return { _id: plain._id, publicId: plain.publicId, @@ -11530,6 +11933,8 @@ export class RequestManagementService { requiresFileMakerApproval: plain.requiresFileMakerApproval, parties: (plain.parties ?? []).map((p: any) => ({ role: p.role, + participants: p.participants, + participantRoles: p.participantRoles, person: { fullName: p.person?.fullName, phoneNumber: p.person?.phoneNumber, @@ -11544,37 +11949,48 @@ export class RequestManagementService { clientId: p.person?.clientId ? String(p.person.clientId) : undefined, licenseType: p.person?.licenseType ?? undefined, }, - insurance: p.insurance ? { - company: p.insurance.company, - policyNumber: p.insurance.policyNumber, - startDate: p.insurance.startDate, - endDate: p.insurance.endDate, - financialCeiling: p.insurance.financialCeiling, - } : undefined, - vehicle: p.vehicle ? { - plateId: p.vehicle.plateId, - vin: p.vehicle.vin, - name: p.vehicle.name, - type: p.vehicle.type, - } : undefined, - inquiryComplete: !!(p.person?.inquiriesCompleted), + insurance: p.insurance + ? { + company: p.insurance.company, + policyNumber: p.insurance.policyNumber, + startDate: p.insurance.startDate, + endDate: p.insurance.endDate, + financialCeiling: p.insurance.financialCeiling, + } + : undefined, + vehicle: p.vehicle + ? { + plateId: p.vehicle.plateId, + previousPlateId: p.vehicle.previousPlateId, + registrationState: p.vehicle.registrationState, + vin: p.vehicle.vin, + name: p.vehicle.name, + type: p.vehicle.type, + } + : undefined, + inquiryComplete: !!p.person?.inquiriesCompleted, hasSigned: p.confirmation != null, })), linkedClaimId: claimPlain ? String(claimPlain._id) : null, - ...(claimPlain ? { - claimStatus: claimPlain.status, - claimWorkflow: claimPlain.workflow, - money: claimPlain.money ?? null, - fanavaran: fanavaranClaimReferences(claimPlain), - fileMakerRejectionCount: claimPlain.fileMakerRejectionCount ?? 0, - fileMakerRejectionReason: claimPlain.fileMakerRejectionReason ?? null, - evaluation: claimPlain.evaluation - ? { - damageExpertReply: claimPlain.evaluation.damageExpertReply ?? null, - damageExpertReplyFinal: claimPlain.evaluation.damageExpertReplyFinal ?? null, - } - : null, - } : {}), + ...(claimPlain + ? { + claimStatus: claimPlain.status, + claimWorkflow: claimPlain.workflow, + money: claimPlain.money ?? null, + fanavaran: fanavaranClaimReferences(claimPlain), + fileMakerRejectionCount: claimPlain.fileMakerRejectionCount ?? 0, + fileMakerRejectionReason: + claimPlain.fileMakerRejectionReason ?? null, + evaluation: claimPlain.evaluation + ? { + damageExpertReply: + claimPlain.evaluation.damageExpertReply ?? null, + damageExpertReplyFinal: + claimPlain.evaluation.damageExpertReplyFinal ?? null, + } + : null, + } + : {}), createdAt: plain.createdAt, updatedAt: plain.updatedAt, }; @@ -11638,7 +12054,10 @@ export class RequestManagementService { })); } - async getMyFileReviewerFileDetail(fileReviewer: any, requestId: string): Promise { + async getMyFileReviewerFileDetail( + fileReviewer: any, + requestId: string, + ): Promise { if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) { throw new ForbiddenException("Only FileReviewers can use this endpoint."); } @@ -11650,7 +12069,9 @@ export class RequestManagementService { !req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON ) { - throw new ForbiddenException("FileReviewer can only access V4/V5 FileMaker files."); + throw new ForbiddenException( + "FileReviewer can only access V4/V5 FileMaker files.", + ); } if (!blameCaseTouchesClient(req, clientKey)) { throw new ForbiddenException( @@ -11661,21 +12082,26 @@ export class RequestManagementService { ? String((req as any).assignedFileReviewerId) : null; if (assignedId && assignedId !== String(fileReviewer.sub)) { - throw new ForbiddenException("This file has been taken by another FileReviewer."); + throw new ForbiddenException( + "This file has been taken by another FileReviewer.", + ); } - if ( - !assignedId && - req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER - ) { + if (!assignedId && req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER) { throw new ForbiddenException("This file is not available for review."); } - const plain = typeof (req as any).toObject === "function" - ? (req as any).toObject({ versionKey: false }) - : { ...(req as any) }; - const claim = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id }); - const claimPlain = claim && typeof (claim as any).toObject === "function" - ? (claim as any).toObject({ versionKey: false }) - : claim ? { ...(claim as any) } : null; + const plain = + typeof (req as any).toObject === "function" + ? (req as any).toObject({ versionKey: false }) + : { ...(req as any) }; + const claim = await this.claimCaseDbService.findOne({ + blameRequestId: (req as any)._id, + }); + const claimPlain = + claim && typeof (claim as any).toObject === "function" + ? (claim as any).toObject({ versionKey: false }) + : claim + ? { ...(claim as any) } + : null; return { _id: plain._id, publicId: plain.publicId, @@ -11690,6 +12116,8 @@ export class RequestManagementService { : null, parties: (plain.parties ?? []).map((p: any) => ({ role: p.role, + participants: p.participants, + participantRoles: p.participantRoles, person: { fullName: p.person?.fullName, phoneNumber: p.person?.phoneNumber, @@ -11704,47 +12132,62 @@ export class RequestManagementService { clientId: p.person?.clientId ? String(p.person.clientId) : undefined, licenseType: p.person?.licenseType ?? undefined, }, - insurance: p.insurance ? { - company: p.insurance.company, - policyNumber: p.insurance.policyNumber, - startDate: p.insurance.startDate, - endDate: p.insurance.endDate, - financialCeiling: p.insurance.financialCeiling, - } : undefined, - vehicle: p.vehicle ? { - plateId: p.vehicle.plateId, - vin: p.vehicle.vin, - name: p.vehicle.name, - type: p.vehicle.type, - } : undefined, - inquiryComplete: !!(p.person?.inquiriesCompleted), + insurance: p.insurance + ? { + company: p.insurance.company, + policyNumber: p.insurance.policyNumber, + startDate: p.insurance.startDate, + endDate: p.insurance.endDate, + financialCeiling: p.insurance.financialCeiling, + } + : undefined, + vehicle: p.vehicle + ? { + plateId: p.vehicle.plateId, + previousPlateId: p.vehicle.previousPlateId, + registrationState: p.vehicle.registrationState, + vin: p.vehicle.vin, + name: p.vehicle.name, + type: p.vehicle.type, + } + : undefined, + inquiryComplete: !!p.person?.inquiriesCompleted, hasSigned: p.confirmation != null, })), - expert: plain.expert ? { - decision: plain.expert.decision ? { - guiltyPartyId: plain.expert.decision.guiltyPartyId - ? String(plain.expert.decision.guiltyPartyId) - : undefined, - description: plain.expert.decision.description, - decidedAt: plain.expert.decision.decidedAt, - } : undefined, - accidentFields: plain.expert.accidentFields, - } : undefined, + expert: plain.expert + ? { + decision: plain.expert.decision + ? { + guiltyPartyId: plain.expert.decision.guiltyPartyId + ? String(plain.expert.decision.guiltyPartyId) + : undefined, + description: plain.expert.decision.description, + decidedAt: plain.expert.decision.decidedAt, + } + : undefined, + accidentFields: plain.expert.accidentFields, + } + : undefined, linkedClaimId: claimPlain ? String(claimPlain._id) : null, - ...(claimPlain ? { - claimStatus: claimPlain.status, - claimWorkflow: claimPlain.workflow, - money: claimPlain.money ?? null, - fanavaran: fanavaranClaimReferences(claimPlain), - fileMakerRejectionCount: claimPlain.fileMakerRejectionCount ?? 0, - fileMakerRejectionReason: claimPlain.fileMakerRejectionReason ?? null, - evaluation: claimPlain.evaluation - ? { - damageExpertReply: claimPlain.evaluation.damageExpertReply ?? null, - damageExpertReplyFinal: claimPlain.evaluation.damageExpertReplyFinal ?? null, - } - : null, - } : {}), + ...(claimPlain + ? { + claimStatus: claimPlain.status, + claimWorkflow: claimPlain.workflow, + money: claimPlain.money ?? null, + fanavaran: fanavaranClaimReferences(claimPlain), + fileMakerRejectionCount: claimPlain.fileMakerRejectionCount ?? 0, + fileMakerRejectionReason: + claimPlain.fileMakerRejectionReason ?? null, + evaluation: claimPlain.evaluation + ? { + damageExpertReply: + claimPlain.evaluation.damageExpertReply ?? null, + damageExpertReplyFinal: + claimPlain.evaluation.damageExpertReplyFinal ?? null, + } + : null, + } + : {}), createdAt: plain.createdAt, updatedAt: plain.updatedAt, }; @@ -11752,7 +12195,9 @@ export class RequestManagementService { async getMyCallCenterFilesV6(agent: any): Promise { if (agent?.role !== RoleEnum.CALL_CENTER) { - throw new ForbiddenException("Only call-center agents can use this endpoint."); + throw new ForbiddenException( + "Only call-center agents can use this endpoint.", + ); } const agentId = new Types.ObjectId(agent.sub); const files = await this.blameRequestDbService.find({ From d060b6d9e258dce9fbf562efcac2a24c68fe1268 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 13 Sep 2026 11:18:37 +0330 Subject: [PATCH 03/12] Harden inquiry participant edge cases --- docs/inquiry-participants-proposal.fa.md | 3 + docs/inquiry-participants-proposal.md | 3 + .../dto/inquiry-participants.dto.ts | 16 +- src/profile/dto/user/AddPlateDto.ts | 2 +- .../dto/create-request-management.dto.ts | 2 +- .../dto/run-call-center-inquiry-v6.dto.ts | 2 +- .../dto/run-inquiries-v3.dto.ts | 2 +- .../entities/schema/partyRole.enum.ts | 1 + .../inquiry-participant-resolver.spec.ts | 189 +++++++++++++- .../inquiry-participant-resolver.ts | 129 ++++++++-- .../request-management.service.ts | 240 +++++++++++++----- 11 files changed, 505 insertions(+), 84 deletions(-) rename src/{request-management => common}/dto/inquiry-participants.dto.ts (89%) diff --git a/docs/inquiry-participants-proposal.fa.md b/docs/inquiry-participants-proposal.fa.md index 382b58c..7185953 100644 --- a/docs/inquiry-participants-proposal.fa.md +++ b/docs/inquiry-participants-proposal.fa.md @@ -34,6 +34,7 @@ "driver": { "nationalCode": "0012345678", "birthday": "1370/01/01", + "hasDrivingLicense": true, "licenseNumber": "123456789", "licenseType": "1" }, @@ -48,6 +49,8 @@ فیلد `carBodyPolicyholder` برای `THIRD_PARTY` مجاز نیست و برای `CAR_BODY` الزامی است. هر نقش باید فقط یکی از دو حالت «اطلاعات شخص» یا `sameAs` را داشته باشد. بک‌اند این ورودی را به فهرست اشخاص یکتا و اتصال نقش‌ها به آن‌ها تبدیل می‌کند. +فقط برای طرف زیان‌دیده در پرونده `THIRD_PARTY` می‌توان نامشخص بودن بیمه‌گذار را به‌صورت صریح با `"thirdPartyPolicyholder": { "unknown": true }` ارسال کرد. این حالت برای طرف مقصر پذیرفته نمی‌شود. در مسیر پلاک، استعلام بیمه شخص ثالث ــ چون به هویت بیمه‌گذار نیاز دارد ــ به‌شکل قابل‌ممیزی «عمداً اجرا نشد» ثبت می‌شود؛ استعلام مبتنی بر VIN همچنان بدون حدس‌زدن هویت قابل اجرا است. اعتبارسنجی راننده و مالک و استعلام‌های هویت، مالکیت و گواهینامه همچنان انجام می‌شوند. + برای راننده، `hasDrivingLicense` الزامی است. اگر مقدار آن `true` باشد، هر دو فیلد `licenseNumber` و `licenseType` نیز الزامی هستند؛ اگر مقدار آن `false` باشد، استعلام گواهینامه عمداً اجرا نمی‌شود. ## انتقال مالکیت اخیر و پلاک قبلی diff --git a/docs/inquiry-participants-proposal.md b/docs/inquiry-participants-proposal.md index 723a264..594e498 100644 --- a/docs/inquiry-participants-proposal.md +++ b/docs/inquiry-participants-proposal.md @@ -34,6 +34,7 @@ Use explicit role references instead: "driver": { "nationalCode": "0012345678", "birthday": "1370/01/01", + "hasDrivingLicense": true, "licenseNumber": "123456789", "licenseType": "1" }, @@ -48,6 +49,8 @@ Use explicit role references instead: `carBodyPolicyholder` is forbidden for `THIRD_PARTY` and required for `CAR_BODY`. A role is either a new person's identity or a `sameAs` reference, never both. The backend should normalize this input into unique participants plus role assignments. +For the damaged party of a `THIRD_PARTY` case only, the policyholder can be explicitly unresolved with `"thirdPartyPolicyholder": { "unknown": true }`. This is not accepted for the guilty party. 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 diff --git a/src/request-management/dto/inquiry-participants.dto.ts b/src/common/dto/inquiry-participants.dto.ts similarity index 89% rename from src/request-management/dto/inquiry-participants.dto.ts rename to src/common/dto/inquiry-participants.dto.ts index ae80299..d9969b3 100644 --- a/src/request-management/dto/inquiry-participants.dto.ts +++ b/src/common/dto/inquiry-participants.dto.ts @@ -50,6 +50,14 @@ export class InquiryParticipantInputDto { @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() @@ -88,9 +96,13 @@ export class InquiryParticipantInputDto { } export class InquiryVehicleInputDto { - @ApiProperty({ enum: VehicleRegistrationState }) + @ApiPropertyOptional({ + enum: VehicleRegistrationState, + default: VehicleRegistrationState.CURRENT, + }) + @IsOptional() @IsEnum(VehicleRegistrationState) - registrationState: VehicleRegistrationState; + registrationState?: VehicleRegistrationState; @ApiProperty({ type: InquiryPlateDto }) @ValidateNested() diff --git a/src/profile/dto/user/AddPlateDto.ts b/src/profile/dto/user/AddPlateDto.ts index 1fc2ac6..3e9f8bd 100644 --- a/src/profile/dto/user/AddPlateDto.ts +++ b/src/profile/dto/user/AddPlateDto.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { PlatesDto } from "src/plates/dto/plate.dto"; import { Plates } from "src/Types&Enums/plate.interface"; -import { InquiryParticipantFieldsDto } from "src/request-management/dto/inquiry-participants.dto"; +import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto"; export class AddPlateDto extends InquiryParticipantFieldsDto { plateId: string; diff --git a/src/request-management/dto/create-request-management.dto.ts b/src/request-management/dto/create-request-management.dto.ts index 6de2d45..6d80bef 100644 --- a/src/request-management/dto/create-request-management.dto.ts +++ b/src/request-management/dto/create-request-management.dto.ts @@ -16,7 +16,7 @@ import { } from "src/Types&Enums/blame-request-management/accident-conditions.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { IsEnum } from "class-validator"; -import { InquiryParticipantFieldsDto } from "./inquiry-participants.dto"; +import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto"; export class InitialFormDto { @ApiProperty({ required: false, default: false }) diff --git a/src/request-management/dto/run-call-center-inquiry-v6.dto.ts b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts index 512f0a1..0b331f3 100644 --- a/src/request-management/dto/run-call-center-inquiry-v6.dto.ts +++ b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts @@ -9,7 +9,7 @@ import { ValidateNested, } from "class-validator"; import { Type } from "class-transformer"; -import { InquiryParticipantFieldsDto } from "./inquiry-participants.dto"; +import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto"; class PlateV6Dto { @ApiProperty({ example: "44", description: "Left two digits" }) diff --git a/src/request-management/dto/run-inquiries-v3.dto.ts b/src/request-management/dto/run-inquiries-v3.dto.ts index c658b57..71e0922 100644 --- a/src/request-management/dto/run-inquiries-v3.dto.ts +++ b/src/request-management/dto/run-inquiries-v3.dto.ts @@ -9,7 +9,7 @@ import { ValidateNested, } from "class-validator"; import { Type } from "class-transformer"; -import { InquiryParticipantFieldsDto } from "./inquiry-participants.dto"; +import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto"; class PlateV3Dto { @ApiProperty({ example: "44", description: "Left two digits" }) diff --git a/src/request-management/entities/schema/partyRole.enum.ts b/src/request-management/entities/schema/partyRole.enum.ts index 6a1ea5c..2915653 100644 --- a/src/request-management/entities/schema/partyRole.enum.ts +++ b/src/request-management/entities/schema/partyRole.enum.ts @@ -66,6 +66,7 @@ export class InquiryParticipant { @Prop({ type: Boolean }) hasDrivingLicense?: boolean; @Prop() licenseNumber?: string; @Prop() licenseType?: string; + @Prop({ type: Boolean }) unknown?: boolean; } export const InquiryParticipantSchema = SchemaFactory.createForClass(InquiryParticipant); diff --git a/src/request-management/inquiry-participant-resolver.spec.ts b/src/request-management/inquiry-participant-resolver.spec.ts index 7e08e2b..062090d 100644 --- a/src/request-management/inquiry-participant-resolver.spec.ts +++ b/src/request-management/inquiry-participant-resolver.spec.ts @@ -3,9 +3,10 @@ import { BlameRequestType } from "src/Types&Enums/blame-request-management/blame import { InquiryParticipantRole, VehicleRegistrationState, -} from "./dto/inquiry-participants.dto"; +} from "src/common/dto/inquiry-participants.dto"; import { assertPreviousPlateInquiryMatchesVin, + isMappedPolicyCurrent, normalizeInquirySubmission, participantForRole, resolveInquiryParticipants, @@ -52,6 +53,19 @@ describe("inquiry participant resolver", () => { ).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("keeps legacy driver/policyholder payloads working during rollout", () => { const resolved = resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, { nationalCodeOfDriver: "0012345678", @@ -261,4 +275,177 @@ describe("inquiry participant resolver", () => { }), ).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; + }>({ + 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("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 }); + }); }); diff --git a/src/request-management/inquiry-participant-resolver.ts b/src/request-management/inquiry-participant-resolver.ts index f1f022e..0fd7303 100644 --- a/src/request-management/inquiry-participant-resolver.ts +++ b/src/request-management/inquiry-participant-resolver.ts @@ -1,12 +1,14 @@ 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 "./dto/inquiry-participants.dto"; +} from "src/common/dto/inquiry-participants.dto"; export interface ResolvedInquiryParticipant { participantId: string; @@ -17,6 +19,11 @@ export interface ResolvedInquiryParticipant { hasDrivingLicense?: boolean; licenseNumber?: string; licenseType?: string; + unknown?: boolean; +} + +export interface InquiryParticipantResolutionOptions { + allowUnknownThirdPartyPolicyholder?: boolean; } export interface InquiryParticipantRoleAssignments { @@ -49,9 +56,13 @@ export interface NormalizedInquirySubmission> { vehicleOwner?: ResolvedInquiryParticipant; thirdPartyPolicyholder: ResolvedInquiryParticipant; carBodyPolicyholder?: ResolvedInquiryParticipant; - vehicle?: InquiryVehicleInputDto; + vehicle?: ResolvedInquiryVehicle; } +export type ResolvedInquiryVehicle = InquiryVehicleInputDto & { + registrationState: VehicleRegistrationState; +}; + export function participantForRole( resolved: ResolvedInquiryParticipants, role: InquiryParticipantRole, @@ -119,6 +130,7 @@ function requiredIdentity( export function resolveInquiryParticipants( caseType: BlameRequestType, input: InquiryParticipantFieldsDto & Record, + options: InquiryParticipantResolutionOptions = {}, ): ResolvedInquiryParticipants { const hasRoleCompleteInput = Object.values(ROLE_FIELDS).some( (field) => input[field] != null, @@ -200,8 +212,40 @@ export function resolveInquiryParticipants( resolving.add(role); let participantId: string; - if (value.sameAs) { - if (value.nationalCode != null || value.birthday != null) { + 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.`, ); @@ -249,7 +293,7 @@ export function resolveInquiryParticipants( export function resolveInquiryVehicle( input: InquiryVehicleInputDto, -): InquiryVehicleInputDto { +): ResolvedInquiryVehicle { const registrationState = input.registrationState ?? VehicleRegistrationState.CURRENT; if (!input.currentPlate) { @@ -274,7 +318,7 @@ export function resolveInquiryVehicle( return { ...input, registrationState }; } -export function vehiclePlateCandidates(input?: InquiryVehicleInputDto): Array<{ +export function vehiclePlateCandidates(input?: ResolvedInquiryVehicle): Array<{ kind: "CURRENT" | "PREVIOUS"; plate: InquiryVehicleInputDto["currentPlate"]; }> { @@ -294,6 +338,35 @@ function normalizeVehicleSerial(value: unknown): string { .replace(/[^A-Z0-9]/g, ""); } +/** A dated result is usable only while the returned policy has not expired. */ +export function isMappedPolicyCurrent( + mapped: Record, + 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("|"); +} + export function assertPreviousPlateInquiryMatchesVin( expectedVin: string, mapped: Record, @@ -319,7 +392,7 @@ export function assertPreviousPlateInquiryMatchesVin( } export async function runPlateInquiryWithFallback(options: { - vehicle?: InquiryVehicleInputDto; + vehicle?: ResolvedInquiryVehicle; fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"]; query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise; isUsable: (value: T) => boolean; @@ -358,7 +431,11 @@ export async function runPlateInquiryWithFallback(options: { usable: false, }); if (!isLast) continue; - return { value, plateKind: candidate.kind, attempts }; + 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( @@ -374,12 +451,23 @@ export async function runPlateInquiryWithFallback(options: { return { value, plateKind: candidate.kind, attempts }; } catch (error) { lastError = error; - attempts.push({ - plateKind: candidate.kind, - succeeded: false, - error: error instanceof Error ? error.message : String(error), - }); - if (isLast) throw error; + const alreadyRecorded = + typeof error === "object" && + error != null && + Array.isArray((error as { attempts?: unknown }).attempts); + if (!alreadyRecorded) { + attempts.push({ + plateKind: candidate.kind, + succeeded: false, + error: error instanceof Error ? error.message : String(error), + }); + } + if (isLast) { + if (typeof error === "object" && error != null) { + (error as { attempts?: typeof attempts }).attempts = attempts; + } + throw error; + } } } @@ -389,8 +477,9 @@ export async function runPlateInquiryWithFallback(options: { export function normalizeInquirySubmission>( caseType: BlameRequestType, input: T, + options: InquiryParticipantResolutionOptions = {}, ): NormalizedInquirySubmission { - const participants = resolveInquiryParticipants(caseType, input); + const participants = resolveInquiryParticipants(caseType, input, options); const driver = participantForRole( participants, InquiryParticipantRole.DRIVER, @@ -415,6 +504,16 @@ export function normalizeInquirySubmission>( const vehicle = input.vehicle ? resolveInquiryVehicle(input.vehicle as InquiryVehicleInputDto) : undefined; + if ( + vehicle && + input.plate && + normalizePlateForComparison(vehicle.currentPlate) !== + normalizePlateForComparison(input.plate) + ) { + throw new BadRequestException( + "plate and vehicle.currentPlate must identify the same current plate.", + ); + } const sameDriverAndPolicyholder = participants.roles.driver === participants.roles.thirdPartyPolicyholder; diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index b7f1aee..3294b1d 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -124,6 +124,7 @@ import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver"; import { normalizePlateText } from "src/utils/plate-normalizer/plate-normalizer.service"; import { fanavaranClaimReferences } from "src/claim-request-management/fanavaran-claim-references"; import { + isMappedPolicyCurrent, NormalizedInquirySubmission, normalizeInquirySubmission, runPlateInquiryWithFallback, @@ -308,8 +309,12 @@ export class RequestManagementService { private normalizeInquiryInput>( type: BlameRequestType, input: T, + partyRole?: PartyRole, ): NormalizedInquirySubmission { - return normalizeInquirySubmission(type, input); + return normalizeInquirySubmission(type, input, { + allowUnknownThirdPartyPolicyholder: + type === BlameRequestType.THIRD_PARTY && partyRole === PartyRole.SECOND, + }); } private applyInquiryParticipantsToParty( @@ -335,6 +340,15 @@ export class RequestManagementService { submission: NormalizedInquirySubmission>, options?: Record, ): Promise { + if (submission.thirdPartyPolicyholder.unknown) { + return { + raw: null, + mapped: {}, + skipped: true, + plateKind: "CURRENT", + attempts: [], + }; + } const result = await runPlateInquiryWithFallback({ vehicle: submission.vehicle, fallbackCurrentPlate: submission.dto.plate, @@ -348,7 +362,9 @@ export class RequestManagementService { options, ), isUsable: (value) => - !value?.mapped?.Error && !!value?.mapped?.CompanyName, + !value?.mapped?.Error && + !!value?.mapped?.CompanyName && + isMappedPolicyCurrent(value.mapped), mappedValue: (value) => value?.mapped ?? {}, }); return { @@ -373,6 +389,7 @@ export class RequestManagementService { }), isUsable: (value) => !!value?.mapped && + isMappedPolicyCurrent(value.mapped) && !!( value.mapped.policyNumber || value.mapped.CompanyName || @@ -395,7 +412,9 @@ export class RequestManagementService { ): Promise { const participants = submission.participants.legacy ? [submission.thirdPartyPolicyholder] - : submission.participants.participants; + : submission.participants.participants.filter( + (participant) => !participant.unknown, + ); const results: Record = {}; for (const participant of participants) { @@ -535,6 +554,7 @@ export class RequestManagementService { message: err?.message || String(err), status: err?.response?.status, data: err?.response?.data, + ...(Array.isArray(err?.attempts) ? { attempts: err.attempts } : {}), }; } @@ -1575,9 +1595,15 @@ export class RequestManagementService { : undefined; } - const inquirySubmission = this.normalizeInquiryInput(req.type, body); + const inquirySubmission = this.normalizeInquiryInput( + req.type, + body, + role, + ); body = inquirySubmission.dto as unknown as AddPlateDto; this.applyInquiryParticipantsToParty(party, inquirySubmission); + const policyholderUnknown = + inquirySubmission.thirdPartyPolicyholder.unknown === true; // Validation: driver/insurer sameness rules if (body.driverIsInsurer === false) { @@ -1637,13 +1663,25 @@ export class RequestManagementService { this.logger.log( `[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`, ); - this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, { - source: inquirySource, - plateKind: inquiry.plateKind, - attempts: inquiry.attempts, - raw: inquiryRaw, - mapped: inquiryMapped, - }); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + !inquiry.skipped, + { + source: inquirySource, + plateKind: inquiry.plateKind, + attempts: inquiry.attempts, + raw: inquiryRaw, + mapped: inquiryMapped, + ...(inquiry.skipped + ? { + skipped: true, + reason: "Third-party policyholder is unknown", + } + : {}), + }, + ); if (inquiry.offline?.fanavaranDriverId != null) { if (!party.person) party.person = {} as any; party.person.fanavaranDriverId = inquiry.offline.fanavaranDriverId; @@ -1714,17 +1752,19 @@ export class RequestManagementService { // Find client by company code const clientName = inquiryMapped?.CompanyName; - if (!clientName) { + if (!clientName && !policyholderUnknown) { throw new BadRequestException( `CompanyName missing from inquiry response`, ); } const companyCode = inquiryMapped?.CompanyCode; - const client = await this.clientService.findOrCreateClientByCompanyCode( - companyCode, - clientName, - ); - if (!client) { + const client = clientName + ? await this.clientService.findOrCreateClientByCompanyCode( + companyCode, + clientName, + ) + : null; + if (clientName && !client) { throw new BadRequestException( `CompanyCode missing or invalid in inquiry response`, ); @@ -1734,7 +1774,7 @@ export class RequestManagementService { if (!party.person) party.person = {} as any; const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; - party.person.clientId = resolvedClientId; + if (resolvedClientId) party.person.clientId = resolvedClientId; party.person.nationalCodeOfInsurer = body.nationalCodeOfInsurer; party.person.nationalCodeOfDriver = body.nationalCodeOfDriver; party.person.insurerLicense = body.insurerLicense; @@ -1980,7 +2020,11 @@ export class RequestManagementService { : undefined; } - const inquirySubmission = this.normalizeInquiryInput(req.type, body); + const inquirySubmission = this.normalizeInquiryInput( + req.type, + body, + role, + ); body = inquirySubmission.dto as unknown as InitialFormVinDto; this.applyInquiryParticipantsToParty(party, inquirySubmission); @@ -2123,7 +2167,7 @@ export class RequestManagementService { // Persist party data const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; - party.person.clientId = resolvedClientId; + if (resolvedClientId) party.person.clientId = resolvedClientId; party.person.nationalCodeOfInsurer = body.nationalCodeOfInsurer; party.person.nationalCodeOfDriver = body.nationalCodeOfDriver; party.person.insurerLicense = body.insurerLicense; @@ -6578,6 +6622,7 @@ export class RequestManagementService { const inquirySubmission = this.normalizeInquiryInput( req.type, formData.firstPartyPlate, + PartyRole.FIRST, ); const firstPartyPlate = inquirySubmission.dto; let sandHubReport: any; @@ -6586,8 +6631,7 @@ export class RequestManagementService { ? await (async () => { const response = await this.sandHubService.getSandHubResponse({ plate: firstPartyPlate.plate, - nationalCodeOfInsurer: - firstPartyPlate.nationalCodeOfInsurer, + nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, }); const mapped = response["_doc"] || response; return { @@ -6857,8 +6901,14 @@ export class RequestManagementService { desc: string, role: PartyRole, ) => { - const inquirySubmission = this.normalizeInquiryInput(req.type, plateDto); + const inquirySubmission = this.normalizeInquiryInput( + req.type, + plateDto, + role, + ); plateDto = inquirySubmission.dto; + const policyholderUnknown = + inquirySubmission.thirdPartyPolicyholder.unknown === true; let inquiry: any; try { if (inquirySubmission.participants.legacy) { @@ -6871,9 +6921,7 @@ export class RequestManagementService { raw: mapped, mapped, plateKind: "CURRENT", - attempts: [ - { plateKind: "CURRENT", succeeded: true, usable: true }, - ], + attempts: [{ plateKind: "CURRENT", succeeded: true, usable: true }], }; } else { inquiry = await this.getThirdPartyPlateInquiry(inquirySubmission); @@ -6897,23 +6945,37 @@ export class RequestManagementService { "THIRD_PARTY", ); } - this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, { + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + !inquiry.skipped, + { source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", plateKind: inquiry.plateKind, attempts: inquiry.attempts, - raw: inquiry.raw, - mapped: inquiry.mapped, - }); + raw: inquiry.raw, + mapped: inquiry.mapped, + ...(inquiry.skipped + ? { + skipped: true, + reason: "Third-party policyholder is unknown", + } + : {}), + }, + ); const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode; - const client = companyCode - ? await this.clientService.findOrCreateClientByCompanyCode( - companyCode, - clientName, - ) - : await this.clientService.findOne({ clientName }); - if (!client) { + const client = clientName + ? companyCode + ? await this.clientService.findOrCreateClientByCompanyCode( + companyCode, + clientName, + ) + : await this.clientService.findOne({ clientName }) + : null; + if (!client && !policyholderUnknown) { throw new NotFoundException( `Client not found for company: ${clientName}`, ); @@ -9112,9 +9174,15 @@ export class RequestManagementService { partyRole: PartyRole, party: any, ): Promise<{ clientId?: string }> { - const inquirySubmission = this.normalizeInquiryInput(req.type, partyData); + const inquirySubmission = this.normalizeInquiryInput( + req.type, + partyData, + partyRole, + ); partyData = inquirySubmission.dto as RunInquiriesV3Dto; this.applyInquiryParticipantsToParty(party, inquirySubmission); + const policyholderUnknown = + inquirySubmission.thirdPartyPolicyholder.unknown === true; const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; let clientId: string | undefined; const inquiryOptions = (cid?: string) => @@ -9137,13 +9205,25 @@ export class RequestManagementService { if (inquiry.offline?.source) { inquirySource = inquiry.offline.source; } - this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, true, { - source: inquirySource, - plateKind: inquiry.plateKind, - attempts: inquiry.attempts, - raw: inquiryRaw, - mapped: inquiryMapped, - }); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + partyRole, + !inquiry.skipped, + { + source: inquirySource, + plateKind: inquiry.plateKind, + attempts: inquiry.attempts, + raw: inquiryRaw, + mapped: inquiryMapped, + ...(inquiry.skipped + ? { + skipped: true, + reason: "Third-party policyholder is unknown", + } + : {}), + }, + ); if (inquiry.offline?.fanavaranDriverId != null) { if (!party.person) party.person = {} as any; party.person.fanavaranDriverId = inquiry.offline.fanavaranDriverId; @@ -9174,16 +9254,18 @@ export class RequestManagementService { const clientName = inquiryMapped?.CompanyName; const companyCode = inquiryMapped?.CompanyCode; - if (!clientName) { + if (!clientName && !policyholderUnknown) { throw new BadRequestException( `CompanyName missing from ${roleLabel} party inquiry response`, ); } - const client = await this.clientService.findOrCreateClientByCompanyCode( - companyCode, - clientName, - ); - if (!client) { + const client = clientName + ? await this.clientService.findOrCreateClientByCompanyCode( + companyCode, + clientName, + ) + : null; + if (clientName && !client) { throw new BadRequestException( `CompanyCode missing or invalid in ${roleLabel} party inquiry response`, ); @@ -9192,7 +9274,7 @@ export class RequestManagementService { clientId = resolvedClientId ? String(resolvedClientId) : undefined; if (!party.person) party.person = {} as any; - party.person.clientId = resolvedClientId; + if (resolvedClientId) party.person.clientId = resolvedClientId; party.person.nationalCodeOfInsurer = partyData.nationalCodeOfInsurer; party.person.nationalCodeOfDriver = partyData.nationalCodeOfDriver; party.person.driverIsInsurer = partyData.driverIsInsurer; @@ -9337,7 +9419,22 @@ export class RequestManagementService { ? partyData.insurerLicense : partyData.driverLicense; - if (!licenseNumber) { + if ( + !inquirySubmission.participants.legacy && + inquirySubmission.driver.hasDrivingLicense === false + ) { + this.recordPartyCaseInquiryStatus( + req, + "drivingLicence", + partyRole, + true, + { + participantId: inquirySubmission.driver.participantId, + skipped: true, + reason: "Driver declared no driving licence", + }, + ); + } else if (!licenseNumber) { this.recordPartyCaseInquiryStatus( req, "drivingLicence", @@ -9683,12 +9780,12 @@ export class RequestManagementService { if (!req) throw new NotFoundException("Blame request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, actor); - dto = this.normalizeInquiryInput(req.type, dto).dto as RunInquiriesV3Dto; - const partyRole = this.resolveV3InquiryPartyRole(req); if (!partyRole) { throw new ConflictException("All party inquiries are already complete."); } + dto = this.normalizeInquiryInput(req.type, dto, partyRole) + .dto as RunInquiriesV3Dto; if (partyRole === PartyRole.FIRST) { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); @@ -9820,12 +9917,12 @@ export class RequestManagementService { if (!req) throw new NotFoundException("Blame request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, actor); - dto = this.normalizeInquiryInput(req.type, dto).dto as RunInquiriesVinV3Dto; - const partyRole = this.resolveV3InquiryPartyRole(req); if (!partyRole) { throw new ConflictException("All party inquiries are already complete."); } + dto = this.normalizeInquiryInput(req.type, dto, partyRole) + .dto as RunInquiriesVinV3Dto; if (partyRole === PartyRole.FIRST) { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); @@ -9952,7 +10049,11 @@ export class RequestManagementService { partyRole: PartyRole, party: any, ): Promise<{ clientId?: string }> { - const inquirySubmission = this.normalizeInquiryInput(req.type, partyData); + const inquirySubmission = this.normalizeInquiryInput( + req.type, + partyData, + partyRole, + ); partyData = inquirySubmission.dto as RunInquiriesVinV3Dto; this.applyInquiryParticipantsToParty(party, inquirySubmission); const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; @@ -10018,7 +10119,7 @@ export class RequestManagementService { clientId = resolvedClientId ? String(resolvedClientId) : undefined; if (!party.person) party.person = {} as any; - party.person.clientId = resolvedClientId; + if (resolvedClientId) party.person.clientId = resolvedClientId; party.person.nationalCodeOfInsurer = partyData.nationalCodeOfInsurer; party.person.nationalCodeOfDriver = partyData.nationalCodeOfDriver; party.person.driverIsInsurer = partyData.driverIsInsurer; @@ -10174,7 +10275,22 @@ export class RequestManagementService { ? partyData.insurerLicense : partyData.driverLicense; - if (!licenseNumber) { + if ( + !inquirySubmission.participants.legacy && + inquirySubmission.driver.hasDrivingLicense === false + ) { + this.recordPartyCaseInquiryStatus( + req, + "drivingLicence", + partyRole, + true, + { + participantId: inquirySubmission.driver.participantId, + skipped: true, + reason: "Driver declared no driving licence", + }, + ); + } else if (!licenseNumber) { this.recordPartyCaseInquiryStatus( req, "drivingLicence", @@ -11403,7 +11519,7 @@ export class RequestManagementService { if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; - dto = this.normalizeInquiryInput(req.type, dto).dto; + dto = this.normalizeInquiryInput(req.type, dto, PartyRole.FIRST).dto; await this.runPartyInquiriesV3Internal( req, @@ -11526,7 +11642,7 @@ export class RequestManagementService { if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; - dto = this.normalizeInquiryInput(req.type, dto).dto; + dto = this.normalizeInquiryInput(req.type, dto, PartyRole.FIRST).dto; await this.runPartyInquiriesVinV3Internal( req, From b5b18114d8dd4fd76abc0dab4d875f15c131c595 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 13 Sep 2026 11:29:43 +0330 Subject: [PATCH 04/12] Complete inquiry participant flow coverage --- .../case-expert-report.builder.spec.ts | 162 +++++--- .../case-expert-report.builder.ts | 207 ++++++++-- .../persian-report-labels.ts | 7 + src/common/dto/inquiry-participants.dto.ts | 9 +- .../schema/request-management.schema.ts | 32 ++ .../inquiry-participant-resolver.spec.ts | 35 ++ .../inquiry-participant-resolver.ts | 28 ++ .../request-management.service.ts | 385 +++++++++++++++--- 8 files changed, 698 insertions(+), 167 deletions(-) diff --git a/src/case-expert-report/case-expert-report.builder.spec.ts b/src/case-expert-report/case-expert-report.builder.spec.ts index e81d259..5fc36d4 100644 --- a/src/case-expert-report/case-expert-report.builder.spec.ts +++ b/src/case-expert-report/case-expert-report.builder.spec.ts @@ -70,6 +70,26 @@ describe("buildInsurerFileReport", () => { }, { role: "SECOND", + participants: [ + { + participantId: "DRIVER", + fullName: "راننده نمونه", + nationalCode: "0012345678", + birthday: "1370/01/01", + licenseNumber: "LIC-1", + }, + { + participantId: "VEHICLE_OWNER", + fullName: "زیان دیده نمونه", + nationalCode: "1234567890", + birthday: "1370/01/01", + }, + ], + participantRoles: { + driver: "DRIVER", + vehicleOwner: "VEHICLE_OWNER", + thirdPartyPolicyholder: "VEHICLE_OWNER", + }, person: { userId: "damaged-user-id", fullName: "زیان دیده نمونه", @@ -165,61 +185,89 @@ describe("buildInsurerFileReport", () => { }, }); - expect(getFieldValue(report, PR.ownerSection, PR.name)).toBe("زیان دیده نمونه"); - expect(getFieldValue(report, PR.guiltyOwnerSection, PR.name)).toBe("مقصر نمونه"); - expect(getFieldValue(report, PR.guiltyOwnerSection, PR.phone)).toBe("09120000000"); + expect(getFieldValue(report, PR.ownerSection, PR.name)).toBe( + "زیان دیده نمونه", + ); + expect( + getFieldValue(report, PR.damagedParticipantsSection, PR.driverRole), + ).toContain("راننده نمونه"); + expect( + getFieldValue( + report, + PR.damagedParticipantsSection, + PR.thirdPartyPolicyholderRole, + ), + ).toContain("1234567890"); + expect(getFieldValue(report, PR.guiltyOwnerSection, PR.name)).toBe( + "مقصر نمونه", + ); + expect(getFieldValue(report, PR.guiltyOwnerSection, PR.phone)).toBe( + "09120000000", + ); expect(getFieldValue(report, PR.guiltyOwnerSection, PR.nationalCode)).toBe( "0987654321", ); - expect(getFieldValue(report, PR.damagedThirdPartyInsuranceSection, PR.policyNumber)).toBe( - "TP-DAMAGED-001", - ); - expect(getFieldValue(report, PR.guiltyThirdPartyInsuranceSection, PR.policyNumber)).toBe( - "TP-GUILTY-001", - ); - expect(getFieldValue(report, PR.damagedCarBodyInsuranceSection, PR.policyNumber)).toBe( - "CB-DAMAGED-001", - ); - expect(getFieldValue(report, PR.guiltyCarBodyInsuranceSection, PR.policyNumber)).toBe( - "CB-GUILTY-001", - ); - - expect(getFieldValue(report, PR.damagedVehicleSection, "خودرو / نام خودرو")).toBe( - "207", - ); - expect(getFieldValue(report, PR.guiltyVehicleSection, "خودرو / نام خودرو")).toBe( - "206", - ); - - expect(getFieldValue(report, PR.damagedStatementSection, PR.partyDescription)).toBe( - "توضیحات زیان‌دیده", - ); - expect(getFieldValue(report, PR.damagedStatementSection, PR.claimsDamage)).toBe( - "بله", - ); expect( - getFieldValue(report, PR.damagedStatementSection, PR.acceptsExpertOpinion), + getFieldValue( + report, + PR.damagedThirdPartyInsuranceSection, + PR.policyNumber, + ), + ).toBe("TP-DAMAGED-001"); + expect( + getFieldValue( + report, + PR.guiltyThirdPartyInsuranceSection, + PR.policyNumber, + ), + ).toBe("TP-GUILTY-001"); + expect( + getFieldValue(report, PR.damagedCarBodyInsuranceSection, PR.policyNumber), + ).toBe("CB-DAMAGED-001"); + expect( + getFieldValue(report, PR.guiltyCarBodyInsuranceSection, PR.policyNumber), + ).toBe("CB-GUILTY-001"); + + expect( + getFieldValue(report, PR.damagedVehicleSection, "خودرو / نام خودرو"), + ).toBe("207"); + expect( + getFieldValue(report, PR.guiltyVehicleSection, "خودرو / نام خودرو"), + ).toBe("206"); + + expect( + getFieldValue(report, PR.damagedStatementSection, PR.partyDescription), + ).toBe("توضیحات زیان‌دیده"); + expect( + getFieldValue(report, PR.damagedStatementSection, PR.claimsDamage), + ).toBe("بله"); + expect( + getFieldValue( + report, + PR.damagedStatementSection, + PR.acceptsExpertOpinion, + ), ).toBe("خیر"); - expect(getFieldValue(report, PR.guiltyStatementSection, PR.partyDescription)).toBe( - "توضیحات مقصر", - ); - expect(getFieldValue(report, PR.guiltyStatementSection, PR.admitsGuilt)).toBe( - "بله", - ); + expect( + getFieldValue(report, PR.guiltyStatementSection, PR.partyDescription), + ).toBe("توضیحات مقصر"); + expect( + getFieldValue(report, PR.guiltyStatementSection, PR.admitsGuilt), + ).toBe("بله"); expect( getFieldValue(report, PR.guiltyStatementSection, PR.acceptsExpertOpinion), ).toBe("بله"); - expect(getFieldValue(report, PR.fanavaranSection, PR.fanavaranClaimNo)).toBe( - "111", - ); + expect( + getFieldValue(report, PR.fanavaranSection, PR.fanavaranClaimNo), + ).toBe("111"); expect( getFieldValue(report, PR.fanavaranSection, PR.fanavaranExpertiseId), ).toBe("444"); - expect(getFieldValue(report, PR.fanavaranSection, PR.fanavaranPolicyId)).toBe( - "555", - ); + expect( + getFieldValue(report, PR.fanavaranSection, PR.fanavaranPolicyId), + ).toBe("555"); expect(getFieldValue(report, PR.timelineSection, PR.fileRegisteredAt)).toBe( "1405/05/19 13:23", @@ -228,15 +276,15 @@ describe("buildInsurerFileReport", () => { getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt), ).toBe("1405/05/19 16:00"); - expect(getFieldValue(report, PR.evaluationSection, PR.evaluationResult)).toBe( - "تأیید شده", - ); - expect(getFieldValue(report, PR.evaluationSection, PR.evaluationExpert)).toBe( - "کارشناس خسارت نمونه", - ); - expect(getFieldValue(report, PR.evaluationSection, PR.evaluationResponse)).toBe( - "نیاز به تعویض سپر جلو", - ); + expect( + getFieldValue(report, PR.evaluationSection, PR.evaluationResult), + ).toBe("تأیید شده"); + expect( + getFieldValue(report, PR.evaluationSection, PR.evaluationExpert), + ).toBe("کارشناس خسارت نمونه"); + expect( + getFieldValue(report, PR.evaluationSection, PR.evaluationResponse), + ).toBe("نیاز به تعویض سپر جلو"); expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / نام قطعه")).toBe( "سپر جلو", ); @@ -345,9 +393,9 @@ describe("buildInsurerFileReport", () => { }); const fields = report.sections.flatMap((section) => section.fields); - expect(fields.find((field) => field.label === "خودرو / خودرو نو")?.value).toBe( - "بله", - ); + expect( + fields.find((field) => field.label === "خودرو / خودرو نو")?.value, + ).toBe("بله"); expect(fields.some((field) => field.value === "خیر")).toBe(true); expect(fields.some((field) => field.value === "true")).toBe(false); expect(fields.some((field) => field.value === "false")).toBe(false); @@ -372,10 +420,12 @@ describe("buildInsurerFileReport", () => { }); const fields = report.sections.flatMap((section) => section.fields); - expect(fields.some((field) => String(field.value).includes("ESG"))).toBe(false); - expect(fields.some((field) => String(field.value).includes("TEJARAT"))).toBe( + expect(fields.some((field) => String(field.value).includes("ESG"))).toBe( false, ); + expect( + fields.some((field) => String(field.value).includes("TEJARAT")), + ).toBe(false); expect(fields.some((field) => field.label.includes("منبع"))).toBe(false); }); diff --git a/src/case-expert-report/case-expert-report.builder.ts b/src/case-expert-report/case-expert-report.builder.ts index e65df0a..7807991 100644 --- a/src/case-expert-report/case-expert-report.builder.ts +++ b/src/case-expert-report/case-expert-report.builder.ts @@ -36,6 +36,8 @@ type ReportParty = ReportRecord & { vehicle?: ReportRecord; statement?: ReportRecord; location?: { lat?: number; lon?: number }; + participants?: ReportRecord[]; + participantRoles?: ReportRecord; }; function asString(value: unknown): string | undefined { @@ -66,7 +68,8 @@ function formatBirthDate(value: unknown): string | undefined { function formatDateTime(value: unknown): string | undefined { if (value === undefined || value === null || value === "") return undefined; - const date = value instanceof Date ? value : new Date(value as string | number); + const date = + value instanceof Date ? value : new Date(value as string | number); if (Number.isNaN(date.getTime())) return asString(value); const [d, t] = toJalaliDateAndTime(date); return `${d} ${t}`; @@ -126,13 +129,13 @@ function flattenObject( if (Array.isArray(obj)) { const value = normalizeListValue(obj); - return value - ? [{ label: persianFieldPath(prefix || "items"), value }] - : []; + return value ? [{ label: persianFieldPath(prefix || "items"), value }] : []; } if (typeof obj !== "object") { - return [{ label: persianFieldPath(prefix || "value"), value: asString(obj) }]; + return [ + { label: persianFieldPath(prefix || "value"), value: asString(obj) }, + ]; } const rows: InsurerFileReportField[] = []; @@ -160,7 +163,9 @@ function flattenObject( return rows; } -function dedupeFields(fields: InsurerFileReportField[]): InsurerFileReportField[] { +function dedupeFields( + fields: InsurerFileReportField[], +): InsurerFileReportField[] { const seen = new Set(); const out: InsurerFileReportField[] = []; @@ -180,7 +185,8 @@ function filterEmptySections( sections: Array, ): InsurerFileReportSection[] { 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; }): string | undefined { if (!snapshot) return undefined; - const name = [snapshot.firstName, snapshot.lastName].filter(Boolean).join(" "); + const name = [snapshot.firstName, snapshot.lastName] + .filter(Boolean) + .join(" "); return name || undefined; } @@ -214,9 +222,8 @@ function collectExpertNames( ): string | undefined { const names = new Set(); - const blameDecision = ( - blame?.expert as Record | undefined - )?.decision as Record | undefined; + const blameDecision = (blame?.expert as Record | undefined) + ?.decision as Record | undefined; const blameExpert = expertNameFromSnapshot( blameDecision?.expertProfileSnapshot as | { firstName?: string; lastName?: string } @@ -280,13 +287,16 @@ function resolveReportBlameContext( (claim?.blameFileContext as Record | undefined) ?.blameRequestType ?? (snapshot.accident as Record | undefined)?.type, - blameStatus: (claim?.blameFileContext as Record | undefined) - ?.blameStatus, + blameStatus: ( + claim?.blameFileContext as Record | undefined + )?.blameStatus, parties: snapshot.parties, }; } -function getPartyRole(party: ReportParty | null | undefined): string | undefined { +function getPartyRole( + party: ReportParty | null | undefined, +): string | undefined { const role = party?.role; return typeof role === "string" ? role : undefined; } @@ -318,7 +328,8 @@ function sameParty( second: ReportParty | null | undefined, ): boolean { if (!first || !second) return false; - const firstUserId = first.person?.userId != null ? String(first.person.userId) : ""; + const firstUserId = + first.person?.userId != null ? String(first.person.userId) : ""; const secondUserId = second.person?.userId != null ? String(second.person.userId) : ""; if (firstUserId && secondUserId) return firstUserId === secondUserId; @@ -330,7 +341,9 @@ function resolveEvaluationReply( ): Record | undefined { const evaluation = claim?.evaluation as Record | undefined; return ( - (evaluation?.damageExpertReplyFinal as Record | undefined) ?? + (evaluation?.damageExpertReplyFinal as + | Record + | undefined) ?? (evaluation?.damageExpertReply as Record | undefined) ); } @@ -375,10 +388,18 @@ function buildThirdPartyInsuranceFields( const role = getPartyRole(party); const direct = (party?.insurance ?? {}) as Record; const blameInquiry = pickInquiryReportPayload( - inquiryRoleData(blame?.inquiries as Record | undefined, "thirdParty", role), + inquiryRoleData( + blame?.inquiries as Record | undefined, + "thirdParty", + role, + ), ); const claimInquiry = pickInquiryReportPayload( - inquiryRoleData(claim?.inquiries as Record | undefined, "thirdParty", role), + inquiryRoleData( + claim?.inquiries as Record | undefined, + "thirdParty", + role, + ), ); return [ @@ -462,12 +483,23 @@ function buildCarBodyInsuranceFields( party?.insurance?.carBody ?? {}) as Record; const blameInquiry = pickInquiryReportPayload( - inquiryRoleData(blame?.inquiries as Record | undefined, "carBody", role), + inquiryRoleData( + blame?.inquiries as Record | undefined, + "carBody", + role, + ), ); const claimInquiry = pickInquiryReportPayload( - inquiryRoleData(claim?.inquiries as Record | undefined, "carBody", role), + inquiryRoleData( + claim?.inquiries as Record | undefined, + "carBody", + role, + ), ); - const legacy = (blame?.carBodyInsuranceDetail ?? {}) as Record; + const legacy = (blame?.carBodyInsuranceDetail ?? {}) as Record< + string, + unknown + >; return [ { @@ -556,7 +588,10 @@ function buildPartyOwnerSection( { label: PR.nationalCode, value: options?.useClaimOwnerFallback - ? firstDefined(person?.nationalCodeOfInsurer, money?.nationalCodeOfInsurer) + ? firstDefined( + person?.nationalCodeOfInsurer, + money?.nationalCodeOfInsurer, + ) : firstDefined(person?.nationalCodeOfInsurer, person?.nationalCode), }, { @@ -572,9 +607,55 @@ function buildPartyOwnerSection( ]); } -function licenseFieldsFromInquiry( - inquiry?: Record, -): { licenseType?: string; licenseDate?: string } { +function buildParticipantRolesSection( + title: 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 = { + 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): { + licenseType?: string; + licenseDate?: string; +} { if (!inquiry) return {}; return { licenseType: firstDefined( @@ -700,7 +781,9 @@ function buildCaseTimelineSection( }, { label: PR.evaluationRegisteredAt, - value: formatDateTime(resolveEvaluationSubmittedAt(claim, evaluationReply)), + value: formatDateTime( + resolveEvaluationSubmittedAt(claim, evaluationReply), + ), }, ]); } @@ -709,10 +792,14 @@ function buildFanavaranCodesSection( claim?: Record | null, ): InsurerFileReportSection | undefined { if (!claim) return undefined; - const sync = (claim.fanavaranSync as Record | undefined) ?? {}; - const baseClaim = (sync.baseClaim as Record | undefined) ?? {}; - const damageCase = (sync.damageCase as Record | undefined) ?? {}; - const expertise = (sync.expertise as Record | undefined) ?? {}; + const sync = + (claim.fanavaranSync as Record | undefined) ?? {}; + const baseClaim = + (sync.baseClaim as Record | undefined) ?? {}; + const damageCase = + (sync.damageCase as Record | undefined) ?? {}; + const expertise = + (sync.expertise as Record | undefined) ?? {}; return buildSection(PR.fanavaranSection, [ { @@ -725,7 +812,11 @@ function buildFanavaranCodesSection( }, { label: PR.fanavaranDamageCaseId, - value: firstDefined(claim.dmgCaseId, damageCase.dmgCaseId, expertise.dmgCaseId), + value: firstDefined( + claim.dmgCaseId, + damageCase.dmgCaseId, + expertise.dmgCaseId, + ), }, { label: PR.fanavaranExpertiseId, @@ -824,10 +915,19 @@ function buildEvaluationPartsSection( const prefix = `${PR.part} ${PERSIAN_NUMBER_FORMATTER.format(index + 1)}`; return [ { label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) }, - { label: `${prefix} / ${PR.damageType}`, value: asString(part.typeOfDamage) }, + { + label: `${prefix} / ${PR.damageType}`, + value: asString(part.typeOfDamage), + }, { label: `${prefix} / ${PR.partPrice}`, value: formatToman(part.price) }, - { label: `${prefix} / ${PR.repairSalary}`, value: formatToman(part.salary) }, - { label: `${prefix} / ${PR.totalPayment}`, value: formatToman(part.totalPayment) }, + { + label: `${prefix} / ${PR.repairSalary}`, + value: formatToman(part.salary), + }, + { + label: `${prefix} / ${PR.totalPayment}`, + value: formatToman(part.totalPayment), + }, { label: `${prefix} / ${PR.factorNeeded}`, value: @@ -835,7 +935,10 @@ function buildEvaluationPartsSection( ? persianStatus(part.factorNeeded) : undefined, }, - { label: `${prefix} / ${PR.daghi}`, value: evaluationDaghiValue(part.daghi) }, + { + label: `${prefix} / ${PR.daghi}`, + value: evaluationDaghiValue(part.daghi), + }, ]; }); @@ -852,10 +955,11 @@ function buildAccidentReportSection( const snapshotAccident = ( claim?.snapshot as { accident?: Record } | undefined )?.accident; - const blameDecision = ( - blame?.expert as Record | undefined - )?.decision as Record | undefined; - const decisionFields = blameDecision?.fields as Record | undefined; + const blameDecision = (blame?.expert as Record | undefined) + ?.decision as Record | undefined; + const decisionFields = blameDecision?.fields as + | Record + | undefined; return buildSection(PR.accidentSection, [ { @@ -868,7 +972,8 @@ function buildAccidentReportSection( }, { label: PR.accidentTime, - value: asString(statement?.accidentTime) ?? asString(snapshotAccident?.time), + value: + asString(statement?.accidentTime) ?? asString(snapshotAccident?.time), }, { label: PR.experts, @@ -914,7 +1019,8 @@ function buildAccidentReportSection( { label: PR.accidentWay, value: asString( - (decisionFields?.accidentWay as { label?: string } | undefined)?.label ?? + (decisionFields?.accidentWay as { label?: string } | undefined) + ?.label ?? ( snapshotAccident?.classification as { accidentWay?: { label?: string }; @@ -925,7 +1031,8 @@ function buildAccidentReportSection( { label: PR.accidentReason, value: asString( - (decisionFields?.accidentReason as { label?: string } | undefined)?.label ?? + (decisionFields?.accidentReason as { label?: string } | undefined) + ?.label ?? ( snapshotAccident?.classification as { accidentReason?: { label?: string }; @@ -936,7 +1043,8 @@ function buildAccidentReportSection( { label: PR.accidentType, value: asString( - (decisionFields?.accidentType as { label?: string } | undefined)?.label ?? + (decisionFields?.accidentType as { label?: string } | undefined) + ?.label ?? ( snapshotAccident?.classification as { accidentType?: { label?: string }; @@ -968,12 +1076,17 @@ export function buildInsurerFileReport(file: { : null; const isCarBody = String((blameContext?.type as string | undefined) ?? "") === "CAR_BODY"; - const includeGuiltySections = !!guiltyParty && !(isCarBody && sameParty(damagedParty, guiltyParty)); + const includeGuiltySections = + !!guiltyParty && !(isCarBody && sameParty(damagedParty, guiltyParty)); const claimVehicle = claim?.vehicle as Record | undefined; const sections = filterEmptySections([ buildCaseTimelineSection(overview, claim), + buildParticipantRolesSection(PR.damagedParticipantsSection, damagedParty), + includeGuiltySections + ? buildParticipantRolesSection(PR.guiltyParticipantsSection, guiltyParty) + : undefined, buildPartyOwnerSection(PR.ownerSection, damagedParty, { claim, useClaimOwnerFallback: true, @@ -1003,7 +1116,11 @@ export function buildInsurerFileReport(file: { buildCarBodyInsuranceFields(guiltyParty, blameContext, claim), ) : undefined, - buildPartyVehicleSection(PR.damagedVehicleSection, damagedParty, claimVehicle), + buildPartyVehicleSection( + PR.damagedVehicleSection, + damagedParty, + claimVehicle, + ), includeGuiltySections ? buildPartyVehicleSection(PR.guiltyVehicleSection, guiltyParty) : undefined, diff --git a/src/case-expert-report/persian-report-labels.ts b/src/case-expert-report/persian-report-labels.ts index 56ef6de..7252051 100644 --- a/src/case-expert-report/persian-report-labels.ts +++ b/src/case-expert-report/persian-report-labels.ts @@ -5,6 +5,8 @@ export const PR = { empty: "-", ownerSection: "مالک خودروی زیان دیده", guiltyOwnerSection: "مالک خودروی مقصر", + damagedParticipantsSection: "اشخاص و نقش‌های خودروی زیان‌دیده", + guiltyParticipantsSection: "اشخاص و نقش‌های خودروی مقصر", driverSection: "راننده خودروی زیان دیده", damagedThirdPartyInsuranceSection: "بیمه شخص ثالث زیان‌دیده", damagedCarBodyInsuranceSection: "بیمه بدنه زیان‌دیده", @@ -61,6 +63,11 @@ export const PR = { claimsDamage: "ادعای خسارت", acceptsExpertOpinion: "پذیرش نظر کارشناس", partyRole: "نقش طرف", + driverRole: "راننده", + vehicleOwnerRole: "مالک وسیله نقلیه", + thirdPartyPolicyholderRole: "بیمه‌گذار شخص ثالث", + carBodyPolicyholderRole: "بیمه‌گذار بدنه", + unknown: "نامشخص", data: "اطلاعات", date: "تاریخ", time: "زمان", diff --git a/src/common/dto/inquiry-participants.dto.ts b/src/common/dto/inquiry-participants.dto.ts index d9969b3..dc19876 100644 --- a/src/common/dto/inquiry-participants.dto.ts +++ b/src/common/dto/inquiry-participants.dto.ts @@ -24,9 +24,8 @@ export enum VehicleRegistrationState { export class InquiryPlateDto { @ApiProperty({ example: "44" }) - @IsString() @IsNotEmpty() - leftDigits: string; + leftDigits: string | number; @ApiProperty({ example: "ب" }) @IsString() @@ -34,14 +33,12 @@ export class InquiryPlateDto { centerAlphabet: string; @ApiProperty({ example: "111" }) - @IsString() @IsNotEmpty() - centerDigits: string; + centerDigits: string | number; @ApiProperty({ example: "22" }) - @IsString() @IsNotEmpty() - ir: string; + ir: string | number; } export class InquiryParticipantInputDto { diff --git a/src/request-management/entities/schema/request-management.schema.ts b/src/request-management/entities/schema/request-management.schema.ts index e6456eb..f81b292 100644 --- a/src/request-management/entities/schema/request-management.schema.ts +++ b/src/request-management/entities/schema/request-management.schema.ts @@ -146,6 +146,22 @@ export class FirstPartyDetail { @Prop({ type: Boolean }) firstPartyCertificate: boolean | null; + + @Prop({ type: [MongooseSchema.Types.Mixed] }) + participants?: Record[]; + + @Prop({ type: MongooseSchema.Types.Mixed }) + participantRoles?: Record; + + @Prop({ type: MongooseSchema.Types.Mixed }) + participantInquiries?: Record; + + @Prop({ type: MongooseSchema.Types.Mixed }) + policyInquiry?: Record; + + @Prop() registrationState?: string; + @Prop() previousPlateId?: string; + @Prop() vehicleVin?: string; } export class SecondPartyDetail { @@ -178,6 +194,22 @@ export class SecondPartyDetail { @Prop({ type: SecondPartyFile }) secondPartyFiles?: SecondPartyFile; + + @Prop({ type: [MongooseSchema.Types.Mixed] }) + participants?: Record[]; + + @Prop({ type: MongooseSchema.Types.Mixed }) + participantRoles?: Record; + + @Prop({ type: MongooseSchema.Types.Mixed }) + participantInquiries?: Record; + + @Prop({ type: MongooseSchema.Types.Mixed }) + policyInquiry?: Record; + + @Prop() registrationState?: string; + @Prop() previousPlateId?: string; + @Prop() vehicleVin?: string; } export class LocationDto { diff --git a/src/request-management/inquiry-participant-resolver.spec.ts b/src/request-management/inquiry-participant-resolver.spec.ts index 062090d..b7646ec 100644 --- a/src/request-management/inquiry-participant-resolver.spec.ts +++ b/src/request-management/inquiry-participant-resolver.spec.ts @@ -421,6 +421,41 @@ describe("inquiry participant resolver", () => { }); }); + 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: { diff --git a/src/request-management/inquiry-participant-resolver.ts b/src/request-management/inquiry-participant-resolver.ts index 0fd7303..fcb6af5 100644 --- a/src/request-management/inquiry-participant-resolver.ts +++ b/src/request-management/inquiry-participant-resolver.ts @@ -391,12 +391,31 @@ export function assertPreviousPlateInquiryMatchesVin( } } +export function isPolicyNotFoundError(error: unknown): boolean { + const candidate = error as Record | 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(options: { vehicle?: ResolvedInquiryVehicle; fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"]; query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise; isUsable: (value: T) => boolean; mappedValue: (value: T) => Record; + shouldFallbackOnError?: (error: unknown) => boolean; }): Promise<{ value: T; plateKind: "CURRENT" | "PREVIOUS"; @@ -462,6 +481,15 @@ export async function runPlateInquiryWithFallback(options: { 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; diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 3294b1d..2f2e9ab 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -542,6 +542,44 @@ export class RequestManagementService { } } + /** Inquiry bundle for the still-routable legacy RequestManagement model. */ + private async collectLegacyParticipantInquiries( + submission: NormalizedInquirySubmission>, + ): Promise | undefined> { + if (submission.participants.legacy) return undefined; + + const personal: Record = {}; + for (const participant of submission.participants.participants) { + if (participant.unknown) continue; + personal[participant.participantId] = + await this.sandHubService.getPersonalInquiry( + participant.nationalCode, + participant.birthday, + ); + } + + const plate = submission.vehicle?.currentPlate ?? submission.dto.plate; + const ownership = + submission.vehicleOwner && plate + ? await this.sandHubService.getCarOwnershipInfo( + plate, + submission.vehicleOwner.nationalCode, + ) + : undefined; + const drivingLicence = + submission.driver.hasDrivingLicense === false + ? { + skipped: true, + reason: "Driver declared no driving licence", + } + : await this.sandHubService.getDrivingLicenseInfo( + submission.driver.nationalCode, + submission.driver.licenseNumber!, + ); + + return { personal, ownership, drivingLicence }; + } + private getPartyIndex(req: any, role: PartyRole): number { return Array.isArray(req.parties) ? req.parties.findIndex((p) => p?.role === role) @@ -3271,6 +3309,9 @@ export class RequestManagementService { user: any, body: AddPlateDto, partyType: "firstParty" | "secondParty", + inquirySubmission?: NormalizedInquirySubmission>, + policyInquiry?: Record, + participantInquiries?: Record, ) { if (request.type === "THIRD_PARTY" && partyType === "firstParty") { this.sandHubService.assertInsuranceMatchesDeployment( @@ -3282,12 +3323,16 @@ export class RequestManagementService { const clientName = sandHubReport?.CompanyName; const companyCode = sandHubReport?.CompanyCode; - const client = await this.clientService.findOrCreateClientByCompanyCode( - companyCode, - clientName, - ); + const policyholderUnknown = + inquirySubmission?.thirdPartyPolicyholder.unknown === true; + const client = clientName + ? await this.clientService.findOrCreateClientByCompanyCode( + companyCode, + clientName, + ) + : null; - if (!client) { + if (!client && !policyholderUnknown) { throw new HttpException("Client not found", HttpStatus.CONFLICT); } @@ -3313,23 +3358,27 @@ export class RequestManagementService { } try { - await this.userDbService.findOneAndUpdate( - { _id: user.sub }, - { clientKey: client["_doc"]._id }, - ); + if (client) { + await this.userDbService.findOneAndUpdate( + { _id: user.sub }, + { clientKey: (client as any)["_doc"]?._id ?? (client as any)._id }, + ); + } const sandHubDocData = { ...sandHubReport, plate: body.plate, nationalCode: body.nationalCodeOfInsurer, }; - const sandHubDoc = await this.sandHubService.sandHubDocumentCreator( - user.sub, - request["_doc"]._id, - sandHubDocData, - ); + const sandHubDoc = policyInquiry?.skipped + ? null + : await this.sandHubService.sandHubDocumentCreator( + user.sub, + request["_doc"]._id, + sandHubDocData, + ); - if (partyType === "firstParty") { + if (partyType === "firstParty" && sandHubDoc) { await this.requestManagementDbService.findAndUpdate( { _id: request._id }, { @@ -3352,8 +3401,6 @@ export class RequestManagementService { body.driverBirthday, [`${partyDetails}.${partyType}CarDetail.carType`]: `${sandHubReport.UsageField} / ${sandHubReport.MapUsageName || "-"}`, [`${partyDetails}.${partyType}CarDetail.isNewCar`]: body.isNewCar, - [`${partyDetails}.${partyType}Client.clientId`]: client["_doc"]._id, - [`${partyDetails}.${partyType}Client.clientName`]: clientName, [`${partyDetails}.${partyType}Certificate`]: body.userNoCertificate, [`${partyDetails}.${partyType}InsuranceDetail.insuranceId`]: sandHubReport.LastCompanyDocumentNumber, @@ -3367,12 +3414,38 @@ export class RequestManagementService { sandHubReport.EndDate, }; + if (client) { + setFields[`${partyDetails}.${partyType}Client.clientId`] = + (client as any)["_doc"]?._id ?? (client as any)._id; + setFields[`${partyDetails}.${partyType}Client.clientName`] = clientName; + } + if (inquirySubmission && !inquirySubmission.participants.legacy) { + setFields[`${partyDetails}.participants`] = + inquirySubmission.participants.participants; + setFields[`${partyDetails}.participantRoles`] = + inquirySubmission.participants.roles; + setFields[`${partyDetails}.participantInquiries`] = + participantInquiries ?? {}; + setFields[`${partyDetails}.policyInquiry`] = policyInquiry ?? {}; + setFields[`${partyDetails}.registrationState`] = + inquirySubmission.vehicle?.registrationState; + setFields[`${partyDetails}.previousPlateId`] = inquirySubmission.vehicle + ?.previousPlate + ? this.plateToPlateIdString(inquirySubmission.vehicle.previousPlate) + : undefined; + setFields[`${partyDetails}.vehicleVin`] = + inquirySubmission.vehicle?.vin; + } + // For CAR_BODY type, persist the provider response and its mapped fields. if (request.type === "CAR_BODY" && partyType === "firstParty") { - const carBodyInquiry = await this.sandHubService.getCarBodyInquiry({ - nationalCodeOfInsurer: body.nationalCodeOfInsurer, - plate: body.plate, - } as any); + const carBodyInquiry = + inquirySubmission && !inquirySubmission.participants.legacy + ? await this.getCarBodyPlateInquiry(inquirySubmission) + : await this.sandHubService.getCarBodyInquiry({ + nationalCodeOfInsurer: body.nationalCodeOfInsurer, + plate: body.plate, + } as any); const carBodyInfo = carBodyInquiry.mapped as any; this.logger.log( @@ -3400,6 +3473,18 @@ export class RequestManagementService { carBodyInquiry.raw; setFields["firstPartyDetails.firstPartyCarBodyInsuranceDetail.source"] = carBodyInquiry.source; + if (inquirySubmission && !inquirySubmission.participants.legacy) { + setFields[`${partyDetails}.policyInquiry`] = { + thirdParty: policyInquiry, + carBody: { + source: carBodyInquiry.source, + plateKind: carBodyInquiry.plateKind, + attempts: carBodyInquiry.attempts, + raw: carBodyInquiry.raw, + mapped: carBodyInquiry.mapped, + }, + }; + } setFields[`${partyDetails}.${partyType}Client.clientId`] = await this.resolveCarBodyPolicyClientId(carBodyInfo); } @@ -3456,6 +3541,13 @@ export class RequestManagementService { (request.nextStep === StepsEnum.F_addPlate || request.currentStep === StepsEnum.F_addPlate) && request.firstPartyDetails.firstPartyPhoneNumber === user.username; + const partyRole = isFirstParty ? PartyRole.FIRST : PartyRole.SECOND; + const inquirySubmission = this.normalizeInquiryInput( + request.type as BlameRequestType, + body, + partyRole, + ); + body = inquirySubmission.dto as unknown as AddPlateDto; if (!isFirstParty) { const firstPartyDetails = request.firstPartyDetails as any; @@ -3482,10 +3574,23 @@ export class RequestManagementService { // TODO: Activate // --- Proceed with existing logic if the check passes --- // 1) Main third-party/block inquiry (existing behavior) - const sanHubResponse = await this.sandHubService.getSandHubResponse({ - plate: body.plate, - nationalCodeOfInsurer: body.nationalCodeOfInsurer, - }); + const policyInquiry = inquirySubmission.participants.legacy + ? await (async () => { + const response = await this.sandHubService.getSandHubResponse({ + plate: body.plate, + nationalCodeOfInsurer: body.nationalCodeOfInsurer, + }); + return { + raw: response, + mapped: response["_doc"] || response, + plateKind: "CURRENT", + attempts: [{ plateKind: "CURRENT", succeeded: true, usable: true }], + }; + })() + : await this.getThirdPartyPlateInquiry(inquirySubmission); + const sanHubResponse = policyInquiry.mapped; + const participantInquiries = + await this.collectLegacyParticipantInquiries(inquirySubmission); // if (sanHubResponse.Error) { // throw new HttpException( @@ -3520,6 +3625,9 @@ export class RequestManagementService { user, body, isFirstParty ? "firstParty" : "secondParty", + inquirySubmission, + policyInquiry, + participantInquiries, ); } @@ -6292,7 +6400,7 @@ export class RequestManagementService { return none; } - /** Party payload for user blame detail: no national codes, licenses, phone, birthdays, or vehicle inquiry blobs. */ + /** Party payload for user blame detail: role links are visible, but participant PII and inquiry blobs remain private. */ private async sanitizePartyForBlameUserView( party: any, ): Promise> { @@ -6311,12 +6419,22 @@ export class RequestManagementService { const vehicle = party.vehicle ? { plateId: party.vehicle.plateId, + previousPlateId: party.vehicle.previousPlateId, + registrationState: party.vehicle.registrationState, name: party.vehicle.name, model: party.vehicle.model, type: party.vehicle.type, isNew: party.vehicle.isNew, } : undefined; + const participants = Array.isArray(party.participants) + ? party.participants.map((participant: any) => ({ + participantId: participant.participantId, + fullName: participant.fullName, + hasDrivingLicense: participant.hasDrivingLicense, + unknown: participant.unknown, + })) + : undefined; const evidenceRaw = party.evidence as Record | undefined; const evidence: Record | undefined = evidenceRaw ? { ...evidenceRaw } @@ -6350,6 +6468,8 @@ export class RequestManagementService { return { role: party.role, + participants, + participantRoles: party.participantRoles, person: safePerson, carBodyFirstForm: party.carBodyFirstForm, location: party.location, @@ -6689,10 +6809,26 @@ export class RequestManagementService { ); } - const carBodyInquiry = await this.getCarBodyPlateInquiry(inquirySubmission); + let carBodyInquiry: any; + try { + carBodyInquiry = await this.getCarBodyPlateInquiry(inquirySubmission); + } catch (error: any) { + this.recordPartyCaseInquiryStatus( + req, + "carBody", + PartyRole.FIRST, + false, + { attempts: error?.attempts ?? [] }, + error, + ); + await (req as any).save(); + this.throwCarBodyInquiryFailure(error); + } const carBodyInfo = carBodyInquiry.mapped as any; this.recordPartyCaseInquiryStatus(req, "carBody", PartyRole.FIRST, true, { source: carBodyInquiry.source, + plateKind: carBodyInquiry.plateKind, + attempts: carBodyInquiry.attempts, raw: carBodyInquiry.raw, mapped: carBodyInfo, }); @@ -7669,13 +7805,30 @@ export class RequestManagementService { stepsToAdd.push(StepsEnum.S_addDescription); // Process first party plate with SandHub lookup - const firstPartyPlate = formData.firstPartyPlate; + const firstInquirySubmission = this.normalizeInquiryInput( + BlameRequestType.THIRD_PARTY, + formData.firstPartyPlate, + PartyRole.FIRST, + ); + const firstPartyPlate = firstInquirySubmission.dto; try { - const sandHubResponse = await this.sandHubService.getSandHubResponse({ - plate: firstPartyPlate.plate, - nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, - }); - const sandHubReport = sandHubResponse["_doc"] || sandHubResponse; + const firstPolicyInquiry = firstInquirySubmission.participants.legacy + ? await (async () => { + const response = await this.sandHubService.getSandHubResponse({ + plate: firstPartyPlate.plate, + nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, + }); + return { + raw: response, + mapped: response["_doc"] || response, + plateKind: "CURRENT", + attempts: [ + { plateKind: "CURRENT", succeeded: true, usable: true }, + ], + }; + })() + : await this.getThirdPartyPlateInquiry(firstInquirySubmission); + const sandHubReport = firstPolicyInquiry.mapped; this.sandHubService.assertInsuranceMatchesDeployment( sandHubReport, "THIRD_PARTY", @@ -7727,6 +7880,26 @@ export class RequestManagementService { startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate, endDate: sandHubReport?.EndDate, }; + if (!firstInquirySubmission.participants.legacy) { + firstPartyDetails.participants = + firstInquirySubmission.participants.participants; + firstPartyDetails.participantRoles = + firstInquirySubmission.participants.roles; + firstPartyDetails.participantInquiries = + await this.collectLegacyParticipantInquiries( + firstInquirySubmission, + ); + firstPartyDetails.policyInquiry = firstPolicyInquiry; + firstPartyDetails.registrationState = + firstInquirySubmission.vehicle?.registrationState; + firstPartyDetails.previousPlateId = firstInquirySubmission.vehicle + ?.previousPlate + ? this.plateToPlateIdString( + firstInquirySubmission.vehicle.previousPlate, + ) + : undefined; + firstPartyDetails.vehicleVin = firstInquirySubmission.vehicle?.vin; + } // Update the firstPartyDetails in the payload with all plate data updatePayload.$set.firstPartyDetails = firstPartyDetails; @@ -7742,26 +7915,47 @@ export class RequestManagementService { // Process second party plate try { - const secondPartyPlate = formData.secondParty.plate; - const sandHubResponse = await this.sandHubService.getSandHubResponse({ - plate: secondPartyPlate.plate, - nationalCodeOfInsurer: secondPartyPlate.nationalCodeOfInsurer, - }); - const sandHubReport = sandHubResponse["_doc"] || sandHubResponse; + const secondInquirySubmission = this.normalizeInquiryInput( + BlameRequestType.THIRD_PARTY, + formData.secondParty.plate, + PartyRole.SECOND, + ); + const secondPartyPlate = secondInquirySubmission.dto; + const secondPolicyInquiry = secondInquirySubmission.participants.legacy + ? await (async () => { + const response = await this.sandHubService.getSandHubResponse({ + plate: secondPartyPlate.plate, + nationalCodeOfInsurer: secondPartyPlate.nationalCodeOfInsurer, + }); + return { + raw: response, + mapped: response["_doc"] || response, + plateKind: "CURRENT", + attempts: [ + { plateKind: "CURRENT", succeeded: true, usable: true }, + ], + }; + })() + : await this.getThirdPartyPlateInquiry(secondInquirySubmission); + const sandHubReport = secondPolicyInquiry.mapped; const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode; // Try to find client by company code first (more reliable) - const client = companyCode - ? await this.clientService.findOrCreateClientByCompanyCode( - companyCode, - clientName, - ) - : await this.clientService.findOne({ clientName: clientName }); + const policyholderUnknown = + secondInquirySubmission.thirdPartyPolicyholder.unknown === true; + const client = clientName + ? companyCode + ? await this.clientService.findOrCreateClientByCompanyCode( + companyCode, + clientName, + ) + : await this.clientService.findOne({ clientName: clientName }) + : null; - if (!client) { + if (!client && !policyholderUnknown) { throw new NotFoundException( `Client not found for company: ${clientName}${companyCode ? ` (code: ${companyCode})` : ""}`, ); @@ -7778,10 +7972,12 @@ export class RequestManagementService { carType: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`, isNewCar: secondPartyPlate.isNewCar, }; - secondPartyDetails.secondPartyClient = { - clientId: (client as any)["_doc"]?._id || (client as any)._id, - clientName: clientName, - }; + if (client) { + secondPartyDetails.secondPartyClient = { + clientId: (client as any)["_doc"]?._id || (client as any)._id, + clientName: clientName, + }; + } secondPartyDetails.secondPartyCertificate = secondPartyPlate.userNoCertificate; secondPartyDetails.secondPartyInsuranceDetail = { @@ -7795,6 +7991,26 @@ export class RequestManagementService { startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate, endDate: sandHubReport?.EndDate, }; + if (!secondInquirySubmission.participants.legacy) { + secondPartyDetails.participants = + secondInquirySubmission.participants.participants; + secondPartyDetails.participantRoles = + secondInquirySubmission.participants.roles; + secondPartyDetails.participantInquiries = + await this.collectLegacyParticipantInquiries( + secondInquirySubmission, + ); + secondPartyDetails.policyInquiry = secondPolicyInquiry; + secondPartyDetails.registrationState = + secondInquirySubmission.vehicle?.registrationState; + secondPartyDetails.previousPlateId = secondInquirySubmission.vehicle + ?.previousPlate + ? this.plateToPlateIdString( + secondInquirySubmission.vehicle.previousPlate, + ) + : undefined; + secondPartyDetails.vehicleVin = secondInquirySubmission.vehicle?.vin; + } // Update the secondPartyDetails in the payload updatePayload.$set.secondPartyDetails = secondPartyDetails; @@ -8014,13 +8230,31 @@ export class RequestManagementService { stepsToAdd.push(StepsEnum.F_addDescription); // Process first party plate with SandHub lookup - const firstPartyPlate = formData.firstPartyPlate; + const firstInquirySubmission = this.normalizeInquiryInput( + BlameRequestType.CAR_BODY, + formData.firstPartyPlate, + PartyRole.FIRST, + ); + const firstPartyPlate = firstInquirySubmission.dto; try { - const sandHubResponse = await this.sandHubService.getSandHubResponse({ - plate: firstPartyPlate.plate, - nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, - }); - const sandHubReport = sandHubResponse["_doc"] || sandHubResponse; + const thirdPartyPolicyInquiry = firstInquirySubmission.participants + .legacy + ? await (async () => { + const response = await this.sandHubService.getSandHubResponse({ + plate: firstPartyPlate.plate, + nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, + }); + return { + raw: response, + mapped: response["_doc"] || response, + plateKind: "CURRENT", + attempts: [ + { plateKind: "CURRENT", succeeded: true, usable: true }, + ], + }; + })() + : await this.getThirdPartyPlateInquiry(firstInquirySubmission); + const sandHubReport = thirdPartyPolicyInquiry.mapped; const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; @@ -8070,10 +8304,12 @@ export class RequestManagementService { }; // CAR_BODY specific insurance info - const carBodyInquiry = await this.sandHubService.getCarBodyInquiry({ - nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, - plate: firstPartyPlate.plate, - } as any); + const carBodyInquiry = firstInquirySubmission.participants.legacy + ? await this.sandHubService.getCarBodyInquiry({ + nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, + plate: firstPartyPlate.plate, + } as any) + : await this.getCarBodyPlateInquiry(firstInquirySubmission); const carBodyInfo = carBodyInquiry.mapped as any; firstPartyDetails.firstPartyCarBodyInsuranceDetail = { @@ -8085,6 +8321,35 @@ export class RequestManagementService { raw: carBodyInquiry.raw, source: carBodyInquiry.source, }; + if (!firstInquirySubmission.participants.legacy) { + firstPartyDetails.participants = + firstInquirySubmission.participants.participants; + firstPartyDetails.participantRoles = + firstInquirySubmission.participants.roles; + firstPartyDetails.participantInquiries = + await this.collectLegacyParticipantInquiries( + firstInquirySubmission, + ); + firstPartyDetails.policyInquiry = { + thirdParty: thirdPartyPolicyInquiry, + carBody: { + source: carBodyInquiry.source, + plateKind: carBodyInquiry.plateKind, + attempts: carBodyInquiry.attempts, + raw: carBodyInquiry.raw, + mapped: carBodyInquiry.mapped, + }, + }; + firstPartyDetails.registrationState = + firstInquirySubmission.vehicle?.registrationState; + firstPartyDetails.previousPlateId = firstInquirySubmission.vehicle + ?.previousPlate + ? this.plateToPlateIdString( + firstInquirySubmission.vehicle.previousPlate, + ) + : undefined; + firstPartyDetails.vehicleVin = firstInquirySubmission.vehicle?.vin; + } firstPartyDetails.firstPartyClient.clientId = await this.resolveCarBodyPolicyClientId(carBodyInfo); From fe22e693512c659c4a14998c3aaf18ea200830ca Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 13 Sep 2026 11:36:03 +0330 Subject: [PATCH 05/12] Persist failed inquiry audit trails --- .../request-management.service.ts | 213 +++++++++++++++--- 1 file changed, 187 insertions(+), 26 deletions(-) diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 2f2e9ab..f4945d3 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -442,6 +442,7 @@ export class RequestManagementService { }, error, ); + await this.persistBlameInquiryAudit(req); throw new BadRequestException( `${participant.participantId} personal identity inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, ); @@ -491,6 +492,7 @@ export class RequestManagementService { { participantId: submission.vehicleOwner.participantId }, error, ); + await this.persistBlameInquiryAudit(req); throw new BadRequestException( `Vehicle ownership inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, ); @@ -536,6 +538,7 @@ export class RequestManagementService { { participantId: submission.driver.participantId }, error, ); + await this.persistBlameInquiryAudit(req); throw new BadRequestException( `Driver licence inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, ); @@ -580,6 +583,51 @@ export class RequestManagementService { return { personal, ownership, drivingLicence }; } + private async persistBlameInquiryAudit(req: any): Promise { + if (!req?._id || !req.inquiries) return; + try { + await this.blameRequestDbService.findByIdAndUpdate(req._id, { + $set: { inquiries: req.inquiries }, + }); + } catch (error: any) { + this.logger.error( + `Failed to persist inquiry audit for blame request ${req._id}: ${error?.message || error}`, + ); + } + } + + private async persistLegacyInquiryAudit( + requestId: string, + partyDetails: "firstPartyDetails" | "secondPartyDetails", + submission: NormalizedInquirySubmission>, + audit: Record, + ): Promise { + if (submission.participants.legacy) return; + const prefix = partyDetails; + try { + await this.requestManagementDbService.findAndUpdate( + { _id: requestId }, + { + $set: { + [`${prefix}.participants`]: submission.participants.participants, + [`${prefix}.participantRoles`]: submission.participants.roles, + [`${prefix}.registrationState`]: + submission.vehicle?.registrationState, + [`${prefix}.previousPlateId`]: submission.vehicle?.previousPlate + ? this.plateToPlateIdString(submission.vehicle.previousPlate) + : undefined, + [`${prefix}.vehicleVin`]: submission.vehicle?.vin, + [`${prefix}.policyInquiry`]: audit, + }, + }, + ); + } catch (error: any) { + this.logger.error( + `Failed to persist legacy inquiry audit for request ${requestId}: ${error?.message || error}`, + ); + } + } + private getPartyIndex(req: any, role: PartyRole): number { return Array.isArray(req.parties) ? req.parties.findIndex((p) => p?.role === role) @@ -3574,23 +3622,63 @@ export class RequestManagementService { // TODO: Activate // --- Proceed with existing logic if the check passes --- // 1) Main third-party/block inquiry (existing behavior) - const policyInquiry = inquirySubmission.participants.legacy - ? await (async () => { - const response = await this.sandHubService.getSandHubResponse({ - plate: body.plate, - nationalCodeOfInsurer: body.nationalCodeOfInsurer, - }); - return { - raw: response, - mapped: response["_doc"] || response, - plateKind: "CURRENT", - attempts: [{ plateKind: "CURRENT", succeeded: true, usable: true }], - }; - })() - : await this.getThirdPartyPlateInquiry(inquirySubmission); + const legacyPartyPath = isFirstParty + ? "firstPartyDetails" + : "secondPartyDetails"; + let policyInquiry: any; + try { + policyInquiry = inquirySubmission.participants.legacy + ? await (async () => { + const response = await this.sandHubService.getSandHubResponse({ + plate: body.plate, + nationalCodeOfInsurer: body.nationalCodeOfInsurer, + }); + return { + raw: response, + mapped: response["_doc"] || response, + plateKind: "CURRENT", + attempts: [ + { plateKind: "CURRENT", succeeded: true, usable: true }, + ], + }; + })() + : await this.getThirdPartyPlateInquiry(inquirySubmission); + await this.persistLegacyInquiryAudit( + requestId, + legacyPartyPath, + inquirySubmission, + policyInquiry, + ); + } catch (error: any) { + await this.persistLegacyInquiryAudit( + requestId, + legacyPartyPath, + inquirySubmission, + { + failed: true, + attempts: error?.attempts ?? [], + error: this.normalizeInquiryError(error), + }, + ); + throw error; + } const sanHubResponse = policyInquiry.mapped; - const participantInquiries = - await this.collectLegacyParticipantInquiries(inquirySubmission); + let participantInquiries: Record | undefined; + try { + participantInquiries = + await this.collectLegacyParticipantInquiries(inquirySubmission); + } catch (error: any) { + await this.persistLegacyInquiryAudit( + requestId, + legacyPartyPath, + inquirySubmission, + { + ...policyInquiry, + participantInquiryFailure: this.normalizeInquiryError(error), + }, + ); + throw error; + } // if (sanHubResponse.Error) { // throw new HttpException( @@ -7811,8 +7899,9 @@ export class RequestManagementService { PartyRole.FIRST, ); const firstPartyPlate = firstInquirySubmission.dto; + let firstPolicyInquiry: any; try { - const firstPolicyInquiry = firstInquirySubmission.participants.legacy + firstPolicyInquiry = firstInquirySubmission.participants.legacy ? await (async () => { const response = await this.sandHubService.getSandHubResponse({ plate: firstPartyPlate.plate, @@ -7829,6 +7918,12 @@ export class RequestManagementService { })() : await this.getThirdPartyPlateInquiry(firstInquirySubmission); const sandHubReport = firstPolicyInquiry.mapped; + await this.persistLegacyInquiryAudit( + requestId, + "firstPartyDetails", + firstInquirySubmission, + firstPolicyInquiry, + ); this.sandHubService.assertInsuranceMatchesDeployment( sandHubReport, "THIRD_PARTY", @@ -7906,6 +8001,17 @@ export class RequestManagementService { stepsToAdd.push(StepsEnum.F_addPlate); } catch (plateError) { + await this.persistLegacyInquiryAudit( + requestId, + "firstPartyDetails", + firstInquirySubmission, + { + ...(firstPolicyInquiry ?? {}), + failed: true, + attempts: (plateError as any)?.attempts ?? [], + error: this.normalizeInquiryError(plateError), + }, + ); this.logger.error("Error processing first party plate:", plateError); if (plateError instanceof HttpException) throw plateError; throw new InternalServerErrorException( @@ -7914,14 +8020,15 @@ export class RequestManagementService { } // Process second party plate + const secondInquirySubmission = this.normalizeInquiryInput( + BlameRequestType.THIRD_PARTY, + formData.secondParty.plate, + PartyRole.SECOND, + ); + let secondPolicyInquiry: any; try { - const secondInquirySubmission = this.normalizeInquiryInput( - BlameRequestType.THIRD_PARTY, - formData.secondParty.plate, - PartyRole.SECOND, - ); const secondPartyPlate = secondInquirySubmission.dto; - const secondPolicyInquiry = secondInquirySubmission.participants.legacy + secondPolicyInquiry = secondInquirySubmission.participants.legacy ? await (async () => { const response = await this.sandHubService.getSandHubResponse({ plate: secondPartyPlate.plate, @@ -7938,6 +8045,12 @@ export class RequestManagementService { })() : await this.getThirdPartyPlateInquiry(secondInquirySubmission); const sandHubReport = secondPolicyInquiry.mapped; + await this.persistLegacyInquiryAudit( + requestId, + "secondPartyDetails", + secondInquirySubmission, + secondPolicyInquiry, + ); const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; @@ -8017,6 +8130,17 @@ export class RequestManagementService { stepsToAdd.push(StepsEnum.S_addPlate); } catch (plateError) { + await this.persistLegacyInquiryAudit( + requestId, + "secondPartyDetails", + secondInquirySubmission, + { + ...(secondPolicyInquiry ?? {}), + failed: true, + attempts: (plateError as any)?.attempts ?? [], + error: this.normalizeInquiryError(plateError), + }, + ); this.logger.error("Error processing second party plate:", plateError); throw new InternalServerErrorException( "Failed to process second party plate information", @@ -8236,9 +8360,10 @@ export class RequestManagementService { PartyRole.FIRST, ); const firstPartyPlate = firstInquirySubmission.dto; + let thirdPartyPolicyInquiry: any; + let carBodyInquiry: any; try { - const thirdPartyPolicyInquiry = firstInquirySubmission.participants - .legacy + thirdPartyPolicyInquiry = firstInquirySubmission.participants.legacy ? await (async () => { const response = await this.sandHubService.getSandHubResponse({ plate: firstPartyPlate.plate, @@ -8255,6 +8380,12 @@ export class RequestManagementService { })() : await this.getThirdPartyPlateInquiry(firstInquirySubmission); const sandHubReport = thirdPartyPolicyInquiry.mapped; + await this.persistLegacyInquiryAudit( + requestId, + "firstPartyDetails", + firstInquirySubmission, + { thirdParty: thirdPartyPolicyInquiry }, + ); const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; @@ -8304,7 +8435,7 @@ export class RequestManagementService { }; // CAR_BODY specific insurance info - const carBodyInquiry = firstInquirySubmission.participants.legacy + carBodyInquiry = firstInquirySubmission.participants.legacy ? await this.sandHubService.getCarBodyInquiry({ nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, plate: firstPartyPlate.plate, @@ -8358,6 +8489,30 @@ export class RequestManagementService { stepsToAdd.push(StepsEnum.F_addPlate); } catch (plateError) { + await this.persistLegacyInquiryAudit( + requestId, + "firstPartyDetails", + firstInquirySubmission, + { + ...(thirdPartyPolicyInquiry + ? { thirdParty: thirdPartyPolicyInquiry } + : {}), + ...(carBodyInquiry + ? { + carBody: { + source: carBodyInquiry.source, + plateKind: carBodyInquiry.plateKind, + attempts: carBodyInquiry.attempts, + raw: carBodyInquiry.raw, + mapped: carBodyInquiry.mapped, + }, + } + : {}), + failed: true, + attempts: (plateError as any)?.attempts ?? [], + error: this.normalizeInquiryError(plateError), + }, + ); this.logger.error("Error processing first party plate:", plateError); throw new InternalServerErrorException( "Failed to process first party plate information", @@ -9505,6 +9660,7 @@ export class RequestManagementService { {}, err, ); + await this.persistBlameInquiryAudit(req); throw new BadRequestException( `${roleLabel} party plate inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, ); @@ -9660,6 +9816,7 @@ export class RequestManagementService { {}, err, ); + await this.persistBlameInquiryAudit(req); this.throwCarBodyInquiryFailure(err); } } @@ -9733,6 +9890,7 @@ export class RequestManagementService { {}, err, ); + await this.persistBlameInquiryAudit(req); throw new BadRequestException( `${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, ); @@ -10352,6 +10510,7 @@ export class RequestManagementService { {}, err, ); + await this.persistBlameInquiryAudit(req); throw new BadRequestException( `${roleLabel} party VIN inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, ); @@ -10516,6 +10675,7 @@ export class RequestManagementService { {}, err, ); + await this.persistBlameInquiryAudit(req); this.throwCarBodyInquiryFailure(err); } } @@ -10589,6 +10749,7 @@ export class RequestManagementService { {}, err, ); + await this.persistBlameInquiryAudit(req); throw new BadRequestException( `${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, ); From 287978a555995d039b925141681e5a683695c7b4 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 13 Sep 2026 11:42:20 +0330 Subject: [PATCH 06/12] Retain inquiry audit context on failure --- .../inquiry-participant-resolver.spec.ts | 10 ++- .../inquiry-participant-resolver.ts | 10 +++ .../request-management.service.ts | 67 +++++++++++++------ 3 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src/request-management/inquiry-participant-resolver.spec.ts b/src/request-management/inquiry-participant-resolver.spec.ts index b7646ec..49c8541 100644 --- a/src/request-management/inquiry-participant-resolver.spec.ts +++ b/src/request-management/inquiry-participant-resolver.spec.ts @@ -230,10 +230,18 @@ describe("inquiry participant resolver", () => { expect(query).toHaveBeenCalledTimes(2); expect(result.plateKind).toBe("PREVIOUS"); - expect(result.attempts).toEqual([ + 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", () => { diff --git a/src/request-management/inquiry-participant-resolver.ts b/src/request-management/inquiry-participant-resolver.ts index fcb6af5..439089b 100644 --- a/src/request-management/inquiry-participant-resolver.ts +++ b/src/request-management/inquiry-participant-resolver.ts @@ -421,6 +421,8 @@ export async function runPlateInquiryWithFallback(options: { plateKind: "CURRENT" | "PREVIOUS"; attempts: Array<{ plateKind: "CURRENT" | "PREVIOUS"; + plate: InquiryVehicleInputDto["currentPlate"]; + vin?: string; succeeded: boolean; usable?: boolean; error?: string; @@ -432,6 +434,8 @@ export async function runPlateInquiryWithFallback(options: { let lastError: unknown; const attempts: Array<{ plateKind: "CURRENT" | "PREVIOUS"; + plate: InquiryVehicleInputDto["currentPlate"]; + vin?: string; succeeded: boolean; usable?: boolean; error?: string; @@ -446,6 +450,8 @@ export async function runPlateInquiryWithFallback(options: { if (!usable) { attempts.push({ plateKind: candidate.kind, + plate: candidate.plate, + ...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), succeeded: true, usable: false, }); @@ -464,6 +470,8 @@ export async function runPlateInquiryWithFallback(options: { } attempts.push({ plateKind: candidate.kind, + plate: candidate.plate, + ...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), succeeded: true, usable: true, }); @@ -477,6 +485,8 @@ export async function runPlateInquiryWithFallback(options: { 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), }); diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index f4945d3..085ac8c 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -336,6 +336,18 @@ export class RequestManagementService { } } + private recordInquiryParticipantContext( + req: any, + role: PartyRole, + submission: NormalizedInquirySubmission>, + ): void { + this.recordPartyCaseInquiryStatus(req, "participantContext", role, true, { + participants: submission.participants.participants, + participantRoles: submission.participants.roles, + vehicle: submission.vehicle, + }); + } + private async getThirdPartyPlateInquiry( submission: NormalizedInquirySubmission>, options?: Record, @@ -587,7 +599,10 @@ export class RequestManagementService { if (!req?._id || !req.inquiries) return; try { await this.blameRequestDbService.findByIdAndUpdate(req._id, { - $set: { inquiries: req.inquiries }, + $set: { + inquiries: req.inquiries, + ...(Array.isArray(req.parties) ? { parties: req.parties } : {}), + }, }); } catch (error: any) { this.logger.error( @@ -1791,9 +1806,7 @@ export class RequestManagementService { {}, err, ); - await this.blameRequestDbService.findByIdAndUpdate(req._id, { - $set: { inquiries: req.inquiries }, - }); + await this.persistBlameInquiryAudit(req); throw new HttpException("Inquiry failed", HttpStatus.BAD_REQUEST); } @@ -1808,9 +1821,7 @@ export class RequestManagementService { raw: inquiryRaw, mapped: inquiryMapped, }); - await this.blameRequestDbService.findByIdAndUpdate(req._id, { - $set: { inquiries: req.inquiries }, - }); + await this.persistBlameInquiryAudit(req); throw new HttpException( inquiryMapped.Error.Message || "Inquiry returned error", HttpStatus.BAD_REQUEST, @@ -1932,9 +1943,7 @@ export class RequestManagementService { {}, err, ); - await this.blameRequestDbService.findByIdAndUpdate(req._id, { - $set: { inquiries: req.inquiries }, - }); + await this.persistBlameInquiryAudit(req); this.throwCarBodyInquiryFailure(err); } @@ -2189,9 +2198,7 @@ export class RequestManagementService { {}, err, ); - await this.blameRequestDbService.findByIdAndUpdate(req._id, { - $set: { inquiries: req.inquiries }, - }); + await this.persistBlameInquiryAudit(req); throw new HttpException("VIN inquiry failed", HttpStatus.BAD_REQUEST); } @@ -2204,9 +2211,7 @@ export class RequestManagementService { raw: inquiryRaw, mapped: inquiryMapped, }); - await this.blameRequestDbService.findByIdAndUpdate(req._id, { - $set: { inquiries: req.inquiries }, - }); + await this.persistBlameInquiryAudit(req); throw new HttpException( inquiryMapped.Error.Message || "VIN inquiry returned error", HttpStatus.BAD_REQUEST, @@ -2363,9 +2368,7 @@ export class RequestManagementService { {}, err, ); - await this.blameRequestDbService.findByIdAndUpdate(req._id, { - $set: { inquiries: req.inquiries }, - }); + await this.persistBlameInquiryAudit(req); this.throwCarBodyInquiryFailure(err); } } @@ -6832,6 +6835,18 @@ export class RequestManagementService { formData.firstPartyPlate, PartyRole.FIRST, ); + this.recordInquiryParticipantContext( + req, + PartyRole.FIRST, + inquirySubmission, + ); + const existingFirstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST); + if (existingFirstPartyIndex !== -1) { + this.applyInquiryParticipantsToParty( + req.parties[existingFirstPartyIndex], + inquirySubmission, + ); + } const firstPartyPlate = inquirySubmission.dto; let sandHubReport: any; try { @@ -7130,6 +7145,14 @@ export class RequestManagementService { plateDto, role, ); + this.recordInquiryParticipantContext(req, role, inquirySubmission); + const existingPartyIndex = this.getPartyIndex(req, role); + if (existingPartyIndex !== -1) { + this.applyInquiryParticipantsToParty( + req.parties[existingPartyIndex], + inquirySubmission, + ); + } plateDto = inquirySubmission.dto; const policyholderUnknown = inquirySubmission.thirdPartyPolicyholder.unknown === true; @@ -9667,6 +9690,7 @@ export class RequestManagementService { } if (inquiryMapped?.Error) { + await this.persistBlameInquiryAudit(req); throw new BadRequestException( inquiryMapped.Error.Message || `${roleLabel} party plate inquiry returned an error`, @@ -9676,6 +9700,7 @@ export class RequestManagementService { const clientName = inquiryMapped?.CompanyName; const companyCode = inquiryMapped?.CompanyCode; if (!clientName && !policyholderUnknown) { + await this.persistBlameInquiryAudit(req); throw new BadRequestException( `CompanyName missing from ${roleLabel} party inquiry response`, ); @@ -9687,6 +9712,7 @@ export class RequestManagementService { ) : null; if (clientName && !client) { + await this.persistBlameInquiryAudit(req); throw new BadRequestException( `CompanyCode missing or invalid in ${roleLabel} party inquiry response`, ); @@ -10517,6 +10543,7 @@ export class RequestManagementService { } if (inquiryMapped?.Error) { + await this.persistBlameInquiryAudit(req); throw new BadRequestException( inquiryMapped.Error.Message || `${roleLabel} party VIN inquiry returned an error`, @@ -10526,6 +10553,7 @@ export class RequestManagementService { const clientName = inquiryMapped?.CompanyName; const companyCode = inquiryMapped?.CompanyCode; if (!clientName) { + await this.persistBlameInquiryAudit(req); throw new BadRequestException( `CompanyName missing from ${roleLabel} party VIN inquiry response`, ); @@ -10535,6 +10563,7 @@ export class RequestManagementService { clientName, ); if (!client) { + await this.persistBlameInquiryAudit(req); throw new BadRequestException( `CompanyCode missing or invalid in ${roleLabel} party VIN inquiry response`, ); From 5812637a1e6decfc26e752feaba413d6c0c6c974 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 13 Sep 2026 12:55:55 +0330 Subject: [PATCH 07/12] Mark rejected inquiry results as failed --- .../request-management.service.ts | 108 ++++++++++++++++-- 1 file changed, 96 insertions(+), 12 deletions(-) diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 085ac8c..8035063 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -9690,20 +9690,50 @@ export class RequestManagementService { } if (inquiryMapped?.Error) { - await this.persistBlameInquiryAudit(req); - throw new BadRequestException( + const error = new BadRequestException( inquiryMapped.Error.Message || `${roleLabel} party plate inquiry returned an error`, ); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + partyRole, + false, + { + source: inquirySource, + plateKind: inquiryPlateKind, + attempts: inquiryAttempts, + raw: inquiryRaw, + mapped: inquiryMapped, + }, + error, + ); + await this.persistBlameInquiryAudit(req); + throw error; } const clientName = inquiryMapped?.CompanyName; const companyCode = inquiryMapped?.CompanyCode; if (!clientName && !policyholderUnknown) { - await this.persistBlameInquiryAudit(req); - throw new BadRequestException( + const error = new BadRequestException( `CompanyName missing from ${roleLabel} party inquiry response`, ); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + partyRole, + false, + { + source: inquirySource, + plateKind: inquiryPlateKind, + attempts: inquiryAttempts, + raw: inquiryRaw, + mapped: inquiryMapped, + }, + error, + ); + await this.persistBlameInquiryAudit(req); + throw error; } const client = clientName ? await this.clientService.findOrCreateClientByCompanyCode( @@ -9712,10 +9742,25 @@ export class RequestManagementService { ) : null; if (clientName && !client) { - await this.persistBlameInquiryAudit(req); - throw new BadRequestException( + const error = new BadRequestException( `CompanyCode missing or invalid in ${roleLabel} party inquiry response`, ); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + partyRole, + false, + { + source: inquirySource, + plateKind: inquiryPlateKind, + attempts: inquiryAttempts, + raw: inquiryRaw, + mapped: inquiryMapped, + }, + error, + ); + await this.persistBlameInquiryAudit(req); + throw error; } const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; clientId = resolvedClientId ? String(resolvedClientId) : undefined; @@ -10543,30 +10588,69 @@ export class RequestManagementService { } if (inquiryMapped?.Error) { - await this.persistBlameInquiryAudit(req); - throw new BadRequestException( + const error = new BadRequestException( inquiryMapped.Error.Message || `${roleLabel} party VIN inquiry returned an error`, ); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + partyRole, + false, + { + source: "ESG_VIN_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }, + error, + ); + await this.persistBlameInquiryAudit(req); + throw error; } const clientName = inquiryMapped?.CompanyName; const companyCode = inquiryMapped?.CompanyCode; if (!clientName) { - await this.persistBlameInquiryAudit(req); - throw new BadRequestException( + const error = new BadRequestException( `CompanyName missing from ${roleLabel} party VIN inquiry response`, ); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + partyRole, + false, + { + source: "ESG_VIN_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }, + error, + ); + await this.persistBlameInquiryAudit(req); + throw error; } const client = await this.clientService.findOrCreateClientByCompanyCode( companyCode, clientName, ); if (!client) { - await this.persistBlameInquiryAudit(req); - throw new BadRequestException( + const error = new BadRequestException( `CompanyCode missing or invalid in ${roleLabel} party VIN inquiry response`, ); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + partyRole, + false, + { + source: "ESG_VIN_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }, + error, + ); + await this.persistBlameInquiryAudit(req); + throw error; } const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; clientId = resolvedClientId ? String(resolvedClientId) : undefined; From cc8a96c875e7064b9ea7108f4e3e073686b9bb0e Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 13 Sep 2026 12:59:39 +0330 Subject: [PATCH 08/12] Persist inquiry audit on client resolution failures --- .../request-management.service.ts | 145 ++++++++++++++++-- 1 file changed, 136 insertions(+), 9 deletions(-) diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 8035063..2d7b633 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -1850,9 +1850,25 @@ export class RequestManagementService { // Find client by company code const clientName = inquiryMapped?.CompanyName; if (!clientName && !policyholderUnknown) { - throw new BadRequestException( + const error = new BadRequestException( `CompanyName missing from inquiry response`, ); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + false, + { + source: inquirySource, + plateKind: inquiryPlateKind, + attempts: inquiryAttempts, + raw: inquiryRaw, + mapped: inquiryMapped, + }, + error, + ); + await this.persistBlameInquiryAudit(req); + throw error; } const companyCode = inquiryMapped?.CompanyCode; const client = clientName @@ -1862,9 +1878,25 @@ export class RequestManagementService { ) : null; if (clientName && !client) { - throw new BadRequestException( + const error = new BadRequestException( `CompanyCode missing or invalid in inquiry response`, ); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + false, + { + source: inquirySource, + plateKind: inquiryPlateKind, + attempts: inquiryAttempts, + raw: inquiryRaw, + mapped: inquiryMapped, + }, + error, + ); + await this.persistBlameInquiryAudit(req); + throw error; } // Persist inquiry/body data into new model @@ -2240,9 +2272,23 @@ export class RequestManagementService { // Resolve insurer client from inquiry response const clientName = inquiryMapped?.CompanyName; if (!clientName) { - throw new BadRequestException( + const error = new BadRequestException( "CompanyName missing from VIN inquiry response", ); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + false, + { + source: "ESG_VIN_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }, + error, + ); + await this.persistBlameInquiryAudit(req); + throw error; } const companyCode = inquiryMapped?.CompanyCode; const client = await this.clientService.findOrCreateClientByCompanyCode( @@ -2250,9 +2296,23 @@ export class RequestManagementService { clientName, ); if (!client) { - throw new BadRequestException( + const error = new BadRequestException( "CompanyCode missing or invalid in VIN inquiry response", ); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + false, + { + source: "ESG_VIN_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }, + error, + ); + await this.persistBlameInquiryAudit(req); + throw error; } // Persist party data @@ -6849,6 +6909,7 @@ export class RequestManagementService { } const firstPartyPlate = inquirySubmission.dto; let sandHubReport: any; + let thirdPartyInquiry: any; try { const inquiry = inquirySubmission.participants.legacy ? await (async () => { @@ -6867,6 +6928,7 @@ export class RequestManagementService { }; })() : await this.getThirdPartyPlateInquiry(inquirySubmission); + thirdPartyInquiry = inquiry; sandHubReport = inquiry.mapped; this.recordPartyCaseInquiryStatus( req, @@ -6907,9 +6969,25 @@ export class RequestManagementService { ) : await this.clientService.findOne({ clientName }); if (!client) { - throw new NotFoundException( + const error = new NotFoundException( `Client not found for company: ${clientName}`, ); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + PartyRole.FIRST, + false, + { + source: thirdPartyInquiry?.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", + plateKind: thirdPartyInquiry?.plateKind, + attempts: thirdPartyInquiry?.attempts, + raw: thirdPartyInquiry?.raw, + mapped: thirdPartyInquiry?.mapped ?? sandHubReport, + }, + error, + ); + await this.persistBlameInquiryAudit(req); + throw error; } let carBodyInquiry: any; @@ -7223,9 +7301,25 @@ export class RequestManagementService { : await this.clientService.findOne({ clientName }) : null; if (!client && !policyholderUnknown) { - throw new NotFoundException( + const error = new NotFoundException( `Client not found for company: ${clientName}`, ); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + false, + { + source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", + plateKind: inquiry.plateKind, + attempts: inquiry.attempts, + raw: inquiry.raw, + mapped: inquiry.mapped, + }, + error, + ); + await this.persistBlameInquiryAudit(req); + throw error; } const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; @@ -7965,9 +8059,20 @@ export class RequestManagementService { : await this.clientService.findOne({ clientName: clientName }); if (!client) { - throw new NotFoundException( + const error = new NotFoundException( `Client not found for company: ${clientName}`, ); + await this.persistLegacyInquiryAudit( + requestId, + "firstPartyDetails", + firstInquirySubmission, + { + ...(firstPolicyInquiry ?? {}), + failed: true, + error: this.normalizeInquiryError(error), + }, + ); + throw error; } // Add first party plate and related data to the existing firstPartyDetails object @@ -8092,9 +8197,20 @@ export class RequestManagementService { : null; if (!client && !policyholderUnknown) { - throw new NotFoundException( + const error = new NotFoundException( `Client not found for company: ${clientName}${companyCode ? ` (code: ${companyCode})` : ""}`, ); + await this.persistLegacyInquiryAudit( + requestId, + "secondPartyDetails", + secondInquirySubmission, + { + ...(secondPolicyInquiry ?? {}), + failed: true, + error: this.normalizeInquiryError(error), + }, + ); + throw error; } // Add second party plate and related data to the existing secondPartyDetails object @@ -8423,9 +8539,20 @@ export class RequestManagementService { : await this.clientService.findOne({ clientName: clientName }); if (!client) { - throw new NotFoundException( + const error = new NotFoundException( `Client not found for company: ${clientName}`, ); + await this.persistLegacyInquiryAudit( + requestId, + "firstPartyDetails", + firstInquirySubmission, + { + thirdParty: thirdPartyPolicyInquiry, + failed: true, + error: this.normalizeInquiryError(error), + }, + ); + throw error; } // Add first party plate and related data to the existing firstPartyDetails object From 5b77241f2894d5eeaa133cd302428738cfd73a52 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 13 Sep 2026 14:13:05 +0330 Subject: [PATCH 09/12] Require structured inquiry participant inputs --- docs/inquiry-participants-proposal.fa.md | 13 +- docs/inquiry-participants-proposal.md | 13 +- src/common/dto/inquiry-participants.dto.ts | 80 ++++++-- src/profile/dto/user/AddPlateDto.ts | 56 +----- .../dto/create-request-management.dto.ts | 74 +------ .../dto/run-call-center-inquiry-v6.dto.ts | 172 +--------------- .../dto/run-inquiries-v3.dto.ts | 190 +----------------- .../inquiry-participant-resolver.spec.ts | 54 +++-- .../inquiry-participant-resolver.ts | 112 +++++------ 9 files changed, 162 insertions(+), 602 deletions(-) diff --git a/docs/inquiry-participants-proposal.fa.md b/docs/inquiry-participants-proposal.fa.md index 7185953..1feeb48 100644 --- a/docs/inquiry-participants-proposal.fa.md +++ b/docs/inquiry-participants-proposal.fa.md @@ -6,9 +6,9 @@ ## interface پیاده‌سازی‌شده -فیلدهای نقش‌ها و آبجکت اختیاری `vehicle` که در ادامه آمده‌اند، مستقیماً در body تمام درخواست‌های استعلام فعلی پذیرفته می‌شوند. این تغییر شامل فرم اولیه کاربر و mirror کارشناس/ثبت‌کننده در V2، جریان کارشناس V3، جریان‌های پرونده‌ساز V4/V5، مرکز تماس V6 و مسیرهای تک‌درخواستی حضوری است. routeهای پلاک و VIN از قوانین مشترک اشخاص استفاده می‌کنند. +فیلدهای نقش‌ها و آبجکت الزامی `vehicle` که در ادامه آمده‌اند، مستقیماً در body تمام درخواست‌های استعلام فعلی پذیرفته می‌شوند. این تغییر شامل فرم اولیه کاربر و mirror کارشناس/ثبت‌کننده در V2، جریان کارشناس V3، جریان‌های پرونده‌ساز V4/V5، مرکز تماس V6 و مسیرهای تک‌درخواستی حضوری است. routeهای پلاک و VIN از قوانین مشترک اشخاص استفاده می‌کنند. فیلدهای تخت راننده/بیمه‌گذار و شماره تلفن، ورودی استعلام نیستند. -در دوره مهاجرت، فیلدهای قدیمی و تخت همچنان پذیرفته می‌شوند. پاسخ‌ها و جزئیات پرونده برای نقش‌های عملیاتی، در صورت وجود داده، فیلدهای نرمال‌شده `participants`، `participantRoles`، `vehicle.registrationState` و `vehicle.previousPlateId` را نمایش می‌دهند. +پاسخ‌ها و جزئیات پرونده برای نقش‌های عملیاتی، در صورت وجود داده، فیلدهای نرمال‌شده `participants`، `participantRoles`، `vehicle.registrationState` و `vehicle.previousPlateId` را نمایش می‌دهند. ## مسئله @@ -106,14 +106,9 @@ جریان‌های V2 کاربر/کارشناس، V3، V4، V5 و V6 باید adapter همین قوانین مشترک باشند و منطق نسبت اشخاص را جداگانه پیاده‌سازی نکنند. -## سازگاری و انتشار تدریجی +## مرز قرارداد -1. در دوره گذار، ساختار جدید در کنار فیلدهای قدیمی پذیرفته شود. -2. `nationalCodeOfDriver` به راننده و `nationalCodeOfInsurer` به بیمه‌گذار شخص ثالث نگاشت شود. اگر `driverIsInsurer=true` است، هر دو نقش به یک شخص متصل شوند. -3. `plate` یا `plateId` قدیمی به پلاک فعلی نگاشت شود؛ پلاک قبلی فقط وقتی ثبت شود که صریحاً از کاربر دریافت شده باشد. -4. برای داده‌های قدیمی، مالک خودرو یا بیمه‌گذار بدنه حدس زده نشود؛ مگر اینکه نتیجه استعلام ذخیره‌شده آن را قطعی کند، نقش «نامشخص» باقی بماند. -5. پاسخ جزئیات و گزارش، اشخاص را بر اساس نقش نمایش دهد و در زمان مهاجرت فیلدهای قدیمی را نیز حفظ کند. -6. پس از مهاجرت همه فرانت‌اندها، قرارداد جدید برای پرونده‌های تازه الزامی و فیلدهای قدیمی deprecated شوند. +ورودی استعلام فقط شامل آبجکت‌های ساختاریافته اشخاص و خودرو است. بک‌اند فیلدهای تختی مانند `nationalCodeOfDriver`، `nationalCodeOfInsurer`، `driverIsInsurer`، `plate`/`vin` سطح بالا و `phoneNumber` را با خطای اعتبارسنجی رد می‌کند. احراز هویت تلفنی و جریان‌های تماس با طرفین از جمع‌آوری هویت برای استعلام جدا هستند. ## تصمیم پیشنهادی diff --git a/docs/inquiry-participants-proposal.md b/docs/inquiry-participants-proposal.md index 594e498..17a8623 100644 --- a/docs/inquiry-participants-proposal.md +++ b/docs/inquiry-participants-proposal.md @@ -6,9 +6,9 @@ Persian version: [inquiry-participants-proposal.fa.md](./inquiry-participants-pr ## Implemented interface -The role fields and optional `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. +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. -Existing flat fields remain accepted during migration. Responses and file-detail views for operational actors expose normalized `participants`, `participantRoles`, `vehicle.registrationState`, and `vehicle.previousPlateId` where available. +Responses and file-detail views for operational actors expose normalized `participants`, `participantRoles`, `vehicle.registrationState`, and `vehicle.previousPlateId` where available. ## Problem @@ -106,14 +106,9 @@ Create one shared participant resolver used by every inquiry route. Its interfac V2 user/expert routes, V3, V4, V5, and V6 should be adapters over this shared rule set rather than implementing their own relationship logic. -## Compatibility and rollout +## Contract boundary -1. Accept the new participant object alongside the legacy fields temporarily. -2. Map legacy `nationalCodeOfDriver` to Driver and `nationalCodeOfInsurer` to Third-party Policyholder. When `driverIsInsurer=true`, bind both roles to the same participant. -3. Map the legacy `plate`/`plateId` to Current Plate; leave Previous Plate absent unless it was explicitly collected. -4. Do not guess Vehicle Owner or Car-body Policyholder for old records. Mark unresolved roles as unknown unless stored inquiry evidence identifies them. -5. Update report/detail responses to expose people by role while retaining legacy fields during migration. -6. After all frontends use the new step, require the new contract for newly created cases and deprecate the legacy fields. +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 diff --git a/src/common/dto/inquiry-participants.dto.ts b/src/common/dto/inquiry-participants.dto.ts index dc19876..3cc97c2 100644 --- a/src/common/dto/inquiry-participants.dto.ts +++ b/src/common/dto/inquiry-participants.dto.ts @@ -1,4 +1,8 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + ApiHideProperty, + ApiProperty, + ApiPropertyOptional, +} from "@nestjs/swagger"; import { Type } from "class-transformer"; import { IsBoolean, @@ -69,11 +73,6 @@ export class InquiryParticipantInputDto { @IsString() fullName?: string; - @ApiPropertyOptional() - @IsOptional() - @IsString() - phoneNumber?: string; - @ApiPropertyOptional({ description: "Required for a driver who has a licence.", }) @@ -117,27 +116,31 @@ export class InquiryVehicleInputDto { @IsString() @MaxLength(17) vin?: string; + + @ApiPropertyOptional({ + description: "Whether the vehicle is newly purchased/registered.", + }) + @IsOptional() + @IsBoolean() + isNewCar?: boolean; } -/** New role-complete contract mixed into every inquiry DTO. Legacy fields remain during rollout. */ +/** Structured role-complete contract mixed into every inquiry DTO. */ export class InquiryParticipantFieldsDto { - @ApiPropertyOptional({ type: InquiryParticipantInputDto }) - @IsOptional() + @ApiProperty({ type: InquiryParticipantInputDto }) @ValidateNested() @Type(() => InquiryParticipantInputDto) - driver?: InquiryParticipantInputDto; + driver: InquiryParticipantInputDto; - @ApiPropertyOptional({ type: InquiryParticipantInputDto }) - @IsOptional() + @ApiProperty({ type: InquiryParticipantInputDto }) @ValidateNested() @Type(() => InquiryParticipantInputDto) - vehicleOwner?: InquiryParticipantInputDto; + vehicleOwner: InquiryParticipantInputDto; - @ApiPropertyOptional({ type: InquiryParticipantInputDto }) - @IsOptional() + @ApiProperty({ type: InquiryParticipantInputDto }) @ValidateNested() @Type(() => InquiryParticipantInputDto) - thirdPartyPolicyholder?: InquiryParticipantInputDto; + thirdPartyPolicyholder: InquiryParticipantInputDto; @ApiPropertyOptional({ type: InquiryParticipantInputDto }) @IsOptional() @@ -145,9 +148,48 @@ export class InquiryParticipantFieldsDto { @Type(() => InquiryParticipantInputDto) carBodyPolicyholder?: InquiryParticipantInputDto; - @ApiPropertyOptional({ type: InquiryVehicleInputDto }) - @IsOptional() + @ApiProperty({ type: InquiryVehicleInputDto }) @ValidateNested() @Type(() => InquiryVehicleInputDto) - vehicle?: 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; } diff --git a/src/profile/dto/user/AddPlateDto.ts b/src/profile/dto/user/AddPlateDto.ts index 3e9f8bd..1431c24 100644 --- a/src/profile/dto/user/AddPlateDto.ts +++ b/src/profile/dto/user/AddPlateDto.ts @@ -1,61 +1,9 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ApiProperty } from "@nestjs/swagger"; import { PlatesDto } from "src/plates/dto/plate.dto"; import { Plates } from "src/Types&Enums/plate.interface"; import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto"; -export class AddPlateDto extends InquiryParticipantFieldsDto { - plateId: string; - @ApiPropertyOptional({ - type: String, - description: "Legacy third-party policyholder national code", - }) - nationalCodeOfInsurer: string; - - @ApiPropertyOptional({ - type: String, - description: "Legacy driver national code", - }) - nationalCodeOfDriver: string; - - @ApiPropertyOptional({ type: String }) - insurerLicense: string; - - @ApiPropertyOptional({ type: String }) - driverLicense: string; - - @ApiPropertyOptional({ - type: PlatesDto, - description: - "Legacy/current plate; may be supplied as vehicle.currentPlate", - }) - plate: Plates; - - @ApiPropertyOptional({ - type: Boolean, - description: "Legacy role relationship", - }) - driverIsInsurer: boolean; - - @ApiProperty({ type: Boolean, required: true, default: false }) - isNewCar: boolean; - - @ApiPropertyOptional({ - type: Boolean, - description: "Legacy; use driver.hasDrivingLicense", - }) - userNoCertificate: boolean; - - @ApiPropertyOptional({ - type: Number, - description: "Legacy third-party policyholder birthday", - }) - insurerBirthday: number; - @ApiProperty({ - type: String, - required: false, - }) - driverBirthday: string | null; -} +export class AddPlateDto extends InquiryParticipantFieldsDto {} export class AddPlateProfileDto { plateId: string; diff --git a/src/request-management/dto/create-request-management.dto.ts b/src/request-management/dto/create-request-management.dto.ts index 6d80bef..97d9a16 100644 --- a/src/request-management/dto/create-request-management.dto.ts +++ b/src/request-management/dto/create-request-management.dto.ts @@ -1,11 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { - IsNotEmpty, - IsOptional, - IsString, - MaxLength, - ValidateIf, -} from "class-validator"; +import { IsNotEmpty, IsOptional, IsString } from "class-validator"; import { Types } from "mongoose"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; @@ -229,70 +223,8 @@ export class BlameConfessionDtoV2 { imGuilty?: boolean; } -/** - * V2 initial-form step submitted with a VIN/chassis number instead of a plate. - * All identity and license fields from {@link AddPlateDto} are preserved; only - * `plate` is replaced by `vin` (the 17-character chassis / VIN string). - */ -export class InitialFormVinDto extends InquiryParticipantFieldsDto { - @ApiPropertyOptional({ - type: String, - required: false, - description: "17-character VIN / chassis number (شماره شاسی)", - example: "NAAM01E15HK123456", - maxLength: 17, - }) - @IsString() - @IsNotEmpty() - @MaxLength(17) - @ValidateIf((dto) => !dto.vehicle?.vin) - vin: string; - - @ApiPropertyOptional({ - type: String, - description: "Legacy third-party policyholder national code", - }) - @ValidateIf((dto) => !dto.thirdPartyPolicyholder) - nationalCodeOfInsurer: string; - - @ApiPropertyOptional({ - type: String, - description: "Legacy driver national code", - }) - @ValidateIf((dto) => !dto.driver) - nationalCodeOfDriver: string; - - @ApiPropertyOptional({ type: String }) - insurerLicense: string; - - @ApiPropertyOptional({ type: String }) - driverLicense: string; - - @ApiPropertyOptional({ - type: Boolean, - description: "Legacy role relationship", - }) - @ValidateIf((dto) => !dto.driver) - driverIsInsurer: boolean; - - @ApiPropertyOptional({ type: Boolean, default: false }) - isNewCar: boolean; - - @ApiPropertyOptional({ - type: Boolean, - description: "Legacy; use driver.hasDrivingLicense", - }) - userNoCertificate: boolean; - - @ApiPropertyOptional({ - type: Number, - description: "Legacy policyholder birthday", - }) - insurerBirthday: number; - - @ApiPropertyOptional({ type: String, required: false }) - driverBirthday: string | null; -} +/** V2 initial-form step submitted with the shared structured vehicle contract. */ +export class InitialFormVinDto extends InquiryParticipantFieldsDto {} // export class DocsOfThisFile { // @ApiProperty() diff --git a/src/request-management/dto/run-call-center-inquiry-v6.dto.ts b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts index 0b331f3..36e29f2 100644 --- a/src/request-management/dto/run-call-center-inquiry-v6.dto.ts +++ b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts @@ -1,182 +1,14 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { - IsBoolean, - IsNotEmpty, - IsOptional, - IsString, - MaxLength, - ValidateIf, - ValidateNested, -} from "class-validator"; -import { Type } from "class-transformer"; import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto"; -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. * Same as V3 but without `sheba` — the user adds their own IBAN later via the link. */ -export class RunCallCenterInquiryV6Dto extends InquiryParticipantFieldsDto { - @ApiPropertyOptional({ - type: PlateV6Dto, - description: "Plate segments — Tejarat block / third-party inquiry.", - }) - @ValidateNested() - @Type(() => PlateV6Dto) - @ValidateIf((dto) => !dto.vehicle?.currentPlate) - plate: PlateV6Dto; - - @ApiPropertyOptional({ - example: "1234567890", - description: "Legacy third-party policyholder national code", - }) - @IsString() - @IsNotEmpty() - @ValidateIf((dto) => !dto.thirdPartyPolicyholder) - nationalCodeOfInsurer: string; - - @ApiPropertyOptional({ - example: "1234567890", - description: "Legacy driver national code", - }) - @IsString() - @IsNotEmpty() - @ValidateIf((dto) => !dto.driver) - nationalCodeOfDriver: string; - - @ApiPropertyOptional({ - example: true, - description: "Legacy role relationship", - }) - @IsBoolean() - @ValidateIf((dto) => !dto.driver) - driverIsInsurer: boolean; - - @ApiPropertyOptional({ - example: 13780624, - description: "Legacy policyholder birth date (Jalali)", - }) - insurerBirthday: number | string; - - @ApiPropertyOptional({ - example: 13780624, - description: - "Driver birth date (Jalali). Required when driverIsInsurer is false.", - }) - @IsOptional() - driverBirthday?: number | string | null; - - @ApiPropertyOptional({ - example: "123456789", - description: "Driver license (required when driverIsInsurer is false).", - }) - @IsOptional() - @IsString() - driverLicense?: string; - - @ApiPropertyOptional({ - example: "123456789", - description: "Insurer license (required when driverIsInsurer is true).", - }) - @IsOptional() - @IsString() - insurerLicense?: string; -} +export class RunCallCenterInquiryV6Dto extends InquiryParticipantFieldsDto {} /** * VIN / chassis variant of the V6 call-center inquiry. * Identical to `RunCallCenterInquiryV6Dto` but replaces `plate` with `vin`. * Sheba (IBAN) is intentionally absent — the user provides it themselves via the link. */ -export class RunCallCenterInquiryVinV6Dto extends InquiryParticipantFieldsDto { - @ApiPropertyOptional({ - example: "NAAM01E15HK123456", - description: "17-character VIN / chassis number (شماره شاسی)", - maxLength: 17, - }) - @IsString() - @IsNotEmpty() - @MaxLength(17) - @ValidateIf((dto) => !dto.vehicle?.vin) - vin: string; - - @ApiPropertyOptional({ - example: "1234567890", - description: "Legacy third-party policyholder national code", - }) - @IsString() - @IsNotEmpty() - @ValidateIf((dto) => !dto.thirdPartyPolicyholder) - nationalCodeOfInsurer: string; - - @ApiPropertyOptional({ - example: "1234567890", - description: "Legacy driver national code", - }) - @IsString() - @IsNotEmpty() - @ValidateIf((dto) => !dto.driver) - nationalCodeOfDriver: string; - - @ApiPropertyOptional({ - example: true, - description: "Legacy role relationship", - }) - @IsBoolean() - @ValidateIf((dto) => !dto.driver) - driverIsInsurer: boolean; - - @ApiPropertyOptional({ - example: 13780624, - description: "Legacy policyholder birth date (Jalali)", - }) - insurerBirthday: number | string; - - @ApiPropertyOptional({ - example: 13780624, - description: - "Driver birth date (Jalali). Required when driverIsInsurer is false.", - }) - @IsOptional() - driverBirthday?: number | string | null; - - @ApiPropertyOptional({ - example: "123456789", - description: "Driver license (required when driverIsInsurer is false).", - }) - @IsOptional() - @IsString() - driverLicense?: string; - - @ApiPropertyOptional({ - example: "123456789", - description: "Insurer license (required when driverIsInsurer is true).", - }) - @IsOptional() - @IsString() - insurerLicense?: string; -} +export class RunCallCenterInquiryVinV6Dto extends InquiryParticipantFieldsDto {} diff --git a/src/request-management/dto/run-inquiries-v3.dto.ts b/src/request-management/dto/run-inquiries-v3.dto.ts index 71e0922..b104050 100644 --- a/src/request-management/dto/run-inquiries-v3.dto.ts +++ b/src/request-management/dto/run-inquiries-v3.dto.ts @@ -1,95 +1,12 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { - IsBoolean, - IsNotEmpty, - IsOptional, - IsString, - MaxLength, - ValidateIf, - ValidateNested, -} from "class-validator"; -import { Type } from "class-transformer"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsOptional, IsString } from "class-validator"; import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto"; -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`. * First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only). */ export class RunInquiriesV3Dto extends InquiryParticipantFieldsDto { - @ApiPropertyOptional({ - type: PlateV3Dto, - description: "Plate segments — Tejarat block / third-party inquiry.", - }) - @ValidateNested() - @Type(() => PlateV3Dto) - @ValidateIf((dto) => !dto.vehicle?.currentPlate) - plate: PlateV3Dto; - - @ApiPropertyOptional({ - example: "1234567890", - description: "Legacy third-party policyholder national code", - }) - @IsString() - @IsNotEmpty() - @ValidateIf((dto) => !dto.thirdPartyPolicyholder) - nationalCodeOfInsurer: string; - - @ApiPropertyOptional({ - example: "1234567890", - description: "Legacy driver national code", - }) - @IsString() - @IsNotEmpty() - @ValidateIf((dto) => !dto.driver) - nationalCodeOfDriver: string; - - @ApiPropertyOptional({ - example: true, - description: "Legacy role relationship", - }) - @IsBoolean() - @ValidateIf((dto) => !dto.driver) - driverIsInsurer: boolean; - - @ApiPropertyOptional({ - example: 13780624, - description: "Legacy policyholder 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: "IR123456789012345678901234", description: @@ -98,32 +15,6 @@ export class RunInquiriesV3Dto extends InquiryParticipantFieldsDto { @IsOptional() @IsString() sheba?: string; - - @ApiPropertyOptional({ - example: "123456789", - description: "Driver license (required when driverIsInsurer is false).", - }) - @IsOptional() - @IsString() - driverLicense?: string; - - @ApiPropertyOptional({ - example: "123456789", - description: "Insurer license (required when driverIsInsurer is true).", - }) - @IsOptional() - @IsString() - insurerLicense?: string; - - @ApiPropertyOptional({ - example: "1", - description: - "Driving licence type code from GET /lookups/driving-licence-types. " + - "Optional — used in V4 FileMaker flow.", - }) - @IsOptional() - @IsString() - licenseType?: string; } /** @@ -132,57 +23,6 @@ export class RunInquiriesV3Dto extends InquiryParticipantFieldsDto { * First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only). */ export class RunInquiriesVinV3Dto extends InquiryParticipantFieldsDto { - @ApiPropertyOptional({ - example: "NAAM01E15HK123456", - description: "17-character VIN / chassis number (شماره شاسی)", - maxLength: 17, - }) - @IsString() - @IsNotEmpty() - @MaxLength(17) - @ValidateIf((dto) => !dto.vehicle?.vin) - vin: string; - - @ApiPropertyOptional({ - example: "1234567890", - description: "Legacy third-party policyholder national code", - }) - @IsString() - @IsNotEmpty() - @ValidateIf((dto) => !dto.thirdPartyPolicyholder) - nationalCodeOfInsurer: string; - - @ApiPropertyOptional({ - example: "1234567890", - description: "Legacy driver national code", - }) - @IsString() - @IsNotEmpty() - @ValidateIf((dto) => !dto.driver) - nationalCodeOfDriver: string; - - @ApiPropertyOptional({ - example: true, - description: "Legacy role relationship", - }) - @IsBoolean() - @ValidateIf((dto) => !dto.driver) - driverIsInsurer: boolean; - - @ApiPropertyOptional({ - example: 13780624, - description: "Legacy policyholder 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: "IR123456789012345678901234", description: @@ -191,30 +31,4 @@ export class RunInquiriesVinV3Dto extends InquiryParticipantFieldsDto { @IsOptional() @IsString() sheba?: string; - - @ApiPropertyOptional({ - example: "123456789", - description: "Driver license (required when driverIsInsurer is false).", - }) - @IsOptional() - @IsString() - driverLicense?: string; - - @ApiPropertyOptional({ - example: "123456789", - description: "Insurer license (required when driverIsInsurer is true).", - }) - @IsOptional() - @IsString() - insurerLicense?: string; - - @ApiPropertyOptional({ - example: "1", - description: - "Driving licence type code from GET /lookups/driving-licence-types. " + - "Optional — used in V4 FileMaker flow.", - }) - @IsOptional() - @IsString() - licenseType?: string; } diff --git a/src/request-management/inquiry-participant-resolver.spec.ts b/src/request-management/inquiry-participant-resolver.spec.ts index 49c8541..c463ec3 100644 --- a/src/request-management/inquiry-participant-resolver.spec.ts +++ b/src/request-management/inquiry-participant-resolver.spec.ts @@ -66,22 +66,34 @@ describe("inquiry participant resolver", () => { ).toBe(VehicleRegistrationState.CURRENT); }); - it("keeps legacy driver/policyholder payloads working during rollout", () => { - const resolved = resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, { - nationalCodeOfDriver: "0012345678", - driverBirthday: "1370/01/01", - driverLicense: "123456789", - nationalCodeOfInsurer: "0098765432", - insurerBirthday: "1360/02/02", - driverIsInsurer: false, - } as any); + 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); + }); - expect(resolved.legacy).toBe(true); - expect(resolved.roles.driver).toBe("DRIVER"); - expect(resolved.roles.thirdPartyPolicyholder).toBe( - "THIRD_PARTY_POLICYHOLDER", - ); - expect(resolved.roles.vehicleOwner).toBeUndefined(); + 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", () => { @@ -116,11 +128,13 @@ describe("inquiry participant resolver", () => { it("projects role-complete input onto the existing inquiry fields", () => { const normalized = normalizeInquirySubmission(BlameRequestType.CAR_BODY, { - plate: { - leftDigits: "44", - centerAlphabet: "ب", - centerDigits: "111", - ir: "22", + vehicle: { + currentPlate: { + leftDigits: "44", + centerAlphabet: "ب", + centerDigits: "111", + ir: "22", + }, }, driver: { nationalCode: "0012345678", diff --git a/src/request-management/inquiry-participant-resolver.ts b/src/request-management/inquiry-participant-resolver.ts index 439089b..1d7448a 100644 --- a/src/request-management/inquiry-participant-resolver.ts +++ b/src/request-management/inquiry-participant-resolver.ts @@ -15,7 +15,6 @@ export interface ResolvedInquiryParticipant { nationalCode: string; birthday: string; fullName?: string; - phoneNumber?: string; hasDrivingLicense?: boolean; licenseNumber?: string; licenseType?: string; @@ -90,6 +89,11 @@ 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) { @@ -118,7 +122,6 @@ function requiredIdentity( nationalCode, birthday, ...(input.fullName ? { fullName: input.fullName } : {}), - ...(input.phoneNumber ? { phoneNumber: input.phoneNumber } : {}), ...(input.hasDrivingLicense != null ? { hasDrivingLicense: input.hasDrivingLicense } : {}), @@ -129,52 +132,17 @@ function requiredIdentity( export function resolveInquiryParticipants( caseType: BlameRequestType, - input: InquiryParticipantFieldsDto & Record, + input: Partial & Record, options: InquiryParticipantResolutionOptions = {}, ): ResolvedInquiryParticipants { + assertStructuredInquiryInput(input); const hasRoleCompleteInput = Object.values(ROLE_FIELDS).some( (field) => input[field] != null, ); if (!hasRoleCompleteInput) { - const driverId = InquiryParticipantRole.DRIVER; - const policyholderId = input.driverIsInsurer - ? driverId - : InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER; - const participants: ResolvedInquiryParticipant[] = [ - { - participantId: driverId, - nationalCode: String(input.nationalCodeOfDriver ?? ""), - birthday: String( - input.driverBirthday ?? - (input.driverIsInsurer ? input.insurerBirthday : "") ?? - "", - ), - ...(input.driverLicense - ? { licenseNumber: String(input.driverLicense) } - : {}), - ...(input.licenseType - ? { licenseType: String(input.licenseType) } - : {}), - ...(input.userNoCertificate != null - ? { hasDrivingLicense: !input.userNoCertificate } - : {}), - }, - ]; - if (policyholderId !== driverId) { - participants.push({ - participantId: policyholderId, - nationalCode: String(input.nationalCodeOfInsurer ?? ""), - birthday: String(input.insurerBirthday ?? ""), - }); - } - return { - participants, - roles: { - driver: driverId, - thirdPartyPolicyholder: policyholderId, - }, - legacy: true, - }; + throw new BadRequestException( + "driver, vehicleOwner, and thirdPartyPolicyholder are required in the structured inquiry format.", + ); } if ( caseType === BlameRequestType.THIRD_PARTY && @@ -367,6 +335,33 @@ function normalizePlateForComparison( .join("|"); } +const LEGACY_INQUIRY_FIELDS = [ + "nationalCodeOfDriver", + "driverBirthday", + "driverLicense", + "licenseType", + "nationalCodeOfInsurer", + "insurerBirthday", + "insurerLicense", + "driverIsInsurer", + "userNoCertificate", + "plate", + "plateId", + "vin", + "isNewCar", +] as const; + +function assertStructuredInquiryInput(input: Record): 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, @@ -517,7 +512,11 @@ export function normalizeInquirySubmission>( input: T, options: InquiryParticipantResolutionOptions = {}, ): NormalizedInquirySubmission { - const participants = resolveInquiryParticipants(caseType, input, options); + const participants = resolveInquiryParticipants( + caseType, + input as any, + options, + ); const driver = participantForRole( participants, InquiryParticipantRole.DRIVER, @@ -539,43 +538,32 @@ export function normalizeInquirySubmission>( participants, InquiryParticipantRole.CAR_BODY_POLICYHOLDER, ); - const vehicle = input.vehicle - ? resolveInquiryVehicle(input.vehicle as InquiryVehicleInputDto) - : undefined; - if ( - vehicle && - input.plate && - normalizePlateForComparison(vehicle.currentPlate) !== - normalizePlateForComparison(input.plate) - ) { - throw new BadRequestException( - "plate and vehicle.currentPlate must identify the same current plate.", - ); - } + const vehicle = resolveInquiryVehicle( + input.vehicle as InquiryVehicleInputDto, + ); const sameDriverAndPolicyholder = participants.roles.driver === participants.roles.thirdPartyPolicyholder; return { dto: { ...input, - ...(vehicle?.currentPlate && !input.plate - ? { plate: vehicle.currentPlate } - : {}), - ...(vehicle?.vin && !input.vin ? { vin: vehicle.vin } : {}), + plate: vehicle.currentPlate, + vin: vehicle.vin, nationalCodeOfDriver: driver.nationalCode, driverBirthday: driver.birthday, driverLicense: driver.licenseNumber, licenseType: driver.licenseType, userNoCertificate: driver.hasDrivingLicense == null - ? input.userNoCertificate + ? undefined : !driver.hasDrivingLicense, nationalCodeOfInsurer: thirdPartyPolicyholder.nationalCode, insurerBirthday: thirdPartyPolicyholder.birthday, driverIsInsurer: sameDriverAndPolicyholder, insurerLicense: sameDriverAndPolicyholder ? driver.licenseNumber - : input.insurerLicense, + : undefined, + isNewCar: vehicle.isNewCar, } as NormalizedInquirySubmission["dto"], participants, driver, From 0eaf55e73cbedd0957444214dd507d52322a53a2 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 13 Sep 2026 14:35:45 +0330 Subject: [PATCH 10/12] Document structured inquiry payload for frontend --- .../inquiry-participants-frontend-guide.fa.md | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 docs/inquiry-participants-frontend-guide.fa.md diff --git a/docs/inquiry-participants-frontend-guide.fa.md b/docs/inquiry-participants-frontend-guide.fa.md new file mode 100644 index 0000000..f9e9466 --- /dev/null +++ b/docs/inquiry-participants-frontend-guide.fa.md @@ -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) قرار دارد. From f9d4b5df9a49840b2e8fac49af7be389b9e90aca Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 13 Sep 2026 16:04:51 +0330 Subject: [PATCH 11/12] Localize timeline system reasons --- .../helper/timeline-fa-labels.spec.ts | 45 +++++++++++++++++++ .../helper/timeline-fa-labels.ts | 12 +++++ 2 files changed, 57 insertions(+) diff --git a/src/expert-insurer/helper/timeline-fa-labels.spec.ts b/src/expert-insurer/helper/timeline-fa-labels.spec.ts index 86895f9..7eff8be 100644 --- a/src/expert-insurer/helper/timeline-fa-labels.spec.ts +++ b/src/expert-insurer/helper/timeline-fa-labels.spec.ts @@ -89,4 +89,49 @@ describe("insurer timeline Persian labels", () => { captureKey: "front", }); }); + + it("localizes the auto-resolved first-party confession reason", () => { + expect( + localizeTimelineMetadata({ + type: "AUTO_CONFESSION_SKIPPED", + metadata: { + reason: + "IN_PERSON expert-initiated: first party is always guilty; confession auto-resolved", + stepKey: "FIRST_BLAME_CONFESSION", + advancedTo: "FIRST_VIDEO", + }, + }), + ).toEqual({ + reason: + "در پرونده حضوری ایجادشده توسط کارشناس، طرف اول همیشه مقصر است؛ بنابراین مرحله اقرار به‌صورت خودکار تعیین تکلیف شد.", + stepKey: "FIRST_BLAME_CONFESSION", + advancedTo: "FIRST_VIDEO", + }); + }); + + it("localizes the call-center auto-confession reason", () => { + expect( + localizeTimelineMetadata({ + type: "AUTO_CONFESSION_SKIPPED", + metadata: { + reason: + "V6 call-center initiated: first party is always guilty; confession auto-resolved", + advancedTo: "FIRST_VIDEO", + }, + }), + ).toEqual({ + reason: + "در پرونده ایجادشده توسط مرکز تماس، طرف اول همیشه مقصر است؛ بنابراین مرحله اقرار به‌صورت خودکار تعیین تکلیف شد.", + advancedTo: "FIRST_VIDEO", + }); + }); + + it("preserves user-entered reasons that are not system messages", () => { + expect( + localizeTimelineMetadata({ + type: "V5_FILE_MAKER_REJECTED", + metadata: { reason: "تصویر کارت خودرو خوانا نیست" }, + }), + ).toEqual({ reason: "تصویر کارت خودرو خوانا نیست" }); + }); }); diff --git a/src/expert-insurer/helper/timeline-fa-labels.ts b/src/expert-insurer/helper/timeline-fa-labels.ts index 2bf59ae..c95cd0c 100644 --- a/src/expert-insurer/helper/timeline-fa-labels.ts +++ b/src/expert-insurer/helper/timeline-fa-labels.ts @@ -190,6 +190,13 @@ export const STEP_KEY_FA_LABELS: Record = { const GENERIC_EVENT_FA_LABEL = "رویداد پرونده"; const GENERIC_EVENT_FA_DESCRIPTION = "رویدادی در روند پرونده ثبت شد."; +const SYSTEM_REASON_FA_TRANSLATIONS: Record = { + "IN_PERSON expert-initiated: first party is always guilty; confession auto-resolved": + "در پرونده حضوری ایجادشده توسط کارشناس، طرف اول همیشه مقصر است؛ بنابراین مرحله اقرار به‌صورت خودکار تعیین تکلیف شد.", + "V6 call-center initiated: first party is always guilty; confession auto-resolved": + "در پرونده ایجادشده توسط مرکز تماس، طرف اول همیشه مقصر است؛ بنابراین مرحله اقرار به‌صورت خودکار تعیین تکلیف شد.", +}; + /** * Persian descriptions for workflow-step events. These are deliberately * complete sentences because the insurer timeline exposes them as the event @@ -276,10 +283,15 @@ export function localizeTimelineMetadata(event: { }): Record | null { if (!event.metadata) return null; + const reason = event.metadata.reason; + const localizedReason = + typeof reason === "string" ? SYSTEM_REASON_FA_TRANSLATIONS[reason] : null; + return { ...event.metadata, ...(Object.prototype.hasOwnProperty.call(event.metadata, "description") ? { description: getEventFaDescription(event) } : {}), + ...(localizedReason ? { reason: localizedReason } : {}), }; } From 6a26811555a90699827729dd4e6d07ad6f07a3cb Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 13 Sep 2026 16:15:12 +0330 Subject: [PATCH 12/12] Calculate insurer report review duration --- .../expert-insurer.file-summary.spec.ts | 52 +++++++++++++++++++ src/expert-insurer/expert-insurer.service.ts | 20 +++++++ 2 files changed, 72 insertions(+) create mode 100644 src/expert-insurer/expert-insurer.file-summary.spec.ts diff --git a/src/expert-insurer/expert-insurer.file-summary.spec.ts b/src/expert-insurer/expert-insurer.file-summary.spec.ts new file mode 100644 index 0000000..1ac54c7 --- /dev/null +++ b/src/expert-insurer/expert-insurer.file-summary.spec.ts @@ -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); + }); +}); diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index ba50fa4..227a42d 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -264,6 +264,18 @@ export class ExpertInsurerService { return String(aid); } + private calculateReviewDurationMinutes( + createdAt: Date | string | undefined, + updatedAt: Date | string | undefined, + ): number | null { + const startedAtMs = new Date(createdAt as any).getTime(); + const reviewedAtMs = new Date(updatedAt as any).getTime(); + if (Number.isNaN(startedAtMs) || Number.isNaN(reviewedAtMs)) return null; + + const durationMinutes = Math.max(reviewedAtMs - startedAtMs, 0) / 60_000; + return Math.round(durationMinutes * 100) / 100; + } + private mapBlameFileSummaryForInsurerExpert(b: any) { return { kind: "blame" as const, @@ -275,6 +287,10 @@ export class ExpertInsurerService { blameStatus: b.blameStatus, createdAt: b.createdAt, updatedAt: b.updatedAt, + reviewDurationMinutes: this.calculateReviewDurationMinutes( + b.createdAt, + b.updatedAt, + ), }; } @@ -288,6 +304,10 @@ export class ExpertInsurerService { claimStatus: c.claimStatus, createdAt: c.createdAt, updatedAt: c.updatedAt, + reviewDurationMinutes: this.calculateReviewDurationMinutes( + c.createdAt, + c.updatedAt, + ), }; }